Files
Lumotia/src/lib/components/MicroSteps.svelte
Jake 07925cf360 feat(ux): PR 1.3 — replace 0→100 hover-reveals with calm baseline
Affordances on MicroSteps rows (thumbs/edit/speaker/just-start) and
transcript-viewer segments (star, copy, delete) used opacity-0 →
opacity-100 on group-hover, which (a) hid them from keyboard users
until focus and (b) violated the "always-visible affordance" line in
the Kon design audit.

Replace with opacity-30 baseline + group-hover:opacity-100 +
focus-visible:opacity-100 (MicroSteps) / focus-within:opacity-100
(viewer, since the actions sit inside an interactive segment row).
Result: affordances are calm but visible at rest, lift on hover or
keyboard focus, never invisible.

HistoryPage was checked — its row checkbox at line 757 is already
opacity-50 → opacity-100 on hover, the pattern this PR is moving
toward, so no change needed there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:20:34 +01:00

328 lines
13 KiB
Svelte

<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { ListTree, Check, Timer, Loader2, ThumbsUp, ThumbsDown, Pencil } from 'lucide-svelte';
import { profilesStore } from '$lib/stores/profiles.svelte.ts';
import { replaceTaskFromDto } from '$lib/stores/page.svelte.js';
import type { TaskDto } from '$lib/types/app.ts';
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props();
interface Subtask {
id: string;
text: string;
done: boolean;
}
let subtasks = $state<Subtask[]>([]);
let loading = $state(false);
let error = $state('');
let decomposing = $state(false);
// Per-step UI state. Keyed by subtask id so we never lose state when
// the list reorders. Values:
// rating[id] — 1 | -1 — the thumbs vote the user gave this session
// editing[id] — true while the user is editing the step text
// draft[id] — the in-flight edit value before save
let rating = $state<Record<string, 1 | -1 | undefined>>({});
let editing = $state<Record<string, boolean>>({});
let draft = $state<Record<string, string>>({});
// Monotonic token per-step. Each time the user kicks off a save we
// bump the token; the pending save remembers its token and only
// rolls back if the failure belongs to the still-current edit.
// Without this, a slow first save that eventually fails will stomp
// a faster second save that already committed.
let saveToken = $state<Record<string, number>>({});
async function loadSubtasks() {
loading = true;
error = '';
try {
subtasks = await invoke<Subtask[]>('list_subtasks_cmd', { parentTaskId });
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
async function decompose() {
decomposing = true;
error = '';
try {
subtasks = await invoke<Subtask[]>('decompose_and_store', {
parentTaskId,
profileId: profilesStore.activeProfileId,
});
// Phase 6 nudge-bus signal. The bus schedules a 15-min idle
// check on this parent task id — if no step or the task itself
// is completed in that window, a gentle "still with that one?"
// nudge fires.
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('kon:microstep-generated', {
detail: { parentTaskId },
}));
}
} catch (e) {
error = String(e);
} finally {
decomposing = false;
}
}
async function checkStep(subtaskId: string) {
try {
const result = await invoke<{
updatedSubtask: TaskDto;
autoCompletedParent: TaskDto | null;
}>('complete_subtask_cmd', { subtaskId });
const idx = subtasks.findIndex(s => s.id === subtaskId);
if (idx >= 0) {
subtasks[idx] = {
...subtasks[idx],
done: result.updatedSubtask.done,
text: result.updatedSubtask.text,
};
}
// PR 1.5: when this completion auto-completed the parent, swap the
// refreshed parent into the Tasks store so its done state, doneAt,
// and badge counts pick up immediately — no full refetch needed.
if (result.autoCompletedParent) {
replaceTaskFromDto(result.autoCompletedParent);
}
// Phase 6 nudge-bus signal. Any completed step clears the
// micro-step-idle timer for this parent task — the breakdown
// is demonstrably getting worked, no nudge needed.
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('kon:step-completed', {
detail: { id: subtaskId, parentTaskId },
}));
}
} catch (_) {}
}
function startTimer(subtaskId: string) {
window.dispatchEvent(new CustomEvent('kon:start-timer', {
detail: { taskId: subtaskId, seconds: 120 }
}));
}
// --- HITL feedback --------------------------------------------------------
//
// All three paths (thumbs up, thumbs down, correction-via-edit) route
// into the same `record_feedback` command. The parent task text is the
// "input" the AI was given, so it travels in context_json so the prompt
// builder can reconstruct the (input, good-output) pair.
function feedbackContextJson() {
return JSON.stringify({ input: parentTaskText ?? '' });
}
async function recordThumb(step: Subtask, ratingValue: 1 | -1) {
// Toggle: if the user already voted the same way, clear it (record
// rating 0 means correction, not a thumb-off — we just skip the
// re-record and drop the local highlight). Unvoting isn't stored;
// the audit trail stays immutable.
if (rating[step.id] === ratingValue) {
const next = { ...rating };
delete next[step.id];
rating = next;
return;
}
rating = { ...rating, [step.id]: ratingValue };
try {
await invoke('record_feedback', {
input: {
targetType: 'microstep',
targetId: step.id,
rating: ratingValue,
originalText: step.text,
correctedText: null,
contextJson: feedbackContextJson(),
profileId: profilesStore.activeProfileId,
},
});
} catch (_) { /* feedback capture is best-effort, never fatal */ }
}
function startEdit(step: Subtask) {
editing = { ...editing, [step.id]: true };
draft = { ...draft, [step.id]: step.text };
}
function cancelEdit(stepId: string) {
const nextE = { ...editing }; delete nextE[stepId]; editing = nextE;
const nextD = { ...draft }; delete nextD[stepId]; draft = nextD;
}
async function saveEdit(step: Subtask) {
const next = (draft[step.id] ?? '').trim();
cancelEdit(step.id);
if (!next || next === step.text) return;
const original = step.text;
// Update in-memory first so the UI is snappy; roll back if the
// persistence call fails so we never show stale-but-different text.
// The monotonic `myToken` guards against a stale rollback stomping
// a fresher successful save that landed while we were waiting.
const myToken = (saveToken[step.id] ?? 0) + 1;
saveToken = { ...saveToken, [step.id]: myToken };
const idx = subtasks.findIndex(s => s.id === step.id);
if (idx >= 0) subtasks[idx] = { ...subtasks[idx], text: next };
try {
await invoke('update_task_cmd', {
id: step.id,
patch: { text: next },
});
// Record correction as the highest-value feedback signal.
await invoke('record_feedback', {
input: {
targetType: 'microstep',
targetId: step.id,
rating: 0,
originalText: original,
correctedText: next,
contextJson: feedbackContextJson(),
profileId: profilesStore.activeProfileId,
},
}).catch(() => {});
} catch (_) {
// Only roll back if our token is still the most recent — a later
// edit that already succeeded must not be overwritten.
if (saveToken[step.id] === myToken) {
const rollbackIdx = subtasks.findIndex(s => s.id === step.id);
if (rollbackIdx >= 0) {
subtasks[rollbackIdx] = { ...subtasks[rollbackIdx], text: original };
}
}
}
}
function handleEditKeydown(evt: KeyboardEvent, step: Subtask) {
if (evt.key === 'Enter') { evt.preventDefault(); saveEdit(step); }
else if (evt.key === 'Escape') { evt.preventDefault(); cancelEdit(step.id); }
}
$effect(() => {
if (parentTaskId) loadSubtasks();
});
</script>
<div class="pl-6 mt-1 flex flex-col gap-1">
{#if loading}
<div class="flex items-center gap-2 text-[12px] text-text-tertiary py-1">
<Loader2 size={12} class="animate-spin" aria-hidden="true" />
Loading steps…
</div>
{:else if subtasks.length === 0 && !error}
<button
class="flex items-center gap-1.5 text-[12px] text-text-tertiary hover:text-accent py-1 disabled:opacity-50"
onclick={decompose}
disabled={decomposing}
aria-label="Break down this task into micro-steps"
>
{#if decomposing}
<Loader2 size={12} class="animate-spin" aria-hidden="true" />
Breaking down…
{:else}
<ListTree size={12} aria-hidden="true" />
Break down
{/if}
</button>
{:else if error}
<p class="text-[11px] text-text-tertiary py-1">{error}</p>
{:else}
{#each subtasks as step (step.id)}
<div class="flex items-center gap-2 group py-0.5">
<button
class="w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0
{step.done
? 'bg-success/20 border-success text-success'
: 'border-border-subtle hover:border-accent'}"
onclick={() => checkStep(step.id)}
disabled={step.done}
aria-label={step.done ? 'Step complete' : 'Mark step complete'}
>
{#if step.done}
<Check size={9} aria-hidden="true" />
{/if}
</button>
{#if editing[step.id]}
<!-- svelte-ignore a11y_autofocus — deliberate: inline edit
is user-initiated and focus must land on the input to
match the UX pattern users expect from any task app. -->
<input
type="text"
bind:value={draft[step.id]}
onkeydown={(e) => handleEditKeydown(e, step)}
onblur={() => saveEdit(step)}
class="text-[12px] flex-1 min-w-0 bg-bg-input border border-accent rounded px-1.5 py-0.5 text-text focus:outline-none"
autofocus
data-no-transition
/>
{:else}
<button
type="button"
class="text-[12px] flex-1 min-w-0 {step.done ? 'line-through text-text-tertiary' : 'text-text-secondary'} truncate text-left cursor-text bg-transparent border-0 p-0"
ondblclick={() => !step.done && startEdit(step)}
disabled={step.done}
aria-label="Double-click to edit this step"
title="Double-click to edit"
>{step.text}</button>
{/if}
{#if !step.done && !editing[step.id]}
<!-- HITL feedback: thumbs vote + pencil edit. All three
route into record_feedback and feed the prompt-conditioning
loop. See docs/roadmap/2026-04-23-... Phase 2. -->
<button
class="opacity-30 group-hover:opacity-100 focus-visible:opacity-100 p-0.5 text-text-tertiary hover:text-success
{rating[step.id] === 1 ? '!opacity-100 text-success' : ''}"
onclick={() => recordThumb(step, 1)}
aria-label={rating[step.id] === 1 ? 'Remove thumbs up' : 'Thumbs up — this is a good step'}
title="Thumbs up — train the model on this style"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui), color var(--duration-ui)'}
>
<ThumbsUp size={10} aria-hidden="true" />
</button>
<button
class="opacity-30 group-hover:opacity-100 focus-visible:opacity-100 p-0.5 text-text-tertiary hover:text-danger
{rating[step.id] === -1 ? '!opacity-100 text-danger' : ''}"
onclick={() => recordThumb(step, -1)}
aria-label={rating[step.id] === -1 ? 'Remove thumbs down' : 'Thumbs down — this misses the mark'}
title="Thumbs down — avoid this style"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui), color var(--duration-ui)'}
>
<ThumbsDown size={10} aria-hidden="true" />
</button>
<button
class="opacity-30 group-hover:opacity-100 focus-visible:opacity-100 p-0.5 text-text-tertiary hover:text-accent"
onclick={() => startEdit(step)}
aria-label="Edit this step (the correction trains future suggestions)"
title="Edit — this is the strongest training signal"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui)'}
>
<Pencil size={10} aria-hidden="true" />
</button>
<span
class="opacity-30 group-hover:opacity-100 focus-visible:opacity-100"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui)'}
>
<SpeakerButton text={step.text} label="Read this step aloud" size={10} />
</span>
<button
class="opacity-30 group-hover:opacity-100 focus-visible:opacity-100 flex items-center gap-1 text-[10px] text-text-tertiary hover:text-accent"
onclick={() => startTimer(step.id)}
aria-label="Start 2-minute timer for this step"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui)'}
>
<Timer size={10} aria-hidden="true" />
Just Start
</button>
{/if}
</div>
{/each}
{/if}
</div>