generated from nhcarrigan/template
feat: productivity suite — task loop, workflow, theming, docs & more (#197)
## 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:
@@ -0,0 +1,230 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { PrdTask, PrdFile } from "./prd";
|
||||
|
||||
export type TaskStatus = "pending" | "running" | "completed" | "failed" | "blocked";
|
||||
export type LoopStatus = "idle" | "running" | "paused" | "stopped";
|
||||
|
||||
export interface TaskLoopTask extends PrdTask {
|
||||
status: TaskStatus;
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
/** Returns the index of the first pending task, or -1 if none. */
|
||||
export function findNextPendingIndex(tasks: TaskLoopTask[]): number {
|
||||
return tasks.findIndex((t) => t.status === "pending");
|
||||
}
|
||||
|
||||
/** Counts tasks with the given status. */
|
||||
export function countByStatus(tasks: TaskLoopTask[], status: TaskStatus): number {
|
||||
return tasks.filter((t) => t.status === status).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a task is blocked — i.e. any of its `dependsOn` IDs refer to a
|
||||
* task that has failed or is already blocked.
|
||||
*/
|
||||
export function isTaskBlocked(task: TaskLoopTask, allTasks: TaskLoopTask[]): boolean {
|
||||
if (!task.dependsOn || task.dependsOn.length === 0) return false;
|
||||
return task.dependsOn.some((depId) => {
|
||||
const dep = allTasks.find((t) => t.id === depId);
|
||||
return dep !== undefined && (dep.status === "failed" || dep.status === "blocked");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns indices of tasks that are ready to start: status is `pending` and all
|
||||
* `dependsOn` tasks are `completed`. Respects `limit` (concurrency cap).
|
||||
*/
|
||||
export function getReadyTasks(tasks: TaskLoopTask[], limit: number): number[] {
|
||||
const ready: number[] = [];
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
if (ready.length >= limit) break;
|
||||
const task = tasks[i];
|
||||
if (task.status !== "pending") continue;
|
||||
const depsAllDone =
|
||||
!task.dependsOn ||
|
||||
task.dependsOn.length === 0 ||
|
||||
task.dependsOn.every((depId) => {
|
||||
const dep = tasks.find((t) => t.id === depId);
|
||||
return dep === undefined || dep.status === "completed";
|
||||
});
|
||||
if (depsAllDone) {
|
||||
ready.push(i);
|
||||
}
|
||||
}
|
||||
return ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups task indices into waves for UI display. Tasks with no pending dependencies
|
||||
* form wave 0; tasks whose deps are all in earlier waves form the next wave, etc.
|
||||
* Circular dependencies are collected into a final "overflow" wave.
|
||||
*/
|
||||
export function computeWaves(tasks: TaskLoopTask[]): number[][] {
|
||||
const waves: number[][] = [];
|
||||
const assigned = new Set<number>();
|
||||
|
||||
// Build an id→index map
|
||||
const idToIndex = new Map<string, number>();
|
||||
tasks.forEach((t, i) => idToIndex.set(t.id, i));
|
||||
|
||||
let remaining = tasks.map((_, i) => i).filter((i) => !assigned.has(i));
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const wave: number[] = [];
|
||||
for (const i of remaining) {
|
||||
const task = tasks[i];
|
||||
const depsAllAssigned =
|
||||
!task.dependsOn ||
|
||||
task.dependsOn.length === 0 ||
|
||||
task.dependsOn.every((depId) => {
|
||||
const depIdx = idToIndex.get(depId);
|
||||
return depIdx === undefined || assigned.has(depIdx);
|
||||
});
|
||||
if (depsAllAssigned) {
|
||||
wave.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (wave.length === 0) {
|
||||
// Circular dependency — dump all remaining into a single wave
|
||||
waves.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
wave.forEach((i) => assigned.add(i));
|
||||
waves.push(wave);
|
||||
remaining = remaining.filter((i) => !assigned.has(i));
|
||||
}
|
||||
|
||||
return waves;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises a file-picker path to a Unix path.
|
||||
*
|
||||
* On Windows/WSL the dialog returns a UNC path like:
|
||||
* \\wsl.localhost\Ubuntu\home\naomi\code\temp\hikari-tasks.json
|
||||
* which the WSL-side Claude process cannot use as a working directory.
|
||||
* This converts that to /home/naomi/code/temp/hikari-tasks.json.
|
||||
*/
|
||||
export function normalizeToUnixPath(path: string): string {
|
||||
// Matches both \\wsl.localhost\<distro>\... and \\wsl$\<distro>\... (legacy)
|
||||
const wslUncMatch = /^[/\\][/\\]wsl(?:\.localhost|\$)?[/\\][^/\\]+(.*)$/i.exec(path);
|
||||
if (wslUncMatch) {
|
||||
return wslUncMatch[1].replaceAll("\\", "/");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the prompt sent to Claude after a task completes to commit the changes.
|
||||
* If `includeSummary` is true, Claude is also asked to write/append to SUMMARY.md first.
|
||||
*/
|
||||
export function buildAutoCommitPrompt(
|
||||
task: TaskLoopTask,
|
||||
prefix: string,
|
||||
includeSummary: boolean,
|
||||
sessionTimestamp: string
|
||||
): string {
|
||||
const escapedTitle = task.title.replaceAll('"', '\\"');
|
||||
const commitMsg = `${prefix}: ${escapedTitle}\\n\\nAuto-committed by Hikari Task Loop\\nTask ID: ${task.id}\\nLoop session: ${sessionTimestamp}`;
|
||||
|
||||
const gitCommands = `git add -A && git commit -m "${commitMsg}"`;
|
||||
|
||||
const summaryRequest = includeSummary
|
||||
? `\n\nBefore committing, please write or append to \`SUMMARY.md\` in the working directory with:\n- What was implemented\n- Key decisions made\n- Files changed\n- Any caveats or follow-up work\n\nInclude SUMMARY.md in the commit.\n`
|
||||
: "";
|
||||
|
||||
return `[Auto-commit] Please run the following in the current working directory:${summaryRequest}
|
||||
|
||||
\`\`\`bash
|
||||
${gitCommands}
|
||||
\`\`\`
|
||||
|
||||
If this fails (e.g. nothing to commit, no git repository), acknowledge it briefly and do not retry.`;
|
||||
}
|
||||
|
||||
/** Builds the prompt sent to Claude Code for an automated task. */
|
||||
export function buildTaskPrompt(
|
||||
task: TaskLoopTask,
|
||||
taskNumber: number,
|
||||
totalTasks: number
|
||||
): string {
|
||||
return `[Automated Task Loop — Task ${taskNumber}/${totalTasks}]\n\n**${task.title}**\n\n${task.prompt}`;
|
||||
}
|
||||
|
||||
function createTaskLoopStore() {
|
||||
const tasks = writable<TaskLoopTask[]>([]);
|
||||
const loopStatus = writable<LoopStatus>("idle");
|
||||
const currentTaskIndex = writable<number>(-1);
|
||||
const sourceFile = writable<string>("");
|
||||
const concurrencyLimit = writable<number>(3);
|
||||
|
||||
async function loadFile(path: string): Promise<void> {
|
||||
const content = await invoke<string>("read_file_content", { path });
|
||||
const data = JSON.parse(content) as PrdFile;
|
||||
const loopTasks: TaskLoopTask[] = data.tasks.map((t) => ({ ...t, status: "pending" }));
|
||||
tasks.set(loopTasks);
|
||||
sourceFile.set(path);
|
||||
loopStatus.set("idle");
|
||||
currentTaskIndex.set(-1);
|
||||
}
|
||||
|
||||
function setTaskStatus(index: number, status: TaskStatus): void {
|
||||
tasks.update((current) => {
|
||||
const result = [...current];
|
||||
if (result[index]) {
|
||||
result[index] = { ...result[index], status };
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function setTaskConversationId(index: number, conversationId: string): void {
|
||||
tasks.update((current) => {
|
||||
const result = [...current];
|
||||
if (result[index]) {
|
||||
result[index] = { ...result[index], conversationId };
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function setLoopStatus(status: LoopStatus): void {
|
||||
loopStatus.set(status);
|
||||
}
|
||||
|
||||
function setCurrentTaskIndex(index: number): void {
|
||||
currentTaskIndex.set(index);
|
||||
}
|
||||
|
||||
function setConcurrencyLimit(limit: number): void {
|
||||
concurrencyLimit.set(Math.max(1, limit));
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
tasks.set([]);
|
||||
loopStatus.set("idle");
|
||||
currentTaskIndex.set(-1);
|
||||
sourceFile.set("");
|
||||
}
|
||||
|
||||
return {
|
||||
tasks: { subscribe: tasks.subscribe },
|
||||
loopStatus: { subscribe: loopStatus.subscribe },
|
||||
currentTaskIndex: { subscribe: currentTaskIndex.subscribe },
|
||||
sourceFile: { subscribe: sourceFile.subscribe },
|
||||
concurrencyLimit: { subscribe: concurrencyLimit.subscribe },
|
||||
loadFile,
|
||||
setTaskStatus,
|
||||
setTaskConversationId,
|
||||
setLoopStatus,
|
||||
setCurrentTaskIndex,
|
||||
setConcurrencyLimit,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const taskLoopStore = createTaskLoopStore();
|
||||
Reference in New Issue
Block a user