fix(ui): show active assistant model in status
This commit is contained in:
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user