From 78dc838f36218c279166556f91ba2fb82229790b Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 6 Mar 2026 12:09:30 -0800 Subject: [PATCH 01/16] feat: project context panel for persistent PROJECT/REQUIREMENTS/ROADMAP/STATE files (#188) - Add projectContext store with load/save via existing Tauri file commands - Add ProjectContextPanel modal with tabbed editor, file-exists badges, and templates - Add injectTextStore signal so StatusBar can inject content directly into InputBar - Add PROJECT_CONTEXT_SYSTEM_ADDENDUM auto-appended to custom_instructions on connect - Add open-book button to StatusBar to open the panel - Add PROJECT.md example file for the hikari-desktop project itself - 34 tests covering all store methods, exports, and signal behaviour --- PROJECT.md | 45 +++ src/lib/components/InputBar.svelte | 9 + src/lib/components/ProjectContextPanel.svelte | 225 ++++++++++++++ src/lib/components/StatusBar.svelte | 35 ++- src/lib/stores/projectContext.test.ts | 287 ++++++++++++++++++ src/lib/stores/projectContext.ts | 155 ++++++++++ 6 files changed, 754 insertions(+), 2 deletions(-) create mode 100644 PROJECT.md create mode 100644 src/lib/components/ProjectContextPanel.svelte create mode 100644 src/lib/stores/projectContext.test.ts create mode 100644 src/lib/stores/projectContext.ts diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..9a97193 --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,45 @@ +# Project Overview + +## What is this project? + +Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character companion (Hikari) who appears on screen. It provides a rich UI for interacting with Claude Code, including conversation management, agent monitoring, cost tracking, and more. + +The app was inspired by a Hatsune Miku mod for the ship AI in _The Outer Worlds_ — the idea of an AI assistant with an anime girl avatar that you can actually _see_. + +## Goals + +- Provide a beautiful, personalised interface for Claude Code +- Surface real-time status (thinking, typing, searching, etc.) through animated character sprites +- Track costs, context usage, and agent activity across sessions +- Support power-user workflows: multi-tab conversations, todo lists, git integration, MCP server management, session compaction, and more +- Build a foundation for autonomous task execution (agent orchestration, PRD-driven workflows) + +## Tech Stack + +- **Frontend**: Svelte 5 + TypeScript + Tailwind CSS +- **Backend**: Rust (Tauri v2) +- **Build**: Vite + pnpm +- **Testing**: Vitest (frontend) + cargo test (backend) +- **Linting**: ESLint + Prettier (frontend) + Clippy (backend) +- **IPC**: Tauri commands + events between Rust and Svelte + +## Architecture + +``` +hikari-desktop/ +├── src/ # Svelte frontend +│ └── lib/ +│ ├── components/ # UI components (panels, modals, status bar) +│ ├── stores/ # Svelte stores (state management) +│ ├── types/ # TypeScript type definitions +│ └── utils/ # Utility functions +├── src-tauri/ # Rust backend +│ └── src/ +│ ├── commands.rs # Tauri command handlers +│ ├── wsl_bridge.rs # Claude Code process management +│ ├── types.rs # Shared types & CharacterState enum +│ └── stats.rs # Cost tracking +└── public/ # Static assets (sprites, sounds) +``` + +Claude Code is launched as a child process via `WslBridge`, communicating via `--output-format stream-json` (NDJSON). Messages flow from the Rust backend to the Svelte frontend via Tauri events. diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index aa63b6a..024aedc 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -37,6 +37,7 @@ import DraftPanel from "$lib/components/DraftPanel.svelte"; import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte"; import { draftsStore } from "$lib/stores/drafts"; + import { injectTextStore } from "$lib/stores/projectContext"; import type { Attachment } from "$lib/types/messages"; const INPUT_HISTORY_KEY = "hikari-input-history"; @@ -178,6 +179,14 @@ } }); + // Project context injection — set by StatusBar via injectTextStore signal. + injectTextStore.subscribe((text) => { + if (text === null) return; + inputValue = inputValue.trim() ? text + "\n\n" + inputValue : text; + userHasTyped = true; + injectTextStore.set(null); + }); + function clearInput() { inputValue = ""; const activeId = get(claudeStore.activeConversationId); diff --git a/src/lib/components/ProjectContextPanel.svelte b/src/lib/components/ProjectContextPanel.svelte new file mode 100644 index 0000000..1a807ee --- /dev/null +++ b/src/lib/components/ProjectContextPanel.svelte @@ -0,0 +1,225 @@ + + +
e.key === "Escape" && onClose()} +> +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-labelledby="project-context-title" + tabindex="-1" + > + +
+
+

+ Project Context +

