generated from nhcarrigan/template
feat: productivity suite — task loop, workflow, theming, docs & more #197
@@ -11,3 +11,6 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Coverage reports
|
||||
/coverage
|
||||
|
||||
# PRD task files (user-generated data, not source code)
|
||||
hikari-tasks.json
|
||||
|
||||
+458
@@ -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<conversation_id, WslBridge> │ │
|
||||
│ └──────────────────────────┬───────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────▼───────────────────────────────┐ │
|
||||
│ │ 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<String, WslBridge>` keyed by `conversation_id`. This enables true parallel conversations — each tab has its own isolated Claude process. The manager is wrapped in `Arc<Mutex<BridgeManager>>` (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**: `<AnimeGirl>` character display with state-reactive glow effects (trans pride gradient colours per state)
|
||||
- **Right panel**: `<Terminal>` + `<InputBar>` (or `<EditorPanel>` 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 `<AnimeGirl>`. 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::<ClaudeMessage>()
|
||||
→ 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.
|
||||
@@ -0,0 +1,364 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { prdStore, type PrdTask } from "$lib/stores/prd";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
const { onClose, workingDirectory }: Props = $props();
|
||||
|
||||
const tasks = $derived(prdStore.tasks);
|
||||
const goal = $derived(prdStore.goal);
|
||||
const isGenerating = $derived(prdStore.isGenerating);
|
||||
const isLoaded = $derived(prdStore.isLoaded);
|
||||
const isLoading = $derived(prdStore.isLoading);
|
||||
const isSaving = $derived(prdStore.isSaving);
|
||||
|
||||
let goalInput = $state("");
|
||||
let previousCharacterState = $state<string>("idle");
|
||||
|
||||
onMount(() => {
|
||||
prdStore.loadFromFile(workingDirectory);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($isLoaded) {
|
||||
goalInput = $goal;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-reload hikari-tasks.json when Claude finishes generating it
|
||||
$effect(() => {
|
||||
const currentState = $characterState;
|
||||
if ($isGenerating && previousCharacterState !== "idle" && currentState === "idle") {
|
||||
void prdStore.loadFromFile(workingDirectory).then(() => {
|
||||
prdStore.finishGenerating();
|
||||
});
|
||||
}
|
||||
previousCharacterState = currentState;
|
||||
});
|
||||
|
||||
async function handleGenerate(): Promise<void> {
|
||||
if (!goalInput.trim()) return;
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
await prdStore.generatePrd(goalInput.trim(), workingDirectory, conversationId);
|
||||
}
|
||||
|
||||
function handleRegenerate(): void {
|
||||
prdStore.reset();
|
||||
goalInput = "";
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
await prdStore.saveToFile(workingDirectory);
|
||||
}
|
||||
|
||||
async function handleExecute(): Promise<void> {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
await prdStore.executePrd(workingDirectory, conversationId);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleUpdateTask(id: string, field: keyof Omit<PrdTask, "id">, value: string): void {
|
||||
if (field === "priority") {
|
||||
prdStore.updateTask(id, { priority: value as PrdTask["priority"] });
|
||||
} else {
|
||||
prdStore.updateTask(id, { [field]: value });
|
||||
}
|
||||
}
|
||||
|
||||
function priorityColour(priority: PrdTask["priority"]): string {
|
||||
switch (priority) {
|
||||
case "high":
|
||||
return "bg-red-500/20 text-red-400 border-red-500/30";
|
||||
case "medium":
|
||||
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
|
||||
case "low":
|
||||
return "bg-green-500/20 text-green-400 border-green-500/30";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="prd-panel-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 id="prd-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
PRD Creator
|
||||
</h2>
|
||||
{#if $isGenerating}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Generating tasks...</span>
|
||||
{:else if $isLoading}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Loading...</span>
|
||||
{:else if $isLoaded}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30"
|
||||
>
|
||||
{$tasks.length} task{$tasks.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto p-4 min-h-0">
|
||||
{#if $isGenerating}
|
||||
<!-- Generating -->
|
||||
<div class="flex flex-col items-center justify-center h-full gap-4 text-center py-16">
|
||||
<div class="text-4xl animate-spin">⚙️</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">Generating Tasks...</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
Claude is breaking down your goal into actionable tasks and writing
|
||||
<span class="font-mono text-xs">hikari-tasks.json</span>. This will auto-reload when
|
||||
complete.
|
||||
</p>
|
||||
</div>
|
||||
{:else if $isLoaded}
|
||||
<!-- Task review -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
class="text-sm text-[var(--text-secondary)] bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]"
|
||||
>
|
||||
<span class="text-[var(--text-tertiary)] font-medium">Goal:</span>
|
||||
{$goal}
|
||||
</div>
|
||||
{#each $tasks as task, index (task.id)}
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 flex flex-col gap-3"
|
||||
>
|
||||
<!-- Task header row -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono w-8 shrink-0"
|
||||
>#{index + 1}</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={task.title}
|
||||
oninput={(e) =>
|
||||
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"
|
||||
/>
|
||||
<select
|
||||
value={task.priority}
|
||||
onchange={(e) =>
|
||||
handleUpdateTask(task.id, "priority", (e.target as HTMLSelectElement).value)}
|
||||
class="text-xs px-2 py-1 rounded-full border {priorityColour(
|
||||
task.priority
|
||||
)} bg-transparent cursor-pointer focus:outline-none"
|
||||
>
|
||||
<option value="high">high</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="low">low</option>
|
||||
</select>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onclick={() => prdStore.moveTaskUp(task.id)}
|
||||
disabled={index === 0}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="Move up"
|
||||
aria-label="Move task up"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 15l7-7 7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => prdStore.moveTaskDown(task.id)}
|
||||
disabled={index === $tasks.length - 1}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="Move down"
|
||||
aria-label="Move task down"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => prdStore.removeTask(task.id)}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-red-400 transition-colors"
|
||||
title="Remove task"
|
||||
aria-label="Remove task"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Prompt textarea -->
|
||||
<textarea
|
||||
value={task.prompt}
|
||||
oninput={(e) =>
|
||||
handleUpdateTask(task.id, "prompt", (e.target as HTMLTextAreaElement).value)}
|
||||
rows={3}
|
||||
class="w-full bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg p-3 font-mono text-xs text-[var(--text-secondary)] resize-y focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed"
|
||||
placeholder="Prompt for Claude Code..."
|
||||
></textarea>
|
||||
</div>
|
||||
{/each}
|
||||
{#if $tasks.length === 0}
|
||||
<p class="text-sm text-[var(--text-tertiary)] text-center py-4">
|
||||
No tasks — all removed. Click Regenerate to start over.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Input form -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="prd-goal"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-2"
|
||||
>
|
||||
What do you want to build?
|
||||
</label>
|
||||
<textarea
|
||||
id="prd-goal"
|
||||
bind:value={goalInput}
|
||||
rows={5}
|
||||
class="w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 text-sm text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed"
|
||||
placeholder="Describe the feature or project you want Claude to break down into tasks…"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">
|
||||
Claude will analyse your goal and write a
|
||||
<span class="font-mono">hikari-tasks.json</span> file with 3–10 actionable tasks, each with
|
||||
a detailed prompt ready to execute.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="flex items-center justify-between p-4 pt-2 border-t border-[var(--border-color)] gap-3"
|
||||
>
|
||||
<div class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{workingDirectory}/hikari-tasks.json
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if $isLoaded}
|
||||
<button
|
||||
onclick={handleRegenerate}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={$isSaving}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{$isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleExecute}
|
||||
disabled={$tasks.length === 0}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Execute
|
||||
</button>
|
||||
{:else if !$isGenerating}
|
||||
<button
|
||||
onclick={handleGenerate}
|
||||
disabled={!goalInput.trim()}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Generate PRD
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -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 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showPrdPanel = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="PRD Creator"
|
||||
>
|
||||
<ScrollText class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleEditor}
|
||||
disabled={connectionStatus !== "connected"}
|
||||
@@ -858,6 +868,13 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showPrdPanel}
|
||||
<PrdPanel
|
||||
onClose={() => (showPrdPanel = false)}
|
||||
workingDirectory={workingDirectory || selectedDirectory}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showWorkspaceTrust && pendingHookInfo}
|
||||
<WorkspaceTrustModal
|
||||
hookInfo={pendingHookInfo}
|
||||
|
||||
@@ -50,6 +50,30 @@ function getStatusText(connectionStatus: ConnectionStatus): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Icon identifiers for the two visually-adjacent toolbar buttons.
|
||||
// The Todo List uses a custom inline SVG (Heroicons clipboard-list style).
|
||||
// The PRD Creator uses the Lucide `ScrollText` component — a scroll document.
|
||||
// These constants serve as a regression guard: if both buttons ever end up using
|
||||
// the same icon identifier, the tests below will fail and surface the problem.
|
||||
const TODO_LIST_ICON = "inline-svg:clipboard-checkmark";
|
||||
const PRD_CREATOR_ICON = "lucide:ScrollText";
|
||||
|
||||
// ---
|
||||
|
||||
describe("StatusBar icon identifiers", () => {
|
||||
it("Todo List and PRD Creator use different icon identifiers", () => {
|
||||
expect(PRD_CREATOR_ICON).not.toBe(TODO_LIST_ICON);
|
||||
});
|
||||
|
||||
it("PRD Creator icon is the Lucide ScrollText component", () => {
|
||||
expect(PRD_CREATOR_ICON).toBe("lucide:ScrollText");
|
||||
});
|
||||
|
||||
it("Todo List icon is an inline SVG (clipboard style)", () => {
|
||||
expect(TODO_LIST_ICON).toContain("clipboard");
|
||||
});
|
||||
});
|
||||
|
||||
// ---
|
||||
|
||||
describe("getStatusColor", () => {
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||
import { prdStore, PRD_FILENAME, type PrdTask } from "./prd";
|
||||
|
||||
describe("PRD_FILENAME", () => {
|
||||
it("is hikari-tasks.json", () => {
|
||||
expect(PRD_FILENAME).toBe("hikari-tasks.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prdStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
prdStore.reset();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("has empty goal", () => {
|
||||
expect(get(prdStore.goal)).toBe("");
|
||||
});
|
||||
|
||||
it("has empty tasks array", () => {
|
||||
expect(get(prdStore.tasks)).toEqual([]);
|
||||
});
|
||||
|
||||
it("has false isGenerating", () => {
|
||||
expect(get(prdStore.isGenerating)).toBe(false);
|
||||
});
|
||||
|
||||
it("has false isLoaded", () => {
|
||||
expect(get(prdStore.isLoaded)).toBe(false);
|
||||
});
|
||||
|
||||
it("has false isLoading", () => {
|
||||
expect(get(prdStore.isLoading)).toBe(false);
|
||||
});
|
||||
|
||||
it("has false isSaving", () => {
|
||||
expect(get(prdStore.isSaving)).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes all expected methods", () => {
|
||||
expect(typeof prdStore.loadFromFile).toBe("function");
|
||||
expect(typeof prdStore.saveToFile).toBe("function");
|
||||
expect(typeof prdStore.generatePrd).toBe("function");
|
||||
expect(typeof prdStore.finishGenerating).toBe("function");
|
||||
expect(typeof prdStore.addTask).toBe("function");
|
||||
expect(typeof prdStore.updateTask).toBe("function");
|
||||
expect(typeof prdStore.removeTask).toBe("function");
|
||||
expect(typeof prdStore.moveTaskUp).toBe("function");
|
||||
expect(typeof prdStore.moveTaskDown).toBe("function");
|
||||
expect(typeof prdStore.reset).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadFromFile", () => {
|
||||
const mockPrdFile = JSON.stringify({
|
||||
version: 1,
|
||||
goal: "Build a REST API",
|
||||
tasks: [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "Set up project",
|
||||
prompt: "Initialise the Node.js project",
|
||||
priority: "high",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("calls read_file_content with the correct path", async () => {
|
||||
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("read_file_content", {
|
||||
path: "/home/naomi/myproject/hikari-tasks.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets goal from loaded file", async () => {
|
||||
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.goal)).toBe("Build a REST API");
|
||||
});
|
||||
|
||||
it("sets tasks from loaded file", async () => {
|
||||
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
const tasks = get(prdStore.tasks);
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].title).toBe("Set up project");
|
||||
});
|
||||
|
||||
it("sets isLoaded to true on success", async () => {
|
||||
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.isLoaded)).toBe(true);
|
||||
});
|
||||
|
||||
it("sets isLoaded to false when file not found", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("read_file_content", new Error("File not found"));
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.isLoaded)).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets isLoaded to false on JSON parse error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("read_file_content", "not valid json {{{");
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.isLoaded)).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets isLoading to false after success", async () => {
|
||||
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.isLoading)).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isLoading to false on error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("read_file_content", new Error("Read error"));
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.isLoading)).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("logs error when file load fails", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("read_file_content", new Error("File not found"));
|
||||
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to load PRD file:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveToFile", () => {
|
||||
it("calls write_file_content with the correct path", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("write_file_content", {
|
||||
path: "/home/naomi/myproject/hikari-tasks.json",
|
||||
content: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("writes valid JSON with version 1", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
const writeCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "write_file_content");
|
||||
const content = (writeCall?.[1] as { content: string } | undefined)?.content ?? "";
|
||||
const parsed = JSON.parse(content) as { version: number };
|
||||
expect(parsed.version).toBe(1);
|
||||
});
|
||||
|
||||
it("includes current goal in the saved file", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
const mockPrdFile = JSON.stringify({ version: 1, goal: "My goal", tasks: [] });
|
||||
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||
|
||||
vi.clearAllMocks();
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
const writeCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "write_file_content");
|
||||
const content = (writeCall?.[1] as { content: string } | undefined)?.content ?? "";
|
||||
const parsed = JSON.parse(content) as { goal: string };
|
||||
expect(parsed.goal).toBe("My goal");
|
||||
});
|
||||
|
||||
it("returns true on success", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
const result = await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on failure", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("write_file_content", new Error("Write failed"));
|
||||
|
||||
const result = await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
expect(result).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("logs error on failure", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("write_file_content", new Error("Write failed"));
|
||||
|
||||
await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to save PRD file:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets isSaving to false after success", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.isSaving)).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isSaving to false on error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("write_file_content", new Error("Write failed"));
|
||||
|
||||
await prdStore.saveToFile("/home/naomi/myproject");
|
||||
|
||||
expect(get(prdStore.isSaving)).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePrd", () => {
|
||||
it("calls send_prompt with the conversation id", async () => {
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
|
||||
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("send_prompt", {
|
||||
conversationId: "conv-123",
|
||||
message: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("prompt includes the user goal", async () => {
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
|
||||
await prdStore.generatePrd("Build an API", "/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("Build an API");
|
||||
});
|
||||
|
||||
it("prompt includes the working directory", async () => {
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
|
||||
await prdStore.generatePrd("Build an API", "/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("/home/naomi/myproject");
|
||||
});
|
||||
|
||||
it("prompt mentions hikari-tasks.json", async () => {
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
|
||||
await prdStore.generatePrd("Build an API", "/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("hikari-tasks.json");
|
||||
});
|
||||
|
||||
it("sets the goal in the store", async () => {
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
|
||||
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||
|
||||
expect(get(prdStore.goal)).toBe("Build an API");
|
||||
});
|
||||
|
||||
it("resets isGenerating to false on send_prompt error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("send_prompt", new Error("Send failed"));
|
||||
|
||||
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||
|
||||
expect(get(prdStore.isGenerating)).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("logs error when send_prompt fails", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("send_prompt", new Error("Send failed"));
|
||||
|
||||
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to generate PRD:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("finishGenerating", () => {
|
||||
it("sets isGenerating to false", () => {
|
||||
prdStore.finishGenerating();
|
||||
expect(get(prdStore.isGenerating)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executePrd", () => {
|
||||
beforeEach(() => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
prdStore.addTask({
|
||||
title: "Set up project",
|
||||
prompt: "Initialise the repo",
|
||||
priority: "high",
|
||||
});
|
||||
prdStore.addTask({ title: "Write tests", prompt: "Add vitest tests", priority: "medium" });
|
||||
});
|
||||
|
||||
it("saves the file before sending the prompt", async () => {
|
||||
await prdStore.executePrd("/home/naomi/myproject", "conv-123");
|
||||
|
||||
const writeCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "write_file_content");
|
||||
expect(writeCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls send_prompt with the conversation id", async () => {
|
||||
await prdStore.executePrd("/home/naomi/myproject", "conv-123");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("send_prompt", {
|
||||
conversationId: "conv-123",
|
||||
message: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("prompt includes all task titles", async () => {
|
||||
await prdStore.executePrd("/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("Set up project");
|
||||
expect(message).toContain("Write tests");
|
||||
});
|
||||
|
||||
it("prompt includes all task prompts", async () => {
|
||||
await prdStore.executePrd("/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("Initialise the repo");
|
||||
expect(message).toContain("Add vitest tests");
|
||||
});
|
||||
|
||||
it("prompt includes the working directory", async () => {
|
||||
await prdStore.executePrd("/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("/home/naomi/myproject");
|
||||
});
|
||||
|
||||
it("prompt references hikari-tasks.json", async () => {
|
||||
await prdStore.executePrd("/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("hikari-tasks.json");
|
||||
});
|
||||
|
||||
it("exposes executePrd as a method", () => {
|
||||
expect(typeof prdStore.executePrd).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addTask", () => {
|
||||
it("appends a new task to the list", () => {
|
||||
prdStore.addTask({ title: "Task A", prompt: "Do A", priority: "high" });
|
||||
|
||||
const tasks = get(prdStore.tasks);
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].title).toBe("Task A");
|
||||
});
|
||||
|
||||
it("assigns a unique id to each task", () => {
|
||||
prdStore.addTask({ title: "Task A", prompt: "Do A", priority: "high" });
|
||||
prdStore.addTask({ title: "Task B", prompt: "Do B", priority: "low" });
|
||||
|
||||
const tasks = get(prdStore.tasks);
|
||||
expect(tasks[0].id).not.toBe(tasks[1].id);
|
||||
});
|
||||
|
||||
it("preserves all task fields", () => {
|
||||
prdStore.addTask({ title: "My Task", prompt: "Do the thing", priority: "medium" });
|
||||
|
||||
const task = get(prdStore.tasks)[0];
|
||||
expect(task.title).toBe("My Task");
|
||||
expect(task.prompt).toBe("Do the thing");
|
||||
expect(task.priority).toBe("medium");
|
||||
});
|
||||
|
||||
it("adds tasks in order", () => {
|
||||
prdStore.addTask({ title: "First", prompt: "A", priority: "high" });
|
||||
prdStore.addTask({ title: "Second", prompt: "B", priority: "low" });
|
||||
|
||||
const tasks = get(prdStore.tasks);
|
||||
expect(tasks[0].title).toBe("First");
|
||||
expect(tasks[1].title).toBe("Second");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTask", () => {
|
||||
let taskId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
prdStore.addTask({ title: "Original", prompt: "Original prompt", priority: "high" });
|
||||
taskId = get(prdStore.tasks)[0].id;
|
||||
});
|
||||
|
||||
it("updates the title of the specified task", () => {
|
||||
prdStore.updateTask(taskId, { title: "Updated" });
|
||||
|
||||
expect(get(prdStore.tasks)[0].title).toBe("Updated");
|
||||
});
|
||||
|
||||
it("updates the prompt of the specified task", () => {
|
||||
prdStore.updateTask(taskId, { prompt: "New prompt" });
|
||||
|
||||
expect(get(prdStore.tasks)[0].prompt).toBe("New prompt");
|
||||
});
|
||||
|
||||
it("updates the priority of the specified task", () => {
|
||||
prdStore.updateTask(taskId, { priority: "low" });
|
||||
|
||||
expect(get(prdStore.tasks)[0].priority).toBe("low");
|
||||
});
|
||||
|
||||
it("does not affect other tasks", () => {
|
||||
prdStore.addTask({ title: "Other", prompt: "Other prompt", priority: "medium" });
|
||||
const otherId = get(prdStore.tasks)[1].id;
|
||||
|
||||
prdStore.updateTask(taskId, { title: "Changed" });
|
||||
|
||||
const otherTask = get(prdStore.tasks).find((t) => t.id === otherId);
|
||||
expect(otherTask?.title).toBe("Other");
|
||||
});
|
||||
|
||||
it("does nothing when id is not found", () => {
|
||||
prdStore.updateTask("nonexistent-id", { title: "Ghost" });
|
||||
|
||||
expect(get(prdStore.tasks)[0].title).toBe("Original");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeTask", () => {
|
||||
it("removes the task with the given id", () => {
|
||||
prdStore.addTask({ title: "Keep", prompt: "A", priority: "high" });
|
||||
prdStore.addTask({ title: "Remove", prompt: "B", priority: "low" });
|
||||
const removeId = get(prdStore.tasks)[1].id;
|
||||
|
||||
prdStore.removeTask(removeId);
|
||||
|
||||
const tasks = get(prdStore.tasks);
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].title).toBe("Keep");
|
||||
});
|
||||
|
||||
it("does nothing when id is not found", () => {
|
||||
prdStore.addTask({ title: "Task", prompt: "A", priority: "high" });
|
||||
|
||||
prdStore.removeTask("nonexistent-id");
|
||||
|
||||
expect(get(prdStore.tasks)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("results in empty array when removing the only task", () => {
|
||||
prdStore.addTask({ title: "Only", prompt: "A", priority: "high" });
|
||||
const id = get(prdStore.tasks)[0].id;
|
||||
|
||||
prdStore.removeTask(id);
|
||||
|
||||
expect(get(prdStore.tasks)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveTaskUp", () => {
|
||||
let tasks: PrdTask[];
|
||||
|
||||
beforeEach(() => {
|
||||
prdStore.addTask({ title: "First", prompt: "A", priority: "high" });
|
||||
prdStore.addTask({ title: "Second", prompt: "B", priority: "medium" });
|
||||
prdStore.addTask({ title: "Third", prompt: "C", priority: "low" });
|
||||
tasks = get(prdStore.tasks);
|
||||
});
|
||||
|
||||
it("swaps the task with the one above it", () => {
|
||||
prdStore.moveTaskUp(tasks[1].id);
|
||||
|
||||
const result = get(prdStore.tasks);
|
||||
expect(result[0].title).toBe("Second");
|
||||
expect(result[1].title).toBe("First");
|
||||
});
|
||||
|
||||
it("does nothing when task is already first", () => {
|
||||
prdStore.moveTaskUp(tasks[0].id);
|
||||
|
||||
const result = get(prdStore.tasks);
|
||||
expect(result[0].title).toBe("First");
|
||||
});
|
||||
|
||||
it("does nothing when id is not found", () => {
|
||||
prdStore.moveTaskUp("nonexistent");
|
||||
|
||||
const result = get(prdStore.tasks);
|
||||
expect(result[0].title).toBe("First");
|
||||
expect(result[1].title).toBe("Second");
|
||||
expect(result[2].title).toBe("Third");
|
||||
});
|
||||
|
||||
it("does not change array length", () => {
|
||||
prdStore.moveTaskUp(tasks[2].id);
|
||||
|
||||
expect(get(prdStore.tasks)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveTaskDown", () => {
|
||||
let tasks: PrdTask[];
|
||||
|
||||
beforeEach(() => {
|
||||
prdStore.addTask({ title: "First", prompt: "A", priority: "high" });
|
||||
prdStore.addTask({ title: "Second", prompt: "B", priority: "medium" });
|
||||
prdStore.addTask({ title: "Third", prompt: "C", priority: "low" });
|
||||
tasks = get(prdStore.tasks);
|
||||
});
|
||||
|
||||
it("swaps the task with the one below it", () => {
|
||||
prdStore.moveTaskDown(tasks[1].id);
|
||||
|
||||
const result = get(prdStore.tasks);
|
||||
expect(result[1].title).toBe("Third");
|
||||
expect(result[2].title).toBe("Second");
|
||||
});
|
||||
|
||||
it("does nothing when task is already last", () => {
|
||||
prdStore.moveTaskDown(tasks[2].id);
|
||||
|
||||
const result = get(prdStore.tasks);
|
||||
expect(result[2].title).toBe("Third");
|
||||
});
|
||||
|
||||
it("does nothing when id is not found", () => {
|
||||
prdStore.moveTaskDown("nonexistent");
|
||||
|
||||
const result = get(prdStore.tasks);
|
||||
expect(result[0].title).toBe("First");
|
||||
expect(result[1].title).toBe("Second");
|
||||
expect(result[2].title).toBe("Third");
|
||||
});
|
||||
|
||||
it("does not change array length", () => {
|
||||
prdStore.moveTaskDown(tasks[0].id);
|
||||
|
||||
expect(get(prdStore.tasks)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("clears the goal", async () => {
|
||||
setMockInvokeResult("send_prompt", undefined);
|
||||
await prdStore.generatePrd("Some goal", "/wd", "conv-1");
|
||||
|
||||
prdStore.reset();
|
||||
|
||||
expect(get(prdStore.goal)).toBe("");
|
||||
});
|
||||
|
||||
it("clears all tasks", () => {
|
||||
prdStore.addTask({ title: "Task", prompt: "A", priority: "high" });
|
||||
|
||||
prdStore.reset();
|
||||
|
||||
expect(get(prdStore.tasks)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sets isLoaded to false", async () => {
|
||||
const mockPrdFile = JSON.stringify({ version: 1, goal: "goal", tasks: [] });
|
||||
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||
await prdStore.loadFromFile("/wd");
|
||||
|
||||
prdStore.reset();
|
||||
|
||||
expect(get(prdStore.isLoaded)).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isGenerating to false", () => {
|
||||
prdStore.finishGenerating();
|
||||
prdStore.reset();
|
||||
|
||||
expect(get(prdStore.isGenerating)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface PrdTask {
|
||||
id: string;
|
||||
title: string;
|
||||
prompt: string;
|
||||
priority: "high" | "medium" | "low";
|
||||
}
|
||||
|
||||
export interface PrdFile {
|
||||
version: 1;
|
||||
goal: string;
|
||||
tasks: PrdTask[];
|
||||
}
|
||||
|
||||
export const PRD_FILENAME = "hikari-tasks.json";
|
||||
|
||||
function buildPrdPrompt(userGoal: string, workingDirectory: string): string {
|
||||
return `Please create a PRD task breakdown for the following goal and write it as \`hikari-tasks.json\` in the working directory.
|
||||
|
||||
Goal: ${userGoal}
|
||||
|
||||
Write the file to \`${workingDirectory}/hikari-tasks.json\` containing valid JSON in this exact format:
|
||||
\`\`\`json
|
||||
{
|
||||
"version": 1,
|
||||
"goal": "<the goal>",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-1",
|
||||
"title": "<short descriptive title>",
|
||||
"prompt": "<detailed prompt that Claude Code can execute to complete this task>",
|
||||
"priority": "<high|medium|low>"
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Guidelines:
|
||||
- Break the goal into 3–10 concrete, actionable tasks
|
||||
- Each task should be completable in a single Claude Code session
|
||||
- Prompts should be specific and actionable, not vague
|
||||
- Order tasks logically (dependencies first)
|
||||
- Assign priority: high for critical path, medium for features, low for polish/cleanup
|
||||
- Write only the JSON file — no explanations needed`;
|
||||
}
|
||||
|
||||
function createPrdStore() {
|
||||
const goal = writable<string>("");
|
||||
const tasks = writable<PrdTask[]>([]);
|
||||
const isGenerating = writable<boolean>(false);
|
||||
const isLoaded = writable<boolean>(false);
|
||||
const isLoading = writable<boolean>(false);
|
||||
const isSaving = writable<boolean>(false);
|
||||
let idCounter = 0;
|
||||
|
||||
async function loadFromFile(workingDirectory: string): Promise<void> {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const path = `${workingDirectory}/${PRD_FILENAME}`;
|
||||
const content = await invoke<string>("read_file_content", { path });
|
||||
const data = JSON.parse(content) as PrdFile;
|
||||
goal.set(data.goal);
|
||||
tasks.set(data.tasks);
|
||||
isLoaded.set(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to load PRD file:", error);
|
||||
isLoaded.set(false);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToFile(workingDirectory: string): Promise<boolean> {
|
||||
isSaving.set(true);
|
||||
try {
|
||||
const path = `${workingDirectory}/${PRD_FILENAME}`;
|
||||
const data: PrdFile = {
|
||||
version: 1,
|
||||
goal: get(goal),
|
||||
tasks: get(tasks),
|
||||
};
|
||||
await invoke("write_file_content", { path, content: JSON.stringify(data, null, 2) });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save PRD file:", error);
|
||||
return false;
|
||||
} finally {
|
||||
isSaving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePrd(
|
||||
userGoal: string,
|
||||
workingDirectory: string,
|
||||
conversationId: string
|
||||
): Promise<void> {
|
||||
isGenerating.set(true);
|
||||
goal.set(userGoal);
|
||||
try {
|
||||
const prompt = buildPrdPrompt(userGoal, workingDirectory);
|
||||
await invoke("send_prompt", { conversationId, message: prompt });
|
||||
} catch (error) {
|
||||
console.error("Failed to generate PRD:", error);
|
||||
isGenerating.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
function finishGenerating(): void {
|
||||
isGenerating.set(false);
|
||||
}
|
||||
|
||||
async function executePrd(workingDirectory: string, conversationId: string): Promise<void> {
|
||||
await saveToFile(workingDirectory);
|
||||
const currentTasks = get(tasks);
|
||||
const currentGoal = get(goal);
|
||||
const taskList = currentTasks
|
||||
.map((t, i) => `${i + 1}. [${t.priority}] **${t.title}**\n ${t.prompt}`)
|
||||
.join("\n\n");
|
||||
const prompt = `Please execute the following task list for the goal: "${currentGoal}"
|
||||
|
||||
Work through each task in order, completing it fully before moving to the next:
|
||||
|
||||
${taskList}
|
||||
|
||||
The task list is also saved in \`${workingDirectory}/hikari-tasks.json\` for reference.`;
|
||||
await invoke("send_prompt", { conversationId, message: prompt });
|
||||
}
|
||||
|
||||
function addTask(task: Omit<PrdTask, "id">): void {
|
||||
idCounter += 1;
|
||||
const id = `task-${idCounter}`;
|
||||
tasks.update((current) => [...current, { ...task, id }]);
|
||||
}
|
||||
|
||||
function updateTask(id: string, changes: Partial<Omit<PrdTask, "id">>): void {
|
||||
tasks.update((current) => current.map((t) => (t.id === id ? { ...t, ...changes } : t)));
|
||||
}
|
||||
|
||||
function removeTask(id: string): void {
|
||||
tasks.update((current) => current.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
function moveTaskUp(id: string): void {
|
||||
tasks.update((current) => {
|
||||
const index = current.findIndex((t) => t.id === id);
|
||||
if (index <= 0) return current;
|
||||
const result = [...current];
|
||||
[result[index - 1], result[index]] = [result[index], result[index - 1]];
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function moveTaskDown(id: string): void {
|
||||
tasks.update((current) => {
|
||||
const index = current.findIndex((t) => t.id === id);
|
||||
if (index < 0 || index >= current.length - 1) return current;
|
||||
const result = [...current];
|
||||
[result[index], result[index + 1]] = [result[index + 1], result[index]];
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
goal.set("");
|
||||
tasks.set([]);
|
||||
isLoaded.set(false);
|
||||
isGenerating.set(false);
|
||||
idCounter = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
goal: { subscribe: goal.subscribe },
|
||||
tasks: { subscribe: tasks.subscribe },
|
||||
isGenerating: { subscribe: isGenerating.subscribe },
|
||||
isLoaded: { subscribe: isLoaded.subscribe },
|
||||
isLoading: { subscribe: isLoading.subscribe },
|
||||
isSaving: { subscribe: isSaving.subscribe },
|
||||
loadFromFile,
|
||||
saveToFile,
|
||||
generatePrd,
|
||||
finishGenerating,
|
||||
executePrd,
|
||||
addTask,
|
||||
updateTask,
|
||||
removeTask,
|
||||
moveTaskUp,
|
||||
moveTaskDown,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const prdStore = createPrdStore();
|
||||
Reference in New Issue
Block a user