storkit: merge 374_story_web_ui_implements_all_bot_commands_as_slash_commands

This commit is contained in:
dave
2026-03-23 18:33:13 +00:00
parent 6d3eab92fd
commit 1a3b69301a
11 changed files with 774 additions and 146 deletions

View File

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

View File

@@ -1,27 +1,27 @@
import { defineConfig } from "@playwright/test";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "@playwright/test";
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,
},
testDir: "./tests/e2e",
fullyParallel: true,
timeout: 30_000,
expect: {
timeout: 5_000,
},
use: {
baseURL: "http://127.0.0.1:41700",
trace: "on-first-retry",
},
webServer: {
command:
"pnpm exec vite --config vite.config.ts --host 127.0.0.1 --port 41700 --strictPort",
url: "http://127.0.0.1:41700/@vite/client",
reuseExistingServer: true,
timeout: 120_000,
cwd: frontendRoot,
},
});

View File

@@ -382,6 +382,14 @@ export const api = {
deleteStory(storyId: string) {
return callMcpTool("delete_story", { story_id: storyId });
},
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
botCommand(command: string, args: string, baseUrl?: string) {
return requestJson<{ response: string }>(
"/bot/command",
{ method: "POST", body: JSON.stringify({ command, args }) },
baseUrl,
);
},
};
async function callMcpTool(

View File

@@ -40,6 +40,7 @@ vi.mock("../api/client", () => {
setAnthropicApiKey: vi.fn(),
readFile: vi.fn(),
listProjectFiles: vi.fn(),
botCommand: vi.fn(),
};
class ChatWebSocket {
connect(handlers: WsHandlers) {
@@ -64,6 +65,7 @@ const mockedApi = {
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
readFile: vi.mocked(api.readFile),
listProjectFiles: vi.mocked(api.listProjectFiles),
botCommand: vi.mocked(api.botCommand),
};
function setupMocks() {
@@ -76,6 +78,7 @@ function setupMocks() {
mockedApi.listProjectFiles.mockResolvedValue([]);
mockedApi.cancelChat.mockResolvedValue(true);
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
mockedApi.botCommand.mockResolvedValue({ response: "Bot response" });
}
describe("Default provider selection (Story 206)", () => {
@@ -1457,3 +1460,204 @@ describe("File reference expansion (Story 269 AC4)", () => {
expect(mockedApi.readFile).not.toHaveBeenCalled();
});
});
describe("Slash command handling (Story 374)", () => {
beforeEach(() => {
capturedWsHandlers = null;
lastSendChatArgs = null;
setupMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it("AC: /status calls botCommand and displays response", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "Pipeline: 3 active" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/status" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith(
"status",
"",
undefined,
);
});
expect(await screen.findByText("Pipeline: 3 active")).toBeInTheDocument();
// Should NOT go to LLM
expect(lastSendChatArgs).toBeNull();
});
it("AC: /status <number> passes args to botCommand", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "Story 42 details" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/status 42" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith(
"status",
"42",
undefined,
);
});
});
it("AC: /start <number> calls botCommand", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "Started agent" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/start 42 opus" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith(
"start",
"42 opus",
undefined,
);
});
expect(await screen.findByText("Started agent")).toBeInTheDocument();
});
it("AC: /git calls botCommand", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "On branch main" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/git" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "", undefined);
});
});
it("AC: /cost calls botCommand", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "$1.23 today" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/cost" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "", undefined);
});
});
it("AC: /reset clears messages and session without LLM", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
// First add a message so there is history to clear
act(() => {
capturedWsHandlers?.onUpdate([
{ role: "user", content: "hello" },
{ role: "assistant", content: "world" },
]);
});
expect(await screen.findByText("world")).toBeInTheDocument();
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/reset" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
// LLM must NOT be invoked
expect(lastSendChatArgs).toBeNull();
// botCommand must NOT be invoked (reset is frontend-only)
expect(mockedApi.botCommand).not.toHaveBeenCalled();
// Confirmation message should appear
expect(await screen.findByText(/Session reset/)).toBeInTheDocument();
});
it("AC: unrecognised slash command shows error message", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/foobar" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
expect(await screen.findByText(/Unknown command/)).toBeInTheDocument();
// Should NOT go to LLM
expect(lastSendChatArgs).toBeNull();
// Should NOT call botCommand
expect(mockedApi.botCommand).not.toHaveBeenCalled();
});
it("AC: /help shows help overlay", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/help" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
expect(await screen.findByTestId("help-overlay")).toBeInTheDocument();
expect(lastSendChatArgs).toBeNull();
expect(mockedApi.botCommand).not.toHaveBeenCalled();
});
it("AC: botCommand API error shows error message in chat", async () => {
mockedApi.botCommand.mockRejectedValue(new Error("Server error"));
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/git" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
expect(
await screen.findByText(/Error running command/),
).toBeInTheDocument();
});
});

