diff --git a/packages/app/src/components/agent-stream-view.test.tsx b/packages/app/src/components/agent-stream-view.test.tsx index 00ef122b7..8f2596be0 100644 --- a/packages/app/src/components/agent-stream-view.test.tsx +++ b/packages/app/src/components/agent-stream-view.test.tsx @@ -11,6 +11,7 @@ import { AgentStreamView } from "./agent-stream-view"; const assistantMessageCalls = vi.hoisted( () => [] as Array<{ message: string; spacing: string | undefined }>, ); +const turnCopyButtonCalls = vi.hoisted(() => [] as Array<{ getContent: () => string }>); const mockSessionState = vi.hoisted(() => ({ sessions: { @@ -121,7 +122,13 @@ vi.mock("./message", async () => { SpeakMessage: () => null, TodoListCard: () => null, ToolCall: () => null, - TurnCopyButton: () => null, + TurnCopyButton: (props: { getContent: () => string }) => { + turnCopyButtonCalls.push(props); + return ReactModule.createElement("button", { + "data-testid": "turn-copy-button", + type: "button", + }); + }, UserMessage: () => null, }; }); @@ -174,6 +181,24 @@ function assistantBlock(params: { }; } +function runningToolCall(id: string): Extract { + return { + kind: "tool_call", + id, + timestamp: new Date("2026-05-01T00:00:00.000Z"), + payload: { + source: "orchestrator", + data: { + toolCallId: id, + toolName: "bash", + arguments: "npm test", + result: null, + status: "executing", + }, + }, + }; +} + describe("AgentStreamView", () => { let root: Root | null = null; let container: HTMLDivElement | null = null; @@ -187,6 +212,7 @@ describe("AgentStreamView", () => { originalScrollTo = HTMLElement.prototype.scrollTo; HTMLElement.prototype.scrollTo = vi.fn(); assistantMessageCalls.length = 0; + turnCopyButtonCalls.length = 0; mockSessionState.sessions.server.agentStreamHead = new Map(); container = document.createElement("div"); document.body.appendChild(container); @@ -255,4 +281,135 @@ describe("AgentStreamView", () => { Array.from({ length: headCalls.length }, () => "compactTop"), ); }); + + it("renders running dots in the assistant turn footer when live text is streaming", () => { + const headBlock = assistantBlock({ + id: "group-1:head", + text: "Streaming paragraph", + blockIndex: 0, + }); + mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]); + const agent = { + id: "agent-1", + serverId: "server", + status: "running", + cwd: "/tmp/project", + } as never; + + act(() => { + root?.render( + React.createElement(AgentStreamView, { + agentId: "agent-1", + serverId: "server", + agent, + streamItems: [], + pendingPermissions: new Map(), + }), + ); + }); + + expect(container?.querySelector('[data-testid="turn-working-indicator"]')).not.toBeNull(); + expect( + container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'), + ).toBeNull(); + expect(container?.querySelector('[data-testid="turn-copy-button"]')).toBeNull(); + }); + + it("only renders running dots on the live assistant row", () => { + const tailBlock = assistantBlock({ + id: "group-1:block:0", + text: "History paragraph", + blockIndex: 0, + }); + const headBlock = assistantBlock({ + id: "group-2:head", + text: "Streaming paragraph", + blockIndex: 0, + }); + mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]); + const agent = { + id: "agent-1", + serverId: "server", + status: "running", + cwd: "/tmp/project", + } as never; + + act(() => { + root?.render( + React.createElement(AgentStreamView, { + agentId: "agent-1", + serverId: "server", + agent, + streamItems: [tailBlock], + pendingPermissions: new Map(), + }), + ); + }); + + expect(container?.querySelectorAll('[data-testid="turn-working-indicator"]')).toHaveLength(1); + expect( + container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'), + ).toBeNull(); + }); + + it("keeps the auxiliary running dots when there is no live assistant row", () => { + mockSessionState.sessions.server.agentStreamHead.set("agent-1", [runningToolCall("tool-1")]); + const agent = { + id: "agent-1", + serverId: "server", + status: "running", + cwd: "/tmp/project", + } as never; + + act(() => { + root?.render( + React.createElement(AgentStreamView, { + agentId: "agent-1", + serverId: "server", + agent, + streamItems: [], + pendingPermissions: new Map(), + }), + ); + }); + + expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull(); + expect( + container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'), + ).not.toBeNull(); + }); + + it("replaces the running footer with the copy button when the assistant turn idles", () => { + const headBlock = assistantBlock({ + id: "group-1:head", + text: "Complete paragraph", + blockIndex: 0, + }); + mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]); + const agent = { + id: "agent-1", + serverId: "server", + status: "idle", + cwd: "/tmp/project", + } as never; + + act(() => { + root?.render( + React.createElement(AgentStreamView, { + agentId: "agent-1", + serverId: "server", + agent, + streamItems: [], + pendingPermissions: new Map(), + }), + ); + }); + + expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull(); + expect(container?.querySelector('[data-testid="turn-copy-button"]')).not.toBeNull(); + expect(turnCopyButtonCalls.length).toBeGreaterThan(0); + expect(turnCopyButtonCalls.map((call) => call.getContent())).toEqual( + Array.from({ length: turnCopyButtonCalls.length }, () => "Complete paragraph"), + ); + }); }); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 412d756fd..0ef3f7c8d 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -292,6 +292,25 @@ const AgentStreamViewComponent = forwardRef { + if (agent.status !== "running") { + return null; + } + const footerItem = baseRenderModel.segments.liveHead.find((item, index, items) => { + if (item.kind !== "assistant_message") { + return false; + } + return ( + getStreamNeighborItem({ + strategy: streamRenderStrategy, + items, + index, + relation: "below", + }) === undefined + ); + }); + return footerItem?.id ?? null; + }, [agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy]); useImperativeHandle( ref, () => ({ @@ -590,21 +609,31 @@ const AgentStreamViewComponent = forwardRef; + } else if (isEndOfAssistantTurn) { + footer = ( + + ); + } return ( {content} - {isEndOfAssistantTurn ? ( - - ) : null} + {footer} ); }, - [getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy], + [ + getGapBetween, + renderStreamItemContent, + agent.status, + streamRenderStrategy, + inlineWorkingIndicatorItemId, + ], ); const pendingPermissionItems = useMemo( @@ -612,7 +641,8 @@ const AgentStreamViewComponent = forwardRef pendingPermissionItems.length > 0 ? ( @@ -626,12 +656,12 @@ const AgentStreamViewComponent = forwardRef - showWorkingIndicator ? ( - + showAuxiliaryWorkingIndicator ? ( + ) : null, - [showWorkingIndicator], + [showAuxiliaryWorkingIndicator], ); const renderModel = useMemo(() => { return { @@ -800,7 +830,7 @@ const AgentStreamViewComponent = forwardRef { @@ -852,8 +882,13 @@ function WorkingIndicator() { [dotThreeStyle], ); + const containerStyle = + variant === "inline" + ? stylesheet.inlineWorkingIndicatorFrame + : stylesheet.workingIndicatorBubble; + return ( - + @@ -863,6 +898,14 @@ function WorkingIndicator() { ); } +function InlineWorkingIndicatorSlot() { + return ( + + + + ); +} + // Permission Request Card Component type TurnContentStrategy = Parameters< typeof collectAssistantTurnContentForStreamRenderStrategy @@ -1227,6 +1270,17 @@ const stylesheet = StyleSheet.create((theme) => ({ paddingBottom: theme.spacing[2], gap: theme.spacing[2], }, + inlineTurnFooter: { + alignSelf: "flex-start", + marginTop: theme.spacing[2], + padding: theme.spacing[2], + paddingTop: 0, + }, + inlineWorkingIndicatorFrame: { + height: 18, + alignItems: "center", + justifyContent: "center", + }, workingIndicatorBubble: { flexDirection: "row", alignItems: "center",