conduit/lib/shared/services
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
..
brand_service.dart refactor(theme): standardize typography styles across various components 2026-04-25 14:17:53 +05:30