Files
Lumotia/src/lib/components/WipTaskList.svelte
Jake 46be0a5aca
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
feat(feedback): Phase 2 — HITL thumbs + correction capture with prompt-conditioning loop
Closes the human-in-the-loop gap from docs/brief/feature-set.md and
Phase 2 of the 2026-04-23 feature-complete roadmap.

Storage (kon-storage):
- Migration v10 adds the `feedback` table: (target_type, target_id,
  rating, original_text, corrected_text, context_json, profile_id,
  created_at) with CHECK constraints on target_type and rating, plus
  indexes on (target_type, rating, created_at DESC) for prompt-time
  retrieval and (profile_id, target_type, created_at DESC) for
  per-profile scoping.
- New public API: `FeedbackTargetType`, `RecordFeedbackParams`,
  `FeedbackRow`, `record_feedback`, `list_feedback_examples`.
- Tests updated — the RB-02 rollback regression now discovers the
  real max version at runtime instead of hard-coding v10 for its
  poison migration.

LLM (kon-llm):
- `prompts::FeedbackExample` — local shape for few-shot exemplars so
  kon-llm stays independent of kon-storage.
- `prompts::build_conditioned_system_prompt` — appends a "here is
  the style this user prefers" block to the base system prompt
  when examples are available; returns the base prompt unchanged
  when empty, so new users and early sessions see generic output.
- `LlmEngine::decompose_task_with_feedback` and
  `LlmEngine::extract_tasks_with_feedback` thread examples through
  to the builder. The old one-arg variants are preserved and now
  call through with an empty slice.
- 4 unit tests covering empty, empty-input-skip, correction-wins,
  and thumbs-up-only fallback.

Tauri (src-tauri):
- New commands::feedback module: `record_feedback`,
  `list_feedback_examples_cmd`.
- `decompose_and_store` and `extract_tasks_from_transcript_cmd`
  now fetch the last 5 positive/neutral feedback rows for their
  target type and pass them through to the LLM, wiring the
  learning loop end-to-end.
- Shared `to_llm_examples` helper parses the `context_json.input`
  field (where the recorder stashes the parent task text / transcript
  chunk) back into the exemplar shape.

Frontend (MicroSteps.svelte):
- Thumbs-up and thumbs-down buttons on every micro-step row.
  Hover-revealed; the vote recolours the icon; clicking again
  clears the local highlight (the row itself stays in the audit
  trail).
- Pencil icon + double-click to edit step text. Save flows through
  update_task_cmd for persistence and records a correction feedback
  row with (original_text, corrected_text) — the highest-value
  training signal.
- Parent task text is captured in context_json.input at record time
  so the prompt builder can reconstruct the (input, preferred-output)
  pair on subsequent decompositions.
- Feedback capture is best-effort — a record_feedback failure never
  interrupts the primary action.

What's deferred to a later phase:
- Thumbs + corrections on extracted tasks (same pipeline, different
  surface — probably TasksPage after the AI-extraction path)
- Thumbs on transcript cleanup output
- Semantic retrieval over the feedback corpus (once there is enough
  data to justify embedding infrastructure; the storage shape is
  already ready for it)
2026-04-24 12:53:51 +01:00

147 lines
5.3 KiB
Svelte

