diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 530bcc059..b5ee6f3ad 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -268,6 +268,17 @@ runtime cost control, and shared AI transport surfaces. that as an active-turn status strip: no idle diagnostics, but while a turn is loading it must show waiting, current tool/workflow progress, or generating status even when the transcript already contains an in-flight assistant row. + The referenced OpenCode source at fetched `origin/dev` commit + `09d9cf01f93798939c1284fbe974b6e1f4d2759d` resolves the direct-run wait when + `packages/opencode/src/cli/cmd/run/stream.transport.ts` verifies the session + is idle, maps `turn.idle` to an idle footer phase in + `packages/opencode/src/cli/cmd/run/footer.ts`, and then flushes scrollback + separately from later state sync. Pulse's Assistant drawer adapts that + completion boundary by clearing the visible active turn as soon as the chat + stream has processed its terminal `done` or `error` event; async + conversation/session-list refresh may continue afterward, but it must not + keep the composer footer or transcript row saying the assistant is still + generating. When multiple governed tools are pending, completed tools must not blank the status while another tool is still running, and the status heartbeat should follow the latest progressed pending tool without reordering the transcript's diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts index 0bb8f2ba8..df5e4237b 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -197,6 +197,54 @@ describe('useChat', () => { dispose(); }); + it('clears the visible active turn before conversation refresh finishes', async () => { + let resolveRefresh!: () => void; + let sendResolved = false; + mockChat.mockImplementation( + ( + _prompt: string, + _session: string | undefined, + _model: string | undefined, + onEvent: (event: StreamEvent) => void, + ) => { + onEvent({ + type: 'content', + data: { text: 'Pulse currently sees 33 compute resources.' }, + } as StreamEvent); + onEvent({ + type: 'done', + data: { model: 'pulse:local-inventory' }, + } as StreamEvent); + return Promise.resolve(); + }, + ); + const onConversationChanged = vi.fn( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + + const { value: chat, dispose } = withRoot(() => useChat({ onConversationChanged })); + const sendPromise = chat.sendMessage('how many devices in this').then((result) => { + sendResolved = true; + return result; + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(chat.isLoading()).toBe(false); + expect(chat.messages()[1]).toMatchObject({ + isStreaming: false, + model: 'pulse:local-inventory', + }); + expect(sendResolved).toBe(false); + + resolveRefresh(); + await expect(sendPromise).resolves.toBe(true); + dispose(); + }); + it('uses the configured default model route for the request and assistant turn', async () => { mockChat.mockResolvedValue(undefined); diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 1e2193405..f4ab8480e 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -1134,6 +1134,19 @@ export function useChat(options: UseChatOptions = {}) { const abortController = new AbortController(); abortControllerRef = abortController; + let visibleTurnCompleted = false; + + const completeVisibleTurn = () => { + if (requestId !== activeRequestId || visibleTurnCompleted) return; + visibleTurnCompleted = true; + abortControllerRef = null; + setIsLoading(false); + if (options?.drainAfter !== false) { + queueMicrotask(() => { + void drainQueuedFollowUps(); + }); + } + }; try { await AIChatAPI.chat( @@ -1155,6 +1168,7 @@ export function useChat(options: UseChatOptions = {}) { if (requestId !== activeRequestId) { return false; } + completeVisibleTurn(); await notifyConversationChanged(); return true; } catch (error) { @@ -1177,17 +1191,12 @@ export function useChat(options: UseChatOptions = {}) { : msg, ), ); + completeVisibleTurn(); await notifyConversationChanged(); return false; } finally { if (requestId === activeRequestId) { - abortControllerRef = null; - setIsLoading(false); - if (options?.drainAfter !== false) { - queueMicrotask(() => { - void drainQueuedFollowUps(); - }); - } + completeVisibleTurn(); } } };