Several changes across the entire application; UI #13

Manually merged
lyssieth merged 16 commits from indev into main 2026-08-02 23:57:18 -04:00
Owner

Does a lot of things. See commit log.

Does a lot of things. See commit log.
feat: Rust-owned conversation state and the intent IPC surface
All checks were successful
ci / build (push) Successful in 2m6s
1ad3268bfe
Phase 0 of the UI push: the backend half of issues #6/#7/#8. Rust owns
live conversation state; the frontend will hold a projection and send
action-level intents. No conversation tree crosses the bridge.

- sermones-runtime/session: ConversationStore wraps the core tree ops
  (send/edit/regenerate/navigate + the three stream terminals),
  validates what the core ops assert on, projects the provider context
  from the current path, and ranks sidebar summaries by recency.
  Mutations do no disk IO — persistence is snapshot() plus a cloned
  HistoryStore moved into a blocking task, and there is deliberately no
  save(&self) to call from the wrong thread.

- StreamAssembler::finish yields (String, Message): HTML cannot be
  turned back into blocks, and the tree stores blocks. read_stream now
  takes the whole flattened path instead of a bare prompt.

- IPC: send/edit/regenerate/navigate/new-conversation intents,
  TurnStreamResult, and the ConversationView / ConversationSummary
  projection. StartStreamParams, StartStreamResult, LoadHistoryResult,
  and SaveConversationParams are removed rather than deprecated —
  start_stream carried no history, and keeping it would leave a second
  path that skips the tree.

- Bridge: owns a ConversationStore; the registry now carries a
  StreamTarget per subscription, which is both how a terminal finds its
  turn and how ConversationSummary.live is answered (the tree cannot —
  a Streaming status left by a crashed run would lie). New signals
  conversation_updated and conversation_list_changed; history_loaded is
  gone with its payload type.

Deviations from the plan, all deliberate: summaries carry no `live` in
the runtime layer (liveness belongs to the registry); a refusal closes
its turn as TurnStatus::Refused, which is what the field means;
last_modified crosses as OffsetDateTime, as Conversation's already did.
DEFAULT_MODEL moves to sermones-core — the intents carry no model, so
the frontend's old per-call default had nowhere to live.

Known limitation, documented: a cancelled stream's partial text never
reaches the tree. The abort lands at a chunk boundary and the reader
dies holding the assembler.

cargo nextest run: 508 passed. just lint: 0 diagnostics. pnpm test: 18
passed. pnpm check FAILS with 9 errors, all in frontend/src/lib/bridge.ts
and App.svelte, which still call the removed start_stream /
save_conversation helpers. That is Phase 1's scope and is not addressed
here.
The Phase 0 work already removed those slots, but a handful of code
comments and doc lines still pointed at them. Sweep them out so the
sources and the intent-based surface match.

- bridge.rs / streaming.rs: comment cleanup, no behaviour change.
- streaming.md: 'the frontend's per-call model' is no longer true; the
  intents carry no model and the config is the only source.
- runtime.md: drop the note about the frontend still using the removed
  helpers (the typed client layer is the follow-up that lands next)
  and the spurious 'tokio runtime handle exposed to the binary' bullet.
Phase 1 of the UI push (issues #6/#7/#8). Rust owns live conversation
state; the frontend now speaks the intent surface end to end.

- lib/bridge.ts: rewrite the projection layer around the intent surface
  — sendMessage / editTurn / regenerateTurn / navigateBranch /
  newConversation / cancelStream / loadHistory — with a typed
  IntentResult<T> = {ok: true, value: T} | {ok: false, error: SermonError}
  so callers handle the typed path and the engine-error path
  uniformly. The old startStream / saveConversation helpers are gone
  (no slot to call). Signal wiring (onStreamChunk / onStreamEnded /
  onConversationUpdated / onConversationListChanged / onErrorRaised)
  returns disconnect() closures so the store can unbind cleanly. The
  configuration and error-store surfaces stay as they were.

- lib/conversations.svelte.ts: the UI's ConversationStore (Svelte 5
  runes class, mirroring errorStore.svelte.ts conventions). Holds the
  summary sidebar, the per-conversation views, the active id, and the
  unread-error set; routes stream_chunk / stream_ended by sub_id into
  the matching turn's assistant_html (wholesale replacement — no per-
  token diffing, no markdown logic); replays conversation_updated /
  conversation_list_changed signals; re-points sub_id mappings after
  edit / regenerate when the leaf turn id changes. Selecting a
  conversation only flips activeId — never cancels subscriptions,
  never touches the error store (subscription-cleanup.md and
  error-visibility.md).

