Hide empty task tabs and move tabs that don't fit into a More menu

This commit is contained in:
Ian Gulliver
2026-10-15 17:49:59 -07:00
parent a19067a665
commit 1741758127
5 changed files with 222 additions and 29 deletions
+89 -11
View File
@@ -8,7 +8,7 @@ import { openSwitcher } from "./navbar.js";
import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js";
import { mountPreview } from "./preview.js";
import { mountProject } from "./project.js";
import { mountReviews } from "./reviews.js";
import { isReviewEvent, mountReviews } from "./reviews.js";
import { mountTranscript } from "./task.js";
import { mountTerminal } from "./terminal.js";
const views = {
@@ -45,17 +45,90 @@ export function taskPane() {
}
replace(picker, task ? `${project?.name ?? ""} ▸ ${task.title}` : (project?.name ?? "No task"), " ▾");
};
const more = h("button", { type: "button", class: "more", title: "More tabs" });
const menu = h("div", { class: "tab-menu", popover: "auto" });
more.onclick = () => {
const rect = more.getBoundingClientRect();
menu.style.top = `${rect.bottom + 4}px`;
menu.style.left = `${rect.left}px`;
menu.togglePopover();
};
let buttons = [];
let drawn = "";
let waiting = 0;
const layout = () => {
for (const { button } of buttons) {
button.hidden = false;
}
more.hidden = true;
const style = getComputedStyle(strip);
const gap = parseFloat(style.columnGap) || 0;
const inner = strip.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
const pickerWidth = Math.min(picker.scrollWidth, inner * 0.4);
const widths = new Map(buttons.map(({ tab, button }) => [tab, button.offsetWidth]));
const total = (kept) => kept.reduce((sum, t) => sum + widths.get(t) + gap, 0);
const all = buttons.map((b) => b.tab);
let kept = all;
if (total(all) + gap + pickerWidth > inner) {
more.hidden = false;
const budget = inner - pickerWidth - more.offsetWidth - 2 * gap;
const tab = state.focus.tab;
kept = [];
for (const t of [tab, ...all.filter((t) => t !== tab)]) {
if (kept.length > 0 && total([...kept, t]) > budget) {
break;
}
kept.push(t);
}
}
const hidden = all.filter((t) => !kept.includes(t));
for (const { tab, button } of buttons) {
button.hidden = hidden.includes(tab);
}
const attention = hidden.includes("inbox");
more.classList.toggle("attention", attention);
replace(more, "More ▾", attention && h("span", { class: "badge", title: "inbox items" }, waiting));
replace(menu, ...hidden.map((t) => {
const b = h("button", { type: "button" }, views[t].label, t === "inbox" && h("span", { class: "badge", title: "inbox items" }, waiting));
b.onclick = () => {
menu.hidePopover();
focusTab(t);
};
return b;
}));
};
const available = (t) => {
if (t === state.focus.tab) {
return true;
}
switch (t) {
case "inbox":
return waiting > 0;
case "preview":
return state.transcript.some((ev) => ev.kind === "preview.exposed");
case "reviews":
return state.transcript.some(isReviewEvent);
default:
return true;
}
};
const render = () => {
const first = state.focus.tabs.filter((t, i, all) => tabs.includes(t) && all.indexOf(t) === i);
const order = [...first, ...tabs.filter((t) => !first.includes(t))];
const tab = state.focus.tab;
const waiting = state.inbox.filter((item) => item.task_id === state.selectedTaskId).length;
replace(strip, ...order.map((t) => {
const attention = t === "inbox" && waiting > 0;
const b = h("button", { class: [t === tab ? "active" : "", attention ? "attention" : ""].join(" ").trim() }, views[t].label, attention && h("span", { class: "badge", title: "inbox items" }, waiting));
b.onclick = () => focusTab(t);
return b;
}), h("span", { class: "spacer" }), picker);
waiting = state.inbox.filter((item) => item.task_id === state.selectedTaskId).length;
const order = [...first, ...tabs.filter((t) => !first.includes(t))].filter(available);
const key = JSON.stringify([order, tab, waiting]);
if (key !== drawn) {
drawn = key;
buttons = order.map((t) => {
const attention = t === "inbox" && waiting > 0;
const button = h("button", { class: [t === tab ? "active" : "", attention ? "attention" : ""].join(" ").trim() }, views[t].label, attention && h("span", { class: "badge", title: "inbox items" }, waiting));
button.onclick = () => focusTab(t);
return { tab: t, button };
});
replace(strip, ...buttons.map((b) => b.button), more, h("span", { class: "spacer" }), picker, menu);
layout();
}
if (tab === shown) {
return;
}
@@ -65,10 +138,15 @@ export function taskPane() {
replace(body, root);
dispose = views[tab].mount(root);
};
onEach(["focus", "inbox", "selection"], render);
onEach(["projects", "tasks", "selection", "focus"], renderPicker);
onEach(["focus", "inbox", "selection", "transcript"], render);
onEach(["projects", "tasks", "selection", "focus"], () => {
renderPicker();
layout();
});
new ResizeObserver(layout).observe(strip);
render();
renderPicker();
layout();
trackPaneFocus(pane);
return pane;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+94 -15
View File
@@ -8,7 +8,7 @@ import { openSwitcher } from "./navbar.js";
import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js";
import { mountPreview } from "./preview.js";
import { mountProject } from "./project.js";
import { mountReviews } from "./reviews.js";
import { isReviewEvent, mountReviews } from "./reviews.js";
import { mountTranscript } from "./task.js";
import { mountTerminal } from "./terminal.js";
@@ -49,27 +49,101 @@ export function taskPane(): HTMLElement {
replace(picker, task ? `${project?.name ?? ""} ▸ ${task.title}` : (project?.name ?? "No task"), " ▾");
};
const more = h("button", { type: "button", class: "more", title: "More tabs" });
const menu = h("div", { class: "tab-menu", popover: "auto" });
more.onclick = () => {
const rect = more.getBoundingClientRect();
menu.style.top = `${rect.bottom + 4}px`;
menu.style.left = `${rect.left}px`;
menu.togglePopover();
};
let buttons: { tab: TaskTab; button: HTMLButtonElement }[] = [];
let drawn = "";
let waiting = 0;
const layout = () => {
for (const { button } of buttons) {
button.hidden = false;
}
more.hidden = true;
const style = getComputedStyle(strip);
const gap = parseFloat(style.columnGap) || 0;
const inner = strip.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
const pickerWidth = Math.min(picker.scrollWidth, inner * 0.4);
const widths = new Map(buttons.map(({ tab, button }) => [tab, button.offsetWidth]));
const total = (kept: TaskTab[]) => kept.reduce((sum, t) => sum + widths.get(t)! + gap, 0);
const all = buttons.map((b) => b.tab);
let kept = all;
if (total(all) + gap + pickerWidth > inner) {
more.hidden = false;
const budget = inner - pickerWidth - more.offsetWidth - 2 * gap;
const tab = state.focus.tab;
kept = [];
for (const t of [tab, ...all.filter((t) => t !== tab)]) {
if (kept.length > 0 && total([...kept, t]) > budget) {
break;
}
kept.push(t);
}
}
const hidden = all.filter((t) => !kept.includes(t));
for (const { tab, button } of buttons) {
button.hidden = hidden.includes(tab);
}
const attention = hidden.includes("inbox");
more.classList.toggle("attention", attention);
replace(more, "More ▾", attention && h("span", { class: "badge", title: "inbox items" }, waiting));
replace(
menu,
...hidden.map((t) => {
const b = h("button", { type: "button" }, views[t].label, t === "inbox" && h("span", { class: "badge", title: "inbox items" }, waiting));
b.onclick = () => {
menu.hidePopover();
focusTab(t);
};
return b;
}),
);
};
const available = (t: TaskTab): boolean => {
if (t === state.focus.tab) {
return true;
}
switch (t) {
case "inbox":
return waiting > 0;
case "preview":
return state.transcript.some((ev) => ev.kind === "preview.exposed");
case "reviews":
return state.transcript.some(isReviewEvent);
default:
return true;
}
};
const render = () => {
const first = state.focus.tabs.filter((t, i, all) => tabs.includes(t) && all.indexOf(t) === i);
const order = [...first, ...tabs.filter((t) => !first.includes(t))];
const tab = state.focus.tab;
const waiting = state.inbox.filter((item) => item.task_id === state.selectedTaskId).length;
replace(
strip,
...order.map((t) => {
waiting = state.inbox.filter((item) => item.task_id === state.selectedTaskId).length;
const order = [...first, ...tabs.filter((t) => !first.includes(t))].filter(available);
const key = JSON.stringify([order, tab, waiting]);
if (key !== drawn) {
drawn = key;
buttons = order.map((t) => {
const attention = t === "inbox" && waiting > 0;
const b = h(
const button = h(
"button",
{ class: [t === tab ? "active" : "", attention ? "attention" : ""].join(" ").trim() },
views[t].label,
attention && h("span", { class: "badge", title: "inbox items" }, waiting),
);
b.onclick = () => focusTab(t);
return b;
}),
h("span", { class: "spacer" }),
picker,
);
button.onclick = () => focusTab(t);
return { tab: t, button };
});
replace(strip, ...buttons.map((b) => b.button), more, h("span", { class: "spacer" }), picker, menu);
layout();
}
if (tab === shown) {
return;
}
@@ -80,10 +154,15 @@ export function taskPane(): HTMLElement {
dispose = views[tab].mount(root);
};
onEach(["focus", "inbox", "selection"], render);
onEach(["projects", "tasks", "selection", "focus"], renderPicker);
onEach(["focus", "inbox", "selection", "transcript"], render);
onEach(["projects", "tasks", "selection", "focus"], () => {
renderPicker();
layout();
});
new ResizeObserver(layout).observe(strip);
render();
renderPicker();
layout();
trackPaneFocus(pane);
return pane;
}
+36
View File
@@ -350,6 +350,42 @@ a.task-link {
margin-left: 6px;
}
.pane-tabs button[hidden] {
display: none;
}
.pane-tabs button:not(.task-picker) {
flex-shrink: 0;
white-space: nowrap;
}
.tab-menu {
position: fixed;
inset: auto;
margin: 0;
padding: 4px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--raised);
color: var(--text);
box-shadow: var(--shadow);
}
.tab-menu:popover-open {
display: flex;
flex-direction: column;
}
.tab-menu button {
border: none;
background: transparent;
text-align: left;
}
.tab-menu button:hover {
background: var(--surface);
}
.pane-body,
.view,
.transcript-view,