Files
byre/web/src/panes.ts
T
2026-10-15 22:08:08 -07:00

216 lines
7.9 KiB
TypeScript

import { put, type TaskTab } from "./api.js";
import { mountChanges } from "./changes.js";
import { h, replace } from "./dom.js";
import { mountFiles } from "./files.js";
import { mountInbox } from "./inbox.js";
import { mountLead } from "./lead.js";
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 { isReviewEvent, latestFindingCount, mountReviews } from "./reviews.js";
import { mountTranscript } from "./task.js";
import { mountTerminal } from "./terminal.js";
const views: Record<TaskTab, { label: string; mount: (root: HTMLElement) => () => void }> = {
transcript: { label: "Transcript", mount: mountTranscript },
changes: { label: "Changes", mount: (root) => mountChanges(root) },
files: { label: "Files", mount: mountFiles },
terminal: { label: "Terminal", mount: mountTerminal },
preview: { label: "Preview", mount: mountPreview },
inbox: { label: "Inbox", mount: mountInbox },
reviews: { label: "Reviews", mount: mountReviews },
project: { label: "Project", mount: mountProject },
};
export function leadPane(): HTMLElement {
const root = h("div", { class: "view view-lead" });
mountLead(root);
return h("section", { class: "pane" }, h("nav", { class: "pane-tabs" }, h("button", { class: "active" }, "Lead")), h("div", { class: "pane-body" }, root));
}
export function taskPane(): HTMLElement {
const strip = h("nav", { class: "pane-tabs" });
const body = h("div", { class: "pane-body" });
const picker = h("button", { class: "task-picker", title: "Switch task (⌘K)" });
picker.onclick = openSwitcher;
const pane = h("section", { class: "pane task-pane", tabIndex: -1 }, h("header", { class: "task-bar" }, picker), strip, body);
let shown: TaskTab | undefined;
let dispose: () => void = () => {};
const renderPicker = () => {
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
const project = focusedProject();
if (task) {
pane.style.setProperty("--task", `var(--task-${task.color})`);
} else {
pane.style.removeProperty("--task");
}
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;
let findings = 0;
const count = (t: TaskTab): number => (t === "inbox" ? waiting : t === "reviews" ? findings : 0);
const badge = (t: TaskTab) => count(t) > 0 && h("span", { class: "badge", title: t === "inbox" ? "inbox items" : "findings in the latest review" }, count(t));
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 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) > inner) {
more.hidden = false;
const budget = inner - more.offsetWidth - 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 flagged = hidden.filter((t) => count(t) > 0);
more.classList.toggle("attention", flagged.length > 0);
replace(more, "More ▾", ...flagged.map(badge));
replace(
menu,
...hidden.map((t) => {
const b = h("button", { type: "button" }, views[t].label, badge(t));
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 tab = state.focus.tab;
waiting = state.inbox.filter((item) => item.task_id === state.selectedTaskId).length;
findings = latestFindingCount(state.transcript);
const order = [...first, ...tabs.filter((t) => !first.includes(t))].filter(available);
const key = JSON.stringify([order, tab, waiting, findings]);
if (key !== drawn) {
drawn = key;
buttons = order.map((t) => {
const button = h("button", { class: [t === tab ? "active" : "", count(t) > 0 ? "attention" : ""].join(" ").trim() }, views[t].label, badge(t));
button.onclick = () => focusTab(t);
return { tab: t, button };
});
replace(strip, ...buttons.map((b) => b.button), more, menu);
layout();
}
if (tab === shown) {
return;
}
dispose();
shown = tab;
const root = h("div", { class: `view view-${tab}` });
replace(body, root);
dispose = views[tab].mount(root);
};
const seen = new Set(state.inbox.map((item) => item.id));
const openAsked = () => {
const fresh = state.inbox.filter((item) => !seen.has(item.id));
for (const item of fresh) {
seen.add(item.id);
}
if (state.focus.tab === "transcript" && fresh.some((item) => item.kind === "question" && item.task_id === state.selectedTaskId)) {
focusTab("inbox");
}
};
on("inbox", openAsked);
onEach(["focus", "inbox", "selection", "transcript"], render);
onEach(["projects", "tasks", "selection"], renderPicker);
new ResizeObserver(layout).observe(strip);
render();
renderPicker();
trackPaneFocus(pane);
return pane;
}
function trackPaneFocus(pane: HTMLElement): void {
let focused = false;
let inside: Element | null = null;
let sending: Promise<unknown> = Promise.resolve();
const send = () => {
const body = { focused };
sending = sending.then(() => put("/api/me/pane-focus", { body })).catch((err: unknown) => console.error("set pane focus", err));
};
const update = () => {
setTimeout(() => {
if (focused && inside && !inside.isConnected && document.activeElement === document.body) {
// A re-render removed the focused element (a tab button, a list row); that isn't the human leaving.
pane.focus({ preventScroll: true });
}
const active = document.activeElement;
const now = document.hasFocus() && pane.contains(active);
inside = now ? active : null;
if (now === focused) {
return;
}
focused = now;
send();
});
};
on("connection", () => {
if (state.connected && focused) {
send();
}
});
document.addEventListener("focusin", update);
document.addEventListener("focusout", update);
addEventListener("focus", update);
addEventListener("blur", update);
addEventListener("pagehide", () => {
if (focused) {
focused = false;
void put("/api/me/pane-focus", { body: { focused: false }, keepalive: true });
}
});
}