Skip to content

feat(custom-model): promote the currently-loaded/last-used model in the Run-menu picker - #459

Open
opticon454 wants to merge 3 commits into
Ark0N:masterfrom
opticon454:feature/run-menu-picker-currently-loaded-model
Open

opticon454 wants to merge 3 commits into
Ark0N:masterfrom
opticon454:feature/run-menu-picker-currently-loaded-model

Conversation

@opticon454

Copy link
Copy Markdown
Contributor

Summary

  • The Run-menu's "choose a model" picker (Custom Model Endpoint Profiles) now promotes one model to the top of the list instead of always showing raw discovery order:
    • "Currently loaded" — when llama-swap reports a model from this host's own list as ready right now (via the existing GET /api/model-endpoints/:id/running-status route). This is what a launch attaches to with zero wait.
    • "Last used" (fallback, when nothing is currently loaded) — the model actually launched last for this exact (harness, endpoint) pair, remembered in a new per-device localStorage key (codeman:customModelLastUsed:<mode>:<endpointId>), written by runCustomModelEntry on every launch attempt.
  • A plain (non-llama-swap) OpenAI-compatible server, an unreachable endpoint, or a loaded-but-not-yet-ready model never promotes anything — the rest of the list keeps its discovery order, and the existing Default tag still shows where neither applies.
  • Fixed a real race found in review: making the picker's open function async (to await the currently-loaded probe) meant a slower probe for an earlier click could resolve after a faster click for a different endpoint and overwrite the modal with the wrong endpoint's models. Guarded with the same generation-counter pattern _watchLlamaSwapLoading already uses.

Test plan

  • npm run typecheck — clean
  • npm run lint — clean
  • npm run check:frontend-syntax — clean
  • npx prettier --check on both changed files — clean
  • 6 new tests in test/custom-model-run-menu-ui.test.ts covering: currently-loaded promotion, last-used fallback, currently-loaded taking precedence over a stale last-used, ignoring a loaded-but-not-ready/no-longer-discovered model, runCustomModelEntry recording last-used, and the async-race regression (58/58 in the file, all green)
  • Full npm test (7723 tests) run for regressions — only pre-existing, unrelated environment failures on this Windows sandbox (no tmux, spawn npx ENOENT, port conflicts — the same class of failure CLAUDE.md already documents as sandbox-only)
  • /code-review high run on the full diff; its one finding (the async race above) fixed and covered by a regression test

🤖 Generated with Claude Code

https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD

opticon454 and others added 2 commits September 20, 2026 19:20
…he Run-menu picker

Custom Model Endpoint Profiles' "which model" picker (session-ui.js's
_openCustomModelPickModal) always listed models in their raw discovery
order, so on a host with several downloaded GGUFs the user had to
remember (or eyeball the "Default" tag) which one llama-swap actually
had hot before picking — the whole point of the picker being fast is
undone if it makes you think first.

The picker now promotes exactly one model to the top of the list:

- If llama-swap reports a model from this host's own list `ready`
  right now (via the existing GET /api/model-endpoints/:id/running-status
  route), that model is promoted and tagged "Currently loaded" — it's
  what a launch attaches to with zero wait.
- Otherwise, the last model actually launched on this exact
  (harness, endpoint) pair is promoted and tagged "Last used", read
  from a new per-device localStorage key
  (codeman:customModelLastUsed:<mode>:<endpointId>), written by
  runCustomModelEntry on every launch attempt regardless of outcome.
- A plain (non-llama-swap) OpenAI-compatible server, an unreachable
  endpoint, or a loaded-but-not-yet-ready model never promotes
  anything — the rest of the list keeps its discovery order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
…perseded probe

Code review (high effort) on the previous commit found a real race: making
_openCustomModelPickModal async (it now awaits the currently-loaded-model
probe before rendering) meant a second, faster call for a different
endpoint could render first, only for the first call's slower probe to
resolve afterwards and overwrite the modal with the wrong endpoint's model
list — while _pendingCustomModelPick (set synchronously, before either
await) still named the second, correct endpoint. Picking a model in that
state would launch/apply the wrong model on the wrong endpoint.

Fixed with the same mutable-generation-counter guard
_watchLlamaSwapLoading already uses for an identical async-superseded-by-
newer-call shape: every DOM write, including _pendingCustomModelPick
itself, is deferred until after the awaited probe, and a call that finds
its generation already superseded bails out untouched instead of clobbering
whatever a newer call already rendered.

Added a regression test driving two overlapping opens with a controlled
promise so the earlier, slower probe resolves after the later, faster one
renders, asserting the late response is a no-op.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
@Ark0N

Ark0N commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for the care in the commit messages. Promoting the model llama-swap already has hot, with a remembered "last used" as the fallback, is the right instinct for this picker: the zero-wait choice should be the one under your thumb. The race you found in your own first commit is real, and I checked it: the new regression test fails on 458ca57 (host A's rows render over host B's modal) and passes on the fix.

