generated from nhcarrigan/template
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.
This commit is contained in:
@@ -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