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

14
frontend/.gitignore vendored
View File

@@ -1,14 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
coverage
*.local

View File

@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Storkit</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +0,0 @@
{
"name": "living-spec-standalone",
"private": true,
"version": "0.4.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"server": "cargo run --manifest-path server/Cargo.toml",
"test": "vitest run",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@types/react-syntax-highlighter": "^15.5.13",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.2",
"@playwright/test": "^1.47.2",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.4.3",
"@types/node": "^25.0.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "^2.1.9",
"jest": "^29.0.0",
"jsdom": "^28.1.0",
"ts-jest": "^29.0.0",
"typescript": "~5.8.3",
"vite": "^5.4.21",
"vitest": "^2.1.4"
}
}

View File

@@ -1,27 +0,0 @@
import { defineConfig } from "@playwright/test";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const configDir = dirname(fileURLToPath(new URL(import.meta.url)));
const frontendRoot = resolve(configDir, ".");
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
timeout: 30_000,
expect: {
timeout: 5_000,
},
use: {
baseURL: "http://127.0.0.1:41700",
trace: "on-first-retry",
},
webServer: {
command:
"pnpm exec vite --config vite.config.ts --host 127.0.0.1 --port 41700 --strictPort",
url: "http://127.0.0.1:41700/@vite/client",
reuseExistingServer: true,
timeout: 120_000,
cwd: frontendRoot,
},
});

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,238 +0,0 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
padding-top: 0;
height: 100vh;
overflow: hidden;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
}
/* Collapsible tool output styling */
details summary {
cursor: pointer;
user-select: none;
}
details summary::-webkit-details-marker {
display: none;
}
details[open] summary span:first-child {
transform: rotate(90deg);
display: inline-block;
transition: transform 0.2s ease;
}
details summary span:first-child {
transition: transform 0.2s ease;
}
/* Markdown body styling for dark theme */
.markdown-body {
color: #ececec;
text-align: left;
}
.markdown-body code {
background: #2f2f2f;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
}
.markdown-body pre {
background: #1a1a1a;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
text-align: left;
}
.markdown-body pre code {
background: transparent;
padding: 0;
}
/* Syntax highlighter styling */
.markdown-body div[class*="language-"] {
margin: 0;
border-radius: 6px;
text-align: left;
}
.markdown-body pre[class*="language-"] {
margin: 0;
padding: 12px;
background: #1a1a1a;
text-align: left;
}
/* Hide scroll bars globally while maintaining scroll functionality */
/* Firefox */
* {
scrollbar-width: none;
}
/* Chrome, Safari, Edge */
*::-webkit-scrollbar {
display: none;
}
/* Ensure scroll functionality is maintained */
html,
body,
#root {
height: 100%;
margin: 0;
overflow: hidden;
}
/* Agent activity indicator pulse */
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(0.85);
}
}
/* Agent lozenge appearance animation (simulates arriving from agents panel) */
@keyframes agentAppear {
from {
opacity: 0;
transform: translateY(-4px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Thinking/loading pulse for text */
.pulse {
animation: pulse 1.5s infinite;
}
/* Agent entry fade-out for completed/failed agents */
@keyframes agentFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@@ -1,366 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { api } from "./api/client";
vi.mock("./api/client", () => {
const api = {
getCurrentProject: vi.fn(),
getKnownProjects: vi.fn(),
getHomeDirectory: vi.fn(),
openProject: vi.fn(),
closeProject: vi.fn(),
forgetKnownProject: vi.fn(),
listDirectoryAbsolute: vi.fn(),
getOllamaModels: vi.fn(),
getAnthropicApiKeyExists: vi.fn(),
getAnthropicModels: vi.fn(),
getModelPreference: vi.fn(),
setModelPreference: vi.fn(),
cancelChat: vi.fn(),
setAnthropicApiKey: vi.fn(),
};
class ChatWebSocket {
connect() {}
close() {}
sendChat() {}
cancel() {}
}
return { api, ChatWebSocket };
});
vi.mock("./api/workflow", () => {
return {
workflowApi: {
getAcceptance: vi.fn().mockResolvedValue({
can_accept: false,
reasons: [],
warning: null,
summary: { total: 0, passed: 0, failed: 0 },
missing_categories: [],
}),
getReviewQueueAll: vi.fn().mockResolvedValue({ stories: [] }),
getUpcomingStories: vi.fn().mockResolvedValue({ stories: [] }),
recordTests: vi.fn(),
ensureAcceptance: vi.fn(),
getReviewQueue: vi.fn(),
collectCoverage: vi.fn(),
recordCoverage: vi.fn(),
getStoryTodos: vi.fn().mockResolvedValue({ stories: [] }),
},
};
});
const mockedApi = vi.mocked(api);
describe("App", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockedApi.getCurrentProject.mockResolvedValue(null);
mockedApi.getKnownProjects.mockResolvedValue([]);
mockedApi.getHomeDirectory.mockResolvedValue("/home/user");
mockedApi.listDirectoryAbsolute.mockResolvedValue([]);
mockedApi.getOllamaModels.mockResolvedValue([]);
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
mockedApi.getAnthropicModels.mockResolvedValue([]);
mockedApi.getModelPreference.mockResolvedValue(null);
});
async function renderApp() {
const { default: App } = await import("./App");
return render(<App />);
}
it("calls getCurrentProject() on mount", async () => {
await renderApp();
await waitFor(() => {
expect(mockedApi.getCurrentProject).toHaveBeenCalledTimes(1);
});
});
it("skips selection screen and shows workspace when server already has a project open", async () => {
mockedApi.getCurrentProject.mockResolvedValue("/home/user/myproject");
await renderApp();
await waitFor(() => {
expect(
screen.queryByPlaceholderText(/\/path\/to\/project/i),
).not.toBeInTheDocument();
});
});
it("renders the selection screen when no project is open", async () => {
await renderApp();
await waitFor(() => {
expect(
screen.getByPlaceholderText(/\/path\/to\/project/i),
).toBeInTheDocument();
});
});
it("populates path input with home directory", async () => {
mockedApi.getHomeDirectory.mockResolvedValue("/Users/dave");
await renderApp();
await waitFor(() => {
const input = screen.getByPlaceholderText(
/\/path\/to\/project/i,
) as HTMLInputElement;
expect(input.value).toBe("/Users/dave/");
});
});
it("opens project and shows chat view", async () => {
mockedApi.openProject.mockResolvedValue("/home/user/myproject");
await renderApp();
await waitFor(() => {
expect(
screen.getByPlaceholderText(/\/path\/to\/project/i),
).toBeInTheDocument();
});
const input = screen.getByPlaceholderText(
/\/path\/to\/project/i,
) as HTMLInputElement;
await userEvent.clear(input);
await userEvent.type(input, "/home/user/myproject");
const openButton = screen.getByRole("button", { name: /open project/i });
await userEvent.click(openButton);
await waitFor(() => {
expect(mockedApi.openProject).toHaveBeenCalledWith(
"/home/user/myproject",
);
});
});
it("shows error when openProject fails", async () => {
mockedApi.openProject.mockRejectedValue(new Error("Path does not exist"));
await renderApp();
await waitFor(() => {
expect(
screen.getByPlaceholderText(/\/path\/to\/project/i),
).toBeInTheDocument();
});
const input = screen.getByPlaceholderText(
/\/path\/to\/project/i,
) as HTMLInputElement;
await userEvent.clear(input);
await userEvent.type(input, "/bad/path");
const openButton = screen.getByRole("button", { name: /open project/i });
await userEvent.click(openButton);
await waitFor(() => {
expect(screen.getByText(/Path does not exist/)).toBeInTheDocument();
});
});
it("shows known projects list", async () => {
mockedApi.getKnownProjects.mockResolvedValue([
"/home/user/project1",
"/home/user/project2",
]);
await renderApp();
await waitFor(() => {
expect(screen.getByTitle("/home/user/project1")).toBeInTheDocument();
expect(screen.getByTitle("/home/user/project2")).toBeInTheDocument();
});
});
it("shows error when path input is empty", async () => {
await renderApp();
await waitFor(() => {
expect(
screen.getByPlaceholderText(/\/path\/to\/project/i),
).toBeInTheDocument();
});
const input = screen.getByPlaceholderText(
/\/path\/to\/project/i,
) as HTMLInputElement;
await userEvent.clear(input);
const openButton = screen.getByRole("button", { name: /open project/i });
await userEvent.click(openButton);
await waitFor(() => {
expect(
screen.getByText(/Please enter a project path/i),
).toBeInTheDocument();
});
});
it("calls forgetKnownProject and removes project from list", async () => {
mockedApi.getKnownProjects.mockResolvedValue(["/home/user/project1"]);
mockedApi.forgetKnownProject.mockResolvedValue(true);
await renderApp();
await waitFor(() => {
expect(screen.getByTitle("/home/user/project1")).toBeInTheDocument();
});
const forgetButton = screen.getByRole("button", {
name: /Forget project1/i,
});
await userEvent.click(forgetButton);
await waitFor(() => {
expect(mockedApi.forgetKnownProject).toHaveBeenCalledWith(
"/home/user/project1",
);
expect(
screen.queryByTitle("/home/user/project1"),
).not.toBeInTheDocument();
});
});
it("closes project and returns to selection screen", async () => {
mockedApi.openProject.mockResolvedValue("/home/user/myproject");
mockedApi.closeProject.mockResolvedValue(true);
await renderApp();
await waitFor(() => {
expect(
screen.getByPlaceholderText(/\/path\/to\/project/i),
).toBeInTheDocument();
});
const input = screen.getByPlaceholderText(
/\/path\/to\/project/i,
) as HTMLInputElement;
await userEvent.clear(input);
await userEvent.type(input, "/home/user/myproject");
const openButton = screen.getByRole("button", { name: /open project/i });
await userEvent.click(openButton);
await waitFor(() => {
expect(mockedApi.openProject).toHaveBeenCalledWith(
"/home/user/myproject",
);
});
// Chat view should appear with close button
const closeButton = await waitFor(() => screen.getByText("✕"));
await userEvent.click(closeButton);
await waitFor(() => {
expect(mockedApi.closeProject).toHaveBeenCalled();
expect(
screen.getByPlaceholderText(/\/path\/to\/project/i),
).toBeInTheDocument();
});
});
it("handles ArrowDown and ArrowUp keyboard navigation when suggestions are visible", async () => {
mockedApi.listDirectoryAbsolute.mockResolvedValue([
{ name: "projects", kind: "dir" },
{ name: "documents", kind: "dir" },
]);
await renderApp();
// Wait for suggestions to appear after debounce
await waitFor(
() => {
expect(screen.getByText(/projects\//)).toBeInTheDocument();
},
{ timeout: 2000 },
);
const input = screen.getByPlaceholderText(/\/path\/to\/project/i);
// ArrowDown with matchList present — moves selection forward
fireEvent.keyDown(input, { key: "ArrowDown" });
// ArrowUp with matchList present — moves selection backward
fireEvent.keyDown(input, { key: "ArrowUp" });
});
it("handles Tab keyboard navigation to accept suggestion", async () => {
mockedApi.listDirectoryAbsolute.mockResolvedValue([
{ name: "myrepo", kind: "dir" },
]);
await renderApp();
await waitFor(
() => {
expect(screen.getByText(/myrepo\//)).toBeInTheDocument();
},
{ timeout: 2000 },
);
const input = screen.getByPlaceholderText(/\/path\/to\/project/i);
// Tab with matchList present — accepts the selected match
fireEvent.keyDown(input, { key: "Tab" });
});
it("handles Escape key to close suggestions", async () => {
mockedApi.listDirectoryAbsolute.mockResolvedValue([
{ name: "workspace", kind: "dir" },
]);
await renderApp();
await waitFor(
() => {
expect(screen.getByText(/workspace\//)).toBeInTheDocument();
},
{ timeout: 2000 },
);
const input = screen.getByPlaceholderText(/\/path\/to\/project/i);
// Escape closes suggestions
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByText(/workspace\//)).not.toBeInTheDocument();
});
});
it("handles Enter key to trigger project open", async () => {
mockedApi.openProject.mockResolvedValue("/home/user/myproject");
await renderApp();
await waitFor(() => {
expect(
screen.getByPlaceholderText(/\/path\/to\/project/i),
).toBeInTheDocument();
});
const input = screen.getByPlaceholderText(
/\/path\/to\/project/i,
) as HTMLInputElement;
await userEvent.clear(input);
await userEvent.type(input, "/home/user/myproject");
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => {
expect(mockedApi.openProject).toHaveBeenCalledWith(
"/home/user/myproject",
);
});
});
});

View File

@@ -1,201 +0,0 @@
import * as React from "react";
import { api } from "./api/client";
import { Chat } from "./components/Chat";
import { SelectionScreen } from "./components/selection/SelectionScreen";
import { usePathCompletion } from "./components/selection/usePathCompletion";
import "./App.css";
function App() {
const [projectPath, setProjectPath] = React.useState<string | null>(null);
const [_view, setView] = React.useState<"chat" | "token-usage">("chat");
const [isCheckingProject, setIsCheckingProject] = React.useState(true);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const [pathInput, setPathInput] = React.useState("");
const [isOpening, setIsOpening] = React.useState(false);
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
const [homeDir, setHomeDir] = React.useState<string | null>(null);
React.useEffect(() => {
api
.getCurrentProject()
.then((path) => {
if (path) {
setProjectPath(path);
}
})
.catch((error) => console.error(error))
.finally(() => {
setIsCheckingProject(false);
});
}, []);
React.useEffect(() => {
api
.getKnownProjects()
.then((projects) => setKnownProjects(projects))
.catch((error) => console.error(error));
}, []);
React.useEffect(() => {
let active = true;
api
.getHomeDirectory()
.then((home) => {
if (!active) return;
setHomeDir(home);
setPathInput((current) => {
if (current.trim()) {
return current;
}
const initial = home.endsWith("/") ? home : `${home}/`;
return initial;
});
})
.catch((error) => {
console.error(error);
});
return () => {
active = false;
};
}, []);
const {
matchList,
selectedMatch,
suggestionTail,
completionError,
currentPartial,
setSelectedMatch,
acceptSelectedMatch,
acceptMatch,
closeSuggestions,
} = usePathCompletion({
pathInput,
setPathInput,
homeDir,
listDirectoryAbsolute: api.listDirectoryAbsolute,
});
async function openProject(path: string) {
const trimmedPath = path.trim();
if (!trimmedPath) {
setErrorMsg("Please enter a project path.");
return;
}
try {
setErrorMsg(null);
setIsOpening(true);
const confirmedPath = await api.openProject(trimmedPath);
setProjectPath(confirmedPath);
} catch (e) {
console.error(e);
const message =
e instanceof Error
? e.message
: typeof e === "string"
? e
: "An error occurred opening the project.";
setErrorMsg(message);
} finally {
setIsOpening(false);
}
}
function handleOpen() {
void openProject(pathInput);
}
async function handleForgetProject(path: string) {
try {
await api.forgetKnownProject(path);
setKnownProjects((prev) => prev.filter((p) => p !== path));
} catch (error) {
console.error(error);
}
}
async function closeProject() {
try {
await api.closeProject();
setProjectPath(null);
setView("chat");
} catch (e) {
console.error(e);
}
}
function handlePathInputKeyDown(
event: React.KeyboardEvent<HTMLInputElement>,
) {
if (event.key === "ArrowDown") {
if (matchList.length > 0) {
event.preventDefault();
setSelectedMatch((selectedMatch + 1) % matchList.length);
}
} else if (event.key === "ArrowUp") {
if (matchList.length > 0) {
event.preventDefault();
setSelectedMatch(
(selectedMatch - 1 + matchList.length) % matchList.length,
);
}
} else if (event.key === "Tab") {
if (matchList.length > 0) {
event.preventDefault();
acceptSelectedMatch();
}
} else if (event.key === "Escape") {
event.preventDefault();
closeSuggestions();
} else if (event.key === "Enter") {
handleOpen();
}
}
if (isCheckingProject) {
return null;
}
return (
<main
className="container"
style={{ height: "100vh", padding: 0, maxWidth: "100%" }}
>
{!projectPath ? (
<SelectionScreen
knownProjects={knownProjects}
onOpenProject={openProject}
onForgetProject={handleForgetProject}
pathInput={pathInput}
homeDir={homeDir}
onPathInputChange={setPathInput}
onPathInputKeyDown={handlePathInputKeyDown}
isOpening={isOpening}
suggestionTail={suggestionTail}
matchList={matchList}
selectedMatch={selectedMatch}
onSelectMatch={setSelectedMatch}
onAcceptMatch={acceptMatch}
onCloseSuggestions={closeSuggestions}
completionError={completionError}
currentPartial={currentPartial}
/>
) : (
<div className="workspace" style={{ height: "100%" }}>
<Chat projectPath={projectPath} onCloseProject={closeProject} />
</div>
)}
{errorMsg && (
<div className="error-message" style={{ marginTop: "20px" }}>
<p style={{ color: "red" }}>Error: {errorMsg}</p>
</div>
)}
</main>
);
}
export default App;

View File

@@ -1,387 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentConfigInfo, AgentEvent, AgentInfo } from "./agents";
import { agentsApi, subscribeAgentStream } from "./agents";
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
});
afterEach(() => {
vi.restoreAllMocks();
});
function okResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function errorResponse(status: number, text: string) {
return new Response(text, { status });
}
const sampleAgent: AgentInfo = {
story_id: "42_story_test",
agent_name: "coder",
status: "running",
session_id: null,
worktree_path: null,
base_branch: null,
log_session_id: null,
};
const sampleConfig: AgentConfigInfo = {
name: "coder",
role: "engineer",
stage: "coder",
model: "claude-sonnet-4-6",
allowed_tools: null,
max_turns: null,
max_budget_usd: null,
};
// ── agentsApi ────────────────────────────────────────────────────────────────
describe("agentsApi", () => {
describe("startAgent", () => {
it("sends POST to /agents/start with story_id", async () => {
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
const result = await agentsApi.startAgent("42_story_test");
expect(mockFetch).toHaveBeenCalledWith(
"/api/agents/start",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
story_id: "42_story_test",
agent_name: undefined,
}),
}),
);
expect(result).toEqual(sampleAgent);
});
it("sends POST with optional agent_name", async () => {
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
await agentsApi.startAgent("42_story_test", "coder");
expect(mockFetch).toHaveBeenCalledWith(
"/api/agents/start",
expect.objectContaining({
body: JSON.stringify({
story_id: "42_story_test",
agent_name: "coder",
}),
}),
);
});
it("uses custom baseUrl when provided", async () => {
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
await agentsApi.startAgent(
"42_story_test",
undefined,
"http://localhost:3002/api",
);
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:3002/api/agents/start",
expect.objectContaining({ method: "POST" }),
);
});
});
describe("stopAgent", () => {
it("sends POST to /agents/stop with story_id and agent_name", async () => {
mockFetch.mockResolvedValueOnce(okResponse(true));
const result = await agentsApi.stopAgent("42_story_test", "coder");
expect(mockFetch).toHaveBeenCalledWith(
"/api/agents/stop",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
story_id: "42_story_test",
agent_name: "coder",
}),
}),
);
expect(result).toBe(true);
});
it("uses custom baseUrl when provided", async () => {
mockFetch.mockResolvedValueOnce(okResponse(false));
await agentsApi.stopAgent(
"42_story_test",
"coder",
"http://localhost:3002/api",
);
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:3002/api/agents/stop",
expect.objectContaining({ method: "POST" }),
);
});
});
describe("listAgents", () => {
it("sends GET to /agents and returns agent list", async () => {
mockFetch.mockResolvedValueOnce(okResponse([sampleAgent]));
const result = await agentsApi.listAgents();
expect(mockFetch).toHaveBeenCalledWith(
"/api/agents",
expect.objectContaining({}),
);
expect(result).toEqual([sampleAgent]);
});
it("returns empty array when no agents running", async () => {
mockFetch.mockResolvedValueOnce(okResponse([]));
const result = await agentsApi.listAgents();
expect(result).toEqual([]);
});
it("uses custom baseUrl when provided", async () => {
mockFetch.mockResolvedValueOnce(okResponse([]));
await agentsApi.listAgents("http://localhost:3002/api");
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:3002/api/agents",
expect.objectContaining({}),
);
});
});
describe("getAgentConfig", () => {
it("sends GET to /agents/config and returns config list", async () => {
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
const result = await agentsApi.getAgentConfig();
expect(mockFetch).toHaveBeenCalledWith(
"/api/agents/config",
expect.objectContaining({}),
);
expect(result).toEqual([sampleConfig]);
});
it("uses custom baseUrl when provided", async () => {
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
await agentsApi.getAgentConfig("http://localhost:3002/api");
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:3002/api/agents/config",
expect.objectContaining({}),
);
});
});
describe("reloadConfig", () => {
it("sends POST to /agents/config/reload", async () => {
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
const result = await agentsApi.reloadConfig();
expect(mockFetch).toHaveBeenCalledWith(
"/api/agents/config/reload",
expect.objectContaining({ method: "POST" }),
);
expect(result).toEqual([sampleConfig]);
});
it("uses custom baseUrl when provided", async () => {
mockFetch.mockResolvedValueOnce(okResponse([]));
await agentsApi.reloadConfig("http://localhost:3002/api");
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:3002/api/agents/config/reload",
expect.objectContaining({ method: "POST" }),
);
});
});
describe("error handling", () => {
it("throws on non-ok response with body text", async () => {
mockFetch.mockResolvedValueOnce(errorResponse(404, "agent not found"));
await expect(agentsApi.listAgents()).rejects.toThrow("agent not found");
});
it("throws with status code when no body", async () => {
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
await expect(agentsApi.listAgents()).rejects.toThrow(
"Request failed (500)",
);
});
});
});
// ── subscribeAgentStream ─────────────────────────────────────────────────────
interface MockESInstance {
url: string;
onmessage: ((e: { data: string }) => void) | null;
onerror: ((e: Event) => void) | null;
close: ReturnType<typeof vi.fn>;
simulateMessage: (data: unknown) => void;
simulateError: (e: Event) => void;
}
function makeMockEventSource() {
const instances: MockESInstance[] = [];
class MockEventSource {
onmessage: ((e: { data: string }) => void) | null = null;
onerror: ((e: Event) => void) | null = null;
close = vi.fn();
constructor(public url: string) {
instances.push(this as unknown as MockESInstance);
}
simulateMessage(data: unknown) {
this.onmessage?.({ data: JSON.stringify(data) });
}
simulateError(e: Event) {
this.onerror?.(e);
}
}
return { MockEventSource, instances };
}
describe("subscribeAgentStream", () => {
let instances: MockESInstance[];
beforeEach(() => {
const { MockEventSource, instances: inst } = makeMockEventSource();
instances = inst;
vi.stubGlobal("EventSource", MockEventSource);
});
it("creates an EventSource with encoded story and agent in URL", () => {
subscribeAgentStream("42_story_test", "coder", vi.fn());
expect(instances).toHaveLength(1);
expect(instances[0].url).toContain(
`/agents/${encodeURIComponent("42_story_test")}/${encodeURIComponent("coder")}/stream`,
);
});
it("calls onEvent when a message is received", () => {
const onEvent = vi.fn();
subscribeAgentStream("42_story_test", "coder", onEvent);
const event: AgentEvent = { type: "output", text: "hello" };
instances[0].simulateMessage(event);
expect(onEvent).toHaveBeenCalledWith(event);
});
it("closes EventSource on 'done' type event", () => {
subscribeAgentStream("42_story_test", "coder", vi.fn());
instances[0].simulateMessage({ type: "done" });
expect(instances[0].close).toHaveBeenCalled();
});
it("closes EventSource on 'error' type event", () => {
subscribeAgentStream("42_story_test", "coder", vi.fn());
instances[0].simulateMessage({
type: "error",
message: "something failed",
});
expect(instances[0].close).toHaveBeenCalled();
});
it("closes EventSource on status=stopped event", () => {
subscribeAgentStream("42_story_test", "coder", vi.fn());
instances[0].simulateMessage({ type: "status", status: "stopped" });
expect(instances[0].close).toHaveBeenCalled();
});
it("does not close on status=running event", () => {
subscribeAgentStream("42_story_test", "coder", vi.fn());
instances[0].simulateMessage({ type: "status", status: "running" });
expect(instances[0].close).not.toHaveBeenCalled();
});
it("does not close on 'output' event", () => {
subscribeAgentStream("42_story_test", "coder", vi.fn());
instances[0].simulateMessage({ type: "output", text: "building..." });
expect(instances[0].close).not.toHaveBeenCalled();
});
it("calls onError and closes on EventSource onerror", () => {
const onError = vi.fn();
subscribeAgentStream("42_story_test", "coder", vi.fn(), onError);
const err = new Event("error");
instances[0].simulateError(err);
expect(onError).toHaveBeenCalledWith(err);
expect(instances[0].close).toHaveBeenCalled();
});
it("closes EventSource when onError is not provided", () => {
subscribeAgentStream("42_story_test", "coder", vi.fn());
const err = new Event("error");
instances[0].simulateError(err);
expect(instances[0].close).toHaveBeenCalled();
});
it("closes EventSource when cleanup function is called", () => {
const cleanup = subscribeAgentStream("42_story_test", "coder", vi.fn());
cleanup();
expect(instances[0].close).toHaveBeenCalled();
});
it("handles malformed JSON without throwing", () => {
subscribeAgentStream("42_story_test", "coder", vi.fn());
expect(() => {
instances[0].onmessage?.({ data: "{ not valid json" });
}).not.toThrow();
});
it("delivers multiple events before a terminal event", () => {
const onEvent = vi.fn();
subscribeAgentStream("42_story_test", "coder", onEvent);
instances[0].simulateMessage({ type: "output", text: "line 1" });
instances[0].simulateMessage({ type: "output", text: "line 2" });
instances[0].simulateMessage({ type: "done" });
expect(onEvent).toHaveBeenCalledTimes(3);
expect(instances[0].close).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,162 +0,0 @@
export type AgentStatusValue = "pending" | "running" | "completed" | "failed";
export interface AgentInfo {
story_id: string;
agent_name: string;
status: AgentStatusValue;
session_id: string | null;
worktree_path: string | null;
base_branch: string | null;
log_session_id: string | null;
}
export interface AgentEvent {
type:
| "status"
| "output"
| "thinking"
| "agent_json"
| "done"
| "error"
| "warning";
story_id?: string;
agent_name?: string;
status?: string;
text?: string;
data?: unknown;
session_id?: string | null;
message?: string;
}
export interface AgentConfigInfo {
name: string;
role: string;
stage: string | null;
model: string | null;
allowed_tools: string[] | null;
max_turns: number | null;
max_budget_usd: number | null;
}
const DEFAULT_API_BASE = "/api";
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
return `${baseUrl}${path}`;
}
async function requestJson<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(buildApiUrl(path, baseUrl), {
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
export const agentsApi = {
startAgent(storyId: string, agentName?: string, baseUrl?: string) {
return requestJson<AgentInfo>(
"/agents/start",
{
method: "POST",
body: JSON.stringify({
story_id: storyId,
agent_name: agentName,
}),
},
baseUrl,
);
},
stopAgent(storyId: string, agentName: string, baseUrl?: string) {
return requestJson<boolean>(
"/agents/stop",
{
method: "POST",
body: JSON.stringify({
story_id: storyId,
agent_name: agentName,
}),
},
baseUrl,
);
},
listAgents(baseUrl?: string) {
return requestJson<AgentInfo[]>("/agents", {}, baseUrl);
},
getAgentConfig(baseUrl?: string) {
return requestJson<AgentConfigInfo[]>("/agents/config", {}, baseUrl);
},
reloadConfig(baseUrl?: string) {
return requestJson<AgentConfigInfo[]>(
"/agents/config/reload",
{ method: "POST" },
baseUrl,
);
},
getAgentOutput(storyId: string, agentName: string, baseUrl?: string) {
return requestJson<{ output: string }>(
`/agents/${encodeURIComponent(storyId)}/${encodeURIComponent(agentName)}/output`,
{},
baseUrl,
);
},
};
/**
* Subscribe to SSE events for a running agent.
* Returns a cleanup function to close the connection.
*/
export function subscribeAgentStream(
storyId: string,
agentName: string,
onEvent: (event: AgentEvent) => void,
onError?: (error: Event) => void,
): () => void {
const url = `/agents/${encodeURIComponent(storyId)}/${encodeURIComponent(agentName)}/stream`;
const eventSource = new EventSource(url);
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as AgentEvent;
onEvent(data);
// Close on terminal events
if (
data.type === "done" ||
data.type === "error" ||
(data.type === "status" && data.status === "stopped")
) {
eventSource.close();
}
} catch (err) {
console.error("Failed to parse agent event:", err);
}
};
eventSource.onerror = (e) => {
onError?.(e);
eventSource.close();
};
return () => {
eventSource.close();
};
}

View File

@@ -1,433 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { api, ChatWebSocket, resolveWsHost } from "./client";
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
});
afterEach(() => {
vi.restoreAllMocks();
});
function okResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function errorResponse(status: number, text: string) {
return new Response(text, { status });
}
describe("api client", () => {
describe("getCurrentProject", () => {
it("sends GET to /project", async () => {
mockFetch.mockResolvedValueOnce(okResponse("/home/user/project"));
const result = await api.getCurrentProject();
expect(mockFetch).toHaveBeenCalledWith(
"/api/project",
expect.objectContaining({}),
);
expect(result).toBe("/home/user/project");
});
it("returns null when no project open", async () => {
mockFetch.mockResolvedValueOnce(okResponse(null));
const result = await api.getCurrentProject();
expect(result).toBeNull();
});
});
describe("openProject", () => {
it("sends POST with path", async () => {
mockFetch.mockResolvedValueOnce(okResponse("/home/user/project"));
await api.openProject("/home/user/project");
expect(mockFetch).toHaveBeenCalledWith(
"/api/project",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ path: "/home/user/project" }),
}),
);
});
});
describe("closeProject", () => {
it("sends DELETE to /project", async () => {
mockFetch.mockResolvedValueOnce(okResponse(true));
await api.closeProject();
expect(mockFetch).toHaveBeenCalledWith(
"/api/project",
expect.objectContaining({ method: "DELETE" }),
);
});
});
describe("getKnownProjects", () => {
it("returns array of project paths", async () => {
mockFetch.mockResolvedValueOnce(okResponse(["/a", "/b"]));
const result = await api.getKnownProjects();
expect(result).toEqual(["/a", "/b"]);
});
});
describe("error handling", () => {
it("throws on non-ok response with body text", async () => {
mockFetch.mockResolvedValueOnce(errorResponse(404, "Not found"));
await expect(api.getCurrentProject()).rejects.toThrow("Not found");
});
it("throws with status code when no body", async () => {
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
await expect(api.getCurrentProject()).rejects.toThrow(
"Request failed (500)",
);
});
});
describe("searchFiles", () => {
it("sends POST with query", async () => {
mockFetch.mockResolvedValueOnce(
okResponse([{ path: "src/main.rs", matches: 1 }]),
);
const result = await api.searchFiles("hello");
expect(mockFetch).toHaveBeenCalledWith(
"/api/fs/search",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ query: "hello" }),
}),
);
expect(result).toHaveLength(1);
});
});
describe("execShell", () => {
it("sends POST with command and args", async () => {
mockFetch.mockResolvedValueOnce(
okResponse({ stdout: "output", stderr: "", exit_code: 0 }),
);
const result = await api.execShell("ls", ["-la"]);
expect(mockFetch).toHaveBeenCalledWith(
"/api/shell/exec",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ command: "ls", args: ["-la"] }),
}),
);
expect(result.exit_code).toBe(0);
});
});
describe("resolveWsHost", () => {
it("uses env port in dev mode", () => {
expect(resolveWsHost(true, "4200", "example.com")).toBe("127.0.0.1:4200");
});
it("defaults to 3001 in dev mode when no env port", () => {
expect(resolveWsHost(true, undefined, "example.com")).toBe(
"127.0.0.1:3001",
);
});
it("uses location host in production", () => {
expect(resolveWsHost(false, "4200", "myapp.com:8080")).toBe(
"myapp.com:8080",
);
});
});
});
// ── ChatWebSocket reconnect tests ───────────────────────────────────────────
interface MockWsInstance {
onopen: (() => void) | null;
onclose: (() => void) | null;
onmessage: ((e: { data: string }) => void) | null;
onerror: (() => void) | null;
readyState: number;
sentMessages: string[];
send: (data: string) => void;
close: () => void;
simulateClose: () => void;
simulateMessage: (data: Record<string, unknown>) => void;
}
function makeMockWebSocket() {
const instances: MockWsInstance[] = [];
class MockWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onmessage: ((e: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
readyState = 0;
sentMessages: string[] = [];
constructor(_url: string) {
instances.push(this as unknown as MockWsInstance);
}
send(data: string) {
this.sentMessages.push(data);
}
close() {
this.readyState = 3;
this.onclose?.();
}
simulateClose() {
this.readyState = 3;
this.onclose?.();
}
simulateMessage(data: Record<string, unknown>) {
this.onmessage?.({ data: JSON.stringify(data) });
}
}
return { MockWebSocket, instances };
}
describe("ChatWebSocket", () => {
beforeEach(() => {
vi.useFakeTimers();
const { MockWebSocket } = makeMockWebSocket();
vi.stubGlobal("WebSocket", MockWebSocket);
// Reset shared static state between tests
(ChatWebSocket as unknown as { sharedSocket: null }).sharedSocket = null;
(ChatWebSocket as unknown as { refCount: number }).refCount = 0;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("schedules reconnect after socket closes unexpectedly", () => {
const { MockWebSocket, instances } = makeMockWebSocket();
vi.stubGlobal("WebSocket", MockWebSocket);
const ws = new ChatWebSocket();
ws.connect({});
expect(instances).toHaveLength(1);
instances[0].simulateClose();
// No new socket created yet
expect(instances).toHaveLength(1);
// Advance past the initial 1s reconnect delay
vi.advanceTimersByTime(1001);
// A new socket should now have been created
expect(instances).toHaveLength(2);
});
it("delivers pipeline_state after reconnect", () => {
const { MockWebSocket, instances } = makeMockWebSocket();
vi.stubGlobal("WebSocket", MockWebSocket);
const onPipelineState = vi.fn();
const ws = new ChatWebSocket();
ws.connect({ onPipelineState });
// Simulate server restart
instances[0].simulateClose();
vi.advanceTimersByTime(1001);
// Server pushes pipeline_state on fresh connection
const freshState = {
backlog: [{ story_id: "1_story_test", name: "Test", error: null }],
current: [],
qa: [],
merge: [],
done: [],
};
instances[1].simulateMessage({ type: "pipeline_state", ...freshState });
expect(onPipelineState).toHaveBeenCalledWith(freshState);
});
it("does not reconnect after explicit close()", () => {
const { MockWebSocket, instances } = makeMockWebSocket();
vi.stubGlobal("WebSocket", MockWebSocket);
const ws = new ChatWebSocket();
ws.connect({});
// Explicit close disables reconnect
ws.close();
// Advance through both the DEV close-defer (250ms) and reconnect window
vi.advanceTimersByTime(2000);
// No new socket should be created
expect(instances).toHaveLength(1);
});
it("uses exponential backoff on repeated failures", () => {
const { MockWebSocket, instances } = makeMockWebSocket();
vi.stubGlobal("WebSocket", MockWebSocket);
const ws = new ChatWebSocket();
ws.connect({});
// First close → reconnects after 1s
instances[0].simulateClose();
vi.advanceTimersByTime(1001);
expect(instances).toHaveLength(2);
// Second close → reconnects after 2s (doubled)
instances[1].simulateClose();
vi.advanceTimersByTime(1500);
// Not yet (delay is now 2s)
expect(instances).toHaveLength(2);
vi.advanceTimersByTime(600);
expect(instances).toHaveLength(3);
});
it("resets reconnect delay after successful open", () => {
const { MockWebSocket, instances } = makeMockWebSocket();
vi.stubGlobal("WebSocket", MockWebSocket);
const ws = new ChatWebSocket();
ws.connect({});
// Disconnect and reconnect twice to raise the delay
instances[0].simulateClose();
vi.advanceTimersByTime(1001);
instances[1].simulateClose();
vi.advanceTimersByTime(2001);
// Simulate a successful open on third socket — resets delay to 1s
instances[2].onopen?.();
// Close again — should use the reset 1s delay
instances[2].simulateClose();
vi.advanceTimersByTime(1001);
expect(instances).toHaveLength(4);
});
});
describe("ChatWebSocket heartbeat", () => {
beforeEach(() => {
vi.useFakeTimers();
const { MockWebSocket } = makeMockWebSocket();
vi.stubGlobal("WebSocket", MockWebSocket);
(ChatWebSocket as unknown as { sharedSocket: null }).sharedSocket = null;
(ChatWebSocket as unknown as { refCount: number }).refCount = 0;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("sends ping after heartbeat interval", () => {
const { MockWebSocket, instances } = makeMockWebSocket();
vi.stubGlobal("WebSocket", MockWebSocket);
const ws = new ChatWebSocket();
ws.connect({});
instances[0].readyState = 1; // OPEN
instances[0].onopen?.(); // starts heartbeat
vi.advanceTimersByTime(29_999);
expect(instances[0].sentMessages).toHaveLength(0);
vi.advanceTimersByTime(1);
expect(instances[0].sentMessages).toHaveLength(1);
expect(JSON.parse(instances[0].sentMessages[0])).toEqual({ type: "ping" });
ws.close();
});
it("closes stale connection when pong is not received", () => {
const { MockWebSocket, instances } = makeMockWebSocket();
vi.stubGlobal("WebSocket", MockWebSocket);
const ws = new ChatWebSocket();
ws.connect({});
instances[0].readyState = 1; // OPEN
instances[0].onopen?.(); // starts heartbeat
// Fire heartbeat — sends ping and starts pong timeout
vi.advanceTimersByTime(30_000);
// No pong received; advance past pong timeout → socket closed → reconnect scheduled
vi.advanceTimersByTime(5_000);
// Advance past reconnect delay
vi.advanceTimersByTime(1_001);
expect(instances).toHaveLength(2);
ws.close();
});
it("does not close when pong is received before timeout", () => {
const { MockWebSocket, instances } = makeMockWebSocket();
vi.stubGlobal("WebSocket", MockWebSocket);
const ws = new ChatWebSocket();
ws.connect({});
instances[0].readyState = 1; // OPEN
instances[0].onopen?.(); // starts heartbeat
// Fire heartbeat
vi.advanceTimersByTime(30_000);
// Server responds with pong — clears the pong timeout
instances[0].simulateMessage({ type: "pong" });
// Advance past where pong timeout would have fired
vi.advanceTimersByTime(5_001);
// No reconnect triggered
expect(instances).toHaveLength(1);
ws.close();
});
it("stops sending pings after explicit close", () => {
const { MockWebSocket, instances } = makeMockWebSocket();
vi.stubGlobal("WebSocket", MockWebSocket);
const ws = new ChatWebSocket();
ws.connect({});
instances[0].readyState = 1; // OPEN
instances[0].onopen?.(); // starts heartbeat
ws.close();
// Advance well past multiple heartbeat intervals
vi.advanceTimersByTime(90_000);
expect(instances[0].sentMessages).toHaveLength(0);
});
});

View File

@@ -1,699 +0,0 @@
export type WsRequest =
| {
type: "chat";
messages: Message[];
config: ProviderConfig;
}
| {
type: "cancel";
}
| {
type: "permission_response";
request_id: string;
approved: boolean;
always_allow: boolean;
}
| { type: "ping" }
| {
type: "side_question";
question: string;
context_messages: Message[];
config: ProviderConfig;
};
export interface AgentAssignment {
agent_name: string;
model: string | null;
status: string;
}
export interface PipelineStageItem {
story_id: string;
name: string | null;
error: string | null;
merge_failure: string | null;
agent: AgentAssignment | null;
review_hold: boolean | null;
qa: string | null;
}
export interface PipelineState {
backlog: PipelineStageItem[];
current: PipelineStageItem[];
qa: PipelineStageItem[];
merge: PipelineStageItem[];
done: PipelineStageItem[];
}
export type WsResponse =
| { type: "token"; content: string }
| { type: "update"; messages: Message[] }
| { type: "session_id"; session_id: string }
| { type: "error"; message: string }
| {
type: "pipeline_state";
backlog: PipelineStageItem[];
current: PipelineStageItem[];
qa: PipelineStageItem[];
merge: PipelineStageItem[];
done: PipelineStageItem[];
}
| {
type: "permission_request";
request_id: string;
tool_name: string;
tool_input: Record<string, unknown>;
}
| { type: "tool_activity"; tool_name: string }
| {
type: "reconciliation_progress";
story_id: string;
status: string;
message: string;
}
/** `.story_kit/project.toml` was modified; re-fetch the agent roster. */
| { type: "agent_config_changed" }
/** An agent started, stopped, or changed state; re-fetch agent list. */
| { type: "agent_state_changed" }
| { type: "tool_activity"; tool_name: string }
/** Heartbeat response confirming the connection is alive. */
| { type: "pong" }
/** Sent on connect when the project still needs onboarding (specs are placeholders). */
| { type: "onboarding_status"; needs_onboarding: boolean }
/** Streaming thinking token from an extended-thinking block, separate from regular text. */
| { type: "thinking_token"; content: string }
/** Streaming token from a /btw side question response. */
| { type: "side_question_token"; content: string }
/** Final signal that the /btw side question has been fully answered. */
| { type: "side_question_done"; response: string }
/** A single server log entry (bulk on connect, then live). */
| { type: "log_entry"; timestamp: string; level: string; message: string };
export interface ProviderConfig {
provider: string;
model: string;
base_url?: string;
enable_tools?: boolean;
session_id?: string;
}
export type Role = "system" | "user" | "assistant" | "tool";
export interface ToolCall {
id?: string;
type: string;
function: {
name: string;
arguments: string;
};
}
export interface Message {
role: Role;
content: string;
tool_calls?: ToolCall[];
tool_call_id?: string;
}
export interface AnthropicModelInfo {
id: string;
context_window: number;
}
export interface WorkItemContent {
content: string;
stage: string;
name: string | null;
agent: string | null;
}
export interface TestCaseResult {
name: string;
status: "pass" | "fail";
details: string | null;
}
export interface TestResultsResponse {
unit: TestCaseResult[];
integration: TestCaseResult[];
}
export interface FileEntry {
name: string;
kind: "file" | "dir";
}
export interface SearchResult {
path: string;
matches: number;
}
export interface AgentCostEntry {
agent_name: string;
model: string | null;
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens: number;
cache_read_input_tokens: number;
total_cost_usd: number;
}
export interface TokenCostResponse {
total_cost_usd: number;
agents: AgentCostEntry[];
}
export interface TokenUsageRecord {
story_id: string;
agent_name: string;
model: string | null;
timestamp: string;
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens: number;
cache_read_input_tokens: number;
total_cost_usd: number;
}
export interface AllTokenUsageResponse {
records: TokenUsageRecord[];
}
export interface CommandOutput {
stdout: string;
stderr: string;
exit_code: number;
}
declare const __STORKIT_PORT__: string;
const DEFAULT_API_BASE = "/api";
const DEFAULT_WS_PATH = "/ws";
export function resolveWsHost(
isDev: boolean,
envPort: string | undefined,
locationHost: string,
): string {
return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost;
}
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
return `${baseUrl}${path}`;
}
async function requestJson<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(buildApiUrl(path, baseUrl), {
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
export const api = {
getCurrentProject(baseUrl?: string) {
return requestJson<string | null>("/project", {}, baseUrl);
},
getKnownProjects(baseUrl?: string) {
return requestJson<string[]>("/projects", {}, baseUrl);
},
forgetKnownProject(path: string, baseUrl?: string) {
return requestJson<boolean>(
"/projects/forget",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
openProject(path: string, baseUrl?: string) {
return requestJson<string>(
"/project",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
closeProject(baseUrl?: string) {
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
},
getModelPreference(baseUrl?: string) {
return requestJson<string | null>("/model", {}, baseUrl);
},
setModelPreference(model: string, baseUrl?: string) {
return requestJson<boolean>(
"/model",
{ method: "POST", body: JSON.stringify({ model }) },
baseUrl,
);
},
getOllamaModels(baseUrlParam?: string, baseUrl?: string) {
const url = new URL(
buildApiUrl("/ollama/models", baseUrl),
window.location.origin,
);
if (baseUrlParam) {
url.searchParams.set("base_url", baseUrlParam);
}
return requestJson<string[]>(url.pathname + url.search, {}, "");
},
getAnthropicApiKeyExists(baseUrl?: string) {
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
},
getAnthropicModels(baseUrl?: string) {
return requestJson<AnthropicModelInfo[]>("/anthropic/models", {}, baseUrl);
},
setAnthropicApiKey(api_key: string, baseUrl?: string) {
return requestJson<boolean>(
"/anthropic/key",
{ method: "POST", body: JSON.stringify({ api_key }) },
baseUrl,
);
},
readFile(path: string, baseUrl?: string) {
return requestJson<string>(
"/fs/read",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
writeFile(path: string, content: string, baseUrl?: string) {
return requestJson<boolean>(
"/fs/write",
{ method: "POST", body: JSON.stringify({ path, content }) },
baseUrl,
);
},
listDirectory(path: string, baseUrl?: string) {
return requestJson<FileEntry[]>(
"/fs/list",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
listDirectoryAbsolute(path: string, baseUrl?: string) {
return requestJson<FileEntry[]>(
"/io/fs/list/absolute",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
createDirectoryAbsolute(path: string, baseUrl?: string) {
return requestJson<boolean>(
"/io/fs/create/absolute",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
getHomeDirectory(baseUrl?: string) {
return requestJson<string>("/io/fs/home", {}, baseUrl);
},
listProjectFiles(baseUrl?: string) {
return requestJson<string[]>("/io/fs/files", {}, baseUrl);
},
searchFiles(query: string, baseUrl?: string) {
return requestJson<SearchResult[]>(
"/fs/search",
{ method: "POST", body: JSON.stringify({ query }) },
baseUrl,
);
},
execShell(command: string, args: string[], baseUrl?: string) {
return requestJson<CommandOutput>(
"/shell/exec",
{ method: "POST", body: JSON.stringify({ command, args }) },
baseUrl,
);
},
cancelChat(baseUrl?: string) {
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
},
getWorkItemContent(storyId: string, baseUrl?: string) {
return requestJson<WorkItemContent>(
`/work-items/${encodeURIComponent(storyId)}`,
{},
baseUrl,
);
},
getTestResults(storyId: string, baseUrl?: string) {
return requestJson<TestResultsResponse | null>(
`/work-items/${encodeURIComponent(storyId)}/test-results`,
{},
baseUrl,
);
},
getTokenCost(storyId: string, baseUrl?: string) {
return requestJson<TokenCostResponse>(
`/work-items/${encodeURIComponent(storyId)}/token-cost`,
{},
baseUrl,
);
},
getAllTokenUsage(baseUrl?: string) {
return requestJson<AllTokenUsageResponse>("/token-usage", {}, baseUrl);
},
/** Trigger a server rebuild and restart. */
rebuildAndRestart() {
return callMcpTool("rebuild_and_restart", {});
},
/** Approve a story in QA, moving it to merge. */
approveQa(storyId: string) {
return callMcpTool("approve_qa", { story_id: storyId });
},
/** Reject a story in QA, moving it back to current with notes. */
rejectQa(storyId: string, notes: string) {
return callMcpTool("reject_qa", { story_id: storyId, notes });
},
/** Launch the QA app for a story's worktree. */
launchQaApp(storyId: string) {
return callMcpTool("launch_qa_app", { story_id: storyId });
},
/** Delete a story from the pipeline, stopping any running agent and removing the worktree. */
deleteStory(storyId: string) {
return callMcpTool("delete_story", { story_id: storyId });
},
};
async function callMcpTool(
toolName: string,
args: Record<string, unknown>,
): Promise<string> {
const res = await fetch("/mcp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: toolName, arguments: args },
}),
});
const json = await res.json();
if (json.error) {
throw new Error(json.error.message);
}
const text = json.result?.content?.[0]?.text ?? "";
return text;
}
export class ChatWebSocket {
private static sharedSocket: WebSocket | null = null;
private static refCount = 0;
private socket?: WebSocket;
private onToken?: (content: string) => void;
private onThinkingToken?: (content: string) => void;
private onUpdate?: (messages: Message[]) => void;
private onSessionId?: (sessionId: string) => void;
private onError?: (message: string) => void;
private onPipelineState?: (state: PipelineState) => void;
private onPermissionRequest?: (
requestId: string,
toolName: string,
toolInput: Record<string, unknown>,
) => void;
private onActivity?: (toolName: string) => void;
private onReconciliationProgress?: (
storyId: string,
status: string,
message: string,
) => void;
private onAgentConfigChanged?: () => void;
private onAgentStateChanged?: () => void;
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
private onSideQuestionToken?: (content: string) => void;
private onSideQuestionDone?: (response: string) => void;
private onLogEntry?: (
timestamp: string,
level: string,
message: string,
) => void;
private onConnected?: () => void;
private connected = false;
private closeTimer?: number;
private wsPath = DEFAULT_WS_PATH;
private reconnectTimer?: number;
private reconnectDelay = 1000;
private shouldReconnect = false;
private heartbeatInterval?: number;
private heartbeatTimeout?: number;
private static readonly HEARTBEAT_INTERVAL = 30_000;
private static readonly HEARTBEAT_TIMEOUT = 5_000;
private _startHeartbeat(): void {
this._stopHeartbeat();
this.heartbeatInterval = window.setInterval(() => {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
const ping: WsRequest = { type: "ping" };
this.socket.send(JSON.stringify(ping));
this.heartbeatTimeout = window.setTimeout(() => {
// No pong received within timeout; close socket to trigger reconnect.
this.socket?.close();
}, ChatWebSocket.HEARTBEAT_TIMEOUT);
}, ChatWebSocket.HEARTBEAT_INTERVAL);
}
private _stopHeartbeat(): void {
window.clearInterval(this.heartbeatInterval);
window.clearTimeout(this.heartbeatTimeout);
this.heartbeatInterval = undefined;
this.heartbeatTimeout = undefined;
}
private _buildWsUrl(): string {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const wsHost = resolveWsHost(
import.meta.env.DEV,
typeof __STORKIT_PORT__ !== "undefined" ? __STORKIT_PORT__ : undefined,
window.location.host,
);
return `${protocol}://${wsHost}${this.wsPath}`;
}
private _attachHandlers(): void {
if (!this.socket) return;
this.socket.onopen = () => {
this.reconnectDelay = 1000;
this._startHeartbeat();
this.onConnected?.();
};
this.socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as WsResponse;
if (data.type === "token") this.onToken?.(data.content);
if (data.type === "thinking_token")
this.onThinkingToken?.(data.content);
if (data.type === "update") this.onUpdate?.(data.messages);
if (data.type === "session_id") this.onSessionId?.(data.session_id);
if (data.type === "error") this.onError?.(data.message);
if (data.type === "pipeline_state")
this.onPipelineState?.({
backlog: data.backlog,
current: data.current,
qa: data.qa,
merge: data.merge,
done: data.done,
});
if (data.type === "permission_request")
this.onPermissionRequest?.(
data.request_id,
data.tool_name,
data.tool_input,
);
if (data.type === "tool_activity") this.onActivity?.(data.tool_name);
if (data.type === "reconciliation_progress")
this.onReconciliationProgress?.(
data.story_id,
data.status,
data.message,
);
if (data.type === "agent_config_changed") this.onAgentConfigChanged?.();
if (data.type === "agent_state_changed") this.onAgentStateChanged?.();
if (data.type === "onboarding_status")
this.onOnboardingStatus?.(data.needs_onboarding);
if (data.type === "side_question_token")
this.onSideQuestionToken?.(data.content);
if (data.type === "side_question_done")
this.onSideQuestionDone?.(data.response);
if (data.type === "log_entry")
this.onLogEntry?.(data.timestamp, data.level, data.message);
if (data.type === "pong") {
window.clearTimeout(this.heartbeatTimeout);
this.heartbeatTimeout = undefined;
}
} catch (err) {
this.onError?.(String(err));
}
};
this.socket.onerror = () => {
this.onError?.("WebSocket error");
};
this.socket.onclose = () => {
if (this.shouldReconnect && this.connected) {
this._scheduleReconnect();
}
};
}
private _scheduleReconnect(): void {
window.clearTimeout(this.reconnectTimer);
const delay = this.reconnectDelay;
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = undefined;
const wsUrl = this._buildWsUrl();
ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
this.socket = ChatWebSocket.sharedSocket;
this._attachHandlers();
}, delay);
}
connect(
handlers: {
onToken?: (content: string) => void;
onThinkingToken?: (content: string) => void;
onUpdate?: (messages: Message[]) => void;
onSessionId?: (sessionId: string) => void;
onError?: (message: string) => void;
onPipelineState?: (state: PipelineState) => void;
onPermissionRequest?: (
requestId: string,
toolName: string,
toolInput: Record<string, unknown>,
) => void;
onActivity?: (toolName: string) => void;
onReconciliationProgress?: (
storyId: string,
status: string,
message: string,
) => void;
onAgentConfigChanged?: () => void;
onAgentStateChanged?: () => void;
onOnboardingStatus?: (needsOnboarding: boolean) => void;
onSideQuestionToken?: (content: string) => void;
onSideQuestionDone?: (response: string) => void;
onLogEntry?: (timestamp: string, level: string, message: string) => void;
onConnected?: () => void;
},
wsPath = DEFAULT_WS_PATH,
) {
this.onToken = handlers.onToken;
this.onThinkingToken = handlers.onThinkingToken;
this.onUpdate = handlers.onUpdate;
this.onSessionId = handlers.onSessionId;
this.onError = handlers.onError;
this.onPipelineState = handlers.onPipelineState;
this.onPermissionRequest = handlers.onPermissionRequest;
this.onActivity = handlers.onActivity;
this.onReconciliationProgress = handlers.onReconciliationProgress;
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
this.onAgentStateChanged = handlers.onAgentStateChanged;
this.onOnboardingStatus = handlers.onOnboardingStatus;
this.onSideQuestionToken = handlers.onSideQuestionToken;
this.onSideQuestionDone = handlers.onSideQuestionDone;
this.onLogEntry = handlers.onLogEntry;
this.onConnected = handlers.onConnected;
this.wsPath = wsPath;
this.shouldReconnect = true;
if (this.connected) {
return;
}
this.connected = true;
ChatWebSocket.refCount += 1;
if (
!ChatWebSocket.sharedSocket ||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED ||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING
) {
const wsUrl = this._buildWsUrl();
ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
}
this.socket = ChatWebSocket.sharedSocket;
this._attachHandlers();
}
sendChat(messages: Message[], config: ProviderConfig) {
this.send({ type: "chat", messages, config });
}
sendSideQuestion(
question: string,
contextMessages: Message[],
config: ProviderConfig,
) {
this.send({
type: "side_question",
question,
context_messages: contextMessages,
config,
});
}
cancel() {
this.send({ type: "cancel" });
}
sendPermissionResponse(
requestId: string,
approved: boolean,
alwaysAllow = false,
) {
this.send({
type: "permission_response",
request_id: requestId,
approved,
always_allow: alwaysAllow,
});
}
close() {
this.shouldReconnect = false;
this._stopHeartbeat();
window.clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
if (!this.connected) return;
this.connected = false;
ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1);
if (import.meta.env.DEV) {
if (this.closeTimer) {
window.clearTimeout(this.closeTimer);
}
this.closeTimer = window.setTimeout(() => {
if (ChatWebSocket.refCount === 0) {
ChatWebSocket.sharedSocket?.close();
ChatWebSocket.sharedSocket = null;
}
this.socket = ChatWebSocket.sharedSocket ?? undefined;
this.closeTimer = undefined;
}, 250);
return;
}
if (ChatWebSocket.refCount === 0) {
ChatWebSocket.sharedSocket?.close();
ChatWebSocket.sharedSocket = null;
}
this.socket = ChatWebSocket.sharedSocket ?? undefined;
}
private send(payload: WsRequest) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
this.onError?.("WebSocket is not connected");
return;
}
this.socket.send(JSON.stringify(payload));
}
}

View File

@@ -1,134 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { settingsApi } from "./settings";
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
});
afterEach(() => {
vi.restoreAllMocks();
});
function okResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function errorResponse(status: number, text: string) {
return new Response(text, { status });
}
describe("settingsApi", () => {
describe("getEditorCommand", () => {
it("sends GET to /settings/editor and returns editor settings", async () => {
const expected = { editor_command: "zed" };
mockFetch.mockResolvedValueOnce(okResponse(expected));
const result = await settingsApi.getEditorCommand();
expect(mockFetch).toHaveBeenCalledWith(
"/api/settings/editor",
expect.objectContaining({
headers: expect.objectContaining({
"Content-Type": "application/json",
}),
}),
);
expect(result).toEqual(expected);
});
it("returns null editor_command when not configured", async () => {
const expected = { editor_command: null };
mockFetch.mockResolvedValueOnce(okResponse(expected));
const result = await settingsApi.getEditorCommand();
expect(result.editor_command).toBeNull();
});
it("uses custom baseUrl when provided", async () => {
mockFetch.mockResolvedValueOnce(okResponse({ editor_command: "code" }));
await settingsApi.getEditorCommand("http://localhost:4000/api");
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:4000/api/settings/editor",
expect.anything(),
);
});
});
describe("setEditorCommand", () => {
it("sends PUT to /settings/editor with command body", async () => {
const expected = { editor_command: "zed" };
mockFetch.mockResolvedValueOnce(okResponse(expected));
const result = await settingsApi.setEditorCommand("zed");
expect(mockFetch).toHaveBeenCalledWith(
"/api/settings/editor",
expect.objectContaining({
method: "PUT",
body: JSON.stringify({ editor_command: "zed" }),
}),
);
expect(result).toEqual(expected);
});
it("sends PUT with null to clear the editor command", async () => {
const expected = { editor_command: null };
mockFetch.mockResolvedValueOnce(okResponse(expected));
const result = await settingsApi.setEditorCommand(null);
expect(mockFetch).toHaveBeenCalledWith(
"/api/settings/editor",
expect.objectContaining({
method: "PUT",
body: JSON.stringify({ editor_command: null }),
}),
);
expect(result.editor_command).toBeNull();
});
it("uses custom baseUrl when provided", async () => {
mockFetch.mockResolvedValueOnce(okResponse({ editor_command: "vim" }));
await settingsApi.setEditorCommand("vim", "http://localhost:4000/api");
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:4000/api/settings/editor",
expect.objectContaining({ method: "PUT" }),
);
});
});
describe("error handling", () => {
it("throws with response body text on non-ok response", async () => {
mockFetch.mockResolvedValueOnce(errorResponse(400, "Bad Request"));
await expect(settingsApi.getEditorCommand()).rejects.toThrow(
"Bad Request",
);
});
it("throws with status code message when response body is empty", async () => {
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
await expect(settingsApi.getEditorCommand()).rejects.toThrow(
"Request failed (500)",
);
});
it("throws on setEditorCommand error", async () => {
mockFetch.mockResolvedValueOnce(errorResponse(403, "Forbidden"));
await expect(settingsApi.setEditorCommand("code")).rejects.toThrow(
"Forbidden",
);
});
});
});

View File

@@ -1,70 +0,0 @@
export interface EditorSettings {
editor_command: string | null;
}
export interface OpenFileResult {
success: boolean;
}
const DEFAULT_API_BASE = "/api";
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
return `${baseUrl}${path}`;
}
async function requestJson<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(buildApiUrl(path, baseUrl), {
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
export const settingsApi = {
getEditorCommand(baseUrl?: string): Promise<EditorSettings> {
return requestJson<EditorSettings>("/settings/editor", {}, baseUrl);
},
setEditorCommand(
command: string | null,
baseUrl?: string,
): Promise<EditorSettings> {
return requestJson<EditorSettings>(
"/settings/editor",
{
method: "PUT",
body: JSON.stringify({ editor_command: command }),
},
baseUrl,
);
},
openFile(
path: string,
line?: number,
baseUrl?: string,
): Promise<OpenFileResult> {
const params = new URLSearchParams({ path });
if (line !== undefined) {
params.set("line", String(line));
}
return requestJson<OpenFileResult>(
`/settings/open-file?${params.toString()}`,
{ method: "POST" },
baseUrl,
);
},
};

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

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

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

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

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

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");
});
});

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>
);
},
);

