mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(custom-model): move the switching/loading status to a centred banner
The "Claude started - switching to <endpoint>..." and "Loading <model> on <endpoint>... this can take a while" messages lived in the top-right toast corner along with everything else, easy to miss given they can each sit on screen for well over a minute (a real llama-swap model load). Adds _showCenterStatus() (panels-ui.js): a single, reused, screen-centred banner with a spinner, non-blocking (no backdrop, pointer-events: none on the wrapper) so it never gets in the way of using the app while it's up. Both call sites (_runCustomModelEntryViaRestart's switching message, _watchLlamaSwapLoading's loading message) now use it instead of showToast. Every OTHER status in these two flows - the llama-swap conflict warning already moved to its own modal, apply failures, cancellation, and _watchLlamaSwapLoading's own final "ready"/"still waiting" outcome - stays exactly where it was, in the corner. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
2936ba6e3d
commit
01b32ee6cd
@@ -5551,6 +5551,49 @@ Object.assign(CodemanApp.prototype, {
|
||||
return { dismiss, setMessage: (text) => { msgSpan.textContent = text; } };
|
||||
},
|
||||
|
||||
/**
|
||||
* A prominent, screen-centred status banner — for the small set of messages that are
|
||||
* 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.
|
||||
*/
|
||||
_showCenterStatus(message) {
|
||||
let el = document.getElementById('customModelCenterStatus');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'customModelCenterStatus';
|
||||
el.className = 'center-status-banner';
|
||||
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;
|
||||
el.hidden = false;
|
||||
requestAnimationFrame(() => el.classList.add('show'));
|
||||
return {
|
||||
dismiss: () => {
|
||||
el.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
el.hidden = true;
|
||||
}, 200);
|
||||
},
|
||||
setMessage: (next) => {
|
||||
const t = el.querySelector('.center-status-text');
|
||||
if (t) t.textContent = next;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// System Stats
|
||||
|
||||
@@ -839,9 +839,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Claude just launched on the NATIVE backend and is about to be restarted onto
|
||||
// the endpoint — without something saying so, that native boot (which can talk
|
||||
// to Opus for a moment) reads as "the endpoint didn't apply" rather than "the
|
||||
// switch hasn't happened yet". Sticky until the apply below settles one way or
|
||||
// the other, or hands off to _watchLlamaSwapLoading's own sticky toast.
|
||||
const switchingToast = this.showToast(`Claude started — switching to ${endpointId}…`, 'info', { duration: 0 });
|
||||
// switch hasn't happened yet". Prominent and screen-centred (not a corner toast)
|
||||
// since this can sit on screen for a while; sticky until the apply below settles
|
||||
// one way or the other, or hands off to _watchLlamaSwapLoading's own banner.
|
||||
const switchingToast = this._showCenterStatus(`Claude started — switching to ${endpointId}…`);
|
||||
|
||||
// A freshly launched CLI reports its OWN startup as 'busy' (spinner, the
|
||||
// workspace-trust check, whatever else it does before its first prompt) —
|
||||
@@ -937,9 +938,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
* rather than only in a test fixture.
|
||||
*/
|
||||
async _watchLlamaSwapLoading(endpointId, modelId, pollIntervalMs = 3000, maxWaitMs = 120000) {
|
||||
const toast = this.showToast(`Loading ${modelId} on ${endpointId}… this can take a while`, 'info', {
|
||||
duration: 0,
|
||||
});
|
||||
// Prominent and screen-centred, not a corner toast — a real llama-swap model load can
|
||||
// sit on screen for well over a minute, easy to mistake for nothing happening there.
|
||||
const toast = this._showCenterStatus(`Loading ${modelId} on ${endpointId}… this can take a while`);
|
||||
const deadline = Date.now() + maxWaitMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
|
||||
@@ -8522,6 +8522,66 @@ kbd {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Custom Model Endpoint Profiles: the "switching backends" / "loading model" states
|
||||
(docs/custom-model-endpoints-plan.md) — a small set of messages prominent and
|
||||
screen-centred rather than corner toasts, since they can sit on screen for well
|
||||
over a minute (a real llama-swap model load) and are easy to mistake for nothing
|
||||
happening. Non-blocking: `pointer-events: none` on the wrapper (no backdrop, no
|
||||
click-catcher) with `auto` restored only on the card itself, purely so the text
|
||||
inside remains selectable — there is nothing to click to dismiss it early. */
|
||||
.center-status-banner {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.96);
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.5rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
max-width: min(90vw, 460px);
|
||||
text-align: left;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.center-status-banner.show {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
.center-status-spinner {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent, var(--text));
|
||||
animation: center-status-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes center-status-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.center-status-text {
|
||||
pointer-events: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.toast-success { border-color: rgba(34, 197, 94, 0.4); }
|
||||
.toast-error { border-color: rgba(239, 68, 68, 0.4); }
|
||||
.toast-warning { border-color: rgba(234, 179, 8, 0.4); }
|
||||
|
||||
@@ -76,6 +76,11 @@ function bootApp(
|
||||
app.loadAppSettingsFromStorage = () => ({ customModelEndpointsEnabled: options.settingsEnabled ?? true });
|
||||
app.isCliAvailable = options.cliAvailable ?? (() => true);
|
||||
app.showToast = () => {};
|
||||
// Real implementation lives in panels-ui.js, not evaluated into this harness (only
|
||||
// constants.js + session-ui.js are — see below) — a no-op default handle matching its
|
||||
// real shape, same reasoning as showToast above; tests of the center status itself
|
||||
// override it.
|
||||
app._showCenterStatus = () => ({ dismiss: () => {}, setMessage: () => {} });
|
||||
// Default no-op so a button's onclick (selectCustomModelEntry -> possibly
|
||||
// straight to runCustomModelEntry for a single-model host) never rejects
|
||||
// with "this.run is not a function"; tests of the launch itself override it.
|
||||
@@ -415,11 +420,11 @@ describe('Custom Model Endpoint Profiles: applying a picked entry', () => {
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: { customModel: { endpointId: 'llama-box' }, restarted: true } }),
|
||||
});
|
||||
const toasts: Array<{ message: string; dismissed: boolean }> = [];
|
||||
const banners: Array<{ message: string; dismissed: boolean }> = [];
|
||||
const messageHistory: string[] = [];
|
||||
app.showToast = (message: string) => {
|
||||
app._showCenterStatus = (message: string) => {
|
||||
const entry = { message, dismissed: false };
|
||||
toasts.push(entry);
|
||||
banners.push(entry);
|
||||
messageHistory.push(message);
|
||||
return {
|
||||
dismiss: () => {
|
||||
@@ -434,12 +439,12 @@ describe('Custom Model Endpoint Profiles: applying a picked entry', () => {
|
||||
|
||||
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
|
||||
|
||||
expect(toasts).toHaveLength(1); // updated in place, not stacked with a second toast
|
||||
expect(banners).toHaveLength(1); // updated in place, not stacked with a second banner
|
||||
expect(messageHistory[0]).toContain('Claude started — switching to llama-box');
|
||||
expect(messageHistory.at(-1)).toContain('Pointed at llama-box — restarting');
|
||||
});
|
||||
|
||||
it('dismisses the status toast on a failed apply rather than leaving it stuck on "switching"', async () => {
|
||||
it('dismisses the status banner on a failed apply rather than leaving it stuck on "switching"', async () => {
|
||||
const { app } = bootApp({});
|
||||
app.activeSessionId = 'old-session';
|
||||
app.run = async () => {
|
||||
@@ -450,22 +455,22 @@ describe('Custom Model Endpoint Profiles: applying a picked entry', () => {
|
||||
status: 500,
|
||||
json: async () => ({ success: false, error: 'boom' }),
|
||||
});
|
||||
const toasts: Array<{ message: string; dismissed: boolean }> = [];
|
||||
let bannerDismissed = false;
|
||||
app._showCenterStatus = () => ({
|
||||
dismiss: () => {
|
||||
bannerDismissed = true;
|
||||
},
|
||||
setMessage: () => {},
|
||||
});
|
||||
let toastMessage: string | undefined;
|
||||
app.showToast = (message: string) => {
|
||||
const entry = { message, dismissed: false };
|
||||
toasts.push(entry);
|
||||
return {
|
||||
dismiss: () => {
|
||||
entry.dismissed = true;
|
||||
},
|
||||
setMessage: () => {},
|
||||
};
|
||||
toastMessage = message;
|
||||
};
|
||||
|
||||
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
|
||||
|
||||
expect(toasts[0].dismissed).toBe(true); // the "switching..." toast, cleaned up
|
||||
expect(toasts.at(-1)?.message).toContain('boom'); // the error toast, separate from it
|
||||
expect(bannerDismissed).toBe(true); // the "switching..." banner, cleaned up
|
||||
expect(toastMessage).toContain('boom'); // the error toast, separate from it
|
||||
});
|
||||
|
||||
it('routes through run() itself, so the Run in-flight lock actually engages', async () => {
|
||||
@@ -597,29 +602,33 @@ describe('Custom Model Endpoint Profiles: _watchLlamaSwapLoading polling', () =>
|
||||
// whose setTimeout is NOT the one vi.useFakeTimers() patches, so advancing fake
|
||||
// timers here would advance nothing and either hang or silently no-op.
|
||||
|
||||
it('dismisses the loading toast as soon as the target model reports ready', async () => {
|
||||
it('dismisses the loading banner as soon as the target model reports ready', async () => {
|
||||
const { app } = bootApp({});
|
||||
const toastCalls: Array<{ message: string; type: string }> = [];
|
||||
const bannerMessages: string[] = [];
|
||||
const dismissed: string[] = [];
|
||||
app.showToast = (message: string, type: string) => {
|
||||
toastCalls.push({ message, type });
|
||||
app._showCenterStatus = (message: string) => {
|
||||
bannerMessages.push(message);
|
||||
return { dismiss: () => dismissed.push(message), setMessage: () => {} };
|
||||
};
|
||||
const toastCalls: string[] = [];
|
||||
app.showToast = (message: string) => {
|
||||
toastCalls.push(message);
|
||||
};
|
||||
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] });
|
||||
|
||||
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 5, 200);
|
||||
|
||||
expect(toastCalls[0].message).toMatch(/loading qwen3/i);
|
||||
expect(dismissed).toContain(toastCalls[0].message);
|
||||
expect(toastCalls.at(-1)?.message).toMatch(/ready/i);
|
||||
expect(bannerMessages[0]).toMatch(/loading qwen3/i);
|
||||
expect(dismissed).toContain(bannerMessages[0]);
|
||||
expect(toastCalls.at(-1)).toMatch(/ready/i);
|
||||
});
|
||||
|
||||
it('gives up after the bounded wait and warns instead of polling forever', async () => {
|
||||
const { app } = bootApp({});
|
||||
app._showCenterStatus = () => ({ dismiss: () => {}, setMessage: () => {} });
|
||||
const toastCalls: string[] = [];
|
||||
app.showToast = (message: string) => {
|
||||
toastCalls.push(message);
|
||||
return { dismiss: () => {}, setMessage: () => {} };
|
||||
};
|
||||
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'something-else', state: 'ready' }] });
|
||||
|
||||
@@ -630,24 +639,31 @@ describe('Custom Model Endpoint Profiles: _watchLlamaSwapLoading polling', () =>
|
||||
|
||||
it('stops polling (without a warning) once the endpoint no longer reads as llama-swap', async () => {
|
||||
const { app } = bootApp({});
|
||||
let bannerDismissed = false;
|
||||
app._showCenterStatus = () => ({
|
||||
dismiss: () => {
|
||||
bannerDismissed = true;
|
||||
},
|
||||
setMessage: () => {},
|
||||
});
|
||||
const toastCalls: string[] = [];
|
||||
app.showToast = (message: string) => {
|
||||
toastCalls.push(message);
|
||||
return { dismiss: () => {}, setMessage: () => {} };
|
||||
};
|
||||
app._apiJson = async () => ({ isLlamaSwap: false, running: [] });
|
||||
|
||||
await app._watchLlamaSwapLoading('llama-box', 'qwen3', 5, 200);
|
||||
|
||||
expect(toastCalls).toHaveLength(1); // only the initial "Loading..." toast, no follow-up warning
|
||||
expect(bannerDismissed).toBe(true);
|
||||
expect(toastCalls).toHaveLength(0); // no follow-up warning toast
|
||||
});
|
||||
|
||||
it('keeps waiting through a transient status-fetch failure instead of giving up early', async () => {
|
||||
const { app } = bootApp({});
|
||||
app._showCenterStatus = () => ({ dismiss: () => {}, setMessage: () => {} });
|
||||
const toastCalls: string[] = [];
|
||||
app.showToast = (message: string) => {
|
||||
toastCalls.push(message);
|
||||
return { dismiss: () => {}, setMessage: () => {} };
|
||||
};
|
||||
let call = 0;
|
||||
app._apiJson = async () => {
|
||||
|
||||
Reference in New Issue
Block a user