Files
hikari-desktop/src/lib/components/TaskLoopPanel.svelte
T
hikari 452fe185df
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m21s
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
feat: CLI v2.1.68–v2.1.74 compatibility updates (#221)
## Summary

This PR brings Hikari Desktop up to full compatibility with Claude Code CLI versions v2.1.68 through v2.1.74, implementing all changelog items audited in issues #200–#218.

## Changes

### Bug Fixes
- Remove deprecated Claude Opus 4.0 and 4.1 models from the model selector
- Auto-migrate users pinned to deprecated models to Opus 4.6

### New Features
- Add cron tool support (`CronCreate`, `CronDelete`, `CronList`) with character state mapping and `CLAUDE_CODE_DISABLE_CRON` settings toggle
- Handle `EnterWorktree` and `ExitWorktree` tools in character state mapping and tool display
- Add CLI update check with npm registry indicator in the version bar
- Add `agent_type` field and support the Agent tool rename from CLI v2.1.69
- Consume `worktree` field from status line hook events
- Display per-agent model override in the agent monitor tree
- Expose Claude Code CLI built-in slash commands (`/simplify`, `/loop`, `/batch`, `/memory`, `/context`) in the command menu with CLI badges
- Add `includeGitInstructions` toggle in settings
- Add `ENABLE_CLAUDEAI_MCP_SERVERS` opt-out setting
- Linkify MCP binary file paths (PDFs, audio, Office docs) in markdown output
- Add auto-memory panel, `/memory` slash command shortcut, and unified toast notification system
- Toast notifications for `WorktreeCreate` and `WorktreeRemove` hook events
- Sort session resume list by most recent activity, with most recent user message as preview
- Convert WSL Linux paths to Windows UNC paths when opening binary files via `open_binary_file` command
- Expose `autoMemoryDirectory` setting in ConfigSidebar (Agent Settings section)
- Add `/context` as a CLI built-in in the slash command menu
- Expose `modelOverrides` setting as a JSON textarea in ConfigSidebar (for AWS Bedrock, Google Vertex, etc.)

> **Note:** The CLI update check commit does not have a corresponding issue — it was a bonus addition during the audit sprint.

## Closes

Closes #200
Closes #201
Closes #202
Closes #205
Closes #206
Closes #207
Closes #208
Closes #209
Closes #210
Closes #211
Closes #212
Closes #213
Closes #214
Closes #215
Closes #216
Closes #217
Closes #218

Reviewed-on: #221
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-13 01:34:44 -07:00

785 lines
30 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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,
auto_memory_directory: cfg.auto_memory_directory || null,
model_overrides: cfg.model_overrides || 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>