View File

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

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

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

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

View File

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

View File

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

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

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>
);
}

View File

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

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

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>
);
}

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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,275 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Message } from "../types";
import { useChatHistory } from "./useChatHistory";
const PROJECT = "/tmp/test-project";
const STORAGE_KEY = `storykit-chat-history:${PROJECT}`;
const LIMIT_KEY = `storykit-chat-history-limit:${PROJECT}`;
const sampleMessages: Message[] = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
];
function makeMessages(count: number): Message[] {
return Array.from({ length: count }, (_, i) => ({
role: "user" as const,
content: `Message ${i + 1}`,
}));
}
describe("useChatHistory", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it("AC1: restores messages from localStorage on mount", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages));
const { result } = renderHook(() => useChatHistory(PROJECT));
expect(result.current.messages).toEqual(sampleMessages);
});
it("AC1: returns empty array when localStorage has no data", () => {
const { result } = renderHook(() => useChatHistory(PROJECT));
expect(result.current.messages).toEqual([]);
});
it("AC1: returns empty array when localStorage contains invalid JSON", () => {
localStorage.setItem(STORAGE_KEY, "not-json{{{");
const { result } = renderHook(() => useChatHistory(PROJECT));
expect(result.current.messages).toEqual([]);
});
it("AC1: returns empty array when localStorage contains a non-array", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ not: "array" }));
const { result } = renderHook(() => useChatHistory(PROJECT));
expect(result.current.messages).toEqual([]);
});
it("AC2: saves messages to localStorage when setMessages is called with an array", () => {
const { result } = renderHook(() => useChatHistory(PROJECT));
act(() => {
result.current.setMessages(sampleMessages);
});
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
expect(stored).toEqual(sampleMessages);
});
it("AC2: saves messages to localStorage when setMessages is called with updater function", () => {
const { result } = renderHook(() => useChatHistory(PROJECT));
act(() => {
result.current.setMessages(() => sampleMessages);
});
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
expect(stored).toEqual(sampleMessages);
});
it("AC3: clearMessages removes messages from state and localStorage", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages));
const { result } = renderHook(() => useChatHistory(PROJECT));
expect(result.current.messages).toEqual(sampleMessages);
act(() => {
result.current.clearMessages();
});
expect(result.current.messages).toEqual([]);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it("AC4: handles localStorage quota errors gracefully", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const setItemSpy = vi
.spyOn(Storage.prototype, "setItem")
.mockImplementation(() => {
throw new DOMException("QuotaExceededError");
});
const { result } = renderHook(() => useChatHistory(PROJECT));
// Should not throw
act(() => {
result.current.setMessages(sampleMessages);
});
// State should still update even though storage failed
expect(result.current.messages).toEqual(sampleMessages);
expect(warnSpy).toHaveBeenCalledWith(
"Failed to persist chat history to localStorage:",
expect.any(DOMException),
);
warnSpy.mockRestore();
setItemSpy.mockRestore();
});
it("AC5: scopes storage key to project path", () => {
const projectA = "/projects/a";
const projectB = "/projects/b";
const keyA = `storykit-chat-history:${projectA}`;
const keyB = `storykit-chat-history:${projectB}`;
const messagesA: Message[] = [{ role: "user", content: "From project A" }];
const messagesB: Message[] = [{ role: "user", content: "From project B" }];
localStorage.setItem(keyA, JSON.stringify(messagesA));
localStorage.setItem(keyB, JSON.stringify(messagesB));
const { result: resultA } = renderHook(() => useChatHistory(projectA));
const { result: resultB } = renderHook(() => useChatHistory(projectB));
expect(resultA.current.messages).toEqual(messagesA);
expect(resultB.current.messages).toEqual(messagesB);
});
it("AC2: removes localStorage key when messages are set to empty array", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages));
const { result } = renderHook(() => useChatHistory(PROJECT));
act(() => {
result.current.setMessages([]);
});
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
// --- Story 179: Chat history pruning tests ---
it("S179: default limit of 200 is applied when saving to localStorage", () => {
const { result } = renderHook(() => useChatHistory(PROJECT));
expect(result.current.maxMessages).toBe(200);
});
it("S179: messages are pruned from the front when exceeding the limit", () => {
// Set a small limit to make testing practical
localStorage.setItem(LIMIT_KEY, "3");
const { result } = renderHook(() => useChatHistory(PROJECT));
const fiveMessages = makeMessages(5);
act(() => {
result.current.setMessages(fiveMessages);
});
// localStorage should contain only the last 3 messages
const stored: Message[] = JSON.parse(
localStorage.getItem(STORAGE_KEY) ?? "[]",
);
expect(stored).toEqual(fiveMessages.slice(-3));
expect(stored).toHaveLength(3);
expect(stored[0].content).toBe("Message 3");
});
it("S179: messages under the limit are not pruned", () => {
localStorage.setItem(LIMIT_KEY, "10");
const { result } = renderHook(() => useChatHistory(PROJECT));
const threeMessages = makeMessages(3);
act(() => {
result.current.setMessages(threeMessages);
});
const stored: Message[] = JSON.parse(
localStorage.getItem(STORAGE_KEY) ?? "[]",
);
expect(stored).toEqual(threeMessages);
expect(stored).toHaveLength(3);
});
it("S179: limit is configurable via localStorage key", () => {
localStorage.setItem(LIMIT_KEY, "5");
const { result } = renderHook(() => useChatHistory(PROJECT));
expect(result.current.maxMessages).toBe(5);
});
it("S179: setMaxMessages updates the limit and persists it", () => {
const { result } = renderHook(() => useChatHistory(PROJECT));
act(() => {
result.current.setMaxMessages(50);
});
expect(result.current.maxMessages).toBe(50);
expect(localStorage.getItem(LIMIT_KEY)).toBe("50");
});
it("S179: a limit of 0 means unlimited (no pruning)", () => {
localStorage.setItem(LIMIT_KEY, "0");
const { result } = renderHook(() => useChatHistory(PROJECT));
const manyMessages = makeMessages(500);
act(() => {
result.current.setMessages(manyMessages);
});
const stored: Message[] = JSON.parse(
localStorage.getItem(STORAGE_KEY) ?? "[]",
);
expect(stored).toHaveLength(500);
expect(stored).toEqual(manyMessages);
});
it("S179: changing the limit re-prunes messages on next save", () => {
const { result } = renderHook(() => useChatHistory(PROJECT));
const tenMessages = makeMessages(10);
act(() => {
result.current.setMessages(tenMessages);
});
// All 10 saved (default limit 200 > 10)
let stored: Message[] = JSON.parse(
localStorage.getItem(STORAGE_KEY) ?? "[]",
);
expect(stored).toHaveLength(10);
// Now lower the limit — the effect re-runs and prunes
act(() => {
result.current.setMaxMessages(3);
});
stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
expect(stored).toHaveLength(3);
expect(stored[0].content).toBe("Message 8");
});
it("S179: invalid limit in localStorage falls back to default", () => {
localStorage.setItem(LIMIT_KEY, "not-a-number");
const { result } = renderHook(() => useChatHistory(PROJECT));
expect(result.current.maxMessages).toBe(200);
});
it("S179: negative limit in localStorage falls back to default", () => {
localStorage.setItem(LIMIT_KEY, "-5");
const { result } = renderHook(() => useChatHistory(PROJECT));
expect(result.current.maxMessages).toBe(200);
});
});