+ {#if $isLoading[$activeFile]} + Loading... + {:else if fileExists($activeFile)} + + ✓ File exists + + {:else} + + ✗ Not created + + {/if} + {#if hasUnsavedChanges} + Unsaved changes + {/if} +
+ +
+ + +
+ {#each PROJECT_FILES as file (file)} + + {/each} +
+ + +
+ +
+ + +
+
+ {workingDirectory}/{PROJECT_FILE_NAMES[$activeFile]} +
+
+ + + +
+
+
+
+ + diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 0c59b7f..6e55b24 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -30,6 +30,8 @@ import CastPanel from "./CastPanel.svelte"; import PluginManagementPanel from "./PluginManagementPanel.svelte"; import McpManagementPanel from "./McpManagementPanel.svelte"; + import ProjectContextPanel from "./ProjectContextPanel.svelte"; + import { injectTextStore, PROJECT_CONTEXT_SYSTEM_ADDENDUM } from "$lib/stores/projectContext"; import { conversationsStore } from "$lib/stores/conversations"; import { generateContextInjection, @@ -62,6 +64,7 @@ let showCastPanel = $state(false); let showPluginPanel = $state(false); let showMcpPanel = $state(false); + let showProjectContext = $state(false); let isSummarising = $state(false); let showWorkspaceTrust = $state(false); let pendingHookInfo: WorkspaceHookInfo | null = $state(null); @@ -185,7 +188,8 @@ working_dir: targetDir, model: currentConfig.model || null, api_key: currentConfig.api_key || null, - custom_instructions: currentConfig.custom_instructions || null, + custom_instructions: + (currentConfig.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM, mcp_servers_json: currentConfig.mcp_servers_json || null, allowed_tools: allAllowedTools, use_worktree: currentConfig.use_worktree ?? false, @@ -300,6 +304,10 @@ onToggleAchievements(); } + function handleInjectContext(content: string): void { + injectTextStore.set(content); + } + async function handleCompactConversation() { const activeId = get(conversationsStore.activeConversationId); if (!activeId) return; @@ -345,7 +353,8 @@ working_dir: workingDirectory || selectedDirectory, model: currentConfig.model || null, api_key: currentConfig.api_key || null, - custom_instructions: currentConfig.custom_instructions || null, + custom_instructions: + (currentConfig.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM, mcp_servers_json: currentConfig.mcp_servers_json || null, allowed_tools: allAllowedTools, use_worktree: currentConfig.use_worktree ?? false, @@ -564,6 +573,20 @@ /> + + + {:else if $activeFile === "CODEBASE" && $isMappingCodebase} + +
+
⚙️
+

Mapping Codebase...

+

+ Claude is analysing the project and writing CODEBASE.md. This will auto-reload when complete. +

+
+ {:else} + + {/if} @@ -157,29 +234,50 @@ {workingDirectory}/{PROJECT_FILE_NAMES[$activeFile]}
- - - + {#if $activeFile === "CODEBASE"} + + + {:else} + + + + {/if}
diff --git a/src/lib/stores/projectContext.test.ts b/src/lib/stores/projectContext.test.ts index 76a69b9..95902e6 100644 --- a/src/lib/stores/projectContext.test.ts +++ b/src/lib/stores/projectContext.test.ts @@ -9,24 +9,30 @@ import { PROJECT_CONTEXT_SYSTEM_ADDENDUM, injectTextStore, type ProjectFile, + type ProjectScan, } from "./projectContext"; describe("PROJECT_FILE_NAMES", () => { - it("maps all four project file types", () => { + it("maps all five project file types", () => { expect(PROJECT_FILE_NAMES.PROJECT).toBe("PROJECT.md"); expect(PROJECT_FILE_NAMES.REQUIREMENTS).toBe("REQUIREMENTS.md"); expect(PROJECT_FILE_NAMES.ROADMAP).toBe("ROADMAP.md"); expect(PROJECT_FILE_NAMES.STATE).toBe("STATE.md"); + expect(PROJECT_FILE_NAMES.CODEBASE).toBe("CODEBASE.md"); }); }); describe("PROJECT_TEMPLATES", () => { - const files: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"]; + const editableFiles: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"]; - it.each(files)("returns a non-empty template for %s", (file) => { + it.each(editableFiles)("returns a non-empty template for %s", (file) => { expect(PROJECT_TEMPLATES[file]).toBeTruthy(); expect(PROJECT_TEMPLATES[file].length).toBeGreaterThan(0); }); + + it("has an empty string template for CODEBASE (auto-generated)", () => { + expect(PROJECT_TEMPLATES.CODEBASE).toBe(""); + }); }); describe("projectContextStore", () => { @@ -41,6 +47,7 @@ describe("projectContextStore", () => { expect(state.REQUIREMENTS).toBeNull(); expect(state.ROADMAP).toBeNull(); expect(state.STATE).toBeNull(); + expect(state.CODEBASE).toBeNull(); }); it("has false isLoading for all files", () => { @@ -49,6 +56,7 @@ describe("projectContextStore", () => { expect(state.REQUIREMENTS).toBe(false); expect(state.ROADMAP).toBe(false); expect(state.STATE).toBe(false); + expect(state.CODEBASE).toBe(false); }); it("has false isSaving for all files", () => { @@ -57,18 +65,25 @@ describe("projectContextStore", () => { expect(state.REQUIREMENTS).toBe(false); expect(state.ROADMAP).toBe(false); expect(state.STATE).toBe(false); + expect(state.CODEBASE).toBe(false); }); it("has PROJECT as the default activeFile", () => { expect(get(projectContextStore.activeFile)).toBe("PROJECT"); }); + it("has false isMappingCodebase initially", () => { + expect(get(projectContextStore.isMappingCodebase)).toBe(false); + }); + it("exposes all expected methods", () => { expect(typeof projectContextStore.loadFile).toBe("function"); expect(typeof projectContextStore.saveFile).toBe("function"); expect(typeof projectContextStore.loadAll).toBe("function"); expect(typeof projectContextStore.setActiveFile).toBe("function"); expect(typeof projectContextStore.getTemplate).toBe("function"); + expect(typeof projectContextStore.mapCodebase).toBe("function"); + expect(typeof projectContextStore.finishMapping).toBe("function"); }); }); @@ -203,7 +218,7 @@ describe("projectContextStore", () => { }); describe("loadAll", () => { - it("loads all four files in parallel", async () => { + it("loads all five files in parallel", async () => { setMockInvokeResult("read_file_content", "file content"); await projectContextStore.loadAll("/home/naomi/myproject"); @@ -215,6 +230,7 @@ describe("projectContextStore", () => { expect(paths).toContain("/home/naomi/myproject/REQUIREMENTS.md"); expect(paths).toContain("/home/naomi/myproject/ROADMAP.md"); expect(paths).toContain("/home/naomi/myproject/STATE.md"); + expect(paths).toContain("/home/naomi/myproject/CODEBASE.md"); }); it("sets all files isLoading to false after completion", async () => { @@ -227,6 +243,7 @@ describe("projectContextStore", () => { expect(loadingState.REQUIREMENTS).toBe(false); expect(loadingState.ROADMAP).toBe(false); expect(loadingState.STATE).toBe(false); + expect(loadingState.CODEBASE).toBe(false); }); }); @@ -257,6 +274,91 @@ describe("projectContextStore", () => { const uniqueTemplates = new Set(templates); expect(uniqueTemplates.size).toBe(files.length); }); + + it("returns empty string for CODEBASE", () => { + expect(projectContextStore.getTemplate("CODEBASE")).toBe(""); + }); + }); + + describe("mapCodebase", () => { + const mockScan: ProjectScan = { + working_dir: "/home/naomi/myproject", + file_tree: "/home/naomi/myproject/\n├── src/\n└── package.json", + detected_type: "Node.js", + key_files: ["package.json"], + }; + + it("calls scan_project with the working directory", async () => { + setMockInvokeResult("scan_project", mockScan); + setMockInvokeResult("send_prompt", undefined); + + await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123"); + + expect(invoke).toHaveBeenCalledWith("scan_project", { + workingDir: "/home/naomi/myproject", + }); + }); + + it("calls send_prompt with the conversation id and a non-empty prompt", async () => { + setMockInvokeResult("scan_project", mockScan); + setMockInvokeResult("send_prompt", undefined); + + await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123"); + + expect(invoke).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: expect.stringContaining("CODEBASE.md"), + }); + }); + + it("prompt includes detected project type", async () => { + setMockInvokeResult("scan_project", mockScan); + setMockInvokeResult("send_prompt", undefined); + + await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123"); + + const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt"); + const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? ""; + expect(message).toContain("Node.js"); + }); + + it("prompt includes file tree", async () => { + setMockInvokeResult("scan_project", mockScan); + setMockInvokeResult("send_prompt", undefined); + + await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123"); + + const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt"); + const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? ""; + expect(message).toContain("package.json"); + }); + + it("resets isMappingCodebase to false on error", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("scan_project", new Error("Scan failed")); + + await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123"); + + expect(get(projectContextStore.isMappingCodebase)).toBe(false); + consoleSpy.mockRestore(); + }); + + it("logs error when scan_project fails", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("scan_project", new Error("Scan failed")); + + await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123"); + + expect(consoleSpy).toHaveBeenCalledWith("Failed to map codebase:", expect.any(Error)); + consoleSpy.mockRestore(); + }); + }); + + describe("finishMapping", () => { + it("sets isMappingCodebase to false", () => { + projectContextStore.finishMapping(); + expect(get(projectContextStore.isMappingCodebase)).toBe(false); + }); }); }); @@ -266,11 +368,12 @@ describe("PROJECT_CONTEXT_SYSTEM_ADDENDUM", () => { expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM.length).toBeGreaterThan(0); }); - it("mentions all four context file names", () => { + it("mentions all five context file names", () => { expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("PROJECT.md"); expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("REQUIREMENTS.md"); expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("ROADMAP.md"); expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("STATE.md"); + expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("CODEBASE.md"); }); }); diff --git a/src/lib/stores/projectContext.ts b/src/lib/stores/projectContext.ts index 6886181..18a5dfb 100644 --- a/src/lib/stores/projectContext.ts +++ b/src/lib/stores/projectContext.ts @@ -1,13 +1,14 @@ import { writable } from "svelte/store"; import { invoke } from "@tauri-apps/api/core"; -export type ProjectFile = "PROJECT" | "REQUIREMENTS" | "ROADMAP" | "STATE"; +export type ProjectFile = "PROJECT" | "REQUIREMENTS" | "ROADMAP" | "STATE" | "CODEBASE"; export const PROJECT_FILE_NAMES: Record = { PROJECT: "PROJECT.md", REQUIREMENTS: "REQUIREMENTS.md", ROADMAP: "ROADMAP.md", STATE: "STATE.md", + CODEBASE: "CODEBASE.md", }; export const PROJECT_TEMPLATES: Record = { @@ -51,16 +52,25 @@ export const PROJECT_TEMPLATES: Record = { ## Next Steps `, + CODEBASE: "", }; const PROJECT_FILES = Object.keys(PROJECT_FILE_NAMES) as ProjectFile[]; +export interface ProjectScan { + working_dir: string; + file_tree: string; + detected_type: string; + key_files: string[]; +} + function createProjectContextStore() { const contents = writable>({ PROJECT: null, REQUIREMENTS: null, ROADMAP: null, STATE: null, + CODEBASE: null, }); const isLoading = writable>({ @@ -68,6 +78,7 @@ function createProjectContextStore() { REQUIREMENTS: false, ROADMAP: false, STATE: false, + CODEBASE: false, }); const isSaving = writable>({ @@ -75,9 +86,11 @@ function createProjectContextStore() { REQUIREMENTS: false, ROADMAP: false, STATE: false, + CODEBASE: false, }); const activeFile = writable("PROJECT"); + const isMappingCodebase = writable(false); async function loadFile(file: ProjectFile, workingDirectory: string): Promise { isLoading.update((state) => ({ ...state, [file]: true })); @@ -123,19 +136,67 @@ function createProjectContextStore() { return PROJECT_TEMPLATES[file]; } + async function mapCodebase(workingDirectory: string, conversationId: string): Promise { + isMappingCodebase.set(true); + try { + const scan = await invoke("scan_project", { + workingDir: workingDirectory, + }); + + const prompt = buildCodebaseMapPrompt(scan); + await invoke("send_prompt", { conversationId, message: prompt }); + } catch (error) { + console.error("Failed to map codebase:", error); + isMappingCodebase.set(false); + } + } + + function finishMapping(): void { + isMappingCodebase.set(false); + } + return { contents: { subscribe: contents.subscribe }, isLoading: { subscribe: isLoading.subscribe }, isSaving: { subscribe: isSaving.subscribe }, activeFile: { subscribe: activeFile.subscribe }, + isMappingCodebase: { subscribe: isMappingCodebase.subscribe }, loadFile, saveFile, loadAll, setActiveFile, getTemplate, + mapCodebase, + finishMapping, }; } +function buildCodebaseMapPrompt(scan: ProjectScan): string { + const keyFilesSection = + scan.key_files.length > 0 + ? `\n\nKey files detected:\n${scan.key_files.map((f) => `- ${f}`).join("\n")}` + : ""; + + return `Please analyse this codebase and generate a comprehensive \`CODEBASE.md\` file in the working directory (${scan.working_dir}). + +Project type detected: **${scan.detected_type}**${keyFilesSection} + +Directory structure: +\`\`\` +${scan.file_tree} +\`\`\` + +The CODEBASE.md file should include: +1. **Overview** — what the project does and its purpose +2. **Architecture** — key directories, how the code is organised, and the overall structure +3. **Key Components** — the most important files and modules, what they do, and how they interact +4. **Data Flow** — how data moves through the system (if applicable) +5. **Dependencies** — notable external dependencies and why they are used +6. **Development Notes** — anything helpful for a developer new to the codebase + +Write the file concisely but thoroughly. Focus on information that helps a developer understand the codebase quickly. Use the actual file structure above to inform your analysis — read the key files as needed before writing.`; +} + export const projectContextStore = createProjectContextStore(); // Signal store for injecting context into the active InputBar. @@ -152,4 +213,5 @@ The following project context files may exist in your working directory. If they - PROJECT.md — project overview, goals, and architecture - REQUIREMENTS.md — functional and non-functional requirements - ROADMAP.md — current sprint, backlog, and completed work -- STATE.md — current state, known issues, and next steps`; +- STATE.md — current state, known issues, and next steps +- CODEBASE.md — auto-generated codebase map and architecture overview`; -- 2.52.0 From 55d65fa2441476d9bd115f17b8c5d29bd771502e Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 6 Mar 2026 17:04:23 -0800 Subject: [PATCH 03/16] feat: prd creator panel with hikari-tasks.json format and distinct icon Adds a PRD Creator panel accessible from the status bar that lets Naomi describe a goal and have Claude break it down into actionable tasks written to hikari-tasks.json. Tasks can be reviewed, edited, reordered, and executed directly from the panel. Uses the Lucide ScrollText icon to distinguish it visually from the Todo List clipboard icon. Also adds CODEBASE.md to the repo and gitignores hikari-tasks.json as a user-generated data file. --- .gitignore | 3 + CODEBASE.md | 458 ++++++++++++++++++++ src/lib/components/PrdPanel.svelte | 364 ++++++++++++++++ src/lib/components/StatusBar.svelte | 17 + src/lib/components/StatusBar.test.ts | 24 ++ src/lib/stores/prd.test.ts | 615 +++++++++++++++++++++++++++ src/lib/stores/prd.ts | 194 +++++++++ 7 files changed, 1675 insertions(+) create mode 100644 CODEBASE.md create mode 100644 src/lib/components/PrdPanel.svelte create mode 100644 src/lib/stores/prd.test.ts create mode 100644 src/lib/stores/prd.ts diff --git a/.gitignore b/.gitignore index 6a66025..072b98d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ vite.config.ts.timestamp-* # Coverage reports /coverage + +# PRD task files (user-generated data, not source code) +hikari-tasks.json diff --git a/CODEBASE.md b/CODEBASE.md new file mode 100644 index 0000000..2b3afdd --- /dev/null +++ b/CODEBASE.md @@ -0,0 +1,458 @@ +# Hikari Desktop — Codebase Map + +> Auto-generated codebase overview. Last updated: 2026-03-06. + +## Overview + +Hikari Desktop is a **Tauri v2** desktop application that wraps the Claude Code CLI with a visual anime character avatar (Hikari) who appears on-screen and reacts in real-time to Claude's activity. When Claude is thinking, she thinks. When it's editing code, she codes. When it's using MCP tools, she glows with magical energy. + +The app supports multiple simultaneous conversations (tabs), each with its own isolated Claude CLI process. It provides a rich UI layer on top of Claude Code, including a built-in file editor, git panel, achievement system, cost tracking, session history, notifications, and more. + +**Repositories:** + +- Primary: `git.nhcarrigan.com` (Gitea) — `nhcarrigan/hikari-desktop` +- Mirror: `github.com/naomi-lgbt/hikari-desktop` + +**Current version:** `1.10.0` + +--- + +## Architecture + +The application follows a standard Tauri architecture: + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Frontend (WebView) │ +│ SvelteKit + Svelte 5 + TailwindCSS 4 + TypeScript │ +│ │ +│ ┌─────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐ │ +│ │AnimeGirl│ │ Terminal │ │ InputBar │ │ Editor │ │ +│ │ Sprites │ │ View │ │ + Slash Cmds│ │CodeMirror│ │ +│ └────┬────┘ └────┬─────┘ └──────┬───────┘ └────┬─────┘ │ +│ │ │ │ │ │ +│ ┌────▼─────────────▼───────────────▼────────────────▼──────┐ │ +│ │ Svelte Stores (reactive state) │ │ +│ │ conversations · character · config · agents · stats … │ │ +│ └──────────────────────────┬───────────────────────────────┘ │ +│ │ tauri.ts (event listeners) │ +└─────────────────────────────┼────────────────────────────────┘ + │ Tauri IPC (invoke / emit) +┌─────────────────────────────┼────────────────────────────────┐ +│ Backend (Rust) │ +│ ┌──────────────────────────▼───────────────────────────────┐ │ +│ │ commands.rs (invoke handlers) │ │ +│ └──────────────────────────┬───────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────▼───────────────────────────────┐ │ +│ │ BridgeManager — HashMap │ │ +│ └──────────────────────────┬───────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────▼───────────────────────────────┐ │ +│ │ WslBridge — spawns `claude --output-format stream-json`│ │ +│ │ reads NDJSON stdout → emits events to frontend │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ config · stats · cost_tracking · sessions · git · clipboard │ +│ achievements · discord_rpc · notifications · snippets … │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## Directory Structure + +``` +hikari-desktop/ +├── src/ # SvelteKit frontend +│ ├── routes/ +│ │ ├── +page.svelte # Main app layout (root page) +│ │ ├── +layout.svelte # App-level layout wrapper +│ │ ├── +layout.ts # SvelteKit layout config (SSR disabled) +│ │ └── test-achievement/ # Dev-only achievement test page +│ ├── lib/ +│ │ ├── tauri.ts # Tauri event listeners + IPC bridge +│ │ ├── commands/ # Slash command definitions +│ │ ├── components/ # 60+ Svelte components +│ │ │ └── editor/ # CodeMirror-based file editor components +│ │ ├── notifications/ # Notification system +│ │ ├── sounds/ # Sound effect triggers +│ │ ├── stores/ # All Svelte reactive stores +│ │ ├── types/ # TypeScript type definitions +│ │ └── utils/ # Pure utility functions +│ ├── app.css # Global styles + CSS variables (themes) +│ └── app.html # HTML shell +│ +├── src-tauri/ # Tauri Rust backend +│ ├── src/ +│ │ ├── main.rs # Process entry point +│ │ ├── lib.rs # Tauri app setup + command registration +│ │ ├── types.rs # All shared Rust types + serialisation +│ │ ├── wsl_bridge.rs # Claude CLI process management + NDJSON parser +│ │ ├── bridge_manager.rs # Per-conversation WslBridge registry +│ │ ├── commands.rs # All #[tauri::command] handlers +│ │ ├── config.rs # Config read/write (tauri-plugin-store) +│ │ ├── stats.rs # Token usage + cost calculation +│ │ ├── cost_tracking.rs # Budget alerts + cost history (CSV export) +│ │ ├── achievements.rs # Achievement unlock logic +│ │ ├── sessions.rs # Conversation session persistence (JSON) +│ │ ├── git.rs # Git operations via CLI +│ │ ├── clipboard.rs # Clipboard history management +│ │ ├── notifications.rs # System notification dispatch +│ │ ├── discord_rpc.rs # Discord Rich Presence manager +│ │ ├── drafts.rs # Draft message persistence +│ │ ├── snippets.rs # Snippet library CRUD +│ │ ├── quick_actions.rs # Quick action CRUD +│ │ ├── debug_logger.rs # TauriLogLayer (routes tracing → frontend) +│ │ ├── temp_manager.rs # Temporary file lifecycle management +│ │ ├── tool_cache.rs # Tool call result caching +│ │ ├── tray.rs # System tray setup +│ │ ├── process_ext.rs # HideWindow trait (Windows console hiding) +│ │ ├── vbs_notification.rs # VBScript-based notification fallback (Windows) +│ │ ├── windows_toast.rs # Windows native toast notifications +│ │ └── wsl_notifications.rs# WSL notify-send bridge +│ ├── capabilities/ # Tauri permission capabilities +│ ├── tests/ # Rust integration tests +│ ├── Cargo.toml +│ ├── Cargo.lock +│ └── tauri.conf.json # Tauri app configuration +│ +├── static/ +│ ├── sprites/ # Anime character PNG sprites (one per state) +│ └── sounds/ # MP3 sound effects (connected, working, done…) +│ +├── check-all.sh # Full QA script (lint → format → types → test) +├── vitest.config.ts # Frontend test configuration +├── vitest.setup.ts # Tauri API mocks for tests +├── svelte.config.js # SvelteKit config (static adapter) +├── vite.config.js # Vite config +├── eslint.config.js # ESLint 9 flat config +├── tsconfig.json # TypeScript config +└── .gitea/workflows/ # CI/CD (Gitea Actions) +``` + +--- + +## Key Components + +### Backend (Rust) + +#### `wsl_bridge.rs` — Claude CLI Process Manager + +The most critical backend file. `WslBridge` spawns a single `claude` CLI process per conversation using `--output-format stream-json`, which causes Claude Code to emit NDJSON messages on stdout. A dedicated reader thread consumes stdout line-by-line, parses each line into a `ClaudeMessage` enum variant, and emits the appropriate frontend events. + +Key responsibilities: + +- Locates the `claude` binary (checks `~/.local/bin`, `~/.claude/local`, system paths, and falls back to a login-shell `which claude`) +- Detects WSL environment to handle cross-platform path differences +- Maps tool names to character states (Read/Glob/Grep → `searching`, Edit/Write → `coding`, `mcp__*` → `mcp`) +- Batches permission requests from a single assistant message +- Tracks token usage per session + +#### `bridge_manager.rs` — Multi-Conversation Orchestrator + +`BridgeManager` holds a `HashMap` keyed by `conversation_id`. This enables true parallel conversations — each tab has its own isolated Claude process. The manager is wrapped in `Arc>` (using `parking_lot`) and injected into Tauri's managed state. + +#### `types.rs` — Shared Type Definitions + +Defines the complete Claude stream-JSON protocol as Rust enums/structs: + +- `ClaudeMessage` — top-level message variants: `System`, `Assistant`, `User`, `StreamEvent`, `Result`, `RateLimitEvent` +- `ContentBlock` — `Text`, `Thinking`, `ToolUse`, `ToolResult` +- `CharacterState` — `Idle | Thinking | Typing | Searching | Coding | Mcp | Permission | Success | Error` +- All frontend event types (`OutputEvent`, `StateChangeEvent`, `PermissionPromptEvent`, `AgentStartEvent`, etc.) + +#### `commands.rs` — IPC Command Handlers + +Registers all Tauri commands exposed to the frontend. Over 80 commands covering: Claude process management, configuration, stats, sessions, git, clipboard, cost tracking, MCP servers, plugins, drafts, snippets, quick actions, file system operations, authentication, and notifications. + +#### `debug_logger.rs` — In-App Debug Console + +A custom `tracing` subscriber layer (`TauriLogLayer`) that captures all `tracing::info!/warn!/error!` calls and emits them as `debug:log` events to the frontend debug console — essential since production Windows builds have no stdout. + +--- + +### Frontend (TypeScript/Svelte 5) + +#### `src/routes/+page.svelte` — Root Layout + +The main page. Renders a two-panel layout: + +- **Left panel**: `` character display with state-reactive glow effects (trans pride gradient colours per state) +- **Right panel**: `` + `` (or `` when the editor is open) + +Also handles: global keyboard shortcuts, compact mode (280×400 mini widget), window close confirmation, Discord RPC updates, and background image loading. + +#### `src/lib/tauri.ts` — Event Bridge + +Sets up all Tauri event listeners on app mount. Translates backend events into store mutations: + +| Event | Action | +| ------------------------ | ----------------------------------------------------------------------- | +| `claude:connection` | Updates conversation connection status; sends greeting on first connect | +| `claude:state` | Updates character state; triggers per-conversation sound effects | +| `claude:output` | Appends lines to the correct conversation's terminal history | +| `claude:session` | Stores the Claude session ID | +| `claude:cwd` | Updates working directory (used by the editor) | +| `claude:permission` | Adds permission requests to conversation state | +| `claude:agent-start/end` | Updates agent monitor panel | +| `claude:question` | Stores pending user question | + +Also manages Discord RPC updates and the session greeting flow. + +#### `src/lib/stores/conversations.ts` — Core State Store + +The central state container. Each conversation (`Conversation` interface) tracks: + +- Terminal lines (`TerminalLine[]`) +- Connection status, session ID, working directory +- Character state, processing flag +- Granted/pending tool permissions +- Pending user questions +- Scroll position, attachments, draft text +- Sound tracking (per-conversation, prevents replays on tab switch) +- Conversation summary (for compaction) + +Tab names are randomly chosen from a curated list of whimsical names (Starfall, Moonbeam, Sakura, etc.). + +#### `src/lib/stores/claude.ts` — Backwards-Compat Facade + +A thin wrapper that re-exports `conversationsStore` methods under the original `claudeStore` API. Maintains backwards compatibility whilst the codebase migrated to multi-conversation support. + +#### `src/lib/stores/character.ts` — Character State Store + +Manages the global character state displayed by ``. Supports `setState()` (persistent) and `setTemporaryState(state, durationMs)` (auto-reverts to `idle` after a timeout — used for success/error flashes). + +#### `src/lib/utils/stateMapper.ts` — Stream → State Mapping + +Pure utility that maps Claude stream-JSON message types to `CharacterState` values. Tool categorisation mirrors the Rust side: search tools → `searching`, coding tools → `coding`, MCP tools → `mcp`, Task tool → `thinking`. + +#### `src/lib/components/` + +Key components beyond the basics: + +| Component | Purpose | +| --------------------------- | ------------------------------------------------------------- | +| `AnimeGirl.svelte` | Displays the character sprite, subscribes to `characterState` | +| `Terminal.svelte` | Renders the conversation message history | +| `InputBar.svelte` | User input with slash command menu, attachment support | +| `StatusBar.svelte` | Top bar: connection indicator, token/cost stats, controls | +| `ConversationTabs.svelte` | Multi-tab navigation with per-tab status indicators | +| `ConfigSidebar.svelte` | Settings panel (model, theme, notifications, budget, etc.) | +| `PermissionModal.svelte` | Handles tool permission grant/deny UI | +| `UserQuestionModal.svelte` | Renders `AskUserQuestion` prompts from Claude | +| `AgentMonitorPanel.svelte` | Live subagent tree with status badges | +| `GitPanel.svelte` | Git status, diff, stage/unstage, commit, push/pull | +| `editor/EditorPanel.svelte` | Full CodeMirror editor with file browser and tabs | +| `DiffViewer.svelte` | Syntax-highlighted diff display | +| `AchievementsPanel.svelte` | Achievement gallery | +| `CostSummary.svelte` | Cost breakdown by session/day/week/month | +| `MemoryBrowserPanel.svelte` | Browse Claude memory files | +| `McpManagementPanel.svelte` | MCP server configuration UI | +| `DebugConsole.svelte` | In-app log viewer (receives `debug:log` events) | +| `ThinkingBlock.svelte` | Collapsible extended thinking display | +| `ToolCallBlock.svelte` | Formatted tool use/result display | + +--- + +## Data Flow + +### User Sends a Message + +``` +User types → InputBar + → invoke("send_prompt", { conversationId, message }) + → BridgeManager.send_prompt(conversation_id, message) + → WslBridge.send_message() → writes JSON to Claude CLI stdin +``` + +### Claude Responds (NDJSON Stream) + +``` +Claude CLI stdout (NDJSON) + → WslBridge reader thread (line-by-line) + → serde_json::from_str::() + → match message type: + System(init) → emit claude:connection(connected) + claude:cwd + StreamEvent → emit claude:state(thinking|typing|searching|coding|mcp) + Assistant → emit claude:output(assistant|tool|thinking lines) + User(tool_result)→ emit claude:output(tool result lines) + Result(success) → emit claude:state(success) + claude:output(result) + Result(error) → emit claude:state(error) + RateLimitEvent → emit claude:output(rate-limit line) + PermissionRequest→ emit claude:permission +``` + +### Frontend Reacts + +``` +tauri.ts event listeners + → conversationsStore mutations + → Svelte reactivity propagates to components + → AnimeGirl.svelte: sprite changes to match characterState + → Terminal.svelte: new line appended + → StatusBar.svelte: token counts update + → ConversationTabs.svelte: tab glow colour updates +``` + +### Permission Flow + +``` +Claude requests tool permission + → WslBridge batches pending tool uses + → emit claude:permission (one or more requests) + → tauri.ts → claudeStore.requestPermissionForConversation() + → PermissionModal.svelte renders + → User clicks Allow/Deny + → invoke("answer_question", { conversationId, toolUseId, granted }) + → WslBridge.send_tool_result() → writes result to Claude stdin + → Claude CLI resumes +``` + +--- + +## State Machine + +The `CharacterState` enum drives both the sprite displayed and the panel glow colour: + +| State | Trigger | Sprite | Panel Glow | +| ------------ | --------------------------------- | ----------------------- | ---------------------- | +| `idle` | Connected, no activity | Standing with clipboard | None | +| `thinking` | Thinking block / Task tool | Hand on chin | Purple/trans gradient | +| `typing` | Text content block | At keyboard | Blue/trans gradient | +| `searching` | Read/Glob/Grep/WebSearch/WebFetch | Magnifying glass | Yellow/trans gradient | +| `coding` | Edit/Write/NotebookEdit | At monitor | Green/trans gradient | +| `mcp` | Any `mcp__*` tool | Magical blue energy | Trans pride vibrant | +| `permission` | Permission requested | Confused shrug | — | +| `success` | Result: success | Celebrating | Emerald/trans gradient | +| `error` | Result: error | Worried | Red/trans gradient | + +`success` and `error` are temporary states (3-second auto-revert to `idle`). + +--- + +## Dependencies + +### Frontend (key packages) + +| Package | Purpose | +| ------------------------------ | -------------------------------------------------------------- | +| `@sveltejs/kit` `svelte` | SvelteKit framework + Svelte 5 | +| `@tauri-apps/api` | Core Tauri IPC (`invoke`, `listen`) | +| `@tauri-apps/plugin-*` | FS, clipboard, notifications, dialog, shell, store, os, opener | +| `tailwindcss` v4 | Utility-first CSS | +| `codemirror` + `@codemirror/*` | Code editor with 20+ language modes | +| `marked` | Markdown → HTML rendering | +| `highlight.js` | Syntax highlighting in markdown blocks | +| `lucide-svelte` | Icon library | + +### Backend (key crates) + +| Crate | Purpose | +| -------------------------------- | ---------------------------------------- | +| `tauri` v2 | Desktop app framework | +| `tokio` | Async runtime | +| `serde` / `serde_json` | JSON serialisation/deserialisation | +| `parking_lot` | Fast mutex (used for `BridgeManager`) | +| `uuid` | Unique ID generation | +| `discord-rich-presence` | Discord RPC integration | +| `chrono` | Date/time handling for cost tracking | +| `semver` | Version comparison for update checks | +| `tempfile` | Temporary file management | +| `tracing` + `tracing-subscriber` | Structured logging | +| `dirs` | Cross-platform home directory resolution | +| `windows` (Windows-only) | Native toast notifications | + +### Dev / Tooling + +| Tool | Purpose | +| -------------------------------- | ----------------------------------------- | +| `vitest` + `@vitest/coverage-v8` | Frontend unit tests with v8 coverage | +| `@testing-library/svelte` | Component testing utilities | +| `jsdom` | DOM environment for tests | +| `eslint` v9 (flat config) | Linting | +| `prettier` | Formatting | +| `svelte-check` | TypeScript type checking for Svelte files | +| `cargo test` + `cargo llvm-cov` | Rust unit tests and coverage | + +--- + +## Development Notes + +### Running the App + +```bash +# Frontend dev server only +source ~/.nvm/nvm.sh && pnpm dev + +# Full Tauri app (Rust + frontend) +source ~/.nvm/nvm.sh && pnpm tauri dev +``` + +### Running Tests + +```bash +# All checks (lint → format → type-check → frontend tests → backend tests) +./check-all.sh + +# Frontend tests only +source ~/.nvm/nvm.sh && pnpm test + +# Frontend with coverage +source ~/.nvm/nvm.sh && pnpm test:coverage + +# Backend tests only +pnpm test:backend +``` + +### Building + +```bash +# Linux build +pnpm build:linux + +# Windows cross-compile (requires cargo-xwin) +pnpm build:windows +``` + +### Adding a New Tauri Command + +1. Add the handler function in the appropriate `src-tauri/src/*.rs` file with `#[tauri::command]` +2. Register it in `lib.rs` `invoke_handler![]` +3. Call it from the frontend via `invoke("command_name", { args })` in `src/lib/tauri.ts` or a store + +### Adding a New Frontend Store + +1. Create `src/lib/stores/my-store.ts` using `writable` or a factory function pattern +2. Create `src/lib/stores/my-store.test.ts` — all stores must have tests +3. Expose the store from the appropriate component + +### Claude Stream-JSON Protocol + +Claude Code is invoked with `--output-format stream-json --verbose`. See `src-tauri/src/types.rs` for the complete message type definitions. The key field distinguishing subagent messages from top-level messages is `parent_tool_use_id` on `Assistant` messages. + +### Multi-Conversation Architecture + +Each tab (`Conversation`) in `conversationsStore` has a unique `conversation_id` string. The backend `BridgeManager` maps these IDs to `WslBridge` instances. All Tauri events carry `conversation_id` in their payload so the frontend can route them to the correct conversation without affecting others. + +### WSL Detection + +`wsl_bridge.rs` detects WSL by checking `/proc/version` for "microsoft"/"wsl" strings, checking for `/proc/sys/fs/binfmt_misc/WSLInterop`, and checking `$WSL_DISTRO_NAME`. On native Windows builds, WSL detection always returns `false` (even if launched from a WSL terminal). + +### Character State Sound Rules + +Sound effects are managed in `src/lib/tauri.ts` per-conversation to prevent replays when switching tabs. The rules are: + +- Entering `thinking` from a clean state (`idle`/`success`/`error`) → reset all sound flags +- Entering `coding` or `searching` (first time per task) → play task-start sound +- Entering `success` after ≥2 seconds in a long-running phase → play completion sound +- Entering `error` → play error sound (always) +- Entering `permission` → play permission sound (always) + +### Workspace Trust Gate + +On first connection to a new working directory, the app checks for Claude hooks and prompts the user to trust the workspace. Trusted workspaces are persisted in `HikariConfig.trusted_workspaces`. + +### Configuration Storage + +All settings are persisted via `tauri-plugin-store` to a JSON file in the app data directory. The frontend `configStore` (`src/lib/stores/config.ts`) loads configuration on startup and provides reactive derived stores. Changes invoke `save_config` to persist to disk. diff --git a/src/lib/components/PrdPanel.svelte b/src/lib/components/PrdPanel.svelte new file mode 100644 index 0000000..274b5c4 --- /dev/null +++ b/src/lib/components/PrdPanel.svelte @@ -0,0 +1,364 @@ + + +
e.key === "Escape" && onClose()} +> +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-labelledby="prd-panel-title" + tabindex="-1" + > + +
+
+

+ PRD Creator +

+ {#if $isGenerating} + Generating tasks... + {:else if $isLoading} + Loading... + {:else if $isLoaded} + + {$tasks.length} task{$tasks.length === 1 ? "" : "s"} + + {/if} +
+ +
+ + +
+ {#if $isGenerating} + +
+
⚙️
+

Generating Tasks...

+

+ Claude is breaking down your goal into actionable tasks and writing + hikari-tasks.json. This will auto-reload when + complete. +

+
+ {:else if $isLoaded} + +
+
+ Goal: + {$goal} +
+ {#each $tasks as task, index (task.id)} +
+ +
+ #{index + 1} + + handleUpdateTask(task.id, "title", (e.target as HTMLInputElement).value)} + class="flex-1 bg-transparent text-sm font-medium text-[var(--text-primary)] border-b border-transparent hover:border-[var(--border-color)] focus:border-[var(--accent-primary)] focus:outline-none transition-colors py-0.5" + placeholder="Task title" + /> + +
+ + + +
+
+ + +
+ {/each} + {#if $tasks.length === 0} +

+ No tasks — all removed. Click Regenerate to start over. +

+ {/if} +
+ {:else} + +
+
+ + +
+

+ Claude will analyse your goal and write a + hikari-tasks.json file with 3–10 actionable tasks, each with + a detailed prompt ready to execute. +

+
+ {/if} +
+ + +
+
+ {workingDirectory}/hikari-tasks.json +
+
+ {#if $isLoaded} + + + + {:else if !$isGenerating} + + {/if} +
+
+
+
+ + diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 6e55b24..e062815 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -31,6 +31,8 @@ import PluginManagementPanel from "./PluginManagementPanel.svelte"; import McpManagementPanel from "./McpManagementPanel.svelte"; import ProjectContextPanel from "./ProjectContextPanel.svelte"; + import PrdPanel from "./PrdPanel.svelte"; + import { ScrollText } from "lucide-svelte"; import { injectTextStore, PROJECT_CONTEXT_SYSTEM_ADDENDUM } from "$lib/stores/projectContext"; import { conversationsStore } from "$lib/stores/conversations"; import { @@ -65,6 +67,7 @@ let showPluginPanel = $state(false); let showMcpPanel = $state(false); let showProjectContext = $state(false); + let showPrdPanel = $state(false); let isSummarising = $state(false); let showWorkspaceTrust = $state(false); let pendingHookInfo: WorkspaceHookInfo | null = $state(null); @@ -587,6 +590,13 @@ /> + - - - - - - - - - - - - - - - - - - - - + + + {#if appVersion} v{appVersion} {/if} - {#if showStats} -
- -
- {/if} {#if connectionStatus === "connected"} + + +
+ {#if loading} +
+
+ Fetching releases... +
+ {:else if error} +
+

{error}

+ +
+ {:else if entries.length === 0} +

No releases found.

+ {:else} +
+ {#each entries as entry (entry.version)} +
+
+ + {entry.version} + + {#if entry.version === `v${currentVersion}`} + + current + + {/if} + {#if entry.prerelease} + + pre-release + + {/if} + + {formatReleaseDate(entry.created_at)} + + +
+ {#if entry.notes} +
+ +
+ {:else} +

No release notes.

+ {/if} +
+ {/each} +
+ {/if} +
+ + diff --git a/src/lib/components/ChangelogPanel.test.ts b/src/lib/components/ChangelogPanel.test.ts new file mode 100644 index 0000000..72a9eac --- /dev/null +++ b/src/lib/components/ChangelogPanel.test.ts @@ -0,0 +1,68 @@ +/** + * ChangelogPanel Component Tests + * + * Tests the pure helper function exported by ChangelogPanel for formatting + * ISO 8601 date strings into human-readable release dates. + * + * What this component does: + * - Opens as a modal dialog from the nav menu + * - Fetches all releases via the `fetch_changelog` Tauri IPC command on mount + * - Shows a loading spinner while fetching + * - Renders each release with version badge, date, pre-release badge, and notes + * - Highlights the currently installed version with a pink "current" badge + * - Provides a "View on Gitea" link per release + * - Shows an error state with a Retry button if the fetch fails + * + * Manual testing checklist: + * - [ ] Changelog item appears in the nav dropdown + * - [ ] Clicking opens the panel with a loading spinner + * - [ ] Spinner resolves to a list of releases + * - [ ] Current version entry shows pink version text + "current" badge + * - [ ] Pre-release entries show a yellow "pre-release" badge + * - [ ] "View on Gitea" opens the release URL in the browser + * - [ ] Backdrop click and Escape key close the panel + * - [ ] Network error shows a red error message and a Retry button + * - [ ] Retry button re-fetches the changelog + */ + +import { describe, it, expect } from "vitest"; + +function formatReleaseDate(isoString: string): string { + if (!isoString) return "Unknown date"; + const date = new Date(isoString); + if (isNaN(date.getTime())) return "Unknown date"; + return date.toLocaleDateString("en-GB", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", + }); +} + +// --- + +describe("formatReleaseDate", () => { + it("formats a valid ISO 8601 timestamp to en-GB locale", () => { + const result = formatReleaseDate("2026-02-25T00:00:00Z"); + // en-GB format: "25 February 2026" + expect(result).toBe("25 February 2026"); + }); + + it("returns 'Unknown date' for an empty string", () => { + expect(formatReleaseDate("")).toBe("Unknown date"); + }); + + it("returns 'Unknown date' for a non-date string", () => { + expect(formatReleaseDate("not-a-date")).toBe("Unknown date"); + }); + + it("handles a timestamp with a time component", () => { + const result = formatReleaseDate("2025-12-01T14:32:00Z"); + expect(result).toBe("1 December 2025"); + }); + + it("formats a single-digit day without leading zero in en-GB", () => { + const result = formatReleaseDate("2026-03-06T00:00:00Z"); + expect(result).toBe("6 March 2026"); + }); +}); diff --git a/src/lib/components/NavMenu.svelte b/src/lib/components/NavMenu.svelte index e309cb5..e29c215 100644 --- a/src/lib/components/NavMenu.svelte +++ b/src/lib/components/NavMenu.svelte @@ -21,6 +21,7 @@ import McpManagementPanel from "./McpManagementPanel.svelte"; import ProjectContextPanel from "./ProjectContextPanel.svelte"; import PrdPanel from "./PrdPanel.svelte"; + import ChangelogPanel from "./ChangelogPanel.svelte"; import { injectTextStore } from "$lib/stores/projectContext"; const DISCORD_URL = "https://chat.nhcarrigan.com"; @@ -63,6 +64,7 @@ let showMcpPanel = $state(false); let showProjectContext = $state(false); let showPrdPanel = $state(false); + let showChangelog = $state(false); const progress = $derived($achievementProgress); const activeAgentCount = $derived($runningAgentCount); @@ -346,6 +348,19 @@ Support Us + + + + + + + + + + + + +
+ {#each PHASES as phase (phase)} + {@const isActive = $workflowState.currentPhase === phase} + {@const isDone = $workflowState.currentPhase > phase} + + {#if phase < 4} + + + + {/if} + {/each} +
+ + + +
+ {#if $workflowState.currentPhase === 1} + +
+
+ + +
+ + {#if isWaitingForDiscuss} +
+
⚙️
+
+

Claude is working...

+

+ Writing CONTEXT.md — will auto-detect when complete. +

+
+
+ {:else if contextContent !== null} +
+
+ ✓ CONTEXT.md captured + +
+ +
+ {:else if isLoadingContext} +

Checking for CONTEXT.md...

+ {:else} +
+

+ {#if $workflowState.quickMode} + Quick mode: your description will be saved directly to CONTEXT.md without + discussion. + {:else} + Claude will analyse your description and write a structured CONTEXT.md with + acceptance criteria. + {/if} +

+
+ {#if !$workflowState.quickMode} + + {:else} + + {/if} + +
+
+ {/if} +
+ {:else if $workflowState.currentPhase === 2} + +
+

+ Use the PRD Creator to generate your task breakdown, then approve it here to advance. +

+ + +
+ {#if $prdIsLoaded && $prdTasks.length > 0} +
+
+

+ {$prdTasks.length} task{$prdTasks.length === 1 ? "" : "s"} ready +

+

hikari-tasks.json loaded

+
+ + Ready + +
+ {:else} +
+
+

No tasks generated yet

+

+ Open PRD Creator to generate a task breakdown +

+
+ + Pending + +
+ {/if} +
+ +
+ + +
+ + {#if $workflowState.plan.tasksApproved} +
+ ✓ Plan approved — ready to advance to Execute +
+ {/if} +
+ {:else if $workflowState.currentPhase === 3} + +
+

+ Run your tasks in the Task Loop panel, then mark execution complete here. +

+ + +
+ {#if $loopTasks.length > 0} + {@const done = completedTaskCount()} + {@const failed = failedTaskCount()} + {@const total = $loopTasks.length} +
+
+

+ {done} / {total} tasks completed +

+ {#if $loopStatus !== "idle"} + + {$loopStatus} + + {/if} +
+ +
+
+
+ {#if failed > 0} +

+ {failed} task{failed === 1 ? "" : "s"} failed +

+ {/if} +
+ {:else} +

+ No tasks loaded — open Task Loop to load and run tasks +

+ {/if} +
+ +
+ +
+ + {#if $workflowState.execute.completed} +
+ ✓ Execution complete — ready to advance to Verify +
+ {/if} +
+ {:else if $workflowState.currentPhase === 4} + +
+

+ Verify the implementation against your acceptance criteria. +

+ + + {#if $workflowState.verify.criteria.length > 0} +
+
+

Acceptance Criteria

+ {#if contextContent !== null} + + {/if} +
+ {#each $workflowState.verify.criteria as criterion (criterion.id)} +
+

{criterion.text}

+
+ + + + +
+
+ {/each} +
+ {:else} +
+

No criteria yet.

+ {#if contextContent !== null} + + {/if} +
+ {/if} + + +
+ e.key === "Enter" && handleAddCriterion()} + class="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-3 py-1.5 text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]" + placeholder="Add criterion..." + /> + +
+ + + {#if isWaitingForVerify} +
+
⚙️
+
+

Claude is verifying...

+

+ Writing VERIFY.md — will auto-detect when complete. +

+
+
+ {:else if verifyContent !== null} +
+
+ ✓ VERIFY.md generated + +
+ +
+ {:else if isLoadingVerify} +

Checking for VERIFY.md...

+ {:else} +
+ {#if !$workflowState.quickMode} + + {/if} + +
+ {/if} + + {#if $workflowState.verify.verificationComplete} +
+ ✓ Verification complete +
+ {/if} +
+ {/if} +
+ + +
+
+ + +
+ +
+ + {#if $workflowState.currentPhase === 2 && !$workflowState.plan.tasksApproved} + + {:else if $workflowState.currentPhase === 3 && !$workflowState.execute.completed} + + {:else if $workflowState.currentPhase === 4 && !$workflowState.verify.verificationComplete && $workflowState.quickMode} + + {/if} + + + +
+
+ + + + diff --git a/src/lib/stores/workflow.test.ts b/src/lib/stores/workflow.test.ts new file mode 100644 index 0000000..2aaefe0 --- /dev/null +++ b/src/lib/stores/workflow.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from "vitest"; +import { + buildDiscussPrompt, + buildVerifyPrompt, + canAdvancePhase, + canGoBack, + getPhaseLabel, + generateCriterionId, + type WorkflowState, + type VerifyCriterion, +} from "./workflow"; + +// ─── buildDiscussPrompt ─────────────────────────────────────────────────────── + +describe("buildDiscussPrompt", () => { + it("includes the description in the output", () => { + const prompt = buildDiscussPrompt("Build a user authentication system"); + expect(prompt).toContain("Build a user authentication system"); + }); + + it("references CONTEXT.md", () => { + const prompt = buildDiscussPrompt("some project"); + expect(prompt).toContain("CONTEXT.md"); + }); + + it("mentions acceptance criteria section", () => { + const prompt = buildDiscussPrompt("some project"); + expect(prompt.toLowerCase()).toContain("acceptance criteria"); + }); + + it("returns a non-empty string for any description", () => { + expect(buildDiscussPrompt("a").length).toBeGreaterThan(0); + expect(buildDiscussPrompt("very long description ".repeat(20)).length).toBeGreaterThan(0); + }); +}); + +// ─── buildVerifyPrompt ──────────────────────────────────────────────────────── + +describe("buildVerifyPrompt", () => { + it("references VERIFY.md", () => { + const prompt = buildVerifyPrompt([]); + expect(prompt).toContain("VERIFY.md"); + }); + + it("handles empty criteria list gracefully", () => { + const prompt = buildVerifyPrompt([]); + expect(prompt.length).toBeGreaterThan(0); + expect(prompt).not.toContain("undefined"); + }); + + it("includes all criteria text in the output", () => { + const criteria: VerifyCriterion[] = [ + { id: "c1", text: "Login must work", status: "pending" }, + { id: "c2", text: "Tests must pass", status: "pending" }, + ]; + const prompt = buildVerifyPrompt(criteria); + expect(prompt).toContain("Login must work"); + expect(prompt).toContain("Tests must pass"); + }); + + it("numbers criteria starting from 1", () => { + const criteria: VerifyCriterion[] = [{ id: "c1", text: "First criterion", status: "pending" }]; + const prompt = buildVerifyPrompt(criteria); + expect(prompt).toContain("1. First criterion"); + }); + + it("includes all criteria when multiple are provided", () => { + const criteria: VerifyCriterion[] = [ + { id: "c1", text: "Alpha", status: "pending" }, + { id: "c2", text: "Beta", status: "pending" }, + { id: "c3", text: "Gamma", status: "pending" }, + ]; + const prompt = buildVerifyPrompt(criteria); + expect(prompt).toContain("1. Alpha"); + expect(prompt).toContain("2. Beta"); + expect(prompt).toContain("3. Gamma"); + }); +}); + +// ─── canAdvancePhase ────────────────────────────────────────────────────────── + +function makeState(overrides: Partial = {}): WorkflowState { + return { + version: 1, + currentPhase: 1, + quickMode: false, + discuss: { description: "", contextCaptured: false }, + plan: { tasksApproved: false }, + execute: { completed: false }, + verify: { criteria: [], verificationComplete: false, report: "" }, + ...overrides, + }; +} + +describe("canAdvancePhase", () => { + describe("phase 1 (Discuss)", () => { + it("returns false when context not captured and not quick mode", () => { + const state = makeState({ currentPhase: 1 }); + expect(canAdvancePhase(state)).toBe(false); + }); + + it("returns true when context is captured", () => { + const state = makeState({ + currentPhase: 1, + discuss: { description: "test", contextCaptured: true }, + }); + expect(canAdvancePhase(state)).toBe(true); + }); + + it("returns true in quick mode even without context captured", () => { + const state = makeState({ currentPhase: 1, quickMode: true }); + expect(canAdvancePhase(state)).toBe(true); + }); + }); + + describe("phase 2 (Plan)", () => { + it("returns false when plan not approved", () => { + const state = makeState({ currentPhase: 2 }); + expect(canAdvancePhase(state)).toBe(false); + }); + + it("returns true when plan is approved", () => { + const state = makeState({ currentPhase: 2, plan: { tasksApproved: true } }); + expect(canAdvancePhase(state)).toBe(true); + }); + }); + + describe("phase 3 (Execute)", () => { + it("returns false when execution not completed", () => { + const state = makeState({ currentPhase: 3 }); + expect(canAdvancePhase(state)).toBe(false); + }); + + it("returns true when execution is completed", () => { + const state = makeState({ currentPhase: 3, execute: { completed: true } }); + expect(canAdvancePhase(state)).toBe(true); + }); + }); + + describe("phase 4 (Verify)", () => { + it("returns false when verification not complete", () => { + const state = makeState({ currentPhase: 4 }); + expect(canAdvancePhase(state)).toBe(false); + }); + + it("returns true when verification is complete", () => { + const state = makeState({ + currentPhase: 4, + verify: { criteria: [], verificationComplete: true, report: "All good" }, + }); + expect(canAdvancePhase(state)).toBe(true); + }); + }); +}); + +// ─── canGoBack ──────────────────────────────────────────────────────────────── + +describe("canGoBack", () => { + it("returns false for phase 1", () => { + expect(canGoBack(1)).toBe(false); + }); + + it("returns true for phase 2", () => { + expect(canGoBack(2)).toBe(true); + }); + + it("returns true for phase 3", () => { + expect(canGoBack(3)).toBe(true); + }); + + it("returns true for phase 4", () => { + expect(canGoBack(4)).toBe(true); + }); +}); + +// ─── getPhaseLabel ──────────────────────────────────────────────────────────── + +describe("getPhaseLabel", () => { + it("returns Discuss for phase 1", () => { + expect(getPhaseLabel(1)).toBe("Discuss"); + }); + + it("returns Plan for phase 2", () => { + expect(getPhaseLabel(2)).toBe("Plan"); + }); + + it("returns Execute for phase 3", () => { + expect(getPhaseLabel(3)).toBe("Execute"); + }); + + it("returns Verify for phase 4", () => { + expect(getPhaseLabel(4)).toBe("Verify"); + }); +}); + +// ─── generateCriterionId ───────────────────────────────────────────────────── + +describe("generateCriterionId", () => { + it("returns a non-empty string", () => { + expect(generateCriterionId().length).toBeGreaterThan(0); + }); + + it("returns unique IDs on successive calls", () => { + const ids = new Set(Array.from({ length: 20 }, () => generateCriterionId())); + expect(ids.size).toBe(20); + }); + + it("starts with the expected prefix", () => { + expect(generateCriterionId()).toMatch(/^criterion-/); + }); +}); diff --git a/src/lib/stores/workflow.ts b/src/lib/stores/workflow.ts new file mode 100644 index 0000000..2773190 --- /dev/null +++ b/src/lib/stores/workflow.ts @@ -0,0 +1,253 @@ +import { writable, get } from "svelte/store"; +import { invoke } from "@tauri-apps/api/core"; + +export type WorkflowPhase = 1 | 2 | 3 | 4; +export type CriterionStatus = "pending" | "pass" | "fail" | "partial"; + +export interface VerifyCriterion { + id: string; + text: string; + status: CriterionStatus; +} + +export interface WorkflowState { + version: 1; + currentPhase: WorkflowPhase; + quickMode: boolean; + discuss: { + description: string; + contextCaptured: boolean; + }; + plan: { + tasksApproved: boolean; + }; + execute: { + completed: boolean; + }; + verify: { + criteria: VerifyCriterion[]; + verificationComplete: boolean; + report: string; + }; +} + +export const WORKFLOW_STATE_FILENAME = "workflow-state.json"; + +const DEFAULT_STATE: WorkflowState = { + version: 1, + currentPhase: 1, + quickMode: false, + discuss: { description: "", contextCaptured: false }, + plan: { tasksApproved: false }, + execute: { completed: false }, + verify: { criteria: [], verificationComplete: false, report: "" }, +}; + +// ─── Pure functions (exported for testing) ─────────────────────────────────── + +export function buildDiscussPrompt(description: string): string { + return `Please help me clarify and document the following project goal, then write a \`CONTEXT.md\` file in the working directory. + +Project description: +${description} + +The \`CONTEXT.md\` file should include: + +## Goal +A clear, one-paragraph statement of what we are building and why. + +## Scope +What is in scope and what is explicitly out of scope. + +## Acceptance Criteria +A numbered list of concrete, verifiable criteria that must be met for this project to be considered complete. Each criterion should be specific and testable. + +## Key Assumptions +Any assumptions being made about the implementation, environment, or user needs. + +## Open Questions +Any questions that need to be resolved before or during development. + +Write the file concisely but thoroughly. Focus on information that guides implementation and defines success.`; +} + +export function buildVerifyPrompt(criteria: VerifyCriterion[]): string { + if (criteria.length === 0) { + return `Please review the project implementation and write a \`VERIFY.md\` file in the working directory with your overall assessment. + +Include: +## Summary +Overall pass/fail assessment. + +## Findings +What you found when reviewing the implementation. + +## Recommendation +Whether the project is ready to ship or what remains to be done.`; + } + + const criteriaList = criteria.map((c, i) => `${i + 1}. ${c.text}`).join("\n"); + + return `Please verify the project implementation against the following acceptance criteria and write a \`VERIFY.md\` file in the working directory. + +Acceptance criteria: +${criteriaList} + +For each criterion, check whether it is met by examining the codebase and any relevant files. Then write \`VERIFY.md\` with: + +## Summary +Overall PASSED / FAILED / PARTIAL status. + +## Criterion Results +For each criterion: state whether it PASSES, FAILS, or is PARTIAL, with a brief explanation. + +## Findings +Any notable issues, edge cases, or improvements spotted during review. + +## Recommendation +Whether the project is ready to ship or what remains to be done.`; +} + +export function canAdvancePhase(state: WorkflowState): boolean { + switch (state.currentPhase) { + case 1: + return state.quickMode || state.discuss.contextCaptured; + case 2: + return state.plan.tasksApproved; + case 3: + return state.execute.completed; + case 4: + return state.verify.verificationComplete; + } +} + +export function canGoBack(phase: WorkflowPhase): boolean { + return phase > 1; +} + +export function getPhaseLabel(phase: WorkflowPhase): string { + switch (phase) { + case 1: + return "Discuss"; + case 2: + return "Plan"; + case 3: + return "Execute"; + case 4: + return "Verify"; + } +} + +export function generateCriterionId(): string { + return `criterion-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +// ─── Store ──────────────────────────────────────────────────────────────────── + +function createWorkflowStore() { + const state = writable({ ...DEFAULT_STATE }); + + async function loadState(workingDirectory: string): Promise { + try { + const path = `${workingDirectory}/${WORKFLOW_STATE_FILENAME}`; + const content = await invoke("read_file_content", { path }); + const parsed = JSON.parse(content) as WorkflowState; + state.set(parsed); + } catch { + state.set({ ...DEFAULT_STATE }); + } + } + + async function saveState(workingDirectory: string): Promise { + try { + const path = `${workingDirectory}/${WORKFLOW_STATE_FILENAME}`; + const current = get(state); + await invoke("write_file_content", { path, content: JSON.stringify(current, null, 2) }); + } catch (error) { + console.error("Failed to save workflow state:", error); + } + } + + function setPhase(phase: WorkflowPhase): void { + state.update((s) => ({ ...s, currentPhase: phase })); + } + + function setQuickMode(value: boolean): void { + state.update((s) => ({ ...s, quickMode: value })); + } + + function reset(): void { + state.set({ ...DEFAULT_STATE }); + } + + function setDiscussDescription(text: string): void { + state.update((s) => ({ ...s, discuss: { ...s.discuss, description: text } })); + } + + function markContextCaptured(): void { + state.update((s) => ({ ...s, discuss: { ...s.discuss, contextCaptured: true } })); + } + + function approvePlan(): void { + state.update((s) => ({ ...s, plan: { tasksApproved: true } })); + } + + function completeExecution(): void { + state.update((s) => ({ ...s, execute: { completed: true } })); + } + + function addCriterion(text: string): void { + const criterion: VerifyCriterion = { + id: generateCriterionId(), + text, + status: "pending", + }; + state.update((s) => ({ + ...s, + verify: { ...s.verify, criteria: [...s.verify.criteria, criterion] }, + })); + } + + function removeCriterion(id: string): void { + state.update((s) => ({ + ...s, + verify: { ...s.verify, criteria: s.verify.criteria.filter((c) => c.id !== id) }, + })); + } + + function updateCriterionStatus(id: string, status: CriterionStatus): void { + state.update((s) => ({ + ...s, + verify: { + ...s.verify, + criteria: s.verify.criteria.map((c) => (c.id === id ? { ...c, status } : c)), + }, + })); + } + + function completeVerification(report: string): void { + state.update((s) => ({ + ...s, + verify: { ...s.verify, verificationComplete: true, report }, + })); + } + + return { + state: { subscribe: state.subscribe }, + loadState, + saveState, + setPhase, + setQuickMode, + reset, + setDiscussDescription, + markContextCaptured, + approvePlan, + completeExecution, + addCriterion, + removeCriterion, + updateCriterionStatus, + completeVerification, + }; +} + +export const workflowStore = createWorkflowStore(); -- 2.52.0 From 7a07958b658a8f59c028817bd3f74dd4f7b28550 Mon Sep 17 00:00:00 2001 From: Hikari Date: Sat, 7 Mar 2026 00:33:23 -0800 Subject: [PATCH 13/16] feat: add back-to-workflow button in PRD Creator and Task Loop panels --- src/lib/components/NavMenu.svelte | 12 +++++++- src/lib/components/PrdPanel.svelte | 41 ++++++++++++++++--------- src/lib/components/TaskLoopPanel.svelte | 41 ++++++++++++++++--------- 3 files changed, 63 insertions(+), 31 deletions(-) diff --git a/src/lib/components/NavMenu.svelte b/src/lib/components/NavMenu.svelte index 59e9590..c4fa55f 100644 --- a/src/lib/components/NavMenu.svelte +++ b/src/lib/components/NavMenu.svelte @@ -512,6 +512,10 @@ {#if showPrdPanel} (showPrdPanel = false)} + onBackToWorkflow={() => { + showPrdPanel = false; + showWorkflowPanel = true; + }} workingDirectory={workingDirectory || selectedDirectory} /> {/if} @@ -521,7 +525,13 @@ {/if} {#if showTaskLoop} - (showTaskLoop = false)} /> + (showTaskLoop = false)} + onBackToWorkflow={() => { + showTaskLoop = false; + showWorkflowPanel = true; + }} + /> {/if} {#if showWorkflowPanel} diff --git a/src/lib/components/PrdPanel.svelte b/src/lib/components/PrdPanel.svelte index 274b5c4..759a7ad 100644 --- a/src/lib/components/PrdPanel.svelte +++ b/src/lib/components/PrdPanel.svelte @@ -8,9 +8,10 @@ interface Props { onClose: () => void; workingDirectory: string; + onBackToWorkflow?: () => void; } - const { onClose, workingDirectory }: Props = $props(); + const { onClose, workingDirectory, onBackToWorkflow }: Props = $props(); const tasks = $derived(prdStore.tasks); const goal = $derived(prdStore.goal); @@ -119,20 +120,30 @@ {/if} - +
+ {#if onBackToWorkflow} + + {/if} + +
diff --git a/src/lib/components/TaskLoopPanel.svelte b/src/lib/components/TaskLoopPanel.svelte index a400006..232ba85 100644 --- a/src/lib/components/TaskLoopPanel.svelte +++ b/src/lib/components/TaskLoopPanel.svelte @@ -16,9 +16,10 @@ interface Props { onClose: () => void; + onBackToWorkflow?: () => void; } - const { onClose }: Props = $props(); + const { onClose, onBackToWorkflow }: Props = $props(); const tasks = $derived(taskLoopStore.tasks); const loopStatus = $derived(taskLoopStore.loopStatus); @@ -307,20 +308,30 @@ {/if} - +
+ {#if onBackToWorkflow} + + {/if} + +
-- 2.52.0 From f60e45e4860e40caa0ad90250101c96283fe7099 Mon Sep 17 00:00:00 2001 From: Hikari Date: Sat, 7 Mar 2026 01:02:14 -0800 Subject: [PATCH 14/16] feat: add wave-based parallel task execution to Task Loop --- src/lib/components/TaskLoopPanel.svelte | 314 +++++++++++++++--------- src/lib/stores/prd.ts | 5 +- src/lib/stores/taskLoop.test.ts | 147 ++++++++++- src/lib/stores/taskLoop.ts | 90 ++++++- 4 files changed, 439 insertions(+), 117 deletions(-) diff --git a/src/lib/components/TaskLoopPanel.svelte b/src/lib/components/TaskLoopPanel.svelte index 232ba85..6a131e7 100644 --- a/src/lib/components/TaskLoopPanel.svelte +++ b/src/lib/components/TaskLoopPanel.svelte @@ -4,7 +4,9 @@ import { invoke } from "@tauri-apps/api/core"; import { taskLoopStore, - findNextPendingIndex, + getReadyTasks, + computeWaves, + isTaskBlocked, buildTaskPrompt, normalizeToUnixPath, type TaskLoopTask, @@ -23,53 +25,60 @@ const tasks = $derived(taskLoopStore.tasks); const loopStatus = $derived(taskLoopStore.loopStatus); - const currentTaskIndex = $derived(taskLoopStore.currentTaskIndex); const sourceFile = $derived(taskLoopStore.sourceFile); const conversations = $derived(claudeStore.conversations); + const concurrencyLimit = $derived(taskLoopStore.concurrencyLimit); - // Orchestration phase (panel-local, not persisted) + // Per-task orchestration phases (panel-local, not persisted) type LoopPhase = "waiting_for_connection" | "waiting_for_completion"; - let loopPhase = $state(null); - let taskEverStarted = $state(false); + let activePhases = $state>({}); + let taskEverStartedMap = $state>({}); let isLoading = $state(false); let errorMessage = $state(null); const completedCount = $derived($tasks.filter((t) => t.status === "completed").length); const failedCount = $derived($tasks.filter((t) => t.status === "failed").length); + const blockedCount = $derived($tasks.filter((t) => t.status === "blocked").length); + const runningCount = $derived($tasks.filter((t) => t.status === "running").length); const totalCount = $derived($tasks.length); + const waves = $derived(computeWaves($tasks)); + const multiWave = $derived(waves.length > 1); const workingStates: CharacterState[] = ["thinking", "typing", "coding", "searching", "mcp"]; - // Watch the current task's conversation for state transitions + // Watch all active tasks' conversations for state transitions $effect(() => { - const phase = loopPhase; - if (!phase) return; + for (const [idxStr, phase] of Object.entries(activePhases)) { + const taskIdx = Number(idxStr); + const taskList = $tasks; + if (taskIdx < 0 || taskIdx >= taskList.length) continue; - const taskIdx = $currentTaskIndex; - const taskList = $tasks; - if (taskIdx < 0 || taskIdx >= taskList.length) return; + const currentTask = taskList[taskIdx]; + if (!currentTask.conversationId) continue; - const currentTask = taskList[taskIdx]; - if (!currentTask.conversationId) return; + const conv = $conversations.get(currentTask.conversationId); + if (!conv) continue; - const conv = $conversations.get(currentTask.conversationId); - if (!conv) return; - - if (phase === "waiting_for_connection" && conv.connectionStatus === "connected") { - loopPhase = "waiting_for_completion"; - taskEverStarted = false; - void sendTaskPrompt(currentTask, taskIdx, taskList.length); - return; - } - - if (phase === "waiting_for_completion") { - if (workingStates.includes(conv.characterState)) { - taskEverStarted = true; + if (phase === "waiting_for_connection" && conv.connectionStatus === "connected") { + activePhases = { ...activePhases, [taskIdx]: "waiting_for_completion" }; + taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: false }; + void sendTaskPrompt(currentTask, taskIdx, taskList.length); + continue; } - if (taskEverStarted && conv.characterState === "idle") { - taskEverStarted = false; - loopPhase = null; - void onTaskCompleted(taskIdx, "completed"); + + if (phase === "waiting_for_completion") { + if (workingStates.includes(conv.characterState)) { + taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: true }; + } + if (taskEverStartedMap[taskIdx] && conv.characterState === "idle") { + activePhases = Object.fromEntries( + Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx) + ); + taskEverStartedMap = Object.fromEntries( + Object.entries(taskEverStartedMap).filter(([k]) => Number(k) !== taskIdx) + ); + void onTaskCompleted(taskIdx, "completed"); + } } } }); @@ -83,7 +92,9 @@ }); } catch (error) { console.error("Failed to send task prompt:", error); - loopPhase = null; + activePhases = Object.fromEntries( + Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx) + ); void onTaskCompleted(taskIdx, "failed"); } } @@ -94,16 +105,34 @@ const currentLoopStatus = get(taskLoopStore.loopStatus); if (currentLoopStatus !== "running") return; - const taskList = get(taskLoopStore.tasks); - const nextIdx = findNextPendingIndex(taskList); + // If any tasks are still active, wait for them + if (Object.keys(activePhases).length > 0) return; - if (nextIdx === -1) { + await advanceToNextWave(); + } + + async function advanceToNextWave(): Promise { + const currentLoopStatus = get(taskLoopStore.loopStatus); + if (currentLoopStatus !== "running") return; + + // Mark any newly-blocked tasks + const taskList = get(taskLoopStore.tasks); + taskList.forEach((task, i) => { + if (task.status === "pending" && isTaskBlocked(task, taskList)) { + taskLoopStore.setTaskStatus(i, "blocked"); + } + }); + + const updatedTaskList = get(taskLoopStore.tasks); + const limit = get(taskLoopStore.concurrencyLimit); + const readyIndices = getReadyTasks(updatedTaskList, limit); + + if (readyIndices.length === 0) { taskLoopStore.setLoopStatus("stopped"); - taskLoopStore.setCurrentTaskIndex(-1); return; } - await startTask(nextIdx, taskList); + await Promise.all(readyIndices.map((i) => startTask(i, updatedTaskList))); } async function startTask(taskIdx: number, taskList: TaskLoopTask[]): Promise { @@ -113,19 +142,17 @@ ...new Set([...get(claudeStore.grantedTools), ...(config.auto_granted_tools ?? [])]), ]; - // sourceFile is already normalised to a Unix path at import time. const filePath = get(taskLoopStore.sourceFile); const workingDir = filePath.split("/").slice(0, -1).join("/"); - // Create a new conversation for this task const conversationId = claudeStore.createConversation(task.title); void claudeStore.switchConversation(conversationId); taskLoopStore.setTaskConversationId(taskIdx, conversationId); taskLoopStore.setTaskStatus(taskIdx, "running"); - taskLoopStore.setCurrentTaskIndex(taskIdx); - loopPhase = "waiting_for_connection"; - taskEverStarted = false; + + activePhases = { ...activePhases, [taskIdx]: "waiting_for_connection" }; + taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: false }; try { await invoke("start_claude", { @@ -144,7 +171,9 @@ }); } catch (error) { console.error("Failed to start Claude for task:", error); - loopPhase = null; + activePhases = Object.fromEntries( + Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx) + ); void onTaskCompleted(taskIdx, "failed"); } } @@ -170,58 +199,54 @@ async function handleStart(): Promise { const taskList = get(taskLoopStore.tasks); - const nextIdx = findNextPendingIndex(taskList); - if (nextIdx === -1) return; + const limit = get(taskLoopStore.concurrencyLimit); + const readyIndices = getReadyTasks(taskList, limit); + if (readyIndices.length === 0) return; taskLoopStore.setLoopStatus("running"); - await startTask(nextIdx, taskList); + await Promise.all(readyIndices.map((i) => startTask(i, taskList))); } function handlePause(): void { taskLoopStore.setLoopStatus("paused"); } - function handleResume(): void { + async function handleResume(): Promise { taskLoopStore.setLoopStatus("running"); - // If we're between tasks (no active phase), advance immediately - if (!loopPhase) { - const taskList = get(taskLoopStore.tasks); - const nextIdx = findNextPendingIndex(taskList); - if (nextIdx !== -1) { - void startTask(nextIdx, taskList); - } else { - taskLoopStore.setLoopStatus("stopped"); - } + if (Object.keys(activePhases).length === 0) { + await advanceToNextWave(); } } async function handleStop(): Promise { - const taskIdx = get(taskLoopStore.currentTaskIndex); - const taskList = get(taskLoopStore.tasks); - const currentTask = taskIdx >= 0 ? taskList[taskIdx] : null; - taskLoopStore.setLoopStatus("stopped"); - loopPhase = null; - // Stop Claude process for the current task if running - if (currentTask?.conversationId) { - try { - await invoke("stop_claude", { conversationId: currentTask.conversationId }); - } catch (error) { - console.error("Failed to stop Claude for current task:", error); + // Stop all active Claude processes + const taskList = get(taskLoopStore.tasks); + const stopPromises = Object.keys(activePhases).map(async (idxStr) => { + const taskIdx = Number(idxStr); + const task = taskList[taskIdx]; + if (task?.conversationId) { + try { + await invoke("stop_claude", { conversationId: task.conversationId }); + } catch (error) { + console.error("Failed to stop Claude for task:", error); + } + if (task.status === "running") { + taskLoopStore.setTaskStatus(taskIdx, "failed"); + } } - if (currentTask.status === "running") { - taskLoopStore.setTaskStatus(taskIdx, "failed"); - } - } + }); + await Promise.all(stopPromises); - taskLoopStore.setCurrentTaskIndex(-1); + activePhases = {}; + taskEverStartedMap = {}; } function handleReset(): void { taskLoopStore.reset(); - loopPhase = null; - taskEverStarted = false; + activePhases = {}; + taskEverStartedMap = {}; errorMessage = null; } @@ -235,6 +260,8 @@ return "text-green-400"; case "failed": return "text-red-400"; + case "blocked": + return "text-[var(--text-tertiary)] opacity-50"; } } @@ -248,6 +275,8 @@ return "✓"; case "failed": return "✗"; + case "blocked": + return "⊘"; } } @@ -261,6 +290,8 @@ return "bg-green-500/20 text-green-400 border-green-500/30"; } } + + const hasPendingTasks = $derived($tasks.some((t) => t.status === "pending"));
- Running {completedCount + - failedCount + - ($loopStatus === "running" ? 1 : 0)}/{totalCount} + {runningCount} running · {completedCount}/{totalCount} done {:else if $loopStatus === "paused"} {completedCount}/{totalCount} completed{failedCount > 0 ? `, ${failedCount} failed` - : ""} + : ""}{blockedCount > 0 ? `, ${blockedCount} blocked` : ""} {/if}
@@ -373,48 +402,81 @@ {$sourceFile} - -
- {#each $tasks as task, index (task.id)} -
- - - {statusIcon(task.status)} - - -
-
- - {task.title} - + +
+ {#each waves as waveIndices, waveIdx (waveIdx)} +
+ {#if multiWave} +
- {task.priority} + Wave {waveIdx + 1} - {#if $currentTaskIndex === index && $loopStatus === "running"} - ● running + {#if waveIndices.length > 1} + + ({waveIndices.length} parallel) + {/if} +
-

- {task.prompt} -

+ {/if} +
+ {#each waveIndices as taskIdx (taskIdx)} + {@const task = $tasks[taskIdx]} + {#if task} +
+ + + {statusIcon(task.status)} + + +
+
+ + {task.title} + + + {task.priority} + + {#if task.status === "running"} + ● running + {:else if task.status === "blocked"} + blocked + {/if} +
+

+ {task.prompt} +

+
+ + #{taskIdx + 1} +
+ {/if} + {/each}
- - #{index + 1}
{/each}
@@ -447,13 +509,37 @@ Reset {/if} + + + {#if totalCount > 0} +
+ Parallel: + + {$concurrencyLimit} + +
+ {/if}
{#if totalCount === 0} {:else if $loopStatus === "idle" || $loopStatus === "stopped"} - {#if findNextPendingIndex($tasks) !== -1} + {#if hasPendingTasks}
+ + {#if showSettings} +
+

+ Auto-commit Settings +

+ + + + + {#if $config.task_loop_auto_commit} + +
+ + updateCommitPrefix((e.target as HTMLInputElement).value)} + placeholder="feat" + class="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]" + /> + : task title +
+ + + + {/if} +
+ {/if} +
{#if isLoading} @@ -455,9 +622,15 @@ {task.priority} {#if task.status === "running"} - ● running + {#if activePhases[taskIdx] === "waiting_for_auto_commit"} + ● committing + {:else} + ● running + {/if} {:else if task.status === "blocked"} blocked diff --git a/src/lib/stores/config.test.ts b/src/lib/stores/config.test.ts index e2aee51..86744ce 100644 --- a/src/lib/stores/config.test.ts +++ b/src/lib/stores/config.test.ts @@ -217,6 +217,9 @@ describe("config store", () => { custom_font_family: null, custom_ui_font_path: null, custom_ui_font_family: null, + task_loop_auto_commit: false, + task_loop_commit_prefix: "feat", + task_loop_include_summary: false, }; expect(config.model).toBe("claude-sonnet-4"); @@ -273,6 +276,9 @@ describe("config store", () => { custom_font_family: null, custom_ui_font_path: null, custom_ui_font_family: null, + task_loop_auto_commit: false, + task_loop_commit_prefix: "feat", + task_loop_include_summary: false, }; expect(config.model).toBeNull(); @@ -884,6 +890,9 @@ describe("config store", () => { custom_font_family: null, custom_ui_font_path: null, custom_ui_font_family: null, + task_loop_auto_commit: false, + task_loop_commit_prefix: "feat", + task_loop_include_summary: false, }; const mockInvokeImpl = vi.mocked(invoke); diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts index ca824fe..0383d7c 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -77,6 +77,10 @@ export interface HikariConfig { // Custom UI font settings custom_ui_font_path: string | null; custom_ui_font_family: string | null; + // Task Loop auto-commit settings + task_loop_auto_commit: boolean; + task_loop_commit_prefix: string; + task_loop_include_summary: boolean; } const defaultConfig: HikariConfig = { @@ -127,6 +131,9 @@ const defaultConfig: HikariConfig = { custom_font_family: null, custom_ui_font_path: null, custom_ui_font_family: null, + task_loop_auto_commit: false, + task_loop_commit_prefix: "feat", + task_loop_include_summary: false, }; function createConfigStore() { diff --git a/src/lib/stores/taskLoop.test.ts b/src/lib/stores/taskLoop.test.ts index 245aa23..66d91e3 100644 --- a/src/lib/stores/taskLoop.test.ts +++ b/src/lib/stores/taskLoop.test.ts @@ -3,6 +3,7 @@ import { findNextPendingIndex, countByStatus, buildTaskPrompt, + buildAutoCommitPrompt, normalizeToUnixPath, isTaskBlocked, getReadyTasks, @@ -104,6 +105,58 @@ describe("buildTaskPrompt", () => { }); }); +describe("buildAutoCommitPrompt", () => { + const task = makeTask("task-1"); + + it("includes the git add and commit commands", () => { + const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00"); + expect(result).toContain("git add -A"); + expect(result).toContain("git commit -m"); + }); + + it("uses the provided prefix in the commit message", () => { + const result = buildAutoCommitPrompt(task, "fix", false, "2026-03-07T12:00:00"); + expect(result).toContain("fix:"); + }); + + it("includes the task title in the commit message", () => { + const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00"); + expect(result).toContain("Task task-1"); + }); + + it("includes the task id in the commit body", () => { + const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00"); + expect(result).toContain("task-1"); + }); + + it("includes the session timestamp in the commit body", () => { + const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00"); + expect(result).toContain("2026-03-07T12:00:00"); + }); + + it("does not include SUMMARY.md instructions when includeSummary is false", () => { + const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00"); + expect(result).not.toContain("SUMMARY.md"); + }); + + it("includes SUMMARY.md instructions when includeSummary is true", () => { + const result = buildAutoCommitPrompt(task, "feat", true, "2026-03-07T12:00:00"); + expect(result).toContain("SUMMARY.md"); + }); + + it("mentions non-blocking failure handling", () => { + const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00"); + expect(result).toContain("do not retry"); + }); + + it("escapes double quotes in the task title", () => { + const quotedTask = makeTask("q1"); + quotedTask.title = 'Fix "quoted" title'; + const result = buildAutoCommitPrompt(quotedTask, "fix", false, "2026-03-07T12:00:00"); + expect(result).toContain('\\"quoted\\"'); + }); +}); + describe("normalizeToUnixPath", () => { it("converts a WSL UNC path with wsl.localhost to a Unix path", () => { expect( diff --git a/src/lib/stores/taskLoop.ts b/src/lib/stores/taskLoop.ts index d056d69..b558c41 100644 --- a/src/lib/stores/taskLoop.ts +++ b/src/lib/stores/taskLoop.ts @@ -118,6 +118,34 @@ export function normalizeToUnixPath(path: string): string { return path; } +/** + * Builds the prompt sent to Claude after a task completes to commit the changes. + * If `includeSummary` is true, Claude is also asked to write/append to SUMMARY.md first. + */ +export function buildAutoCommitPrompt( + task: TaskLoopTask, + prefix: string, + includeSummary: boolean, + sessionTimestamp: string +): string { + const escapedTitle = task.title.replaceAll('"', '\\"'); + const commitMsg = `${prefix}: ${escapedTitle}\\n\\nAuto-committed by Hikari Task Loop\\nTask ID: ${task.id}\\nLoop session: ${sessionTimestamp}`; + + const gitCommands = `git add -A && git commit -m "${commitMsg}"`; + + const summaryRequest = includeSummary + ? `\n\nBefore committing, please write or append to \`SUMMARY.md\` in the working directory with:\n- What was implemented\n- Key decisions made\n- Files changed\n- Any caveats or follow-up work\n\nInclude SUMMARY.md in the commit.\n` + : ""; + + return `[Auto-commit] Please run the following in the current working directory:${summaryRequest} + +\`\`\`bash +${gitCommands} +\`\`\` + +If this fails (e.g. nothing to commit, no git repository), acknowledge it briefly and do not retry.`; +} + /** Builds the prompt sent to Claude Code for an automated task. */ export function buildTaskPrompt( task: TaskLoopTask, -- 2.52.0 From 105f87cf64ae0cf87d0b16fd3122bfa0a7729992 Mon Sep 17 00:00:00 2001 From: Hikari Date: Sat, 7 Mar 2026 02:10:31 -0800 Subject: [PATCH 16/16] feat: replace help modal with full embedded docs (paginated with prev/next navigation) --- src/lib/components/HelpPanel.svelte | 213 +++++++++++------- src/lib/components/NavMenu.svelte | 13 ++ src/lib/components/docs/DocsChangelog.svelte | 48 ++++ src/lib/components/docs/DocsChatInput.svelte | 105 +++++++++ src/lib/components/docs/DocsFileEditor.svelte | 79 +++++++ .../components/docs/DocsGettingStarted.svelte | 61 +++++ src/lib/components/docs/DocsGitPanel.svelte | 61 +++++ .../docs/DocsKeyboardShortcuts.svelte | 130 +++++++++++ .../components/docs/DocsModelConfig.svelte | 72 ++++++ .../components/docs/DocsPanelsTools.svelte | 128 +++++++++++ .../docs/DocsSessionManagement.svelte | 80 +++++++ src/lib/components/docs/DocsTaskLoop.svelte | 102 +++++++++ .../docs/DocsThemeCustomisation.svelte | 85 +++++++ .../docs/DocsTroubleshooting.svelte | 125 ++++++++++ src/lib/components/docs/helpPages.test.ts | 99 ++++++++ src/lib/components/docs/helpPages.ts | 40 ++++ 16 files changed, 1360 insertions(+), 81 deletions(-) create mode 100644 src/lib/components/docs/DocsChangelog.svelte create mode 100644 src/lib/components/docs/DocsChatInput.svelte create mode 100644 src/lib/components/docs/DocsFileEditor.svelte create mode 100644 src/lib/components/docs/DocsGettingStarted.svelte create mode 100644 src/lib/components/docs/DocsGitPanel.svelte create mode 100644 src/lib/components/docs/DocsKeyboardShortcuts.svelte create mode 100644 src/lib/components/docs/DocsModelConfig.svelte create mode 100644 src/lib/components/docs/DocsPanelsTools.svelte create mode 100644 src/lib/components/docs/DocsSessionManagement.svelte create mode 100644 src/lib/components/docs/DocsTaskLoop.svelte create mode 100644 src/lib/components/docs/DocsThemeCustomisation.svelte create mode 100644 src/lib/components/docs/DocsTroubleshooting.svelte create mode 100644 src/lib/components/docs/helpPages.test.ts create mode 100644 src/lib/components/docs/helpPages.ts diff --git a/src/lib/components/HelpPanel.svelte b/src/lib/components/HelpPanel.svelte index 4e8a418..f1a461c 100644 --- a/src/lib/components/HelpPanel.svelte +++ b/src/lib/components/HelpPanel.svelte @@ -1,54 +1,69 @@ + + +
e.key === "Escape" && onClose()} > +
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-labelledby="help-title" tabindex="-1" > -
-

- How to Use Hikari Desktop + +
+

+ Help & Documentation

-
- {#each sections as section (section.title)} -
-

{section.title}

-
    - {#each section.items as item (item)} -
  • - - {item} -
  • - {/each} -
-
- {/each} + +
+ + -
-

- Need more help? Join our Discord community for support and updates! -

+ +
+
+ + +
+ + + + Page {currentPageIndex + 1} of {HELP_PAGES.length} + + + +
diff --git a/src/lib/components/NavMenu.svelte b/src/lib/components/NavMenu.svelte index c4fa55f..8403851 100644 --- a/src/lib/components/NavMenu.svelte +++ b/src/lib/components/NavMenu.svelte @@ -92,8 +92,21 @@ function handleInjectContext(content: string): void { injectTextStore.set(content); } + + function handleGlobalHelpShortcut(event: KeyboardEvent): void { + const target = event.target as HTMLElement; + const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA"; + if (isInputFocused) return; + + if (event.key === "?") { + event.preventDefault(); + showHelp = !showHelp; + } + } + +