mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-16 11:53:26 +00:00
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:
parent
e30c73716a
commit
006b4f790e
5 changed files with 164 additions and 6 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
53
packages/ui/src/lib/message-timing.test.ts
Normal file
53
packages/ui/src/lib/message-timing.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
66
packages/ui/src/lib/message-timing.ts
Normal file
66
packages/ui/src/lib/message-timing.ts
Normal 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)}`
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue