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>
328 lines
13 KiB
Svelte
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>
|