- qwebchannel.d.ts: replace the old QObject surface (start_stream /
  save_conversation / load_history / history_loaded) with the intent
  surface (send_message / edit_turn / regenerate_turn / navigate_branch
  / new_conversation / cancel_stream / load_history / conversation_updated
  / conversation_list_changed / error_raised). Signal connections now
  expose disconnect().

- App.svelte: minimal placeholder so the page still renders + svelte-check
  passes; the Phase 5 shell (sidebar + chat view + composer) replaces it.

- lib/conversations.test.ts: 16 vitest cases covering intent envelopes,
  chunk routing, terminal transitions (EndOfStream / Cancelled /
  ProviderError), the unread-error flag, the select-does-not-cancel
  rule, stale-chunk drop, the sub-id re-pointing on edit, and detach.
  Slot answers are real IpcEnvelopes built via wrapCall so the parse
  layer exercises the same code path it would on a live bridge.
Docked multiline textarea below the centered column with the full
state matrix from primitives.scss.

- Enter sends, Shift+Enter inserts a newline. Empty drafts are a no-op,
  not an error.
- Autosize grows the textarea from one visible line up to ~6 lines
  (~220px), then scrolls internally so the composer never eats the
  viewport.
- Focus = border → --focus-ring, no outline ring (palette.md input rule).
- Disabled state for drift / unconfigured provider; resting border in
  the default state.
- Streaming state: Send is replaced by Stop while any sub_id on the
  active conversation is live. Stop uses the error palette so it
  reads as an interrupt, not a primary action.
- Attachment affordance (Paperclip icon, dragover-cancellable) is
  present visually per the brief; end-to-end wiring is issue #11, so
  the click does nothing yet — same for paste / drop targets.
The message surface the new composer streams into. Two components:
one turn and one column.

- MessageView.svelte: one rendered turn. Sanitized HTML replaced
  wholesale via {@html}; no markdown logic. Hover- and
  focus-within-revealed toolbar with Copy / Edit / Regenerate,
  identical position and vocabulary on every bubble. Inline edit =
  progressive disclosure (textarea in place of the user text, no
  modal), save → onedit, Escape cancels. Branch pager (‹ k of n ›)
  on turns with sibling_count > 1, quiet, wired through the
  siblingIds prop. TurnStatus-driven visuals: Streaming = quiet
  accent live indicator (no typing theater); Abandoned = dimmed,
  no error surfaced (error-visibility.md); Errored = inline error
  styling on the bubble, the actual error lives in ErrorsPanel.
  Reasoning <details> stays open during the stream, collapses on
  finish (handled by the streaming flag through assistant_html
  replacement — no client-side re-render needed).