View File

@@ -1,117 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { Message } from "../types";
const STORAGE_KEY_PREFIX = "storykit-chat-history:";
const LIMIT_KEY_PREFIX = "storykit-chat-history-limit:";
const DEFAULT_LIMIT = 200;
function storageKey(projectPath: string): string {
return `${STORAGE_KEY_PREFIX}${projectPath}`;
}
function limitKey(projectPath: string): string {
return `${LIMIT_KEY_PREFIX}${projectPath}`;
}
function loadLimit(projectPath: string): number {
try {
const raw = localStorage.getItem(limitKey(projectPath));
if (raw === null) return DEFAULT_LIMIT;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_LIMIT;
return Math.floor(parsed);
} catch {
return DEFAULT_LIMIT;
}
}
function saveLimit(projectPath: string, limit: number): void {
try {
localStorage.setItem(limitKey(projectPath), String(limit));
} catch {
// Ignore — quota or security errors.
}
}
function loadMessages(projectPath: string): Message[] {
try {
const raw = localStorage.getItem(storageKey(projectPath));
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed as Message[];
} catch {
return [];
}
}
function pruneMessages(messages: Message[], limit: number): Message[] {
if (limit === 0 || messages.length <= limit) return messages;
return messages.slice(-limit);
}
function saveMessages(
projectPath: string,
messages: Message[],
limit: number,
): void {
try {
const pruned = pruneMessages(messages, limit);
if (pruned.length === 0) {
localStorage.removeItem(storageKey(projectPath));
} else {
localStorage.setItem(storageKey(projectPath), JSON.stringify(pruned));
}
} catch (e) {
console.warn("Failed to persist chat history to localStorage:", e);
}
}
export function useChatHistory(projectPath: string) {
const [messages, setMessagesState] = useState<Message[]>(() =>
loadMessages(projectPath),
);
const [maxMessages, setMaxMessagesState] = useState<number>(() =>
loadLimit(projectPath),
);
const projectPathRef = useRef(projectPath);
// Keep the ref in sync so the effect closure always has the latest path.
projectPathRef.current = projectPath;
// Persist whenever messages or limit change.
useEffect(() => {
saveMessages(projectPathRef.current, messages, maxMessages);
}, [messages, maxMessages]);
const setMessages = useCallback(
(update: Message[] | ((prev: Message[]) => Message[])) => {
setMessagesState(update);
},
[],
);
const setMaxMessages = useCallback((limit: number) => {
setMaxMessagesState(limit);
saveLimit(projectPathRef.current, limit);
}, []);
const clearMessages = useCallback(() => {
setMessagesState([]);
// Eagerly remove from storage so clearSession doesn't depend on the
// effect firing before the component unmounts or re-renders.
try {
localStorage.removeItem(storageKey(projectPathRef.current));
} catch {
// Ignore — quota or security errors.
}
}, []);
return {
messages,
setMessages,
clearMessages,
maxMessages,
setMaxMessages,
} as const;
}

