feat(custom-model): live countdown on the loading banner; timeout is now an error

The loading banner now shows a live countdown against its own timeout
(updated every poll, so every second by default) instead of a static
"this can take a while" — e.g. "Loading qwen3.8-27b (16.4 GB, typically
~1-3 min) on llama-swap - 47s remaining".

If the countdown reaches zero and the model still isn't ready, this is now
treated as a real failure rather than a "keep waiting" shrug:
- The banner turns into a sticky error (_showCenterStatus gains a `type`
  option - 'error' drops the spinner and adds a close button, since nothing
  is "in progress" anymore and a sticky message needs a way to dismiss it),
  naming the llama-swap server's own logs as where to look for detail.
- The session that load was for is closed automatically (closeSession) -
  requested explicitly: a console left open and pointed at a model that
  never finished loading is worse than no console at all. Both apply paths
  now thread the new session's id through to _watchLlamaSwapLoading for
  this (new required 3rd parameter, after endpointId/modelId).

_watchLlamaSwapGeneration's existing stale-call guard extends naturally to
this: a superseded call's own eventual timeout recognises it no longer owns
the banner and neither shows the error nor closes a session that may by
then belong to a different, newer launch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
This commit is contained in:
Devvyn
2026-09-16 20:22:02 +08:00
co-authored by Claude Sonnet 5
parent 55dae31530
commit 7bbe408e44
7 changed files with 181 additions and 64 deletions
+40 -18
View File
@@ -5556,37 +5556,59 @@ Object.assign(CodemanApp.prototype, {
* genuinely worth interrupting the eye for rather than living in the corner with every
* other toast (currently: a custom-model session's "switching backends" and "loading
* model" states, both of which can sit on screen for well over a minute and are easy to
* mistake for nothing happening). Non-blocking (`pointer-events: none`, no backdrop) —
* this is informational, never a gate the user has to dismiss to keep working. Only one
* is ever shown at a time (the DOM node is created once and reused), which matches every
* current caller: each hands off to the next rather than stacking.
* mistake for nothing happening). Non-blocking (`pointer-events: none` on the wrapper,
* restored only on the card) — an info banner is never a gate the user has to dismiss to
* keep working. Only one is ever shown at a time (the DOM node is created once and
* reused), which matches every current caller: each hands off to the next rather than
* stacking.
*
* `opts.type` — `'info'` (default, spinner, no close button — a caller ends it itself via
* `dismiss()`) or `'error'` (no spinner — nothing is in progress once this shows — with a
* close button, since a sticky error the user cannot dismiss would just sit there). The
* DOM is rebuilt fresh each call rather than patched, since which children exist differs
* by type; `setMessage` still only ever touches the text node afterwards.
*/
_showCenterStatus(message) {
_showCenterStatus(message, opts = {}) {
const { type = 'info' } = opts;
let el = document.getElementById('customModelCenterStatus');
if (!el) {
el = document.createElement('div');
el.id = 'customModelCenterStatus';
el.className = 'center-status-banner';
document.body.appendChild(el);
}
el.className = `center-status-banner center-status-${type}`;
el.innerHTML = '';
const dismiss = () => {
el.classList.remove('show');
setTimeout(() => {
el.hidden = true;
}, 200);
};
if (type !== 'error') {
const spinner = document.createElement('span');
spinner.className = 'center-status-spinner';
spinner.setAttribute('aria-hidden', 'true');
const text = document.createElement('span');
text.className = 'center-status-text';
el.appendChild(spinner);
el.appendChild(text);
document.body.appendChild(el);
}
const textEl = el.querySelector('.center-status-text');
if (textEl) textEl.textContent = message;
const text = document.createElement('span');
text.className = 'center-status-text';
text.textContent = message;
el.appendChild(text);
if (type === 'error') {
const closeBtn = document.createElement('button');
closeBtn.className = 'center-status-close';
closeBtn.textContent = '×';
closeBtn.setAttribute('aria-label', 'Dismiss');
closeBtn.onclick = (e) => {
e.stopPropagation();
dismiss();
};
el.appendChild(closeBtn);
}
el.hidden = false;
requestAnimationFrame(() => el.classList.add('show'));
return {
dismiss: () => {
el.classList.remove('show');
setTimeout(() => {
el.hidden = true;
}, 200);
},
dismiss,
setMessage: (next) => {
const t = el.querySelector('.center-status-text');
if (t) t.textContent = next;