fix(ui): show active assistant model in status

This commit is contained in:
Bohdan Triapitsyn
2026-07-21 22:49:16 +03:00
parent d2efa707ff
commit ab97e00ff2
3 changed files with 130 additions and 26 deletions
@@ -21,21 +21,19 @@ export const StatusRowContainer: React.FC = React.memo(() => {
return state.sessionAbortFlags?.get(currentSessionId) ?? null; return state.sessionAbortFlags?.get(currentSessionId) ?? null;
}, [currentSessionId]), }, [currentSessionId]),
); );
const { working } = useAssistantStatus(); const { activeModel, working } = useAssistantStatus();
const currentAgentName = useConfigStore((state) => state.currentAgentName); 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 providers = useConfigStore((state) => state.providers);
const modelDisplayName = React.useMemo(() => { const modelDisplayName = React.useMemo(() => {
if (!currentModelId) { if (!activeModel) {
return null; return null;
} }
const provider = currentProviderId && providers.length > 0 const provider = providers.length > 0
? providers.find((candidate) => candidate.id === currentProviderId) ? providers.find((candidate) => candidate.id === activeModel.providerId)
: undefined; : undefined;
return getProviderModelDisplayName(provider, currentModelId) || null; return getProviderModelDisplayName(provider, activeModel.modelId) || null;
}, [currentProviderId, currentModelId, providers]); }, [activeModel, providers]);
const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged); const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged);
@@ -52,7 +50,7 @@ export const StatusRowContainer: React.FC = React.memo(() => {
showTodos={false} showTodos={false}
agentName={currentAgentName} agentName={currentAgentName}
modelName={modelDisplayName} modelName={modelDisplayName}
providerId={currentProviderId ?? null} providerId={activeModel?.providerId ?? null}
/> />
); );
}); });
@@ -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,
});
});
});
+63 -17
View File
@@ -3,7 +3,7 @@ import type { Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode
import type { MessageStreamPhase } from '@/stores/types/sessionTypes'; import type { MessageStreamPhase } from '@/stores/types/sessionTypes';
import { useSessionUIStore } from '@/sync/session-ui-store'; 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 { isFullySyntheticMessage } from '@/lib/messages/synthetic';
import { useCurrentSessionActivity } from './useSessionActivity'; import { useCurrentSessionActivity } from './useSessionActivity';
@@ -37,10 +37,21 @@ interface FormingSummary {
} }
export interface AssistantStatusSnapshot { export interface AssistantStatusSnapshot {
activeModel: ActiveAssistantModel | null;
forming: FormingSummary; forming: FormingSummary;
working: WorkingSummary; working: WorkingSummary;
} }
interface ActiveAssistantModel {
providerId: string;
modelId: string;
}
interface ActiveAssistantContext {
assistantId: string | null;
model: ActiveAssistantModel | null;
}
const DEFAULT_WORKING: WorkingSummary = { const DEFAULT_WORKING: WorkingSummary = {
activity: 'idle', activity: 'idle',
hasWorkingContext: false, hasWorkingContext: false,
@@ -63,7 +74,6 @@ const DEFAULT_WORKING: WorkingSummary = {
retryInfo: null, retryInfo: null,
}; };
const EMPTY_MESSAGES: Message[] = [];
const EMPTY_PARTS: Part[] = []; const EMPTY_PARTS: Part[] = [];
const STATUS_SIGNATURE_SEPARATOR = '\u0000'; const STATUS_SIGNATURE_SEPARATOR = '\u0000';
const EDITING_TOOLS = new Set(['edit', 'write', 'multiedit', 'apply_patch']); 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'; 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 { export function useAssistantStatus(): AssistantStatusSnapshot {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const rawSessionMessages = useDirectorySync( const rawSessionMessages = useSessionMessages(
React.useCallback((state) => { currentSessionId ?? '',
if (!currentSessionId) {
return EMPTY_MESSAGES;
}
return state.message[currentSessionId] ?? EMPTY_MESSAGES;
}, [currentSessionId]),
currentSessionDirectory ?? undefined, currentSessionDirectory ?? undefined,
); );
// Only subscribe to parts for the last assistant message — avoids re-render const activeAssistant = React.useMemo(
// on every part delta for earlier messages. () => getActiveAssistantContext(rawSessionMessages),
const lastAssistantId = React.useMemo(() => { [rawSessionMessages],
for (let i = rawSessionMessages.length - 1; i >= 0; i--) { );
if (rawSessionMessages[i].role === 'assistant') return rawSessionMessages[i].id; const lastAssistantId = activeAssistant.assistantId;
}
return null;
}, [rawSessionMessages]);
const lastAssistantStatusSignature = useDirectorySync( const lastAssistantStatusSignature = useDirectorySync(
React.useCallback((state) => { React.useCallback((state) => {
@@ -412,6 +457,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
}, [baseWorking, sessionPermissionRequests, sessionQuestionRequests]); }, [baseWorking, sessionPermissionRequests, sessionQuestionRequests]);
return { return {
activeModel: activeAssistant.model,
forming, forming,
working, working,
}; };