- ChatView.svelte: the centered column at --measure-prose. Keyed
  {#each view.turns as turn (turn.turn_id)} so a chunk re-renders
  exactly one MessageView (streaming-backpressure.md). CSS
  containment (content-visibility + contain-intrinsic-size) on
  message blocks for long-conversation perf — no JS windowing.
  Scroll contract: auto-scroll only when within ~1 line of the
  bottom; "Jump to bottom" pill visible only while scrolled up AND
  chunks arriving; reading history stays stable. Empty state
  teaches the interface (Enter / Shift+Enter / branching). The
  module-scoped siblingIdsFor() exports the sibling resolution so
  future tests or other surfaces can call it directly.
Conversation list surface. One row per ConversationSummary, recency-
ranked (the engine hands them most-recent-first), with the full
affordance set from the brief.

- --surface-panel background, hairline border on the right edge.
- New-conversation button (Plus icon) at the top, quiet until hovered.
- Search field on top filtering summaries client-side by title match;
  recency rank is preserved. Empty state teaches the interface.
- Rows: title (truncated server-side), relative recency text, live
  accent dot while  (gentle pulse, not a spinner), unread-error
  marker (warning icon) when a stream ended in ProviderError since
  the user last selected the conversation.
- Active row visibly selected (--accent-muted). Selecting only sets
  activeId — never cancels subscriptions, never touches the error
  panel (subscription-cleanup.md, error-visibility.md).
- Empty state teaches the interface: "No conversations yet — click
  New to start one" and "No matches for <query>" when the search
  filters them out.

Plus a small follow-up: trim the primitives mixin to only emit
selectors that the current surfaces actually use. The full matrix
(aria-disabled, aria-busy, .loading, .error, aria-invalid) is
documented in the file header and lands back in the mixin the first
time a surface needs it. This silences Svelte's CSS-scope
"unused selector" warnings that were firing across the new
components.

Also: ChatView's module script uses the modern  attribute
instead of the deprecated .
feat: Phase 5 shell + XDG spec conformance
All checks were successful
ci / build (push) Successful in 2m11s
c45155eefe
- App.svelte: pure composition. Top strip (theme toggle), ErrorsPanel,
  then Sidebar | ChatView + Composer in a row. The ConversationStore is
  the glue: attach runs the boot handshake, loads history, wires signal
  listeners; detach unbinds them on unmount. Drift detected at the
  handshake surfaces a fixed banner with the rebuild-and-relaunch
  message; the composer stays disabled until the bridge is up.
- app.scss: shell layout only (vertical stack, sidebar+main row, top
  strip). The component styles for messages / errors / composer moved
  into their respective components — the global stylesheet is now just
  body + shell.
- docs/components/frontend-ui.md: new component doc. Inventory, store /
  projection architecture, signal-routing rules, state matrix and
  containment rules, cross-link map to streaming-backpressure /
  subscription-cleanup / error-visibility / palette, tests, what's not
  in v1 yet.
- AGENTS.md: project-structure bullets updated for the new components
  and the runtime session module description.

- XDG spec conformance: persistent user data belongs in
  $XDG_DATA_HOME (~/.local/share), not $XDG_STATE_HOME
  (~/.local/state). The spec puts STATE_HOME for logs and recent files
  that can be discarded without losing anything important; conversation
  history is plainly the opposite. Renamed:
  - persistence::default_state_dir → default_data_dir
  - $SERMONES_STATE_DIR → $SERMONES_DATA_DIR
  - resolution: $XDG_DATA_HOME/sermones → $HOME/.local/share/sermones
  Updated every reference: persistence tests, errors.rs (which uses
  default_data_dir for errors.json), config.rs doc comment, runtime.md
  table + storage section, error-store.md, error-visibility.md
  invariant, errors.md architecture, glossary.
fix: hover scope leak + svelte-preprocess pipeline
All checks were successful
ci / build (push) Successful in 2m6s
c8ab342312
Two issues addressed.

1. Toolbar visibility leaked across instances. Svelte 5 scopes a
   descendant selector like `.message:hover .toolbar` as
   `.message.svelte-hash:hover .toolbar:where(.svelte-hash)` — the
   deeper `:where(.svelte-hash)` has zero specificity but matches the
   hash class on *any* element in the document, including toolbars
   inside sibling MessageView instances. Hovering one message then
   revealed every message's toolbars.

   Fix: drive toolbar visibility from event handlers on the message
   element (`onmouseenter` / `onmouseleave` / `onfocusin` /
   `onfocusout` on the article) and bind a `has-toolbar` class to
   a `$derived(hovered || focused)` flag. The CSS now reads
   `.has-toolbar .toolbar` — a self-referencing class on the same
   element — and there is no cross-instance ambiguity. Keyboard
   reachability (the brief asks for hover *and* focus-within) is kept.

2. SCSS pipeline swap. Earlier versions routed <style lang="scss">
   blocks through Vite's CSS pipeline with lightningcss as the
   transformer; lightningcss does not understand Svelte's `:global()`
   marker and surfaces it as an unknown pseudo-class. The split now is:

   - svelte-preprocess (with sass) compiles SCSS inside <style> blocks
     before the Svelte compiler adds the scope hash. `:global()` is
     expanded in this pass.
   - lightningcss runs as Vite's CSS transformer for the final
     post-Svelte pass — plain CSS files like app.scss, plus the
     minification step.

   Drop sass-embedded in favour of the sass peer dep svelte-preprocess
   needs; add svelte-preprocess to devDependencies; approve its
   postinstall script in pnpm-workspace.yaml.
fix: emit conversation_updated for every loaded conversation
All checks were successful
ci / build (push) Successful in 2m1s
ae322b4b0b
`load_history` was emitting `conversation_list_changed` (with
summaries) but not `conversation_updated` for each loaded
conversation. The frontend's per-conversation view cache stayed empty,
so `activeView = views.get(activeId)` returned null when a sidebar
row was clicked — and the chat view kept rendering the empty state.

Fix: after adopting the loaded conversations into the store on the Qt
thread, emit `conversation_updated` for each one before
`conversation_list_changed` rides out. The summaries signal tells
the sidebar which conversations exist; the per-conversation signal
fills the chat view cache.

Also add a regression test in conversations.test.ts pinning the
select-then-update flow at the store layer: `store.select(id)` before
`conversation_updated` lands leaves views empty (the bug); once the
update arrives, the view is populated and the active id stays set
(the fix).
fix: per-section toolbar reveal, working Copy, form field names
All checks were successful
ci / build (push) Successful in 2m2s
3bce99fefd
Three fixes from testing on the desktop.

1. Toolbar reveal was per-article, not per-section. The message
   article wraps both the user and assistant halves; hovering anywhere
   in the article popped both toolbars. Now each section (user /
   assistant) tracks its own hover + focus state and toggles its own
   `has-toolbar` class, so hovering the user text reveals only the
   user toolbar and hovering the reply reveals only the reply toolbar.
   The sections gained role="group" + aria-label to satisfy the
   a11y rule that elements with mouseenter/mouseleave handlers carry
   an ARIA role (svelte-check was flagging it).

2. Copy didn't work. Two bugs: the assistant toolbar's Copy was
   copying `turn.user_text` (the user message) instead of the reply,
   and `navigator.clipboard.writeText` can reject silently in
   QtWebEngine / sandboxed contexts. Rewrote it as `copyText` with a
   fallback chain: try the async Clipboard API, fall back to a hidden
   textarea + `document.execCommand('copy')` on rejection. The user
   Copy copies the user's source text; the assistant Copy strips the
   rendered HTML to its text content via innerText so the clipboard
   gets clean text, not markup.

3. Chromium warning about form fields needing id/name: the composer
   form and its textarea now carry name attributes
   (name="composer" / name="composer-input").
fix: contain code blocks inside the assistant reasoning area
All checks were successful
ci / build (push) Successful in 1m51s
c36b5a64dc
The assistant `.content` div — which carries the injected reasoning
<details> and any <pre> blocks inside it — had no styles at all. The
old app.scss rule for message content was dropped when component
styles moved into MessageView, so a long code line inside the
reasoning trace pushed the bubble past the prose measure and overflow
the edge.

- `.content`: min-width: 0 + max-width: 100% + overflow-wrap so a
  pre can't force the bubble wider (flex items default to
  min-width: auto, which refuses to shrink below content width).
