import { put } 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 { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js"; import { previewLinks } from "./preview.js"; import { mountProject } from "./project.js"; import { findingCount, isReviewEvent, latestReview, mountReviews } from "./reviews.js"; import { mountSettings } from "./settings.js"; import { mountStatus } from "./status.js"; import { openSwitcher } from "./switcher.js"; import { mountTranscript } from "./task.js"; import { mountTerminal } from "./terminal.js"; const views = { transcript: { label: "Transcript", mount: mountTranscript }, changes: { label: "Changes", mount: (root) => mountChanges(root) }, files: { label: "Files", mount: mountFiles }, terminal: { label: "Terminal", mount: mountTerminal }, inbox: { label: "Inbox", mount: mountInbox }, reviews: { label: "Reviews", mount: mountReviews }, project: { label: "Project", mount: mountProject }, }; const leftViews = { status: { label: "Status", mount: mountStatus }, settings: { label: "Settings", mount: mountSettings }, }; let showLeft = () => { }; export function focusLead() { showLeft("lead"); document.querySelector(".view-lead .composer textarea")?.focus(); } export function leadPane() { const lead = h("div", { class: "view view-lead" }); mountLead(lead); let other = h("div", { class: "view", hidden: true }); const buttons = { lead: h("button", {}, "Lead"), status: h("button", {}, leftViews.status.label), settings: h("button", {}, leftViews.settings.label), }; let shown; let dispose = () => { }; showLeft = (tab) => { if (tab === shown) { return; } dispose(); dispose = () => { }; shown = tab; for (const [t, button] of Object.entries(buttons)) { button.classList.toggle("active", t === tab); } lead.hidden = tab !== "lead"; const root = h("div", { class: tab === "lead" ? "view" : `view view-${tab}`, hidden: tab === "lead" }); other.replaceWith(root); other = root; if (tab === "lead") { return; } dispose = leftViews[tab].mount(root); }; for (const [t, button] of Object.entries(buttons)) { button.onclick = () => showLeft(t); } const conn = h("span", { class: "connection down", title: "connecting" }); const connLabel = h("span", { class: "connection-label" }, "reconnecting…"); const me = h("span", { class: "me" }); const renderConn = () => { conn.className = state.connected ? "connection up" : "connection down"; conn.title = state.connected ? "live" : "reconnecting"; connLabel.hidden = state.connected; replace(me, state.me?.display_name ?? ""); }; onEach(["me", "connection"], renderConn); renderConn(); showLeft("lead"); return h("section", { class: "pane" }, h("nav", { class: "pane-tabs" }, buttons.lead, buttons.status, buttons.settings, h("span", { class: "pane-status" }, connLabel, conn, me)), h("div", { class: "pane-body" }, lead, other)); } export function taskPane() { 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, previewLinks()), strip, body); let shown; let dispose = () => { }; 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 = []; let drawn = ""; let waiting = 0; let findings = 0; let reviewing = false; const count = (t) => (t === "inbox" ? waiting : t === "reviews" && !reviewing ? findings : 0); const badge = (t) => { if (t === "reviews" && reviewing) { return h("span", { class: "spinner", title: "review in progress" }); } return count(t) > 0 && h("span", { class: "badge", title: t === "inbox" ? "inbox items" : "open 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) => 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) => { if (t === state.focus.tab) { return true; } switch (t) { case "inbox": return waiting > 0; 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; const latest = latestReview(state.transcript); reviewing = latest?.state === "running"; findings = latest ? findingCount(latest) : 0; const order = [...first, ...tabs.filter((t) => !first.includes(t))].filter(available); const key = JSON.stringify([order, tab, waiting, findings, reviewing]); 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) { let focused = false; let drafting = false; let inside = null; let sending = Promise.resolve(); const send = () => { const body = { focused, drafting }; sending = sending.then(() => put("/api/me/pane-focus", { body })).catch((err) => 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); const draft = [...pane.querySelectorAll("textarea:not(.xterm-helper-textarea)")].some((t) => t.value.trim() !== ""); inside = now ? active : null; if (now === focused && draft === drafting) { return; } focused = now; drafting = draft; send(); }); }; on("connection", () => { if (state.connected && focused) { send(); } }); document.addEventListener("focusin", update); document.addEventListener("focusout", update); document.addEventListener("input", update); document.addEventListener("submit", update); addEventListener("focus", update); addEventListener("blur", update); addEventListener("pagehide", () => { if (focused) { focused = false; void put("/api/me/pane-focus", { body: { focused: false, drafting }, keepalive: true }); } }); } //# sourceMappingURL=panes.js.map