generated from nhcarrigan/template
feat: project context panel for persistent PROJECT/REQUIREMENTS/ROADMAP/STATE files (#188)
- Add projectContext store with load/save via existing Tauri file commands - Add ProjectContextPanel modal with tabbed editor, file-exists badges, and templates - Add injectTextStore signal so StatusBar can inject content directly into InputBar - Add PROJECT_CONTEXT_SYSTEM_ADDENDUM auto-appended to custom_instructions on connect - Add open-book button to StatusBar to open the panel - Add PROJECT.md example file for the hikari-desktop project itself - 34 tests covering all store methods, exports, and signal behaviour
This commit is contained in:
+45
@@ -0,0 +1,45 @@
|
||||
# Project Overview
|
||||
|
||||
## What is this project?
|
||||
|
||||
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character companion (Hikari) who appears on screen. It provides a rich UI for interacting with Claude Code, including conversation management, agent monitoring, cost tracking, and more.
|
||||
|
||||
The app was inspired by a Hatsune Miku mod for the ship AI in _The Outer Worlds_ — the idea of an AI assistant with an anime girl avatar that you can actually _see_.
|
||||
|
||||
## Goals
|
||||
|
||||
- Provide a beautiful, personalised interface for Claude Code
|
||||
- Surface real-time status (thinking, typing, searching, etc.) through animated character sprites
|
||||
- Track costs, context usage, and agent activity across sessions
|
||||
- Support power-user workflows: multi-tab conversations, todo lists, git integration, MCP server management, session compaction, and more
|
||||
- Build a foundation for autonomous task execution (agent orchestration, PRD-driven workflows)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: Svelte 5 + TypeScript + Tailwind CSS
|
||||
- **Backend**: Rust (Tauri v2)
|
||||
- **Build**: Vite + pnpm
|
||||
- **Testing**: Vitest (frontend) + cargo test (backend)
|
||||
- **Linting**: ESLint + Prettier (frontend) + Clippy (backend)
|
||||
- **IPC**: Tauri commands + events between Rust and Svelte
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
hikari-desktop/
|
||||
├── src/ # Svelte frontend
|
||||
│ └── lib/
|
||||
│ ├── components/ # UI components (panels, modals, status bar)
|
||||
│ ├── stores/ # Svelte stores (state management)
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ └── utils/ # Utility functions
|
||||
├── src-tauri/ # Rust backend
|
||||
│ └── src/
|
||||
│ ├── commands.rs # Tauri command handlers
|
||||
│ ├── wsl_bridge.rs # Claude Code process management
|
||||
│ ├── types.rs # Shared types & CharacterState enum
|
||||
│ └── stats.rs # Cost tracking
|
||||
└── public/ # Static assets (sprites, sounds)
|
||||
```
|
||||
|
||||
Claude Code is launched as a child process via `WslBridge`, communicating via `--output-format stream-json` (NDJSON). Messages flow from the Rust backend to the Svelte frontend via Tauri events.
|
||||
@@ -37,6 +37,7 @@
|
||||
import DraftPanel from "$lib/components/DraftPanel.svelte";
|
||||
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
|
||||
import { draftsStore } from "$lib/stores/drafts";
|
||||
import { injectTextStore } from "$lib/stores/projectContext";
|
||||
import type { Attachment } from "$lib/types/messages";
|
||||
|
||||
const INPUT_HISTORY_KEY = "hikari-input-history";
|
||||
@@ -178,6 +179,14 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Project context injection — set by StatusBar via injectTextStore signal.
|
||||
injectTextStore.subscribe((text) => {
|
||||
if (text === null) return;
|
||||
inputValue = inputValue.trim() ? text + "\n\n" + inputValue : text;
|
||||
userHasTyped = true;
|
||||
injectTextStore.set(null);
|
||||
});
|
||||
|
||||
function clearInput() {
|
||||
inputValue = "";
|
||||
const activeId = get(claudeStore.activeConversationId);
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
projectContextStore,
|
||||
PROJECT_FILE_NAMES,
|
||||
type ProjectFile,
|
||||
} from "$lib/stores/projectContext";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onInject: (content: string) => void;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
const { onClose, onInject, workingDirectory }: Props = $props();
|
||||
|
||||
const PROJECT_FILES: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
|
||||
|
||||
const contents = $derived(projectContextStore.contents);
|
||||
const isLoading = $derived(projectContextStore.isLoading);
|
||||
const isSaving = $derived(projectContextStore.isSaving);
|
||||
const activeFile = $derived(projectContextStore.activeFile);
|
||||
|
||||
let editContent = $state("");
|
||||
let hasUnsavedChanges = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
projectContextStore.loadAll(workingDirectory);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const file = $activeFile;
|
||||
const fileContent = $contents[file];
|
||||
editContent = fileContent ?? projectContextStore.getTemplate(file);
|
||||
hasUnsavedChanges = false;
|
||||
});
|
||||
|
||||
function handleTabSwitch(file: ProjectFile): void {
|
||||
projectContextStore.setActiveFile(file);
|
||||
}
|
||||
|
||||
function handleUseTemplate(): void {
|
||||
editContent = projectContextStore.getTemplate($activeFile);
|
||||
hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
function handleInject(): void {
|
||||
onInject(editContent);
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
const saved = await projectContextStore.saveFile($activeFile, editContent, workingDirectory);
|
||||
if (saved) {
|
||||
hasUnsavedChanges = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextChange(event: Event): void {
|
||||
editContent = (event.target as HTMLTextAreaElement).value;
|
||||
hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
function fileExists(file: ProjectFile): boolean {
|
||||
return $contents[file] !== null;
|
||||
}
|
||||
</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-4xl w-full max-h-[90vh] flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="project-context-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="project-context-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Project Context
|
||||
</h2>
|
||||
{#if $isLoading[$activeFile]}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Loading...</span>
|
||||
{:else if fileExists($activeFile)}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30"
|
||||
>
|
||||
✓ File exists
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30"
|
||||
>
|
||||
✗ Not created
|
||||
</span>
|
||||
{/if}
|
||||
{#if hasUnsavedChanges}
|
||||
<span class="text-xs text-[var(--text-tertiary)] italic">Unsaved changes</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>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="flex border-b border-[var(--border-color)] px-4">
|
||||
{#each PROJECT_FILES as file (file)}
|
||||
<button
|
||||
onclick={() => handleTabSwitch(file)}
|
||||
class="px-4 py-2 text-sm font-medium transition-colors relative {$activeFile === file
|
||||
? 'text-[var(--accent-primary)] border-b-2 border-[var(--accent-primary)] -mb-px'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}"
|
||||
>
|
||||
{PROJECT_FILE_NAMES[file]}
|
||||
{#if !fileExists(file)}
|
||||
<span class="ml-1 text-amber-500">•</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Editor area -->
|
||||
<div class="flex-1 overflow-hidden p-4 min-h-0">
|
||||
<textarea
|
||||
value={editContent}
|
||||
oninput={handleTextChange}
|
||||
class="w-full h-full min-h-[400px] bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 font-mono text-sm text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed"
|
||||
placeholder="File content will appear here..."
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</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)]">
|
||||
<span class="font-mono">{workingDirectory}/{PROJECT_FILE_NAMES[$activeFile]}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={handleUseTemplate}
|
||||
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"
|
||||
>
|
||||
Use Template
|
||||
</button>
|
||||
<button
|
||||
onclick={handleInject}
|
||||
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"
|
||||
>
|
||||
Inject into Prompt
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={$isSaving[$activeFile]}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if $isSaving[$activeFile]}
|
||||
Saving...
|
||||
{:else}
|
||||
Save
|
||||
{/if}
|
||||
</button>
|
||||
</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>
|
||||
@@ -30,6 +30,8 @@
|
||||
import CastPanel from "./CastPanel.svelte";
|
||||
import PluginManagementPanel from "./PluginManagementPanel.svelte";
|
||||
import McpManagementPanel from "./McpManagementPanel.svelte";
|
||||
import ProjectContextPanel from "./ProjectContextPanel.svelte";
|
||||
import { injectTextStore, PROJECT_CONTEXT_SYSTEM_ADDENDUM } from "$lib/stores/projectContext";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import {
|
||||
generateContextInjection,
|
||||
@@ -62,6 +64,7 @@
|
||||
let showCastPanel = $state(false);
|
||||
let showPluginPanel = $state(false);
|
||||
let showMcpPanel = $state(false);
|
||||
let showProjectContext = $state(false);
|
||||
let isSummarising = $state(false);
|
||||
let showWorkspaceTrust = $state(false);
|
||||
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
|
||||
@@ -185,7 +188,8 @@
|
||||
working_dir: targetDir,
|
||||
model: currentConfig.model || null,
|
||||
api_key: currentConfig.api_key || null,
|
||||
custom_instructions: currentConfig.custom_instructions || null,
|
||||
custom_instructions:
|
||||
(currentConfig.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
||||
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: currentConfig.use_worktree ?? false,
|
||||
@@ -300,6 +304,10 @@
|
||||
onToggleAchievements();
|
||||
}
|
||||
|
||||
function handleInjectContext(content: string): void {
|
||||
injectTextStore.set(content);
|
||||
}
|
||||
|
||||
async function handleCompactConversation() {
|
||||
const activeId = get(conversationsStore.activeConversationId);
|
||||
if (!activeId) return;
|
||||
@@ -345,7 +353,8 @@
|
||||
working_dir: workingDirectory || selectedDirectory,
|
||||
model: currentConfig.model || null,
|
||||
api_key: currentConfig.api_key || null,
|
||||
custom_instructions: currentConfig.custom_instructions || null,
|
||||
custom_instructions:
|
||||
(currentConfig.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
||||
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: currentConfig.use_worktree ?? false,
|
||||
@@ -564,6 +573,20 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showProjectContext = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Project Context"
|
||||
>
|
||||
<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="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleEditor}
|
||||
disabled={connectionStatus !== "connected"}
|
||||
@@ -827,6 +850,14 @@
|
||||
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showProjectContext}
|
||||
<ProjectContextPanel
|
||||
onClose={() => (showProjectContext = false)}
|
||||
onInject={handleInjectContext}
|
||||
workingDirectory={workingDirectory || selectedDirectory}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showWorkspaceTrust && pendingHookInfo}
|
||||
<WorkspaceTrustModal
|
||||
hookInfo={pendingHookInfo}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
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 {
|
||||
projectContextStore,
|
||||
PROJECT_FILE_NAMES,
|
||||
PROJECT_TEMPLATES,
|
||||
PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
||||
injectTextStore,
|
||||
type ProjectFile,
|
||||
} from "./projectContext";
|
||||
|
||||
describe("PROJECT_FILE_NAMES", () => {
|
||||
it("maps all four project file types", () => {
|
||||
expect(PROJECT_FILE_NAMES.PROJECT).toBe("PROJECT.md");
|
||||
expect(PROJECT_FILE_NAMES.REQUIREMENTS).toBe("REQUIREMENTS.md");
|
||||
expect(PROJECT_FILE_NAMES.ROADMAP).toBe("ROADMAP.md");
|
||||
expect(PROJECT_FILE_NAMES.STATE).toBe("STATE.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PROJECT_TEMPLATES", () => {
|
||||
const files: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
|
||||
|
||||
it.each(files)("returns a non-empty template for %s", (file) => {
|
||||
expect(PROJECT_TEMPLATES[file]).toBeTruthy();
|
||||
expect(PROJECT_TEMPLATES[file].length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectContextStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("has null contents for all files", () => {
|
||||
const state = get(projectContextStore.contents);
|
||||
expect(state.PROJECT).toBeNull();
|
||||
expect(state.REQUIREMENTS).toBeNull();
|
||||
expect(state.ROADMAP).toBeNull();
|
||||
expect(state.STATE).toBeNull();
|
||||
});
|
||||
|
||||
it("has false isLoading for all files", () => {
|
||||
const state = get(projectContextStore.isLoading);
|
||||
expect(state.PROJECT).toBe(false);
|
||||
expect(state.REQUIREMENTS).toBe(false);
|
||||
expect(state.ROADMAP).toBe(false);
|
||||
expect(state.STATE).toBe(false);
|
||||
});
|
||||
|
||||
it("has false isSaving for all files", () => {
|
||||
const state = get(projectContextStore.isSaving);
|
||||
expect(state.PROJECT).toBe(false);
|
||||
expect(state.REQUIREMENTS).toBe(false);
|
||||
expect(state.ROADMAP).toBe(false);
|
||||
expect(state.STATE).toBe(false);
|
||||
});
|
||||
|
||||
it("has PROJECT as the default activeFile", () => {
|
||||
expect(get(projectContextStore.activeFile)).toBe("PROJECT");
|
||||
});
|
||||
|
||||
it("exposes all expected methods", () => {
|
||||
expect(typeof projectContextStore.loadFile).toBe("function");
|
||||
expect(typeof projectContextStore.saveFile).toBe("function");
|
||||
expect(typeof projectContextStore.loadAll).toBe("function");
|
||||
expect(typeof projectContextStore.setActiveFile).toBe("function");
|
||||
expect(typeof projectContextStore.getTemplate).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadFile", () => {
|
||||
it("calls read_file_content with the correct path", async () => {
|
||||
setMockInvokeResult("read_file_content", "# Project\n\nContent here");
|
||||
|
||||
await projectContextStore.loadFile("PROJECT", "/home/naomi/myproject");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("read_file_content", {
|
||||
path: "/home/naomi/myproject/PROJECT.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates contents store with file content on success", async () => {
|
||||
const content = "# My Project\n\nDescription here";
|
||||
setMockInvokeResult("read_file_content", content);
|
||||
|
||||
await projectContextStore.loadFile("PROJECT", "/home/naomi/myproject");
|
||||
|
||||
expect(get(projectContextStore.contents).PROJECT).toBe(content);
|
||||
});
|
||||
|
||||
it("sets content to null when file does not exist", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("read_file_content", new Error("File not found"));
|
||||
|
||||
await projectContextStore.loadFile("REQUIREMENTS", "/home/naomi/myproject");
|
||||
|
||||
expect(get(projectContextStore.contents).REQUIREMENTS).toBeNull();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets isLoading to false after completion", async () => {
|
||||
setMockInvokeResult("read_file_content", "content");
|
||||
|
||||
await projectContextStore.loadFile("ROADMAP", "/home/naomi/myproject");
|
||||
|
||||
expect(get(projectContextStore.isLoading).ROADMAP).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isLoading to false even on error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("read_file_content", new Error("Read error"));
|
||||
|
||||
await projectContextStore.loadFile("STATE", "/home/naomi/myproject");
|
||||
|
||||
expect(get(projectContextStore.isLoading).STATE).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses correct filename for each file type", async () => {
|
||||
const files: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
|
||||
for (const file of files) {
|
||||
setMockInvokeResult("read_file_content", `content for ${file}`);
|
||||
await projectContextStore.loadFile(file, "/wd");
|
||||
expect(invoke).toHaveBeenCalledWith("read_file_content", {
|
||||
path: `/wd/${PROJECT_FILE_NAMES[file]}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveFile", () => {
|
||||
it("calls write_file_content with the correct path and content", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
await projectContextStore.saveFile("PROJECT", "# New content", "/home/naomi/myproject");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("write_file_content", {
|
||||
path: "/home/naomi/myproject/PROJECT.md",
|
||||
content: "# New content",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns true on success", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
const result = await projectContextStore.saveFile(
|
||||
"PROJECT",
|
||||
"# Content",
|
||||
"/home/naomi/myproject"
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("updates contents store with saved content on success", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
const newContent = "# Updated Project\n\nNew content";
|
||||
await projectContextStore.saveFile("REQUIREMENTS", newContent, "/home/naomi/myproject");
|
||||
|
||||
expect(get(projectContextStore.contents).REQUIREMENTS).toBe(newContent);
|
||||
});
|
||||
|
||||
it("returns false and logs error on failure", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("write_file_content", new Error("Write failed"));
|
||||
|
||||
const result = await projectContextStore.saveFile(
|
||||
"ROADMAP",
|
||||
"content",
|
||||
"/home/naomi/myproject"
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to save project context file:",
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets isSaving to false after completion", async () => {
|
||||
setMockInvokeResult("write_file_content", undefined);
|
||||
|
||||
await projectContextStore.saveFile("STATE", "content", "/home/naomi/myproject");
|
||||
|
||||
expect(get(projectContextStore.isSaving).STATE).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isSaving to false even on error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("write_file_content", new Error("Error"));
|
||||
|
||||
await projectContextStore.saveFile("PROJECT", "content", "/home/naomi/myproject");
|
||||
|
||||
expect(get(projectContextStore.isSaving).PROJECT).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAll", () => {
|
||||
it("loads all four files in parallel", async () => {
|
||||
setMockInvokeResult("read_file_content", "file content");
|
||||
|
||||
await projectContextStore.loadAll("/home/naomi/myproject");
|
||||
|
||||
const calls = vi.mocked(invoke).mock.calls.filter(([cmd]) => cmd === "read_file_content");
|
||||
const paths = calls.map(([, args]) => (args as { path: string }).path);
|
||||
|
||||
expect(paths).toContain("/home/naomi/myproject/PROJECT.md");
|
||||
expect(paths).toContain("/home/naomi/myproject/REQUIREMENTS.md");
|
||||
expect(paths).toContain("/home/naomi/myproject/ROADMAP.md");
|
||||
expect(paths).toContain("/home/naomi/myproject/STATE.md");
|
||||
});
|
||||
|
||||
it("sets all files isLoading to false after completion", async () => {
|
||||
setMockInvokeResult("read_file_content", "content");
|
||||
|
||||
await projectContextStore.loadAll("/home/naomi/myproject");
|
||||
|
||||
const loadingState = get(projectContextStore.isLoading);
|
||||
expect(loadingState.PROJECT).toBe(false);
|
||||
expect(loadingState.REQUIREMENTS).toBe(false);
|
||||
expect(loadingState.ROADMAP).toBe(false);
|
||||
expect(loadingState.STATE).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setActiveFile", () => {
|
||||
it("updates the activeFile store", () => {
|
||||
projectContextStore.setActiveFile("REQUIREMENTS");
|
||||
expect(get(projectContextStore.activeFile)).toBe("REQUIREMENTS");
|
||||
|
||||
projectContextStore.setActiveFile("STATE");
|
||||
expect(get(projectContextStore.activeFile)).toBe("STATE");
|
||||
|
||||
projectContextStore.setActiveFile("PROJECT");
|
||||
expect(get(projectContextStore.activeFile)).toBe("PROJECT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTemplate", () => {
|
||||
const files: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
|
||||
|
||||
it.each(files)("returns a non-empty string for %s", (file) => {
|
||||
const template = projectContextStore.getTemplate(file);
|
||||
expect(typeof template).toBe("string");
|
||||
expect(template.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns distinct templates for each file type", () => {
|
||||
const templates = files.map((f) => projectContextStore.getTemplate(f));
|
||||
const uniqueTemplates = new Set(templates);
|
||||
expect(uniqueTemplates.size).toBe(files.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PROJECT_CONTEXT_SYSTEM_ADDENDUM", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(typeof PROJECT_CONTEXT_SYSTEM_ADDENDUM).toBe("string");
|
||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("mentions all four context file names", () => {
|
||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("PROJECT.md");
|
||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("REQUIREMENTS.md");
|
||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("ROADMAP.md");
|
||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("STATE.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectTextStore", () => {
|
||||
it("initialises to null", () => {
|
||||
expect(get(injectTextStore)).toBeNull();
|
||||
});
|
||||
|
||||
it("can be set and read", () => {
|
||||
injectTextStore.set("hello world");
|
||||
expect(get(injectTextStore)).toBe("hello world");
|
||||
injectTextStore.set(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type ProjectFile = "PROJECT" | "REQUIREMENTS" | "ROADMAP" | "STATE";
|
||||
|
||||
export const PROJECT_FILE_NAMES: Record<ProjectFile, string> = {
|
||||
PROJECT: "PROJECT.md",
|
||||
REQUIREMENTS: "REQUIREMENTS.md",
|
||||
ROADMAP: "ROADMAP.md",
|
||||
STATE: "STATE.md",
|
||||
};
|
||||
|
||||
export const PROJECT_TEMPLATES: Record<ProjectFile, string> = {
|
||||
PROJECT: `# Project Overview
|
||||
|
||||
## What is this project?
|
||||
|
||||
## Goals
|
||||
|
||||
## Tech Stack
|
||||
|
||||
## Architecture
|
||||
`,
|
||||
REQUIREMENTS: `# Requirements
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
## Out of Scope
|
||||
`,
|
||||
ROADMAP: `# Roadmap
|
||||
|
||||
## Current Sprint
|
||||
|
||||
## Next Sprint
|
||||
|
||||
## Backlog
|
||||
|
||||
## Completed
|
||||
`,
|
||||
STATE: `# Current State
|
||||
|
||||
## Last Updated
|
||||
|
||||
## What's Working
|
||||
|
||||
## In Progress
|
||||
|
||||
## Known Issues
|
||||
|
||||
## Next Steps
|
||||
`,
|
||||
};
|
||||
|
||||
const PROJECT_FILES = Object.keys(PROJECT_FILE_NAMES) as ProjectFile[];
|
||||
|
||||
function createProjectContextStore() {
|
||||
const contents = writable<Record<ProjectFile, string | null>>({
|
||||
PROJECT: null,
|
||||
REQUIREMENTS: null,
|
||||
ROADMAP: null,
|
||||
STATE: null,
|
||||
});
|
||||
|
||||
const isLoading = writable<Record<ProjectFile, boolean>>({
|
||||
PROJECT: false,
|
||||
REQUIREMENTS: false,
|
||||
ROADMAP: false,
|
||||
STATE: false,
|
||||
});
|
||||
|
||||
const isSaving = writable<Record<ProjectFile, boolean>>({
|
||||
PROJECT: false,
|
||||
REQUIREMENTS: false,
|
||||
ROADMAP: false,
|
||||
STATE: false,
|
||||
});
|
||||
|
||||
const activeFile = writable<ProjectFile>("PROJECT");
|
||||
|
||||
async function loadFile(file: ProjectFile, workingDirectory: string): Promise<void> {
|
||||
isLoading.update((state) => ({ ...state, [file]: true }));
|
||||
try {
|
||||
const path = `${workingDirectory}/${PROJECT_FILE_NAMES[file]}`;
|
||||
const content = await invoke<string>("read_file_content", { path });
|
||||
contents.update((state) => ({ ...state, [file]: content }));
|
||||
} catch {
|
||||
contents.update((state) => ({ ...state, [file]: null }));
|
||||
} finally {
|
||||
isLoading.update((state) => ({ ...state, [file]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile(
|
||||
file: ProjectFile,
|
||||
content: string,
|
||||
workingDirectory: string
|
||||
): Promise<boolean> {
|
||||
isSaving.update((state) => ({ ...state, [file]: true }));
|
||||
try {
|
||||
const path = `${workingDirectory}/${PROJECT_FILE_NAMES[file]}`;
|
||||
await invoke("write_file_content", { path, content });
|
||||
contents.update((state) => ({ ...state, [file]: content }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save project context file:", error);
|
||||
return false;
|
||||
} finally {
|
||||
isSaving.update((state) => ({ ...state, [file]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll(workingDirectory: string): Promise<void> {
|
||||
await Promise.all(PROJECT_FILES.map((file) => loadFile(file, workingDirectory)));
|
||||
}
|
||||
|
||||
function setActiveFile(file: ProjectFile): void {
|
||||
activeFile.set(file);
|
||||
}
|
||||
|
||||
function getTemplate(file: ProjectFile): string {
|
||||
return PROJECT_TEMPLATES[file];
|
||||
}
|
||||
|
||||
return {
|
||||
contents: { subscribe: contents.subscribe },
|
||||
isLoading: { subscribe: isLoading.subscribe },
|
||||
isSaving: { subscribe: isSaving.subscribe },
|
||||
activeFile: { subscribe: activeFile.subscribe },
|
||||
loadFile,
|
||||
saveFile,
|
||||
loadAll,
|
||||
setActiveFile,
|
||||
getTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
export const projectContextStore = createProjectContextStore();
|
||||
|
||||
// Signal store for injecting context into the active InputBar.
|
||||
// StatusBar sets this; InputBar subscribes and applies it to inputValue directly,
|
||||
// then resets it to null so the signal only fires once.
|
||||
export const injectTextStore = writable<string | null>(null);
|
||||
|
||||
// Appended silently to custom_instructions at connection time (never saved to config).
|
||||
// Mirrors how CLAUDE.md works natively — Claude checks the files itself if they exist.
|
||||
export const PROJECT_CONTEXT_SYSTEM_ADDENDUM = `
|
||||
|
||||
---
|
||||
The following project context files may exist in your working directory. If they exist, read and refer to them as needed:
|
||||
- PROJECT.md — project overview, goals, and architecture
|
||||
- REQUIREMENTS.md — functional and non-functional requirements
|
||||
- ROADMAP.md — current sprint, backlog, and completed work
|
||||
- STATE.md — current state, known issues, and next steps`;
|
||||
Reference in New Issue
Block a user