diff --git a/CHANGELOG.md b/CHANGELOG.md index 6308da9..3224eb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ version with its date and start a fresh empty `[Unreleased]` above it. The toggle is off by default; off keeps the numbered tabs and the existing interactions unchanged. +- When the selected model is at capacity, the waiting indicator now says so + with the queue position and estimated wait, e.g. + `Model is queued (3 ahead · ~12s wait)...`, and returns to the usual + wording as soon as the request leaves the queue. + ## [1.0.14] - 2026-09-20 ### Changed diff --git a/src/core/types/chat.ts b/src/core/types/chat.ts index e77d35d..7d89b55 100644 --- a/src/core/types/chat.ts +++ b/src/core/types/chat.ts @@ -154,6 +154,7 @@ export type StreamChunk = | { type: 'tool_output'; id: string; content: string } | { type: 'error'; content: string; code?: string } | { type: 'notice'; content: string; level?: 'info' | 'warning' } + | { type: 'model_queue'; status: 'queued' | 'ready'; queueCount?: number; waitTimeMs?: number } | { type: 'done' } | { type: 'usage'; usage: UsageInfo; sessionId?: string | null } | { type: 'context_compacted' } diff --git a/src/features/chat/controllers/stream-controller.ts b/src/features/chat/controllers/stream-controller.ts index ff07363..0ea1fce 100644 --- a/src/features/chat/controllers/stream-controller.ts +++ b/src/features/chat/controllers/stream-controller.ts @@ -174,6 +174,10 @@ export class StreamController { await this.appendText(`\n\n⚠️ **${chunk.level === 'warning' ? 'Blocked' : 'Notice'}:** ${chunk.content}`); break; + case 'model_queue': + this.thinkingIndicator.setQueue(chunk); + break; + case 'error': await this.handleError(chunk, msg); break; diff --git a/src/features/chat/controllers/thinking-indicator-controller.ts b/src/features/chat/controllers/thinking-indicator-controller.ts index ced6f8e..f30bfe0 100644 --- a/src/features/chat/controllers/thinking-indicator-controller.ts +++ b/src/features/chat/controllers/thinking-indicator-controller.ts @@ -1,4 +1,5 @@ import { formatDurationMmSs } from '../../../core/time/date'; +import type { StreamChunk } from '../../../core/types'; import { FLAVOR_TEXTS } from '../flavor-texts'; import type { ChatState } from '../state/chat-state'; @@ -8,10 +9,21 @@ interface ThinkingIndicatorControllerDeps { updateQueueIndicator: () => void; } +type ModelQueueChunk = Extract; + const SHOW_DELAY_MS = 400; +function buildQueueLabel(chunk: ModelQueueChunk): string { + const parts: string[] = []; + if (chunk.queueCount && chunk.queueCount > 0) parts.push(`${chunk.queueCount} ahead`); + if (chunk.waitTimeMs && chunk.waitTimeMs > 0) parts.push(`~${Math.ceil(chunk.waitTimeMs / 1000)}s wait`); + return parts.length > 0 ? `Model is queued (${parts.join(' · ')})...` : 'Model is queued...'; +} + /** Owns the delayed flavor-text indicator and its elapsed-time timer. */ export class ThinkingIndicatorController { + private queueLabel: string | null = null; + constructor(private readonly deps: ThinkingIndicatorControllerDeps) {} show(overrideText?: string, overrideCls?: string): void { @@ -35,10 +47,12 @@ export class ThinkingIndicatorController { state.setThinkingIndicatorTimeout(null, null); if (!state.currentContentEl || state.thinkingEl || state.currentThinkingState) return; - const cls = overrideCls ? `qoderian-thinking ${overrideCls}` : 'qoderian-thinking'; + const queueActive = this.queueLabel !== null; + const queueCls = queueActive ? ' qoderian-thinking--queue' : ''; + const cls = `qoderian-thinking${overrideCls ? ` ${overrideCls}` : queueCls}`; state.thinkingEl = state.currentContentEl.createDiv({ cls }); - const text = overrideText || FLAVOR_TEXTS[Math.floor(Math.random() * FLAVOR_TEXTS.length)]; - state.thinkingEl.createSpan({ text }); + const text = overrideText || this.queueLabel || FLAVOR_TEXTS[Math.floor(Math.random() * FLAVOR_TEXTS.length)]; + state.thinkingEl.createSpan({ cls: 'qoderian-thinking-text', text }); const timerSpan = state.thinkingEl.createSpan({ cls: 'qoderian-thinking-hint' }); const updateTimer = () => { @@ -64,8 +78,32 @@ export class ThinkingIndicatorController { }, SHOW_DELAY_MS), timerWindow); } + /** Reflects CLI model-capacity queue progress onto the waiting indicator. */ + setQueue(chunk: ModelQueueChunk): void { + const { state } = this.deps; + if (chunk.status === 'ready') { + if (this.queueLabel === null) return; + this.queueLabel = null; + if (state.thinkingEl) { + state.thinkingEl.removeClass('qoderian-thinking--queue'); + const label = state.thinkingEl.querySelector('.qoderian-thinking-text'); + label?.setText(FLAVOR_TEXTS[Math.floor(Math.random() * FLAVOR_TEXTS.length)]); + } + return; + } + + this.queueLabel = buildQueueLabel(chunk); + if (state.thinkingEl) { + state.thinkingEl.addClass('qoderian-thinking--queue'); + state.thinkingEl.querySelector('.qoderian-thinking-text')?.setText(this.queueLabel); + return; + } + this.show(this.queueLabel, 'qoderian-thinking--queue'); + } + hide(): void { const { state } = this.deps; + this.queueLabel = null; if (state.thinkingIndicatorTimeout) { const activeWindow = this.deps.getMessagesEl().ownerDocument.defaultView ?? window; state.clearThinkingIndicatorTimeout(activeWindow); diff --git a/src/qoder/stream/transform-qoder-message.ts b/src/qoder/stream/transform-qoder-message.ts index f9fa07b..78c1fd5 100644 --- a/src/qoder/stream/transform-qoder-message.ts +++ b/src/qoder/stream/transform-qoder-message.ts @@ -428,6 +428,13 @@ export function* transformSDKMessage( if (notification) { yield notification; } + } else if (message.subtype === 'model_queue_status') { + yield { + type: 'model_queue', + status: message.status, + queueCount: message.queue_count, + waitTimeMs: message.wait_time_ms, + }; } break; diff --git a/src/style/components/thinking.css b/src/style/components/thinking.css index 07a5012..f98c302 100644 --- a/src/style/components/thinking.css +++ b/src/style/components/thinking.css @@ -10,6 +10,10 @@ color: var(--qoderian-compact); } +.qoderian-thinking.qoderian-thinking--queue { + color: var(--text-warning); +} + .qoderian-thinking-hint { color: var(--text-muted); font-style: normal; diff --git a/tests/unit/qoder/stream/transform-qoder-message.test.ts b/tests/unit/qoder/stream/transform-qoder-message.test.ts index deb2053..ee7da9d 100644 --- a/tests/unit/qoder/stream/transform-qoder-message.test.ts +++ b/tests/unit/qoder/stream/transform-qoder-message.test.ts @@ -136,6 +136,52 @@ describe('transformSDKMessage', () => { }, ]); }); + + it('maps model_queue_status queued progress to a model_queue chunk', () => { + const message = msg({ + type: 'system', + subtype: 'model_queue_status', + status: 'queued', + request_id: 'req-1', + request_set_id: 'set-1', + model_key: 'qoder-model-queue-test', + queue_count: 3, + wait_time_ms: 12000, + } as any); + + const results = [...transformSDKMessage(message)]; + + expect(results).toEqual([ + { + type: 'model_queue', + status: 'queued', + queueCount: 3, + waitTimeMs: 12000, + }, + ]); + }); + + it('maps model_queue_status ready without optional queue fields', () => { + const message = msg({ + type: 'system', + subtype: 'model_queue_status', + status: 'ready', + request_id: 'req-2', + request_set_id: 'set-2', + model_key: 'qoder-model-queue-test', + } as any); + + const results = [...transformSDKMessage(message)]; + + expect(results).toEqual([ + { + type: 'model_queue', + status: 'ready', + queueCount: undefined, + waitTimeMs: undefined, + }, + ]); + }); }); describe('assistant messages', () => {