From 1d17a4d756eb0a636d832ca0fbcdea7398703aef Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 28 Feb 2026 09:28:27 +0000 Subject: [PATCH] story-kit: queue 235_story_show_agent_logs_for_a_story_in_expanded_work_item for merge --- ..._logs_for_a_story_in_expanded_work_item.md | 2 +- ...sults_for_a_story_in_expanded_work_item.md | 0 .../components/WorkItemDetailPanel.test.tsx | 315 +++++++++++++++++- .../src/components/WorkItemDetailPanel.tsx | 142 +++++++- 4 files changed, 446 insertions(+), 13 deletions(-) rename .story_kit/work/{3_qa => 4_merge}/236_story_show_test_results_for_a_story_in_expanded_work_item.md (100%) diff --git a/.story_kit/work/4_merge/235_story_show_agent_logs_for_a_story_in_expanded_work_item.md b/.story_kit/work/4_merge/235_story_show_agent_logs_for_a_story_in_expanded_work_item.md index 03740fe..e20a20d 100644 --- a/.story_kit/work/4_merge/235_story_show_agent_logs_for_a_story_in_expanded_work_item.md +++ b/.story_kit/work/4_merge/235_story_show_agent_logs_for_a_story_in_expanded_work_item.md @@ -1,6 +1,6 @@ --- name: "Show agent logs for a story in expanded work item" -merge_failure: "Merge pipeline infrastructure failure: squash merge succeeded on merge-queue branch, conflicts auto-resolved (WorkItemDetailPanel.test.tsx), quality gates passed, but final cherry-pick onto master failed with \"fatal: bad revision 'merge-queue/235_story_show_agent_logs_for_a_story_in_expanded_work_item'\" — the merge-queue branch reference was not found at cherry-pick time. Master is untouched." +merge_failure: "Merge workspace is occupied by story 236 (merge-queue/236_story_show_test_results_for_a_story_in_expanded_work_item). The merge pipeline uses a single merge_workspace directory and it is currently locked by another merge. Story 235 cannot proceed until the 236 merge completes or its stale worktree is cleaned up. To unblock: either wait for 236's merge to finish, or manually clean up with `git worktree remove .story_kit/merge_workspace` and `git branch -D merge-queue/236_story_show_test_results_for_a_story_in_expanded_work_item`." --- # Story 235: Show agent logs for a story in expanded work item diff --git a/.story_kit/work/3_qa/236_story_show_test_results_for_a_story_in_expanded_work_item.md b/.story_kit/work/4_merge/236_story_show_test_results_for_a_story_in_expanded_work_item.md similarity index 100% rename from .story_kit/work/3_qa/236_story_show_test_results_for_a_story_in_expanded_work_item.md rename to .story_kit/work/4_merge/236_story_show_test_results_for_a_story_in_expanded_work_item.md diff --git a/frontend/src/components/WorkItemDetailPanel.test.tsx b/frontend/src/components/WorkItemDetailPanel.test.tsx index faac353..e12e082 100644 --- a/frontend/src/components/WorkItemDetailPanel.test.tsx +++ b/frontend/src/components/WorkItemDetailPanel.test.tsx @@ -1,18 +1,43 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { WorkItemDetailPanel } from "./WorkItemDetailPanel"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentEvent, AgentInfo } from "../api/agents"; +import { agentsApi, subscribeAgentStream } from "../api/agents"; +import { api } from "../api/client"; vi.mock("../api/client", () => ({ api: { - getWorkItemContent: vi.fn().mockResolvedValue({ - content: "# Big Title\n\nSome content here.", - stage: "current", - name: "Big Title Story", - }), + getWorkItemContent: vi.fn(), }, })); +vi.mock("../api/agents", () => ({ + agentsApi: { + listAgents: vi.fn(), + }, + subscribeAgentStream: vi.fn(() => () => {}), +})); + +// Dynamic import so mocks are in place before the module loads +const { WorkItemDetailPanel } = await import("./WorkItemDetailPanel"); + +const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent); +const mockedListAgents = vi.mocked(agentsApi.listAgents); +const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream); + +const DEFAULT_CONTENT = { + content: "# Big Title\n\nSome content here.", + stage: "current", + name: "Big Title Story", +}; + describe("WorkItemDetailPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedGetWorkItemContent.mockResolvedValue(DEFAULT_CONTENT); + mockedListAgents.mockResolvedValue([]); + mockedSubscribeAgentStream.mockReturnValue(() => {}); + }); + it("renders the story name in the header", async () => { render( {}} />); await waitFor(() => { @@ -41,9 +66,279 @@ describe("WorkItemDetailPanel", () => { const content = screen.getByTestId("detail-panel-content"); const h1 = content.querySelector("h1"); expect(h1).not.toBeNull(); - // Headings must have a constrained fontSize inline style - // Browser-default 2em is too large for the work item detail panel expect(h1?.style.fontSize).toBeTruthy(); }); }); }); + +describe("WorkItemDetailPanel - Agent Logs", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedGetWorkItemContent.mockResolvedValue(DEFAULT_CONTENT); + mockedListAgents.mockResolvedValue([]); + mockedSubscribeAgentStream.mockReturnValue(() => {}); + }); + + it("shows placeholder when no agent is assigned to the story", async () => { + render( {}} />); + await screen.findByTestId("detail-panel-content"); + const placeholder = screen.getByTestId("placeholder-agent-logs"); + expect(placeholder).toBeInTheDocument(); + expect(placeholder).toHaveTextContent("Coming soon"); + }); + + it("shows agent name and running status when agent is running", async () => { + const agentList: AgentInfo[] = [ + { + story_id: "42_story_test", + agent_name: "coder-1", + status: "running", + session_id: null, + worktree_path: "/tmp/wt", + base_branch: "master", + log_session_id: null, + }, + ]; + mockedListAgents.mockResolvedValue(agentList); + + render( {}} />); + + const statusBadge = await screen.findByTestId("agent-status-badge"); + expect(statusBadge).toHaveTextContent("coder-1"); + expect(statusBadge).toHaveTextContent("running"); + }); + + it("shows log output when agent emits output events", async () => { + let emitEvent: ((e: AgentEvent) => void) | null = null; + mockedSubscribeAgentStream.mockImplementation( + (_storyId, _agentName, onEvent) => { + emitEvent = onEvent; + return () => {}; + }, + ); + + const agentList: AgentInfo[] = [ + { + story_id: "42_story_test", + agent_name: "coder-1", + status: "running", + session_id: null, + worktree_path: "/tmp/wt", + base_branch: "master", + log_session_id: null, + }, + ]; + mockedListAgents.mockResolvedValue(agentList); + + render( {}} />); + + await screen.findByTestId("agent-status-badge"); + + await act(async () => { + emitEvent?.({ + type: "output", + story_id: "42_story_test", + agent_name: "coder-1", + text: "Writing tests...", + }); + }); + + const logOutput = screen.getByTestId("agent-log-output"); + expect(logOutput).toHaveTextContent("Writing tests..."); + }); + + it("appends multiple output events to the log", async () => { + let emitEvent: ((e: AgentEvent) => void) | null = null; + mockedSubscribeAgentStream.mockImplementation( + (_storyId, _agentName, onEvent) => { + emitEvent = onEvent; + return () => {}; + }, + ); + + const agentList: AgentInfo[] = [ + { + story_id: "42_story_test", + agent_name: "coder-1", + status: "running", + session_id: null, + worktree_path: "/tmp/wt", + base_branch: "master", + log_session_id: null, + }, + ]; + mockedListAgents.mockResolvedValue(agentList); + + render( {}} />); + + await screen.findByTestId("agent-status-badge"); + + await act(async () => { + emitEvent?.({ + type: "output", + story_id: "42_story_test", + agent_name: "coder-1", + text: "Line one\n", + }); + }); + + await act(async () => { + emitEvent?.({ + type: "output", + story_id: "42_story_test", + agent_name: "coder-1", + text: "Line two\n", + }); + }); + + const logOutput = screen.getByTestId("agent-log-output"); + expect(logOutput.textContent).toContain("Line one"); + expect(logOutput.textContent).toContain("Line two"); + }); + + it("updates status to completed after done event", async () => { + let emitEvent: ((e: AgentEvent) => void) | null = null; + mockedSubscribeAgentStream.mockImplementation( + (_storyId, _agentName, onEvent) => { + emitEvent = onEvent; + return () => {}; + }, + ); + + const agentList: AgentInfo[] = [ + { + story_id: "42_story_test", + agent_name: "coder-1", + status: "running", + session_id: null, + worktree_path: "/tmp/wt", + base_branch: "master", + log_session_id: null, + }, + ]; + mockedListAgents.mockResolvedValue(agentList); + + render( {}} />); + + await screen.findByTestId("agent-status-badge"); + + await act(async () => { + emitEvent?.({ + type: "done", + story_id: "42_story_test", + agent_name: "coder-1", + session_id: "session-123", + }); + }); + + const statusBadge = screen.getByTestId("agent-status-badge"); + expect(statusBadge).toHaveTextContent("completed"); + }); + + it("shows failed status after error event", async () => { + let emitEvent: ((e: AgentEvent) => void) | null = null; + mockedSubscribeAgentStream.mockImplementation( + (_storyId, _agentName, onEvent) => { + emitEvent = onEvent; + return () => {}; + }, + ); + + const agentList: AgentInfo[] = [ + { + story_id: "42_story_test", + agent_name: "coder-1", + status: "running", + session_id: null, + worktree_path: "/tmp/wt", + base_branch: "master", + log_session_id: null, + }, + ]; + mockedListAgents.mockResolvedValue(agentList); + + render( {}} />); + + await screen.findByTestId("agent-status-badge"); + + await act(async () => { + emitEvent?.({ + type: "error", + story_id: "42_story_test", + agent_name: "coder-1", + message: "Process failed", + }); + }); + + const statusBadge = screen.getByTestId("agent-status-badge"); + expect(statusBadge).toHaveTextContent("failed"); + + const logOutput = screen.getByTestId("agent-log-output"); + expect(logOutput.textContent).toContain("[ERROR] Process failed"); + }); + + it("shows completed agent status without subscribing to stream", async () => { + const agentList: AgentInfo[] = [ + { + story_id: "42_story_test", + agent_name: "coder-1", + status: "completed", + session_id: "session-123", + worktree_path: "/tmp/wt", + base_branch: "master", + log_session_id: null, + }, + ]; + mockedListAgents.mockResolvedValue(agentList); + + render( {}} />); + + const statusBadge = await screen.findByTestId("agent-status-badge"); + expect(statusBadge).toHaveTextContent("completed"); + expect(mockedSubscribeAgentStream).not.toHaveBeenCalled(); + }); + + it("shows failed agent status for a failed agent without subscribing to stream", async () => { + const agentList: AgentInfo[] = [ + { + story_id: "42_story_test", + agent_name: "coder-1", + status: "failed", + session_id: null, + worktree_path: null, + base_branch: "master", + log_session_id: null, + }, + ]; + mockedListAgents.mockResolvedValue(agentList); + + render( {}} />); + + const statusBadge = await screen.findByTestId("agent-status-badge"); + expect(statusBadge).toHaveTextContent("failed"); + expect(mockedSubscribeAgentStream).not.toHaveBeenCalled(); + }); + + it("shows agent logs section (not placeholder) when agent is assigned", async () => { + const agentList: AgentInfo[] = [ + { + story_id: "42_story_test", + agent_name: "coder-1", + status: "running", + session_id: null, + worktree_path: "/tmp/wt", + base_branch: "master", + log_session_id: null, + }, + ]; + mockedListAgents.mockResolvedValue(agentList); + + render( {}} />); + + await screen.findByTestId("agent-logs-section"); + + expect( + screen.queryByTestId("placeholder-agent-logs"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/WorkItemDetailPanel.tsx b/frontend/src/components/WorkItemDetailPanel.tsx index 99d9aa9..370b1fe 100644 --- a/frontend/src/components/WorkItemDetailPanel.tsx +++ b/frontend/src/components/WorkItemDetailPanel.tsx @@ -1,5 +1,7 @@ import * as React from "react"; import Markdown from "react-markdown"; +import type { AgentEvent, AgentInfo, AgentStatusValue } from "../api/agents"; +import { agentsApi, subscribeAgentStream } from "../api/agents"; import { api } from "../api/client"; const { useEffect, useRef, useState } = React; @@ -13,6 +15,13 @@ const STAGE_LABELS: Record = { archived: "Archived", }; +const STATUS_COLORS: Record = { + running: "#3fb950", + pending: "#e3b341", + completed: "#aaa", + failed: "#f85149", +}; + interface WorkItemDetailPanelProps { storyId: string; onClose: () => void; @@ -27,7 +36,11 @@ export function WorkItemDetailPanel({ const [name, setName] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [agentInfo, setAgentInfo] = useState(null); + const [agentLog, setAgentLog] = useState([]); + const [agentStatus, setAgentStatus] = useState(null); const panelRef = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); useEffect(() => { setLoading(true); @@ -47,6 +60,61 @@ export function WorkItemDetailPanel({ }); }, [storyId]); + useEffect(() => { + cleanupRef.current?.(); + cleanupRef.current = null; + setAgentInfo(null); + setAgentLog([]); + setAgentStatus(null); + + agentsApi + .listAgents() + .then((agents) => { + const agent = agents.find((a) => a.story_id === storyId); + if (!agent) return; + setAgentInfo(agent); + setAgentStatus(agent.status); + + if (agent.status === "running" || agent.status === "pending") { + const cleanup = subscribeAgentStream( + storyId, + agent.agent_name, + (event: AgentEvent) => { + switch (event.type) { + case "status": + setAgentStatus((event.status as AgentStatusValue) ?? null); + break; + case "output": + setAgentLog((prev) => [...prev, event.text ?? ""]); + break; + case "done": + setAgentStatus("completed"); + break; + case "error": + setAgentStatus("failed"); + setAgentLog((prev) => [ + ...prev, + `[ERROR] ${event.message ?? "Unknown error"}`, + ]); + break; + default: + break; + } + }, + ); + cleanupRef.current = cleanup; + } + }) + .catch((err: unknown) => { + console.error("Failed to load agents:", err); + }); + + return () => { + cleanupRef.current?.(); + cleanupRef.current = null; + }; + }, [storyId]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { @@ -187,7 +255,6 @@ export function WorkItemDetailPanel({ )} - {/* Placeholder sections for future content */}
+ {/* Agent Logs section */} +
+
+
+ Agent Logs +
+ {agentInfo && agentStatus && ( +
+ {agentInfo.agent_name} — {agentStatus} +
+ )} +
+ {agentInfo && agentLog.length > 0 ? ( +
+ {agentLog.join("")} +
+ ) : agentInfo ? ( +
+ {agentStatus === "running" || agentStatus === "pending" + ? "Waiting for output..." + : "No output."} +
+ ) : ( +
+ Coming soon +
+ )} +
+ + {/* Placeholder sections for future content */} {( [ - { id: "agent-logs", label: "Agent Logs" }, { id: "test-output", label: "Test Output" }, { id: "coverage", label: "Coverage" }, ] as { id: string; label: string }[]