feat: productivity suite — task loop, workflow, theming, docs & more (#197)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m39s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled

## Summary

A large productivity-focused feature branch delivering a suite of improvements across automation, project management, theming, performance, and documentation.

### Features

- **Guided Project Workflow** (#189) — Four-phase workflow panel (Discuss → Plan → Execute → Verify) to keep projects structured from idea to completion
- **Automated Task Loop** (#179) — Per-task conversation orchestration with wave-based parallel execution, blocked-task detection, and concurrency control
- **Wave-Based Parallel Execution** (#191) — Tasks run in dependency-aware waves with configurable concurrency; independent tasks execute in parallel
- **Auto-Commit After Task Completion** (#192) — Task Loop optionally commits after each completed task so progress is never lost
- **PRD Creator** (#180) — AI-assisted PRD and task list panel that outputs `hikari-tasks.json` for the Task Loop to consume
- **Project Context Panel** (#188) — Persistent `PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, and `STATE.md` files injected into Claude's context automatically
- **Codebase Mapper** (#190) — Generates a `CODEBASE.md` architectural summary so Claude always understands the project structure
- **Community Preset Themes** (#181) — Six built-in community themes: Dracula, Catppuccin Mocha, Nord, Solarized Dark, Gruvbox Dark, and Rosé Pine
- **In-App Changelog Panel** (#193) — Fetches release notes from GitHub at runtime and displays them inside the app
- **Full Embedded Documentation** (#196) — Replaced the single-page help modal with a 12-page paginated docs browser featuring a sidebar TOC, prev/next navigation, keyboard navigation (arrow keys, `?` shortcut), and comprehensive coverage of every feature

### Performance & Fixes

- **Lazy Loading & Virtualisation** (#194) — Virtual windowing for conversation history, markdown memoisation, and debounced search for smooth rendering of large sessions
- **Ctrl+C Copy Fix** (#195) — `Ctrl+C` now copies selected text as expected; interrupt-Claude behaviour only fires when no text is selected

### UX

- Back-to-workflow button in PRD Creator and Task Loop panels for easy navigation
- Navigation icon cluster replaced with a single clean dropdown menu

## Closes

Closes #179
Closes #180
Closes #181
Closes #188
Closes #189
Closes #190
Closes #191
Closes #192
Closes #193
Closes #194
Closes #195
Closes #196

---

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #197
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #197.
This commit is contained in:
2026-03-07 03:08:33 -08:00
committed by Naomi Carrigan
parent 1ae440659c
commit e6e9f7ae59
52 changed files with 8865 additions and 529 deletions
+390
View File
@@ -0,0 +1,390 @@
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,
type ProjectScan,
} from "./projectContext";
describe("PROJECT_FILE_NAMES", () => {
it("maps all five project file types", () => {
expect(PROJECT_FILE_NAMES.PROJECT).toBe("PROJECT.md");
expect(PROJECT_FILE_NAMES.REQUIREMENTS).toBe("REQUIREMENTS.md");
expect(PROJECT_FILE_NAMES.ROADMAP).toBe("ROADMAP.md");
expect(PROJECT_FILE_NAMES.STATE).toBe("STATE.md");
expect(PROJECT_FILE_NAMES.CODEBASE).toBe("CODEBASE.md");
});
});
describe("PROJECT_TEMPLATES", () => {
const editableFiles: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
it.each(editableFiles)("returns a non-empty template for %s", (file) => {
expect(PROJECT_TEMPLATES[file]).toBeTruthy();
expect(PROJECT_TEMPLATES[file].length).toBeGreaterThan(0);
});
it("has an empty string template for CODEBASE (auto-generated)", () => {
expect(PROJECT_TEMPLATES.CODEBASE).toBe("");
});
});
describe("projectContextStore", () => {
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();
expect(state.CODEBASE).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);
expect(state.CODEBASE).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);
expect(state.CODEBASE).toBe(false);
});
it("has PROJECT as the default activeFile", () => {
expect(get(projectContextStore.activeFile)).toBe("PROJECT");
});
it("has false isMappingCodebase initially", () => {
expect(get(projectContextStore.isMappingCodebase)).toBe(false);
});
it("exposes all expected methods", () => {
expect(typeof projectContextStore.loadFile).toBe("function");
expect(typeof projectContextStore.saveFile).toBe("function");
expect(typeof projectContextStore.loadAll).toBe("function");
expect(typeof projectContextStore.setActiveFile).toBe("function");
expect(typeof projectContextStore.getTemplate).toBe("function");
expect(typeof projectContextStore.mapCodebase).toBe("function");
expect(typeof projectContextStore.finishMapping).toBe("function");
});
});
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 five 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");
expect(paths).toContain("/home/naomi/myproject/CODEBASE.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);
expect(loadingState.CODEBASE).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);
});
it("returns empty string for CODEBASE", () => {
expect(projectContextStore.getTemplate("CODEBASE")).toBe("");
});
});
describe("mapCodebase", () => {
const mockScan: ProjectScan = {
working_dir: "/home/naomi/myproject",
file_tree: "/home/naomi/myproject/\n├── src/\n└── package.json",
detected_type: "Node.js",
key_files: ["package.json"],
};
it("calls scan_project with the working directory", async () => {
setMockInvokeResult("scan_project", mockScan);
setMockInvokeResult("send_prompt", undefined);
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
expect(invoke).toHaveBeenCalledWith("scan_project", {
workingDir: "/home/naomi/myproject",
});
});
it("calls send_prompt with the conversation id and a non-empty prompt", async () => {
setMockInvokeResult("scan_project", mockScan);
setMockInvokeResult("send_prompt", undefined);
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
expect(invoke).toHaveBeenCalledWith("send_prompt", {
conversationId: "conv-123",
message: expect.stringContaining("CODEBASE.md"),
});
});
it("prompt includes detected project type", async () => {
setMockInvokeResult("scan_project", mockScan);
setMockInvokeResult("send_prompt", undefined);
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
expect(message).toContain("Node.js");
});
it("prompt includes file tree", async () => {
setMockInvokeResult("scan_project", mockScan);
setMockInvokeResult("send_prompt", undefined);
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
expect(message).toContain("package.json");
});
it("resets isMappingCodebase to false on error", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("scan_project", new Error("Scan failed"));
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
expect(get(projectContextStore.isMappingCodebase)).toBe(false);
consoleSpy.mockRestore();
});
it("logs error when scan_project fails", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("scan_project", new Error("Scan failed"));
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
expect(consoleSpy).toHaveBeenCalledWith("Failed to map codebase:", expect.any(Error));
consoleSpy.mockRestore();
});
});
describe("finishMapping", () => {
it("sets isMappingCodebase to false", () => {
projectContextStore.finishMapping();
expect(get(projectContextStore.isMappingCodebase)).toBe(false);
});
});
});
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 five context file names", () => {
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("PROJECT.md");
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("REQUIREMENTS.md");
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("ROADMAP.md");
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("STATE.md");
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("CODEBASE.md");
});
});
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);
});
});