feat(ui): add message timing metrics (#357)

Fixes #297

## Summary
- show total assistant response duration next to the existing message
timestamp using only explicit OpenCode message timing
- show reasoning duration on reasoning cards only when OpenCode provides
explicit part timing

## Why
This revision keeps the UI scoped to explicit server-side timing data
that OpenCode already provides, which matches the maintainer feedback on
this PR.

## Validation
- node --experimental-strip-types --test
"packages/ui/src/lib/message-timing.test.ts"
- npm run build --workspace @codenomad/ui
This commit is contained in:
Pascal André 2026-06-06 22:51:27 +02:00 committed by GitHub
parent e30c73716a
commit 006b4f790e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 164 additions and 6 deletions

View file

@ -17,6 +17,7 @@ import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
import { createFollowScroll } from "../lib/follow-scroll"
import { formatElapsedClock, getMessageStartedAt, getPartStartedAt, inferReasoningDurationMs } from "../lib/message-timing"
import type { SessionSearchMatch } from "../lib/session-search"
import ActionOverflowMenu, { type ActionOverflowMenuItem } from "./action-overflow-menu"
@ -698,6 +699,7 @@ type ReasoningDisplayItem = {
key: string
part: ClientPart
messageInfo?: MessageInfo
durationMs?: number
showAgentMeta?: boolean
defaultExpanded: boolean
messageId: string
@ -948,6 +950,7 @@ export default function MessageBlock(props: MessageBlockProps) {
key,
part,
messageInfo: info,
durationMs: inferReasoningDurationMs(orderedParts, part, info, current.status),
showAgentMeta,
defaultExpanded: props.thinkingDefaultExpanded(),
messageId: current.id,
@ -1098,6 +1101,7 @@ export default function MessageBlock(props: MessageBlockProps) {
<ReasoningCard
part={(item() as ReasoningDisplayItem).part}
messageInfo={(item() as ReasoningDisplayItem).messageInfo}
durationMs={(item() as ReasoningDisplayItem).durationMs}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={(item() as ReasoningDisplayItem).messageId}
@ -1493,6 +1497,7 @@ function formatCostValue(value: number) {
interface ReasoningCardProps {
part: ClientPart
messageInfo?: MessageInfo
durationMs?: number
instanceId: string
sessionId: string
messageId: string
@ -1575,7 +1580,7 @@ function ReasoningStreamOutput(props: {
}
function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n()
const { locale, t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
@ -1593,11 +1598,13 @@ function ReasoningCard(props: ReasoningCardProps) {
})
const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const value = getPartStartedAt(props.part) ?? getMessageStartedAt(props.messageInfo) ?? Date.now()
const date = new Date(value)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const durationLabel = () => formatElapsedClock(props.durationMs, locale())
const agentIdentifier = () => {
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
@ -1790,6 +1797,9 @@ function ReasoningCard(props: ReasoningCardProps) {
</Show>
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
<Show when={durationLabel()}>
{(value) => <span class="message-reasoning-duration">{value()}</span>}
</Show>
</span>
</span>
</button>
@ -1860,7 +1870,9 @@ function ReasoningCard(props: ReasoningCardProps) {
minItems={2}
/>
<span class="message-reasoning-time">{timestamp()}</span>
<div class="message-reasoning-timing">
<span class="message-reasoning-time">{timestamp()}</span>
</div>
</div>
</div>

View file

@ -14,6 +14,7 @@ import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
import ActionOverflowMenu, { type ActionOverflowMenuItem } from "./action-overflow-menu"
import { formatElapsedClock, getMessageDurationMs, getMessageStartedAt } from "../lib/message-timing"
function DeleteUpToIcon() {
return (
@ -42,7 +43,7 @@ interface MessageItemProps {
}
export default function MessageItem(props: MessageItemProps) {
const { t } = useI18n()
const { locale, t } = useI18n()
const [copied, setCopied] = createSignal(false)
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
@ -139,7 +140,9 @@ export default function MessageItem(props: MessageItemProps) {
})
const isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
const createdTimestamp = () => getMessageStartedAt(props.messageInfo, props.record.createdAt) ?? props.record.createdAt
const totalDuration = () => getMessageDurationMs(props.messageInfo, props.record.status, props.record.createdAt)
const totalDurationLabel = () => (!isUser() ? formatElapsedClock(totalDuration(), locale()) : "")
const timestamp = () => {
const date = new Date(createdTimestamp())
@ -490,6 +493,9 @@ export default function MessageItem(props: MessageItemProps) {
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
{speakerLabel()}
</span>
<Show when={totalDurationLabel()}>
{(value) => <span class="message-duration">{value()}</span>}
</Show>
</div>
<Show when={metaText() && showMetaInline()}>
@ -641,7 +647,9 @@ export default function MessageItem(props: MessageItemProps) {
minItems={2}
/>
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
<div class="message-meta-timing">
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>
</div>
</div>

View file

@ -0,0 +1,53 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { formatElapsedClock, getMessageDurationMs, inferReasoningDurationMs } from "./message-timing.ts"
describe("message timing helpers", () => {
it("formats elapsed durations as clock values without unit suffixes", () => {
assert.equal(formatElapsedClock(900), "0:01")
assert.equal(formatElapsedClock(65_000), "1:05")
assert.equal(formatElapsedClock(3_725_000), "1:02:05")
})
it("localizes elapsed clock digits while keeping compact clock separators", () => {
const wholeNumber = new Intl.NumberFormat("ar-EG", { useGrouping: false })
const twoDigitNumber = new Intl.NumberFormat("ar-EG", { minimumIntegerDigits: 2, useGrouping: false })
assert.equal(formatElapsedClock(65_000, "ar-EG"), `${wholeNumber.format(1)}:${twoDigitNumber.format(5)}`)
})
it("uses message created/completed times for assistant durations", () => {
const duration = getMessageDurationMs({ time: { created: 1_000, completed: 7_000 } } as any, "complete")
assert.equal(duration, 6_000)
})
it("does not infer message duration from legacy end or updated fields", () => {
const fromEnd = getMessageDurationMs({ time: { created: 1_000, end: 7_000 } } as any, "complete")
const fromUpdated = getMessageDurationMs({ time: { created: 1_000, updated: 5_000 } } as any, "error")
assert.equal(fromEnd, undefined)
assert.equal(fromUpdated, undefined)
})
it("does not infer message duration from explicit duration fields", () => {
const duration = getMessageDurationMs({ duration: 6_000, time: { duration: 6_000 } } as any, "complete")
assert.equal(duration, undefined)
})
it("uses reasoning start/end times when OpenCode provides them on the part", () => {
const reasoningPart = { id: "reasoning-1", type: "reasoning", time: { start: 1_000, end: 4_500 } } as any
const duration = inferReasoningDurationMs([reasoningPart], reasoningPart)
assert.equal(duration, 3_500)
})
it("does not infer reasoning duration from message completion or fallback fields", () => {
const reasoningPart = { id: "reasoning-1", type: "reasoning", duration: 3_000, time: { created: 1_000, start: 2_000 } } as any
const duration = inferReasoningDurationMs(
[reasoningPart],
reasoningPart,
{ time: { created: 1_000, completed: 8_000 } } as any,
"complete",
)
assert.equal(duration, undefined)
})
})

View file

@ -0,0 +1,66 @@
import type { ClientPart, MessageInfo } from "../types/message"
import type { MessageStatus } from "../stores/message-v2/types"
function getPositiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined
}
function getTimeValue(source: unknown, key: "created" | "updated" | "completed" | "end" | "start"): number | undefined {
return getPositiveNumber((source as any)?.time?.[key])
}
function getDurationBetween(startedAt?: number, endedAt?: number): number | undefined {
if (!startedAt || !endedAt || endedAt <= startedAt) return undefined
return endedAt - startedAt
}
export function getMessageStartedAt(messageInfo?: MessageInfo, fallback?: number): number | undefined {
return getTimeValue(messageInfo, "created") ?? getPositiveNumber(fallback)
}
export function getMessageCompletedAt(messageInfo?: MessageInfo, _status?: MessageStatus): number | undefined {
return getTimeValue(messageInfo, "completed")
}
// Match OpenChamber's explicit OpenCode message timing model:
// message duration is defined by time.created -> time.completed.
export function getMessageDurationMs(messageInfo?: MessageInfo, _status?: MessageStatus, _fallbackStartedAt?: number): number | undefined {
return getDurationBetween(getTimeValue(messageInfo, "created"), getMessageCompletedAt(messageInfo))
}
export function getPartStartedAt(part?: ClientPart): number | undefined {
return getTimeValue(part, "start") ?? getTimeValue(part, "created")
}
export function getPartDurationMs(part?: ClientPart): number | undefined {
return getDurationBetween(getTimeValue(part, "start"), getTimeValue(part, "end"))
}
export function inferReasoningDurationMs(
_parts: ClientPart[],
reasoningPart: ClientPart,
_messageInfo?: MessageInfo,
_status?: MessageStatus,
): number | undefined {
return getPartDurationMs(reasoningPart)
}
export function formatElapsedClock(durationMs?: number, locale?: string): string {
const safeDuration = getPositiveNumber(durationMs)
if (!safeDuration) {
return ""
}
const formatNumber = (value: number, minimumIntegerDigits = 1) =>
new Intl.NumberFormat(locale, { minimumIntegerDigits, useGrouping: false }).format(value)
const totalSeconds = Math.max(1, Math.round(safeDuration / 1000))
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${formatNumber(hours)}:${formatNumber(minutes, 2)}:${formatNumber(seconds, 2)}`
}
return `${formatNumber(minutes)}:${formatNumber(seconds, 2)}`
}

View file

@ -45,6 +45,7 @@
.message-speaker-primary {
@apply inline-flex items-center;
gap: 0.375rem;
white-space: nowrap;
flex: 0 0 auto;
}
@ -129,6 +130,18 @@
@apply text-[11px] text-[var(--text-muted)];
}
.message-meta-timing {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.message-duration,
.message-reasoning-duration {
@apply text-[11px] text-[var(--text-muted)];
font-variant-numeric: tabular-nums;
}
.assistant-message {
/* gap: 0.25rem; */
padding: 0.6rem 0.65rem;
@ -539,6 +552,12 @@
color: var(--text-muted);
}
.message-reasoning-timing {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.message-reasoning-expanded {
display: flex;
flex-direction: column;