View File

@@ -612,6 +612,80 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
return;
}
// /reset — clear session and message history without LLM
if (/^\/reset\s*$/i.test(messageText)) {
setMessages([]);
setClaudeSessionId(null);
setStreamingContent("");
setStreamingThinking("");
setActivityStatus(null);
setMessages([
{
role: "assistant",
content: "Session reset. Starting a fresh conversation.",
},
]);
return;
}
// Slash commands forwarded to the backend bot command endpoint
const slashMatch = messageText.match(/^\/(\S+)(?:\s+([\s\S]*))?$/);
if (slashMatch) {
const cmd = slashMatch[1].toLowerCase();
const args = (slashMatch[2] ?? "").trim();
// Ignore commands handled elsewhere
if (cmd !== "btw") {
const knownCommands = new Set([
"status",
"assign",
"start",
"show",
"move",
"delete",
"cost",
"git",
"overview",
"rebuild",
]);
if (knownCommands.has(cmd)) {
// Show the slash command in chat as a user message (display only)
setMessages((prev: Message[]) => [
...prev,
{ role: "user", content: messageText },
]);
try {
const result = await api.botCommand(cmd, args, undefined);
setMessages((prev: Message[]) => [
...prev,
{ role: "assistant", content: result.response },
]);
} catch (e) {
setMessages((prev: Message[]) => [
...prev,
{
role: "assistant",
content: `**Error running command:** ${e}`,
},
]);
}
return;
}
// Unknown slash command
setMessages((prev: Message[]) => [
...prev,
{ role: "user", content: messageText },
{
role: "assistant",
content: `Unknown command: \`/${cmd}\`. Type \`/help\` to see available commands.`,
},
]);
return;
}
}
// /btw <question> — answered from context without disrupting main chat
const btwMatch = messageText.match(/^\/btw\s+(.+)/s);
if (btwMatch) {

View File

@@ -12,6 +12,57 @@ const SLASH_COMMANDS: SlashCommand[] = [
name: "/help",
description: "Show this list of available slash commands.",
},
{
name: "/status",
description:
"Show pipeline status and agent availability. `/status <number>` shows a story triage dump.",
},
{
name: "/assign <number> <model>",
description: "Pre-assign a model to a story (e.g. `/assign 42 opus`).",
},
{
name: "/start <number>",
description:
"Start a coder on a story. Optionally specify a model: `/start <number> opus`.",
},
{
name: "/show <number>",
description: "Display the full text of a work item.",
},
{
name: "/move <number> <stage>",
description:
"Move a work item to a pipeline stage (backlog, current, qa, merge, done).",
},
{
name: "/delete <number>",
description:
"Remove a work item from the pipeline and stop any running agent.",
},
{
name: "/cost",
description:
"Show token spend: 24h total, top stories, breakdown by agent type, and all-time total.",
},
{
name: "/git",
description:
"Show git status: branch, uncommitted changes, and ahead/behind remote.",
},
{
name: "/overview <number>",
description: "Show the implementation summary for a merged story.",
},
{
name: "/rebuild",
description: "Rebuild the server binary and restart.",
},
{
name: "/reset",
description:
"Clear the current Claude Code session and start fresh (messages and session ID are cleared locally).",
},
{
name: "/btw <question>",
description:

View File

@@ -1,24 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"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",
/* 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"]
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

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

View File

@@ -2,26 +2,26 @@ 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",
},
},
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",
},
},
});