View File

@@ -1,9 +0,0 @@
import * as React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -1,20 +0,0 @@
import "@testing-library/jest-dom";
import { beforeEach, vi } from "vitest";
// Provide a default fetch mock so components that call API endpoints on mount
// don't throw URL-parse errors in the jsdom test environment. Tests that need
// specific responses should mock the relevant `api.*` method as usual.
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn((input: string | URL | Request) => {
const url = typeof input === "string" ? input : input.toString();
// Endpoints that return arrays need [] not {} to avoid "not iterable" errors.
const arrayEndpoints = ["/agents", "/agents/config"];
const body = arrayEndpoints.some((ep) => url.endsWith(ep))
? JSON.stringify([])
: JSON.stringify({});
return Promise.resolve(new Response(body, { status: 200 }));
}),
);
});

View File

@@ -1,80 +0,0 @@
export type Role = "system" | "user" | "assistant" | "tool";
export interface ToolCall {
id?: string;
type: string;
function: {
name: string;
arguments: string;
};
}
export interface Message {
role: Role;
content: string;
tool_calls?: ToolCall[];
tool_call_id?: string;
}
export interface ProviderConfig {
provider: string;
model: string;
base_url?: string;
enable_tools?: boolean;
session_id?: string;
}
export interface FileEntry {
name: string;
kind: "file" | "dir";
}
export interface SearchResult {
path: string;
matches: number;
}
export interface CommandOutput {
stdout: string;
stderr: string;
exit_code: number;
}
export type WsRequest =
| {
type: "chat";
messages: Message[];
config: ProviderConfig;
}
| {
type: "cancel";
}
| {
type: "permission_response";
request_id: string;
approved: boolean;
always_allow: boolean;
};
export type WsResponse =
| { type: "token"; content: string }
| { type: "update"; messages: Message[] }
| { type: "session_id"; session_id: string }
| { type: "error"; message: string }
| {
type: "permission_request";
request_id: string;
tool_name: string;
tool_input: Record<string, unknown>;
};
// Re-export API client types for convenience
export type {
Message as ApiMessage,
ProviderConfig as ApiProviderConfig,
FileEntry as ApiFileEntry,
SearchResult as ApiSearchResult,
CommandOutput as ApiCommandOutput,
WsRequest as ApiWsRequest,
WsResponse as ApiWsResponse,
};

