mirror of
https://github.com/cogwheel0/conduit.git
synced 2026-08-27 11:31:42 +00:00
8 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3b4817138c | Preserve voice mode and handle completion errors | ||
|
|
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 |
||
|
|
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). " 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 (&quot; decoding once back to a visible
"). 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
|
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
c864f95633
|
Offload large-conversation assembly, fix attachment-queue logout behavior, refresh docs (#565)
* docs: disclose location/camera/speech permissions and refresh stale stack docs - PRIVACY_POLICY.md: add Location, Camera, and Speech recognition bullets to the Permissions section to match iOS Info.plist and Android manifest declarations; update effective date to 2026-07-03 - README.md: replace "Hive and shared preferences" with "Drift (SQLite)" in the Stack section; add --recursive to the clone command with a note about the openwebui-src submodule; add Terminal row to the Feature Snapshot table - AGENTS.md: update persistence sentence to reflect Drift as primary store, with a note that Hive CE is staged for removal (CLAUDE.md is a symlink, so it picks up this change automatically) * perf(sync): offload large conversation assembly to worker isolate Add assembleConversationGuarded() as the enforced entry point for conversation assembly. The helper respects the existing 100-message worker threshold and calls through to parseFullConversationModelWorker via a ConversationParseOffload closure when the threshold is exceeded. Wire the offload at the three violating call sites: - pull_sync.dart (PullSync.pullChat) via constructor-injected closure - chat_providers.dart (db-watch path) via workerManagerProvider - request_completion_runner.dart (headless path) via workerManagerProvider Move kLocalConversationWorkerThreshold to conversation_assembler.dart (single source of truth); re-export from local_conversation_loader.dart for backward compatibility. Update assembleConversation() doc to direct new callers to the guarded variant. Add 3 unit tests covering below-threshold/above-threshold/null- offload cases. * fix(attachments): stop upload queue timer/closure surviving logout; drop dead provider - Add `deactivate()` to `AttachmentUploadQueue` singleton: cancels the 10s periodic timer and nulls `_onUpload`/`_onQueueChanged`/`_databaseResolver` so stale server closures cannot fire after logout or server switch. Does NOT close the shared broadcast `queueStream` and does NOT cancel in-flight `_cancelTokens`. - Wire `ref.listen(apiServiceProvider, ...)` in the `mediaUploadController` provider factory to call `AttachmentUploadQueue().deactivate()` on every API change (server switch / logout). Queued items survive in Drift and reload on the next `initialize()` call. - Remove the orphaned `attachmentUploadQueueProvider` (nothing in lib/ or test/ ever read it) and its now-unused import from `app_providers.dart`. - Add regression tests in `test/core/services/attachment_upload_queue_test.dart` covering: timer stops after deactivate, processQueue no-op post-deactivate, queueStream stays open across deactivate/re-initialize, idempotent deactivate. * fix(attachments): resolve pending-upload futures and recover stale uploads on deactivate Addresses PR review (Greptile P1, Macroscope High, CodeRabbit Major): - deactivate() now cancels in-flight tokens and drives non-terminal queue items to cancelled with a notify, so queueStream listeners (upload completers) resolve instead of hanging after logout/server switch. - _load() resets persisted 'uploading' items to 'pending' so an upload interrupted by deactivate or an app crash is retried, not stranded. - Adds a regression test; clarifies Android permission wording in README. * fix(attachments): persist cancelled statuses on deactivate; docs + test Follow-up to PR re-review (Greptile P1, Macroscope Medium, CodeRabbit): - deactivate() now persists the cancelled statuses best-effort (guarded _save() before dropping the resolver) so items don't reappear as pending on reconnect to the same server. - Corrects the deactivate() doc comment to match the current behavior (in-flight tokens ARE cancelled). - Test now synchronizes on upload start and releases the hanging completer in teardown instead of relying on a bare microtask. * refactor(attachments): provider-owned upload queue with deterministic lifecycle Replaces the AttachmentUploadQueue singleton + deactivate()-on-listen approach with a per-server instance owned by attachmentUploadQueueProvider, closing the teardown persistence race Greptile flagged: - Drops the singleton; the queue is a plain per-server object. - attachmentUploadQueueProvider (keepAlive FutureProvider) constructs it, awaits initialize() so consumers that await .future get a fully-loaded queue (no enqueue-before-load race), and disposes it via ref.onDispose on server change. - dispose() cancels in-flight tokens and CLOSES the stream; MediaUploadController reads the queue from the provider (awaited) and resolves its completer via onDone when the stream closes — so no upload future hangs on server switch. - No teardown DB write, so the 'cancelled rows race db-close' window is gone: the previous server's rows stay in its Drift table and resume when it is next active. Rewrites the lifecycle tests accordingly. Full flutter analyze clean; 615 tests pass across core/services + core/providers. * fix(attachments): instance-owned ready future + keep temp file for retry Addresses two review findings on the lifecycle refactor: - Macroscope High: replace the async FutureProvider with a sync Provider plus a queue-owned `ready` future. Awaiting `queue.ready` (instead of a `FutureProvider.future`) cannot hang if the provider rebuilds mid-init on a server switch, since the future belongs to the instance, not the provider. - Macroscope Medium: the dispose `onDone` path no longer calls cleanupTemp(), so the converted-image temp file the kept-for-retry row points at survives (an interrupted upload can resume; the success/cancel paths still clean up). - Adds a test that `ready` resolves even when disposed mid-initialization. flutter analyze clean; attachment queue tests pass (5/5). * fix(attachments): throw on enqueue-after-dispose; swallow init failures Addresses PR review of the lifecycle refactor: - enqueue() now throws StateError if the queue was disposed, so a stale caller gets a loud failure instead of an item that _save/_notify/_processSafe all skip (which looked enqueued but was silently never persisted or uploaded). Macroscope + CodeRabbit both flagged this. +regression test. - _initInternal() wraps its load in try/catch so a Drift load failure logs and degrades to an empty queue instead of surfacing as an uncaught async error from the provider's fire-and-forget initialize(). * fix(attachments): propagate init failures and dispose queue on logout Final PR re-review fixes: - _initInternal logs then rethrows load failures, so queue.ready rejects and MediaUploadController aborts before enqueue; _load stages the full read and conversion before replacing _queue, preventing a failed load followed by _save() from clearing persisted attachment rows. - The provider attaches an immediate error-consuming branch to the init future (avoids uncaught fire-and-forget errors) while queue.ready retains the original rejecting future. - Gate attachmentUploadQueueProvider on isAuthenticatedProvider2. Logout flips it false before its first await even though activeServer/apiService are preserved, so the queue is disposed immediately: retry timer stops, in-flight tokens cancel, and the stream closes. - Adds a load-failure regression test preserving the existing snapshot. Full flutter analyze clean; 618 tests pass across core/services + core/providers. |
||
|
|
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.) |