Skip to content

fix(ui-web): stop the settings load repainting the conversation chip - #596

Merged
gloryfromca merged 1 commit into
refactor/ui_web_architecturefrom
fix/settings_load_keeps_session_model
Sep 21, 2026
Merged

gloryfromca merged 1 commit into
refactor/ui_web_architecturefrom
fix/settings_load_keeps_session_model

Conversation

@gloryfromca

Copy link
Copy Markdown
Member

Summary

The composer's model chip showed a model the conversation was not running.

loadSettings painted the chip from agents.defaults.model, which is what NEW
conversations start on, not what the open one runs. Every settings load ran that
paint: opening the dialog, and every settings write, each of which reloads. So a
conversation that had switched model had the default put back over it, the switch
read as lost, and a page reload was what appeared to "apply" it. The turns had
been running on the picked model the whole time -- only the chip was wrong. The
same line moved the chip onto a new default even for a conversation holding a
model of its own.

The line is older than the split between the two values. That split added
setDefaultPair right above it, for the settings page's own default-model row,
and left this one behind. Deleting it is the whole change: the chip belongs to
loadProviders, which asks model.options for the visible conversation and runs
on every path that changes which conversation that is -- including a
default-scoped write, when the server answers that this conversation follows the
default.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

Reproduced and re-verified on a real host (raven web on an isolated
RAVEN_HOME built from a real config, driven through the browser), reading the
model each turn actually ran from <raven_home>/telemetry/usage-<date>.jsonl
rather than from the page:

Step Chip before the fix Chip after Model the turn ran
First turn of a new conversation v4-pro v4-pro v4-pro
Pick v4-flash in the chip's picker v4-flash v4-flash --
Next turn v4-flash v4-flash v4-flash
Open the settings dialog v4-pro v4-flash --
Next turn v4-pro v4-flash v4-flash
Reload the page v4-flash v4-flash --
Change the default while this conversation holds its own model follows the default stays on v4-flash v4-flash
Change the default for a conversation that holds none follows follows the new default

The last row is the regression the deleted line could have taken with it: it goes
through applies_to_session and loadProviders, not through this line.

Commands:

  • npm test --prefix ui-web -- 189 files, 2469 tests, all pass
  • npm run lint --prefix ui-web -- 0 errors (4 pre-existing warnings in
    CronPage/SubagentsPage, untouched here)
  • npm run type-check --prefix ui-web -- clean
  • npm run gen:check --prefix ui-web -- generated.ts matches the contract
  • npm run --prefix ui-web build && python3 ui-web/build.py -- boot-snapshot OK
    (235 nodes match golden), then check-page.mjs, check-css.mjs,
    check-class-namespace.mjs all OK
  • npx commitlint --from github/refactor/ui_web_architecture --to HEAD -- clean

The new case, a settings load moves the default pair and leaves the conversation chip alone, was run against the unfixed module first and fails
there on the assertion it is named for.

  • Relevant tests pass locally
  • Relevant lint / type checks pass locally
  • User-facing docs or screenshots are updated when needed

Risk

User-visible and in the right direction: the chip stops contradicting the
conversation. No behaviour changes on the server, no stored state changes, and
nothing else reads what the deleted line wrote. Rollback is reverting one commit.

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

Related Issues

N/A

The model chip shows the open conversation's model; agents.defaults holds what
new conversations start on. `loadSettings` painted the chip from the defaults,
so opening the settings dialog -- or any settings write, each of which reloads
-- put the default back over a conversation that had switched. The turns kept
running on the model the reader picked, but the chip said otherwise until the
page was reloaded, so the switch read as lost. The same line moved the chip
onto a new default even for a conversation holding its own model.

The line is older than the split between the two values; the split updated
`setDefaultPair` above it and left this behind. The chip belongs to
`loadProviders`, which asks `model.options` for the visible conversation and
runs on every path that changes which one that is -- including the default
write, when the server says that conversation follows the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gloryfromca
gloryfromca requested a review from 0xKT September 21, 2026 11:14
@gloryfromca
gloryfromca merged commit 9001e69 into refactor/ui_web_architecture Sep 21, 2026
22 checks passed
@gloryfromca
gloryfromca deleted the fix/settings_load_keeps_session_model branch September 21, 2026 11:18
0xKT pushed a commit that referenced this pull request Sep 21, 2026
## Summary