View File

@@ -1,14 +0,0 @@
/// <reference types="vite/client" />
declare global {
const __BUILD_TIME__: string;
}
declare module "react" {
interface InputHTMLAttributes<T> {
webkitdirectory?: string;
directory?: string;
}
}
export {};

View File

@@ -1,16 +0,0 @@
import { expect, test } from "@playwright/test";
test.describe("App boot smoke test", () => {
test("renders the app without errors", async ({ page }) => {
await page.goto("/");
// The app should render either the project selection screen or the
// workspace chat view, depending on whether a project is already open.
// We intentionally do NOT call DELETE /api/project here because that
// would nuke the live server's project_root state and break any
// background agents that depend on it.
//
// Just verify the page loads and has a visible <main> container.
await expect(page.locator("main.container")).toBeVisible();
});
});

View File

@@ -1,24 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -1,51 +0,0 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig(() => {
const backendPort = Number(process.env.STORKIT_PORT || "3001");
return {
plugins: [react()],
define: {
__STORKIT_PORT__: JSON.stringify(String(backendPort)),
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
},
server: {
port: backendPort + 2172,
proxy: {
"/api": {
target: `http://127.0.0.1:${String(backendPort)}`,
timeout: 120000,
configure: (proxy) => {
proxy.on("error", (_err) => {
// Swallow proxy errors (e.g. ECONNREFUSED during backend restart)
// so the vite dev server doesn't crash.
});
},
},
"/agents": {
target: `http://127.0.0.1:${String(backendPort)}`,
timeout: 120000,
configure: (proxy) => {
proxy.on("error", (_err) => {});
},
},
},
watch: {
ignored: [
"**/.story_kit/**",
"**/target/**",
"**/.git/**",
"**/server/**",
"**/Cargo.*",
"**/vendor/**",
"**/node_modules/**",
],
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
};
});

View File

@@ -1,27 +0,0 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
define: {
__BUILD_TIME__: JSON.stringify("2026-01-01T00:00:00.000Z"),
},
test: {
environment: "jsdom",
environmentOptions: {
jsdom: {
url: "http://localhost:3000",
},
},
globals: true,
testTimeout: 10_000,
setupFiles: ["./src/setupTests.ts"],
css: true,
exclude: ["tests/e2e/**", "node_modules/**"],
coverage: {
provider: "v8",
reporter: ["text", "json-summary"],
reportsDirectory: "./coverage",
},
},
});