From 859154f751dba23359806b3725277aea69f5a2f9 Mon Sep 17 00:00:00 2001 From: Regan Bell Date: Wed, 29 Jul 2026 14:18:08 -0700 Subject: [PATCH 01/17] fix(web-ui): make the crons page's links actually links, and flatten the run list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web-ui): make cron links real links, and flatten the run list The crons page had no addressable detail view: a cron opened only as in-page state, and the "Open worklog" link on a run pointed at `?session=`. On /crons that resolves to the crons view with the session silently ignored, so the link did nothing. Crons are now path-addressed as /crons/: the row is a real anchor (cmd-click and copy-link work), the URL follows the open cron, and a pasted /crons/ boots straight into that cron — with a plain notice on the list when the id is unknown. Worklog links now build the chats deep link (/?session=) through deepLinkPath instead of splicing the current path, so they land on the conversation. Run history drops the card-per-run layout for one line per run: status, time, error or reply excerpt, worklog link. * fix(web-ui): address review — history, load errors, notice styling Review of the first commit found four real defects, fixed here: - A failed /api/crons load reported "That cron wasn't found, or you don't have access to it." and hid the actual error, because the not-found notice occupied the row slot the error text renders in. The notice is now gated on the load having succeeded. - A deep-linked cron id could survive a mid-load view switch and open itself minutes later; it is consumed before the view guard. - Row anchors promised Back but replaceState broke it. Opening a cron from the list now pushes history, and a popstate listener routes the crons view back to the list or into a cron. - The notice used a class with no styling; it reuses .action-notice. Also: alt+click ("save link as") falls through to the browser, and deepLinkPath throws on an item id for a view addressed by session or scope rather than silently dropping it. Tests cover the worklog href, the row anchor, modified-click fallthrough, history behavior, the load-error path, and the pending-id lifecycle. * docs: PR screenshots for the crons page change --- plugins/web-ui/src/crons.ts | 85 ++++++++++++++++----- plugins/web-ui/src/deep-link.ts | 34 +++++---- plugins/web-ui/src/shell.css | 37 ++++++--- plugins/web-ui/src/shell.ts | 16 +++- plugins/web-ui/test/crons-deep-link.test.ts | 39 ++++++++++ plugins/web-ui/test/deep-link.test.ts | 34 +++++++-- test/slack-delivery.test.ts | 16 ++-- 7 files changed, 204 insertions(+), 57 deletions(-) create mode 100644 plugins/web-ui/test/crons-deep-link.test.ts diff --git a/plugins/web-ui/src/crons.ts b/plugins/web-ui/src/crons.ts index 738ebd4..688e927 100644 --- a/plugins/web-ui/src/crons.ts +++ b/plugins/web-ui/src/crons.ts @@ -7,6 +7,7 @@ import { listBackLink, listPageTpl } from "./list-page"; import { ensureContexts, scopeChip } from "./contexts"; import { appState } from "./shell"; import { chatState, newChat } from "./chat"; +import { deepLinkPath, UI_BASE } from "./deep-link"; import { cronNextFire, cronRunSummary, @@ -67,11 +68,33 @@ const cronRuns = new Map(); const cronRunsLoading = new Set(); let cronDialog: { kind: "rename" | "delete"; cron: CronView } | null = null; let activeCronId: string | null = null; +let pendingCronId: string | null = null; export function resetActiveCron(): void { cronsScope = null; } +export function openCronById(id: string): void { + pendingCronId = id; +} + +function syncCronUrl(cronId: string | null, push = false): void { + if (appState.currentView !== "crons") return; + const next = deepLinkPath(UI_BASE, "crons", null, null, cronId); + if (`${location.pathname}${location.search}` === next) return; + if (push) history.pushState(null, "", next); + else history.replaceState(null, "", next); +} + +export function routeCronsHistory(cronId: string | null): void { + if (appState.currentView !== "crons") return; + const cron = cronId + ? (cronList.find((c) => c.id === cronId) ?? visibleCronList.find((c) => c.id === cronId)) + : undefined; + if (cron) openCron(cron); + else drawCronsPage(); +} + async function refreshCrons(opts: { showLoading?: boolean } = {}): Promise { const seq = ++cronRefreshSeq; if (opts.showLoading) { @@ -165,13 +188,25 @@ export async function renderCronsPage(): Promise { if (appState.currentView !== "crons") return; await ensureContexts(); drawCronsPage(); - await refreshCrons({ showLoading: cronList.length === 0 && visibleCronList.length === 0 }); - if (appState.currentView === "crons") drawCronsPage(); + const loaded = await refreshCrons({ showLoading: cronList.length === 0 && visibleCronList.length === 0 }); + const wanted = pendingCronId; + pendingCronId = null; + if (appState.currentView !== "crons") return; + if (!loaded) return drawCronsPage(); + const cron = wanted + ? (cronList.find((c) => c.id === wanted) ?? visibleCronList.find((c) => c.id === wanted)) + : undefined; + if (wanted && !cron) { + cronActionNotice = "That cron wasn't found, or you don't have access to it."; + } + if (cron) openCron(cron); + else drawCronsPage(); } function drawCronsPage(): void { if (appState.currentView !== "crons" || !appState.mainEl) return; activeCronId = null; + syncCronUrl(null); if (!cronsPageHost || cronsPageHost.parentElement !== appState.mainEl) { cronsPageHost = document.createElement("div"); cronsPageHost.className = "pane crons-page"; @@ -194,6 +229,10 @@ function drawCronsPage(): void { const counts: Record = { yours: yours.length, shared: shared.length, archived: archived.length }; const rows: TemplateResult[] = []; + if (cronActionNotice) { + rows.push(html`
${cronActionNotice}
`); + cronActionNotice = ""; + } if (all.length) rows.push(cronTabs(counts)); if (cronTab === "yours") { rows.push(...yoursEnabled.map(({ c }) => cronPageRow(c, true))); @@ -297,14 +336,22 @@ function cronPageRow(c: CronView, mine: boolean): TemplateResult { const meta = `${cronScheduleSummary(c)} · ${cronRunSummary(c)}`; return html` `; @@ -381,9 +428,10 @@ function cronRowActions(c: CronView): TemplateResult { `; } -function openCron(c: CronView): void { +function openCron(c: CronView, opts: { push?: boolean } = {}): void { if (!appState.mainEl) return; activeCronId = c.id; + syncCronUrl(c.id, opts.push); const mine = cronList.some((x) => x.id === c.id); const manageable = canManageCron(c, mine); const notice = cronActionNotice; @@ -516,18 +564,21 @@ function cronRunHistory(c: CronView): TemplateResult { return html`
${heading}
- ${[...runs].reverse().map( - (run) => - html`
-
- ${run.status ?? "completed"} - ${new Date(run.firedAt).toLocaleString()} -
- ${run.note ? html`
${run.note}
` : nothing} - ${run.reply ? html`
${clipWords(run.reply, 180)}
` : nothing} - ${run.sessionId ? html`Open worklog` : nothing} -
`, - )} + ${[...runs].reverse().map((run) => { + const detail = run.note ?? (run.reply ? clipWords(run.reply, 120) : ""); + return html`
+ ${run.status ?? "completed"} + ${new Date(run.firedAt).toLocaleString()} + + ${detail} + + ${ + run.sessionId + ? html`Worklog` + : nothing + } +
`; + })}
`; } diff --git a/plugins/web-ui/src/deep-link.ts b/plugins/web-ui/src/deep-link.ts index b02f243..6785504 100644 --- a/plugins/web-ui/src/deep-link.ts +++ b/plugins/web-ui/src/deep-link.ts @@ -8,33 +8,39 @@ export function deepLinkPath( view: string, sessionId: string | null, contextScope?: string | null, + itemId?: string | null, ): string { const b = base.replace(/\/$/, ""); - if (view === "contexts" && contextScope) return `${b}/contexts?scope=${encodeURIComponent(contextScope)}`; - if (view !== "chats") return `${b}/${encodeURIComponent(view)}`; + if (view === "contexts" && contextScope) { + if (itemId) throw new Error("the contexts view is addressed by scope, not by item id"); + return `${b}/contexts?scope=${encodeURIComponent(contextScope)}`; + } + if (view !== "chats") return `${b}/${encodeURIComponent(view)}${itemId ? `/${encodeURIComponent(itemId)}` : ""}`; + if (itemId) throw new Error("the chats view is addressed by session, not by item id"); return `${b}/${sessionId ? `?session=${encodeURIComponent(sessionId)}` : ""}`; } +function decodeSegment(seg: string): string | null { + if (!seg) return null; + try { + return decodeURIComponent(seg); + } catch { + return null; + } +} + export function parseDeepLink( base: string, pathname: string, search: string, -): { view: string | null; session: string | null } { +): { view: string | null; session: string | null; item: string | null } { const params = new URLSearchParams(search); const b = base.replace(/\/$/, ""); const rel = pathname.startsWith(b) ? pathname.slice(b.length) : pathname; - const seg = rel.replace(/^\/+/, "").split("/")[0] ?? ""; - let fromPath: string | null = null; - if (seg) { - try { - fromPath = decodeURIComponent(seg); - } catch { - fromPath = null; - } - } - const requestedView = params.get("view") ?? fromPath; + const segments = rel.replace(/^\/+/, "").split("/"); + const requestedView = params.get("view") ?? decodeSegment(segments[0] ?? ""); const view = requestedView === "connectors" ? "keychain" : requestedView; - return { view, session: params.get("session") }; + return { view, session: params.get("session"), item: decodeSegment(segments[1] ?? "") }; } export function sessionLink(origin: string, base: string, sessionId: string): string { diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index 1b30ad5..fa8a9dc 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -4242,7 +4242,7 @@ body.resizing-sidebar { .cron-run-list { display: grid; - gap: 8px; + gap: 0; } .cron-run-heading { @@ -4258,22 +4258,40 @@ body.resizing-sidebar { } .cron-run-row { - display: grid; - gap: 5px; - padding: 10px 12px; - border: 1px solid var(--border); - border-radius: 8px; + display: flex; + align-items: center; + gap: 10px; + padding: 6px 0; + border-bottom: 1px solid color-mix(in srgb, var(--border) 65%, transparent); font-size: 12px; + white-space: nowrap; } -.cron-run-error { - color: var(--destructive, #b42318); +.cron-run-row:last-child { + border-bottom: 0; } -.cron-run-reply { +.cron-run-time { + font-variant-numeric: tabular-nums; color: var(--muted-foreground); } +.cron-run-detail { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + color: var(--muted-foreground); +} + +.cron-run-link { + margin-left: auto; +} + +.cron-run-error { + color: var(--destructive, #b42318); +} + .action-notice { padding: 9px 11px; border-radius: 8px; @@ -5737,6 +5755,7 @@ body.resizing-sidebar { cursor: default; } .cron-row-main { + text-decoration: none; display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: diff --git a/plugins/web-ui/src/shell.ts b/plugins/web-ui/src/shell.ts index 776d313..c8b5c67 100644 --- a/plugins/web-ui/src/shell.ts +++ b/plugins/web-ui/src/shell.ts @@ -63,7 +63,7 @@ import { sessionsState, toggleWebOnly, } from "./sessions"; -import { renderCronsPage, resetActiveCron } from "./crons"; +import { openCronById, renderCronsPage, resetActiveCron, routeCronsHistory } from "./crons"; import { renderFiles } from "./files"; import { clearConnectorNotice, noteConnectorResult, renderConnectors, resetKeychainState } from "./connectors"; import { renderDeploys } from "./deploys"; @@ -772,6 +772,13 @@ export function replacePanePreservingFocus(host: HTMLElement): void { replaceChildrenPreservingFocus(appState.mainEl, host); } +window.addEventListener("popstate", () => { + if (embedMode || appState.currentView !== "crons") return; + const { view, item } = parseDeepLink(UI_BASE, location.pathname, location.search); + if (view !== "crons") return; + routeCronsHistory(item); +}); + window.addEventListener("focus", () => { if (!appState.me || embedMode) return; if (appState.currentView === "contexts") void renderContexts(); @@ -843,7 +850,11 @@ export async function boot(): Promise { loadPersistedSplit(); const params = new URLSearchParams(location.search); - const { view: wanted, session: wantedSession } = parseDeepLink(UI_BASE, location.pathname, location.search); + const { + view: wanted, + session: wantedSession, + item: wantedItem, + } = parseDeepLink(UI_BASE, location.pathname, location.search); const connectedProvider = params.get("status") === "connected" ? params.get("connector") : null; if (connectedProvider) markConnectorConnected(connectedProvider); const viewIntent = isView(wanted) && wanted !== "chats"; @@ -885,6 +896,7 @@ export async function boot(): Promise { const scope = params.get("scope"); if (scope) contextsState.selected = scope; } + if (wanted === "crons" && wantedItem) openCronById(wantedItem); switchView(wanted as View); } else if (wantedSession) { const match = sessionsState.list.find((s) => s.id === wantedSession); diff --git a/plugins/web-ui/test/crons-deep-link.test.ts b/plugins/web-ui/test/crons-deep-link.test.ts new file mode 100644 index 0000000..da567ba --- /dev/null +++ b/plugins/web-ui/test/crons-deep-link.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = readFileSync(new URL("../src/crons.ts", import.meta.url), "utf8"); +const shell = readFileSync(new URL("../src/shell.ts", import.meta.url), "utf8"); + +test("a run's worklog link addresses the chats view, not the page it was rendered on", () => { + assert.match(source, /class="cron-run-link" href=\$\{deepLinkPath\(UI_BASE, "chats", run\.sessionId\)\}/); + assert.doesNotMatch(source, /location\.pathname\}\?session=/); +}); + +test("a cron row is a real link to its own path", () => { + assert.match(source, / { + assert.match(source, /event\.metaKey \|\| event\.ctrlKey \|\| event\.shiftKey \|\| event\.altKey \|\| event\.button/); +}); + +test("opening a cron from the list pushes history, so Back returns to the list", () => { + assert.match(source, /openCron\(c, \{ push: true \}\)/); + assert.match(source, /if \(push\) history\.pushState\(null, "", next\);\s+else history\.replaceState/); + assert.match(shell, /addEventListener\("popstate"[\s\S]*?routeCronsHistory\(item\)/); +}); + +test("a failed load is never reported as a missing cron", () => { + assert.match(source, /const loaded = await refreshCrons/); + assert.match(source, /if \(!loaded\) return drawCronsPage\(\);/); + const body = source.slice(source.indexOf("export async function renderCronsPage")); + assert.ok(body.indexOf("if (!loaded)") < body.indexOf("wasn't found")); +}); + +test("a pending deep-linked cron is consumed even when the view changed mid-load", () => { + const body = source.slice(source.indexOf("export async function renderCronsPage")); + const consume = body.indexOf("pendingCronId = null"); + const guard = body.indexOf('appState.currentView !== "crons") return', body.indexOf("await refreshCrons")); + assert.ok(consume !== -1 && guard !== -1 && consume < guard); +}); diff --git a/plugins/web-ui/test/deep-link.test.ts b/plugins/web-ui/test/deep-link.test.ts index 4bddc4f..19ec311 100644 --- a/plugins/web-ui/test/deep-link.test.ts +++ b/plugins/web-ui/test/deep-link.test.ts @@ -30,26 +30,46 @@ test("session ids are URI-encoded", () => { }); test("parseDeepLink reads the view from the path", () => { - assert.deepEqual(parseDeepLink("", "/crons", ""), { view: "crons", session: null }); - assert.deepEqual(parseDeepLink("/web-ui/", "/web-ui/crons", ""), { view: "crons", session: null }); - assert.deepEqual(parseDeepLink("", "/", "?session=s1"), { view: null, session: "s1" }); + assert.deepEqual(parseDeepLink("", "/crons", ""), { view: "crons", session: null, item: null }); + assert.deepEqual(parseDeepLink("/web-ui/", "/web-ui/crons", ""), { view: "crons", session: null, item: null }); + assert.deepEqual(parseDeepLink("", "/", "?session=s1"), { view: null, session: "s1", item: null }); }); test("parseDeepLink degrades a malformed percent-escape to no view instead of throwing", () => { - assert.deepEqual(parseDeepLink("", "/%E0%A4%A", ""), { view: null, session: null }); + assert.deepEqual(parseDeepLink("", "/%E0%A4%A", ""), { view: null, session: null, item: null }); }); test("parseDeepLink still honors legacy ?view= links", () => { - assert.deepEqual(parseDeepLink("", "/", "?view=crons"), { view: "crons", session: null }); + assert.deepEqual(parseDeepLink("", "/", "?view=crons"), { view: "crons", session: null, item: null }); assert.deepEqual(parseDeepLink("/web-ui/", "/web-ui/", "?view=contexts&scope=channel:C1"), { view: "contexts", session: null, + item: null, }); }); test("legacy connectors links resolve to the keychain view", () => { - assert.deepEqual(parseDeepLink("", "/connectors", ""), { view: "keychain", session: null }); - assert.deepEqual(parseDeepLink("", "/", "?view=connectors"), { view: "keychain", session: null }); + assert.deepEqual(parseDeepLink("", "/connectors", ""), { view: "keychain", session: null, item: null }); + assert.deepEqual(parseDeepLink("", "/", "?view=connectors"), { view: "keychain", session: null, item: null }); +}); + +test("a cron is addressed by /crons/", () => { + assert.equal(deepLinkPath("", "crons", null, null, "abc 1"), "/crons/abc%201"); + assert.deepEqual(parseDeepLink("", "/crons/abc%201", ""), { view: "crons", session: null, item: "abc 1" }); + assert.deepEqual(parseDeepLink("/web-ui/", "/web-ui/crons/c1", ""), { + view: "crons", + session: null, + item: "c1", + }); +}); + +test("an item id is rejected for views that are not addressed that way", () => { + assert.throws(() => deepLinkPath("", "chats", "s1", null, "x")); + assert.throws(() => deepLinkPath("", "contexts", null, "channel:C1", "x")); +}); + +test("only the first two path segments are addressed", () => { + assert.deepEqual(parseDeepLink("", "/crons/a/b", ""), { view: "crons", session: null, item: "a" }); }); test("sessionLink builds an absolute link under the serving base", () => { diff --git a/test/slack-delivery.test.ts b/test/slack-delivery.test.ts index 8f5f258..24f1c2b 100644 --- a/test/slack-delivery.test.ts +++ b/test/slack-delivery.test.ts @@ -624,7 +624,7 @@ function headerHarness( webUiPublicUrl: "https://claw.acme.dev", ids: { botUserId: "U0BOT" }, }); - const scope = kind === "dm" ? "personal:josh@acme.dev" : "channel:C1"; + const scope = kind === "dm" ? "personal:user.one@acme.dev" : "channel:C1"; const ensure = (c: unknown, channel: string) => raw(c as never, channel, scope, kind); const flush = async (): Promise => { for (let i = 0; i < 12; i++) await Promise.resolve(); @@ -639,7 +639,7 @@ test("surface header ensurer writes the header once, then goes quiet", async () assert.equal(h.calls.set, 1); assert.equal( h.read()?.value, - "Model: Claude Opus 4.8 · https://claw.acme.dev/contexts?scope=personal%3Ajosh%40acme.dev", + "Model: Claude Opus 4.8 · https://claw.acme.dev/contexts?scope=personal%3Auser.one%40acme.dev", ); h.ensure(h.client, "D1"); await h.flush(); @@ -668,7 +668,7 @@ test("surface header ensurer collapses a burst on one channel into a single writ webUiPublicUrl: "https://claw.acme.dev", ids: { botUserId: "U0BOT" }, }); - for (let i = 0; i < 5; i++) ensure(client as any, "D1", "personal:josh@acme.dev", "dm"); + for (let i = 0; i < 5; i++) ensure(client as any, "D1", "personal:user.one@acme.dev", "dm"); await new Promise((r) => setTimeout(r, 60)); assert.equal(infos, 1, "the in-flight guard spares the concurrent probes"); assert.equal(sets, 1); @@ -685,7 +685,7 @@ test("surface header ensurer caps its per-channel memo", async () => { maxTracked: 3, }); for (let i = 0; i < 10; i++) { - ensure(client as any, `D${i}`, "personal:josh@acme.dev", "dm"); + ensure(client as any, `D${i}`, "personal:user.one@acme.dev", "dm"); await new Promise((r) => setTimeout(r, 2)); } let reprobed = 0; @@ -698,7 +698,7 @@ test("surface header ensurer caps its per-channel memo", async () => { setTopic: async () => ({}), }, }; - ensure(spy as any, "D0", "personal:josh@acme.dev", "dm"); + ensure(spy as any, "D0", "personal:user.one@acme.dev", "dm"); await new Promise((r) => setTimeout(r, 20)); assert.equal(reprobed, 1, "an evicted channel is re-probed, so the map cannot grow forever"); }); @@ -729,8 +729,8 @@ test("scopeSurfaceUrl deep-links each context to its own project page", () => { "https://claw.acme.dev/contexts?scope=channel%3AC1", ); assert.equal( - scopeSurfaceUrl("https://claw.acme.dev", "personal:josh@acme.dev"), - "https://claw.acme.dev/contexts?scope=personal%3Ajosh%40acme.dev", + scopeSurfaceUrl("https://claw.acme.dev", "personal:user.one@acme.dev"), + "https://claw.acme.dev/contexts?scope=personal%3Auser.one%40acme.dev", ); assert.equal(scopeSurfaceUrl(undefined, "channel:C1"), undefined); assert.equal(scopeSurfaceUrl("https://claw.acme.dev", ""), undefined); @@ -752,6 +752,6 @@ test("surface header ensurer swallows a Slack failure instead of surfacing it to webUiPublicUrl: "https://claw.acme.dev", ids: { botUserId: "U0BOT" }, }); - assert.doesNotThrow(() => ensure({} as any, "D1", "personal:josh@acme.dev", "dm")); + assert.doesNotThrow(() => ensure({} as any, "D1", "personal:user.one@acme.dev", "dm")); for (let i = 0; i < 12; i++) await Promise.resolve(); }); From 0c35efbe054317d12ae1e92dfe5235bc6a32fea3 Mon Sep 17 00:00:00 2001 From: Regan Bell Date: Wed, 29 Jul 2026 15:33:34 -0700 Subject: [PATCH 02/17] feat(deploy): 'Request access' button on the app not-shared page * feat(deploy): 'Request access' button on the not-shared gate page A signed-in visitor who lands on an app that isn't shared with them can now click Request access; the gateway DMs the app's owner (personal home scope, else creator) naming the visitor and the app, deduped per visitor+app+day via the delivery outbox idempotency key. Signed-out visitors still get the sign-in bounce and cannot post requests. * test: non-null assertions for indexed delivery reads --------- --- src/api/routes/deployments.ts | 38 ++++++++++++++++++++++- test/deploy-subdomain-signin.test.ts | 45 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/api/routes/deployments.ts b/src/api/routes/deployments.ts index 9b59f7d..cfa94f9 100644 --- a/src/api/routes/deployments.ts +++ b/src/api/routes/deployments.ts @@ -20,6 +20,7 @@ import { CONFIG_DEFAULTS } from "../../config.ts"; import { resolveShareTarget as resolveShareTargetGrammar } from "../artifact-share.ts"; import { mintDeployOwnerToken, verifyDeployGitAccess, verifyDeployOwnerToken } from "../../deploy/access-token.ts"; import { EDIT_WIDGET_JS, EDIT_WIDGET_PATH_PREFIX, editWidgetTag } from "../../deploy/edit-widget.ts"; +import { principalDestination } from "../../reach/reach.ts"; import { portalSessionSub } from "../../deploy/viewer-session.ts"; import { proxyHeaders } from "../../util/http-proxy.ts"; @@ -588,6 +589,32 @@ export async function proxyDeploymentSubdomain(ctx: BaseCtx): Promise { if (deps.auditLog?.recordOnce) await deps.auditLog.recordOnce(`reach_denied|${sub}|${slug}|${hour}`, ev); else deps.auditLog?.record(ev); } + if (reach.status === "denied" && ctx.method === "POST" && pathname === REQUEST_ACCESS_PATH) { + const d = await app.getDeployment(slug).catch(() => null); + if (!d) { + sendJson(res, 404, { error: "not_found" }); + return true; + } + // The recipient is the app's owner: the personal home scope if it has one, else whoever created it. + const [ownerKind, ownerRef] = String(d.ownerScopeId).split(":", 2); + const ownerId = ownerKind === "personal" && ownerRef ? ownerRef : d.createdBy; + const label = d.displayName ?? d.name ?? slug; + // One request per visitor per app per day — the idempotent outbox absorbs button mashing. + const day = Math.floor(Date.now() / 86_400_000); + try { + await app.enqueueDelivery({ + destination: principalDestination(ownerId, sub), + text: + `${sub} is asking for access to your app "${label}" (https://${rawHost}/). ` + + `They signed in but the app isn't shared with them. To grant it, share the deployment with personal:${sub}.`, + idempotencyKey: `deploy-access-request:${slug}:${sub}:${day}`, + }); + sendJson(res, 200, { ok: true }); + } catch { + sendJson(res, 502, { error: "delivery_failed", message: "the request could not be delivered — try again later" }); + } + return true; + } if (reach.status === "denied" && wantsHtml) { res.writeHead(403, { "content-type": "text/html; charset=utf-8", @@ -615,11 +642,20 @@ p{color:#a3a3a3;margin:0 0 8px}b{color:#fafafa}a{color:#fafafa}

${escapeHtml(title)}

${paragraphsHtml}
`; } +const REQUEST_ACCESS_PATH = "/__claw__/request-access"; + function notSharedHtml(sub: string): string { return gateCardHtml( "This app hasn't been shared with you", `

You're signed in as ${escapeHtml(sub)}, but this app's owner hasn't shared it with you.

-

Ask the owner for access, or for the app's share link.

`, +

Ask the owner for access, or for the app's share link.

+

+`, ); } diff --git a/test/deploy-subdomain-signin.test.ts b/test/deploy-subdomain-signin.test.ts index cfe29aa..0bede05 100644 --- a/test/deploy-subdomain-signin.test.ts +++ b/test/deploy-subdomain-signin.test.ts @@ -73,6 +73,22 @@ function httpGet( }); } +function httpPost( + port: number, + path: string, + headers: Record, +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = httpRequest({ host: "localhost", port, path, method: "POST", headers }, (res) => { + let body = ""; + res.on("data", (c) => (body += c)); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body })); + }); + req.on("error", reject); + req.end(); + }); +} + test("subdomain ingress: portal sign-in admits the owner, denies strangers, bounces the signed-out", async () => { let upstreamCookie: string | undefined = "unset"; let upstreamUrl = ""; @@ -114,6 +130,10 @@ test("subdomain ingress: portal sign-in admits the owner, denies strangers, boun files: [], name: "mysite", }); + const deliveries: { destination: unknown; text: string; idempotencyKey: string }[] = []; + (app as unknown as Record).enqueueDelivery = async (input: (typeof deliveries)[number]) => { + deliveries.push(input); + }; const server = createInsecureTestServer(app, { deployAppsDomain: "apps.example.com", deployGateSecret: "gate-secret", @@ -184,6 +204,31 @@ test("subdomain ingress: portal sign-in admits the owner, denies strangers, boun }); assert.equal(strangerXhr.status, 403, "non-HTML denial stays JSON"); + assert.match(stranger.body, /Request access/, "the denial page offers a request-access button"); + const asked = await httpPost(port, "/__claw__/request-access", { + Host: host, + Cookie: `portal_session=${mintPortalSession("mallory@example.com")}`, + }); + assert.equal(asked.status, 200, "a denied visitor can ask the owner for access"); + const again = await httpPost(port, "/__claw__/request-access", { + Host: host, + Cookie: `portal_session=${mintPortalSession("mallory@example.com")}`, + }); + assert.equal(again.status, 200, "asking twice is idempotent, not an error"); + assert.equal(deliveries.length, 2, "both posts enqueue (the outbox dedupes by idempotency key)"); + assert.equal(deliveries[0]!.idempotencyKey, deliveries[1]!.idempotencyKey, "same visitor+app+day dedupes"); + assert.match(deliveries[0]!.text, /mallory@example\.com is asking for access/); + assert.match(deliveries[0]!.text, /mysite/); + assert.deepEqual(deliveries[0]!.destination, { + type: "principal", + target: "alice@example.com", + audienceScopeId: "personal:alice@example.com", + onBehalfOf: "mallory@example.com", + }); + + const signedOutAsk = await httpPost(port, "/__claw__/request-access", { Host: host }); + assert.equal(signedOutAsk.status, 401, "a signed-out visitor cannot send access requests"); + const forged = await httpGet(port, "/consultants", { Host: host, Accept: "text/html", From 5f70c6b949e8983b7ecaea2bd8612bd962d7e6e9 Mon Sep 17 00:00:00 2001 From: Joshua France Date: Wed, 29 Jul 2026 22:55:03 -0700 Subject: [PATCH 03/17] perf(web-ui): one app with in-document panes, and a byte-budgeted transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(web-ui): one app with in-document panes, and a byte-budgeted transcript Loading a nine-pane Split Canvas flickered for ~10s before settling. Two roots, both structural. The canvas multiplied the *application* instead of the conversation. `chatState` is a singleton, so the earlier iframe change gave each pane its own same-origin iframe rather than refactor it — and same-origin iframes share one renderer process and one event loop, so nine SPA boots buy no parallelism, only nine copies of the fixed cost: bundle parse, `/me`, runtime-config, `/api/sessions` (twice each), `/api/contexts`, and a live app behind every tab the viewer cannot see. Under that load dockview's `always` renderer could not keep its absolutely-positioned pane overlays placed, which is what visibly fluttered. `chat.ts` and `composer.ts` now expose `createChatSurface` / `createComposerSurface` factories; `conversations.ts` wires a pair into a Conversation and owns the registry, the single delivery stream, and the full-screen conversation. Panes mount a Conversation into their own element, so embed mode, its postMessage protocol, and the delivery relay are gone, hidden tabs render nothing until shown, and pane state is read rather than messaged. Most of this diff is the indentation of wrapping two modules in a factory — `git diff -w` is 1.1k lines, not 3.5k. Second root: the transcript wire format is the storage format. `windowedTranscript` bounded the window in turns, a storage concept, so `tailTurns=25` shipped whole `tool_result` bodies — 8 MB for one large session, 94% of it output the UI renders collapsed, refetched in full every time a turn settled. It now also bounds bytes and previews oversized tool payloads, with `GET /v1/sessions/:id/entries/:seq` fetching a full body when someone expands it. Measured on a nine-pane canvas against a stub core holding realistic transcripts, throttled 4x CPU / 40ms RTT: visually settled 11.1s -> 1.4s, 12 repaints -> 4, one 463ms window where the page could not paint at all -> none, heap 130MB -> 38MB, 9 iframes -> 0, and 4 panes mounted instead of 9. * fix(web-ui): close the cross-pane and transcript-cap holes an adversarial pass found Nine follow-up fixes to the first commit, every one a consequence of the two structural changes. Panes are conversations in one realm now, so anything left at module scope is shared by every pane. The runtime config was: `activeModelOptions`, `defaultRuntimeValue`, and the fast-mode id set were global, so with panes on different scopes the last runtime fetch decided every pane's picker, default model, and fast-mode eligibility — a pane could hold model A while its turn carried harness B. All three are keyed by scope now, and a test pins the isolation. The transcript cap leaked into paths that were not asking for a window: - `tool_call` payloads with `action: "post"` carry the agent's own reply, which the client promotes into the visible assistant message. Truncating them cut posted replies to 2 KB with no way to see the rest. Post text is conversation text and is now exempt. - Fork cutoffs count user entries in an unwindowed read, so a capped read forked after the wrong message. The byte budget now applies only when a window was requested; an unwindowed read still owes the caller every entry. - A refresh that trimmed the front of its window left `transcriptAnchorSeq` pointing at an entry no longer in the page, so "Show earlier" skipped the gap. The anchor follows the page. - A read-only conversation refreshed on delivery through the unwindowed endpoint and dropped `earlierEntries`, losing its pagination button. - Strings below the depth-8 walk were neither truncated nor counted (charged 8 bytes), so nested JSON could smuggle a megabyte past the cap. - The byte cut landed anywhere, splitting a tool call from its result across pages; it snaps to a turn boundary like the turn cut already did. And three lifecycle gaps: sign-out reset only the main conversation while pane agents, timers, and registrations kept running; `sessionsReady()` never settled when the first session-list fetch failed, hanging restored panes on a spinner forever; and a pane closed mid-load could remount an agent into detached DOM. Re-measured on the same nine-pane canvas, throttled 4x CPU / 40ms RTT: settled 11.5s -> 1.6s, 12 repaints -> 3, 871ms frozen -> none, heap 127MB -> 38MB. * fix(web-ui): a live run belongs to one conversation, and a refresh never shrinks the window Three more review findings, all cross-pane consequences of mounting conversations in one realm. `liveRun` was a module singleton in core-bridge, so a second pane's turn overwrote the first: Stop or Steer in one pane signalled another pane's run. Each conversation owns a `RunSlot` now, threaded from the stream factories down to `followRun`, and the composer signals its own chat surface instead of a global. The byte budget applied to `sinceSeq` reads too. That path re-reads a window the client already holds and replaces the rendered messages with it, so trimming it made messages the reader was looking at disappear after every settled turn. A `sinceSeq` read is a refresh, not a page: it is never trimmed. `tailTurns` and `beforeSeq` still are. `renderList()` ran on every agent subscription event, including stream deltas, so each token rebuilt the session sidebar and notified every canvas header. With one conversation that was waste; with nine it is the load cost this PR set out to remove. It runs on `agent_end` and on working-state flips only. * fix(web-ui): a read-only transcript redraws, and keeps the pages it loaded Two more review findings, both in the read-only path the previous commit touched. `loadFullEntry` redrew through `drawActiveChat`, which returns early when there is no agent or host — exactly the state a read-only mount leaves behind. So in a Slack transcript "Show full output" fetched the entry and changed nothing on screen. It redraws through whichever surface is mounted now. The read-only delivery refresh always refetched the tail, so a nudge threw away history the reader had opened with "Show earlier messages" and snapped them back to the bottom. The read-only view remembers its anchor and refreshes from it — a `sinceSeq` read, which is never trimmed. --------- --- plugins/web-ui/server/index.ts | 15 +- plugins/web-ui/src/chat.ts | 3348 +++++++++-------- plugins/web-ui/src/composer.ts | 2269 +++++------ plugins/web-ui/src/contexts.ts | 4 +- plugins/web-ui/src/conv-types.ts | 119 + plugins/web-ui/src/conversations.ts | 87 + plugins/web-ui/src/core-bridge.ts | 55 +- plugins/web-ui/src/crons.ts | 12 +- plugins/web-ui/src/deploys.ts | 12 +- plugins/web-ui/src/embed.ts | 99 - plugins/web-ui/src/main.ts | 41 +- plugins/web-ui/src/model-options.ts | 53 +- plugins/web-ui/src/pi-models.ts | 13 +- plugins/web-ui/src/session-list.ts | 5 +- plugins/web-ui/src/sessions.ts | 92 +- plugins/web-ui/src/shell.css | 68 +- plugins/web-ui/src/shell.ts | 91 +- plugins/web-ui/src/split-layout.ts | 20 + plugins/web-ui/src/split.ts | 242 +- .../web-ui/test/composer-no-scrollbar.test.ts | 4 +- plugins/web-ui/test/composer-source.test.ts | 5 +- plugins/web-ui/test/core-wire-contract.ts | 3 +- plugins/web-ui/test/layout-thrash.test.ts | 2 +- plugins/web-ui/test/model-options.test.ts | 31 +- plugins/web-ui/test/openrouter-turn.test.ts | 1 + .../web-ui/test/pane-composer-source.test.ts | 15 +- plugins/web-ui/test/pi-models.test.ts | 16 +- .../test/runtime-config-handoff.test.ts | 74 + .../web-ui/test/split-canvas-entry.test.ts | 85 +- src/api/app-sessions.ts | 10 + src/api/app-types.ts | 11 +- src/api/routes/surface.ts | 15 + src/api/user-scoped-routes.ts | 1 + src/sessions/session-store.ts | 86 +- test/transcript-window.test.ts | 115 +- 35 files changed, 3943 insertions(+), 3176 deletions(-) create mode 100644 plugins/web-ui/src/conv-types.ts create mode 100644 plugins/web-ui/src/conversations.ts delete mode 100644 plugins/web-ui/src/embed.ts create mode 100644 plugins/web-ui/test/runtime-config-handoff.test.ts diff --git a/plugins/web-ui/server/index.ts b/plugins/web-ui/server/index.ts index be031e1..08d0b2a 100644 --- a/plugins/web-ui/server/index.ts +++ b/plugins/web-ui/server/index.ts @@ -1073,6 +1073,16 @@ const routeRequest = async (req: IncomingMessage, res: ServerResponse) => { return relay(res, r); } + if (method === "GET" && /^\/api\/sessions\/[^/]+\/entries\/\d+$/.test(path)) { + const [, , , rawId, , seq] = path.split("/"); + const id = decodeURIComponent(rawId!); + const r = await coreFetch( + "GET", + `/v1/sessions/${encodeURIComponent(id)}/entries/${seq}?viewer=${encodeURIComponent(user)}`, + ); + return relay(res, r); + } + if (method === "GET" && path.startsWith("/api/sessions/")) { const id = decodeURIComponent(path.slice("/api/sessions/".length)); const qs = new URLSearchParams({ viewer: user }); @@ -1413,7 +1423,10 @@ const routeRequest = async (req: IncomingMessage, res: ServerResponse) => { if (!threadRef.startsWith(`web:${user}:`)) { const sessionId = typeof record.sessionId === "string" ? record.sessionId : ""; const visible = sessionId - ? await coreFetch("GET", `/v1/sessions/${encodeURIComponent(sessionId)}?viewer=${encodeURIComponent(user)}`) + ? await coreFetch( + "GET", + `/v1/sessions/${encodeURIComponent(sessionId)}?viewer=${encodeURIComponent(user)}&tailTurns=1`, + ) : null; if (visible?.status !== 200) return json(res, 404, { error: "not_found" }); } diff --git a/plugins/web-ui/src/chat.ts b/plugins/web-ui/src/chat.ts index 44a3c18..8ba125f 100644 --- a/plugins/web-ui/src/chat.ts +++ b/plugins/web-ui/src/chat.ts @@ -34,8 +34,12 @@ import { import { activeRunForThread, api, + createRunSlot, + hasLiveRun, + signalLiveRun, attachPendingApprovals, entriesToMessages, + fetchEntry, fetchTranscript, forkCutSeq, forkSession, @@ -44,7 +48,6 @@ import { makeRunResumeStreamFn, runApprovalTurn, sharedContextLabel, - subscribeDeliveries, TAIL_TURNS, type ApprovalDecision, type AssistantWork, @@ -63,15 +66,7 @@ import { import { buildTimeline, toolRowKind, type TimelineItem, type ToolPayload, type ToolRowModel } from "./timeline"; import { CONNECTOR_NAMES, connectorLinksIn, stripConnectorLinks, type ConnectorLink } from "./connector-link"; import { deepLinkPath, UI_BASE } from "./deep-link"; -import { - currentDensity, - embedMode, - onDensityChange, - onRelayedDelivery, - postPaneState, - requestPaneExpand, -} from "./embed"; -import { exitSplitIfActive, relayDeliveryToPanes } from "./split"; +import type { ChatSurface, ConvCtx } from "./conv-types"; import { errMessage, swallow } from "../../chassis/src/errors"; import { showStateError } from "./error-banner"; import { escapeLoneDollars } from "./markdown-dollars"; @@ -94,279 +89,298 @@ import { sessionSlackUrl, surfaceOf, } from "./sessions"; -import { applySessionState, backgroundLabel, clearWorking, conversationBackground, markWorking } from "./session-list"; +import { backgroundLabel, clearWorking, conversationBackground, markWorking } from "./session-list"; import { liveTurnThreadRef } from "./working-dot"; -import { - carryModelPick, - composerForm, - composerState, - currentModelOption, - focusComposerEnd, - onDragEnter, - onDragLeave, - onDragOver, - onDrop, - refreshRuntimeSelection, - resetComposer, - resizeComposer, -} from "./composer"; import { newChatDraftKey, saveDraft, storedDraft } from "./drafts"; installMarkdownSanitizer(); -export const chatState = { - agent: null as Agent | null, - host: null as HTMLElement | null, - threadRef: null as string | null, - sessionId: null as string | null, - scopeId: null as string | null, - contextName: null as string | null, - rememberedThreadRef: null as string | null, - rememberedSessionId: null as string | null, - rememberedScopeId: null as string | null, - rememberedContextName: null as string | null, - liveWork: null as WorkBlock | null, - pendingSend: null as string | null, - normalStreamFn: null as Agent["streamFn"] | null, - onWork: null as ((work: WorkBlock) => void) | null, - resolvingApprovals: new Set(), - transcriptAnchorSeq: null as number | null, - earlierCount: 0, - loadingEarlier: false, -}; - -let workTicker: ReturnType | null = null; -let revealedTailLen = 0; const detachedAgents = new WeakSet(); -let liveWorkExpanded = false; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); - -export function notePendingSessionOnSend(): void { - if (!chatState.threadRef || chatState.sessionId !== null) return; - const existing = sessionsState.list.find((s) => s.id && s.threadRef === chatState.threadRef); - if (existing) { - if (chatState.agent) adoptActiveSessionFromList(chatState.agent); - return; - } - addPendingSession(chatState.threadRef, chatState.scopeId, chatState.contextName); +interface SettledRowKey { + index: number; + activity: WorkBlock["activity"] | undefined; + status: WorkBlock["status"] | undefined; + stale: boolean | undefined; + deliveredFiles: unknown; + stopReason: unknown; + errorMessage: unknown; + approvalDecision: unknown; + forkable: boolean; + tpl: TemplateResult | typeof nothing; } +const settledRowCache = new WeakMap(); +const connectedConnectors = new Set(); +const redrawHooks = new Set<() => void>(); +let proactiveOpenerStarted = false; -let readOnlyView: { id: string; threadRef: string; session: CoreSession } | null = null; - -export function teardownActiveChat(): void { - readOnlyView = null; - preserveOutgoingWorkingDot(null); - detachActiveAgent(); - chatState.agent = null; - clearLiveWork(); - resetBackgroundPanel(); - chatState.host = null; - chatState.threadRef = null; - chatState.sessionId = null; - chatState.scopeId = null; - chatState.contextName = null; - chatState.normalStreamFn = null; - chatState.onWork = null; - chatState.resolvingApprovals.clear(); - chatState.transcriptAnchorSeq = null; - chatState.earlierCount = 0; - chatState.loadingEarlier = false; -} +export const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); -export function resetChatState(): void { - teardownActiveChat(); - proactiveOpenerStarted = false; - chatState.rememberedThreadRef = null; - chatState.rememberedSessionId = null; - chatState.rememberedScopeId = null; - chatState.rememberedContextName = null; - connectedConnectors.clear(); -} +export function markConnectorConnected(provider: string): void { + if (!provider) return; + connectedConnectors.add(provider); + for (const hook of redrawHooks) hook(); +} + +export function createChatSurface(ctx: ConvCtx): ChatSurface { + const runSlot = createRunSlot(); + + const chatState = { + agent: null as Agent | null, + host: null as HTMLElement | null, + threadRef: null as string | null, + sessionId: null as string | null, + scopeId: null as string | null, + contextName: null as string | null, + rememberedThreadRef: null as string | null, + rememberedSessionId: null as string | null, + rememberedScopeId: null as string | null, + rememberedContextName: null as string | null, + liveWork: null as WorkBlock | null, + pendingSend: null as string | null, + normalStreamFn: null as Agent["streamFn"] | null, + onWork: null as ((work: WorkBlock) => void) | null, + resolvingApprovals: new Set(), + transcriptAnchorSeq: null as number | null, + earlierCount: 0, + loadingEarlier: false, + }; -export function newChat(context?: { scopeId: string; name: string | null }): string { - appState.currentView = "chats"; - renderSidebarTop(); - const user = appState.me?.user ?? "anon"; - const threadRef = `web:${user}:${crypto.randomUUID()}`; - const carried = storedDraft(newChatDraftKey(user)); - if (carried) saveDraft(threadRef, carried); - resetComposer(); - mountContinuable(threadRef, null, context?.scopeId ?? null, [], context?.name ?? null); - renderList(); - focusComposerEnd(); - return threadRef; -} + let workTicker: ReturnType | null = null; + let revealedTailLen = 0; + let liveWorkExpanded = false; -function preserveOutgoingWorkingDot(nextThreadRef: string | null): void { - const live = liveTurnThreadRef({ - mountedThreadRef: chatState.threadRef, - isStreaming: Boolean(chatState.agent?.state.isStreaming), - pendingSend: chatState.pendingSend, - }); - if (!live || live === nextThreadRef) return; - sessionsState.list = markWorking(sessionsState.list, live); -} + function notePendingSessionOnSend(): void { + if (!chatState.threadRef || chatState.sessionId !== null) return; + const existing = sessionsState.list.find((s) => s.id && s.threadRef === chatState.threadRef); + if (existing) { + if (chatState.agent) adoptActiveSessionFromList(chatState.agent); + return; + } + addPendingSession(chatState.threadRef, chatState.scopeId, chatState.contextName); + } -function detachActiveAgent(): void { - if (!chatState.agent) return; - detachedAgents.add(chatState.agent); - chatState.agent.abort(); -} + let readOnlyView: { id: string; threadRef: string; session: CoreSession; anchorSeq: number | null } | null = null; + + function teardownActiveChat(): void { + readOnlyView = null; + preserveOutgoingWorkingDot(null); + detachActiveAgent(); + chatState.agent = null; + clearLiveWork(); + resetBackgroundPanel(); + chatState.host = null; + chatState.threadRef = null; + chatState.sessionId = null; + chatState.scopeId = null; + chatState.contextName = null; + chatState.normalStreamFn = null; + chatState.onWork = null; + chatState.resolvingApprovals.clear(); + chatState.transcriptAnchorSeq = null; + chatState.earlierCount = 0; + chatState.loadingEarlier = false; + } -export function mountContinuable( - threadRef: string, - sessionId: string | null, - scopeId: string | null, - messages: ReturnType, - contextName: string | null = null, -): void { - if (!appState.mainEl) return; - exitSplitIfActive(); - readOnlyView = null; - preserveOutgoingWorkingDot(threadRef); - detachActiveAgent(); - resetComposer(); - chatState.threadRef = threadRef; - chatState.sessionId = sessionId; - chatState.scopeId = scopeId; - chatState.contextName = contextName; - chatState.rememberedThreadRef = threadRef; - chatState.rememberedSessionId = sessionId; - chatState.rememberedScopeId = scopeId; - chatState.rememberedContextName = contextName; - composerState.draft = storedDraft(threadRef); - syncUrlFromState(); - chatState.transcriptAnchorSeq = null; - chatState.earlierCount = 0; - chatState.loadingEarlier = false; - chatState.host = document.createElement("div"); - chatState.host.className = "custom-chat"; - - const model = currentModelOption().model; - const defaultThinkingLevel = defaultEffortForModel(model); - const agent = new Agent({ - initialState: { - systemPrompt: "", - model, - ...(defaultThinkingLevel === "low" ? { thinkingLevel: "low" as const } : {}), - messages, - tools: [], - }, - convertToLlm: (messages) => import("@earendil-works/pi-web-ui").then((m) => m.defaultConvertToLlm(messages)), - }); - chatState.agent = agent; - clearLiveWork(); - resetBackgroundPanel(); - chatState.resolvingApprovals.clear(); - const onWork = observeLiveWork(agent); - const normalStreamFn = makeCoreStreamFn(threadRef, agent, currentTurnOptions, onWork); - agent.streamFn = normalStreamFn; - chatState.normalStreamFn = normalStreamFn; - chatState.onWork = onWork; - void refreshRuntimeSelection(scopeId, agent); - - agent.subscribe((e) => { - scheduleStreamDraw(agent); - if (agent === chatState.agent && (agent.state.isStreaming || e.type === "agent_end")) chatState.pendingSend = null; - if (e.type === "agent_end" && !detachedAgents.has(agent)) - sessionsState.list = clearWorking(sessionsState.list, threadRef); + function resetChatState(): void { + teardownActiveChat(); + proactiveOpenerStarted = false; + chatState.rememberedThreadRef = null; + chatState.rememberedSessionId = null; + chatState.rememberedScopeId = null; + chatState.rememberedContextName = null; + connectedConnectors.clear(); + } + + function newChat(context?: { scopeId: string; name: string | null }): string { + appState.currentView = "chats"; + renderSidebarTop(); + const user = appState.me?.user ?? "anon"; + const threadRef = `web:${user}:${crypto.randomUUID()}`; + const carried = storedDraft(newChatDraftKey(user)); + if (carried) saveDraft(threadRef, carried); + ctx.composer.resetComposer(); + mountContinuable(threadRef, null, context?.scopeId ?? null, [], context?.name ?? null); renderList(); - if (e.type !== "agent_end") return; - void agent.waitForIdle().then(async () => { - if (agent === chatState.agent) clearLiveWork(); - const wasUnsaved = agent === chatState.agent && chatState.sessionId === null; - try { - await refreshSessions({ silent: true }); - } catch { - void 0; - } - if (!detachedAgents.has(agent)) { + ctx.composer.focusComposerEnd(); + return threadRef; + } + + function preserveOutgoingWorkingDot(nextThreadRef: string | null): void { + const live = liveTurnThreadRef({ + mountedThreadRef: chatState.threadRef, + isStreaming: Boolean(chatState.agent?.state.isStreaming), + pendingSend: chatState.pendingSend, + }); + if (!live || live === nextThreadRef) return; + sessionsState.list = markWorking(sessionsState.list, live); + } + + function detachActiveAgent(): void { + if (!chatState.agent) return; + detachedAgents.add(chatState.agent); + chatState.agent.abort(); + } + + function mountContinuable( + threadRef: string, + sessionId: string | null, + scopeId: string | null, + messages: ReturnType, + contextName: string | null = null, + ): void { + const container = ctx.claimContainer(); + if (!container) return; + readOnlyView = null; + preserveOutgoingWorkingDot(threadRef); + detachActiveAgent(); + ctx.composer.resetComposer(); + chatState.threadRef = threadRef; + chatState.sessionId = sessionId; + chatState.scopeId = scopeId; + chatState.contextName = contextName; + chatState.rememberedThreadRef = threadRef; + chatState.rememberedSessionId = sessionId; + chatState.rememberedScopeId = scopeId; + chatState.rememberedContextName = contextName; + ctx.composer.state.draft = storedDraft(threadRef); + syncLocation(); + chatState.transcriptAnchorSeq = null; + chatState.earlierCount = 0; + chatState.loadingEarlier = false; + chatState.host = document.createElement("div"); + chatState.host.className = "custom-chat"; + + const model = ctx.composer.currentModelOption().model; + const defaultThinkingLevel = defaultEffortForModel(model); + const agent = new Agent({ + initialState: { + systemPrompt: "", + model, + ...(defaultThinkingLevel === "low" ? { thinkingLevel: "low" as const } : {}), + messages, + tools: [], + }, + convertToLlm: (messages) => import("@earendil-works/pi-web-ui").then((m) => m.defaultConvertToLlm(messages)), + }); + chatState.agent = agent; + clearLiveWork(); + resetBackgroundPanel(); + chatState.resolvingApprovals.clear(); + const onWork = observeLiveWork(agent); + const normalStreamFn = makeCoreStreamFn(threadRef, agent, currentTurnOptions, onWork, runSlot); + agent.streamFn = normalStreamFn; + chatState.normalStreamFn = normalStreamFn; + chatState.onWork = onWork; + void ctx.composer.refreshRuntimeSelection(scopeId, agent); + + let listedWorking = false; + agent.subscribe((e) => { + scheduleStreamDraw(agent); + if (agent === chatState.agent && (agent.state.isStreaming || e.type === "agent_end")) + chatState.pendingSend = null; + if (e.type === "agent_end" && !detachedAgents.has(agent)) sessionsState.list = clearWorking(sessionsState.list, threadRef); + const working = agent.state.isStreaming || chatState.pendingSend !== null; + if (working !== listedWorking || e.type === "agent_end") { + listedWorking = working; renderList(); } - if (agent !== chatState.agent) return; - adoptActiveSessionFromList(agent); - await refreshTranscriptFromEntries(agent); - if (wasUnsaved && chatState.sessionId) void settleNewSessionTitle(agent, chatState.sessionId); + if (e.type !== "agent_end") return; + void agent.waitForIdle().then(async () => { + if (agent === chatState.agent) clearLiveWork(); + const wasUnsaved = agent === chatState.agent && chatState.sessionId === null; + try { + await refreshSessions({ silent: true }); + } catch { + void 0; + } + if (!detachedAgents.has(agent)) { + sessionsState.list = clearWorking(sessionsState.list, threadRef); + renderList(); + } + if (agent !== chatState.agent) return; + adoptActiveSessionFromList(agent); + await refreshTranscriptFromEntries(agent); + if (wasUnsaved && chatState.sessionId) void settleNewSessionTitle(agent, chatState.sessionId); + }); }); - }); - - stickToBottom = true; - appState.mainEl.replaceChildren(chatState.host); - const opening = startProactiveOpenerIfNew(agent, threadRef, normalStreamFn, onWork, sessionId, scopeId, messages); - drawActiveChat(agent, { forceScroll: true }); - focusComposerEnd(); - ensureDeliveryStream(); - if (!opening) void resumeTrackedRun(agent, threadRef, normalStreamFn, onWork); - consumeBackgroundPanelRequest(); -} -let proactiveOpenerStarted = false; + stickToBottom = true; + container.replaceChildren(chatState.host); + const opening = startProactiveOpenerIfNew(agent, threadRef, normalStreamFn, onWork, sessionId, scopeId, messages); + drawActiveChat(agent, { forceScroll: true }); + ctx.composer.focusComposerEnd(); + ctx.ensureDeliveryStream(); + if (!opening) void resumeTrackedRun(agent, threadRef, normalStreamFn, onWork); + consumeBackgroundPanelRequest(); + } -function startProactiveOpenerIfNew( - agent: Agent, - threadRef: string, - normalStreamFn: Agent["streamFn"], - onWork: (work: WorkBlock) => void, - sessionId: string | null, - scopeId: string | null, - messages: ReturnType, -): boolean { - if (embedMode) return false; - if (proactiveOpenerStarted || sessionId !== null || scopeId !== null || messages.length > 0) return false; - if (!sessionsState.loaded) return false; - if (sessionsState.list.some((s) => s.id)) return false; - proactiveOpenerStarted = true; - agent.state.messages = [{ role: "user", content: "", opener: true } as unknown as AgentMessage]; - agent.streamFn = makeOpenerStreamFn(threadRef, agent, currentTurnOptions, onWork); - void (async () => { - try { - await agent.continue(); - } catch (err) { - if (agent === chatState.agent) composerState.error = errMessage(err, "Could not start the conversation."); - } finally { - if (agent === chatState.agent) { - agent.streamFn = normalStreamFn; - const last = agent.state.messages[agent.state.messages.length - 1] as AssistantMessage | undefined; - if ( - last?.role === "assistant" && - (last.stopReason === "error" || last.stopReason === "aborted") && - !messageText(last).trim() - ) { - agent.state.messages = []; + function startProactiveOpenerIfNew( + agent: Agent, + threadRef: string, + normalStreamFn: Agent["streamFn"], + onWork: (work: WorkBlock) => void, + sessionId: string | null, + scopeId: string | null, + messages: ReturnType, + ): boolean { + if (ctx.pane) return false; + if (proactiveOpenerStarted || sessionId !== null || scopeId !== null || messages.length > 0) return false; + if (!sessionsState.loaded) return false; + if (sessionsState.list.some((s) => s.id)) return false; + proactiveOpenerStarted = true; + agent.state.messages = [{ role: "user", content: "", opener: true } as unknown as AgentMessage]; + agent.streamFn = makeOpenerStreamFn(threadRef, agent, currentTurnOptions, onWork, runSlot); + void (async () => { + try { + await agent.continue(); + } catch (err) { + if (agent === chatState.agent) ctx.composer.state.error = errMessage(err, "Could not start the conversation."); + } finally { + if (agent === chatState.agent) { + agent.streamFn = normalStreamFn; + const last = agent.state.messages[agent.state.messages.length - 1] as AssistantMessage | undefined; + if ( + last?.role === "assistant" && + (last.stopReason === "error" || last.stopReason === "aborted") && + !messageText(last).trim() + ) { + agent.state.messages = []; + } + drawActiveChat(agent); } - drawActiveChat(agent); } - } - })(); - return true; -} + })(); + return true; + } -function currentTurnOptions(): TurnOptions { - const { harnessId: harness } = currentModelOption(); - return { - ...(harnessSupportsEffort(harness) ? { effortLevel: composerState.effortLevel } : {}), - ...(harnessSupportsFastMode(harness) && typeof composerState.fastMode === "boolean" - ? { fastMode: composerState.fastMode } - : {}), - harness, - scopeId: chatState.scopeId, - channelName: chatState.contextName, - }; -} + function currentTurnOptions(): TurnOptions { + const { harnessId: harness } = ctx.composer.currentModelOption(); + return { + ...(harnessSupportsEffort(harness) ? { effortLevel: ctx.composer.state.effortLevel } : {}), + ...(harnessSupportsFastMode(harness) && typeof ctx.composer.state.fastMode === "boolean" + ? { fastMode: ctx.composer.state.fastMode } + : {}), + harness, + scopeId: chatState.scopeId, + channelName: chatState.contextName, + }; + } -let deliveryStreamOpen = false; -export function ensureDeliveryStream(): void { - if (deliveryStreamOpen) return; - deliveryStreamOpen = true; - const onNudge = (threadRef: string): void => { - if (!embedMode) void refreshSessions({ silent: true }); + function onDelivery(threadRef: string): void { const ro = readOnlyView; if (ro && threadRef === ro.threadRef) { - void api<{ entries: SessionEntry[] }>(`/api/sessions/${encodeURIComponent(ro.id)}`) - .then((r) => { - if (readOnlyView?.id === ro.id) - mountReadOnly(readOnlyView.session, entriesToMessages(r.entries ?? [], transcriptModel())); + void fetchTranscript(ro.id, ro.anchorSeq !== null ? { sinceSeq: ro.anchorSeq } : { tailTurns: TAIL_TURNS }) + .then((page) => { + if (readOnlyView?.id !== ro.id) return; + const earlier = page.earlierEntries ?? 0; + mountReadOnly( + readOnlyView.session, + entriesToMessages(page.entries ?? [], transcriptModel()), + earlier, + earlier > 0 ? (page.entries?.[0]?.seq ?? null) : null, + ); }) .catch(() => {}); return; @@ -374,1552 +388,1600 @@ export function ensureDeliveryStream(): void { const agent = chatState.agent; if (!agent || threadRef !== chatState.threadRef || agent.state.isStreaming) return; void refreshTranscriptFromEntries(agent); - }; - if (embedMode) { - onRelayedDelivery(onNudge); - } else { - subscribeDeliveries( - (threadRef) => { - relayDeliveryToPanes(threadRef); - onNudge(threadRef); - }, - (event) => { - const { list, matched } = applySessionState(sessionsState.list, event); - if (matched) { - sessionsState.list = list; - renderList(); - } else { - void refreshSessions({ silent: true }); - } - }, - () => void refreshSessions({ silent: true }), - ); } - if (typeof document !== "undefined") { - document.addEventListener("visibilitychange", () => { - if (document.visibilityState !== "visible") return; - const agent = chatState.agent; - if (!agent || agent.state.isStreaming || !chatState.threadRef || !chatState.normalStreamFn || !chatState.onWork) - return; - void resumeTrackedRun(agent, chatState.threadRef, chatState.normalStreamFn, chatState.onWork); - }); + + function resumeIfIdle(): void { + const agent = chatState.agent; + if (!agent || agent.state.isStreaming || !chatState.threadRef || !chatState.normalStreamFn || !chatState.onWork) + return; + void resumeTrackedRun(agent, chatState.threadRef, chatState.normalStreamFn, chatState.onWork); } -} -async function approveCommand(agent: Agent, decision: ApprovalDecision): Promise { - if (agent !== chatState.agent || !chatState.threadRef || agent.state.isStreaming) return; - if (chatState.resolvingApprovals.size > 0) return; - chatState.resolvingApprovals.add(decision.requestId); - composerState.error = ""; - drawActiveChat(agent); - try { - await runApprovalTurn(chatState.threadRef, agent, decision, currentTurnOptions, chatState.onWork ?? undefined); - } catch (err) { - if (agent === chatState.agent) { - composerState.error = err instanceof Error ? err.message : "Could not send the approval."; - drawActiveChat(agent); - } - } finally { - chatState.resolvingApprovals.delete(decision.requestId); - if (agent === chatState.agent) { - clearLiveWork(); - try { - await refreshSessions({ silent: true }); - } catch { - void 0; + function syncLocation(): void { + if (ctx.ownsUrl) syncUrlFromState(); + else postCurrentPaneState(); + } + + function redrawForConnector(): void { + if (chatState.agent) drawActiveChat(); + } + + function dispose(): void { + redrawHooks.delete(redrawForConnector); + teardownActiveChat(); + } + + async function approveCommand(agent: Agent, decision: ApprovalDecision): Promise { + if (agent !== chatState.agent || !chatState.threadRef || agent.state.isStreaming) return; + if (chatState.resolvingApprovals.size > 0) return; + chatState.resolvingApprovals.add(decision.requestId); + ctx.composer.state.error = ""; + drawActiveChat(agent); + try { + await runApprovalTurn( + chatState.threadRef, + agent, + decision, + currentTurnOptions, + chatState.onWork ?? undefined, + undefined, + runSlot, + ); + } catch (err) { + if (agent === chatState.agent) { + ctx.composer.state.error = err instanceof Error ? err.message : "Could not send the approval."; + drawActiveChat(agent); + } + } finally { + chatState.resolvingApprovals.delete(decision.requestId); + if (agent === chatState.agent) { + clearLiveWork(); + try { + await refreshSessions({ silent: true }); + } catch { + void 0; + } + await refreshTranscriptFromEntries(agent); } - await refreshTranscriptFromEntries(agent); } } -} -export function resolveCommandApproval(decision: ApprovalDecision): void { - const agent = chatState.agent; - if (agent) void approveCommand(agent, decision); -} + function resolveCommandApproval(decision: ApprovalDecision): void { + const agent = chatState.agent; + if (agent) void approveCommand(agent, decision); + } -export function activePendingApprovals(): PendingApproval[] { - const agent = chatState.agent; - if (!agent || agent.state.isStreaming) return []; - const byId = new Map(); - for (const m of agent.state.messages) { - if ((m as { role?: string }).role !== "assistant") continue; - for (const approval of (m as AssistantWork).work?.pendingApprovals ?? []) { - byId.set(approval.requestId, approval); + function activePendingApprovals(): PendingApproval[] { + const agent = chatState.agent; + if (!agent || agent.state.isStreaming) return []; + const byId = new Map(); + for (const m of agent.state.messages) { + if ((m as { role?: string }).role !== "assistant") continue; + for (const approval of (m as AssistantWork).work?.pendingApprovals ?? []) { + byId.set(approval.requestId, approval); + } } + return [...byId.values()]; } - return [...byId.values()]; -} -export function hasUnresolvedApproval(): boolean { - return activePendingApprovals().length > 0; -} + function hasUnresolvedApproval(): boolean { + return activePendingApprovals().length > 0; + } -async function refreshTranscriptFromEntries(agent: Agent): Promise { - const sessionId = chatState.sessionId; - if (!sessionId || agent !== chatState.agent || agent.state.isStreaming) return drawActiveChat(agent); - const last = agent.state.messages[agent.state.messages.length - 1] as { stopReason?: string } | undefined; - if (last?.stopReason === "error" || last?.stopReason === "aborted") return drawActiveChat(agent); - try { - const anchor = chatState.transcriptAnchorSeq; - const page = await fetchTranscript(sessionId, anchor !== null ? { sinceSeq: anchor } : undefined); - if (agent !== chatState.agent || agent.state.isStreaming) return; - const messages = entriesToMessages(page.entries ?? [], transcriptModel()); + async function refreshTranscriptFromEntries(agent: Agent): Promise { + const sessionId = chatState.sessionId; + if (!sessionId || agent !== chatState.agent || agent.state.isStreaming) return drawActiveChat(agent); + const last = agent.state.messages[agent.state.messages.length - 1] as { stopReason?: string } | undefined; + if (last?.stopReason === "error" || last?.stopReason === "aborted") return drawActiveChat(agent); try { - const r = await api<{ approvals: PendingApproval[] }>(`/api/sessions/${encodeURIComponent(sessionId)}/approvals`); - attachPendingApprovals(messages, r.approvals ?? [], transcriptModel()); + const anchor = chatState.transcriptAnchorSeq; + const page = await fetchTranscript(sessionId, anchor !== null ? { sinceSeq: anchor } : undefined); + if (agent !== chatState.agent || agent.state.isStreaming) return; + const messages = entriesToMessages(page.entries ?? [], transcriptModel()); + try { + const r = await api<{ approvals: PendingApproval[] }>( + `/api/sessions/${encodeURIComponent(sessionId)}/approvals`, + ); + attachPendingApprovals(messages, r.approvals ?? [], transcriptModel()); + } catch { + void 0; + } + if (agent !== chatState.agent || agent.state.isStreaming) return; + agent.state.messages = messages; + chatState.earlierCount = page.earlierEntries ?? 0; + chatState.transcriptAnchorSeq = chatState.earlierCount > 0 ? (page.entries?.[0]?.seq ?? null) : null; } catch { void 0; } - if (agent !== chatState.agent || agent.state.isStreaming) return; - agent.state.messages = messages; - chatState.earlierCount = page.earlierEntries ?? 0; - if (chatState.earlierCount === 0) chatState.transcriptAnchorSeq = null; - } catch { - void 0; - } - drawActiveChat(agent); -} - -function observeLiveWork(agent: Agent): (work: WorkBlock) => void { - return (work: WorkBlock) => { - if (agent !== chatState.agent) return; - chatState.liveWork = work; - syncWorkTicker(); drawActiveChat(agent); - }; -} - -async function resumeTrackedRun( - agent: Agent, - threadRef: string, - normalStreamFn: Agent["streamFn"], - onWork: (work: WorkBlock) => void, -): Promise { - if (!agent.state.messages.length) return false; - let activeRun: Awaited>; - try { - activeRun = await activeRunForThread(threadRef); - } catch { - return false; - } - if (!activeRun || agent !== chatState.agent || appState.currentView !== "chats" || agent.state.isStreaming) - return false; - const msgs = agent.state.messages.slice(); - while (msgs.length && (msgs[msgs.length - 1] as { role?: string }).role === "assistant") msgs.pop(); - if (!msgs.length) return false; - agent.state.messages = msgs; - agent.streamFn = makeRunResumeStreamFn(activeRun.runId, activeRun.run, onWork); - try { - await agent.continue(); - } catch (err) { - if (agent === chatState.agent) - composerState.error = err instanceof Error ? err.message : "Could not reconnect to the running task."; - } finally { - if (agent === chatState.agent) { - agent.streamFn = normalStreamFn; - await refreshTranscriptFromEntries(agent); - } } - return true; -} - -function adoptActiveSessionFromList(agent: Agent): void { - if (agent !== chatState.agent || chatState.sessionId !== null || chatState.threadRef === null) return; - const match = sessionsState.list.find((s) => s.id && s.threadRef === chatState.threadRef); - if (!match) return; - chatState.sessionId = match.id; - chatState.scopeId = match.scopeId; - if (match.channelName) chatState.contextName = match.channelName; - chatState.rememberedSessionId = match.id; - chatState.rememberedScopeId = match.scopeId; - chatState.rememberedContextName = chatState.contextName; - syncUrlFromState(); - renderList(); - drawActiveChat(agent); -} - -export const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); -export function postCurrentPaneState(): void { - if (!embedMode) return; - const live = liveTurnThreadRef({ - mountedThreadRef: chatState.threadRef, - isStreaming: Boolean(chatState.agent?.state.isStreaming), - pendingSend: chatState.pendingSend, - }); - postPaneState({ - threadRef: chatState.threadRef ?? chatState.rememberedThreadRef, - sessionId: chatState.sessionId ?? chatState.rememberedSessionId, - working: live !== null, - }); -} + function observeLiveWork(agent: Agent): (work: WorkBlock) => void { + return (work: WorkBlock) => { + if (agent !== chatState.agent) return; + chatState.liveWork = work; + syncWorkTicker(); + drawActiveChat(agent); + }; + } -async function settleNewSessionTitle(agent: Agent, sessionId: string): Promise { - const titled = (): boolean => { - const s = sessionsState.list.find((row) => row.id === sessionId); - return Boolean(s?.title && s.title.trim()); - }; - if (titled()) return; - for (const delay of [600, 1200, 2400, 4000]) { - await sleep(delay); - if (agent !== chatState.agent || chatState.sessionId !== sessionId) return; + async function resumeTrackedRun( + agent: Agent, + threadRef: string, + normalStreamFn: Agent["streamFn"], + onWork: (work: WorkBlock) => void, + ): Promise { + if (!agent.state.messages.length) return false; + let activeRun: Awaited>; try { - await refreshSessions({ silent: true }); + activeRun = await activeRunForThread(threadRef); } catch { - void 0; + return false; } - if (titled()) return; + if (!activeRun || agent !== chatState.agent || appState.currentView !== "chats" || agent.state.isStreaming) + return false; + const msgs = agent.state.messages.slice(); + while (msgs.length && (msgs[msgs.length - 1] as { role?: string }).role === "assistant") msgs.pop(); + if (!msgs.length) return false; + agent.state.messages = msgs; + agent.streamFn = makeRunResumeStreamFn(activeRun.runId, activeRun.run, onWork, runSlot); + try { + await agent.continue(); + } catch (err) { + if (agent === chatState.agent) + ctx.composer.state.error = err instanceof Error ? err.message : "Could not reconnect to the running task."; + } finally { + if (agent === chatState.agent) { + agent.streamFn = normalStreamFn; + await refreshTranscriptFromEntries(agent); + } + } + return true; } -} -export function mountLoadingPane(): void { - if (!appState.mainEl || appState.currentView !== "chats") return; - const host = document.createElement("div"); - host.className = "custom-chat"; - render( - html`
-
-
`, - host, - ); - appState.mainEl.replaceChildren(host); -} + function adoptActiveSessionFromList(agent: Agent): void { + if (agent !== chatState.agent || chatState.sessionId !== null || chatState.threadRef === null) return; + const match = sessionsState.list.find((s) => s.id && s.threadRef === chatState.threadRef); + if (!match) return; + chatState.sessionId = match.id; + chatState.scopeId = match.scopeId; + if (match.channelName) chatState.contextName = match.channelName; + chatState.rememberedSessionId = match.id; + chatState.rememberedScopeId = match.scopeId; + chatState.rememberedContextName = chatState.contextName; + syncLocation(); + renderList(); + drawActiveChat(agent); + } + + function postCurrentPaneState(): void { + if (!ctx.pane) return; + const live = liveTurnThreadRef({ + mountedThreadRef: chatState.threadRef, + isStreaming: Boolean(chatState.agent?.state.isStreaming), + pendingSend: chatState.pendingSend, + }); + ctx.onState?.({ + threadRef: chatState.threadRef ?? chatState.rememberedThreadRef, + sessionId: chatState.sessionId ?? chatState.rememberedSessionId, + working: live !== null, + }); + } + + async function settleNewSessionTitle(agent: Agent, sessionId: string): Promise { + const titled = (): boolean => { + const s = sessionsState.list.find((row) => row.id === sessionId); + return Boolean(s?.title && s.title.trim()); + }; + if (titled()) return; + for (const delay of [600, 1200, 2400, 4000]) { + await sleep(delay); + if (agent !== chatState.agent || chatState.sessionId !== sessionId) return; + try { + await refreshSessions({ silent: true }); + } catch { + void 0; + } + if (titled()) return; + } + } -export function mountReadOnly( - s: CoreSession, - messages: ReturnType, - earlierCount = 0, - anchorSeq: number | null = null, -): void { - if (!appState.mainEl) return; - exitSplitIfActive(); - preserveOutgoingWorkingDot(s.threadRef); - detachActiveAgent(); - chatState.agent = null; - clearLiveWork(); - chatState.host = null; - resetComposer(); - chatState.threadRef = null; - chatState.sessionId = s.id; - chatState.scopeId = s.scopeId; - syncUrlFromState(); - - resetBackgroundPanel(); - const host = document.createElement("div"); - host.className = "custom-chat readonly-chat"; - const draw = () => + function mountLoadingPane(): void { + const container = ctx.container(); + if (!container || !ctx.visible()) return; + const host = document.createElement("div"); + host.className = "custom-chat"; render( - html` -
- ${chatHeader(groupDmTitle(s), surfaceOf(s), true)} -
- ${ - surfaceOf(s) === "slack" - ? html`This conversation lives in Slack. Replies happen - there.${ - sessionSlackUrl(s) - ? html` Open in Slack` - : nothing - }` - : "This conversation is read-only here." - } -
- ${backgroundActivityStrip()} -
-
+ html`
+
+
`, + host, + ); + container.replaceChildren(host); + } + + function mountReadOnly( + s: CoreSession, + messages: ReturnType, + earlierCount = 0, + anchorSeq: number | null = null, + ): void { + const container = ctx.claimContainer(); + if (!container) return; + preserveOutgoingWorkingDot(s.threadRef); + detachActiveAgent(); + chatState.agent = null; + clearLiveWork(); + chatState.host = null; + ctx.composer.resetComposer(); + chatState.threadRef = null; + chatState.sessionId = s.id; + chatState.scopeId = s.scopeId; + syncLocation(); + + resetBackgroundPanel(); + const host = document.createElement("div"); + host.className = "custom-chat readonly-chat"; + const draw = () => + render( + html` +
+ ${chatHeader(groupDmTitle(s), surfaceOf(s), true)} +
${ - earlierCount > 0 - ? html`
- -
` - : nothing + surfaceOf(s) === "slack" + ? html`This conversation lives in Slack. Replies happen + there.${ + sessionSlackUrl(s) + ? html` Open in Slack` + : nothing + }` + : "This conversation is read-only here." } - ${messages.length ? messages.map((m, i) => chatMessage(m, i)) : html`
No readable messages in this conversation.
`}
-
-
- `, - host, - ); - readonlyRedraw = draw; - draw(); - appState.mainEl.replaceChildren(host); - readOnlyView = { id: s.id, threadRef: s.threadRef, session: s }; - ensureDeliveryStream(); - consumeBackgroundPanelRequest(); -} + ${backgroundActivityStrip()} +
+
+ ${ + earlierCount > 0 + ? html`
+ +
` + : nothing + } + ${messages.length ? messages.map((m, i) => chatMessage(m, i)) : html`
No readable messages in this conversation.
`} +
+
+ + `, + host, + ); + readonlyRedraw = draw; + draw(); + container.replaceChildren(host); + readOnlyView = { id: s.id, threadRef: s.threadRef, session: s, anchorSeq }; + ctx.ensureDeliveryStream(); + consumeBackgroundPanelRequest(); + } -function welcomeGreeting(): TemplateResult { - return html` -
-
-
- ${markdown( - "Hi — I'm your AI teammate 👋\n\n" + - "I run tasks on a computer of my own and work across your connected tools — Slack, Google Workspace, GitHub, Linear, and the open web — and I remember what we work on together.\n\n" + - "Want to get set up? Tell me your name and what you're working on, and I'll take it from there — or just ask me anything to dive straight in.", - )} + function welcomeGreeting(): TemplateResult { + return html` +
+
+
+ ${markdown( + "Hi — I'm your AI teammate 👋\n\n" + + "I run tasks on a computer of my own and work across your connected tools — Slack, Google Workspace, GitHub, Linear, and the open web — and I remember what we work on together.\n\n" + + "Want to get set up? Tell me your name and what you're working on, and I'll take it from there — or just ask me anything to dive straight in.", + )} +
-
-
- `; -} + + `; + } -export function setTranscriptWindow(anchorSeq: number | null, earlierCount: number): void { - chatState.transcriptAnchorSeq = earlierCount > 0 ? anchorSeq : null; - chatState.earlierCount = earlierCount; - if (chatState.agent) drawActiveChat(chatState.agent); -} + function setTranscriptWindow(anchorSeq: number | null, earlierCount: number): void { + chatState.transcriptAnchorSeq = earlierCount > 0 ? anchorSeq : null; + chatState.earlierCount = earlierCount; + if (chatState.agent) drawActiveChat(chatState.agent); + } -function earlierNotice(agent: Agent): TemplateResult { - return html`
- -
`; -} + function earlierNotice(agent: Agent): TemplateResult { + return html`
+ +
`; + } -async function loadEarlierMessages(): Promise { - const agent = chatState.agent; - const sessionId = chatState.sessionId; - const anchor = chatState.transcriptAnchorSeq; - if (!agent || !sessionId || anchor === null || chatState.loadingEarlier || agent.state.isStreaming) return; - chatState.loadingEarlier = true; - drawActiveChat(agent); - try { - const page = await fetchTranscript(sessionId, { beforeSeq: anchor, tailTurns: TAIL_TURNS }); - if (agent !== chatState.agent || agent.state.isStreaming) return; - const earlierMessages = entriesToMessages(page.entries ?? [], transcriptModel()); - const scroller = chatState.host?.querySelector(".chat-scroll"); - const priorHeight = scroller?.scrollHeight ?? 0; - const priorTop = scroller?.scrollTop ?? 0; - agent.state.messages = [...earlierMessages, ...agent.state.messages]; - const remaining = page.earlierEntries ?? 0; - chatState.transcriptAnchorSeq = remaining > 0 ? (page.entries?.[0]?.seq ?? null) : null; - chatState.earlierCount = remaining; - chatState.loadingEarlier = false; + async function loadEarlierMessages(): Promise { + const agent = chatState.agent; + const sessionId = chatState.sessionId; + const anchor = chatState.transcriptAnchorSeq; + if (!agent || !sessionId || anchor === null || chatState.loadingEarlier || agent.state.isStreaming) return; + chatState.loadingEarlier = true; drawActiveChat(agent); - requestAnimationFrame(() => { - const scrollerNow = chatState.host?.querySelector(".chat-scroll"); - if (!scrollerNow) return; - const prev = scrollerNow.style.scrollBehavior; - scrollerNow.style.scrollBehavior = "auto"; - scrollerNow.scrollTop = priorTop + (scrollerNow.scrollHeight - priorHeight); - scrollerNow.style.scrollBehavior = prev; - }); - } catch { - void 0; - } finally { - if (chatState.loadingEarlier) { + try { + const page = await fetchTranscript(sessionId, { beforeSeq: anchor, tailTurns: TAIL_TURNS }); + if (agent !== chatState.agent || agent.state.isStreaming) return; + const earlierMessages = entriesToMessages(page.entries ?? [], transcriptModel()); + const scroller = chatState.host?.querySelector(".chat-scroll"); + const priorHeight = scroller?.scrollHeight ?? 0; + const priorTop = scroller?.scrollTop ?? 0; + agent.state.messages = [...earlierMessages, ...agent.state.messages]; + const remaining = page.earlierEntries ?? 0; + chatState.transcriptAnchorSeq = remaining > 0 ? (page.entries?.[0]?.seq ?? null) : null; + chatState.earlierCount = remaining; chatState.loadingEarlier = false; - if (agent === chatState.agent) drawActiveChat(agent); + drawActiveChat(agent); + requestAnimationFrame(() => { + const scrollerNow = chatState.host?.querySelector(".chat-scroll"); + if (!scrollerNow) return; + const prev = scrollerNow.style.scrollBehavior; + scrollerNow.style.scrollBehavior = "auto"; + scrollerNow.scrollTop = priorTop + (scrollerNow.scrollHeight - priorHeight); + scrollerNow.style.scrollBehavior = prev; + }); + } catch { + void 0; + } finally { + if (chatState.loadingEarlier) { + chatState.loadingEarlier = false; + if (agent === chatState.agent) drawActiveChat(agent); + } } } -} -let streamDrawScheduled = false; -let streamDrawAgent: Agent | null = null; -function scheduleStreamDraw(agent: Agent): void { - streamDrawAgent = agent; - if (streamDrawScheduled) return; - streamDrawScheduled = true; - requestAnimationFrame(() => { - streamDrawScheduled = false; - const target = streamDrawAgent; - streamDrawAgent = null; - if (target) drawActiveChat(target); - }); -} + let streamDrawScheduled = false; + let streamDrawAgent: Agent | null = null; + function scheduleStreamDraw(agent: Agent): void { + streamDrawAgent = agent; + if (streamDrawScheduled) return; + streamDrawScheduled = true; + requestAnimationFrame(() => { + streamDrawScheduled = false; + const target = streamDrawAgent; + streamDrawAgent = null; + if (target) drawActiveChat(target); + }); + } -function paneGlance(agent: Agent, messages: AgentMessage[], tier: "card" | "strip"): TemplateResult { - const now = paneNowLine(agent); - const last = [...messages].reverse().find((m) => m.role === "assistant" && messageText(m).trim()); - const snippet = last ? messageText(last).trim() : ""; - if (tier === "strip") { + function paneGlance(agent: Agent, messages: AgentMessage[], tier: "card" | "strip"): TemplateResult { + const now = paneNowLine(agent); + const last = [...messages].reverse().find((m) => m.role === "assistant" && messageText(m).trim()); + const snippet = last ? messageText(last).trim() : ""; + if (tier === "strip") { + return html` + + `; + } return html` - +
+ ${now ? html`
Now${now}
` : nothing} + ${snippet ? html`
${snippet}
` : nothing} +
`; } - return html` -
- ${now ? html`
Now${now}
` : nothing} - ${snippet ? html`
${snippet}
` : nothing} -
- `; -} -function paneNowLine(agent: Agent): string | null { - if (activePendingApprovals().length) return "Needs your approval"; - if (agent.state.isStreaming || chatState.resolvingApprovals.size > 0) { - const work = chatState.liveWork ?? { status: "thinking", activity: [] }; - const summary = liveWorkSummary(work); - if (!summary) return "Thinking…"; - return summary.detail ? `${summary.label} — ${summary.detail}` : summary.label; + function paneNowLine(agent: Agent): string | null { + if (activePendingApprovals().length) return "Needs your approval"; + if (agent.state.isStreaming || chatState.resolvingApprovals.size > 0) { + const work = chatState.liveWork ?? { status: "thinking", activity: [] }; + const summary = liveWorkSummary(work); + if (!summary) return "Thinking…"; + return summary.detail ? `${summary.label} — ${summary.detail}` : summary.label; + } + return null; } - return null; -} -onDensityChange(() => drawActiveChat()); + ctx.onDensityChange(() => drawActiveChat()); -export function drawActiveChat(agent = chatState.agent, opts: { forceScroll?: boolean } = {}): void { - if (!agent || agent !== chatState.agent || !chatState.host || appState.currentView !== "chats") return; - const messages = visibleMessages(agent); - const isNewUser = sessionsState.list.filter((s) => s.id).length === 0; - let messageContent: Array | TemplateResult | typeof nothing = nothing; - if (messages.length) { - messageContent = messages.map((m, i) => - settledChatMessage(m, i, agent.state.isStreaming && m === agent.state.streamingMessage), + function drawActiveChat(agent = chatState.agent, opts: { forceScroll?: boolean } = {}): void { + if (!agent || agent !== chatState.agent || !chatState.host || appState.currentView !== "chats") return; + const messages = visibleMessages(agent); + const isNewUser = sessionsState.list.filter((s) => s.id).length === 0; + let messageContent: Array | TemplateResult | typeof nothing = nothing; + if (messages.length) { + messageContent = messages.map((m, i) => + settledChatMessage(m, i, agent.state.isStreaming && m === agent.state.streamingMessage), + ); + } else if (isNewUser) { + messageContent = welcomeGreeting(); + } + const tier = ctx.density(); + const glanceTier = tier === "card" || tier === "strip" ? tier : null; + render( + html` +
ctx.composer.onDragEnter(e)} + @dragover=${(e: DragEvent) => ctx.composer.onDragOver(e)} + @dragleave=${(e: DragEvent) => ctx.composer.onDragLeave(e)} + @drop=${(e: DragEvent) => void ctx.composer.onDrop(e, agent)} + > + ${ + ctx.composer.state.dragging + ? html`
+
${icon(Files, 30)}Drop files or folders to attach
+
` + : nothing + } + ${contextBanner()} + ${ + glanceTier + ? paneGlance(agent, messages, glanceTier) + : html`
+
+ ${chatState.earlierCount > 0 ? earlierNotice(agent) : nothing} ${messageContent} + ${showStateError(messages, agent.state.errorMessage) ? html`
${agent.state.errorMessage}
` : nothing} +
+
` + } +
+ ${backgroundActivityStrip()} ${liveWorkDock(agent)} ${ctx.composer.composerForm(agent)} +
+
+ `, + chatState.host, ); - } else if (isNewUser) { - messageContent = welcomeGreeting(); - } - const tier = currentDensity(); - const glanceTier = tier === "card" || tier === "strip" ? tier : null; - render( - html` -
onDragEnter(e)} - @dragover=${(e: DragEvent) => onDragOver(e)} - @dragleave=${(e: DragEvent) => onDragLeave(e)} - @drop=${(e: DragEvent) => void onDrop(e, agent)} - > - ${ - composerState.dragging - ? html`
-
${icon(Files, 30)}Drop files or folders to attach
-
` - : nothing - } - ${contextBanner()} - ${ - glanceTier - ? paneGlance(agent, messages, glanceTier) - : html`
-
- ${chatState.earlierCount > 0 ? earlierNotice(agent) : nothing} ${messageContent} - ${showStateError(messages, agent.state.errorMessage) ? html`
${agent.state.errorMessage}
` : nothing} -
-
` - } -
${backgroundActivityStrip()} ${liveWorkDock(agent)} ${composerForm(agent)}
-
- `, - chatState.host, - ); - decorateStreamingTail(); - resizeComposer(); - scrollTranscript(opts.forceScroll); - postCurrentPaneState(); -} - -function decorateStreamingTail(): void { - const blocks = chatState.host?.querySelectorAll(".streaming-text.live-stream markdown-block"); - const block = blocks?.length ? blocks[blocks.length - 1] : undefined; - if (!block) { - revealedTailLen = 0; - return; - } - if (reduceMotion.matches) return; - const fullLen = (block.textContent ?? "").replace(/\s+$/u, "").length; - const grown = fullLen - revealedTailLen; - revealedTailLen = fullLen; - if (grown <= 0 || grown > 240) return; - const last = lastTextNode(block); - if (!last || !last.textContent) return; - const visibleEnd = last.textContent.replace(/\s+$/u, "").length; - const n = Math.min(grown, visibleEnd); - if (n <= 0) return; - const tail = last.splitText(visibleEnd - n); - if (tail.textContent && tail.textContent.length > n) tail.splitText(n); - const parent = tail.parentNode; - if (!parent) return; - const span = document.createElement("span"); - span.className = "tok-in"; - parent.insertBefore(span, tail); - span.appendChild(tail); -} + decorateStreamingTail(); + ctx.composer.resizeComposer(); + scrollTranscript(opts.forceScroll); + postCurrentPaneState(); + } -function lastTextNode(el: Node): Text | null { - for (let i = el.childNodes.length - 1; i >= 0; i--) { - const child = el.childNodes[i]!; - if (child.nodeType === Node.TEXT_NODE && /\S/u.test(child.textContent ?? "")) return child as Text; - const deep = lastTextNode(child); - if (deep) return deep; + function decorateStreamingTail(): void { + const blocks = chatState.host?.querySelectorAll(".streaming-text.live-stream markdown-block"); + const block = blocks?.length ? blocks[blocks.length - 1] : undefined; + if (!block) { + revealedTailLen = 0; + return; + } + if (reduceMotion.matches) return; + const fullLen = (block.textContent ?? "").replace(/\s+$/u, "").length; + const grown = fullLen - revealedTailLen; + revealedTailLen = fullLen; + if (grown <= 0 || grown > 240) return; + const last = lastTextNode(block); + if (!last || !last.textContent) return; + const visibleEnd = last.textContent.replace(/\s+$/u, "").length; + const n = Math.min(grown, visibleEnd); + if (n <= 0) return; + const tail = last.splitText(visibleEnd - n); + if (tail.textContent && tail.textContent.length > n) tail.splitText(n); + const parent = tail.parentNode; + if (!parent) return; + const span = document.createElement("span"); + span.className = "tok-in"; + parent.insertBefore(span, tail); + span.appendChild(tail); } - return null; -} -function contextBanner(): TemplateResult | typeof nothing { - const label = sharedContextLabel(chatState.scopeId, chatState.contextName); - if (!label) return nothing; - const glyph = chatState.scopeId?.startsWith("group:") ? Users : Hash; - return html`
- ${icon(glyph, 13)}${label} context -
`; -} + function lastTextNode(el: Node): Text | null { + for (let i = el.childNodes.length - 1; i >= 0; i--) { + const child = el.childNodes[i]!; + if (child.nodeType === Node.TEXT_NODE && /\S/u.test(child.textContent ?? "")) return child as Text; + const deep = lastTextNode(child); + if (deep) return deep; + } + return null; + } -function chatHeader(title: string | TemplateResult, detail: string, readOnly: boolean): TemplateResult { - return html` -
-
-
${title}
-
${readOnly ? "Read-only" : detail}
-
-
- ${ - chatState.sessionId && can("admin") - ? html`${icon(ScrollText, 17)}` - : nothing - } - -
-
- `; -} - -function visibleMessages(agent: Agent): AgentMessage[] { - const out = [...agent.state.messages]; - if (agent.state.streamingMessage) out.push(agent.state.streamingMessage); - return out; -} - -interface SettledRowKey { - index: number; - activity: WorkBlock["activity"] | undefined; - status: WorkBlock["status"] | undefined; - stale: boolean | undefined; - deliveredFiles: unknown; - stopReason: unknown; - errorMessage: unknown; - approvalDecision: unknown; - forkable: boolean; - tpl: TemplateResult | typeof nothing; -} -const settledRowCache = new WeakMap(); - -function settledChatMessage( - message: AgentMessage, - index: number, - isStreaming: boolean, -): TemplateResult | typeof nothing { - const msg = message as AssistantWork & { stopReason?: string; errorMessage?: string; approvalDecision?: string }; - const work = msg.work; - const cacheable = - !isStreaming && - (!work || ((work.status === "complete" || work.status === "failed") && !work.pendingApprovals?.length)); - if (!cacheable) return chatMessage(message, index, isStreaming); - const forkable = Boolean(chatState.threadRef && chatState.sessionId && chatState.agent); - const hit = settledRowCache.get(message as object); - if ( - hit && - hit.index === index && - hit.activity === work?.activity && - hit.status === work?.status && - hit.stale === work?.stale && - hit.deliveredFiles === msg.deliveredFiles && - hit.stopReason === msg.stopReason && - hit.errorMessage === msg.errorMessage && - hit.approvalDecision === msg.approvalDecision && - hit.forkable === forkable - ) { - return hit.tpl; - } - const tpl = chatMessage(message, index, isStreaming); - settledRowCache.set(message as object, { - index, - activity: work?.activity, - status: work?.status, - stale: work?.stale, - deliveredFiles: msg.deliveredFiles, - stopReason: msg.stopReason, - errorMessage: msg.errorMessage, - approvalDecision: msg.approvalDecision, - forkable, - tpl, - }); - return tpl; -} + function contextBanner(): TemplateResult | typeof nothing { + const label = sharedContextLabel(chatState.scopeId, chatState.contextName); + if (!label) return nothing; + const glyph = chatState.scopeId?.startsWith("group:") ? Users : Hash; + return html`
+ ${icon(glyph, 13)}${label} context +
`; + } -function chatMessage(message: AgentMessage, index: number, isStreaming = false): TemplateResult | typeof nothing { - if ((message as { opener?: boolean }).opener) return nothing; - const role = (message as { role?: string }).role; - if (role === "user" || role === "user-with-attachments") { - const attachments = ((message as UserMessageWithAttachments).attachments ?? []) as UserAttachmentView[]; - const steered = Boolean((message as { steered?: boolean }).steered); + function chatHeader(title: string | TemplateResult, detail: string, readOnly: boolean): TemplateResult { return html` -
- ${steered ? html`
↪ steered the running task
` : nothing} -
- ${markdown(messageText(message))} - ${attachments.length ? html`
${attachments.map(userAttachmentBadge)}
` : nothing} +
+
+
${title}
+
${readOnly ? "Read-only" : detail}
- ${messageMeta(message, index)} -
- `; - } - if (role === "assistant") { - const msg = message as AssistantMessage; - const work = isStreaming ? null : (msg as AssistantWork).work; - const text = messageText(msg).trim(); - const hasText = Boolean(text); - const showWork = shouldShowApprovalWork(msg, work, text) && shouldShowWork(work, hasText); - const deliveredFiles = (msg as AssistantWork).deliveredFiles; - const hasVisibleContent = - showWork || - hasText || - Boolean(deliveredFiles?.length) || - msg.content.some((chunk) => chunk.type === "thinking" && chunk.thinking.trim()); - if (!hasVisibleContent && msg.stopReason !== "error" && msg.stopReason !== "aborted") return nothing; - return html` -
-
- ${showWork ? workBlock(work, isStreaming) : nothing} ${assistantContent(msg, isStreaming, showWork)} - ${assistantFileList(deliveredFiles)} - ${msg.stopReason === "error" && msg.errorMessage ? html`
${msg.errorMessage}
` : nothing} - ${msg.stopReason === "aborted" ? html`
${icon(Ban, 13)}Stopped
` : nothing} - ${isStreaming ? nothing : messageMeta(msg, index)} +
+ ${ + chatState.sessionId && can("admin") + ? html`${icon(ScrollText, 17)}` + : nothing + } +
-
+ `; } - return nothing; -} -function messageMeta(message: AgentMessage, index: number): TemplateResult | typeof nothing { - const text = messageText(message).trim(); - const ts = (message as { timestamp?: number }).timestamp; - if (!text && ts === undefined) return nothing; - const forkable = Boolean(chatState.threadRef && chatState.sessionId && chatState.agent); - return html` -
- ${ts !== undefined ? html`${formatClock(ts)}` : nothing} - ${ - text - ? html`` - : nothing - } - ${ - forkable - ? html`` - : nothing - } -
- `; -} + function visibleMessages(agent: Agent): AgentMessage[] { + const out = [...agent.state.messages]; + if (agent.state.streamingMessage) out.push(agent.state.streamingMessage); + return out; + } -async function forkFromMessage(index: number): Promise { - const agent = chatState.agent; - const sessionId = chatState.sessionId; - const sourceThreadRef = chatState.threadRef; - if (!agent || !sessionId) return; - const messages = agent.state.messages as Array<{ role?: string }>; - const target = messages[index]; - if (!target) return; - const isUser = target.role === "user" || target.role === "user-with-attachments"; - let userOrdinal = 0; - for (let i = 0; i <= index; i++) { - const role = messages[i]?.role; - if (role === "user" || role === "user-with-attachments") userOrdinal++; - } - try { - const { entries } = await api<{ entries: SessionEntry[] }>(`/api/sessions/${encodeURIComponent(sessionId)}`); - const anchor = chatState.transcriptAnchorSeq; - if (anchor !== null) userOrdinal += userMessagesBefore(entries ?? [], anchor); - const upToSeq = forkCutSeq(entries ?? [], userOrdinal, isUser); - const forked = await forkSession(sessionId, upToSeq); - carryModelPick(sourceThreadRef, forked.session.threadRef); - mountContinuable( - forked.session.threadRef, - forked.session.id, - forked.session.scopeId, - entriesToMessages(forked.entries ?? [], transcriptModel()), - forked.session.channelName ?? null, - ); - await refreshSessions({ silent: true }); - renderList(); - } catch (err) { - composerState.error = errMessage(err, "Could not fork the conversation."); - drawActiveChat(); + function settledChatMessage( + message: AgentMessage, + index: number, + isStreaming: boolean, + ): TemplateResult | typeof nothing { + const msg = message as AssistantWork & { stopReason?: string; errorMessage?: string; approvalDecision?: string }; + const work = msg.work; + const cacheable = + !isStreaming && + (!work || ((work.status === "complete" || work.status === "failed") && !work.pendingApprovals?.length)); + if (!cacheable) return chatMessage(message, index, isStreaming); + const forkable = Boolean(chatState.threadRef && chatState.sessionId && chatState.agent); + const hit = settledRowCache.get(message as object); + if ( + hit && + hit.index === index && + hit.activity === work?.activity && + hit.status === work?.status && + hit.stale === work?.stale && + hit.deliveredFiles === msg.deliveredFiles && + hit.stopReason === msg.stopReason && + hit.errorMessage === msg.errorMessage && + hit.approvalDecision === msg.approvalDecision && + hit.forkable === forkable + ) { + return hit.tpl; + } + const tpl = chatMessage(message, index, isStreaming); + settledRowCache.set(message as object, { + index, + activity: work?.activity, + status: work?.status, + stale: work?.stale, + deliveredFiles: msg.deliveredFiles, + stopReason: msg.stopReason, + errorMessage: msg.errorMessage, + approvalDecision: msg.approvalDecision, + forkable, + tpl, + }); + return tpl; } -} -function formatClock(ms: number): string { - try { - return new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); - } catch { - return ""; + function chatMessage(message: AgentMessage, index: number, isStreaming = false): TemplateResult | typeof nothing { + if ((message as { opener?: boolean }).opener) return nothing; + const role = (message as { role?: string }).role; + if (role === "user" || role === "user-with-attachments") { + const attachments = ((message as UserMessageWithAttachments).attachments ?? []) as UserAttachmentView[]; + const steered = Boolean((message as { steered?: boolean }).steered); + return html` +
+ ${steered ? html`
↪ steered the running task
` : nothing} +
+ ${markdown(messageText(message))} + ${attachments.length ? html`
${attachments.map(userAttachmentBadge)}
` : nothing} +
+ ${messageMeta(message, index)} +
+ `; + } + if (role === "assistant") { + const msg = message as AssistantMessage; + const work = isStreaming ? null : (msg as AssistantWork).work; + const text = messageText(msg).trim(); + const hasText = Boolean(text); + const showWork = shouldShowApprovalWork(msg, work, text) && shouldShowWork(work, hasText); + const deliveredFiles = (msg as AssistantWork).deliveredFiles; + const hasVisibleContent = + showWork || + hasText || + Boolean(deliveredFiles?.length) || + msg.content.some((chunk) => chunk.type === "thinking" && chunk.thinking.trim()); + if (!hasVisibleContent && msg.stopReason !== "error" && msg.stopReason !== "aborted") return nothing; + return html` +
+
+ ${showWork ? workBlock(work, isStreaming) : nothing} ${assistantContent(msg, isStreaming, showWork)} + ${assistantFileList(deliveredFiles)} + ${msg.stopReason === "error" && msg.errorMessage ? html`
${msg.errorMessage}
` : nothing} + ${msg.stopReason === "aborted" ? html`
${icon(Ban, 13)}Stopped
` : nothing} + ${isStreaming ? nothing : messageMeta(msg, index)} +
+
+ `; + } + return nothing; } -} -async function copyMessage(text: string, btn: HTMLButtonElement): Promise { - try { - await navigator.clipboard.writeText(text); - } catch { - return; - } - btn.classList.add("copied"); - btn.replaceChildren(icon(Check, 13)); - setTimeout(() => { - if (!btn.isConnected) return; - btn.classList.remove("copied"); - btn.replaceChildren(icon(Copy, 13)); - }, 1200); -} + function messageMeta(message: AgentMessage, index: number): TemplateResult | typeof nothing { + const text = messageText(message).trim(); + const ts = (message as { timestamp?: number }).timestamp; + if (!text && ts === undefined) return nothing; + const forkable = Boolean(chatState.threadRef && chatState.sessionId && chatState.agent); + return html` +
+ ${ts !== undefined ? html`${formatClock(ts)}` : nothing} + ${ + text + ? html`` + : nothing + } + ${ + forkable + ? html`` + : nothing + } +
+ `; + } -const connectedConnectors = new Set(); + async function forkFromMessage(index: number): Promise { + const agent = chatState.agent; + const sessionId = chatState.sessionId; + const sourceThreadRef = chatState.threadRef; + if (!agent || !sessionId) return; + const messages = agent.state.messages as Array<{ role?: string }>; + const target = messages[index]; + if (!target) return; + const isUser = target.role === "user" || target.role === "user-with-attachments"; + let userOrdinal = 0; + for (let i = 0; i <= index; i++) { + const role = messages[i]?.role; + if (role === "user" || role === "user-with-attachments") userOrdinal++; + } + try { + const { entries } = await api<{ entries: SessionEntry[] }>(`/api/sessions/${encodeURIComponent(sessionId)}`); + const anchor = chatState.transcriptAnchorSeq; + if (anchor !== null) userOrdinal += userMessagesBefore(entries ?? [], anchor); + const upToSeq = forkCutSeq(entries ?? [], userOrdinal, isUser); + const forked = await forkSession(sessionId, upToSeq); + ctx.composer.carryModelPick(sourceThreadRef, forked.session.threadRef); + mountContinuable( + forked.session.threadRef, + forked.session.id, + forked.session.scopeId, + entriesToMessages(forked.entries ?? [], transcriptModel()), + forked.session.channelName ?? null, + ); + await refreshSessions({ silent: true }); + renderList(); + } catch (err) { + ctx.composer.state.error = errMessage(err, "Could not fork the conversation."); + drawActiveChat(); + } + } -export function markConnectorConnected(provider: string): void { - if (!provider) return; - connectedConnectors.add(provider); - if (chatState.agent) drawActiveChat(); -} + function formatClock(ms: number): string { + try { + return new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); + } catch { + return ""; + } + } -function withReturnTo(url: string): string { - const returnTo = deepLinkPath(UI_BASE, "chats", chatState.sessionId); - const sep = url.includes("?") ? "&" : "?"; - return `${url}${sep}returnTo=${encodeURIComponent(returnTo)}`; -} + async function copyMessage(text: string, btn: HTMLButtonElement): Promise { + try { + await navigator.clipboard.writeText(text); + } catch { + return; + } + btn.classList.add("copied"); + btn.replaceChildren(icon(Check, 13)); + setTimeout(() => { + if (!btn.isConnected) return; + btn.classList.remove("copied"); + btn.replaceChildren(icon(Copy, 13)); + }, 1200); + } + + function withReturnTo(url: string): string { + const returnTo = deepLinkPath(UI_BASE, "chats", chatState.sessionId); + const sep = url.includes("?") ? "&" : "?"; + return `${url}${sep}returnTo=${encodeURIComponent(returnTo)}`; + } -function connectorWidget(link: ConnectorLink): TemplateResult { - const name = - CONNECTOR_NAMES[link.provider] ?? - (link.provider ? link.provider[0]!.toUpperCase() + link.provider.slice(1) : "your account"); - if (link.provider && connectedConnectors.has(link.provider)) { - return html`
- ${icon(Check, 18)} + function connectorWidget(link: ConnectorLink): TemplateResult { + const name = + CONNECTOR_NAMES[link.provider] ?? + (link.provider ? link.provider[0]!.toUpperCase() + link.provider.slice(1) : "your account"); + if (link.provider && connectedConnectors.has(link.provider)) { + return html`
+ ${icon(Check, 18)} + Connected ${name}Authorized — its tools work here now +
`; + } + return html` + ${icon(Plug, 18)} Connected ${name}Authorized — its tools work here nowConnect ${name}Authorize access in a new tab -
`; + ${icon(ChevronRight, 16)} + `; } - return html` - ${icon(Plug, 18)} - Connect ${name}Authorize access in a new tab - ${icon(ChevronRight, 16)} - `; -} -function assistantContent(message: AssistantMessage, isStreaming = false, hasWork = false): TemplateResult[] { - const parts: TemplateResult[] = []; - for (const chunk of message.content) { - if (chunk.type === "text" && chunk.text.trim()) { - const links = connectorLinksIn(chunk.text, location.origin); - const body = links.length ? stripConnectorLinks(chunk.text) : chunk.text; - if (body.trim()) + function assistantContent(message: AssistantMessage, isStreaming = false, hasWork = false): TemplateResult[] { + const parts: TemplateResult[] = []; + for (const chunk of message.content) { + if (chunk.type === "text" && chunk.text.trim()) { + const links = connectorLinksIn(chunk.text, location.origin); + const body = links.length ? stripConnectorLinks(chunk.text) : chunk.text; + if (body.trim()) + parts.push( + html`
+ ${isStreaming ? streamingMarkdown(body) : markdown(body)} +
`, + ); + for (const link of links) parts.push(connectorWidget(link)); + } + if (chunk.type === "thinking" && chunk.thinking.trim()) { parts.push( - html`
- ${isStreaming ? streamingMarkdown(body) : markdown(body)} -
`, + html`
+ ${sheenLabel("Thinking", isStreaming)} + ${markdown(chunk.thinking)} +
`, ); - for (const link of links) parts.push(connectorWidget(link)); - } - if (chunk.type === "thinking" && chunk.thinking.trim()) { - parts.push( - html`
- ${sheenLabel("Thinking", isStreaming)} - ${markdown(chunk.thinking)} -
`, - ); + } } + if (parts.length === 0 && message.stopReason !== "error" && message.stopReason !== "aborted" && !hasWork) + parts.push(typingRow()); + return parts; } - if (parts.length === 0 && message.stopReason !== "error" && message.stopReason !== "aborted" && !hasWork) - parts.push(typingRow()); - return parts; -} -function assistantFileList(files: DeliveredFile[] | undefined): TemplateResult | typeof nothing { - if (!files?.length) return nothing; - return html`
${files.map((f) => deliveredFileBadge(f))}
`; -} + function assistantFileList(files: DeliveredFile[] | undefined): TemplateResult | typeof nothing { + if (!files?.length) return nothing; + return html`
${files.map((f) => deliveredFileBadge(f))}
`; + } -function markdown(text: string): TemplateResult { - return html``; -} + function markdown(text: string): TemplateResult { + return html``; + } -let escapedSegs: string[] = []; -let escapedSrc: string[] = []; -function streamingMarkdown(text: string): TemplateResult { - const { segments, tail } = splitStreamingMarkdown(text); - if (segments.length < escapedSrc.length) { - escapedSrc = []; - escapedSegs = []; - } - for (let i = 0; i < segments.length; i++) { - const seg = segments[i] ?? ""; - if (escapedSrc[i] !== seg) { - escapedSrc[i] = seg; - escapedSegs[i] = escapeLoneDollars(seg); + let escapedSegs: string[] = []; + let escapedSrc: string[] = []; + function streamingMarkdown(text: string): TemplateResult { + const { segments, tail } = splitStreamingMarkdown(text); + if (segments.length < escapedSrc.length) { + escapedSrc = []; + escapedSegs = []; } + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] ?? ""; + if (escapedSrc[i] !== seg) { + escapedSrc[i] = seg; + escapedSegs[i] = escapeLoneDollars(seg); + } + } + escapedSrc.length = segments.length; + escapedSegs.length = segments.length; + return html`${escapedSegs.map((seg) => html``)}`; } - escapedSrc.length = segments.length; - escapedSegs.length = segments.length; - return html`${escapedSegs.map((seg) => html``)}`; -} -function messageText(message: AgentMessage): string { - const content = (message as { content?: unknown }).content; - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .filter((c): c is TextContent => Boolean(c) && typeof c === "object" && (c as { type?: string }).type === "text") - .map((c) => c.text ?? "") - .join("\n"); + function messageText(message: AgentMessage): string { + const content = (message as { content?: unknown }).content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter( + (c): c is TextContent => Boolean(c) && typeof c === "object" && (c as { type?: string }).type === "text", + ) + .map((c) => c.text ?? "") + .join("\n"); + } + return ""; } - return ""; -} -function typingRow(): TemplateResult { - return html`
${sheenLabel("Thinking", true)}
`; -} + function typingRow(): TemplateResult { + return html`
${sheenLabel("Thinking", true)}
`; + } -function syncWorkTicker(): void { - const active = chatState.liveWork?.status === "working" && !chatState.liveWork.stale; - if (active && !workTicker) { - workTicker = setInterval(() => drawActiveChat(), 1000); - } else if (!active && workTicker) { - clearInterval(workTicker); - workTicker = null; + function syncWorkTicker(): void { + const active = chatState.liveWork?.status === "working" && !chatState.liveWork.stale; + if (active && !workTicker) { + workTicker = setInterval(() => drawActiveChat(), 1000); + } else if (!active && workTicker) { + clearInterval(workTicker); + workTicker = null; + } } -} -function clearLiveWork(): void { - chatState.liveWork = null; - chatState.pendingSend = null; - syncWorkTicker(); -} + function clearLiveWork(): void { + chatState.liveWork = null; + chatState.pendingSend = null; + syncWorkTicker(); + } -function shouldShowWork(work: WorkBlock | null | undefined, hasText: boolean): work is WorkBlock { - if (!work) return false; - if (work.activity.length > 0) return true; - if (work.pendingApprovals?.length) return true; - return work.status === "thinking" && !hasText; -} + function shouldShowWork(work: WorkBlock | null | undefined, hasText: boolean): work is WorkBlock { + if (!work) return false; + if (work.activity.length > 0) return true; + if (work.pendingApprovals?.length) return true; + return work.status === "thinking" && !hasText; + } -function shouldShowApprovalWork(message: AssistantMessage, work: WorkBlock | null | undefined, text: string): boolean { - if ((message as AssistantWork & { approvalDecision?: "denied" }).approvalDecision === "denied") return false; - if (text === "Denied." && work?.activity.some((a) => a.type === "tool_call" || a.type === "approval_request")) - return false; - return true; -} + function shouldShowApprovalWork( + message: AssistantMessage, + work: WorkBlock | null | undefined, + text: string, + ): boolean { + if ((message as AssistantWork & { approvalDecision?: "denied" }).approvalDecision === "denied") return false; + if (text === "Denied." && work?.activity.some((a) => a.type === "tool_call" || a.type === "approval_request")) + return false; + return true; + } -let readonlyRedraw: (() => void) | null = null; - -const bgPanel = { - requested: null as { sessionId: string | null; threadRef: string | null } | null, - open: false, - loading: false, - error: "", - detail: null as SessionBackgroundView | null, - openJob: null as string | null, - output: new Map(), - timer: null as ReturnType | null, - fetchSeq: 0, - epoch: 0, -}; - -export function requestBackgroundPanel(sessionId: string | null, threadRef: string | null): void { - const mounted = sessionId ? sessionId === chatState.sessionId : threadRef === chatState.threadRef; - if (mounted) { - openBackgroundPanel(); - return; - } - bgPanel.requested = { sessionId, threadRef }; -} + let readonlyRedraw: (() => void) | null = null; + + const bgPanel = { + requested: null as { sessionId: string | null; threadRef: string | null } | null, + open: false, + loading: false, + error: "", + detail: null as SessionBackgroundView | null, + openJob: null as string | null, + output: new Map(), + timer: null as ReturnType | null, + fetchSeq: 0, + epoch: 0, + }; -function resetBackgroundPanel(): void { - if (bgPanel.timer) clearInterval(bgPanel.timer); - bgPanel.timer = null; - bgPanel.open = false; - bgPanel.loading = false; - bgPanel.error = ""; - bgPanel.detail = null; - bgPanel.openJob = null; - bgPanel.output.clear(); - bgPanel.fetchSeq++; - bgPanel.epoch++; - readonlyRedraw = null; -} + function requestBackgroundPanel(sessionId: string | null, threadRef: string | null): void { + const mounted = sessionId ? sessionId === chatState.sessionId : threadRef === chatState.threadRef; + if (mounted) { + openBackgroundPanel(); + return; + } + bgPanel.requested = { sessionId, threadRef }; + } -function consumeBackgroundPanelRequest(): void { - const req = bgPanel.requested; - bgPanel.requested = null; - if (!req) return; - const matches = req.sessionId ? req.sessionId === chatState.sessionId : req.threadRef === chatState.threadRef; - if (matches) openBackgroundPanel(); -} + function resetBackgroundPanel(): void { + if (bgPanel.timer) clearInterval(bgPanel.timer); + bgPanel.timer = null; + bgPanel.open = false; + bgPanel.loading = false; + bgPanel.error = ""; + bgPanel.detail = null; + bgPanel.openJob = null; + bgPanel.output.clear(); + bgPanel.fetchSeq++; + bgPanel.epoch++; + readonlyRedraw = null; + } -function openBackgroundPanel(): void { - if (bgPanel.open) return; - bgPanel.open = true; - void refreshBackgroundDetail(); - bgPanel.timer = setInterval(() => void backgroundPanelTick(), 2_500); - redrawBackgroundPanel(); -} + function consumeBackgroundPanelRequest(): void { + const req = bgPanel.requested; + bgPanel.requested = null; + if (!req) return; + const matches = req.sessionId ? req.sessionId === chatState.sessionId : req.threadRef === chatState.threadRef; + if (matches) openBackgroundPanel(); + } -function closeBackgroundPanel(): void { - if (bgPanel.timer) clearInterval(bgPanel.timer); - bgPanel.timer = null; - bgPanel.open = false; - bgPanel.openJob = null; - redrawBackgroundPanel(); -} + function openBackgroundPanel(): void { + if (bgPanel.open) return; + bgPanel.open = true; + void refreshBackgroundDetail(); + bgPanel.timer = setInterval(() => void backgroundPanelTick(), 2_500); + redrawBackgroundPanel(); + } -function toggleBackgroundPanel(): void { - if (bgPanel.open) closeBackgroundPanel(); - else openBackgroundPanel(); -} + function closeBackgroundPanel(): void { + if (bgPanel.timer) clearInterval(bgPanel.timer); + bgPanel.timer = null; + bgPanel.open = false; + bgPanel.openJob = null; + redrawBackgroundPanel(); + } -function redrawBackgroundPanel(): void { - if (readonlyRedraw) readonlyRedraw(); - else drawActiveChat(); -} + function toggleBackgroundPanel(): void { + if (bgPanel.open) closeBackgroundPanel(); + else openBackgroundPanel(); + } -async function refreshBackgroundDetail(): Promise { - const id = chatState.sessionId; - if (!id) { - bgPanel.detail = { jobs: [], watches: [] }; - return; - } - const seq = ++bgPanel.fetchSeq; - bgPanel.loading = !bgPanel.detail; - try { - const d = await api(`/api/sessions/${encodeURIComponent(id)}/background`); - if (seq !== bgPanel.fetchSeq) return; - bgPanel.detail = d; - bgPanel.error = ""; - } catch (e) { - if (seq !== bgPanel.fetchSeq) return; - bgPanel.error = errMessage(e, "Failed to load background activity."); - } finally { - if (seq === bgPanel.fetchSeq) { - bgPanel.loading = false; - redrawBackgroundPanel(); + function redrawBackgroundPanel(): void { + if (readonlyRedraw) readonlyRedraw(); + else drawActiveChat(); + } + + async function refreshBackgroundDetail(): Promise { + const id = chatState.sessionId; + if (!id) { + bgPanel.detail = { jobs: [], watches: [] }; + return; + } + const seq = ++bgPanel.fetchSeq; + bgPanel.loading = !bgPanel.detail; + try { + const d = await api(`/api/sessions/${encodeURIComponent(id)}/background`); + if (seq !== bgPanel.fetchSeq) return; + bgPanel.detail = d; + bgPanel.error = ""; + } catch (e) { + if (seq !== bgPanel.fetchSeq) return; + bgPanel.error = errMessage(e, "Failed to load background activity."); + } finally { + if (seq === bgPanel.fetchSeq) { + bgPanel.loading = false; + redrawBackgroundPanel(); + } } } -} -async function backgroundPanelTick(): Promise { - await refreshBackgroundDetail(); - if (bgPanel.openJob) await pollJobOutput(bgPanel.openJob); - const d = bgPanel.detail; - if (d) { - const row = sessionsState.list.find((r) => - chatState.sessionId ? r.id === chatState.sessionId : r.threadRef === chatState.threadRef, - ); - if (row && ((row.backgroundJobs ?? 0) !== d.jobs.length || (row.watches ?? 0) !== d.watches.length)) { - await refreshSessions({ silent: true }); - redrawBackgroundPanel(); + async function backgroundPanelTick(): Promise { + await refreshBackgroundDetail(); + if (bgPanel.openJob) await pollJobOutput(bgPanel.openJob); + const d = bgPanel.detail; + if (d) { + const row = sessionsState.list.find((r) => + chatState.sessionId ? r.id === chatState.sessionId : r.threadRef === chatState.threadRef, + ); + if (row && ((row.backgroundJobs ?? 0) !== d.jobs.length || (row.watches ?? 0) !== d.watches.length)) { + await refreshSessions({ silent: true }); + redrawBackgroundPanel(); + } } } -} -function toggleJobOutput(processId: string): void { - bgPanel.openJob = bgPanel.openJob === processId ? null : processId; - if (bgPanel.openJob && !bgPanel.output.has(processId)) void pollJobOutput(processId); - redrawBackgroundPanel(); -} + function toggleJobOutput(processId: string): void { + bgPanel.openJob = bgPanel.openJob === processId ? null : processId; + if (bgPanel.openJob && !bgPanel.output.has(processId)) void pollJobOutput(processId); + redrawBackgroundPanel(); + } -async function pollJobOutput(processId: string): Promise { - const id = chatState.sessionId; - if (!id) return; - const epoch = bgPanel.epoch; - const prev = bgPanel.output.get(processId); - let text = prev?.text ?? ""; - let cursor = prev?.cursor ?? 0; - let state: "running" | "exited" = prev?.state ?? "running"; - let exitCode = prev?.exitCode; - try { - for (let i = 0; i < 8; i++) { - const read = await api( - `/api/sessions/${encodeURIComponent(id)}/background/${encodeURIComponent(processId)}/output?sinceCursor=${cursor}`, - ); - cursor = read.cursor; - text = (text + read.chunk).slice(-16_384); - state = read.state; - exitCode = read.exitCode; - if (read.chunk.length < 60_000) break; + async function pollJobOutput(processId: string): Promise { + const id = chatState.sessionId; + if (!id) return; + const epoch = bgPanel.epoch; + const prev = bgPanel.output.get(processId); + let text = prev?.text ?? ""; + let cursor = prev?.cursor ?? 0; + let state: "running" | "exited" = prev?.state ?? "running"; + let exitCode = prev?.exitCode; + try { + for (let i = 0; i < 8; i++) { + const read = await api( + `/api/sessions/${encodeURIComponent(id)}/background/${encodeURIComponent(processId)}/output?sinceCursor=${cursor}`, + ); + cursor = read.cursor; + text = (text + read.chunk).slice(-16_384); + state = read.state; + exitCode = read.exitCode; + if (read.chunk.length < 60_000) break; + } + if (epoch !== bgPanel.epoch) return; + bgPanel.output.set(processId, { text, cursor, state, ...(exitCode !== undefined ? { exitCode } : {}) }); + } catch (e) { + swallow("web-ui: background job output read", e); } if (epoch !== bgPanel.epoch) return; - bgPanel.output.set(processId, { text, cursor, state, ...(exitCode !== undefined ? { exitCode } : {}) }); - } catch (e) { - swallow("web-ui: background job output read", e); + redrawBackgroundPanel(); } - if (epoch !== bgPanel.epoch) return; - redrawBackgroundPanel(); -} - -function timeLeft(expiresAt: number): string { - const mins = Math.round((expiresAt - Date.now()) / 60_000); - if (mins <= 0) return "expiring"; - if (mins < 60) return `${mins}m left`; - return `${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, "0")}m left`; -} -function backgroundActivityStrip(): TemplateResult | typeof nothing { - const row = conversationBackground(sessionsState.list, chatState.sessionId, chatState.threadRef); - const live = - bgPanel.open && bgPanel.detail ? backgroundLabel(bgPanel.detail.jobs.length, bgPanel.detail.watches.length) : null; - const label = (live ?? row)?.label; - if (!label && !bgPanel.open) return nothing; - return html` -
- - ${bgPanel.open ? backgroundPanelBody() : nothing} -
- `; -} + function timeLeft(expiresAt: number): string { + const mins = Math.round((expiresAt - Date.now()) / 60_000); + if (mins <= 0) return "expiring"; + if (mins < 60) return `${mins}m left`; + return `${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, "0")}m left`; + } -function backgroundPanelBody(): TemplateResult { - const d = bgPanel.detail; - const empty = d && d.jobs.length === 0 && d.watches.length === 0; - return html`
- ${bgPanel.error ? html`
${bgPanel.error}
` : nothing} - ${!d && bgPanel.loading ? html`
Loading…
` : nothing} - ${empty && !bgPanel.error ? html`
Nothing running here anymore.
` : nothing} - ${d ? d.jobs.map((j) => backgroundJobRow(j)) : nothing} ${d ? d.watches.map((w) => backgroundWatchRow(w)) : nothing} -
`; -} + function backgroundActivityStrip(): TemplateResult | typeof nothing { + const row = conversationBackground(sessionsState.list, chatState.sessionId, chatState.threadRef); + const live = + bgPanel.open && bgPanel.detail + ? backgroundLabel(bgPanel.detail.jobs.length, bgPanel.detail.watches.length) + : null; + const label = (live ?? row)?.label; + if (!label && !bgPanel.open) return nothing; + return html` +
+ + ${bgPanel.open ? backgroundPanelBody() : nothing} +
+ `; + } -function backgroundJobRow(j: SessionBackgroundView["jobs"][number]): TemplateResult { - const open = bgPanel.openJob === j.processId; - const out = bgPanel.output.get(j.processId); - const status = - out?.state === "exited" ? `exited${out.exitCode !== undefined ? ` (${out.exitCode})` : ""}` : timeLeft(j.expiresAt); - return html` -
- - ${open ? html`
${out ? out.text || "(no output yet)" : "Loading output…"}
` : nothing} -
- `; -} + function backgroundPanelBody(): TemplateResult { + const d = bgPanel.detail; + const empty = d && d.jobs.length === 0 && d.watches.length === 0; + return html`
+ ${bgPanel.error ? html`
${bgPanel.error}
` : nothing} + ${!d && bgPanel.loading ? html`
Loading…
` : nothing} + ${empty && !bgPanel.error ? html`
Nothing running here anymore.
` : nothing} + ${d ? d.jobs.map((j) => backgroundJobRow(j)) : nothing} + ${d ? d.watches.map((w) => backgroundWatchRow(w)) : nothing} +
`; + } -function backgroundWatchRow(w: SessionBackgroundView["watches"][number]): TemplateResult { - const what = w.pattern ? `output matching /${w.pattern}/` : "any new output"; - const note = w.instructions?.trim(); - return html` -
-
- ${icon(Radar, 13)} - Watch — wakes on ${what}${note ? ` · “${note}”` : ""} - armed ${relTime(w.createdAt)}${w.lastFiredAt ? ` · last fired ${relTime(w.lastFiredAt)}` : ""} · - ${timeLeft(w.expiresAt)} + + ${open ? html`
${out ? out.text || "(no output yet)" : "Loading output…"}
` : nothing}
-
- `; -} + `; + } -function liveWorkDock(agent: Agent): TemplateResult | typeof nothing { - if (!agent.state.isStreaming && chatState.resolvingApprovals.size === 0) return nothing; - const work = chatState.liveWork ?? { status: "thinking", activity: [] }; - if (work.status !== "thinking" && work.status !== "working") return nothing; - const summary = liveWorkSummary(work); - const expandable = Boolean(summary?.detail); - const expanded = expandable && liveWorkExpanded; - let title = ""; - if (expandable) title = liveWorkExpanded ? "Show less" : "Show more"; - return html` -
- -
- `; -} + ${summary ? html`${icon(summary.icon, 15)}` : nothing} + ${summary ? summary.label : sheenLabel(`Thinking${usedToolsSuffix(work)}`, true)} + ${summary?.detail ? html`${summary.detail}` : nothing} + ${expandable ? html`${icon(ChevronRight, 14)}` : nothing} + + + `; + } -function toggleLiveWorkExpanded(): void { - liveWorkExpanded = !liveWorkExpanded; - drawActiveChat(); -} + function toggleLiveWorkExpanded(): void { + liveWorkExpanded = !liveWorkExpanded; + drawActiveChat(); + } -function liveWorkSummary(work: WorkBlock): { icon: IconNode; label: string; detail: string } | null { - if (work.stale) { + function liveWorkSummary(work: WorkBlock): { icon: IconNode; label: string; detail: string } | null { + if (work.stale) { + const active = activeToolRow(work); + const call = (active?.call?.payload ?? {}) as ToolPayload; + const tool = call.tool ?? ""; + const verb = active ? (TOOL_META[tool] ?? UNKNOWN_TOOL).active : null; + return { + icon: RefreshCw, + label: verb ? `${verb} interrupted — resuming…` : "Interrupted — resuming…", + detail: active ? toolDetail(tool, call, (active.result?.payload ?? {}) as ToolPayload) : "", + }; + } const active = activeToolRow(work); - const call = (active?.call?.payload ?? {}) as ToolPayload; - const tool = call.tool ?? ""; - const verb = active ? (TOOL_META[tool] ?? UNKNOWN_TOOL).active : null; + return active ? activeToolSummary(active, work) : null; + } + + function activeToolRow(work: WorkBlock): ToolRowModel | null { + const timeline = buildTimeline(work); + for (let i = timeline.length - 1; i >= 0; i--) { + const item = timeline[i]!; + if (item.kind === "tool" && toolRowKind(item.row, work.status) === "running") return item.row; + } + return null; + } + + function activeToolSummary(row: ToolRowModel, work: WorkBlock): { icon: IconNode; label: string; detail: string } { + const call = (row.call?.payload ?? {}) as ToolPayload; + const result = (row.result?.payload ?? {}) as ToolPayload; + const tool = call.tool ?? result.tool ?? "unknown"; + const meta = TOOL_META[tool] ?? UNKNOWN_TOOL; + const secs = elapsedSeconds(row.call?.createdAt) || workSeconds(work); return { - icon: RefreshCw, - label: verb ? `${verb} interrupted — resuming…` : "Interrupted — resuming…", - detail: active ? toolDetail(tool, call, (active.result?.payload ?? {}) as ToolPayload) : "", + icon: meta.icon, + label: secs > 0 ? `${meta.active} for ${secs}s` : meta.active, + detail: toolDetail(tool, call, result), }; } - const active = activeToolRow(work); - return active ? activeToolSummary(active, work) : null; -} -function activeToolRow(work: WorkBlock): ToolRowModel | null { - const timeline = buildTimeline(work); - for (let i = timeline.length - 1; i >= 0; i--) { - const item = timeline[i]!; - if (item.kind === "tool" && toolRowKind(item.row, work.status) === "running") return item.row; + function elapsedSeconds(startedAt: number | null | undefined): number { + if (typeof startedAt !== "number" || startedAt <= 0) return 0; + return Math.max(0, Math.round((Date.now() - startedAt) / 1000)); } - return null; -} -function activeToolSummary(row: ToolRowModel, work: WorkBlock): { icon: IconNode; label: string; detail: string } { - const call = (row.call?.payload ?? {}) as ToolPayload; - const result = (row.result?.payload ?? {}) as ToolPayload; - const tool = call.tool ?? result.tool ?? "unknown"; - const meta = TOOL_META[tool] ?? UNKNOWN_TOOL; - const secs = elapsedSeconds(row.call?.createdAt) || workSeconds(work); - return { - icon: meta.icon, - label: secs > 0 ? `${meta.active} for ${secs}s` : meta.active, - detail: toolDetail(tool, call, result), - }; -} + function workSeconds(work: WorkBlock): number { + if (work.startedAt == null) return 0; + const end = work.finishedAt ?? Date.now(); + return Math.max(0, Math.round((end - work.startedAt) / 1000)); + } -function elapsedSeconds(startedAt: number | null | undefined): number { - if (typeof startedAt !== "number" || startedAt <= 0) return 0; - return Math.max(0, Math.round((Date.now() - startedAt) / 1000)); -} + function usedToolsSuffix(work: WorkBlock): string { + const n = work.activity.filter((a) => a.type === "tool_call").length; + return n > 0 ? ` (used ${n} tool${n === 1 ? "" : "s"})` : ""; + } -function workSeconds(work: WorkBlock): number { - if (work.startedAt == null) return 0; - const end = work.finishedAt ?? Date.now(); - return Math.max(0, Math.round((end - work.startedAt) / 1000)); -} + function workLabel(work: WorkBlock): string { + if (work.stale && (work.status === "thinking" || work.status === "working")) return "Interrupted — resuming…"; + if (work.status === "thinking") return "Thinking"; + const secs = workSeconds(work); + return work.status === "working" ? `Working for ${secs}s` : `Worked for ${secs}s`; + } -function usedToolsSuffix(work: WorkBlock): string { - const n = work.activity.filter((a) => a.type === "tool_call").length; - return n > 0 ? ` (used ${n} tool${n === 1 ? "" : "s"})` : ""; -} + function workBlock(work: WorkBlock, isStreaming: boolean): TemplateResult { + if (work.status === "thinking" && !work.activity.length) { + return html`
+
${sheenLabel(workLabel(work), isStreaming)}
+
`; + } + const timeline = buildTimeline(work); + const rows = timeline.length + ? html`
${timeline.map((it) => renderTimelineItem(it, work))}
` + : nothing; + const body = html`
+ ${rows}`; + if (isStreaming || work.status === "working" || work.status === "thinking") { + return html`
+
${sheenLabel(workLabel(work), isStreaming)}
+ ${body} +
`; + } + const openFolds = !!work.pendingApprovals?.length; + const parts: TemplateResult[] = []; + let seg: TimelineItem[] = []; + const flushSeg = (): void => { + if (!seg.length) return; + const items = seg; + seg = []; + parts.push( + html`
+ ${segmentSummaryLabel(items, work)}${icon(ChevronRight, 14)} +
+
${items.map((it) => renderTimelineItem(it, work))}
+
`, + ); + }; + for (const it of timeline) { + const demoted = it.kind === "text" && (it.activity.payload as { demoted?: boolean } | null)?.demoted === true; + if (it.kind === "text" && !demoted) { + flushSeg(); + const text = ((it.activity.payload as { text?: string } | null)?.text ?? "").trim(); + if (text) parts.push(html`
${markdown(text)}
`); + } else { + seg.push(it); + } + } + flushSeg(); + return html`
${parts}
`; + } -function workLabel(work: WorkBlock): string { - if (work.stale && (work.status === "thinking" || work.status === "working")) return "Interrupted — resuming…"; - if (work.status === "thinking") return "Thinking"; - const secs = workSeconds(work); - return work.status === "working" ? `Working for ${secs}s` : `Worked for ${secs}s`; -} + function segmentSummaryLabel(items: TimelineItem[], work: WorkBlock): string { + const tools = items.filter((it) => it.kind === "tool").length; + if (tools > 0) return `${tools} tool call${tools === 1 ? "" : "s"}`; + const secs = workSeconds(work); + return work.status === "failed" ? `Failed after ${secs}s` : `Worked for ${secs}s`; + } -function workBlock(work: WorkBlock, isStreaming: boolean): TemplateResult { - if (work.status === "thinking" && !work.activity.length) { - return html`
-
${sheenLabel(workLabel(work), isStreaming)}
-
`; + function approvalSummaryView(a: PendingApproval, expanded = false): TemplateResult { + const summary = firstLine(a.command, 80); + const truncated = a.command.includes("\n") || a.command.length > 80; + return html` +
+ Approval needed + ${a.reason ? html`${a.reason}` : nothing} +
+ ${a.summary ? html`
${a.summary}
` : nothing} + ${a.purpose ? html`
Why${a.purpose}
` : nothing} + ${ + expanded + ? html`${a.command}` + : html`${summary}` + } + ${ + a.matched + ? html`
+ Triggered by${a.matched} +
` + : nothing + } + ${ + !expanded && truncated + ? html`
+ Show full command + ${a.command} +
` + : nothing + } + `; } - const timeline = buildTimeline(work); - const rows = timeline.length - ? html`
- ${timeline.map((it) => renderTimelineItem(it, work.status, work.stale === true))} -
` - : nothing; - const body = html`
- ${rows}`; - if (isStreaming || work.status === "working" || work.status === "thinking") { - return html`
-
${sheenLabel(workLabel(work), isStreaming)}
- ${body} + + function approvalMarker(a: PendingApproval): TemplateResult { + return html`
+
${approvalSummaryView(a)}
`; } - const openFolds = !!work.pendingApprovals?.length; - const parts: TemplateResult[] = []; - let seg: TimelineItem[] = []; - const flushSeg = (): void => { - if (!seg.length) return; - const items = seg; - seg = []; - parts.push( - html`
- ${segmentSummaryLabel(items, work)}${icon(ChevronRight, 14)} -
-
${items.map((it) => renderTimelineItem(it, work.status, work.stale === true))}
-
`, - ); - }; - for (const it of timeline) { - const demoted = it.kind === "text" && (it.activity.payload as { demoted?: boolean } | null)?.demoted === true; - if (it.kind === "text" && !demoted) { - flushSeg(); - const text = ((it.activity.payload as { text?: string } | null)?.text ?? "").trim(); - if (text) parts.push(html`
${markdown(text)}
`); - } else { - seg.push(it); - } - } - flushSeg(); - return html`
${parts}
`; -} - -function segmentSummaryLabel(items: TimelineItem[], work: WorkBlock): string { - const tools = items.filter((it) => it.kind === "tool").length; - if (tools > 0) return `${tools} tool call${tools === 1 ? "" : "s"}`; - const secs = workSeconds(work); - return work.status === "failed" ? `Failed after ${secs}s` : `Worked for ${secs}s`; -} -export function approvalSummaryView(a: PendingApproval, expanded = false): TemplateResult { - const summary = firstLine(a.command, 80); - const truncated = a.command.includes("\n") || a.command.length > 80; - return html` -
- Approval needed - ${a.reason ? html`${a.reason}` : nothing} -
- ${a.summary ? html`
${a.summary}
` : nothing} - ${a.purpose ? html`
Why${a.purpose}
` : nothing} - ${ - expanded - ? html`${a.command}` - : html`${summary}` - } - ${ - a.matched - ? html`
- Triggered by${a.matched} -
` - : nothing - } - ${ - !expanded && truncated - ? html`
- Show full command - ${a.command} -
` - : nothing - } - `; -} - -function approvalMarker(a: PendingApproval): TemplateResult { - return html`
-
${approvalSummaryView(a)}
-
`; -} + function sheenLabel(label: string, active: boolean): TemplateResult { + return html`${label}`; + } -function sheenLabel(label: string, active: boolean): TemplateResult { - return html`${label}`; -} + function renderTimelineItem(item: TimelineItem, work: WorkBlock): TemplateResult { + const status = work.status; + const stale = work.stale === true; + if (item.kind === "thinking") return thinkingRow(item.activity); + if (item.kind === "text") return messageRow(item.activity); + if (item.kind === "approval") return approvalMarker(item.approval); + return toolRow(item.row, work, status, stale); + } -function renderTimelineItem(item: TimelineItem, status: WorkBlock["status"], stale = false): TemplateResult { - if (item.kind === "thinking") return thinkingRow(item.activity); - if (item.kind === "text") return messageRow(item.activity); - if (item.kind === "approval") return approvalMarker(item.approval); - return toolRow(item.row, status, stale); -} + function thinkingRow(activity: ToolActivity): TemplateResult { + const text = (activity.payload as { thinking?: string } | null)?.thinking ?? ""; + return html`
${markdown(text)}
`; + } -function thinkingRow(activity: ToolActivity): TemplateResult { - const text = (activity.payload as { thinking?: string } | null)?.thinking ?? ""; - return html`
${markdown(text)}
`; -} + function messageRow(activity: ToolActivity): TemplateResult { + const text = (activity.payload as { text?: string } | null)?.text ?? ""; + return html`
${markdown(text)}
`; + } -function messageRow(activity: ToolActivity): TemplateResult { - const text = (activity.payload as { text?: string } | null)?.text ?? ""; - return html`
${markdown(text)}
`; -} + const TOOL_META: Record = { + execute: { icon: Terminal, active: "Running command", done: "Ran command", attempted: "Tried command" }, + read: { icon: FileText, active: "Reading file", done: "Read file", attempted: "Tried reading file" }, + write: { icon: Pencil, active: "Writing file", done: "Wrote file", attempted: "Tried writing file" }, + publish: { icon: Rocket, active: "Publishing", done: "Published", attempted: "Tried publishing" }, + recall: { icon: Brain, active: "Searching memory", done: "Searched memory", attempted: "Tried searching memory" }, + memory: { icon: Brain, active: "Using memory", done: "Used memory", attempted: "Tried using memory" }, + history: { + icon: ScrollText, + active: "Searching history", + done: "Searched history", + attempted: "Tried searching history", + }, + background: { + icon: Terminal, + active: "Managing process", + done: "Managed process", + attempted: "Tried managing process", + }, + }; + const UNKNOWN_TOOL = { icon: Wrench, active: "Working", done: "Finished step", attempted: "Tried step" }; -const TOOL_META: Record = { - execute: { icon: Terminal, active: "Running command", done: "Ran command", attempted: "Tried command" }, - read: { icon: FileText, active: "Reading file", done: "Read file", attempted: "Tried reading file" }, - write: { icon: Pencil, active: "Writing file", done: "Wrote file", attempted: "Tried writing file" }, - publish: { icon: Rocket, active: "Publishing", done: "Published", attempted: "Tried publishing" }, - recall: { icon: Brain, active: "Searching memory", done: "Searched memory", attempted: "Tried searching memory" }, - memory: { icon: Brain, active: "Using memory", done: "Used memory", attempted: "Tried using memory" }, - history: { - icon: ScrollText, - active: "Searching history", - done: "Searched history", - attempted: "Tried searching history", - }, - background: { - icon: Terminal, - active: "Managing process", - done: "Managed process", - attempted: "Tried managing process", - }, -}; -const UNKNOWN_TOOL = { icon: Wrench, active: "Working", done: "Finished step", attempted: "Tried step" }; - -function firstLine(s: string, max = 72): string { - const line = s.split("\n")[0] ?? ""; - return line.length > max ? `${line.slice(0, max - 1)}…` : line; -} + function firstLine(s: string, max = 72): string { + const line = s.split("\n")[0] ?? ""; + return line.length > max ? `${line.slice(0, max - 1)}…` : line; + } -function toolDetail(tool: string, call: ToolPayload, result: ToolPayload): string { - switch (tool) { - case "execute": - return call.command ? firstLine(call.command) : ""; - case "read": - return call.path ?? result.path ?? ""; - case "write": { - const path = call.path ?? result.path ?? ""; - const bytes = result.bytes ?? call.bytes; - return bytes !== undefined ? `${path} · ${formatBytes(bytes)}` : path; - } - case "publish": - return result.url ?? result.name ?? call.name ?? ""; - case "recall": - case "history": { - const q = call.query ?? result.query ?? ""; - return result.count !== undefined ? `${q} · ${result.count} result${result.count === 1 ? "" : "s"}` : q; - } - case "memory": { - const action = call.action ?? result.action ?? ""; - const q = call.query ?? result.query ?? ""; - let detail = q; - if (result.count !== undefined) { - detail = `${q} · ${result.count} result${result.count === 1 ? "" : "s"}`; - } else if (result.added !== undefined) { - detail = `${result.added} saved`; + function toolDetail(tool: string, call: ToolPayload, result: ToolPayload): string { + switch (tool) { + case "execute": + return call.command ? firstLine(call.command) : ""; + case "read": + return call.path ?? result.path ?? ""; + case "write": { + const path = call.path ?? result.path ?? ""; + const bytes = result.bytes ?? call.bytes; + return bytes !== undefined ? `${path} · ${formatBytes(bytes)}` : path; } - return [action, detail].filter(Boolean).join(" "); + case "publish": + return result.url ?? result.name ?? call.name ?? ""; + case "recall": + case "history": { + const q = call.query ?? result.query ?? ""; + return result.count !== undefined ? `${q} · ${result.count} result${result.count === 1 ? "" : "s"}` : q; + } + case "memory": { + const action = call.action ?? result.action ?? ""; + const q = call.query ?? result.query ?? ""; + let detail = q; + if (result.count !== undefined) { + detail = `${q} · ${result.count} result${result.count === 1 ? "" : "s"}`; + } else if (result.added !== undefined) { + detail = `${result.added} saved`; + } + return [action, detail].filter(Boolean).join(" "); + } + case "background": { + const action = call.action ?? result.action ?? ""; + const target = call.command ? firstLine(call.command, 48) : (call.process_id ?? call.monitor_id ?? ""); + return [action, target].filter(Boolean).join(" "); + } + default: + return ""; + } + } + + function toolRow(row: ToolRowModel, work: WorkBlock, status: WorkBlock["status"], stale = false): TemplateResult { + if (row.approval) { + const p = (row.approval.payload ?? {}) as ToolPayload; + return html`
+ ${icon(Wrench, 15)} + Approval + needed${p.reason ? html` ${firstLine(p.reason, 90)}` : nothing} +
`; } - case "background": { - const action = call.action ?? result.action ?? ""; - const target = call.command ? firstLine(call.command, 48) : (call.process_id ?? call.monitor_id ?? ""); - return [action, target].filter(Boolean).join(" "); + const call = (row.call?.payload ?? {}) as ToolPayload; + const result = (row.result?.payload ?? {}) as ToolPayload; + const tool = call.tool ?? result.tool ?? "unknown"; + const meta = TOOL_META[tool] ?? UNKNOWN_TOOL; + const kind = toolRowKind(row, status); + let label = meta.attempted; + if (kind === "approval") label = "Approval needed"; + else if (kind === "running") label = stale ? `${meta.active} — interrupted` : meta.active; + else if (kind === "ok") label = meta.done; + let why = ""; + if (kind === "approval") why = firstLine(result.reason ?? "", 90); + else if (kind === "failed") why = firstLine(result.error ?? result.reason ?? "", 90); + const base = kind === "approval" ? "" : toolDetail(tool, call, result); + const attempts = row.attempts && row.attempts > 1 ? `${row.attempts} attempts` : ""; + const detail = [base, why, attempts].filter(Boolean).join(" · "); + const classes = ["tool-row", `tool-${kind}`].join(" "); + const head = html`${icon(meta.icon, 15)} + ${label}${detail ? html` ${detail}` : nothing}`; + if (tool === "execute" && row.result && (result.stdout || result.stderr)) { + return html`
+ ${head}${icon(ChevronRight, 14)} + ${execOutputCard(result, work, row.result ?? null)} +
`; } - default: - return ""; + return html`
${head}
`; } -} -function toolRow(row: ToolRowModel, status: WorkBlock["status"], stale = false): TemplateResult { - if (row.approval) { - const p = (row.approval.payload ?? {}) as ToolPayload; - return html`
- ${icon(Wrench, 15)} - Approval needed${p.reason ? html` ${firstLine(p.reason, 90)}` : nothing} + function execOutputCard(result: ToolPayload, work: WorkBlock, activity: ToolActivity | null): TemplateResult { + const out = [result.stdout ?? "", result.stderr ? `[stderr]\n${result.stderr}` : ""].filter(Boolean).join("\n"); + return html`
+
bash
+
${out}
+
+ exit ${result.code ?? 0}${result.timedOut ? " · timed out" : ""} + ${ + activity?.truncated + ? html`` + : nothing + } +
`; } - const call = (row.call?.payload ?? {}) as ToolPayload; - const result = (row.result?.payload ?? {}) as ToolPayload; - const tool = call.tool ?? result.tool ?? "unknown"; - const meta = TOOL_META[tool] ?? UNKNOWN_TOOL; - const kind = toolRowKind(row, status); - let label = meta.attempted; - if (kind === "approval") label = "Approval needed"; - else if (kind === "running") label = stale ? `${meta.active} — interrupted` : meta.active; - else if (kind === "ok") label = meta.done; - let why = ""; - if (kind === "approval") why = firstLine(result.reason ?? "", 90); - else if (kind === "failed") why = firstLine(result.error ?? result.reason ?? "", 90); - const base = kind === "approval" ? "" : toolDetail(tool, call, result); - const attempts = row.attempts && row.attempts > 1 ? `${row.attempts} attempts` : ""; - const detail = [base, why, attempts].filter(Boolean).join(" · "); - const classes = ["tool-row", `tool-${kind}`].join(" "); - const head = html`${icon(meta.icon, 15)} - ${label}${detail ? html` ${detail}` : nothing}`; - if (tool === "execute" && row.result && (result.stdout || result.stderr)) { - return html`
- ${head}${icon(ChevronRight, 14)} - ${execOutputCard(result)} -
`; - } - return html`
${head}
`; -} -function execOutputCard(result: ToolPayload): TemplateResult { - const out = [result.stdout ?? "", result.stderr ? `[stderr]\n${result.stderr}` : ""].filter(Boolean).join("\n"); - return html`
-
bash
-
${out}
-
exit ${result.code ?? 0}${result.timedOut ? " · timed out" : ""}
-
`; -} + function redrawTranscript(): void { + if (readonlyRedraw) readonlyRedraw(); + else drawActiveChat(); + } -function chipBadge(glyph: IconNode, name: string, size?: number, href?: string, download = false): TemplateResult { - const inner = html`${icon(glyph, 14)}${name}${typeof size === "number" ? html`${formatBytes(size)}` : nothing}`; - if (!href) return html`${inner}`; - return download - ? html`${inner}` - : html`${inner}`; -} + async function loadFullEntry(work: WorkBlock, activity: ToolActivity): Promise { + const sessionId = chatState.sessionId; + if (!sessionId || !activity.truncated) return; + try { + const full = await fetchEntry(sessionId, activity.seq); + work.activity = work.activity.map((a) => + a === activity ? { ...a, payload: full.payload, truncated: false } : a, + ); + } catch (err) { + ctx.composer.state.error = errMessage(err, "Couldn't load the full output."); + } + redrawTranscript(); + } -function fileChip(name: string, size?: number, href?: string): TemplateResult { - return chipBadge(Paperclip, name, size, href); -} + function chipBadge(glyph: IconNode, name: string, size?: number, href?: string, download = false): TemplateResult { + const inner = html`${icon(glyph, 14)}${name}${typeof size === "number" ? html`${formatBytes(size)}` : nothing}`; + if (!href) return html`${inner}`; + return download + ? html`${inner}` + : html`${inner}`; + } -function imageChip(name: string, size?: number, href?: string): TemplateResult { - return chipBadge(FileImage, name, size, href, true); -} + function fileChip(name: string, size?: number, href?: string): TemplateResult { + return chipBadge(Paperclip, name, size, href); + } -interface UserAttachmentView { - fileName: string; - mimeType?: string; - size?: number; - content?: string; - artifactId?: string; -} + function imageChip(name: string, size?: number, href?: string): TemplateResult { + return chipBadge(FileImage, name, size, href, true); + } -function userAttachmentBadge(a: UserAttachmentView): TemplateResult { - const artifactHref = a.artifactId ? withBase(`/api/files/${encodeURIComponent(a.artifactId)}/content`) : undefined; - if (a.mimeType?.startsWith("image/")) { - let src = artifactHref; - if (!src && a.content) { - src = a.content.startsWith("data:") ? a.content : `data:${a.mimeType};base64,${a.content}`; - } - if (src && !browserRenderableImage(a.mimeType)) return imageChip(a.fileName, a.size, src); - if (src) { - const img = html`${a.fileName}`; - return artifactHref - ? html`${img}` - : html`${img}`; + interface UserAttachmentView { + fileName: string; + mimeType?: string; + size?: number; + content?: string; + artifactId?: string; + } + + function userAttachmentBadge(a: UserAttachmentView): TemplateResult { + const artifactHref = a.artifactId ? withBase(`/api/files/${encodeURIComponent(a.artifactId)}/content`) : undefined; + if (a.mimeType?.startsWith("image/")) { + let src = artifactHref; + if (!src && a.content) { + src = a.content.startsWith("data:") ? a.content : `data:${a.mimeType};base64,${a.content}`; + } + if (src && !browserRenderableImage(a.mimeType)) return imageChip(a.fileName, a.size, src); + if (src) { + const img = html`${a.fileName}`; + return artifactHref + ? html`${img}` + : html`${img}`; + } } + return fileChip(a.fileName, a.size, artifactHref); } - return fileChip(a.fileName, a.size, artifactHref); -} -function deliveredFileBadge(file: DeliveredFile): TemplateResult { - if (!file.artifactId) return fileChip(file.name, file.sizeBytes); - const href = withBase(`/api/files/${encodeURIComponent(file.artifactId)}/content`); - if (file.mimetype?.startsWith("image/")) { - if (!browserRenderableImage(file.mimetype)) return imageChip(file.name, file.sizeBytes, href); - return html`${file.name}`; + function deliveredFileBadge(file: DeliveredFile): TemplateResult { + if (!file.artifactId) return fileChip(file.name, file.sizeBytes); + const href = withBase(`/api/files/${encodeURIComponent(file.artifactId)}/content`); + if (file.mimetype?.startsWith("image/")) { + if (!browserRenderableImage(file.mimetype)) return imageChip(file.name, file.sizeBytes, href); + return html`${file.name}`; + } + return fileChip(file.name, file.sizeBytes, href); } - return fileChip(file.name, file.sizeBytes, href); -} -let stickToBottom = true; + let stickToBottom = true; -function onTranscriptScroll(e: Event): void { - const s = e.currentTarget as HTMLElement; - stickToBottom = s.scrollHeight - s.scrollTop - s.clientHeight <= 120; -} + function onTranscriptScroll(e: Event): void { + const s = e.currentTarget as HTMLElement; + stickToBottom = s.scrollHeight - s.scrollTop - s.clientHeight <= 120; + } -function scrollTranscript(force = false): void { - const scroller = chatState.host?.querySelector(".chat-scroll"); - if (!scroller) return; - if (!force && !stickToBottom) return; - requestAnimationFrame(() => { - if (force) { - const prev = scroller.style.scrollBehavior; - scroller.style.scrollBehavior = "auto"; + function scrollTranscript(force = false): void { + const scroller = chatState.host?.querySelector(".chat-scroll"); + if (!scroller) return; + if (!force && !stickToBottom) return; + requestAnimationFrame(() => { + if (force) { + const prev = scroller.style.scrollBehavior; + scroller.style.scrollBehavior = "auto"; + scroller.scrollTop = scroller.scrollHeight; + requestAnimationFrame(() => { + scroller.style.scrollBehavior = prev; + }); + return; + } scroller.scrollTop = scroller.scrollHeight; - requestAnimationFrame(() => { - scroller.style.scrollBehavior = prev; - }); - return; - } - scroller.scrollTop = scroller.scrollHeight; - }); + }); + } + + redrawHooks.add(redrawForConnector); + + return { + state: chatState, + hasLiveRun: () => hasLiveRun(runSlot), + signalLiveRun: (kind, text) => signalLiveRun(runSlot, kind, text), + newChat, + teardown: teardownActiveChat, + resetChatState, + mountContinuable, + mountReadOnly, + mountLoadingPane, + drawActiveChat, + setTranscriptWindow, + requestBackgroundPanel, + activePendingApprovals, + hasUnresolvedApproval, + resolveCommandApproval, + approvalSummaryView, + notePendingSessionOnSend, + syncPaneState: postCurrentPaneState, + onDelivery, + resumeIfIdle, + redraw: () => drawActiveChat(), + dispose, + }; } diff --git a/plugins/web-ui/src/composer.ts b/plugins/web-ui/src/composer.ts index 03fe4fe..cc20e6e 100644 --- a/plugins/web-ui/src/composer.ts +++ b/plugins/web-ui/src/composer.ts @@ -21,8 +21,6 @@ import { import { api, fetchRuntimeConfig, - hasLiveRun, - signalLiveRun, updateRuntimeConfig, type ApprovalDecision, type PendingApproval, @@ -30,7 +28,6 @@ import { } from "./core-bridge"; import { errMessage, swallow } from "../../chassis/src/errors"; import { icon } from "./ui"; -import { embedMode } from "./embed"; import { EFFORT_LEVELS, applyRuntimeOptions, @@ -47,15 +44,7 @@ import { type ModelOptionValue, } from "./model-options"; import { modelSupportsFastMode, setFastModeModelIds } from "./pi-models"; -import { - activePendingApprovals, - approvalSummaryView, - chatState, - drawActiveChat, - hasUnresolvedApproval, - notePendingSessionOnSend, - resolveCommandApproval, -} from "./chat"; +import type { ComposerSurface, ConvCtx } from "./conv-types"; import { bumpSessionActivity, dropPendingSession, renderList } from "./sessions"; import { adminSessionLogUrl, appState, can } from "./shell"; import { base64ToText, bytesToBase64, insertIntoDraft, pasteChipLabel } from "./paste-text"; @@ -85,8 +74,17 @@ function loadThreadPicks(): Map { } let threadModelPicks = loadThreadPicks(); -let activeRuntimeConfig: RuntimeConfig | null = null; -let runtimeRequest = 0; +let seededRuntime: { scopeId: string | null; config: RuntimeConfig } | null = null; + +export function seedRuntimeConfig(scopeId: string | null, config: RuntimeConfig): void { + seededRuntime = { scopeId: runtimeScopeKey(scopeId), config }; +} + +function runtimeScopeKey(scopeId: string | null): string | null { + if (scopeId) return scopeId; + const user = appState.me?.user; + return user ? `personal:${user}` : null; +} if (typeof window !== "undefined") { window.addEventListener("storage", (e) => { @@ -109,8 +107,8 @@ export function carryModelPick(fromThreadRef: string | null, toThreadRef: string if (pick) rememberThreadPick(toThreadRef, pick); } -function modelOptionFor(value: ModelOptionValue): ModelOption { - const options = getModelOptions(); +function modelOptionFor(value: ModelOptionValue, scopeKey?: string | null): ModelOption { + const options = getModelOptions(scopeKey); return ( options.find((option) => option.value === value) ?? options.find((option) => option.value === defaultModelValue()) ?? @@ -147,24 +145,6 @@ function persistPreference(key: string, value: string): void { } } -function isUnsentNewChat(): boolean { - return ( - chatState.sessionId === null && - !(chatState.agent?.state.messages ?? []).some((m) => !(m as { opener?: boolean }).opener) - ); -} - -function persistDraft(): void { - if (!chatState.threadRef) return; - saveDraft(chatState.threadRef, composerState.draft); - if (isUnsentNewChat()) saveDraft(newChatDraftKey(appState.me?.user), composerState.draft); -} - -function clearActiveDraft(): void { - if (chatState.threadRef) clearDraft(chatState.threadRef); - if (chatState.sessionId === null) clearDraft(newChatDraftKey(appState.me?.user)); -} - export interface SkillItem { id?: string; name: string; @@ -189,93 +169,17 @@ interface SkillMatch { end: number; } -export const composerState = { - draft: "", - attachments: [] as Attachment[], - error: "", - processingFiles: false, - dragging: false, - openMenu: null as ComposerMenu | null, - skillsCache: null as SkillItem[] | null, - slashDismissed: false, - effortLevel: loadStoredEffort(defaultEffortForModel(modelOptionFor(defaultModelValue()).model)), - fastMode: loadStoredFastMode(), - pasteView: null as { id: string; text: string; initial: string; dirty: boolean } | null, -}; - -const pastedTextIds = new Set(); - -let dragDepth = 0; -let skillsLoading = false; -let slashActiveIndex = 0; -let fastModeCharging = false; -let orgFastModeDefault = false; - -function effectiveFastMode(): boolean { - return composerState.fastMode ?? orgFastModeDefault; -} -let fastModeChargeTimer: ReturnType | null = null; - -export function resetComposer(): void { - composerState.draft = ""; - composerState.attachments = []; - composerState.pasteView = null; - pastedTextIds.clear(); - composerState.error = ""; - composerState.processingFiles = false; - composerState.openMenu = null; - slashActiveIndex = 0; - composerState.slashDismissed = false; -} +let skillsCache: SkillItem[] | null = null; -export function currentModelOption(): ModelOption { - const picked = chatState.threadRef ? threadModelPicks.get(chatState.threadRef) : undefined; - return modelOptionFor(picked ?? defaultModelValue()); +export function clearSkillsCache(): void { + skillsCache = null; } -export async function refreshRuntimeSelection(scopeId: string | null, agent?: Agent): Promise { - const request = ++runtimeRequest; - activeRuntimeConfig = null; - composerState.error = ""; - drawActiveChat(agent); - const config = await fetchRuntimeConfig(scopeId); - if (request !== runtimeRequest) return; - if (!config) { - composerState.error = "Could not load runtime settings."; - drawActiveChat(agent); - return; - } - activeRuntimeConfig = config; - setFastModeModelIds(config.fastModeModelIds); - orgFastModeDefault = config.interactiveFastMode === true; - applyRuntimeOptions(config.approvedHarnesses, config.modelsByHarness, config.effective, config.modelCatalog); - if (agent && (!chatState.threadRef || !threadModelPicks.has(chatState.threadRef))) - agent.state.model = currentModelOption().model; - drawActiveChat(agent); - if (pendingComposerFocus) focusComposerEnd(); -} +const SLASH_TOKEN = /(^|\s)\/([a-zA-Z0-9_-]*)$/; -async function changeScopeRuntime( - change: { harnessId?: string; modelId?: string; inherit?: boolean; keep?: boolean }, - agent: Agent, -): Promise { - const request = ++runtimeRequest; - const scopeId = chatState.scopeId; - try { - const config = await updateRuntimeConfig(scopeId, change); - if (request !== runtimeRequest || scopeId !== chatState.scopeId) return; - activeRuntimeConfig = config; - setFastModeModelIds(config.fastModeModelIds); - orgFastModeDefault = config.interactiveFastMode === true; - applyRuntimeOptions(config.approvedHarnesses, config.modelsByHarness, config.effective, config.modelCatalog); - if (!chatState.threadRef || !threadModelPicks.has(chatState.threadRef)) - agent.state.model = currentModelOption().model; - composerState.error = ""; - } catch (e) { - if (request !== runtimeRequest || scopeId !== chatState.scopeId) return; - composerState.error = errMessage(e, "Could not update the scope default."); - } - drawActiveChat(agent); +export function slashQuery(draft: string): string | null { + const m = SLASH_TOKEN.exec(draft); + return m ? (m[2] ?? "") : null; } export function resyncModelSelection(): void { @@ -284,1080 +188,1263 @@ export function resyncModelSelection(): void { } catch { void 0; } - composerState.effortLevel = loadStoredEffort(defaultEffortForModel(currentModelOption().model)); } -export function composerForm(agent: Agent): TemplateResult { - const selectedModel = currentModelOption(); - const effortAvailable = harnessSupportsEffort(selectedModel.harnessId); - const fastSupported = harnessSupportsFastMode(selectedModel.harnessId); - const fastAvailable = fastSupported && modelSupportsFastMode(selectedModel.model.id); - const fastOn = fastAvailable && effectiveFastMode(); - const fastCharging = fastModeCharging && fastOn; - let fastTitle = "Fast mode is only available on Opus models"; - if (fastAvailable) fastTitle = fastOn ? "Fast mode active" : "Fast mode"; - const approvalPauses = activePendingApprovals(); - const runtimePending = activeRuntimeConfig === null; - const modelToggled = !runtimePending && selectedModel.value !== defaultModelValue(); - const inputBlocked = runtimePending || chatState.resolvingApprovals.size > 0 || approvalPauses.length > 0; - const attachingDisabled = inputBlocked || agent.state.isStreaming; - let placeholder = "Ask anything"; - if (inputBlocked) placeholder = runtimePending ? "Loading runtime…" : "Approve or deny to continue"; - else if (agent.state.isStreaming) placeholder = "Steer the running task…"; - let composerNotice: TemplateResult | typeof nothing = nothing; - if (composerState.processingFiles) { - composerNotice = html`
Preparing files...
`; - } else if (!approvalPauses.length && runtimePending) { - composerNotice = html`
- ${composerState.error || "Loading runtime settings…"} - ${composerState.error ? html`` : nothing} -
`; - } else if (composerState.error) { - composerNotice = html`
${composerState.error}
`; - } - return html` -
submitComposer(e, agent)}> - ${slashMenu(agent)} - ${ - activeRuntimeConfig?.upgradeAvailable - ? html`
- The org now recommends - ${modelOptionFor(`${activeRuntimeConfig.orgDefault.harnessId}:${activeRuntimeConfig.orgDefault.modelId}`).harnessLabel} - · - ${modelOptionFor(`${activeRuntimeConfig.orgDefault.harnessId}:${activeRuntimeConfig.orgDefault.modelId}`).buttonLabel}. - - - -
` - : nothing - } - ${ - composerState.attachments.length - ? html` -
- ${composerState.attachments.map( - (a) => html` - - ${ - pastedTextIds.has(a.id) - ? html` - - ` - : html`${icon(Paperclip, 14)}${a.fileName}` - } - - - `, - )} -
- ` - : nothing - } - ${ - approvalPauses.length - ? composerApprovalPanel(approvalPauses) - : html` - - ` - } -
-
- ${ - !embedMode && chatState.sessionId && can("admin") - ? html`${icon(ScrollText, 18)}` - : nothing - } - void onFilesSelected(e, agent)} - /> - - ${ - embedMode - ? nothing - : html` - ${ - effortAvailable - ? menuControl({ - kind: "effort", - glyph: Brain, - label: effortLabel(composerState.effortLevel), - title: "Effort", - selected: composerState.effortLevel, - options: EFFORT_LEVELS, - disabled: inputBlocked, - onSelect: (value: string) => selectEffort(value as EffortLevel, agent), - }) - : nothing - } - ${ - fastSupported - ? html`` - : nothing - } - ` - } -
-
- ${ - embedMode - ? settingsControl(agent, selectedModel, inputBlocked) - : html` - ${ - modelToggled - ? html`` - : nothing - } - ${ - modelToggled && activeRuntimeConfig?.scopeOverride - ? html`` - : nothing - } - ${menuControl({ - kind: "model", - label: selectedModel.buttonLabel, - title: "Model", - selected: selectedModel.value, - align: "right", - options: getModelOptionsForHarness(selectedModel.harnessId).map((option) => ({ - value: option.value, - label: option.label, - })), - disabled: inputBlocked, - onSelect: (value: string) => selectModel(value, agent), - })} - ${menuControl({ - kind: "harness", - label: selectedModel.harnessLabel, - title: "Harness", - selected: selectedModel.harnessId, - align: "right", - options: getHarnessOptions(), - disabled: inputBlocked, - onSelect: (value: string) => selectHarness(value, agent), - })} - ` - } - ${sendControls(agent)} -
-
- ${composerNotice} -
- ${pasteViewDialog(agent)} - `; -} +export function createComposerSurface(ctx: ConvCtx): ComposerSurface { + let activeRuntimeConfig: RuntimeConfig | null = null; + let runtimeRequest = 0; -function pasteViewDialog(agent: Agent): TemplateResult | typeof nothing { - const view = composerState.pasteView; - if (!view) return nothing; - return html` -
e.target === e.currentTarget && closePasteView(agent)} - @keydown=${(e: KeyboardEvent) => e.key === "Escape" && closePasteView(agent)} - > - -
- `; -} + function isUnsentNewChat(): boolean { + return ( + ctx.chat.state.sessionId === null && + !(ctx.chat.state.agent?.state.messages ?? []).some((m) => !(m as { opener?: boolean }).opener) + ); + } -function openPasteView(id: string, agent: Agent): void { - const attachment = composerState.attachments.find((a) => a.id === id); - if (!attachment) return; - const text = attachment.extractedText ?? base64ToText(attachment.content); - composerState.pasteView = { id, text, initial: text, dirty: false }; - drawActiveChat(agent); - requestAnimationFrame(() => chatState.host?.querySelector(".paste-dialog-text")?.focus()); -} + function persistDraft(): void { + if (!ctx.chat.state.threadRef) return; + saveDraft(ctx.chat.state.threadRef, composerState.draft); + if (isUnsentNewChat()) saveDraft(newChatDraftKey(appState.me?.user), composerState.draft); + } -function closePasteView(agent: Agent): void { - const view = composerState.pasteView; - if (!view) return; - const attachment = composerState.attachments.find((a) => a.id === view.id); - if (attachment && view.dirty) { - const bytes = new TextEncoder().encode(view.text); - attachment.content = bytesToBase64(bytes); - attachment.size = bytes.length; - attachment.extractedText = view.text; - } - composerState.pasteView = null; - drawActiveChat(agent); -} + function clearActiveDraft(): void { + if (ctx.chat.state.threadRef) clearDraft(ctx.chat.state.threadRef); + if (ctx.chat.state.sessionId === null) clearDraft(newChatDraftKey(appState.me?.user)); + } -function insertPasteIntoDraft(agent: Agent): void { - const view = composerState.pasteView; - if (!view) return; - const ta = chatState.host?.querySelector(".composer-input"); - const { draft, cursor } = insertIntoDraft(composerState.draft, view.text, ta ? ta.selectionStart : null); - composerState.draft = draft; - persistDraft(); - composerState.pasteView = null; - removeAttachment(view.id, agent); - resizeComposer(); - requestAnimationFrame(() => { - const input = chatState.host?.querySelector(".composer-input"); - if (!input) return; - input.focus(); - input.setSelectionRange(cursor, cursor); - }); -} + const composerState = { + draft: "", + attachments: [] as Attachment[], + error: "", + processingFiles: false, + dragging: false, + openMenu: null as ComposerMenu | null, + slashDismissed: false, + effortLevel: loadStoredEffort(defaultEffortForModel(modelOptionFor(defaultModelValue()).model)), + fastMode: loadStoredFastMode(), + pasteView: null as { id: string; text: string; initial: string; dirty: boolean } | null, + }; -function sendControls(agent: Agent): TemplateResult { - if (!agent.state.isStreaming) { - return html``; - } - const canSteer = Boolean(composerState.draft.trim()); - return html` - - - `; -} + const pastedTextIds = new Set(); -function composerApprovalPanel(approvals: PendingApproval[]): TemplateResult { - const busy = chatState.resolvingApprovals.size > 0; - const decide = (decision: ApprovalDecision): void => { - if (!busy) resolveCommandApproval(decision); - }; - return html`
- ${approvals.map( - (a) => - html`
-
${approvalSummaryView(a, true)}
-
- +
` + : html`
Loading runtime settings…
`; + } else if (composerState.error) { + composerNotice = html`
${composerState.error}
`; + } + return html` +
submitComposer(e, agent)}> + ${slashMenu(agent)} + ${ + activeRuntimeConfig?.upgradeAvailable + ? html`
+ The org now recommends + ${modelOptionFor(`${activeRuntimeConfig.orgDefault.harnessId}:${activeRuntimeConfig.orgDefault.modelId}`).harnessLabel} + · + ${modelOptionFor(`${activeRuntimeConfig.orgDefault.harnessId}:${activeRuntimeConfig.orgDefault.modelId}`).buttonLabel}. + + + +
` + : nothing + } + ${ + composerState.attachments.length + ? html` +
+ ${composerState.attachments.map( + (a) => html` + + ${ + pastedTextIds.has(a.id) + ? html` + + ` + : html`${icon(Paperclip, 14)}${a.fileName}` + } + + + `, + )} +
+ ` + : nothing + } + ${ + approvalPauses.length + ? composerApprovalPanel(approvalPauses) + : html` + + ` + } +
+
+ ${ + !ctx.pane && ctx.chat.state.sessionId && can("admin") + ? html`${icon(ScrollText, 18)}` + : nothing + } + void onFilesSelected(e, agent)} + /> ${ - a.grantModes?.session === false + ctx.pane ? nothing - : html`` + : html` + ${ + effortAvailable + ? menuControl({ + kind: "effort", + glyph: Brain, + label: effortLabel(composerState.effortLevel), + title: "Effort", + selected: composerState.effortLevel, + options: EFFORT_LEVELS, + disabled: inputBlocked, + onSelect: (value: string) => selectEffort(value as EffortLevel, agent), + }) + : nothing + } + ${ + fastSupported + ? html`` + : nothing + } + ` } +
+
${ - a.grantModes?.always === false - ? nothing - : html`` + ctx.pane + ? settingsControl(agent, selectedModel, inputBlocked) + : html` + ${ + modelToggled + ? html`` + : nothing + } + ${ + modelToggled && activeRuntimeConfig?.scopeOverride + ? html`` + : nothing + } + ${menuControl({ + kind: "model", + label: selectedModel.buttonLabel, + title: "Model", + selected: selectedModel.value, + align: "right", + options: getModelOptionsForHarness(selectedModel.harnessId, scopeKey()).map((option) => ({ + value: option.value, + label: option.label, + })), + disabled: inputBlocked, + onSelect: (value: string) => selectModel(value, agent), + })} + ${menuControl({ + kind: "harness", + label: selectedModel.harnessLabel, + title: "Harness", + selected: selectedModel.harnessId, + align: "right", + options: getHarnessOptions(scopeKey()), + disabled: inputBlocked, + onSelect: (value: string) => selectHarness(value, agent), + })} + ` } + ${sendControls(agent)}
-
`, - )} -
`; -} +
+ ${composerNotice} + + ${pasteViewDialog(agent)} + `; + } + + function pasteViewDialog(agent: Agent): TemplateResult | typeof nothing { + const view = composerState.pasteView; + if (!view) return nothing; + return html` +
e.target === e.currentTarget && closePasteView(agent)} + @keydown=${(e: KeyboardEvent) => e.key === "Escape" && closePasteView(agent)} + > + +
+ `; + } + + function openPasteView(id: string, agent: Agent): void { + const attachment = composerState.attachments.find((a) => a.id === id); + if (!attachment) return; + const text = attachment.extractedText ?? base64ToText(attachment.content); + composerState.pasteView = { id, text, initial: text, dirty: false }; + ctx.chat.drawActiveChat(agent); + requestAnimationFrame(() => ctx.chat.state.host?.querySelector(".paste-dialog-text")?.focus()); + } + + function closePasteView(agent: Agent): void { + const view = composerState.pasteView; + if (!view) return; + const attachment = composerState.attachments.find((a) => a.id === view.id); + if (attachment && view.dirty) { + const bytes = new TextEncoder().encode(view.text); + attachment.content = bytesToBase64(bytes); + attachment.size = bytes.length; + attachment.extractedText = view.text; + } + composerState.pasteView = null; + ctx.chat.drawActiveChat(agent); + } + + function insertPasteIntoDraft(agent: Agent): void { + const view = composerState.pasteView; + if (!view) return; + const ta = ctx.chat.state.host?.querySelector(".composer-input"); + const { draft, cursor } = insertIntoDraft(composerState.draft, view.text, ta ? ta.selectionStart : null); + composerState.draft = draft; + persistDraft(); + composerState.pasteView = null; + removeAttachment(view.id, agent); + resizeComposer(); + requestAnimationFrame(() => { + const input = ctx.chat.state.host?.querySelector(".composer-input"); + if (!input) return; + input.focus(); + input.setSelectionRange(cursor, cursor); + }); + } -function settingsControl(agent: Agent, selected: ModelOption, disabled: boolean): TemplateResult { - const open = composerState.openMenu === "settings"; - const fastAvailable = harnessSupportsFastMode(selected.harnessId) && modelSupportsFastMode(selected.model.id); - const fastOn = fastAvailable && effectiveFastMode(); - const summary = `${selected.buttonLabel} · ${effortLabel(composerState.effortLevel)}${fastOn ? " · Fast" : ""}`; - return html` - + `; + } -export function slashQuery(draft: string): string | null { - const m = draft.match(SLASH_TOKEN); - return m ? (m[2] ?? "") : null; -} + function menuControl(args: { + kind: ComposerMenu; + glyph?: IconNode; + label: string; + title: string; + selected: string; + options: Array<{ value: string; label: string }>; + disabled?: boolean; + align?: "left" | "right"; + onSelect: (value: string) => void; + }): TemplateResult { + const open = composerState.openMenu === args.kind; + const menuId = `composer-${args.kind}-menu`; + let controlClass = ""; + if (args.kind === "model") controlClass = "model-control"; + else if (args.kind === "harness") controlClass = "harness-control"; + return html` + + `; + } -function matchSkills(query: string, skills: SkillItem[]): SkillMatch[] { - const q = query.toLowerCase(); - if (!q) return skills.map((skill) => ({ skill, start: -1, end: -1 })); - const out: SkillMatch[] = []; - for (const skill of skills) { - const at = skill.name.toLowerCase().indexOf(q); - if (at >= 0) out.push({ skill, start: at, end: at + q.length }); + function toggleComposerMenu(e: Event, kind: ComposerMenu): void { + e.stopPropagation(); + composerState.openMenu = composerState.openMenu === kind ? null : kind; + ctx.chat.drawActiveChat(); } - return out.sort((a, b) => a.start - b.start || a.skill.name.localeCompare(b.skill.name)); -} -function currentSlashMenu(): { open: boolean; loading: boolean; matches: SkillMatch[] } { - const query = slashQuery(composerState.draft); - if (query === null || composerState.slashDismissed) return { open: false, loading: false, matches: [] }; - const loading = skillsLoading; - const matches = composerState.skillsCache ? matchSkills(query, composerState.skillsCache) : []; - return { open: loading || matches.length > 0, loading, matches }; -} + function matchSkills(query: string, skills: SkillItem[]): SkillMatch[] { + const q = query.toLowerCase(); + if (!q) return skills.map((skill) => ({ skill, start: -1, end: -1 })); + const out: SkillMatch[] = []; + for (const skill of skills) { + const at = skill.name.toLowerCase().indexOf(q); + if (at >= 0) out.push({ skill, start: at, end: at + q.length }); + } + return out.sort((a, b) => a.start - b.start || a.skill.name.localeCompare(b.skill.name)); + } -function clampedActive(matchCount: number): number { - return Math.max(0, Math.min(slashActiveIndex, matchCount - 1)); -} + function currentSlashMenu(): { open: boolean; loading: boolean; matches: SkillMatch[] } { + const query = slashQuery(composerState.draft); + if (query === null || composerState.slashDismissed) return { open: false, loading: false, matches: [] }; + const loading = skillsLoading; + const matches = skillsCache ? matchSkills(query, skillsCache) : []; + return { open: loading || matches.length > 0, loading, matches }; + } -async function loadSkills(agent: Agent): Promise { - if (skillsLoading || composerState.skillsCache !== null) return; - skillsLoading = true; - drawActiveChat(agent); - try { - const r = await api<{ skills: SkillItem[] }>("/api/skills"); - composerState.skillsCache = r.skills ?? []; - } catch { - composerState.skillsCache = null; - } finally { - skillsLoading = false; - if (agent === chatState.agent) drawActiveChat(agent); + function clampedActive(matchCount: number): number { + return Math.max(0, Math.min(slashActiveIndex, matchCount - 1)); } -} -function acceptSkill(skill: SkillItem, agent: Agent): void { - composerState.draft = composerState.draft.replace(SLASH_TOKEN, (_m, pre: string) => `${pre}/${skill.name} `); - persistDraft(); - slashActiveIndex = 0; - composerState.slashDismissed = false; - drawActiveChat(agent); - focusComposerEnd(); -} + async function loadSkills(agent: Agent): Promise { + if (skillsLoading || skillsCache !== null) return; + skillsLoading = true; + ctx.chat.drawActiveChat(agent); + try { + const r = await api<{ skills: SkillItem[] }>("/api/skills"); + skillsCache = r.skills ?? []; + } catch { + skillsCache = null; + } finally { + skillsLoading = false; + if (agent === ctx.chat.state.agent) ctx.chat.drawActiveChat(agent); + } + } -let pendingComposerFocus = false; + function acceptSkill(skill: SkillItem, agent: Agent): void { + composerState.draft = composerState.draft.replace(SLASH_TOKEN, (_m, pre: string) => `${pre}/${skill.name} `); + persistDraft(); + slashActiveIndex = 0; + composerState.slashDismissed = false; + ctx.chat.drawActiveChat(agent); + focusComposerEnd(); + } -export function focusComposerEnd(): void { - requestAnimationFrame(() => { - const ta = chatState.host?.querySelector(".composer-input"); - if (!ta) return; - if (ta.disabled) { - pendingComposerFocus = true; - return; - } - pendingComposerFocus = false; - ta.focus(); - ta.setSelectionRange(ta.value.length, ta.value.length); - }); -} + let pendingComposerFocus = false; -function closeSlashMenu(agent: Agent): void { - composerState.slashDismissed = true; - drawActiveChat(agent); -} + function focusComposerEnd(): void { + requestAnimationFrame(() => { + const ta = ctx.chat.state.host?.querySelector(".composer-input"); + if (!ta) return; + if (ta.disabled) { + pendingComposerFocus = true; + return; + } + pendingComposerFocus = false; + ta.focus(); + ta.setSelectionRange(ta.value.length, ta.value.length); + }); + } -function slashMenu(agent: Agent): TemplateResult | typeof nothing { - const slash = currentSlashMenu(); - if (!slash.open) return nothing; - if (slash.loading && slash.matches.length === 0) { - return html`
- -
Loading skills…
-
`; + function closeSlashMenu(agent: Agent): void { + composerState.slashDismissed = true; + ctx.chat.drawActiveChat(agent); } - const active = clampedActive(slash.matches.length); - return html` -
- - ${slash.matches.map((m, i) => slashRow(m, i === active, agent))} -
- `; -} -function slashRow(m: SkillMatch, active: boolean, agent: Agent): TemplateResult { - return html` - - `; -} + function slashMenu(agent: Agent): TemplateResult | typeof nothing { + const slash = currentSlashMenu(); + if (!slash.open) return nothing; + if (slash.loading && slash.matches.length === 0) { + return html`
+ +
Loading skills…
+
`; + } + const active = clampedActive(slash.matches.length); + return html` +
+ + ${slash.matches.map((m, i) => slashRow(m, i === active, agent))} +
+ `; + } -function highlightName(m: SkillMatch): TemplateResult { - const { name } = m.skill; - if (m.start < 0 || m.end <= m.start) return html`${name}`; - return html`${name.slice(0, m.start)}${name.slice(m.start, m.end)}${name.slice(m.end)}`; -} + function slashRow(m: SkillMatch, active: boolean, agent: Agent): TemplateResult { + return html` + + `; + } -function scopeBadge(scope: string): string { - return scope ? scope.charAt(0).toUpperCase() + scope.slice(1) : ""; -} + function highlightName(m: SkillMatch): TemplateResult { + const { name } = m.skill; + if (m.start < 0 || m.end <= m.start) return html`${name}`; + return html`${name.slice(0, m.start)}${name.slice(m.start, m.end)}${name.slice(m.end)}`; + } -function submitComposer(e: Event, agent: Agent): void { - e.preventDefault(); - void sendPrompt(agent); -} + function scopeBadge(scope: string): string { + return scope ? scope.charAt(0).toUpperCase() + scope.slice(1) : ""; + } -function onDraftInput(e: InputEvent, agent: Agent): void { - composerState.draft = (e.currentTarget as HTMLTextAreaElement).value; - persistDraft(); - const hadError = Boolean(composerState.error); - composerState.error = ""; - composerState.slashDismissed = false; - slashActiveIndex = 0; - const armed = slashQuery(composerState.draft) !== null; - if (armed && composerState.skillsCache === null && !skillsLoading) void loadSkills(agent); - const popoverShown = Boolean(chatState.host?.querySelector(".slash-popover")); - if (armed || popoverShown || hadError) { - drawActiveChat(agent); - return; - } - syncComposerControls(agent); - resizeComposer(); -} + function submitComposer(e: Event, agent: Agent): void { + e.preventDefault(); + void sendPrompt(agent); + } -function composerCanSend(): boolean { - return ( - Boolean(composerState.draft.trim() || composerState.attachments.length) && - !composerState.processingFiles && - activeRuntimeConfig !== null && - chatState.resolvingApprovals.size === 0 && - !hasUnresolvedApproval() - ); -} + function onDraftInput(e: InputEvent, agent: Agent): void { + composerState.draft = (e.currentTarget as HTMLTextAreaElement).value; + persistDraft(); + const hadError = Boolean(composerState.error); + composerState.error = ""; + composerState.slashDismissed = false; + slashActiveIndex = 0; + const armed = slashQuery(composerState.draft) !== null; + if (armed && skillsCache === null && !skillsLoading) void loadSkills(agent); + const popoverShown = Boolean(ctx.chat.state.host?.querySelector(".slash-popover")); + if (armed || popoverShown || hadError) { + ctx.chat.drawActiveChat(agent); + return; + } + syncComposerControls(agent); + resizeComposer(); + } -function syncComposerControls(agent: Agent): void { - if (!chatState.host || agent !== chatState.agent) return; - const send = chatState.host.querySelector(".send-btn"); - if (send) send.disabled = agent.state.isStreaming ? !composerState.draft.trim() : !composerCanSend(); -} + function composerCanSend(): boolean { + return ( + Boolean(composerState.draft.trim() || composerState.attachments.length) && + !composerState.processingFiles && + activeRuntimeConfig !== null && + ctx.chat.state.resolvingApprovals.size === 0 && + !ctx.chat.hasUnresolvedApproval() + ); + } -function clearComposerDom(agent: Agent): void { - if (!chatState.host || agent !== chatState.agent) return; - const input = chatState.host.querySelector(".composer-input"); - if (input) { - input.value = ""; - input.style.height = "auto"; - input.style.overflowY = "hidden"; - input.scrollTop = 0; + function syncComposerControls(agent: Agent): void { + if (!ctx.chat.state.host || agent !== ctx.chat.state.agent) return; + const send = ctx.chat.state.host.querySelector(".send-btn"); + if (send) send.disabled = agent.state.isStreaming ? !composerState.draft.trim() : !composerCanSend(); } - const send = chatState.host.querySelector(".send-btn"); - if (send) send.disabled = true; -} -function onComposerKeydown(e: KeyboardEvent, agent: Agent): void { - const slash = currentSlashMenu(); - if (slash.open) { - if (e.key === "Escape") { - e.preventDefault(); - return closeSlashMenu(agent); + function clearComposerDom(agent: Agent): void { + if (!ctx.chat.state.host || agent !== ctx.chat.state.agent) return; + const input = ctx.chat.state.host.querySelector(".composer-input"); + if (input) { + input.value = ""; + input.style.height = "auto"; + input.style.overflowY = "hidden"; + input.scrollTop = 0; } - if (slash.matches.length) { - const count = slash.matches.length; - if (e.key === "ArrowDown") { - e.preventDefault(); - slashActiveIndex = (clampedActive(count) + 1) % count; - return drawActiveChat(agent); - } - if (e.key === "ArrowUp") { + const send = ctx.chat.state.host.querySelector(".send-btn"); + if (send) send.disabled = true; + } + + function onComposerKeydown(e: KeyboardEvent, agent: Agent): void { + const slash = currentSlashMenu(); + if (slash.open) { + if (e.key === "Escape") { e.preventDefault(); - slashActiveIndex = (clampedActive(count) - 1 + count) % count; - return drawActiveChat(agent); + return closeSlashMenu(agent); } - if (!e.shiftKey && (e.key === "Enter" || e.key === "Tab")) { + if (slash.matches.length) { + const count = slash.matches.length; + if (e.key === "ArrowDown") { + e.preventDefault(); + slashActiveIndex = (clampedActive(count) + 1) % count; + return ctx.chat.drawActiveChat(agent); + } + if (e.key === "ArrowUp") { + e.preventDefault(); + slashActiveIndex = (clampedActive(count) - 1 + count) % count; + return ctx.chat.drawActiveChat(agent); + } + if (!e.shiftKey && (e.key === "Enter" || e.key === "Tab")) { + e.preventDefault(); + return acceptSkill(slash.matches[clampedActive(count)]!.skill, agent); + } + } else if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - return acceptSkill(slash.matches[clampedActive(count)]!.skill, agent); + return; } - } else if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - return; } + if (e.key !== "Enter" || e.shiftKey) return; + e.preventDefault(); + void sendPrompt(agent); } - if (e.key !== "Enter" || e.shiftKey) return; - e.preventDefault(); - void sendPrompt(agent); -} -function stopStreaming(agent: Agent): void { - void signalLiveRun("abort").catch((e) => swallow("web-ui: abort signal", e)); - agent.abort(); -} + function stopStreaming(agent: Agent): void { + void ctx.chat.signalLiveRun("abort").catch((e) => swallow("web-ui: abort signal", e)); + agent.abort(); + } -async function sendSteer(agent: Agent): Promise { - const text = composerState.draft.trim(); - if (!text || !hasLiveRun()) return; - if (chatState.threadRef) bumpSessionActivity(chatState.threadRef); - clearActiveDraft(); - composerState.draft = ""; - composerState.error = ""; - agent.state.messages.push({ - role: "user", - content: text, - timestamp: Date.now(), - steered: true, - } as unknown as AgentMessage); - drawActiveChat(agent); - clearComposerDom(agent); - try { - await signalLiveRun("steer", text); - } catch (err) { - composerState.error = errMessage(err, "Could not steer the running task."); - drawActiveChat(agent); + async function sendSteer(agent: Agent): Promise { + const text = composerState.draft.trim(); + if (!text || !ctx.chat.hasLiveRun()) return; + if (ctx.chat.state.threadRef) bumpSessionActivity(ctx.chat.state.threadRef); + clearActiveDraft(); + composerState.draft = ""; + composerState.error = ""; + agent.state.messages.push({ + role: "user", + content: text, + timestamp: Date.now(), + steered: true, + } as unknown as AgentMessage); + ctx.chat.drawActiveChat(agent); + clearComposerDom(agent); + try { + await ctx.chat.signalLiveRun("steer", text); + } catch (err) { + composerState.error = errMessage(err, "Could not steer the running task."); + ctx.chat.drawActiveChat(agent); + } } -} -async function sendPrompt(agent: Agent): Promise { - if (composerState.processingFiles) return; - if (!activeRuntimeConfig && !agent.state.isStreaming) return; - if (composerState.pasteView) closePasteView(agent); - if (chatState.resolvingApprovals.size > 0) return; - if (hasUnresolvedApproval()) return; - if (agent.state.isStreaming) return sendSteer(agent); - const text = composerState.draft.trim(); - if (!text && composerState.attachments.length === 0) return; - if (chatState.threadRef) { - bumpSessionActivity(chatState.threadRef); - chatState.pendingSend = chatState.threadRef; - renderList(); - } - const attachments = composerState.attachments; - notePendingSessionOnSend(); - clearActiveDraft(); - resetComposer(); - drawActiveChat(agent); - clearComposerDom(agent); - try { - if (attachments.length) { - await agent.prompt({ role: "user-with-attachments", content: text, attachments, timestamp: Date.now() }); - } else { - await agent.prompt(text); + async function sendPrompt(agent: Agent): Promise { + if (composerState.processingFiles) return; + if (!activeRuntimeConfig && !agent.state.isStreaming) return; + if (composerState.pasteView) closePasteView(agent); + if (ctx.chat.state.resolvingApprovals.size > 0) return; + if (ctx.chat.hasUnresolvedApproval()) return; + if (agent.state.isStreaming) return sendSteer(agent); + const text = composerState.draft.trim(); + if (!text && composerState.attachments.length === 0) return; + if (ctx.chat.state.threadRef) { + bumpSessionActivity(ctx.chat.state.threadRef); + ctx.chat.state.pendingSend = ctx.chat.state.threadRef; + renderList(); + } + const attachments = composerState.attachments; + ctx.chat.notePendingSessionOnSend(); + clearActiveDraft(); + resetComposer(); + ctx.chat.drawActiveChat(agent); + clearComposerDom(agent); + try { + if (attachments.length) { + await agent.prompt({ role: "user-with-attachments", content: text, attachments, timestamp: Date.now() }); + } else { + await agent.prompt(text); + } + } catch (err) { + ctx.chat.state.pendingSend = null; + if (ctx.chat.state.threadRef && ctx.chat.state.sessionId === null) dropPendingSession(ctx.chat.state.threadRef); + renderList(); + composerState.error = errMessage(err, "Could not send message."); + ctx.chat.drawActiveChat(agent); } - } catch (err) { - chatState.pendingSend = null; - if (chatState.threadRef && chatState.sessionId === null) dropPendingSession(chatState.threadRef); - renderList(); - composerState.error = errMessage(err, "Could not send message."); - drawActiveChat(agent); } -} -const LARGE_PASTE_CHARS = 2000; + const LARGE_PASTE_CHARS = 2000; -async function onComposerPaste(e: ClipboardEvent, agent: Agent): Promise { - const data = e.clipboardData; - if (!data) return; - const files = Array.from(data.items) - .filter((item) => item.kind === "file") - .map((item) => item.getAsFile()) - .filter((file): file is File => file !== null); - if (files.length) { + async function onComposerPaste(e: ClipboardEvent, agent: Agent): Promise { + const data = e.clipboardData; + if (!data) return; + const files = Array.from(data.items) + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null); + if (files.length) { + e.preventDefault(); + await addFiles(files, agent); + return; + } + const text = data.getData("text/plain"); + if (text.length <= LARGE_PASTE_CHARS) return; + if ( + ctx.chat.hasUnresolvedApproval() || + ctx.chat.state.resolvingApprovals.size > 0 || + agent.state.isStreaming || + composerState.processingFiles + ) + return; e.preventDefault(); + const names = new Set(composerState.attachments.map((a) => a.fileName)); + let n = 1; + while (names.has(n === 1 ? "pasted-text.txt" : `pasted-text-${n}.txt`)) n++; + const bytes = new TextEncoder().encode(text); + const attachment: Attachment = { + id: `paste_${Date.now()}_${Math.random()}`, + type: "document", + fileName: n === 1 ? "pasted-text.txt" : `pasted-text-${n}.txt`, + mimeType: "text/plain", + size: bytes.length, + content: bytesToBase64(bytes), + extractedText: text, + }; + pastedTextIds.add(attachment.id); + composerState.attachments = [...composerState.attachments, attachment]; + ctx.chat.drawActiveChat(agent); + } + + async function onFilesSelected(e: Event, agent: Agent): Promise { + const input = e.currentTarget as HTMLInputElement; + const files = Array.from(input.files ?? []); + input.value = ""; await addFiles(files, agent); - return; - } - const text = data.getData("text/plain"); - if (text.length <= LARGE_PASTE_CHARS) return; - if ( - hasUnresolvedApproval() || - chatState.resolvingApprovals.size > 0 || - agent.state.isStreaming || - composerState.processingFiles - ) - return; - e.preventDefault(); - const names = new Set(composerState.attachments.map((a) => a.fileName)); - let n = 1; - while (names.has(n === 1 ? "pasted-text.txt" : `pasted-text-${n}.txt`)) n++; - const bytes = new TextEncoder().encode(text); - const attachment: Attachment = { - id: `paste_${Date.now()}_${Math.random()}`, - type: "document", - fileName: n === 1 ? "pasted-text.txt" : `pasted-text-${n}.txt`, - mimeType: "text/plain", - size: bytes.length, - content: bytesToBase64(bytes), - extractedText: text, - }; - pastedTextIds.add(attachment.id); - composerState.attachments = [...composerState.attachments, attachment]; - drawActiveChat(agent); -} + } -async function onFilesSelected(e: Event, agent: Agent): Promise { - const input = e.currentTarget as HTMLInputElement; - const files = Array.from(input.files ?? []); - input.value = ""; - await addFiles(files, agent); -} + async function fileToBase64(file: File): Promise { + return bytesToBase64(new Uint8Array(await file.arrayBuffer())); + } -async function fileToBase64(file: File): Promise { - return bytesToBase64(new Uint8Array(await file.arrayBuffer())); -} + async function loadAnyAttachment(file: File): Promise { + try { + const { loadAttachment } = await import("@earendil-works/pi-web-ui"); + return await loadAttachment(file); + } catch { + return { + id: `${file.name}_${Date.now()}_${Math.random()}`, + type: "document", + fileName: file.name, + mimeType: file.type || "application/octet-stream", + size: file.size, + content: await fileToBase64(file), + }; + } + } -async function loadAnyAttachment(file: File): Promise { - try { - const { loadAttachment } = await import("@earendil-works/pi-web-ui"); - return await loadAttachment(file); - } catch { - return { - id: `${file.name}_${Date.now()}_${Math.random()}`, - type: "document", - fileName: file.name, - mimeType: file.type || "application/octet-stream", - size: file.size, - content: await fileToBase64(file), - }; + async function addFiles(files: File[], agent: Agent, folders: DropEntryLike[] = []): Promise { + if ( + (!files.length && !folders.length) || + ctx.chat.hasUnresolvedApproval() || + ctx.chat.state.resolvingApprovals.size > 0 || + agent.state.isStreaming + ) + return; + if (composerState.processingFiles) { + composerState.error = "Still preparing the previous drop — try again in a moment."; + ctx.chat.drawActiveChat(agent); + return; + } + composerState.processingFiles = true; + composerState.error = ""; + ctx.chat.drawActiveChat(agent); + try { + const zipped: File[] = []; + for (const folder of folders) zipped.push(await folderToZipFile(folder)); + const loaded = await Promise.all([...files, ...zipped].map((file) => loadAnyAttachment(file))); + composerState.attachments = [...composerState.attachments, ...loaded]; + } catch (err) { + if (err instanceof FolderDropError) composerState.error = err.message; + else if (isFolderReadError(err)) + composerState.error = + "That drop included a folder this browser can't read — zip it and drop the archive instead."; + else composerState.error = errMessage(err, "Could not attach that file."); + } finally { + composerState.processingFiles = false; + ctx.chat.drawActiveChat(agent); + } } -} -async function addFiles(files: File[], agent: Agent, folders: DropEntryLike[] = []): Promise { - if ( - (!files.length && !folders.length) || - hasUnresolvedApproval() || - chatState.resolvingApprovals.size > 0 || - agent.state.isStreaming - ) - return; - if (composerState.processingFiles) { - composerState.error = "Still preparing the previous drop — try again in a moment."; - drawActiveChat(agent); - return; - } - composerState.processingFiles = true; - composerState.error = ""; - drawActiveChat(agent); - try { - const zipped: File[] = []; - for (const folder of folders) zipped.push(await folderToZipFile(folder)); - const loaded = await Promise.all([...files, ...zipped].map((file) => loadAnyAttachment(file))); - composerState.attachments = [...composerState.attachments, ...loaded]; - } catch (err) { - if (err instanceof FolderDropError) composerState.error = err.message; - else if (isFolderReadError(err)) - composerState.error = - "That drop included a folder this browser can't read — zip it and drop the archive instead."; - else composerState.error = errMessage(err, "Could not attach that file."); - } finally { - composerState.processingFiles = false; - drawActiveChat(agent); + function dragHasFiles(e: DragEvent): boolean { + const types = e.dataTransfer?.types; + return types ? Array.from(types).includes("Files") : false; } -} -function dragHasFiles(e: DragEvent): boolean { - const types = e.dataTransfer?.types; - return types ? Array.from(types).includes("Files") : false; -} + function onDragEnter(e: DragEvent): void { + if (!dragHasFiles(e)) return; + e.preventDefault(); + dragDepth += 1; + if (!composerState.dragging) { + composerState.dragging = true; + ctx.chat.drawActiveChat(); + } + } -export function onDragEnter(e: DragEvent): void { - if (!dragHasFiles(e)) return; - e.preventDefault(); - dragDepth += 1; - if (!composerState.dragging) { - composerState.dragging = true; - drawActiveChat(); + function onDragOver(e: DragEvent): void { + if (!dragHasFiles(e)) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; } -} -export function onDragOver(e: DragEvent): void { - if (!dragHasFiles(e)) return; - e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; -} + function onDragLeave(e: DragEvent): void { + if (!dragHasFiles(e)) return; + e.preventDefault(); + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0 && composerState.dragging) { + composerState.dragging = false; + ctx.chat.drawActiveChat(); + } + } -export function onDragLeave(e: DragEvent): void { - if (!dragHasFiles(e)) return; - e.preventDefault(); - dragDepth = Math.max(0, dragDepth - 1); - if (dragDepth === 0 && composerState.dragging) { + async function onDrop(e: DragEvent, agent: Agent): Promise { + if (!dragHasFiles(e)) return; + e.preventDefault(); + dragDepth = 0; composerState.dragging = false; - drawActiveChat(); + const { files, folders } = splitDropItems(Array.from(e.dataTransfer?.items ?? [])); + if (!files.length && !folders.length) files.push(...Array.from(e.dataTransfer?.files ?? [])); + ctx.chat.drawActiveChat(agent); + await addFiles(files, agent, folders); } -} - -export async function onDrop(e: DragEvent, agent: Agent): Promise { - if (!dragHasFiles(e)) return; - e.preventDefault(); - dragDepth = 0; - composerState.dragging = false; - const { files, folders } = splitDropItems(Array.from(e.dataTransfer?.items ?? [])); - if (!files.length && !folders.length) files.push(...Array.from(e.dataTransfer?.files ?? [])); - drawActiveChat(agent); - await addFiles(files, agent, folders); -} -function pickFiles(): void { - if (hasUnresolvedApproval() || chatState.resolvingApprovals.size > 0 || chatState.agent?.state.isStreaming) return; - chatState.host?.querySelector(".file-input")?.click(); -} + function pickFiles(): void { + if ( + ctx.chat.hasUnresolvedApproval() || + ctx.chat.state.resolvingApprovals.size > 0 || + ctx.chat.state.agent?.state.isStreaming + ) + return; + ctx.chat.state.host?.querySelector(".file-input")?.click(); + } -function removeAttachment(id: string, agent: Agent): void { - composerState.attachments = composerState.attachments.filter((a) => a.id !== id); - pastedTextIds.delete(id); - if (composerState.pasteView?.id === id) composerState.pasteView = null; - drawActiveChat(agent); -} + function removeAttachment(id: string, agent: Agent): void { + composerState.attachments = composerState.attachments.filter((a) => a.id !== id); + pastedTextIds.delete(id); + if (composerState.pasteView?.id === id) composerState.pasteView = null; + ctx.chat.drawActiveChat(agent); + } -function selectModel(value: string, agent: Agent): void { - const option = getModelOptions().find((candidate) => candidate.value === value); - if (!option) return; - const previousDefaultEffort = defaultEffortForModel(currentModelOption().model); - if (chatState.threadRef) rememberThreadPick(chatState.threadRef, option.value); - agent.state.model = option.model; - if (composerState.effortLevel === previousDefaultEffort) { - composerState.effortLevel = defaultEffortForModel(option.model); - persistPreference(EFFORT_STORAGE_KEY, composerState.effortLevel); - } - if (composerState.openMenu !== "settings") composerState.openMenu = null; - drawActiveChat(agent); -} + function selectModel(value: string, agent: Agent): void { + const option = getModelOptions(scopeKey()).find((candidate) => candidate.value === value); + if (!option) return; + const previousDefaultEffort = defaultEffortForModel(currentModelOption().model); + if (ctx.chat.state.threadRef) rememberThreadPick(ctx.chat.state.threadRef, option.value); + agent.state.model = option.model; + if (composerState.effortLevel === previousDefaultEffort) { + composerState.effortLevel = defaultEffortForModel(option.model); + persistPreference(EFFORT_STORAGE_KEY, composerState.effortLevel); + } + if (composerState.openMenu !== "settings") composerState.openMenu = null; + ctx.chat.drawActiveChat(agent); + } -function selectHarness(harnessId: string, agent: Agent): void { - const current = currentModelOption(); - const options = getModelOptionsForHarness(harnessId); - const option = options.find((candidate) => candidate.model.id === current.model.id) ?? options[0]; - if (option) selectModel(option.value, agent); -} + function selectHarness(harnessId: string, agent: Agent): void { + const current = currentModelOption(); + const options = getModelOptionsForHarness(harnessId, scopeKey()); + const option = options.find((candidate) => candidate.model.id === current.model.id) ?? options[0]; + if (option) selectModel(option.value, agent); + } -function selectEffort(level: EffortLevel, agent: Agent): void { - composerState.effortLevel = level; - persistPreference(EFFORT_STORAGE_KEY, level); - if (composerState.openMenu !== "settings") composerState.openMenu = null; - drawActiveChat(agent); -} + function selectEffort(level: EffortLevel, agent: Agent): void { + composerState.effortLevel = level; + persistPreference(EFFORT_STORAGE_KEY, level); + if (composerState.openMenu !== "settings") composerState.openMenu = null; + ctx.chat.drawActiveChat(agent); + } -function toggleFastMode(agent: Agent): void { - if (hasUnresolvedApproval() || chatState.resolvingApprovals.size > 0) return; - if (!modelSupportsFastMode(currentModelOption().model.id)) return; - composerState.fastMode = !effectiveFastMode(); - persistPreference(FAST_MODE_STORAGE_KEY, composerState.fastMode ? "1" : "0"); - if (fastModeChargeTimer) { - clearTimeout(fastModeChargeTimer); - fastModeChargeTimer = null; - } - fastModeCharging = composerState.fastMode === true; - drawActiveChat(agent); - if (fastModeCharging) { - fastModeChargeTimer = setTimeout(() => { - fastModeCharging = false; + function toggleFastMode(agent: Agent): void { + if (ctx.chat.hasUnresolvedApproval() || ctx.chat.state.resolvingApprovals.size > 0) return; + if (!modelSupportsFastMode(scopeKey(), currentModelOption().model.id)) return; + composerState.fastMode = !effectiveFastMode(); + persistPreference(FAST_MODE_STORAGE_KEY, composerState.fastMode ? "1" : "0"); + if (fastModeChargeTimer) { + clearTimeout(fastModeChargeTimer); fastModeChargeTimer = null; - if (agent === chatState.agent) drawActiveChat(agent); - }, 760); + } + fastModeCharging = composerState.fastMode === true; + ctx.chat.drawActiveChat(agent); + if (fastModeCharging) { + fastModeChargeTimer = setTimeout(() => { + fastModeCharging = false; + fastModeChargeTimer = null; + if (agent === ctx.chat.state.agent) ctx.chat.drawActiveChat(agent); + }, 760); + } } -} - -let autosizedTa: HTMLTextAreaElement | null = null; -let autosizedValue: string | null = null; -let autosizeObserver: ResizeObserver | null = null; -export function resizeComposer(): void { - requestAnimationFrame(() => { - const ta = chatState.host?.querySelector(".composer-input"); - if (!ta) return; - if (autosizedTa !== ta && typeof ResizeObserver !== "undefined") { - autosizeObserver ??= new ResizeObserver(() => { + let autosizedTa: HTMLTextAreaElement | null = null; + let autosizedValue: string | null = null; + let autosizeObserver: ResizeObserver | null = null; + + function resizeComposer(): void { + requestAnimationFrame(() => { + const ta = ctx.chat.state.host?.querySelector(".composer-input"); + if (!ta) return; + if (autosizedTa !== ta && typeof ResizeObserver !== "undefined") { + autosizeObserver ??= new ResizeObserver(() => { + autosizedValue = null; + resizeComposer(); + }); + if (autosizedTa) autosizeObserver.unobserve(autosizedTa); + autosizeObserver.observe(ta); + autosizedTa = ta; autosizedValue = null; - resizeComposer(); - }); - if (autosizedTa) autosizeObserver.unobserve(autosizedTa); - autosizeObserver.observe(ta); - autosizedTa = ta; - autosizedValue = null; + } + if (ta.value === autosizedValue) return; + autosizedValue = ta.value; + ta.style.height = "auto"; + const cap = parseFloat(getComputedStyle(ta).maxHeight) || 180; + const content = ta.scrollHeight; + ta.style.height = `${Math.min(cap, Math.max(ctx.pane ? 0 : 48, content))}px`; + if (content > cap) { + ta.style.overflowY = "auto"; + } else { + ta.style.overflowY = "hidden"; + ta.scrollTop = 0; + } + }); + } + + function closeMenus(): boolean { + let changed = false; + if (composerState.openMenu) { + composerState.openMenu = null; + changed = true; } - if (ta.value === autosizedValue) return; - autosizedValue = ta.value; - ta.style.height = "auto"; - const cap = parseFloat(getComputedStyle(ta).maxHeight) || 180; - const content = ta.scrollHeight; - ta.style.height = `${Math.min(cap, Math.max(embedMode ? 0 : 48, content))}px`; - if (content > cap) { - ta.style.overflowY = "auto"; - } else { - ta.style.overflowY = "hidden"; - ta.scrollTop = 0; + if (!composerState.slashDismissed && slashQuery(composerState.draft) !== null) { + composerState.slashDismissed = true; + changed = true; } - }); + return changed; + } + + function dispose(): void { + autosizeObserver?.disconnect(); + autosizeObserver = null; + autosizedTa = null; + if (fastModeChargeTimer !== null) clearTimeout(fastModeChargeTimer); + } + + return { + state: composerState, + composerForm, + resetComposer, + focusComposerEnd, + resizeComposer, + currentModelOption, + carryModelPick, + refreshRuntimeSelection, + onDragEnter, + onDragOver, + onDragLeave, + onDrop, + closeMenus, + dispose, + }; } diff --git a/plugins/web-ui/src/contexts.ts b/plugins/web-ui/src/contexts.ts index 5790af3..8471721 100644 --- a/plugins/web-ui/src/contexts.ts +++ b/plugins/web-ui/src/contexts.ts @@ -31,7 +31,7 @@ import { UI_BASE } from "./deep-link"; import { errMessage } from "../../chassis/src/errors"; import { actionSnippet, closeFormMenus, formatBytes, icon, initials, relTime, toggleFormMenu } from "./ui"; import { appState, renderSidebarTop, replacePanePreservingFocus, switchView, syncUrlFromState } from "./shell"; -import { newChat } from "./chat"; +import { mainConversation } from "./conversations"; import { groupDmTitle, openSession, refreshSessions, sessionsState, slackLogo, surfaceOf } from "./sessions"; import { activityOf } from "./session-list"; import type { CronView } from "./crons"; @@ -1204,7 +1204,7 @@ function selectContext(scopeId: string | null): void { } function startChatIn(c: CoreContext): void { - newChat(c.kind === "personal" ? undefined : { scopeId: c.scopeId, name: c.name }); + mainConversation().newChat(c.kind === "personal" ? undefined : { scopeId: c.scopeId, name: c.name }); } async function openFromContext(s: CoreSession): Promise { diff --git a/plugins/web-ui/src/conv-types.ts b/plugins/web-ui/src/conv-types.ts new file mode 100644 index 0000000..f767c2d --- /dev/null +++ b/plugins/web-ui/src/conv-types.ts @@ -0,0 +1,119 @@ +import type { Agent } from "@earendil-works/pi-agent-core"; +import type { TemplateResult } from "lit"; +import type { DensityTier } from "./density"; +import type { Attachment } from "@earendil-works/pi-web-ui"; +import type { ApprovalDecision, CoreSession, PendingApproval, entriesToMessages } from "./core-bridge"; +import type { EffortLevel, ModelOption } from "./model-options"; +import type { ComposerMenu } from "./composer"; + +interface PaneState { + threadRef: string | null; + sessionId: string | null; + working: boolean; +} + +export interface ConvHost { + pane: boolean; + ownsUrl: boolean; + container(): HTMLElement | null; + claimContainer(): HTMLElement | null; + visible(): boolean; + density(): DensityTier; + onDensityChange(handler: () => void): void; + ensureDeliveryStream(): void; + onState?(state: PaneState): void; + onExpand?(): void; +} + +export interface ConvCtx extends ConvHost { + chat: ChatSurface; + composer: ComposerSurface; +} + +interface ChatState { + agent: Agent | null; + host: HTMLElement | null; + threadRef: string | null; + sessionId: string | null; + scopeId: string | null; + contextName: string | null; + rememberedThreadRef: string | null; + rememberedSessionId: string | null; + rememberedScopeId: string | null; + rememberedContextName: string | null; + pendingSend: string | null; + resolvingApprovals: Set; + transcriptAnchorSeq: number | null; + earlierCount: number; + loadingEarlier: boolean; +} + +export interface ChatSurface { + state: ChatState; + hasLiveRun(): boolean; + signalLiveRun(kind: "abort" | "steer", text?: string): Promise; + newChat(context?: { scopeId: string; name: string | null }): string; + teardown(): void; + resetChatState(): void; + mountContinuable( + threadRef: string, + sessionId: string | null, + scopeId: string | null, + messages: ReturnType, + contextName?: string | null, + ): void; + mountReadOnly( + s: CoreSession, + messages: ReturnType, + earlierCount?: number, + anchorSeq?: number | null, + ): void; + mountLoadingPane(): void; + drawActiveChat(agent?: Agent | null, opts?: { forceScroll?: boolean }): void; + setTranscriptWindow(anchorSeq: number | null, earlierCount: number): void; + requestBackgroundPanel(sessionId: string | null, threadRef: string | null): void; + activePendingApprovals(): PendingApproval[]; + hasUnresolvedApproval(): boolean; + resolveCommandApproval(decision: ApprovalDecision): void; + approvalSummaryView(a: PendingApproval, expanded?: boolean): TemplateResult; + notePendingSessionOnSend(): void; + syncPaneState(): void; + onDelivery(threadRef: string): void; + resumeIfIdle(): void; + redraw(): void; + dispose(): void; +} + +interface ComposerState { + draft: string; + attachments: Attachment[]; + error: string; + processingFiles: boolean; + dragging: boolean; + openMenu: ComposerMenu | null; + slashDismissed: boolean; + effortLevel: EffortLevel; + fastMode: boolean | undefined; + pasteView: { id: string; text: string; initial: string; dirty: boolean } | null; +} + +export interface ComposerSurface { + state: ComposerState; + composerForm(agent: Agent): TemplateResult; + resetComposer(): void; + focusComposerEnd(): void; + resizeComposer(): void; + currentModelOption(): ModelOption; + carryModelPick(fromThreadRef: string | null, toThreadRef: string): void; + refreshRuntimeSelection(scopeId: string | null, agent?: Agent): Promise; + onDragEnter(e: DragEvent): void; + onDragOver(e: DragEvent): void; + onDragLeave(e: DragEvent): void; + onDrop(e: DragEvent, agent: Agent): Promise; + closeMenus(): boolean; + dispose(): void; +} + +export interface Conversation extends ChatSurface { + composer: ComposerSurface; +} diff --git a/plugins/web-ui/src/conversations.ts b/plugins/web-ui/src/conversations.ts new file mode 100644 index 0000000..120a2a0 --- /dev/null +++ b/plugins/web-ui/src/conversations.ts @@ -0,0 +1,87 @@ +import { createChatSurface } from "./chat"; +import { createComposerSurface } from "./composer"; +import { densityTierFor, type DensityTier } from "./density"; +import { subscribeDeliveries } from "./core-bridge"; +import { applySessionState } from "./session-list"; +import { refreshSessions, renderList, sessionsState } from "./sessions"; +import { appState } from "./shell-state"; +import type { Conversation, ConvCtx, ConvHost } from "./conv-types"; + +const live = new Set(); +let main: Conversation | null = null; + +export function createConversation(host: ConvHost): Conversation { + const ctx = { ...host } as ConvCtx; + ctx.chat = createChatSurface(ctx); + ctx.composer = createComposerSurface(ctx); + const conv = ctx.chat as Conversation; + conv.composer = ctx.composer; + live.add(conv); + return conv; +} + +export function disposeConversation(conv: Conversation): void { + live.delete(conv); + if (main === conv) main = null; + conv.composer.dispose(); + conv.dispose(); +} + +export function allConversations(): Conversation[] { + return [...live]; +} + +export function mainConversation(): Conversation { + main ??= createConversation({ + pane: false, + ownsUrl: true, + container: () => appState.mainEl, + claimContainer: () => { + exitCanvas(); + return appState.mainEl; + }, + visible: () => appState.currentView === "chats", + density: () => "full" as DensityTier, + onDensityChange: () => {}, + ensureDeliveryStream, + }); + return main; +} + +export function paneDensity(el: HTMLElement): DensityTier { + const r = el.getBoundingClientRect(); + return densityTierFor(r.width || el.clientWidth, r.height || el.clientHeight); +} + +let exitCanvas: () => void = () => {}; + +export function onExitCanvas(fn: () => void): void { + exitCanvas = fn; +} + +let deliveryStreamOpen = false; + +export function ensureDeliveryStream(): void { + if (deliveryStreamOpen) return; + deliveryStreamOpen = true; + subscribeDeliveries( + (threadRef) => { + void refreshSessions({ silent: true }); + for (const conv of live) conv.onDelivery(threadRef); + }, + (event) => { + const { list, matched } = applySessionState(sessionsState.list, event); + if (matched) { + sessionsState.list = list; + renderList(); + } else { + void refreshSessions({ silent: true }); + } + }, + () => void refreshSessions({ silent: true }), + ); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState !== "visible") return; + for (const conv of live) conv.resumeIfIdle(); + }); +} diff --git a/plugins/web-ui/src/core-bridge.ts b/plugins/web-ui/src/core-bridge.ts index d06e0e7..02acec0 100644 --- a/plugins/web-ui/src/core-bridge.ts +++ b/plugins/web-ui/src/core-bridge.ts @@ -189,6 +189,13 @@ export async function fetchTranscript( return api(`/api/sessions/${encodeURIComponent(id)}${suffix}`); } +export async function fetchEntry(sessionId: string, seq: number): Promise { + const r = await api<{ entry: SessionEntry }>( + `/api/sessions/${encodeURIComponent(sessionId)}/entries/${encodeURIComponent(String(seq))}`, + ); + return r.entry; +} + export async function regenerateTitle(id: string): Promise<{ title: string | null }> { return api<{ title: string | null }>(`/api/sessions/${encodeURIComponent(id)}/title`, { method: "POST" }); } @@ -209,6 +216,7 @@ export interface SessionEntry { createdAt: number; seq?: number; parentSeq?: number | null; + truncated?: boolean; } export interface ToolActivity { @@ -217,6 +225,7 @@ export interface ToolActivity { type: "tool_call" | "tool_result" | "approval_request" | "approval_resolved" | "thinking" | "text"; payload: unknown; createdAt: number; + truncated?: boolean; } type WorkStatus = "thinking" | "working" | "complete" | "failed"; export interface WorkBlock { @@ -431,14 +440,20 @@ export async function updateRuntimeConfig( export type WorkObserver = (work: WorkBlock) => void; -let liveRun: { runId: string } | null = null; +export interface RunSlot { + runId: string | null; +} + +export function createRunSlot(): RunSlot { + return { runId: null }; +} -export function hasLiveRun(): boolean { - return liveRun !== null; +export function hasLiveRun(slot: RunSlot): boolean { + return slot.runId !== null; } -export async function signalLiveRun(kind: "abort" | "steer", text?: string): Promise { - const run = liveRun; +export async function signalLiveRun(slot: RunSlot, kind: "abort" | "steer", text?: string): Promise { + const run = slot.runId !== null ? { runId: slot.runId } : null; if (!run) throw new Error("No active run to signal."); await api(runPath(run.runId, "/signal"), { method: "POST", @@ -451,6 +466,7 @@ export function makeCoreStreamFn( agent: Agent, getTurnOptions?: () => TurnOptions, onWork?: WorkObserver, + slot?: RunSlot, ): StreamFn { const fn = ( model: Model, @@ -458,7 +474,7 @@ export function makeCoreStreamFn( options?: { signal?: AbortSignal }, ): AssistantMessageEventStream => { const stream = createAssistantMessageEventStream(); - void drive(stream, model, threadRef, agent, getTurnOptions, options?.signal, onWork); + void drive(stream, model, threadRef, agent, getTurnOptions, options?.signal, onWork, undefined, false, slot); return stream; }; return fn as unknown as StreamFn; @@ -470,14 +486,19 @@ export async function activeRunForThread(threadRef: string): Promise, _context: Context, options?: { signal?: AbortSignal }, ): AssistantMessageEventStream => { const stream = createAssistantMessageEventStream(); - void resumeDrive(stream, model, runId, initialRun, options?.signal, onWork); + void resumeDrive(stream, model, runId, initialRun, options?.signal, onWork, slot); return stream; }; return fn as unknown as StreamFn; @@ -490,9 +511,10 @@ export async function runApprovalTurn( getTurnOptions: (() => TurnOptions) | undefined, onWork: WorkObserver | undefined, signal?: AbortSignal, + slot?: RunSlot, ): Promise { const stream = createAssistantMessageEventStream(); - await drive(stream, agent.state.model, threadRef, agent, getTurnOptions, signal, onWork, decision); + await drive(stream, agent.state.model, threadRef, agent, getTurnOptions, signal, onWork, decision, false, slot); const outcome = await stream.result(); if (outcome.stopReason === "error") throw new Error(outcome.errorMessage || "Could not send the approval."); } @@ -502,6 +524,7 @@ export function makeOpenerStreamFn( agent: Agent, getTurnOptions: (() => TurnOptions) | undefined, onWork: WorkObserver | undefined, + slot?: RunSlot, ): StreamFn { const fn = ( model: Model, @@ -509,7 +532,7 @@ export function makeOpenerStreamFn( options?: { signal?: AbortSignal }, ): AssistantMessageEventStream => { const stream = createAssistantMessageEventStream(); - void drive(stream, model, threadRef, agent, getTurnOptions, options?.signal, onWork, undefined, true); + void drive(stream, model, threadRef, agent, getTurnOptions, options?.signal, onWork, undefined, true, slot); return stream; }; return fn as unknown as StreamFn; @@ -525,6 +548,7 @@ async function drive( onWork?: WorkObserver, approval?: ApprovalDecision, opener?: boolean, + slot?: RunSlot, ): Promise { const partial = baseAssistant(model); const work: WorkBlock = { status: "thinking", activity: [] }; @@ -564,7 +588,7 @@ async function drive( }); if (submit.runId) { - await followRun(stream, partial, submit.runId, signal, notify); + await followRun(stream, partial, submit.runId, signal, notify, undefined, slot); return; } @@ -587,6 +611,7 @@ async function resumeDrive( initialRun?: RunPoll, signal?: AbortSignal, onWork?: WorkObserver, + slot?: RunSlot, ): Promise { const partial = baseAssistant(model); const work: WorkBlock = { status: "thinking", activity: [] }; @@ -598,7 +623,7 @@ async function resumeDrive( stream.push({ type: "text_start", contentIndex: 0, partial }); const st: Acc = { acc: "", lastProgressAt: now() }; if (initialRun && applyRun(stream, partial, st, initialRun, notify) === "terminal") return; - await followRun(stream, partial, runId, signal, notify, st); + await followRun(stream, partial, runId, signal, notify, st, slot); } catch (e) { work.status = "failed"; work.finishedAt = Date.now(); @@ -614,8 +639,9 @@ async function followRun( signal?: AbortSignal, notify?: () => void, st: Acc = { acc: "", lastProgressAt: now() }, + slot?: RunSlot, ): Promise { - liveRun = { runId }; + if (slot) slot.runId = runId; try { if (signal?.aborted) return abortStream(stream, partial); const viaSse = await streamRunViaSse(stream, partial, runId, st, signal, notify); @@ -623,7 +649,7 @@ async function followRun( if (signal?.aborted) return abortStream(stream, partial); return await pollRun(stream, partial, runId, st, signal, notify); } finally { - if (liveRun?.runId === runId) liveRun = null; + if (slot?.runId === runId) slot.runId = null; } } @@ -1110,6 +1136,7 @@ export function entriesToMessages(entries: SessionEntry[], model: Model): A type: e.type as ToolActivity["type"], payload: e.payload, createdAt: e.createdAt, + ...(e.truncated ? { truncated: true } : {}), }; if (e.type === "tool_call") { const postText = postCallText(e.payload); diff --git a/plugins/web-ui/src/crons.ts b/plugins/web-ui/src/crons.ts index 688e927..e9fc8d2 100644 --- a/plugins/web-ui/src/crons.ts +++ b/plugins/web-ui/src/crons.ts @@ -6,7 +6,7 @@ import { icon } from "./ui"; import { listBackLink, listPageTpl } from "./list-page"; import { ensureContexts, scopeChip } from "./contexts"; import { appState } from "./shell"; -import { chatState, newChat } from "./chat"; +import { mainConversation } from "./conversations"; import { deepLinkPath, UI_BASE } from "./deep-link"; import { cronNextFire, @@ -744,8 +744,9 @@ async function saveCronEdit(event: SubmitEvent, c: CronView): Promise { function editCronWithAgent(c: CronView): void { cronDialog = null; - newChat(); - void chatState.agent?.prompt( + const conv = mainConversation(); + conv.newChat(); + void conv.state.agent?.prompt( `Help me edit cron ${c.id} ("${cronTitle(c)}"). Its current schedule is ${cronScheduleSummary(c)}. Ask what I want changed, then update its task, schedule, timezone, destination, or run mode as requested.`, ); } @@ -831,8 +832,9 @@ function onCreateCron(e: Event): void { if (errSlot) errSlot.textContent = "Describe the cron you want."; return; } - newChat(); - void chatState.agent?.prompt( + const conv = mainConversation(); + conv.newChat(); + void conv.state.agent?.prompt( `Set up a cron for me: ${text}\n\n(Sent from the web UI's New-cron pane — create it now with your scheduling API, use a calendar schedule with timezone for daily/weekly/monthly timing, give it a 2-5 word title naming what the cron is for and distinctive in a list, like "Gmail unread digest" or "GitLab CI watch" — not the command and not a generic word, and confirm what you created.)`, ); } diff --git a/plugins/web-ui/src/deploys.ts b/plugins/web-ui/src/deploys.ts index 32cff53..2ffa004 100644 --- a/plugins/web-ui/src/deploys.ts +++ b/plugins/web-ui/src/deploys.ts @@ -7,8 +7,7 @@ import { copyText, icon, relTime } from "./ui"; import { listBackLink, listPageTpl } from "./list-page"; import { contextsState, ensureContexts, scopeChip } from "./contexts"; import { appState } from "./shell"; -import { chatState, drawActiveChat, newChat } from "./chat"; -import { composerState, focusComposerEnd } from "./composer"; +import { mainConversation } from "./conversations"; import { focusDialogCancel, restoreDialogFocus, trapDialogFocus } from "./dialog-focus"; import { withDeploymentDetailNotice, @@ -844,10 +843,11 @@ async function openLiveEdit(d: DeploymentView, button: HTMLButtonElement): Promi } function deployWithAgent(): void { - newChat(); - composerState.draft = "Deploy an app for me. "; - drawActiveChat(chatState.agent); - focusComposerEnd(); + const conv = mainConversation(); + conv.newChat(); + conv.composer.state.draft = "Deploy an app for me. "; + conv.drawActiveChat(conv.state.agent); + conv.composer.focusComposerEnd(); } async function refreshDeployments(): Promise<"updated" | "failed" | "superseded"> { diff --git a/plugins/web-ui/src/embed.ts b/plugins/web-ui/src/embed.ts deleted file mode 100644 index 5833e71..0000000 --- a/plugins/web-ui/src/embed.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { densityTierFor, type DensityTier } from "./density"; - -export const PANE_STATE_MSG = "webui:pane-state"; -export const PANE_DELIVERY_MSG = "webui:pane-delivery"; -export const PANE_FOCUS_MSG = "webui:pane-focus"; -export const PANE_EXPAND_MSG = "webui:pane-expand"; -export const PANE_COLLAPSE_MSG = "webui:pane-collapse"; - -export interface PaneStateMsg { - type: typeof PANE_STATE_MSG; - threadRef: string | null; - sessionId: string | null; - working: boolean; -} - -export const embedMode: boolean = (() => { - try { - return new URLSearchParams(location.search).get("embed") === "1" && window.parent !== window; - } catch { - return false; - } -})(); - -let densityTier: DensityTier = embedMode ? densityTierFor(window.innerWidth, window.innerHeight) : "full"; -const densityHandlers: Array<() => void> = []; - -export function currentDensity(): DensityTier { - return densityTier; -} - -export function onDensityChange(handler: () => void): void { - densityHandlers.push(handler); -} - -if (embedMode) { - document.documentElement.dataset.density = densityTier; - window.addEventListener("resize", () => { - const next = densityTierFor(window.innerWidth, window.innerHeight); - if (next === densityTier) return; - densityTier = next; - document.documentElement.dataset.density = next; - for (const handler of densityHandlers) handler(); - }); -} - -let lastPosted = ""; - -export function postPaneState(state: Omit): void { - if (!embedMode) return; - const key = `${state.threadRef}|${state.sessionId}|${state.working}`; - if (key === lastPosted) return; - lastPosted = key; - try { - window.parent.postMessage({ type: PANE_STATE_MSG, ...state } satisfies PaneStateMsg, location.origin); - } catch { - void 0; - } -} - -export function requestPaneExpand(): void { - if (!embedMode) return; - try { - window.parent.postMessage({ type: PANE_EXPAND_MSG }, location.origin); - } catch { - void 0; - } -} - -if (embedMode) { - window.addEventListener("focus", () => { - try { - window.parent.postMessage({ type: PANE_FOCUS_MSG }, location.origin); - } catch { - void 0; - } - }); - window.addEventListener( - "keydown", - (e) => { - if (e.key !== "Escape" || e.defaultPrevented) return; - if (document.querySelector('[role="dialog"], .menu-popover, .slash-popover, .session-menu-popover')) return; - try { - window.parent.postMessage({ type: PANE_COLLAPSE_MSG }, location.origin); - } catch { - void 0; - } - }, - true, - ); -} - -export function onRelayedDelivery(handler: (threadRef: string) => void): void { - if (!embedMode) return; - window.addEventListener("message", (e: MessageEvent) => { - if (e.origin !== location.origin || e.source !== window.parent) return; - const d = e.data as { type?: string; threadRef?: string }; - if (d?.type === PANE_DELIVERY_MSG && typeof d.threadRef === "string" && d.threadRef) handler(d.threadRef); - }); -} diff --git a/plugins/web-ui/src/main.ts b/plugins/web-ui/src/main.ts index 0aa64e6..e5f0e3d 100644 --- a/plugins/web-ui/src/main.ts +++ b/plugins/web-ui/src/main.ts @@ -2,46 +2,39 @@ import "dockview-core/dist/styles/dockview.css"; import "./shell.css"; import { bootSafely } from "./shell"; import { closeFormMenus } from "./ui"; -import { drawActiveChat } from "./chat"; -import { composerState, slashQuery } from "./composer"; +import { allConversations } from "./conversations"; import { closeOpenSessionMenu, renderList, sessionsState } from "./sessions"; import { closeDeployMenu } from "./deploys"; +function closeComposerMenus(keepOpenWithin: Element | null): boolean { + let changed = false; + for (const conv of allConversations()) { + if (keepOpenWithin && conv.state.host?.contains(keepOpenWithin)) continue; + if (!conv.composer.closeMenus()) continue; + changed = true; + conv.redraw(); + } + return changed; +} + document.addEventListener("click", (e) => { const target = e.target as Element | null; - let redrawChat = false; - if (composerState.openMenu && !target?.closest(".menu-control")) { - composerState.openMenu = null; - redrawChat = true; - } - if (!composerState.slashDismissed && slashQuery(composerState.draft) !== null && !target?.closest(".composer-wrap")) { - composerState.slashDismissed = true; - redrawChat = true; - } + const inside = target?.closest(".menu-control, .composer-wrap") ?? null; + closeComposerMenus(inside); if (!target?.closest(".form-menu-control")) closeFormMenus(); if (sessionsState.openMenuId && !target?.closest(".session-menu")) { sessionsState.openMenuId = null; renderList(); } closeDeployMenu(target); - if (redrawChat) drawActiveChat(); }); document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; - let changed = false; - if (composerState.openMenu) { - composerState.openMenu = null; - changed = true; - } - if (!composerState.slashDismissed && slashQuery(composerState.draft) !== null) { - composerState.slashDismissed = true; - changed = true; - } + closeComposerMenus(null); closeOpenSessionMenu(); - changed = closeDeployMenu(null, true) || changed; - changed = closeFormMenus() || changed; - if (changed) drawActiveChat(); + closeDeployMenu(null, true); + closeFormMenus(); }); void bootSafely(); diff --git a/plugins/web-ui/src/model-options.ts b/plugins/web-ui/src/model-options.ts index 3a6f4a4..2dd5361 100644 --- a/plugins/web-ui/src/model-options.ts +++ b/plugins/web-ui/src/model-options.ts @@ -115,50 +115,69 @@ function buildOptions( return harnessId === "pi" ? buildOptions(DEFAULT_PICKER_MODEL_IDS, "pi", qualified, catalog) : []; } -let activeModelOptions: ModelOption[] = buildOptions(DEFAULT_PICKER_MODEL_IDS); -let defaultRuntimeValue: string | null = null; +interface RuntimeOptions { + options: ModelOption[]; + defaultValue: string | null; +} + +const FALLBACK: RuntimeOptions = { options: buildOptions(DEFAULT_PICKER_MODEL_IDS), defaultValue: null }; +const byScope = new Map(); +let lastApplied: RuntimeOptions = FALLBACK; + +function runtimeFor(scopeKey?: string | null): RuntimeOptions { + if (scopeKey === undefined) return lastApplied; + return (scopeKey !== null ? byScope.get(scopeKey) : undefined) ?? lastApplied; +} -export function getModelOptions(): ModelOption[] { - return activeModelOptions; +export function getModelOptions(scopeKey?: string | null): ModelOption[] { + return runtimeFor(scopeKey).options; } -export function getHarnessOptions(): Array<{ value: string; label: string }> { - return [...new Map(activeModelOptions.map((option) => [option.harnessId, option.harnessLabel])).entries()].map( +export function getHarnessOptions(scopeKey?: string | null): Array<{ value: string; label: string }> { + const options = runtimeFor(scopeKey).options; + return [...new Map(options.map((option) => [option.harnessId, option.harnessLabel])).entries()].map( ([value, label]) => ({ value, label }), ); } -export function getModelOptionsForHarness(harnessId: string): ModelOption[] { - return activeModelOptions.filter((option) => option.harnessId === harnessId); +export function getModelOptionsForHarness(harnessId: string, scopeKey?: string | null): ModelOption[] { + return runtimeFor(scopeKey).options.filter((option) => option.harnessId === harnessId); } export function applyPickerModelIds(ids: readonly string[] | null | undefined, baseModelId?: string | null): void { - activeModelOptions = buildOptions(ids && ids.length ? ids : DEFAULT_PICKER_MODEL_IDS); - defaultRuntimeValue = baseModelId ?? null; + lastApplied = { + options: buildOptions(ids && ids.length ? ids : DEFAULT_PICKER_MODEL_IDS), + defaultValue: baseModelId ?? null, + }; } export function applyRuntimeOptions( + scopeKey: string | null, approvedHarnesses: readonly string[], modelsByHarness: Readonly>, effective: { harnessId: string; modelId: string }, catalog: Readonly> = {}, ): void { - activeModelOptions = approvedHarnesses.flatMap((harnessId) => { + let options = approvedHarnesses.flatMap((harnessId) => { const configured = buildOptions(modelsByHarness[harnessId] ?? [], harnessId, true, catalog); return configured.length ? configured : buildOptions(defaultModelIdsForHarness(harnessId), harnessId, true, catalog); }); - if (!activeModelOptions.length) activeModelOptions = buildOptions(DEFAULT_PICKER_MODEL_IDS); - defaultRuntimeValue = `${effective.harnessId}:${effective.modelId}`; + if (!options.length) options = buildOptions(DEFAULT_PICKER_MODEL_IDS); + const applied = { options, defaultValue: `${effective.harnessId}:${effective.modelId}` }; + lastApplied = applied; + if (scopeKey !== null) byScope.set(scopeKey, applied); } -export function defaultModelValue(): ModelOptionValue { - return activeModelOptions.find((o) => o.value === defaultRuntimeValue)?.value ?? activeModelOptions[0].value; +export function defaultModelValue(scopeKey?: string | null): ModelOptionValue { + const { options, defaultValue } = runtimeFor(scopeKey); + return options.find((o) => o.value === defaultValue)?.value ?? options[0]!.value; } -export function transcriptModel(): Model { - return (activeModelOptions.find((o) => o.value === defaultRuntimeValue) ?? activeModelOptions[0]).model; +export function transcriptModel(scopeKey?: string | null): Model { + const { options, defaultValue } = runtimeFor(scopeKey); + return (options.find((o) => o.value === defaultValue) ?? options[0]!).model; } export type EffortLevel = "low" | "medium" | "high" | "xhigh" | "max" | "ultracode" | "auto"; diff --git a/plugins/web-ui/src/pi-models.ts b/plugins/web-ui/src/pi-models.ts index 3859344..ca2128a 100644 --- a/plugins/web-ui/src/pi-models.ts +++ b/plugins/web-ui/src/pi-models.ts @@ -41,12 +41,15 @@ function cloneModel(model: PiModel, id: string, name: string): PiModel { return { ...structuredClone(model), id, name }; } -let fastModeModelIds = new Set(); +const fastModeByScope = new Map>(); +let lastFastModeIds = new Set(); -export function setFastModeModelIds(ids: readonly string[] | undefined): void { - fastModeModelIds = new Set(ids ?? []); +export function setFastModeModelIds(scopeKey: string | null, ids: readonly string[] | undefined): void { + lastFastModeIds = new Set(ids ?? []); + if (scopeKey !== null) fastModeByScope.set(scopeKey, lastFastModeIds); } -export function modelSupportsFastMode(modelId: string | undefined): boolean { - return !!modelId && fastModeModelIds.has(modelId); +export function modelSupportsFastMode(scopeKey: string | null, modelId: string | undefined): boolean { + const ids = (scopeKey !== null ? fastModeByScope.get(scopeKey) : undefined) ?? lastFastModeIds; + return !!modelId && ids.has(modelId); } diff --git a/plugins/web-ui/src/session-list.ts b/plugins/web-ui/src/session-list.ts index 125931b..f1a795e 100644 --- a/plugins/web-ui/src/session-list.ts +++ b/plugins/web-ui/src/session-list.ts @@ -159,9 +159,10 @@ export function backgroundLabel(jobs: number, watches: number): { count: number; return parts.length ? { count: jobs + watches, label: parts.join(" · ") } : null; } -export function rowIndicators(s: CoreSession, liveThreadRef: string | null): RowIndicators { +export function rowIndicators(s: CoreSession, liveThreads: ReadonlySet | string | null): RowIndicators { + const live = typeof liveThreads === "string" ? new Set([liveThreads]) : (liveThreads ?? new Set()); return { - working: Boolean(s.working) || (Boolean(s.threadRef) && s.threadRef === liveThreadRef), + working: Boolean(s.working) || (Boolean(s.threadRef) && live.has(s.threadRef)), awaiting: Boolean(s.awaitingInput), background: backgroundLabel(s.backgroundJobs ?? 0, s.watches ?? 0), }; diff --git a/plugins/web-ui/src/sessions.ts b/plugins/web-ui/src/sessions.ts index a60919b..7e803b3 100644 --- a/plugins/web-ui/src/sessions.ts +++ b/plugins/web-ui/src/sessions.ts @@ -69,15 +69,8 @@ import { import { groupDmLabel, groupDmText } from "./group-dm-label"; import { transcriptModel } from "./model-options"; import { appState, closeSidebarOnNarrowView, renderSidebarTop, showMainEmpty } from "./shell"; -import { - chatState, - mountContinuable, - mountLoadingPane, - mountReadOnly, - newChat, - requestBackgroundPanel, - setTranscriptWindow, -} from "./chat"; +import { allConversations, mainConversation } from "./conversations"; +import type { Conversation } from "./conv-types"; import { addBlankPane, beginSessionDrag, @@ -390,7 +383,7 @@ function startProjectChat(event: Event, scopeId: string, name: string | null): v closeSidebarOnNarrowView(); sessionsState.collapsedProjectScopes.delete(scopeId); if (addBlankPane(scopeId)) return; - addPendingSession(newChat({ scopeId, name }), scopeId, name); + addPendingSession(mainConversation().newChat({ scopeId, name }), scopeId, name); } function projectMenuPopover(item: Extract): TemplateResult { @@ -455,7 +448,7 @@ export async function renderChatsPage(): Promise { export function drawChatsPage(): void { if (appState.currentView !== "chats" || !appState.mainEl || splitState.active) return; - chatState.host = null; + mainConversation().state.host = null; if (!chatsPageHost || chatsPageHost.parentElement !== appState.mainEl) { chatsPageHost = document.createElement("div"); chatsPageHost.className = "pane chats-page"; @@ -483,7 +476,7 @@ export function drawChatsPage(): void { drawChatsPage(); }, onRefresh: () => void renderChatsPage(), - action: { label: "New chat", onClick: () => newChat() }, + action: { label: "New chat", onClick: () => mainConversation().newChat() }, search: { value: chatsPageQuery, placeholder: "Search chats…", @@ -554,20 +547,25 @@ export const syncWorkingPulse = (el?: Element): void => { else requestAnimationFrame(pin); }; -function liveThread(): string | null { - return liveTurnThreadRef({ - mountedThreadRef: chatState.threadRef, - isStreaming: Boolean(chatState.agent?.state.isStreaming), - pendingSend: chatState.pendingSend, - }); +function liveThreads(): ReadonlySet { + const live = new Set(); + for (const conv of allConversations()) { + const ref = liveTurnThreadRef({ + mountedThreadRef: conv.state.threadRef, + isStreaming: Boolean(conv.state.agent?.state.isStreaming), + pendingSend: conv.state.pendingSend, + }); + if (ref) live.add(ref); + } + return live; } function sessionWorking(s: CoreSession): boolean { - return rowIndicators(s, liveThread()).working; + return rowIndicators(s, liveThreads()).working; } function statusMarks(s: CoreSession): TemplateResult { - const ind = rowIndicators(s, liveThread()); + const ind = rowIndicators(s, liveThreads()); return html`${ind.working ? html`` : nothing}${ ind.awaiting ? html`` @@ -591,17 +589,15 @@ function statusMarks(s: CoreSession): TemplateResult { function openBackgroundInspector(e: Event, s: CoreSession): void { e.stopPropagation(); e.preventDefault(); - requestBackgroundPanel(s.id || null, s.threadRef); + mainConversation().requestBackgroundPanel(s.id || null, s.threadRef); void openSession(s); } function isActiveRow(s: CoreSession): boolean { if (splitState.active) return Boolean(s.id) && sessionInCanvas(s.id); if (sessionsState.openingKey) return Boolean(s.id) && s.id === sessionsState.openingKey; - return Boolean( - (chatState.sessionId && s.id === chatState.sessionId) || - (chatState.threadRef && s.threadRef === chatState.threadRef), - ); + const main = mainConversation().state; + return Boolean((main.sessionId && s.id === main.sessionId) || (main.threadRef && s.threadRef === main.threadRef)); } function chatPageRow(s: CoreSession): TemplateResult { @@ -1103,6 +1099,13 @@ async function persistSessionPatch( } } +let listSettled: (() => void) | null = null; +const listReady = new Promise((resolve) => (listSettled = resolve)); + +export function sessionsReady(): Promise { + return sessionsState.loaded ? Promise.resolve() : listReady; +} + export async function refreshSessions( opts: { showLoading?: boolean; silent?: boolean; refreshContexts?: boolean } = {}, ): Promise { @@ -1125,6 +1128,8 @@ export async function refreshSessions( if (!opts.silent) sessionsNotice = errMessage(e, "Failed to load conversations."); return false; } finally { + listSettled?.(); + listSettled = null; if (seq === sessionRefreshSeq) { sessionsLoading = false; renderList(); @@ -1136,22 +1141,33 @@ export async function openSession(s: CoreSession, entriesPrefetch?: Promise, +): Promise { + const tracked = conv === mainConversation(); if (!s.id) { - if (chatState.threadRef !== s.threadRef) { - mountContinuable(s.threadRef, null, s.scopeId || null, [], s.channelName ?? null); + if (conv.state.threadRef !== s.threadRef) { + conv.mountContinuable(s.threadRef, null, s.scopeId || null, [], s.channelName ?? null); renderList(); } return; } - if (s.id === chatState.sessionId) return; + if (s.id === conv.state.sessionId) return; void refreshSessions({ silent: true }); - sessionsState.openingKey = s.id; const opening = s.id; - renderList(); + if (tracked) { + sessionsState.openingKey = opening; + renderList(); + } const skeletonTimer = window.setTimeout(() => { - if (sessionsState.openingKey === opening) mountLoadingPane(); + if (!tracked || sessionsState.openingKey === opening) conv.mountLoadingPane(); }, 140); const fetchEntries = (): Promise => @@ -1165,11 +1181,13 @@ export async function openSession(s: CoreSession, entriesPrefetch?: Promise { renderAuthGate(gateFor(authMode, detail.reason)); }); +onExitCanvas(() => exitSplitIfActive()); + export const ADMIN_BASE = (() => { const base = ((import.meta as unknown as { env?: { BASE_URL?: string } }).env?.BASE_URL ?? "/").replace(/\/$/, ""); return base ? base.replace(/\/[^/]+$/, "/admin") : "/admin"; @@ -94,14 +89,7 @@ export function adminSessionLogUrl(sessionId: string, scopeId: string): string { } export function syncUrlFromState(): void { - if (embedMode) { - postCurrentPaneState(); - if (parseDeepLink(UI_BASE, location.pathname, location.search).view === "app-edit") return; - const sessionId = chatState.sessionId ?? chatState.rememberedSessionId; - const next = sessionId ? `${deepLinkPath(UI_BASE, "chats", sessionId)}&embed=1` : `${UI_BASE}/?embed=1`; - if (`${location.pathname}${location.search}` !== next) history.replaceState(null, "", next); - return; - } + const chatState = mainConversation().state; const sessionId = splitState.active ? null : (chatState.sessionId ?? chatState.rememberedSessionId); const next = deepLinkPath(UI_BASE, appState.currentView, sessionId, contextsState.selected); if (`${location.pathname}${location.search}` !== next) history.replaceState(null, "", next); @@ -204,14 +192,15 @@ export async function signOut(): Promise { } appState.me = null; clearAllDrafts(); - resetChatState(); + exitSplitIfActive(); + mainConversation().resetChatState(); resetSessionsState(); appState.currentView = "chats"; - composerState.skillsCache = null; + clearSkillsCache(); resetMemoryState(); resetContextsState(); resetKeychainState(); - resetComposer(); + mainConversation().composer.resetComposer(); if (!portal) { renderAuthGate({ kind: "dev" }); return; @@ -427,19 +416,6 @@ function gateFor(mode: AuthMode, reason: "unauthenticated" | "not_allowed" | und } export function mountShell(): void { - if (embedMode) { - render( - html`
-
Loading…
-
`, - appEl as HTMLElement, - ); - appState.topEl = null; - appState.listEl = null; - appState.mainEl = (appEl as HTMLElement).querySelector("#main"); - shellMounted = true; - return; - } applySavedSidebarWidth(); const impersonatedBy = appState.me?.impersonatedBy ?? null; let banner: TemplateResult | null = null; @@ -532,7 +508,7 @@ export function renderSidebarTop(): void { class="new-chat" @click=${() => { closeSidebarOnNarrowView(); - if (!addBlankPane()) newChat(); + if (!addBlankPane()) mainConversation().newChat(); }} > ${icon(ICON.newChat, 17)}${splitState.active ? "New session" : "New chat"} @@ -596,8 +572,8 @@ export function switchView(v: View): void { sessionsState.openMenuId = null; sessionsState.renamingId = null; if (v !== "chats") { - teardownActiveChat(); - resetComposer(); + mainConversation().teardown(); + mainConversation().composer.resetComposer(); } renderSidebarTop(); syncUrlFromState(); @@ -666,7 +642,7 @@ function refreshActiveView(v: View): void { export function showMainEmpty(text: string): void { exitSplitIfActive(); - chatState.host = null; + mainConversation().state.host = null; if (appState.mainEl) appState.mainEl.replaceChildren( Object.assign(document.createElement("div"), { className: "empty", textContent: text }), @@ -773,14 +749,14 @@ export function replacePanePreservingFocus(host: HTMLElement): void { } window.addEventListener("popstate", () => { - if (embedMode || appState.currentView !== "crons") return; + if (appState.currentView !== "crons") return; const { view, item } = parseDeepLink(UI_BASE, location.pathname, location.search); if (view !== "crons") return; routeCronsHistory(item); }); window.addEventListener("focus", () => { - if (!appState.me || embedMode) return; + if (!appState.me) return; if (appState.currentView === "contexts") void renderContexts(); else if (appState.currentView === "chats") void refreshSessions({ silent: true, refreshContexts: true }); }); @@ -801,7 +777,7 @@ function openAppEditChat(slug: string): void { return; } if (!storedDraft(threadRef)) saveDraft(threadRef, `Update my deployed app "${slug}": `); - mountContinuable(threadRef, null, null, []); + mainConversation().mountContinuable(threadRef, null, null, []); renderList(); } @@ -836,16 +812,21 @@ export async function boot(): Promise { appState.me = (await r.json()) as Me; authMode = appState.me.mode ?? "portal"; clearPortalAttempt(); - const runtimeConfig = await fetchRuntimeConfig(`personal:${appState.me.user}`); - if (runtimeConfig) + const personalScope = `personal:${appState.me.user}`; + const runtimeConfig = await fetchRuntimeConfig(personalScope); + if (runtimeConfig) { applyRuntimeOptions( + personalScope, runtimeConfig.approvedHarnesses, runtimeConfig.modelsByHarness, runtimeConfig.effective, runtimeConfig.modelCatalog, ); + seedRuntimeConfig(personalScope, runtimeConfig); + } resyncModelSelection(); mountShell(); + ensureDeliveryStream(); warmDeferredChunks(); loadPersistedSplit(); @@ -861,6 +842,9 @@ export async function boot(): Promise { const entriesPrefetch = wantedSession && !viewIntent ? fetchTranscript(wantedSession, { tailTurns: TAIL_TURNS }).catch(() => null) : null; + const bareEntry = !viewIntent && !wantedSession && wanted !== "app-edit" && !connectedProvider; + if (bareEntry && !restoredCanvasNeedsSessionList()) mountRestoredCanvas(); + await refreshSessions({ showLoading: true }); if (wanted === "app-edit") { @@ -873,19 +857,6 @@ export async function boot(): Promise { return; } - if (embedMode) { - if (wantedSession) { - const match = sessionsState.list.find((s) => s.id === wantedSession); - if (match) await openSession(match, entriesPrefetch ?? undefined); - else showMainEmpty("That conversation wasn't found, or you don't have access to it."); - } else { - const scope = params.get("scope"); - const context = scope ? (await ensureContexts()).find((c) => c.scopeId === scope) : undefined; - newChat(context ? { scopeId: context.scopeId, name: context.name ?? null } : undefined); - } - return; - } - if (wanted === "keychain") { const provider = params.get("connector"); const status = params.get("status"); @@ -914,7 +885,7 @@ export async function boot(): Promise { const recent = [...sessionsState.list].sort((a, b) => activityOf(b) - activityOf(a))[0]!; exitSplitIfActive(); await openSession(recent); - } else if (!mountRestoredCanvas()) { - newChat(); + } else if (!mountRestoredCanvas() && !mainConversation().state.threadRef) { + mainConversation().newChat(); } } diff --git a/plugins/web-ui/src/split-layout.ts b/plugins/web-ui/src/split-layout.ts index 2f18127..fb5e661 100644 --- a/plugins/web-ui/src/split-layout.ts +++ b/plugins/web-ui/src/split-layout.ts @@ -53,3 +53,23 @@ export function dropAddsTile(drop: { edge: boolean; wholeTile: boolean; sourceTi if (!drop.edge || drop.wholeTile) return false; return drop.sourceTilePanes !== 1; } + +export function layoutNeedsSessionList(layout: unknown): boolean { + const panels = (layout as { panels?: unknown } | null)?.panels; + if (!panels || typeof panels !== "object") return true; + return Object.values(panels as Record).some((panel) => { + const params = (panel as { params?: unknown } | null)?.params as PaneSeedLike | undefined; + return paneNeedsSessionList(params ?? {}); + }); +} + +interface PaneSeedLike { + sessionId?: unknown; + threadRef?: unknown; +} + +export function paneNeedsSessionList(p: PaneSeedLike): boolean { + const hasSession = typeof p.sessionId === "string" && p.sessionId !== ""; + const hasThread = typeof p.threadRef === "string" && p.threadRef !== ""; + return !hasSession && hasThread; +} diff --git a/plugins/web-ui/src/split.ts b/plugins/web-ui/src/split.ts index c6c01b5..15c5dad 100644 --- a/plugins/web-ui/src/split.ts +++ b/plugins/web-ui/src/split.ts @@ -15,31 +15,42 @@ import { type SerializedDockview, type TabPartInitParameters, } from "dockview-core"; -import { - embedMode, - PANE_COLLAPSE_MSG, - PANE_DELIVERY_MSG, - PANE_EXPAND_MSG, - PANE_FOCUS_MSG, - PANE_STATE_MSG, -} from "./embed"; import { dropAddsTile, MAX_PANES, MAX_TILES, serializedTileCount, v1PaneSeeds, + layoutNeedsSessionList, + paneNeedsSessionList, type DropEdge, type PaneSeed, type SplitEdge, } from "./split-layout"; -import { deepLinkPath, UI_BASE } from "./deep-link"; import { icon } from "./ui"; +import { contextsState } from "./contexts"; +import type { DensityTier } from "./density"; import { appState } from "./shell-state"; import { renderSidebarTop, switchView, syncUrlFromState } from "./shell"; -import { chatState, ensureDeliveryStream, newChat, sleep, teardownActiveChat } from "./chat"; -import { composerState, resetComposer } from "./composer"; -import { openSession, refreshSessions, renderList, sessionsState, sessionTitle, syncWorkingPulse } from "./sessions"; +import { sleep } from "./chat"; +import { + createConversation, + disposeConversation, + ensureDeliveryStream, + mainConversation, + paneDensity, +} from "./conversations"; +import type { Conversation } from "./conv-types"; +import { + openSession, + openSessionInto, + refreshSessions, + sessionsReady, + renderList, + sessionsState, + sessionTitle, + syncWorkingPulse, +} from "./sessions"; import { conversationBackground, type RowIndicators } from "./session-list"; import type { CoreSession } from "./core-bridge"; @@ -63,7 +74,6 @@ let dockApi: DockviewApi | null = null; let toastEl: HTMLElement | null = null; let lastLayout: SerializedDockview | null = null; let pendingSeed: PendingSeed | null = null; -const paneWorking = new Map(); const paneContents = new Map(); const paneTabs = new Set(); const groupActions = new Set(); @@ -109,7 +119,6 @@ function buildDock(): DockviewApi { createTabComponent: () => new PaneTab(), createRightHeaderActionComponent: () => new GroupActions(), singleTabMode: "fullwidth", - defaultRenderer: "always", disableFloatingGroups: true, }); const inner = dockEl.querySelector(":scope > .dv-dockview") as HTMLElement | null; @@ -188,7 +197,7 @@ function ensureCanvas(): boolean { canvasHost = document.createElement("div"); canvasHost.className = "split-canvas"; appState.mainEl.replaceChildren(canvasHost); - chatState.host = null; + mainConversation().state.host = null; dockApi = buildDock(); const seed = pendingSeed; pendingSeed = null; @@ -259,8 +268,8 @@ function largestGroupPanel(api: DockviewApi): { panel: IDockviewPanel; wide: boo } function activateCanvas(first: PaneParams, second: PaneParams, edge: SplitEdge): void { - teardownActiveChat(); - resetComposer(); + mainConversation().teardown(); + mainConversation().composer.resetComposer(); splitState.active = true; lastLayout = null; pendingSeed = null; @@ -288,13 +297,11 @@ export function exitSplitIfActive(): void { persist(); disposeDock(); canvasHost = null; - paneWorking.clear(); headerSignature = ""; renderSidebarTop(); } export function loadPersistedSplit(): void { - if (embedMode) return; let raw: unknown; try { raw = JSON.parse(localStorage.getItem(STORE_KEY) ?? "null"); @@ -318,7 +325,14 @@ export function loadPersistedSplit(): void { splitState.active = true; } +export function restoredCanvasNeedsSessionList(): boolean { + if (!pendingSeed) return false; + if (pendingSeed.kind === "v1") return pendingSeed.seeds.some((seed) => paneNeedsSessionList(seed)); + return layoutNeedsSessionList(pendingSeed.layout); +} + export function mountRestoredCanvas(): boolean { + if (splitState.active && (dockApi?.panels.length ?? 0) > 0) return true; if (!splitState.active || (!pendingSeed && !lastLayout)) return false; if (!ensureCanvas()) { splitState.active = false; @@ -370,7 +384,6 @@ function openInPane(paneId: string, sessionId: string, threadRef: string): void if (!target) return; const fresh = addPane({ sessionId, threadRef }, { referencePanel: target.id, direction: "within" }); dockApi.removePanel(target); - paneWorking.delete(target.id); fresh.api.setActive(); persist(); } @@ -425,7 +438,6 @@ function closePanels(panels: IDockviewPanel[]): void { if (!dockApi) return; for (const p of panels) { dockApi.removePanel(p); - paneWorking.delete(p.id); } reconcileAfterClose(); } @@ -434,7 +446,7 @@ function reconcileAfterClose(): void { const rest = dockApi?.panels ?? []; if (rest.length === 0) { exitSplitIfActive(); - newChat(); + mainConversation().newChat(); return; } if (rest.length === 1) { @@ -444,7 +456,7 @@ function reconcileAfterClose(): void { void maximizePane(params); } else { exitSplitIfActive(); - newChat(); + mainConversation().newChat(); } return; } @@ -555,11 +567,13 @@ function paneZoneAct(paneId: string): (edge: DropEdge) => () => void { } function currentChatParams(): PaneParams | null { + const conv = mainConversation(); + const chatState = conv.state; if (!chatState.host) return null; if (chatState.sessionId) return { sessionId: chatState.sessionId, ...(chatState.threadRef ? { threadRef: chatState.threadRef } : {}) }; const untouched = - !chatState.pendingSend && !composerState.draft.trim() && (chatState.agent?.state.messages.length ?? 0) === 0; + !chatState.pendingSend && !conv.composer.state.draft.trim() && (chatState.agent?.state.messages.length ?? 0) === 0; return untouched ? {} : null; } @@ -571,7 +585,7 @@ function showSingleDropOverlay(): void { if (!drag) return; const session = sessionsState.list.find((s) => s.id === drag.sessionId); const current = edge === "center" ? null : currentChatParams(); - if (edge === "center" || chatState.sessionId === drag.sessionId || !current) { + if (edge === "center" || mainConversation().state.sessionId === drag.sessionId || !current) { if (session) void openSession(session); return; } @@ -625,7 +639,10 @@ function paneTitle(panel: IDockviewPanel): string { } function paneIsWorking(panel: IDockviewPanel): boolean { - return paneWorking.get(panel.id) === true || Boolean(paneSession(panel)?.working); + const conv = paneContents.get(panel.id)?.conversation; + const agent = conv?.state.agent; + if (agent?.state.isStreaming || (conv && conv.state.pendingSend !== null)) return true; + return Boolean(paneSession(panel)?.working); } function paneAwaitsInput(panel: IDockviewPanel): boolean { @@ -637,49 +654,113 @@ function paneBackground(panel: IDockviewPanel): RowIndicators["background"] { return conversationBackground(sessionsState.list, sessionId ?? null, threadRef ?? null); } -function paneSrc(params: PaneParams): string { - const sessionId = - params.sessionId ?? - (params.threadRef ? (sessionsState.list.find((s) => s.threadRef === params.threadRef)?.id ?? null) : null); - if (sessionId) return `${deepLinkPath(UI_BASE, "chats", sessionId)}&embed=1`; - const scope = params.scopeId ? `&scope=${encodeURIComponent(params.scopeId)}` : ""; - return `${UI_BASE}/?embed=1${scope}`; -} - class PaneContent implements IContentRenderer { readonly element: HTMLElement; - private readonly frame: HTMLIFrameElement; + readonly conversation: Conversation; + private readonly chatEl: HTMLElement; private readonly zonesEl: HTMLElement; + private readonly resize: ResizeObserver; private panelId = ""; private panel: IDockviewPanel | null = null; + private params: PaneParams = {}; + private density: DensityTier = "full"; + private loaded = false; + private disposed = false; + private redrawOnResize: Array<() => void> = []; constructor() { this.element = document.createElement("div"); this.element.className = "split-pane-content"; - this.frame = document.createElement("iframe"); - this.frame.className = "split-pane-frame"; - this.frame.setAttribute("allow", "clipboard-read; clipboard-write"); + this.chatEl = document.createElement("div"); + this.chatEl.className = "split-pane-chat"; this.zonesEl = document.createElement("div"); this.zonesEl.className = "split-zones"; - this.element.append(this.frame, this.zonesEl); + this.element.append(this.chatEl, this.zonesEl); + this.conversation = createConversation({ + pane: true, + ownsUrl: false, + container: () => this.chatEl, + claimContainer: () => this.chatEl, + visible: () => splitState.active && appState.currentView === "chats", + density: () => this.density, + onDensityChange: (handler) => this.redrawOnResize.push(handler), + ensureDeliveryStream, + onState: (paneState) => { + notePaneSession(this.panelId, paneState.sessionId, paneState.threadRef); + refreshHeaders(); + }, + onExpand: () => { + const panel = dockApi?.getPanel(this.panelId); + if (panel && !panel.api.isMaximized()) panel.api.maximize(); + }, + }); + this.element.addEventListener("focusin", () => focusPane(this.panelId)); + this.resize = new ResizeObserver(() => this.syncDensity()); } init(p: GroupPanelPartInitParameters): void { this.panelId = p.api.id; this.panel = p.containerApi.getPanel(p.api.id) ?? null; - this.frame.dataset.paneId = this.panelId; - this.frame.title = this.panel ? paneTitle(this.panel) : "Conversation pane"; - this.frame.src = paneSrc((p.params ?? {}) as PaneParams); + this.params = (p.params ?? {}) as PaneParams; + this.element.dataset.paneId = this.panelId; paneContents.set(this.panelId, this); + this.resize.observe(this.element); this.syncZones(); + p.api.onDidDimensionsChange(() => this.syncDensity()); + p.api.onDidVisibilityChange((e) => { + if (e.isVisible) void this.load(); + }); + if (p.api.isVisible) void this.load(); } - update(): void { + private syncDensity(): void { + this.element.dataset.density = this.density = paneDensity(this.element); + for (const handler of this.redrawOnResize) handler(); + } + + private async load(): Promise { + if (this.loaded || this.disposed) return; + this.loaded = true; + this.syncDensity(); + const { sessionId, threadRef, scopeId } = this.params; + const wanted = + sessionId ?? (threadRef ? (sessionsState.list.find((s) => s.threadRef === threadRef)?.id ?? null) : null); + if (!wanted) { + const context = scopeId ? contextsState.list.find((c) => c.scopeId === scopeId) : undefined; + this.conversation.newChat(context ? { scopeId: context.scopeId, name: context.name ?? null } : undefined); + return; + } + this.conversation.mountLoadingPane(); + let session = sessionsState.list.find((s) => s.id === wanted); + if (!session) { + await sessionsReady(); + if (this.disposed) return; + session = sessionsState.list.find((s) => s.id === wanted); + } + if (!session) { + await refreshSessions({ silent: true }); + if (this.disposed) return; + session = sessionsState.list.find((s) => s.id === wanted); + } + if (!session) { + this.conversation.mountReadOnly( + { id: wanted, threadRef: threadRef ?? "", scopeId: "", title: "" } as CoreSession, + [], + ); + return; + } + await openSessionInto(this.conversation, session); + if (this.disposed) return; + refreshHeaders(); + } + + update(p: { params: Record }): void { + this.params = (p.params ?? {}) as PaneParams; this.syncTitle(); } syncTitle(): void { - if (this.panel) this.frame.title = paneTitle(this.panel); + if (this.panel) this.element.title = paneTitle(this.panel); } syncZones(): void { @@ -687,7 +768,10 @@ class PaneContent implements IContentRenderer { } dispose(): void { + this.disposed = true; + this.resize.disconnect(); paneContents.delete(this.panelId); + disposeConversation(this.conversation); } } @@ -837,61 +921,19 @@ class GroupActions implements IHeaderActionsRenderer { } } -export function relayDeliveryToPanes(threadRef: string): void { - if (!splitState.active || !canvasHost) return; - for (const frame of canvasHost.querySelectorAll("iframe.split-pane-frame")) { - frame.contentWindow?.postMessage({ type: PANE_DELIVERY_MSG, threadRef }, location.origin); - } -} - -window.addEventListener("message", (e: MessageEvent) => { - if (e.origin !== location.origin || !splitState.active || !canvasHost || !dockApi) return; - const d = e.data as { type?: string; threadRef?: unknown; sessionId?: unknown; working?: unknown } | null; - if ( - d?.type !== PANE_STATE_MSG && - d?.type !== PANE_FOCUS_MSG && - d?.type !== PANE_EXPAND_MSG && - d?.type !== PANE_COLLAPSE_MSG - ) - return; - const frames = canvasHost.querySelectorAll("iframe.split-pane-frame"); - const frame = [...frames].find((f) => f.contentWindow === e.source); - const paneId = frame?.dataset.paneId; - if (!paneId) return; - if (d.type === PANE_FOCUS_MSG) { - focusPane(paneId); - return; - } - if (d.type === PANE_EXPAND_MSG) { - const expanding = dockApi.getPanel(paneId); - if (expanding && !expanding.api.isMaximized()) expanding.api.maximize(); - return; - } - if (d.type === PANE_COLLAPSE_MSG) { - if (dockApi.hasMaximizedGroup()) dockApi.exitMaximizedGroup(); - return; - } - const panel = dockApi.getPanel(paneId); +function notePaneSession(paneId: string, sessionId: string | null, threadRef: string | null): void { + const panel = dockApi?.getPanel(paneId); if (!panel) return; - const wasWorking = paneWorking.get(paneId) === true; - const working = d.working === true; - paneWorking.set(paneId, working); const params = panelParams(panel); - const postedSession = typeof d.sessionId === "string" && d.sessionId ? d.sessionId : null; - const postedThread = typeof d.threadRef === "string" && d.threadRef ? d.threadRef : null; - if (!params.sessionId && (postedSession || (postedThread && postedThread !== params.threadRef))) { - panel.api.updateParameters({ - ...(postedSession ? { sessionId: postedSession } : {}), - ...(postedThread ? { threadRef: postedThread } : {}), - }); - persist(); - if (postedSession) void settlePaneTitle(postedSession); - } else if (wasWorking && !working && params.sessionId) { - const settledId = params.sessionId; - void settlePoll([0, 2000, 5000], () => sessionsState.list.find((s) => s.id === settledId)?.working !== true); - } + if (params.sessionId || (!sessionId && (!threadRef || threadRef === params.threadRef))) return; + panel.api.updateParameters({ + ...(sessionId ? { sessionId } : {}), + ...(threadRef ? { threadRef } : {}), + }); + persist(); + if (sessionId) void settlePaneTitle(sessionId); refreshHeaders(); -}); +} async function settlePaneTitle(sessionId: string): Promise { const titled = (): boolean => Boolean(sessionsState.list.find((s) => s.id === sessionId)?.title?.trim()); @@ -911,12 +953,6 @@ async function settlePoll(delays: number[], done: () => boolean): Promise } } -window.addEventListener("blur", () => { - if (!splitState.active) return; - const el = document.activeElement; - if (el instanceof HTMLIFrameElement && el.dataset.paneId) focusPane(el.dataset.paneId); -}); - document.addEventListener("keydown", (e) => { if (e.key === "Escape" && splitState.active && dockApi?.hasMaximizedGroup()) dockApi.exitMaximizedGroup(); }); diff --git a/plugins/web-ui/test/composer-no-scrollbar.test.ts b/plugins/web-ui/test/composer-no-scrollbar.test.ts index 4e9c035..480d1d0 100644 --- a/plugins/web-ui/test/composer-no-scrollbar.test.ts +++ b/plugins/web-ui/test/composer-no-scrollbar.test.ts @@ -11,7 +11,7 @@ test("composer input rests overflow-hidden below the height cap", () => { }); test("resizeComposer opens scrolling only past the cap and pins scrollTop under it", () => { - const fn = composer.match(/export function resizeComposer\(\): void \{[\s\S]*?\n\}/)?.[0] ?? ""; + const fn = composer.match(/function resizeComposer\(\): void \{[\s\S]*?\n {2}\}/)?.[0] ?? ""; assert.match( fn, /const cap = parseFloat\(getComputedStyle\(ta\)\.maxHeight\) \|\| 180;/, @@ -24,7 +24,7 @@ test("resizeComposer opens scrolling only past the cap and pins scrollTop under }); test("the send-clear path resets overflow and scroll before the input is seen empty", () => { - const fn = composer.match(/function clearComposerDom\(agent: Agent\): void \{[\s\S]*?\n\}/)?.[0] ?? ""; + const fn = composer.match(/function clearComposerDom\(agent: Agent\): void \{[\s\S]*?\n {2}\}/)?.[0] ?? ""; assert.match(fn, /overflowY = "hidden"/, "clearing must close the scroller synchronously"); assert.match(fn, /scrollTop = 0/, "clearing must drop the clamped scroll offset"); }); diff --git a/plugins/web-ui/test/composer-source.test.ts b/plugins/web-ui/test/composer-source.test.ts index 504aeb2..38f6cc4 100644 --- a/plugins/web-ui/test/composer-source.test.ts +++ b/plugins/web-ui/test/composer-source.test.ts @@ -5,7 +5,10 @@ import test from "node:test"; const composer = readFileSync(new URL("../src/composer.ts", import.meta.url), "utf8"); test("scope-default buttons render only after the model selection is toggled off the default", () => { - assert.match(composer, /const modelToggled = !runtimePending && selectedModel\.value !== defaultModelValue\(\)/); + assert.match( + composer, + /const modelToggled = !runtimePending && selectedModel\.value !== defaultModelValue\(scopeKey\(\)\)/, + ); assert.match(composer, /\$\{\s*modelToggled\s*\? html`[\s\S]{0,800}?>\s*Make default\s*<\/button>/); assert.match(composer, /\$\{\s*modelToggled && activeRuntimeConfig\?\.scopeOverride/); }); diff --git a/plugins/web-ui/test/core-wire-contract.ts b/plugins/web-ui/test/core-wire-contract.ts index ca82c78..886638a 100644 --- a/plugins/web-ui/test/core-wire-contract.ts +++ b/plugins/web-ui/test/core-wire-contract.ts @@ -1,4 +1,5 @@ -import type { Session, SessionEntry as CoreSessionEntry } from "../../../src/types.ts"; +import type { Session } from "../../../src/types.ts"; +import type { TranscriptEntry as CoreSessionEntry } from "../../../src/sessions/session-store.ts"; import type { ContextSummary, ProjectView } from "../../../src/api/app.ts"; import type { CoreContext, CoreProject, CoreSession, SessionEntry } from "../src/core-bridge.ts"; diff --git a/plugins/web-ui/test/layout-thrash.test.ts b/plugins/web-ui/test/layout-thrash.test.ts index a6aaa71..062723d 100644 --- a/plugins/web-ui/test/layout-thrash.test.ts +++ b/plugins/web-ui/test/layout-thrash.test.ts @@ -6,7 +6,7 @@ const composer = readFileSync(new URL("../src/composer.ts", import.meta.url), "u const chat = readFileSync(new URL("../src/chat.ts", import.meta.url), "utf8"); test("resizeComposer skips the forced-reflow measure pass when the draft value is unchanged", () => { - const fn = composer.match(/export function resizeComposer\(\): void \{[\s\S]*?\n\}/)?.[0] ?? ""; + const fn = composer.match(/function resizeComposer\(\): void \{[\s\S]*?\n {2}\}/)?.[0] ?? ""; const skip = fn.indexOf("if (ta.value === autosizedValue) return;"); const measure = fn.indexOf('ta.style.height = "auto"'); assert.ok(skip >= 0, "the value memo must gate the measure pass"); diff --git a/plugins/web-ui/test/model-options.test.ts b/plugins/web-ui/test/model-options.test.ts index 4c3ba48..0b1bb9e 100644 --- a/plugins/web-ui/test/model-options.test.ts +++ b/plugins/web-ui/test/model-options.test.ts @@ -10,6 +10,7 @@ import { harnessSupportsEffort, harnessSupportsFastMode, } from "../src/model-options.ts"; +import { modelSupportsFastMode, setFastModeModelIds } from "../src/pi-models.ts"; test("the built-in picker includes Fable alongside Opus/Sonnet/Haiku", () => { applyPickerModelIds(null); @@ -56,6 +57,7 @@ test("the Codex picker can render and select the OpenAI model", () => { test("runtime options qualify harness/model pairs and select the effective pair", () => { applyRuntimeOptions( + null, ["pi", "codex"], { pi: ["claude-opus-4-8"], codex: ["gpt-5.6-sol"] }, { harnessId: "codex", modelId: "gpt-5.6-sol" }, @@ -80,6 +82,7 @@ test("runtime options qualify harness/model pairs and select the effective pair" test("runtime options preserve a fetched OpenRouter model as the selected web turn model", () => { applyRuntimeOptions( + null, ["pi"], { pi: ["anthropic/claude-sonnet-4.5"] }, { harnessId: "pi", modelId: "anthropic/claude-sonnet-4.5" }, @@ -94,6 +97,7 @@ test("runtime options preserve a fetched OpenRouter model as the selected web tu test("runtime options hide retired persisted model ids", () => { applyRuntimeOptions( + null, ["claude", "codex"], { claude: ["claude-fable-5", "claude-sonnet-4-6", "claude-sonnet-5"], @@ -123,7 +127,7 @@ test("harness-only turn controls are exposed only where the adapter supports the }); test("an all-retired list falls back within the approved harness", () => { - applyRuntimeOptions(["codex"], { codex: ["gpt-5.5"] }, { harnessId: "codex", modelId: "gpt-5.5" }); + applyRuntimeOptions(null, ["codex"], { codex: ["gpt-5.5"] }, { harnessId: "codex", modelId: "gpt-5.5" }); assert.deepEqual(getHarnessOptions(), [{ value: "codex", label: "Codex" }]); assert.deepEqual( getModelOptionsForHarness("codex").map((o) => o.label), @@ -153,3 +157,28 @@ test("duplicate ids are de-duped, preserving first occurrence order", () => { ); applyPickerModelIds(null); }); + +test("two panes on different scopes keep their own picker, default, and fast-mode set", () => { + applyRuntimeOptions("personal:me", ["pi"], { pi: ["claude-opus-5"] }, { harnessId: "pi", modelId: "claude-opus-5" }); + setFastModeModelIds("personal:me", ["claude-opus-5"]); + applyRuntimeOptions( + "group:team", + ["codex"], + { codex: ["gpt-5.6-sol"] }, + { harnessId: "codex", modelId: "gpt-5.6-sol" }, + ); + setFastModeModelIds("group:team", []); + + assert.equal( + defaultModelValue("personal:me"), + "pi:claude-opus-5", + "the later scope must not capture the earlier one", + ); + assert.equal(defaultModelValue("group:team"), "codex:gpt-5.6-sol"); + assert.deepEqual( + getModelOptions("personal:me").map((o) => o.value), + ["pi:claude-opus-5"], + ); + assert.equal(modelSupportsFastMode("personal:me", "claude-opus-5"), true); + assert.equal(modelSupportsFastMode("group:team", "claude-opus-5"), false); +}); diff --git a/plugins/web-ui/test/openrouter-turn.test.ts b/plugins/web-ui/test/openrouter-turn.test.ts index 56b1488..00e936e 100644 --- a/plugins/web-ui/test/openrouter-turn.test.ts +++ b/plugins/web-ui/test/openrouter-turn.test.ts @@ -13,6 +13,7 @@ afterEach(() => { test("a web turn submits the fetched OpenRouter model selected by runtime config", async () => { applyRuntimeOptions( + null, ["pi"], { pi: ["anthropic/claude-sonnet-4.5"] }, { harnessId: "pi", modelId: "anthropic/claude-sonnet-4.5" }, diff --git a/plugins/web-ui/test/pane-composer-source.test.ts b/plugins/web-ui/test/pane-composer-source.test.ts index 21cf27d..110710a 100644 --- a/plugins/web-ui/test/pane-composer-source.test.ts +++ b/plugins/web-ui/test/pane-composer-source.test.ts @@ -5,19 +5,20 @@ import test from "node:test"; const css = readFileSync(new URL("../src/shell.css", import.meta.url), "utf8"); const composer = readFileSync(new URL("../src/composer.ts", import.meta.url), "utf8"); -test("pane composer collapses to a single line in embed mode", () => { - assert.match(css, /\.embed-layout \.composer-wrap \{[^}]*display: flex;/); - assert.match(css, /\.embed-layout \.composer-toolbar \{\s*display: contents;/); - assert.match(css, /\.embed-layout \.composer-input \{[^}]*min-height: 0;/); - assert.match(composer, /Math\.max\(embedMode \? 0 : 48, content\)/); +test("pane composer collapses to a single line — keyed off the pane, not a whole-document layout class", () => { + assert.doesNotMatch(css, /\.embed-layout/, "panes are elements now, not framed documents"); + assert.match(css, /\[data-density\] \.composer-wrap \{[^}]*display: flex;/); + assert.match(css, /\[data-density\] \.composer-toolbar \{\s*display: contents;/); + assert.match(css, /\[data-density\] \.composer-input \{[^}]*min-height: 0;/); + assert.match(composer, /Math\.max\(ctx\.pane \? 0 : 48, content\)/); }); test("phone touch layout cannot inflate a pane's composer controls", () => { assert.match( css, - /\.embed-layout \.composer-toolbar \.icon-btn,\s*\.embed-layout \.composer-toolbar \.menu-button,\s*\.embed-layout \.composer-toolbar \.send-btn \{\s*width: 34px;\s*height: 34px;\s*min-height: 34px;/, + /\[data-density\] \.composer-toolbar \.icon-btn,\s*\[data-density\] \.composer-toolbar \.menu-button,\s*\[data-density\] \.composer-toolbar \.send-btn \{\s*width: 34px;\s*height: 34px;\s*min-height: 34px;/, ); - assert.match(css, /\.embed-layout \.composer-left,\s*\.embed-layout \.composer-right \{\s*width: auto;/); + assert.match(css, /\[data-density\] \.composer-left,\s*\[data-density\] \.composer-right \{\s*width: auto;/); }); test("pane settings control is visible without hover", () => { diff --git a/plugins/web-ui/test/pi-models.test.ts b/plugins/web-ui/test/pi-models.test.ts index a033097..8f586d4 100644 --- a/plugins/web-ui/test/pi-models.test.ts +++ b/plugins/web-ui/test/pi-models.test.ts @@ -31,12 +31,12 @@ test("models this pi-ai build lacks are cloned from a template of their own prov }); test("fast-mode support is fed from core's runtime config, not a hardcoded client copy", () => { - setFastModeModelIds([]); - assert.equal(modelSupportsFastMode("claude-opus-4-8"), false); - - setFastModeModelIds(["claude-opus-4-8", "claude-opus-4-7"]); - assert.equal(modelSupportsFastMode("claude-opus-4-8"), true); - assert.equal(modelSupportsFastMode("claude-sonnet-4-6"), false); - assert.equal(modelSupportsFastMode("claude-haiku-4-5"), false); - assert.equal(modelSupportsFastMode(undefined), false); + setFastModeModelIds(null, []); + assert.equal(modelSupportsFastMode(null, "claude-opus-4-8"), false); + + setFastModeModelIds(null, ["claude-opus-4-8", "claude-opus-4-7"]); + assert.equal(modelSupportsFastMode(null, "claude-opus-4-8"), true); + assert.equal(modelSupportsFastMode(null, "claude-sonnet-4-6"), false); + assert.equal(modelSupportsFastMode(null, "claude-haiku-4-5"), false); + assert.equal(modelSupportsFastMode(null, undefined), false); }); diff --git a/plugins/web-ui/test/runtime-config-handoff.test.ts b/plugins/web-ui/test/runtime-config-handoff.test.ts new file mode 100644 index 0000000..5b1d49b --- /dev/null +++ b/plugins/web-ui/test/runtime-config-handoff.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const composer = readFileSync(new URL("../src/composer.ts", import.meta.url), "utf8"); +const css = readFileSync(new URL("../src/shell.css", import.meta.url), "utf8"); +const shell = readFileSync(new URL("../src/shell.ts", import.meta.url), "utf8"); + +test("boot hands its runtime config to the composer instead of dropping it", () => { + assert.match(shell, /const personalScope = `personal:\$\{appState\.me\.user\}`;/); + assert.match(shell, /seedRuntimeConfig\(personalScope, runtimeConfig\);/); + const fetchAt = shell.indexOf("await fetchRuntimeConfig(personalScope)"); + const seedAt = shell.indexOf("seedRuntimeConfig(personalScope, runtimeConfig)"); + assert.ok(fetchAt > 0 && seedAt > fetchAt, "boot must seed the config it just fetched"); +}); + +test("the first mount in the seeded scope renders from it — no blanking, no second fetch", () => { + const fn = composer.slice( + composer.indexOf("async function refreshRuntimeSelection"), + composer.indexOf("function applySelectedRuntime"), + ); + assert.ok(fn, "refreshRuntimeSelection not found"); + const seedRead = fn.indexOf("seededRuntime?.scopeId === scopeKey"); + const blank = fn.indexOf("activeRuntimeConfig = null"); + const fetchCall = fn.indexOf("await fetchRuntimeConfig(scopeId)"); + assert.ok(seedRead > 0, "the seeded config must be consulted"); + assert.ok(seedRead < blank, "consult the seed BEFORE blanking the composer"); + assert.ok(seedRead < fetchCall, "consult the seed BEFORE refetching"); + assert.match(fn, /if \(seeded\) \{\s*applySelectedRuntime\(seeded, agent\);\s*return;\s*\}/); + assert.match(fn, /seededRuntime\?\.scopeId === scopeKey \? seededRuntime\.config : null/); + assert.match(fn, /const scopeKey = runtimeScopeKey\(scopeId\);/); + assert.doesNotMatch(fn, /seededRuntime = null;/, "every pane booting on the seeded scope may read the seed"); + const change = composer.slice(composer.indexOf("async function changeScopeRuntime")); + assert.match( + change.slice(0, change.indexOf("\n }")), + /seededRuntime = null;/, + "changing the scope default is what retires the boot seed", + ); +}); + +test("a still-loading composer is not painted as a failure", () => { + const branch = composer.slice( + composer.indexOf("} else if (!approvalPauses.length && runtimePending) {"), + composer.indexOf("} else if (composerState.error) {"), + ); + assert.ok(branch, "the runtime-pending branch not found"); + assert.match(branch, /composerState\.error\s*\?/, "the branch must split on a real error"); + assert.match(branch, /class="composer-note">Loading runtime settings…/, "loading is a note"); + const loadingAt = branch.indexOf("Loading runtime settings…"); + const errorClassAt = branch.indexOf('class="composer-error"'); + assert.ok(errorClassAt >= 0 && errorClassAt < loadingAt, "only the real error keeps the error class"); + assert.ok( + !/class="composer-error">[\s\S]{0,80}Loading runtime settings…/.test(branch), + "the loading placeholder must not render inside .composer-error", + ); + const retryAt = branch.indexOf("Retry"); + assert.ok(retryAt >= 0 && retryAt < loadingAt, "Retry stays on the error side"); +}); + +test(".composer-error is the destructive colour — which is why loading must not use it", () => { + assert.match(css, /^\.composer-error \{\n {2}color: var\(--destructive/m); +}); + +test("a personal mount passing null still matches the seeded personal scope", () => { + const resolver = composer.slice( + composer.indexOf("function runtimeScopeKey"), + composer.indexOf("function modelOptionFor"), + ); + assert.ok(resolver, "runtimeScopeKey not found"); + assert.match(resolver, /if \(scopeId\) return scopeId;/, "a named scope is used as-is"); + assert.match(resolver, /return user \? `personal:\$\{user\}` : null;/, "null resolves to the personal scope"); + assert.match(composer, /seededRuntime = \{ scopeId: runtimeScopeKey\(scopeId\), config \};/); + assert.match(composer, /scopeKey !== null && seededRuntime\?\.scopeId === scopeKey/); +}); diff --git a/plugins/web-ui/test/split-canvas-entry.test.ts b/plugins/web-ui/test/split-canvas-entry.test.ts index 5da5d8f..c9a1137 100644 --- a/plugins/web-ui/test/split-canvas-entry.test.ts +++ b/plugins/web-ui/test/split-canvas-entry.test.ts @@ -6,11 +6,14 @@ const read = (f: string): string => readFileSync(new URL(`../src/${f}`, import.m const split = read("split.ts"); const sessions = read("sessions.ts"); const shell = read("shell.ts"); +const chat = read("chat.ts"); const layout = read("split-layout.ts"); const css = read("shell.css"); const fn = (src: string, name: string): string => { - const body = src.match(new RegExp(`^(?:export )?function ${name}\\([\\s\\S]*?\\n\\}`, "m"))?.[0] ?? ""; + const top = src.match(new RegExp(`^(?:export )?(?:async )?function ${name}\\([\\s\\S]*?\\n\\}`, "m"))?.[0]; + const nested = src.match(new RegExp(`^ {2}(?:async )?function ${name}\\([\\s\\S]*?\\n {2}\\}`, "m"))?.[0]; + const body = top ?? nested ?? ""; assert.ok(body, `${name} not found`); return body; }; @@ -22,12 +25,15 @@ test("no new-chat affordance can cost the user their canvas", () => { assert.match(add, /return true;\n\}$/, "having split, the canvas owns the click"); assert.doesNotMatch(add.slice(add.indexOf("splitPane(")), /return false/, "a full canvas must not fall through"); - assert.match(shell, /if \(!addBlankPane\(\)\) newChat\(\);/); + assert.match(shell, /if \(!addBlankPane\(\)\) mainConversation\(\)\.newChat\(\);/); const plus = fn(sessions, "startProjectChat"); const claimed = plus.indexOf("addBlankPane(scopeId)"); assert.ok(claimed > 0, "the project + must offer the click to the canvas"); assert.match(plus.slice(claimed), /^addBlankPane\(scopeId\)\) return;/m, "and bail out when the canvas takes it"); - assert.ok(plus.indexOf("addPendingSession(newChat(") > claimed, "only then may it mount a single chat"); + assert.ok( + plus.indexOf("addPendingSession(mainConversation().newChat(") > claimed, + "only then may it mount a single chat", + ); assert.match(fn(split, "exitSplitIfActive"), /splitState\.active = false;/); assert.doesNotMatch(fn(split, "exitSplitIfActive"), /removeItem|lastLayout = null/); @@ -35,15 +41,29 @@ test("no new-chat affordance can cost the user their canvas", () => { test("a pane opened from a project's + starts its chat in that project", () => { assert.match(split, /scopeId\?: string;/, "PaneParams must carry the project"); - const src = fn(split, "paneSrc"); - assert.match(src, /scope=\$\{encodeURIComponent\(params\.scopeId\)\}/); - assert.match(src, /if \(sessionId\) return/, "an adopted session still wins over the seed scope"); - - const embed = shell.match(/if \(embedMode\) \{[\s\S]*?\n {4}\}\n {4}return;/)?.[0] ?? ""; - assert.ok(embed, "the embed boot branch not found"); - assert.match(embed, /await ensureContexts\(\)\)\.find\(\(c\) => c\.scopeId === scope\)/); - assert.doesNotMatch(embed, /newChat\(\{ scopeId: scope/, "the URL's scope must not reach newChat unchecked"); - assert.match(embed, /newChat\(context \? \{ scopeId: context\.scopeId/); + const load = split.match(/private async load\(\): Promise \{[\s\S]*?\n {2}\}/)?.[0] ?? ""; + assert.ok(load, "the pane loader not found"); + const seeded = load.indexOf("contextsState.list.find((c) => c.scopeId === scopeId)"); + assert.ok(seeded > 0, "the seed scope must resolve against contexts the viewer can actually use"); + assert.ok(load.indexOf("if (!wanted)") < seeded, "an adopted session still wins over the seed scope"); + assert.doesNotMatch(load, /newChat\(\{ scopeId: scopeId/, "an unchecked scope must not reach newChat"); + assert.match(load, /newChat\(context \? \{ scopeId: context\.scopeId/); +}); + +test("a pane is an element in this document — never a second copy of the app", () => { + assert.doesNotMatch(split, /iframe/i, "panes must not reload the whole SPA per conversation"); + assert.doesNotMatch(split, /postMessage/, "same-document panes talk by call, not by message"); + assert.doesNotMatch(split, /defaultRenderer: "always"/, "a pane behind a tab must cost nothing until shown"); + assert.match(split, /createConversation\(\{/, "each pane owns a conversation instance"); + assert.match(split, /disposeConversation\(this\.conversation\)/, "and releases it when the pane closes"); + const load = split.match(/private async load\(\): Promise \{[\s\S]*?\n {2}\}/)?.[0] ?? ""; + assert.match( + load, + /if \(this\.loaded \|\| this\.disposed\) return;/, + "a pane loads its transcript once, and never after it closes", + ); + assert.match(load, /if \(this\.disposed\) return;/, "and drops the continuation if the pane closed mid-load"); + assert.match(split, /onDidVisibilityChange\(\(e\) => \{\s*\n\s*if \(e\.isVisible\) void this\.load\(\);/); }); test("a conversation dropped on a pane's tab strip joins that pane — and only there", () => { @@ -94,3 +114,44 @@ test("the tile cap only judges dockview's own panel drags", () => { assert.ok(bail > 0, "a foreign drag must be waved through"); assert.ok(bail < hold.indexOf("dropAddsTile"), "before the tile arithmetic, not after"); }); + +test("boot mounts a restored canvas before it awaits the session list", () => { + const boot = shell.match(/export async function boot\(\): Promise \{[\s\S]*?\n\}/)?.[0] ?? ""; + assert.ok(boot, "boot not found"); + const early = boot.indexOf("if (bareEntry && !restoredCanvasNeedsSessionList()) mountRestoredCanvas();"); + const listAwait = boot.indexOf("await refreshSessions({ showLoading: true });"); + assert.ok(early > 0, "boot must offer the canvas its head start"); + assert.ok(listAwait > 0, "boot still loads the session list"); + assert.ok(early < listAwait, "the mount must come BEFORE the list fetch the panes never read"); + + assert.match(boot, /const bareEntry = !viewIntent && !wantedSession && wanted !== "app-edit" && !connectedProvider;/); + + assert.match( + boot.slice(listAwait), + /\} else if \(!mountRestoredCanvas\(\) && !mainConversation\(\)\.state\.threadRef\) \{/, + ); + const mount = fn(split, "mountRestoredCanvas"); + assert.match(mount, /^ {2}if \(splitState\.active && \(dockApi\?\.panels\.length \?\? 0\) > 0\) return true;/m); +}); + +test("boot's fallback never replaces a chat the user mounted during the wait", () => { + const boot = shell.match(/export async function boot\(\): Promise \{[\s\S]*?\n\}/)?.[0] ?? ""; + const listAwait = boot.indexOf("await refreshSessions({ showLoading: true });"); + const tail = boot.slice(listAwait); + assert.match(tail, /\} else if \(!mountRestoredCanvas\(\) && !mainConversation\(\)\.state\.threadRef\) \{/); + const guard = tail.indexOf("!mainConversation().state.threadRef"); + const mint = tail.indexOf("newChat();", guard); + assert.ok(guard > 0 && mint > guard, "the guard must gate the mint, not follow it"); + + assert.match(chat, /^ {2}function mountContinuable\(/m); + assert.match(fn(chat, "mountContinuable"), /chatState\.threadRef = threadRef;/); + + const reconcile = fn(split, "reconcileAfterClose"); + assert.match( + reconcile, + /exitSplitIfActive\(\);\s*\n\s*mainConversation\(\)\.newChat\(\);/, + "a blank lone survivor mounts a new chat", + ); + assert.match(reconcile, /void maximizePane\(params\);/, "a lone survivor with a session is maximized"); + assert.match(fn(split, "exitSplitIfActive"), /splitState\.active = false;/); +}); diff --git a/src/api/app-sessions.ts b/src/api/app-sessions.ts index e12f5f8..9502a5f 100644 --- a/src/api/app-sessions.ts +++ b/src/api/app-sessions.ts @@ -24,6 +24,7 @@ export function createSessionMethods( App, | "getSession" | "getSessionForViewer" + | "getSessionEntryForViewer" | "listFilesForViewer" | "uploadFileForViewer" | "openFileForViewer" @@ -90,6 +91,15 @@ export function createSessionMethods( return { session, entries: w.entries, ...(w.earlier > 0 ? { earlierEntries: w.earlier } : {}) }; }, + async getSessionEntryForViewer(sessionId, principalId, seq) { + const session = (await sessionsForViewer(principalId)).find((s) => s.id === sessionId); + if (!session) return null; + const entry = transcriptEntries(await deps.sessions.visibleEntries(sessionId, principalId)).find( + (e) => e.seq === seq, + ); + return entry ? { entry } : null; + }, + listFilesForViewer(principalId, opts, inScope) { return filesForViewer(principalId, opts, inScope); }, diff --git a/src/api/app-types.ts b/src/api/app-types.ts index f2b461e..7ab7c2b 100644 --- a/src/api/app-types.ts +++ b/src/api/app-types.ts @@ -13,7 +13,7 @@ import type { OutgoingAttachment } from "../types.ts"; import type { Readable } from "node:stream"; import { type FileArtifact, type FileArtifactStore, type ListOwnedOptions } from "../files/file-artifact-store.ts"; import type { IdentityService } from "../identity/identity-service.ts"; -import type { SessionStore } from "../sessions/session-store.ts"; +import type { SessionStore, TranscriptEntry } from "../sessions/session-store.ts"; import { type Sandbox } from "../sandbox/sandbox.ts"; import type { ProcessRegistry } from "../processes/process-registry.ts"; import type { MonitorStore } from "../monitors/monitor-store.ts"; @@ -243,12 +243,17 @@ export interface App { getSession( sessionId: string, window?: TranscriptWindow, - ): Promise<{ session: Session; entries: SessionEntry[]; earlierEntries?: number } | null>; + ): Promise<{ session: Session; entries: TranscriptEntry[]; earlierEntries?: number } | null>; getSessionForViewer( sessionId: string, principalId: string, window?: TranscriptWindow, - ): Promise<{ session: Session; entries: SessionEntry[]; earlierEntries?: number } | null>; + ): Promise<{ session: Session; entries: TranscriptEntry[]; earlierEntries?: number } | null>; + getSessionEntryForViewer( + sessionId: string, + principalId: string, + seq: number, + ): Promise<{ entry: SessionEntry } | null>; listSessions(principalId: string): Promise; sessionBackground(sessionId: string, viewer: string): Promise; readSessionBackgroundOutput( diff --git a/src/api/routes/surface.ts b/src/api/routes/surface.ts index 397a558..958babf 100644 --- a/src/api/routes/surface.ts +++ b/src/api/routes/surface.ts @@ -106,6 +106,20 @@ async function getSession(ctx: ApiCtx): Promise { return sendJson(res, 200, found); } +async function getSessionEntry(ctx: ApiCtx): Promise { + const { res, app, url } = ctx; + const id = ctx.params.id!; + const viewer = url.searchParams.get("viewer"); + if (!viewer) return sendJson(res, 400, { error: "bad_request", message: "viewer required" }); + const seq = Number(ctx.params.seq); + if (!Number.isInteger(seq) || seq < 0) { + return sendJson(res, 400, { error: "bad_request", message: "seq must be a non-negative integer" }); + } + const found = await app.getSessionEntryForViewer(id, viewer, seq); + if (!found) return sendJson(res, 404, { error: "not_found" }); + return sendJson(res, 200, found); +} + async function listSessionApprovals(ctx: ApiCtx): Promise { const { res, app, url } = ctx; const id = ctx.params.id!; @@ -1131,6 +1145,7 @@ export const surfaceRoutes: ReadonlyArray> = [ auth: "source", handle: getSessionBackgroundOutput, }, + { method: "GET", path: "/v1/sessions/:id/entries/:seq", auth: "source", handle: getSessionEntry }, { method: "GET", path: "/v1/sessions/:id", auth: "source", handle: getSession }, { method: "GET", path: "/v1/files/:id/content", auth: "source", handle: getFileContent }, { method: "POST", path: "/v1/files/upload", auth: "source", handle: uploadFile }, diff --git a/src/api/user-scoped-routes.ts b/src/api/user-scoped-routes.ts index 032788f..9947905 100644 --- a/src/api/user-scoped-routes.ts +++ b/src/api/user-scoped-routes.ts @@ -10,6 +10,7 @@ function pat(method: string, template: string, field?: Field): Rule { const USER_SCOPED: Rule[] = [ pat("GET", "/v1/sessions/:id", { in: "query", name: "viewer" }), + pat("GET", "/v1/sessions/:id/entries/:seq", { in: "query", name: "viewer" }), pat("GET", "/v1/sessions/:id/approvals", { in: "query", name: "viewer" }), pat("GET", "/v1/sessions/:id/background", { in: "query", name: "viewer" }), pat("GET", "/v1/sessions/:id/background/:pid/output", { in: "query", name: "viewer" }), diff --git a/src/sessions/session-store.ts b/src/sessions/session-store.ts index ec08712..9b5a544 100644 --- a/src/sessions/session-store.ts +++ b/src/sessions/session-store.ts @@ -289,10 +289,78 @@ export function transcriptEntries(entries: readonly SessionEntry[]): SessionEntr return entries.filter((e) => e.type !== "soul"); } +export const TRANSCRIPT_BYTE_BUDGET = 400_000; +export const ENTRY_STRING_BUDGET = 2_000; + +export type TranscriptEntry = SessionEntry & { truncated?: true }; + +const PROJECTED_TYPES: ReadonlySet = new Set(["tool_call", "tool_result"]); +const WALK_DEPTH = 8; + +function shortenStrings(value: unknown, depth: number): { value: unknown; truncated: boolean } { + if (typeof value === "string") { + return value.length > ENTRY_STRING_BUDGET + ? { value: value.slice(0, ENTRY_STRING_BUDGET), truncated: true } + : { value, truncated: false }; + } + if (value === null || typeof value !== "object") return { value, truncated: false }; + if (depth >= WALK_DEPTH) { + return payloadBytes(value, depth) > ENTRY_STRING_BUDGET + ? { value: null, truncated: true } + : { value, truncated: false }; + } + let truncated = false; + if (Array.isArray(value)) { + const next = value.map((item) => { + const walked = shortenStrings(item, depth + 1); + truncated ||= walked.truncated; + return walked.value; + }); + return truncated ? { value: next, truncated } : { value, truncated }; + } + const next: Record = {}; + for (const [key, item] of Object.entries(value)) { + const walked = shortenStrings(item, depth + 1); + truncated ||= walked.truncated; + next[key] = walked.value; + } + return truncated ? { value: next, truncated } : { value, truncated }; +} + +function postsToTheConversation(entry: SessionEntry): boolean { + const p = entry.payload as { action?: unknown } | null; + return entry.type === "tool_call" && p?.action === "post"; +} + +function projectEntry(entry: SessionEntry): TranscriptEntry { + if (!PROJECTED_TYPES.has(entry.type) || postsToTheConversation(entry)) return entry; + const walked = shortenStrings(entry.payload, 0); + return walked.truncated ? { ...entry, payload: walked.value, truncated: true } : entry; +} + +function payloadBytes(value: unknown, depth: number): number { + if (typeof value === "string") return value.length + 2; + if (value === null || typeof value !== "object") return 8; + if (depth >= WALK_DEPTH) { + try { + return JSON.stringify(value)?.length ?? 8; + } catch { + return 8; + } + } + let bytes = 2; + if (Array.isArray(value)) { + for (const item of value) bytes += payloadBytes(item, depth + 1) + 1; + return bytes; + } + for (const [key, item] of Object.entries(value)) bytes += key.length + payloadBytes(item, depth + 1) + 4; + return bytes; +} + export function windowedTranscript( entries: SessionEntry[], window?: { tailTurns?: number; sinceSeq?: number; beforeSeq?: number }, -): { entries: SessionEntry[]; earlier: number } { +): { entries: TranscriptEntry[]; earlier: number } { if (window?.beforeSeq !== undefined) { const at = entries.findIndex((e) => e.seq >= window.beforeSeq!); entries = entries.slice(0, at < 0 ? entries.length : at); @@ -311,7 +379,21 @@ export function windowedTranscript( } } } - return { entries: cut > 0 ? entries.slice(cut) : entries, earlier: cut }; + const windowed = (cut > 0 ? entries.slice(cut) : entries).map(projectEntry); + if (window === undefined || window.sinceSeq !== undefined) return { entries: windowed, earlier: cut }; + let spend = 0; + let from = windowed.length; + while (from > 0) { + const bytes = payloadBytes(windowed[from - 1]!.payload, 0); + if (spend + bytes > TRANSCRIPT_BYTE_BUDGET && from < windowed.length) break; + spend += bytes; + from--; + } + if (from > 0) { + const boundary = windowed.findIndex((e, i) => i >= from && e.type === "user"); + if (boundary > 0) from = boundary; + } + return { entries: from > 0 ? windowed.slice(from) : windowed, earlier: cut + from }; } export function isOverheardEntry(e: Pick): boolean { diff --git a/test/transcript-window.test.ts b/test/transcript-window.test.ts index 1903825..c680348 100644 --- a/test/transcript-window.test.ts +++ b/test/transcript-window.test.ts @@ -8,7 +8,7 @@ import { join } from "node:path"; import type { AddressInfo } from "node:net"; import { createInsecureTestServer } from "../src/api/server.ts"; import { buildApp, type BuiltApp } from "../src/wiring.ts"; -import { windowedTranscript } from "../src/sessions/session-store.ts"; +import { ENTRY_STRING_BUDGET, TRANSCRIPT_BYTE_BUDGET, windowedTranscript } from "../src/sessions/session-store.ts"; import type { SessionEntry } from "../src/types.ts"; import { scopeId } from "../src/types.ts"; import { testConfig } from "./support/test-config.ts"; @@ -123,6 +123,51 @@ test("windowedTranscript: beforeSeq alone truncates; wider-than-history pages re ); }); +function fat(seq: number, type: SessionEntry["type"], chars: number): SessionEntry { + return { ...entry(seq, type), payload: { tool: "execute", output: "x".repeat(chars) } }; +} + +test("windowedTranscript: a fat tool payload is previewed, and the entry says so", () => { + const log = [entry(0, "user"), fat(1, "tool_call", 50_000), fat(2, "tool_result", 50_000), entry(3, "assistant")]; + const w = windowedTranscript(log, { tailTurns: 1 }); + assert.equal(w.entries.length, 4); + for (const e of w.entries.filter((x) => x.type === "tool_call" || x.type === "tool_result")) { + assert.equal((e.payload as { output: string }).output.length, ENTRY_STRING_BUDGET); + assert.equal(e.truncated, true); + } + assert.equal(w.entries[0]!.truncated, undefined, "a small entry is shipped whole and unmarked"); +}); + +test("windowedTranscript: conversation text is never truncated — only tool payloads are", () => { + const said = "y".repeat(50_000); + const log = [ + { ...entry(0, "user"), payload: { text: said } }, + { ...entry(1, "assistant"), payload: { text: said } }, + { ...entry(2, "thinking"), payload: { text: said } }, + ]; + for (const e of windowedTranscript(log).entries) { + assert.equal((e.payload as { text: string }).text.length, said.length); + assert.equal(e.truncated, undefined); + } +}); + +test("windowedTranscript: the byte budget drops the oldest entries and counts them as earlier", () => { + const log = [entry(0, "user")]; + for (let seq = 1; seq <= 400; seq++) log.push(fat(seq, "tool_result", ENTRY_STRING_BUDGET)); + const w = windowedTranscript(log, { tailTurns: 99 }); + const shipped = w.entries.reduce((a, e) => a + JSON.stringify(e.payload).length, 0); + assert.ok(shipped <= TRANSCRIPT_BYTE_BUDGET * 1.1, `shipped ${shipped} must respect the budget`); + assert.ok(w.entries.length < log.length, "the budget must bite"); + assert.equal(w.entries.length + w.earlier, log.length, "everything dropped is counted as earlier"); + assert.equal(w.entries[w.entries.length - 1]!.seq, 400, "the newest end is what survives"); +}); + +test("windowedTranscript: one entry over budget still ships — a window is never empty", () => { + const w = windowedTranscript([fat(0, "user", TRANSCRIPT_BYTE_BUDGET * 2)], { tailTurns: 1 }); + assert.equal(w.entries.length, 1); + assert.equal(w.earlier, 0); +}); + function start(): { base: string; built: BuiltApp; close: () => Promise } { const built = buildApp(testConfig({ dataDir: mkdtempSync(join(tmpdir(), "transcript-window-")) })); const server = createInsecureTestServer(built.app, { @@ -189,6 +234,19 @@ test("GET /v1/sessions/:id honors tailTurns/sinceSeq and reports earlierEntries" ); assert.equal(beforeBody.earlierEntries, userSeqs[1], "what remains before this page"); + const seq = fullBody.entries[0]!.seq; + const one = await fetch(`${srv.base}/v1/sessions/${encodeURIComponent(sessionId)}/entries/${seq}?viewer=U1`); + assert.equal(one.status, 200); + const oneBody = (await one.json()) as { entry: SessionEntry }; + assert.deepEqual(oneBody.entry, fullBody.entries[0], "the whole entry, exactly as stored"); + + const missing = await fetch(`${srv.base}/v1/sessions/${encodeURIComponent(sessionId)}/entries/99999?viewer=U1`); + assert.equal(missing.status, 404, "a seq that isn't in this session"); + const stranger = await fetch(`${srv.base}/v1/sessions/${encodeURIComponent(sessionId)}/entries/${seq}?viewer=U2`); + assert.equal(stranger.status, 404, "a viewer who cannot see the session cannot see its entries"); + const noViewer = await fetch(`${srv.base}/v1/sessions/${encodeURIComponent(sessionId)}/entries/${seq}`); + assert.equal(noViewer.status, 400, "viewer is required"); + const bad = await fetch(`${srv.base}/v1/sessions/${encodeURIComponent(sessionId)}?viewer=U1&tailTurns=0`); assert.equal(bad.status, 400, "tailTurns must be a positive integer"); const badSince = await fetch(`${srv.base}/v1/sessions/${encodeURIComponent(sessionId)}?viewer=U1&sinceSeq=-1`); @@ -199,3 +257,58 @@ test("GET /v1/sessions/:id honors tailTurns/sinceSeq and reports earlierEntries" await srv.close(); } }); + +test("windowedTranscript: an unwindowed read is still whole — fork cutoffs count on it", () => { + const log = [entry(0, "user")]; + for (let seq = 1; seq <= 400; seq++) log.push(fat(seq, "tool_result", ENTRY_STRING_BUDGET * 3)); + const all = windowedTranscript(log); + assert.equal(all.entries.length, log.length, "no window asked for, nothing dropped"); + assert.equal(all.earlier, 0); + assert.equal(all.entries[1]!.truncated, true, "though fat payloads are still previewed"); +}); + +test("windowedTranscript: the text a post tool call puts in the conversation is never previewed", () => { + const said = "z".repeat(50_000); + const log = [ + entry(0, "user"), + { ...entry(1, "tool_call"), payload: { tool: "reach", action: "post", text: said, callId: "c1" } }, + { ...entry(2, "tool_result"), payload: { tool: "reach", callId: "c1", ok: true } }, + ]; + const posted = windowedTranscript(log, { tailTurns: 1 }).entries[1]!; + assert.equal((posted.payload as { text: string }).text.length, said.length, "the agent's reply is conversation text"); + assert.equal(posted.truncated, undefined); +}); + +test("windowedTranscript: a deeply nested payload cannot smuggle bytes past the budget", () => { + const deep = (depth: number, leaf: unknown): unknown => (depth === 0 ? leaf : { nest: deep(depth - 1, leaf) }); + const log = [entry(0, "user")]; + for (let seq = 1; seq <= 6; seq++) { + log.push({ ...entry(seq, "tool_result"), payload: { tool: "execute", out: deep(12, "q".repeat(300_000)) } }); + } + const w = windowedTranscript(log, { tailTurns: 1 }); + const shipped = JSON.stringify(w.entries).length; + assert.ok(shipped < TRANSCRIPT_BYTE_BUDGET * 2, `a nested subtree must be charged its real size, shipped ${shipped}`); +}); + +test("windowedTranscript: the byte cut lands on a turn boundary, so a call keeps its result", () => { + const log: SessionEntry[] = []; + let seq = 0; + const said = (n: number): SessionEntry => ({ ...entry(n, "assistant"), payload: { text: "s".repeat(20_000) } }); + for (let turn = 0; turn < 40; turn++) { + log.push(entry(seq++, "user")); + log.push(fat(seq++, "tool_call", ENTRY_STRING_BUDGET * 2)); + log.push(fat(seq++, "tool_result", ENTRY_STRING_BUDGET * 2)); + log.push(said(seq++)); + } + const w = windowedTranscript(log, { tailTurns: 99 }); + assert.ok(w.earlier > 0, "the budget must bite for this to mean anything"); + assert.equal(w.entries[0]!.type, "user", "a page opens on a turn, never mid-turn"); +}); + +test("windowedTranscript: a sinceSeq re-read is never trimmed — it refreshes what the client already holds", () => { + const log = [entry(0, "user")]; + for (let seq = 1; seq <= 400; seq++) log.push(fat(seq, "tool_result", ENTRY_STRING_BUDGET * 3)); + const w = windowedTranscript(log, { sinceSeq: 1 }); + assert.equal(w.entries.length, log.length - 1, "a refresh that shrinks the window makes read messages vanish"); + assert.equal(w.earlier, 1); +}); From 8967b660378dd540c959f91e0fc886373018aff4 Mon Sep 17 00:00:00 2001 From: Joshua France Date: Thu, 30 Jul 2026 13:43:42 -0700 Subject: [PATCH 04/17] fix(web-ui): restore the pane conversation's height chain lost with the iframes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-view change mounts a conversation's .custom-chat directly into .split-pane-chat, but that wrapper was display: block — a leftover from when it styled a replaced iframe whose own document supplied html/body height. .custom-chat sizes itself with flex: 1 / min-height: 0, which is meaningless inside a block parent, so a pane's conversation collapsed to content height: the transcript never scroll-contained and the composer trailed the last message (mid-pane on short chats, clipped past the pane on long ones). Make .split-pane-chat the same flex column the full-screen .main already is, so panes and full screen give the conversation one height chain. --- plugins/web-ui/src/shell.css | 3 ++- plugins/web-ui/test/split-canvas-entry.test.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index 20b0be3..d8a5203 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -6395,7 +6395,8 @@ body.resizing-sidebar { } .split-pane-chat { - display: block; + display: flex; + flex-direction: column; width: 100%; height: 100%; overflow: hidden; diff --git a/plugins/web-ui/test/split-canvas-entry.test.ts b/plugins/web-ui/test/split-canvas-entry.test.ts index c9a1137..6a3e921 100644 --- a/plugins/web-ui/test/split-canvas-entry.test.ts +++ b/plugins/web-ui/test/split-canvas-entry.test.ts @@ -155,3 +155,18 @@ test("boot's fallback never replaces a chat the user mounted during the wait", ( assert.match(reconcile, /void maximizePane\(params\);/, "a lone survivor with a session is maximized"); assert.match(fn(split, "exitSplitIfActive"), /splitState\.active = false;/); }); + +test("a pane gives the conversation the same height chain the full-screen .main does", () => { + // .custom-chat sizes itself with flex: 1 / min-height: 0, so every container it + // mounts into must be a flex column of definite height — .main is; the pane + // wrapper must be too, or the transcript never scroll-contains and the composer + // trails the content instead of pinning to the pane's bottom edge. + const main = css.match(/^\.main \{[^}]*\}/m)?.[0] ?? ""; + assert.match(main, /display: flex;/); + assert.match(main, /flex-direction: column;/); + const pane = css.match(/^\.split-pane-chat \{[^}]*\}/m)?.[0] ?? ""; + assert.match(pane, /display: flex;/, "the pane wrapper must be a flex container"); + assert.match(pane, /flex-direction: column;/, "…a column, like .main"); + assert.match(pane, /height: 100%;/, "…of definite height"); + assert.match(pane, /overflow: hidden;/, "…that clips instead of growing the pane"); +}); From e8c451fac09e2c46af66268448db3ee4d191aa27 Mon Sep 17 00:00:00 2001 From: Joshua France Date: Thu, 30 Jul 2026 17:40:14 -0700 Subject: [PATCH 05/17] web-ui: set a scope's default model from its project page, and keep Slack's description honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web-ui): set a scope's default model from its project page, and keep Slack's description honest A scope's model default could only be changed from inside a chat ("Make default" next to the composer's picker) or by an admin in Governance, so the project page — the place members go to configure the project — could not say which model the project runs on, let alone change it. The page now carries a Model panel: the org default or any approved model, saved through the same /v1/runtime-config endpoint the composer writes, so the two can never disagree. Slack's channel description names that model, and it was only rewritten when a message arrived: the header ensurer hangs off inbound Slack traffic, and a default changed anywhere else (this page, an admin, the agent) left the description naming the old model until someone next posted. The config store now announces a scope's model change, the Slack plugin re-ensures that channel's header at once, and the header reads " Model: · " so a workspace running more than one agent can tell them apart. * fix(slack): a model change landing mid-ensure re-runs instead of being dropped Adversarial review found the one case where the push still left a lying description: the ensurer's in-flight guard dropped any call arriving while a probe was already running, so a default changed in that window was read from the old config and never re-checked. Concurrent calls now collapse into a single re-run when the in-flight one finishes. * fix(web-ui): make the project settings form's selects look like selects The base stylesheet strips a control's UA chrome, so a bare (a label points at it, the keyboard and the mobile picker work, the option list can't escape the viewport) and draws the shell's own lucide ChevronDown, inset 12px like every other control's content, with the text stopping 8px short of it. One rule replaces .list-select select, .deploy-sort select, .ambient-bot-mode and the two bare ones; a compact modifier covers the toolbar filters. * fix(web-ui): lift the ambient value mapping out of a nested ternary * fix(web-ui): model pin survives colons and delisting, and the ambient ordering test bites Adversarial review findings on this branch: - a model id containing ':' was truncated by destructured split; parse on the first colon only, matching how scope ids are split elsewhere - a pinned model no longer among the offered options silently displayed as 'Org default' while the hint claimed a pin; it now renders as an explicit selected option marked 'no longer offered' - the ambient field-ordering test matched ' = { user: "Treat like a person", }; +function ambientValue(enabled: boolean | null): string { + if (enabled === null) return "default"; + return enabled ? "on" : "off"; +} + function botRow(b: BotPolicyView, i: number): TemplateResult { return html`
${b.name} - + }, + options: BOT_MODES.map((m) => html``), + })} ${ b.mode === "rollup" ? html`
- - - - -
-

Automated posters

-

Control how messages from bots and integrations wake the agent.

+
+ + ${fieldSelect({ + id: "ambient-enabled", + className: "ambient-enabled-select", + focusKey: "ambient-enabled", + describedBy: "ambient-enabled-hint", + disabled: ambientPolicyState.saving, + value: ambientValue(ambientPolicyState.ambientEnabled), + onChange: (v) => { + ambientPolicyState.ambientEnabled = v === "default" ? null : v === "on"; + markDirty(); + }, + options: [ + html``, + html``, + html``, + ], + })} +

+ When off, the agent never acts on overheard messages here — it only responds to direct @mentions. Default: on + only when standing orders (or an action-mode bot) are set below — otherwise mention-only. +

- ${ambientPolicyState.bots.length ? html`
${ambientPolicyState.bots.map((b, i) => botRow(b, i))}
` : html`
No bots added. All bot posts are treated as activity.
`} -
{ - e.preventDefault(); - addBot(); - }} - > - + + +

+ Plain-language guidance for proactive work. Leave empty to respond only when addressed. +

+
+
+

Automated posters

+

Control how messages from bots and integrations wake the agent.

+ ${ambientPolicyState.bots.length ? html`
${ambientPolicyState.bots.map((b, i) => botRow(b, i))}
` : html`
No bots added. All bot posts are treated as activity.
`} + { + e.preventDefault(); + addBot(); + }} + > + { + ambientPolicyState.newBotName = (e.currentTarget as HTMLInputElement).value; + redraw(); + }} + /> + + +
${status ? html`
${status}
` : nothing} ${projectList} @@ -407,7 +406,6 @@ function contextCard(c: CoreContext): TemplateResult { function detailTpl(c: CoreContext): TemplateResult { const { title, sub, glyph } = contextMeta(c); const sessions = sessionsIn(c.scopeId); - const hasSettings = Boolean(c.project) || ambientPolicyApplies(c.scopeId); const completelyEmpty = sessions.length === 0 && scopeResourcesEmpty(c.scopeId); return html`
@@ -438,7 +436,7 @@ function detailTpl(c: CoreContext): TemplateResult {
-
+
${ completelyEmpty @@ -468,13 +466,10 @@ function detailTpl(c: CoreContext): TemplateResult { ` }
- ${ - hasSettings - ? html`` - : nothing - } +
`; @@ -1195,11 +1190,13 @@ function selectContext(scopeId: string | null): void { contextsState.resourcesNotice = ""; contextsState.resourcesLoading = false; resetAmbientPolicy(); + resetContextModel(); syncUrlFromState(); drawContexts(); if (scopeId) { void loadScopeResources(scopeId); void loadAmbientPolicy(scopeId, drawContexts); + void loadContextModel(scopeId, drawContexts); } } diff --git a/plugins/web-ui/src/deploys.ts b/plugins/web-ui/src/deploys.ts index 2ffa004..8c8ab54 100644 --- a/plugins/web-ui/src/deploys.ts +++ b/plugins/web-ui/src/deploys.ts @@ -3,7 +3,7 @@ import { live } from "lit/directives/live.js"; import { Archive, Check, Copy, ExternalLink, MoreHorizontal, Pencil, RotateCcw, X } from "lucide"; import { api, withBase } from "./core-bridge"; import { errMessage } from "../../chassis/src/errors"; -import { copyText, icon, relTime } from "./ui"; +import { copyText, fieldSelect, icon, relTime } from "./ui"; import { listBackLink, listPageTpl } from "./list-page"; import { contextsState, ensureContexts, scopeChip } from "./contexts"; import { appState } from "./shell"; @@ -305,19 +305,20 @@ function drawDeploysPage(): void { onRefresh: () => void renderDeploys(), action: { label: "Deploy with Agent", onClick: deployWithAgent }, controls: html`Newest`, + html``, + html``, + ], + })}`, search: { value: deployQuery, diff --git a/plugins/web-ui/src/files.ts b/plugins/web-ui/src/files.ts index 36f1f6c..2fc996b 100644 --- a/plugins/web-ui/src/files.ts +++ b/plugins/web-ui/src/files.ts @@ -2,7 +2,7 @@ import { html, nothing, render } from "lit"; import { File, Image, Upload } from "lucide"; import { api, reportSigninRequired, type SigninRequired, withBase } from "./core-bridge"; import { errMessage } from "../../chassis/src/errors"; -import { browserRenderableImage, formatBytes, icon, relTime } from "./ui"; +import { browserRenderableImage, fieldSelect, formatBytes, icon, relTime } from "./ui"; import { contextsState, ensureContexts, personalScopeId, scopeChip, scopeFilterControl } from "./contexts"; import { appState } from "./shell"; import { fileListNeedsAllPages } from "./file-list"; @@ -56,10 +56,12 @@ function selectControl( onChange: (value: string) => void, ) { return html`${label}${fieldSelect({ + compact: true, + value, + onChange, + options: options.map(([v, text]) => html``), + })}`; } diff --git a/plugins/web-ui/src/model-options.ts b/plugins/web-ui/src/model-options.ts index 2dd5361..5ee2c79 100644 --- a/plugins/web-ui/src/model-options.ts +++ b/plugins/web-ui/src/model-options.ts @@ -151,20 +151,28 @@ export function applyPickerModelIds(ids: readonly string[] | null | undefined, b }; } -export function applyRuntimeOptions( - scopeKey: string | null, +export function runtimeModelOptions( approvedHarnesses: readonly string[], modelsByHarness: Readonly>, - effective: { harnessId: string; modelId: string }, catalog: Readonly> = {}, -): void { - let options = approvedHarnesses.flatMap((harnessId) => { +): ModelOption[] { + const options = approvedHarnesses.flatMap((harnessId) => { const configured = buildOptions(modelsByHarness[harnessId] ?? [], harnessId, true, catalog); return configured.length ? configured : buildOptions(defaultModelIdsForHarness(harnessId), harnessId, true, catalog); }); - if (!options.length) options = buildOptions(DEFAULT_PICKER_MODEL_IDS); + return options.length ? options : buildOptions(DEFAULT_PICKER_MODEL_IDS); +} + +export function applyRuntimeOptions( + scopeKey: string | null, + approvedHarnesses: readonly string[], + modelsByHarness: Readonly>, + effective: { harnessId: string; modelId: string }, + catalog: Readonly> = {}, +): void { + const options = runtimeModelOptions(approvedHarnesses, modelsByHarness, catalog); const applied = { options, defaultValue: `${effective.harnessId}:${effective.modelId}` }; lastApplied = applied; if (scopeKey !== null) byScope.set(scopeKey, applied); diff --git a/plugins/web-ui/src/sessions.ts b/plugins/web-ui/src/sessions.ts index 7e803b3..5967732 100644 --- a/plugins/web-ui/src/sessions.ts +++ b/plugins/web-ui/src/sessions.ts @@ -56,7 +56,7 @@ import { type ChatBrowseStatus, } from "./session-list"; import { errMessage } from "../../chassis/src/errors"; -import { copyText, icon, relTime } from "./ui"; +import { copyText, fieldSelect, icon, relTime } from "./ui"; import { listPageTpl } from "./list-page"; import { contextsState, @@ -512,18 +512,19 @@ export function drawChatsPage(): void { )} All surfaces`, + html``, + html``, + ], + })} `, rows, diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index d8a5203..902eb6a 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -2934,6 +2934,14 @@ body.resizing-sidebar { font-size: 14px; box-sizing: border-box; } +.skill-form-page .field-select { + display: flex; +} +.skill-form-page .field-select > select { + min-height: 43px; + padding: 10px 36px 10px 11px; + font-size: 14px; +} .skill-form-page .skill-body-input { min-height: 320px; } @@ -3110,7 +3118,7 @@ body.resizing-sidebar { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } .skill-filter-fields .list-select, - .skill-filter-fields select { + .skill-filter-fields .field-select { min-width: 0; width: 100%; } @@ -4373,16 +4381,6 @@ body.resizing-sidebar { font-size: 11px; font-weight: 600; } -.list-select select { - min-height: 34px; - padding: 0 28px 0 9px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--background); - color: var(--foreground); - font: inherit; - font-size: 12px; -} .resource-tabs { width: min(960px, 100%); margin: 16px auto 0; @@ -5374,15 +5372,6 @@ body.resizing-sidebar { color: var(--muted-foreground); font-size: 12px; } -.deploy-sort select { - height: 32px; - padding: 0 24px 0 8px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--background); - color: var(--foreground); - font: inherit; -} .deploy-row-main { min-width: 0; display: grid; @@ -5946,7 +5935,80 @@ body.resizing-sidebar { flex-shrink: 0; opacity: 0.85; } - +.field-select { + position: relative; + min-width: 0; + display: inline-flex; + align-items: center; + max-width: 100%; + color: var(--foreground); +} +.field-select > select { + min-width: 0; + width: 100%; + min-height: 34px; + box-sizing: border-box; + padding: 0 36px 0 10px; + appearance: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + outline: 0; + background: var(--background); + color: inherit; + font: inherit; + font-size: 13px; + text-overflow: ellipsis; +} +.field-select > .icon { + position: absolute; + right: 12px; + opacity: 0.55; + pointer-events: none; +} +.field-select.compact > select { + min-height: 30px; + padding-right: 32px; + font-size: 12.5px; +} +.field-select.compact > .icon { + right: 10px; +} +.field-select > select:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--foreground) 30%, var(--border)); +} +.field-select > select:focus-visible { + border-color: var(--foreground); + outline: 2px solid color-mix(in srgb, var(--foreground) 65%, transparent); + outline-offset: 2px; +} +.field-select:has(> select:disabled) { + opacity: 0.55; +} +.field-select > select:disabled { + cursor: not-allowed; +} +.context-model { + display: flex; + flex-direction: column; + gap: 10px; +} +.context-model-select { + align-self: flex-start; + min-width: min(260px, 100%); +} +.context-model-hint { + margin: 0; + color: var(--muted-foreground); + font-size: 12px; + line-height: 1.45; +} +.context-model-status { + color: var(--muted-foreground); + font-size: 12px; +} +.context-model-status.error { + color: var(--destructive, #c0392b); +} .ambient-policy { display: flex; flex-direction: column; @@ -5957,30 +6019,28 @@ body.resizing-sidebar { font-size: 12px; line-height: 1.45; } -.ambient-field { +.ambient-group { display: flex; flex-direction: column; - gap: 3px; + gap: 6px; +} +.ambient-group + .ambient-group { + margin-top: 4px; + padding-top: 14px; + border-top: 1px solid var(--border); } .ambient-enabled-select { align-self: flex-start; - margin-bottom: 8px; + min-width: min(300px, 100%); } -.ambient-field-label, -.ambient-field-heading h3 { +.ambient-field-label { color: var(--foreground); font-size: 12.5px; font-weight: 600; } -.ambient-field-heading { - margin-top: 6px; -} -.ambient-field-heading h3 { +h3.ambient-field-label { margin: 0; } -.ambient-field-heading p { - margin: 3px 0 0; -} .ambient-orders { width: 100%; min-height: 112px; @@ -5997,7 +6057,6 @@ body.resizing-sidebar { line-height: 1.5; } .ambient-orders:focus-visible, -.ambient-bot-mode:focus-visible, .ambient-bot-hours input:focus-visible, .ambient-bot-add input:focus-visible { border-color: var(--foreground); @@ -6027,7 +6086,6 @@ body.resizing-sidebar { white-space: nowrap; font-size: 13px; } -.ambient-bot-mode, .ambient-bot-hours input, .ambient-bot-add input { box-sizing: border-box; @@ -6040,8 +6098,7 @@ body.resizing-sidebar { font-size: 12.5px; } .ambient-bot-mode { - max-width: 160px; - padding: 5px 7px; + max-width: 150px; } .ambient-bot-hours { display: inline-flex; @@ -6074,7 +6131,9 @@ body.resizing-sidebar { display: flex; align-items: center; gap: 10px; - padding-top: 2px; + margin-top: 4px; + padding-top: 14px; + border-top: 1px solid var(--border); } .ambient-policy-status { color: var(--muted-foreground); diff --git a/plugins/web-ui/src/skills.ts b/plugins/web-ui/src/skills.ts index 575db34..1840fed 100644 --- a/plugins/web-ui/src/skills.ts +++ b/plugins/web-ui/src/skills.ts @@ -3,7 +3,7 @@ import { Box } from "lucide"; import { api, type CoreContext } from "./core-bridge"; import type { SkillItem } from "./composer"; import { errMessage } from "../../chassis/src/errors"; -import { icon } from "./ui"; +import { fieldSelect, icon } from "./ui"; import { appState } from "./shell"; import { skillActions } from "./skill-actions"; import { @@ -381,18 +381,17 @@ function creatorPane() {