story-kit: merge 160_story_constrain_thinking_trace_height_in_agent_stream_ui

This commit is contained in:
Dave
2026-02-24 18:03:08 +00:00
parent 95fc5aba49
commit 464b1e5530
4 changed files with 351 additions and 5 deletions

View File

@@ -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;

View File

@@ -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(<AgentPanel />);
// 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(<AgentPanel />);
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(<AgentPanel />);
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(<AgentPanel />);
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();
});
});

View File

@@ -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<HTMLDivElement>(null);
// Auto-scroll to bottom whenever text grows
useEffect(() => {
const el = scrollRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
}
}, [text]);
return (
<div
data-testid="thinking-block"
ref={scrollRef}
style={{
maxHeight: "96px",
overflowY: "auto",
background: "#161b22",
border: "1px solid #2d333b",
borderRadius: "6px",
padding: "6px 10px",
fontSize: "0.78em",
fontFamily: "monospace",
color: "#6e7681",
fontStyle: "italic",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
lineHeight: "1.4",
}}
>
<span
style={{
display: "block",
fontSize: "0.8em",
color: "#444c56",
marginBottom: "4px",
fontStyle: "normal",
letterSpacing: "0.04em",
textTransform: "uppercase",
}}
>
thinking
</span>
{text}
</div>
);
}
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 (
<div
style={{
@@ -396,6 +470,36 @@ export function AgentPanel({ configVersion = 0 }: AgentPanelProps) {
</div>
)}
{/* Per-agent streaming output: thinking trace + regular text */}
{activeAgents.map((agent) => (
<div
key={`stream-${agent.agentName}`}
data-testid={`agent-stream-${agent.agentName}`}
style={{
display: "flex",
flexDirection: "column",
gap: "4px",
}}
>
{agent.thinking.length > 0 && <ThinkingBlock text={agent.thinking} />}
{agent.log.length > 0 && (
<div
data-testid={`agent-output-${agent.agentName}`}
style={{
fontSize: "0.8em",
fontFamily: "monospace",
color: "#ccc",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
lineHeight: "1.5",
}}
>
{agent.log.join("")}
</div>
)}
</div>
))}
{actionError && (
<div
style={{

View File

@@ -115,6 +115,12 @@ pub enum AgentEvent {
agent_name: String,
message: String,
},
/// Thinking tokens from an extended-thinking block.
Thinking {
story_id: String,
agent_name: String,
text: String,
},
}
#[derive(Debug, Clone, Serialize, PartialEq)]
@@ -3357,7 +3363,23 @@ fn run_agent_pty_blocking(
&& let Some(content) = message.get("content").and_then(|c| c.as_array())
{
for block in content {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
if block_type == "thinking" {
if let Some(thinking) =
block.get("thinking").and_then(|t| t.as_str())
{
emit_event(
AgentEvent::Thinking {
story_id: story_id.to_string(),
agent_name: agent_name.to_string(),
text: thinking.to_string(),
},
tx,
event_log,
log_writer,
);
}
} else if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
emit_event(
AgentEvent::Output {
story_id: story_id.to_string(),