storkit: create 365_story_surface_api_rate_limit_warnings_in_chat

This commit is contained in:
dave
2026-03-22 18:19:23 +00:00
parent f346712dd1
commit e4227cf673
175 changed files with 0 additions and 83945 deletions
-313
View File
@@ -1,313 +0,0 @@
import { act, render, screen } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentConfigInfo, AgentEvent, AgentInfo } from "../api/agents";
import { agentsApi, subscribeAgentStream } from "../api/agents";
vi.mock("../api/agents", () => {
const agentsApi = {
listAgents: vi.fn(),
getAgentConfig: vi.fn(),
startAgent: vi.fn(),
stopAgent: vi.fn(),
reloadConfig: vi.fn(),
};
return { agentsApi, subscribeAgentStream: vi.fn(() => () => {}) };
});
// 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),
startAgent: vi.mocked(agentsApi.startAgent),
};
const ROSTER: AgentConfigInfo[] = [
{
name: "coder-1",
role: "Full-stack engineer",
stage: "coder",
model: "sonnet",
allowed_tools: null,
max_turns: 50,
max_budget_usd: 5.0,
},
];
describe("AgentPanel active work list removed", () => {
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
});
beforeEach(() => {
mockedAgents.getAgentConfig.mockResolvedValue(ROSTER);
mockedAgents.listAgents.mockResolvedValue([]);
});
it("does not render active agent entries even when agents are running", async () => {
const agentList: AgentInfo[] = [
{
story_id: "83_active",
agent_name: "coder-1",
status: "running",
session_id: null,
worktree_path: "/tmp/wt",
base_branch: "master",
log_session_id: null,
},
];
mockedAgents.listAgents.mockResolvedValue(agentList);
const { container } = render(<AgentPanel />);
// Roster badge should still be visible
await screen.findByTestId("roster-badge-coder-1");
// No agent entry divs should exist
expect(
container.querySelector('[data-testid^="agent-entry-"]'),
).not.toBeInTheDocument();
});
});
describe("Running count visibility in header", () => {
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
});
beforeEach(() => {
mockedAgents.getAgentConfig.mockResolvedValue(ROSTER);
mockedAgents.listAgents.mockResolvedValue([]);
});
// AC1: When no agents are running, "0 running" is NOT visible
it("does not show running count when no agents are running", async () => {
render(<AgentPanel />);
// Wait for roster to load
await screen.findByTestId("roster-badge-coder-1");
expect(screen.queryByText(/0 running/)).not.toBeInTheDocument();
});
// AC2: When agents are running, "N running" IS visible
it("shows running count when agents are running", async () => {
const agentList: AgentInfo[] = [
{
story_id: "99_active",
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.findByText(/1 running/);
});
});
describe("RosterBadge availability state", () => {
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
});
beforeEach(() => {
mockedAgents.getAgentConfig.mockResolvedValue(ROSTER);
mockedAgents.listAgents.mockResolvedValue([]);
});
it("shows a green dot for an idle agent", async () => {
render(<AgentPanel />);
const dot = await screen.findByTestId("roster-dot-coder-1");
// JSDOM normalizes #3fb950 to rgb(63, 185, 80)
expect(dot.style.background).toBe("rgb(63, 185, 80)");
expect(dot.style.animation).toBe("");
});
it("shows grey badge styling for an idle agent", async () => {
render(<AgentPanel />);
const badge = await screen.findByTestId("roster-badge-coder-1");
// JSDOM normalizes #aaa18 to rgba(170, 170, 170, 0.094) and #aaa to rgb(170, 170, 170)
expect(badge.style.background).toBe("rgba(170, 170, 170, 0.094)");
expect(badge.style.color).toBe("rgb(170, 170, 170)");
});
// AC1: roster badge always shows idle (grey) even when agent is running
it("shows a static green dot for a running agent (roster always idle)", async () => {
const agentList: AgentInfo[] = [
{
story_id: "81_active",
agent_name: "coder-1",
status: "running",
session_id: null,
worktree_path: null,
base_branch: null,
log_session_id: null,
},
];
mockedAgents.listAgents.mockResolvedValue(agentList);
render(<AgentPanel />);
const dot = await screen.findByTestId("roster-dot-coder-1");
expect(dot.style.background).toBe("rgb(63, 185, 80)");
// Roster is always idle — no pulsing animation
expect(dot.style.animation).toBe("");
});
// AC1: roster badge always shows idle (grey) even when agent is running
it("shows grey (idle) badge styling for a running agent", async () => {
const agentList: AgentInfo[] = [
{
story_id: "81_active",
agent_name: "coder-1",
status: "running",
session_id: null,
worktree_path: null,
base_branch: null,
log_session_id: null,
},
];
mockedAgents.listAgents.mockResolvedValue(agentList);
render(<AgentPanel />);
const badge = await screen.findByTestId("roster-badge-coder-1");
// Always idle: grey background and grey text
expect(badge.style.background).toBe("rgba(170, 170, 170, 0.094)");
expect(badge.style.color).toBe("rgb(170, 170, 170)");
});
// AC2: after agent completes and returns to roster, badge shows idle
it("shows idle state after agent status changes from running to completed", async () => {
const agentList: AgentInfo[] = [
{
story_id: "81_completed",
agent_name: "coder-1",
status: "completed",
session_id: null,
worktree_path: null,
base_branch: null,
log_session_id: null,
},
];
mockedAgents.listAgents.mockResolvedValue(agentList);
render(<AgentPanel />);
const badge = await screen.findByTestId("roster-badge-coder-1");
const dot = screen.getByTestId("roster-dot-coder-1");
// Completed agent: badge is idle
expect(badge.style.background).toBe("rgba(170, 170, 170, 0.094)");
expect(badge.style.color).toBe("rgb(170, 170, 170)");
expect(dot.style.animation).toBe("");
});
});
describe("Agent output not shown in sidebar (story 290)", () => {
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
});
beforeEach(() => {
mockedAgents.getAgentConfig.mockResolvedValue(ROSTER);
mockedAgents.listAgents.mockResolvedValue([]);
mockedSubscribeAgentStream.mockReturnValue(() => {});
});
// AC1: output events do not appear in the agents sidebar
it("does not render agent output when output event arrives", async () => {
let emitEvent: ((e: AgentEvent) => void) | null = null;
mockedSubscribeAgentStream.mockImplementation(
(_storyId, _agentName, onEvent) => {
emitEvent = onEvent;
return () => {};
},
);
const agentList: AgentInfo[] = [
{
story_id: "290_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);
const { container } = render(<AgentPanel />);
await screen.findByTestId("roster-badge-coder-1");
await act(async () => {
emitEvent?.({
type: "output",
story_id: "290_output",
agent_name: "coder-1",
text: "doing some work...",
});
});
// No output elements in the sidebar
expect(
container.querySelector('[data-testid^="agent-output-"]'),
).not.toBeInTheDocument();
expect(
container.querySelector('[data-testid^="agent-stream-"]'),
).not.toBeInTheDocument();
});
// AC1: thinking events do not appear in the agents sidebar
it("does not render thinking block 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: "290_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 />);
await screen.findByTestId("roster-badge-coder-1");
await act(async () => {
emitEvent?.({
type: "thinking",
story_id: "290_thinking",
agent_name: "coder-1",
text: "Let me consider the problem carefully...",
});
});
// No thinking block or output in sidebar
expect(screen.queryByTestId("thinking-block")).not.toBeInTheDocument();
expect(
screen.queryByText("Let me consider the problem carefully..."),
).not.toBeInTheDocument();
});
});
-419
View File
@@ -1,419 +0,0 @@
import * as React from "react";
import type {
AgentConfigInfo,
AgentEvent,
AgentStatusValue,
} from "../api/agents";
import { agentsApi, subscribeAgentStream } from "../api/agents";
import { settingsApi } from "../api/settings";
import { useLozengeFly } from "./LozengeFlyContext";
const { useCallback, useEffect, useRef, useState } = React;
interface AgentState {
agentName: string;
status: AgentStatusValue;
sessionId: string | null;
worktreePath: string | null;
baseBranch: string | null;
terminalAt: number | null;
}
const formatTimestamp = (value: Date | null): string => {
if (!value) return "";
return value.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
function RosterBadge({ agent }: { agent: AgentConfigInfo }) {
const { registerRosterEl } = useLozengeFly();
const badgeRef = useRef<HTMLSpanElement>(null);
// Register this element so fly animations know where to start/end
useEffect(() => {
const el = badgeRef.current;
if (el) registerRosterEl(agent.name, el);
return () => registerRosterEl(agent.name, null);
}, [agent.name, registerRosterEl]);
return (
<span
ref={badgeRef}
data-testid={`roster-badge-${agent.name}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "2px 8px",
borderRadius: "6px",
fontSize: "0.7em",
background: "#aaaaaa18",
color: "#aaa",
border: "1px solid #aaaaaa44",
}}
title={`${agent.role || agent.name} — available`}
>
<span
data-testid={`roster-dot-${agent.name}`}
style={{
width: "5px",
height: "5px",
borderRadius: "50%",
background: "#3fb950",
flexShrink: 0,
}}
/>
<span style={{ fontWeight: 600, color: "#aaa" }}>{agent.name}</span>
{agent.model && <span style={{ color: "#888" }}>{agent.model}</span>}
<span style={{ color: "#888", fontStyle: "italic" }}>available</span>
</span>
);
}
/** Build a composite key for tracking agent state. */
function agentKey(storyId: string, agentName: string): string {
return `${storyId}:${agentName}`;
}
interface AgentPanelProps {
/** Increment this to trigger a re-fetch of the agent roster. */
configVersion?: number;
/** Increment this to trigger a re-fetch of the agent list (agent state changed). */
stateVersion?: number;
}
export function AgentPanel({
configVersion = 0,
stateVersion = 0,
}: AgentPanelProps) {
const { hiddenRosterAgents } = useLozengeFly();
const [agents, setAgents] = useState<Record<string, AgentState>>({});
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
const [actionError, setActionError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [editorCommand, setEditorCommand] = useState<string | null>(null);
const [editorInput, setEditorInput] = useState<string>("");
const [editingEditor, setEditingEditor] = useState(false);
const cleanupRefs = useRef<Record<string, () => void>>({});
// Re-fetch roster whenever configVersion changes (triggered by agent_config_changed WS event).
useEffect(() => {
agentsApi
.getAgentConfig()
.then(setRoster)
.catch((err) => console.error("Failed to load agent config:", err));
}, [configVersion]);
const subscribeToAgent = useCallback((storyId: string, agentName: string) => {
const key = agentKey(storyId, agentName);
cleanupRefs.current[key]?.();
const cleanup = subscribeAgentStream(
storyId,
agentName,
(event: AgentEvent) => {
setAgents((prev) => {
const current = prev[key] ?? {
agentName,
status: "pending" as AgentStatusValue,
sessionId: null,
worktreePath: null,
baseBranch: null,
terminalAt: null,
};
switch (event.type) {
case "status": {
const newStatus =
(event.status as AgentStatusValue) ?? current.status;
const isTerminal =
newStatus === "completed" || newStatus === "failed";
return {
...prev,
[key]: {
...current,
status: newStatus,
terminalAt: isTerminal
? (current.terminalAt ?? Date.now())
: current.terminalAt,
},
};
}
case "done":
return {
...prev,
[key]: {
...current,
status: "completed",
sessionId: event.session_id ?? current.sessionId,
terminalAt: current.terminalAt ?? Date.now(),
},
};
case "error":
return {
...prev,
[key]: {
...current,
status: "failed",
terminalAt: current.terminalAt ?? Date.now(),
},
};
default:
// output, thinking, and other events are not displayed in the sidebar.
// Agent output streams appear in the work item detail panel instead.
return prev;
}
});
},
() => {
// SSE error — agent may not be streaming yet
},
);
cleanupRefs.current[key] = cleanup;
}, []);
/** Shared helper: fetch the agent list and update state + SSE subscriptions. */
const refreshAgents = useCallback(() => {
agentsApi
.listAgents()
.then((agentList) => {
const agentMap: Record<string, AgentState> = {};
const now = Date.now();
for (const a of agentList) {
const key = agentKey(a.story_id, a.agent_name);
const isTerminal = a.status === "completed" || a.status === "failed";
agentMap[key] = {
agentName: a.agent_name,
status: a.status,
sessionId: a.session_id,
worktreePath: a.worktree_path,
baseBranch: a.base_branch,
terminalAt: isTerminal ? now : null,
};
if (a.status === "running" || a.status === "pending") {
subscribeToAgent(a.story_id, a.agent_name);
}
}
setAgents(agentMap);
setLastRefresh(new Date());
})
.catch((err) => console.error("Failed to load agents:", err));
}, [subscribeToAgent]);
// Load existing agents and editor preference on mount
useEffect(() => {
refreshAgents();
settingsApi
.getEditorCommand()
.then((s) => {
setEditorCommand(s.editor_command);
setEditorInput(s.editor_command ?? "");
})
.catch((err) => console.error("Failed to load editor command:", err));
return () => {
for (const cleanup of Object.values(cleanupRefs.current)) {
cleanup();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Re-fetch agent list when agent state changes (via WebSocket notification).
// Skip the initial render (stateVersion=0) since the mount effect handles that.
useEffect(() => {
if (stateVersion > 0) {
refreshAgents();
}
}, [stateVersion, refreshAgents]);
const handleSaveEditor = async () => {
try {
const trimmed = editorInput.trim() || null;
const result = await settingsApi.setEditorCommand(trimmed);
setEditorCommand(result.editor_command);
setEditorInput(result.editor_command ?? "");
setEditingEditor(false);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`Failed to save editor: ${message}`);
}
};
return (
<div
style={{
border: "1px solid #333",
borderRadius: "10px",
padding: "12px 16px",
background: "#1f1f1f",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div style={{ fontWeight: 600 }}>Agents</div>
{Object.values(agents).filter((a) => a.status === "running").length >
0 && (
<div
style={{
fontSize: "0.75em",
color: "#777",
fontFamily: "monospace",
}}
>
{
Object.values(agents).filter((a) => a.status === "running")
.length
}{" "}
running
</div>
)}
</div>
{lastRefresh && (
<div style={{ fontSize: "0.7em", color: "#555" }}>
Loaded {formatTimestamp(lastRefresh)}
</div>
)}
</div>
{/* Editor preference */}
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<span style={{ fontSize: "0.75em", color: "#666" }}>Editor:</span>
{editingEditor ? (
<>
<input
type="text"
value={editorInput}
onChange={(e) => setEditorInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveEditor();
if (e.key === "Escape") setEditingEditor(false);
}}
placeholder="zed, code, cursor..."
style={{
fontSize: "0.75em",
background: "#111",
border: "1px solid #444",
borderRadius: "4px",
color: "#ccc",
padding: "2px 6px",
width: "120px",
}}
/>
<button
type="button"
onClick={handleSaveEditor}
style={{
fontSize: "0.7em",
padding: "2px 8px",
borderRadius: "4px",
border: "1px solid #238636",
background: "#238636",
color: "#fff",
cursor: "pointer",
}}
>
Save
</button>
<button
type="button"
onClick={() => setEditingEditor(false)}
style={{
fontSize: "0.7em",
padding: "2px 8px",
borderRadius: "4px",
border: "1px solid #444",
background: "none",
color: "#888",
cursor: "pointer",
}}
>
Cancel
</button>
</>
) : (
<button
type="button"
onClick={() => setEditingEditor(true)}
style={{
fontSize: "0.75em",
background: "none",
border: "1px solid #333",
borderRadius: "4px",
color: editorCommand ? "#aaa" : "#555",
cursor: "pointer",
padding: "2px 8px",
fontFamily: editorCommand ? "monospace" : "inherit",
}}
>
{editorCommand ?? "Set editor..."}
</button>
)}
</div>
{/* Roster badges — agents always display in idle state here */}
{roster.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "4px",
}}
>
{roster.map((a) => {
const isHidden = hiddenRosterAgents.has(a.name);
return (
<div
key={`roster-wrapper-${a.name}`}
data-testid={`roster-badge-wrapper-${a.name}`}
style={{
overflow: "hidden",
maxWidth: isHidden ? "0" : "300px",
opacity: isHidden ? 0 : 1,
transition: "max-width 0.35s ease, opacity 0.2s ease",
}}
>
<RosterBadge agent={a} />
</div>
);
})}
</div>
)}
{actionError && (
<div
style={{
fontSize: "0.85em",
color: "#ff7b72",
padding: "4px 8px",
background: "#ff7b7211",
borderRadius: "6px",
}}
>
{actionError}
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-314
View File
@@ -1,314 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ChatHeader } from "./ChatHeader";
vi.mock("../api/client", () => ({
api: {
rebuildAndRestart: vi.fn(),
},
}));
interface ChatHeaderProps {
projectPath: string;
onCloseProject: () => void;
contextUsage: { used: number; total: number; percentage: number };
onClearSession: () => void;
model: string;
availableModels: string[];
claudeModels: string[];
hasAnthropicKey: boolean;
onModelChange: (model: string) => void;
enableTools: boolean;
onToggleTools: (enabled: boolean) => void;
wsConnected: boolean;
}
function makeProps(overrides: Partial<ChatHeaderProps> = {}): ChatHeaderProps {
return {
projectPath: "/test/project",
onCloseProject: vi.fn(),
contextUsage: { used: 1000, total: 10000, percentage: 10 },
onClearSession: vi.fn(),
model: "claude-sonnet",
availableModels: ["llama3"],
claudeModels: ["claude-sonnet"],
hasAnthropicKey: true,
onModelChange: vi.fn(),
enableTools: true,
onToggleTools: vi.fn(),
wsConnected: false,
...overrides,
};
}
describe("ChatHeader", () => {
it("renders project path", () => {
render(<ChatHeader {...makeProps()} />);
expect(screen.getByText("/test/project")).toBeInTheDocument();
});
it("calls onCloseProject when close button is clicked", () => {
const onCloseProject = vi.fn();
render(<ChatHeader {...makeProps({ onCloseProject })} />);
fireEvent.click(screen.getByText("\u2715"));
expect(onCloseProject).toHaveBeenCalled();
});
it("displays context percentage with green emoji when low", () => {
render(
<ChatHeader
{...makeProps({
contextUsage: { used: 1000, total: 10000, percentage: 10 },
})}
/>,
);
expect(screen.getByText(/10%/)).toBeInTheDocument();
});
it("displays yellow emoji when context is 75-89%", () => {
render(
<ChatHeader
{...makeProps({
contextUsage: { used: 8000, total: 10000, percentage: 80 },
})}
/>,
);
expect(screen.getByText(/80%/)).toBeInTheDocument();
});
it("displays red emoji when context is 90%+", () => {
render(
<ChatHeader
{...makeProps({
contextUsage: { used: 9500, total: 10000, percentage: 95 },
})}
/>,
);
expect(screen.getByText(/95%/)).toBeInTheDocument();
});
it("calls onClearSession when New Session button is clicked", () => {
const onClearSession = vi.fn();
render(<ChatHeader {...makeProps({ onClearSession })} />);
fireEvent.click(screen.getByText(/New Session/));
expect(onClearSession).toHaveBeenCalled();
});
it("renders select dropdown when model options are available", () => {
render(<ChatHeader {...makeProps()} />);
const select = screen.getByRole("combobox");
expect(select).toBeInTheDocument();
});
it("renders text input when no model options are available", () => {
render(
<ChatHeader {...makeProps({ availableModels: [], claudeModels: [] })} />,
);
expect(screen.getByPlaceholderText("Model")).toBeInTheDocument();
});
it("calls onModelChange when model is selected from dropdown", () => {
const onModelChange = vi.fn();
render(<ChatHeader {...makeProps({ onModelChange })} />);
const select = screen.getByRole("combobox");
fireEvent.change(select, { target: { value: "llama3" } });
expect(onModelChange).toHaveBeenCalledWith("llama3");
});
it("calls onModelChange when text is typed in model input", () => {
const onModelChange = vi.fn();
render(
<ChatHeader
{...makeProps({
availableModels: [],
claudeModels: [],
onModelChange,
})}
/>,
);
const input = screen.getByPlaceholderText("Model");
fireEvent.change(input, { target: { value: "custom-model" } });
expect(onModelChange).toHaveBeenCalledWith("custom-model");
});
it("calls onToggleTools when checkbox is toggled", () => {
const onToggleTools = vi.fn();
render(<ChatHeader {...makeProps({ onToggleTools })} />);
const checkbox = screen.getByRole("checkbox");
fireEvent.click(checkbox);
expect(onToggleTools).toHaveBeenCalled();
});
it("displays the build timestamp in human-readable format", () => {
render(<ChatHeader {...makeProps()} />);
expect(screen.getByText("Built: 2026-01-01 00:00")).toBeInTheDocument();
});
it("displays Storkit branding in the header", () => {
render(<ChatHeader {...makeProps()} />);
expect(screen.getByText("Storkit")).toBeInTheDocument();
});
it("labels the claude-pty optgroup as 'Claude Code'", () => {
render(<ChatHeader {...makeProps()} />);
const optgroup = document.querySelector('optgroup[label="Claude Code"]');
expect(optgroup).toBeInTheDocument();
});
it("labels the Anthropic API optgroup as 'Anthropic API'", () => {
render(<ChatHeader {...makeProps()} />);
const optgroup = document.querySelector('optgroup[label="Anthropic API"]');
expect(optgroup).toBeInTheDocument();
});
it("shows disabled placeholder when claudeModels is empty and no API key", () => {
render(
<ChatHeader
{...makeProps({
claudeModels: [],
hasAnthropicKey: false,
availableModels: ["llama3"],
})}
/>,
);
expect(
screen.getByText("Add Anthropic API key to load models"),
).toBeInTheDocument();
});
// ── Close button hover/focus handlers ─────────────────────────────────────
it("close button changes background on mouseOver and resets on mouseOut", () => {
render(<ChatHeader {...makeProps()} />);
const closeBtn = screen.getByText("\u2715");
fireEvent.mouseOver(closeBtn);
expect(closeBtn.style.background).toBe("rgb(51, 51, 51)");
fireEvent.mouseOut(closeBtn);
expect(closeBtn.style.background).toBe("transparent");
});
it("close button changes background on focus and resets on blur", () => {
render(<ChatHeader {...makeProps()} />);
const closeBtn = screen.getByText("\u2715");
fireEvent.focus(closeBtn);
expect(closeBtn.style.background).toBe("rgb(51, 51, 51)");
fireEvent.blur(closeBtn);
expect(closeBtn.style.background).toBe("transparent");
});
// ── New Session button hover/focus handlers ───────────────────────────────
it("New Session button changes style on mouseOver and resets on mouseOut", () => {
render(<ChatHeader {...makeProps()} />);
const sessionBtn = screen.getByText(/New Session/);
fireEvent.mouseOver(sessionBtn);
expect(sessionBtn.style.backgroundColor).toBe("rgb(63, 63, 63)");
expect(sessionBtn.style.color).toBe("rgb(204, 204, 204)");
fireEvent.mouseOut(sessionBtn);
expect(sessionBtn.style.backgroundColor).toBe("rgb(47, 47, 47)");
expect(sessionBtn.style.color).toBe("rgb(136, 136, 136)");
});
it("New Session button changes style on focus and resets on blur", () => {
render(<ChatHeader {...makeProps()} />);
const sessionBtn = screen.getByText(/New Session/);
fireEvent.focus(sessionBtn);
expect(sessionBtn.style.backgroundColor).toBe("rgb(63, 63, 63)");
expect(sessionBtn.style.color).toBe("rgb(204, 204, 204)");
fireEvent.blur(sessionBtn);
expect(sessionBtn.style.backgroundColor).toBe("rgb(47, 47, 47)");
expect(sessionBtn.style.color).toBe("rgb(136, 136, 136)");
});
// ── Rebuild button ────────────────────────────────────────────────────────
it("renders rebuild button", () => {
render(<ChatHeader {...makeProps()} />);
expect(
screen.getByTitle("Rebuild and restart the server"),
).toBeInTheDocument();
});
it("shows confirmation dialog when rebuild button is clicked", () => {
render(<ChatHeader {...makeProps()} />);
fireEvent.click(screen.getByTitle("Rebuild and restart the server"));
expect(screen.getByText("Rebuild and restart?")).toBeInTheDocument();
});
it("hides confirmation dialog when cancel is clicked", () => {
render(<ChatHeader {...makeProps()} />);
fireEvent.click(screen.getByTitle("Rebuild and restart the server"));
fireEvent.click(screen.getByText("Cancel"));
expect(screen.queryByText("Rebuild and restart?")).not.toBeInTheDocument();
});
it("calls api.rebuildAndRestart and shows Building... when confirmed", async () => {
const { api } = await import("../api/client");
vi.mocked(api.rebuildAndRestart).mockReturnValue(new Promise(() => {}));
render(<ChatHeader {...makeProps()} />);
fireEvent.click(screen.getByTitle("Rebuild and restart the server"));
fireEvent.click(screen.getByText("Rebuild"));
await waitFor(() => {
expect(screen.getByText("Building...")).toBeInTheDocument();
});
expect(api.rebuildAndRestart).toHaveBeenCalled();
});
it("shows Reconnecting... when rebuild triggers a network error", async () => {
const { api } = await import("../api/client");
vi.mocked(api.rebuildAndRestart).mockRejectedValue(
new TypeError("Failed to fetch"),
);
render(<ChatHeader {...makeProps()} />);
fireEvent.click(screen.getByTitle("Rebuild and restart the server"));
fireEvent.click(screen.getByText("Rebuild"));
await waitFor(() => {
expect(screen.getByText("Reconnecting...")).toBeInTheDocument();
});
});
it("shows error when rebuild returns a failure message", async () => {
const { api } = await import("../api/client");
vi.mocked(api.rebuildAndRestart).mockResolvedValue(
"error[E0308]: mismatched types",
);
render(<ChatHeader {...makeProps()} />);
fireEvent.click(screen.getByTitle("Rebuild and restart the server"));
fireEvent.click(screen.getByText("Rebuild"));
await waitFor(() => {
expect(screen.getByText("⚠ Rebuild failed")).toBeInTheDocument();
expect(
screen.getByText("error[E0308]: mismatched types"),
).toBeInTheDocument();
});
});
it("clears reconnecting state when wsConnected transitions to true", async () => {
const { api } = await import("../api/client");
vi.mocked(api.rebuildAndRestart).mockRejectedValue(
new TypeError("Failed to fetch"),
);
const { rerender } = render(
<ChatHeader {...makeProps({ wsConnected: false })} />,
);
fireEvent.click(screen.getByTitle("Rebuild and restart the server"));
fireEvent.click(screen.getByText("Rebuild"));
await waitFor(() => {
expect(screen.getByText("Reconnecting...")).toBeInTheDocument();
});
rerender(<ChatHeader {...makeProps({ wsConnected: true })} />);
await waitFor(() => {
expect(screen.getByText("↺ Rebuild")).toBeInTheDocument();
});
});
});
-545
View File
@@ -1,545 +0,0 @@
import * as React from "react";
import { api } from "../api/client";
const { useState, useEffect } = React;
function formatBuildTime(isoString: string): string {
const d = new Date(isoString);
const year = d.getUTCFullYear();
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
const hours = String(d.getUTCHours()).padStart(2, "0");
const minutes = String(d.getUTCMinutes()).padStart(2, "0");
return `Built: ${year}-${month}-${day} ${hours}:${minutes}`;
}
interface ContextUsage {
used: number;
total: number;
percentage: number;
}
interface ChatHeaderProps {
projectPath: string;
onCloseProject: () => void;
contextUsage: ContextUsage;
onClearSession: () => void;
model: string;
availableModels: string[];
claudeModels: string[];
hasAnthropicKey: boolean;
onModelChange: (model: string) => void;
enableTools: boolean;
onToggleTools: (enabled: boolean) => void;
wsConnected: boolean;
}
const getContextEmoji = (percentage: number): string => {
if (percentage >= 90) return "🔴";
if (percentage >= 75) return "🟡";
return "🟢";
};
type RebuildStatus = "idle" | "building" | "reconnecting" | "error";
export function ChatHeader({
projectPath,
onCloseProject,
contextUsage,
onClearSession,
model,
availableModels,
claudeModels,
hasAnthropicKey,
onModelChange,
enableTools,
onToggleTools,
wsConnected,
}: ChatHeaderProps) {
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
const [showConfirm, setShowConfirm] = useState(false);
const [rebuildStatus, setRebuildStatus] = useState<RebuildStatus>("idle");
const [rebuildError, setRebuildError] = useState<string | null>(null);
// When WS reconnects after a rebuild, clear the reconnecting status.
useEffect(() => {
if (rebuildStatus === "reconnecting" && wsConnected) {
setRebuildStatus("idle");
}
}, [wsConnected, rebuildStatus]);
function handleRebuildClick() {
setRebuildError(null);
setShowConfirm(true);
}
function handleRebuildConfirm() {
setShowConfirm(false);
setRebuildStatus("building");
api
.rebuildAndRestart()
.then((result) => {
// Got a response = build failed (server still running).
setRebuildStatus("error");
setRebuildError(result || "Rebuild failed");
})
.catch(() => {
// Network error = server is restarting (build succeeded).
setRebuildStatus("reconnecting");
});
}
function handleRebuildCancel() {
setShowConfirm(false);
}
function handleDismissError() {
setRebuildStatus("idle");
setRebuildError(null);
}
const rebuildButtonLabel =
rebuildStatus === "building"
? "Building..."
: rebuildStatus === "reconnecting"
? "Reconnecting..."
: rebuildStatus === "error"
? "⚠ Rebuild Failed"
: "↺ Rebuild";
const rebuildButtonDisabled =
rebuildStatus === "building" || rebuildStatus === "reconnecting";
return (
<>
{/* Confirmation dialog overlay */}
{showConfirm && (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
style={{
background: "#1e1e1e",
border: "1px solid #444",
borderRadius: "8px",
padding: "24px",
maxWidth: "400px",
width: "90%",
color: "#ececec",
}}
>
<div
style={{
fontWeight: "600",
fontSize: "1em",
marginBottom: "8px",
}}
>
Rebuild and restart?
</div>
<div
style={{
fontSize: "0.85em",
color: "#aaa",
marginBottom: "20px",
lineHeight: "1.5",
}}
>
This will run <code>cargo build</code> and replace the running
server. All agents will be stopped. The page will reconnect
automatically when the new server is ready.
</div>
<div
style={{
display: "flex",
gap: "10px",
justifyContent: "flex-end",
}}
>
<button
type="button"
onClick={handleRebuildCancel}
style={{
padding: "6px 16px",
borderRadius: "6px",
border: "1px solid #444",
background: "transparent",
color: "#aaa",
cursor: "pointer",
fontSize: "0.9em",
}}
>
Cancel
</button>
<button
type="button"
onClick={handleRebuildConfirm}
style={{
padding: "6px 16px",
borderRadius: "6px",
border: "none",
background: "#c0392b",
color: "#fff",
cursor: "pointer",
fontSize: "0.9em",
fontWeight: "600",
}}
>
Rebuild
</button>
</div>
</div>
</div>
)}
{/* Error toast */}
{rebuildStatus === "error" && rebuildError && (
<div
style={{
position: "fixed",
bottom: "20px",
right: "20px",
background: "#3a1010",
border: "1px solid #c0392b",
borderRadius: "8px",
padding: "12px 16px",
maxWidth: "480px",
color: "#ececec",
zIndex: 1000,
fontSize: "0.85em",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: "12px",
}}
>
<div>
<div style={{ fontWeight: "600", marginBottom: "4px" }}>
Rebuild failed
</div>
<pre
style={{
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
color: "#f08080",
maxHeight: "120px",
overflowY: "auto",
}}
>
{rebuildError}
</pre>
</div>
<button
type="button"
onClick={handleDismissError}
style={{
background: "transparent",
border: "none",
color: "#aaa",
cursor: "pointer",
fontSize: "1em",
flexShrink: 0,
}}
>
</button>
</div>
</div>
)}
<div
style={{
padding: "12px 24px",
borderBottom: "1px solid #333",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
background: "#171717",
flexShrink: 0,
fontSize: "0.9rem",
color: "#ececec",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
overflow: "hidden",
flex: 1,
marginRight: "20px",
}}
>
<span
style={{
fontWeight: "700",
fontSize: "1em",
color: "#ececec",
flexShrink: 0,
letterSpacing: "0.02em",
}}
>
Storkit
</span>
<div
title={projectPath}
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: "500",
color: "#aaa",
direction: "rtl",
textAlign: "left",
fontFamily: "monospace",
fontSize: "0.85em",
}}
>
{projectPath}
</div>
<button
type="button"
onClick={onCloseProject}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
color: "#999",
fontSize: "0.8em",
padding: "4px 8px",
borderRadius: "4px",
}}
onMouseOver={(e) => {
e.currentTarget.style.background = "#333";
}}
onMouseOut={(e) => {
e.currentTarget.style.background = "transparent";
}}
onFocus={(e) => {
e.currentTarget.style.background = "#333";
}}
onBlur={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
<div
style={{
fontSize: "0.75em",
color: "#555",
whiteSpace: "nowrap",
fontFamily: "monospace",
}}
title={__BUILD_TIME__}
>
{formatBuildTime(__BUILD_TIME__)}
</div>
<div
style={{
fontSize: "0.9em",
color: "#ccc",
whiteSpace: "nowrap",
}}
title={`Context: ${contextUsage.used.toLocaleString()} / ${contextUsage.total.toLocaleString()} tokens (${contextUsage.percentage}%)`}
>
{getContextEmoji(contextUsage.percentage)} {contextUsage.percentage}
%
</div>
<button
type="button"
onClick={handleRebuildClick}
disabled={rebuildButtonDisabled}
title="Rebuild and restart the server"
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.85em",
backgroundColor:
rebuildStatus === "error" ? "#5a1010" : "#2f2f2f",
color:
rebuildStatus === "error"
? "#f08080"
: rebuildButtonDisabled
? "#555"
: "#888",
cursor: rebuildButtonDisabled ? "not-allowed" : "pointer",
outline: "none",
transition: "all 0.2s",
opacity: rebuildButtonDisabled ? 0.7 : 1,
}}
onMouseOver={(e) => {
if (!rebuildButtonDisabled) {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}
}}
onMouseOut={(e) => {
if (!rebuildButtonDisabled) {
e.currentTarget.style.backgroundColor =
rebuildStatus === "error" ? "#5a1010" : "#2f2f2f";
e.currentTarget.style.color =
rebuildStatus === "error" ? "#f08080" : "#888";
}
}}
onFocus={(e) => {
if (!rebuildButtonDisabled) {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}
}}
onBlur={(e) => {
if (!rebuildButtonDisabled) {
e.currentTarget.style.backgroundColor =
rebuildStatus === "error" ? "#5a1010" : "#2f2f2f";
e.currentTarget.style.color =
rebuildStatus === "error" ? "#f08080" : "#888";
}
}}
>
{rebuildButtonLabel}
</button>
<button
type="button"
onClick={onClearSession}
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.85em",
backgroundColor: "#2f2f2f",
color: "#888",
cursor: "pointer",
outline: "none",
transition: "all 0.2s",
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = "#2f2f2f";
e.currentTarget.style.color = "#888";
}}
onFocus={(e) => {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}}
onBlur={(e) => {
e.currentTarget.style.backgroundColor = "#2f2f2f";
e.currentTarget.style.color = "#888";
}}
>
🔄 New Session
</button>
{hasModelOptions ? (
<select
value={model}
onChange={(e) => onModelChange(e.target.value)}
style={{
padding: "6px 32px 6px 16px",
borderRadius: "99px",
border: "none",
fontSize: "0.9em",
backgroundColor: "#2f2f2f",
color: "#ececec",
cursor: "pointer",
outline: "none",
appearance: "none",
WebkitAppearance: "none",
backgroundImage: `url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23ececec%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 12px center",
backgroundSize: "10px",
}}
>
<optgroup label="Claude Code">
<option value="claude-code-pty">claude-code-pty</option>
</optgroup>
{(claudeModels.length > 0 || !hasAnthropicKey) && (
<optgroup label="Anthropic API">
{claudeModels.length > 0 ? (
claudeModels.map((m: string) => (
<option key={m} value={m}>
{m}
</option>
))
) : (
<option value="" disabled>
Add Anthropic API key to load models
</option>
)}
</optgroup>
)}
{availableModels.length > 0 && (
<optgroup label="Ollama">
{availableModels.map((m: string) => (
<option key={m} value={m}>
{m}
</option>
))}
</optgroup>
)}
</select>
) : (
<input
value={model}
onChange={(e) => onModelChange(e.target.value)}
placeholder="Model"
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.9em",
background: "#2f2f2f",
color: "#ececec",
outline: "none",
}}
/>
)}
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
cursor: "pointer",
fontSize: "0.9em",
color: "#aaa",
}}
title="Allow the Agent to read/write files"
>
<input
type="checkbox"
checked={enableTools}
onChange={(e) => onToggleTools(e.target.checked)}
style={{ accentColor: "#000" }}
/>
<span>Tools</span>
</label>
</div>
</div>
</>
);
}
-279
View File
@@ -1,279 +0,0 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import * as React from "react";
import { describe, expect, it, vi } from "vitest";
import type { ChatInputHandle } from "./ChatInput";
import { ChatInput } from "./ChatInput";
describe("ChatInput component (Story 178 AC1)", () => {
it("renders a textarea with Send a message... placeholder", () => {
render(
<ChatInput
loading={false}
queuedMessages={[]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const textarea = screen.getByPlaceholderText("Send a message...");
expect(textarea.tagName.toLowerCase()).toBe("textarea");
});
it("manages input state internally — typing updates value without calling onSubmit", async () => {
const onSubmit = vi.fn();
render(
<ChatInput
loading={false}
queuedMessages={[]}
onSubmit={onSubmit}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "hello world" } });
});
expect((textarea as HTMLTextAreaElement).value).toBe("hello world");
expect(onSubmit).not.toHaveBeenCalled();
});
it("calls onSubmit with the input text on Enter key press", async () => {
const onSubmit = vi.fn();
render(
<ChatInput
loading={false}
queuedMessages={[]}
onSubmit={onSubmit}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "test message" } });
});
await act(async () => {
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: false });
});
expect(onSubmit).toHaveBeenCalledWith("test message");
});
it("clears input after submitting", async () => {
render(
<ChatInput
loading={false}
queuedMessages={[]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "hello" } });
});
await act(async () => {
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: false });
});
expect((textarea as HTMLTextAreaElement).value).toBe("");
});
it("does not submit on Shift+Enter", async () => {
const onSubmit = vi.fn();
render(
<ChatInput
loading={false}
queuedMessages={[]}
onSubmit={onSubmit}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "multiline" } });
});
await act(async () => {
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true });
});
expect(onSubmit).not.toHaveBeenCalled();
expect((textarea as HTMLTextAreaElement).value).toBe("multiline");
});
it("calls onCancel when stop button is clicked while loading with empty input", async () => {
const onCancel = vi.fn();
render(
<ChatInput
loading={true}
queuedMessages={[]}
onSubmit={vi.fn()}
onCancel={onCancel}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const stopButton = screen.getByRole("button", { name: "■" });
await act(async () => {
fireEvent.click(stopButton);
});
expect(onCancel).toHaveBeenCalled();
});
it("renders queued message indicators", () => {
render(
<ChatInput
loading={true}
queuedMessages={[
{ id: "1", text: "first message" },
{ id: "2", text: "second message" },
]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const indicators = screen.getAllByTestId("queued-message-indicator");
expect(indicators).toHaveLength(2);
expect(indicators[0]).toHaveTextContent("first message");
expect(indicators[1]).toHaveTextContent("second message");
});
it("calls onRemoveQueuedMessage when cancel button is clicked", async () => {
const onRemove = vi.fn();
render(
<ChatInput
loading={true}
queuedMessages={[{ id: "q1", text: "to remove" }]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRemoveQueuedMessage={onRemove}
/>,
);
const cancelBtn = screen.getByTitle("Cancel queued message");
await act(async () => {
fireEvent.click(cancelBtn);
});
expect(onRemove).toHaveBeenCalledWith("q1");
});
it("edit button restores queued message text to input and removes from queue", async () => {
const onRemove = vi.fn();
render(
<ChatInput
loading={true}
queuedMessages={[{ id: "q1", text: "edit me back" }]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRemoveQueuedMessage={onRemove}
/>,
);
const editBtn = screen.getByTitle("Edit queued message");
await act(async () => {
fireEvent.click(editBtn);
});
const textarea = screen.getByPlaceholderText("Send a message...");
expect((textarea as HTMLTextAreaElement).value).toBe("edit me back");
expect(onRemove).toHaveBeenCalledWith("q1");
});
});
describe("ChatInput appendToInput (Bug 215 regression)", () => {
it("appendToInput sets text into an empty input", async () => {
const ref = React.createRef<ChatInputHandle>();
render(
<ChatInput
ref={ref}
loading={false}
queuedMessages={[]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
await act(async () => {
ref.current?.appendToInput("queued message");
});
const textarea = screen.getByPlaceholderText("Send a message...");
expect((textarea as HTMLTextAreaElement).value).toBe("queued message");
});
it("appendToInput appends to existing input content with a newline separator", async () => {
const ref = React.createRef<ChatInputHandle>();
render(
<ChatInput
ref={ref}
loading={false}
queuedMessages={[]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "existing text" } });
});
await act(async () => {
ref.current?.appendToInput("appended text");
});
expect((textarea as HTMLTextAreaElement).value).toBe(
"existing text\nappended text",
);
});
it("multiple queued messages joined with newlines are appended on cancel", async () => {
const ref = React.createRef<ChatInputHandle>();
render(
<ChatInput
ref={ref}
loading={false}
queuedMessages={[]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRemoveQueuedMessage={vi.fn()}
/>,
);
await act(async () => {
ref.current?.appendToInput("msg one\nmsg two");
});
const textarea = screen.getByPlaceholderText("Send a message...");
expect((textarea as HTMLTextAreaElement).value).toBe("msg one\nmsg two");
});
});
-419
View File
@@ -1,419 +0,0 @@
import * as React from "react";
import { api } from "../api/client";
const {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} = React;
export interface ChatInputHandle {
appendToInput(text: string): void;
}
interface ChatInputProps {
loading: boolean;
queuedMessages: { id: string; text: string }[];
onSubmit: (message: string) => void;
onCancel: () => void;
onRemoveQueuedMessage: (id: string) => void;
}
/** Fuzzy-match: returns true if all chars of `query` appear in order in `str`. */
function fuzzyMatch(str: string, query: string): boolean {
if (!query) return true;
const lower = str.toLowerCase();
const q = query.toLowerCase();
let qi = 0;
for (let i = 0; i < lower.length && qi < q.length; i++) {
if (lower[i] === q[qi]) qi++;
}
return qi === q.length;
}
/** Score a fuzzy match: lower is better. Exact prefix match wins, then shorter paths. */
function fuzzyScore(str: string, query: string): number {
const lower = str.toLowerCase();
const q = query.toLowerCase();
// Prefer matches where query appears as a contiguous substring
if (lower.includes(q)) return lower.indexOf(q);
return str.length;
}
interface FilePickerOverlayProps {
query: string;
files: string[];
selectedIndex: number;
onSelect: (file: string) => void;
onDismiss: () => void;
anchorRef: React.RefObject<HTMLTextAreaElement | null>;
}
function FilePickerOverlay({
query,
files,
selectedIndex,
onSelect,
}: FilePickerOverlayProps) {
const filtered = files
.filter((f) => fuzzyMatch(f, query))
.sort((a, b) => fuzzyScore(a, query) - fuzzyScore(b, query))
.slice(0, 10);
if (filtered.length === 0) return null;
return (
<div
data-testid="file-picker-overlay"
style={{
position: "absolute",
bottom: "100%",
left: 0,
right: 0,
background: "#1e1e1e",
border: "1px solid #444",
borderRadius: "8px",
marginBottom: "6px",
overflow: "hidden",
zIndex: 100,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
maxHeight: "240px",
overflowY: "auto",
}}
>
{filtered.map((file, idx) => (
<button
key={file}
type="button"
data-testid={`file-picker-item-${idx}`}
onClick={() => onSelect(file)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "8px 14px",
background: idx === selectedIndex ? "#2d4a6e" : "transparent",
border: "none",
color: idx === selectedIndex ? "#ececec" : "#aaa",
cursor: "pointer",
fontFamily: "monospace",
fontSize: "0.85rem",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{file}
</button>
))}
</div>
);
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
function ChatInput(
{ loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage },
ref,
) {
const [input, setInput] = useState("");
const inputRef = useRef<HTMLTextAreaElement>(null);
// File picker state
const [projectFiles, setProjectFiles] = useState<string[]>([]);
const [pickerQuery, setPickerQuery] = useState<string | null>(null);
const [pickerSelectedIndex, setPickerSelectedIndex] = useState(0);
const [pickerAtStart, setPickerAtStart] = useState(0);
useImperativeHandle(ref, () => ({
appendToInput(text: string) {
setInput((prev) => (prev ? `${prev}\n${text}` : text));
},
}));
useEffect(() => {
inputRef.current?.focus();
}, []);
// Compute filtered files for current picker query
const filteredFiles =
pickerQuery !== null
? projectFiles
.filter((f) => fuzzyMatch(f, pickerQuery))
.sort(
(a, b) => fuzzyScore(a, pickerQuery) - fuzzyScore(b, pickerQuery),
)
.slice(0, 10)
: [];
const dismissPicker = useCallback(() => {
setPickerQuery(null);
setPickerSelectedIndex(0);
}, []);
const selectFile = useCallback(
(file: string) => {
// Replace the @query portion with @file
const before = input.slice(0, pickerAtStart);
const cursorPos = inputRef.current?.selectionStart ?? input.length;
const after = input.slice(cursorPos);
setInput(`${before}@${file}${after}`);
dismissPicker();
// Restore focus after state update
setTimeout(() => inputRef.current?.focus(), 0);
},
[input, pickerAtStart, dismissPicker],
);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
setInput(val);
const cursor = e.target.selectionStart ?? val.length;
// Find the last @ before the cursor that starts a reference token
const textUpToCursor = val.slice(0, cursor);
// Match @ not preceded by non-whitespace (i.e. @ at start or after space/newline)
const atMatch = textUpToCursor.match(/(^|[\s\n])@([^\s@]*)$/);
if (atMatch) {
const query = atMatch[2];
const atPos = textUpToCursor.lastIndexOf("@");
setPickerAtStart(atPos);
setPickerQuery(query);
setPickerSelectedIndex(0);
// Lazily load files on first trigger
if (projectFiles.length === 0) {
api
.listProjectFiles()
.then(setProjectFiles)
.catch(() => {});
}
} else {
if (pickerQuery !== null) dismissPicker();
}
},
[projectFiles.length, pickerQuery, dismissPicker],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (pickerQuery !== null && filteredFiles.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setPickerSelectedIndex((i) =>
Math.min(i + 1, filteredFiles.length - 1),
);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setPickerSelectedIndex((i) => Math.max(i - 1, 0));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
selectFile(filteredFiles[pickerSelectedIndex] ?? filteredFiles[0]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
dismissPicker();
return;
}
} else if (e.key === "Escape" && pickerQuery !== null) {
e.preventDefault();
dismissPicker();
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[
pickerQuery,
filteredFiles,
pickerSelectedIndex,
selectFile,
dismissPicker,
],
);
const handleSubmit = () => {
if (!input.trim()) return;
onSubmit(input);
setInput("");
dismissPicker();
};
return (
<div
style={{
padding: "24px",
background: "#171717",
display: "flex",
justifyContent: "center",
}}
>
<div
style={{
maxWidth: "768px",
width: "100%",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
{/* Queued message indicators */}
{queuedMessages.map(({ id, text }) => (
<div
key={id}
data-testid="queued-message-indicator"
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px 12px",
background: "#1e1e1e",
border: "1px solid #3a3a3a",
borderRadius: "12px",
fontSize: "0.875rem",
}}
>
<span
style={{
color: "#666",
flexShrink: 0,
fontSize: "0.7rem",
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
}}
>
Queued
</span>
<span
style={{
color: "#888",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{text}
</span>
<button
type="button"
title="Edit queued message"
onClick={() => {
setInput(text);
onRemoveQueuedMessage(id);
inputRef.current?.focus();
}}
style={{
background: "none",
border: "none",
color: "#666",
cursor: "pointer",
padding: "2px 6px",
fontSize: "0.8rem",
flexShrink: 0,
borderRadius: "4px",
}}
>
Edit
</button>
<button
type="button"
title="Cancel queued message"
onClick={() => onRemoveQueuedMessage(id)}
style={{
background: "none",
border: "none",
color: "#666",
cursor: "pointer",
padding: "2px 4px",
fontSize: "0.875rem",
flexShrink: 0,
borderRadius: "4px",
}}
>
</button>
</div>
))}
{/* Input row with file picker overlay */}
<div
style={{
display: "flex",
gap: "8px",
alignItems: "center",
position: "relative",
}}
>
{pickerQuery !== null && (
<FilePickerOverlay
query={pickerQuery}
files={projectFiles}
selectedIndex={pickerSelectedIndex}
onSelect={selectFile}
onDismiss={dismissPicker}
anchorRef={inputRef}
/>
)}
<textarea
ref={inputRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Send a message..."
rows={1}
style={{
flex: 1,
padding: "14px 20px",
borderRadius: "24px",
border: "1px solid #333",
outline: "none",
fontSize: "1rem",
fontWeight: "500",
background: "#2f2f2f",
color: "#ececec",
boxShadow: "0 2px 6px rgba(0,0,0,0.02)",
resize: "none",
overflowY: "auto",
fontFamily: "inherit",
}}
/>
<button
type="button"
onClick={loading && !input.trim() ? onCancel : handleSubmit}
disabled={!loading && !input.trim()}
style={{
background: "#ececec",
color: "black",
border: "none",
borderRadius: "50%",
width: "32px",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
opacity: !loading && !input.trim() ? 0.5 : 1,
flexShrink: 0,
}}
>
{loading && !input.trim() ? "■" : "↑"}
</button>
</div>
</div>
</div>
);
},
);
@@ -1,194 +0,0 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { api } from "../api/client";
import { ChatInput } from "./ChatInput";
vi.mock("../api/client", () => ({
api: {
listProjectFiles: vi.fn(),
},
}));
const mockedListProjectFiles = vi.mocked(api.listProjectFiles);
const defaultProps = {
loading: false,
queuedMessages: [],
onSubmit: vi.fn(),
onCancel: vi.fn(),
onRemoveQueuedMessage: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockedListProjectFiles.mockResolvedValue([
"src/main.rs",
"src/lib.rs",
"frontend/index.html",
"README.md",
]);
});
describe("File picker overlay (Story 269 AC1)", () => {
it("shows file picker overlay when @ is typed", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "@" } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
});
});
it("does not show file picker overlay for text without @", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "hello world" } });
});
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
});
});
describe("File picker fuzzy matching (Story 269 AC2)", () => {
it("filters files by query typed after @", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "@main" } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
});
// main.rs should be visible, README.md should not
expect(screen.getByText("src/main.rs")).toBeInTheDocument();
expect(screen.queryByText("README.md")).not.toBeInTheDocument();
});
it("shows all files when @ is typed with no query", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "@" } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
});
// All 4 files should be visible
expect(screen.getByText("src/main.rs")).toBeInTheDocument();
expect(screen.getByText("src/lib.rs")).toBeInTheDocument();
expect(screen.getByText("README.md")).toBeInTheDocument();
});
});
describe("File picker selection (Story 269 AC3)", () => {
it("clicking a file inserts @path into the message", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "@" } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-item-0")).toBeInTheDocument();
});
await act(async () => {
fireEvent.click(screen.getByTestId("file-picker-item-0"));
});
// Picker should be dismissed and the file reference inserted
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
expect((textarea as HTMLTextAreaElement).value).toMatch(/^@\S+/);
});
it("Enter key selects highlighted file and inserts it into message", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "@main" } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
});
await act(async () => {
fireEvent.keyDown(textarea, { key: "Enter" });
});
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
expect((textarea as HTMLTextAreaElement).value).toContain("@src/main.rs");
});
});
describe("File picker dismiss (Story 269 AC5)", () => {
it("Escape key dismisses the file picker", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(textarea, { target: { value: "@" } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
});
await act(async () => {
fireEvent.keyDown(textarea, { key: "Escape" });
});
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
});
});
describe("Multiple @ references (Story 269 AC6)", () => {
it("typing @ after a completed reference triggers picker again", async () => {
render(<ChatInput {...defaultProps} />);
const textarea = screen.getByPlaceholderText("Send a message...");
// First reference
await act(async () => {
fireEvent.change(textarea, { target: { value: "@main" } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
});
// Select file
await act(async () => {
fireEvent.keyDown(textarea, { key: "Enter" });
});
// Type a second @
await act(async () => {
const current = (textarea as HTMLTextAreaElement).value;
fireEvent.change(textarea, { target: { value: `${current} @` } });
});
await waitFor(() => {
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
});
});
});
-98
View File
@@ -1,98 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { InlineCodeWithRefs, parseCodeRefs } from "./CodeRef";
// Mock the settingsApi so we don't make real HTTP calls in tests
vi.mock("../api/settings", () => ({
settingsApi: {
openFile: vi.fn(() => Promise.resolve({ success: true })),
},
}));
describe("parseCodeRefs (Story 193)", () => {
it("returns a single text part for plain text with no code refs", () => {
const parts = parseCodeRefs("Hello world, no code here");
expect(parts).toHaveLength(1);
expect(parts[0]).toEqual({
type: "text",
value: "Hello world, no code here",
});
});
it("detects a simple code reference", () => {
const parts = parseCodeRefs("src/main.rs:42");
expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({
type: "ref",
path: "src/main.rs",
line: 42,
});
});
it("detects a code reference embedded in surrounding text", () => {
const parts = parseCodeRefs("See src/lib.rs:100 for details");
expect(parts).toHaveLength(3);
expect(parts[0]).toEqual({ type: "text", value: "See " });
expect(parts[1]).toMatchObject({
type: "ref",
path: "src/lib.rs",
line: 100,
});
expect(parts[2]).toEqual({ type: "text", value: " for details" });
});
it("detects multiple code references", () => {
const parts = parseCodeRefs("Check src/a.rs:1 and src/b.ts:200");
const refs = parts.filter((p) => p.type === "ref");
expect(refs).toHaveLength(2);
expect(refs[0]).toMatchObject({ path: "src/a.rs", line: 1 });
expect(refs[1]).toMatchObject({ path: "src/b.ts", line: 200 });
});
it("does not match text without a file extension", () => {
const parts = parseCodeRefs("something:42");
// "something" has no dot so it should not match
expect(parts.every((p) => p.type === "text")).toBe(true);
});
it("matches nested paths with multiple slashes", () => {
const parts = parseCodeRefs("frontend/src/components/Chat.tsx:55");
expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({
type: "ref",
path: "frontend/src/components/Chat.tsx",
line: 55,
});
});
});
describe("InlineCodeWithRefs component (Story 193)", () => {
it("renders plain text without buttons", () => {
render(<InlineCodeWithRefs text="just some text" />);
expect(screen.getByText("just some text")).toBeInTheDocument();
expect(screen.queryByRole("button")).toBeNull();
});
it("renders a code reference as a clickable button", () => {
render(<InlineCodeWithRefs text="src/main.rs:42" />);
const button = screen.getByRole("button", { name: /src\/main\.rs:42/ });
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute("title", "Open src/main.rs:42 in editor");
});
it("calls settingsApi.openFile when a code reference is clicked", async () => {
const { settingsApi } = await import("../api/settings");
render(<InlineCodeWithRefs text="src/main.rs:42" />);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(settingsApi.openFile).toHaveBeenCalledWith("src/main.rs", 42);
});
it("renders mixed text and code references correctly", () => {
render(<InlineCodeWithRefs text="See src/lib.rs:10 for the impl" />);
// getByText normalizes text (trims whitespace), so "See " → "See"
expect(screen.getByText("See")).toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
expect(screen.getByText("for the impl")).toBeInTheDocument();
});
});
-118
View File
@@ -1,118 +0,0 @@
import * as React from "react";
import { settingsApi } from "../api/settings";
// Matches patterns like `src/main.rs:42` or `path/to/file.tsx:123`
// Path must contain at least one dot (file extension) and a colon followed by digits.
const CODE_REF_PATTERN = /\b([\w.\-/]+\.\w+):(\d+)\b/g;
export interface CodeRefPart {
type: "text" | "ref";
value: string;
path?: string;
line?: number;
}
/**
* Parse a string into text and code-reference parts.
* Code references have the format `path/to/file.ext:line`.
*/
export function parseCodeRefs(text: string): CodeRefPart[] {
const parts: CodeRefPart[] = [];
let lastIndex = 0;
const re = new RegExp(CODE_REF_PATTERN.source, "g");
let match: RegExpExecArray | null;
match = re.exec(text);
while (match !== null) {
if (match.index > lastIndex) {
parts.push({ type: "text", value: text.slice(lastIndex, match.index) });
}
parts.push({
type: "ref",
value: match[0],
path: match[1],
line: Number(match[2]),
});
lastIndex = re.lastIndex;
match = re.exec(text);
}
if (lastIndex < text.length) {
parts.push({ type: "text", value: text.slice(lastIndex) });
}
return parts;
}
interface CodeRefLinkProps {
path: string;
line: number;
children: React.ReactNode;
}
function CodeRefLink({ path, line, children }: CodeRefLinkProps) {
const handleClick = React.useCallback(() => {
settingsApi.openFile(path, line).catch(() => {
// Silently ignore errors (e.g. no editor configured)
});
}, [path, line]);
return (
<button
type="button"
onClick={handleClick}
title={`Open ${path}:${line} in editor`}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
color: "#7ec8e3",
fontFamily: "monospace",
fontSize: "inherit",
textDecoration: "underline",
textDecorationStyle: "dotted",
}}
>
{children}
</button>
);
}
interface InlineCodeWithRefsProps {
text: string;
}
/**
* Renders inline text with code references converted to clickable links.
*/
export function InlineCodeWithRefs({ text }: InlineCodeWithRefsProps) {
const parts = parseCodeRefs(text);
if (parts.length === 1 && parts[0].type === "text") {
return <>{text}</>;
}
return (
<>
{parts.map((part) => {
if (
part.type === "ref" &&
part.path !== undefined &&
part.line !== undefined
) {
return (
<CodeRefLink
key={`ref-${part.path}:${part.line}`}
path={part.path}
line={part.line}
>
{part.value}
</CodeRefLink>
);
}
return <span key={`text-${part.value}`}>{part.value}</span>;
})}
</>
);
}
-158
View File
@@ -1,158 +0,0 @@
import * as React from "react";
const { useEffect, useRef } = React;
interface SlashCommand {
name: string;
description: string;
}
const SLASH_COMMANDS: SlashCommand[] = [
{
name: "/help",
description: "Show this list of available slash commands.",
},
{
name: "/btw <question>",
description:
"Ask a side question using the current conversation as context. The question and answer are not added to the conversation history.",
},
];
interface HelpOverlayProps {
onDismiss: () => void;
}
/**
* Dismissible overlay that lists all available slash commands.
* Dismiss with Escape, Enter, or Space.
*/
export function HelpOverlay({ onDismiss }: HelpOverlayProps) {
const dismissRef = useRef(onDismiss);
dismissRef.current = onDismiss;
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
dismissRef.current();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss is supplementary; keyboard handled via window keydown
// biome-ignore lint/a11y/useKeyWithClickEvents: keyboard dismiss handled via window keydown listener
<div
data-testid="help-overlay"
onClick={onDismiss}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation only; no real interaction */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop-propagation only; no real interaction */}
<div
data-testid="help-panel"
onClick={(e) => e.stopPropagation()}
style={{
background: "#2f2f2f",
border: "1px solid #444",
borderRadius: "12px",
padding: "24px",
maxWidth: "560px",
width: "90vw",
display: "flex",
flexDirection: "column",
gap: "16px",
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span
style={{
fontSize: "0.7rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "#a0d4a0",
}}
>
Slash Commands
</span>
<button
type="button"
onClick={onDismiss}
title="Dismiss (Escape, Enter, or Space)"
style={{
background: "none",
border: "none",
color: "#666",
cursor: "pointer",
fontSize: "1.1rem",
padding: "2px 6px",
borderRadius: "4px",
}}
>
</button>
</div>
{/* Command list */}
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{SLASH_COMMANDS.map((cmd) => (
<div
key={cmd.name}
style={{ display: "flex", flexDirection: "column", gap: "2px" }}
>
<code
style={{
fontSize: "0.88rem",
color: "#e0e0e0",
fontFamily: "monospace",
}}
>
{cmd.name}
</code>
<span
style={{
fontSize: "0.85rem",
color: "#999",
lineHeight: "1.5",
}}
>
{cmd.description}
</span>
</div>
))}
</div>
{/* Footer hint */}
<div
style={{
fontSize: "0.75rem",
color: "#555",
textAlign: "center",
}}
>
Press Escape, Enter, or Space to dismiss
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,445 +0,0 @@
/**
* LozengeFlyContext FLIP-style animation system for agent lozenges.
*
* When an agent is assigned to a story, a fixed-positioned clone of the
* agent lozenge "flies" from the roster badge in AgentPanel to the slot
* in StagePanel (or vice-versa when the agent is removed). The overlay
* travels above all other UI elements (z-index 9999) so it is never
* clipped by the layout.
*/
import * as React from "react";
import * as ReactDOM from "react-dom";
import type { PipelineState } from "../api/client";
const {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} = React;
// ─── Public context shape ─────────────────────────────────────────────────────
export interface LozengeFlyContextValue {
/** Register/unregister a roster badge DOM element by agent name. */
registerRosterEl: (agentName: string, el: HTMLElement | null) => void;
/**
* Save the latest DOMRect for a story's lozenge slot.
* Called on every render of AgentLozenge via useLayoutEffect.
*/
saveSlotRect: (storyId: string, rect: DOMRect) => void;
/**
* Set of storyIds whose slot lozenges should be hidden because a
* fly-in animation is in progress.
*/
pendingFlyIns: ReadonlySet<string>;
/**
* Set of agent names whose roster badge should be hidden.
* An agent is hidden while it is assigned to a work item OR while its
* fly-out animation (work item roster) is still in flight.
*/
hiddenRosterAgents: ReadonlySet<string>;
}
const noop = () => {};
const emptySet: ReadonlySet<string> = new Set();
export const LozengeFlyContext = createContext<LozengeFlyContextValue>({
registerRosterEl: noop,
saveSlotRect: noop,
pendingFlyIns: emptySet,
hiddenRosterAgents: emptySet,
});
// ─── Internal flying-lozenge state ───────────────────────────────────────────
interface FlyingLozenge {
id: string;
label: string;
isActive: boolean;
startX: number;
startY: number;
endX: number;
endY: number;
/** false = positioned at start, true = CSS transition to end */
flying: boolean;
}
interface PendingFlyIn {
storyId: string;
agentName: string;
label: string;
isActive: boolean;
}
interface PendingFlyOut {
storyId: string;
agentName: string;
label: string;
}
// ─── Provider ─────────────────────────────────────────────────────────────────
interface LozengeFlyProviderProps {
children: React.ReactNode;
pipeline: PipelineState;
}
export function LozengeFlyProvider({
children,
pipeline,
}: LozengeFlyProviderProps) {
const rosterElsRef = useRef<Map<string, HTMLElement>>(new Map());
const savedSlotRectsRef = useRef<Map<string, DOMRect>>(new Map());
const prevPipelineRef = useRef<PipelineState | null>(null);
// Actions detected in useLayoutEffect, consumed in useEffect
const pendingFlyInActionsRef = useRef<PendingFlyIn[]>([]);
const pendingFlyOutActionsRef = useRef<PendingFlyOut[]>([]);
// Track the active animation ID per story/agent so stale timeouts
// from superseded animations don't prematurely clear state.
const activeFlyInPerStory = useRef<Map<string, string>>(new Map());
const activeFlyOutPerAgent = useRef<Map<string, string>>(new Map());
const [pendingFlyIns, setPendingFlyIns] = useState<ReadonlySet<string>>(
new Set(),
);
const [flyingLozenges, setFlyingLozenges] = useState<FlyingLozenge[]>([]);
// Agents currently assigned to a work item (derived from pipeline state).
const assignedAgentNames = useMemo(() => {
const names = new Set<string>();
for (const item of [
...pipeline.backlog,
...pipeline.current,
...pipeline.qa,
...pipeline.merge,
]) {
if (item.agent) names.add(item.agent.agent_name);
}
return names;
}, [pipeline]);
// Agents whose fly-out (work item → roster) animation is still in flight.
// Kept hidden until the clone lands so no duplicate badge flashes.
const [flyingOutAgents, setFlyingOutAgents] = useState<ReadonlySet<string>>(
new Set(),
);
// Union: hide badge whenever the agent is assigned OR still flying back.
const hiddenRosterAgents = useMemo(() => {
if (flyingOutAgents.size === 0) return assignedAgentNames;
const combined = new Set(assignedAgentNames);
for (const name of flyingOutAgents) combined.add(name);
return combined;
}, [assignedAgentNames, flyingOutAgents]);
const registerRosterEl = useCallback(
(agentName: string, el: HTMLElement | null) => {
if (el) {
rosterElsRef.current.set(agentName, el);
} else {
rosterElsRef.current.delete(agentName);
}
},
[],
);
const saveSlotRect = useCallback((storyId: string, rect: DOMRect) => {
savedSlotRectsRef.current.set(storyId, rect);
}, []);
// ── Detect pipeline changes (runs before paint) ───────────────────────────
// Sets pendingFlyIns so slot lozenges hide before the browser paints,
// preventing a one-frame "flash" of the visible lozenge before fly-in.
useLayoutEffect(() => {
if (prevPipelineRef.current === null) {
prevPipelineRef.current = pipeline;
return;
}
const prev = prevPipelineRef.current;
const allPrev = [
...prev.backlog,
...prev.current,
...prev.qa,
...prev.merge,
];
const allCurr = [
...pipeline.backlog,
...pipeline.current,
...pipeline.qa,
...pipeline.merge,
];
const newFlyInStoryIds = new Set<string>();
for (const curr of allCurr) {
const prevItem = allPrev.find((p) => p.story_id === curr.story_id);
const agentChanged =
curr.agent &&
(!prevItem?.agent ||
prevItem.agent.agent_name !== curr.agent.agent_name);
if (agentChanged && curr.agent) {
const label = curr.agent.model
? `${curr.agent.agent_name} ${curr.agent.model}`
: curr.agent.agent_name;
pendingFlyInActionsRef.current.push({
storyId: curr.story_id,
agentName: curr.agent.agent_name,
label,
isActive: curr.agent.status === "running",
});
newFlyInStoryIds.add(curr.story_id);
}
}
for (const prevItem of allPrev) {
if (!prevItem.agent) continue;
const currItem = allCurr.find((c) => c.story_id === prevItem.story_id);
const agentRemoved =
!currItem?.agent ||
currItem.agent.agent_name !== prevItem.agent.agent_name;
if (agentRemoved) {
const label = prevItem.agent.model
? `${prevItem.agent.agent_name} ${prevItem.agent.model}`
: prevItem.agent.agent_name;
pendingFlyOutActionsRef.current.push({
storyId: prevItem.story_id,
agentName: prevItem.agent.agent_name,
label,
});
}
}
prevPipelineRef.current = pipeline;
// Only hide slots for stories that have a matching roster element
if (newFlyInStoryIds.size > 0) {
const hideable = new Set<string>();
for (const storyId of newFlyInStoryIds) {
const action = pendingFlyInActionsRef.current.find(
(a) => a.storyId === storyId,
);
if (action && rosterElsRef.current.has(action.agentName)) {
hideable.add(storyId);
}
}
if (hideable.size > 0) {
setPendingFlyIns((prev) => {
const next = new Set(prev);
for (const id of hideable) next.add(id);
return next;
});
}
}
}, [pipeline]);
// ── Execute animations (runs after paint, DOM positions are stable) ───────
useEffect(() => {
const flyIns = [...pendingFlyInActionsRef.current];
pendingFlyInActionsRef.current = [];
const flyOuts = [...pendingFlyOutActionsRef.current];
pendingFlyOutActionsRef.current = [];
for (const action of flyIns) {
const rosterEl = rosterElsRef.current.get(action.agentName);
const slotRect = savedSlotRectsRef.current.get(action.storyId);
if (!rosterEl || !slotRect) {
// No roster element: immediately reveal the slot lozenge
setPendingFlyIns((prev) => {
const next = new Set(prev);
next.delete(action.storyId);
return next;
});
continue;
}
const rosterRect = rosterEl.getBoundingClientRect();
const id = `fly-in-${action.agentName}-${action.storyId}-${Date.now()}`;
activeFlyInPerStory.current.set(action.storyId, id);
setFlyingLozenges((prev) => [
...prev,
{
id,
label: action.label,
isActive: action.isActive,
startX: rosterRect.left,
startY: rosterRect.top,
endX: slotRect.left,
endY: slotRect.top,
flying: false,
},
]);
// FLIP "Play" step: after two frames the transition begins
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setFlyingLozenges((prev) =>
prev.map((l) => (l.id === id ? { ...l, flying: true } : l)),
);
});
});
// After the transition completes, remove clone and reveal slot lozenge.
// Only clear pendingFlyIns if this is still the active animation for
// this story — a newer animation may have superseded this one.
setTimeout(() => {
setFlyingLozenges((prev) => prev.filter((l) => l.id !== id));
if (activeFlyInPerStory.current.get(action.storyId) === id) {
activeFlyInPerStory.current.delete(action.storyId);
setPendingFlyIns((prev) => {
const next = new Set(prev);
next.delete(action.storyId);
return next;
});
}
}, 500);
}
for (const action of flyOuts) {
const rosterEl = rosterElsRef.current.get(action.agentName);
const slotRect = savedSlotRectsRef.current.get(action.storyId);
if (!slotRect) continue;
// Keep the roster badge hidden while the clone is flying back.
setFlyingOutAgents((prev) => {
const next = new Set(prev);
next.add(action.agentName);
return next;
});
const rosterRect = rosterEl?.getBoundingClientRect();
const id = `fly-out-${action.agentName}-${action.storyId}-${Date.now()}`;
activeFlyOutPerAgent.current.set(action.agentName, id);
setFlyingLozenges((prev) => [
...prev,
{
id,
label: action.label,
isActive: false,
startX: slotRect.left,
startY: slotRect.top,
endX: rosterRect?.left ?? slotRect.left,
endY: rosterRect?.top ?? Math.max(0, slotRect.top - 80),
flying: false,
},
]);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setFlyingLozenges((prev) =>
prev.map((l) => (l.id === id ? { ...l, flying: true } : l)),
);
});
});
// Only reveal the roster badge if this is still the active fly-out
// for this agent — a newer fly-out may have superseded this one.
setTimeout(() => {
setFlyingLozenges((prev) => prev.filter((l) => l.id !== id));
if (activeFlyOutPerAgent.current.get(action.agentName) === id) {
activeFlyOutPerAgent.current.delete(action.agentName);
setFlyingOutAgents((prev) => {
const next = new Set(prev);
next.delete(action.agentName);
return next;
});
}
}, 500);
}
}, [pipeline]);
const contextValue = useMemo(
() => ({
registerRosterEl,
saveSlotRect,
pendingFlyIns,
hiddenRosterAgents,
}),
[registerRosterEl, saveSlotRect, pendingFlyIns, hiddenRosterAgents],
);
return (
<LozengeFlyContext.Provider value={contextValue}>
{children}
{ReactDOM.createPortal(
<FloatingLozengeSurface lozenges={flyingLozenges} />,
document.body,
)}
</LozengeFlyContext.Provider>
);
}
// ─── Portal surface ───────────────────────────────────────────────────────────
function FloatingLozengeSurface({ lozenges }: { lozenges: FlyingLozenge[] }) {
return (
<>
{lozenges.map((l) => (
<FlyingLozengeClone key={l.id} lozenge={l} />
))}
</>
);
}
function FlyingLozengeClone({ lozenge }: { lozenge: FlyingLozenge }) {
const color = lozenge.isActive ? "#3fb950" : "#e3b341";
const x = lozenge.flying ? lozenge.endX : lozenge.startX;
const y = lozenge.flying ? lozenge.endY : lozenge.startY;
return (
<div
data-testid={`flying-lozenge-${lozenge.id}`}
style={{
position: "fixed",
left: `${x}px`,
top: `${y}px`,
zIndex: 9999,
pointerEvents: "none",
transition: lozenge.flying
? "left 0.4s cubic-bezier(0.4, 0, 0.2, 1), top 0.4s cubic-bezier(0.4, 0, 0.2, 1)"
: "none",
display: "inline-flex",
alignItems: "center",
gap: "5px",
padding: "2px 8px",
borderRadius: "999px",
fontSize: "0.72em",
fontWeight: 600,
background: `${color}18`,
color,
border: `1px solid ${color}44`,
whiteSpace: "nowrap",
}}
>
{lozenge.isActive && (
<span
style={{
width: "5px",
height: "5px",
borderRadius: "50%",
background: color,
animation: "pulse 1.5s infinite",
flexShrink: 0,
}}
/>
)}
{lozenge.label}
</div>
);
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useLozengeFly(): LozengeFlyContextValue {
return useContext(LozengeFlyContext);
}
@@ -1,137 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MessageItem } from "./MessageItem";
vi.mock("../api/settings", () => ({
settingsApi: {
openFile: vi.fn(() => Promise.resolve({ success: true })),
},
}));
describe("MessageItem component (Story 178 AC3)", () => {
it("renders user message as a bubble", () => {
render(<MessageItem msg={{ role: "user", content: "Hello there!" }} />);
expect(screen.getByText("Hello there!")).toBeInTheDocument();
});
it("renders assistant message with markdown-body class", () => {
render(
<MessageItem
msg={{ role: "assistant", content: "Here is my response." }}
/>,
);
expect(screen.getByText("Here is my response.")).toBeInTheDocument();
const text = screen.getByText("Here is my response.");
expect(text.closest(".markdown-body")).toBeTruthy();
});
it("renders tool message as collapsible details", () => {
render(
<MessageItem
msg={{
role: "tool",
content: "tool output content",
tool_call_id: "toolu_1",
}}
/>,
);
expect(screen.getByText(/Tool Output/)).toBeInTheDocument();
});
it("renders tool call badges for assistant messages with tool_calls", () => {
render(
<MessageItem
msg={{
role: "assistant",
content: "I will read the file.",
tool_calls: [
{
id: "toolu_1",
type: "function",
function: {
name: "Read",
arguments: '{"file_path":"src/main.rs"}',
},
},
],
}}
/>,
);
expect(screen.getByText("I will read the file.")).toBeInTheDocument();
expect(screen.getByText("Read(src/main.rs)")).toBeInTheDocument();
});
it("is wrapped in React.memo (has displayName or $$typeof memo)", () => {
// React.memo wraps the component — verify the export is memoized
// by checking that the component has a memo wrapper
const { type } = { type: MessageItem };
// React.memo returns an object with $$typeof === Symbol(react.memo)
// biome-ignore lint/suspicious/noExplicitAny: checking React internals for test
expect((type as any).$$typeof).toBeDefined();
// biome-ignore lint/suspicious/noExplicitAny: checking React internals for test
const typeofStr = String((type as any).$$typeof);
expect(typeofStr).toContain("memo");
});
});
describe("MessageItem code reference rendering (Story 193)", () => {
it("renders inline code with a code reference as a clickable button in assistant messages", () => {
render(
<MessageItem
msg={{
role: "assistant",
content: "Check `src/main.rs:42` for the implementation.",
}}
/>,
);
const button = screen.getByRole("button", { name: /src\/main\.rs:42/ });
expect(button).toBeInTheDocument();
});
});
describe("MessageItem user message code fence rendering (Story 196)", () => {
it("renders code fences in user messages as code blocks", () => {
const { container } = render(
<MessageItem
msg={{
role: "user",
content: "Here is some code:\n```js\nconsole.log('hi');\n```",
}}
/>,
);
// Syntax highlighter renders a pre > div > code structure
const codeEl = container.querySelector("pre code");
expect(codeEl).toBeInTheDocument();
expect(codeEl?.textContent).toContain("console.log");
});
it("renders inline code with single backticks in user messages", () => {
render(
<MessageItem
msg={{ role: "user", content: "Use `npm install` to install." }}
/>,
);
const codeEl = screen.getByText("npm install");
expect(codeEl.tagName.toLowerCase()).toBe("code");
});
it("renders user messages with code blocks inside user-markdown-body class", () => {
const { container } = render(
<MessageItem
msg={{
role: "user",
content: "```js\nconsole.log('hi');\n```",
}}
/>,
);
expect(container.querySelector(".user-markdown-body")).toBeTruthy();
});
});
-168
View File
@@ -1,168 +0,0 @@
import * as React from "react";
import Markdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import type { Message, ToolCall } from "../types";
import { InlineCodeWithRefs } from "./CodeRef";
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
function CodeBlock({ className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || "");
const isInline = !className;
const text = String(children);
if (!isInline && match) {
return (
<SyntaxHighlighter
// biome-ignore lint/suspicious/noExplicitAny: oneDark style types are incompatible
style={oneDark as any}
language={match[1]}
PreTag="div"
>
{text.replace(/\n$/, "")}
</SyntaxHighlighter>
);
}
// For inline code, detect and render code references as clickable links
return (
<code className={className} {...props}>
<InlineCodeWithRefs text={text} />
</code>
);
}
interface MessageItemProps {
msg: Message;
}
function MessageItemInner({ msg }: MessageItemProps) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: msg.role === "user" ? "flex-end" : "flex-start",
}}
>
<div
style={{
maxWidth: "100%",
padding: msg.role === "user" ? "10px 16px" : "0",
borderRadius: msg.role === "user" ? "20px" : "0",
background:
msg.role === "user"
? "#2f2f2f"
: msg.role === "tool"
? "#222"
: "transparent",
color: "#ececec",
border: msg.role === "tool" ? "1px solid #333" : "none",
fontFamily: msg.role === "tool" ? "monospace" : "inherit",
fontSize: msg.role === "tool" ? "0.85em" : "1em",
fontWeight: "500",
whiteSpace: msg.role === "tool" ? "pre-wrap" : "normal",
lineHeight: "1.6",
}}
>
{msg.role === "user" ? (
<div className="user-markdown-body">
<Markdown components={{ code: CodeBlock }}>{msg.content}</Markdown>
</div>
) : msg.role === "tool" ? (
<details style={{ cursor: "pointer" }}>
<summary
style={{
color: "#aaa",
fontSize: "0.9em",
marginBottom: "8px",
listStyle: "none",
display: "flex",
alignItems: "center",
gap: "6px",
}}
>
<span style={{ fontSize: "0.8em" }}></span>
<span>
Tool Output
{msg.tool_call_id && ` (${msg.tool_call_id})`}
</span>
</summary>
<pre
style={{
maxHeight: "300px",
overflow: "auto",
margin: 0,
padding: "8px",
background: "#1a1a1a",
borderRadius: "4px",
fontSize: "0.85em",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{msg.content}
</pre>
</details>
) : (
<div className="markdown-body">
<Markdown components={{ code: CodeBlock }}>{msg.content}</Markdown>
</div>
)}
{msg.tool_calls && (
<div
style={{
marginTop: "12px",
fontSize: "0.85em",
color: "#aaa",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
{msg.tool_calls.map((tc: ToolCall, i: number) => {
let argsSummary = "";
try {
const args = JSON.parse(tc.function.arguments);
const firstKey = Object.keys(args)[0];
if (firstKey && args[firstKey]) {
argsSummary = String(args[firstKey]);
if (argsSummary.length > 50) {
argsSummary = `${argsSummary.substring(0, 47)}...`;
}
}
} catch (_e) {
// ignore
}
return (
<div
key={`tool-${i}-${tc.function.name}`}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
fontFamily: "monospace",
}}
>
<span style={{ color: "#888" }}></span>
<span
style={{
background: "#333",
padding: "2px 6px",
borderRadius: "4px",
}}
>
{tc.function.name}
{argsSummary && `(${argsSummary})`}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
export const MessageItem = React.memo(MessageItemInner);
-246
View File
@@ -1,246 +0,0 @@
import * as React from "react";
const { useCallback, useEffect, useRef, useState } = React;
export interface LogEntry {
timestamp: string;
level: string;
message: string;
}
interface ServerLogsPanelProps {
logs: LogEntry[];
}
function levelColor(level: string): string {
switch (level.toUpperCase()) {
case "ERROR":
return "#e06c75";
case "WARN":
return "#e5c07b";
default:
return "#98c379";
}
}
export function ServerLogsPanel({ logs }: ServerLogsPanelProps) {
const [isOpen, setIsOpen] = useState(false);
const [filter, setFilter] = useState("");
const [severityFilter, setSeverityFilter] = useState<string>("ALL");
const scrollRef = useRef<HTMLDivElement>(null);
const userScrolledUpRef = useRef(false);
const lastScrollTopRef = useRef(0);
const filteredLogs = logs.filter((entry) => {
const matchesSeverity =
severityFilter === "ALL" || entry.level.toUpperCase() === severityFilter;
const matchesFilter =
filter === "" ||
entry.message.toLowerCase().includes(filter.toLowerCase()) ||
entry.timestamp.includes(filter);
return matchesSeverity && matchesFilter;
});
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
lastScrollTopRef.current = el.scrollTop;
}
}, []);
// Auto-scroll when new entries arrive (unless user scrolled up).
useEffect(() => {
if (!isOpen) return;
if (!userScrolledUpRef.current) {
scrollToBottom();
}
}, [filteredLogs.length, isOpen, scrollToBottom]);
const handleScroll = () => {
const el = scrollRef.current;
if (!el) return;
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 5;
if (el.scrollTop < lastScrollTopRef.current) {
userScrolledUpRef.current = true;
}
if (isAtBottom) {
userScrolledUpRef.current = false;
}
lastScrollTopRef.current = el.scrollTop;
};
const severityButtons = ["ALL", "INFO", "WARN", "ERROR"] as const;
return (
<div
data-testid="server-logs-panel"
style={{
borderRadius: "8px",
border: "1px solid #333",
overflow: "hidden",
}}
>
{/* Header / toggle */}
<button
type="button"
data-testid="server-logs-panel-toggle"
onClick={() => setIsOpen((v) => !v)}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 12px",
background: "#1e1e1e",
border: "none",
cursor: "pointer",
color: "#ccc",
fontSize: "0.85em",
fontWeight: 600,
textAlign: "left",
}}
>
<span>Server Logs</span>
<span style={{ color: "#666", fontSize: "0.85em" }}>
{logs.length > 0 && (
<span style={{ marginRight: "8px", color: "#555" }}>
{logs.length}
</span>
)}
{isOpen ? "▲" : "▼"}
</span>
</button>
{isOpen && (
<div style={{ background: "#0d1117" }}>
{/* Filter controls */}
<div
style={{
display: "flex",
gap: "6px",
padding: "8px",
borderBottom: "1px solid #1e1e1e",
flexWrap: "wrap",
alignItems: "center",
}}
>
<input
type="text"
data-testid="server-logs-filter-input"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter logs..."
style={{
flex: 1,
minWidth: "80px",
padding: "4px 8px",
borderRadius: "4px",
border: "1px solid #333",
background: "#161b22",
color: "#ccc",
fontSize: "0.8em",
outline: "none",
}}
/>
{severityButtons.map((sev) => (
<button
key={sev}
type="button"
data-testid={`server-logs-severity-${sev.toLowerCase()}`}
onClick={() => setSeverityFilter(sev)}
style={{
padding: "3px 8px",
borderRadius: "4px",
border: "1px solid",
borderColor:
severityFilter === sev ? levelColor(sev) : "#333",
background:
severityFilter === sev
? "rgba(255,255,255,0.06)"
: "transparent",
color:
sev === "ALL"
? severityFilter === "ALL"
? "#ccc"
: "#555"
: levelColor(sev),
fontSize: "0.75em",
cursor: "pointer",
fontWeight: severityFilter === sev ? 700 : 400,
}}
>
{sev}
</button>
))}
</div>
{/* Log entries */}
<div
ref={scrollRef}
onScroll={handleScroll}
data-testid="server-logs-entries"
style={{
maxHeight: "240px",
overflowY: "auto",
padding: "4px 0",
fontFamily: "monospace",
fontSize: "0.75em",
}}
>
{filteredLogs.length === 0 ? (
<div
style={{
padding: "16px",
color: "#444",
textAlign: "center",
fontSize: "0.9em",
}}
>
No log entries
</div>
) : (
filteredLogs.map((entry, idx) => (
<div
key={`${entry.timestamp}-${idx}`}
style={{
display: "flex",
gap: "6px",
padding: "1px 8px",
lineHeight: "1.5",
borderBottom: "1px solid #111",
}}
>
<span
style={{ color: "#444", flexShrink: 0, minWidth: "70px" }}
>
{entry.timestamp.replace("T", " ").replace("Z", "")}
</span>
<span
style={{
color: levelColor(entry.level),
flexShrink: 0,
minWidth: "38px",
fontWeight: 700,
}}
>
{entry.level}
</span>
<span
style={{
color: "#c9d1d9",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}}
>
{entry.message}
</span>
</div>
))
)}
</div>
</div>
)}
</div>
);
}
@@ -1,159 +0,0 @@
import * as React from "react";
import Markdown from "react-markdown";
const { useEffect, useRef } = React;
interface SideQuestionOverlayProps {
question: string;
/** Streaming response text. Empty while loading. */
response: string;
loading: boolean;
onDismiss: () => void;
}
/**
* Dismissible overlay that shows a /btw side question and its streamed response.
* The question and response are NOT part of the main conversation history.
* Dismiss with Escape, Enter, or Space.
*/
export function SideQuestionOverlay({
question,
response,
loading,
onDismiss,
}: SideQuestionOverlayProps) {
const dismissRef = useRef(onDismiss);
dismissRef.current = onDismiss;
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
dismissRef.current();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss is supplementary; keyboard handled via window keydown
// biome-ignore lint/a11y/useKeyWithClickEvents: keyboard dismiss handled via window keydown listener
<div
data-testid="side-question-overlay"
onClick={onDismiss}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation only; no real interaction */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop-propagation only; no real interaction */}
<div
data-testid="side-question-panel"
onClick={(e) => e.stopPropagation()}
style={{
background: "#2f2f2f",
border: "1px solid #444",
borderRadius: "12px",
padding: "24px",
maxWidth: "640px",
width: "90vw",
maxHeight: "60vh",
display: "flex",
flexDirection: "column",
gap: "16px",
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: "12px",
}}
>
<div>
<span
style={{
display: "block",
fontSize: "0.7rem",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "#a0d4a0",
marginBottom: "4px",
}}
>
/btw
</span>
<span
style={{
fontSize: "1rem",
color: "#ececec",
fontWeight: 500,
}}
>
{question}
</span>
</div>
<button
type="button"
onClick={onDismiss}
title="Dismiss (Escape, Enter, or Space)"
style={{
background: "none",
border: "none",
color: "#666",
cursor: "pointer",
fontSize: "1.1rem",
padding: "2px 6px",
borderRadius: "4px",
flexShrink: 0,
}}
>
</button>
</div>
{/* Response area */}
<div
style={{
overflowY: "auto",
flex: 1,
color: "#ccc",
fontSize: "0.95rem",
lineHeight: "1.6",
}}
>
{loading && !response && (
<span style={{ color: "#666", fontStyle: "italic" }}>
Thinking
</span>
)}
{response && <Markdown>{response}</Markdown>}
</div>
{/* Footer hint */}
{!loading && (
<div
style={{
fontSize: "0.75rem",
color: "#555",
textAlign: "center",
}}
>
Press Escape, Enter, or Space to dismiss
</div>
)}
</div>
</div>
);
}
-311
View File
@@ -1,311 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { PipelineStageItem } from "../api/client";
import { StagePanel } from "./StagePanel";
describe("StagePanel", () => {
it("renders empty message when no items", () => {
render(<StagePanel title="Current" items={[]} />);
expect(screen.getByText("Empty.")).toBeInTheDocument();
});
it("renders story item without agent lozenge when agent is null", () => {
const items: PipelineStageItem[] = [
{
story_id: "42_story_no_agent",
name: "No Agent Story",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
},
];
render(<StagePanel title="Current" items={items} />);
expect(screen.getByText("No Agent Story")).toBeInTheDocument();
// No agent lozenge
expect(screen.queryByText(/coder-/)).not.toBeInTheDocument();
});
it("shows agent lozenge with agent name and model when agent is running", () => {
const items: PipelineStageItem[] = [
{
story_id: "43_story_with_agent",
name: "Active Story",
error: null,
merge_failure: null,
agent: {
agent_name: "coder-1",
model: "sonnet",
status: "running",
},
review_hold: null,
qa: null,
},
];
render(<StagePanel title="Current" items={items} />);
expect(screen.getByText("Active Story")).toBeInTheDocument();
expect(screen.getByText("coder-1 sonnet")).toBeInTheDocument();
});
it("shows agent lozenge with only agent name when model is null", () => {
const items: PipelineStageItem[] = [
{
story_id: "44_story_no_model",
name: "No Model Story",
error: null,
merge_failure: null,
agent: {
agent_name: "coder-2",
model: null,
status: "running",
},
review_hold: null,
qa: null,
},
];
render(<StagePanel title="Current" items={items} />);
expect(screen.getByText("coder-2")).toBeInTheDocument();
});
it("shows agent lozenge for pending agent", () => {
const items: PipelineStageItem[] = [
{
story_id: "45_story_pending",
name: "Pending Story",
error: null,
merge_failure: null,
agent: {
agent_name: "coder-1",
model: "haiku",
status: "pending",
},
review_hold: null,
qa: null,
},
];
render(<StagePanel title="QA" items={items} />);
expect(screen.getByText("coder-1 haiku")).toBeInTheDocument();
});
it("shows story number extracted from story_id", () => {
const items: PipelineStageItem[] = [
{
story_id: "59_story_current_work_panel",
name: "Current Work Panel",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
},
];
render(<StagePanel title="Current" items={items} />);
expect(screen.getByText("#59")).toBeInTheDocument();
});
it("shows error message when item has an error", () => {
const items: PipelineStageItem[] = [
{
story_id: "1_story_bad",
name: null,
error: "Missing front matter",
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
},
];
render(<StagePanel title="Upcoming" items={items} />);
expect(screen.getByText("Missing front matter")).toBeInTheDocument();
});
it("shows STORY badge for story items", () => {
const items: PipelineStageItem[] = [
{
story_id: "10_story_some_feature",
name: "Some Feature",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
},
];
render(<StagePanel title="Upcoming" items={items} />);
expect(
screen.getByTestId("type-badge-10_story_some_feature"),
).toHaveTextContent("STORY");
});
it("shows BUG badge for bug items", () => {
const items: PipelineStageItem[] = [
{
story_id: "11_bug_broken_thing",
name: "Broken Thing",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
},
];
render(<StagePanel title="Current" items={items} />);
expect(
screen.getByTestId("type-badge-11_bug_broken_thing"),
).toHaveTextContent("BUG");
});
it("shows SPIKE badge for spike items", () => {
const items: PipelineStageItem[] = [
{
story_id: "12_spike_investigate_perf",
name: "Investigate Perf",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
},
];
render(<StagePanel title="QA" items={items} />);
expect(
screen.getByTestId("type-badge-12_spike_investigate_perf"),
).toHaveTextContent("SPIKE");
});
it("shows no badge for unrecognised type prefix", () => {
const items: PipelineStageItem[] = [
{
story_id: "13_task_do_something",
name: "Do Something",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
},
];
render(<StagePanel title="Done" items={items} />);
expect(
screen.queryByTestId("type-badge-13_task_do_something"),
).not.toBeInTheDocument();
});
it("card has uniform border on all sides for story items", () => {
const items: PipelineStageItem[] = [
{
story_id: "20_story_uniform_border",
name: "Uniform Border",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
},
];
render(<StagePanel title="Upcoming" items={items} />);
const card = screen.getByTestId("card-20_story_uniform_border");
// No 3px colored left border - all sides match the uniform shorthand
expect(card.style.borderLeft).not.toContain("3px");
expect(card.style.borderLeft).toBe(card.style.borderTop);
expect(card.style.borderLeft).toBe(card.style.borderRight);
expect(card.style.borderLeft).toBe(card.style.borderBottom);
});
it("card has uniform border on all sides for bug items", () => {
const items: PipelineStageItem[] = [
{
story_id: "21_bug_uniform_border",
name: "Uniform Border Bug",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
},
];
render(<StagePanel title="Current" items={items} />);
const card = screen.getByTestId("card-21_bug_uniform_border");
expect(card.style.borderLeft).not.toContain("3px");
expect(card.style.borderLeft).toBe(card.style.borderTop);
});
it("card has uniform border on all sides for spike items", () => {
const items: PipelineStageItem[] = [
{
story_id: "22_spike_uniform_border",
name: "Uniform Border Spike",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
},
];
render(<StagePanel title="QA" items={items} />);
const card = screen.getByTestId("card-22_spike_uniform_border");
expect(card.style.borderLeft).not.toContain("3px");
expect(card.style.borderLeft).toBe(card.style.borderTop);
});
it("card has uniform border on all sides for unrecognised type", () => {
const items: PipelineStageItem[] = [
{
story_id: "23_task_uniform_border",
name: "Uniform Border Task",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
},
];
render(<StagePanel title="Done" items={items} />);
const card = screen.getByTestId("card-23_task_uniform_border");
expect(card.style.borderLeft).not.toContain("3px");
expect(card.style.borderLeft).toBe(card.style.borderTop);
});
it("shows merge failure icon and reason when merge_failure is set", () => {
const items: PipelineStageItem[] = [
{
story_id: "30_story_merge_failed",
name: "Failed Merge Story",
error: null,
merge_failure: "Squash merge failed: conflicts in Cargo.lock",
agent: null,
review_hold: null,
qa: null,
},
];
render(<StagePanel title="Merge" items={items} />);
expect(
screen.getByTestId("merge-failure-icon-30_story_merge_failed"),
).toBeInTheDocument();
expect(
screen.getByTestId("merge-failure-reason-30_story_merge_failed"),
).toHaveTextContent("Squash merge failed: conflicts in Cargo.lock");
});
it("does not show merge failure elements when merge_failure is null", () => {
const items: PipelineStageItem[] = [
{
story_id: "31_story_no_failure",
name: "Clean Story",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
},
];
render(<StagePanel title="Merge" items={items} />);
expect(
screen.queryByTestId("merge-failure-icon-31_story_no_failure"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("merge-failure-reason-31_story_no_failure"),
).not.toBeInTheDocument();
});
});
-517
View File
@@ -1,517 +0,0 @@
import * as React from "react";
import type { AgentConfigInfo } from "../api/agents";
import type { AgentAssignment, PipelineStageItem } from "../api/client";
import { useLozengeFly } from "./LozengeFlyContext";
const { useLayoutEffect, useRef, useState } = React;
type WorkItemType = "story" | "bug" | "spike" | "refactor" | "unknown";
const TYPE_COLORS: Record<WorkItemType, string> = {
story: "#3fb950",
bug: "#f85149",
spike: "#58a6ff",
refactor: "#a371f7",
unknown: "#444",
};
const TYPE_LABELS: Record<WorkItemType, string | null> = {
story: "STORY",
bug: "BUG",
spike: "SPIKE",
refactor: "REFACTOR",
unknown: null,
};
function getWorkItemType(storyId: string): WorkItemType {
const match = storyId.match(/^\d+_([a-z]+)_/);
if (!match) return "unknown";
const segment = match[1];
if (
segment === "story" ||
segment === "bug" ||
segment === "spike" ||
segment === "refactor"
) {
return segment;
}
return "unknown";
}
interface StagePanelProps {
title: string;
items: PipelineStageItem[];
emptyMessage?: string;
onItemClick?: (item: PipelineStageItem) => void;
onStopAgent?: (storyId: string, agentName: string) => void;
onDeleteItem?: (item: PipelineStageItem) => void;
/** Map of story_id → total_cost_usd for displaying cost badges. */
costs?: Map<string, number>;
/** Agent roster to populate the start agent dropdown. */
agentRoster?: AgentConfigInfo[];
/** Names of agents currently running/pending (busy). */
busyAgentNames?: Set<string>;
/** Called when the user requests to start an agent on a story. */
onStartAgent?: (storyId: string, agentName?: string) => void;
}
function AgentLozenge({
agent,
storyId,
onStop,
}: {
agent: AgentAssignment;
storyId: string;
onStop?: () => void;
}) {
const { saveSlotRect, pendingFlyIns } = useLozengeFly();
const lozengeRef = useRef<HTMLDivElement>(null);
const isRunning = agent.status === "running";
const isPending = agent.status === "pending";
const color = isRunning ? "#3fb950" : isPending ? "#e3b341" : "#aaa";
const label = agent.model
? `${agent.agent_name} ${agent.model}`
: agent.agent_name;
const isFlyingIn = pendingFlyIns.has(storyId);
// Save our rect on every render so flyOut can reference it after unmount
useLayoutEffect(() => {
if (lozengeRef.current) {
saveSlotRect(storyId, lozengeRef.current.getBoundingClientRect());
}
});
return (
<div
ref={lozengeRef}
className="agent-lozenge"
data-testid={`slot-lozenge-${storyId}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "5px",
padding: "2px 8px",
borderRadius: "999px",
fontSize: "0.72em",
fontWeight: 600,
background: `${color}18`,
color,
border: `1px solid ${color}44`,
marginTop: "4px",
// Fixed intrinsic width never stretches to fill parent panel
alignSelf: "flex-start",
// Hidden during fly-in; revealed with a fade once the clone arrives
opacity: isFlyingIn ? 0 : 1,
transition: isFlyingIn ? "none" : "opacity 0.15s",
animation: isFlyingIn ? "none" : "agentAppear 0.3s ease-out",
}}
>
{isRunning && (
<span
style={{
width: "5px",
height: "5px",
borderRadius: "50%",
background: color,
animation: "pulse 1.5s infinite",
flexShrink: 0,
}}
/>
)}
{isPending && (
<span
style={{
width: "5px",
height: "5px",
borderRadius: "50%",
background: color,
opacity: 0.7,
flexShrink: 0,
}}
/>
)}
{label}
{isRunning && onStop && (
<button
type="button"
data-testid={`stop-agent-${storyId}`}
onClick={(e) => {
e.stopPropagation();
onStop();
}}
title="Stop agent"
style={{
marginLeft: "4px",
padding: "0 3px",
background: "transparent",
border: "none",
color,
cursor: "pointer",
fontSize: "0.9em",
lineHeight: 1,
opacity: 0.8,
flexShrink: 0,
}}
>
</button>
)}
</div>
);
}
function StartAgentControl({
storyId,
agentRoster,
busyAgentNames,
onStartAgent,
}: {
storyId: string;
agentRoster: AgentConfigInfo[];
busyAgentNames: Set<string>;
onStartAgent: (storyId: string, agentName?: string) => void;
}) {
const [selectedAgent, setSelectedAgent] = useState<string>("");
const allBusy =
agentRoster.length > 0 &&
agentRoster.every((a) => busyAgentNames.has(a.name));
const handleStart = (e: React.MouseEvent) => {
e.stopPropagation();
onStartAgent(storyId, selectedAgent || undefined);
};
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
e.stopPropagation();
setSelectedAgent(e.target.value);
};
return (
<div
style={{
display: "flex",
gap: "4px",
marginTop: "6px",
alignItems: "center",
}}
>
{agentRoster.length > 1 && (
<select
value={selectedAgent}
onChange={handleSelectChange}
disabled={allBusy}
data-testid={`start-agent-select-${storyId}`}
style={{
background: "#2a2a2a",
color: allBusy ? "#555" : "#ccc",
border: "1px solid #444",
borderRadius: "5px",
padding: "2px 4px",
fontSize: "0.75em",
cursor: allBusy ? "not-allowed" : "pointer",
flex: 1,
minWidth: 0,
}}
>
<option value="">Default agent</option>
{agentRoster.map((a) => (
<option key={a.name} value={a.name}>
{a.name}
</option>
))}
</select>
)}
<button
type="button"
onClick={handleStart}
disabled={allBusy}
data-testid={`start-agent-btn-${storyId}`}
title={allBusy ? "All agents are busy" : "Start a coder on this story"}
style={{
background: allBusy ? "#1a1a1a" : "#1a3a1a",
color: allBusy ? "#555" : "#3fb950",
border: `1px solid ${allBusy ? "#333" : "#2a5a2a"}`,
borderRadius: "5px",
padding: "2px 8px",
fontSize: "0.75em",
fontWeight: 600,
cursor: allBusy ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
Start
</button>
</div>
);
}
export function StagePanel({
title,
items,
emptyMessage = "Empty.",
onItemClick,
onStopAgent,
onDeleteItem,
costs,
agentRoster,
busyAgentNames,
onStartAgent,
}: StagePanelProps) {
const showStartButton =
Boolean(onStartAgent) &&
agentRoster !== undefined &&
agentRoster.length > 0;
return (
<div
style={{
border: "1px solid #333",
borderRadius: "10px",
padding: "12px 16px",
background: "#1f1f1f",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div style={{ fontWeight: 600 }}>{title}</div>
<div
style={{
fontSize: "0.85em",
color: "#aaa",
}}
>
{items.length}
</div>
</div>
{items.length === 0 ? (
<div style={{ fontSize: "0.85em", color: "#555" }}>{emptyMessage}</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "6px",
}}
>
{items.map((item) => {
const itemNumber = item.story_id.match(/^(\d+)/)?.[1];
const itemType = getWorkItemType(item.story_id);
const borderColor = TYPE_COLORS[itemType];
const typeLabel = TYPE_LABELS[itemType];
const hasMergeFailure = Boolean(item.merge_failure);
const cardStyle = {
border: hasMergeFailure
? "1px solid #6e1b1b"
: item.agent
? "1px solid #2a3a4a"
: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "8px 12px",
background: hasMergeFailure
? "#1f1010"
: item.agent
? "#161e2a"
: "#191919",
display: "flex",
flexDirection: "column" as const,
gap: "2px",
width: "100%",
textAlign: "left" as const,
color: "inherit",
font: "inherit",
cursor: onItemClick ? "pointer" : "default",
};
// Only offer "Start" when the item has no assigned agent
const canStart = showStartButton && !item.agent;
const cardInner = (
<>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: "0.9em" }}>
{hasMergeFailure && (
<span
data-testid={`merge-failure-icon-${item.story_id}`}
title="Merge failed"
style={{
color: "#f85149",
marginRight: "6px",
fontStyle: "normal",
}}
>
</span>
)}
{itemNumber && (
<span
style={{
color: "#777",
fontFamily: "monospace",
marginRight: "8px",
}}
>
#{itemNumber}
</span>
)}
{typeLabel && (
<span
data-testid={`type-badge-${item.story_id}`}
style={{
fontSize: "0.7em",
fontWeight: 700,
color: borderColor,
marginRight: "8px",
letterSpacing: "0.05em",
}}
>
{typeLabel}
</span>
)}
{costs?.has(item.story_id) && (
<span
data-testid={`cost-badge-${item.story_id}`}
style={{
fontSize: "0.65em",
fontWeight: 600,
color: "#e3b341",
marginRight: "8px",
}}
>
${costs.get(item.story_id)?.toFixed(2)}
</span>
)}
{item.name ?? item.story_id}
</div>
{item.error && (
<div
style={{
fontSize: "0.8em",
color: "#ff7b72",
marginTop: "4px",
}}
>
{item.error}
</div>
)}
{item.merge_failure && (
<div
data-testid={`merge-failure-reason-${item.story_id}`}
style={{
fontSize: "0.8em",
color: "#f85149",
marginTop: "4px",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{item.merge_failure}
</div>
)}
</div>
{item.agent && (
<AgentLozenge
agent={item.agent}
storyId={item.story_id}
onStop={
onStopAgent && item.agent.status === "running"
? () =>
onStopAgent(
item.story_id,
item.agent?.agent_name ?? "",
)
: undefined
}
/>
)}
{canStart && onStartAgent && (
<StartAgentControl
storyId={item.story_id}
agentRoster={agentRoster ?? []}
busyAgentNames={busyAgentNames ?? new Set()}
onStartAgent={onStartAgent}
/>
)}
</>
);
const card = onItemClick ? (
<button
type="button"
data-testid={`card-${item.story_id}`}
onClick={() => onItemClick(item)}
style={cardStyle}
>
{cardInner}
</button>
) : (
<div data-testid={`card-${item.story_id}`} style={cardStyle}>
{cardInner}
</div>
);
return (
<div
key={`${title}-${item.story_id}`}
style={{ position: "relative" }}
>
{card}
{onDeleteItem && (
<button
type="button"
data-testid={`delete-btn-${item.story_id}`}
title={`Delete ${item.name ?? item.story_id}`}
onClick={(e) => {
e.stopPropagation();
const label = item.name ?? item.story_id;
if (
window.confirm(
`Delete "${label}"? This cannot be undone.`,
)
) {
onDeleteItem(item);
}
}}
style={{
position: "absolute",
top: "4px",
right: "4px",
background: "transparent",
border: "none",
color: "#555",
cursor: "pointer",
fontSize: "0.85em",
lineHeight: 1,
padding: "2px 4px",
borderRadius: "4px",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.color =
"#f85149";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.color =
"#555";
}}
>
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
}
-440
View File
@@ -1,440 +0,0 @@
import * as React from "react";
import type { TokenUsageRecord } from "../api/client";
import { api } from "../api/client";
type SortKey =
| "timestamp"
| "story_id"
| "agent_name"
| "model"
| "total_cost_usd";
type SortDir = "asc" | "desc";
function formatCost(usd: number): string {
if (usd === 0) return "$0.00";
if (usd < 0.001) return `$${usd.toFixed(6)}`;
if (usd < 0.01) return `$${usd.toFixed(4)}`;
return `$${usd.toFixed(3)}`;
}
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
function formatTimestamp(iso: string): string {
const d = new Date(iso);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day} ${h}:${m}`;
}
/** Infer an agent type from the agent name. */
function agentType(agentName: string): string {
const lower = agentName.toLowerCase();
if (lower.startsWith("coder")) return "coder";
if (lower.startsWith("qa")) return "qa";
if (lower.startsWith("mergemaster") || lower.startsWith("merge"))
return "mergemaster";
return "other";
}
interface SortHeaderProps {
label: string;
sortKey: SortKey;
current: SortKey;
dir: SortDir;
onSort: (key: SortKey) => void;
align?: "left" | "right";
}
function SortHeader({
label,
sortKey,
current,
dir,
onSort,
align = "left",
}: SortHeaderProps) {
const active = current === sortKey;
return (
<th
style={{
padding: "8px 12px",
textAlign: align,
cursor: "pointer",
userSelect: "none",
borderBottom: "1px solid #333",
color: active ? "#ececec" : "#aaa",
fontWeight: active ? "700" : "500",
whiteSpace: "nowrap",
fontSize: "0.8em",
letterSpacing: "0.05em",
textTransform: "uppercase",
}}
onClick={() => onSort(sortKey)}
>
{label}
{active ? (dir === "asc" ? " ↑" : " ↓") : ""}
</th>
);
}
interface TokenUsagePageProps {
projectPath: string;
}
export function TokenUsagePage({
projectPath: _projectPath,
}: TokenUsagePageProps) {
const [records, setRecords] = React.useState<TokenUsageRecord[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [sortKey, setSortKey] = React.useState<SortKey>("timestamp");
const [sortDir, setSortDir] = React.useState<SortDir>("desc");
React.useEffect(() => {
setLoading(true);
setError(null);
api
.getAllTokenUsage()
.then((resp) => setRecords(resp.records))
.catch((e) =>
setError(e instanceof Error ? e.message : "Failed to load token usage"),
)
.finally(() => setLoading(false));
}, []);
function handleSort(key: SortKey) {
if (key === sortKey) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir(key === "timestamp" ? "desc" : "asc");
}
}
const sorted = React.useMemo(() => {
return [...records].sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case "timestamp":
cmp = a.timestamp.localeCompare(b.timestamp);
break;
case "story_id":
cmp = a.story_id.localeCompare(b.story_id);
break;
case "agent_name":
cmp = a.agent_name.localeCompare(b.agent_name);
break;
case "model":
cmp = (a.model ?? "").localeCompare(b.model ?? "");
break;
case "total_cost_usd":
cmp = a.total_cost_usd - b.total_cost_usd;
break;
}
return sortDir === "asc" ? cmp : -cmp;
});
}, [records, sortKey, sortDir]);
// Compute summary totals
const totalCost = records.reduce((s, r) => s + r.total_cost_usd, 0);
const byAgentType = React.useMemo(() => {
const map: Record<string, number> = {};
for (const r of records) {
const t = agentType(r.agent_name);
map[t] = (map[t] ?? 0) + r.total_cost_usd;
}
return map;
}, [records]);
const byModel = React.useMemo(() => {
const map: Record<string, number> = {};
for (const r of records) {
const m = r.model ?? "unknown";
map[m] = (map[m] ?? 0) + r.total_cost_usd;
}
return map;
}, [records]);
const cellStyle: React.CSSProperties = {
padding: "7px 12px",
borderBottom: "1px solid #222",
fontSize: "0.85em",
color: "#ccc",
whiteSpace: "nowrap",
};
return (
<div
style={{
height: "100%",
overflowY: "auto",
background: "#111",
padding: "24px",
fontFamily: "monospace",
}}
>
<h2
style={{
color: "#ececec",
margin: "0 0 20px",
fontSize: "1.1em",
fontWeight: "700",
letterSpacing: "0.04em",
}}
>
Token Usage
</h2>
{/* Summary totals */}
<div
style={{
display: "flex",
gap: "16px",
flexWrap: "wrap",
marginBottom: "24px",
}}
>
<SummaryCard
label="Total Cost"
value={formatCost(totalCost)}
highlight
/>
{Object.entries(byAgentType)
.sort(([a], [b]) => a.localeCompare(b))
.map(([type, cost]) => (
<SummaryCard
key={type}
label={`${type.charAt(0).toUpperCase()}${type.slice(1)}`}
value={formatCost(cost)}
/>
))}
{Object.entries(byModel)
.sort(([, a], [, b]) => b - a)
.map(([model, cost]) => (
<SummaryCard key={model} label={model} value={formatCost(cost)} />
))}
</div>
{loading && (
<p style={{ color: "#555", fontSize: "0.9em" }}>Loading...</p>
)}
{error && <p style={{ color: "#e05c5c", fontSize: "0.9em" }}>{error}</p>}
{!loading && !error && records.length === 0 && (
<p style={{ color: "#555", fontSize: "0.9em" }}>
No token usage records found.
</p>
)}
{!loading && !error && records.length > 0 && (
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.9em",
}}
>
<thead>
<tr style={{ background: "#1a1a1a" }}>
<SortHeader
label="Date"
sortKey="timestamp"
current={sortKey}
dir={sortDir}
onSort={handleSort}
/>
<SortHeader
label="Story"
sortKey="story_id"
current={sortKey}
dir={sortDir}
onSort={handleSort}
/>
<SortHeader
label="Agent"
sortKey="agent_name"
current={sortKey}
dir={sortDir}
onSort={handleSort}
/>
<SortHeader
label="Model"
sortKey="model"
current={sortKey}
dir={sortDir}
onSort={handleSort}
/>
<th
style={{
...cellStyle,
borderBottom: "1px solid #333",
textAlign: "right",
color: "#aaa",
fontSize: "0.8em",
letterSpacing: "0.05em",
textTransform: "uppercase",
fontWeight: "500",
}}
>
Input
</th>
<th
style={{
...cellStyle,
borderBottom: "1px solid #333",
textAlign: "right",
color: "#aaa",
fontSize: "0.8em",
letterSpacing: "0.05em",
textTransform: "uppercase",
fontWeight: "500",
}}
>
Cache+
</th>
<th
style={{
...cellStyle,
borderBottom: "1px solid #333",
textAlign: "right",
color: "#aaa",
fontSize: "0.8em",
letterSpacing: "0.05em",
textTransform: "uppercase",
fontWeight: "500",
}}
>
Cache
</th>
<th
style={{
...cellStyle,
borderBottom: "1px solid #333",
textAlign: "right",
color: "#aaa",
fontSize: "0.8em",
letterSpacing: "0.05em",
textTransform: "uppercase",
fontWeight: "500",
}}
>
Output
</th>
<SortHeader
label="Cost"
sortKey="total_cost_usd"
current={sortKey}
dir={sortDir}
onSort={handleSort}
align="right"
/>
</tr>
</thead>
<tbody>
{sorted.map((r, i) => (
<tr
key={`${r.story_id}-${r.agent_name}-${r.timestamp}`}
style={{ background: i % 2 === 0 ? "#111" : "#161616" }}
>
<td style={cellStyle}>{formatTimestamp(r.timestamp)}</td>
<td
style={{
...cellStyle,
color: "#8b9cf7",
maxWidth: "220px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{r.story_id}
</td>
<td style={{ ...cellStyle, color: "#7ec8a4" }}>
{r.agent_name}
</td>
<td style={{ ...cellStyle, color: "#c9a96e" }}>
{r.model ?? "—"}
</td>
<td style={{ ...cellStyle, textAlign: "right" }}>
{formatTokens(r.input_tokens)}
</td>
<td style={{ ...cellStyle, textAlign: "right" }}>
{formatTokens(r.cache_creation_input_tokens)}
</td>
<td style={{ ...cellStyle, textAlign: "right" }}>
{formatTokens(r.cache_read_input_tokens)}
</td>
<td style={{ ...cellStyle, textAlign: "right" }}>
{formatTokens(r.output_tokens)}
</td>
<td
style={{
...cellStyle,
textAlign: "right",
color: "#e08c5c",
fontWeight: "600",
}}
>
{formatCost(r.total_cost_usd)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function SummaryCard({
label,
value,
highlight = false,
}: {
label: string;
value: string;
highlight?: boolean;
}) {
return (
<div
style={{
background: highlight ? "#1e1e2e" : "#1a1a1a",
border: `1px solid ${highlight ? "#3a3a5a" : "#2a2a2a"}`,
borderRadius: "8px",
padding: "12px 16px",
minWidth: "120px",
}}
>
<div
style={{
fontSize: "0.7em",
color: "#666",
textTransform: "uppercase",
letterSpacing: "0.07em",
marginBottom: "4px",
}}
>
{label}
</div>
<div
style={{
fontSize: "1.1em",
fontWeight: "700",
color: highlight ? "#c9a96e" : "#ececec",
fontFamily: "monospace",
}}
>
{value}
</div>
</div>
);
}
@@ -1,761 +0,0 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentEvent, AgentInfo } from "../api/agents";
import type { TestResultsResponse, TokenCostResponse } from "../api/client";
vi.mock("../api/client", async () => {
const actual =
await vi.importActual<typeof import("../api/client")>("../api/client");
return {
...actual,
api: {
...actual.api,
getWorkItemContent: vi.fn(),
getTestResults: vi.fn(),
getTokenCost: vi.fn(),
},
};
});
vi.mock("../api/agents", () => ({
agentsApi: {
listAgents: vi.fn(),
getAgentConfig: vi.fn(),
stopAgent: vi.fn(),
startAgent: vi.fn(),
},
subscribeAgentStream: vi.fn(() => () => {}),
}));
import { agentsApi, subscribeAgentStream } from "../api/agents";
import { api } from "../api/client";
const { WorkItemDetailPanel } = await import("./WorkItemDetailPanel");
const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent);
const mockedGetTestResults = vi.mocked(api.getTestResults);
const mockedGetTokenCost = vi.mocked(api.getTokenCost);
const mockedListAgents = vi.mocked(agentsApi.listAgents);
const mockedGetAgentConfig = vi.mocked(agentsApi.getAgentConfig);
const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream);
const DEFAULT_CONTENT = {
content: "# Big Title\n\nSome content here.",
stage: "current",
name: "Big Title Story",
agent: null,
};
const sampleTestResults: TestResultsResponse = {
unit: [
{ name: "test_add", status: "pass", details: null },
{ name: "test_subtract", status: "fail", details: "expected 3, got 4" },
],
integration: [{ name: "test_api_endpoint", status: "pass", details: null }],
};
beforeEach(() => {
vi.clearAllMocks();
mockedGetWorkItemContent.mockResolvedValue(DEFAULT_CONTENT);
mockedGetTestResults.mockResolvedValue(null);
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
mockedListAgents.mockResolvedValue([]);
mockedGetAgentConfig.mockResolvedValue([]);
mockedSubscribeAgentStream.mockReturnValue(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("WorkItemDetailPanel", () => {
it("renders the story name in the header", async () => {
render(
<WorkItemDetailPanel
storyId="237_bug_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByTestId("detail-panel-title")).toHaveTextContent(
"Big Title Story",
);
});
});
it("shows loading state initially", () => {
render(
<WorkItemDetailPanel
storyId="237_bug_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
expect(screen.getByTestId("detail-panel-loading")).toBeInTheDocument();
});
it("calls onClose when close button is clicked", async () => {
const onClose = vi.fn();
render(
<WorkItemDetailPanel
storyId="237_bug_test"
pipelineVersion={0}
onClose={onClose}
/>,
);
const closeButton = screen.getByTestId("detail-panel-close");
closeButton.click();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("renders markdown headings with constrained inline font size", async () => {
render(
<WorkItemDetailPanel
storyId="237_bug_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await waitFor(() => {
const content = screen.getByTestId("detail-panel-content");
const h1 = content.querySelector("h1");
expect(h1).not.toBeNull();
expect(h1?.style.fontSize).toBeTruthy();
});
});
});
describe("WorkItemDetailPanel - Agent Logs", () => {
it("shows placeholder when no agent is assigned to the story", async () => {
render(
<WorkItemDetailPanel
storyId="42_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
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(
<WorkItemDetailPanel
storyId="42_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
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(
<WorkItemDetailPanel
storyId="42_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
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(
<WorkItemDetailPanel
storyId="42_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
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(
<WorkItemDetailPanel
storyId="42_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
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(
<WorkItemDetailPanel
storyId="42_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
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(
<WorkItemDetailPanel
storyId="42_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
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(
<WorkItemDetailPanel
storyId="42_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
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(
<WorkItemDetailPanel
storyId="42_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await screen.findByTestId("agent-logs-section");
expect(
screen.queryByTestId("placeholder-agent-logs"),
).not.toBeInTheDocument();
});
});
describe("WorkItemDetailPanel - Assigned Agent", () => {
it("shows assigned agent name when agent front matter field is set", async () => {
mockedGetWorkItemContent.mockResolvedValue({
...DEFAULT_CONTENT,
agent: "coder-opus",
});
render(
<WorkItemDetailPanel
storyId="271_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
const agentEl = await screen.findByTestId("detail-panel-assigned-agent");
expect(agentEl).toHaveTextContent("coder-opus");
});
it("omits assigned agent field when no agent is set in front matter", async () => {
render(
<WorkItemDetailPanel
storyId="271_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await screen.findByTestId("detail-panel-content");
expect(
screen.queryByTestId("detail-panel-assigned-agent"),
).not.toBeInTheDocument();
});
it("shows the specific agent name not just 'assigned'", async () => {
mockedGetWorkItemContent.mockResolvedValue({
...DEFAULT_CONTENT,
agent: "coder-haiku",
});
render(
<WorkItemDetailPanel
storyId="271_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
const agentEl = await screen.findByTestId("detail-panel-assigned-agent");
expect(agentEl).toHaveTextContent("coder-haiku");
expect(agentEl).not.toHaveTextContent("assigned");
});
});
describe("WorkItemDetailPanel - Test Results", () => {
it("shows empty test results message when no results exist", async () => {
mockedGetTestResults.mockResolvedValue(null);
render(
<WorkItemDetailPanel
storyId="42_story_foo"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByTestId("test-results-empty")).toBeInTheDocument();
});
expect(screen.getByText("No test results recorded")).toBeInTheDocument();
});
it("shows unit and integration test results when available", async () => {
mockedGetTestResults.mockResolvedValue(sampleTestResults);
render(
<WorkItemDetailPanel
storyId="42_story_foo"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByTestId("test-results-content")).toBeInTheDocument();
});
// Unit test section
expect(screen.getByTestId("test-section-unit")).toBeInTheDocument();
expect(
screen.getByText("Unit Tests (1 passed, 1 failed)"),
).toBeInTheDocument();
// Integration test section
expect(screen.getByTestId("test-section-integration")).toBeInTheDocument();
expect(
screen.getByText("Integration Tests (1 passed, 0 failed)"),
).toBeInTheDocument();
});
it("shows pass/fail status and details for each test", async () => {
mockedGetTestResults.mockResolvedValue(sampleTestResults);
render(
<WorkItemDetailPanel
storyId="42_story_foo"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByTestId("test-case-test_add")).toBeInTheDocument();
});
// Passing test
expect(screen.getByTestId("test-status-test_add")).toHaveTextContent(
"PASS",
);
expect(screen.getByText("test_add")).toBeInTheDocument();
// Failing test with details
expect(screen.getByTestId("test-status-test_subtract")).toHaveTextContent(
"FAIL",
);
expect(screen.getByText("test_subtract")).toBeInTheDocument();
expect(screen.getByTestId("test-details-test_subtract")).toHaveTextContent(
"expected 3, got 4",
);
// Integration test
expect(
screen.getByTestId("test-status-test_api_endpoint"),
).toHaveTextContent("PASS");
});
it("re-fetches test results when pipelineVersion changes", async () => {
mockedGetTestResults.mockResolvedValue(null);
const { rerender } = render(
<WorkItemDetailPanel
storyId="42_story_foo"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await waitFor(() => {
expect(mockedGetTestResults).toHaveBeenCalledTimes(1);
});
// Update with new results and bump pipelineVersion.
mockedGetTestResults.mockResolvedValue(sampleTestResults);
rerender(
<WorkItemDetailPanel
storyId="42_story_foo"
pipelineVersion={1}
onClose={() => {}}
/>,
);
await waitFor(() => {
expect(mockedGetTestResults).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(screen.getByTestId("test-results-content")).toBeInTheDocument();
});
});
});
describe("WorkItemDetailPanel - Token Cost", () => {
const sampleTokenCost: TokenCostResponse = {
total_cost_usd: 0.012345,
agents: [
{
agent_name: "coder-1",
model: "claude-sonnet-4-6",
input_tokens: 1000,
output_tokens: 500,
cache_creation_input_tokens: 200,
cache_read_input_tokens: 100,
total_cost_usd: 0.009,
},
{
agent_name: "coder-2",
model: null,
input_tokens: 800,
output_tokens: 300,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
total_cost_usd: 0.003345,
},
],
};
it("shows empty state when no token data exists", async () => {
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
render(
<WorkItemDetailPanel
storyId="42_story_foo"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByTestId("token-cost-empty")).toBeInTheDocument();
});
expect(screen.getByText("No token data recorded")).toBeInTheDocument();
});
it("shows per-agent breakdown and total cost when data exists", async () => {
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
render(
<WorkItemDetailPanel
storyId="42_story_foo"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByTestId("token-cost-content")).toBeInTheDocument();
});
expect(screen.getByTestId("token-cost-total")).toHaveTextContent(
"$0.012345",
);
expect(screen.getByTestId("token-cost-agent-coder-1")).toBeInTheDocument();
expect(screen.getByTestId("token-cost-agent-coder-2")).toBeInTheDocument();
});
it("shows agent name and model when model is present", async () => {
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
render(
<WorkItemDetailPanel
storyId="42_story_foo"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await waitFor(() => {
expect(
screen.getByTestId("token-cost-agent-coder-1"),
).toBeInTheDocument();
});
const agentRow = screen.getByTestId("token-cost-agent-coder-1");
expect(agentRow).toHaveTextContent("coder-1");
expect(agentRow).toHaveTextContent("claude-sonnet-4-6");
});
it("shows agent name without model when model is null", async () => {
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
render(
<WorkItemDetailPanel
storyId="42_story_foo"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await waitFor(() => {
expect(
screen.getByTestId("token-cost-agent-coder-2"),
).toBeInTheDocument();
});
const agentRow = screen.getByTestId("token-cost-agent-coder-2");
expect(agentRow).toHaveTextContent("coder-2");
expect(agentRow).not.toHaveTextContent("null");
});
it("re-fetches token cost when pipelineVersion changes", async () => {
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
const { rerender } = render(
<WorkItemDetailPanel
storyId="42_story_foo"
pipelineVersion={0}
onClose={() => {}}
/>,
);
await waitFor(() => {
expect(mockedGetTokenCost).toHaveBeenCalledTimes(1);
});
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
rerender(
<WorkItemDetailPanel
storyId="42_story_foo"
pipelineVersion={1}
onClose={() => {}}
/>,
);
await waitFor(() => {
expect(mockedGetTokenCost).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(screen.getByTestId("token-cost-content")).toBeInTheDocument();
});
});
});
@@ -1,787 +0,0 @@
import * as React from "react";
import Markdown from "react-markdown";
import type {
AgentConfigInfo,
AgentEvent,
AgentInfo,
AgentStatusValue,
} from "../api/agents";
import { agentsApi, subscribeAgentStream } from "../api/agents";
import type {
AgentCostEntry,
TestCaseResult,
TestResultsResponse,
TokenCostResponse,
} from "../api/client";
import { api } from "../api/client";
const { useCallback, useEffect, useRef, useState } = React;
const STAGE_LABELS: Record<string, string> = {
backlog: "Backlog",
current: "Current",
qa: "QA",
merge: "To Merge",
done: "Done",
archived: "Archived",
};
const STATUS_COLORS: Record<AgentStatusValue, string> = {
running: "#3fb950",
pending: "#e3b341",
completed: "#aaa",
failed: "#f85149",
};
interface WorkItemDetailPanelProps {
storyId: string;
pipelineVersion: number;
onClose: () => void;
/** True when the item is in QA and awaiting human review. */
reviewHold?: boolean;
}
function TestCaseRow({ tc }: { tc: TestCaseResult }) {
const isPassing = tc.status === "pass";
return (
<div
data-testid={`test-case-${tc.name}`}
style={{
display: "flex",
flexDirection: "column",
gap: "2px",
padding: "4px 0",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<span
data-testid={`test-status-${tc.name}`}
style={{
fontSize: "0.85em",
color: isPassing ? "#3fb950" : "#f85149",
}}
>
{isPassing ? "PASS" : "FAIL"}
</span>
<span style={{ fontSize: "0.82em", color: "#ccc" }}>{tc.name}</span>
</div>
{tc.details && (
<div
data-testid={`test-details-${tc.name}`}
style={{
fontSize: "0.75em",
color: "#888",
paddingLeft: "22px",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{tc.details}
</div>
)}
</div>
);
}
function TestSection({
title,
tests,
testId,
}: {
title: string;
tests: TestCaseResult[];
testId: string;
}) {
const passCount = tests.filter((t) => t.status === "pass").length;
const failCount = tests.length - passCount;
return (
<div data-testid={testId}>
<div
style={{
fontSize: "0.78em",
fontWeight: 600,
color: "#aaa",
marginBottom: "6px",
}}
>
{title} ({passCount} passed, {failCount} failed)
</div>
{tests.length === 0 ? (
<div style={{ fontSize: "0.75em", color: "#555", fontStyle: "italic" }}>
No tests recorded
</div>
) : (
tests.map((tc) => <TestCaseRow key={tc.name} tc={tc} />)
)}
</div>
);
}
export function WorkItemDetailPanel({
storyId,
pipelineVersion,
onClose,
reviewHold: _reviewHold,
}: WorkItemDetailPanelProps) {
const [content, setContent] = useState<string | null>(null);
const [stage, setStage] = useState<string>("");
const [name, setName] = useState<string | null>(null);
const [assignedAgent, setAssignedAgent] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [agentInfo, setAgentInfo] = useState<AgentInfo | null>(null);
const [agentLog, setAgentLog] = useState<string[]>([]);
const [agentStatus, setAgentStatus] = useState<AgentStatusValue | null>(null);
const [testResults, setTestResults] = useState<TestResultsResponse | null>(
null,
);
const [tokenCost, setTokenCost] = useState<TokenCostResponse | null>(null);
const [agentConfig, setAgentConfig] = useState<AgentConfigInfo[]>([]);
const [assigning, setAssigning] = useState(false);
const [assignError, setAssignError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const cleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
api
.getWorkItemContent(storyId)
.then((data) => {
setContent(data.content);
setStage(data.stage);
setName(data.name);
setAssignedAgent(data.agent);
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "Failed to load content");
})
.finally(() => {
setLoading(false);
});
}, [storyId]);
// Fetch test results on mount and when pipeline updates arrive.
useEffect(() => {
api
.getTestResults(storyId)
.then((data) => {
setTestResults(data);
})
.catch(() => {
// Silently ignore — test results may not exist yet.
});
}, [storyId, pipelineVersion]);
// Fetch token cost on mount and when pipeline updates arrive.
useEffect(() => {
api
.getTokenCost(storyId)
.then((data) => {
setTokenCost(data);
})
.catch(() => {
// Silently ignore — token cost may not exist yet.
});
}, [storyId, pipelineVersion]);
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") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
// Load agent config roster for the dropdown.
useEffect(() => {
agentsApi
.getAgentConfig()
.then((config) => {
setAgentConfig(config);
})
.catch((err: unknown) => {
console.error("Failed to load agent config:", err);
});
}, []);
// Map pipeline stage → agent stage filter.
const STAGE_TO_AGENT_STAGE: Record<string, string> = {
current: "coder",
qa: "qa",
merge: "mergemaster",
};
const filteredAgents = agentConfig.filter(
(a) => a.stage === STAGE_TO_AGENT_STAGE[stage],
);
// The currently active agent name for this story (running or pending).
const activeAgentName =
agentInfo && (agentStatus === "running" || agentStatus === "pending")
? agentInfo.agent_name
: null;
const handleAgentAssign = useCallback(
async (selectedAgentName: string) => {
setAssigning(true);
setAssignError(null);
try {
// Stop current running agent if there is one.
if (activeAgentName) {
await agentsApi.stopAgent(storyId, activeAgentName);
}
// Start the new agent (or skip if "none" selected).
if (selectedAgentName) {
await agentsApi.startAgent(storyId, selectedAgentName);
}
} catch (err: unknown) {
setAssignError(
err instanceof Error ? err.message : "Failed to assign agent",
);
} finally {
setAssigning(false);
}
},
[storyId, activeAgentName],
);
const stageLabel = STAGE_LABELS[stage] ?? stage;
const hasTestResults =
testResults &&
(testResults.unit.length > 0 || testResults.integration.length > 0);
return (
<div
data-testid="work-item-detail-panel"
ref={panelRef}
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
background: "#1a1a1a",
borderRadius: "8px",
border: "1px solid #333",
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
borderBottom: "1px solid #333",
flexShrink: 0,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "2px",
minWidth: 0,
}}
>
<div
data-testid="detail-panel-title"
style={{
fontWeight: 600,
fontSize: "0.95em",
color: "#ececec",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{name ?? storyId}
</div>
{stage && (
<div
data-testid="detail-panel-stage"
style={{ fontSize: "0.75em", color: "#888" }}
>
{stageLabel}
</div>
)}
{filteredAgents.length > 0 && (
<div
data-testid="detail-panel-agent-assignment"
style={{
display: "flex",
alignItems: "center",
gap: "6px",
marginTop: "4px",
}}
>
<span style={{ fontSize: "0.75em", color: "#666" }}>Agent:</span>
<select
data-testid="agent-assignment-dropdown"
disabled={assigning}
value={activeAgentName ?? assignedAgent ?? ""}
onChange={(e) => handleAgentAssign(e.target.value)}
style={{
background: "#1a1a1a",
border: "1px solid #444",
borderRadius: "4px",
color: "#ccc",
cursor: assigning ? "not-allowed" : "pointer",
fontSize: "0.75em",
padding: "2px 6px",
opacity: assigning ? 0.6 : 1,
}}
>
<option value=""> none </option>
{filteredAgents.map((a) => {
const isRunning =
agentInfo?.agent_name === a.name &&
agentStatus === "running";
const isPending =
agentInfo?.agent_name === a.name &&
agentStatus === "pending";
const statusLabel = isRunning
? " — running"
: isPending
? " — pending"
: " — idle";
const modelPart = a.model ? ` (${a.model})` : "";
return (
<option key={a.name} value={a.name}>
{a.name}
{modelPart}
{statusLabel}
</option>
);
})}
</select>
{assigning && (
<span style={{ fontSize: "0.7em", color: "#888" }}>
Assigning
</span>
)}
{assignError && (
<span
data-testid="agent-assignment-error"
style={{ fontSize: "0.7em", color: "#f85149" }}
>
{assignError}
</span>
)}
</div>
)}
{filteredAgents.length === 0 && assignedAgent ? (
<div
data-testid="detail-panel-assigned-agent"
style={{ fontSize: "0.75em", color: "#888" }}
>
Agent: {assignedAgent}
</div>
) : null}
</div>
<button
type="button"
data-testid="detail-panel-close"
onClick={onClose}
style={{
background: "none",
border: "1px solid #444",
borderRadius: "6px",
color: "#aaa",
cursor: "pointer",
padding: "4px 10px",
fontSize: "0.8em",
flexShrink: 0,
}}
>
Close
</button>
</div>
{/* Scrollable content area */}
<div
style={{
flex: 1,
overflowY: "auto",
padding: "16px",
display: "flex",
flexDirection: "column",
gap: "16px",
}}
>
{loading && (
<div
data-testid="detail-panel-loading"
style={{ color: "#666", fontSize: "0.85em" }}
>
Loading...
</div>
)}
{error && (
<div
data-testid="detail-panel-error"
style={{ color: "#ff7b72", fontSize: "0.85em" }}
>
{error}
</div>
)}
{!loading && !error && content !== null && (
<div
data-testid="detail-panel-content"
className="markdown-body"
style={{ fontSize: "0.9em", lineHeight: 1.6 }}
>
<Markdown
components={{
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
h1: ({ children }: any) => (
<h1 style={{ fontSize: "1.2em" }}>{children}</h1>
),
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
h2: ({ children }: any) => (
<h2 style={{ fontSize: "1.1em" }}>{children}</h2>
),
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
h3: ({ children }: any) => (
<h3 style={{ fontSize: "1em" }}>{children}</h3>
),
}}
>
{content}
</Markdown>
</div>
)}
{/* Token Cost section */}
<div
data-testid="token-cost-section"
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#555",
marginBottom: "8px",
}}
>
Token Cost
</div>
{tokenCost && tokenCost.agents.length > 0 ? (
<div data-testid="token-cost-content">
<div
style={{
fontSize: "0.75em",
color: "#888",
marginBottom: "8px",
}}
>
Total:{" "}
<span data-testid="token-cost-total" style={{ color: "#ccc" }}>
${tokenCost.total_cost_usd.toFixed(6)}
</span>
</div>
{tokenCost.agents.map((agent: AgentCostEntry) => (
<div
key={agent.agent_name}
data-testid={`token-cost-agent-${agent.agent_name}`}
style={{
fontSize: "0.75em",
color: "#888",
padding: "4px 0",
borderTop: "1px solid #222",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: "2px",
}}
>
<span style={{ color: "#ccc", fontWeight: 600 }}>
{agent.agent_name}
{agent.model ? (
<span
style={{ color: "#666", fontWeight: 400 }}
>{` (${agent.model})`}</span>
) : null}
</span>
<span style={{ color: "#aaa" }}>
${agent.total_cost_usd.toFixed(6)}
</span>
</div>
<div style={{ color: "#555" }}>
in {agent.input_tokens.toLocaleString()} / out{" "}
{agent.output_tokens.toLocaleString()}
{(agent.cache_creation_input_tokens > 0 ||
agent.cache_read_input_tokens > 0) && (
<>
{" "}
/ cache +
{agent.cache_creation_input_tokens.toLocaleString()}{" "}
read {agent.cache_read_input_tokens.toLocaleString()}
</>
)}
</div>
</div>
))}
</div>
) : (
<div
data-testid="token-cost-empty"
style={{ fontSize: "0.75em", color: "#444" }}
>
No token data recorded
</div>
)}
</div>
{/* Test Results section */}
<div
data-testid="test-results-section"
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#555",
marginBottom: "8px",
}}
>
Test Results
</div>
{hasTestResults ? (
<div
data-testid="test-results-content"
style={{
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<TestSection
title="Unit Tests"
tests={testResults.unit}
testId="test-section-unit"
/>
<TestSection
title="Integration Tests"
tests={testResults.integration}
testId="test-section-integration"
/>
</div>
) : (
<div
data-testid="test-results-empty"
style={{ fontSize: "0.75em", color: "#444" }}
>
No test results recorded
</div>
)}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
{/* Agent Logs section */}
{!agentInfo && (
<div
data-testid="placeholder-agent-logs"
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#555",
marginBottom: "4px",
}}
>
Agent Logs
</div>
<div style={{ fontSize: "0.75em", color: "#444" }}>
Coming soon
</div>
</div>
)}
{agentInfo && (
<div
data-testid="agent-logs-section"
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "6px",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#888",
}}
>
Agent Logs
</div>
{agentStatus && (
<div
data-testid="agent-status-badge"
style={{
fontSize: "0.7em",
color: STATUS_COLORS[agentStatus],
fontWeight: 600,
}}
>
{agentInfo.agent_name} {agentStatus}
</div>
)}
</div>
{agentLog.length > 0 ? (
<div
data-testid="agent-log-output"
style={{
fontSize: "0.75em",
fontFamily: "monospace",
color: "#ccc",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
lineHeight: "1.5",
maxHeight: "200px",
overflowY: "auto",
}}
>
{agentLog.join("")}
</div>
) : (
<div style={{ fontSize: "0.75em", color: "#444" }}>
{agentStatus === "running" || agentStatus === "pending"
? "Waiting for output..."
: "No output."}
</div>
)}
</div>
)}
{/* Placeholder sections for future content */}
{(
[{ id: "coverage", label: "Coverage" }] as {
id: string;
label: string;
}[]
).map(({ id, label }) => (
<div
key={id}
data-testid={`placeholder-${id}`}
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#555",
marginBottom: "4px",
}}
>
{label}
</div>
<div style={{ fontSize: "0.75em", color: "#444" }}>
Coming soon
</div>
</div>
))}
</div>
</div>
</div>
);
}
@@ -1,170 +0,0 @@
export interface ProjectPathMatch {
name: string;
path: string;
}
export interface ProjectPathInputProps {
value: string;
onChange: (value: string) => void;
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
suggestionTail: string;
matchList: ProjectPathMatch[];
selectedMatch: number;
onSelectMatch: (index: number) => void;
onAcceptMatch: (path: string) => void;
onCloseSuggestions: () => void;
currentPartial: string;
}
function renderHighlightedMatch(text: string, query: string) {
if (!query) return text;
let qIndex = 0;
const lowerQuery = query.toLowerCase();
const counts = new Map<string, number>();
return text.split("").map((char) => {
const isMatch =
qIndex < lowerQuery.length && char.toLowerCase() === lowerQuery[qIndex];
if (isMatch) {
qIndex += 1;
}
const count = counts.get(char) ?? 0;
counts.set(char, count + 1);
return (
<span
key={`${char}-${count}`}
style={isMatch ? { fontWeight: 600, color: "#222" } : undefined}
>
{char}
</span>
);
});
}
export function ProjectPathInput({
value,
onChange,
onKeyDown,
suggestionTail,
matchList,
selectedMatch,
onSelectMatch,
onAcceptMatch,
onCloseSuggestions,
currentPartial,
}: ProjectPathInputProps) {
return (
<div
style={{
position: "relative",
marginTop: "12px",
marginBottom: "170px",
}}
>
<div
style={{
position: "absolute",
inset: 0,
padding: "10px",
color: "#aaa",
fontFamily: "monospace",
whiteSpace: "pre",
overflow: "hidden",
textOverflow: "ellipsis",
pointerEvents: "none",
}}
>
{value}
{suggestionTail}
</div>
<input
type="text"
value={value}
placeholder="/path/to/project"
onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown}
style={{
width: "100%",
padding: "10px",
fontFamily: "monospace",
background: "transparent",
position: "relative",
zIndex: 1,
}}
/>
{matchList.length > 0 && (
<div
style={{
position: "absolute",
top: "100%",
left: 0,
right: 0,
marginTop: "6px",
border: "1px solid #ddd",
borderRadius: "6px",
overflow: "hidden",
background: "#fff",
fontFamily: "monospace",
height: "160px",
overflowY: "auto",
boxSizing: "border-box",
zIndex: 2,
}}
>
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
padding: "4px 6px",
borderBottom: "1px solid #eee",
background: "#fafafa",
}}
>
<button
type="button"
aria-label="Close suggestions"
onClick={onCloseSuggestions}
style={{
width: "24px",
height: "24px",
borderRadius: "4px",
border: "1px solid #ddd",
background: "#fff",
cursor: "pointer",
lineHeight: 1,
}}
>
×
</button>
</div>
{matchList.map((match, index) => {
const isSelected = index === selectedMatch;
return (
<button
key={match.path}
type="button"
onMouseEnter={() => onSelectMatch(index)}
onMouseDown={(event) => {
event.preventDefault();
onSelectMatch(index);
onAcceptMatch(match.path);
}}
style={{
width: "100%",
textAlign: "left",
padding: "6px 8px",
border: "none",
background: isSelected ? "#f0f0f0" : "transparent",
cursor: "pointer",
fontFamily: "inherit",
}}
>
{renderHighlightedMatch(match.name, currentPartial)}/
</button>
);
})}
</div>
)}
</div>
);
}
@@ -1,66 +0,0 @@
export interface RecentProjectsListProps {
projects: string[];
onOpenProject: (path: string) => void;
onForgetProject: (path: string) => void;
}
export function RecentProjectsList({
projects,
onOpenProject,
onForgetProject,
}: RecentProjectsListProps) {
return (
<div style={{ marginTop: "12px" }}>
<div style={{ fontSize: "0.9em", color: "#666" }}>Recent projects</div>
<ul style={{ listStyle: "none", padding: 0, margin: "8px 0 0" }}>
{projects.map((project) => {
const displayName =
project.split("/").filter(Boolean).pop() ?? project;
return (
<li key={project} style={{ marginBottom: "6px" }}>
<div
style={{ display: "flex", gap: "6px", alignItems: "center" }}
>
<button
type="button"
onClick={() => onOpenProject(project)}
style={{
flex: 1,
textAlign: "left",
padding: "8px 10px",
borderRadius: "6px",
border: "1px solid #ddd",
background: "#f7f7f7",
cursor: "pointer",
fontFamily: "monospace",
fontSize: "0.9em",
}}
title={project}
>
{displayName}
</button>
<button
type="button"
aria-label={`Forget ${displayName}`}
onClick={() => onForgetProject(project)}
style={{
width: "32px",
height: "32px",
borderRadius: "6px",
border: "1px solid #ddd",
background: "#fff",
cursor: "pointer",
fontSize: "1.1em",
lineHeight: 1,
}}
>
×
</button>
</div>
</li>
);
})}
</ul>
</div>
);
}
@@ -1,136 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { KeyboardEvent } from "react";
import { describe, expect, it, vi } from "vitest";
import type { SelectionScreenProps } from "./SelectionScreen";
import { SelectionScreen } from "./SelectionScreen";
function makeProps(
overrides: Partial<SelectionScreenProps> = {},
): SelectionScreenProps {
return {
knownProjects: [],
onOpenProject: vi.fn(),
onForgetProject: vi.fn(),
pathInput: "",
homeDir: null,
onPathInputChange: vi.fn(),
onPathInputKeyDown: vi.fn() as (
event: KeyboardEvent<HTMLInputElement>,
) => void,
isOpening: false,
suggestionTail: "",
matchList: [],
selectedMatch: -1,
onSelectMatch: vi.fn(),
onAcceptMatch: vi.fn(),
onCloseSuggestions: vi.fn(),
completionError: null,
currentPartial: "",
...overrides,
};
}
describe("SelectionScreen", () => {
it("renders the title and description", () => {
render(<SelectionScreen {...makeProps()} />);
expect(screen.getByText("Storkit")).toBeInTheDocument();
expect(
screen.getByText("Paste or complete a project path to start."),
).toBeInTheDocument();
});
it("renders recent projects list when knownProjects is non-empty", () => {
render(
<SelectionScreen
{...makeProps({ knownProjects: ["/Users/test/project"] })}
/>,
);
expect(screen.getByText("Recent projects")).toBeInTheDocument();
});
it("does not render recent projects list when knownProjects is empty", () => {
render(<SelectionScreen {...makeProps({ knownProjects: [] })} />);
expect(screen.queryByText("Recent projects")).not.toBeInTheDocument();
});
it("calls onOpenProject when Open Project button is clicked", () => {
const onOpenProject = vi.fn();
render(
<SelectionScreen
{...makeProps({ pathInput: "/my/path", onOpenProject })}
/>,
);
fireEvent.click(screen.getByText("Open Project"));
expect(onOpenProject).toHaveBeenCalledWith("/my/path");
});
it("shows Opening... text and disables buttons when isOpening is true", () => {
render(<SelectionScreen {...makeProps({ isOpening: true })} />);
expect(screen.getByText("Opening...")).toBeInTheDocument();
const buttons = screen.getAllByRole("button");
for (const button of buttons) {
if (
button.textContent === "Opening..." ||
button.textContent === "New Project"
) {
expect(button).toBeDisabled();
}
}
});
it("displays completion error when completionError is provided", () => {
render(
<SelectionScreen {...makeProps({ completionError: "Path not found" })} />,
);
expect(screen.getByText("Path not found")).toBeInTheDocument();
});
it("does not display error div when completionError is null", () => {
const { container } = render(<SelectionScreen {...makeProps()} />);
const errorDiv = container.querySelector('[style*="color: red"]');
expect(errorDiv).toBeNull();
});
it("New Project button calls onPathInputChange with homeDir (trailing slash appended)", () => {
const onPathInputChange = vi.fn();
const onCloseSuggestions = vi.fn();
render(
<SelectionScreen
{...makeProps({
homeDir: "/Users/test",
onPathInputChange,
onCloseSuggestions,
})}
/>,
);
fireEvent.click(screen.getByText("New Project"));
expect(onPathInputChange).toHaveBeenCalledWith("/Users/test/");
expect(onCloseSuggestions).toHaveBeenCalled();
});
it("New Project button uses homeDir as-is when it already ends with /", () => {
const onPathInputChange = vi.fn();
const onCloseSuggestions = vi.fn();
render(
<SelectionScreen
{...makeProps({
homeDir: "/Users/test/",
onPathInputChange,
onCloseSuggestions,
})}
/>,
);
fireEvent.click(screen.getByText("New Project"));
expect(onPathInputChange).toHaveBeenCalledWith("/Users/test/");
expect(onCloseSuggestions).toHaveBeenCalled();
});
it("New Project button uses empty string when homeDir is null", () => {
const onPathInputChange = vi.fn();
render(
<SelectionScreen {...makeProps({ homeDir: null, onPathInputChange })} />,
);
fireEvent.click(screen.getByText("New Project"));
expect(onPathInputChange).toHaveBeenCalledWith("");
});
});
@@ -1,116 +0,0 @@
import type { KeyboardEvent } from "react";
import { ProjectPathInput } from "./ProjectPathInput.tsx";
import { RecentProjectsList } from "./RecentProjectsList.tsx";
export interface RecentProjectMatch {
name: string;
path: string;
}
export interface SelectionScreenProps {
knownProjects: string[];
onOpenProject: (path: string) => void;
onForgetProject: (path: string) => void;
pathInput: string;
homeDir?: string | null;
onPathInputChange: (value: string) => void;
onPathInputKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
isOpening: boolean;
suggestionTail: string;
matchList: RecentProjectMatch[];
selectedMatch: number;
onSelectMatch: (index: number) => void;
onAcceptMatch: (path: string) => void;
onCloseSuggestions: () => void;
completionError: string | null;
currentPartial: string;
}
export function SelectionScreen({
knownProjects,
onOpenProject,
onForgetProject,
pathInput,
homeDir,
onPathInputChange,
onPathInputKeyDown,
isOpening,
suggestionTail,
matchList,
selectedMatch,
onSelectMatch,
onAcceptMatch,
onCloseSuggestions,
completionError,
currentPartial,
}: SelectionScreenProps) {
const resolvedHomeDir = homeDir
? homeDir.endsWith("/")
? homeDir
: `${homeDir}/`
: "";
return (
<div
className="selection-screen"
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
>
<h1>Storkit</h1>
<p>Paste or complete a project path to start.</p>
{knownProjects.length > 0 && (
<RecentProjectsList
projects={knownProjects}
onOpenProject={onOpenProject}
onForgetProject={onForgetProject}
/>
)}
<ProjectPathInput
value={pathInput}
onChange={onPathInputChange}
onKeyDown={onPathInputKeyDown}
suggestionTail={suggestionTail}
matchList={matchList}
selectedMatch={selectedMatch}
onSelectMatch={onSelectMatch}
onAcceptMatch={onAcceptMatch}
onCloseSuggestions={onCloseSuggestions}
currentPartial={currentPartial}
/>
<div
style={{
display: "flex",
gap: "8px",
marginTop: "8px",
alignItems: "center",
}}
>
<button
type="button"
onClick={() => onOpenProject(pathInput)}
disabled={isOpening}
>
{isOpening ? "Opening..." : "Open Project"}
</button>
<button
type="button"
onClick={() => {
onPathInputChange(resolvedHomeDir);
onCloseSuggestions();
}}
disabled={isOpening}
>
New Project
</button>
<div style={{ fontSize: "0.85em", color: "#666" }}>
Press Tab to complete the next path segment
</div>
</div>
{completionError && (
<div style={{ color: "red", marginTop: "8px" }}>{completionError}</div>
)}
</div>
);
}
@@ -1,461 +0,0 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FileEntry } from "./usePathCompletion";
import {
getCurrentPartial,
isFuzzyMatch,
usePathCompletion,
} from "./usePathCompletion";
describe("isFuzzyMatch", () => {
it("matches when query is empty", () => {
expect(isFuzzyMatch("anything", "")).toBe(true);
});
it("matches exact prefix", () => {
expect(isFuzzyMatch("Documents", "Doc")).toBe(true);
});
it("matches fuzzy subsequence", () => {
expect(isFuzzyMatch("Documents", "dms")).toBe(true);
});
it("is case insensitive", () => {
expect(isFuzzyMatch("Documents", "DOCU")).toBe(true);
});
it("rejects when chars not found in order", () => {
expect(isFuzzyMatch("abc", "acb")).toBe(false);
});
it("rejects completely unrelated", () => {
expect(isFuzzyMatch("hello", "xyz")).toBe(false);
});
});
describe("getCurrentPartial", () => {
it("returns empty for empty input", () => {
expect(getCurrentPartial("")).toBe("");
});
it("returns empty when input ends with slash", () => {
expect(getCurrentPartial("/home/user/")).toBe("");
});
it("returns last segment", () => {
expect(getCurrentPartial("/home/user/Doc")).toBe("Doc");
});
it("returns full input when no slash", () => {
expect(getCurrentPartial("Doc")).toBe("Doc");
});
it("trims then evaluates: trailing-slash input returns empty", () => {
// " /home/user/ " trims to "/home/user/" which ends with slash
expect(getCurrentPartial(" /home/user/ ")).toBe("");
});
it("trims then returns last segment", () => {
expect(getCurrentPartial(" /home/user/Doc ")).toBe("Doc");
});
});
describe("usePathCompletion hook", () => {
const mockListDir = vi.fn<(path: string) => Promise<FileEntry[]>>();
beforeEach(() => {
mockListDir.mockReset();
});
it("returns empty matchList for empty input", async () => {
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
// Allow effect + setTimeout(0) to fire
await waitFor(() => {
expect(mockListDir).not.toHaveBeenCalled();
});
expect(result.current.matchList).toEqual([]);
});
it("fetches directory listing and returns matches", async () => {
mockListDir.mockResolvedValue([
{ name: "Documents", kind: "dir" },
{ name: "Downloads", kind: "dir" },
{ name: ".bashrc", kind: "file" },
]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/user/",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(2);
});
expect(result.current.matchList[0].name).toBe("Documents");
expect(result.current.matchList[1].name).toBe("Downloads");
expect(result.current.matchList.every((m) => m.path.endsWith("/"))).toBe(
true,
);
});
it("filters by fuzzy match on partial input", async () => {
mockListDir.mockResolvedValue([
{ name: "Documents", kind: "dir" },
{ name: "Downloads", kind: "dir" },
{ name: "Desktop", kind: "dir" },
]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/user/Doc",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(1);
});
expect(result.current.matchList[0].name).toBe("Documents");
});
it("calls setPathInput when acceptMatch is invoked", () => {
const setPathInput = vi.fn();
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/",
setPathInput,
homeDir: "/home",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
act(() => {
result.current.acceptMatch("/home/user/Documents/");
});
expect(setPathInput).toHaveBeenCalledWith("/home/user/Documents/");
});
it("uses homeDir when input has no slash (bare partial)", async () => {
mockListDir.mockResolvedValue([
{ name: "Documents", kind: "dir" },
{ name: "Downloads", kind: "dir" },
]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "Doc",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(1);
});
expect(mockListDir).toHaveBeenCalledWith("/home/user");
expect(result.current.matchList[0].name).toBe("Documents");
expect(result.current.matchList[0].path).toBe("/home/user/Documents/");
});
it("returns early when input has no slash and homeDir is null", async () => {
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "Doc",
setPathInput: vi.fn(),
homeDir: null,
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
// Wait for debounce + effect to fire
await waitFor(() => {
expect(result.current.matchList).toEqual([]);
});
expect(mockListDir).not.toHaveBeenCalled();
});
it("returns empty matchList when no dirs match the fuzzy filter", async () => {
mockListDir.mockResolvedValue([
{ name: "Documents", kind: "dir" },
{ name: "Downloads", kind: "dir" },
]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/user/zzz",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(mockListDir).toHaveBeenCalled();
});
// No dirs match "zzz" fuzzy filter, so matchList stays empty
expect(result.current.matchList).toEqual([]);
});
it("sets completionError when listDirectoryAbsolute throws an Error", async () => {
mockListDir.mockRejectedValue(new Error("Permission denied"));
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/root/",
setPathInput: vi.fn(),
homeDir: null,
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.completionError).toBe("Permission denied");
});
});
it("sets generic completionError when listDirectoryAbsolute throws a non-Error", async () => {
mockListDir.mockRejectedValue("some string error");
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/root/",
setPathInput: vi.fn(),
homeDir: null,
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.completionError).toBe(
"Failed to compute suggestion.",
);
});
});
it("clears suggestionTail when selected match path does not start with input", async () => {
mockListDir.mockResolvedValue([{ name: "Documents", kind: "dir" }]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "Doc",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
// Wait for matches to load (path will be /home/user/Documents/)
await waitFor(() => {
expect(result.current.matchList.length).toBe(1);
});
// The match path is "/home/user/Documents/" which does NOT start with "Doc"
// so suggestionTail should be ""
expect(result.current.suggestionTail).toBe("");
});
it("acceptSelectedMatch calls setPathInput with the selected match path", async () => {
mockListDir.mockResolvedValue([
{ name: "Documents", kind: "dir" },
{ name: "Downloads", kind: "dir" },
]);
const setPathInput = vi.fn();
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/user/",
setPathInput,
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(2);
});
act(() => {
result.current.acceptSelectedMatch();
});
expect(setPathInput).toHaveBeenCalledWith("/home/user/Documents/");
});
it("acceptSelectedMatch does nothing when matchList is empty", () => {
const setPathInput = vi.fn();
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "",
setPathInput,
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
act(() => {
result.current.acceptSelectedMatch();
});
expect(setPathInput).not.toHaveBeenCalled();
});
it("closeSuggestions clears matchList, selectedMatch, suggestionTail, and completionError", async () => {
mockListDir.mockResolvedValue([{ name: "Documents", kind: "dir" }]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/user/",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(1);
});
act(() => {
result.current.closeSuggestions();
});
expect(result.current.matchList).toEqual([]);
expect(result.current.selectedMatch).toBe(0);
expect(result.current.suggestionTail).toBe("");
expect(result.current.completionError).toBeNull();
});
it("uses homeDir with trailing slash as-is", async () => {
mockListDir.mockResolvedValue([{ name: "Projects", kind: "dir" }]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "Pro",
setPathInput: vi.fn(),
homeDir: "/home/user/",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(1);
});
expect(mockListDir).toHaveBeenCalledWith("/home/user");
expect(result.current.matchList[0].path).toBe("/home/user/Projects/");
});
it("handles root directory listing (dir = '/')", async () => {
mockListDir.mockResolvedValue([
{ name: "home", kind: "dir" },
{ name: "etc", kind: "dir" },
]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/",
setPathInput: vi.fn(),
homeDir: null,
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(2);
});
expect(mockListDir).toHaveBeenCalledWith("/");
expect(result.current.matchList[0].name).toBe("etc");
expect(result.current.matchList[1].name).toBe("home");
});
it("computes suggestionTail when match path starts with trimmed input", async () => {
mockListDir.mockResolvedValue([{ name: "Documents", kind: "dir" }]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/user/",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(1);
});
// path is "/home/user/Documents/" and input is "/home/user/"
// so tail should be "Documents/"
expect(result.current.suggestionTail).toBe("Documents/");
});
it("setSelectedMatch updates the selected index", async () => {
mockListDir.mockResolvedValue([
{ name: "Documents", kind: "dir" },
{ name: "Downloads", kind: "dir" },
]);
const { result } = renderHook(() =>
usePathCompletion({
pathInput: "/home/user/",
setPathInput: vi.fn(),
homeDir: "/home/user",
listDirectoryAbsolute: mockListDir,
debounceMs: 0,
}),
);
await waitFor(() => {
expect(result.current.matchList.length).toBe(2);
});
act(() => {
result.current.setSelectedMatch(1);
});
expect(result.current.selectedMatch).toBe(1);
// After selecting index 1, suggestionTail should reflect "Downloads/"
expect(result.current.suggestionTail).toBe("Downloads/");
});
});
@@ -1,192 +0,0 @@
import * as React from "react";
export interface FileEntry {
name: string;
kind: "file" | "dir";
}
export interface ProjectPathMatch {
name: string;
path: string;
}
export interface UsePathCompletionArgs {
pathInput: string;
setPathInput: (value: string) => void;
homeDir: string | null;
listDirectoryAbsolute: (path: string) => Promise<FileEntry[]>;
debounceMs?: number;
}
export interface UsePathCompletionResult {
matchList: ProjectPathMatch[];
selectedMatch: number;
suggestionTail: string;
completionError: string | null;
currentPartial: string;
setSelectedMatch: (index: number) => void;
acceptSelectedMatch: () => void;
acceptMatch: (path: string) => void;
closeSuggestions: () => void;
}
export function isFuzzyMatch(candidate: string, query: string) {
if (!query) return true;
const lowerCandidate = candidate.toLowerCase();
const lowerQuery = query.toLowerCase();
let idx = 0;
for (const char of lowerQuery) {
idx = lowerCandidate.indexOf(char, idx);
if (idx === -1) return false;
idx += 1;
}
return true;
}
export function getCurrentPartial(input: string) {
const trimmed = input.trim();
if (!trimmed) return "";
if (trimmed.endsWith("/")) return "";
const idx = trimmed.lastIndexOf("/");
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed;
}
export function usePathCompletion({
pathInput,
setPathInput,
homeDir,
listDirectoryAbsolute,
debounceMs = 60,
}: UsePathCompletionArgs): UsePathCompletionResult {
const [matchList, setMatchList] = React.useState<ProjectPathMatch[]>([]);
const [selectedMatch, setSelectedMatch] = React.useState(0);
const [suggestionTail, setSuggestionTail] = React.useState("");
const [completionError, setCompletionError] = React.useState<string | null>(
null,
);
React.useEffect(() => {
let active = true;
async function computeSuggestion() {
setCompletionError(null);
setSuggestionTail("");
setMatchList([]);
setSelectedMatch(0);
const trimmed = pathInput.trim();
if (!trimmed) {
return;
}
const endsWithSlash = trimmed.endsWith("/");
let dir = trimmed;
let partial = "";
if (!endsWithSlash) {
const idx = trimmed.lastIndexOf("/");
if (idx >= 0) {
dir = trimmed.slice(0, idx + 1);
partial = trimmed.slice(idx + 1);
} else {
dir = "";
partial = trimmed;
}
}
if (!dir) {
if (homeDir) {
dir = homeDir.endsWith("/") ? homeDir : `${homeDir}/`;
} else {
return;
}
}
const dirForListing = dir === "/" ? "/" : dir.replace(/\/+$/, "");
const entries = await listDirectoryAbsolute(dirForListing);
if (!active) return;
const matches = entries
.filter((entry) => entry.kind === "dir")
.filter((entry) => isFuzzyMatch(entry.name, partial))
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 8);
if (matches.length === 0) {
return;
}
const basePrefix = dir.endsWith("/") ? dir : `${dir}/`;
const list = matches.map((entry) => ({
name: entry.name,
path: `${basePrefix}${entry.name}/`,
}));
setMatchList(list);
}
const debounceId = window.setTimeout(() => {
computeSuggestion().catch((error) => {
console.error(error);
if (!active) return;
setCompletionError(
error instanceof Error
? error.message
: "Failed to compute suggestion.",
);
});
}, debounceMs);
return () => {
active = false;
window.clearTimeout(debounceId);
};
}, [pathInput, homeDir, listDirectoryAbsolute, debounceMs]);
React.useEffect(() => {
if (matchList.length === 0) {
setSuggestionTail("");
return;
}
const index = Math.min(selectedMatch, matchList.length - 1);
const next = matchList[index];
const trimmed = pathInput.trim();
if (next.path.startsWith(trimmed)) {
setSuggestionTail(next.path.slice(trimmed.length));
} else {
setSuggestionTail("");
}
}, [matchList, selectedMatch, pathInput]);
const acceptMatch = React.useCallback(
(path: string) => {
setPathInput(path);
},
[setPathInput],
);
const acceptSelectedMatch = React.useCallback(() => {
const next = matchList[selectedMatch]?.path;
if (next) {
setPathInput(next);
}
}, [matchList, selectedMatch, setPathInput]);
const closeSuggestions = React.useCallback(() => {
setMatchList([]);
setSelectedMatch(0);
setSuggestionTail("");
setCompletionError(null);
}, []);
return {
matchList,
selectedMatch,
suggestionTail,
completionError,
currentPartial: getCurrentPartial(pathInput),
setSelectedMatch,
acceptSelectedMatch,
acceptMatch,
closeSuggestions,
};
}