diff --git a/plugins/web-ui/src/chat.ts b/plugins/web-ui/src/chat.ts index 9ff43b31d..2767f94e9 100644 --- a/plugins/web-ui/src/chat.ts +++ b/plugins/web-ui/src/chat.ts @@ -19,6 +19,7 @@ import { Files, GitFork, Maximize2, + MessageSquare, Paperclip, Pencil, Plug, @@ -67,7 +68,7 @@ import { type WorkBlock, withBase, } from "./core-bridge"; -import { buildTimeline, toolRowKind, type TimelineItem, type ToolPayload, type ToolRowModel } from "./timeline"; +import { buildTimeline, postSpeechText, toolRowKind, type TimelineItem, type ToolPayload, type ToolRowModel } from "./timeline"; import { CONNECTOR_NAMES, connectorLinksIn, stripConnectorLinks, type ConnectorLink } from "./connector-link"; import { deepLinkPath, UI_BASE } from "./deep-link"; import type { ChatSurface, ConvCtx } from "./conv-types"; @@ -1898,8 +1899,16 @@ export function createChatSurface( 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); + const posting = postSpeechText(row, true); + if (posting) { + return { + icon: MessageSquare, + label: secs > 0 ? `Posting message for ${secs}s` : "Posting message", + detail: firstLine(posting, 60), + }; + } + const meta = TOOL_META[tool] ?? UNKNOWN_TOOL; return { icon: meta.icon, label: secs > 0 ? `${meta.active} for ${secs}s` : meta.active, @@ -1984,6 +1993,14 @@ export function createChatSurface( flushSeg(); const text = ((it.activity.payload as { text?: string } | null)?.text ?? "").trim(); if (text) parts.push(html`
${markdown(text)}
`); + } else if (it.kind === "tool") { + const speech = postSpeechText(it.row); + if (speech) { + flushSeg(); + parts.push(html`
${markdown(speech)}
`); + } else { + seg.push(it); + } } else { seg.push(it); } @@ -2063,6 +2080,8 @@ export function createChatSurface( if (item.kind === "thinking") return thinkingRow(item.activity); if (item.kind === "text") return messageRow(item.activity); if (item.kind === "approval") return approvalMarker(item.approval); + const speech = postSpeechText(item.row, work.status === "working" || work.status === "thinking"); + if (speech) return messageRow({ ...item.row.call!, payload: { text: speech } }); return toolRow(item.row, work, status, stale); } diff --git a/plugins/web-ui/src/timeline.ts b/plugins/web-ui/src/timeline.ts index 9248d33f6..94d925ff8 100644 --- a/plugins/web-ui/src/timeline.ts +++ b/plugins/web-ui/src/timeline.ts @@ -19,6 +19,7 @@ export interface ToolPayload { code?: number; timedOut?: boolean; action?: string; + text?: string; process_id?: string; monitor_id?: string; added?: number; @@ -64,6 +65,17 @@ function callIdOf(a: ToolActivity): string | undefined { return typeof id === "string" && id ? id : undefined; } +export function postSpeechText(row: ToolRowModel, allowInFlight = false): string | null { + const call = (row.call?.payload ?? {}) as ToolPayload; + if (call.action !== "post" || typeof call.text !== "string" || !call.text.trim()) return null; + if (row.result) { + const result = (row.result.payload ?? {}) as ToolPayload & { isError?: boolean; ok?: boolean }; + if (result.isError === true || result.ok === false || result.error || result.denied === true) return null; + return call.text; + } + return allowInFlight ? call.text : null; +} + function orphanCallSignature(row: ToolRowModel): string | null { if (!row.call || row.result || row.approval) return null; const p = (row.call.payload ?? {}) as ToolPayload; diff --git a/plugins/web-ui/test/chat-post-speech.test.ts b/plugins/web-ui/test/chat-post-speech.test.ts new file mode 100644 index 000000000..3d29ee136 --- /dev/null +++ b/plugins/web-ui/test/chat-post-speech.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const chat = readFileSync(new URL("../src/chat.ts", import.meta.url), "utf8"); + +test("a surface post renders as conversation speech on both render paths, never as a bare tool row", () => { + assert.match( + chat, + /const speech = postSpeechText\(item\.row, work\.status === "working" \|\| work\.status === "thinking"\);\s*if \(speech\) return messageRow\(\{ \.\.\.item\.row\.call!, payload: \{ text: speech \} \}\);/, + "live timeline rows must turn a post's text into a message row, gated on live work status so unconfirmed posts never render as speech inside settled folds", + ); + assert.match( + chat, + /const speech = postSpeechText\(it\.row\);\s*if \(speech\) \{\s*flushSeg\(\);\s*parts\.push\(html`
\$\{markdown\(speech\)\}<\/div>`\);\s*\} else \{\s*seg\.push\(it\);/, + "settled folds must flush the segment and render the confirmed post text as a work-said bubble instead of folding it away", + ); +}); + +test("the live dock previews an in-flight post instead of showing a generic step", () => { + assert.match( + chat, + /const posting = postSpeechText\(row, true\);\s*if \(posting\) \{\s*return \{\s*icon: MessageSquare,\s*label: secs > 0 \? `Posting message for \$\{secs\}s` : "Posting message",\s*detail: firstLine\(posting, 60\),\s*\};\s*\}/, + ); +}); diff --git a/plugins/web-ui/test/timeline.test.ts b/plugins/web-ui/test/timeline.test.ts index 4f4e597d8..36afe0384 100644 --- a/plugins/web-ui/test/timeline.test.ts +++ b/plugins/web-ui/test/timeline.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import type { ToolActivity, WorkBlock } from "../src/core-bridge.ts"; -import { buildTimeline, toolRowKind, type ToolRowModel } from "../src/timeline.ts"; +import { buildTimeline, postSpeechText, toolRowKind, type ToolRowModel } from "../src/timeline.ts"; function act(seq: number, type: ToolActivity["type"], payload: unknown): ToolActivity { return { seq, parentSeq: null, type, payload, createdAt: seq }; @@ -260,6 +260,40 @@ test("toolRowKind: no result yet — `running` mid-turn, `attempted`/`failed` on ); }); +test("postSpeechText: a delivered surface post is speech; failed or unconfirmed posts are not", () => { + const okRow: ToolRowModel = { + call: act(1, "tool_call", { tool: "web", action: "post", text: "Which plan should I use?", callId: "a" }), + result: act(2, "tool_result", { tool: "web", callId: "a", ok: true, deliveryId: "d" }), + }; + assert.equal(postSpeechText(okRow), "Which plan should I use?"); + + const inFlight: ToolRowModel = { + call: act(1, "tool_call", { tool: "web", action: "post", text: "Still composing", callId: "b" }), + result: null, + }; + assert.equal(postSpeechText(inFlight), null, "a post without a result never claims delivered speech"); + assert.equal(postSpeechText(inFlight, true), "Still composing", "live contexts may preview the in-flight text"); + + const failed: ToolRowModel = { + call: act(1, "tool_call", { tool: "web", action: "post", text: "Never delivered", callId: "c" }), + result: act(2, "tool_result", { tool: "web", callId: "c", error: "channel unavailable" }), + }; + assert.equal(postSpeechText(failed), null); + assert.equal(postSpeechText(failed, true), null); + + const failedFlag: ToolRowModel = { + call: act(1, "tool_call", { tool: "web", action: "post", text: "Also never delivered", callId: "e" }), + result: act(2, "tool_result", { tool: "web", callId: "e", ok: false }), + }; + assert.equal(postSpeechText(failedFlag), null); + + const nonPost: ToolRowModel = { + call: act(1, "tool_call", { tool: "read", path: "/x", callId: "d" }), + result: act(2, "tool_result", { tool: "read", callId: "d" }), + }; + assert.equal(postSpeechText(nonPost), null); +}); + function workWith(activity: ToolActivity[], status: WorkBlock["status"] = "working"): WorkBlock { return { status, activity }; }