diff --git a/tests/web/app-render.test.ts b/tests/web/app-render.test.ts index c35a3f88..1b12543e 100644 --- a/tests/web/app-render.test.ts +++ b/tests/web/app-render.test.ts @@ -89,8 +89,8 @@ const SNAPSHOT = { id: "sa-1", title: "explore", status: "done", - createdAt: "2026-09-01T10:00:00Z", - settledAt: "2026-09-01T10:00:30Z", + createdAt: Date.parse("2026-09-01T10:00:00Z"), + settledAt: Date.parse("2026-09-01T10:00:30Z"), }, ], omitted: 2, @@ -102,8 +102,8 @@ const SNAPSHOT = { runId: "wf-1", name: "delivery", status: "completed", - startedAt: "2026-09-01T10:00:00Z", - finishedAt: "2026-09-01T10:01:00Z", + startedAt: Date.parse("2026-09-01T10:00:00Z"), + finishedAt: Date.parse("2026-09-01T10:01:00Z"), agents: { total: 1, running: 0, done: 1, error: 0, uncertain: 0 }, }, ], @@ -438,14 +438,28 @@ test("app.js renders a full session without runtime errors", async () => { const conversation = elements.get("conversation"); assert.ok(conversation, "conversation element exists"); assert.match(conversation.innerHTML, /message-row user/); - assert.match(conversation.innerHTML, /assistant-detail/); - assert.match(conversation.innerHTML, /tool-details/); - assert.match(conversation.innerHTML, /runtime-activity/); + assert.match(conversation.innerHTML, /thinking-line/); + assert.match(conversation.innerHTML, /tool-line/); + // Settled tool calls carry an outcome glyph at the row's right edge. + assert.match(conversation.innerHTML, /tool-status done/); + assert.match(conversation.innerHTML, /activity-card subagent/); + assert.match(conversation.innerHTML, /activity-card workflow/); assert.match(conversation.innerHTML, /explore/); - assert.match(conversation.innerHTML, /\+2 omitted/); - assert.match(conversation.innerHTML, /delivery/); assert.match(conversation.innerHTML, /file1/); assert.match(conversation.innerHTML, /最终总结/); + // Copy/time shows on user messages and each turn's final assistant answer + // (this fixture is a single turn: one user row + one final answer). + const actionBars = conversation.innerHTML.match(/message-actions/g) || []; + assert.equal(actionBars.length, 2); + const activityBar = elements.get("activity-bar"); + assert.ok(activityBar, "activity bar element exists"); + assert.equal(activityBar.hidden, false); + assert.match(activityBar.innerHTML, /activity-chip workflow done/); + assert.match(activityBar.innerHTML, /activity-chip subagent done/); + assert.match(activityBar.innerHTML, /delivery/); + assert.match(activityBar.innerHTML, /\+2/); + const turnRail = elements.get("turn-rail"); + assert.ok(turnRail, "turn rail element exists"); }); test("app.js moves the bootstrap token into tab storage and clears the URL", async () => { diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index d80df4fa..3e277f1a 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -128,8 +128,11 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn assert.match(pageHtml, /id="workspace-menu"/); assert.match(pageHtml, /Rename workspace/); assert.match(pageHtml, /Remove from sidebar/); - assert.doesNotMatch(pageHtml, /theme-picker-trigger|data-theme-value/); - assert.doesNotMatch(pageHtml, /settings-dialog|language-picker/u); + // Settings persistence/entry is deferred to #350 (canonical config contract). + assert.doesNotMatch( + pageHtml, + /settings-dialog|open-settings|theme-picker|language-picker/, + ); const app = await fetch(`${launched.origin}/app.js`); assert.equal(app.status, 200); @@ -163,23 +166,47 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn assert.match(appSource, /visibleUngroupedSessions/); assert.match(appSource, /workspaceDeleteConfirm/); assert.match(appSource, /workspace-delete-dialog/); - assert.match(appSource, /runtime-activity/); + assert.match(appSource, /message-copy/); + assert.match(appSource, /messageActionsMarkup/); + assert.match(appSource, /turn-tick/); + assert.match(appSource, /turn-rail/); + assert.match(appSource, /scrollIntoView/); + assert.match(appSource, /activity-card/); + assert.match(appSource, /familyToolCallCard/); + assert.match(appSource, /toolLineMarkup/); + assert.match(appSource, /toolCallSummary/); + assert.match(appSource, /groupRows/); + assert.match(appSource, /tool-group/); + assert.match(appSource, /thinkingLineMarkup/); + assert.match(appSource, /data-thinking-start/); + assert.match(appSource, /message-edit-input/); + assert.match(appSource, /enterMessageEdit/); + assert.match(appSource, /renderActivityBar/); + assert.match(appSource, /activity-chip/); assert.match(appSource, /runtime_changed/); + assert.match(appSource, /resultsByCallId/); + assert.match(appSource, /pinnedToBottom/); + assert.match(appSource, /behavior: "instant"/); + assert.match(appSource, /customType/); + assert.match(appSource, /subagent-result/); + assert.match(appSource, /workflow-result/); assert.match(appSource, /sessionStorage\.setItem/); assert.match(appSource, /history\.replaceState/); assert.match(appSource, /\/events\?cursor=/); - assert.doesNotMatch(appSource, /localStorage|openpi\.archived-sessions/); - assert.doesNotMatch( - appSource, - /applyTheme|message-edit-input|enterMessageEdit/, - ); - assert.doesNotMatch(appSource, /language-picker|open-settings/u); + assert.doesNotMatch(appSource, /openpi\.archived-sessions/); + assert.doesNotMatch(appSource, /applyTheme|data-theme-value|open-settings/); + assert.doesNotMatch(appSource, /openpi\.language|openpi\.web\.theme/); const marked = await fetch(`${launched.origin}/marked.js`); assert.equal(marked.status, 200); assert.match(marked.headers.get("content-type") || "", /javascript/); assert.match(await marked.text(), /marked v18/); + const favicon = await fetch(`${launched.origin}/favicon.svg`); + assert.equal(favicon.status, 200); + assert.match(favicon.headers.get("content-type") || "", /image\/svg\+xml/); + assert.match(await favicon.text(), / translations[state.language][key] || translations.en[key] || key; @@ -345,83 +365,487 @@ function renderWorkspaces() { }); } -function messageMarkup(entry) { +function formatTurnTime(timestamp) { + if (!timestamp) return ""; + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return ""; + return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +} + +function formatElapsedMs(start, end) { + if (typeof start !== "number") return ""; + const totalSeconds = Math.max(0, Math.round(((typeof end === "number" ? end : Date.now()) - start) / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, "0")}s` : `${seconds}s`; +} + +const ACTIVITY_ICONS = { + subagent: ``, + workflow: ``, +}; +const ACTIVITY_STATUS_GLYPHS = { done: "✓", error: "✗", warn: "?" }; + +const TOOL_ICONS = { + bash: ``, + read: ``, + write: ``, + edit: ``, + grep: ``, + glob: ``, + ls: ``, + webfetch: ``, + websearch: ``, + thinking: ``, + default: ``, +}; + +function toolIcon(name) { + return TOOL_ICONS[name] || TOOL_ICONS.default; +} + +/** Pull the one argument that identifies what the tool call actually did. */ +function toolCallSummary(name, args) { + if (!args) return ""; + const value = + name === "bash" ? args.command : + name === "read" || name === "write" || name === "edit" || name === "ls" ? args.path : + name === "grep" || name === "glob" ? args.pattern : + name === "webfetch" ? args.url : + name === "websearch" ? args.query : + ""; + if (typeof value !== "string" || !value) return ""; + const line = value.split("\n").find((part) => part.trim()) || ""; + return line.length > 90 ? `${line.slice(0, 90)}…` : line; +} + +/** Thinking row: "思考中..." with a live timer while active, "思考过程 · 1m03s" once settled. */ +function thinkingLineMarkup(body, thinking) { + const active = Boolean(thinking?.active); + const label = active ? t("thinkingActive") : t("thinkingDone"); + let meta = ""; + if (active) { + meta = `${formatElapsedMs(thinking.startedAt)}`; + } else if (thinking?.elapsedMs) { + meta = `· ${formatElapsedMs(0, thinking.elapsedMs)}`; + } + return `
+ + + + ${escapeHtml(label)}${meta ? `${meta}` : ""} + +
${escapeHtml(body || "")}
+
`; +} + +let thinkingTimerInterval = null; + +function syncThinkingTimer(hasActive) { + if (hasActive && thinkingTimerInterval === null) { + thinkingTimerInterval = window.setInterval(() => { + document.querySelectorAll("[data-thinking-start]").forEach((element) => { + element.textContent = formatElapsedMs(Number(element.dataset.thinkingStart)); + }); + }, 1000); + } else if (!hasActive && thinkingTimerInterval !== null) { + window.clearInterval(thinkingTimerInterval); + thinkingTimerInterval = null; + } +} + +/** Compact collapsible row for ordinary tool calls/results, with an icon. + The right edge carries the outcome: ✓/✗ once settled, a pulsing dot while running. */ +function toolLineMarkup(name, summary, body, status, extraClass) { + const statusMarkup = + status === "done" || status === "error" + ? `${ACTIVITY_STATUS_GLYPHS[status]}` + : status === "running" + ? `` + : ""; + return `
+ + + + ${escapeHtml(name)}${summary ? `${escapeHtml(summary)}` : ""} + ${statusMarkup} + +
${escapeHtml(body || "")}
+
`; +} + +function activityCardMarkup(family, title, meta, body, status) { + const glyph = ACTIVITY_STATUS_GLYPHS[status]; + const indicator = + status === "running" + ? `` + : glyph + ? `` + : ""; + return `
+ + + ${escapeHtml(title)}${meta ? `${escapeHtml(meta)}` : ""} + ${indicator} + + +
${escapeHtml(body || "")}
+
`; +} + +function parseToolArguments(raw) { + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +/** Card status: merged tool result wins; a family call without one is running. */ +function canonicalActivityStatus(value) { + if (value === "running") return "running"; + if (value === "done" || value === "completed") return "done"; + if (value === "error" || value === "failed" || value === "aborted" || value === "killed" || value === "timed_out") return "error"; + if (value === "uncertain") return "warn"; + return "unknown"; +} + +function cardStatus(result) { + if (!result) return "running"; + if (result.isError === true) return "error"; + const details = result.details && typeof result.details === "object" ? result.details : null; + const status = canonicalActivityStatus(details?.status); + if (status !== "unknown") return status; + if (result.isError === false) return "done"; + return "unknown"; +} + +/** Rich cards for the two headline capabilities instead of a generic tool row. */ +function familyToolCallCard(part, result) { + const name = part.name || ""; + const args = parseToolArguments(part.arguments) || {}; + const details = result?.details && typeof result.details === "object" ? result.details : undefined; + const status = cardStatus(result); + if (name === "subagent_spawn") { + const meta = [args.agent_type, args.model, args.working_dir].filter(Boolean).join(" · "); + const title = `Spawn Subagent · ${details?.title || args.name || "subagent"}`; + return activityCardMarkup("subagent", title, meta || details?.cwd, result?.content || args.prompt || part.arguments, status); + } + if (name === "subagent_wait") { + const results = Array.isArray(details?.results) ? details.results : []; + const failed = results.filter((item) => item && item.status !== "done").length; + const meta = results.length > 0 ? `${results.length} settled${failed ? ` · ${failed} failed` : ""}` : (args.ids || []).join(" · ") || undefined; + return activityCardMarkup("subagent", "Wait for Subagents", meta, result?.content || part.arguments, status); + } + if (name === "subagent_cancel") return activityCardMarkup("subagent", "Cancel Subagents", (args.ids || []).join(" · ") || undefined, result?.content || part.arguments, status); + if (name === "subagent_send") return activityCardMarkup("subagent", `Send to Subagent · ${args.id || ""}`.trim(), undefined, result?.content || args.text || part.arguments, status); + if (name === "subagent_check") return activityCardMarkup("subagent", `Check Subagent · ${args.id || ""}`.trim(), undefined, result?.content || part.arguments, status); + if (name === "subagent_list") return activityCardMarkup("subagent", "List Subagents", undefined, result?.content || part.arguments, status); + if (name === "workflow") { + const script = typeof args.script === "string" ? args.script : ""; + const workflowName = details?.name || script.match(/\bname:\s*["'`]([^"'`]+)["'`]/)?.[1]; + const description = script.match(/\bdescription:\s*["'`]([^"'`]+)["'`]/)?.[1]; + const agents = Array.isArray(details?.agents) ? details.agents : []; + const settled = agents.filter((agent) => agent && agent.state !== "running").length; + const meta = details + ? [details.runId, details.status, agents.length > 0 ? `${settled}/${agents.length} agents` : ""].filter(Boolean).join(" · ") + : description; + return activityCardMarkup("workflow", `Workflow · ${workflowName || "unnamed"}`, meta, result?.content || script || part.arguments, status); + } + if (name === "workflow_stop") return activityCardMarkup("workflow", `Stop Workflow · ${args.runId || ""}`.trim(), undefined, result?.content || part.arguments, status); + if (name === "workflow_status") return activityCardMarkup("workflow", "Workflow Status", args.runId, result?.content || part.arguments, status); + return ""; +} + +function familyToolResultCard(message) { + const toolName = message.toolName || ""; + const family = toolName.startsWith("subagent") ? "subagent" : toolName.startsWith("workflow") ? "workflow" : ""; + if (!family) return ""; + const content = message.content || ""; + const status = canonicalActivityStatus(message.details?.status); + const resolvedStatus = status === "unknown" + ? (message.isError === true ? "error" : message.isError === false ? "done" : "unknown") + : status; + const title = `${toolName.replace(/_/g, " ")}${content ? ` · ${compactSummary(content)}` : ""}`; + return activityCardMarkup(family, title, undefined, content, resolvedStatus); +} + +/** Background delivery messages (subagent-result / workflow-result). */ +function customMessageMarkup(message) { + const details = message.details && typeof message.details === "object" ? message.details : {}; + if (message.customType === "subagent-result") { + const status = canonicalActivityStatus(details.status); + const meta = [details.id, details.outcome, details.elapsed].filter(Boolean).join(" · "); + const card = activityCardMarkup("subagent", `Subagent ${details.id || ""} · ${details.title || "result"}`, meta || undefined, message.content, status); + return `
+
${card}
+
`; + } + if (message.customType === "workflow-result") { + const entries = Array.isArray(details.entries) ? details.entries : []; + const entryStatuses = entries.map((item) => canonicalActivityStatus(item?.status)); + const status = entryStatuses.some((item) => item === "error") + ? "error" + : entryStatuses.some((item) => item === "warn") + ? "warn" + : entryStatuses.length > 0 && entryStatuses.every((item) => item === "done") + ? "done" + : entryStatuses.some((item) => item === "running") + ? "running" + : "unknown"; + const title = entries.length > 1 + ? `Workflow results · ${entries.length} runs` + : `Workflow ${entries[0]?.runId || "result"} · ${entries[0]?.status || "delivered"}`; + const body = entries.length > 0 + ? entries + .map((item) => { + const glyph = item.status === "completed" ? "✓" : "✗"; + const alerts = Array.isArray(item.alerts) && item.alerts.length > 0 ? ` (${item.alerts.join("; ")})` : ""; + const preview = item.resultPreview ? `\nResult: ${item.resultPreview}` : ""; + return `${glyph} ${item.summary || item.runId || "run"}${alerts}${preview}`; + }) + .join("\n") + : message.content; + const card = activityCardMarkup("workflow", title, undefined, body, status); + return `
+
${card}
+
`; + } + return ""; +} + +function turnTitle(content) { + const line = String(content || "").split("\n").map((part) => part.trim()).find(Boolean) || ""; + return line.length > 60 ? `${line.slice(0, 60)}…` : line; +} + +/** Empty tool outputs like "", "[]", "{}" carry no information. */ +function isEmptyToolOutput(content) { + const text = String(content ?? "").trim(); + return text === "" || text === "[]" || text === "{}" || text === "null"; +} + +function messageActionsMarkup(entry, canEdit = false) { + const time = formatTurnTime(entry.timestamp); + const editButton = canEdit + ? `` + : ""; + return `
${time ? `` : ""}${editButton}
`; +} + +let messageEditActive = false; + +function enterMessageEdit(row) { + const body = row.querySelector(".message-body"); + const content = row.querySelector(".message-content"); + if (!body || !content || row.classList.contains("editing")) return; + const original = body.textContent || ""; + row.classList.add("editing"); + messageEditActive = true; + content.innerHTML = ` +
+ + +
`; + const textarea = content.querySelector(".message-edit-input"); + if (!textarea) return; + const grow = () => { + textarea.style.height = "auto"; + textarea.style.height = `${Math.min(textarea.scrollHeight, 220)}px`; + }; + grow(); + textarea.focus(); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.addEventListener("input", grow); + textarea.addEventListener("keydown", (event) => { + if (event.isComposing || event.keyCode === 229) return; + if (event.key === "Escape") { + event.preventDefault(); + messageEditActive = false; + renderConversation(); + } + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + void confirmMessageEdit(textarea.value); + } + }); +} + +/** Confirming an edit resends the text as a new prompt (queued if running). */ +async function confirmMessageEdit(value) { + messageEditActive = false; + const content = value.trim(); + if (!content) { + renderConversation(); + return; + } + await sendPrompt(content); +} + +/** Render one entry to a list of row objects: { kind: "user"|"assistant"|"detail", icon?, error?, activeThinking?, html }. */ +function messageMarkup(entry, turn, resultsByCallId, familyCallIds, context, showActions = true) { const message = entry.message; - if (!message) return ""; + if (!message) return []; + if (message.role === "custom") { + const html = customMessageMarkup(message); + if (!html) return []; + const icon = message.customType === "workflow-result" ? ACTIVITY_ICONS.workflow : ACTIVITY_ICONS.subagent; + return [{ kind: "detail", icon, html }]; + } if (message.role === "user") { - return `
+ return [{ kind: "user", html: `
${escapeHtml(message.content)}
-
`; + ${messageActionsMarkup(entry, Boolean(context?.showEdit))} +
` }]; } if (message.role === "assistant") { const parts = Array.isArray(message.parts) ? message.parts : []; - const detailItems = parts - .filter((part) => part.type === "thinking" || part.type === "toolCall") - .map((part) => { - const title = part.type === "thinking" ? "Thinking" : `${part.name} · tool call`; - const body = part.type === "thinking" ? part.text : part.arguments; - return `
- ${escapeHtml(title)} -
${escapeHtml(body)}
-
`; - }); - const detailRows = detailItems - .map( - (detail) => `
+ const rows = []; + for (const part of parts) { + if (part.type !== "thinking" && part.type !== "toolCall") continue; + let detail; + let icon; + let groupable = false; + if (part.type === "toolCall") { + const result = part.id ? resultsByCallId?.get(part.id) : undefined; + const card = familyToolCallCard(part, result); + if (card) { + detail = card; + icon = /^workflow/.test(part.name || "") ? ACTIVITY_ICONS.workflow : ACTIVITY_ICONS.subagent; + } else { + const args = parseToolArguments(part.arguments); + const body = part.name === "bash" && typeof args?.command === "string" ? args.command : part.arguments; + const status = result ? (result.isError ? "error" : "done") : "running"; + detail = toolLineMarkup(part.name || "tool", toolCallSummary(part.name, args), body, status); + icon = toolIcon(part.name || "tool"); + groupable = true; + } + } else { + detail = thinkingLineMarkup(part.text, context?.thinking); + icon = TOOL_ICONS.thinking; + } + rows.push({ kind: "detail", icon, groupable, activeThinking: part.type === "thinking" && Boolean(context?.thinking?.active), html: `
${detail}
-
`, - ) - .join(""); +
` }); + } const content = typeof message.content === "string" ? message.content.trim() : ""; - const contentRow = content - ? `
+ if (content) { + rows.push({ kind: "assistant", html: `
${renderMarkdown(content)}
-
` - : ""; - return detailRows + contentRow; + ${showActions ? messageActionsMarkup(entry) : ""} +
` }); + } + return rows; } if (message.role === "toolResult") { + // Family results merged into their call card do not get a separate row. + if (message.toolCallId && familyCallIds?.has(message.toolCallId)) return []; + const family = (message.toolName || "").startsWith("subagent") ? "subagent" : (message.toolName || "").startsWith("workflow") ? "workflow" : ""; + const card = family ? familyToolResultCard(message) : ""; const toolName = message.toolName || "tool"; - const summary = compactSummary(message.content || "completed"); - return `
-
- ${escapeHtml(toolName)} · ${escapeHtml(summary)} -
${escapeHtml(message.content || "completed")}
-
-
`; + const icon = family ? ACTIVITY_ICONS[family] : toolIcon(toolName); + if (!card && isEmptyToolOutput(message.content)) { + const emptyStatus = `${ACTIVITY_STATUS_GLYPHS[message.isError ? "error" : "done"]}`; + return [{ kind: "detail", icon, groupable: true, html: `
+
${escapeHtml(toolName)}${escapeHtml(t("noOutput"))}${emptyStatus}
+
` }]; + } + const detail = card || toolLineMarkup(toolName, compactSummary(message.content || "completed"), message.content || "completed", message.isError ? "error" : "done"); + return [{ kind: "detail", icon, groupable: !family, error: Boolean(message.isError), html: `
+
${detail}
+
` }]; } - return ""; + return []; +} + +/** Collapse runs of 4+ consecutive ordinary tool rows into one expandable + group. Thinking, subagent, and workflow rows always stay visible. */ +function groupRows(rows) { + const blocks = []; + for (const row of rows) { + const groupable = row.kind === "detail" && row.groupable; + const last = blocks[blocks.length - 1]; + if (groupable && last?.kind === "group") last.rows.push(row); + else if (groupable) blocks.push({ kind: "group", rows: [row] }); + else blocks.push({ kind: "row", rows: [row] }); + } + return blocks + .map((block) => { + if (block.kind !== "group" || block.rows.length < 4) { + return block.rows.map((row) => row.html).join(""); + } + const icons = [...new Set(block.rows.map((row) => row.icon))].filter(Boolean).slice(0, 4).join(""); + const hasError = block.rows.some((row) => row.error); + return `
+ ${block.rows.length} ${escapeHtml(t("stepsLabel"))} +
${block.rows.map((row) => row.html).join("")}
+
`; + }) + .join(""); } function landingMarkup() { return `
-
Open
+
Open
`; } -function runtimeActivityMarkup(capabilities) { - const labels = { - subagents: "Subagent", - workflows: "Workflow", - "background-terminals": "Terminal", - }; - const rows = Object.entries(capabilities || {}).flatMap(([kind, projection]) => { - const items = Array.isArray(projection?.items) ? projection.items : []; - const projected = items.map((item) => { - const title = item.title || item.name || item.id || item.runId || labels[kind] || kind; - const status = typeof item.status === "string" ? item.status : "unknown"; - return `
  • ${escapeHtml(labels[kind] || kind)}${escapeHtml(title)}${escapeHtml(status)}
  • `; - }); - if (Number.isSafeInteger(projection?.omitted) && projection.omitted > 0) { - projected.push(`
  • ${escapeHtml(labels[kind] || kind)}+${projection.omitted} omitted
  • `); - } - return projected; - }); - return rows.length > 0 - ? `` - : ""; +const ACTIVITY_CHIP_LIMIT = 5; + +function activityChipMarkup(kind, status, text) { + const glyph = ACTIVITY_STATUS_GLYPHS[status === "completed" || status === "done" ? "done" : status] || "✓"; + const indicator = + status === "running" + ? `` + : ``; + return `${indicator}${escapeHtml(text)}`; +} + +function activityBarMarkup() { + const capabilities = state.snapshot?.runtime?.capabilities || {}; + // The current protocol projects each capability as { items, omitted }. + const projectionItems = (value) => (Array.isArray(value) ? value : Array.isArray(value?.items) ? value.items : []); + const projectionOmitted = (value) => (Number.isSafeInteger(value?.omitted) ? value.omitted : 0); + const subagents = projectionItems(capabilities.subagents); + const workflows = projectionItems(capabilities.workflows); + const chips = []; + for (const run of workflows) { + // Current protocol projects agent counts; older snapshots list agents. + const agents = Array.isArray(run.agents) ? run.agents : null; + const total = agents ? agents.length : Number(run.agents?.total) || 0; + const settled = agents + ? agents.filter((agent) => agent.state !== "running").length + : total - (Number(run.agents?.running) || 0); + const progress = total > 0 ? ` · ${settled}/${total} agents` : ""; + const phase = run.status === "running" && run.currentPhase ? ` · ${run.currentPhase}` : ""; + const elapsed = run.status === "running" ? formatElapsedMs(run.startedAt) : formatElapsedMs(run.startedAt, run.finishedAt); + const status = canonicalActivityStatus(run.status); + const label = `${run.name || run.runId || "workflow"}${run.status === "running" ? phase + progress : progress}${elapsed ? ` · ${elapsed}` : ""}`; + chips.push({ status, markup: activityChipMarkup("workflow", status, label) }); + } + for (const snap of subagents) { + const elapsed = formatElapsedMs(snap.createdAt, snap.settledAt); + const status = canonicalActivityStatus(snap.status); + chips.push({ status, markup: activityChipMarkup("subagent", status, `${snap.title || snap.id}${elapsed ? ` · ${elapsed}` : ""}`) }); + } + chips.sort((a, b) => (a.status === "running" ? 0 : 1) - (b.status === "running" ? 0 : 1)); + const visible = chips.slice(0, ACTIVITY_CHIP_LIMIT).map((chip) => chip.markup); + const overflow = chips.length - visible.length + projectionOmitted(capabilities.subagents) + projectionOmitted(capabilities.workflows); + if (overflow > 0) visible.push(`+${overflow}`); + return visible.join(""); } +function renderActivityBar() { + const bar = $("activity-bar"); + if (!bar) return; + const markup = activityBarMarkup(); + bar.hidden = !markup; + bar.innerHTML = markup; +} + +let lastRenderedSessionPath = null; +let scrollToBottomOnNextRender = false; + function renderConversation() { const snapshot = state.snapshot; const selected = snapshot?.selectedSession; @@ -433,11 +857,18 @@ function renderConversation() { const summary = snapshot?.sessions.find((session) => session.path === state.selectedPath); if (state.sessionSwitching) { shell.classList.remove("landing"); + const switchingRail = $("turn-rail"); + if (switchingRail) { + switchingRail.hidden = true; + switchingRail.innerHTML = ""; + } + renderActivityBar(); $("session-header").innerHTML = `${escapeHtml(summary ? sessionTitle(summary) : t("newSession"))}${escapeHtml(t("switchingSession"))}`; $("conversation").innerHTML = `
    ${escapeHtml(t("switchingSession"))}
    `; updateComposer(); return; } + const isCurrentSession = Boolean(selected && snapshot && selected.id === snapshot.currentSessionId); const persistedEntries = selected?.entries || []; const persistedMessageKeys = new Set( persistedEntries.map((entry) => `${entry.message?.role || ""}:${entry.message?.content || ""}`), @@ -445,32 +876,127 @@ function renderConversation() { const liveEntries = selected ? state.liveMessages .filter(({ message }) => !persistedMessageKeys.has(`${message.role || ""}:${message.content || ""}`)) - .map(({ message }) => messageMarkup({ type: "message", message })) + .map(({ key, message }) => ({ type: "message", key, message })) : []; - const messages = selected - ? [...persistedEntries.map(messageMarkup).filter(Boolean), ...liveEntries].join("") - : ""; + const allEntries = selected ? [...persistedEntries, ...liveEntries] : []; + const resultsByCallId = new Map(); + const familyCallIds = new Set(); + for (const entry of allEntries) { + const message = entry.message; + if (!message) continue; + if (message.role === "toolResult" && message.toolCallId) { + resultsByCallId.set(message.toolCallId, message); + } + if (Array.isArray(message.parts)) { + for (const part of message.parts) { + if (part.type === "toolCall" && part.id && /^(subagent|workflow)/.test(part.name || "")) { + familyCallIds.add(part.id); + } + } + } + } + const turns = []; + let turnCounter = 0; + let prevEntryTime = 0; + const lastEntryIndex = allEntries.length - 1; + // Each turn's final assistant answer gets the copy/time action bar. + const turnLastAssistant = new Set(); + let turnAssistantCandidate = -1; + let lastUserIndex = -1; + allEntries.forEach((entry, index) => { + const message = entry.message; + if (message?.role === "user") { + lastUserIndex = index; + if (turnAssistantCandidate >= 0) turnLastAssistant.add(turnAssistantCandidate); + turnAssistantCandidate = -1; + return; + } + if (message?.role === "assistant" && typeof message.content === "string" && message.content.trim()) { + turnAssistantCandidate = index; + } + }); + if (turnAssistantCandidate >= 0) turnLastAssistant.add(turnAssistantCandidate); + const rows = allEntries.flatMap((entry, index) => { + let turn = 0; + if (entry.message?.role === "user") { + turn = ++turnCounter; + turns.push({ turn, title: turnTitle(entry.message.content) }); + } + // Thinking duration: live entries time it precisely; persisted entries + // fall back to the gap since the previous entry's timestamp. + const context = {}; + if (isCurrentSession && index === lastUserIndex) context.showEdit = true; + const hasThinking = Array.isArray(entry.message?.parts) && entry.message.parts.some((part) => part.type === "thinking"); + if (hasThinking) { + const startMs = entry.key ? state.thinkingStarts[entry.key] : undefined; + const storedMs = entry.key ? state.thinkingDurations[entry.key] : undefined; + if (startMs && state.liveRunning && index === lastEntryIndex) { + context.thinking = { active: true, startedAt: startMs }; + } else if (storedMs) { + context.thinking = { elapsedMs: storedMs }; + } else { + const endMs = new Date(entry.timestamp).getTime(); + const gap = endMs - prevEntryTime; + if (endMs && gap > 999 && gap < 30 * 60 * 1000) context.thinking = { elapsedMs: gap }; + } + } + const entryTime = new Date(entry.timestamp).getTime(); + if (entryTime) prevEntryTime = entryTime; + return messageMarkup(entry, turn, resultsByCallId, familyCallIds, context, turnLastAssistant.has(index) || entry.message?.role === "user"); + }); + const messages = groupRows(rows); + syncThinkingTimer(rows.some((row) => row.activeThinking)); const landing = !selected || !summary || !messages; shell.classList.toggle("landing", landing); + renderActivityBar(); if (landing) { $("conversation").innerHTML = landingMarkup(); + const landingRail = $("turn-rail"); + if (landingRail) { + landingRail.hidden = true; + landingRail.innerHTML = ""; + } $("session-header").innerHTML = `${escapeHtml(t("newSession"))}${escapeHtml(t("chooseWorkspaceHint"))}`; updateComposer(); return; } $("session-header").innerHTML = `${escapeHtml(sessionTitle(summary))}${escapeHtml(selected.cwd)}`; - const isCurrentSession = selected.id === snapshot.currentSessionId; const isRunning = isCurrentSession && (snapshot.runtime.status === "running" || state.liveRunning); const runningLabel = state.liveRetry ? `${t("modelRetrying")} (${state.liveRetry.attempt}/${state.liveRetry.maxAttempts})` : state.livePhase === "preparing" ? t("modelPreparing") : t("modelRunning"); - const activity = isCurrentSession - ? runtimeActivityMarkup(snapshot.runtime.capabilities) - : ""; - $("conversation").innerHTML = `${messages}${activity}${isRunning ? `
    ${escapeHtml(runningLabel)}
    ` : ""}`; - $("conversation").scrollTop = $("conversation").scrollHeight; + const conversation = $("conversation"); + // While a sent message is being edited inline, keep the editor intact: + // streaming updates resume on the next render after confirm/cancel. + if (messageEditActive) { + updateComposer(); + return; + } + // Stick-to-bottom: only follow the stream when the user is already near the + // bottom; anyone scrolling up to read keeps their position. Session switches + // and first loads always land at the bottom. + const sessionPath = selected?.path || null; + const forceBottom = sessionPath !== lastRenderedSessionPath || scrollToBottomOnNextRender; + scrollToBottomOnNextRender = false; + const pinnedToBottom = + conversation.scrollTop + conversation.clientHeight >= conversation.scrollHeight - 48; + conversation.innerHTML = `${messages}${isRunning ? `
    ${escapeHtml(runningLabel)}
    ` : ""}`; + const rail = $("turn-rail"); + if (rail) { + rail.hidden = turns.length < 2; + rail.setAttribute("aria-label", t("conversationTurns")); + rail.innerHTML = turns + .map( + ({ turn, title }) => ``, + ) + .join(""); + } + if (forceBottom || pinnedToBottom) { + conversation.scrollTo({ top: conversation.scrollHeight, behavior: "instant" }); + } + lastRenderedSessionPath = sessionPath; updateComposer(); } @@ -713,11 +1239,11 @@ function scheduleSnapshotRefresh(delay = 160) { }, delay); } -async function sendPrompt() { +async function sendPrompt(overrideContent) { if (!state.selectedWorkspace) { await chooseWorkspace(); } - const content = $("prompt-input").value.trim(); + const content = (overrideContent ?? $("prompt-input").value).trim(); if ( !content || !state.selectedWorkspace || @@ -738,6 +1264,7 @@ async function sendPrompt() { ].slice(-8); state.promptAdmissionPending = true; state.promptAdmissionToken = admissionToken; + scrollToBottomOnNextRender = true; renderConversation(); $("composer-hint").classList.remove("error"); try { @@ -749,8 +1276,10 @@ async function sendPrompt() { const alreadySettled = state.terminalPromptIds.has(receipt.id); state.liveRunning = !alreadySettled; state.livePhase = alreadySettled ? "idle" : "preparing"; - $("prompt-input").value = ""; - resizePrompt(); + if (overrideContent === undefined) { + $("prompt-input").value = ""; + resizePrompt(); + } $("composer-hint").textContent = t("acceptedHint"); scheduleSnapshotRefresh(120); } catch (error) { @@ -1094,6 +1623,12 @@ function applyRuntimeEvent(event) { const live = { key, message: event.detail.message }; if (index >= 0) state.liveMessages[index] = live; else state.liveMessages = [...state.liveMessages, live].slice(-8); + if (event.detail.message.parts?.some((part) => part.type === "thinking")) { + if (!state.thinkingStarts[key]) state.thinkingStarts[key] = Date.now(); + if (event.type === "message_end") { + state.thinkingDurations[key] = Date.now() - state.thinkingStarts[key]; + } + } renderConversation(); } if ( @@ -1165,6 +1700,8 @@ function resetLiveState() { state.liveRunning = false; state.livePhase = "idle"; state.liveRetry = null; + state.thinkingStarts = {}; + state.thinkingDurations = {}; } async function connectEvents() { @@ -1373,6 +1910,74 @@ $("model-picker")?.addEventListener("click", () => { picker.setAttribute("aria-expanded", String(!menu.hidden)); }); $("mobile-sidebar")?.addEventListener("click", () => document.body.classList.toggle("sidebar-open")); +async function copyText(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + const area = document.createElement("textarea"); + area.value = text; + area.style.position = "fixed"; + area.style.opacity = "0"; + document.body.appendChild(area); + area.select(); + let ok = false; + try { + ok = document.execCommand("copy"); + } catch { + ok = false; + } + area.remove(); + return ok; + } +} + +$("conversation")?.addEventListener("click", async (event) => { + const target = event.target instanceof Element ? event.target : null; + if (!target) return; + const editButton = target.closest(".message-edit"); + if (editButton) { + const row = editButton.closest(".message-row"); + if (row) enterMessageEdit(row); + return; + } + const confirmButton = target.closest(".message-edit-confirm"); + if (confirmButton) { + const input = confirmButton.closest(".message-content")?.querySelector(".message-edit-input"); + if (input) await confirmMessageEdit(input.value); + return; + } + if (target.closest(".message-edit-cancel")) { + messageEditActive = false; + renderConversation(); + return; + } + const copyButton = target.closest(".message-copy"); + if (copyButton) { + const body = copyButton.closest(".message-row")?.querySelector(".message-body"); + const text = body?.textContent?.trim() || ""; + if (text && (await copyText(text))) { + copyButton.classList.add("copied"); + copyButton.title = t("copiedMessage"); + setTimeout(() => { + copyButton.classList.remove("copied"); + copyButton.title = t("copyMessage"); + }, 1200); + } + return; + } + // Replay the landing brand animation when the logo is clicked: swapping in a + // fresh clone restarts its CSS animations. + const brand = target.closest(".landing-brand"); + if (brand) brand.replaceWith(brand.cloneNode(true)); +}); +$("turn-rail")?.addEventListener("click", (event) => { + const tick = event.target instanceof Element ? event.target.closest(".turn-tick") : null; + if (!tick) return; + document.querySelectorAll(".turn-tick.active").forEach((el) => el.classList.remove("active")); + tick.classList.add("active"); + document.querySelector(`.message-row[data-turn="${tick.dataset.turn}"]`)?.scrollIntoView({ behavior: "smooth", block: "start" }); +}); document.querySelector(".sidebar-scrim")?.addEventListener("click", () => document.body.classList.remove("sidebar-open")); $("composer")?.addEventListener("pointerdown", (event) => { if (state.selectedWorkspace) return; diff --git a/web/ui/favicon.svg b/web/ui/favicon.svg new file mode 100644 index 00000000..c28d6242 --- /dev/null +++ b/web/ui/favicon.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/web/ui/index.html b/web/ui/index.html index 7aa60047..b6fd5c34 100644 --- a/web/ui/index.html +++ b/web/ui/index.html @@ -5,6 +5,7 @@ OpenPI + @@ -73,7 +74,6 @@ -
    @@ -91,12 +91,15 @@
    - Open + Open
    + +
    +