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 };
}