- `.content pre`: max-width: 100% + overflow-x: auto — cut with
  scroll, not wrap, per the user's preference.
- Restored the reasoning <details> styling that used to live in
  app.scss (secondary text, hairline left border, italic summary)
  scoped to `.content`, and gave the details max-width: 100% so the
  disclosure itself can't overflow.
fix: copy via the Qt bridge slot instead of the JS clipboard APIs
All checks were successful
ci / build (push) Successful in 1m53s
3f95f1f5d5
Copy was still dead. The JS clipboard paths are blocked inside
QtWebEngine: javascriptCanAccessClipboard is off by default, and even
when enabled, navigator.clipboard.writeText throws "Write permission
denied" because the clipboard-write permission grant stays denied
(QTBUG-77450). document.execCommand('copy') is deprecated and equally
blocked. No amount of JS-side fallback was going to fix it — the
sandbox has to be bypassed, not worked around.

Fix: route copy through Qt itself, exactly the "native workaround"
Qt recommends for this case.

- shell.cpp/shell.h: copy_to_clipboard(const QString&) calls
  QGuiApplication::clipboard()->setText(text). The system clipboard
  is owned by QGuiApplication; the web sandbox is not involved.
- bridge.rs: declares copy_to_clipboard in the extern "C++" block
  and exposes it as the qinvokable slot copy_text(text: QString) ->
  QString. Plain-text slot (like get_schema_hash), fire-and-forget,
  returns empty on success.
- bridge.ts: copyText(bridge, text) helper; qwebchannel.d.ts declares
  the slot.
- MessageView: the Copy buttons now route through an oncopy prop
  (wired App.svelte → ChatView → MessageView) instead of doing JS
  clipboard work. copyUser sends the user source text; copyAssistant
  strips assistant_html to innerText first so the clipboard gets
  clean text. The checkmark affordance stays local.
- App.svelte: onCopy calls the bridge slot, with a navigator.clipboard
  fallback for browser-dev-server workflows (localhost is a secure
  context there).