Restoring a conversation from the archive page put it back everywhere
except the
screen. `loadSessions` replaced the rail's rows and never drew them, and
the
restore reaches the rail only through that function, so the row came
back in the
server's listing and on disk while the rail kept showing the list from
before.
Reloading the page was what appeared to restore it.

Every other writer in this feature already draws after a replace --
`leave.ts`
does it on both of its transitions, the session registry does it after
its
reconcile -- so the fix is the missing call in the one path that did
not, rather
than a draw at the restore call site. At boot the rail is still held,
where a
draw sets the skeleton and `releaseRail` paints the rows, so the other
caller is
unaffected.

Found while driving the merged archive flow end to end on a real host,
after
#585, #589 and #596. It is the same shape as #596: server state correct,
screen
stale until a reload.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

On a real host (`raven web` on an isolated `RAVEN_HOME` built from a
real
config, driven through the browser), reading the archived flag from the
last
metadata record of the session transcript rather than from the page:

| Step | Before | After |
| --- | --- | --- |
| Archive a conversation from the rail | leaves the rail, `archived:
true` on disk | same |
| Settings, Archive page | it is listed | same |
| Restart the gateway | still archived, does not come back | same |
| Restore it from the Archive page | `archived: false` on disk, **rail
still does not show it** | rail shows it at once |
| Reload the page | rail shows it | rail shows it |

Commands:

- `npm test --prefix ui-web` -- 189 files, 2470 tests, all pass
- `npm run lint --prefix ui-web` -- 0 errors (4 pre-existing warnings in
  CronPage/SubagentsPage, untouched here)
- `npm run type-check --prefix ui-web` -- clean
- `npm run --prefix ui-web build && python3 ui-web/build.py` --
boot-snapshot OK
  (235 nodes match golden)
- `npx commitlint --from github/refactor/ui_web_architecture --to HEAD`
-- clean

The new case, `a plain re-read draws the rows it just replaced`, was run
against
the unfixed module first and fails there on the assertion it is named
for.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

One added call in a function with two callers; the other one runs while
the rail
is held, where the draw is a no-op beyond the skeleton it already sets.
No server
change, no stored state change. Rollback is reverting one commit.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

---------

Co-authored-by: gloryfromca <23442919+gloryfromca@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
0xKT pushed a commit that referenced this pull request Sep 21, 2026
…604)

## Summary

The model picker showed a conversation's model under a vendor whose key
does not
serve it.

`is_current` is the whole difference between the two `model.options`
reads: the
backend marks the conversation's provider when the request names a
session, and
`agents.defaults`' when it does not. Both answers landed in one
module-level
array, so the default-scoped read -- the settings dialog's, repeated by
every
settings write, and the agents page's -- overwrote the conversation's.

What the reader saw: a conversation running a Gemini model while the
default is
a DeepSeek one, one visit to the settings dialog, then a click on the
model
chip. The picker opened on DeepSeek's column with `gemini-3.5-flash`
prepended
and ticked inside it, DeepSeek's count raised by that phantom row, and
the
current dot on both vendors.

Split, the way the model pair one layer down already is (#596 separated
`defaultModelLive` from the chip's own value and left this array
shared).
`providersLive` is the visible conversation's answer, read by the picker
through
`modelSource`; `defaultProvidersLive` is the configured default's, read
by the
settings snapshot, the onboarding model step, and the agents page, which
loads
that list for itself.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Driven on a real host (`raven web` on an isolated `RAVEN_HOME` built
from a real
config, default `deepseek/deepseek-v4-pro`, the conversation switched to
`gemini/gemini-3.5-flash`):

| Picker opened after one visit to the settings dialog | Before | After
|
| --- | --- | --- |
| Column it opens on | DeepSeek | Gemini |
| Where the tick sits | on a prepended `gemini-3.5-flash` row inside
DeepSeek | on Gemini 3.5 Flash, in Gemini |
| DeepSeek's model count | 4 (three plus the phantom) | 3 |
| Vendors carrying the current dot | DeepSeek and Gemini | Gemini |

Without the settings visit the picker was correct in both columns, which
is what
makes the shared array the cause.

Commands:

- `npm test --prefix ui-web` -- 191 files, 2486 tests, all pass
- `npm run lint --prefix ui-web` -- 0 errors (4 pre-existing warnings in
  CronPage/SubagentsPage, untouched here)
- `npm run type-check --prefix ui-web` -- clean, and it is what found
the third
reader: the agents page imports this export under an alias, so grep
missed it
- `npm run --prefix ui-web build && python3 ui-web/build.py && node
ui-web/scripts/check-page.mjs` -- boot-snapshot OK (235 nodes match
golden),
  page OK
