storkit: create 365_story_surface_api_rate_limit_warnings_in_chat
This commit is contained in:
14
frontend/.gitignore
vendored
14
frontend/.gitignore
vendored
@@ -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
|
||||
@@ -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>
|
||||
8408
frontend/package-lock.json
generated
8408
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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 |
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,419 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { api } from "../api/client";
|
||||
|
||||
const {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} = React;
|
||||
|
||||
export interface ChatInputHandle {
|
||||
appendToInput(text: string): void;
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
loading: boolean;
|
||||
queuedMessages: { id: string; text: string }[];
|
||||
onSubmit: (message: string) => void;
|
||||
onCancel: () => void;
|
||||
onRemoveQueuedMessage: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Fuzzy-match: returns true if all chars of `query` appear in order in `str`. */
|
||||
function fuzzyMatch(str: string, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const lower = str.toLowerCase();
|
||||
const q = query.toLowerCase();
|
||||
let qi = 0;
|
||||
for (let i = 0; i < lower.length && qi < q.length; i++) {
|
||||
if (lower[i] === q[qi]) qi++;
|
||||
}
|
||||
return qi === q.length;
|
||||
}
|
||||
|
||||
/** Score a fuzzy match: lower is better. Exact prefix match wins, then shorter paths. */
|
||||
function fuzzyScore(str: string, query: string): number {
|
||||
const lower = str.toLowerCase();
|
||||
const q = query.toLowerCase();
|
||||
// Prefer matches where query appears as a contiguous substring
|
||||
if (lower.includes(q)) return lower.indexOf(q);
|
||||
return str.length;
|
||||
}
|
||||
|
||||
interface FilePickerOverlayProps {
|
||||
query: string;
|
||||
files: string[];
|
||||
selectedIndex: number;
|
||||
onSelect: (file: string) => void;
|
||||
onDismiss: () => void;
|
||||
anchorRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
}
|
||||
|
||||
function FilePickerOverlay({
|
||||
query,
|
||||
files,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
}: FilePickerOverlayProps) {
|
||||
const filtered = files
|
||||
.filter((f) => fuzzyMatch(f, query))
|
||||
.sort((a, b) => fuzzyScore(a, query) - fuzzyScore(b, query))
|
||||
.slice(0, 10);
|
||||
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="file-picker-overlay"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "100%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: "#1e1e1e",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "6px",
|
||||
overflow: "hidden",
|
||||
zIndex: 100,
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
|
||||
maxHeight: "240px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{filtered.map((file, idx) => (
|
||||
<button
|
||||
key={file}
|
||||
type="button"
|
||||
data-testid={`file-picker-item-${idx}`}
|
||||
onClick={() => onSelect(file)}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: "8px 14px",
|
||||
background: idx === selectedIndex ? "#2d4a6e" : "transparent",
|
||||
border: "none",
|
||||
color: idx === selectedIndex ? "#ececec" : "#aaa",
|
||||
cursor: "pointer",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.85rem",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{file}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
function ChatInput(
|
||||
{ loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage },
|
||||
ref,
|
||||
) {
|
||||
const [input, setInput] = useState("");
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// File picker state
|
||||
const [projectFiles, setProjectFiles] = useState<string[]>([]);
|
||||
const [pickerQuery, setPickerQuery] = useState<string | null>(null);
|
||||
const [pickerSelectedIndex, setPickerSelectedIndex] = useState(0);
|
||||
const [pickerAtStart, setPickerAtStart] = useState(0);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
appendToInput(text: string) {
|
||||
setInput((prev) => (prev ? `${prev}\n${text}` : text));
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Compute filtered files for current picker query
|
||||
const filteredFiles =
|
||||
pickerQuery !== null
|
||||
? projectFiles
|
||||
.filter((f) => fuzzyMatch(f, pickerQuery))
|
||||
.sort(
|
||||
(a, b) => fuzzyScore(a, pickerQuery) - fuzzyScore(b, pickerQuery),
|
||||
)
|
||||
.slice(0, 10)
|
||||
: [];
|
||||
|
||||
const dismissPicker = useCallback(() => {
|
||||
setPickerQuery(null);
|
||||
setPickerSelectedIndex(0);
|
||||
}, []);
|
||||
|
||||
const selectFile = useCallback(
|
||||
(file: string) => {
|
||||
// Replace the @query portion with @file
|
||||
const before = input.slice(0, pickerAtStart);
|
||||
const cursorPos = inputRef.current?.selectionStart ?? input.length;
|
||||
const after = input.slice(cursorPos);
|
||||
setInput(`${before}@${file}${after}`);
|
||||
dismissPicker();
|
||||
// Restore focus after state update
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
},
|
||||
[input, pickerAtStart, dismissPicker],
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
setInput(val);
|
||||
|
||||
const cursor = e.target.selectionStart ?? val.length;
|
||||
// Find the last @ before the cursor that starts a reference token
|
||||
const textUpToCursor = val.slice(0, cursor);
|
||||
// Match @ not preceded by non-whitespace (i.e. @ at start or after space/newline)
|
||||
const atMatch = textUpToCursor.match(/(^|[\s\n])@([^\s@]*)$/);
|
||||
|
||||
if (atMatch) {
|
||||
const query = atMatch[2];
|
||||
const atPos = textUpToCursor.lastIndexOf("@");
|
||||
setPickerAtStart(atPos);
|
||||
setPickerQuery(query);
|
||||
setPickerSelectedIndex(0);
|
||||
|
||||
// Lazily load files on first trigger
|
||||
if (projectFiles.length === 0) {
|
||||
api
|
||||
.listProjectFiles()
|
||||
.then(setProjectFiles)
|
||||
.catch(() => {});
|
||||
}
|
||||
} else {
|
||||
if (pickerQuery !== null) dismissPicker();
|
||||
}
|
||||
},
|
||||
[projectFiles.length, pickerQuery, dismissPicker],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (pickerQuery !== null && filteredFiles.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setPickerSelectedIndex((i) =>
|
||||
Math.min(i + 1, filteredFiles.length - 1),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setPickerSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
selectFile(filteredFiles[pickerSelectedIndex] ?? filteredFiles[0]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
dismissPicker();
|
||||
return;
|
||||
}
|
||||
} else if (e.key === "Escape" && pickerQuery !== null) {
|
||||
e.preventDefault();
|
||||
dismissPicker();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[
|
||||
pickerQuery,
|
||||
filteredFiles,
|
||||
pickerSelectedIndex,
|
||||
selectFile,
|
||||
dismissPicker,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!input.trim()) return;
|
||||
onSubmit(input);
|
||||
setInput("");
|
||||
dismissPicker();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "24px",
|
||||
background: "#171717",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "768px",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
{/* Queued message indicators */}
|
||||
{queuedMessages.map(({ id, text }) => (
|
||||
<div
|
||||
key={id}
|
||||
data-testid="queued-message-indicator"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "8px 12px",
|
||||
background: "#1e1e1e",
|
||||
border: "1px solid #3a3a3a",
|
||||
borderRadius: "12px",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: "#666",
|
||||
flexShrink: 0,
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
Queued
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: "#888",
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Edit queued message"
|
||||
onClick={() => {
|
||||
setInput(text);
|
||||
onRemoveQueuedMessage(id);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#666",
|
||||
cursor: "pointer",
|
||||
padding: "2px 6px",
|
||||
fontSize: "0.8rem",
|
||||
flexShrink: 0,
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Cancel queued message"
|
||||
onClick={() => onRemoveQueuedMessage(id)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#666",
|
||||
cursor: "pointer",
|
||||
padding: "2px 4px",
|
||||
fontSize: "0.875rem",
|
||||
flexShrink: 0,
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* Input row with file picker overlay */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{pickerQuery !== null && (
|
||||
<FilePickerOverlay
|
||||
query={pickerQuery}
|
||||
files={projectFiles}
|
||||
selectedIndex={pickerSelectedIndex}
|
||||
onSelect={selectFile}
|
||||
onDismiss={dismissPicker}
|
||||
anchorRef={inputRef}
|
||||
/>
|
||||
)}
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Send a message..."
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "14px 20px",
|
||||
borderRadius: "24px",
|
||||
border: "1px solid #333",
|
||||
outline: "none",
|
||||
fontSize: "1rem",
|
||||
fontWeight: "500",
|
||||
background: "#2f2f2f",
|
||||
color: "#ececec",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.02)",
|
||||
resize: "none",
|
||||
overflowY: "auto",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loading && !input.trim() ? onCancel : handleSubmit}
|
||||
disabled={!loading && !input.trim()}
|
||||
style={{
|
||||
background: "#ececec",
|
||||
color: "black",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
opacity: !loading && !input.trim() ? 0.5 : 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{loading && !input.trim() ? "■" : "↑"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,194 +0,0 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "../api/client";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
api: {
|
||||
listProjectFiles: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedListProjectFiles = vi.mocked(api.listProjectFiles);
|
||||
|
||||
const defaultProps = {
|
||||
loading: false,
|
||||
queuedMessages: [],
|
||||
onSubmit: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
onRemoveQueuedMessage: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedListProjectFiles.mockResolvedValue([
|
||||
"src/main.rs",
|
||||
"src/lib.rs",
|
||||
"frontend/index.html",
|
||||
"README.md",
|
||||
]);
|
||||
});
|
||||
|
||||
describe("File picker overlay (Story 269 AC1)", () => {
|
||||
it("shows file picker overlay when @ is typed", async () => {
|
||||
render(<ChatInput {...defaultProps} />);
|
||||
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: "@" } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show file picker overlay for text without @", async () => {
|
||||
render(<ChatInput {...defaultProps} />);
|
||||
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: "hello world" } });
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("File picker fuzzy matching (Story 269 AC2)", () => {
|
||||
it("filters files by query typed after @", async () => {
|
||||
render(<ChatInput {...defaultProps} />);
|
||||
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: "@main" } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// main.rs should be visible, README.md should not
|
||||
expect(screen.getByText("src/main.rs")).toBeInTheDocument();
|
||||
expect(screen.queryByText("README.md")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows all files when @ is typed with no query", async () => {
|
||||
render(<ChatInput {...defaultProps} />);
|
||||
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: "@" } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// All 4 files should be visible
|
||||
expect(screen.getByText("src/main.rs")).toBeInTheDocument();
|
||||
expect(screen.getByText("src/lib.rs")).toBeInTheDocument();
|
||||
expect(screen.getByText("README.md")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("File picker selection (Story 269 AC3)", () => {
|
||||
it("clicking a file inserts @path into the message", async () => {
|
||||
render(<ChatInput {...defaultProps} />);
|
||||
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: "@" } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("file-picker-item-0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("file-picker-item-0"));
|
||||
});
|
||||
|
||||
// Picker should be dismissed and the file reference inserted
|
||||
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||||
expect((textarea as HTMLTextAreaElement).value).toMatch(/^@\S+/);
|
||||
});
|
||||
|
||||
it("Enter key selects highlighted file and inserts it into message", async () => {
|
||||
render(<ChatInput {...defaultProps} />);
|
||||
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: "@main" } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||||
expect((textarea as HTMLTextAreaElement).value).toContain("@src/main.rs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("File picker dismiss (Story 269 AC5)", () => {
|
||||
it("Escape key dismisses the file picker", async () => {
|
||||
render(<ChatInput {...defaultProps} />);
|
||||
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: "@" } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple @ references (Story 269 AC6)", () => {
|
||||
it("typing @ after a completed reference triggers picker again", async () => {
|
||||
render(<ChatInput {...defaultProps} />);
|
||||
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||
|
||||
// First reference
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: "@main" } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select file
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
});
|
||||
|
||||
// Type a second @
|
||||
await act(async () => {
|
||||
const current = (textarea as HTMLTextAreaElement).value;
|
||||
fireEvent.change(textarea, { target: { value: `${current} @` } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
const { useEffect, useRef } = React;
|
||||
|
||||
interface SlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{
|
||||
name: "/help",
|
||||
description: "Show this list of available slash commands.",
|
||||
},
|
||||
{
|
||||
name: "/btw <question>",
|
||||
description:
|
||||
"Ask a side question using the current conversation as context. The question and answer are not added to the conversation history.",
|
||||
},
|
||||
];
|
||||
|
||||
interface HelpOverlayProps {
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismissible overlay that lists all available slash commands.
|
||||
* Dismiss with Escape, Enter, or Space.
|
||||
*/
|
||||
export function HelpOverlay({ onDismiss }: HelpOverlayProps) {
|
||||
const dismissRef = useRef(onDismiss);
|
||||
dismissRef.current = onDismiss;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" || e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
dismissRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss is supplementary; keyboard handled via window keydown
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: keyboard dismiss handled via window keydown listener
|
||||
<div
|
||||
data-testid="help-overlay"
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation only; no real interaction */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop-propagation only; no real interaction */}
|
||||
<div
|
||||
data-testid="help-panel"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: "#2f2f2f",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "12px",
|
||||
padding: "24px",
|
||||
maxWidth: "560px",
|
||||
width: "90vw",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
color: "#a0d4a0",
|
||||
}}
|
||||
>
|
||||
Slash Commands
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
title="Dismiss (Escape, Enter, or Space)"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#666",
|
||||
cursor: "pointer",
|
||||
fontSize: "1.1rem",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Command list */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
{SLASH_COMMANDS.map((cmd) => (
|
||||
<div
|
||||
key={cmd.name}
|
||||
style={{ display: "flex", flexDirection: "column", gap: "2px" }}
|
||||
>
|
||||
<code
|
||||
style={{
|
||||
fontSize: "0.88rem",
|
||||
color: "#e0e0e0",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{cmd.name}
|
||||
</code>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
color: "#999",
|
||||
lineHeight: "1.5",
|
||||
}}
|
||||
>
|
||||
{cmd.description}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "#555",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Press Escape, Enter, or Space to dismiss
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,445 +0,0 @@
|
||||
/**
|
||||
* LozengeFlyContext – FLIP-style animation system for agent lozenges.
|
||||
*
|
||||
* When an agent is assigned to a story, a fixed-positioned clone of the
|
||||
* agent lozenge "flies" from the roster badge in AgentPanel to the slot
|
||||
* in StagePanel (or vice-versa when the agent is removed). The overlay
|
||||
* travels above all other UI elements (z-index 9999) so it is never
|
||||
* clipped by the layout.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import type { PipelineState } from "../api/client";
|
||||
|
||||
const {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} = React;
|
||||
|
||||
// ─── Public context shape ─────────────────────────────────────────────────────
|
||||
|
||||
export interface LozengeFlyContextValue {
|
||||
/** Register/unregister a roster badge DOM element by agent name. */
|
||||
registerRosterEl: (agentName: string, el: HTMLElement | null) => void;
|
||||
/**
|
||||
* Save the latest DOMRect for a story's lozenge slot.
|
||||
* Called on every render of AgentLozenge via useLayoutEffect.
|
||||
*/
|
||||
saveSlotRect: (storyId: string, rect: DOMRect) => void;
|
||||
/**
|
||||
* Set of storyIds whose slot lozenges should be hidden because a
|
||||
* fly-in animation is in progress.
|
||||
*/
|
||||
pendingFlyIns: ReadonlySet<string>;
|
||||
/**
|
||||
* Set of agent names whose roster badge should be hidden.
|
||||
* An agent is hidden while it is assigned to a work item OR while its
|
||||
* fly-out animation (work item → roster) is still in flight.
|
||||
*/
|
||||
hiddenRosterAgents: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
const emptySet: ReadonlySet<string> = new Set();
|
||||
|
||||
export const LozengeFlyContext = createContext<LozengeFlyContextValue>({
|
||||
registerRosterEl: noop,
|
||||
saveSlotRect: noop,
|
||||
pendingFlyIns: emptySet,
|
||||
hiddenRosterAgents: emptySet,
|
||||
});
|
||||
|
||||
// ─── Internal flying-lozenge state ───────────────────────────────────────────
|
||||
|
||||
interface FlyingLozenge {
|
||||
id: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
/** false = positioned at start, true = CSS transition to end */
|
||||
flying: boolean;
|
||||
}
|
||||
|
||||
interface PendingFlyIn {
|
||||
storyId: string;
|
||||
agentName: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface PendingFlyOut {
|
||||
storyId: string;
|
||||
agentName: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface LozengeFlyProviderProps {
|
||||
children: React.ReactNode;
|
||||
pipeline: PipelineState;
|
||||
}
|
||||
|
||||
export function LozengeFlyProvider({
|
||||
children,
|
||||
pipeline,
|
||||
}: LozengeFlyProviderProps) {
|
||||
const rosterElsRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const savedSlotRectsRef = useRef<Map<string, DOMRect>>(new Map());
|
||||
const prevPipelineRef = useRef<PipelineState | null>(null);
|
||||
|
||||
// Actions detected in useLayoutEffect, consumed in useEffect
|
||||
const pendingFlyInActionsRef = useRef<PendingFlyIn[]>([]);
|
||||
const pendingFlyOutActionsRef = useRef<PendingFlyOut[]>([]);
|
||||
|
||||
// Track the active animation ID per story/agent so stale timeouts
|
||||
// from superseded animations don't prematurely clear state.
|
||||
const activeFlyInPerStory = useRef<Map<string, string>>(new Map());
|
||||
const activeFlyOutPerAgent = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const [pendingFlyIns, setPendingFlyIns] = useState<ReadonlySet<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [flyingLozenges, setFlyingLozenges] = useState<FlyingLozenge[]>([]);
|
||||
|
||||
// Agents currently assigned to a work item (derived from pipeline state).
|
||||
const assignedAgentNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
for (const item of [
|
||||
...pipeline.backlog,
|
||||
...pipeline.current,
|
||||
...pipeline.qa,
|
||||
...pipeline.merge,
|
||||
]) {
|
||||
if (item.agent) names.add(item.agent.agent_name);
|
||||
}
|
||||
return names;
|
||||
}, [pipeline]);
|
||||
|
||||
// Agents whose fly-out (work item → roster) animation is still in flight.
|
||||
// Kept hidden until the clone lands so no duplicate badge flashes.
|
||||
const [flyingOutAgents, setFlyingOutAgents] = useState<ReadonlySet<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// Union: hide badge whenever the agent is assigned OR still flying back.
|
||||
const hiddenRosterAgents = useMemo(() => {
|
||||
if (flyingOutAgents.size === 0) return assignedAgentNames;
|
||||
const combined = new Set(assignedAgentNames);
|
||||
for (const name of flyingOutAgents) combined.add(name);
|
||||
return combined;
|
||||
}, [assignedAgentNames, flyingOutAgents]);
|
||||
|
||||
const registerRosterEl = useCallback(
|
||||
(agentName: string, el: HTMLElement | null) => {
|
||||
if (el) {
|
||||
rosterElsRef.current.set(agentName, el);
|
||||
} else {
|
||||
rosterElsRef.current.delete(agentName);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const saveSlotRect = useCallback((storyId: string, rect: DOMRect) => {
|
||||
savedSlotRectsRef.current.set(storyId, rect);
|
||||
}, []);
|
||||
|
||||
// ── Detect pipeline changes (runs before paint) ───────────────────────────
|
||||
// Sets pendingFlyIns so slot lozenges hide before the browser paints,
|
||||
// preventing a one-frame "flash" of the visible lozenge before fly-in.
|
||||
useLayoutEffect(() => {
|
||||
if (prevPipelineRef.current === null) {
|
||||
prevPipelineRef.current = pipeline;
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = prevPipelineRef.current;
|
||||
const allPrev = [
|
||||
...prev.backlog,
|
||||
...prev.current,
|
||||
...prev.qa,
|
||||
...prev.merge,
|
||||
];
|
||||
const allCurr = [
|
||||
...pipeline.backlog,
|
||||
...pipeline.current,
|
||||
...pipeline.qa,
|
||||
...pipeline.merge,
|
||||
];
|
||||
|
||||
const newFlyInStoryIds = new Set<string>();
|
||||
|
||||
for (const curr of allCurr) {
|
||||
const prevItem = allPrev.find((p) => p.story_id === curr.story_id);
|
||||
const agentChanged =
|
||||
curr.agent &&
|
||||
(!prevItem?.agent ||
|
||||
prevItem.agent.agent_name !== curr.agent.agent_name);
|
||||
if (agentChanged && curr.agent) {
|
||||
const label = curr.agent.model
|
||||
? `${curr.agent.agent_name} ${curr.agent.model}`
|
||||
: curr.agent.agent_name;
|
||||
pendingFlyInActionsRef.current.push({
|
||||
storyId: curr.story_id,
|
||||
agentName: curr.agent.agent_name,
|
||||
label,
|
||||
isActive: curr.agent.status === "running",
|
||||
});
|
||||
newFlyInStoryIds.add(curr.story_id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const prevItem of allPrev) {
|
||||
if (!prevItem.agent) continue;
|
||||
const currItem = allCurr.find((c) => c.story_id === prevItem.story_id);
|
||||
const agentRemoved =
|
||||
!currItem?.agent ||
|
||||
currItem.agent.agent_name !== prevItem.agent.agent_name;
|
||||
if (agentRemoved) {
|
||||
const label = prevItem.agent.model
|
||||
? `${prevItem.agent.agent_name} ${prevItem.agent.model}`
|
||||
: prevItem.agent.agent_name;
|
||||
pendingFlyOutActionsRef.current.push({
|
||||
storyId: prevItem.story_id,
|
||||
agentName: prevItem.agent.agent_name,
|
||||
label,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
prevPipelineRef.current = pipeline;
|
||||
|
||||
// Only hide slots for stories that have a matching roster element
|
||||
if (newFlyInStoryIds.size > 0) {
|
||||
const hideable = new Set<string>();
|
||||
for (const storyId of newFlyInStoryIds) {
|
||||
const action = pendingFlyInActionsRef.current.find(
|
||||
(a) => a.storyId === storyId,
|
||||
);
|
||||
if (action && rosterElsRef.current.has(action.agentName)) {
|
||||
hideable.add(storyId);
|
||||
}
|
||||
}
|
||||
if (hideable.size > 0) {
|
||||
setPendingFlyIns((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of hideable) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [pipeline]);
|
||||
|
||||
// ── Execute animations (runs after paint, DOM positions are stable) ───────
|
||||
useEffect(() => {
|
||||
const flyIns = [...pendingFlyInActionsRef.current];
|
||||
pendingFlyInActionsRef.current = [];
|
||||
const flyOuts = [...pendingFlyOutActionsRef.current];
|
||||
pendingFlyOutActionsRef.current = [];
|
||||
|
||||
for (const action of flyIns) {
|
||||
const rosterEl = rosterElsRef.current.get(action.agentName);
|
||||
const slotRect = savedSlotRectsRef.current.get(action.storyId);
|
||||
|
||||
if (!rosterEl || !slotRect) {
|
||||
// No roster element: immediately reveal the slot lozenge
|
||||
setPendingFlyIns((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(action.storyId);
|
||||
return next;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const rosterRect = rosterEl.getBoundingClientRect();
|
||||
const id = `fly-in-${action.agentName}-${action.storyId}-${Date.now()}`;
|
||||
activeFlyInPerStory.current.set(action.storyId, id);
|
||||
|
||||
setFlyingLozenges((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
label: action.label,
|
||||
isActive: action.isActive,
|
||||
startX: rosterRect.left,
|
||||
startY: rosterRect.top,
|
||||
endX: slotRect.left,
|
||||
endY: slotRect.top,
|
||||
flying: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// FLIP "Play" step: after two frames the transition begins
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setFlyingLozenges((prev) =>
|
||||
prev.map((l) => (l.id === id ? { ...l, flying: true } : l)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// After the transition completes, remove clone and reveal slot lozenge.
|
||||
// Only clear pendingFlyIns if this is still the active animation for
|
||||
// this story — a newer animation may have superseded this one.
|
||||
setTimeout(() => {
|
||||
setFlyingLozenges((prev) => prev.filter((l) => l.id !== id));
|
||||
if (activeFlyInPerStory.current.get(action.storyId) === id) {
|
||||
activeFlyInPerStory.current.delete(action.storyId);
|
||||
setPendingFlyIns((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(action.storyId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
for (const action of flyOuts) {
|
||||
const rosterEl = rosterElsRef.current.get(action.agentName);
|
||||
const slotRect = savedSlotRectsRef.current.get(action.storyId);
|
||||
if (!slotRect) continue;
|
||||
|
||||
// Keep the roster badge hidden while the clone is flying back.
|
||||
setFlyingOutAgents((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(action.agentName);
|
||||
return next;
|
||||
});
|
||||
|
||||
const rosterRect = rosterEl?.getBoundingClientRect();
|
||||
const id = `fly-out-${action.agentName}-${action.storyId}-${Date.now()}`;
|
||||
activeFlyOutPerAgent.current.set(action.agentName, id);
|
||||
|
||||
setFlyingLozenges((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
label: action.label,
|
||||
isActive: false,
|
||||
startX: slotRect.left,
|
||||
startY: slotRect.top,
|
||||
endX: rosterRect?.left ?? slotRect.left,
|
||||
endY: rosterRect?.top ?? Math.max(0, slotRect.top - 80),
|
||||
flying: false,
|
||||
},
|
||||
]);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setFlyingLozenges((prev) =>
|
||||
prev.map((l) => (l.id === id ? { ...l, flying: true } : l)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Only reveal the roster badge if this is still the active fly-out
|
||||
// for this agent — a newer fly-out may have superseded this one.
|
||||
setTimeout(() => {
|
||||
setFlyingLozenges((prev) => prev.filter((l) => l.id !== id));
|
||||
if (activeFlyOutPerAgent.current.get(action.agentName) === id) {
|
||||
activeFlyOutPerAgent.current.delete(action.agentName);
|
||||
setFlyingOutAgents((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(action.agentName);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, [pipeline]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
registerRosterEl,
|
||||
saveSlotRect,
|
||||
pendingFlyIns,
|
||||
hiddenRosterAgents,
|
||||
}),
|
||||
[registerRosterEl, saveSlotRect, pendingFlyIns, hiddenRosterAgents],
|
||||
);
|
||||
|
||||
return (
|
||||
<LozengeFlyContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{ReactDOM.createPortal(
|
||||
<FloatingLozengeSurface lozenges={flyingLozenges} />,
|
||||
document.body,
|
||||
)}
|
||||
</LozengeFlyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Portal surface ───────────────────────────────────────────────────────────
|
||||
|
||||
function FloatingLozengeSurface({ lozenges }: { lozenges: FlyingLozenge[] }) {
|
||||
return (
|
||||
<>
|
||||
{lozenges.map((l) => (
|
||||
<FlyingLozengeClone key={l.id} lozenge={l} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FlyingLozengeClone({ lozenge }: { lozenge: FlyingLozenge }) {
|
||||
const color = lozenge.isActive ? "#3fb950" : "#e3b341";
|
||||
const x = lozenge.flying ? lozenge.endX : lozenge.startX;
|
||||
const y = lozenge.flying ? lozenge.endY : lozenge.startY;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`flying-lozenge-${lozenge.id}`}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
zIndex: 9999,
|
||||
pointerEvents: "none",
|
||||
transition: lozenge.flying
|
||||
? "left 0.4s cubic-bezier(0.4, 0, 0.2, 1), top 0.4s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
: "none",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "5px",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "999px",
|
||||
fontSize: "0.72em",
|
||||
fontWeight: 600,
|
||||
background: `${color}18`,
|
||||
color,
|
||||
border: `1px solid ${color}44`,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{lozenge.isActive && (
|
||||
<span
|
||||
style={{
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
animation: "pulse 1.5s infinite",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{lozenge.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useLozengeFly(): LozengeFlyContextValue {
|
||||
return useContext(LozengeFlyContext);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MessageItem } from "./MessageItem";
|
||||
|
||||
vi.mock("../api/settings", () => ({
|
||||
settingsApi: {
|
||||
openFile: vi.fn(() => Promise.resolve({ success: true })),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("MessageItem component (Story 178 AC3)", () => {
|
||||
it("renders user message as a bubble", () => {
|
||||
render(<MessageItem msg={{ role: "user", content: "Hello there!" }} />);
|
||||
|
||||
expect(screen.getByText("Hello there!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders assistant message with markdown-body class", () => {
|
||||
render(
|
||||
<MessageItem
|
||||
msg={{ role: "assistant", content: "Here is my response." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Here is my response.")).toBeInTheDocument();
|
||||
const text = screen.getByText("Here is my response.");
|
||||
expect(text.closest(".markdown-body")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders tool message as collapsible details", () => {
|
||||
render(
|
||||
<MessageItem
|
||||
msg={{
|
||||
role: "tool",
|
||||
content: "tool output content",
|
||||
tool_call_id: "toolu_1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Tool Output/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tool call badges for assistant messages with tool_calls", () => {
|
||||
render(
|
||||
<MessageItem
|
||||
msg={{
|
||||
role: "assistant",
|
||||
content: "I will read the file.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "toolu_1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "Read",
|
||||
arguments: '{"file_path":"src/main.rs"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("I will read the file.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Read(src/main.rs)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is wrapped in React.memo (has displayName or $$typeof memo)", () => {
|
||||
// React.memo wraps the component — verify the export is memoized
|
||||
// by checking that the component has a memo wrapper
|
||||
const { type } = { type: MessageItem };
|
||||
// React.memo returns an object with $$typeof === Symbol(react.memo)
|
||||
// biome-ignore lint/suspicious/noExplicitAny: checking React internals for test
|
||||
expect((type as any).$$typeof).toBeDefined();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: checking React internals for test
|
||||
const typeofStr = String((type as any).$$typeof);
|
||||
expect(typeofStr).toContain("memo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MessageItem code reference rendering (Story 193)", () => {
|
||||
it("renders inline code with a code reference as a clickable button in assistant messages", () => {
|
||||
render(
|
||||
<MessageItem
|
||||
msg={{
|
||||
role: "assistant",
|
||||
content: "Check `src/main.rs:42` for the implementation.",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button", { name: /src\/main\.rs:42/ });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MessageItem user message code fence rendering (Story 196)", () => {
|
||||
it("renders code fences in user messages as code blocks", () => {
|
||||
const { container } = render(
|
||||
<MessageItem
|
||||
msg={{
|
||||
role: "user",
|
||||
content: "Here is some code:\n```js\nconsole.log('hi');\n```",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Syntax highlighter renders a pre > div > code structure
|
||||
const codeEl = container.querySelector("pre code");
|
||||
expect(codeEl).toBeInTheDocument();
|
||||
expect(codeEl?.textContent).toContain("console.log");
|
||||
});
|
||||
|
||||
it("renders inline code with single backticks in user messages", () => {
|
||||
render(
|
||||
<MessageItem
|
||||
msg={{ role: "user", content: "Use `npm install` to install." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const codeEl = screen.getByText("npm install");
|
||||
expect(codeEl.tagName.toLowerCase()).toBe("code");
|
||||
});
|
||||
|
||||
it("renders user messages with code blocks inside user-markdown-body class", () => {
|
||||
const { container } = render(
|
||||
<MessageItem
|
||||
msg={{
|
||||
role: "user",
|
||||
content: "```js\nconsole.log('hi');\n```",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".user-markdown-body")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -1,246 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
const { useCallback, useEffect, useRef, useState } = React;
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ServerLogsPanelProps {
|
||||
logs: LogEntry[];
|
||||
}
|
||||
|
||||
function levelColor(level: string): string {
|
||||
switch (level.toUpperCase()) {
|
||||
case "ERROR":
|
||||
return "#e06c75";
|
||||
case "WARN":
|
||||
return "#e5c07b";
|
||||
default:
|
||||
return "#98c379";
|
||||
}
|
||||
}
|
||||
|
||||
export function ServerLogsPanel({ logs }: ServerLogsPanelProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [severityFilter, setSeverityFilter] = useState<string>("ALL");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const userScrolledUpRef = useRef(false);
|
||||
const lastScrollTopRef = useRef(0);
|
||||
|
||||
const filteredLogs = logs.filter((entry) => {
|
||||
const matchesSeverity =
|
||||
severityFilter === "ALL" || entry.level.toUpperCase() === severityFilter;
|
||||
const matchesFilter =
|
||||
filter === "" ||
|
||||
entry.message.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
entry.timestamp.includes(filter);
|
||||
return matchesSeverity && matchesFilter;
|
||||
});
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastScrollTopRef.current = el.scrollTop;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-scroll when new entries arrive (unless user scrolled up).
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!userScrolledUpRef.current) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [filteredLogs.length, isOpen, scrollToBottom]);
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 5;
|
||||
if (el.scrollTop < lastScrollTopRef.current) {
|
||||
userScrolledUpRef.current = true;
|
||||
}
|
||||
if (isAtBottom) {
|
||||
userScrolledUpRef.current = false;
|
||||
}
|
||||
lastScrollTopRef.current = el.scrollTop;
|
||||
};
|
||||
|
||||
const severityButtons = ["ALL", "INFO", "WARN", "ERROR"] as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="server-logs-panel"
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #333",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header / toggle */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="server-logs-panel-toggle"
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "8px 12px",
|
||||
background: "#1e1e1e",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "#ccc",
|
||||
fontSize: "0.85em",
|
||||
fontWeight: 600,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span>Server Logs</span>
|
||||
<span style={{ color: "#666", fontSize: "0.85em" }}>
|
||||
{logs.length > 0 && (
|
||||
<span style={{ marginRight: "8px", color: "#555" }}>
|
||||
{logs.length}
|
||||
</span>
|
||||
)}
|
||||
{isOpen ? "▲" : "▼"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div style={{ background: "#0d1117" }}>
|
||||
{/* Filter controls */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "6px",
|
||||
padding: "8px",
|
||||
borderBottom: "1px solid #1e1e1e",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
data-testid="server-logs-filter-input"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter logs..."
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: "80px",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #333",
|
||||
background: "#161b22",
|
||||
color: "#ccc",
|
||||
fontSize: "0.8em",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
{severityButtons.map((sev) => (
|
||||
<button
|
||||
key={sev}
|
||||
type="button"
|
||||
data-testid={`server-logs-severity-${sev.toLowerCase()}`}
|
||||
onClick={() => setSeverityFilter(sev)}
|
||||
style={{
|
||||
padding: "3px 8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid",
|
||||
borderColor:
|
||||
severityFilter === sev ? levelColor(sev) : "#333",
|
||||
background:
|
||||
severityFilter === sev
|
||||
? "rgba(255,255,255,0.06)"
|
||||
: "transparent",
|
||||
color:
|
||||
sev === "ALL"
|
||||
? severityFilter === "ALL"
|
||||
? "#ccc"
|
||||
: "#555"
|
||||
: levelColor(sev),
|
||||
fontSize: "0.75em",
|
||||
cursor: "pointer",
|
||||
fontWeight: severityFilter === sev ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
{sev}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Log entries */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
data-testid="server-logs-entries"
|
||||
style={{
|
||||
maxHeight: "240px",
|
||||
overflowY: "auto",
|
||||
padding: "4px 0",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75em",
|
||||
}}
|
||||
>
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
color: "#444",
|
||||
textAlign: "center",
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
>
|
||||
No log entries
|
||||
</div>
|
||||
) : (
|
||||
filteredLogs.map((entry, idx) => (
|
||||
<div
|
||||
key={`${entry.timestamp}-${idx}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "6px",
|
||||
padding: "1px 8px",
|
||||
lineHeight: "1.5",
|
||||
borderBottom: "1px solid #111",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ color: "#444", flexShrink: 0, minWidth: "70px" }}
|
||||
>
|
||||
{entry.timestamp.replace("T", " ").replace("Z", "")}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: levelColor(entry.level),
|
||||
flexShrink: 0,
|
||||
minWidth: "38px",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{entry.level}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: "#c9d1d9",
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{entry.message}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import * as React from "react";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
const { useEffect, useRef } = React;
|
||||
|
||||
interface SideQuestionOverlayProps {
|
||||
question: string;
|
||||
/** Streaming response text. Empty while loading. */
|
||||
response: string;
|
||||
loading: boolean;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismissible overlay that shows a /btw side question and its streamed response.
|
||||
* The question and response are NOT part of the main conversation history.
|
||||
* Dismiss with Escape, Enter, or Space.
|
||||
*/
|
||||
export function SideQuestionOverlay({
|
||||
question,
|
||||
response,
|
||||
loading,
|
||||
onDismiss,
|
||||
}: SideQuestionOverlayProps) {
|
||||
const dismissRef = useRef(onDismiss);
|
||||
dismissRef.current = onDismiss;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" || e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
dismissRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss is supplementary; keyboard handled via window keydown
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: keyboard dismiss handled via window keydown listener
|
||||
<div
|
||||
data-testid="side-question-overlay"
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation only; no real interaction */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop-propagation only; no real interaction */}
|
||||
<div
|
||||
data-testid="side-question-panel"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: "#2f2f2f",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "12px",
|
||||
padding: "24px",
|
||||
maxWidth: "640px",
|
||||
width: "90vw",
|
||||
maxHeight: "60vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
color: "#a0d4a0",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
/btw
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
color: "#ececec",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
title="Dismiss (Escape, Enter, or Space)"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#666",
|
||||
cursor: "pointer",
|
||||
fontSize: "1.1rem",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Response area */}
|
||||
<div
|
||||
style={{
|
||||
overflowY: "auto",
|
||||
flex: 1,
|
||||
color: "#ccc",
|
||||
fontSize: "0.95rem",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
>
|
||||
{loading && !response && (
|
||||
<span style={{ color: "#666", fontStyle: "italic" }}>
|
||||
Thinking…
|
||||
</span>
|
||||
)}
|
||||
{response && <Markdown>{response}</Markdown>}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
{!loading && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "#555",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Press Escape, Enter, or Space to dismiss
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
import * as React from "react";
|
||||
import type { TokenUsageRecord } from "../api/client";
|
||||
import { api } from "../api/client";
|
||||
|
||||
type SortKey =
|
||||
| "timestamp"
|
||||
| "story_id"
|
||||
| "agent_name"
|
||||
| "model"
|
||||
| "total_cost_usd";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
function formatCost(usd: number): string {
|
||||
if (usd === 0) return "$0.00";
|
||||
if (usd < 0.001) return `$${usd.toFixed(6)}`;
|
||||
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
||||
return `$${usd.toFixed(3)}`;
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const h = String(d.getHours()).padStart(2, "0");
|
||||
const m = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day} ${h}:${m}`;
|
||||
}
|
||||
|
||||
/** Infer an agent type from the agent name. */
|
||||
function agentType(agentName: string): string {
|
||||
const lower = agentName.toLowerCase();
|
||||
if (lower.startsWith("coder")) return "coder";
|
||||
if (lower.startsWith("qa")) return "qa";
|
||||
if (lower.startsWith("mergemaster") || lower.startsWith("merge"))
|
||||
return "mergemaster";
|
||||
return "other";
|
||||
}
|
||||
|
||||
interface SortHeaderProps {
|
||||
label: string;
|
||||
sortKey: SortKey;
|
||||
current: SortKey;
|
||||
dir: SortDir;
|
||||
onSort: (key: SortKey) => void;
|
||||
align?: "left" | "right";
|
||||
}
|
||||
|
||||
function SortHeader({
|
||||
label,
|
||||
sortKey,
|
||||
current,
|
||||
dir,
|
||||
onSort,
|
||||
align = "left",
|
||||
}: SortHeaderProps) {
|
||||
const active = current === sortKey;
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
textAlign: align,
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
borderBottom: "1px solid #333",
|
||||
color: active ? "#ececec" : "#aaa",
|
||||
fontWeight: active ? "700" : "500",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: "0.8em",
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
onClick={() => onSort(sortKey)}
|
||||
>
|
||||
{label}
|
||||
{active ? (dir === "asc" ? " ↑" : " ↓") : ""}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
interface TokenUsagePageProps {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export function TokenUsagePage({
|
||||
projectPath: _projectPath,
|
||||
}: TokenUsagePageProps) {
|
||||
const [records, setRecords] = React.useState<TokenUsageRecord[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [sortKey, setSortKey] = React.useState<SortKey>("timestamp");
|
||||
const [sortDir, setSortDir] = React.useState<SortDir>("desc");
|
||||
|
||||
React.useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.getAllTokenUsage()
|
||||
.then((resp) => setRecords(resp.records))
|
||||
.catch((e) =>
|
||||
setError(e instanceof Error ? e.message : "Failed to load token usage"),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function handleSort(key: SortKey) {
|
||||
if (key === sortKey) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir(key === "timestamp" ? "desc" : "asc");
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = React.useMemo(() => {
|
||||
return [...records].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortKey) {
|
||||
case "timestamp":
|
||||
cmp = a.timestamp.localeCompare(b.timestamp);
|
||||
break;
|
||||
case "story_id":
|
||||
cmp = a.story_id.localeCompare(b.story_id);
|
||||
break;
|
||||
case "agent_name":
|
||||
cmp = a.agent_name.localeCompare(b.agent_name);
|
||||
break;
|
||||
case "model":
|
||||
cmp = (a.model ?? "").localeCompare(b.model ?? "");
|
||||
break;
|
||||
case "total_cost_usd":
|
||||
cmp = a.total_cost_usd - b.total_cost_usd;
|
||||
break;
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [records, sortKey, sortDir]);
|
||||
|
||||
// Compute summary totals
|
||||
const totalCost = records.reduce((s, r) => s + r.total_cost_usd, 0);
|
||||
|
||||
const byAgentType = React.useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const r of records) {
|
||||
const t = agentType(r.agent_name);
|
||||
map[t] = (map[t] ?? 0) + r.total_cost_usd;
|
||||
}
|
||||
return map;
|
||||
}, [records]);
|
||||
|
||||
const byModel = React.useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const r of records) {
|
||||
const m = r.model ?? "unknown";
|
||||
map[m] = (map[m] ?? 0) + r.total_cost_usd;
|
||||
}
|
||||
return map;
|
||||
}, [records]);
|
||||
|
||||
const cellStyle: React.CSSProperties = {
|
||||
padding: "7px 12px",
|
||||
borderBottom: "1px solid #222",
|
||||
fontSize: "0.85em",
|
||||
color: "#ccc",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
background: "#111",
|
||||
padding: "24px",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
color: "#ececec",
|
||||
margin: "0 0 20px",
|
||||
fontSize: "1.1em",
|
||||
fontWeight: "700",
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
Token Usage
|
||||
</h2>
|
||||
|
||||
{/* Summary totals */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "16px",
|
||||
flexWrap: "wrap",
|
||||
marginBottom: "24px",
|
||||
}}
|
||||
>
|
||||
<SummaryCard
|
||||
label="Total Cost"
|
||||
value={formatCost(totalCost)}
|
||||
highlight
|
||||
/>
|
||||
{Object.entries(byAgentType)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([type, cost]) => (
|
||||
<SummaryCard
|
||||
key={type}
|
||||
label={`${type.charAt(0).toUpperCase()}${type.slice(1)}`}
|
||||
value={formatCost(cost)}
|
||||
/>
|
||||
))}
|
||||
{Object.entries(byModel)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([model, cost]) => (
|
||||
<SummaryCard key={model} label={model} value={formatCost(cost)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<p style={{ color: "#555", fontSize: "0.9em" }}>Loading...</p>
|
||||
)}
|
||||
{error && <p style={{ color: "#e05c5c", fontSize: "0.9em" }}>{error}</p>}
|
||||
|
||||
{!loading && !error && records.length === 0 && (
|
||||
<p style={{ color: "#555", fontSize: "0.9em" }}>
|
||||
No token usage records found.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && records.length > 0 && (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ background: "#1a1a1a" }}>
|
||||
<SortHeader
|
||||
label="Date"
|
||||
sortKey="timestamp"
|
||||
current={sortKey}
|
||||
dir={sortDir}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortHeader
|
||||
label="Story"
|
||||
sortKey="story_id"
|
||||
current={sortKey}
|
||||
dir={sortDir}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortHeader
|
||||
label="Agent"
|
||||
sortKey="agent_name"
|
||||
current={sortKey}
|
||||
dir={sortDir}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortHeader
|
||||
label="Model"
|
||||
sortKey="model"
|
||||
current={sortKey}
|
||||
dir={sortDir}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<th
|
||||
style={{
|
||||
...cellStyle,
|
||||
borderBottom: "1px solid #333",
|
||||
textAlign: "right",
|
||||
color: "#aaa",
|
||||
fontSize: "0.8em",
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
Input
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
...cellStyle,
|
||||
borderBottom: "1px solid #333",
|
||||
textAlign: "right",
|
||||
color: "#aaa",
|
||||
fontSize: "0.8em",
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
Cache+
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
...cellStyle,
|
||||
borderBottom: "1px solid #333",
|
||||
textAlign: "right",
|
||||
color: "#aaa",
|
||||
fontSize: "0.8em",
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
Cache↩
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
...cellStyle,
|
||||
borderBottom: "1px solid #333",
|
||||
textAlign: "right",
|
||||
color: "#aaa",
|
||||
fontSize: "0.8em",
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
Output
|
||||
</th>
|
||||
<SortHeader
|
||||
label="Cost"
|
||||
sortKey="total_cost_usd"
|
||||
current={sortKey}
|
||||
dir={sortDir}
|
||||
onSort={handleSort}
|
||||
align="right"
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((r, i) => (
|
||||
<tr
|
||||
key={`${r.story_id}-${r.agent_name}-${r.timestamp}`}
|
||||
style={{ background: i % 2 === 0 ? "#111" : "#161616" }}
|
||||
>
|
||||
<td style={cellStyle}>{formatTimestamp(r.timestamp)}</td>
|
||||
<td
|
||||
style={{
|
||||
...cellStyle,
|
||||
color: "#8b9cf7",
|
||||
maxWidth: "220px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{r.story_id}
|
||||
</td>
|
||||
<td style={{ ...cellStyle, color: "#7ec8a4" }}>
|
||||
{r.agent_name}
|
||||
</td>
|
||||
<td style={{ ...cellStyle, color: "#c9a96e" }}>
|
||||
{r.model ?? "—"}
|
||||
</td>
|
||||
<td style={{ ...cellStyle, textAlign: "right" }}>
|
||||
{formatTokens(r.input_tokens)}
|
||||
</td>
|
||||
<td style={{ ...cellStyle, textAlign: "right" }}>
|
||||
{formatTokens(r.cache_creation_input_tokens)}
|
||||
</td>
|
||||
<td style={{ ...cellStyle, textAlign: "right" }}>
|
||||
{formatTokens(r.cache_read_input_tokens)}
|
||||
</td>
|
||||
<td style={{ ...cellStyle, textAlign: "right" }}>
|
||||
{formatTokens(r.output_tokens)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
...cellStyle,
|
||||
textAlign: "right",
|
||||
color: "#e08c5c",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{formatCost(r.total_cost_usd)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
highlight = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: highlight ? "#1e1e2e" : "#1a1a1a",
|
||||
border: `1px solid ${highlight ? "#3a3a5a" : "#2a2a2a"}`,
|
||||
borderRadius: "8px",
|
||||
padding: "12px 16px",
|
||||
minWidth: "120px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.7em",
|
||||
color: "#666",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.07em",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1.1em",
|
||||
fontWeight: "700",
|
||||
color: highlight ? "#c9a96e" : "#ececec",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,761 +0,0 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentEvent, AgentInfo } from "../api/agents";
|
||||
import type { TestResultsResponse, TokenCostResponse } from "../api/client";
|
||||
|
||||
vi.mock("../api/client", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../api/client")>("../api/client");
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
getWorkItemContent: vi.fn(),
|
||||
getTestResults: vi.fn(),
|
||||
getTokenCost: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../api/agents", () => ({
|
||||
agentsApi: {
|
||||
listAgents: vi.fn(),
|
||||
getAgentConfig: vi.fn(),
|
||||
stopAgent: vi.fn(),
|
||||
startAgent: vi.fn(),
|
||||
},
|
||||
subscribeAgentStream: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
||||
import { api } from "../api/client";
|
||||
|
||||
const { WorkItemDetailPanel } = await import("./WorkItemDetailPanel");
|
||||
|
||||
const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent);
|
||||
const mockedGetTestResults = vi.mocked(api.getTestResults);
|
||||
const mockedGetTokenCost = vi.mocked(api.getTokenCost);
|
||||
const mockedListAgents = vi.mocked(agentsApi.listAgents);
|
||||
const mockedGetAgentConfig = vi.mocked(agentsApi.getAgentConfig);
|
||||
const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream);
|
||||
|
||||
const DEFAULT_CONTENT = {
|
||||
content: "# Big Title\n\nSome content here.",
|
||||
stage: "current",
|
||||
name: "Big Title Story",
|
||||
agent: null,
|
||||
};
|
||||
|
||||
const sampleTestResults: TestResultsResponse = {
|
||||
unit: [
|
||||
{ name: "test_add", status: "pass", details: null },
|
||||
{ name: "test_subtract", status: "fail", details: "expected 3, got 4" },
|
||||
],
|
||||
integration: [{ name: "test_api_endpoint", status: "pass", details: null }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedGetWorkItemContent.mockResolvedValue(DEFAULT_CONTENT);
|
||||
mockedGetTestResults.mockResolvedValue(null);
|
||||
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
||||
mockedListAgents.mockResolvedValue([]);
|
||||
mockedGetAgentConfig.mockResolvedValue([]);
|
||||
mockedSubscribeAgentStream.mockReturnValue(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("WorkItemDetailPanel", () => {
|
||||
it("renders the story name in the header", async () => {
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="237_bug_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("detail-panel-title")).toHaveTextContent(
|
||||
"Big Title Story",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="237_bug_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("detail-panel-loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClose when close button is clicked", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="237_bug_test"
|
||||
pipelineVersion={0}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
const closeButton = screen.getByTestId("detail-panel-close");
|
||||
closeButton.click();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders markdown headings with constrained inline font size", async () => {
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="237_bug_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
const content = screen.getByTestId("detail-panel-content");
|
||||
const h1 = content.querySelector("h1");
|
||||
expect(h1).not.toBeNull();
|
||||
expect(h1?.style.fontSize).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkItemDetailPanel - Agent Logs", () => {
|
||||
it("shows placeholder when no agent is assigned to the story", async () => {
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
await screen.findByTestId("detail-panel-content");
|
||||
const placeholder = screen.getByTestId("placeholder-agent-logs");
|
||||
expect(placeholder).toBeInTheDocument();
|
||||
expect(placeholder).toHaveTextContent("Coming soon");
|
||||
});
|
||||
|
||||
it("shows agent name and running status when agent is running", async () => {
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusBadge = await screen.findByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("coder-1");
|
||||
expect(statusBadge).toHaveTextContent("running");
|
||||
});
|
||||
|
||||
it("shows log output when agent emits output events", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-status-badge");
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "output",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
text: "Writing tests...",
|
||||
});
|
||||
});
|
||||
|
||||
const logOutput = screen.getByTestId("agent-log-output");
|
||||
expect(logOutput).toHaveTextContent("Writing tests...");
|
||||
});
|
||||
|
||||
it("appends multiple output events to the log", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-status-badge");
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "output",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
text: "Line one\n",
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "output",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
text: "Line two\n",
|
||||
});
|
||||
});
|
||||
|
||||
const logOutput = screen.getByTestId("agent-log-output");
|
||||
expect(logOutput.textContent).toContain("Line one");
|
||||
expect(logOutput.textContent).toContain("Line two");
|
||||
});
|
||||
|
||||
it("updates status to completed after done event", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-status-badge");
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "done",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
session_id: "session-123",
|
||||
});
|
||||
});
|
||||
|
||||
const statusBadge = screen.getByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("completed");
|
||||
});
|
||||
|
||||
it("shows failed status after error event", async () => {
|
||||
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
||||
mockedSubscribeAgentStream.mockImplementation(
|
||||
(_storyId, _agentName, onEvent) => {
|
||||
emitEvent = onEvent;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-status-badge");
|
||||
|
||||
await act(async () => {
|
||||
emitEvent?.({
|
||||
type: "error",
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
message: "Process failed",
|
||||
});
|
||||
});
|
||||
|
||||
const statusBadge = screen.getByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("failed");
|
||||
|
||||
const logOutput = screen.getByTestId("agent-log-output");
|
||||
expect(logOutput.textContent).toContain("[ERROR] Process failed");
|
||||
});
|
||||
|
||||
it("shows completed agent status without subscribing to stream", async () => {
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "completed",
|
||||
session_id: "session-123",
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusBadge = await screen.findByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("completed");
|
||||
expect(mockedSubscribeAgentStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows failed agent status for a failed agent without subscribing to stream", async () => {
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "failed",
|
||||
session_id: null,
|
||||
worktree_path: null,
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusBadge = await screen.findByTestId("agent-status-badge");
|
||||
expect(statusBadge).toHaveTextContent("failed");
|
||||
expect(mockedSubscribeAgentStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows agent logs section (not placeholder) when agent is assigned", async () => {
|
||||
const agentList: AgentInfo[] = [
|
||||
{
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder-1",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: "/tmp/wt",
|
||||
base_branch: "master",
|
||||
log_session_id: null,
|
||||
},
|
||||
];
|
||||
mockedListAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("agent-logs-section");
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("placeholder-agent-logs"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkItemDetailPanel - Assigned Agent", () => {
|
||||
it("shows assigned agent name when agent front matter field is set", async () => {
|
||||
mockedGetWorkItemContent.mockResolvedValue({
|
||||
...DEFAULT_CONTENT,
|
||||
agent: "coder-opus",
|
||||
});
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="271_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const agentEl = await screen.findByTestId("detail-panel-assigned-agent");
|
||||
expect(agentEl).toHaveTextContent("coder-opus");
|
||||
});
|
||||
|
||||
it("omits assigned agent field when no agent is set in front matter", async () => {
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="271_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("detail-panel-content");
|
||||
expect(
|
||||
screen.queryByTestId("detail-panel-assigned-agent"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the specific agent name not just 'assigned'", async () => {
|
||||
mockedGetWorkItemContent.mockResolvedValue({
|
||||
...DEFAULT_CONTENT,
|
||||
agent: "coder-haiku",
|
||||
});
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="271_story_test"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const agentEl = await screen.findByTestId("detail-panel-assigned-agent");
|
||||
expect(agentEl).toHaveTextContent("coder-haiku");
|
||||
expect(agentEl).not.toHaveTextContent("assigned");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkItemDetailPanel - Test Results", () => {
|
||||
it("shows empty test results message when no results exist", async () => {
|
||||
mockedGetTestResults.mockResolvedValue(null);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("test-results-empty")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("No test results recorded")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows unit and integration test results when available", async () => {
|
||||
mockedGetTestResults.mockResolvedValue(sampleTestResults);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("test-results-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Unit test section
|
||||
expect(screen.getByTestId("test-section-unit")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Unit Tests (1 passed, 1 failed)"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Integration test section
|
||||
expect(screen.getByTestId("test-section-integration")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Integration Tests (1 passed, 0 failed)"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows pass/fail status and details for each test", async () => {
|
||||
mockedGetTestResults.mockResolvedValue(sampleTestResults);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("test-case-test_add")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Passing test
|
||||
expect(screen.getByTestId("test-status-test_add")).toHaveTextContent(
|
||||
"PASS",
|
||||
);
|
||||
expect(screen.getByText("test_add")).toBeInTheDocument();
|
||||
|
||||
// Failing test with details
|
||||
expect(screen.getByTestId("test-status-test_subtract")).toHaveTextContent(
|
||||
"FAIL",
|
||||
);
|
||||
expect(screen.getByText("test_subtract")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("test-details-test_subtract")).toHaveTextContent(
|
||||
"expected 3, got 4",
|
||||
);
|
||||
|
||||
// Integration test
|
||||
expect(
|
||||
screen.getByTestId("test-status-test_api_endpoint"),
|
||||
).toHaveTextContent("PASS");
|
||||
});
|
||||
|
||||
it("re-fetches test results when pipelineVersion changes", async () => {
|
||||
mockedGetTestResults.mockResolvedValue(null);
|
||||
|
||||
const { rerender } = render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetTestResults).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Update with new results and bump pipelineVersion.
|
||||
mockedGetTestResults.mockResolvedValue(sampleTestResults);
|
||||
|
||||
rerender(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={1}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetTestResults).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("test-results-content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkItemDetailPanel - Token Cost", () => {
|
||||
const sampleTokenCost: TokenCostResponse = {
|
||||
total_cost_usd: 0.012345,
|
||||
agents: [
|
||||
{
|
||||
agent_name: "coder-1",
|
||||
model: "claude-sonnet-4-6",
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_creation_input_tokens: 200,
|
||||
cache_read_input_tokens: 100,
|
||||
total_cost_usd: 0.009,
|
||||
},
|
||||
{
|
||||
agent_name: "coder-2",
|
||||
model: null,
|
||||
input_tokens: 800,
|
||||
output_tokens: 300,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: 0.003345,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("shows empty state when no token data exists", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("token-cost-empty")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("No token data recorded")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows per-agent breakdown and total cost when data exists", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("token-cost-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("token-cost-total")).toHaveTextContent(
|
||||
"$0.012345",
|
||||
);
|
||||
expect(screen.getByTestId("token-cost-agent-coder-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("token-cost-agent-coder-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows agent name and model when model is present", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("token-cost-agent-coder-1"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const agentRow = screen.getByTestId("token-cost-agent-coder-1");
|
||||
expect(agentRow).toHaveTextContent("coder-1");
|
||||
expect(agentRow).toHaveTextContent("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("shows agent name without model when model is null", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
||||
|
||||
render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("token-cost-agent-coder-2"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const agentRow = screen.getByTestId("token-cost-agent-coder-2");
|
||||
expect(agentRow).toHaveTextContent("coder-2");
|
||||
expect(agentRow).not.toHaveTextContent("null");
|
||||
});
|
||||
|
||||
it("re-fetches token cost when pipelineVersion changes", async () => {
|
||||
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
||||
|
||||
const { rerender } = render(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={0}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetTokenCost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
||||
|
||||
rerender(
|
||||
<WorkItemDetailPanel
|
||||
storyId="42_story_foo"
|
||||
pipelineVersion={1}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedGetTokenCost).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("token-cost-content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,787 +0,0 @@
|
||||
import * as React from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import type {
|
||||
AgentConfigInfo,
|
||||
AgentEvent,
|
||||
AgentInfo,
|
||||
AgentStatusValue,
|
||||
} from "../api/agents";
|
||||
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
||||
import type {
|
||||
AgentCostEntry,
|
||||
TestCaseResult,
|
||||
TestResultsResponse,
|
||||
TokenCostResponse,
|
||||
} from "../api/client";
|
||||
import { api } from "../api/client";
|
||||
|
||||
const { useCallback, useEffect, useRef, useState } = React;
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
backlog: "Backlog",
|
||||
current: "Current",
|
||||
qa: "QA",
|
||||
merge: "To Merge",
|
||||
done: "Done",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<AgentStatusValue, string> = {
|
||||
running: "#3fb950",
|
||||
pending: "#e3b341",
|
||||
completed: "#aaa",
|
||||
failed: "#f85149",
|
||||
};
|
||||
|
||||
interface WorkItemDetailPanelProps {
|
||||
storyId: string;
|
||||
pipelineVersion: number;
|
||||
onClose: () => void;
|
||||
/** True when the item is in QA and awaiting human review. */
|
||||
reviewHold?: boolean;
|
||||
}
|
||||
|
||||
function TestCaseRow({ tc }: { tc: TestCaseResult }) {
|
||||
const isPassing = tc.status === "pass";
|
||||
return (
|
||||
<div
|
||||
data-testid={`test-case-${tc.name}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
padding: "4px 0",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<span
|
||||
data-testid={`test-status-${tc.name}`}
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color: isPassing ? "#3fb950" : "#f85149",
|
||||
}}
|
||||
>
|
||||
{isPassing ? "PASS" : "FAIL"}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.82em", color: "#ccc" }}>{tc.name}</span>
|
||||
</div>
|
||||
{tc.details && (
|
||||
<div
|
||||
data-testid={`test-details-${tc.name}`}
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#888",
|
||||
paddingLeft: "22px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{tc.details}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TestSection({
|
||||
title,
|
||||
tests,
|
||||
testId,
|
||||
}: {
|
||||
title: string;
|
||||
tests: TestCaseResult[];
|
||||
testId: string;
|
||||
}) {
|
||||
const passCount = tests.filter((t) => t.status === "pass").length;
|
||||
const failCount = tests.length - passCount;
|
||||
return (
|
||||
<div data-testid={testId}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.78em",
|
||||
fontWeight: 600,
|
||||
color: "#aaa",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
{title} ({passCount} passed, {failCount} failed)
|
||||
</div>
|
||||
{tests.length === 0 ? (
|
||||
<div style={{ fontSize: "0.75em", color: "#555", fontStyle: "italic" }}>
|
||||
No tests recorded
|
||||
</div>
|
||||
) : (
|
||||
tests.map((tc) => <TestCaseRow key={tc.name} tc={tc} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkItemDetailPanel({
|
||||
storyId,
|
||||
pipelineVersion,
|
||||
onClose,
|
||||
reviewHold: _reviewHold,
|
||||
}: WorkItemDetailPanelProps) {
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [stage, setStage] = useState<string>("");
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [assignedAgent, setAssignedAgent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [agentInfo, setAgentInfo] = useState<AgentInfo | null>(null);
|
||||
const [agentLog, setAgentLog] = useState<string[]>([]);
|
||||
const [agentStatus, setAgentStatus] = useState<AgentStatusValue | null>(null);
|
||||
const [testResults, setTestResults] = useState<TestResultsResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [tokenCost, setTokenCost] = useState<TokenCostResponse | null>(null);
|
||||
const [agentConfig, setAgentConfig] = useState<AgentConfigInfo[]>([]);
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [assignError, setAssignError] = useState<string | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.getWorkItemContent(storyId)
|
||||
.then((data) => {
|
||||
setContent(data.content);
|
||||
setStage(data.stage);
|
||||
setName(data.name);
|
||||
setAssignedAgent(data.agent);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to load content");
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [storyId]);
|
||||
|
||||
// Fetch test results on mount and when pipeline updates arrive.
|
||||
useEffect(() => {
|
||||
api
|
||||
.getTestResults(storyId)
|
||||
.then((data) => {
|
||||
setTestResults(data);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore — test results may not exist yet.
|
||||
});
|
||||
}, [storyId, pipelineVersion]);
|
||||
|
||||
// Fetch token cost on mount and when pipeline updates arrive.
|
||||
useEffect(() => {
|
||||
api
|
||||
.getTokenCost(storyId)
|
||||
.then((data) => {
|
||||
setTokenCost(data);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore — token cost may not exist yet.
|
||||
});
|
||||
}, [storyId, pipelineVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanupRef.current?.();
|
||||
cleanupRef.current = null;
|
||||
setAgentInfo(null);
|
||||
setAgentLog([]);
|
||||
setAgentStatus(null);
|
||||
|
||||
agentsApi
|
||||
.listAgents()
|
||||
.then((agents) => {
|
||||
const agent = agents.find((a) => a.story_id === storyId);
|
||||
if (!agent) return;
|
||||
setAgentInfo(agent);
|
||||
setAgentStatus(agent.status);
|
||||
|
||||
if (agent.status === "running" || agent.status === "pending") {
|
||||
const cleanup = subscribeAgentStream(
|
||||
storyId,
|
||||
agent.agent_name,
|
||||
(event: AgentEvent) => {
|
||||
switch (event.type) {
|
||||
case "status":
|
||||
setAgentStatus((event.status as AgentStatusValue) ?? null);
|
||||
break;
|
||||
case "output":
|
||||
setAgentLog((prev) => [...prev, event.text ?? ""]);
|
||||
break;
|
||||
case "done":
|
||||
setAgentStatus("completed");
|
||||
break;
|
||||
case "error":
|
||||
setAgentStatus("failed");
|
||||
setAgentLog((prev) => [
|
||||
...prev,
|
||||
`[ERROR] ${event.message ?? "Unknown error"}`,
|
||||
]);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
cleanupRef.current = cleanup;
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("Failed to load agents:", err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cleanupRef.current?.();
|
||||
cleanupRef.current = null;
|
||||
};
|
||||
}, [storyId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
// Load agent config roster for the dropdown.
|
||||
useEffect(() => {
|
||||
agentsApi
|
||||
.getAgentConfig()
|
||||
.then((config) => {
|
||||
setAgentConfig(config);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("Failed to load agent config:", err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Map pipeline stage → agent stage filter.
|
||||
const STAGE_TO_AGENT_STAGE: Record<string, string> = {
|
||||
current: "coder",
|
||||
qa: "qa",
|
||||
merge: "mergemaster",
|
||||
};
|
||||
|
||||
const filteredAgents = agentConfig.filter(
|
||||
(a) => a.stage === STAGE_TO_AGENT_STAGE[stage],
|
||||
);
|
||||
|
||||
// The currently active agent name for this story (running or pending).
|
||||
const activeAgentName =
|
||||
agentInfo && (agentStatus === "running" || agentStatus === "pending")
|
||||
? agentInfo.agent_name
|
||||
: null;
|
||||
|
||||
const handleAgentAssign = useCallback(
|
||||
async (selectedAgentName: string) => {
|
||||
setAssigning(true);
|
||||
setAssignError(null);
|
||||
try {
|
||||
// Stop current running agent if there is one.
|
||||
if (activeAgentName) {
|
||||
await agentsApi.stopAgent(storyId, activeAgentName);
|
||||
}
|
||||
// Start the new agent (or skip if "none" selected).
|
||||
if (selectedAgentName) {
|
||||
await agentsApi.startAgent(storyId, selectedAgentName);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setAssignError(
|
||||
err instanceof Error ? err.message : "Failed to assign agent",
|
||||
);
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
},
|
||||
[storyId, activeAgentName],
|
||||
);
|
||||
|
||||
const stageLabel = STAGE_LABELS[stage] ?? stage;
|
||||
const hasTestResults =
|
||||
testResults &&
|
||||
(testResults.unit.length > 0 || testResults.integration.length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="work-item-detail-panel"
|
||||
ref={panelRef}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
background: "#1a1a1a",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #333",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 16px",
|
||||
borderBottom: "1px solid #333",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="detail-panel-title"
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.95em",
|
||||
color: "#ececec",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{name ?? storyId}
|
||||
</div>
|
||||
{stage && (
|
||||
<div
|
||||
data-testid="detail-panel-stage"
|
||||
style={{ fontSize: "0.75em", color: "#888" }}
|
||||
>
|
||||
{stageLabel}
|
||||
</div>
|
||||
)}
|
||||
{filteredAgents.length > 0 && (
|
||||
<div
|
||||
data-testid="detail-panel-agent-assignment"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "0.75em", color: "#666" }}>Agent:</span>
|
||||
<select
|
||||
data-testid="agent-assignment-dropdown"
|
||||
disabled={assigning}
|
||||
value={activeAgentName ?? assignedAgent ?? ""}
|
||||
onChange={(e) => handleAgentAssign(e.target.value)}
|
||||
style={{
|
||||
background: "#1a1a1a",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "#ccc",
|
||||
cursor: assigning ? "not-allowed" : "pointer",
|
||||
fontSize: "0.75em",
|
||||
padding: "2px 6px",
|
||||
opacity: assigning ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<option value="">— none —</option>
|
||||
{filteredAgents.map((a) => {
|
||||
const isRunning =
|
||||
agentInfo?.agent_name === a.name &&
|
||||
agentStatus === "running";
|
||||
const isPending =
|
||||
agentInfo?.agent_name === a.name &&
|
||||
agentStatus === "pending";
|
||||
const statusLabel = isRunning
|
||||
? " — running"
|
||||
: isPending
|
||||
? " — pending"
|
||||
: " — idle";
|
||||
const modelPart = a.model ? ` (${a.model})` : "";
|
||||
return (
|
||||
<option key={a.name} value={a.name}>
|
||||
{a.name}
|
||||
{modelPart}
|
||||
{statusLabel}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{assigning && (
|
||||
<span style={{ fontSize: "0.7em", color: "#888" }}>
|
||||
Assigning…
|
||||
</span>
|
||||
)}
|
||||
{assignError && (
|
||||
<span
|
||||
data-testid="agent-assignment-error"
|
||||
style={{ fontSize: "0.7em", color: "#f85149" }}
|
||||
>
|
||||
{assignError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filteredAgents.length === 0 && assignedAgent ? (
|
||||
<div
|
||||
data-testid="detail-panel-assigned-agent"
|
||||
style={{ fontSize: "0.75em", color: "#888" }}
|
||||
>
|
||||
Agent: {assignedAgent}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="detail-panel-close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "6px",
|
||||
color: "#aaa",
|
||||
cursor: "pointer",
|
||||
padding: "4px 10px",
|
||||
fontSize: "0.8em",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "16px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<div
|
||||
data-testid="detail-panel-loading"
|
||||
style={{ color: "#666", fontSize: "0.85em" }}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
data-testid="detail-panel-error"
|
||||
style={{ color: "#ff7b72", fontSize: "0.85em" }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && content !== null && (
|
||||
<div
|
||||
data-testid="detail-panel-content"
|
||||
className="markdown-body"
|
||||
style={{ fontSize: "0.9em", lineHeight: 1.6 }}
|
||||
>
|
||||
<Markdown
|
||||
components={{
|
||||
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
|
||||
h1: ({ children }: any) => (
|
||||
<h1 style={{ fontSize: "1.2em" }}>{children}</h1>
|
||||
),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
|
||||
h2: ({ children }: any) => (
|
||||
<h2 style={{ fontSize: "1.1em" }}>{children}</h2>
|
||||
),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
|
||||
h3: ({ children }: any) => (
|
||||
<h3 style={{ fontSize: "1em" }}>{children}</h3>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token Cost section */}
|
||||
<div
|
||||
data-testid="token-cost-section"
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: "#555",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Token Cost
|
||||
</div>
|
||||
{tokenCost && tokenCost.agents.length > 0 ? (
|
||||
<div data-testid="token-cost-content">
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#888",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Total:{" "}
|
||||
<span data-testid="token-cost-total" style={{ color: "#ccc" }}>
|
||||
${tokenCost.total_cost_usd.toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
{tokenCost.agents.map((agent: AgentCostEntry) => (
|
||||
<div
|
||||
key={agent.agent_name}
|
||||
data-testid={`token-cost-agent-${agent.agent_name}`}
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#888",
|
||||
padding: "4px 0",
|
||||
borderTop: "1px solid #222",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "2px",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#ccc", fontWeight: 600 }}>
|
||||
{agent.agent_name}
|
||||
{agent.model ? (
|
||||
<span
|
||||
style={{ color: "#666", fontWeight: 400 }}
|
||||
>{` (${agent.model})`}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span style={{ color: "#aaa" }}>
|
||||
${agent.total_cost_usd.toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: "#555" }}>
|
||||
in {agent.input_tokens.toLocaleString()} / out{" "}
|
||||
{agent.output_tokens.toLocaleString()}
|
||||
{(agent.cache_creation_input_tokens > 0 ||
|
||||
agent.cache_read_input_tokens > 0) && (
|
||||
<>
|
||||
{" "}
|
||||
/ cache +
|
||||
{agent.cache_creation_input_tokens.toLocaleString()}{" "}
|
||||
read {agent.cache_read_input_tokens.toLocaleString()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="token-cost-empty"
|
||||
style={{ fontSize: "0.75em", color: "#444" }}
|
||||
>
|
||||
No token data recorded
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Results section */}
|
||||
<div
|
||||
data-testid="test-results-section"
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: "#555",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Test Results
|
||||
</div>
|
||||
{hasTestResults ? (
|
||||
<div
|
||||
data-testid="test-results-content"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<TestSection
|
||||
title="Unit Tests"
|
||||
tests={testResults.unit}
|
||||
testId="test-section-unit"
|
||||
/>
|
||||
<TestSection
|
||||
title="Integration Tests"
|
||||
tests={testResults.integration}
|
||||
testId="test-section-integration"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="test-results-empty"
|
||||
style={{ fontSize: "0.75em", color: "#444" }}
|
||||
>
|
||||
No test results recorded
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
{/* Agent Logs section */}
|
||||
{!agentInfo && (
|
||||
<div
|
||||
data-testid="placeholder-agent-logs"
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: "#555",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
Agent Logs
|
||||
</div>
|
||||
<div style={{ fontSize: "0.75em", color: "#444" }}>
|
||||
Coming soon
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{agentInfo && (
|
||||
<div
|
||||
data-testid="agent-logs-section"
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
Agent Logs
|
||||
</div>
|
||||
{agentStatus && (
|
||||
<div
|
||||
data-testid="agent-status-badge"
|
||||
style={{
|
||||
fontSize: "0.7em",
|
||||
color: STATUS_COLORS[agentStatus],
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{agentInfo.agent_name} — {agentStatus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{agentLog.length > 0 ? (
|
||||
<div
|
||||
data-testid="agent-log-output"
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
fontFamily: "monospace",
|
||||
color: "#ccc",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
lineHeight: "1.5",
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{agentLog.join("")}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: "0.75em", color: "#444" }}>
|
||||
{agentStatus === "running" || agentStatus === "pending"
|
||||
? "Waiting for output..."
|
||||
: "No output."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Placeholder sections for future content */}
|
||||
{(
|
||||
[{ id: "coverage", label: "Coverage" }] as {
|
||||
id: string;
|
||||
label: string;
|
||||
}[]
|
||||
).map(({ id, label }) => (
|
||||
<div
|
||||
key={id}
|
||||
data-testid={`placeholder-${id}`}
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#161616",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8em",
|
||||
color: "#555",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.75em", color: "#444" }}>
|
||||
Coming soon
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
export interface ProjectPathMatch {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ProjectPathInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
suggestionTail: string;
|
||||
matchList: ProjectPathMatch[];
|
||||
selectedMatch: number;
|
||||
onSelectMatch: (index: number) => void;
|
||||
onAcceptMatch: (path: string) => void;
|
||||
onCloseSuggestions: () => void;
|
||||
currentPartial: string;
|
||||
}
|
||||
|
||||
function renderHighlightedMatch(text: string, query: string) {
|
||||
if (!query) return text;
|
||||
let qIndex = 0;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const counts = new Map<string, number>();
|
||||
return text.split("").map((char) => {
|
||||
const isMatch =
|
||||
qIndex < lowerQuery.length && char.toLowerCase() === lowerQuery[qIndex];
|
||||
if (isMatch) {
|
||||
qIndex += 1;
|
||||
}
|
||||
const count = counts.get(char) ?? 0;
|
||||
counts.set(char, count + 1);
|
||||
return (
|
||||
<span
|
||||
key={`${char}-${count}`}
|
||||
style={isMatch ? { fontWeight: 600, color: "#222" } : undefined}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function ProjectPathInput({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
suggestionTail,
|
||||
matchList,
|
||||
selectedMatch,
|
||||
onSelectMatch,
|
||||
onAcceptMatch,
|
||||
onCloseSuggestions,
|
||||
currentPartial,
|
||||
}: ProjectPathInputProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
marginTop: "12px",
|
||||
marginBottom: "170px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
padding: "10px",
|
||||
color: "#aaa",
|
||||
fontFamily: "monospace",
|
||||
whiteSpace: "pre",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
{suggestionTail}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder="/path/to/project"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
fontFamily: "monospace",
|
||||
background: "transparent",
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
{matchList.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
marginTop: "6px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "6px",
|
||||
overflow: "hidden",
|
||||
background: "#fff",
|
||||
fontFamily: "monospace",
|
||||
height: "160px",
|
||||
overflowY: "auto",
|
||||
boxSizing: "border-box",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
padding: "4px 6px",
|
||||
borderBottom: "1px solid #eee",
|
||||
background: "#fafafa",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close suggestions"
|
||||
onClick={onCloseSuggestions}
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ddd",
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{matchList.map((match, index) => {
|
||||
const isSelected = index === selectedMatch;
|
||||
return (
|
||||
<button
|
||||
key={match.path}
|
||||
type="button"
|
||||
onMouseEnter={() => onSelectMatch(index)}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onSelectMatch(index);
|
||||
onAcceptMatch(match.path);
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: "6px 8px",
|
||||
border: "none",
|
||||
background: isSelected ? "#f0f0f0" : "transparent",
|
||||
cursor: "pointer",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
{renderHighlightedMatch(match.name, currentPartial)}/
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
export interface RecentProjectsListProps {
|
||||
projects: string[];
|
||||
onOpenProject: (path: string) => void;
|
||||
onForgetProject: (path: string) => void;
|
||||
}
|
||||
|
||||
export function RecentProjectsList({
|
||||
projects,
|
||||
onOpenProject,
|
||||
onForgetProject,
|
||||
}: RecentProjectsListProps) {
|
||||
return (
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<div style={{ fontSize: "0.9em", color: "#666" }}>Recent projects</div>
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: "8px 0 0" }}>
|
||||
{projects.map((project) => {
|
||||
const displayName =
|
||||
project.split("/").filter(Boolean).pop() ?? project;
|
||||
return (
|
||||
<li key={project} style={{ marginBottom: "6px" }}>
|
||||
<div
|
||||
style={{ display: "flex", gap: "6px", alignItems: "center" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenProject(project)}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: "left",
|
||||
padding: "8px 10px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #ddd",
|
||||
background: "#f7f7f7",
|
||||
cursor: "pointer",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
title={project}
|
||||
>
|
||||
{displayName}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Forget ${displayName}`}
|
||||
onClick={() => onForgetProject(project)}
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #ddd",
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "1.1em",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SelectionScreenProps } from "./SelectionScreen";
|
||||
import { SelectionScreen } from "./SelectionScreen";
|
||||
|
||||
function makeProps(
|
||||
overrides: Partial<SelectionScreenProps> = {},
|
||||
): SelectionScreenProps {
|
||||
return {
|
||||
knownProjects: [],
|
||||
onOpenProject: vi.fn(),
|
||||
onForgetProject: vi.fn(),
|
||||
pathInput: "",
|
||||
homeDir: null,
|
||||
onPathInputChange: vi.fn(),
|
||||
onPathInputKeyDown: vi.fn() as (
|
||||
event: KeyboardEvent<HTMLInputElement>,
|
||||
) => void,
|
||||
isOpening: false,
|
||||
suggestionTail: "",
|
||||
matchList: [],
|
||||
selectedMatch: -1,
|
||||
onSelectMatch: vi.fn(),
|
||||
onAcceptMatch: vi.fn(),
|
||||
onCloseSuggestions: vi.fn(),
|
||||
completionError: null,
|
||||
currentPartial: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SelectionScreen", () => {
|
||||
it("renders the title and description", () => {
|
||||
render(<SelectionScreen {...makeProps()} />);
|
||||
expect(screen.getByText("Storkit")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Paste or complete a project path to start."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders recent projects list when knownProjects is non-empty", () => {
|
||||
render(
|
||||
<SelectionScreen
|
||||
{...makeProps({ knownProjects: ["/Users/test/project"] })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Recent projects")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render recent projects list when knownProjects is empty", () => {
|
||||
render(<SelectionScreen {...makeProps({ knownProjects: [] })} />);
|
||||
expect(screen.queryByText("Recent projects")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onOpenProject when Open Project button is clicked", () => {
|
||||
const onOpenProject = vi.fn();
|
||||
render(
|
||||
<SelectionScreen
|
||||
{...makeProps({ pathInput: "/my/path", onOpenProject })}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("Open Project"));
|
||||
expect(onOpenProject).toHaveBeenCalledWith("/my/path");
|
||||
});
|
||||
|
||||
it("shows Opening... text and disables buttons when isOpening is true", () => {
|
||||
render(<SelectionScreen {...makeProps({ isOpening: true })} />);
|
||||
expect(screen.getByText("Opening...")).toBeInTheDocument();
|
||||
const buttons = screen.getAllByRole("button");
|
||||
for (const button of buttons) {
|
||||
if (
|
||||
button.textContent === "Opening..." ||
|
||||
button.textContent === "New Project"
|
||||
) {
|
||||
expect(button).toBeDisabled();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("displays completion error when completionError is provided", () => {
|
||||
render(
|
||||
<SelectionScreen {...makeProps({ completionError: "Path not found" })} />,
|
||||
);
|
||||
expect(screen.getByText("Path not found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not display error div when completionError is null", () => {
|
||||
const { container } = render(<SelectionScreen {...makeProps()} />);
|
||||
const errorDiv = container.querySelector('[style*="color: red"]');
|
||||
expect(errorDiv).toBeNull();
|
||||
});
|
||||
|
||||
it("New Project button calls onPathInputChange with homeDir (trailing slash appended)", () => {
|
||||
const onPathInputChange = vi.fn();
|
||||
const onCloseSuggestions = vi.fn();
|
||||
render(
|
||||
<SelectionScreen
|
||||
{...makeProps({
|
||||
homeDir: "/Users/test",
|
||||
onPathInputChange,
|
||||
onCloseSuggestions,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("New Project"));
|
||||
expect(onPathInputChange).toHaveBeenCalledWith("/Users/test/");
|
||||
expect(onCloseSuggestions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("New Project button uses homeDir as-is when it already ends with /", () => {
|
||||
const onPathInputChange = vi.fn();
|
||||
const onCloseSuggestions = vi.fn();
|
||||
render(
|
||||
<SelectionScreen
|
||||
{...makeProps({
|
||||
homeDir: "/Users/test/",
|
||||
onPathInputChange,
|
||||
onCloseSuggestions,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("New Project"));
|
||||
expect(onPathInputChange).toHaveBeenCalledWith("/Users/test/");
|
||||
expect(onCloseSuggestions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("New Project button uses empty string when homeDir is null", () => {
|
||||
const onPathInputChange = vi.fn();
|
||||
render(
|
||||
<SelectionScreen {...makeProps({ homeDir: null, onPathInputChange })} />,
|
||||
);
|
||||
fireEvent.click(screen.getByText("New Project"));
|
||||
expect(onPathInputChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import type { KeyboardEvent } from "react";
|
||||
import { ProjectPathInput } from "./ProjectPathInput.tsx";
|
||||
import { RecentProjectsList } from "./RecentProjectsList.tsx";
|
||||
|
||||
export interface RecentProjectMatch {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface SelectionScreenProps {
|
||||
knownProjects: string[];
|
||||
onOpenProject: (path: string) => void;
|
||||
onForgetProject: (path: string) => void;
|
||||
pathInput: string;
|
||||
homeDir?: string | null;
|
||||
onPathInputChange: (value: string) => void;
|
||||
onPathInputKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
isOpening: boolean;
|
||||
suggestionTail: string;
|
||||
matchList: RecentProjectMatch[];
|
||||
selectedMatch: number;
|
||||
onSelectMatch: (index: number) => void;
|
||||
onAcceptMatch: (path: string) => void;
|
||||
onCloseSuggestions: () => void;
|
||||
completionError: string | null;
|
||||
currentPartial: string;
|
||||
}
|
||||
|
||||
export function SelectionScreen({
|
||||
knownProjects,
|
||||
onOpenProject,
|
||||
onForgetProject,
|
||||
pathInput,
|
||||
homeDir,
|
||||
onPathInputChange,
|
||||
onPathInputKeyDown,
|
||||
isOpening,
|
||||
suggestionTail,
|
||||
matchList,
|
||||
selectedMatch,
|
||||
onSelectMatch,
|
||||
onAcceptMatch,
|
||||
onCloseSuggestions,
|
||||
completionError,
|
||||
currentPartial,
|
||||
}: SelectionScreenProps) {
|
||||
const resolvedHomeDir = homeDir
|
||||
? homeDir.endsWith("/")
|
||||
? homeDir
|
||||
: `${homeDir}/`
|
||||
: "";
|
||||
return (
|
||||
<div
|
||||
className="selection-screen"
|
||||
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
|
||||
>
|
||||
<h1>Storkit</h1>
|
||||
<p>Paste or complete a project path to start.</p>
|
||||
|
||||
{knownProjects.length > 0 && (
|
||||
<RecentProjectsList
|
||||
projects={knownProjects}
|
||||
onOpenProject={onOpenProject}
|
||||
onForgetProject={onForgetProject}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProjectPathInput
|
||||
value={pathInput}
|
||||
onChange={onPathInputChange}
|
||||
onKeyDown={onPathInputKeyDown}
|
||||
suggestionTail={suggestionTail}
|
||||
matchList={matchList}
|
||||
selectedMatch={selectedMatch}
|
||||
onSelectMatch={onSelectMatch}
|
||||
onAcceptMatch={onAcceptMatch}
|
||||
onCloseSuggestions={onCloseSuggestions}
|
||||
currentPartial={currentPartial}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
marginTop: "8px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenProject(pathInput)}
|
||||
disabled={isOpening}
|
||||
>
|
||||
{isOpening ? "Opening..." : "Open Project"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPathInputChange(resolvedHomeDir);
|
||||
onCloseSuggestions();
|
||||
}}
|
||||
disabled={isOpening}
|
||||
>
|
||||
New Project
|
||||
</button>
|
||||
<div style={{ fontSize: "0.85em", color: "#666" }}>
|
||||
Press Tab to complete the next path segment
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{completionError && (
|
||||
<div style={{ color: "red", marginTop: "8px" }}>{completionError}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FileEntry } from "./usePathCompletion";
|
||||
import {
|
||||
getCurrentPartial,
|
||||
isFuzzyMatch,
|
||||
usePathCompletion,
|
||||
} from "./usePathCompletion";
|
||||
|
||||
describe("isFuzzyMatch", () => {
|
||||
it("matches when query is empty", () => {
|
||||
expect(isFuzzyMatch("anything", "")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches exact prefix", () => {
|
||||
expect(isFuzzyMatch("Documents", "Doc")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches fuzzy subsequence", () => {
|
||||
expect(isFuzzyMatch("Documents", "dms")).toBe(true);
|
||||
});
|
||||
|
||||
it("is case insensitive", () => {
|
||||
expect(isFuzzyMatch("Documents", "DOCU")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when chars not found in order", () => {
|
||||
expect(isFuzzyMatch("abc", "acb")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects completely unrelated", () => {
|
||||
expect(isFuzzyMatch("hello", "xyz")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentPartial", () => {
|
||||
it("returns empty for empty input", () => {
|
||||
expect(getCurrentPartial("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty when input ends with slash", () => {
|
||||
expect(getCurrentPartial("/home/user/")).toBe("");
|
||||
});
|
||||
|
||||
it("returns last segment", () => {
|
||||
expect(getCurrentPartial("/home/user/Doc")).toBe("Doc");
|
||||
});
|
||||
|
||||
it("returns full input when no slash", () => {
|
||||
expect(getCurrentPartial("Doc")).toBe("Doc");
|
||||
});
|
||||
|
||||
it("trims then evaluates: trailing-slash input returns empty", () => {
|
||||
// " /home/user/ " trims to "/home/user/" which ends with slash
|
||||
expect(getCurrentPartial(" /home/user/ ")).toBe("");
|
||||
});
|
||||
|
||||
it("trims then returns last segment", () => {
|
||||
expect(getCurrentPartial(" /home/user/Doc ")).toBe("Doc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePathCompletion hook", () => {
|
||||
const mockListDir = vi.fn<(path: string) => Promise<FileEntry[]>>();
|
||||
|
||||
beforeEach(() => {
|
||||
mockListDir.mockReset();
|
||||
});
|
||||
|
||||
it("returns empty matchList for empty input", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "",
|
||||
setPathInput: vi.fn(),
|
||||
homeDir: "/home/user",
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// Allow effect + setTimeout(0) to fire
|
||||
await waitFor(() => {
|
||||
expect(mockListDir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(result.current.matchList).toEqual([]);
|
||||
});
|
||||
|
||||
it("fetches directory listing and returns matches", async () => {
|
||||
mockListDir.mockResolvedValue([
|
||||
{ name: "Documents", kind: "dir" },
|
||||
{ name: "Downloads", kind: "dir" },
|
||||
{ name: ".bashrc", kind: "file" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "/home/user/",
|
||||
setPathInput: vi.fn(),
|
||||
homeDir: "/home/user",
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.matchList.length).toBe(2);
|
||||
});
|
||||
|
||||
expect(result.current.matchList[0].name).toBe("Documents");
|
||||
expect(result.current.matchList[1].name).toBe("Downloads");
|
||||
expect(result.current.matchList.every((m) => m.path.endsWith("/"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("filters by fuzzy match on partial input", async () => {
|
||||
mockListDir.mockResolvedValue([
|
||||
{ name: "Documents", kind: "dir" },
|
||||
{ name: "Downloads", kind: "dir" },
|
||||
{ name: "Desktop", kind: "dir" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "/home/user/Doc",
|
||||
setPathInput: vi.fn(),
|
||||
homeDir: "/home/user",
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.matchList.length).toBe(1);
|
||||
});
|
||||
|
||||
expect(result.current.matchList[0].name).toBe("Documents");
|
||||
});
|
||||
|
||||
it("calls setPathInput when acceptMatch is invoked", () => {
|
||||
const setPathInput = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "/home/",
|
||||
setPathInput,
|
||||
homeDir: "/home",
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.acceptMatch("/home/user/Documents/");
|
||||
});
|
||||
|
||||
expect(setPathInput).toHaveBeenCalledWith("/home/user/Documents/");
|
||||
});
|
||||
|
||||
it("uses homeDir when input has no slash (bare partial)", async () => {
|
||||
mockListDir.mockResolvedValue([
|
||||
{ name: "Documents", kind: "dir" },
|
||||
{ name: "Downloads", kind: "dir" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "Doc",
|
||||
setPathInput: vi.fn(),
|
||||
homeDir: "/home/user",
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.matchList.length).toBe(1);
|
||||
});
|
||||
|
||||
expect(mockListDir).toHaveBeenCalledWith("/home/user");
|
||||
expect(result.current.matchList[0].name).toBe("Documents");
|
||||
expect(result.current.matchList[0].path).toBe("/home/user/Documents/");
|
||||
});
|
||||
|
||||
it("returns early when input has no slash and homeDir is null", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "Doc",
|
||||
setPathInput: vi.fn(),
|
||||
homeDir: null,
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for debounce + effect to fire
|
||||
await waitFor(() => {
|
||||
expect(result.current.matchList).toEqual([]);
|
||||
});
|
||||
|
||||
expect(mockListDir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns empty matchList when no dirs match the fuzzy filter", async () => {
|
||||
mockListDir.mockResolvedValue([
|
||||
{ name: "Documents", kind: "dir" },
|
||||
{ name: "Downloads", kind: "dir" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "/home/user/zzz",
|
||||
setPathInput: vi.fn(),
|
||||
homeDir: "/home/user",
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListDir).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// No dirs match "zzz" fuzzy filter, so matchList stays empty
|
||||
expect(result.current.matchList).toEqual([]);
|
||||
});
|
||||
|
||||
it("sets completionError when listDirectoryAbsolute throws an Error", async () => {
|
||||
mockListDir.mockRejectedValue(new Error("Permission denied"));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "/root/",
|
||||
setPathInput: vi.fn(),
|
||||
homeDir: null,
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.completionError).toBe("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
it("sets generic completionError when listDirectoryAbsolute throws a non-Error", async () => {
|
||||
mockListDir.mockRejectedValue("some string error");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "/root/",
|
||||
setPathInput: vi.fn(),
|
||||
homeDir: null,
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.completionError).toBe(
|
||||
"Failed to compute suggestion.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears suggestionTail when selected match path does not start with input", async () => {
|
||||
mockListDir.mockResolvedValue([{ name: "Documents", kind: "dir" }]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "Doc",
|
||||
setPathInput: vi.fn(),
|
||||
homeDir: "/home/user",
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for matches to load (path will be /home/user/Documents/)
|
||||
await waitFor(() => {
|
||||
expect(result.current.matchList.length).toBe(1);
|
||||
});
|
||||
|
||||
// The match path is "/home/user/Documents/" which does NOT start with "Doc"
|
||||
// so suggestionTail should be ""
|
||||
expect(result.current.suggestionTail).toBe("");
|
||||
});
|
||||
|
||||
it("acceptSelectedMatch calls setPathInput with the selected match path", async () => {
|
||||
mockListDir.mockResolvedValue([
|
||||
{ name: "Documents", kind: "dir" },
|
||||
{ name: "Downloads", kind: "dir" },
|
||||
]);
|
||||
const setPathInput = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "/home/user/",
|
||||
setPathInput,
|
||||
homeDir: "/home/user",
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.matchList.length).toBe(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.acceptSelectedMatch();
|
||||
});
|
||||
|
||||
expect(setPathInput).toHaveBeenCalledWith("/home/user/Documents/");
|
||||
});
|
||||
|
||||
it("acceptSelectedMatch does nothing when matchList is empty", () => {
|
||||
const setPathInput = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "",
|
||||
setPathInput,
|
||||
homeDir: "/home/user",
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.acceptSelectedMatch();
|
||||
});
|
||||
|
||||
expect(setPathInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closeSuggestions clears matchList, selectedMatch, suggestionTail, and completionError", async () => {
|
||||
mockListDir.mockResolvedValue([{ name: "Documents", kind: "dir" }]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "/home/user/",
|
||||
setPathInput: vi.fn(),
|
||||
homeDir: "/home/user",
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.matchList.length).toBe(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.closeSuggestions();
|
||||
});
|
||||
|
||||
expect(result.current.matchList).toEqual([]);
|
||||
expect(result.current.selectedMatch).toBe(0);
|
||||
expect(result.current.suggestionTail).toBe("");
|
||||
expect(result.current.completionError).toBeNull();
|
||||
});
|
||||
|
||||
it("uses homeDir with trailing slash as-is", async () => {
|
||||
mockListDir.mockResolvedValue([{ name: "Projects", kind: "dir" }]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "Pro",
|
||||
setPathInput: vi.fn(),
|
||||
homeDir: "/home/user/",
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.matchList.length).toBe(1);
|
||||
});
|
||||
|
||||
expect(mockListDir).toHaveBeenCalledWith("/home/user");
|
||||
expect(result.current.matchList[0].path).toBe("/home/user/Projects/");
|
||||
});
|
||||
|
||||
it("handles root directory listing (dir = '/')", async () => {
|
||||
mockListDir.mockResolvedValue([
|
||||
{ name: "home", kind: "dir" },
|
||||
{ name: "etc", kind: "dir" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "/",
|
||||
setPathInput: vi.fn(),
|
||||
homeDir: null,
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.matchList.length).toBe(2);
|
||||
});
|
||||
|
||||
expect(mockListDir).toHaveBeenCalledWith("/");
|
||||
expect(result.current.matchList[0].name).toBe("etc");
|
||||
expect(result.current.matchList[1].name).toBe("home");
|
||||
});
|
||||
|
||||
it("computes suggestionTail when match path starts with trimmed input", async () => {
|
||||
mockListDir.mockResolvedValue([{ name: "Documents", kind: "dir" }]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "/home/user/",
|
||||
setPathInput: vi.fn(),
|
||||
homeDir: "/home/user",
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.matchList.length).toBe(1);
|
||||
});
|
||||
|
||||
// path is "/home/user/Documents/" and input is "/home/user/"
|
||||
// so tail should be "Documents/"
|
||||
expect(result.current.suggestionTail).toBe("Documents/");
|
||||
});
|
||||
|
||||
it("setSelectedMatch updates the selected index", async () => {
|
||||
mockListDir.mockResolvedValue([
|
||||
{ name: "Documents", kind: "dir" },
|
||||
{ name: "Downloads", kind: "dir" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePathCompletion({
|
||||
pathInput: "/home/user/",
|
||||
setPathInput: vi.fn(),
|
||||
homeDir: "/home/user",
|
||||
listDirectoryAbsolute: mockListDir,
|
||||
debounceMs: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.matchList.length).toBe(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedMatch(1);
|
||||
});
|
||||
|
||||
expect(result.current.selectedMatch).toBe(1);
|
||||
// After selecting index 1, suggestionTail should reflect "Downloads/"
|
||||
expect(result.current.suggestionTail).toBe("Downloads/");
|
||||
});
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
kind: "file" | "dir";
|
||||
}
|
||||
|
||||
export interface ProjectPathMatch {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface UsePathCompletionArgs {
|
||||
pathInput: string;
|
||||
setPathInput: (value: string) => void;
|
||||
homeDir: string | null;
|
||||
listDirectoryAbsolute: (path: string) => Promise<FileEntry[]>;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export interface UsePathCompletionResult {
|
||||
matchList: ProjectPathMatch[];
|
||||
selectedMatch: number;
|
||||
suggestionTail: string;
|
||||
completionError: string | null;
|
||||
currentPartial: string;
|
||||
setSelectedMatch: (index: number) => void;
|
||||
acceptSelectedMatch: () => void;
|
||||
acceptMatch: (path: string) => void;
|
||||
closeSuggestions: () => void;
|
||||
}
|
||||
|
||||
export function isFuzzyMatch(candidate: string, query: string) {
|
||||
if (!query) return true;
|
||||
const lowerCandidate = candidate.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
let idx = 0;
|
||||
for (const char of lowerQuery) {
|
||||
idx = lowerCandidate.indexOf(char, idx);
|
||||
if (idx === -1) return false;
|
||||
idx += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getCurrentPartial(input: string) {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.endsWith("/")) return "";
|
||||
const idx = trimmed.lastIndexOf("/");
|
||||
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed;
|
||||
}
|
||||
|
||||
export function usePathCompletion({
|
||||
pathInput,
|
||||
setPathInput,
|
||||
homeDir,
|
||||
listDirectoryAbsolute,
|
||||
debounceMs = 60,
|
||||
}: UsePathCompletionArgs): UsePathCompletionResult {
|
||||
const [matchList, setMatchList] = React.useState<ProjectPathMatch[]>([]);
|
||||
const [selectedMatch, setSelectedMatch] = React.useState(0);
|
||||
const [suggestionTail, setSuggestionTail] = React.useState("");
|
||||
const [completionError, setCompletionError] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function computeSuggestion() {
|
||||
setCompletionError(null);
|
||||
setSuggestionTail("");
|
||||
setMatchList([]);
|
||||
setSelectedMatch(0);
|
||||
|
||||
const trimmed = pathInput.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const endsWithSlash = trimmed.endsWith("/");
|
||||
let dir = trimmed;
|
||||
let partial = "";
|
||||
|
||||
if (!endsWithSlash) {
|
||||
const idx = trimmed.lastIndexOf("/");
|
||||
if (idx >= 0) {
|
||||
dir = trimmed.slice(0, idx + 1);
|
||||
partial = trimmed.slice(idx + 1);
|
||||
} else {
|
||||
dir = "";
|
||||
partial = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dir) {
|
||||
if (homeDir) {
|
||||
dir = homeDir.endsWith("/") ? homeDir : `${homeDir}/`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dirForListing = dir === "/" ? "/" : dir.replace(/\/+$/, "");
|
||||
const entries = await listDirectoryAbsolute(dirForListing);
|
||||
if (!active) return;
|
||||
|
||||
const matches = entries
|
||||
.filter((entry) => entry.kind === "dir")
|
||||
.filter((entry) => isFuzzyMatch(entry.name, partial))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.slice(0, 8);
|
||||
|
||||
if (matches.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const basePrefix = dir.endsWith("/") ? dir : `${dir}/`;
|
||||
const list = matches.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: `${basePrefix}${entry.name}/`,
|
||||
}));
|
||||
setMatchList(list);
|
||||
}
|
||||
|
||||
const debounceId = window.setTimeout(() => {
|
||||
computeSuggestion().catch((error) => {
|
||||
console.error(error);
|
||||
if (!active) return;
|
||||
setCompletionError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to compute suggestion.",
|
||||
);
|
||||
});
|
||||
}, debounceMs);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearTimeout(debounceId);
|
||||
};
|
||||
}, [pathInput, homeDir, listDirectoryAbsolute, debounceMs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (matchList.length === 0) {
|
||||
setSuggestionTail("");
|
||||
return;
|
||||
}
|
||||
const index = Math.min(selectedMatch, matchList.length - 1);
|
||||
const next = matchList[index];
|
||||
const trimmed = pathInput.trim();
|
||||
if (next.path.startsWith(trimmed)) {
|
||||
setSuggestionTail(next.path.slice(trimmed.length));
|
||||
} else {
|
||||
setSuggestionTail("");
|
||||
}
|
||||
}, [matchList, selectedMatch, pathInput]);
|
||||
|
||||
const acceptMatch = React.useCallback(
|
||||
(path: string) => {
|
||||
setPathInput(path);
|
||||
},
|
||||
[setPathInput],
|
||||
);
|
||||
|
||||
const acceptSelectedMatch = React.useCallback(() => {
|
||||
const next = matchList[selectedMatch]?.path;
|
||||
if (next) {
|
||||
setPathInput(next);
|
||||
}
|
||||
}, [matchList, selectedMatch, setPathInput]);
|
||||
|
||||
const closeSuggestions = React.useCallback(() => {
|
||||
setMatchList([]);
|
||||
setSelectedMatch(0);
|
||||
setSuggestionTail("");
|
||||
setCompletionError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
matchList,
|
||||
selectedMatch,
|
||||
suggestionTail,
|
||||
completionError,
|
||||
currentPartial: getCurrentPartial(pathInput),
|
||||
setSelectedMatch,
|
||||
acceptSelectedMatch,
|
||||
acceptMatch,
|
||||
closeSuggestions,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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 }));
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
14
frontend/src/vite-env.d.ts
vendored
14
frontend/src/vite-env.d.ts
vendored
@@ -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 {};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user