diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 64835505..eda7557d 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -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 diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index ee0cd68e..7bbdff6d 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -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)); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index cefc4d6b..adf9491c 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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); } diff --git a/test/custom-model-run-menu-ui.test.ts b/test/custom-model-run-menu-ui.test.ts index 35e92ea4..f1271c58 100644 --- a/test/custom-model-run-menu-ui.test.ts +++ b/test/custom-model-run-menu-ui.test.ts @@ -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 () => {