Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions plugins/web-ui/src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
Files,
GitFork,
Maximize2,
MessageSquare,
Paperclip,
Pencil,
Plug,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1984,6 +1993,14 @@ export function createChatSurface(
flushSeg();
const text = ((it.activity.payload as { text?: string } | null)?.text ?? "").trim();
if (text) parts.push(html`<div class="work-said">${markdown(text)}</div>`);
} else if (it.kind === "tool") {
const speech = postSpeechText(it.row);
if (speech) {
flushSeg();
parts.push(html`<div class="work-said">${markdown(speech)}</div>`);
} else {
seg.push(it);
}
} else {
seg.push(it);
}
Expand Down Expand Up @@ -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);
}

Expand Down
12 changes: 12 additions & 0 deletions plugins/web-ui/src/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface ToolPayload {
code?: number;
timedOut?: boolean;
action?: string;
text?: string;
process_id?: string;
monitor_id?: string;
added?: number;
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions plugins/web-ui/test/chat-post-speech.test.ts
Original file line number Diff line number Diff line change
@@ -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`<div class="work-said">\$\{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*\}/,
);
});
36 changes: 35 additions & 1 deletion plugins/web-ui/test/timeline.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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 };
}
Expand Down