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/ambient-policy.ts b/plugins/web-ui/src/ambient-policy.ts index bb19509..27e70e6 100644 --- a/plugins/web-ui/src/ambient-policy.ts +++ b/plugins/web-ui/src/ambient-policy.ts @@ -1,6 +1,7 @@ import { html, nothing, type TemplateResult } from "lit"; import { api } from "./core-bridge"; import { errMessage } from "../../chassis/src/errors"; +import { fieldSelect } from "./ui"; export const BOT_MODES = ["ignore", "rollup", "action", "user"] as const; export type BotMode = (typeof BOT_MODES)[number]; @@ -153,22 +154,28 @@ const BOT_MODE_LABELS: Record = { 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(); + }} + /> + + +
-
` - : 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 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 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)} + 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; + } + 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(" "); } - 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 ""; } - 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(" "); + } + + 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} +
`; } - default: - return ""; + 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)} +
`; + } + 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..52612bd 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,1334 @@ 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 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` - + +
+ + + +
+
+
+ `; + } + + 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 sendControls(agent: Agent): TemplateResult { + if (!agent.state.isStreaming) { + return html``; + } + const canSteer = Boolean(composerState.draft.trim()); + const steerTitle = composerState.attachments.length + ? "Steer the running task (attachments stay for your next message)" + : "Steer the running task"; + return html` + + - ${ - open && !disabled - ? html` - + ` + : nothing + } + + `; + } -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(); + } + if (e.key !== "Enter" || e.shiftKey) return; + e.preventDefault(); + void sendPrompt(agent); + } + + 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) 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); + if (!ctx.chat.hasLiveRun()) { + // The turn is between run states: submitted but /api/turn hasn't returned the + // run id yet, or the stream is tearing down. Dropping the message here is a + // silent no-op the user reads as a dead composer — hold it and deliver when + // the run slot settles (steer the live run, or resend as an ordinary prompt). + steerWhenLive(agent, text, 0); return; } + await deliverSteer(agent, text); } - 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(); -} + async function deliverSteer(agent: Agent, text: string): Promise { + try { + const outcome = await ctx.chat.signalLiveRun("steer", text); + if (!outcome.ok) recoverEndedRunSteer(agent, text, outcome); + } catch (err) { + composerState.error = errMessage(err, "Could not steer the running task."); + ctx.chat.drawActiveChat(agent); + } + } -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); + function steerWhenLive(agent: Agent, text: string, attempt: number): void { + if (agent !== ctx.chat.state.agent) return; + if (ctx.chat.hasLiveRun()) { + void deliverSteer(agent, text); + return; + } + if (!agent.state.isStreaming) { + // The run ended without the slot ever going live — recover exactly like a + // steer that raced the run's end: resend the text as an ordinary prompt. + recoverEndedRunSteer(agent, text, {}); + return; + } + if (attempt < 40) { + window.setTimeout(() => steerWhenLive(agent, text, attempt + 1), 250); + return; + } + const last = agent.state.messages[agent.state.messages.length - 1] as + { role?: string; content?: unknown } | undefined; + if (last?.role === "user" && last.content === text) agent.state.messages.pop(); + // Don't clobber anything typed while the message was held: put the held text + // back in front of the newer draft instead of overwriting it. + composerState.draft = composerState.draft.trim() ? `${text}\n\n${composerState.draft}` : text; + composerState.error = "Could not deliver the message — the running task never settled. It is back in the composer."; + 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); + // The run ended before the steer landed (the client believed it was still live). + // Core either replayed the text as a fresh turn (`replayed`) or never stored it. + // Either way the message must not silently vanish: detach from the stale stream, + // then attach to the replay run — or resend the text as an ordinary prompt. + function recoverEndedRunSteer(agent: Agent, text: string, outcome: { replayed?: boolean }): void { + agent.abort(); + if (outcome.replayed) { + const last = agent.state.messages[agent.state.messages.length - 1] as + { role?: string; content?: unknown; steered?: boolean } | undefined; + // It is now an ordinary user turn in the transcript, not a mid-run steer. + if (last?.role === "user" && last.content === text && last.steered) delete last.steered; + ctx.chat.drawActiveChat(agent); + attachWhenIdle(agent, 0); + return; } - } 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 last = agent.state.messages[agent.state.messages.length - 1] as + { role?: string; content?: unknown } | undefined; + if (last?.role === "user" && last.content === text) agent.state.messages.pop(); + composerState.draft = text; + ctx.chat.drawActiveChat(agent); + resendWhenIdle(agent, text, 0); } -} -const LARGE_PASTE_CHARS = 2000; + function attachWhenIdle(agent: Agent, attempt: number): void { + if (agent !== ctx.chat.state.agent) return; + if (agent.state.isStreaming) { + if (attempt < 20) window.setTimeout(() => attachWhenIdle(agent, attempt + 1), 250); + return; + } + ctx.chat.resumeIfIdle(); + } -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 ( - 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); -} + function resendWhenIdle(agent: Agent, text: string, attempt: number): void { + if (agent !== ctx.chat.state.agent) return; + if (agent.state.isStreaming) { + if (attempt < 20) window.setTimeout(() => resendWhenIdle(agent, text, attempt + 1), 250); + else { + composerState.error = + "Could not deliver the message — the running task ended mid-send. It is back in the composer."; + ctx.chat.drawActiveChat(agent); + } + return; + } + if (composerState.draft === text) void sendPrompt(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 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); + } + } -async function fileToBase64(file: File): Promise { - return bytesToBase64(new Uint8Array(await file.arrayBuffer())); -} + const LARGE_PASTE_CHARS = 2000; -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()}`, + 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 || 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: file.name, - mimeType: file.type || "application/octet-stream", - size: file.size, - content: await fileToBase64(file), + 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 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); + 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); } -} -function dragHasFiles(e: DragEvent): boolean { - const types = e.dataTransfer?.types; - return types ? Array.from(types).includes("Files") : false; -} + async function fileToBase64(file: File): Promise { + return bytesToBase64(new Uint8Array(await file.arrayBuffer())); + } -export function onDragEnter(e: DragEvent): void { - if (!dragHasFiles(e)) return; - e.preventDefault(); - dragDepth += 1; - if (!composerState.dragging) { - composerState.dragging = true; - drawActiveChat(); + 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), + }; + } } -} -export function onDragOver(e: DragEvent): void { - if (!dragHasFiles(e)) return; - e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; -} + async function addFiles(files: File[], agent: Agent, folders: DropEntryLike[] = []): Promise { + if ( + (!files.length && !folders.length) || + ctx.chat.hasUnresolvedApproval() || + ctx.chat.state.resolvingApprovals.size > 0 + ) + 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); + } + } -export 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; - drawActiveChat(); + function dragHasFiles(e: DragEvent): boolean { + const types = e.dataTransfer?.types; + return types ? Array.from(types).includes("Files") : false; } -} -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 onDragEnter(e: DragEvent): void { + if (!dragHasFiles(e)) return; + e.preventDefault(); + dragDepth += 1; + if (!composerState.dragging) { + composerState.dragging = true; + ctx.chat.drawActiveChat(); + } + } -function pickFiles(): void { - if (hasUnresolvedApproval() || chatState.resolvingApprovals.size > 0 || chatState.agent?.state.isStreaming) return; - chatState.host?.querySelector(".file-input")?.click(); -} + function onDragOver(e: DragEvent): void { + if (!dragHasFiles(e)) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; + } -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 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(); + } + } -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); -} + 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 ?? [])); + ctx.chat.drawActiveChat(agent); + await addFiles(files, agent, folders); + } -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 pickFiles(): void { + if (ctx.chat.hasUnresolvedApproval() || ctx.chat.state.resolvingApprovals.size > 0) return; + ctx.chat.state.host?.querySelector(".file-input")?.click(); + } -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 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 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; - fastModeChargeTimer = null; - if (agent === chatState.agent) drawActiveChat(agent); - }, 760); + 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, scopeKey()); + const option = options.find((candidate) => candidate.model.id === current.model.id) ?? options[0]; + if (option) selectModel(option.value, agent); } -} -let autosizedTa: HTMLTextAreaElement | null = null; -let autosizedValue: string | null = null; -let autosizeObserver: ResizeObserver | null = null; + 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 (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; + } + 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); + } + } -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/connectors.ts b/plugins/web-ui/src/connectors.ts index f22d149..d178055 100644 --- a/plugins/web-ui/src/connectors.ts +++ b/plugins/web-ui/src/connectors.ts @@ -256,9 +256,12 @@ function addCredentialCard(): TemplateResult { return html`
- Secure drop + New credential

Add a credential

-

The secret goes directly to your encrypted keychain. It never enters chat or this page.

+

+ Describe the credential here, then paste the secret itself on a private one-time page. It goes straight to + your encrypted keychain — it is never shown in chat or stored on this page. +

${icon(LockKeyhole, 20)}
@@ -266,10 +269,11 @@ function addCredentialCard(): TemplateResult { secureDropUrl ? html`
- Secure form readyOpen it in a new tab to enter the credential. + Your one-time page is readyOpen it in a new tab and paste the secret there.
- Open secure formOpen the one-time page
` @@ -489,17 +493,6 @@ function drawConnectors(loading = false): void {
- +
@@ -552,7 +556,7 @@ function drawConnectors(loading = false): void {

Stored credentials

${keychainCredentials.length}
-

API keys, tokens, and files added through a one-time secure form.

+

API keys, tokens, and files you added through the one-time page.

${ @@ -727,12 +731,12 @@ async function createDrop(): Promise { body: JSON.stringify(submittedDraft), }); if (!keychainOperations.isCurrentEpoch(stateEpoch)) return; - if (!result.url) throw new Error("No secure form URL was returned."); + if (!result.url) throw new Error("No one-time page URL was returned."); secureDropUrl = result.url; - connectorNotice = "Secure credential form ready."; + connectorNotice = "Your one-time page is ready."; } catch (e) { if (!keychainOperations.isCurrentEpoch(stateEpoch)) return; - connectorNotice = errMessage(e, "Could not create the secure form."); + connectorNotice = errMessage(e, "Could not create the one-time page."); } finally { if (keychainOperations.isCurrentEpoch(stateEpoch)) { keychainOperations.finishDrop(stateEpoch); diff --git a/plugins/web-ui/src/context-model.ts b/plugins/web-ui/src/context-model.ts new file mode 100644 index 0000000..0b46557 --- /dev/null +++ b/plugins/web-ui/src/context-model.ts @@ -0,0 +1,162 @@ +import { html, nothing, type TemplateResult } from "lit"; +import { fetchRuntimeConfig, updateRuntimeConfig, type RuntimeConfig } from "./core-bridge"; +import { runtimeModelOptions, type ModelOption } from "./model-options"; +import { fieldSelect } from "./ui"; +import { errMessage } from "../../chassis/src/errors"; + +const INHERIT = ""; + +export const contextModelState = { + scope: null as string | null, + loading: false, + saving: false, + config: null as RuntimeConfig | null, + notice: "", + noticeKind: "" as "" | "saved" | "error", +}; + +let loadSeq = 0; +let redraw: () => void = () => {}; + +export function resetContextModel(): void { + loadSeq += 1; + contextModelState.scope = null; + contextModelState.loading = false; + contextModelState.saving = false; + contextModelState.config = null; + contextModelState.notice = ""; + contextModelState.noticeKind = ""; +} + +export async function loadContextModel(scopeId: string, onChange: () => void): Promise { + redraw = onChange; + if (contextModelState.scope === scopeId) return; + resetContextModel(); + const seq = ++loadSeq; + contextModelState.scope = scopeId; + contextModelState.loading = true; + const config = await fetchRuntimeConfig(scopeId); + if (seq !== loadSeq) return; + contextModelState.config = config; + contextModelState.loading = false; + if (!config) { + contextModelState.notice = "Couldn't load this project's model."; + contextModelState.noticeKind = "error"; + } + redraw(); +} + +function optionsFor(config: RuntimeConfig): ModelOption[] { + return runtimeModelOptions(config.approvedHarnesses, config.modelsByHarness, config.modelCatalog); +} + +function optionLabel(option: ModelOption, multiHarness: boolean): string { + return multiHarness ? `${option.harnessLabel} · ${option.label}` : option.label; +} + +function labelForRuntime(config: RuntimeConfig, runtime: { harnessId: string; modelId: string }): string { + const options = optionsFor(config); + const multiHarness = new Set(options.map((o) => o.harnessId)).size > 1; + const match = options.find((o) => o.value === `${runtime.harnessId}:${runtime.modelId}`); + return match ? optionLabel(match, multiHarness) : runtime.modelId; +} + +function selectedValue(config: RuntimeConfig): string { + return config.scopeOverride ? `${config.scopeOverride.harnessId}:${config.scopeOverride.modelId}` : INHERIT; +} + +async function choose(scope: string, value: string): Promise { + if (contextModelState.saving) return; + const seq = loadSeq; + contextModelState.saving = true; + contextModelState.notice = ""; + contextModelState.noticeKind = ""; + redraw(); + try { + const sep = value.indexOf(":"); + const config = await updateRuntimeConfig( + scope, + value === INHERIT ? { inherit: true } : { harnessId: value.slice(0, sep), modelId: value.slice(sep + 1) }, + ); + if (seq !== loadSeq) return; + contextModelState.config = config; + contextModelState.notice = `Saved — new conversations here run on ${labelForRuntime(config, config.effective)}.`; + contextModelState.noticeKind = "saved"; + } catch (e) { + if (seq !== loadSeq) return; + contextModelState.notice = errMessage(e, "Couldn't change the model — try again."); + contextModelState.noticeKind = "error"; + } finally { + if (seq === loadSeq) { + contextModelState.saving = false; + redraw(); + } + } +} + +export function contextModelSection(scopeId: string): TemplateResult | typeof nothing { + if (contextModelState.scope !== scopeId) return nothing; + if (contextModelState.loading) + return html`
+

Model

+
Loading…
+
`; + const config = contextModelState.config; + if (!config) + return html`
+

Model

+ ${contextModelState.notice} +
`; + const options = optionsFor(config); + const multiHarness = new Set(options.map((o) => o.harnessId)).size > 1; + const selected = selectedValue(config); + const stalePin = selected !== INHERIT && !options.some((o) => o.value === selected); + const isSlack = scopeId.startsWith("channel:"); + return html` +
+
+
+

Model

+

The model every conversation here starts on.

+
+
+ ${fieldSelect({ + id: "context-model-select", + className: "context-model-select", + focusKey: "context-model", + ariaLabel: "Default model for this project", + disabled: contextModelState.saving, + value: selected, + onChange: (value) => void choose(scopeId, value), + options: [ + html``, + ...options.map((o) => html``), + ...(stalePin + ? [ + html``, + ] + : []), + ], + })} +

+ ${ + selected === INHERIT + ? "Following the org default — it changes when the org's does." + : "Pinned for this project. Anyone in a chat can still pick a different model for that conversation." + } + ${isSlack ? " The channel description in Slack names this model." : ""} +

+ ${ + contextModelState.notice + ? html`${contextModelState.notice}` + : nothing + } +
+ `; +} diff --git a/plugins/web-ui/src/contexts.ts b/plugins/web-ui/src/contexts.ts index 5790af3..b008063 100644 --- a/plugins/web-ui/src/contexts.ts +++ b/plugins/web-ui/src/contexts.ts @@ -29,15 +29,16 @@ import { } from "./core-bridge"; import { UI_BASE } from "./deep-link"; import { errMessage } from "../../chassis/src/errors"; -import { actionSnippet, closeFormMenus, formatBytes, icon, initials, relTime, toggleFormMenu } from "./ui"; +import { actionSnippet, closeFormMenus, fieldSelect, 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"; import { cronRunSummary, cronRunSummaryTitle, cronScheduleSummary } from "./cron-format"; import { restoreDialogFocus } from "./dialog-focus"; -import { ambientPolicyApplies, ambientPolicySection, loadAmbientPolicy, resetAmbientPolicy } from "./ambient-policy"; +import { ambientPolicySection, loadAmbientPolicy, resetAmbientPolicy } from "./ambient-policy"; +import { contextModelSection, loadContextModel, resetContextModel } from "./context-model"; interface ScopeFile { id: string; @@ -180,6 +181,7 @@ export async function renderContexts(): Promise { ) { void loadScopeResources(contextsState.selected); void loadAmbientPolicy(contextsState.selected, drawContexts); + void loadContextModel(contextsState.selected, drawContexts); } drawContexts(); } @@ -224,6 +226,18 @@ export function personalScopeId(): string | null { return contextsState.list.find((c) => c.kind === "personal")?.scopeId ?? null; } +export function resolveProjectScope(contexts: readonly CoreContext[], slug: string): string | null { + if (slug.startsWith("channel:") || slug.startsWith("group:")) { + return contexts.some((context) => context.scopeId === slug) ? slug : null; + } + const normalized = slug.toLowerCase(); + const matches = contexts.filter((context) => { + const match = /^personal:([^@]+)@/.exec(context.scopeId); + return match?.[1]?.toLowerCase() === normalized; + }); + return matches.length === 1 ? matches[0]!.scopeId : null; +} + function metaForScope(scopeId: string | null, fallbackName?: string | null): { title: string; glyph: IconNode } { const c = scopeId ? contextsState.list.find((x) => x.scopeId === scopeId) : undefined; if (c) { @@ -364,18 +378,15 @@ function gridTpl(): TemplateResult { }} /> Active only`, html``], + })}
${status ? html`
${status}
` : nothing} ${projectList} @@ -407,7 +418,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 +448,7 @@ function detailTpl(c: CoreContext): TemplateResult {
-
+
${ completelyEmpty @@ -468,13 +478,10 @@ function detailTpl(c: CoreContext): TemplateResult { ` }
- ${ - hasSettings - ? html`` - : nothing - } +
`; @@ -1195,16 +1202,18 @@ 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); } } 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..d9c1fff --- /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..1e8f21d --- /dev/null +++ b/plugins/web-ui/src/conversations.ts @@ -0,0 +1,92 @@ +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 }); + } + // A run can start server-side for an open conversation without this tab asking + // for it (a steer replayed as a fresh turn after its run ended, a cron wake, a + // message from another surface). Attach the open view instead of waiting for a + // visibilitychange, so the new turn — and its triggering message — show up live. + if (event.state === "working") for (const conv of live) conv.resumeIfIdle(); + }, + () => 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..9f6a55b 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 { @@ -354,10 +363,12 @@ async function toCoreAttachment(a: PiAttachment): Promise { export class ApiError extends Error { status: number; - constructor(message: string, status: number) { + body: unknown; + constructor(message: string, status: number, body?: unknown) { super(message); this.name = "ApiError"; this.status = status; + this.body = body; } } @@ -391,7 +402,7 @@ export async function api(path: string, init?: RequestInit): Promis (body as { error?: string; message?: string })?.message ?? (body as { error?: string })?.error ?? `HTTP ${r.status}`; - throw new ApiError(msg, r.status); + throw new ApiError(msg, r.status, body); } return body as T; } @@ -431,19 +442,43 @@ 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 type SignalOutcome = { ok: true } | { ok: false; reason: string; replayed?: boolean }; + +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", - body: JSON.stringify({ kind, ...(text !== undefined ? { text } : {}) }), - }); + try { + await api(runPath(run.runId, "/signal"), { + method: "POST", + body: JSON.stringify({ kind, ...(text !== undefined ? { text } : {}) }), + }); + return { ok: true }; + } catch (err) { + // The run ended before (or as) the signal arrived. For a steer, core replays the + // text as a fresh turn when it can; surface that outcome instead of failing so the + // caller can attach to the replay run or resend, rather than dropping the message. + if (err instanceof ApiError && (err.status === 409 || err.status === 404)) { + const body = (err.body ?? {}) as { reason?: string; replayed?: boolean }; + return { + ok: false, + reason: body.reason ?? (err.status === 404 ? "not_found" : "terminal"), + ...(body.replayed ? { replayed: true } : {}), + }; + } + throw err; + } } export function makeCoreStreamFn( @@ -451,6 +486,7 @@ export function makeCoreStreamFn( agent: Agent, getTurnOptions?: () => TurnOptions, onWork?: WorkObserver, + slot?: RunSlot, ): StreamFn { const fn = ( model: Model, @@ -458,7 +494,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 +506,20 @@ 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, seedText); return stream; }; return fn as unknown as StreamFn; @@ -490,9 +532,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 +545,7 @@ export function makeOpenerStreamFn( agent: Agent, getTurnOptions: (() => TurnOptions) | undefined, onWork: WorkObserver | undefined, + slot?: RunSlot, ): StreamFn { const fn = ( model: Model, @@ -509,7 +553,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 +569,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 +609,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 +632,8 @@ async function resumeDrive( initialRun?: RunPoll, signal?: AbortSignal, onWork?: WorkObserver, + slot?: RunSlot, + seedText?: string, ): Promise { const partial = baseAssistant(model); const work: WorkBlock = { status: "thinking", activity: [] }; @@ -597,8 +644,13 @@ async function resumeDrive( stream.push({ type: "start", partial }); stream.push({ type: "text_start", contentIndex: 0, partial }); const st: Acc = { acc: "", lastProgressAt: now() }; + // Keep any assistant text the transcript already showed for this in-flight + // run visible: seed the accumulator with it so attaching the live stream + // never blanks text the person has already read. The server's own partial + // (when longer) simply replaces it via the normal delta path. + if (seedText?.trim()) pushDelta(stream, partial, st, seedText); 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 +666,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 +676,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 +1163,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 738ebd4..e9fc8d2 100644 --- a/plugins/web-ui/src/crons.ts +++ b/plugins/web-ui/src/crons.ts @@ -6,7 +6,8 @@ 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, 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 + } +
`; + })}
`; } @@ -693,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.`, ); } @@ -780,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/deep-link.ts b/plugins/web-ui/src/deep-link.ts index b02f243..659ec3b 100644 --- a/plugins/web-ui/src/deep-link.ts +++ b/plugins/web-ui/src/deep-link.ts @@ -8,33 +8,52 @@ 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 segments = rel.replace(/^\/+/, "").split("/"); + const pathView = decodeSegment(segments[0] ?? ""); + const projectKind = segments[1] === "channel" || segments[1] === "group" ? segments[1] : null; + let projectItem: string | null = null; + if (pathView === "projects") { + projectItem = projectKind ? decodeSegment(segments[2] ?? "") : decodeSegment(segments[1] ?? ""); } - const requestedView = params.get("view") ?? fromPath; + const requestedView = params.get("view") ?? (pathView === "projects" ? "contexts" : pathView); const view = requestedView === "connectors" ? "keychain" : requestedView; - return { view, session: params.get("session") }; + return { + view, + session: params.get("session"), + item: + pathView === "projects" && projectKind && projectItem + ? `${projectKind}:${projectItem}` + : (projectItem ?? decodeSegment(segments[1] ?? "")), + }; } export function sessionLink(origin: string, base: string, sessionId: string): string { diff --git a/plugins/web-ui/src/deploy-view.ts b/plugins/web-ui/src/deploy-view.ts index d0d28af..9c40252 100644 --- a/plugins/web-ui/src/deploy-view.ts +++ b/plugins/web-ui/src/deploy-view.ts @@ -94,7 +94,7 @@ export function deploymentTab(d: DeploymentView, viewer: string | undefined): De export function deploymentTabEmptyMessage(tab: DeploymentTab): string { if (tab === "shared") return "No apps shared with you."; if (tab === "archived") return "Nothing archived."; - return "No apps in Yours."; + return "No apps of your own yet."; } export function filterDeployments( diff --git a/plugins/web-ui/src/deploys.ts b/plugins/web-ui/src/deploys.ts index 32cff53..1eba4f8 100644 --- a/plugins/web-ui/src/deploys.ts +++ b/plugins/web-ui/src/deploys.ts @@ -3,12 +3,11 @@ 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"; -import { chatState, drawActiveChat, newChat } from "./chat"; -import { composerState, focusComposerEnd } from "./composer"; +import { mainConversation } from "./conversations"; import { focusDialogCancel, restoreDialogFocus, trapDialogFocus } from "./dialog-focus"; import { withDeploymentDetailNotice, @@ -161,7 +160,7 @@ function deploymentRow(d: DeploymentView): TemplateResult { ${contextScope ? scopeChip(contextScope) : nothing} ${ownerLabel(d)} - ${permissionBadge(d)} + ${canManage(d) ? permissionBadge(d) : nothing} ${versionLabel(d)} ${deployedLabel(d)} @@ -306,19 +305,20 @@ function drawDeploysPage(): void { onRefresh: () => void renderDeploys(), action: { label: "Deploy with Agent", onClick: deployWithAgent }, controls: html`Newest`, + html``, + html``, + ], + })}`, search: { value: deployQuery, @@ -844,10 +844,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/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/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..5ee2c79 100644 --- a/plugins/web-ui/src/model-options.ts +++ b/plugins/web-ui/src/model-options.ts @@ -115,50 +115,77 @@ 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( +export function runtimeModelOptions( approvedHarnesses: readonly string[], modelsByHarness: Readonly>, - effective: { harnessId: string; modelId: string }, catalog: Readonly> = {}, -): void { - activeModelOptions = 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 (!activeModelOptions.length) activeModelOptions = buildOptions(DEFAULT_PICKER_MODEL_IDS); - defaultRuntimeValue = `${effective.harnessId}:${effective.modelId}`; + 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); } -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/pane-focus.ts b/plugins/web-ui/src/pane-focus.ts index 511ee71..2bbec4a 100644 --- a/plugins/web-ui/src/pane-focus.ts +++ b/plugins/web-ui/src/pane-focus.ts @@ -1,33 +1,51 @@ +type Selection = { start: number | null; end: number | null; direction: "forward" | "backward" | "none" | null }; + +function isTextControl( + view: (Window & typeof globalThis) | null, + element: Element | null, +): element is HTMLInputElement { + return Boolean( + view && element && (element instanceof view.HTMLInputElement || element instanceof view.HTMLTextAreaElement), + ); +} + +function readSelection(view: (Window & typeof globalThis) | null, element: Element | null): Selection | null { + if (!isTextControl(view, element)) return null; + return { start: element.selectionStart, end: element.selectionEnd, direction: element.selectionDirection }; +} + +function applySelection( + view: (Window & typeof globalThis) | null, + element: HTMLElement, + selection: Selection | null, +): void { + if (!selection || selection.start === null || selection.end === null) return; + if (!isTextControl(view, element)) return; + element.setSelectionRange(selection.start, selection.end, selection.direction ?? undefined); +} + export function replaceChildrenPreservingFocus(container: HTMLElement, host: HTMLElement): void { const view = container.ownerDocument.defaultView; const active = container.contains(container.ownerDocument.activeElement) ? (container.ownerDocument.activeElement as HTMLElement) : null; const key = active?.dataset.focusKey; - const textControl = Boolean( - view && active && (active instanceof view.HTMLInputElement || active instanceof view.HTMLTextAreaElement), - ); - const selection = textControl - ? { - start: (active as HTMLInputElement).selectionStart, - end: (active as HTMLInputElement).selectionEnd, - direction: (active as HTMLInputElement).selectionDirection, - } - : null; + const selection = readSelection(view, active); container.replaceChildren(host); if (!key) return; const next = [...container.querySelectorAll("[data-focus-key]")].find( (element) => element.dataset.focusKey === key, ); next?.focus(); - if ( - view && - next && - selection && - (next instanceof view.HTMLInputElement || next instanceof view.HTMLTextAreaElement) && - selection.start !== null && - selection.end !== null - ) { - next.setSelectionRange(selection.start, selection.end, selection.direction ?? undefined); - } + if (next) applySelection(view, next, selection); +} + +export function preservingFocus(doc: Document, mutate: () => void): void { + const view = doc.defaultView; + const active = doc.activeElement as HTMLElement | null; + const selection = readSelection(view, active); + mutate(); + if (!active || doc.activeElement === active || !active.isConnected) return; + active.focus(); + applySelection(view, active, selection); } 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..6bc352d 100644 --- a/plugins/web-ui/src/session-list.ts +++ b/plugins/web-ui/src/session-list.ts @@ -149,19 +149,23 @@ export function applySessionState( export interface RowIndicators { working: boolean; awaiting: boolean; - background: { count: number; label: string } | null; + background: { jobs: number; watches: number; label: string } | null; } -export function backgroundLabel(jobs: number, watches: number): { count: number; label: string } | null { +export function backgroundLabel( + jobs: number, + watches: number, +): { jobs: number; watches: number; label: string } | null { const parts: string[] = []; if (jobs > 0) parts.push(`${jobs} background job${jobs === 1 ? "" : "s"} running`); if (watches > 0) parts.push(`${watches} watch${watches === 1 ? "" : "es"} armed`); - return parts.length ? { count: jobs + watches, label: parts.join(" · ") } : null; + return parts.length ? { 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..bd8f6fd 100644 --- a/plugins/web-ui/src/sessions.ts +++ b/plugins/web-ui/src/sessions.ts @@ -3,11 +3,12 @@ import { live } from "lit/directives/live.js"; import { ref } from "lit/directives/ref.js"; import { repeat } from "lit/directives/repeat.js"; import { - Activity, Archive, + Binoculars, ArchiveRestore, ChevronDown, ChevronRight, + Cog, EllipsisVertical, Folder, Hash, @@ -55,8 +56,9 @@ import { type RecentItem, type ChatBrowseStatus, } from "./session-list"; +import { hideTooltip, showTooltip } from "./tooltip"; 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, @@ -69,15 +71,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 +385,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 +450,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 +478,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…", @@ -519,18 +514,19 @@ export function drawChatsPage(): void { )} All surfaces`, + html``, + html``, + ], + })} `, rows, @@ -554,20 +550,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`` @@ -578,11 +579,17 @@ function statusMarks(s: CoreSession): TemplateResult { class="bg-chip" role="button" tabindex="0" - title="${ind.background.label} — click to inspect" aria-label="${ind.background.label} — click to inspect" + @mouseenter=${(e: Event) => + showTooltip(e.currentTarget as Element, `${ind.background!.label} — click to inspect`)} + @mouseleave=${(e: Event) => hideTooltip(e.currentTarget as Element)} + @focus=${(e: Event) => showTooltip(e.currentTarget as Element, `${ind.background!.label} — click to inspect`)} + @blur=${(e: Event) => hideTooltip(e.currentTarget as Element)} @click=${(e: Event) => openBackgroundInspector(e, s)} @keydown=${(e: KeyboardEvent) => (e.key === "Enter" || e.key === " ") && openBackgroundInspector(e, s)} - >${icon(Activity, 11)}${ind.background.count}${ind.background.jobs > 0 ? icon(Cog, 11) : nothing}${ + ind.background.watches > 0 ? icon(Binoculars, 11) : nothing + }` : nothing }`; @@ -591,17 +598,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 { @@ -785,6 +790,11 @@ function sessionRow(s: CoreSession, projectChild = false): TemplateResult { @dragstart=${(e: DragEvent) => onSessionDragStart(e, s)} @dragend=${() => endSessionDrag()} @click=${() => openSession(s)} + @dblclick=${(e: Event) => { + if (!saved) return; + e.preventDefault(); + startRename(s); + }} >
${statusMarks(s)}${surfaceGlyph(s)}${readOnly ? html`${icon(Lock, 12)}` : nothing} + @@ -507,7 +483,12 @@ export function mountShell(): void { export function renderSidebarTop(): void { if (!appState.topEl) return; const navRow = (v: View, glyph: IconNode, label: string) => - html``; const navGroup = (id: string, title: string, open: boolean, toggle: () => void, rows: TemplateResult) => html` @@ -530,9 +511,10 @@ export function renderSidebarTop(): void { html`
All scopes`, + html``, + html``, + html``, + html``, + html``, + ], + })} All sources`, + html``, + html``, + html``, + ], + })}
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..90b8fec 100644 --- a/plugins/web-ui/src/split.ts +++ b/plugins/web-ui/src/split.ts @@ -1,6 +1,6 @@ import { html, nothing, render, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; -import { Activity, Expand, Maximize2, Plus, Shrink, X } from "lucide"; +import { Binoculars, Cog, Expand, Maximize2, Plus, Shrink, X } from "lucide"; import { createDockview, type DockviewApi, @@ -15,31 +15,44 @@ 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 { preservingFocus } from "./pane-focus"; +import { hideTooltip, showTooltip } from "./tooltip"; 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 +76,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 +121,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 +199,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 +270,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 +299,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 +327,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 +386,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 +440,6 @@ function closePanels(panels: IDockviewPanel[]): void { if (!dockApi) return; for (const p of panels) { dockApi.removePanel(p); - paneWorking.delete(p.id); } reconcileAfterClose(); } @@ -434,7 +448,7 @@ function reconcileAfterClose(): void { const rest = dockApi?.panels ?? []; if (rest.length === 0) { exitSplitIfActive(); - newChat(); + mainConversation().newChat(); return; } if (rest.length === 1) { @@ -444,7 +458,7 @@ function reconcileAfterClose(): void { void maximizePane(params); } else { exitSplitIfActive(); - newChat(); + mainConversation().newChat(); } return; } @@ -475,7 +489,9 @@ async function maximizePane(params: PaneParams): Promise { } function focusPane(paneId: string): void { - dockApi?.getPanel(paneId)?.api.setActive(); + const panel = dockApi?.getPanel(paneId); + if (!panel || panel.api.isActive) return; + preservingFocus(document, () => panel.api.setActive()); } export function canvasToast(msg: string): void { @@ -555,11 +571,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 +589,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 +643,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 +658,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 +772,10 @@ class PaneContent implements IContentRenderer { } dispose(): void { + this.disposed = true; + this.resize.disconnect(); paneContents.delete(this.panelId); + disposeConversation(this.conversation); } } @@ -728,8 +816,14 @@ class PaneTab implements ITabRenderer { ${awaiting ? html`` : nothing} ${ background - ? html`${icon(Activity, 11)}${background.count} showTooltip(e.currentTarget as Element, background.label)} + @mouseleave=${(e: Event) => hideTooltip(e.currentTarget as Element)} + >${background.jobs > 0 ? icon(Cog, 11) : nothing}${ + background.watches > 0 ? icon(Binoculars, 11) : nothing + }` : nothing } @@ -837,61 +931,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 +963,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/src/tooltip.ts b/plugins/web-ui/src/tooltip.ts new file mode 100644 index 0000000..54e4b53 --- /dev/null +++ b/plugins/web-ui/src/tooltip.ts @@ -0,0 +1,39 @@ +/** Lightweight floating tooltip — our own styled element, never the browser's + * native `title` bubble. Anchored above the target, clamped to the viewport, + * appended to so list-row overflow clipping can't hide it. */ + +let tipEl: HTMLDivElement | null = null; +let anchor: Element | null = null; + +function ensureEl(): HTMLDivElement { + if (!tipEl) { + tipEl = document.createElement("div"); + tipEl.className = "qm-tooltip"; + tipEl.setAttribute("role", "tooltip"); + document.body.appendChild(tipEl); + } + return tipEl; +} + +export function showTooltip(target: Element, text: string): void { + if (!text) return; + anchor = target; + const el = ensureEl(); + el.textContent = text; + el.classList.add("visible"); + // Measure after content is set. + const r = target.getBoundingClientRect(); + const tr = el.getBoundingClientRect(); + let left = r.left + r.width / 2 - tr.width / 2; + left = Math.max(6, Math.min(left, window.innerWidth - tr.width - 6)); + let top = r.top - tr.height - 7; + if (top < 6) top = r.bottom + 7; // no room above — flip below + el.style.left = `${Math.round(left)}px`; + el.style.top = `${Math.round(top)}px`; +} + +export function hideTooltip(target?: Element): void { + if (target && anchor && target !== anchor) return; + anchor = null; + tipEl?.classList.remove("visible"); +} diff --git a/plugins/web-ui/src/ui.ts b/plugins/web-ui/src/ui.ts index 515ae9c..00d92c2 100644 --- a/plugins/web-ui/src/ui.ts +++ b/plugins/web-ui/src/ui.ts @@ -1,5 +1,5 @@ -import { html, type TemplateResult } from "lit"; -import { createElement, type IconNode } from "lucide"; +import { html, nothing, type TemplateResult } from "lit"; +import { ChevronDown, createElement, type IconNode } from "lucide"; export function brandName(): string { if (typeof document === "undefined") return "QM"; @@ -22,6 +22,36 @@ export function icon(node: IconNode, size = 18): SVGElement { return el; } +export function fieldSelect(props: { + options: TemplateResult | TemplateResult[]; + onChange: (value: string, event: Event) => void; + value?: string; + id?: string; + ariaLabel?: string; + describedBy?: string; + focusKey?: string; + disabled?: boolean; + compact?: boolean; + className?: string; +}): TemplateResult { + return html` + + ${icon(ChevronDown, 16)} + `; +} + export function initials(s: string): string { const base = (s.split("@")[0] || s).trim(); const parts = base.split(/[.\-_ ]+/).filter(Boolean); diff --git a/plugins/web-ui/test/ambient-policy-source.test.ts b/plugins/web-ui/test/ambient-policy-source.test.ts index d7aaad0..c145aa4 100644 --- a/plugins/web-ui/test/ambient-policy-source.test.ts +++ b/plugins/web-ui/test/ambient-policy-source.test.ts @@ -19,12 +19,31 @@ test("policy edits redraw immediately and preserve focused text controls", () => test("policy controls use product language and persistent accessible labels", () => { for (const label of ["Ignore", "Batch updates", "Act immediately", "Treat like a person"]) assert.match(policy, new RegExp(label)); - assert.match(policy, /