269 lines
11 KiB
JavaScript
269 lines
11 KiB
JavaScript
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";
|
|
import { mountWorkflow } from "./workflow.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 },
|
|
workflow: { label: "Workflow", mount: mountWorkflow },
|
|
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);
|
|
case "workflow":
|
|
return state.selectedTaskId !== undefined && findTask(state.selectedTaskId)?.workflow_run !== undefined;
|
|
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", "tasks"], 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
|