fix: copy the assistant's raw markdown source, not rendered innerText
All checks were successful
ci / build (push) Successful in 1m58s
88476d825f
The assistant Copy was stripping assistant_html to innerText, which
dragged the reasoning trace along (the <details class="reasoning">
section's text) and lost the markdown source the user actually wants
to reuse.

The raw text never crossed the IPC boundary: TurnView carried
assistant_html (rendered, reasoning included) but no assistant_text.
The user half already had user_text; the assistant half now does too.

- sermones-core/ipc.rs: TurnView gains assistant_text — the
  concatenated Block::Output runs, reasoning excluded by
  construction. Schema hash changes; codegen regenerates the TS type.
- sermones/ipc.rs: conversation_view populates it from the same
  output_text the provider context uses.
- MessageView: copyAssistant sends turn.assistant_text through the
  oncopy path; the innerText stripping hack is gone.
- New projection test: assistant_text is markdown source without the
  reasoning, while assistant_html still carries it for display.
fix: a considerable amount of fixes, full detail not available
All checks were successful
ci / build (push) Successful in 3m20s
00049b8bff
refactor: conversation model to two-level sibling structure
All checks were successful
ci / build (push) Successful in 5m42s
f2216c61f3
Replace the turn-based tree (TurnNode { user, exchanges }) with a flat
list of UserMessage / AssistantMessage nodes. Edit and regenerate become
structurally distinct operations on different message kinds, each with its
own sibling pager. Wire format is a clean break: renamed slots, new IPC
types, new schema hash. Old-format files migrate one-shot on load.

- Core: MessageId, UserMessage, AssistantMessage, Message, AssistantStatus;
  append/edit/regenerate/navigate/mark/set-content ops; recover_path
- Runtime: rewritten ConversationStore intents + context projection that
  skips the in-progress streaming assistant; persistence migration step
- Bridge: renamed slots, StreamTarget now names assistant_message_id,
  MessageView projection with per-kind sibling_index/sibling_ids
- Frontend: store routes subs to assistant messages; MessageView renders
  user-or-assistant with two independent pagers
- Docs: updated component/architecture docs; new user-message,
  assistant-message, and conversation-migration docs
fix: pager buttons re-enable after a stream ends; cancelled messages keep their text
All checks were successful
ci / build (push) Successful in 3m21s
56e4ca564b
Pagers stayed disabled after a generation because the
`#subToAssistant` map was a plain `Map`; Svelte 5 only tracks
`$state` reads, so the `.delete()` in `#onEnded` did not
re-trigger the `streaming` / `anyLive` consumer. Switched to
`SvelteMap` so the entry removal drives the re-evaluation.

Cancellation was an `abort` on both tokio tasks; the reader died
holding the assembler, so the tree got an empty `Abandoned` and
the bubble collapsed to nothing. Made the user-cancel path
*cooperative*: an `Arc<Notify>` is wired into `Subscription`,
the reader races it in a `select!` against the chunk loop, and
on cancel calls a new `flush_partial` that finishes the
assembler and emits `StreamEvent::Partial(Message)`. The
forwarder writes the blocks via the new
`ConversationStore::abandon_with_content` and marks the turn
`Abandoned` before the terminal `stream_ended(Cancelled)`.

The force-quit path (`bridge::Drop` → `abort_all`) still uses
`abort` — when the UI is gone, the partial is moot.

Tests: 533 Rust + 41 frontend, all passing. The new
end-to-end cases (`read_stream_emits_partial_on_cancel_after_chunks`,
`read_stream_emits_empty_partial_on_cancel_before_stream`) run a
real `LlamaClient` against an in-process TCP server that streams
SSE, and pin the cooperative-cancel contract. Docs updated
(`streaming.md`, `subscription-cleanup.md`).
lyssieth changed title from [DRAFT] fix: pager buttons re-enable after a stream ends; cancelled messages keep their text to WIP: [DRAFT] fix: pager buttons re-enable after a stream ends; cancelled messages keep their text 2026-08-02 23:53:18 -04:00
lyssieth changed title from WIP: [DRAFT] fix: pager buttons re-enable after a stream ends; cancelled messages keep their text to WIP: Several changes across the entire application; UI 2026-08-02 23:53:44 -04:00
lyssieth added this to the v1 milestone 2026-08-02 23:54:25 -04:00
lyssieth changed title from WIP: Several changes across the entire application; UI to Several changes across the entire application; UI 2026-08-02 23:56:08 -04:00
lyssieth manually merged commit 56e4ca564b into main 2026-08-02 23:57:18 -04:00
lyssieth deleted branch indev 2026-08-02 23:57:30 -04:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lyssieth/sermones!13
No description provided.