Commit graph

82 commits

Author SHA1 Message Date
cogwheel
f80f79352e
feat(openwebui): support v0.11.1 (#667) 2026-08-26 18:41:29 +05:30
cogwheel
1926af4b7f Harden sync, interaction, and markdown rendering 2026-08-25 10:25:11 +05:30
cogwheel
1371c352d5 Improve scroll performance and sync efficiency 2026-08-25 00:09:19 +05:30
cogwheel
2ee24a5817 Cache markdown extents and defer code highlighting 2026-08-23 20:14:48 +05:30
cogwheel
f4d76fd411
Improve chat transcript scrolling performance (#652)
* perf: smooth chat transcript scrolling

* fix: address scrolling review feedback
2026-08-23 14:59:56 +05:30
cogwheel
3891dde3f1
Add Apple on-device support and improve direct chats (#651)
Some checks failed
L10n / l10n (push) Has been cancelled
* feat: add Apple model backends and context compaction

* Add Apple on-device and PCC direct providers

* dev: show all onboarding backends in debug builds

* Fix short response pin-to-top settlement

* Polish Direct Connections context settings

* Fix onboarding selection row spacing

* Address PR review feedback

* Harden context and native request bounds

* Strengthen compaction regression coverage

* Close final context and image review gaps

* Validate structured array bounds
2026-08-22 23:47:51 +05:30
cogwheel
f79ccf9c51 Use system fonts and preserve streaming state 2026-08-20 22:03:38 +05:30
cogwheel
43182d7282
fix: resolve reported rendering, voice, terminal, and connection bugs (#643)
Some checks are pending
L10n / l10n (push) Waiting to run
* fix: resolve reported rendering, voice, terminal, and connection bugs

LaTeX (#642, #533): render complex MathJax SVGs through the compatible
renderer so underbrace/boxed/pmatrix no longer glitch or leak `amp`, and
center block equations horizontally.

Fonts (#501): rebuild the bundled Geist faces, which mapped lowercase
Greek omega to the uppercase glyph.

Hermes (#637): connection tests now exercise authenticated capability and
toolset discovery instead of the unauthenticated health endpoint, so a
server that only answers /health no longer reports "connected".

Voice (#632): request microphone permission before starting the Android
foreground service, which crashed when permission had never been granted.

Voice (#630): stop local STT while TTS plays so the model no longer hears
itself. Barge-in is off by default and opt-in from Audio settings, and the
voice overlay gains a speakerphone toggle.

Transport (#629): drop the plaintext-HTTP restriction for Hermes and
direct connections. Tailscale/Headscale CGNAT addresses and other private
networks were rejected outright. Custom headers and the self-signed
certificate toggle are now shared by all backends via a single Hermes
transport configurator.

Terminal (#626): download now opens the native save dialog instead of the
share sheet.

Terminal (#608): route the `terminal:display_file` event into the Terminal
files panel rather than dropping it in the chat event handler.

Auth (#606): drive reverse-proxy login through the real WebView identity
and cookie store so Authelia and similar providers finish sign-in.

Also fixes pre-existing test failures on main: three assertions still
expected the single-newline block join replaced in #641, and six expected
the "Delete connection" button label renamed to "Delete" in #628.

* fix: route Android speakerphone and address review feedback

Speakerphone on Android: setSpeakerphoneOn is deprecated and no-ops on
Android 12+ once a communication device is selected, which the voice
session does for Bluetooth SCO. The toggle rendered but never changed the
route. Select the built-in speaker as the communication device instead,
keeping the legacy call as the pre-31 fallback, and tear down the active
SCO route before re-routing so a headset cannot keep owning playback.

Voice: treat TtsError as a terminal assistant-speech event. With barge-in
disabled the recognizer is stopped for playback and only TtsCompleted
restarted it, so a failed TTS request left voice mode active with no way
to accept the next utterance.

Hermes: cancel active runs when certificate trust changes. A trust-only
edit rebuilt the transport while old cancellation tokens stayed live.

Terminal: sanitize the download file name, which comes from the server's
Content-Disposition header, before passing it to the save dialog.

Hermes settings: use the existing headerName/headerValue/addHeader/
removeHeader localizations instead of hardcoded English.

Adds coverage for the speakerphone toggle, TTS-error recovery, and
HttpClient forwarding through the desktop RPC channel factory.

* fix: fall back for dot-only download file names

`.` and `..` survive character sanitization but name a directory rather
than a file, so writing them throws instead of producing a download. Use
the timestamp fallback for those, and cover the sanitizer directly.

Windows reserved device names are not handled: this app ships only iOS and
Android targets.
2026-08-18 15:25:53 +05:30
cogwheel
ea82ee164e
perf: chat scrolling and markdown rendering (#640)
* perf: stop shell rebuilds cascading into rows and cut per-flush markdown cost

Scrolling:
- Give the timeline slivers a delegate with a real shouldRebuild keyed on
  rowBuilder/entries identity and centerIndex, so ChatPage setState (drag
  start, keyboard insets, composer resize, pin transitions) rebuilds only
  the shell, not every mounted row.
- Memoize the transcript window, ChatTimelineRenderModel, and rowBuilder by
  identity in ChatPage; the fresh window list per build also defeated the
  stable-layout cache's identity fast path.
- Replace full MediaQuery.of dependencies with scoped paddingOf/sizeOf.
- Raise the streaming cacheExtent from 120px to 600px; the small extent
  evicted rows that then remounted with a synchronous markdown compile.

Markdown:
- Memoize buildMarkdownDisplayParts by compiled-document identity; it
  re-derived one sub-document per block with deep compares on every build.
- Add identical() fast paths to CompiledMarkdownDocument and
  PreparedMarkdownText equality; compare rope segments instead of
  materializing both sides.
- Cache per-message structure-signature fragments by ChatMessage identity
  instead of rebuilding O(messages x versions) strings per emission.
- Gate incremental preparation and the reference-definition strip on one
  shared line-anchored predicate; a bare "]:" substring no longer forces
  full re-preparation of the whole message every flush.
- Make the streaming split's unsafe-line detection fence-aware and cap
  freezing at the first raw HTML block instead of keeping the entire
  document mutable; reuse the fence-close helper instead of compiling a
  RegExp per fenced block.
- Skip the four LaTeX extraction regex passes when content has no $ or
  backslash; memoize the error-heuristic content scans in the assistant
  footer; gate profiler map allocations to profiling builds; memoize the
  code-block line split and render >50k-char code plain.

* fix: detect reference definitions past raw HTML blocks in streaming split

The single-pass unsafe-line scan returned at the first raw HTML line, so a
reference definition appearing after that block was never seen and blocks
containing its links could be frozen with the link unresolved. Scan the whole
region for definitions and only record the first raw HTML offset as the
freeze cap.

* fix: distrust fence state after a raw HTML block starts in unsafe-line scan

Backtick lines inside raw HTML are content, not fences; an odd count left the
fence tracker 'inside' a fence and skipped a later real reference definition,
freezing earlier blocks with unresolved links. After the first raw HTML start,
check every line for a definition regardless of fence state — at worst more
conservative than the whole-document check this replaced.

* fix: long-response truncation at completion and follow-ups never arriving

Truncation: the streamed buffer is never periodically folded into message
state, so /api/chat/completed was built from a stale prefix of the response
and the server's echo of that payload truncated the full content when merged
back (worst on the HTTP/SSE transport, which reached completion without any
terminal flush). Flush the buffer before building the completed payload, and
guard the three unguarded overwrite paths (completed echo, replay-gap
authoritative recovery, cumulative chat:completion content snapshots) so a
strict prefix of already-streamed content is never adopted.

Follow-ups: the server emits chat:message:follow_ups only after
chat:completion {done:true}, and the per-stream socket subscription is
disposed synchronously by that done event, so the streaming handler for
follow-ups was unreachable. The passive conversation subscription is the
surviving delivery path; apply the pushed payload directly to the target
message there instead of relying on a debounced refetch that races the
server's own persistence of the suggestions.

* fix: address review feedback on follow-ups delivery and splitter

- Fall through to the debounced refetch when a pushed follow-ups payload
  targets a message id not present in local state.
- Split the follow-ups envelope parser into a private implementation with a
  visibleForTesting wrapper, matching file convention.
- Detect reference-definition labels containing escaped brackets in the
  streaming splitter's unsafe-line scan.
- Use package:checks in the new follow-ups parser test.

* fix: deferred structured-output projections dropping response content

The structured-output projector defers full re-projections geometrically
(next re-render only at 2x the last projected length) and permanently
disables its plain-append fast path once the text contains a backtick, so
the visible content can trail the logical content by up to half the
response. Two consequences fixed here:

- A plain content delta arriving after a deferred projection appended onto
  the short stale render and flipped structuredOutputIsLatest, which also
  made the terminal projector finalize bail — permanently dropping the
  deferred middle of the response on screen and in the persisted echo.
  appendVisibleAssistantChunk now materializes the full projection (new
  StructuredOutputStreamingProjector.syncProjectionToLatest) before
  switching the content basis to plain appends.
- handleCompletionDone flushed the buffer before building the completed
  payload but did not finalize the projector first, so the payload (and the
  outlet-filter echo derived from it) could carry the stale short render.

Also fold the un-flushed streaming buffer into state in _cancelMessageStream
(conversation switch / message deletion mid-stream discarded the entire
un-synced tail), skipped during provider dispose where state is untouchable.

* fix: harden remaining content-adoption paths against divergent server bodies

Local and server renders of the same turn wrap reasoning/tool sections in
semantic <details> blocks with different attributes (locally injected
duration=\"0\" vs the server's real duration), so every raw startsWith/length
guard was dead on reasoning turns. Content comparisons now strip rendered
semantic details and compare answer bodies:

- applyServerContent adopts only when the server's answer body is at least
  as long as the local one; a snapshot whose raw length grew (long reasoning
  block) while the answer shrank no longer replaces a complete local answer.
- _shouldPreserveLocalAssistantContent (all snapshot adoptions including the
  reopened-stream reconcile and its buffer rebase) compares stripped bodies.
- The completed-echo, replay-gap recovery, and cumulative content-snapshot
  guards compare stripped bodies, and an echo differing only by details
  wrappers is a no-op instead of an adoption.

Also:
- Hermes: a terminal/recovered output that is a strict prefix of the
  streamed text no longer replaces it (lagging aggregate or incomplete
  recovery would truncate delivered content).
- The local turn echo payload now carries output, files, embeds, usage,
  sources, statusHistory, followUps, and error: the sync outbox rebuilds the
  chat blob from these rows and the server merge replaces message objects
  wholesale, so omitted fields were wiped from the server copy on push.
- A stale settled markdown refresh no longer leaves the preparation flag set
  when nothing newer is queued (indefinite loading skeleton).

* fix: address review findings on the unsafe-line scan and echo payload

- Remove the backslash overlap in the reference-definition label pattern;
  the overlapping alternation could backtrack exponentially on long
  malformed labels, on the UI isolate.
- Track <details> bodies opaquely (open/close depth) in the unsafe-line
  scan, mirroring the block scanner: an unmatched backtick line inside a
  details body no longer opens a phantom outer fence that hid later
  reference definitions.
- Persist codeExecutions in the local turn-echo payload alongside the other
  durable server-shape fields.

* fix: live-tail freeze/duplication, follow-up persistence, scroll-down jank

Live tail (regression from the projection-sync fix): syncProjectionToLatest
re-armed the projector's geometric backoff to 2x the full content length
while the plain-append transition disabled the append path, so subsequent
output snapshots all deferred and the visible tail froze for the rest of the
turn. The sync now preserves the backoff threshold. Same-frame handling now
also matches the upstream client contract (Chat.svelte): a frame carrying an
output snapshot supersedes its own choices delta / content field — Conduit
applied the delta first and the snapshot second, duplicating text the
snapshot already contained.

Follow-ups: pushed suggestions were applied to in-memory state only; the
turn echo had been persisted at completion before the event fired, so a
conversation switch reloaded the message without them. The passive handler
now re-persists the message row after applying the payload.

Scroll-down jank: three down-only per-frame costs while returning toward the
bottom — the bottom-anchor recompute re-armed a full layout-maintenance pass
(row-rect snapshot + pin geometry) on every metrics tick once anchored (now
only on anchored-state transitions); pin geometry re-measured three global
rects per frame mid-scroll (now skipped until motion settles once reported);
and UserScrollNotification(idle) was treated as drag end even though Flutter
publishes it at ballistic START, running mode flips and jump-to-latest
arming mid-fling (drag end now comes from ScrollEndNotification, which fires
at actual rest).

* fix: streamed word drops and quote/entity rendering defects

Quotes/entities:
- Answer text no longer escapes double quotes (element-mode escaping; tags
  are still neutralized). &quot; escaped into a context the markdown decoder
  skips — immediately after a backquote, or inside code via the streaming
  fragment path — surfaced literally on screen. Attribute-mode escaping
  stays for <details> attribute values.
- The plain streaming accumulator was seeded/refilled from the RENDERED
  (already-escaped) body on reopen/reasoning sync; the next full render
  escaped it a second time (&amp;quot; decoding once back to a visible
  &quot;). Plain-content derivation now strips semantic details AND
  unescapes entities.
- Clipboard copy and TTS decode presentation entities back to literal text.
  API replay deliberately does not (it cannot distinguish model-typed
  entities from presentation escaping, and the direct bridge has trusted
  raw replay for fidelity).

Missing words:
- Whitespace-only deltas were discarded on one transport (trim() guard),
  gluing words together and losing paragraph breaks.
- Whitespace-only semantic text blocks were dropped from full renders and
  the streaming append delta never re-emits the swallowed prefix — the
  blank line between a reasoning section and the answer vanished.
- Once a backtick/tilde disabled the projector's append path for the turn,
  geometric backoff left the visible tail up to 50% behind until
  completion; renders now use a bounded additive step when appends are
  unavailable.
- The SSE parser now mutes same-frame deltas only when the output snapshot
  parses into renderable blocks, matching the socket path — an output whose
  items all parse away no longer mutes the delta while rendering nothing.

* fix: address review feedback on plain-content whitespace and coverage

- The semantic-details strip in plain-content derivation now consumes only
  the wrapper's own trailing newline instead of \s* plus trim, preserving
  answer whitespace such as a leading indented code block's indentation.
- Regression tests: the additive re-projection schedule for code-bearing
  streams, and a non-renderable output snapshot not muting the same-frame
  delta.

* fix: match the details parser's exact close token in streaming scans

The streaming details trackers accepted '</details >' as a close while the
details parser recognizes only the literal '</details>'. A close lookalike
inside a streamed details body exited details tracking early, let a body
backtick open a phantom outer fence, and hid a valid reference definition
after the real close — freezing an earlier reference-style link unresolved.
The preparation engine's checkpoint scanner had the same loose pattern and
could split prepared content mid-block. Both now match the parser exactly,
with a regression test verified to fail against the loose pattern.

* fix: reconcile deferred snapshots at terminal finalize and audit lengths

finalizeStructuredOutputProjection bailed whenever a plain chunk was the
last content-affecting operation; if output snapshots after that chunk had
deferred under the re-projection threshold, the deferred tail was dropped
from the final content. The finalize now adopts the terminal render unless
the accumulated visible text is longer (matching upstream's output-replaces-
content contract while preserving delta-only hybrid streams).

The done-signal log now records message/rendered/plain lengths so a
truncation report can be pinpointed from a single log line: message shorter
than rendered points at a lost flush; rendered shorter than plain points at
an unrepaired deferred projection.

* fix: reconcile terminal projection when plain chunks ended the stream

finalizeStructuredOutputProjection bailed whenever a plain chunk was the
last content-affecting operation. The terminal snapshot render is
authoritative upstream (output replaces content wholesale in Chat.svelte);
adopt it unless the accumulated visible text is longer, preserving
delta-only hybrid streams.

The done-signal log now records message/rendered/plain lengths so a
truncation report can be pinpointed from one log line.

* fix: render output[] in poll recovery when persisted content is empty

OWUI 0.11 never persists a flat content string for a normal completion —
the durable body is the output[] item array, so a reasoning turn's raw
content is ''. pollServerForMessage ignored output[] entirely: whenever the
live socket missed the final frames (buffer caps on long reasoning
streams, reconnects), every recovery path polled the server, extracted an
empty string, adopted nothing, saw done=true, and finished the turn with
the partial local text — permanent tail truncation, reasoning models only.
Recovery now renders output[] with the same renderer the snapshot parser
uses when flat content is empty. Regression test verified to fail without
the fix.

* revert: drop the speculative terminal-finalize reconciliation

The longer-wins adoption added in 349bb708 was based on a wrong premise:
structuredOutputIsLatest is set back to true by every output snapshot,
applied or deferred, so the bail it targeted only holds when a plain chunk
was the very last content-affecting operation — where the accumulated
visible text is already the right terminal value (syncProjectionToLatest
keeps it complete). Its regression test passes with and without the change,
and adopting the terminal snapshot in that narrow case could drop
equal-length delta-only text. Restore the original bail, with a comment
explaining why it is correct.

* fix: keep the settle transition extent-neutral

Measured the streaming-to-settled swap (typing-indicator footer 16+28+4 vs
in-card action row 16+32): it is extent-neutral by design for plain,
reasoning, and completed-status turns. The residual jump came from status
rows whose updates never reported done — the settle filter emptied the list
and dropped the entire row (~30px shift on the bottom-anchored viewport),
also losing the only description of what the turn did. The last update now
stays visible (without the pending spinner) when filtering would otherwise
empty the row.

A new widget test measures both sides of the swap and asserts
extent-neutrality for the plain, scaled, versioned, pending-status, and
reasoning cases. Settle-only additions (sources row, a text-scaled version
chip) legitimately grow the card; an AnimatedSize wrapper was tried and
reverted — RenderAnimatedSize asserts when mutated during this tree's
layout pass.

* fix: count inline nested details opens like the parser; tighten projector test

The streaming split scanner counted details opens with the line-anchored
pattern, but the details parser counts complete opening tags anywhere in a
line. A nested inline open inside a streamed details body made the first
closing tag exit scanner details mode one level early, so a body backtick
poisoned fence state and hid later reference definitions from the fallback
scan. Depth counting now uses the parser's complete-tag pattern (still
crediting a line-leading partial tag so unterminated opens stay mutable);
block entry detection stays line-anchored, matching the parser.

Also assert the exact terminal projection content in the additive-schedule
projector test instead of suffix/length checks that a truncated result
could satisfy.

* fix: address thermo audit and outside-diff review findings

- Persist local-echo and direct/Hermes message payloads in the server
  shape: citation-shaped sources, snake_case code_executions via the
  shared converters, and null-stripped files. Client-model shapes broke
  the OpenWebUI web client's code-execution panels and citations for any
  chat synced from Conduit. The converters move from api_service.dart to
  core/utils/openwebui_message_payload.dart, and localEchoRowForMessage
  is now top-level with a test pinning payload completeness and shape.
- Bail out of syncProjectionToLatest when the projector never owned the
  visible basis (observe-only path): materializing the snapshot there
  shrank visible content that was deliberately kept as a superset.
- Copy user messages verbatim: the assistant clipboard sanitizer decoded
  entities the user actually typed and stripped definition-shaped lines.
- Route replaceVisibleAssistantContent's fallback through
  initialPlainStreamingContent so cumulative content frames cannot leak
  details wrappers into the plain accumulator and re-escape later.
- Stop treating definition-shaped lines inside a details body as
  reference definitions in the unsafe-line scan: the parser lifts details
  bodies into body_markdown compiled as its own document, so they cannot
  couple frozen and mutable segments (CodeRabbit outside-diff finding).
- Deduplicate the semantic-details strip and stale-prefix guards into
  core/utils/semantic_details.dart; five divergent regex copies and five
  hand-rolled prefix checks now share one definition.
- Extract the follow-ups socket-event parser to
  features/chat/utils/follow_ups_socket_event.dart and drop the
  ForTesting shims.
- Drop the dead streaming parameter on the chat cache-extent helper,
  hoist hot-loop regexes in the raw-boundary checkpoint scan, re-resolve
  the message index before the passive follow-ups re-persist.

* style: complete the dart format migration repo-wide

The Dart 3.13 formatter had been applied piecemeal to files this branch
touched, leaving the repo half-migrated and smearing mechanical churn
through functional diffs. One-shot migration of the remaining 214 files;
no semantic changes.

* fix: count details depth on the raw line, not the dedented candidate

The details parser counts open/close tags on the raw line regardless of
indentation inside a block, but both scanner depth-tracking sites routed
counting through the block-starter candidate, which is null for lines
indented four or more columns. An indented nested open was silently
dropped, so the first close exited scanner details mode a level early
and a body backtick opened a phantom fence that hid later reference
definitions. Depth now counts on the raw line (the partial-open carve-out
dedents before its anchored check so an indented incomplete tag still
holds the block open).

* fix: count only complete details tags; keep incomplete entry tags mutable

Crediting a partial line-leading <details as an open (added so an
unterminated streamed entry tag would not freeze) left scanner depth
permanently stale when a body contained a literal <details that never
completed: the parser counts only complete tags, so it closed the block
while the scanner stayed inside it and swallowed the document-level
reference definition after the block. Depth counting is now parser-exact
(complete tags only), and the unterminated-entry case is handled
structurally: an entry line whose tag has no closing > keeps the tail
mutable instead of faking a depth of one.
2026-08-18 01:20:40 +05:30
cogwheel
8d626d0f93
feat: add Hermes Desktop Gateway backend (#636)
Some checks failed
L10n / l10n (push) Has been cancelled
* feat: add Hermes Desktop Gateway backend

* fix: address Hermes Desktop review feedback

* fix: harden Hermes review edge cases

* fix: disambiguate restored decision IDs

* fix: apply remaining review feedback

* fix: restore partially written Hermes principal

* test: assert Hermes runtime rollback

* fix: add contrast to Hermes model avatar

* fix: adapt Hermes avatar to theme

* fix: surface Hermes Desktop models

* fix: show Hermes avatar in model suggestions

* fix: keep Hermes Stop enabled during local streams

* fix: correct Hermes model selectors

* fix: refresh Hermes model selector state

* fix: simplify Hermes agent model name
2026-08-16 19:51:36 +05:30
cogwheel
332f0c75bb
feat: unify app surfaces with native grouped design (#628)
Some checks failed
L10n / l10n (push) Has been cancelled
Squash-merge the reviewed native UI, sidebar, onboarding, direct connection, Hermes, workspace, theming, haptics, and consistency updates.
2026-08-15 18:37:46 +05:30
cogwheel
af7bb8a655
Migrate adaptive UI to native iOS glass (#622)
* Migrate adaptive UI to native iOS glass

* fix(sidebar): unify chat and folder gutters

The alignment regression persisted because nested folder rows combined their hierarchy depth with an additional fixed outer inset. Share the tile surface geometry, keep only the tree-depth offset, and lock the header, root-row, tint, and nested-row coordinates with widget tests.

* fix: harden adaptive iOS UI migration

* address greptile review feedback (greploop iteration 1)

* address review feedback (greploop iteration 2)

* address review feedback (greploop iteration 3)

* address review feedback (greploop iteration 4)

* address greptile review feedback (greploop iteration 1)

* address greptile review feedback (greploop iteration 2)

* address greptile review feedback (greploop iteration 3)

* refine iOS native chrome and composer
2026-08-09 12:05:01 +05:30
cogwheel
a13c6fdf79
fix: polish iOS 26 native glass controls (#610)
* fix: polish iOS 26 native glass controls

The platform-view optimization reused Flutter glyph sizing and native label defaults, causing oversized symbols, wrapped model names, and stale theme colors. Use explicit optical sizes, bounded native labels, and color-keyed view identity so the regressions remain fixed.

* perf: avoid unused native selector subtree

* fix iOS native toolbar polish

* use native model selector chevron

* shrink native model chevron

* fix iOS toolbar grouping and selection menus

Keep paired actions in one UIKit toolbar surface and delegate editable selection to the system menu. Avoid rebuilding EditableText from overlay visibility, which caused the menu and handles to cycle.

* fix: address iOS toolbar review feedback

* fix: preserve composer loading surface

* fix: enlarge native toolbar icons

* fix: balance native toolbar symbols

* fix: standardize native toolbar icon sizing

* fix: group channel and folder toolbar actions

* fix: preserve full model selector semantics

* test: cover native model selector semantics payload
2026-08-04 16:48:25 +05:30
cogwheel
3561f55de5 fix: release notes 2026-08-01 13:58:12 +05:30
cogwheel
e8b519ee90
Enable inline interactive HTML tool embeds (#603)
* feat: render interactive tool embeds inline

* fix: stabilize composer controls across layout states

Measure multiline expansion from the rendered compact width instead of focus or character count. Keep shared control sizes, text gutters, and modal close theming consistent across platforms.

* fix: address embed and composer review feedback

* fix: require real embed navigation gestures

* fix: align embed fragments and composer measurement

* fix: handle Android embed popups safely

* fix: finalize embed popup review

* fix: sandbox remote tool embeds

* fix: restore remote embed bootstrap

* fix: surface remote embed load failures

* fix: isolate remote embed bootstrap

* perf: avoid duplicate embed observers

* fix(chat): guard timeline geometry during route transitions

* fix(markdown): defer embeds until tool completion
2026-07-31 22:45:57 +05:30
cogwheel
677a20e2ba
Redesign the Conduit 4.0 release announcement (#602)
* feat: bundled JSON release notes for 4.0, polished changelog sheet, remove IAP

- Move release-note content from ARB to assets/release_notes/<locale>.json
  with locale-fallback repository and schema-checked validator
- Polish the release notes sheet (staggered reveal, version badge,
  feature glyph bullets, action cards) and add the 4.0 note in 13 locales
- Prune pre-4.0 notes
- Remove in_app_purchase and the tip jar entirely; keep the Buy Me a
  Coffee donation link on all platforms

* Refresh release notes sheet and localized copy

* Add release notes banner and native review support

* Redesign release announcement sheet

* Target release announcement to 4.0.1

* Address release announcement review feedback

* Tighten release note locale handling
2026-07-31 21:54:47 +05:30
cogwheel
b2f725a425
Polish chat composer and unify cross-platform typography (#598)
* Polish chat composer layout and platform styling

* Unify product typography across platforms

* Address automated review feedback
2026-07-29 11:42:22 +05:30
cogwheel
f2c3a718f7
fix: reconcile streaming chats after reopen (#593)
* fix: reconcile streaming chat state after reopen

* fix: harden reopened state reconciliation

* fix: address state recovery review feedback

* test: verify knowledge cache LRU identity

* fix: fence knowledge files across provider rebuilds

* fix: address outside-diff review feedback

* fix: arm reopened monitor before socket attach

* fix: wait for authoritative reopened completion

* refactor: address final review feedback

* fix: clear channel state on owner change

* fix: fence channel operations across owner changes

* fix: clear active channel during route changes

* fix: fence channel reaction picker ownership

* fix: complete destructive sign-out and auth routing

Purge the direct-local chat database after a committed full-data clear, and distinguish durable local cleanup from best-effort WebView cleanup so a cookie failure cannot leave on-device chats behind. Strip credential-bearing headers and mTLS material when preserving server details, and route completed optional OpenWebUI authentication back to chat even while server state refreshes. Regression tests cover the durable purge and both Direct/Hermes route shapes.

* fix: avoid authenticated router redirect loop

Keep the connection-issue route stable when active server resolution fails while authenticated. The regression exercises auth-to-chat completion, chat-to-error routing, and terminal error-page behavior.

* fix: keep destructive cleanup fail closed

Do not reopen Direct write or run admission when the on-device chat purge fails. Fence reaction pickers with the channel operation generation to reject A-B-A owner cycles, and extend regression coverage for both boundaries.

* fix: fence channel actions across owner ABA

Capture the channel operation generation across sends, attachment selection and upload, edits, deletes, pinning, channel dialogs, and member loading so A-B-A owner cycles cannot revive stale continuations. Keep app-data-clear write resumption centralized in the fail-closed finalizer.

* perf: reduce streaming UI and recovery activity

* perf: window chat transcripts with positioned scrolling

* perf: bound raster media decoding

* fix: bound pre-handler socket event buffering

* fix: address performance review feedback

* test: share transcript chain fixtures

* fix: recover transcripts from dangling tips

* fix: address outside-diff performance feedback

* fix: settle terminal replay snapshots

* fix: unify image preview bounds

* fix: stabilize reversed chat anchoring

The positioned-list migration treated item zero's trailing edge as the latest edge even though reversed lists report the latest boundary at leading edge zero. That kept reissuing streaming follow animations and delayed explicit detachment detection. Pinning also began before its synthetic spacer had real row measurements, so the target geometry changed during the animation.\n\nUse the reversed leading-edge invariant, track manual detachment separately, wait for measured pin rows, and hold the newest item at a constant minimum extent while the assistant consumes the remaining viewport. Regression tests cover the package edge semantics, initial settle, button gating, and stable streaming growth.

* fix: address chat viewport review feedback

Fence delayed anchor restores to their conversation, separate structural overflow from scroll-button eligibility, and restore viewport-aware markdown prewarming. Keep every image preview state on a stable bounded geometry and guard late pin measurements after disposal.

Regression coverage verifies reversed-list overflow classification, immediate detached-button eligibility, conversation restore fencing, visible/fallback prewarm windows, and shared preview dimensions.

* fix: align chat viewport bounds

Compute scrollability from the rendered transcript window, centralize anchor recomputation, and size raster decodes from the stable preview box so unbounded layouts do not decode at the inline cap.

The unbounded 3x preview regression now verifies a 900 by 900 decode target instead of 1536 by 1536.

* fix: align windowed chat navigation

* fix: fence deferred chat state updates

* fix: scope chat send admission ownership

* fix: keep pinned streaming viewport stable

The previous fix still revealed the first turn before user-row measurement and handed the active pin to tail-follow after spacer exhaustion. Settle first turns before reveal, keep active pins fixed through overflow, and expose latest navigation only once content is scrollable.

* fix: harden initial transcript settlement

Keep the hidden first-turn transcript out of hit testing and semantics until its positioned jump completes. Limit initial list seeding to the first settlement and bound the streaming stability test's frame drain.

* fix: preserve transcript state while settling

Keep the positioned transcript under a stable semantics and pointer wrapper while the first-turn pin settles. Toggle only wrapper properties so reveal cannot recreate the reversed list or reset its initial index.

* fix: stabilize pinned streaming scroll

The regression recurred because scroll-to-latest discarded measured pin geometry while streaming growth launched overlapping eased corrections. Keep the anchor through reattachment, animate measured end space once, and fence non-animated live maintenance behind the explicit navigation generation.

* fix: enforce exclusive streaming scroll ownership

The regressions recurred because pin completion and stale latest navigation could both reposition the list, while a detached multi-viewport assistant row continued rebuilding beneath the viewport. Settle measured pin geometry without a second item jump, fence competing bottom actions, and freeze detached tail presentation until an explicit latest action. Regression tests cover post-transition stability, responseDone settlement, and detached presentation.

* fix: anchor chat scrolling with forward slivers

Replace reversed item-position scrolling with a chronological, centered
CustomScrollView so streamed row growth and history prepends preserve exact
pixels.

The regression recurred because item-position tests bypassed the real
streaming subscription, while scrollable_positioned_list cannot preserve an
intra-row pixel offset as a growing row relayouts.

* fix: address final performance review

* fix: address hosted review gates

* fix: retain undispatched screen context

* fix: retry undispatched screen context

* fix: bound screen context retries

* fix: stabilize active streaming navigation

The regression recurred because the latest action restored pin state while still targeting the footer, and ChatPage duplicated the viewport's post-layout streaming corrections as the same assistant row grew.

Route active pinned turns back to their measured user row while pin space remains, leave layout maintenance to the sliver viewport, and replace the linear dots with Conduit's low-frequency painted orbit.

* fix: keep pinned streaming turns in stable slivers

The regression persisted because the forward-sliver port still coupled the
live footer and a shrinking pin spacer to streamed layout. Real markdown
growth changed maxScrollExtent between frames, so Flutter corrected the
viewport even without a follow-latest command; the earlier item tests did not
exercise that runtime subscription and layout sequence.

Render the live footer as its own sliver, keep fixed viewport-sized support
for the active pin, exclude that support from logical latest metrics, and
retain the pinned prompt as the semantic latest target across lazy unmounts
and manual detachment.

* fix: detach timeline follow for pointer scrolling

Mouse-wheel and trackpad input has no drag details, so it previously left the
timeline in automatic follow ownership during a live response. Treat non-idle
user-scroll notifications as manual ownership while keeping driven latest
animations excluded, and cover both paths with a pointer-signal regression.

* fix: omit absent timeline slivers

Use null-check patterns for optional footer and trailing content so the sliver
tree contains no empty adapters when those widgets are absent. Add a regression
that verifies removing the live footer removes its sliver as well.

* fix: retire pinned chat viewport ownership

The regression kept recurring because the previous tests encoded pinned-row navigation as the desired latest action and exercised item geometry without covering the terminal ownership transition used by real streaming updates.

Retire pin support on completion, failure, drag, or explicit latest; remove the spacer before the single real-footer navigation; fence stale callbacks; and clip earlier turns at the app-bar content boundary. Add red-to-green lifecycle, footer, streaming-growth, and clipping guards.

* fix: fence deferred pin release from drags

Prevent either deferred pin-release continuation from reclaiming latest ownership after real user interaction. Remove the constant-only latest-state helper and cover the shared production guard directly.

* fix: release orphaned pinned turns

Treat a missing pinned assistant as a terminal lifecycle transition so edit, regeneration, or reconciliation cannot retain synthetic pin support for a row that no longer exists.

* fix: restore glass-safe pinned chat geometry

The regression recurred because clipping hid the overlapped rows instead of removing their geometry, which also deprived native glass of backdrop content. Earlier tests rebuilt simplified rows and missed the direct streaming subscription's repeated metrics notifications. Reserve real sliver clearance for the pinned prompt and stop maintaining settled pin geometry on every streamed extent update. Regression coverage now drives mounted streaming growth and a real completed assistant row.

* test: restore chat row extent regressions

Cover archived variants at their real zero-sized production placeholder and drive completion content growth through the mounted AssistantMessageWidget row. This replaces the obsolete estimated-extent assertions removed with the forward-sliver viewport.

* fix: keep completed turns anchored below chat chrome

The regression recurred because per-pin top clearance disappeared at completion, while simplified initial-pin tests never exercised the established-chat transition. Latest-button visibility also duplicated scroll ownership in a bottom-anchor flag that stale post-pin metrics could clear.

Keep toolbar clearance at the oldest transcript edge, preserve the active prompt position when lifecycle pin support retires, and make free-scrolling mode authoritative for latest-button visibility. Add established-chat completion and stale-metrics regressions that were observed failing before the fix.

Fresh SimDeck validation held the second prompt at logical y=126 through thinking, streaming, and completion. Real overflow exposed latest at y=736, and one manual tap reached the actual footer without terminal auto-scroll.

* feat: support native iOS transcript scroll to top

The chat viewport used a private centered sliver controller, so Flutter’s native iOS status-bar event had no correct route to the transcript; offset zero is also not the oldest edge once saved anchors or older pages move the center.

Handle the native event only for the current, ticker-enabled viewport, transfer ownership to free scrolling, and navigate to the exact minimum extent with bounded post-layout correction. This avoids the generic message seeker, whose viewport-count budget failed across a single very tall assistant row.

Regression coverage observes red with the callback disabled and with the old bounded seeker, then green for a 20,000-pixel assistant row. The focused 102-test chat suite, full 4,926-test suite, analyzer, diff check, and iOS simulator build pass.

* test: harden native transcript navigation

* fix: stabilize first-turn streaming scroll

Preserve the initial turn pin when a newly created local conversation receives its first ID, and maintain the attached trailing edge during layout so streamed row growth never paints an intermediate jump.

* perf: reduce streaming render and markdown work

Keep high-frequency content updates inside the assistant body, materialize coalesced reasoning snapshots only at publication, and prevent transient Markdown revisions from filling settled caches.

Retire idle Markdown workers, sample debug-only diagnostics, and cover rebuild, cache, and lifecycle bounds with deterministic regression tests.

* fix: fence markdown cache eviction

Advance a global cache epoch before memory-pressure eviction and reject late writes from single compiles, shared followers, and batches that began before the clear.

Add deterministic delayed single and batch regressions covering post-eviction followers and subsequent current-epoch caching.

* fix: fence disposed markdown followers

Prevent single and batch follower continuations from repopulating the shared compiled cache after their MarkdownCompileService has been disposed.

Add deterministic delayed disposal regressions for both follower paths.
2026-07-28 13:34:54 +05:30
cogwheel
4539a7d5ab
Support Open WebUI 0.11 (#594)
* feat: support Open WebUI 0.11

Treat both 404 and 405 as the removed active-chat route because v0.11 deployments can surface the missing POST as Method Not Allowed. Use the active field from paginated chat lists and render the compatibility warning as an undecorated greeting card.

* fix: address Open WebUI 0.11 review feedback

* fix: keep empty-state scroll viewport accessible
2026-07-27 22:18:45 +05:30
cogwheel
633e10b790
Add sign-out data retention options (#588)
Adds sign-out retention choices, clears local preferences and Direct/Hermes connections by default, and fixes the iOS post-sign-out provider lifecycle hang.
2026-07-24 23:45:46 +05:30
cogwheel
c71af21118
Render Mermaid diagrams natively (#585)
* feat: render Mermaid diagrams natively

* fix: apply CodeRabbit auto-fixes

* ci: harden submodule checkout credentials

* fix: hide persistent glass controls under sheets

Route-level scroll controls were not subscribed to tracked sheet coverage, so their UIKit compositor layers could bleed through modal routes. Centralize pre-presentation native chrome suppression and add a regression guard.

* fix: respect Dynamic Type in chat chrome

Cupertino navigation bars suppress inherited scaling and the composer used fixed control extents. Restore the route text scaler, bound control geometry to 1.5x, and add a regression guard.

* fix: honor accessibility settings across custom chrome

Match native Bold Text for Flutter icon glyphs and route every custom Cupertino toolbar through the shared Dynamic Type-aware shell. Keep route content and refresh offsets aligned with the scaled toolbar height.

* fix: scale sidebar navigation with Dynamic Type

The native iOS 26 tab bar applies Bold Text but keeps fixed label and symbol metrics. Retain it at normal size and use the bounded, scalable Flutter navigation bar for enlarged accessibility categories.

* Revert "fix: scale sidebar navigation with Dynamic Type"

This reverts commit 048e9de00b.

* fix: address accessibility review feedback

Use adaptive note editor insets, hide the workspace native section menu beneath tracked sheets, and align the new accessibility assertions with the test suite's checks convention.

* test: use checks for workspace sheet coverage
2026-07-22 22:53:45 +05:30
cogwheel
d5c6b11b28
Optimize performance and harden lifecycle handling (#581)
* Optimize performance and harden lifecycle handling

* Address PR review feedback

* Clean up duplicate App Intent image retries

* Fix follow-up pin-to-top routing

* Restore no-jump pin dismissal on user scroll

A later timeline optimization kept the synthetic pin spacer active through every manual scroll, undoing the guarded dismissal from #560 and allowing iOS range correction to snap the viewport. Restore the phantom-free range guard and cover both unsafe and safe dismissal offsets so the regression cannot recur silently.

* Rebuild chat turn anchoring like T3 Code

Create the anchor from the exact optimistic user-message ID, replace the full-screen phantom range and capped physics with measured anchored end space, and use item-level layout correction until real content fills the viewport. Cancel automatic corrections on the first user gesture so streaming growth cannot reintroduce scroll jumps.

* Fix Android cookie-clear verification and allow https-upgrade capture origins

The verified cookie clear treated Android's unimplemented getAllCookies as
failure, blocking SSO sign-in on empty stores and permanently arming the
incomplete-logout fence. Exact-origin capture checks silently dropped token
capture for http-configured servers upgraded to https by their proxy; capture
now also trusts the default-port https upgrade of the configured origin.
Also stabilize the background-validation sanitization test's poll deadline.

* Restore same-origin redirect recovery, pool warmup, and native fixes

- Replay credential-safe 3xx hops (same origin or default-port https
  upgrade) for idempotent methods on the shared API client; cross-origin
  hops still surface to the caller.
- Warm the completion client's actual connection pool at startup again;
  checkHealth's request-scoped probe no longer touches it.
- Graceful ApiService dispose so provider rebuilds cannot abort in-flight
  SSE streams; cap connectivity failure backoff at the healthy interval.
- iOS: thread the trusted origin into native sheet avatar loads so auth
  headers are attached again; let oversized STT tap buffers fall back to
  one-off copies instead of being dropped.

* Harden PR re-application: auth, upload/share, and provider regression fixes

Auth: logout preserves connection prerequisites (custom headers, mTLS)
while still revoking session credentials (legacy apiKey, captured proxy
Cookie headers); config-header edits and legacy apiKey migration no
longer sign the user out; cold-start background validation retries for
~7s to cover slow tunnels; interactively reissued byte-identical tokens
are accepted after logout; SSO button failures surface visibly.

Uploads/share: native-share durable keys derive from payload id +
ordinal instead of mutable content checksums; Hermes/direct-model
shares route through the local composer path instead of retrying
forever; pre-connection network failures defer instead of failing
terminally; orphaned receipt-held rows are garbage collected once
native storage is confirmed drained; disposed-queue persistence reports
failure so staged files survive; legacy staging roots are reclaimable.

Providers/UI: queued-completion banner watches every ownership-fence
input so retry/cancel cannot silently no-op; drawer keeps previous rows
during pagination reloads; authenticated image cache keys derive from a
stable server+token digest so the disk cache survives restarts while
accounts stay isolated.

* Make share staging indeterminate-ownership test hermetic under concurrency

The test snapshotted the process-global staging temp root, so files staged
or cleaned by concurrently running suites broke exact set equality. Assert
only that this test's own artifact never appears.

* Stabilize streaming UI and pending share persistence

Persist Android pending-share state atomically with migration coverage. Keep prompt anchoring and streaming haptics stable across row remounts, and reduce markdown streaming churn while hardening placeholder cleanup.

* Make streaming Markdown preparation incremental

* Reuse stable Markdown render inputs

* Avoid cumulative structured stream rebuilds

* Instrument and streamline structured output

* Hide dismissed sidebar native chrome

* Reduce streaming platform view retention
2026-07-20 21:04:28 +05:30
cogwheel
eb99937f09
Fix chat title generation, copying, and note markdown styling (#579)
* fix: restore chat metadata and rich content handling

* fix: apply CodeRabbit auto-fixes

* fix: harden markdown code masking
2026-07-18 09:27:57 +05:30
cogwheel
66308acf46
Optimize chat timeline, streaming, Hermes, and native navigation (#578)
Refactor chat timeline anchoring and streaming rendering, extend Hermes session and history support, and preserve stable native navigation and Liquid Glass controls.

Validated with flutter analyze and flutter test (4,012 passed; 2 skipped).
2026-07-18 09:19:41 +05:30
cogwheel
df8eaa1ce3
feat: add direct OpenAI-compatible and Ollama connections (#567)
* feat: add direct provider connections

* fix: harden direct connection workflows

* fix: stabilize direct connection state

* fix: ignore failed direct image attachments

* fix: serialize direct profile reloads

* fix: recheck queued media upload routes

* fix: prune stale direct models during refresh

* feat: polish adaptive backend onboarding

* fix: restore explicit backend onboarding back paths

Hermes and Direct onboarding are entered with replacement routes, so they cannot rely on an implicit navigator pop. Give both flows explicit destinations and align their setup screens with the adaptive auth shell.

* fix: harden direct onboarding state

Re-read profiles after asynchronous confirmation, guard discovery writes after disposal, and keep disabled segments unselected. Consolidate the shared onboarding shell and header-security resets to prevent drift.

* fix: keep local backends independent after logout

Logout intentionally retains OpenWebUI server state, so routing and model selection now key off resolved backend usability, terminal auth, trusted Direct bindings, and auth-session identity across loading, error, retained-value, cold-start, and backend-switch races.

* fix: preserve chat storage boundaries on recovery

Treat OpenWebUI cache reads as best-effort only when ownership is explicit, retain ambiguity and Direct-local failures instead of crossing providers, and contain asynchronous default-model cache write errors. Regression tests cover all three paths.

* fix: retain legacy OpenWebUI chat ownership

When the merged chat list is still loading, an explicitly OpenWebUI-scoped active summary now restores ownership for legacy raw IDs without trusting Direct-local or unannotated rows. This prevents same-ID local chats from making a valid OpenWebUI conversation ambiguous.

* feat: migrate direct providers to typed SDKs

* fix: isolate direct transport and reject blank streams

* fix: hide stale direct model bindings

* fix: apply CodeRabbit auto-fixes

* feat: support Hermes attachments via Responses API

* fix: address direct and Hermes review findings

* fix: avoid OpenWebUI settings load in direct composer

* fix: preserve images during direct regeneration

* fix: preserve repeated direct images across turns

* fix: settle direct reasoning and Android chrome

* fix: harden multi-backend streaming ownership

* fix: harden multi-backend recovery lifecycles

* fix: close adversarial multi-backend edge cases

* fix: separate Responses reasoning items

* fix: preserve streamed reasoning boundaries

* fix: address final multi-backend review findings

* fix: close remaining security review findings

* fix: address PR review findings
2026-07-14 22:18:12 +05:30
cogwheel
cb32a22ba3
Fix full-width drawer gesture arbitration (#568)
Fix drawer gesture ownership for wide Markdown content while preserving selection, horizontal scrolling, and embedded gesture owners. Closes #556.
2026-07-12 20:31:54 +05:30
cogwheel
a5a8e5d1c2
Warn instead of blocking newer Open WebUI servers (#564)
* fix: warn instead of blocking newer servers

* fix: keep compatibility warning across app routes

* fix: retain verified server compatibility state

* fix: avoid stale compatibility config writes
2026-07-11 00:17:45 +05:30
cogwheel
f843481092
Polish phase-aware chat streaming and markdown rendering (#537)
* Polish chat streaming timeline

* Refactor streaming markdown into display parts

* Keep streaming markdown part metadata local

* Address review round 1 on chat streaming timeline

Correctness:
- chatTurnPhaseForMessage: treat metadata['responseDone'] as completed so the
  typing footer hides and the action row appears during the responseDone gap
- timeline tail detection excludes archivedVariant assistants (history sliver
  already hides them; prevents a stale archived answer flashing as the live tail)
- markdown display parts: image-only blocks keep non-empty normalizedContent so
  ConduitMarkdownWidget no longer drops standalone/base64 images
- compose(): recompute mutableBlockStartIndex after tool_calls details merge
  across the frozen/tail boundary so the streaming tail keeps its fade; align the
  segment filter with isEmpty so blocks-only segments survive
- bottom anchor: release the sticky latch on the final correction attempt so the
  scroll-to-bottom button isn't suppressed when a correction never lands
- chat_page: suppress mount fade for a completed assistant migrating into history

Simplification (behavior-preserving):
- drop redundant streaming-preservation guards duplicated in the stale-echo check
- remove dead carried state: ChatTurnFooterHost.sourceIndex/phase,
  MarkdownDisplayPart.kind/sourceBlockIndex/sourceBlockId, historyIndexByMessageId,
  completedFooterHost, and the pass-through bottom-anchor wrapper

Tests: add coverage for the failed/responseDone/archived tail phases, the
stale-echo completion fields + server-advance/non-tail preservation branches,
the bottom-anchor state transitions + latch release, the image-block render path,
display-part id uniqueness, and the tool_calls compose realignment + frozen tail.

* Address review round 2 on chat streaming timeline

Simplification:
- extract the shared assistant-row wiring used by the history and live-tail
  slivers into _buildAssistantMessageRowContent, removing ~35 lines of duplication
- gate animateOnMount on the settled (completed/failed) phase instead of
  responseDone alone, applied uniformly to both slivers — suppresses the mount
  fade for a completed tail on first load and on tail->history migration even
  when responseDone was never written, and removes the prior asymmetry
- drop the redundant tailAssistantSourceIndex null check when building footerHost
- collapse the unreachable non-ChatMessage fallbacks in _responseCompleted /
  _uiTreatsAsStreaming to the shared phase rule

Tests: add coverage for the scroll-to-bottom hysteresis (currentlyShowing:true)
and the non-scrollable re-anchor path; multi-row historyIndexByMessageKey; the
no-mutable-metadata streaming display-part fallback; details-block display-part
documents without a root node; compose() single-segment and empty-segment
branches; co-firing streaming-state/content/modelName preservation; the
StreamingTurnFooter running-haptic (fire-once, re-arm, suppression); and the
errored-streaming action-footer phase at the widget level.

* Address review round 3 on chat streaming timeline

Correctness / consistency:
- _activeStreamingAssistantId uses the chatTurnPhaseForMessage running phase so
  the scroll-keepalive agrees with the footer/pin logic across the responseDone gap
- _correctStickyBottomAnchor clears the sticky latch when a correction is
  abandoned (widget gone / scroll not yet attached), instead of leaving it
  latched and wrongly suppressing the scroll-to-bottom button; generation /
  user-interaction aborts still bail without touching the latch

Simplification:
- ChatTurnFooterHost stores just the messageId (the only field ever read)
- extract the duplicated sticky-latch predicate into _stickyLatchHolds
- document the details-block id-rebasing invariant in _rebaseCompiledMarkdownBlock

Tests: cover the sources/codeExecutions stale-echo retirement branches, the
empty-message-list timeline, the completed-footer predicate across all phases,
the empty-document and details-group display-part branches, and the streaming
display-part cache reuse/eviction path across a shrinking part list.

* Address review round 4 on chat streaming timeline

Correctness / simplification:
- _correctStickyBottomAnchor checks correction-generation ownership before the
  abandoned-correction latch clear, so a stale generation can't drop a newer
  correction's sticky latch
- inline the unconditional footerSwitchDuration (Duration.zero) at its two
  AnimatedSwitcher call sites

Tests: cover the trimLastBlockBottomPadding=false branch (trailing-block spacing)
and its base-render cache invalidation; the StreamingTurnFooter hidden/empty
state and the haptic re-arm on hide-then-reshow of the same id; the
verifyStickyCorrection nearBottom-over-isFinalAttempt precedence; sticky-latch
suppression while the user interacts; and display-part id dedup for blocks that
share a block id.

* Address review round 5 on chat streaming timeline

All round-5 findings were test-coverage gaps (no code defects found).

- add a @visibleForTesting debugShouldCleanupStreamingFromServer accessor and a
  seam test pinning the stale-echo guard vs. real (responseDone/error) completions
  in _shouldCleanupStreamingFromServer independently of the merge path
- cover the boundToTail (socket-resumed foreign server id) streaming-state
  preservation path
- cover shouldShowScrollToBottom with the sticky latch held AND currentlyShowing
- verify the isMutableTail -> ConduitMarkdownWidget.enableStreamingTextFade gate
  end-to-end (only the mutable tail part fades)

Not done: extracting the _correctStickyBottomAnchor generation counter into the
controller purely to unit-test it. The reviewer confirmed no bug exists there and
the generation-ownership ordering is already covered by the round-4 guard fix; a
test-only production refactor was judged not worthwhile.

* Lock chatTurnPhaseForMessage null / non-assistant branch

Final convergence pass: rounds 5 and 6 surfaced no code defects, only
test-coverage observations. Add a direct test for the one remaining trivial
untested public-function branch (null / non-assistant -> ChatTurnPhase.none).

* Fix bottom-anchor latch detach + cross-conversation leak (review)

Two Medium findings from Macroscope on the bottom-anchor logic:

- updateAnchor() no longer force-detaches mid-drag: while a sticky content
  change is pending it keeps the view anchored regardless of
  isUserInteractingWithScroll, so only shouldDetachForUserScrollAway (which
  honors userScrollAwayThreshold) breaks the latch. Previously a sub-threshold
  accidental drag during streaming dropped bottom anchoring.
- _handleConversationChanged() clears the sticky latch on conversation switch so
  a new conversation doesn't inherit a stale anchored state. The clear lives
  here rather than in _cancelPendingStickyBottomCorrection() because that method
  is also called on drag-start, where the latch must survive to gate the
  scroll-away threshold.

Rewrote the interaction controller test to lock the corrected small-drag-keeps-
anchor / threshold-drag-detaches behavior.

* Fix responseDone/failed phase staleness (CodeRabbit review)

- Include responseDone in the chat message structure signature so the list
  shell rebuilds on the responseDone transition (running footer host /
  pin-to-top would otherwise stay stale through the responseDone gap until the
  transport isStreaming flag cleared).
- AssistantMessageWidget: re-run the action-row settle when message.error
  changes, and settle on the failed phase, so a turn that fails in place while
  isStreaming is still true reveals the action row instead of staying stuck.
  Added a mid-stream-fail regression widget test.
- CompiledMarkdownDocument.isMutableRootBlock bounds-checks the index against
  blocks.length so an out-of-range query can't be misclassified as mutable.

Not changed: CodeRabbit's suggestion to clear the latch in
_cancelPendingStickyBottomCorrection (would break the drag-start threshold; the
conversation-change leak is already handled in 2d4a08a).

* Smooth streaming bottom-follow + fix long-response scroll jump

Two streaming/scroll UX issues (root-caused with adversarial verification):

Bug 1 — streamed text stepped UP from the bottom instead of fading in place:
bottom-stickiness was a per-chunk instant jumpTo (_scrollToBottom(smooth:false)),
so each token batch yanked the viewport up a frame after the taller content was
painted, overpowering the in-place suffix fade. _handleLiveTurnSizeChange now
requests a delta-aware smooth follow: for small per-chunk growth (<= 48px from
bottom) it glides to the bottom over 140ms and settles the sticky latch directly
(verifyStickyCorrection(nearBottom:true)) without the synchronous-distance read
or per-frame recursion the jump path uses; larger growth and all non-live
corrections (initial settle, extent-invalidation) still jumpTo. The latch/verify
logic and the _isUserInteractingWithScroll guard are otherwise untouched.

Bug 2 — scrolling up through a long response jumped past the prompt to the first
response: _estimateChatMessageExtent hard-clamped to 2400px, far below a real
multi-thousand-pixel response, so a never-yet-measured long history row (e.g. one
that migrated out of the live-tail sliver) produced a large SuperSliverList
scroll-offset correction on first reveal. Raised the ceiling to 20000, added
code-block/image structure terms, and excluded base64 data-uri payloads from the
line estimate so a generated image can't over-estimate. Added estimate tests.

Note: Bug 1 is animation behavior (not unit-testable); verify in-app with a fast
streaming response that text fades in place, the scroll-to-bottom button never
flashes mid-stream, and stickiness still converges.

* Estimate rendered height for raw standalone base64 image lines (review)

Macroscope: the row-extent estimate stripped data-uri payloads from the line
count but only added a per-image term for markdown ![] images, so a raw
standalone base64 image line (the common generated-image form, converted to an
image at display time with no markdown wrapper) under-estimated to ~one line and
re-introduced the scroll jump. Now count each rendered image once: markdown ![]
images plus raw standalone data-uri lines (a data-uri inside a markdown image is
not at line start, so it isn't double-counted). Added a regression test.

* Use idiomatic string generation in estimate tests (review)

CodeRabbit flagged 'A' * 20000. It compiled/ran via a transitively-imported
String operator* extension, but that's a fragile dependency; switch to
List.filled(20000, 'A').join() for an unambiguous, self-contained payload.

* Exclude fenced-code-block markup from the image extent heuristic (review)

Macroscope: image/data-uri markup inside a fenced code block renders verbatim
(as text), so counting it as a rendered image (+220px) and stripping its data-uri
payload from the line count corrupted the estimate — a code sample displaying a
base64 blob would under-estimate and re-introduce the scroll jump. Now separate
fenced code blocks: count their content in full for line height and apply the
image-term / data-uri-strip logic only to prose outside them. Added a test.

* Harden #540 fix: clear stale content on scope change + unique scopeless PageStorage keys (review)

Two Macroscope findings on the #540 fix area:

- Cross-message/version stale content: with 'always render a non-null doc', a
  StreamingMarkdownWidget reused across a stateScopeId change (a version/message
  switch) briefly showed the previous scope's content while the new body
  compiled. Fix without re-introducing the #540 flash: thread a clearStaleDocument
  signal so a scope change clears the stale doc (skeleton covers the gap), while
  same-scope content growth keeps its document. Added a regression test (fails
  before: stale 'First version' lingers on scope switch).
- PageStorage key collision: _stateScopeIdForPart fell back to the raw partId
  when no stateScopeId was given, and block ids repeat across documents, so two
  scope-less StreamingMarkdownWidgets on a route cross-restored details expansion
  state. Use a unique per-instance scopeless fallback prefix.

* Retire stale streaming echo once server moves past the turn (#537 review)

Greptile: _shouldCleanupStreamingFromServer treated an empty non-streaming
server echo of the in-flight assistant as 'keep streaming' even when the server
snapshot also carried newer messages after it. The stale-echo early return ran
before the serverMessages.length > state.length cleanup check, so the old
assistant stayed marked as the active stream after the server had already moved
past that turn — leaving the streaming footer/task state pinned to a
no-longer-tail message.

Gate the stale-echo return on the server NOT having additional messages, the
same serverHasAdditionalMessages guard the sibling
_shouldPreserveLocalAssistantStreamingState already uses, so the cleanup and
preserve paths agree. Adds a regression case to the existing cleanup test.

* Finalize the stream when a metadata-only server snapshot arrives (#537 review)

_isStaleStreamingAssistantEcho classified any content-empty server
snapshot as a stale streaming echo, ignoring statusHistory, versions, and
usage. A final metadata-only snapshot (e.g. a closing statusHistory entry
with empty content) was therefore misclassified as stale, which forced
isStreaming back to true via _preserveFreshLocalAssistantState and kept
_shouldCleanupStreamingFromServer from finalizing — leaving the typing
indicator stuck until a later refresh.

Require statusHistory/versions/usage to also be empty before treating a
snapshot as a stale echo, and extend the parameterized completion-field
seam test with statusHistory, versions, and usage cases.

* Keep the stream alive for metadata-only status echoes (#537 review)

Reverts the statusHistory/versions/usage check added in de7da9b1. Those
fields are populated on the assistant message *during* streaming — the
server pushes status/usage updates as content-empty, non-streaming
snapshots before the answer tokens arrive (streaming_helper) — so treating
their presence as a completed update retired the active stream prematurely
and dropped the typing footer mid-turn (Greptile P1).

Real completion is already proven by responseDone/error or by non-empty
content/output/files/embeds/followUps/sources/codeExecutions, so a finished
turn is never a metadata-only echo. Document why those fields are excluded
and replace the completion-field test cases with a regression test that a
content-empty, in-progress status-only echo keeps the stream alive.

* Preserve resume-bound remote id through stale server adopts (#537 review)

Dropping transport before preserve cleared `_boundRemoteMessageId`, so a foreign-id stale echo could retire the streaming tail early. Also skip DB-watch adopts while resume streaming is active.

* Cancel the message stream before dropping transport on adopt cleanup

CodeRabbit: `_dropStreamingTransportState` nulls `_messageStream` without canceling the controller, so cancel must run first while it is still attached.

* Address post-rebase review findings on streaming timeline polish

Fix blank details shrink, sticky detach on scroll-toward-bottom, queued typing footer, pin-to-top stuck after drag, and foreign-id stream cleanup.

* Add staggered follow-up animation and fix pin-to-top scroll jump

* fix(chat): preserve streaming anchor on drag start

Non-update scroll notifications were mapped to a synthetic threshold-sized delta after the rebase, bypassing the small-drag guard. Pass no delta until a real ScrollUpdate arrives and cover that boundary.

Also remove the stale-scope test duplicated from main so the shared markdown cache no longer makes the full suite fail.

* fix(chat): detach streaming anchor for pointer scrolls

Wheel and trackpad updates carry no drag details, so the touch-only gate skipped their delta after the directional notification. Treat null-detail updates as user-driven only while a user interaction is active, preserving programmatic scroll corrections.

Add classifier coverage for touch, pointer, and programmatic paths so this input-path regression cannot recur unnoticed.

* fix(chat): cancel retired streaming controllers

Server adoption could release a tracked StreamingResponseController without cancelling its subscription, allowing late chunks or terminal callbacks to run against an adopted snapshot. Make the shared stale-transport teardown cancel the controller before dropping its reference.

Add provider coverage that adopts a replacement snapshot and proves the retired controller is inactive and ignores a late chunk.

* test(chat): cover streaming cleanup cancellation

Exercise server adoption with a live streaming tail and a real tracked controller so the needsCleanup path is guarded directly. Assert responseDone settles the message, cancels the controller, and suppresses late chunks.

* fix(chat): remove post-response settle motion

Completion reused two independently added motion paths: follow-up suggestions ran a staggered entrance while every measured live-tail resize requested the streaming smooth bottom-follow. That chained a settle animation with an animated scroll after the response.

Render follow-ups immediately and allow smooth bottom correction only while the turn phase is running. responseDone and settled follow-up layout changes now preserve the bottom anchor instantly, with regressions covering both decisions.

* fix(chat): polish motion and reduced motion

* fix(chat): address motion review feedback

* fix(chat): keep archived versions settled
2026-07-10 21:38:55 +05:30
cogwheel
fdf89a8149
Fix streaming body strobing between content and loading skeleton (#540) (#541)
* Fix streaming body strobing between content and loading skeleton (#540)

Photosensitivity/seizure hazard: during a response the assistant body flashed at
~13Hz between the rendered markdown and the MarkdownLoadingSkeleton.

Root cause: StreamingMarkdownWidget let the loading skeleton (and a second
SizedBox.shrink blank path) REPLACE already-rendered content. When the turn phase
latches to completed mid-response (responseDone gap / status+tool phases), the
body is treated as non-streaming while answer tokens keep arriving; each
non-streaming content growth left the compiled document stale-but-valid during
the async recompile, and the gate regressed to the skeleton instead of keeping
the rendered content.

Make the skeleton/blank strictly a first-paint state: once a compiled document
exists it is always rendered (a stale doc lags at most one frame and is
superseded by _applyCompiledDocumentState), so the skeleton can only appear when
there is no renderable content yet. Removed the now-dead
_preserveStaleCompiledDocumentUntilFreshFinal field.

Adds a deterministic regression test (delayed compile service) reproducing the
non-streaming content-growth stale window; fails before the fix (skeleton over
rendered content), passes after.

* Clear stale content on scope change so a version switch can't show the old body (#541 review)

Macroscope + Greptile flagged that the #540 fix ('always render a non-null doc')
lets a reused StreamingMarkdownWidget show the previous message/version's content
while the new body compiles asynchronously. Fix without re-introducing the #540
strobe: thread a clearStaleDocument signal so a stateScopeId change (a new
message/version) clears the stale document — the skeleton covers the gap — while
same-scope content growth keeps its document. Added a regression test (fails
before: the previous version's text lingers on a scope switch).

* Don't make a stale streaming document selectable (#541 review)

Macroscope: the #540 fix removed the blank path that previously prevented a
stale (non-streaming, not-yet-fresh) document from being wrapped in SelectionArea.
During the responseDone gap the body is non-streaming while content still grows,
so a stale doc was made selectable — re-enabling the concurrent-modification
crash in Flutter's selection system. Gate SelectionArea on hasFreshCompiledDocument
so a stale doc renders (no #540 flash) but isn't selectable until the compile
settles. Extended the regression test to assert it.

* Clear stale streaming document on scope change (#541 review)

Greptile: the scope-change clear only ran on the non-streaming path. A scope
change while streaming (e.g. switching from an old version back to the live,
still-streaming current) kept the previous scope's compiled document visible
until the deferred refresh landed, flashing a prior message under the new scope.

Extend the existing clearDocumentWhenAsync mechanism to resolveStreamingPrepared
(same async-only clear, so cached/sync compiles never flash), thread it through
_resolveCompiledDocument's streaming branch, and carry the scope change into the
deferred streaming refresh via _pendingClearStaleDocument so only the first
post-scope-change refresh clears (same-scope growth still keeps its doc, #540).
Adds a regression test for the deferred streaming scope-change path.

(cherry picked from commit 11d75779fcab4c0fb3e338f8d4d4b96c7d950448)

* Clear the stale streaming document immediately on scope change (#541 review)

CodeRabbit + Greptile: the deferred streaming scope-change path only armed a
pending clear (_pendingClearStaleDocument), consumed later by the scheduled
refresh. build() now keeps rendering any existing compiled document while
streaming (#540), so the previous scope painted for one frame under the new
stateScopeId before the deferred refresh landed — exactly the flash this guards.

Clear synchronously in didUpdateWidget instead: add
MarkdownDocumentController.clearDocument() (cancels in-flight resolves, nulls the
document, notifies) and call it on the deferred-path scope change; the scheduled
refresh then compiles the new content. Removes the _pendingClearStaleDocument
flag machinery entirely. The sync streaming and non-streaming paths already clear
synchronously via clearDocumentWhenAsync, so all scope-change clears now happen
within didUpdateWidget — no first-frame stale paint on any path.

clearDocument() always invalidates pending async resolves first (even when no
document is rendered yet) so a stale compile started under the old scope can't
land after the clear. Test asserts the FIRST post-switch frame is already clear,
that the new body stays absent until the compile is released (proving a genuine
async gap), and pins the harness key so the switch exercises didUpdateWidget
reuse rather than a remount. Verified: the first-frame assertion fails with the
deferred behaviour, passes with the immediate clear.

(cherry picked from commit 2798fe542d9477fe1fc3a4f518a2aac549282390)
2026-06-30 21:06:30 +05:30
cogwheel
efc893d584
Add native voice pipeline and voice UI polish 2026-06-24 18:53:06 +05:30
cogwheel
2468f7143e
Open WebUI streaming parity: modelName, optimistic state, fade caching, socket resume (#523)
Brings the chat streaming experience closer to upstream Open WebUI.

- Model name: extract/display Open WebUI modelName (camel+snake), thread through the durable payload, preserve across streaming-error handling and server-snapshot adoption (incl. empty placeholders and empty-server-value edges).
- Optimistic state: preserve locally-streamed assistant content/follow-ups when adopting a server snapshot, scoped to the streaming tail; metadata-union keeps local-only fields; consolidated streaming-error teardown (fail-by-message-id).
- Fade caching: cache the opacity-1 span tree per content change and fade only the suffix via AnimatedBuilder (no per-frame markdown rebuild); widget spans only fade when fully past the boundary; surrogate/LaTeX/code offset alignment preserved.
- Sidebar indicator: socket-driven active-chat set, optimistic setActive on task_ids with last-task-aware + token-guarded setInactive (incl. delayed done-recovery path), clear on logout.
- Socket resume: reopening an in-flight chat streams token-by-token over the shared Socket.IO events envelope (REPLACE/APPEND, foreign message_id binding, nested-id extraction, chat-scope guard), with the 1s task poll demoted to a fallback that resolves the server message by the bound foreign id.

Verification: build_runner + flutter analyze clean; affected suites green (new coverage for fade caching, active-chats sync, resume binding/finalize races, metadata preservation). Reviewed via Greptile (iterated) and CodeRabbit; Macroscope correctness check passing.
2026-06-21 10:12:54 +05:30
cogwheel
a24f993e66
Offline-first persistence with Drift (#508)
* feat(persistence): CDT-RFC-001 Phase 0 — drift foundations, blob mapper, golden fixtures, fake server harness

- Add drift 2.34 / drift_flutter 0.3 / drift_dev; scaffold AppDatabase with
  per-server open seam (D-08) and the sync_meta table (§6)
- ChatBlobMapper (pure Dart) with the §6.1 round-trip invariant: payload/
  rawExtra preservation, presence sentinels, unmappable-entry passthrough,
  deriveChildrenIds, treeIsConsistent
- 12 golden chat-blob fixtures authored verbatim from openwebui-src shapes
  (branched trees, files, web-search sources, code execution, usage, embeds,
  error/annotation/arena/merged, legacy no-history, timestamp ties)
- FakeOpenWebUiServer test harness mirroring vendored list/create/get/update/
  delete semantics incl. the update route's shallow top-level merge and
  output→content re-derivation (serialize_output port)
- 137 new tests; full suite 1577 passing; analyze clean for all new files
- RFC amended to rev 3: §3.iii is a shallow top-level merge, not blob replace;
  pushes must always send the complete reconstructed blob

* feat(persistence): CDT-RFC-001 Phase 1 — read path, DB as source of truth

Schema/DAOs/manager:
- chats/messages/folders/outbox tables (schema v2) with hand-rolled DESC and
  partial indexes (§10) and FK cascade; ChatsDao/MessagesDao/FoldersDao/
  SyncMetaDao; per-server DatabaseManager + scoped provider (D-04)
- conversation_assembler: MessageRowData.payload -> ChatMessage / Conversation

Sync engine (pull only this phase):
- chat_locks per-chat async mutex; pull_sync watermark delta (§7.1: 5s overlap,
  pool of 4, one transaction per chat merge, watermark advances only on full
  success); SyncApiClient seam over api_service / FakeOpenWebUiServer
- sync_triggers (§7.6): once-only start gate, auth/connectivity edges,
  foreground + 5-min periodic timer with offline skip, single debounced funnel
- sync_engine orchestrator; §9.3 legacy Hive purge gated on first fully-
  successful pull, idempotent via hive_cache_purged flag

Provider inversion (read path):
- conversation list renders from ChatsDao.watchChatList() (no message bodies);
  chat page from MessagesDao.watchForChat(); streaming overlay untouched
- D-07 stream-completion + app-pause echo into DB under the chat lock
- all Hive local_conversations/local_folders reads removed

Tests: DAO/manager/pull/triggers suites; offline cold-start acceptance;
new D-07 notifier-level persistence test; sync_triggers timing fixes
(fakeAsync timer flush + redirect materialization). Full suite 1670 green,
analyze clean.

* feat(persistence): CDT-RFC-001 Phase 2 foundation — outbox, ID remap, push, migrator (dormant)

Data-layer write path, built and tested in isolation; not yet wired into
production (task_queue.dart remains the live path until the cutover commit).

- OutboxDao: transactional enqueue (no inner txn), per-chat-FIFO claim
  respecting nextAttemptAt + parked predecessors, coalescing collapse
  (updateChat→newest, create+updates→create, delete annihilates), N=5
  requestCompletion parking, stranded-inFlight recovery (§7.2)
- OutboxDrainer: pool-of-2, seq order per chat, backoff 2s→5min full jitter
  (injectable clock+jitter), offline-deferred reschedule (no attempt burn)
- IdRemapper: single-tx local:<uuid>→serverId rewrite of chats/messages/
  outbox; content-hash recorded on createChat op (§7.3)
- pull_sync crash-heal: pendingCreateForHash matches a pulled chat to its
  pending createChat op and remaps instead of duplicating
- push_sync: createChat/updateChat/deleteChat reconstruct the FULL blob via
  rowsToBlob (§3.iii), serverUpdatedAt/dirty-clear rule; SyncApiClient
  extended (fake server mirrors shallow-merge + toggle semantics)
- ChatsDao *WithOutbox mutation methods (row + op in one tx, caller holds lock)
- OutboxTaskQueueMigrator: idempotent, flag-gated, content-hash dedupe,
  abort-without-flag on failure (§9.2)

Full suite 1767 green, analyze clean. Cutover (drainer/migrator invocation,
send-path + mutation + remapEvents wiring, task_queue retirement) follows.

* feat(persistence): CDT-RFC-001 Phase 2 cutover — outbox is the live write path

Activates the dormant outbox in production and retires the legacy Hive task
queue. Phase 2 acceptance (§11) now met.

Activation:
- ChatRequestCompletionRunner (features/chat) implements the core
  RequestCompletionRunner seam, re-entering the EXISTING streaming pipeline
  (runQueuedCompletion) — no second streaming impl; guards: defer when a live
  stream owns the chat, no-op when the turn already completed; shared
  assistantMessageId guarantees one assistant row per turn (R8)
- Provider layer: idRemapper/pushSync/outboxDrainer/requestCompletionRunner +
  migrator, all reusing the engine's chatLocks/folderLocks/clock/remapper
- SyncEngine._runOnce: after pull, run migrateIfNeeded() once then drain();
  single shared OutboxDrainer instance (drain + drainNow) — fixes a
  concurrent-instance resetInFlightToPending double-send (review high finding)
- drainNow() on connectivity-regained; remapRouteSyncProvider swaps the open
  chat/folder id in place on remap (no nav, no stale-route window)

Site swaps (15) + retirement:
- send/retry/folder-mutation/peripheral sites → outbox *WithOutbox methods
  (row + op in one tx under ChatLocks); optimistic UI preserved
- media uploads folded into media_upload_controller.dart (not an outbox op)
- deleted task_queue.dart / task_worker.dart / outbound_task.dart

Tradeoff (documented for Phase 3): queued completions use Option A — a drained
background completion makes that chat active. Option B (headless UI-free
streaming sink) deferred to Phase 3.

§11 acceptance tests: offline-compose→force-quit→reconnect→create+send+remap;
queued-Hive-task migration→drain; crash-between-create-and-remap heals without
duplicate. Full suite 1776 green, analyze clean.

* feat(persistence): CDT-RFC-001 Phase 3 — three-way merge, deletion reconcile, folder LWW

Conflict merge (§7.4) — replaces the Phase-2 fast-forward-only stub:
- chat_merger.dart pure three-way: union messages by id (local-dirty wins,
  remote-deleted clean drops, dirty kept), childrenIds always derived from
  parentId, currentId local-if-dirty, envelope LWW, rawExtra server-wholesale
- idempotent (B unchanged on three-way; re-pull hits noRemoteChange); never
  drops a dirty message; ancestor-reachability re-parents a surviving dirty
  descendant when a clean ancestor is remotely deleted (no dangling parentId)
- mergeServerChat treats a never-synced stub (serverUpdatedAt==null) as a
  plain server write, not a body-dropping three-way

Deletion reconcile (§7.5):
- full-ID enumeration diffed against local server-keyed chats; purge ONLY on a
  confirmed-gone probe; 24h throttle (background) + manual pull-to-refresh
- safety valve aborts if >50% of local chats are candidates (token-expiry
  guard); a watermark-0 pull records last_full_reconcile_at (it already fully
  enumerated) so the first cycle does no redundant re-enumeration
- session-liveness re-check before the first purge: the vendored GET returns
  401 for genuine deletes AND for an expired token, so a one-shot authed ping
  distinguishes them and aborts without purging if the session is dead
- wired into SyncEngine._runOnce (background) + reconcileNow() (manual)

Folders (§7.6): LWW pull, tombstones, folderUpsert orders before dependent
chat ops; folder delete uses delete_contents=false.

Review findings (fix phase died on infra) applied inline: orphan re-parenting,
session-liveness guard, reconcile wiring + regression tests. Full suite 1823
green, analyze clean.

* feat(persistence): CDT-RFC-001 Phase 4 — FTS5 offline search, lazy list, perf budgets

FTS5 search:
- standalone chat_fts vtable (unicode61, remove_diacritics) over message
  content + chat titles, created via schema migration; triggers keep it
  consistent across every message/title write path (insert/update/delete) and
  an explicit chats-AFTER-DELETE trigger purges on FK-cascade (recursive
  triggers off) and tombstone-filtered out of results
- populated AFTER first full sync (unawaited, idempotent, flag-gated) so it
  never blocks first interactive render; onUpgrade backfills existing installs
- SearchDao: bm25-ranked, grouped one row per chat with snippet; fts_query
  sanitizer turns arbitrary user text into a safe parameterized MATCH (quotes,
  operators, empty/unicode/injection all neutralized — no crash)
- offline search wired into the existing serverSearchProvider: ranked FTS when
  offline/reviewer, FTS fallback when server search unavailable
- id_remapper now repoints message FTS rows on local:->serverId remap (was a
  no-op TODO that dropped the index for offline-composed chats — review high)

Lazy list + perf:
- paged conversation-list reads from the DB (no load-all), Conversations
  provider API stable
- §10 budgets measured on a 1000-chat file-backed fixture: cold-start→list
  21-104ms (≤400), append-to-500-msg ~0ms steady (≤10), one list emission per
  merge tx (zero jank), FTS population off the render path

fts-build db-closed teardown race logged at debug (expected on server switch),
not error. Full suite 1883 green, analyze clean.

* feat(persistence): CDT-RFC-001 Phase 5 — notes as a synced entity

Notes sync through the SAME infra as chats (outbox/drainer/id-remap/FTS/locks),
extracted behind a SyncEntityAdapter seam now that two real adapters exist.

- notes table + NotesDao (field-LWW upsert, *WithOutbox row+op in one tx,
  tombstone); note_mapper with the §6.1 round-trip invariant (access_grants +
  unknown keys preserved verbatim via rawExtra)
- note_sync: nanosecond notes_pull_watermark in its OWN sync_meta key, never
  compared to the chat (seconds) watermark — distinct overlap constants (D-11,
  R-09); list-ordered-updated_at-DESC pull mirroring PullSync
- note_conflict (pure): field-level LWW resolving title and data independently;
  a concurrent data edit spawns a conflict copy (local data preserved, never
  silently dropped) with no infinite-copy chain (canonical goes clean + base
  advances)
- OutboxKind noteCreate/noteUpdate/noteDelete/notePin with patch-map coalescing;
  pushNoteUpdate always carries title (router requires it); pin via the
  dedicated toggle endpoint reconciled against server state
- IdRemapper entityKind='note'; FTS extended over note title+text (kind
  discriminator partitions note rows from chat rows in the shared vtable)
- offline notes UI (list + editor) wired into navigation; search returns note
  hits alongside chat hits

SyncEntityAdapter seam (chat + note adapters); SyncEngine drains both kinds and
pulls both with separate watermarks.

Stabilized after the build agents died mid-run (sustained API outage): fixed
test API drift (drainer adapters param, RecordingSyncApiClient note methods),
and wrote the RFC-mandated acceptance tests the dead agents never reached —
R-09 watermark isolation, D-11 conflict-copy/field-LWW, §6.1 round-trip. Full
suite 1899 green, analyze clean.

Known follow-ups (low): notes have no deletion-reconcile yet (server-side note
deletes not purged locally); notes_dao/note_sync integration unit tests pending
(core conflict resolver + wiring covered).

* feat(persistence): CDT-RFC-001 follow-ups — non-disruptive completions, note reconcile, note sync tests

(1) Option B — non-disruptive queued completions:
- request_completion_runner now DEFERS (throws CompletionBusyException → op
  stays pending) when a DIFFERENT chat is foregrounded, so a drained background
  completion never yanks the user to another chat. It drives live (Option A's
  proven pipeline) only when its own chat is active or none is.
- SyncEngine.drainOutbox() (plain drain, no backoff reset) + a SyncTriggers
  listener on activeConversationProvider so a deferred completion runs the
  moment the user opens its chat.
- (Chosen over a from-scratch headless streaming consumer, which would
  re-implement ~370 lines of subtle stream-accumulation logic that can't be
  validated without a live server — strictly riskier than this for an edge-case
  UX win. A true background-streaming sink remains an optional follow-up.)

(2) Note deletion-reconcile (§7.5 for notes):
- NoteDeletionReconcile mirrors the chat reconcile over the note list/probe
  endpoints: purge only on a confirmed-gone probe, 50% safety valve, session-
  liveness guard, own throttle key (notes_last_full_reconcile_at), note lock
  domain; wired into SyncEngine background + reconcileNow.

(3) Note sync/reconcile integration tests through the real DB:
- note_deletion_reconcile_test (8): gone-purge, transient-skip, safety valve,
  dead-session abort, feature-disabled, throttle
- note_sync_test (3): nanosecond-watermark pull (+ chat watermark untouched),
  the DB-level conflict copy (concurrent data edit → two surviving notes, none
  lost), updateNoteWithOutbox row+op in one tx
- runner tests extended for the Option B deferral policy

Full suite 1911 green, analyze clean.

* feat(persistence): CDT-RFC-001 Option B — headless background completions (live-validated)

Live validation against the env.local server proved the load-bearing fact:
Open WebUI persists the assistant message SERVER-SIDE during a completion
(server's utils/middleware.py upsert_message_to_chat...; the outlet handler
'replaces the POST /api/chat/completed round-trip'). A probe that fired a real
completion and DISCARDED every stream chunk still found the full reply
persisted on the chat. Also confirmed: note updated_at is 19-digit NANOSECONDS
and chat updated_at is 10-digit SECONDS (D-11/R-09 holds on the real server),
and the persisted reply lands in history.messages in the shape the pull-merge
consumes.

So the headless consumer needs NO re-implementation of stream accumulation —
the pattern is fire -> drain -> pull:
- runHeadlessCompletion (chat_providers): builds the request from the target
  chat's DB rows (not the globally-active chatMessagesProvider), fires
  sendMessageSession, drains the byte stream to EOF so the server runs to
  completion, then pullChatNow merges the server-persisted reply into the local
  DB (Phase 3 merge) — bounded poll for the socket/task flow.
- request_completion_runner now drives LIVE (Option A) only when the target
  chat is the one the user is viewing, else runs HEADLESS — never switching the
  user's active conversation. Replaces the prior defer-only policy.

Full suite 1911 green, analyze clean.

* refactor(persistence): review + simplify pass over the offline-persistence branch

Multi-round adversarial review/simplify loop (findings verified before applying;
sync invariants preserved). Full suite 1911 green, analyze clean.

Correctness:
- note_deletion_reconcile: the session-liveness probe now actually detects a
  dead session — getNoteListRaw swallows 401/403 and signals via
  featureEnabled=false, so a thrown error OR (_, false) both abort with no purge
  (the prior try/catch could not catch the swallowed auth failure)
- runHeadlessCompletion: both transport flows now poll pullChatNow with backoff
  until the reply lands (the server persists asynchronously —
  ENABLE_REALTIME_CHAT_SAVE defaults False; live-validated that attempt 0 still
  catches the common case, retries cover the async-persist edge)

Simplification / dedup / dead-code (behavior-preserving):
- chats_dao: _writeServerRows+_writeMergedRows → one _writeChatRows; shared
  _activeChatsListQuery() backs watchChatList+getChatPage; _maxLastReadAt via
  math.max
- id_remapper: chat+note FTS remap share _remapFtsRowsWhere
- chat_providers: runQueuedCompletion/runHeadlessCompletion share request-build
  helpers; _modelUsesReasoning + _lastUserMessageId extracted
- note_sync/note_mapper/folders_dao/notes_dao: shared decodeJsonMap + asNs
- outbox_drainer: dropped 3 unused injected deps; collapsed duplicate branches
- pull_sync/chat_merger: removed dead params; optimized_storage_service: deleted
  the dead legacy-cache migration path
- chats_drawer/media_upload_controller: minor dedup + firstOrNull

Deliberately NOT applied (documented): cross-file timestamp-helper collapse
(load-bearing R-09 ns-vs-seconds semantics), conversation_assembler key-coercion
tolerance, and an upload-concurrency change (not behavior-preserving).

* refactor(sync): remove dead NotePullSync.run() parallel driver; test the live path

Final review round (1 confirmed finding). NotePullSync.run() + pullNote() +
_parseListItem + NotePullResult + _ChangedNote + kNotePullFetchConcurrency were
a ~95-line parallel copy of the generic runPullFor driver, dead in production
(the engine drives note pull EXCLUSIVELY via runPullFor(NoteAdapter), which
delegates only getListPageRaw/fetchRaw/mergeNoteResponse). Deleted the copy and
rewired note_sync_test to drive the real production path (runPullFor over a
NoteAdapter) — so the watermark / conflict-copy / outbox assertions now exercise
the code that actually runs. Kept the live seam (NotePushSync, mergeNoteResponse,
_asNs, fetch/list helpers). Full suite 1911 green, analyze clean.

* fix(sync): address greptile review — reconcile safety-valve floor (P1) + archive race (P2)

Greptile branch review (PR #508) found a real defect my own loop missed.

P1 (deletion_reconcile + note_deletion_reconcile): the safety valve
'candidates > total * 0.5' tripped for small libraries — a user with ONE
server chat/note that was genuinely deleted hit '1 > 0.5 = true', so the valve
aborted on every run (even manualRefresh) and the phantom row never purged.
Added an absolute floor: the fraction valve now trips only when candidates
exceed max(kReconcileMinCandidatesForValve=5, total*0.5). Safe because the
session-liveness guard + per-candidate confirmed-gone probe are the real
token-expiry protection; the fraction valve is only a coarse backstop against
an implausibly-LARGE candidate set. Regression tests added (single deletion now
purges; valve still trips above the floor).

P2 (push_sync archive toggle): gave the archive toggle the same read-before-
toggle race protection the pin path already has — re-read the live archived
state via getChatRaw and flip only on a real delta, so a concurrent client
can't make it blind-toggle archive back to the wrong state.

Full suite 1913 green, analyze clean.

* fix(sync): address greptile re-review round 2 (note-pin probe, archive null-guard, dead push, notes reconcile gate)

Greptile re-review confirmed the prior P1/P2 resolved, then found 4 more:

1. P1 pushNotePin did a blind toggle-FIRST then corrected, leaving a transient
   wrong-state window (and the doc claimed it probed first). Now probes live
   is_pinned via getNoteRaw and toggles only on a real delta — symmetric with
   the chat pin/archive paths. Regression test added.
2. P2 (regression from the prior archive fix): getChatRaw can 404 if the chat
   was deleted in the window after updateChat succeeded; toggling a gone chat
   threw a terminal error and PARKED an op whose updateChat already succeeded.
   Now skips the toggle when the probe returns null (reconcile purges the row).
3. P2 dead code: _ensureDrainer built a discarded PushSync purely for a
   null-guard already subsumed by _buildAdapters(). Removed.
4. P2 efficiency: pre-advance the NOTE reconcile gate after a watermark-0 pull
   (symmetric with the chat gate) so a fresh install skips a redundant
   getNoteListRaw + full-ID diff right after the first full pull.

Full suite 1914 green, analyze clean.

* polish(sync): address greptile round-3 minor observations (4/5 'safe to merge')

All P1/P2 resolved last round; greptile rated this 'safe to merge'. Cleaning up
the remaining minor observations:

- note_adapter.listPageSize → large sentinel (1<<30): notes fetch in a single
  unpaged call, so this stops runPullFor from ever requesting an empty page 2
  for users with ≥60 notes (was one no-op Dart loop iteration, no network).
- note_conflict.resolveNoteMerge: added an assert(serverUpdatedAt >= base) for
  parity with chat_merger, surfacing a server-clock regression in tests instead
  of silently no-op'ing.
- note_deletion_reconcile: documented the deliberate trade-off that a Notes-
  feature-disabled-mid-reconcile reads as session-dead (conservative skip, no
  data loss; chat side can't conflate because getChatListPage throws on auth).
- migrator raw==null: left as-is — the existing comment already documents why
  the flag stays unset (a later SharedPrefs→Hive migration could still land
  tasks); marking it migrated would risk dropping those.

Full suite 1914 green, analyze clean.

* polish(sync): address greptile round-4 style observations (still 4/5 'safe to merge')

All correctness concerns resolved in prior rounds; these are the 3 remaining
style-level observations:

- sync_engine: capture + log the note pull's AdapterPullResult (note-cycle-done
  telemetry) so a stuck note watermark / failed fetch has success-path
  observability, mirroring PullSync.run's chat cycle-done log.
- SyncEntityAdapter: removed the dead 'locks' getter (never read through the
  interface — each adapter's real lock domain lives in its injected PullSync/
  PushSync). Dropped the now-unused _locks field + constructor param from both
  ChatAdapter/NoteAdapter and their construction sites; future adapters no
  longer implement an unused member.
- dedup the pull concurrency constant: kPullFetchConcurrency now DERIVES from
  the canonical kAdapterPullFetchConcurrency (single literal), so chat and note
  pull concurrency can never silently diverge.

Full suite 1914 green, analyze clean.

* fix(sync): address greptile round-5 — migration duplicate-turn (P1) + reconcile try/catch (P2)

P1 (data corruption for upgrading users): OutboxTaskQueueMigrator's existing-
chat branch deduped only on a deterministic v5 message id, which never matches
the server's own ids. _runOnce pulls BEFORE migration, so a legacy 'running'
task whose completion already finished server-side was re-appended as a
DUPLICATE turn + an unwanted second requestCompletion (extra AI generation,
corrupted conversation). Added _latestTurnAlreadyCompleted: if the chat's most-
recent user message matches the task text and already has a non-empty assistant
reply, the turn is done → skip. Targets the latest turn (not any historical
message) to avoid false-skipping legitimately-repeated text. Regression test
added.

P2: split the shared chat+note reconcile try/catch in _runOnce and
reconcileNow so an unexpected chat-reconcile error can't skip the note reconcile
(each entity now has its own try/catch + log scope).

Also fixed a latent log-level bug: the benign FTS db-closed teardown race was
still logging at ERROR because the substring check was 'closed' but the SQLite
message says 'closing'/'re-open'; now matched and downgraded to debug.

Full suite 1915 green, analyze clean.

* polish(sync): address greptile round-6 edge cases (active-branch migration guard, pin 404)

Both non-blocking P2s (greptile: 4/5 'safe to merge'); refining for correctness:

- _latestTurnAlreadyCompleted now follows the ACTIVE branch via currentMessageId
  (tip → parent user message) instead of orderIndex. orderIndex is a DFS over
  the full message DAG, so a regeneration branch could lead it and an off-branch
  message with coincidentally-matching text could false-skip a migration task.
  Walking currentMessageId is the precise active-branch turn. Test updated.
- push pin/archive: ONE getChatRaw liveness fetch now guards BOTH toggles. The
  pin path previously called getChatPinned with no 404 guard (asymmetric with
  the archive path); a chat deleted in the window would throw and cost an extra
  drainer retry cycle. Now a 404 on the shared liveness fetch skips both toggles
  (reconcile purges the row); getChatPinned is still used for pin accuracy but
  only after the chat is confirmed to exist.

Full suite 1915 green, analyze clean.

* fix(sync): address greptile round-7 P1 — migrated turn orphaned in pulled chats

The migrator's existing-chat branch always set the new user message's parentId
to null. For an already-pulled chat (with prior messages), the migrated turn
became a DISCONNECTED root in the conversation DAG: pushing updateChat sent a
blob where the new user message floats as a new root, and OpenWebUI's active-
branch trace (currentId → parent) reached only the orphan, HIDING all prior
history — a permanently malformed conversation.

rowsToBlob emits each message's payload verbatim (no childrenIds derivation),
so the fix threads existing.currentMessageId (the prior active-branch tip) as
the new user message's parentId (column + payload). existing == null (fresh
stub) correctly stays a null root. Regression test asserts the migrated turn
hangs off the prior tip.

Full suite 1916 green, analyze clean.

* harden(sync): address greptile round-8 defensive P2s (4/5 'safe to merge')

Both are defensive guards for conditions that should not arise in normal
operation:

- chat_merger: added a release-mode runtime guard for serverUpdatedAt < base.
  The assert is elided in release, so a clock-skew/server-bug could fall
  through to fast-forward, overwriting local rows with a STALE server snapshot
  AND regressing the merge base below its prior value. Now mirrors
  resolveNoteMerge: leaves local rows untouched (noRemoteChange). The assert
  stays to surface the anomaly in tests.
- chat_adapter.pushOp: requestCompletion (which IS in ownsKind but is dispatched
  by the drainer's RequestCompletionRunner seam BEFORE the adapter loop) now
  THROWS instead of silently no-op'ing, so a future reorder that lets it fall
  through surfaces loudly instead of silently dropping the completion.

Full suite 1916 green, analyze clean.

* fix(sync): preserve folder on envelope stub refresh

* fix(sync): harden migration and folder delete dedupe

* fix(sync): park malformed folder delete ops

* perf(sync): avoid redundant pull work

* fix(sync): dedupe note delete and pin-only pulls

* fix(sync): retry task queue migration failures

* fix(sync): treat missing folder delete as success

* fix(sync): guard corrupted chat merge cycles

* fix(sync): defer stub chat update pushes

* fix(sync): stop retrying missing folder updates

* fix(sync): full-fetch notes and close create remap gap

* fix(sync): harden note merge and fts cleanup

* fix(sync): remove stale note conflict claims

* fix(sync): dedupe pending chat deletes

* fix(sync): close note create remap window

* fix(sync): retry fts build after watermark advances

* test(sync): cover fts build retry failure

* fix(sync): prevent recursive note conflicts

* fix(sync): preserve dirty stubs during pull

* fix(sync): drop local note tombstones

* fix(search): page combined results and filter fts backfill

* fix(sync): drop annihilated chat and folder stubs

* fix(sync): close chat create remap window

* fix(sync): park malformed completion ops

* docs(sync): update create heal lock comment

* fix(sync): address CodeRabbit review findings

* fix(sync): preserve folder parent edits

* fix(sync): use remapped folder outbox id

* fix(sync): clear CodeRabbit folder follow-ups

* fix(sync): skip tombstoned note creates

* fix(sync): merge coalesced folder upserts

* fix(sync): resolve CodeRabbit follow-ups

* fix(sync): crash-heal note creates

* docs(sync): clarify note create lock ordering

* fix(sync): bind cycles to dependency epoch

* fix(search): make fts backfill idempotent

* fix(sync): harden note and fts error handling

* perf(sync): bound outbox and search scans

* fix(sync): defer chat create for local folders

* chore(sync): tidy note search review items

* fix(sync): refresh coalesced note create hash

* fix: address CodeRabbit review findings

* fix(sync): assert coalesced note row invariant

* fix(sync): harden note pull and pin coalescing

* fix(sync): clamp null current message ids

* fix(sync): refire start and abort terminal reconcile

* fix(sync): back off chat folder remap deferral

* fix(sync): fail malformed note list pages

* fix(sync): skip malformed note list slots

* fix(sync): atomically reassert chat merge pushes

* fix(sync): address greptile review feedback

* fix(sync): keep echo turns and fts deletes consistent

* fix(sync): abort chat reconcile on auth probe failure

* Fix CodeRabbit sync review issues

* fix(sync): harden echo replay and note reconcile

* Fix CodeRabbit drain migration issues

* fix(sync): preserve dirty chat titles on stub refresh

* Fix CodeRabbit replay and remap issues

* fix(sync): refresh coalesced chat create hashes

* address greptile review feedback (greploop iteration 1)

* fix(sync): guard deferred pulls and completions

* fix(sync): prevent duplicate headless completions

* fix(sync): address CodeRabbit review feedback

* fix(sync): isolate outbox queue domains

* fix(sync): cleanly cancel pending completions

* test(database): avoid duplicate FTS migration message id

* fix note pull pagination after malformed pages

* fix(chat): preserve image attachment types in durable sends

* fix(sync): tighten outbox follow-up handling

* fix(sync): unblock deletes and bound pull pagination

* fix(sync): guard reconcile pagination

* fix(sync): stop pull pagination on malformed pages

* Sandbox inline web embeds

* fix(sync): harden note merge edge cases

* fix(sync): drop stale note pin ops

* fix(sync): claim create heals before remap

* fix(sync): preserve migrated attachment urls

* fix(sync): preserve note edits across create remap

* fix(sync): resolve stale update and mime edge cases

* fix(sync): purge outbox ops after push deletes

* fix(sync): preserve note conflict metadata

* fix(sync): remap open note routes

* fix(sync): harden note remap edge cases

* fix(sync): handle archive confirmation races

* fix(sync): purge orphaned folder outbox ops

* fix(sync): avoid stale streaming and archive stalls

* fix(sync): mirror chat pin races

* fix(sync): harden upload and legacy migration races

* fix(sync): clarify outbox remap coverage

* Address CodeRabbit review findings

* Implement chat message queuing and handling for offline scenarios, including UI updates for queued completions and localized messages for user feedback. Enhance the `ChatsDao` with methods to manage queued completions and update assistant message links. Refactor `AssistantMessageWidget` to display queued message states and integrate new localization strings for various languages.

* Address Greptile chat merge dedupe finding

* Refactor authentication and database handling: Removed redundant checks in `AuthStateManager`, updated database schema documentation, added error handling in conversation loading, and enhanced message retrieval methods in `MessagesDao`. Improved attachment upload handling with cancellation support and refined sync triggers for better error management.

* refactor: apply reviewed simplifications and edge-case fixes

Round 1 of an automated review+simplify pass over the branch diff.
Verified each change is behavior-preserving (or a confirmed edge-case
fix) and runs clean through `flutter analyze` + the test suite.

Bug/edge-case fixes:
- database_manager: track and await pending db close before deleteFor,
  closing an open/close race
- pull_sync: stop double-counting archived-with-synced-body chats in
  changedChats / cycle-done log
- media_upload_controller: clean up converted temp dir on the
  error/abort path instead of leaking it
- sync_triggers: seed foreground state on cold launch so the periodic
  foreground pull starts when launched already foregrounded

Simplifications / dead-code removal:
- auth_state_manager: drop unused params and duplicate error log
- notes_dao: remove dead `meta` param from updateNoteWithOutbox
- search_dao: drop unused `kind` projection from the hits CTE
- fts_query: make _quoteToken non-nullable, remove unreachable guard
- note_mapper / note_sync: collapse duplicate _decodeMap/_asNs helpers
  onto the shared decodeJsonMap/asNs
- outbox_task_queue_migrator: fetch chat row once instead of twice
- push_sync / sync_api_client: remove always-true guard and unreachable
  404 branch
- backoff: drop redundant .toInt()
- chat_transport_dispatch / request_completion_runner /
  local_conversation_loader: simplify return, fix stale doc comments,
  drop redundant cast/import
- chat_page: fire attachment uploads non-blocking with error logging

* fix(sync): keep remap route consumer live across session rebinds

remapRouteSyncProvider subscribes to SyncEngine.remapEvents exactly once
at startup, but remapEvents returned the current per-session IdRemapper's
stream. Every dependency rebind (db/client/auth/lock/clock/backoff/
completion-runner swap) disposes that remapper and lazily mints a new one
with a fresh stream, leaving the startup subscription bound to the dead
stream — so post-rebind remaps were silently dropped and an open chat
route would never swap its local id to the server id in place.

Back remapEvents with a long-lived broadcast controller owned by the
engine and forward each session's remapper into it, re-establishing the
forward on every rebind. The consumer's single subscription now survives
rebinds. Verified by remap_route_sync_test + sync_engine_test.

* refactor: second review+simplify pass (dead code, parity, doc fixes)

Round 2 over the branch diff. Verified behavior-preserving (or a
confirmed consistency fix) via flutter analyze + full test suite.

Behavior / consistency fixes:
- app_providers: Conversations.updateConversation now requests a
  reconcile pull on a cold/missing chat-list projection instead of
  silently dropping the envelope mutation, mirroring Folders.updateFolder
- database_manager: chain pending db closes (don't drop an in-flight
  close on rapid switches) and await the pending close in closeActive

Dead-code removal / simplification:
- sync_api_client: drop the no-op _withTerminalAuth wrapper; the *Raw
  endpoints already translate 401/403 -> SyncTerminalException, so its
  auth branch was unreachable. createFolder keeps an explicit inline
  translation (its endpoint does not translate)
- chats_dao: merge adjacent identical `if (placeholder != null)` blocks
- folders_dao / note_sync: inline single-use private helpers
- deletion_reconcile: drop always-true operand from safety-valve guard
- request_completion_runner: gate live-only reads behind the active-chat
  check via short-circuit

Doc-comment corrections:
- id_remapper: fix trigger #6 -> #7 FTS citations
- api_service / request_completion_runner_provider / app_providers:
  correct stale references and offline-only comment

* fix: third review+simplify pass (verified findings)

Applies 17 adversarially-verified findings from a multi-agent review of the
branch's changed production files.

Correctness:
- chat_merger: carry server.unmappableMessageOrder through three-way merge so
  unmappable history keeps its original positions on round-trip.
- chat_page: clear context attachments on durable send (were silently re-sent
  next message) and recover the streaming placeholder + log on durableSend
  failure instead of swallowing it.
- folder_page: fire attachment uploads concurrently (unawaited + catchError)
  so one upload failure no longer aborts the remaining attachments.
- user_message_bubble: skip note attachments in _inlineEditAttachmentIds so a
  note id is not re-sent as a bogus file attachment.
- home_widget_service: fire photo uploads concurrently and focus the composer
  once up front rather than after each blocking upload.
- deletion_reconcile: log the underlying error/stackTrace on preflight abort.

Simplification / docs:
- auth_state_manager: drop dead canCommit param from _claimAuthCommit.
- chat_providers: drop dead parentId param from _localEchoRow.
- chat_transport_dispatch: remove unused writeAbortHandleMetadata.
- push_sync: drop redundant trailing return.
- note_deletion_reconcile: drop dead localServerIds.isNotEmpty guard.
- Doc fixes: chats_dao getChatPage, sync_meta_dao watermark, app_database
  trigger count, id_remapper outbox-status comment.

flutter analyze clean; affected sync/chat/dao/widget suites pass.

* fix: fourth review+simplify pass (round 2 verified findings)

Three adversarially-verified findings from re-reviewing the round-1 changes.

Correctness:
- folder_page: recover the streaming placeholder + log on durableSend failure
  in the folder composer send path (parity with chat_page; was a try/finally
  with no catch, leaving the assistant bubble stuck isStreaming forever).
- push_sync: a folder move forces pinned=false server-side, so run the move
  BEFORE the pin reconcile and treat the post-move pin as false — a pinned
  chat moved in the same coalesced update no longer silently loses its pin.
  Adds a regression test (and teaches the fake server to reset pin on move,
  mirroring the vendored update_chat_folder_id_by_id_and_user_id).

Simplification:
- auth_state_manager: drop the dead canCommit/claimCommit/trackAuthAttempt
  plumbing from _loginInternal/_loginWithApiKeyInternal (their only callers are
  the public wrappers, which never pass these), removing ~6 always-true
  branches from security-sensitive code. Silent-login path untouched.

flutter analyze clean; push_sync (incl. new pin+move test) and folder_page
suites pass.

* docs: correct stale _claimAuthCommit comment (round 3)

Round 2 removed the two login() call sites that passed claimCommit: null, so
_claimAuthCommit's only remaining caller is the silent-login commit, where
claimCommit is non-null on the background path. Update the comment to describe
the actual foreground(null)/background(non-null) split instead of the now-false
"null at every call site" claim. Comment-only; no behavior change.

* refactor: drop now-dead clearOnFailure param (round 4)

Cascade from round 2/3: once the login methods stopped passing
clearOnFailure, no caller supplies it, so the guard
`clearOnFailure == null || clearOnFailure()` was always true. Remove the
parameter and collapse the catch to the unconditional token clear it always
performed. Behavior-preserving.

* feat: enhance notes feature with user-specific data and markdown previews

- Updated NoteListEntry to include userId and a bounded markdown preview.
- Modified watchNotes method to filter notes by userId and return a markdown preview.
- Implemented searchNotesByQuery to allow searching notes with user context.
- Added functionality to getNoteForUser to restrict note retrieval to the current user.
- Introduced caching for feature availability based on user and server context.
- Enhanced note context actions to handle markdown previews correctly.
- Updated note editor and list views to utilize new data structure and features.

This commit improves the user experience by ensuring that notes are displayed and managed according to the authenticated user's context, while also optimizing data handling for markdown previews.

* address greptile review feedback (greploop iteration 1)

* address greptile review feedback (greploop iteration 2)

* refactor: enhance chat request completion logic to handle optimistic placeholders

- Updated the logic in ChatRequestCompletionRunner to prevent deferring when the assistant's own optimistic placeholder is active.
- Added checks for the active streaming assistant ID and its relation to the current assistant message ID.
- Enhanced logging to include additional context data for better debugging.
- Introduced a test to verify that the runner does not defer its own optimistic streaming placeholder.

This change improves the responsiveness of the chat feature by ensuring that the assistant can continue processing without unnecessary delays when it is actively streaming its own message.

* address greptile review feedback (greploop iteration 1)

* refactor: apply review + simplification findings (ultracode round 1)

Per-file code review + simplification pass over the offline-persistence/sync
branch. 34 confirmed, behavior-preserving fixes plus targeted bug fixes:

- id_remapper: repoint nested child folders' parent_id on folder remap
  (prevents orphaned subfolders after an id swap)
- share_receiver/chat_page: harden attachment upload loops against a
  throwing file.length() and run uploads non-blocking
- notes_providers: cancel Drift watch subscription on every recompute
  (remove one-shot onDispose guard that leaked the subscription)
- many duplicate-logic, dead-code, and idiom cleanups across daos/sync/mappers

Reverted two auto-applied changes that regressed: a streaming_helper
snapshot-refresh that contradicts existing tests, and a chats_drawer
firstWhereOrNull that pulled in a non-production collection dependency.

Verified: flutter analyze clean, full test suite (2099) green.

* fix: resolve 4 sync/persistence correctness bugs (ultracode round 2)

Adversarially confirmed (with code + openwebui-src evidence) and fixed the
real bugs round 1 flagged but deemed unsafe to auto-apply:

- outbox_task_queue_migrator: new-chat dedup contentHash folded in per-message
  wall-clock timestamps, so a partial-failure re-run on a later second produced
  a different hash, missed dedup, and POSTed a DUPLICATE chat. Blob per-message
  timestamps are now deterministic (decoupled from the clock); row envelope
  timestamps unchanged. createChatContentHash (binding contract) untouched.
- chat_providers: headless stream drain leaked the HTTP byte-stream
  subscription/socket on timeout (Future.timeout does not cancel the drain).
  Now aborts the request (CancelToken) before deferring, in both error branches.
- remap_route_sync_provider: an open /folder/<local-id> route was not remapped
  after a folder id swap, leaving a dead 'unable to load folder' page. Now
  rewrites the open route to the server id, mirroring the note-route branch.
- sync_api_client.updateFolder: a 2xx response with a non-map/null body (server
  returns 200+null on duplicate-name/db-error while the folder still exists) was
  treated as a 404 and PURGED the local folder. Null is now reserved for genuine
  404s; a 2xx-null is treated as success and keeps the folder.

Added regression tests for each. Verified: analyze clean, full suite (2104) green.

* refactor: convergence cleanups (ultracode round 3)

Re-reviewed the round 1+2 diffs for newly-introduced issues. No bugs found;
addressed the remaining low-severity duplication/dead-code:

- fts_query: drop redundant terms.isEmpty guard (join already yields '')
- note_mapper: inline _serverJsonObjectOrEmpty (now a pure passthrough to _asMap)
- remap_route_sync_provider: collapse the duplicated note/folder route helpers
  into one shared _remappedSingleSegmentRoute, and extract the duplicated
  log/go/try-catch navigation into a single goRemappedRoute closure
- notes_providers: remove the now-dead _watchedDb/_watchedUserId dedup branch
  (the dispose-before-recompute fix makes the watch always re-subscribe; both
  branches already cancelled + re-subscribed, so the tracking was inert)

Verified: analyze clean, full suite (2104) green.

* feat: route note mutations through the durable offline outbox

Note create/update/pin/delete and the note editor's save paths were
API-first: they wrote through api.* and only persisted after the server
responded, so offline edits were lost and never queued — unlike chat,
which already uses the *WithOutbox DAO path. (greptile review feedback)

- NoteUpdater/NotePinToggler/NoteDeleter now write the row + outbox op
  transactionally under noteLocks.runExclusive, then kick the drainer;
  the UI updates from the reactive watchNotes stream.
- NoteCreator creates a durable local note + noteCreate op when offline,
  staying API-first when online so the editor opens on the server id
  with no local->server remap underneath it.
- note_editor_page save/audio/file-remove paths route through a shared
  durable _persistNoteUpdate helper.
- Drop debugPrints that logged note content/data (privacy).
- Update NoteUpdater/NotePinToggler tests for durable semantics.

* fix: read back durable note edits through the local->server remap

Greptile P1: durableUpdate/Pin/Create read back via getNote(id), but the
*WithOutbox writers resolve a stale local: id to the remapped server id
before mutating. After a create remap, an editor autosave holding the
local id wrote the server-id row but read back null -> the editor showed
the save as failed and kept stale state.

- Add NotesDao.getNoteResolvingRemap; durable helpers use it for read-back.
- Test: getNoteResolvingRemap follows a local->server remap.
- Make NoteUpdater/NotePinToggler durable tests deterministic with a
  no-drain SyncEngine override so the queued op stays PENDING. (greploop iteration 2)

* fix: take the note lock on the resolved (remapped) id

Greptile P1: durable note mutations acquired noteLocks on the caller's
stale local: id, but the *WithOutbox writers resolve local->server
internally before mutating. The UI write could then serialize on a
different key than concurrent pull/push for the same row, interleaving an
autosave with a pull merge / outbox push and losing one side's update.

Resolve the remap target BEFORE acquiring the lock and use the resolved
id for the lock, the DAO write, and the read-back. Replaces the narrower
getNoteResolvingRemap read-back helper with NotesDao.resolveNoteRemapTarget.
(greploop iteration 3)

* fix: use the caller-captured session in _persistNoteUpdate

Greptile P1: _persistNoteUpdate re-read the live database/API instead of
the session the caller captured before its awaits. If a save, audio
attach, or file removal started on one account and the user switched mid
in-flight await, the helper could write the old editor's note + content
into the newly active account's database.

Pass the captured api/db into the helper and bail (return null, persist
nothing) when the session is no longer current before writing. (greploop
iteration 4)

* fix: alias the note lock key on create crash-heal remap

Greptile P1: the pull-side create crash-heal remapped a local: note to
the server id while holding only the local-id lock but never aliased the
lock key. An edit/pin/delete already queued on the stale local id could
then run under the local-id lock while the DAO resolves internally to the
server id and mutates the server row — interleaving with pull/push that
lock on the server id.

Mirror the create-push path: call _locks.remapKeyInPlace(localId ->
serverId) after remapper.remapNote succeeds so queued mutations reroute
to the server-id lock. (greploop iteration 5)

* fix: don't publish authenticated without a user on cached-token bootstrap

Greptile P1: the stored-token fast-path published AuthStatus.authenticated
with user=null when a token existed but no user row was cached, so
isAuthenticatedProvider2 became true while currentUserProvider2 stayed
null. User-scoped local reads (notes) then cancel their Drift watch and
render empty; offline, background validation never recovers the user, so
the state stays hidden for the whole session.

- When no user is cached, hold the normal startup loading/revalidation
  state (token kept) instead of authenticating; background validation
  promotes to authenticated WITH the user once reachable.
- Add an offline fallback (_proceedAuthenticatedWithoutCachedUser) so a
  genuinely-offline no-cached-user bootstrap proceeds instead of hanging
  on loading — no worse than before for that corner, strictly better
  online. (greploop iteration 5, auth follow-up)

* fix: resolve no-cached-user auth bootstrap to re-login, never userless-auth or hang

Greptile P1 follow-ups to the cached-token bootstrap fix:
- The offline fallback still published AuthStatus.authenticated without a
  user, so isAuthenticatedProvider2 was true while currentUserProvider2
  was null (user-scoped notes pass the gate then return empty).
- A transient (non-auth) getCurrentUser failure only logged, leaving the
  no-cached-user bootstrap stuck on AuthStatus.loading (splash) forever.

Replace the userless-authenticated fallback with _failBootstrapWithoutCachedUser,
which resolves the bootstrap to AuthStatus.unauthenticated (needsLogin; token
kept) whenever a scoped user can't be recovered — offline, unreachable, user==null,
or a transient validation error. Never userless-authenticated, never an
indefinite loading hang. The cached-user path (offline-first common case) is
unchanged. (greploop iteration 6)

* fix: re-check note session after durable await before publishing

Greptile P1: the durable update/pin/create provider branches captured
api/db, awaited the DAO/lock write, then published the returned note (and
updated activeNoteProvider for pin) without re-checking the session. A
server/account switch during the await could surface a note from the
previous database into the new session.

Re-check _isCurrentNoteSession after each durable await before setting
shared note state / activeNote (mirrors the API-first branches). Delete
is unaffected (publishes only a bool, never a note). (greploop iteration 6)

* fix: guard stale session on durable note delete success

Greptile P1: the durable delete branch returned true after the DB/outbox
await without re-checking the captured session. The editor caller treats
true as success and navigates away, so a server/account switch during
durableDeleteNote could publish a delete success into the new session.

Mirror the create/update/pin post-await guard: re-check
_isCurrentNoteSession and report false (no success) when the session
changed. (greploop iteration 7)

* fix: re-resolve note id after drain in durable update/pin read-back

Greptile P1: durableUpdateNote/durablePinNote read back with the id
resolved BEFORE the drainer kick. The drain can push a brand-new note and
remap its local: id to the server id between the write and the read, so
the read looked up the deleted local row and returned null — a false
'save failed' that left the editor on stale state. durableCreateNote
already re-resolved post-drain; unify all three through a shared
_drainAndReadBackNote helper so update/pin do too. (greploop iteration 8)

* fix: hold loading during bootstrap silent login to avoid sign-in flash

Greptile P1: on cold start with saved credentials but no stored token,
the init branch published AuthStatus.unauthenticated before starting the
background silent login. authNavigationStateProvider maps that to
needsLogin, so the router briefly showed the sign-in page before a valid
silent login completed and bounced the user back to chat.

- Hold AuthStatus.loading (splash) while the bootstrap silent login is in
  flight instead of unauthenticated.
- Widen the background canCommit() to accept the loading state so a
  successful login still commits authenticated.
- Wrap the call (_bootstrapSilentLogin) with a safety net that resolves
  loading -> unauthenticated when the login commits nothing, so failures
  reach the sign-in page instead of hanging on the splash. (greploop iteration 8, auth follow-up)

* fix: final freshness gate before committing silent-login session

Greptile P1: _commitSilentLoginResult published the in-memory
authenticated state (invalidate providers, _update, install token) after
the last _canCommitAuth check without a final gate. A logout / newer auth
attempt landing during persistence could let a stale background login
resurrect the previous session.

Add a final _canCommitAuth check immediately before the in-memory commit;
when stale, restore the prior persistence (mirroring the existing
setActiveServerId/saveAuthToken stale branches) and return false. (greploop iteration 9, auth follow-up)

* fix: gate bootstrap silent-login fallback on the attempt revision

Greptile P1: _bootstrapSilentLogin's fallback fired whenever the result
was uncommitted and state was loading-without-token — but a newer
foreground login that just started is also loading-without-token. A stale
bootstrap task could then publish unauthenticated and interrupt the newer
login, bouncing the user to sign-in.

Capture _authAttemptRevision before the background login and only run the
fallback when it is unchanged (no newer login/logout/token-invalidation,
all of which bump it via _beginAuthAttempt). (greploop iteration 10, auth follow-up)

* fix: clear rejected credentials on background silent-login auth failure

Greptile P1: on a confirmed auth failure the background silent-login path
returned false without deleting the saved credential or publishing
credentialError. _bootstrapSilentLogin then turned that into a generic
unauthenticated state, so a remembered-but-rejected credential/JWT was
retried on every cold start instead of being cleaned up.

Gate the cleanup on staleness (_canCommitAuth) instead of bailing for all
background calls: a non-stale confirmed auth failure now deletes the bad
credential and publishes credentialError (re-checking freshness after the
delete await before committing state). (greploop iteration 11, auth follow-up)

* fix: clear credentials for a deleted server on background silent login

Greptile P1: when cold start has saved credentials whose server config was
removed, the background silent-login branch returned without deleting the
credentials or clearing the active server. _bootstrapSilentLogin fell back
to unauthenticated, leaving stale creds so every later cold start re-entered
the same impossible silent-login path.

Gate the missing-server cleanup on staleness (_canCommitAuth) instead of
bailing for all background calls: a non-stale attempt now deletes the
credentials + clears the active server (re-checking freshness after the
delete awaits before publishing the error state). (greploop iteration 12, auth follow-up)

* fix: harden note readback + silent-login TOCTOU races (greptile round)

- notes: read the just-written row back BEFORE kicking the fire-and-forget
  drainer (_readBackThenDrainNote). The write is already committed under the
  note lock, so reading first can't race a push+remap that deletes the local
  row — no UI-blocking await needed. (notes_providers.dart:145)
- auth #1091: when a CLAIMED background silent login fails during persistence,
  resolve the in-memory state to unauthenticated before rethrowing, so the
  claim's revision bump doesn't leave cold start stuck on loading.
- auth #1191: only clear missing-server credentials/active-server when the
  stored tuple still matches the attempted one (value-match) and the active
  server still points at the missing id.
- auth #1422: snapshot the attempted credentials and delete on auth failure
  only if the stored tuple still matches, so a concurrent login's freshly
  saved credentials aren't clobbered. (greploop iteration 13)

* fix: gate foreground silent login + tighten active-server clear

- auth #1033: foreground _performSilentLogin now runs under a revision
  freshness gate (canCommit/claimCommit), so a saved-credential silent
  login in flight can't publish authenticated/credentialError/error over a
  newer manual login or logout that bumped _authAttemptRevision.
- auth #1209: clear the dangling active server only when a final
  _canCommitAuth check passes AND it still equals the missing serverId, so
  a concurrent foreground login/server switch isn't clobbered. (greploop iteration 14)

* fix: compare-and-clear active server on missing-server cleanup

Greptile P1: the missing-server bootstrap cleanup read getActiveServerId()
then cleared it, which it flagged as a clear-after-read race. Add an
atomic-style OptimizedStorageService.clearActiveServerIdIfMatches(expected)
helper (read + conditional write in one continuation) and use it, so a
concurrently-selected active server is never cleared by a stale cleanup. (greploop iteration 15)

* refactor(auth-storage): serialize auth read-modify-writes under a lock

Greptile flagged the compare-and-clear/restore helpers as non-atomic
(Hive has no CAS). Introduce a single OptimizedStorageService._authStateLock
(package:synchronized) that serializes ALL writes to the auth token, saved
credentials, and active server id, and run the compound read-modify-write
helpers under one hold via private _*Unlocked bodies (lock is non-reentrant):

- saveAuthToken/deleteAuthToken/saveCredentials/deleteSavedCredentials/
  setActiveServerId now lock + delegate to _*Unlocked.
- clearActiveServerIdIfMatches: locked compare-and-clear.
- deleteSavedCredentialsIfMatches(expected): locked compare-and-delete.
- restoreActiveServerAndTokenIfStale(...): locked value-matched restore.
- auth_state_manager routes missing-server cleanup, auth-failure credential
  delete, and _restoreStaleSilentLoginPersistence through these atomic
  helpers, so a stale silent-login task can no longer clobber a newer
  login / server selection. Adds synchronized as a direct dependency. (greploop iteration 16)

* fix: clear raw active-server id + restore partial silent-login writes

- #344: clearActiveServerIdIfMatches / restoreActiveServerAndTokenIfStale
  compared against getActiveServerId(), which validates against saved
  configs and returns null once the server is deleted — the exact
  missing-server case — so the dangling raw preference was never cleared.
  Compare the RAW stored id (_readActiveServerIdState().rawServerId) under
  the lock instead.
- #1395: _commitSilentLoginResult now restores value-matched persistence
  whenever a partial write happened (wrotePersistence), not only when the
  attempt went stale, so a storage failure between setActiveServerId and
  saveAuthToken can't strand the app on the silent-login server/token. (greploop iteration 17)

* fix: read raw Hive active-server id (bypass cache) in compare-and-clear

#346: clearActiveServerIdIfMatches / restoreActiveServerAndTokenIfStale read
the active-server id via _readActiveServerIdState(), which returns the cached
value — and getActiveServerId() may have already validated a deleted server
and cached null. The compare then saw null and skipped clearing the dangling
raw preference. Read the raw Hive value directly (_rawStoredActiveServerId,
bypassing cache + validation) under the lock so a removed server's stale
active-server id is detected and cleared. (greploop iteration 18)

* fix: gate foreground logins on attempt revision + clear invalid saved tokens

- #553: _loginWithApiKeyInternal, _loginInternal, and ldapLogin now capture
  the auth attempt revision and skip persisting a token / publishing
  authenticated|error state when _authAttemptSuperseded (a newer login /
  logout / token-invalidation started). The gate only trips under genuine
  concurrency, so single logins are unaffected; it stops a slow attempt from
  overwriting a newer one's session.
- #1458: local saved-token validation failures (apiKeyNotSupported, invalid
  token format, empty token) from _authenticateSavedJwt are now treated as
  terminal credential failures, so the bad saved credential is value-match
  cleared instead of being retried on every cold start. (greploop iteration 19)

* fix: re-check attempt revision before publishing foreground login state

Closes the residual window named in greptile's summary: the foreground
login gate checked _authAttemptSuperseded before saveAuthToken, but a newer
login/logout could still start during the saveAuthToken/saveCredentials
awaits, after which the older attempt would publish authenticated state.
_loginWithApiKeyInternal, _loginInternal, and ldapLogin now re-check
_authAttemptSuperseded immediately before the _update(authenticated) too,
so a superseded attempt never publishes over the newer session. (greploop iteration 20)

* fix: roll back superseded foreground login's persisted writes

Greptile P1 (#781/#636): the post-persistence freshness gate stopped a
superseded login from publishing state but left the tokenA/credentials it
had already written to secure storage, so the next cold start could restore
the rejected session.

Add OptimizedStorageService.deleteAuthTokenIfMatches (locked compare-and-
delete) and a _rollbackSupersededLoginWrites helper; the pre-publish
superseded branches in _loginWithApiKeyInternal, _loginInternal, and
ldapLogin now value-match delete the token (and remembered credentials)
they wrote before returning false — never clobbering a newer login's
writes. (greploop iteration 21)

* fix: roll back token/credentials on failed foreground login

Greptile summary residual: a foreground login that threw AFTER writing the
token (e.g. saveCredentials or a later step fails) left the token in secure
storage, so a cold start could restore a session the user saw fail.

_loginWithApiKeyInternal, _loginInternal, and ldapLogin now track the
persisted token/credentials and, in their catch, value-match roll them back
via _rollbackUncommittedLoginWrites before rethrowing — so a failed login
never leaves a restorable session. (greploop iteration 22)

* fix: restore interceptor token when a foreground login is superseded

Greptile P1: _validateIssuedToken installs the candidate token on the shared
ApiService before the staleness check, so a superseded login left tokenA on
the interceptor — subsequent requests could use it until the next update.

Add _restoreApiServiceTokenToCurrent() and call it in every post-validation
superseded branch (pre-save gate, pre-publish gate, and the catch) of
_loginWithApiKeyInternal, _loginInternal, and ldapLogin, so the interceptor
token is reset to the authoritative current auth state instead of keeping the
rejected attempt's token. (greploop iteration 23)

* fix: claim own attempt revision in foreground silent login

Greptile P1: _performSilentLogin captured _authAttemptRevision without
starting its own attempt. A silent re-login (retry / token-invalidation)
could see the same revision as an in-flight manual login and claimCommit the
OLD saved credentials, superseding the manual login.

Call _beginAuthAttempt() up front so silent login owns a distinct revision:
a manual login that starts afterward bumps again and wins, and a stale silent
login can't commit over it. (greploop iteration 24)

* fix: use one credential snapshot for silent-login attempt and cleanup

Greptile P1: _performSilentLoginInternal snapshotted credentials, but
_performSilentLoginAttempt re-read them for the actual login. If they changed
between the two reads and the second set failed terminal validation, the
failure cleanup value-match-deleted the FIRST snapshot, leaving the truly-bad
credentials in storage to be retried each cold start.

Thread the single snapshot into _performSilentLoginAttempt so the attempted
login and the cleanup target are always the same credentials. (greploop iteration 25)

* fix: roll back LDAP token writes on post-save !ref.mounted returns

Greptile P1: ldapLogin had early 'if (!ref.mounted) return false' guards
after saveAuthToken/saveCredentials that bypass the catch rollback, leaving
an uncommitted LDAP token (and possibly credentials) in secure storage — a
cold start could then restore a session that never committed in memory.

Each post-save !ref.mounted guard now value-match rolls back persistedToken
and writtenCredentials before returning. (greploop iteration 26)

* fix: no retry/cancel banner on first send; gate terminal tab offline by cache

1. Queued-completion banner on first prompt of a new chat:
   queuedCompletionInfoForMessageProvider hid a fresh, never-attempted
   requestCompletion op only when isOnline. At app/first-send time
   connectivity is often still resolving (reported not-online), so the
   retry/cancel banner flashed on the first message. Hide any fresh pending
   op (no lastError, no nextAttemptAt) regardless of isOnline — it's
   'sending', not 'queued'. The banner still appears once the op is genuinely
   offline-deferred (lastError='offline'), backoff-scheduled, or failed.

2. Terminal tab shown in offline mode even when disabled on the server:
   the sidebar derived showTerminalTab from terminalAvailableServersProvider
   with error/loading => true, with NO cached flag (unlike notes/channels).
   Offline, it always showed. Add a cached terminalFeatureEnabledProvider
   (per server/user, same _FeatureAvailabilityCache as notes/channels) and a
   terminalTabVisibleProvider that uses the live server list when resolved
   (writing back the cache) and the cached last-known value when offline. So
   a terminal-disabled server no longer surfaces the tab offline.

Notes/channels offline gating was already correct (cached per server/user,
default true); verified, no change.

Tests: queued_completion_provider_test (fresh hidden offline; offline-deferred
and parked still show) and terminal_tab_visible_test (live empty/non-empty;
offline falls back to cached, not default-true).

* fix: don't cache a placeholder empty terminal server list

Greptile P1: terminalAvailableServersProvider returns an empty list when
terminalServiceProvider is transiently null (startup / auth / active-server
rebuild before the API is ready). terminalTabVisibleProvider treated that
placeholder as a real 'terminal disabled' probe and cached false, poisoning
the per-server/user cache so the offline/error fallback kept hiding the tab
for a terminal-enabled server.

Only persist the enablement after a real getAvailableServers() succeeds
(service present); the null-service placeholder writes nothing. The cache
write moves into terminalAvailableServersProvider (gated on a real probe);
terminalTabVisibleProvider now just shows live data when resolved and the
cached last-known value while loading/errored.

* fix: keep terminal availability unresolved (not empty) when no service

Greptile P1 (x2): the no-service placeholder still resolved
terminalAvailableServersProvider as data:[], which terminalTabVisibleProvider
treated as a completed 'terminal disabled' probe — briefly hiding the tab (and
clamping navigation away) during startup/auth/rebuild even when the cached flag
was enabled.

When terminalServiceProvider is null, the provider now stays UNRESOLVED
(loading) instead of returning an empty list, so terminalTabVisibleProvider's
orElse falls back to the cached last-known value. A real probe (service present)
still resolves to the live list and persists the enablement. selected-server
consumers already tolerate the loading state (.asData?.value).

* fix: derive sidebar create-action terminal visibility from the shared provider

Greptile: sidebar_create_action computed terminal-tab presence with its own
terminalAvailableServersProvider.maybeWhen(orElse: true), which no longer
matched the sidebar's cached-aware terminalTabVisibleProvider after the
stay-loading change. The two could disagree (e.g. terminal loading or
cached-disabled), shifting the create-action's tab-index mapping out of sync
with the rendered tabs and hiding the Channels create action.

Both _watchTerminalTabVisible/_readTerminalTabVisible now read
terminalTabVisibleProvider — one source of truth shared with the sidebar.

* fix: only mark login credentials rollback-owned after they're written

Greptile P1: writtenCredentials was set BEFORE saveCredentials succeeded, so
if saveAuthToken succeeded and saveCredentials then threw, the catch's
_rollbackUncommittedLoginWrites value-match-deleted a pre-existing identical
remembered credential this attempt never wrote — losing remembered login data
after a transient secure-storage failure.

Set writtenCredentials only AFTER saveCredentials completes in
_loginWithApiKeyInternal, _loginInternal, and ldapLogin. A failed credential
write now leaves writtenCredentials null, so the catch rolls back only the
token this attempt actually persisted.

* fix: coalesce token-invalidation onto an in-flight silent re-login

Greptile P1: onTokenInvalidated() bumped _authAttemptRevision before checking
for an in-flight silent login. Two near-simultaneous 401s would have the second
call bump the revision (marking the first silentLogin stale) and then skip
starting a replacement (reloginInProgress) — the only re-login bailed and the
app stayed in tokenExpired despite valid saved credentials.

Early-return and coalesce onto the running silent login before any revision
bump / token clear / state publish, so the in-flight login resolves normally.

* style: group test imports (dart, external, project) per CodeRabbit

Reorder imports in queued_completion_provider_test.dart (CodeRabbit finding)
and terminal_tab_visible_test.dart for consistency: dart core, then external
packages, then conduit project imports.

* fix: don't flash queued banner for a transient completion retry

The first message of a session can race a cold network/socket connection: the
completion's HTTP request (sendMessageSession) fails once, the drainer
markFailedRetryable's it with a ~1s backoff, and the auto-retry then succeeds.
The previous queued_completion_provider fix still surfaced the retry/cancel
banner during that ~1s window, so it flashed on the first chat message.

Surface a PENDING completion only when it genuinely needs attention:
  - offline-deferred (lastError == 'offline'), or
  - stalled across >= _queuedCompletionStallAttempts (2) attempts.
A failed (parked) op always surfaces for manual retry. A single transient
first-attempt retry now auto-recovers invisibly.

Socket lifecycle: the socket already connects proactively on auth-ready
(_scheduleConnect), force-reconnects on connectivity regained, and the
completion path ensureConnected's it (1200ms); the residual transient is the
cold HTTP request, which the auto-retry recovers. Tests cover transient-hidden
and stalled-shown.

* feat: warm the API connection on auth so the first completion isn't cold

The first chat completion of a session posts to /api/chat/completions via
ApiService._dio. On a fresh session that connection is cold, so the first
request can lose a TLS/HTTP handshake race and transiently fail (then the
outbox auto-retries ~1s later).

Warm that exact connection pool as soon as we're authenticated and the API
service is ready: _runPostAuthenticationStartup now fires a cheap, fire-and-
forget GET /health (ApiService.checkHealth, same _dio the completion POST
reuses). The keep-alive connection is then warm for the first send.

Pairs with the queued-completion banner fix: warming prevents the cold-start
transient in the common case; the banner still stays hidden for any residual
transient (e.g. connection idled out before the first send).

* fix: keep the socket continuously available across manager rebuilds

Root cause of the socket not being connected for the whole session: the socket
manager is an async (FutureProvider) keepAlive provider, and socketServiceProvider
collapsed its `loading` state to null on every rebuild — so the live socket
momentarily read as null (dropping sends to HTTP-only). It also rebuilt
needlessly because SocketTransportAvailability (derived fresh from backend
config) had no value equality, so each recompute (e.g. when backendConfig
resolves shortly after boot) emitted a new instance that forced a manager
rebuild even when the transport was unchanged — landing right in the
first-message window.

Two fixes (no behavior change to genuine server/transport-change recreation):
- Add == / hashCode to SocketTransportAvailability so a logically-identical
  resolved value doesn't churn watchers (incl. the socket manager).
- socketServiceProvider now falls back to the manager's currentService while
  the manager is reloading, instead of returning null, so the live socket
  stays visible across rebuilds.

Tests: socket_transport_availability_test (value equality).

* fix: clear the rejected token before coalescing token-invalidation

Follow-up to the coalescing fix: onTokenInvalidated early-returned when ANY
silent re-login was in flight (manual retry / bootstrap, not just a prior
invalidation), skipping the rejected-token cleanup. If that unrelated login
then failed, the invalid token lingered and could be restored on the next cold
start.

Now value-match delete the rejected token (deleteAuthTokenIfMatches, so a fresh
token the in-flight login may have saved is never clobbered) BEFORE coalescing.
Still no revision bump / no duplicate login, so the in-flight login resolves
normally.

* fix: preserve note versions/files on editor saves (no data truncation)

The note editor's save paths (plain save, audio-attach, remove-file) rebuilt
the note `data` map from scratch with only content (+files), dropping
`versions` — and the plain save also dropped `files`. Since the durable
update persists/sends this map as the note's full data, every save truncated
server-managed note data (version history, and attachments on a text-only
save).

Add _composeUpdatedNoteData: start from the existing note's data
(_note.data.toJson() — content/versions/files) and override only content (and
files when changed). All three save sites use it, so versions are always
preserved and files survive a text-only save.

* fix: merge note data patch in durableUpdateNote (preserve versions/files)

Greptile: NoteUpdater.updateNote builds a content-only data blob, which (as the
note's whole data) dropped versions/files locally and on the next server push —
a latent data-loss path (not currently wired to UI, but real).

durableUpdateNote now merges the partial `data` patch onto the existing row's
data (decodeNoteData(existing) + patch), so content/files override while
versions (and anything else) are preserved. This is the single durable
chokepoint for both NoteUpdater and the editor; the editor's own
_composeUpdatedNoteData still covers its reviewer/no-db API path.

Test: NoteUpdater content-only update preserves existing versions and files.

* fix: read the merge baseline inside the note lock in durableUpdateNote

Greptile: the existing-note read for the data merge ran BEFORE acquiring the
note lock, so a concurrent pull/merge (which takes the same note lock) could
change the row between the read and the locked write, invalidating the merge
baseline and clobbering the concurrent change.

Move the getNote + merge inside noteLocks.runExclusive so read+merge+write are
atomic w.r.t. other note ops on that id.

* fix: editor sends a minimal note-data patch (no stale versions)

Greptile: _composeUpdatedNoteData spread the stale in-memory _note.data
(including versions); durableUpdateNote then merged {...dbRow, ...patch}, so the
editor's stale versions overrode the DB row's current ones — reverting any
server-added versions a pull made while the note was open, and pushing the
reversion on the next drain.

The editor now emits only what it changes (content, plus files when provided).
durableUpdateNote's DB-row merge preserves server-managed versions (and files
when unchanged) from the CURRENT row.

* fix: show typing indicator directly so no empty block is reserved first

The typing indicator was a child of the content AnimatedSwitcher, so when
the gate elapsed the switcher reserved its full height while fading it up
from zero opacity (220ms) on top of the dots' own fade-in. With the queued
banner no longer flashing into that slot, the result read as an empty
"blocked" block for a beat before the dots appeared. Render the indicator
directly (instant) and keep the AnimatedSwitcher only for the content path.

* feat: typing indicator becomes the streaming footer, swaps to action row

The typing indicator now lives in the action-row position below the message
and persists for the whole generation while text/tool-calls/status stream in
above it, then crossfades to the action row once streaming completes — matching
OpenWebUI. Previously it sat in the content slot and vanished as soon as the
first tokens arrived.

Also removes the action-row flash: the row is gated behind a settle signal
(genuine streaming-end transition or a confirmed responseDone) and is reset
while streaming, so a message that is or was streaming never paints the row
mid-flight. History messages (never streamed in this widget) still show their
row immediately.

The footer slot is a single AnimatedSwitcher swapping typing <-> actions;
the content slot no longer renders the indicator. Haptics tests now disable
animations (the persistent indicator's repeating animation otherwise leaves
pending timers at teardown); adds footer-slot + history-message tests.

* feat: sidebar generating spinner for active chats (cold open + global)

Adds OpenWebUI-style sidebar activity indication for any chat with an active
server generation, including on cold open and for generations started by other
sessions/devices.

- api.checkActiveChats: POST /api/tasks/active/chats {chat_ids} ->
  {active_chat_ids}; empty input short-circuits, 404 degrades to empty and is
  cached so older servers aren't re-probed.
- ActiveChatsSync (keepAlive): bulk-fetches into activeChatIds on first
  conversation-list load and on socket reconnect (setAll), and registers a
  GLOBAL wildcard chat:active handler so the set tracks every chat, not just
  the locally-streaming one. Activated from post-auth startup.
- ConversationTile/Content gain an isGenerating spinner (tap-load spinner takes
  precedence); the drawer drives it from activeChatIds.

Tests: checkActiveChats request shape + 404 caching; tile generating spinner
and precedence.

* feat: resume streaming when reopening a chat that's still generating

Reopening a chat that is still generating on the server now shows the typing
indicator and streams content in progressively, instead of an empty/partial
response. The server never sends isStreaming, so:

- _detectActiveOnOpen: on conversation change, probe the task registry
  (activeChatIds fast-path, else getTaskIdsByChat). If active, mark the last
  assistant message isStreaming:true (without clobbering content), pre-seed
  _observedRemoteTask, and start the remote-task monitor. Skips temporary
  chats, offline (probe failure), and genuine local streams; re-checks the
  active conversation after the await.
- _syncRemoteTaskStatus: while tasks are active and no local transport owns the
  chat, adopt growing server content (monotonic-length guard via
  pullChatOrFetch) so the resume streams in. Final adopt on tasksDone is
  unchanged.
- _isResumeStreamingActive guard: during a resume the progressive poll owns
  content; passive server refreshes and same-id snapshot adoption are skipped
  so an isStreaming:false snapshot can't end the resume prematurely.

Tests: reopen-active re-engages streaming; settled chat does not; progressive
content growth; temporary chats are never probed.

* fix: value-match the token delete in onTokenInvalidated non-coalesce path

The non-coalesce path called deleteAuthToken() unconditionally, which (when a
concurrent foreground login had already saved a fresh token through
_authStateLock) could delete that fresh token via lock-serialised
save-fresh -> delete-fresh, leaving the app authenticated in memory with no
stored token (a cold start then has nothing to restore).

Capture the rejected token synchronously at the top of onTokenInvalidated()
(before the silent-login check and any await/revision bump) and use
deleteAuthTokenIfMatches(rejectedToken) in both the coalesce and non-coalesce
paths, mirroring the already-fixed coalesce branch. Greptile #14/#15 (the 4/5
blocker).

* fix: session-guard the cached-note fallback in noteById

The catch path returned `cachedNote` (read before the detail fetch await)
without the session check the success path performs. If the account switched
during a failed `getNoteById`, a note from the previous session could leak
into the new one. Add the same `ref.mounted` + `_isCurrentNoteSession` guard
before returning the cached note. Greptile 4/5 blocker.

* fix: re-arm active-chats cold-open fetch when a new socket is bound

_initialFetchDone is a one-shot guard that was never reset, so after a
logout/login cycle (a new SocketService is bound) the conversations listener
skipped the initial bulk active-chats fetch and the new session's sidebar
spinners were never populated. Reset the flag when binding a new non-null
socket so the next conversation-list resolution re-fetches. (CodeRabbit)

* fix: hoist noteById provider reads before the first await

ref.watch(apiServiceProvider)/ref.watch(isOnlineProvider) ran after the
getNoteForUser await, which is a Riverpod anti-pattern (the dependency may not
be tracked and can throw on newer SDK versions). Read all provider deps
up-front before any await. Tests that exercise noteById now override
isOnlineProvider (previously short-circuited before it was watched). (Greptile)

* test: assert the bootstrap silent-login revision-sharing contract

Greptile's remaining 4/5 note: the _bootstrapSilentLogin /
_performSilentLoginInBackground pair is correct but relies on a subtle
revision-sharing pattern (both share the pre-existing revision; a successful
login bumps it lazily via claimCommit) with no dedicated test, so a future
change could silently break the concurrency contract.

Extract the fallback decision into a pure bootstrapShouldFallbackToUnauthenticated()
and refactor _bootstrapSilentLogin to use it, then assert the contract: the
bootstrap fallback to unauthenticated fires only when nothing committed and the
revision is unchanged in the loading/no-token state, and is suppressed by a
successful commit, a newer attempt bumping the revision, a published session,
or a restored token. (The success path itself is network-bound via an internal
ApiService and not unit-testable; this locks in the revision logic.)
2026-06-19 15:08:16 +05:30
cogwheel
b6ee878775 fix(security): restrict external link launching to safe schemes 2026-06-10 20:51:15 +05:30
cogwheel
107f6a90ef feat(pdf): integrate PDF rendering support in markdown
- Added support for rendering PDF links within markdown content using the new PdfInlineView widget.
- Enhanced BlockRenderer to extract and render single PDF links with appropriate styling.
- Updated InlineRenderer to conditionally display PDF previews instead of standard links based on configuration.
- Introduced new dependencies in pubspec.yaml and updated pubspec.lock to include the pdfrx package for PDF handling.
- Improved overall markdown rendering capabilities by accommodating PDF content.
2026-06-08 15:42:17 +05:30
cogwheel
aa42cc9cc7 feat(markdown): enhance BlockRenderer to support standalone images in paragraphs
- Introduced functionality to render standalone images within markdown paragraphs, improving the visual layout of mixed content.
- Added methods to handle soft line breaks before and after images, ensuring proper spacing and alignment.
- Updated the rendering logic to accommodate inline text and images seamlessly, enhancing overall markdown rendering capabilities.
2026-06-07 15:50:03 +05:30
cogwheel
67f0519483 fix(adaptive_ui): implement opaque glass fallback for adaptive buttons across chat and note editor components
- Introduced `conduitUsesOpaqueGlassFallback` utility to determine button styles based on platform capabilities.
- Updated button styles in `ChatPage`, `ExpandedTextEditor`, `ModernChatInput`, and `NoteEditorPage` to utilize opaque glass fallback where necessary.
- Enhanced visual consistency by applying the new styling logic to various adaptive buttons throughout the chat and note editing interfaces.
2026-06-06 15:33:35 +05:30
cogwheel
c541667348 refactor(settings): remove large text preference and related functionality
- Eliminated the `largeText` preference from `UserSettings`, `AppSettings`, and related services to streamline accessibility settings.
- Updated persistence keys and migrator logic to reflect the removal of large text handling.
- Refactored UI components to remove dependencies on large text scaling, ensuring a more consistent text rendering approach.
- Adjusted tests to align with the updated settings structure, removing checks for the now-removed large text preference.
2026-06-04 23:25:04 +05:30
cogwheel
40c2c8034a feat(fonts): add Geist Sans and Geist Mono font families to pubspec.yaml
- Introduced new font families, Geist Sans and Geist Mono, with various weights and styles for enhanced typography options across the application.
- Updated the main.dart file to streamline MediaQuery usage, improving accessibility handling.
- Refactored accessibility service methods to remove unnecessary text scale clamping, ensuring more flexible text scaling.
- Adjusted styles in various widgets to utilize the new typography settings, enhancing visual consistency.
- Added tests to verify the integration of the new font families and their application in the UI.
2026-06-04 22:37:08 +05:30
cogwheel
134f32f895 refactor(responsive_drawer): remove haptic feedback on drawer interactions
- Eliminated haptic feedback calls from the ResponsiveDrawerLayout, ensuring a smoother user experience without tactile interruptions.
- Updated related tests to reflect the absence of haptic feedback during drawer open, close, and settle actions, confirming that no haptic events are emitted.
2026-05-28 00:12:21 +05:30
cogwheel
f87bf0d44c refactor(chat): centralize draggable sheet sizes for improved consistency
- Introduced a new `DraggableModalSheetSizes` class to define default size fractions for `DraggableScrollableSheet` across various widgets.
- Updated `CodeExecutionListView` and `StreamingStatusWidget` to utilize the centralized size constants, enhancing maintainability and consistency in modal sheet behavior.
- Refactored the `MarkdownDetailsBlock` to improve the handling of sheet content and ensure proper separation of header and body surfaces in the UI.
- Added tests to validate the new layout behavior and ensure accurate rendering of markdown details in the updated structure.
2026-05-25 20:33:04 +05:30
cogwheel
cc97f111eb feat(chat): enhance chat message streaming and markdown handling
- Refactored the chat message providers to improve streaming content updates and handling of markdown rendering.
- Introduced a new utility function to strip link reference definitions from markdown content for more efficient streaming.
- Enhanced the `_AssistantMessageWidget` to support a cheaper streaming text path for long plain content, improving performance.
- Updated the `StreamingMarkdownWidget` to handle both standard and cheap streaming text rendering, ensuring a seamless user experience.
- Added tests to validate the new streaming behavior and markdown processing, ensuring accurate content display during streaming.
2026-05-25 12:58:48 +05:30
cogwheel
7793bb761d feat(dependencies): update package dependencies and integrate jovial_svg
- Changed the dependency type for `flutter_svg` from direct to transitive in `pubspec.lock`.
- Added new dependencies for `jovial_misc` and `jovial_svg` in `pubspec.lock` and `pubspec.yaml`.
- Replaced instances of `SvgPicture` with `JovialSvgImage` in `enhanced_image_attachment.dart`, `latex_preprocessor.dart`, and `markdown_config.dart` to utilize the new SVG handling.
- Updated tests to reflect changes in SVG handling and ensure proper functionality.
2026-05-24 22:54:01 +05:30
cogwheel
5b17618d7a feat(responsive_drawer): enhance edge drag handling for horizontal scrollable content
- Introduced new logic to manage edge drag interactions, allowing horizontal scrollable content to influence drawer behavior.
- Added methods to detect horizontal scrollable hits and adjust drag thresholds accordingly, improving user experience.
- Updated tests to validate the new edge drag functionality, ensuring that horizontal scrollable content can open or suppress the drawer as expected.
- Refactored existing drag handling methods for clarity and maintainability.
2026-05-24 14:24:56 +05:30
cogwheel
d79c04af6f feat(chat): enhance serialization and JSON handling for chat messages and conversations
- Added custom JSON serialization methods for `statusHistory`, `codeExecutions`, and `messages` in `ChatMessage` and `Conversation` models, improving data structure consistency.
- Implemented new utility functions to handle serialization of nested models, ensuring accurate JSON representation.
- Updated tests to validate the serialization and deserialization processes for chat messages, including nested structures and collections.
- Refactored related components to utilize the new serialization methods, enhancing overall code clarity and maintainability.
2026-05-24 11:20:20 +05:30
cogwheel
d8b161fa5a refactor(api): streamline conversation fetching and parsing logic
- Updated conversation fetching methods to utilize new summary parsing functions, improving clarity and performance.
- Refactored the handling of pinned and archived conversations to enhance data retrieval efficiency.
- Introduced normalization utilities for JSON data handling across various services, ensuring consistent data structures.
- Simplified error handling and data coercion processes in conversation parsing, enhancing robustness.
- Updated related tests to reflect changes in data structures and ensure comprehensive coverage.
2026-05-22 00:25:32 +05:30
cogwheel
fd84d9c15f feat(chat): enhance streaming message comparison and visibility handling
- Introduced a new method to read local message comparison snapshots, improving the accuracy of message content comparisons during streaming.
- Refactored existing logic to utilize the new comparison snapshot method, ensuring consistent handling of local and server message content.
- Enhanced the chat message notifier to differentiate messages based on streaming signatures, improving synchronization with server updates.
- Updated the assistant message widget to manage displayed content more effectively during streaming sessions, ensuring a smoother user experience.
- Added tests to verify the correct behavior of streaming content updates and message comparisons.
2026-05-21 17:18:42 +05:30
cogwheel
99c196cd5f feat(chat): enhance streaming content handling and performance
- Added support for retrieving visible streaming content in the chat stream, improving the user experience during message rendering.
- Refactored the streaming completion logic to ensure proper cleanup and finalization of streaming tasks.
- Introduced dynamic scheduling for streaming content updates, optimizing performance based on content length.
- Enhanced the handling of message updates to ensure more responsive UI behavior during streaming sessions.
- Updated the markdown rendering components to better manage streaming content, improving overall rendering efficiency.
2026-05-21 03:09:19 +05:30
cogwheel
42121ac252 feat(markdown): integrate MarkdownLoadingSkeleton for improved loading experience
- Added `MarkdownLoadingSkeleton` to the chat page and markdown rendering components to enhance the user experience during content loading.
- Refactored the chat page to utilize a new `_buildLoadingMessagePlaceholder` method for displaying loading states.
- Updated `StreamingMarkdownWidget` and `ConduitMarkdownWidget` to show the loading skeleton while waiting for markdown content to compile.
- Enhanced tests to verify the presence and behavior of the loading skeleton during asynchronous operations.
2026-05-20 20:53:17 +05:30
cogwheel
80ad1b5614 feat(markdown): enhance markdown content preparation and streaming performance
- Introduced dynamic streaming display intervals based on content length in `AssistantMessageWidget` and `StreamingMarkdownWidget`, improving responsiveness for large texts.
- Added a new `_MarkdownPrepareBackend` to handle asynchronous content preparation, optimizing performance for long streaming text.
- Updated `MarkdownCompileService` to support synchronous and asynchronous content preparation paths, enhancing flexibility in handling markdown content.
- Refactored tests to validate the new content preparation logic and ensure proper fallback mechanisms are in place for async failures.
2026-05-20 12:20:40 +05:30
cogwheel
29ac5534f7 refactor(markdown): remove blockSourceLength and improve heavy block handling
- Removed `blockSourceLength` from `CompiledMarkdownElement` and related processing, simplifying the data structure.
- Updated `BlockRenderer` to handle heavy blocks more efficiently, introducing a new placeholder for deferred heavy previews.
- Enhanced `StreamingMarkdownWidget` to manage compiled view lifecycle with debug callbacks for mounted and disposed states.
- Refactored tests to align with the removal of `blockSourceLength` and ensure proper handling of heavy previews during streaming.
2026-05-19 16:57:19 +05:30