From ab97e00ff28b1400ee4e0c420deede466e6b4d25 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 21 Jul 2026 22:49:16 +0300 Subject: [PATCH] fix(ui): show active assistant model in status --- .../components/chat/StatusRowContainer.tsx | 16 ++-- .../ui/src/hooks/useAssistantStatus.test.ts | 60 ++++++++++++++ packages/ui/src/hooks/useAssistantStatus.ts | 80 +++++++++++++++---- 3 files changed, 130 insertions(+), 26 deletions(-) create mode 100644 packages/ui/src/hooks/useAssistantStatus.test.ts diff --git a/packages/ui/src/components/chat/StatusRowContainer.tsx b/packages/ui/src/components/chat/StatusRowContainer.tsx index 03f95dd0..46c8bd3a 100644 --- a/packages/ui/src/components/chat/StatusRowContainer.tsx +++ b/packages/ui/src/components/chat/StatusRowContainer.tsx @@ -21,21 +21,19 @@ export const StatusRowContainer: React.FC = React.memo(() => { return state.sessionAbortFlags?.get(currentSessionId) ?? null; }, [currentSessionId]), ); - const { working } = useAssistantStatus(); + const { activeModel, working } = useAssistantStatus(); const currentAgentName = useConfigStore((state) => state.currentAgentName); - const currentProviderId = useConfigStore((state) => state.currentProviderId); - const currentModelId = useConfigStore((state) => state.currentModelId); const providers = useConfigStore((state) => state.providers); const modelDisplayName = React.useMemo(() => { - if (!currentModelId) { + if (!activeModel) { return null; } - const provider = currentProviderId && providers.length > 0 - ? providers.find((candidate) => candidate.id === currentProviderId) + const provider = providers.length > 0 + ? providers.find((candidate) => candidate.id === activeModel.providerId) : undefined; - return getProviderModelDisplayName(provider, currentModelId) || null; - }, [currentProviderId, currentModelId, providers]); + return getProviderModelDisplayName(provider, activeModel.modelId) || null; + }, [activeModel, providers]); const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged); @@ -52,7 +50,7 @@ export const StatusRowContainer: React.FC = React.memo(() => { showTodos={false} agentName={currentAgentName} modelName={modelDisplayName} - providerId={currentProviderId ?? null} + providerId={activeModel?.providerId ?? null} /> ); }); diff --git a/packages/ui/src/hooks/useAssistantStatus.test.ts b/packages/ui/src/hooks/useAssistantStatus.test.ts new file mode 100644 index 00000000..0485171c --- /dev/null +++ b/packages/ui/src/hooks/useAssistantStatus.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test'; +import type { Message } from '@opencode-ai/sdk/v2'; + +import { getActiveAssistantContext } from './useAssistantStatus'; + +const userMessage = (id: string, providerID: string, modelID: string): Message => ({ + id, + role: 'user', + sessionID: 'ses_1', + time: { created: 1 }, + model: { providerID, modelID }, +} as Message); + +const assistantMessage = (id: string, parentID: string): Message => ({ + id, + role: 'assistant', + sessionID: 'ses_1', + parentID, + time: { created: 2 }, +} as Message); + +describe('getActiveAssistantContext', () => { + test('uses the active assistant parent model instead of the latest user selection', () => { + const activeParent = userMessage('user_1', 'anthropic', 'claude-opus-4-1'); + const assistant = assistantMessage('assistant_1', activeParent.id); + const laterSelection = userMessage('user_2', 'openai', 'gpt-5.6-sol'); + + expect(getActiveAssistantContext([activeParent, assistant, laterSelection])).toEqual({ + assistantId: assistant.id, + model: { + providerId: 'anthropic', + modelId: 'claude-opus-4-1', + }, + }); + }); + + test('switches models only when a newer assistant links to the newer user message', () => { + const firstUser = userMessage('user_1', 'anthropic', 'claude-opus-4-1'); + const firstAssistant = assistantMessage('assistant_1', firstUser.id); + const secondUser = userMessage('user_2', 'openai', 'gpt-5.6-sol'); + const secondAssistant = assistantMessage('assistant_2', secondUser.id); + + expect(getActiveAssistantContext([firstUser, firstAssistant, secondUser, secondAssistant])).toEqual({ + assistantId: secondAssistant.id, + model: { + providerId: 'openai', + modelId: 'gpt-5.6-sol', + }, + }); + }); + + test('does not guess a model when the parent message is unavailable', () => { + const assistant = assistantMessage('assistant_1', 'missing_user'); + + expect(getActiveAssistantContext([assistant])).toEqual({ + assistantId: assistant.id, + model: null, + }); + }); +}); diff --git a/packages/ui/src/hooks/useAssistantStatus.ts b/packages/ui/src/hooks/useAssistantStatus.ts index f88f1bc3..414e1981 100644 --- a/packages/ui/src/hooks/useAssistantStatus.ts +++ b/packages/ui/src/hooks/useAssistantStatus.ts @@ -3,7 +3,7 @@ import type { Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode import type { MessageStreamPhase } from '@/stores/types/sessionTypes'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useDirectorySync, useSessionPermissions, useSessionQuestions, useSessionStatus } from '@/sync/sync-context'; +import { useDirectorySync, useSessionMessages, useSessionPermissions, useSessionQuestions, useSessionStatus } from '@/sync/sync-context'; import { isFullySyntheticMessage } from '@/lib/messages/synthetic'; import { useCurrentSessionActivity } from './useSessionActivity'; @@ -37,10 +37,21 @@ interface FormingSummary { } export interface AssistantStatusSnapshot { + activeModel: ActiveAssistantModel | null; forming: FormingSummary; working: WorkingSummary; } +interface ActiveAssistantModel { + providerId: string; + modelId: string; +} + +interface ActiveAssistantContext { + assistantId: string | null; + model: ActiveAssistantModel | null; +} + const DEFAULT_WORKING: WorkingSummary = { activity: 'idle', hasWorkingContext: false, @@ -63,7 +74,6 @@ const DEFAULT_WORKING: WorkingSummary = { retryInfo: null, }; -const EMPTY_MESSAGES: Message[] = []; const EMPTY_PARTS: Part[] = []; const STATUS_SIGNATURE_SEPARATOR = '\u0000'; const EDITING_TOOLS = new Set(['edit', 'write', 'multiedit', 'apply_patch']); @@ -247,28 +257,63 @@ const getToolDisplayName = (part: ToolPart): string => { return typeof candidate.name === 'string' ? candidate.name : 'tool'; }; +export const getActiveAssistantContext = (messages: Message[]): ActiveAssistantContext => { + let assistantId: string | null = null; + let parentId: string | null = null; + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role !== 'assistant') continue; + + const candidate = message as Message & { parentID?: unknown }; + assistantId = message.id; + parentId = typeof candidate.parentID === 'string' && candidate.parentID.trim().length > 0 + ? candidate.parentID + : null; + break; + } + + if (!assistantId || !parentId) { + return { assistantId, model: null }; + } + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role !== 'user' || message.id !== parentId) continue; + + const candidate = message as Message & { + model?: { providerID?: unknown; modelID?: unknown }; + }; + const providerId = typeof candidate.model?.providerID === 'string' + ? candidate.model.providerID.trim() + : ''; + const modelId = typeof candidate.model?.modelID === 'string' + ? candidate.model.modelID.trim() + : ''; + + return { + assistantId, + model: providerId && modelId ? { providerId, modelId } : null, + }; + } + + return { assistantId, model: null }; +}; + export function useAssistantStatus(): AssistantStatusSnapshot { const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); - const rawSessionMessages = useDirectorySync( - React.useCallback((state) => { - if (!currentSessionId) { - return EMPTY_MESSAGES; - } - return state.message[currentSessionId] ?? EMPTY_MESSAGES; - }, [currentSessionId]), + const rawSessionMessages = useSessionMessages( + currentSessionId ?? '', currentSessionDirectory ?? undefined, ); - // Only subscribe to parts for the last assistant message — avoids re-render - // on every part delta for earlier messages. - const lastAssistantId = React.useMemo(() => { - for (let i = rawSessionMessages.length - 1; i >= 0; i--) { - if (rawSessionMessages[i].role === 'assistant') return rawSessionMessages[i].id; - } - return null; - }, [rawSessionMessages]); + const activeAssistant = React.useMemo( + () => getActiveAssistantContext(rawSessionMessages), + [rawSessionMessages], + ); + const lastAssistantId = activeAssistant.assistantId; const lastAssistantStatusSignature = useDirectorySync( React.useCallback((state) => { @@ -412,6 +457,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot { }, [baseWorking, sessionPermissionRequests, sessionQuestionRequests]); return { + activeModel: activeAssistant.model, forming, working, };