generated from nhcarrigan/template
feat: productivity suite — task loop, workflow, theming, docs & more #197
@@ -22,6 +22,7 @@
|
||||
import ProjectContextPanel from "./ProjectContextPanel.svelte";
|
||||
import PrdPanel from "./PrdPanel.svelte";
|
||||
import ChangelogPanel from "./ChangelogPanel.svelte";
|
||||
import TaskLoopPanel from "./TaskLoopPanel.svelte";
|
||||
import { injectTextStore } from "$lib/stores/projectContext";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
@@ -65,6 +66,7 @@
|
||||
let showProjectContext = $state(false);
|
||||
let showPrdPanel = $state(false);
|
||||
let showChangelog = $state(false);
|
||||
let showTaskLoop = $state(false);
|
||||
|
||||
const progress = $derived($achievementProgress);
|
||||
const activeAgentCount = $derived($runningAgentCount);
|
||||
@@ -230,6 +232,19 @@
|
||||
<span>PRD Creator</span>
|
||||
</button>
|
||||
|
||||
<!-- Task Loop -->
|
||||
<button onclick={menuAction(() => (showTaskLoop = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>Task Loop</span>
|
||||
</button>
|
||||
|
||||
<!-- File Editor -->
|
||||
<button
|
||||
onclick={menuAction(() => editorStore.toggleEditor())}
|
||||
@@ -494,6 +509,10 @@
|
||||
<ChangelogPanel onClose={() => (showChangelog = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showTaskLoop}
|
||||
<TaskLoopPanel onClose={() => (showTaskLoop = false)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.nav-item {
|
||||
display: flex;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte";
|
||||
import type { WorkspaceHookInfo } from "$lib/types/messages";
|
||||
import NavMenu from "./NavMenu.svelte";
|
||||
import { taskLoopStore } from "$lib/stores/taskLoop";
|
||||
|
||||
let connectionStatus: ConnectionStatus = $state("disconnected");
|
||||
let workingDirectory = $state("");
|
||||
@@ -90,6 +91,14 @@
|
||||
streamerModeActive = value;
|
||||
});
|
||||
|
||||
const loopStatus = $derived(taskLoopStore.loopStatus);
|
||||
const loopTasks = $derived(taskLoopStore.tasks);
|
||||
const loopCurrentIndex = $derived(taskLoopStore.currentTaskIndex);
|
||||
const loopCompletedCount = $derived(
|
||||
$loopTasks.filter((t) => t.status === "completed" || t.status === "failed").length
|
||||
);
|
||||
const loopTotalCount = $derived($loopTasks.length);
|
||||
|
||||
onMount(async () => {
|
||||
appVersion = await getVersion();
|
||||
});
|
||||
@@ -410,6 +419,19 @@
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if $loopStatus === "running" || $loopStatus === "paused"}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full border shrink-0 {$loopStatus === 'running'
|
||||
? 'bg-blue-500/20 text-blue-400 border-blue-500/30 animate-pulse'
|
||||
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'}"
|
||||
title="Task loop {$loopStatus}"
|
||||
>
|
||||
Loop {$loopStatus === "running" ? "▶" : "⏸"}
|
||||
{loopCompletedCount +
|
||||
($loopStatus === "running" && $loopCurrentIndex >= 0 ? 1 : 0)}/{loopTotalCount}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<NavMenu
|
||||
{connectionStatus}
|
||||
{workingDirectory}
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
<script lang="ts">
|
||||
import { get } from "svelte/store";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
taskLoopStore,
|
||||
findNextPendingIndex,
|
||||
buildTaskPrompt,
|
||||
normalizeToUnixPath,
|
||||
type TaskLoopTask,
|
||||
} from "$lib/stores/taskLoop";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
import { PROJECT_CONTEXT_SYSTEM_ADDENDUM } from "$lib/stores/projectContext";
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
const tasks = $derived(taskLoopStore.tasks);
|
||||
const loopStatus = $derived(taskLoopStore.loopStatus);
|
||||
const currentTaskIndex = $derived(taskLoopStore.currentTaskIndex);
|
||||
const sourceFile = $derived(taskLoopStore.sourceFile);
|
||||
const conversations = $derived(claudeStore.conversations);
|
||||
|
||||
// Orchestration phase (panel-local, not persisted)
|
||||
type LoopPhase = "waiting_for_connection" | "waiting_for_completion";
|
||||
let loopPhase = $state<LoopPhase | null>(null);
|
||||
let taskEverStarted = $state(false);
|
||||
let isLoading = $state(false);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
|
||||
const completedCount = $derived($tasks.filter((t) => t.status === "completed").length);
|
||||
const failedCount = $derived($tasks.filter((t) => t.status === "failed").length);
|
||||
const totalCount = $derived($tasks.length);
|
||||
|
||||
const workingStates: CharacterState[] = ["thinking", "typing", "coding", "searching", "mcp"];
|
||||
|
||||
// Watch the current task's conversation for state transitions
|
||||
$effect(() => {
|
||||
const phase = loopPhase;
|
||||
if (!phase) return;
|
||||
|
||||
const taskIdx = $currentTaskIndex;
|
||||
const taskList = $tasks;
|
||||
if (taskIdx < 0 || taskIdx >= taskList.length) return;
|
||||
|
||||
const currentTask = taskList[taskIdx];
|
||||
if (!currentTask.conversationId) return;
|
||||
|
||||
const conv = $conversations.get(currentTask.conversationId);
|
||||
if (!conv) return;
|
||||
|
||||
if (phase === "waiting_for_connection" && conv.connectionStatus === "connected") {
|
||||
loopPhase = "waiting_for_completion";
|
||||
taskEverStarted = false;
|
||||
void sendTaskPrompt(currentTask, taskIdx, taskList.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "waiting_for_completion") {
|
||||
if (workingStates.includes(conv.characterState)) {
|
||||
taskEverStarted = true;
|
||||
}
|
||||
if (taskEverStarted && conv.characterState === "idle") {
|
||||
taskEverStarted = false;
|
||||
loopPhase = null;
|
||||
void onTaskCompleted(taskIdx, "completed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function sendTaskPrompt(task: TaskLoopTask, taskIdx: number, total: number): Promise<void> {
|
||||
const prompt = buildTaskPrompt(task, taskIdx + 1, total);
|
||||
try {
|
||||
await invoke("send_prompt", {
|
||||
conversationId: task.conversationId,
|
||||
message: prompt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send task prompt:", error);
|
||||
loopPhase = null;
|
||||
void onTaskCompleted(taskIdx, "failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function onTaskCompleted(taskIdx: number, status: "completed" | "failed"): Promise<void> {
|
||||
taskLoopStore.setTaskStatus(taskIdx, status);
|
||||
|
||||
const currentLoopStatus = get(taskLoopStore.loopStatus);
|
||||
if (currentLoopStatus !== "running") return;
|
||||
|
||||
const taskList = get(taskLoopStore.tasks);
|
||||
const nextIdx = findNextPendingIndex(taskList);
|
||||
|
||||
if (nextIdx === -1) {
|
||||
taskLoopStore.setLoopStatus("stopped");
|
||||
taskLoopStore.setCurrentTaskIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
await startTask(nextIdx, taskList);
|
||||
}
|
||||
|
||||
async function startTask(taskIdx: number, taskList: TaskLoopTask[]): Promise<void> {
|
||||
const task = taskList[taskIdx];
|
||||
const config = get(configStore.config);
|
||||
const allAllowedTools = [
|
||||
...new Set([...get(claudeStore.grantedTools), ...(config.auto_granted_tools ?? [])]),
|
||||
];
|
||||
|
||||
// sourceFile is already normalised to a Unix path at import time.
|
||||
const filePath = get(taskLoopStore.sourceFile);
|
||||
const workingDir = filePath.split("/").slice(0, -1).join("/");
|
||||
|
||||
// Create a new conversation for this task
|
||||
const conversationId = claudeStore.createConversation(task.title);
|
||||
void claudeStore.switchConversation(conversationId);
|
||||
|
||||
taskLoopStore.setTaskConversationId(taskIdx, conversationId);
|
||||
taskLoopStore.setTaskStatus(taskIdx, "running");
|
||||
taskLoopStore.setCurrentTaskIndex(taskIdx);
|
||||
loopPhase = "waiting_for_connection";
|
||||
taskEverStarted = false;
|
||||
|
||||
try {
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDir,
|
||||
model: config.model ?? null,
|
||||
api_key: config.api_key ?? null,
|
||||
custom_instructions: (config.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
||||
mcp_servers_json: config.mcp_servers_json ?? null,
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: config.use_worktree ?? false,
|
||||
disable_1m_context: config.disable_1m_context ?? false,
|
||||
max_output_tokens: config.max_output_tokens ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to start Claude for task:", error);
|
||||
loopPhase = null;
|
||||
void onTaskCompleted(taskIdx, "failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportFile(): Promise<void> {
|
||||
const selected = await open({
|
||||
title: "Select hikari-tasks.json",
|
||||
filters: [{ name: "Hikari Tasks", extensions: ["json"] }],
|
||||
multiple: false,
|
||||
});
|
||||
if (!selected || typeof selected !== "string") return;
|
||||
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
try {
|
||||
await taskLoopStore.loadFile(normalizeToUnixPath(selected));
|
||||
} catch (error) {
|
||||
errorMessage = `Failed to load file: ${error instanceof Error ? error.message : String(error)}`;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart(): Promise<void> {
|
||||
const taskList = get(taskLoopStore.tasks);
|
||||
const nextIdx = findNextPendingIndex(taskList);
|
||||
if (nextIdx === -1) return;
|
||||
|
||||
taskLoopStore.setLoopStatus("running");
|
||||
await startTask(nextIdx, taskList);
|
||||
}
|
||||
|
||||
function handlePause(): void {
|
||||
taskLoopStore.setLoopStatus("paused");
|
||||
}
|
||||
|
||||
function handleResume(): void {
|
||||
taskLoopStore.setLoopStatus("running");
|
||||
// If we're between tasks (no active phase), advance immediately
|
||||
if (!loopPhase) {
|
||||
const taskList = get(taskLoopStore.tasks);
|
||||
const nextIdx = findNextPendingIndex(taskList);
|
||||
if (nextIdx !== -1) {
|
||||
void startTask(nextIdx, taskList);
|
||||
} else {
|
||||
taskLoopStore.setLoopStatus("stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop(): Promise<void> {
|
||||
const taskIdx = get(taskLoopStore.currentTaskIndex);
|
||||
const taskList = get(taskLoopStore.tasks);
|
||||
const currentTask = taskIdx >= 0 ? taskList[taskIdx] : null;
|
||||
|
||||
taskLoopStore.setLoopStatus("stopped");
|
||||
loopPhase = null;
|
||||
|
||||
// Stop Claude process for the current task if running
|
||||
if (currentTask?.conversationId) {
|
||||
try {
|
||||
await invoke("stop_claude", { conversationId: currentTask.conversationId });
|
||||
} catch (error) {
|
||||
console.error("Failed to stop Claude for current task:", error);
|
||||
}
|
||||
if (currentTask.status === "running") {
|
||||
taskLoopStore.setTaskStatus(taskIdx, "failed");
|
||||
}
|
||||
}
|
||||
|
||||
taskLoopStore.setCurrentTaskIndex(-1);
|
||||
}
|
||||
|
||||
function handleReset(): void {
|
||||
taskLoopStore.reset();
|
||||
loopPhase = null;
|
||||
taskEverStarted = false;
|
||||
errorMessage = null;
|
||||
}
|
||||
|
||||
function statusColour(status: TaskLoopTask["status"]): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "text-[var(--text-tertiary)]";
|
||||
case "running":
|
||||
return "text-blue-400";
|
||||
case "completed":
|
||||
return "text-green-400";
|
||||
case "failed":
|
||||
return "text-red-400";
|
||||
}
|
||||
}
|
||||
|
||||
function statusIcon(status: TaskLoopTask["status"]): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "○";
|
||||
case "running":
|
||||
return "⟳";
|
||||
case "completed":
|
||||
return "✓";
|
||||
case "failed":
|
||||
return "✗";
|
||||
}
|
||||
}
|
||||
|
||||
function priorityColour(priority: TaskLoopTask["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-2xl w-full max-h-[90vh] flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="task-loop-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="task-loop-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Task Loop
|
||||
</h2>
|
||||
{#if $loopStatus === "running"}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400 border border-blue-500/30 animate-pulse"
|
||||
>
|
||||
Running {completedCount +
|
||||
failedCount +
|
||||
($loopStatus === "running" ? 1 : 0)}/{totalCount}
|
||||
</span>
|
||||
{:else if $loopStatus === "paused"}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30"
|
||||
>
|
||||
Paused
|
||||
</span>
|
||||
{:else if $loopStatus === "stopped" && totalCount > 0}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-[var(--bg-secondary)] text-[var(--text-tertiary)] border border-[var(--border-color)]"
|
||||
>
|
||||
{completedCount}/{totalCount} completed{failedCount > 0
|
||||
? `, ${failedCount} failed`
|
||||
: ""}
|
||||
</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 isLoading}
|
||||
<div class="flex items-center justify-center py-16 gap-3 text-[var(--text-secondary)]">
|
||||
<div class="animate-spin text-2xl">⚙️</div>
|
||||
<span class="text-sm">Loading tasks...</span>
|
||||
</div>
|
||||
{:else if errorMessage}
|
||||
<div class="flex flex-col items-center justify-center py-16 gap-4 text-center">
|
||||
<p class="text-sm text-red-400">{errorMessage}</p>
|
||||
<button
|
||||
onclick={handleImportFile}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
{:else if totalCount === 0}
|
||||
<!-- Empty state -->
|
||||
<div class="flex flex-col items-center justify-center py-16 gap-4 text-center">
|
||||
<div class="text-4xl">📋</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">No Tasks Loaded</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] max-w-sm">
|
||||
Import a <span class="font-mono text-xs">hikari-tasks.json</span> file created by the PRD
|
||||
Creator to run tasks automatically.
|
||||
</p>
|
||||
<button
|
||||
onclick={handleImportFile}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
|
||||
>
|
||||
Import hikari-tasks.json
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Source file path -->
|
||||
<div class="text-xs text-[var(--text-tertiary)] font-mono mb-3 truncate">
|
||||
{$sourceFile}
|
||||
</div>
|
||||
|
||||
<!-- Task list -->
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each $tasks as task, index (task.id)}
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-3 flex items-start gap-3 {$currentTaskIndex ===
|
||||
index && $loopStatus === 'running'
|
||||
? 'border-blue-500/40 bg-blue-500/5'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Status icon -->
|
||||
<span
|
||||
class="text-sm font-mono mt-0.5 w-4 text-center shrink-0 {statusColour(
|
||||
task.status
|
||||
)}"
|
||||
>
|
||||
{statusIcon(task.status)}
|
||||
</span>
|
||||
<!-- Task info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{task.title}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs px-1.5 py-0.5 rounded-full border shrink-0 {priorityColour(
|
||||
task.priority
|
||||
)}"
|
||||
>
|
||||
{task.priority}
|
||||
</span>
|
||||
{#if $currentTaskIndex === index && $loopStatus === "running"}
|
||||
<span class="text-xs text-blue-400 animate-pulse shrink-0">● running</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-0.5 line-clamp-2 font-mono">
|
||||
{task.prompt}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Task number -->
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono shrink-0"
|
||||
>#{index + 1}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</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="flex items-center gap-2">
|
||||
{#if totalCount > 0 && $loopStatus === "idle"}
|
||||
<button
|
||||
onclick={handleImportFile}
|
||||
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"
|
||||
>
|
||||
Change File
|
||||
</button>
|
||||
<button
|
||||
onclick={handleReset}
|
||||
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"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
{:else if $loopStatus === "stopped"}
|
||||
<button
|
||||
onclick={handleReset}
|
||||
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"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if totalCount === 0}
|
||||
<!-- no actions until tasks are loaded -->
|
||||
{:else if $loopStatus === "idle" || $loopStatus === "stopped"}
|
||||
{#if findNextPendingIndex($tasks) !== -1}
|
||||
<button
|
||||
onclick={handleStart}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
|
||||
>
|
||||
Start Loop
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">All tasks complete</span>
|
||||
{/if}
|
||||
{:else if $loopStatus === "running"}
|
||||
<button
|
||||
onclick={handlePause}
|
||||
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"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
<button
|
||||
onclick={handleStop}
|
||||
class="px-3 py-1.5 text-sm text-red-400 hover:text-red-300 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-lg transition-colors"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
{:else if $loopStatus === "paused"}
|
||||
<button
|
||||
onclick={handleResume}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<button
|
||||
onclick={handleStop}
|
||||
class="px-3 py-1.5 text-sm text-red-400 hover:text-red-300 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-lg transition-colors"
|
||||
>
|
||||
Stop
|
||||
</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);
|
||||
}
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
findNextPendingIndex,
|
||||
countByStatus,
|
||||
buildTaskPrompt,
|
||||
normalizeToUnixPath,
|
||||
type TaskLoopTask,
|
||||
} from "./taskLoop";
|
||||
|
||||
const makeTask = (id: string, status: TaskLoopTask["status"] = "pending"): TaskLoopTask => ({
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
prompt: `Do the thing for ${id}`,
|
||||
priority: "medium",
|
||||
status,
|
||||
});
|
||||
|
||||
describe("findNextPendingIndex", () => {
|
||||
it("returns -1 for an empty list", () => {
|
||||
expect(findNextPendingIndex([])).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns 0 when the first task is pending", () => {
|
||||
const tasks = [makeTask("1", "pending"), makeTask("2", "pending")];
|
||||
expect(findNextPendingIndex(tasks)).toBe(0);
|
||||
});
|
||||
|
||||
it("skips completed and failed tasks to find the next pending", () => {
|
||||
const tasks = [
|
||||
makeTask("1", "completed"),
|
||||
makeTask("2", "failed"),
|
||||
makeTask("3", "pending"),
|
||||
makeTask("4", "pending"),
|
||||
];
|
||||
expect(findNextPendingIndex(tasks)).toBe(2);
|
||||
});
|
||||
|
||||
it("returns -1 when all tasks are completed", () => {
|
||||
const tasks = [makeTask("1", "completed"), makeTask("2", "completed")];
|
||||
expect(findNextPendingIndex(tasks)).toBe(-1);
|
||||
});
|
||||
|
||||
it("skips running tasks", () => {
|
||||
const tasks = [makeTask("1", "running"), makeTask("2", "pending")];
|
||||
expect(findNextPendingIndex(tasks)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countByStatus", () => {
|
||||
it("returns 0 for an empty list", () => {
|
||||
expect(countByStatus([], "pending")).toBe(0);
|
||||
});
|
||||
|
||||
it("counts only tasks with the specified status", () => {
|
||||
const tasks = [
|
||||
makeTask("1", "pending"),
|
||||
makeTask("2", "completed"),
|
||||
makeTask("3", "pending"),
|
||||
makeTask("4", "failed"),
|
||||
];
|
||||
expect(countByStatus(tasks, "pending")).toBe(2);
|
||||
expect(countByStatus(tasks, "completed")).toBe(1);
|
||||
expect(countByStatus(tasks, "failed")).toBe(1);
|
||||
expect(countByStatus(tasks, "running")).toBe(0);
|
||||
});
|
||||
|
||||
it("counts all tasks when all have the same status", () => {
|
||||
const tasks = [makeTask("1", "completed"), makeTask("2", "completed")];
|
||||
expect(countByStatus(tasks, "completed")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTaskPrompt", () => {
|
||||
it("includes the task number and total", () => {
|
||||
const task = makeTask("1");
|
||||
const result = buildTaskPrompt(task, 1, 5);
|
||||
expect(result).toContain("1/5");
|
||||
});
|
||||
|
||||
it("includes the task title", () => {
|
||||
const task = makeTask("abc");
|
||||
const result = buildTaskPrompt(task, 2, 3);
|
||||
expect(result).toContain("Task abc");
|
||||
});
|
||||
|
||||
it("includes the task prompt", () => {
|
||||
const task = makeTask("x");
|
||||
const result = buildTaskPrompt(task, 1, 1);
|
||||
expect(result).toContain("Do the thing for x");
|
||||
});
|
||||
|
||||
it("labels the output as an automated task loop entry", () => {
|
||||
const task = makeTask("1");
|
||||
const result = buildTaskPrompt(task, 1, 1);
|
||||
expect(result).toContain("Automated Task Loop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeToUnixPath", () => {
|
||||
it("converts a WSL UNC path with wsl.localhost to a Unix path", () => {
|
||||
expect(
|
||||
normalizeToUnixPath("\\\\wsl.localhost\\Ubuntu\\home\\naomi\\code\\temp\\file.json")
|
||||
).toBe("/home/naomi/code/temp/file.json");
|
||||
});
|
||||
|
||||
it("converts a WSL UNC path with wsl$ (legacy) to a Unix path", () => {
|
||||
expect(normalizeToUnixPath("\\\\wsl$\\Ubuntu\\home\\naomi\\file.json")).toBe(
|
||||
"/home/naomi/file.json"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles forward-slash UNC paths produced by some tools", () => {
|
||||
expect(normalizeToUnixPath("//wsl.localhost/Ubuntu/home/naomi/file.json")).toBe(
|
||||
"/home/naomi/file.json"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a plain Unix path unchanged", () => {
|
||||
expect(normalizeToUnixPath("/home/naomi/code/temp/file.json")).toBe(
|
||||
"/home/naomi/code/temp/file.json"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves an empty string unchanged", () => {
|
||||
expect(normalizeToUnixPath("")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
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";
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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>("");
|
||||
|
||||
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 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 },
|
||||
loadFile,
|
||||
setTaskStatus,
|
||||
setTaskConversationId,
|
||||
setLoopStatus,
|
||||
setCurrentTaskIndex,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const taskLoopStore = createTaskLoopStore();
|
||||
Reference in New Issue
Block a user