feat(tasks): task board UI — chat window becomes Tasks + card grid

Reframes the chat surface as tasks:
- New Tasks.svelte: a card grid of tasks, each showing status (Running /
  Needs input / Done / Failed), the goal as title, the outcome summary, and
  relative time; filterable by status with live counts; delete on hover;
  "New task" and per-card click open the conversation.
- Board updates LIVE off the events stream (goal.set / task.status /
  question.*) via an explicit liveEvents.subscribe with a debounced refetch —
  scanning all events newer than the last seen, since entity.touched bursts
  bury task events below index 0.
- App shell: primary nav "Chat" → "Tasks" (board is now the home route),
  "New chat" → "New task", conversation header gets a Tasks / Conversation
  breadcrumb. Removed the superseded Sessions page.
- api.ts Session type carries the task fields (goal/status/outcome/summary).

Also fixes a pre-existing SSE bug that blocked ALL live updates app-wide:
writeSSE emitted `event: <type>`, which EventSource only delivers to
addEventListener(type) handlers — but stores/events.ts (and every page reading
liveEvents) consumes via onmessage, which never fires for named events. So the
live stream delivered nothing to the UI. Dropped the event-name line; the type
is already in the JSON payload, and new event types now need zero client
changes. SSE test still green (it parses data: lines).

Verified in the browser against the live stack: the board renders 50 tasks
with correct status buckets; a goal-driven task appears and flips to a Done
card with its summary in real time without a reload; Events page confirms the
stream now delivers to onmessage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 13:22:27 +02:00
parent 014e5c74e0
commit 413bf54daf
5 changed files with 251 additions and 185 deletions

View File

@@ -209,12 +209,22 @@ func sqlcEventToGen(ev sqlcgen.Event) gen.Event {
// writeSSE writes a single Event as an SSE message. Returns false if the
// write failed (client disconnected). flusher may be nil (io.Pipe path,
// which has no separate flush step).
//
// We deliberately DO NOT set the SSE `event:` name field, even though every
// event has a type. A named SSE event is only delivered to a matching
// addEventListener(type) handler, NOT to EventSource.onmessage — and the whole
// frontend (stores/events.ts and every page that reads liveEvents) consumes the
// stream via onmessage, reading the type from the JSON payload's `type` field.
// Emitting `event: <type>` silently routed every event away from onmessage, so
// the live stream delivered nothing to the UI. Leaving the name off sends all
// events to onmessage; the type is already in `data`, and new event types need
// zero client changes. `id:` is kept for Last-Event-ID reconnection.
func writeSSE(w ioWriter, flusher http.Flusher, ev sqlcgen.Event) bool {
data, err := json.Marshal(sqlcEventToGen(ev))
if err != nil {
return true // skip un-serializable events
}
_, err = fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, data)
_, err = fmt.Fprintf(w, "id: %d\ndata: %s\n\n", ev.ID, data)
if err != nil {
return false
}