diff --git a/packages/coding-agent/src/prompts/system/workflow-notice.md b/packages/coding-agent/src/prompts/system/workflow-notice.md index 74eddee21..3e62ca5b0 100644 --- a/packages/coding-agent/src/prompts/system/workflow-notice.md +++ b/packages/coding-agent/src/prompts/system/workflow-notice.md @@ -51,20 +51,20 @@ Decompose first, then {{#if taskBatch}}batch the independent leaves{{else}}issue {{#if taskBatch}} task( - context: "# Goal\nReview the auth diff...\n# Constraints\nRead-only...\n# Contract\nReturn findings as severity/file/line/fix...", + context: "# Goal\nReview the auth diff…\n# Constraints\nRead-only…\n# Contract\nReturn findings as severity/file/line/fix…", tasks: [ - { id: "AuthOwner", role: "Auth Storage Reviewer", assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nTrace credential selection...\n# Acceptance\nReturn confirmed findings only..." }, - { id: "PromptOwner", role: "Prompt Contract Reviewer", assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance...\n# Acceptance\nReturn mismatches and exact prompt lines..." }, + { id: "AuthOwner", role: "Auth Storage Reviewer", assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nTrace credential selection…\n# Acceptance\nReturn confirmed findings only…" }, + { id: "PromptOwner", role: "Prompt Contract Reviewer", assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance…\n# Acceptance\nReturn mismatches and exact prompt lines…" }, ] ) {{else}} task( role: "Auth Storage Reviewer", - assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nReview the auth diff. Shared contract: read-only; return findings as severity/file/line/fix.\n# Acceptance\nReturn confirmed findings only..." + assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nReview the auth diff. Shared contract: read-only; return findings as severity/file/line/fix.\n# Acceptance\nReturn confirmed findings only…" ) task( role: "Prompt Contract Reviewer", - assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance. Shared contract: read-only; return mismatches and exact prompt lines.\n# Acceptance\nReturn confirmed findings only..." + assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance. Shared contract: read-only; return mismatches and exact prompt lines.\n# Acceptance\nReturn confirmed findings only…" ) {{/if}} diff --git a/packages/collab-web/CHANGELOG.md b/packages/collab-web/CHANGELOG.md index f198055f4..ae1142de2 100644 --- a/packages/collab-web/CHANGELOG.md +++ b/packages/collab-web/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the live collab transcript rendering duplicate tool cards and a stale `thinking…` shimmer while a committed tool call is still running. ([#4948](https://github.com/can1357/oh-my-pi/issues/4948)) + ## [16.3.7] - 2026-07-05 ### Fixed diff --git a/packages/collab-web/src/components/transcript/Transcript.tsx b/packages/collab-web/src/components/transcript/Transcript.tsx index 48d537645..30e105564 100644 --- a/packages/collab-web/src/components/transcript/Transcript.tsx +++ b/packages/collab-web/src/components/transcript/Transcript.tsx @@ -263,16 +263,22 @@ export function Transcript(props: TranscriptProps): ReactNode { if (el !== null && lockRef.current) el.scrollTop = el.scrollHeight; }, [entries, stream, activeTools, working]); - // Active tools not already represented as toolCall blocks in the stream ghost. - const streamIds = new Set(); + // Active tools not already represented as toolCall blocks in committed rows or the stream ghost. + const renderedToolIds = new Set(); + for (const entry of entries) { + if (entry.type !== "message" || entry.message.role !== "assistant") continue; + for (const block of entry.message.content) { + if (block.type === "toolCall") renderedToolIds.add(block.id); + } + } if (stream !== null) { for (const block of stream.content) { - if (block.type === "toolCall") streamIds.add(block.id); + if (block.type === "toolCall") renderedToolIds.add(block.id); } } const tailTools: ActiveTool[] = []; for (const tool of activeTools.values()) { - if (!streamIds.has(tool.toolCallId)) tailTools.push(tool); + if (!renderedToolIds.has(tool.toolCallId)) tailTools.push(tool); } return ( @@ -317,7 +323,7 @@ export function Transcript(props: TranscriptProps): ReactNode { ))} )} - {working && stream === null && ( + {working && stream === null && activeTools.size === 0 && (
thinking…
diff --git a/packages/collab-web/test/transcript-dom-shim.ts b/packages/collab-web/test/transcript-dom-shim.ts new file mode 100644 index 000000000..37fc05ff0 --- /dev/null +++ b/packages/collab-web/test/transcript-dom-shim.ts @@ -0,0 +1,4 @@ +class TestHTMLElement {} + +const globals = globalThis as typeof globalThis & { HTMLElement?: typeof HTMLElement }; +globals.HTMLElement ??= TestHTMLElement as unknown as typeof HTMLElement; diff --git a/packages/collab-web/test/transcript.test.tsx b/packages/collab-web/test/transcript.test.tsx new file mode 100644 index 000000000..f3b004eac --- /dev/null +++ b/packages/collab-web/test/transcript.test.tsx @@ -0,0 +1,109 @@ +import { describe, expect, it } from "bun:test"; +import type { AssistantMessage, SessionEntry } from "@oh-my-pi/pi-wire"; +import { renderToStaticMarkup } from "react-dom/server"; +import "./transcript-dom-shim"; +import { Transcript } from "../src/components/transcript/Transcript"; +import type { ActiveTool } from "../src/lib/client"; + +const TOOL_CALL_ID = "call-running-tool"; +const TOOL_NAME = "probe_tool"; + +function assistantUsage(): AssistantMessage["usage"] { + return { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { total: 0 } }; +} + +function committedAssistantToolCall(): SessionEntry { + return { + type: "message", + id: "assistant-entry-1", + parentId: null, + timestamp: "2026-07-09T00:00:00Z", + message: { + role: "assistant", + content: [ + { type: "text", text: "I will run the tool." }, + { + type: "toolCall", + id: TOOL_CALL_ID, + name: TOOL_NAME, + arguments: { target: "fixture-input" }, + intent: "Inspect fixture input", + }, + ], + model: "test/model", + usage: assistantUsage(), + stopReason: "stop", + timestamp: 1, + }, + }; +} + +function activeTool(): ActiveTool { + return { + toolCallId: TOOL_CALL_ID, + toolName: TOOL_NAME, + args: { target: "fixture-input" }, + intent: "Inspect fixture input", + startedAt: 1, + }; +} + +function renderTranscript(props: { + entries?: readonly SessionEntry[]; + activeTools?: ReadonlyMap; + working: boolean; +}): string { + return renderToStaticMarkup( + , + ); +} + +function countElements(html: string, selector: string): number { + let count = 0; + new HTMLRewriter() + .on(selector, { + element() { + count++; + }, + }) + .transform(html); + return count; +} + +function countOccurrences(text: string, needle: string): number { + let count = 0; + let start = 0; + while (true) { + const index = text.indexOf(needle, start); + if (index === -1) return count; + count++; + start = index + needle.length; + } +} + +describe("Transcript live tool rendering", () => { + it("renders one running card for a committed tool call without the working shimmer", () => { + const html = renderTranscript({ + entries: [committedAssistantToolCall()], + activeTools: new Map([[TOOL_CALL_ID, activeTool()]]), + working: true, + }); + + expect(countElements(html, ".tv-card")).toBe(1); + expect(countElements(html, '[aria-label="running"]')).toBe(1); + expect(countOccurrences(html, TOOL_NAME)).toBe(1); + expect(html).not.toContain("thinking…"); + }); + + it("keeps the working shimmer when no tool is active", () => { + const html = renderTranscript({ working: true, activeTools: new Map() }); + + expect(html).toContain("thinking…"); + }); +});