Four things, then it goes in.

  1. src/web/public/session-ui.js:706: awaiting the probe before any DOM write means the modal does not open until the probe answers. The route's own /running fetch is bounded at 5s (RUNNING_TIMEOUT_MS, custom-model-routes.ts:291) and _apiJson sets no client-side signal, so against an endpoint that is asleep or firewalled the picker is completely absent for five seconds after the click: no spinner, no toast, and the Run menu has already closed. I measured it in jsdom driving the real module: 5012 ms on this branch, immediate on master. A saved endpoint keeps its discovered models cached, so "the GPU box is off" is a normal way to reach this path, not an exotic one. Could you bound the probe on the client as well, for example racing the _apiJson call in _getCustomModelCurrentlyLoaded against an ~800 ms timer that resolves null? The function's own docstring already says a failed probe just skips promotion, so a timeout is in contract. (Rendering first and decorating afterwards is the other option, but it reorders rows under the user's finger, so I prefer the race.)

  2. src/web/public/session-ui.js:850: _setCustomModelLastUsed runs on the attempt, so a model the user explicitly declined still becomes "Last used". The one that bites is the context-window warning: cancel there and the picker promotes, next time, the one model that cannot work with that CLI at all. Writing the key after the confirmations are passed (or just skipping it when _confirmContextWarning returns false) fixes it.

  3. Docs: this picker's behaviour is written down in two places that the change makes incomplete, CLAUDE.md:232 ("two or more open #customModelPickModal to ask, with defaultModelId marked but never auto-chosen") and docs/custom-model-endpoints.md:181. A sentence in each covering the promotion order, the tag precedence and the new codeman:customModelLastUsed:* key. Nothing needs to change in docs/api-reference.md: running-status is already documented there and you did not touch it.

  4. src/web/public/i18n.js: "Currently loaded" and "Last used" need zh-CN entries. Every other string in this modal already has one ("Choose a model", "Custom Endpoints", "Default"), so these two would be the only ones left in English on a Chinese UI.

Two smaller things I will take at merge rather than send back: the promoted row loses its "Default" marking, since the tag slot is exclusive (so when the default is also the loaded model, nothing says it is the default), and .set-scope is only styled inside the three settings modals, so these tags render as plain text in the pick modal instead of as a pill. The second is pre-existing, but your tag is now doing real work.

Everything else is clean here: typecheck, lint, format:check, check:frontend-syntax and the test suites all pass, CI is green on the head commit, and the six new tests are real tests rather than assertions about strings. Push 1 and 2 and I will merge, or say the word and I will fold them in myself.

…out, defer "last used" past confirmation, docs, zh-CN

Four things from the maintainer's review on PR Ark0N#459, all fixed:

1. Bound _getCustomModelCurrentlyLoaded's probe client-side (~800ms via
   Promise.race, on top of — never instead of — the route's own 5s
   server-side timeout). Without it, an asleep/firewalled endpoint behind
   a saved model list left the picker completely invisible for up to 5s
   after the Run menu had already closed, with no spinner or toast.
   `timeoutMs` is an optional param (default 800, real callers never pass
   it) so a test can drive it in milliseconds, same pattern as
   `_watchLlamaSwapLoading`'s own `pollIntervalMs` — this code runs in a
   JSDOM window's own realm, whose setTimeout vi.useFakeTimers() cannot
   patch.

2. "Last used" is now written only once a launch actually applies, never
   on the mere click. It moved out of runCustomModelEntry (unconditional)
   and into each path's own success point: _quickStartWithCustomModelConfirm
   after the final post succeeds, and _runCustomModelEntryViaRestart right
   after the apply's success check. A context-window-warning decline means
   this exact model cannot work with this CLI at all, so the old
   unconditional write would promote, next time the picker opened, the one
   model guaranteed to fail again.

3. Documented the promotion/tag precedence and the new
   codeman:customModelLastUsed:<mode>:<endpointId> localStorage key in both
   CLAUDE.md's Custom Model Endpoint Profiles section and
   docs/custom-model-endpoints.md's Run-menu picker section.

4. Added zh-CN entries for "Currently loaded" and "Last used" in i18n.js,
   next to this modal's existing "Choose a model"/"Custom Endpoints" pair.

New tests: the client-side timeout (endpoint that never answers, one that
answers within the bound, and a rejected-after-timeout probe settling
quietly), and "last used" recording on success vs. NOT recording on either
confirmation's decline, for both the restart and one-shot paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
@opticon454

Copy link
Copy Markdown
Contributor Author

Thanks for the careful read — all four fixed in 88e5b7b:

  1. _getCustomModelCurrentlyLoaded now races the probe against an ~800ms client-side timer (Promise.race), on top of the route's own 5s server-side bound. Took the same "optional timeoutMs param, real callers never pass it" approach _watchLlamaSwapLoading already uses, since this runs in the JSDOM window's own realm and vi.useFakeTimers() can't reach it — added tests for a probe that never answers, one that answers well inside the bound, and a rejected probe settling quietly after losing the race (no unhandled-rejection noise).
  2. Moved the "last used" write out of runCustomModelEntry entirely and into each path's own success point — after _quickStartWithCustomModelConfirm's final post succeeds, and right after _runCustomModelEntryViaRestart's apply-success check. Declining either confirmation (context-window or swap) now leaves the key untouched. Added tests for both paths × both confirmation outcomes.
  3. Added a paragraph to both CLAUDE.md (Custom Model Endpoint Profiles section) and docs/custom-model-endpoints.md (Run-menu picker section) covering the promotion order, tag precedence, the client-side timeout, and the new codeman:customModelLastUsed:<mode>:<endpointId> key.
  4. Added zh-CN entries for "Currently loaded" / "Last used" in i18n.js, next to the existing "Choose a model" / "Custom Endpoints" pair for this modal.

Left the two "I'll take at merge" items (Default-marking on the promoted row, .set-scope pill styling in this modal) for you as noted.

typecheck/lint/format:check/check:frontend-syntax all clean; new + existing custom-model suites green (only the pre-existing, unrelated Windows-sandbox failures noted before: no tmux, a POSIX-chmod test that doesn't apply on this box).

🤖 Generated with Claude Code

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