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,780 @@
|
||||
<script lang="ts">
|
||||
import { get } from "svelte/store";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
taskLoopStore,
|
||||
getReadyTasks,
|
||||
computeWaves,
|
||||
isTaskBlocked,
|
||||
buildTaskPrompt,
|
||||
buildAutoCommitPrompt,
|
||||
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;
|
||||
onBackToWorkflow?: () => void;
|
||||
}
|
||||
|
||||
const { onClose, onBackToWorkflow }: Props = $props();
|
||||
|
||||
const tasks = $derived(taskLoopStore.tasks);
|
||||
const loopStatus = $derived(taskLoopStore.loopStatus);
|
||||
const sourceFile = $derived(taskLoopStore.sourceFile);
|
||||
const conversations = $derived(claudeStore.conversations);
|
||||
const concurrencyLimit = $derived(taskLoopStore.concurrencyLimit);
|
||||
const config = $derived(configStore.config);
|
||||
|
||||
// Per-task orchestration phases (panel-local, not persisted)
|
||||
type LoopPhase = "waiting_for_connection" | "waiting_for_completion" | "waiting_for_auto_commit";
|
||||
let activePhases = $state<Record<number, LoopPhase>>({});
|
||||
let taskEverStartedMap = $state<Record<number, boolean>>({});
|
||||
let commitEverStartedMap = $state<Record<number, boolean>>({});
|
||||
let isLoading = $state(false);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let sessionTimestamp = $state("");
|
||||
let showSettings = $state(false);
|
||||
|
||||
const completedCount = $derived($tasks.filter((t) => t.status === "completed").length);
|
||||
const failedCount = $derived($tasks.filter((t) => t.status === "failed").length);
|
||||
const blockedCount = $derived($tasks.filter((t) => t.status === "blocked").length);
|
||||
const runningCount = $derived($tasks.filter((t) => t.status === "running").length);
|
||||
const totalCount = $derived($tasks.length);
|
||||
const waves = $derived(computeWaves($tasks));
|
||||
const multiWave = $derived(waves.length > 1);
|
||||
|
||||
const workingStates: CharacterState[] = ["thinking", "typing", "coding", "searching", "mcp"];
|
||||
|
||||
// Watch all active tasks' conversations for state transitions
|
||||
$effect(() => {
|
||||
for (const [idxStr, phase] of Object.entries(activePhases)) {
|
||||
const taskIdx = Number(idxStr);
|
||||
const taskList = $tasks;
|
||||
if (taskIdx < 0 || taskIdx >= taskList.length) continue;
|
||||
|
||||
const currentTask = taskList[taskIdx];
|
||||
if (!currentTask.conversationId) continue;
|
||||
|
||||
const conv = $conversations.get(currentTask.conversationId);
|
||||
if (!conv) continue;
|
||||
|
||||
if (phase === "waiting_for_connection" && conv.connectionStatus === "connected") {
|
||||
activePhases = { ...activePhases, [taskIdx]: "waiting_for_completion" };
|
||||
taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: false };
|
||||
void sendTaskPrompt(currentTask, taskIdx, taskList.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "waiting_for_completion") {
|
||||
if (workingStates.includes(conv.characterState)) {
|
||||
taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: true };
|
||||
}
|
||||
if (taskEverStartedMap[taskIdx] && conv.characterState === "idle") {
|
||||
taskEverStartedMap = Object.fromEntries(
|
||||
Object.entries(taskEverStartedMap).filter(([k]) => Number(k) !== taskIdx)
|
||||
);
|
||||
|
||||
const autoCommit = get(configStore.config).task_loop_auto_commit;
|
||||
if (autoCommit) {
|
||||
activePhases = { ...activePhases, [taskIdx]: "waiting_for_auto_commit" };
|
||||
commitEverStartedMap = { ...commitEverStartedMap, [taskIdx]: false };
|
||||
void sendAutoCommitPrompt(currentTask, taskIdx);
|
||||
} else {
|
||||
activePhases = Object.fromEntries(
|
||||
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
|
||||
);
|
||||
void onTaskCompleted(taskIdx, "completed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === "waiting_for_auto_commit") {
|
||||
if (workingStates.includes(conv.characterState)) {
|
||||
commitEverStartedMap = { ...commitEverStartedMap, [taskIdx]: true };
|
||||
}
|
||||
if (commitEverStartedMap[taskIdx] && conv.characterState === "idle") {
|
||||
activePhases = Object.fromEntries(
|
||||
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
|
||||
);
|
||||
commitEverStartedMap = Object.fromEntries(
|
||||
Object.entries(commitEverStartedMap).filter(([k]) => Number(k) !== taskIdx)
|
||||
);
|
||||
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);
|
||||
activePhases = Object.fromEntries(
|
||||
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
|
||||
);
|
||||
void onTaskCompleted(taskIdx, "failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAutoCommitPrompt(task: TaskLoopTask, taskIdx: number): Promise<void> {
|
||||
const cfg = get(configStore.config);
|
||||
const prompt = buildAutoCommitPrompt(
|
||||
task,
|
||||
cfg.task_loop_commit_prefix || "feat",
|
||||
cfg.task_loop_include_summary,
|
||||
sessionTimestamp
|
||||
);
|
||||
try {
|
||||
await invoke("send_prompt", {
|
||||
conversationId: task.conversationId,
|
||||
message: prompt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send auto-commit prompt:", error);
|
||||
// Non-blocking: still mark task as completed even if commit prompt fails
|
||||
activePhases = Object.fromEntries(
|
||||
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
|
||||
);
|
||||
void onTaskCompleted(taskIdx, "completed");
|
||||
}
|
||||
}
|
||||
|
||||
async function onTaskCompleted(taskIdx: number, status: "completed" | "failed"): Promise<void> {
|
||||
taskLoopStore.setTaskStatus(taskIdx, status);
|
||||
|
||||
const currentLoopStatus = get(taskLoopStore.loopStatus);
|
||||
if (currentLoopStatus !== "running") return;
|
||||
|
||||
// If any tasks are still active, wait for them
|
||||
if (Object.keys(activePhases).length > 0) return;
|
||||
|
||||
await advanceToNextWave();
|
||||
}
|
||||
|
||||
async function advanceToNextWave(): Promise<void> {
|
||||
const currentLoopStatus = get(taskLoopStore.loopStatus);
|
||||
if (currentLoopStatus !== "running") return;
|
||||
|
||||
// Mark any newly-blocked tasks
|
||||
const taskList = get(taskLoopStore.tasks);
|
||||
taskList.forEach((task, i) => {
|
||||
if (task.status === "pending" && isTaskBlocked(task, taskList)) {
|
||||
taskLoopStore.setTaskStatus(i, "blocked");
|
||||
}
|
||||
});
|
||||
|
||||
const updatedTaskList = get(taskLoopStore.tasks);
|
||||
const limit = get(taskLoopStore.concurrencyLimit);
|
||||
const readyIndices = getReadyTasks(updatedTaskList, limit);
|
||||
|
||||
if (readyIndices.length === 0) {
|
||||
taskLoopStore.setLoopStatus("stopped");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(readyIndices.map((i) => startTask(i, updatedTaskList)));
|
||||
}
|
||||
|
||||
async function startTask(taskIdx: number, taskList: TaskLoopTask[]): Promise<void> {
|
||||
const task = taskList[taskIdx];
|
||||
const cfg = get(configStore.config);
|
||||
const allAllowedTools = [
|
||||
...new Set([...get(claudeStore.grantedTools), ...(cfg.auto_granted_tools ?? [])]),
|
||||
];
|
||||
|
||||
const filePath = get(taskLoopStore.sourceFile);
|
||||
const workingDir = filePath.split("/").slice(0, -1).join("/");
|
||||
|
||||
const conversationId = claudeStore.createConversation(task.title);
|
||||
void claudeStore.switchConversation(conversationId);
|
||||
|
||||
taskLoopStore.setTaskConversationId(taskIdx, conversationId);
|
||||
taskLoopStore.setTaskStatus(taskIdx, "running");
|
||||
|
||||
activePhases = { ...activePhases, [taskIdx]: "waiting_for_connection" };
|
||||
taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: false };
|
||||
|
||||
try {
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDir,
|
||||
model: cfg.model ?? null,
|
||||
api_key: cfg.api_key ?? null,
|
||||
custom_instructions: (cfg.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
||||
mcp_servers_json: cfg.mcp_servers_json ?? null,
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: cfg.use_worktree ?? false,
|
||||
disable_1m_context: cfg.disable_1m_context ?? false,
|
||||
max_output_tokens: cfg.max_output_tokens ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to start Claude for task:", error);
|
||||
activePhases = Object.fromEntries(
|
||||
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
|
||||
);
|
||||
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 limit = get(taskLoopStore.concurrencyLimit);
|
||||
const readyIndices = getReadyTasks(taskList, limit);
|
||||
if (readyIndices.length === 0) return;
|
||||
|
||||
sessionTimestamp = new Date().toISOString();
|
||||
taskLoopStore.setLoopStatus("running");
|
||||
await Promise.all(readyIndices.map((i) => startTask(i, taskList)));
|
||||
}
|
||||
|
||||
function handlePause(): void {
|
||||
taskLoopStore.setLoopStatus("paused");
|
||||
}
|
||||
|
||||
async function handleResume(): Promise<void> {
|
||||
taskLoopStore.setLoopStatus("running");
|
||||
if (Object.keys(activePhases).length === 0) {
|
||||
await advanceToNextWave();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop(): Promise<void> {
|
||||
taskLoopStore.setLoopStatus("stopped");
|
||||
|
||||
// Stop all active Claude processes
|
||||
const taskList = get(taskLoopStore.tasks);
|
||||
const stopPromises = Object.keys(activePhases).map(async (idxStr) => {
|
||||
const taskIdx = Number(idxStr);
|
||||
const task = taskList[taskIdx];
|
||||
if (task?.conversationId) {
|
||||
try {
|
||||
await invoke("stop_claude", { conversationId: task.conversationId });
|
||||
} catch (error) {
|
||||
console.error("Failed to stop Claude for task:", error);
|
||||
}
|
||||
if (task.status === "running") {
|
||||
taskLoopStore.setTaskStatus(taskIdx, "failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(stopPromises);
|
||||
|
||||
activePhases = {};
|
||||
taskEverStartedMap = {};
|
||||
commitEverStartedMap = {};
|
||||
}
|
||||
|
||||
function handleReset(): void {
|
||||
taskLoopStore.reset();
|
||||
activePhases = {};
|
||||
taskEverStartedMap = {};
|
||||
commitEverStartedMap = {};
|
||||
errorMessage = null;
|
||||
sessionTimestamp = "";
|
||||
}
|
||||
|
||||
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";
|
||||
case "blocked":
|
||||
return "text-[var(--text-tertiary)] opacity-50";
|
||||
}
|
||||
}
|
||||
|
||||
function statusIcon(status: TaskLoopTask["status"]): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "○";
|
||||
case "running":
|
||||
return "⟳";
|
||||
case "completed":
|
||||
return "✓";
|
||||
case "failed":
|
||||
return "✗";
|
||||
case "blocked":
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
const hasPendingTasks = $derived($tasks.some((t) => t.status === "pending"));
|
||||
|
||||
async function toggleAutoCommit(): Promise<void> {
|
||||
await configStore.updateConfig({ task_loop_auto_commit: !$config.task_loop_auto_commit });
|
||||
}
|
||||
|
||||
async function toggleIncludeSummary(): Promise<void> {
|
||||
await configStore.updateConfig({
|
||||
task_loop_include_summary: !$config.task_loop_include_summary,
|
||||
});
|
||||
}
|
||||
|
||||
async function updateCommitPrefix(value: string): Promise<void> {
|
||||
await configStore.updateConfig({ task_loop_commit_prefix: value });
|
||||
}
|
||||
</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"
|
||||
>
|
||||
{runningCount} running · {completedCount}/{totalCount} done
|
||||
</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`
|
||||
: ""}{blockedCount > 0 ? `, ${blockedCount} blocked` : ""}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if onBackToWorkflow}
|
||||
<button
|
||||
onclick={onBackToWorkflow}
|
||||
class="px-2 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-md transition-colors"
|
||||
>
|
||||
← Workflow
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => (showSettings = !showSettings)}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Toggle settings"
|
||||
aria-pressed={showSettings}
|
||||
>
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Settings panel (collapsible) -->
|
||||
{#if showSettings}
|
||||
<div
|
||||
class="px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-secondary)] flex flex-col gap-3"
|
||||
>
|
||||
<p class="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide">
|
||||
Auto-commit Settings
|
||||
</p>
|
||||
|
||||
<!-- Auto-commit toggle -->
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<div
|
||||
class="relative w-9 h-5 rounded-full transition-colors {$config.task_loop_auto_commit
|
||||
? 'bg-[var(--accent-primary)]'
|
||||
: 'bg-[var(--bg-tertiary)] border border-[var(--border-color)]'}"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform {$config.task_loop_auto_commit
|
||||
? 'left-4'
|
||||
: 'left-0.5'}"
|
||||
></div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only"
|
||||
checked={$config.task_loop_auto_commit}
|
||||
onchange={toggleAutoCommit}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-[var(--text-primary)]">Auto-commit on task completion</span>
|
||||
</label>
|
||||
|
||||
{#if $config.task_loop_auto_commit}
|
||||
<!-- Commit prefix -->
|
||||
<div class="flex items-center gap-3">
|
||||
<label
|
||||
class="text-sm text-[var(--text-secondary)] shrink-0 w-28"
|
||||
for="commit-prefix-input"
|
||||
>
|
||||
Commit prefix
|
||||
</label>
|
||||
<input
|
||||
id="commit-prefix-input"
|
||||
type="text"
|
||||
value={$config.task_loop_commit_prefix}
|
||||
onchange={(e) => updateCommitPrefix((e.target as HTMLInputElement).value)}
|
||||
placeholder="feat"
|
||||
class="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">: task title</span>
|
||||
</div>
|
||||
|
||||
<!-- Include SUMMARY.md toggle -->
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<div
|
||||
class="relative w-9 h-5 rounded-full transition-colors {$config.task_loop_include_summary
|
||||
? 'bg-[var(--accent-primary)]'
|
||||
: 'bg-[var(--bg-tertiary)] border border-[var(--border-color)]'}"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform {$config.task_loop_include_summary
|
||||
? 'left-4'
|
||||
: 'left-0.5'}"
|
||||
></div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only"
|
||||
checked={$config.task_loop_include_summary}
|
||||
onchange={toggleIncludeSummary}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-[var(--text-primary)]">Generate SUMMARY.md before commit</span
|
||||
>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Wave-grouped task list -->
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each waves as waveIndices, waveIdx (waveIdx)}
|
||||
<div>
|
||||
{#if multiWave}
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
class="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide"
|
||||
>
|
||||
Wave {waveIdx + 1}
|
||||
</span>
|
||||
{#if waveIndices.length > 1}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">
|
||||
({waveIndices.length} parallel)
|
||||
</span>
|
||||
{/if}
|
||||
<div class="flex-1 border-t border-[var(--border-color)]"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each waveIndices as taskIdx (taskIdx)}
|
||||
{@const task = $tasks[taskIdx]}
|
||||
{#if task}
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-3 flex items-start gap-3 {task.status ===
|
||||
'running'
|
||||
? 'border-blue-500/40 bg-blue-500/5'
|
||||
: task.status === 'blocked'
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Status icon -->
|
||||
<span
|
||||
class="text-sm font-mono mt-0.5 w-4 text-center shrink-0 {statusColour(
|
||||
task.status
|
||||
)} {task.status === 'running' ? 'animate-spin' : ''}"
|
||||
>
|
||||
{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 task.status === "running"}
|
||||
{#if activePhases[taskIdx] === "waiting_for_auto_commit"}
|
||||
<span class="text-xs text-violet-400 animate-pulse shrink-0"
|
||||
>● committing</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-blue-400 animate-pulse shrink-0"
|
||||
>● running</span
|
||||
>
|
||||
{/if}
|
||||
{:else if task.status === "blocked"}
|
||||
<span class="text-xs text-[var(--text-tertiary)] shrink-0">blocked</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"
|
||||
>#{taskIdx + 1}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</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}
|
||||
|
||||
<!-- Concurrency limit control -->
|
||||
{#if totalCount > 0}
|
||||
<div class="flex items-center gap-1 ml-2">
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Parallel:</span>
|
||||
<button
|
||||
onclick={() => taskLoopStore.setConcurrencyLimit($concurrencyLimit - 1)}
|
||||
class="w-5 h-5 flex items-center justify-center text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded transition-colors"
|
||||
aria-label="Decrease concurrency limit"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span class="text-xs font-mono text-[var(--text-primary)] w-4 text-center"
|
||||
>{$concurrencyLimit}</span
|
||||
>
|
||||
<button
|
||||
onclick={() => taskLoopStore.setConcurrencyLimit($concurrencyLimit + 1)}
|
||||
class="w-5 h-5 flex items-center justify-center text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded transition-colors"
|
||||
aria-label="Increase concurrency limit"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{/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 hasPendingTasks}
|
||||
<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>
|
||||
Reference in New Issue
Block a user