<script lang="ts">
import { tasks, addTask, completeTask, uncompleteTask, deleteTask } from '$lib/stores/page.svelte.js';
import MicroSteps from '$lib/components/MicroSteps.svelte';
import { ChevronDown, ChevronRight, Timer } from 'lucide-svelte';
function startFocusTimer(task: { id: string; text: string }) {
window.dispatchEvent(new CustomEvent('kon:start-timer', {
detail: { taskId: task.id, seconds: 300, label: task.text }
}));
}
let { wipLimit = 3 } = $props();
let newTaskText = $state('');
let showOverflow = $state(false);
let expandedTaskIds = $state(new Set<string>());
// Exclude subtasks from WIP count — only top-level tasks count
let activeTasks = $derived(tasks.filter(t => !t.done && !t.parentTaskId));
let visibleTasks = $derived(activeTasks.slice(0, wipLimit));
let overflowTasks = $derived(activeTasks.slice(wipLimit));
let hasOverflow = $derived(overflowTasks.length > 0);
function handleAdd() {
const text = newTaskText.trim();
if (!text) return;
addTask({ text, bucket: 'inbox' });
newTaskText = '';
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') handleAdd();
}
function toggleExpand(taskId: string) {
const next = new Set(expandedTaskIds);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
expandedTaskIds = next;
}
</script>
<div class="flex flex-col gap-3">
<!-- Add task input -->
<div class="flex gap-2">
<input
type="text"
bind:value={newTaskText}
onkeydown={handleKeydown}
placeholder="Add a task…"
class="flex-1 px-3 py-2 rounded-lg bg-bg-input border border-border-subtle text-text text-[13px]
placeholder:text-text-tertiary focus:border-accent focus:outline-none"
data-no-transition
/>
</div>
<!-- Active tasks (WIP-limited) -->
{#if visibleTasks.length === 0}
<p class="text-[12px] text-text-tertiary text-center py-4">No active tasks</p>
{:else}
<div class="flex flex-col gap-1">
{#each visibleTasks as task (task.id)}
<div>
<!-- Task row -->
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-hover group"
style="transition-duration: var(--duration-ui)">
<button
class="w-4 h-4 rounded border border-border-subtle flex items-center justify-center flex-shrink-0
hover:border-accent"
onclick={() => completeTask(task.id)}
aria-label="Complete task"
></button>
<span class="text-[13px] text-text flex-1 min-w-0 truncate">{task.text}</span>
<!-- 5-min focus timer — the "just-start" button from the brief -->
<button
class="opacity-0 group-hover:opacity-100 text-text-tertiary hover:text-accent"
onclick={() => startFocusTimer(task)}
aria-label="Start 5-minute focus timer for this task"
title="Start 5-minute focus timer"
style="transition: opacity var(--duration-ui)"
>
<Timer size={12} aria-hidden="true" />
</button>
<!-- Expand/collapse micro-steps toggle -->
<button
class="opacity-0 group-hover:opacity-100 text-text-tertiary hover:text-accent"
onclick={() => toggleExpand(task.id)}
aria-label={expandedTaskIds.has(task.id) ? 'Collapse steps' : 'Expand steps'}
style="transition: opacity var(--duration-ui)"
>
{#if expandedTaskIds.has(task.id)}
<ChevronDown size={12} aria-hidden="true" />
{:else}
<ChevronRight size={12} aria-hidden="true" />
{/if}
</button>
<button
class="text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 text-[11px]"
onclick={() => deleteTask(task.id)}
aria-label="Delete task"
>
&times;
</button>
</div>
<!-- Micro-steps panel (expanded) -->
{#if expandedTaskIds.has(task.id)}
<MicroSteps parentTaskId={task.id} parentTaskText={task.text} />
{/if}
</div>
{/each}
</div>
{/if}
<!-- Overflow -->
{#if hasOverflow}
<button
class="flex items-center gap-1.5 text-[11px] text-text-tertiary hover:text-text-secondary px-3"
onclick={() => showOverflow = !showOverflow}
>
<ChevronDown size={12} class="transform {showOverflow ? 'rotate-180' : ''}" aria-hidden="true"
style="transition: transform var(--duration-ui)" />
{overflowTasks.length} more in your list
</button>
{#if showOverflow}
<div class="flex flex-col gap-1 opacity-60">
{#each overflowTasks as task (task.id)}
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg">
<div class="w-3 h-3 rounded border border-border-subtle flex-shrink-0"></div>
<span class="text-[12px] text-text-secondary truncate">{task.text}</span>
</div>
{/each}
</div>
{/if}
{/if}
<!-- WIP indicator -->
{#if activeTasks.length > 0}
<p class="text-[10px] text-text-tertiary text-right">
{Math.min(activeTasks.length, wipLimit)}/{wipLimit} active
</p>
{/if}
</div>