generated from nhcarrigan/template
9aecec6f64
Adds a toggle in the MCP Servers settings section to control whether Claude Code connects to MCP servers configured in Claude.ai. When disabled, sets ENABLE_CLAUDEAI_MCP_SERVERS=false. Also fixes a pre-existing omission of disable_cron from the TypeScript HikariConfig interface. Closes #210
783 lines
30 KiB
Svelte
783 lines
30 KiB
Svelte
<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,
|
||
include_git_instructions: cfg.include_git_instructions ?? true,
|
||
enable_claudeai_mcp_servers: cfg.enable_claudeai_mcp_servers ?? true,
|
||
},
|
||
});
|
||
} 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>
|