- `npx commitlint --from github/refactor/ui_web_architecture --to HEAD`
-- clean

The new case, `a default-scoped read leaves the conversation rows
alone`, was run
against a module made to share one array again and fails there on the
assertion
it is named for.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

One module-level array becomes two, and every reader was enumerated and
pointed
at the scope it means. No server change, no stored state change, no
change to
what any read asks for. Rollback is reverting one commit.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

Co-authored-by: gloryfromca <23442919+gloryfromca@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
gloryfromca added a commit that referenced this pull request Sep 21, 2026
…596)

## Summary

The composer's model chip showed a model the conversation was not
running.

`loadSettings` painted the chip from `agents.defaults.model`, which is
what NEW
conversations start on, not what the open one runs. Every settings load
ran that
paint: opening the dialog, and every settings write, each of which
reloads. So a
conversation that had switched model had the default put back over it,
the switch
read as lost, and a page reload was what appeared to "apply" it. The
turns had
been running on the picked model the whole time -- only the chip was
wrong. The
same line moved the chip onto a new default even for a conversation
holding a
model of its own.

The line is older than the split between the two values. That split
added
`setDefaultPair` right above it, for the settings page's own
default-model row,
and left this one behind. Deleting it is the whole change: the chip
belongs to
`loadProviders`, which asks `model.options` for the visible conversation
and runs
on every path that changes which conversation that is -- including a
default-scoped write, when the server answers that this conversation
follows the
default.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Reproduced and re-verified on a real host (`raven web` on an isolated
`RAVEN_HOME` built from a real config, driven through the browser),
reading the
model each turn actually ran from
`<raven_home>/telemetry/usage-<date>.jsonl`
rather than from the page:

| Step | Chip before the fix | Chip after | Model the turn ran |
| --- | --- | --- | --- |
| First turn of a new conversation | v4-pro | v4-pro | v4-pro |
| Pick v4-flash in the chip's picker | v4-flash | v4-flash | -- |
| Next turn | v4-flash | v4-flash | v4-flash |
| Open the settings dialog | **v4-pro** | v4-flash | -- |
| Next turn | v4-pro | v4-flash | v4-flash |
| Reload the page | v4-flash | v4-flash | -- |
| Change the default while this conversation holds its own model |
**follows the default** | stays on v4-flash | v4-flash |
| Change the default for a conversation that holds none | follows |
follows | the new default |

The last row is the regression the deleted line could have taken with
it: it goes
through `applies_to_session` and `loadProviders`, not through this line.

Commands:

- `npm test --prefix ui-web` -- 189 files, 2469 tests, all pass
- `npm run lint --prefix ui-web` -- 0 errors (4 pre-existing warnings in
  CronPage/SubagentsPage, untouched here)
- `npm run type-check --prefix ui-web` -- clean
- `npm run gen:check --prefix ui-web` -- generated.ts matches the
contract
- `npm run --prefix ui-web build && python3 ui-web/build.py` --
boot-snapshot OK
  (235 nodes match golden), then `check-page.mjs`, `check-css.mjs`,
  `check-class-namespace.mjs` all OK
- `npx commitlint --from github/refactor/ui_web_architecture --to HEAD`
-- clean

The new case, `a settings load moves the default pair and leaves the
conversation chip alone`, was run against the unfixed module first and
fails
there on the assertion it is named for.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

User-visible and in the right direction: the chip stops contradicting
the
conversation. No behaviour changes on the server, no stored state
changes, and
nothing else reads what the deleted line wrote. Rollback is reverting
one commit.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
gloryfromca added a commit that referenced this pull request Sep 21, 2026
## Summary

Restoring a conversation from the archive page put it back everywhere
except the
screen. `loadSessions` replaced the rail's rows and never drew them, and
the
restore reaches the rail only through that function, so the row came
back in the
server's listing and on disk while the rail kept showing the list from
before.
Reloading the page was what appeared to restore it.

Every other writer in this feature already draws after a replace --
`leave.ts`
does it on both of its transitions, the session registry does it after
its
reconcile -- so the fix is the missing call in the one path that did
not, rather
than a draw at the restore call site. At boot the rail is still held,
where a
draw sets the skeleton and `releaseRail` paints the rows, so the other
caller is
unaffected.

Found while driving the merged archive flow end to end on a real host,
after
#585, #589 and #596. It is the same shape as #596: server state correct,
screen
stale until a reload.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

