From 413bf54dafb9b029dc3edac29ff0e032a4f81bc1 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 11 Jul 2026 13:22:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20task=20board=20UI=20=E2=80=94=20?= =?UTF-8?q?chat=20window=20becomes=20Tasks=20+=20card=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: `, 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 --- internal/httpapi/sse.go | 12 +- web/src/App.svelte | 31 +++-- web/src/lib/api.ts | 8 ++ web/src/pages/Sessions.svelte | 172 --------------------------- web/src/pages/Tasks.svelte | 213 ++++++++++++++++++++++++++++++++++ 5 files changed, 251 insertions(+), 185 deletions(-) delete mode 100644 web/src/pages/Sessions.svelte create mode 100644 web/src/pages/Tasks.svelte diff --git a/internal/httpapi/sse.go b/internal/httpapi/sse.go index 4a5a398..9cd928f 100644 --- a/internal/httpapi/sse.go +++ b/internal/httpapi/sse.go @@ -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: ` 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 } diff --git a/web/src/App.svelte b/web/src/App.svelte index f3cceeb..e7378c1 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,6 +1,6 @@ - -
-

Sessions

-
- {#each $sessions as session (session.id)} -
- - -
- {:else} -
No sessions yet. Start chatting with Nomos.
- {/each} -
-
- - diff --git a/web/src/pages/Tasks.svelte b/web/src/pages/Tasks.svelte new file mode 100644 index 0000000..397bcf0 --- /dev/null +++ b/web/src/pages/Tasks.svelte @@ -0,0 +1,213 @@ + + +
+
+
+

Tasks

+

Every task is a goal Nomos works to completion.

+
+ +
+ + +
+ {#each FILTERS as f} + + {/each} +
+ +
+ {#if visible.length === 0} +
+

+ {filter === 'all' + ? 'No tasks yet. Start one and Nomos will plan it, execute it, and report the outcome.' + : `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`} +

+ {#if filter === 'all'} + + {/if} +
+ {:else} +
+ {#each visible as s (s.id)} + {@const st = statusStyle(s)} +
+ + +
+ {/each} +
+ {/if} +
+