feat(ui): add MicroSteps component with expandable micro-steps in WipTaskList

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-21 14:48:43 +00:00
parent 954f83d2f4
commit d56c37d9b6
2 changed files with 270 additions and 36 deletions

View File

@@ -0,0 +1,132 @@
<script>
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { Check, Timer, Loader2 } from 'lucide-svelte';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
const prefs = getPreferences();
let { parentTaskId } = $props();
let steps = $state([]);
let loading = $state(false);
let error = $state('');
let reduceMotion = $derived(
prefs.accessibility?.reduceMotion === 'on'
|| (prefs.accessibility?.reduceMotion === 'system'
&& typeof window !== 'undefined'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches)
);
onMount(() => {
loadSteps();
});
async function loadSteps() {
loading = true;
error = '';
try {
steps = await invoke('list_subtasks', { parentTaskId });
} catch (e) {
error = typeof e === 'string' ? e : 'Failed to load micro-steps';
console.error('Failed to load subtasks:', e);
} finally {
loading = false;
}
}
async function toggleStep(step) {
if (step.done) return; // Already done — no uncomplete for micro-steps
try {
const parentCompleted = await invoke('complete_subtask', { subtaskId: step.id });
// Update local state
const idx = steps.findIndex(s => s.id === step.id);
if (idx >= 0) {
steps[idx].done = true;
steps[idx].status = 'done';
}
// If parent was auto-completed, notify via event so task list refreshes
if (parentCompleted) {
window.dispatchEvent(new CustomEvent('kon:tasks-changed'));
}
} catch (e) {
console.error('Failed to complete micro-step:', e);
}
}
function startTimer(step) {
window.dispatchEvent(new CustomEvent('kon:start-timer', {
detail: { id: step.id, text: step.text, duration: 120 }
}));
}
let allDone = $derived(steps.length > 0 && steps.every(s => s.done));
let doneCount = $derived(steps.filter(s => s.done).length);
</script>
<div class="pl-6 border-l-2 border-border-subtle ml-2 mt-1 mb-2"
class:animate-fade-in={!reduceMotion}>
{#if loading}
<div class="flex items-center gap-2 py-3 text-text-tertiary">
<Loader2 size={14} class="animate-spin" aria-hidden="true" />
<span class="text-[11px]">Breaking down task…</span>
</div>
{:else if error}
<p class="text-[11px] text-text-tertiary py-2">{error}</p>
{:else if steps.length === 0}
<p class="text-[11px] text-text-tertiary py-2">No micro-steps yet</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each steps as step (step.id)}
<div class="flex items-center gap-2 px-2 py-1.5 rounded-lg group
{step.done ? 'opacity-50' : 'hover:bg-hover'}"
style="transition-duration: var(--duration-ui)">
<!-- Checkbox -->
<button
class="w-3.5 h-3.5 rounded border flex-shrink-0 flex items-center justify-center
{step.done
? 'border-accent bg-accent/20'
: 'border-border-subtle hover:border-accent'}"
onclick={() => toggleStep(step)}
aria-label={step.done ? 'Completed' : 'Complete micro-step'}
disabled={step.done}
>
{#if step.done}
<Check size={10} class="text-accent" aria-hidden="true" />
{/if}
</button>
<!-- Step text -->
<span class="text-[12px] flex-1 min-w-0
{step.done ? 'text-text-tertiary line-through' : 'text-text'}">
{step.text}
</span>
<!-- Just Start timer button -->
{#if !step.done}
<button
class="text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
style="transition: opacity var(--duration-ui)"
onclick={() => startTimer(step)}
aria-label="Just start 2 minutes"
title="Just start — 2 minutes"
>
<Timer size={12} aria-hidden="true" />
</button>
{/if}
</div>
{/each}
</div>
<!-- Progress -->
<p class="text-[10px] text-text-tertiary mt-1 px-2">
{doneCount}/{steps.length} steps done
{#if allDone}
<span class="text-success ml-1">— all done</span>
{/if}
</p>
{/if}
</div>

View File

@@ -1,23 +1,48 @@
<script>
import { getTasks, createTask, completeTask, deleteTask } from '$lib/stores/tasks.svelte.js';
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { getTasks, createTask, completeTask, deleteTask, loadTasks } from '$lib/stores/tasks.svelte.js';
const tasks = getTasks();
import { ChevronDown, Timer } from 'lucide-svelte';
function startTimer(task, duration = 120) {
window.dispatchEvent(new CustomEvent('kon:start-timer', {
detail: { id: task.id, text: task.text, duration }
}));
}
import { ChevronDown, ChevronUp, Timer, ListTree, Loader2 } from 'lucide-svelte';
import MicroSteps from './MicroSteps.svelte';
let { wipLimit = 3 } = $props();
let newTaskText = $state('');
let showOverflow = $state(false);
let expandedTaskIds = $state(new Set());
let decomposing = $state(new Set());
let hasChildren = $state(new Map());
let activeTasks = $derived(tasks.filter(t => !t.done));
let visibleTasks = $derived(activeTasks.slice(0, wipLimit));
let overflowTasks = $derived(activeTasks.slice(wipLimit));
let hasOverflow = $derived(overflowTasks.length > 0);
let hasOverflowTasks = $derived(overflowTasks.length > 0);
// Check which tasks have children on mount and when tasks change
onMount(() => {
checkChildrenStatus();
window.addEventListener('kon:tasks-changed', onTasksChanged);
return () => window.removeEventListener('kon:tasks-changed', onTasksChanged);
});
async function onTasksChanged() {
await loadTasks();
await checkChildrenStatus();
}
async function checkChildrenStatus() {
for (const task of activeTasks) {
try {
const subtasks = await invoke('list_subtasks', { parentTaskId: task.id });
hasChildren.set(task.id, subtasks.length > 0);
} catch {
// Tauri not available — ignore
}
}
// Trigger reactivity
hasChildren = new Map(hasChildren);
}
async function handleAdd() {
const text = newTaskText.trim();
@@ -29,6 +54,44 @@
function handleKeydown(e) {
if (e.key === 'Enter') handleAdd();
}
function toggleExpand(taskId) {
const next = new Set(expandedTaskIds);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
expandedTaskIds = next;
}
async function decomposeTask(task) {
const next = new Set(decomposing);
next.add(task.id);
decomposing = next;
try {
await invoke('decompose_and_store', { parentTaskId: task.id });
// Mark as having children and expand
hasChildren.set(task.id, true);
hasChildren = new Map(hasChildren);
const expanded = new Set(expandedTaskIds);
expanded.add(task.id);
expandedTaskIds = expanded;
} catch (e) {
console.error('Decompose failed:', e);
} finally {
const done = new Set(decomposing);
done.delete(task.id);
decomposing = done;
}
}
function startTimer(task, duration = 120) {
window.dispatchEvent(new CustomEvent('kon:start-timer', {
detail: { id: task.id, text: task.text, duration }
}));
}
</script>
<div class="flex flex-col gap-3">
@@ -51,6 +114,7 @@
{:else}
<div class="flex flex-col gap-1">
{#each visibleTasks as task (task.id)}
<div>
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-hover group"
style="transition-duration: var(--duration-ui)">
<button
@@ -61,6 +125,37 @@
>
</button>
<span class="text-[13px] text-text flex-1 min-w-0 truncate">{task.text}</span>
<!-- Expand/Decompose button -->
{#if hasChildren.get(task.id)}
<button
class="text-text-tertiary hover:text-text-secondary"
style="transition: color var(--duration-ui)"
onclick={() => toggleExpand(task.id)}
aria-label={expandedTaskIds.has(task.id) ? 'Collapse micro-steps' : 'Expand micro-steps'}
>
{#if expandedTaskIds.has(task.id)}
<ChevronUp size={14} aria-hidden="true" />
{:else}
<ChevronDown size={14} aria-hidden="true" />
{/if}
</button>
{:else if decomposing.has(task.id)}
<Loader2 size={14} class="text-accent animate-spin" aria-hidden="true" />
{:else}
<button
class="flex items-center gap-1 text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100 text-[10px]"
style="transition: opacity var(--duration-ui)"
onclick={() => decomposeTask(task)}
aria-label="Break down into micro-steps"
title="Break down"
>
<ListTree size={12} aria-hidden="true" />
<span>Break down</span>
</button>
{/if}
<!-- Timer button -->
<button
class="text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
style="transition: opacity var(--duration-ui)"
@@ -70,6 +165,7 @@
>
<Timer size={14} aria-hidden="true" />
</button>
<button
class="text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 text-[11px]"
onclick={() => deleteTask(task.id)}
@@ -78,12 +174,18 @@
&times;
</button>
</div>
<!-- Expanded micro-steps -->
{#if expandedTaskIds.has(task.id)}
<MicroSteps parentTaskId={task.id} />
{/if}
</div>
{/each}
</div>
{/if}
<!-- Overflow -->
{#if hasOverflow}
{#if hasOverflowTasks}
<button
class="flex items-center gap-1.5 text-[11px] text-text-tertiary hover:text-text-secondary px-3"
onclick={() => showOverflow = !showOverflow}