Skip to content

Commit 0812795

Browse files
committed
feat: add message duration tracking and refactor message renderer
- Add duration_ms field to Message model to record wall-clock execution time - Update message schema and service to handle duration - Refactor MessageRenderer with new SegmentView and WorkedRollup components - Update frontend hooks and utilities to support duration display
1 parent a869ccd commit 0812795

20 files changed

Lines changed: 246 additions & 77 deletions

File tree

backend/app/models/db_models/chat.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ class Message(Base):
160160
total_cost_usd: Mapped[float | None] = mapped_column(
161161
Float, nullable=True, default=0.0, server_default="0.0"
162162
)
163+
# Total wall-clock run time, recorded once at the terminal snapshot.
164+
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
163165
stream_status: Mapped[MessageStreamStatus] = mapped_column(
164166
SQLAlchemyEnum(
165167
MessageStreamStatus,

backend/app/models/schemas/chat.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class Message(MessageBase):
5454
created_at: datetime
5555
model_id: str | None = None
5656
stream_status: MessageStreamStatus | None = None
57+
duration_ms: int | None = None
5758
attachments: list[MessageAttachment] = Field(default_factory=list)
5859
checkpoint_id: UUID | None = None
5960

backend/app/services/message.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,17 @@ async def update_message_snapshot(
148148
}
149149
if stream_status is not None:
150150
values["stream_status"] = stream_status
151+
# Record total run time once, at the terminal snapshot. created_at
152+
# marks run start — the empty assistant row is inserted before
153+
# streaming begins.
154+
if stream_status != MessageStreamStatus.IN_PROGRESS:
155+
created_at = await db.scalar(
156+
select(Message.created_at).where(Message.id == message_id)
157+
)
158+
if created_at is not None:
159+
values["duration_ms"] = int(
160+
(now - created_at).total_seconds() * 1000
161+
)
151162
if total_cost_usd is not None:
152163
values["total_cost_usd"] = total_cost_usd
153164

backend/app/services/streaming/runtime.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -380,11 +380,13 @@ async def _save_final_snapshot(
380380
self,
381381
stream_result: StreamResult,
382382
stream_status: MessageStreamStatus,
383-
) -> None:
383+
) -> int | None:
384+
# Returns the persisted run duration so the terminal event can carry it
385+
# to the live client without waiting for a refetch.
384386
if not self.assistant_message_id:
385-
return
387+
return None
386388
await self._flush_event_buffer()
387-
await self.message_service.update_message_snapshot(
389+
message = await self.message_service.update_message_snapshot(
388390
UUID(self.assistant_message_id),
389391
content_text=self.snapshot.content_text,
390392
content_render=self.snapshot.to_render(),
@@ -393,13 +395,14 @@ async def _save_final_snapshot(
393395
stream_status=stream_status,
394396
total_cost_usd=stream_result.total_cost_usd,
395397
)
398+
return message.duration_ms if message else None
396399

397400
async def _complete_stream(
398401
self,
399402
stream_result: StreamResult,
400403
status: MessageStreamStatus,
401404
) -> str:
402-
await self._save_final_snapshot(stream_result, status)
405+
duration_ms = await self._save_final_snapshot(stream_result, status)
403406
final_content = self.snapshot.content_text
404407

405408
if status == MessageStreamStatus.COMPLETED:
@@ -413,7 +416,7 @@ async def _complete_stream(
413416
await self._emit_context_usage(stream_result)
414417
await self.emit_event(
415418
"complete",
416-
{"status": "completed"},
419+
{"status": "completed", "duration_ms": duration_ms},
417420
apply_snapshot=False,
418421
)
419422
elif status == MessageStreamStatus.INTERRUPTED:
@@ -428,14 +431,14 @@ async def _complete_stream(
428431
await self._emit_context_usage(stream_result)
429432
await self.emit_event(
430433
"cancelled",
431-
{"status": status.value},
434+
{"status": status.value, "duration_ms": duration_ms},
432435
apply_snapshot=False,
433436
)
434437
else:
435438
await self._emit_context_usage(stream_result)
436439
await self.emit_event(
437440
"complete",
438-
{"status": status.value},
441+
{"status": status.value, "duration_ms": duration_ms},
439442
apply_snapshot=False,
440443
)
441444

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Revision ID: d2e3f4a5b6c7
2+
# Revises: c1a2b3d4e5f6
3+
# Create Date: 2026-06-29 00:00:00.000000
4+
5+
from typing import Sequence
6+
7+
from alembic import op
8+
import sqlalchemy as sa
9+
10+
# revision identifiers, used by Alembic.
11+
revision: str = "d2e3f4a5b6c7"
12+
down_revision: str | None = "c1a2b3d4e5f6"
13+
branch_labels: Sequence[str] | None = None
14+
depends_on: Sequence[str] | None = None
15+
16+
17+
def upgrade() -> None:
18+
op.add_column(
19+
"messages",
20+
sa.Column("duration_ms", sa.Integer(), nullable=True),
21+
)
22+
23+
24+
def downgrade() -> None:
25+
op.drop_column("messages", "duration_ms")

frontend/src/components/chat/chat-window/Chat.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ export const Chat = memo(function Chat() {
350350
const uploadingAttachmentIds = shouldShowUploadingOverlay ? localAttachmentIds : undefined;
351351

352352
return (
353-
<div className="w-full lg:mx-auto lg:max-w-3xl">
353+
<div className="w-full lg:mx-auto lg:max-w-4xl">
354354
{isBotMessage ? (
355355
<AssistantMessage
356356
id={msg.id}
@@ -360,6 +360,7 @@ export const Chat = memo(function Chat() {
360360
isStreaming={messageIsStreaming}
361361
createdAt={msg.created_at}
362362
modelId={msg.model_id}
363+
durationMs={msg.duration_ms}
363364
checkpointId={msg.checkpoint_id}
364365
isLastBotMessage={isLastBotMessage && !messageIsStreaming}
365366
/>
@@ -386,7 +387,7 @@ export const Chat = memo(function Chat() {
386387
}
387388

388389
return (
389-
<div className="w-full lg:mx-auto lg:max-w-3xl">
390+
<div className="w-full lg:mx-auto lg:max-w-4xl">
390391
<div className="flex h-4 items-center justify-center p-4">
391392
{isFetchingNextPage && (
392393
<div className="flex items-center gap-2 text-sm text-text-secondary dark:text-text-dark-secondary">
@@ -407,7 +408,7 @@ export const Chat = memo(function Chat() {
407408
}
408409

409410
return (
410-
<div className="w-full lg:mx-auto lg:max-w-3xl">
411+
<div className="w-full lg:mx-auto lg:max-w-4xl">
411412
{showThinking && (
412413
<ThinkingIndicator key={streamStartTime} streamStartTime={streamStartTime} />
413414
)}
@@ -442,7 +443,7 @@ export const Chat = memo(function Chat() {
442443
{showScrollButton && <ScrollButton onClick={scrollToBottom} />}
443444

444445
<div className="relative bg-surface pb-safe dark:bg-surface-dark">
445-
<div className="relative w-full py-2 lg:mx-auto lg:max-w-3xl">
446+
<div className="relative w-full py-2 lg:mx-auto lg:max-w-4xl">
446447
{pendingMessages.length > 0 && (
447448
<div className="relative z-0 -mb-4 px-10 sm:px-14">
448449
<div className="flex flex-col overflow-hidden rounded-t-2xl border border-b-0 border-border/50 bg-surface-secondary pb-4 dark:border-border-dark/50 dark:bg-surface-dark-secondary">

frontend/src/components/chat/message-bubble/Message.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export interface AssistantMessageProps extends SharedContentProps {
7474
checkpointId: string | null;
7575
createdAt?: string;
7676
modelId?: string;
77+
durationMs?: number | null;
7778
isLastBotMessage?: boolean;
7879
}
7980

@@ -86,6 +87,7 @@ export const AssistantMessage = memo(function AssistantMessage({
8687
isStreaming,
8788
createdAt,
8889
modelId,
90+
durationMs,
8991
isLastBotMessage,
9092
}: AssistantMessageProps) {
9193
const { chatId, sandboxId } = useChatContext();
@@ -127,6 +129,7 @@ export const AssistantMessage = memo(function AssistantMessage({
127129
isStreaming={isStreaming}
128130
chatId={chatId}
129131
isLastBotMessage={isLastBotMessage}
132+
durationMs={durationMs}
130133
onSuggestionSelect={onSuggestionSelect}
131134
agentKind={agentKind}
132135
/>

frontend/src/components/chat/message-bubble/MessageContent.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const UserMessageContent = memo(function UserMessageContent({
3737

3838
interface AssistantMessageContentProps extends SharedContentProps {
3939
isLastBotMessage?: boolean;
40+
durationMs?: number | null;
4041
onSuggestionSelect?: (suggestion: string) => void;
4142
agentKind?: AgentKind;
4243
}
@@ -47,6 +48,7 @@ export const AssistantMessageContent = memo(function AssistantMessageContent({
4748
isStreaming,
4849
chatId,
4950
isLastBotMessage,
51+
durationMs,
5052
onSuggestionSelect,
5153
agentKind,
5254
}: AssistantMessageContentProps) {
@@ -57,6 +59,7 @@ export const AssistantMessageContent = memo(function AssistantMessageContent({
5759
isStreaming={isStreaming}
5860
chatId={chatId}
5961
isLastBotMessage={isLastBotMessage}
62+
durationMs={durationMs}
6063
onSuggestionSelect={onSuggestionSelect}
6164
agentKind={agentKind}
6265
/>

frontend/src/components/chat/message-bubble/MessageRenderer.tsx

Lines changed: 50 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
1-
import React, { memo, Suspense } from 'react';
2-
import { LazyMarkDown } from '@/components/ui/LazyMarkDown';
3-
import { ThinkingBlock } from './ThinkingBlock';
4-
import { PromptSuggestions } from './PromptSuggestions';
5-
import { getToolComponent } from '@/components/chat/tools/registry';
1+
import React, { memo } from 'react';
2+
import { SegmentView } from './SegmentView';
3+
import { WorkedRollup } from './WorkedRollup';
64
import { buildSegments } from './segmentBuilder';
75
import { AgentToolsContext } from '@/contexts/AgentToolsContext';
86
import type { AgentKind, AssistantStreamEvent } from '@/types/chat.types';
97
import type { ToolAggregate } from '@/types/tools.types';
10-
import { Spinner } from '@/components/ui/primitives/Spinner';
118

129
interface MessageRendererProps {
1310
events: AssistantStreamEvent[];
1411
className?: string;
1512
isStreaming?: boolean;
1613
chatId?: string;
1714
isLastBotMessage?: boolean;
15+
durationMs?: number | null;
1816
onSuggestionSelect?: (suggestion: string) => void;
1917
agentKind?: AgentKind;
2018
}
@@ -25,6 +23,7 @@ const MessageRendererInner: React.FC<MessageRendererProps> = ({
2523
isStreaming = false,
2624
chatId,
2725
isLastBotMessage = false,
26+
durationMs = null,
2827
onSuggestionSelect,
2928
agentKind,
3029
}) => {
@@ -65,68 +64,54 @@ const MessageRendererInner: React.FC<MessageRendererProps> = ({
6564
[segments],
6665
);
6766

67+
// Split a completed turn at its last tool/thinking segment: everything up to
68+
// it is the collapsible work trace, everything after is the final answer.
69+
// While streaming, the trace stays empty so the live work renders flat.
70+
const { traceSegments, tailSegments } = React.useMemo(() => {
71+
if (isStreaming) return { traceSegments: [], tailSegments: segments };
72+
let traceEnd = -1;
73+
for (let i = segments.length - 1; i >= 0; i--) {
74+
if (segments[i].kind === 'tool' || segments[i].kind === 'thinking') {
75+
traceEnd = i;
76+
break;
77+
}
78+
}
79+
if (traceEnd < 0) return { traceSegments: [], tailSegments: segments };
80+
return {
81+
traceSegments: segments.slice(0, traceEnd + 1),
82+
tailSegments: segments.slice(traceEnd + 1),
83+
};
84+
}, [segments, isStreaming]);
85+
6886
return (
6987
<AgentToolsContext value={agentTools}>
7088
<div className={className}>
71-
{segments.map((segment) => {
72-
switch (segment.kind) {
73-
case 'text':
74-
return (
75-
<div
76-
key={segment.id}
77-
className="prose prose-sm dark:prose-invert max-w-none break-words"
78-
>
79-
<LazyMarkDown content={segment.text} />
80-
</div>
81-
);
82-
case 'thinking': {
83-
return (
84-
<div key={segment.id} className="mb-2 mt-0.5">
85-
<ThinkingBlock
86-
content={segment.text}
87-
isActiveThinking={segment.eventIndex === activeThinkingIndex}
88-
/>
89-
</div>
90-
);
91-
}
92-
case 'tool': {
93-
const Component = getToolComponent(segment.tool.name, agentKind);
94-
return (
95-
<div key={segment.id} className="mb-2 mt-1">
96-
<Suspense
97-
fallback={
98-
<div className="flex items-center gap-2 rounded-lg border border-border/50 px-3 py-2 dark:border-border-dark/50">
99-
<Spinner
100-
size="sm"
101-
className="text-text-quaternary dark:text-text-dark-quaternary"
102-
/>
103-
<span className="text-xs text-text-tertiary dark:text-text-dark-tertiary">
104-
Loading tool output...
105-
</span>
106-
</div>
107-
}
108-
>
109-
<Component tool={segment.tool} chatId={chatId} />
110-
</Suspense>
111-
</div>
112-
);
113-
}
114-
case 'suggestions': {
115-
if (!isLastBotMessage || !onSuggestionSelect) {
116-
return null;
117-
}
118-
return (
119-
<PromptSuggestions
120-
key={segment.id}
121-
suggestions={segment.suggestions}
122-
onSelect={onSuggestionSelect}
123-
/>
124-
);
125-
}
126-
default:
127-
return null;
128-
}
129-
})}
89+
{traceSegments.length > 0 && (
90+
<WorkedRollup durationMs={durationMs}>
91+
{traceSegments.map((segment) => (
92+
<SegmentView
93+
key={segment.id}
94+
segment={segment}
95+
chatId={chatId}
96+
agentKind={agentKind}
97+
activeThinkingIndex={activeThinkingIndex}
98+
isLastBotMessage={isLastBotMessage}
99+
onSuggestionSelect={onSuggestionSelect}
100+
/>
101+
))}
102+
</WorkedRollup>
103+
)}
104+
{tailSegments.map((segment) => (
105+
<SegmentView
106+
key={segment.id}
107+
segment={segment}
108+
chatId={chatId}
109+
agentKind={agentKind}
110+
activeThinkingIndex={activeThinkingIndex}
111+
isLastBotMessage={isLastBotMessage}
112+
onSuggestionSelect={onSuggestionSelect}
113+
/>
114+
))}
130115
</div>
131116
</AgentToolsContext>
132117
);

0 commit comments

Comments
 (0)