From c463c023139301355b662b2720d8670badad7530 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Mon, 21 Sep 2026 00:52:25 +0800 Subject: [PATCH] feat(chat): surface model capacity queue progress on the waiting indicator The CLI emits model_queue_status while a request waits for capacity; the plugin now maps it to a model_queue stream chunk and shows the position and estimated wait on the thinking indicator, restoring the usual wording once the request is ready. Older CLIs that never emit the subtype are unaffected. Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 5 ++ src/core/types/chat.ts | 1 + .../chat/controllers/stream-controller.ts | 4 ++ .../thinking-indicator-controller.ts | 44 ++++++++++++++++-- src/qoder/stream/transform-qoder-message.ts | 7 +++ src/style/components/thinking.css | 4 ++ .../stream/transform-qoder-message.test.ts | 46 +++++++++++++++++++ 7 files changed, 108 insertions(+), 3 deletions(-) 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', () => {