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:
2026-03-06 17:04:23 -08:00
committed by Naomi Carrigan
parent 9136f3351d
commit 55d65fa244
7 changed files with 1675 additions and 0 deletions
+364
View File
@@ -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 310 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>
+17
View File
@@ -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}
+24
View File
@@ -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", () => {
+615
View File
@@ -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);
});
});
});
+194
View File
@@ -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 310 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();