Commit graph

78 commits

Author SHA1 Message Date
cogwheel
f80f79352e
feat(openwebui): support v0.11.1 (#667) 2026-08-26 18:41:29 +05:30
cogwheel
3e7e3c0aaa
Fix voice call speech and default new calls to the speakerphone (#650)
* fix(voice): keep reasoning blocks out of speech and stop skipping the answer

Voice mode speaks the assistant content as it streams, and since the
streaming merge started preserving local <details> wrappers, TTS only
stripped blocks that were already closed. An open reasoning or tool_calls
wrapper was read aloud, and once its </details> landed the block vanished
from the split, shifting every later chunk left past the monotonic chunk
cursor. The answer itself was then never spoken.

TTS now sanitizes through the shared semantic_details helpers and treats an
unterminated semantic opener as a hard stop, so the speakable text only ever
grows. The streaming feed re-anchors on the text of the last chunk it handed
to playback instead of trusting a bare index.

* feat(voice): start calls on the speakerphone when nothing is plugged in

A voice call runs the audio session in communication mode, so a phone with
no accessory attached routes playback to the earpiece. Held like a call it
is fine, held like a speakerphone it is barely audible.

The coordinator now scans the attached audio devices at call start and
engages the loudspeaker only when it finds nothing to play through. A failed
scan leaves the route alone, since blaring an answer over someone's headset
is worse than a quiet earpiece. It keeps watching for the rest of the call,
so pulling headphones out moves playback to the speaker and connecting a
headset takes it back off. Pressing the speaker button ends the automatic
switching for that call.

* fix(voice): serialize audio route and TTS feed changes

Route changes are several platform calls deep, so two of them running at
once interleaved and the slower one got the last word. A headset pulled
out during a reroute could leave the call on the earpiece, and an
automatic reroute could land after the user pressed the speaker button or
after the call ended.

Queue route changes one at a time and re-read the state each one assumed
before it applies, so a stale reroute stands down instead of overwriting
a newer decision. Deactivation drains the queue before tearing the route
down.

Streaming TTS feeds had the same shape of problem: a second feed could
append its chunks in between the ones an earlier feed was still handing
to playback, so sentences could be spoken out of order. Chain the feeds.

* fix(voice): catch uppercase semantic details wrappers

The complete-block pattern in the TTS sanitizer already ignored case, but
the shared opener and block patterns did not, so an uppercase
`<DETAILS TYPE="reasoning">` that had not closed yet was read aloud.
Tag and attribute names are case-insensitive in HTML and this is model
output, so match either case everywhere.

* fix(voice): ignore a default route scan that came back too late

The accessory scan at the start of a call can still be out when the user
presses the speaker button or hangs up. Its result would then resubscribe
to device changes and push the call onto the loudspeaker after teardown.
Stamp the scan with a call generation and drop the answer if the call it
belongs to is over or the user has since chosen a route.

* fix(voice): only treat routes a call can actually use as accessories

A voice call runs the session in communication mode, which cannot route
to A2DP or AirPlay, so a device offering only those is not somewhere the
call can play and should not suppress the loudspeaker default. Headsets
that also speak HFP still register as bluetoothSco.

Accessory detection also required only a matching device type, so a
plugged-in microphone counted as somewhere to play. Require an output.

Manual speaker toggles are rejected once teardown has started, where
honouring them would put communication mode back after the route was
handed back.

* fix(voice): anchor the speech cursor on spoken text, not one chunk

The cursor was re-anchored by searching the fresh split for the text of
the last chunk handed to playback. When an answer repeats a sentence, that
search can match the wrong copy and skip everything in between, and when
the server rewrites the answer the anchor disappears and the cursor stops
moving at all.

Carry the text already spoken instead and walk the new split against it.
The match is positional, so repeated sentences are unambiguous, and a
rewrite resumes at the point the two versions stop agreeing rather than
replaying or skipping.

* fix(tts): strip nested details blocks before speaking

A non-greedy `</details>` match stops at the first close tag, so a
wrapper nested inside another left the outer block's tail in the text
handed to TTS: `<details><details>x</details>secret</details>` spoke
`secret`. Replace the pattern with a depth-counting walk shared from
`semantic_details.dart`, which also keeps the existing behaviour of
withholding the tail of a semantic wrapper that has not closed yet.

* fix(voice): re-check the route owner before publishing a reroute

The speaker button can be pressed, or the call can end, while the
platform calls behind an automatic reroute are still in flight.
Publishing afterwards left the speaker control showing a route nobody
chose.

* fix(tts): keep scanning inside an unclosed ordinary details

An ordinary `<details>` still waiting for its close tag kept the whole
suffix, so a reasoning wrapper opened inside it reached TTS. Keep the
tag and carry on from just past it: nested complete blocks are stripped
and a nested open wrapper still truncates.

* fix(voice): let a newer device event own the published route

A device event that lands while an earlier reroute is mid-flight claims
_accessoryAttached before queueing its own work. The earlier reroute
then published its now-stale value, so the speaker control showed the
old route until the newer operation caught up.

* fix(tts): speak on the call route during a voice call

Android routes voice-communication and media output separately. Device
TTS spoke as USAGE_MEDIA while the call held focus as
USAGE_VOICE_COMMUNICATION in MODE_IN_COMMUNICATION, so it ignored the
call's speakerphone choice and went silent once the app was backgrounded
mid-call.

The engine now gets voice-communication audio attributes (and the
matching legacy stream param) while a call is up, and goes back to the
media stream for read-aloud.

* fix(voice): only report a route change the platform took

A refused reroute still published to speakerphoneRouteChanges and stuck
in _speakerphoneEnabled, so the speaker button pointed at a route nobody
was hearing and the enabled == _speakerphoneEnabled guard dropped the
next identical device event as already handled.

The route calls now answer whether they landed, the flag rolls back when
they did not, and neither the device-change path nor the user toggle
publishes without a successful move.

* fix(tts): hand the engine back to read aloud after a call

Disposing the voice-mode provider mid-call left TtsManager in voice-call
mode, so a later read-aloud spoke on the call route: earpiece, at call
volume. Provider disposal now clears the flag, and so does reset().

reset() also stops rewinding _sessionCounter. A feed queued on the
previous chain only checks the active session's id, so reusing an id let
it append its old text to the next session, and the id names the server
chunk temp dir and background lease that the same stale work tears
down.

* fix(voice): light the speaker button only on a confirmed route

The default route is picked before the audio session exists, so the move
to the loudspeaker happens on the next configureFor* pass. The snapshot
was set from the pick, not the move, so a refused reroute still lit the
speaker button while the call stayed on the earpiece.

applyDefaultSpeakerphoneRoute now only sets the preference. The
configure pass reports whether the platform took it and announces it on
speakerphoneRouteChanges, which is already the one path the snapshot
follows. A refused move puts the flag back so the next device event can
try again.

* fix(voice): queue configure-pass routing behind the other reroutes

The listening, speaking and barge-in passes made the same platform route
calls as the button and device-change reroutes, off the same
_speakerphoneEnabled flag, without going through _routeSerial. A pass
that started before a headset was pulled out could finish after the
reroute and put the call back on the route it had just left.

They now queue with everything else, so each one reads the route
decision when it runs rather than when it was scheduled, and the default
route is only announced while the loudspeaker is still the current
choice.

* test(tts): assert with package:checks

The rest of the suite asserts with package:checks; this file was the odd
one out on expect(), which a review flagged while reading the new voice
call tests.

* fix(tts): stop a rewrite replaying the sentences it left alone

The cursor resumes at the point the old and new splits stop agreeing, so
a server that revises one sentence in the middle of an answer queues
everything after it a second time. The listener hears the tail twice.

Skip forward through the text playback already heard, in order, and only
speak the chunks that are not in it. Scanning forward rather than
searching the whole string keeps a sentence that genuinely repeats later
in the answer spoken once for each time it appears.

* fix(tts): hold the session open until the last feed lands

finishStreaming marks the response finalized before its own feed reaches
the serial chain. If the engine finishes the chunk it is speaking in that
window, playback sees a finalized session with nothing left queued, ends
it, and the feed carrying the rest of the answer finds no session to
append to. The answer stops mid-sentence.

Count the feeds still queued or running and treat playback as waiting
while any remain.

* fix(voice): keep automatic routing after a refused speaker press

The speaker button claimed the route before the platform had taken the
move, and kept the claim even when the move was refused. Nothing had
changed, but the call ignored every accessory event from then on: plug a
headset in afterwards and it stayed on the old route.

Count presses that are queued or on the wire so an automatic reroute
behind one still stands down, and only make the override permanent once
the platform reports the move applied.

* fix(voice): release the call route when stopping tts throws

The engine is shared with read-aloud, and the hand-back sat after the
stop call in the same teardown step. A stop that threw skipped it and
left read-aloud speaking on the call route. Give it its own step.

* fix(voice): hand the route back when the coordinator is disposed first

Riverpod gives no order to provider disposal, so the coordinator can go
before the controller that would have called deactivate. Its dispose only
cancelled the device watch, leaving the phone in communication mode with
the call's route still selected.

Disposal now runs the same teardown as hanging up, and that teardown is
safe to run twice. Session activation also joins the route queue, so a
configure pass already in flight cannot reactivate the session after
teardown drained the queue, and a default-route scan started during a
teardown is rejected instead of riding its generation bump through.

* fix(voice): keep the route shut once the coordinator is disposed

Teardown lifts the shutter again so the next call can route. A disposed
coordinator has no next call, so a deactivate arriving behind disposal
left the speaker button able to put the phone back into communication
mode after the coordinator was gone.

* fix(voice): retry an accessory move the platform refused

The transition is claimed in the accessory snapshot before the route
operation starts, so the burst of events a single headset sends collapses
into one move. A refused move left the claim standing, and since the
hardware never moved there is no fresh transition to come: the next
notification about the same headset matched the snapshot and was dropped
as old news, stranding the call on its previous route.

* fix(voice): keep a stale configure pass and overlapping teardowns apart

Session configuration is awaited before the activation step joins the
route queue, so a teardown that both starts and finishes inside that
window puts the shutter back up and the queued step reactivates a call
that is over. Each pass now carries the call generation it was started
for and stands down when it no longer matches.

Overlapping teardowns had the same shape: hanging up and disposal each
ran their own finally, and whichever finished first lifted the shutter
while the other was still restoring the platform route. The shutter now
waits for the last one out.

* fix(tts): keep heard sentences the held-back chunk hides

Mid stream the trailing chunk stays put until finalization, so a rewrite
arriving then leaves the sentences behind it out of the returned spoken
text. Finalization has nothing left to match them against and queues them
again. Carry the unconsumed heard history along instead.

* fix(voice): retry a default route the platform refused

A bare phone repeats the same device list rather than announcing a
transition, so the snapshot the refused default was picked from makes the
next notification look like old news and the call stays on the earpiece.

* fix(tts): stop a stale feed holding the next session open

A feed already inside the engine's speak call outlives the session that
queued it. Its count carried over to the next session, which then waited
for a chunk that was never coming and never finished speaking.

* fix(voice): drop a speaker press the teardown overtakes

A press queued before the call ended still reached the platform behind the
teardown, and answered the caller that the route had moved.

* fix(tts): hold server completion for a feed still on its way

finishStreaming marks the response finalized before its own feed reaches
the chain. The device path already waits that window out; the server path
ended the session there instead and dropped the rest of the answer.

* fix(tts): keep a stale fetch off the next session's bookkeeping

A fetch that outlives its session cleared the marker the next session had
put down for the same chunk index.

* fix(tts): keep a dead session's fetch error to itself

A failed fetch from a session that is over was reported as an error
against whatever is playing now.
2026-08-25 11:50:36 +05:30
Kamil Maciąg
a376e617ce
feat(l10n): add Polish localization (#617)
* feat(l10n): add Polish localization

- add complete Polish Flutter localization (app_pl.arb) mirroring app_en.arb
- add pl to preferred-supported-locales, language selector, and native language sheets
- add Polish iOS native resources (pl.lproj) and Xcode project registration
- add Polish release notes and language count documentation
- add focused tests for locale resolution, resource completeness, and Polish plurals

* fix(l10n): address Polish localization review feedback

- localize the 'polish' language name per catalog (Polish/Polnisch/Polonais/Poľština/...)
- label the credentials auth mode 'E-mail i hasło' instead of 'Hasło'
- pluralize the active-schedule count in hermesSchedulesSummary (1 aktywny / 2 aktywne / 5 aktywnych)
- use feminine 'Poprzednia' for the previous-version action label
- replace literal 'szybkie pigułki' with 'szybkie akcje'
- reword server-downgrade instruction, shared-chat notice, and direct-connection delete message
- use the official product name 'Open Web UI' in user-facing values
- make the placeholder parity test robust to Dart identity-based set/list equality

* fix(l10n): address remaining Polish review feedback

- use a username-inclusive label 'Dane logowania' for the credentials auth mode
- reword the prompt version delete confirmation to 'Ta wersja zostanie usunięta'
- keep channelMembers plural forms unchanged (with digits, '2 członków' is correct, not '2 członkowie')

Assisted-by: Open WebUI

* fix(l10n): rebase Polish catalog onto current English keys

Polish was complete against an older template; fill the 134 strings added on main (Hermes jobs/sessions, voice barge-in, sidebar) so locale validation can pass after merge.

---------

Co-authored-by: Kamil Maciąg <6450912+Dragonk@users.noreply.github.com>
Co-authored-by: cogwheel <172976095+cogwheel0@users.noreply.github.com>
2026-08-20 22:12:00 +05:30
cogwheel
f79ccf9c51 Use system fonts and preserve streaming state 2026-08-20 22:03:38 +05:30
cogwheel
ea82ee164e
perf: chat scrolling and markdown rendering (#640)
* perf: stop shell rebuilds cascading into rows and cut per-flush markdown cost

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

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

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

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

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

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

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

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

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

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

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

* fix: deferred structured-output projections dropping response content

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* revert: drop the speculative terminal-finalize reconciliation

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

* fix: keep the settle transition extent-neutral

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

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

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

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

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

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

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

* style: complete the dart format migration repo-wide

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

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

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

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

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

* fix: address Hermes Desktop review feedback

* fix: harden Hermes review edge cases

* fix: disambiguate restored decision IDs

* fix: apply remaining review feedback

* fix: restore partially written Hermes principal

* test: assert Hermes runtime rollback

* fix: add contrast to Hermes model avatar

* fix: adapt Hermes avatar to theme

* fix: surface Hermes Desktop models

* fix: show Hermes avatar in model suggestions

* fix: keep Hermes Stop enabled during local streams

* fix: correct Hermes model selectors

* fix: refresh Hermes model selector state

* fix: simplify Hermes agent model name
2026-08-16 19:51:36 +05:30
cogwheel
332f0c75bb
feat: unify app surfaces with native grouped design (#628)
Some checks failed
L10n / l10n (push) Has been cancelled
Squash-merge the reviewed native UI, sidebar, onboarding, direct connection, Hermes, workspace, theming, haptics, and consistency updates.
2026-08-15 18:37:46 +05:30
cogwheel
76412a748b
Fix voice calls for Direct-only setups (#605)
* fix(voice): support accountless call entry points

* fix(voice): address review feedback

* fix(voice): preserve startup ownership

* fix(voice): make startup cancellation-safe

* fix(voice): preserve first-turn ownership

* fix(voice): preserve launch ownership

* fix(voice): cancel stale startup promptly

* fix(carplay): preserve concurrent call ownership

* fix(carplay): scope disconnect ownership cleanup
2026-08-01 12:01:42 +05:30
cogwheel
4539a7d5ab
Support Open WebUI 0.11 (#594)
* feat: support Open WebUI 0.11

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

* fix: address Open WebUI 0.11 review feedback

* fix: keep empty-state scroll viewport accessible
2026-07-27 22:18:45 +05:30
cogwheel
bdeb1a0057
feat: add first-party OpenRouter support (#589)
* feat: add first-party OpenRouter support

* fix: address OpenRouter review feedback

* fix: preserve OpenRouter server auth options

* fix: localize reviewed catalog entries

* feat: normalize OpenRouter reasoning effort

* fix: validate raw OpenRouter PDF filenames

* fix: refine connection setup and reasoning defaults

* fix: keep selected models visible in picker

* fix: restore OpenRouter image generation defaults

A review fix conflated native image output modalities with OpenRouter server-tool eligibility. Restore the image tool for every device-owned Chat Completions model, localize the provider label, and add a persisted Personalization override for the image model.

* fix: preserve image model during settings hydration

Wait for the pending preferences load before applying the OpenRouter image-model override so a startup write cannot be replaced by stale persisted state.

* fix: localize OpenRouter image settings

Translate the new image-model preference in every supported locale and complete the German, Korean, and Dutch OpenRouter settings strings identified during review.

* fix: correct Dutch OpenRouter description

* fix: expose image model in native Chats sheet

The setting was added to an unreachable legacy Personalization detail while the live iOS profile menu routes model defaults through Chats. Build the visibility row from one tested helper and include it in the active Chats detail.

* fix: buffer OpenRouter image tool completions

Image server-tool child jobs can succeed before the parent streaming continuation fails, and a committed SSE response cannot fail over. Buffer only image-tool requests so OpenRouter returns the complete markdown image result while all other completions remain streamed.

* fix: decode OpenRouter generated image results

The buffered image-tool fix still assumed generated images lived in message content, so successful message.images payloads were rejected and nested choice errors were masked. Normalize bounded image outputs into markdown, surface choice-level errors, and require the captured direct route to remain selected across preparation awaits.

* fix: keep OpenRouter images on continuation errors

The earlier decoder fix still prioritized choices[].error before message.images, and Gemini server tools can return both after generating successfully. Treat usable OpenRouter images as completion while preserving top-level and image-less choice failures.

* fix: retain direct route ownership through preflight

The earlier route fix covered initial preparation but the later dispatch guard still accepted any live captured binding. Require the captured model to remain selected after attachment/message preflight, with a regression that keeps both bindings live while selection changes.

* fix: restore direct answer when regeneration preflight stops

A late model switch could cancel regeneration after its empty same-id placeholder had replaced the completed answer. Restore and persist the captured previous assistant on preflight cancellation, with a regression covering the UI and durable timeline.

* feat: use OpenRouter dedicated image API

Generate and persist image assets independently from parent narration. Prompt refinement and acknowledgement are best-effort, while dedicated Image API output remains authoritative across continuation and transport failures.

* fix: preserve image and route progress

Exclude immediate image durability writes from provider stream timeouts, and retain trusted binding authority across metadata-only catalog refreshes while still revoking edited or removed profiles.

* fix: harden OpenRouter image responses

* refactor: remove legacy OpenRouter image parsing

* fix: preserve completed answer across chained regeneration

A superseding direct regeneration could capture the first run's empty same-ID preflight placeholder, so a later cancellation restored and persisted an empty answer. Normalize pristine streaming placeholders back to their last completed version before building the next placeholder, while retaining any partial or generated output. Add a deterministic two-preflight regression covering visible and durable restoration.

* fix: stabilize OpenRouter follow-ups and source favicons

* fix: preserve follow-up timeline state across remounts

Keep the assistant shell stable when a live tail enters history so generated images do not collapse and re-expand above the pinned prompt. Remember the running haptic per message so list virtualization cannot replay feedback.

* fix: apply OpenRouter review feedback
2026-07-26 10:50:00 +05:30
cogwheel
c5e4998e9a
feat: expand native Ollama and Direct connection support (#587)
* feat: add native Ollama Cloud support

* feat: unify model and backend settings UX

* feat: polish direct backend model and composer UX

* fix: gate attachments by backend capabilities

* feat: add trusted direct document attachments

* feat: show Ollama web search sources

* fix: address PR review feedback

* fix: address follow-up review feedback

* test: use checks for reasoning effort coverage

* fix: harden model effort selection

* fix: surface effort persistence failures
2026-07-24 19:03:55 +05:30
cogwheel
df8eaa1ce3
feat: add direct OpenAI-compatible and Ollama connections (#567)
* feat: add direct provider connections

* fix: harden direct connection workflows

* fix: stabilize direct connection state

* fix: ignore failed direct image attachments

* fix: serialize direct profile reloads

* fix: recheck queued media upload routes

* fix: prune stale direct models during refresh

* feat: polish adaptive backend onboarding

* fix: restore explicit backend onboarding back paths

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

* fix: harden direct onboarding state

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

* fix: keep local backends independent after logout

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

* fix: preserve chat storage boundaries on recovery

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

* fix: retain legacy OpenWebUI chat ownership

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

* feat: migrate direct providers to typed SDKs

* fix: isolate direct transport and reject blank streams

* fix: hide stale direct model bindings

* fix: apply CodeRabbit auto-fixes

* feat: support Hermes attachments via Responses API

* fix: address direct and Hermes review findings

* fix: avoid OpenWebUI settings load in direct composer

* fix: preserve images during direct regeneration

* fix: preserve repeated direct images across turns

* fix: settle direct reasoning and Android chrome

* fix: harden multi-backend streaming ownership

* fix: harden multi-backend recovery lifecycles

* fix: close adversarial multi-backend edge cases

* fix: separate Responses reasoning items

* fix: preserve streamed reasoning boundaries

* fix: address final multi-backend review findings

* fix: close remaining security review findings

* fix: address PR review findings
2026-07-14 22:18:12 +05:30
cogwheel
6430a31851
Add native Workspace and unify settings navigation (#566)
Adds capability-aware Workspace management for Models, Knowledge, Prompts, Skills, and Tools, and unifies settings navigation across Android and iOS. Includes native-sheet routing fixes, adaptive Workspace back navigation, Hermes settings integration, localization, and regression coverage.
2026-07-13 03:10:14 +05:30
cogwheel
a5a8e5d1c2
Warn instead of blocking newer Open WebUI servers (#564)
* fix: warn instead of blocking newer servers

* fix: keep compatibility warning across app routes

* fix: retain verified server compatibility state

* fix: avoid stale compatibility config writes
2026-07-11 00:17:45 +05:30
cogwheel
d5779e88fb
Bump max supported Open WebUI version to 0.10.2 (#547) 2026-07-02 00:48:43 +05:30
cogwheel
c149c6e686
feat: gate app on unsupported Open WebUI server versions (#545)
* feat: gate app on unsupported Open WebUI server versions

Refuse to operate against Open WebUI servers newer than this build is
known to support (max 0.10.1) and surface a clear "downgrade the server
or wait for an app update" message, instead of failing in confusing ways
deep inside features when the upstream API drifts.

Enforced at two points:
- Connect time (direct + reverse-proxy flows): probe /api/config and, if
  the version is unsupported, show a blocking dialog and abort without
  saving the server.
- Runtime/restored sessions: a router redirect gate gates every in-app
  route to a blocking ServerIncompatiblePage while the active server is
  incompatible (covers servers upgraded after connecting). The
  server-connection route stays reachable so users can switch servers,
  and the gate auto-clears once a supported version is reported.

Details:
- ServerVersionCompat: pure, unit-tested comparator; tolerates a "v"
  prefix and -dev/+build suffixes; fails open on unknown/unparseable
  versions to avoid false lockouts.
- BackendConfig.version captured from /api/config and round-tripped
  through the local cache.
- serverIncompatibleProvider derives the gate state; RouterNotifier
  listens to it.
- Reviewer/demo mode bypasses the gate.
- New strings localized across all 12 supported locales.

To support a newer server later, bump ServerVersionCompat.maxSupportedVersion
after validating against openwebui-src/.

* fix: address PR review — don't trap users on the compatibility gate

Resolves the P1 flagged independently by Macroscope and Greptile, plus a
CodeRabbit maintainability nit.

- BackendConfigNotifier: the cached backend config (and its version) is
  global, not per-server. On a genuine active-server switch (A->B), drop
  the stale config (fail open) and re-fetch against the new server, so a
  previous server's version can't keep the gate up. Tracks the last
  non-null server id so the switch is detected even when activeServer
  passes through a transient loading/null state on invalidate.
- Router gate: allow the full auth/connection flow through the gate (not
  just server-connection) so the "use a different server" recovery can
  actually reach the sign-in page while the old, unsupported server is
  still active. The connect-time gate still prevents authenticating into
  an unsupported server.
- server_connection_page: extract the duplicated version-compat refusal
  into a single _refuseIfServerIncompatible helper shared by the direct
  and reverse-proxy flows; drop the redundant _isConnecting reset (the
  caller's finally handles it).

Greptile's P2 (corrupted zh_Hant title) was a false positive — verified
no U+FFFD replacement characters in any ARB file.

* fix: make the compatibility gate server-aware (PR review round 2)

The previous fix used a fragile active-server-change listener that the
re-review showed was racy and incomplete (Macroscope High, CodeRabbit
Major, Greptile P1). Replace it with server-id tagging, which is robust
against every stale-config path:

- BackendConfig gains a serverId, set in _loadBackendConfig to the active
  server it was fetched from and persisted in the cache.
- serverIncompatibleProvider now watches activeServerProvider and gates
  only when the cached config's serverId matches the active server id;
  otherwise it fails open. A stale config from a previously-active server
  — left over after a switch, an out-of-order refresh, or restored from
  disk on a cold start — can no longer trap a supported server on the
  gate. Removed the listener and its saveLocalBackendConfig(null) race.

- Router gate (Macroscope Medium): narrow the exemption so an in-progress
  connection/auth flow is allowed through the gate only when it targets a
  DIFFERENT server than the active (unsupported) one. Re-authenticating
  into the same unsupported server now stays gated instead of being
  waved through on /authentication, /sso-auth, etc.

Adds server_incompatible_provider_test.dart covering matching/mismatched/
untagged/absent configs.

* fix: gate legacy untagged caches + canonicalize recovery URLs (review r3)

- serverIncompatibleProvider (Macroscope Medium): a backend config restored
  from a pre-tagging app version has a null serverId. Failing open on null
  let an unsupported server through on cold start. Treat a null serverId as
  the active server's config so it still gates; only an explicitly
  different (non-null) serverId fails open. Fresh configs are always tagged,
  so this can't reintroduce the server-switch trap.
- _isConnectFlowToDifferentServer (CodeRabbit Minor): canonicalize URLs
  (trim, strip trailing slash, lowercase) before comparing, so the same
  server entered/stored with a trailing slash or different case isn't read
  as a different server and granted the gate exemption.
- Tests updated: a null-serverId (legacy) config now gates an unsupported
  active server and stays open for a supported one.

Macroscope's "serverConnection loops back to the gate" High is a false
positive: the gate returns null for Routes.serverConnection before the
authenticated-user redirect at lines 142-147, so that branch is
unreachable while incompatible.

* fix: gate only on a config confirmed for the active server (review r4)

Resolves Greptile P1: a legacy untagged cache (null serverId) could trap a
supported server on the incompatibility gate when a refresh was pending or
failed after switching away from an unsupported server.

This is the deliberate counter-decision to the earlier Macroscope Medium
(which wanted null serverId to gate). The two concerns conflict only for an
unattributable legacy cache, and failing open is the safer side:

- gate only when config.serverId == active server id (a config the refresh
  confirmed came from this server);
- a different non-null serverId is stale-after-switch -> fail open;
- a null serverId is a pre-tagging cache we can't attribute -> fail open.

The cost is that right after upgrading the app while on an unsupported
server, the gate stays open until the build()-time refresh returns a
freshly-tagged config (~one round-trip). That brief, self-healing delay is
preferable to risking a false lockout from a valid server. Documented inline
so this doesn't oscillate.

Test updated: an untagged legacy config now fails open.
2026-06-30 23:48:19 +05:30
cogwheel
0976724146
Add Open WebUI 0.10 compatibility (#543)
* Add Open WebUI 0.10 compatibility

Update Conduit for newer Open WebUI API and streaming behavior while preserving legacy fallbacks for older servers.

* Fix Open WebUI compatibility review findings

Address PR review edge cases around idempotent toggles, knowledge fallbacks, structured output merging, and task-socket recovery semantics.

* Address follow-up review edge cases

Handle legacy knowledge prefetch failures, bound pagination, harden code-interpreter fences, and keep structured streaming plain text synchronized.

* Preserve structured output on assistant versions

Keep parsed structured output attached to alternate assistant versions when serializing chat history back to Open WebUI.

* Tighten structured output recovery edge cases

Refine recovery completion inference, stale content handling, detail-only rendering order, and pending tool-call state from review feedback.

* Harden remaining Open WebUI edge cases

Avoid retrying stateless toggles, bound tag pagination, narrow legacy fallbacks, and support custom tool-call outputs from structured responses.

* Assert preserved version output payload

Tighten the structured-output regression test to verify the full assistant-version payload.

* Avoid provider refresh during chat build

Lazy-read voice input outside initState and avoid footer subscriptions that can schedule provider refreshes while assistant rows are building.

* Address remaining review edge cases

Handle wrapped chat toggle state, avoid stale rendered-details reuse, preserve newer structured text, and harden structured-output parsing for reasoning and terminal statuses.

* Preserve plain content during detail cleanup

Restrict semantic detail stripping to renderer-owned blocks and keep modern knowledge-file text from nested data payloads.

* Clean up latest review edge cases

Recognize more upload ID shapes, clear stale semantic details for plain outputs, and seed rendered tool-call keys to prevent duplicate placeholders.

* Avoid duplicate knowledge uploads

Stop falling back to legacy uploads after ambiguous modern upload success, narrow attach 400 fallbacks, and preserve nonsemantic content when removing stale generated details.

* Tighten stale detail and upload edge handling

Expand modern upload ID extraction and keep newer plain snapshots from being overwritten by stale stripped streaming text.

* Handle final knowledge API edge cases

Fallback on non-JSON knowledge file responses and constrain upload file-id extraction to file-shaped payloads.

* Tighten final API review fixes

Avoid ambiguous upload ID extraction and surface unconfirmed conversation toggles instead of reporting success.

* Fail fast on unconfirmed API outcomes

Preserve audio defaults, reject failed knowledge deletes, and avoid accepting ambiguous upload IDs or mismatched toggle confirmations.

* Preserve API config defaults during audio merge

* Tighten knowledge fallback and upload IDs
2026-06-30 21:51:14 +05:30
cogwheel
efc893d584
Add native voice pipeline and voice UI polish 2026-06-24 18:53:06 +05:30
cogwheel
5de99c65d5
refactor(persistence): move preferences from Hive to shared_preferences (PR-1) (#516)
* refactor(persistence): move preferences from Hive to shared_preferences (PR-1)

First of three PRs removing Hive. Routes simple config off the Hive
`preferences_v1` box onto shared_preferences, keeping the synchronous read
ergonomics the app relies on at build time.

- PreferencesStore: a static, preloaded legacy SharedPreferences wrapper
  (sync getters). Preloaded in main.dart before runApp so theme/locale/settings
  reads stay synchronous with no cold-start flash.
- Re-point all preference readers/writers: SettingsService, OptimizedStorageService
  (theme/palette/locale/reviewerMode/activeServerId), sidebar/drawer notifiers,
  current_localizations, and _FeatureAvailabilityCache (serverFeatureAvailability
  now a JSON string with a static decoded cache).
- androidAssistantTrigger now writes only to shared_preferences; native
  ConduitVoiceInteractionSession already reads flutter.android_assistant_trigger,
  so the Hive dual-write is dropped.
- transport_options moves to a per-server shared_preferences key
  (transport_options:<base64 serverId>) to preserve its synchronous read and
  avoid socket churn.
- HivePrefsMigrator: one-time, gated, crash-safe copy of the Hive prefs box +
  transport-options slot into shared_preferences (overwrite, flag-last). The old
  PersistenceMigrator's reverse prefs path is neutered so it can't clobber/loop;
  it still migrates caches/queues (still Hive-backed until PR-2). Hive stays
  installed (read-only) to source legacy data; the dependency drops in PR-3.

clearAll preserves the migration gate so a wipe can't re-import stale Hive prefs.

Tests: PreferencesStore-backed rewrites of settings/storage/feature-flag/migrator
tests + a new HivePrefsMigrator test. flutter analyze clean; full suite green.

* refactor: pure-read feature-flag cache, single deep-copy on write

Greptile PR-1 feedback on _FeatureAvailabilityCache:
- Reads now use the cached map directly (no per-call shallow copy); _readFeature
  takes the already-fetched map, so a write no longer triggers redundant
  _allFlags() decodes per cache key.
- Writes build ONE deep copy of the cached map, mutate it, then replace the
  cache — eliminating the shared-nested-map (shallow-copy) hazard. _cachedMap is
  now documented as read-only.
2026-06-19 21:06:51 +05:30
cogwheel
a24f993e66
Offline-first persistence with Drift (#508)
* feat(persistence): CDT-RFC-001 Phase 0 — drift foundations, blob mapper, golden fixtures, fake server harness

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Full suite 1911 green, analyze clean.

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

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

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

Full suite 1911 green, analyze clean.

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

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

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

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

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

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

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

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

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

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

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

Full suite 1913 green, analyze clean.

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

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

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

Full suite 1914 green, analyze clean.

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

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

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

Full suite 1914 green, analyze clean.

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

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

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

Full suite 1914 green, analyze clean.

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

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

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

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

Full suite 1915 green, analyze clean.

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

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

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

Full suite 1915 green, analyze clean.

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

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

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

Full suite 1916 green, analyze clean.

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

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

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

Full suite 1916 green, analyze clean.

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

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

* fix(sync): park malformed folder delete ops

* perf(sync): avoid redundant pull work

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

* fix(sync): retry task queue migration failures

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

* fix(sync): guard corrupted chat merge cycles

* fix(sync): defer stub chat update pushes

* fix(sync): stop retrying missing folder updates

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

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

* fix(sync): remove stale note conflict claims

* fix(sync): dedupe pending chat deletes

* fix(sync): close note create remap window

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

* test(sync): cover fts build retry failure

* fix(sync): prevent recursive note conflicts

* fix(sync): preserve dirty stubs during pull

* fix(sync): drop local note tombstones

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

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

* fix(sync): close chat create remap window

* fix(sync): park malformed completion ops

* docs(sync): update create heal lock comment

* fix(sync): address CodeRabbit review findings

* fix(sync): preserve folder parent edits

* fix(sync): use remapped folder outbox id

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

* fix(sync): skip tombstoned note creates

* fix(sync): merge coalesced folder upserts

* fix(sync): resolve CodeRabbit follow-ups

* fix(sync): crash-heal note creates

* docs(sync): clarify note create lock ordering

* fix(sync): bind cycles to dependency epoch

* fix(search): make fts backfill idempotent

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

* perf(sync): bound outbox and search scans

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

* chore(sync): tidy note search review items

* fix(sync): refresh coalesced note create hash

* fix: address CodeRabbit review findings

* fix(sync): assert coalesced note row invariant

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

* fix(sync): clamp null current message ids

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

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

* fix(sync): fail malformed note list pages

* fix(sync): skip malformed note list slots

* fix(sync): atomically reassert chat merge pushes

* fix(sync): address greptile review feedback

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

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

* Fix CodeRabbit sync review issues

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

* Fix CodeRabbit drain migration issues

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

* Fix CodeRabbit replay and remap issues

* fix(sync): refresh coalesced chat create hashes

* address greptile review feedback (greploop iteration 1)

* fix(sync): guard deferred pulls and completions

* fix(sync): prevent duplicate headless completions

* fix(sync): address CodeRabbit review feedback

* fix(sync): isolate outbox queue domains

* fix(sync): cleanly cancel pending completions

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

* fix note pull pagination after malformed pages

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

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

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

* fix(sync): guard reconcile pagination

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

* Sandbox inline web embeds

* fix(sync): harden note merge edge cases

* fix(sync): drop stale note pin ops

* fix(sync): claim create heals before remap

* fix(sync): preserve migrated attachment urls

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

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

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

* fix(sync): preserve note conflict metadata

* fix(sync): remap open note routes

* fix(sync): harden note remap edge cases

* fix(sync): handle archive confirmation races

* fix(sync): purge orphaned folder outbox ops

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

* fix(sync): mirror chat pin races

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

* fix(sync): clarify outbox remap coverage

* Address CodeRabbit review findings

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

* Address Greptile chat merge dedupe finding

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

* refactor: apply reviewed simplifications and edge-case fixes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: correct stale _claimAuthCommit comment (round 3)

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

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

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

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

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

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

* address greptile review feedback (greploop iteration 1)

* address greptile review feedback (greploop iteration 2)

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

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

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

* address greptile review feedback (greploop iteration 1)

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

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

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

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

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

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

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

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

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

* refactor: convergence cleanups (ultracode round 3)

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

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

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

* feat: route note mutations through the durable offline outbox

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

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

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

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

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

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

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

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

* fix: use the caller-captured session in _persistNoteUpdate

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: guard stale session on durable note delete success

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: claim own attempt revision in foreground silent login

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: keep the socket continuously available across manager rebuilds

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

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

Tests: socket_transport_availability_test (value equality).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: hoist noteById provider reads before the first await

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

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

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

Extract the fallback decision into a pure bootstrapShouldFallbackToUnauthenticated()
and refactor _bootstrapSilentLogin to use it, then assert the contract: the
bootstrap fallback to unauthenticated fires only when nothing committed and the
revision is unchanged in the loading/no-token state, and is suppressed by a
successful commit, a newer attempt bumping the revision, a published session,
or a restored token. (The success path itself is network-bound via an internal
ApiService and not unit-testable; this locks in the revision logic.)
2026-06-19 15:08:16 +05:30
cogwheel
e46570bfc7 feat(localization): add Czech and Slovak languages support
- Updated localization files to include Czech and Slovak language options.
- Modified l10n.yaml, Info.plist, and various .arb files to incorporate translations for Czech and Slovak.
- Enhanced native language handling in the app to support new languages in dropdowns and labels.
2026-06-08 11:09:07 +05:30
cogwheel
793d962952 feat(settings): add STT language code preference and UI integration
- Introduced support for configuring the speech-to-text (STT) language code, allowing users to specify a language for transcription.
- Updated `SettingsService` to handle normalization and persistence of the STT language code.
- Enhanced UI components in `AppCustomizationPage` and `AudioSettingsPage` to include a language picker for STT settings.
- Implemented logic to refresh native voice details upon changes to STT preferences.
- Added localization strings for STT language settings in multiple languages.
- Updated tests to cover new functionality and ensure proper handling of STT language code.
2026-06-06 17:10:07 +05:30
cogwheel
1866879d11 fix(ui): enhance system UI overlay style handling across multiple pages
- Introduced a utility function `systemUiOverlayStyleForBrightness` to streamline the application of system UI styles based on theme brightness.
- Updated `AppTheme` to utilize the new utility for setting system overlay styles.
- Refactored `AppStartupFlow` to determine brightness from the theme mode, improving consistency in UI appearance.
- Modified `AdaptiveAppBar` implementations in `ChannelPage`, `ChatPage`, `FolderPage`, and `NoteEditorPage` to apply the appropriate system overlay style based on the current theme, enhancing visual coherence across the app.
2026-06-06 15:05:43 +05:30
cogwheel
0f29ebbc24 refactor(native_sheet): integrate native sheet hydration service across chat and navigation components
- Replaced direct model loading logic with the new `nativeSheetHydrationService` in `ChatPage`, `FolderPage`, and `PersonalizationPage` to streamline model selection and improve code maintainability.
- Removed redundant model sorting logic from `ModelSelectorSheet`, leveraging the hydration service for model management.
- Updated imports to reflect the new service usage, enhancing clarity and reducing dependencies on outdated utilities.
- Ensured consistent handling of model selection across various UI components, improving user experience.
2026-06-04 21:46:59 +05:30
cogwheel
d8b161fa5a refactor(api): streamline conversation fetching and parsing logic
- Updated conversation fetching methods to utilize new summary parsing functions, improving clarity and performance.
- Refactored the handling of pinned and archived conversations to enhance data retrieval efficiency.
- Introduced normalization utilities for JSON data handling across various services, ensuring consistent data structures.
- Simplified error handling and data coercion processes in conversation parsing, enhancing robustness.
- Updated related tests to reflect changes in data structures and ensure comprehensive coverage.
2026-05-22 00:25:32 +05:30
cogwheel
8c1a14f4ae feat(tool-calls): enhance tool call handling and localization support
- Added `resultText` property to `ToolCallEntry` for storing raw unescaped result text.
- Implemented logic to extract body content from `<details>` blocks when the `result` attribute is absent.
- Introduced `MarkdownDetailsGroup` to group multiple tool calls, improving UI presentation.
- Updated localization files to support new titles for grouped tool call details in multiple languages.
- Enhanced tests to verify new functionality and ensure proper handling of tool call results.
2026-05-18 15:36:53 +05:30
cogwheel
c855a42e9c feat(localization): enhance localization support across various components
- Introduced localized strings for error messages, dropdowns, and UI elements in NativeDropdownBridge, NativeSheetBridge, and AppDelegate.
- Updated API error handling to utilize localized messages for better user experience.
- Refactored chat and thread components to incorporate localized labels and placeholders, improving accessibility and consistency.
- Added support for multiple languages by integrating Localizable.strings files into the project structure.
2026-05-15 11:48:01 +05:30
cogwheel
571a1acf95 feat(native-sheets): enhance NativeSheet with section support and new routes
- Introduced `NativeSheetSection` and `NativeSheetSectionConfig` to organize items into sections within the native sheet.
- Updated `NativeSheetConfiguration` to include sections, allowing for better categorization of menu items.
- Added new routes for appearance, chats, AI memory, and data connection in `NativeSheetRoutes`.
- Refactored `SidebarProfileAppBarLeading` to utilize sections for improved layout and organization of profile-related options.
- Enhanced hydration methods to support new detail configurations for AI memory and signal style settings.
2026-05-15 01:00:55 +05:30
cogwheel
230601566c refactor(auth): remove prefetchConversations method and clean up unused code
- Eliminated the _prefetchConversations method from AuthStateManager to streamline the authentication process.
- Removed redundant calls to _prefetchConversations in various login flows, enhancing performance and clarity.
- Cleaned up whitespace and formatting in several files for improved readability and consistency.
2026-05-14 23:27:31 +05:30
cogwheel
68cd8cb8be feat(native-sheets): integrate native sheet functionality across various components
- Added support for native sheets in the iOS app, enhancing user interactions in chat, navigation, and other features.
- Implemented native sheet presentations for options selectors, detail views, and share functionalities in multiple widgets.
- Updated the AppDelegate to configure new native sheet bridges for improved communication with the native layer.
- Refactored existing components to utilize native sheets, ensuring a consistent user experience across the application.
- Enhanced error handling and responsiveness for native sheet interactions, particularly on iOS.
2026-05-13 10:55:31 +05:30
cogwheel
334a7538de chore(dependencies): update pubspec and Podfile for package management
- Added `cupertino_context_menu_plus` version 1.0.3 to dependencies in `pubspec.yaml`.
- Removed deprecated packages including `device_info_plus`, `super_context_menu`, and others from `pubspec.lock`.
- Updated `Podfile` to remove unnecessary patching for `super_native_extensions`.
- Introduced a new utility file `message_tree_utils.dart` for improved message handling in the API service.
- Refactored message processing logic in `api_service.dart` and `conversation_parsing.dart` to utilize the new utility functions.
- Enhanced channel management UI with new dialog components for creating and editing channels.
2026-05-06 17:59:41 +05:30
cogwheel
f191efb815 refactor(citation): consolidate source reference handling and improve markdown rendering
Introduce SourceReferenceHelper to centralize URL extraction, domain parsing, and label formatting across citation badges and OpenWebUI sources widgets. Update citation parser to support suffix syntax (e.g., [1#foo]) while preserving numeric IDs. Enhance OpenWebUI source parser to prefer canonical URLs over metadata fallbacks and preserve first-occurrence labels for duplicate source IDs. Improve block renderer to correctly handle mixed inline and block content in list items.
2026-03-28 22:18:49 +05:30
cogwheel
fbc54ce9ce refactor(chat): use native markdown details blocks for reasoning and tool calls
Rewrites assistant message rendering to parse Open WebUI-style <details> blocks
directly in the markdown layer instead of maintaining parallel segment parsing.
This enables consistent rendering of reasoning, tool calls, and embeds through the
shared markdown pipeline with proper collapsible UI and localization support.

Adds embed support throughout the message lifecycle: parsing, API serialization,
and streaming. Also switches to webview_flutter_plus for enhanced WebView APIs.
2026-03-24 01:42:15 +05:30
cogwheel
4cbb8a623c feat: Implement voice call functionality with state management and audio session handling
- Added voice call models to represent call phases, failure reasons, and session state.
- Created infrastructure for voice call transport using socket service.
- Implemented background policy for managing call notifications and wake lock.
- Developed audio session coordinator for handling audio transitions during calls.
- Integrated permission orchestration for microphone and speech recognition.
- Added native call surface implementation using CallKit for iOS.
- Created voice input and output engines for speech recognition and text-to-speech.
- Developed voice call launcher for initiating calls from various entry points.
- Implemented tests for TTS manager, voice call controller, call state machine, and voice call page.
2026-03-02 21:47:21 +05:30
cogwheel
f3f997ce3a fix(knowledgebase): parsing for knowledge 2026-01-13 09:21:17 +05:30
cogwheel
5fd68f86fe refactor(markdown): remove deprecated stream formatter and enhance preprocessor 2025-12-22 14:07:04 +05:30
cogwheel0
5b7cd0dd42 feat(l10n): Add localization for code interpreter states 2025-12-10 18:16:04 +05:30
cogwheel0
6e4ee2acd3 feat(widget): Add citation badge for source references 2025-12-07 22:35:16 +05:30
cogwheel0
898f1773c7 feat(chat): Add prompt variables 2025-12-07 10:48:25 +05:30
cogwheel0
6b1ecff302 feat(reasoning): Add html_unescape and enhance reasoning parser 2025-12-04 15:05:20 +05:30
cogwheel0
c59a46d568 fix(chat): Improve server message synchronization and streaming recovery 2025-12-02 16:15:16 +05:30
cogwheel0
2ef49a2974 feat(voice-call): Improve socket connection and mic permission handling 2025-11-29 13:30:31 +05:30
cogwheel0
75ba0dc01d feat(chat): Add context attachment and knowledge base support 2025-11-26 22:19:19 +05:30
cogwheel0
6d56f5d160 feat(ios): Add App Intents support for Conduit interactions 2025-11-25 00:53:13 +05:30
cogwheel0
2d88519abe feat(ios): add ios shortcuts support 2025-11-25 00:08:51 +05:30
cogwheel0
65379aea1f feat(model-avatar): Update model icon resolution for OpenWebUI 2025-11-24 22:12:21 +05:30
cogwheel0
4822d1ed38 feat(profile): Add Android assistant trigger customization option 2025-11-24 15:07:46 +05:30
cogwheel0
8ed75f8f14 refactor: Remove _processScreenContext and directly set screen context in _handleMethodCall. 2025-11-22 12:01:17 +05:30
cogwheel0
1a6ec3f9ad feat(assistant): Improve screen context processing with model selection 2025-11-21 21:12:39 +05:30
cogwheel0
9d47c8a964 feat(voice-call): Implement direct voice call launch from assistant 2025-11-21 21:05:59 +05:30