On a real host (`raven web` on an isolated `RAVEN_HOME` built from a
real
config, driven through the browser), reading the archived flag from the
last
metadata record of the session transcript rather than from the page:

| Step | Before | After |
| --- | --- | --- |
| Archive a conversation from the rail | leaves the rail, `archived:
true` on disk | same |
| Settings, Archive page | it is listed | same |
| Restart the gateway | still archived, does not come back | same |
| Restore it from the Archive page | `archived: false` on disk, **rail
still does not show it** | rail shows it at once |
| Reload the page | rail shows it | rail shows it |

Commands:

- `npm test --prefix ui-web` -- 189 files, 2470 tests, all pass
- `npm run lint --prefix ui-web` -- 0 errors (4 pre-existing warnings in
  CronPage/SubagentsPage, untouched here)
- `npm run type-check --prefix ui-web` -- clean
- `npm run --prefix ui-web build && python3 ui-web/build.py` --
boot-snapshot OK
  (235 nodes match golden)
- `npx commitlint --from github/refactor/ui_web_architecture --to HEAD`
-- clean

The new case, `a plain re-read draws the rows it just replaced`, was run
against
the unfixed module first and fails there on the assertion it is named
for.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

One added call in a function with two callers; the other one runs while
the rail
is held, where the draw is a no-op beyond the skeleton it already sets.
No server
change, no stored state change. Rollback is reverting one commit.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

---------

Co-authored-by: gloryfromca <23442919+gloryfromca@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
gloryfromca added a commit that referenced this pull request Sep 21, 2026
…604)

## Summary

The model picker showed a conversation's model under a vendor whose key
does not
serve it.

`is_current` is the whole difference between the two `model.options`
reads: the
backend marks the conversation's provider when the request names a
session, and
`agents.defaults`' when it does not. Both answers landed in one
module-level
array, so the default-scoped read -- the settings dialog's, repeated by
every
settings write, and the agents page's -- overwrote the conversation's.

What the reader saw: a conversation running a Gemini model while the
default is
a DeepSeek one, one visit to the settings dialog, then a click on the
model
chip. The picker opened on DeepSeek's column with `gemini-3.5-flash`
prepended
and ticked inside it, DeepSeek's count raised by that phantom row, and
the
current dot on both vendors.

Split, the way the model pair one layer down already is (#596 separated
`defaultModelLive` from the chip's own value and left this array
shared).
`providersLive` is the visible conversation's answer, read by the picker
through
`modelSource`; `defaultProvidersLive` is the configured default's, read
by the
settings snapshot, the onboarding model step, and the agents page, which
loads
that list for itself.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Driven on a real host (`raven web` on an isolated `RAVEN_HOME` built
from a real
config, default `deepseek/deepseek-v4-pro`, the conversation switched to
`gemini/gemini-3.5-flash`):

| Picker opened after one visit to the settings dialog | Before | After
|
| --- | --- | --- |
| Column it opens on | DeepSeek | Gemini |
| Where the tick sits | on a prepended `gemini-3.5-flash` row inside
DeepSeek | on Gemini 3.5 Flash, in Gemini |
| DeepSeek's model count | 4 (three plus the phantom) | 3 |
| Vendors carrying the current dot | DeepSeek and Gemini | Gemini |

Without the settings visit the picker was correct in both columns, which
is what
makes the shared array the cause.

Commands:

- `npm test --prefix ui-web` -- 191 files, 2486 tests, all pass
- `npm run lint --prefix ui-web` -- 0 errors (4 pre-existing warnings in
  CronPage/SubagentsPage, untouched here)
- `npm run type-check --prefix ui-web` -- clean, and it is what found
the third
reader: the agents page imports this export under an alias, so grep
missed it
- `npm run --prefix ui-web build && python3 ui-web/build.py && node
ui-web/scripts/check-page.mjs` -- boot-snapshot OK (235 nodes match
golden),
  page OK
- `npx commitlint --from github/refactor/ui_web_architecture --to HEAD`
-- clean

The new case, `a default-scoped read leaves the conversation rows
alone`, was run
against a module made to share one array again and fails there on the
assertion
it is named for.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

One module-level array becomes two, and every reader was enumerated and
pointed
at the scope it means. No server change, no stored state change, no
change to
what any read asks for. Rollback is reverting one commit.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

Co-authored-by: gloryfromca <23442919+gloryfromca@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants