diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts index 2df9f35..cf2b79c 100644 --- a/frontend/src/api/agents.ts +++ b/frontend/src/api/agents.ts @@ -11,7 +11,14 @@ export interface AgentInfo { } export interface AgentEvent { - type: "status" | "output" | "agent_json" | "done" | "error" | "warning"; + type: + | "status" + | "output" + | "thinking" + | "agent_json" + | "done" + | "error" + | "warning"; story_id?: string; agent_name?: string; status?: string; diff --git a/frontend/src/components/AgentPanel.test.tsx b/frontend/src/components/AgentPanel.test.tsx index e1d517c..282c5c4 100644 --- a/frontend/src/components/AgentPanel.test.tsx +++ b/frontend/src/components/AgentPanel.test.tsx @@ -1,7 +1,7 @@ -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AgentConfigInfo, AgentInfo } from "../api/agents"; -import { agentsApi } from "../api/agents"; +import type { AgentConfigInfo, AgentEvent, AgentInfo } from "../api/agents"; +import { agentsApi, subscribeAgentStream } from "../api/agents"; vi.mock("../api/agents", () => { const agentsApi = { @@ -17,6 +17,8 @@ vi.mock("../api/agents", () => { // Dynamic import so the mock is in place before the module loads const { AgentPanel } = await import("./AgentPanel"); +const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream); + const mockedAgents = { listAgents: vi.mocked(agentsApi.listAgents), getAgentConfig: vi.mocked(agentsApi.getAgentConfig), @@ -210,3 +212,214 @@ describe("RosterBadge availability state", () => { expect(dot.style.animation).toBe(""); }); }); + +describe("Thinking trace block in agent stream UI", () => { + beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); + }); + + beforeEach(() => { + mockedAgents.getAgentConfig.mockResolvedValue(ROSTER); + mockedAgents.listAgents.mockResolvedValue([]); + mockedSubscribeAgentStream.mockReturnValue(() => {}); + }); + + // AC1+AC2: thinking block renders with fixed max-height and is visually distinct + it("renders thinking block with max-height 96px when thinking event arrives", async () => { + let emitEvent: ((e: AgentEvent) => void) | null = null; + mockedSubscribeAgentStream.mockImplementation( + (_storyId, _agentName, onEvent) => { + emitEvent = onEvent; + return () => {}; + }, + ); + + const agentList: AgentInfo[] = [ + { + story_id: "160_thinking", + agent_name: "coder-1", + status: "running", + session_id: null, + worktree_path: "/tmp/wt", + base_branch: "master", + log_session_id: null, + }, + ]; + mockedAgents.listAgents.mockResolvedValue(agentList); + + render(); + + // Wait for the subscription to be set up + await screen.findByTestId("roster-badge-coder-1"); + + // Fire a thinking event + await act(async () => { + emitEvent?.({ + type: "thinking", + story_id: "160_thinking", + agent_name: "coder-1", + text: "Let me consider the problem carefully...", + }); + }); + + const block = screen.getByTestId("thinking-block"); + expect(block).toBeInTheDocument(); + // AC2: fixed max-height + expect(block.style.maxHeight).toBe("96px"); + // AC2: overflow scrolling + expect(block.style.overflowY).toBe("auto"); + // AC1: visually distinct — italic monospace font + expect(block.style.fontStyle).toBe("italic"); + expect(block.style.fontFamily).toBe("monospace"); + // Contains the thinking text + expect(block.textContent).toContain( + "Let me consider the problem carefully...", + ); + }); + + // AC3: thinking block renders the "thinking" label + it("shows a thinking label in the block header", async () => { + let emitEvent: ((e: AgentEvent) => void) | null = null; + mockedSubscribeAgentStream.mockImplementation( + (_storyId, _agentName, onEvent) => { + emitEvent = onEvent; + return () => {}; + }, + ); + + const agentList: AgentInfo[] = [ + { + story_id: "160_label", + agent_name: "coder-1", + status: "running", + session_id: null, + worktree_path: "/tmp/wt", + base_branch: "master", + log_session_id: null, + }, + ]; + mockedAgents.listAgents.mockResolvedValue(agentList); + + render(); + await screen.findByTestId("roster-badge-coder-1"); + + await act(async () => { + emitEvent?.({ + type: "thinking", + story_id: "160_label", + agent_name: "coder-1", + text: "thinking...", + }); + }); + + const block = screen.getByTestId("thinking-block"); + expect(block.textContent).toContain("thinking"); + }); + + // AC4: regular text output renders outside the thinking container + it("renders regular output outside the thinking block", async () => { + let emitEvent: ((e: AgentEvent) => void) | null = null; + mockedSubscribeAgentStream.mockImplementation( + (_storyId, _agentName, onEvent) => { + emitEvent = onEvent; + return () => {}; + }, + ); + + const agentList: AgentInfo[] = [ + { + story_id: "160_output", + agent_name: "coder-1", + status: "running", + session_id: null, + worktree_path: "/tmp/wt", + base_branch: "master", + log_session_id: null, + }, + ]; + mockedAgents.listAgents.mockResolvedValue(agentList); + + render(); + await screen.findByTestId("roster-badge-coder-1"); + + // First: thinking event + await act(async () => { + emitEvent?.({ + type: "thinking", + story_id: "160_output", + agent_name: "coder-1", + text: "thinking deeply", + }); + }); + + // Then: text output event + await act(async () => { + emitEvent?.({ + type: "output", + story_id: "160_output", + agent_name: "coder-1", + text: "Here is the result.", + }); + }); + + const thinkingBlock = screen.getByTestId("thinking-block"); + const outputArea = screen.getByTestId("agent-output-coder-1"); + + // Thinking still visible + expect(thinkingBlock).toBeInTheDocument(); + expect(thinkingBlock.textContent).toContain("thinking deeply"); + + // Output renders in a separate element, not inside the thinking block + expect(outputArea).toBeInTheDocument(); + expect(outputArea.textContent).toContain("Here is the result."); + expect(thinkingBlock.contains(outputArea)).toBe(false); + }); + + // AC5: thinking block remains visible when text starts + it("keeps thinking block visible after output arrives", async () => { + let emitEvent: ((e: AgentEvent) => void) | null = null; + mockedSubscribeAgentStream.mockImplementation( + (_storyId, _agentName, onEvent) => { + emitEvent = onEvent; + return () => {}; + }, + ); + + const agentList: AgentInfo[] = [ + { + story_id: "160_persist", + agent_name: "coder-1", + status: "running", + session_id: null, + worktree_path: "/tmp/wt", + base_branch: "master", + log_session_id: null, + }, + ]; + mockedAgents.listAgents.mockResolvedValue(agentList); + + render(); + await screen.findByTestId("roster-badge-coder-1"); + + await act(async () => { + emitEvent?.({ + type: "thinking", + story_id: "160_persist", + agent_name: "coder-1", + text: "my thoughts", + }); + }); + + await act(async () => { + emitEvent?.({ + type: "output", + story_id: "160_persist", + agent_name: "coder-1", + text: "final answer", + }); + }); + + // Thinking block still in the DOM after output arrives + expect(screen.getByTestId("thinking-block")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/AgentPanel.tsx b/frontend/src/components/AgentPanel.tsx index e1eaff9..6db2a29 100644 --- a/frontend/src/components/AgentPanel.tsx +++ b/frontend/src/components/AgentPanel.tsx @@ -14,12 +14,66 @@ interface AgentState { agentName: string; status: AgentStatusValue; log: string[]; + /** Accumulated thinking text for the current turn. */ + thinking: string; + /** True once regular output has been received after thinking started. */ + thinkingDone: boolean; sessionId: string | null; worktreePath: string | null; baseBranch: string | null; terminalAt: number | null; } +/** Fixed-height thinking trace block that auto-scrolls to bottom as text arrives. */ +function ThinkingBlock({ text }: { text: string }) { + const scrollRef = useRef(null); + + // Auto-scroll to bottom whenever text grows + useEffect(() => { + const el = scrollRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + } + }, [text]); + + return ( +
+ + thinking + + {text} +
+ ); +} + const formatTimestamp = (value: Date | null): string => { if (!value) return ""; return value.toLocaleTimeString([], { @@ -117,6 +171,8 @@ export function AgentPanel({ configVersion = 0 }: AgentPanelProps) { agentName: a.agent_name, status: a.status, log: [], + thinking: "", + thinkingDone: false, sessionId: a.session_id, worktreePath: a.worktree_path, baseBranch: a.base_branch, @@ -160,6 +216,8 @@ export function AgentPanel({ configVersion = 0 }: AgentPanelProps) { agentName, status: "pending" as AgentStatusValue, log: [], + thinking: "", + thinkingDone: false, sessionId: null, worktreePath: null, baseBranch: null, @@ -183,12 +241,23 @@ export function AgentPanel({ configVersion = 0 }: AgentPanelProps) { }, }; } + case "thinking": + return { + ...prev, + [key]: { + ...current, + thinking: current.thinking + (event.text ?? ""), + }, + }; case "output": return { ...prev, [key]: { ...current, log: [...current.log, event.text ?? ""], + // Receiving text output signals thinking phase is over + thinkingDone: + current.thinking.length > 0 ? true : current.thinkingDone, }, }; case "done": @@ -240,6 +309,11 @@ export function AgentPanel({ configVersion = 0 }: AgentPanelProps) { } }; + // Agents that have streaming content to show (thinking or log) + const activeAgents = Object.values(agents).filter( + (a) => a.thinking.length > 0 || a.log.length > 0, + ); + return (
)} + {/* Per-agent streaming output: thinking trace + regular text */} + {activeAgents.map((agent) => ( +
+ {agent.thinking.length > 0 && } + {agent.log.length > 0 && ( +
+ {agent.log.join("")} +
+ )} +
+ ))} + {actionError && (