Show Assistant turn duration after completion

This commit is contained in:
rcourtman
2026-06-06 08:53:02 +01:00
parent 06939189a5
commit ce882f08aa
6 changed files with 86 additions and 0 deletions
@@ -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
@@ -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<MessageItemProps> = (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<MessageItemProps> = (props) => {
{messageModelLabel()}
</span>
</Show>
<Show when={messageDurationLabel()}>
<span
class="inline-flex shrink-0 items-center gap-1 rounded border border-border-subtle bg-surface-alt px-1.5 py-0.5 text-[10px] font-medium text-muted"
title="Turn duration"
aria-label={`Turn duration ${messageDurationLabel()}`}
>
<ClockIcon class="h-3 w-3" aria-hidden="true" />
{messageDurationLabel()}
</span>
</Show>
<Show when={shouldShowHeaderWorkflowStatus()}>
<span
class="inline-flex min-w-0 max-w-[18rem] items-center gap-1.5 rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200"
@@ -358,6 +358,41 @@ describe('MessageItem', () => {
expect(screen.getByText('DeepSeek: DeepSeek V4 Pro via OpenRouter')).toBeInTheDocument();
});
it('renders completed assistant turn duration without token counts', () => {
render(() => (
<MessageItem
message={makeMessage({
role: 'assistant',
model: 'openrouter:deepseek/deepseek-v4-pro',
timestamp: new Date('2026-03-01T12:00:00Z'),
completedAt: new Date('2026-03-01T12:00:04Z'),
tokens: { input: 500, output: 200 },
isStreaming: false,
})}
{...makeHandlers()}
/>
));
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(() => (
<MessageItem
message={makeMessage({
role: 'assistant',
timestamp: new Date('2026-03-01T12:00:00Z'),
completedAt: new Date('2026-03-01T12:00:04Z'),
isStreaming: true,
})}
{...makeHandlers()}
/>
));
expect(screen.queryByLabelText('Turn duration 4s')).not.toBeInTheDocument();
});
it('does not use right-alignment for assistant messages', () => {
const { container } = render(() => (
<MessageItem message={makeMessage({ role: 'assistant' })} {...makeHandlers()} />
@@ -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();
@@ -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',
@@ -148,6 +148,7 @@ export interface ChatMessage {
thinkingChunks?: string[];
streamEvents?: StreamDisplayEvent[];
timestamp: Date;
completedAt?: Date;
model?: string;
tokens?: { input: number; output: number };
toolCalls?: ToolExecution[];