diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 5e8b59814..55a76d53d 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -600,6 +600,15 @@ runtime cost control, and shared AI transport surfaces. remain route-distinct: if the configured chat override resolves to the same route as the effective default or the already selected session model, the drawer must not render a duplicate override action. + The referenced OpenCode source at fetched `origin/dev` commit + `1399323b78a04229d9bfe00c7436d7f41770fda8` renders the completed assistant + footer in + `packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx` + (`AssistantMessage`) with agent, provider/model, and turn duration rather + than token counts. Pulse Assistant rows adapt that by keeping visible token + accounting out of the transcript while showing a compact completed-turn + duration beside the effective model label once a turn reaches `done`, + `error`, or user interruption. 7. Keep AI chat presentation helpers aligned through `frontend-modern/src/components/AI/Chat/` and the shared `frontend-modern/src/utils/textPresentation.ts` 8. Keep assistant drawer context, session, and org-switch reset state aligned through the shared `frontend-modern/src/stores/aiChat.ts` boundary instead of letting `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, or feature callers fork their own assistant shell state That shared drawer ownership also covers passive resource reads while the diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 9d21937b6..53f49ca41 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -60,6 +60,24 @@ interface MessageItemProps { onCancelQueued?: () => void; } +const formatAssistantTurnDuration = (startedAt: Date, completedAt?: Date): string => { + if (!completedAt) return ''; + const durationMs = completedAt.getTime() - startedAt.getTime(); + if (!Number.isFinite(durationMs) || durationMs < 0) return ''; + if (durationMs < 1000) return '<1s'; + + const totalSeconds = Math.max(1, Math.round(durationMs / 1000)); + if (totalSeconds < 60) return `${totalSeconds}s`; + + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`; + + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +}; + const markdownClass = 'text-sm prose prose-slate prose-sm dark:prose-invert max-w-none prose-p:leading-relaxed prose-p:my-2 prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-md prose-pre:text-xs prose-pre:border prose-pre:border-slate-800 prose-code:text-blue-700 dark:prose-code:text-blue-300 prose-code:bg-blue-50 dark:prose-code:bg-blue-900 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:font-mono prose-code:text-[0.9em] prose-code:border prose-code:border-blue-100 dark:prose-code:border-blue-800 prose-code:before:content-none prose-code:after:content-none prose-headings:font-semibold prose-hr:border-slate-200 dark:prose-hr:border-slate-700 prose-ul:my-2 prose-ol:my-2 prose-li:my-1'; @@ -135,6 +153,10 @@ export const MessageItem: Component = (props) => { return props.getModelRouteLabel?.(model) || formatAIModelRouteLabel(model); }; const messageModelLabel = () => modelRouteLabel(props.message.model); + const messageDurationLabel = () => + props.message.isStreaming + ? '' + : formatAssistantTurnDuration(props.message.timestamp, props.message.completedAt); // Check if currently streaming content (no tools pending, still streaming) const isStreamingText = () => @@ -270,6 +292,16 @@ export const MessageItem: Component = (props) => { {messageModelLabel()} + + + + { expect(screen.getByText('DeepSeek: DeepSeek V4 Pro via OpenRouter')).toBeInTheDocument(); }); + it('renders completed assistant turn duration without token counts', () => { + render(() => ( + + )); + + expect(screen.getByLabelText('Turn duration 4s')).toBeInTheDocument(); + expect(screen.queryByText('500 in ยท 200 out')).not.toBeInTheDocument(); + }); + + it('does not show turn duration while the assistant is still streaming', () => { + render(() => ( + + )); + + expect(screen.queryByLabelText('Turn duration 4s')).not.toBeInTheDocument(); + }); + it('does not use right-alignment for assistant messages', () => { const { container } = render(() => ( 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 ce28804bc..d390f9986 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -754,6 +754,7 @@ describe('useChat', () => { interruption: 'stopped', isStreaming: false, }); + expect(assistant?.completedAt).toBeInstanceOf(Date); dispose(); }); @@ -1633,6 +1634,7 @@ describe('useChat', () => { const assistant = chat.messages().find((m) => m.role === 'assistant')!; expect(assistant.isStreaming).toBe(false); + expect(assistant.completedAt).toBeInstanceOf(Date); expect(assistant.model).toBe('gemini:gemini-3.1-flash-lite'); expect(assistant.tokens).toEqual({ input: 100, output: 50 }); expect(assistant.pendingTools).toHaveLength(0); @@ -1650,6 +1652,7 @@ describe('useChat', () => { const assistant = chat.messages().find((m) => m.role === 'assistant')!; expect(assistant.isStreaming).toBe(false); + expect(assistant.completedAt).toBeInstanceOf(Date); expect(assistant.tokens).toBeUndefined(); dispose(); }); @@ -1679,6 +1682,7 @@ describe('useChat', () => { const assistant = chat.messages().find((m) => m.role === 'assistant')!; expect(assistant.isStreaming).toBe(false); + expect(assistant.completedAt).toBeInstanceOf(Date); expect(assistant.error).toBe('Rate limited'); expect(assistant.pendingTools).toHaveLength(0); dispose(); diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 655e62f9d..f5679eab7 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -198,6 +198,7 @@ export function useChat(options: UseChatOptions = {}) { ? { ...msg, isStreaming: false, + completedAt: new Date(), interruption, pendingTools: [], pendingApprovals: [], @@ -1071,6 +1072,7 @@ export function useChat(options: UseChatOptions = {}) { } case 'done': { + const completedAt = new Date(); const pendingText = suppressedRawContentMessageIds.has(assistantId) ? '' : flushPendingAssistantOutputText(outputArtifactStateFor(assistantId)); @@ -1088,6 +1090,7 @@ export function useChat(options: UseChatOptions = {}) { return { ...flushedMsg, isStreaming: false, + completedAt, ...(completedModel ? { model: completedModel } : {}), pendingTools: [], tokens, @@ -1097,6 +1100,7 @@ export function useChat(options: UseChatOptions = {}) { return { ...flushedMsg, isStreaming: false, + completedAt, ...(completedModel ? { model: completedModel } : {}), pendingTools: [], workflowStatus: undefined, @@ -1111,6 +1115,7 @@ export function useChat(options: UseChatOptions = {}) { return { ...msg, isStreaming: false, + completedAt: new Date(), pendingTools: [], workflowStatus: undefined, error: errorMsg || 'Request failed', diff --git a/frontend-modern/src/components/AI/Chat/types.ts b/frontend-modern/src/components/AI/Chat/types.ts index 650390667..3462ea754 100644 --- a/frontend-modern/src/components/AI/Chat/types.ts +++ b/frontend-modern/src/components/AI/Chat/types.ts @@ -148,6 +148,7 @@ export interface ChatMessage { thinkingChunks?: string[]; streamEvents?: StreamDisplayEvent[]; timestamp: Date; + completedAt?: Date; model?: string; tokens?: { input: number; output: number }; toolCalls?: ToolExecution[];