| author | |
| committer | |
| log | 94cc8152fff5fd06baafd25964f54d7ec1a34f8d |
| tree | 43fe83d0e6d9cbb9c49125b20c0ba60837feaae1 |
| parent | 931d41f933b2536f699975412169f5318e6adad5 |
| signature | Signed by PGP key B5690EEEBB952194 |
Long runs display total minutes, such as `419m 50s`, which is hard to read.
Show `6h 59m 50s` instead. Web, desktop, and mobile use the shared formatter for long live, completed, and stopped runs.
## Verification
- 210 tests pass with `vp test run packages/shared/src/orchestrationTiming.test.ts apps/web/src/components/chat/MessagesTimeline.logic.test.ts apps/mobile/src/lib/threadActivity.test.ts`.
- Shared, web, and mobile typechecks pass.
- Changed-file lint and formatting pass, with existing lint warnings.
- Browser verification and screenshots skipped at Theo's request.
Created with GPT-6 Astra (preview) in Codex.5 files changed, 59 insertions(+), 42 deletions(-)
apps/mobile/src/features/threads/floating-working-control.tsx+4| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | ||
| 1 | import { GlassContainer, GlassView } from "expo-glass-effect"; | 2 | import { GlassContainer, GlassView } from "expo-glass-effect"; |
| 2 | import { useEffect, useState } from "react"; | 3 | import { useEffect, useState } from "react"; |
| 3 | import { ActivityIndicator, Text as SystemText, View } from "react-native"; | 4 | import { ActivityIndicator, Text as SystemText, View } from "react-native"; |
| ... | @@ -235,6 +236,9 @@ function formatWorkingDuration(startedAt: string, nowMs: number): string { | ... | @@ -235,6 +236,9 @@ function formatWorkingDuration(startedAt: string, nowMs: number): string { |
| 235 | if (totalSeconds < 60) { | 236 | if (totalSeconds < 60) { |
| 236 | return `${totalSeconds}s`; | 237 | return `${totalSeconds}s`; |
| 237 | } | 238 | } |
| 239 | if (totalSeconds >= 3_600) { | ||
| 240 | return formatDuration(totalSeconds * 1_000); | ||
| 241 | } | ||
| 238 | 242 | ||
| 239 | const minutes = Math.floor(totalSeconds / 60); | 243 | const minutes = Math.floor(totalSeconds / 60); |
| 240 | const seconds = String(totalSeconds % 60).padStart(2, "0"); | 244 | const seconds = String(totalSeconds % 60).padStart(2, "0"); |
apps/web/src/components/chat/MessagesTimeline.tsx+3-10| ... | @@ -27,6 +27,7 @@ const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; | ... | @@ -27,6 +27,7 @@ const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; |
| 27 | const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; | 27 | const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; |
| 28 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 28 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 29 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; | 29 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; |
| 30 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | ||
| 30 | import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; | 31 | import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; |
| 31 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; | 32 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; |
| 32 | import { | 33 | import { |
| ... | @@ -1707,7 +1708,7 @@ function CompactingLabel() { | ... | @@ -1707,7 +1708,7 @@ function CompactingLabel() { |
| 1707 | // does not create a React commit every second while a response is streaming. | 1708 | // does not create a React commit every second while a response is streaming. |
| 1708 | // --------------------------------------------------------------------------- | 1709 | // --------------------------------------------------------------------------- |
| 1709 | 1710 | ||
| 1710 | /** Live "Working for Xs" label. */ | 1711 | /** Live elapsed time for the "Working for" label. */ |
| 1711 | function WorkingTimer({ createdAt }: { createdAt: string }) { | 1712 | function WorkingTimer({ createdAt }: { createdAt: string }) { |
| 1712 | const textRef = useRef<HTMLSpanElement>(null); | 1713 | const textRef = useRef<HTMLSpanElement>(null); |
| 1713 | const initialText = formatWorkingTimerNow(createdAt); | 1714 | const initialText = formatWorkingTimerNow(createdAt); |
| ... | @@ -2628,15 +2629,7 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null { | ... | @@ -2628,15 +2629,7 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null { |
| 2628 | return `${elapsedSeconds}s`; | 2629 | return `${elapsedSeconds}s`; |
| 2629 | } | 2630 | } |
| 2630 | 2631 | ||
| 2631 | const hours = Math.floor(elapsedSeconds / 3600); | 2632 | return formatDuration(elapsedSeconds * 1_000); |
| 2632 | const minutes = Math.floor((elapsedSeconds % 3600) / 60); | ||
| 2633 | const seconds = elapsedSeconds % 60; | ||
| 2634 | |||
| 2635 | if (hours > 0) { | ||
| 2636 | return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; | ||
| 2637 | } | ||
| 2638 | |||
| 2639 | return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; | ||
| 2640 | } | 2633 | } |
| 2641 | 2634 | ||
| 2642 | function formatWorkingTimerNow(startIso: string): string { | 2635 | function formatWorkingTimerNow(startIso: string): string { |
apps/web/src/session-logic.ts+2-26| ... | @@ -36,6 +36,8 @@ import { | ... | @@ -36,6 +36,8 @@ import { |
| 36 | type TurnDiffSummary, | 36 | type TurnDiffSummary, |
| 37 | } from "./types"; | 37 | } from "./types"; |
| 38 | 38 | ||
| 39 | export { formatDuration, formatElapsed } from "@t3tools/shared/orchestrationTiming"; | ||
| 40 | |||
| 39 | export type ProviderPickerKind = ProviderDriverKind; | 41 | export type ProviderPickerKind = ProviderDriverKind; |
| 40 | 42 | ||
| 41 | export const PROVIDER_OPTIONS: Array<{ | 43 | export const PROVIDER_OPTIONS: Array<{ |
| ... | @@ -355,32 +357,6 @@ export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolea | ... | @@ -355,32 +357,6 @@ export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolea |
| 355 | return true; | 357 | return true; |
| 356 | } | 358 | } |
| 357 | 359 | ||
| 358 | export function formatDuration(durationMs: number): string { | ||
| 359 | if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; | ||
| 360 | if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; | ||
| 361 | if (durationMs < 10_000) { | ||
| 362 | const tenths = Math.round(durationMs / 100) / 10; | ||
| 363 | // 9.95s+ rounds up to the next bucket — render "10s", not "10.0s". | ||
| 364 | return tenths >= 10 ? "10s" : `${tenths.toFixed(1)}s`; | ||
| 365 | } | ||
| 366 | if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; | ||
| 367 | const minutes = Math.floor(durationMs / 60_000); | ||
| 368 | const seconds = Math.round((durationMs % 60_000) / 1_000); | ||
| 369 | if (seconds === 0) return `${minutes}m`; | ||
| 370 | if (seconds === 60) return `${minutes + 1}m`; | ||
| 371 | return `${minutes}m ${seconds}s`; | ||
| 372 | } | ||
| 373 | |||
| 374 | export function formatElapsed(startIso: string, endIso: string | undefined): string | null { | ||
| 375 | if (!endIso) return null; | ||
| 376 | const startedAt = Date.parse(startIso); | ||
| 377 | const endedAt = Date.parse(endIso); | ||
| 378 | if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { | ||
| 379 | return null; | ||
| 380 | } | ||
| 381 | return formatDuration(endedAt - startedAt); | ||
| 382 | } | ||
| 383 | |||
| 384 | type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">; | 360 | type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">; |
| 385 | type SessionActivityState = Pick<NonNullable<Thread["session"]>, "status" | "activeTurnId">; | 361 | type SessionActivityState = Pick<NonNullable<Thread["session"]>, "status" | "activeTurnId">; |
| 386 | 362 |
packages/shared/src/orchestrationTiming.test.ts created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { formatDuration, formatElapsed } from "./orchestrationTiming.ts"; | ||
| 4 | |||
| 5 | describe("formatDuration", () => { | ||
| 6 | it.each([ | ||
| 7 | [0, "1ms"], | ||
| 8 | [250, "250ms"], | ||
| 9 | [1_500, "1.5s"], | ||
| 10 | [9_950, "10s"], | ||
| 11 | [22_000, "22s"], | ||
| 12 | [60_000, "1m"], | ||
| 13 | [65_000, "1m 5s"], | ||
| 14 | [119_500, "2m"], | ||
| 15 | [3_599_499, "59m 59s"], | ||
| 16 | [3_599_500, "1h"], | ||
| 17 | [3_600_000, "1h"], | ||
| 18 | [3_601_000, "1h 1s"], | ||
| 19 | [3_660_000, "1h 1m"], | ||
| 20 | [3_661_000, "1h 1m 1s"], | ||
| 21 | [7_199_500, "2h"], | ||
| 22 | [25_190_000, "6h 59m 50s"], | ||
| 23 | [90_061_000, "25h 1m 1s"], | ||
| 24 | ])("formats %d ms as %s", (durationMs, expected) => { | ||
| 25 | expect(formatDuration(durationMs)).toBe(expected); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it.each([-1, NaN, Infinity, -Infinity])("handles invalid durations: %s", (durationMs) => { | ||
| 29 | expect(formatDuration(durationMs)).toBe("0ms"); | ||
| 30 | }); | ||
| 31 | }); | ||
| 32 | |||
| 33 | describe("formatElapsed", () => { | ||
| 34 | it("formats a long run across midnight", () => { | ||
| 35 | expect(formatElapsed("2026-09-03T22:00:00Z", "2026-09-04T04:59:50Z")).toBe("6h 59m 50s"); | ||
| 36 | }); | ||
| 37 | }); | ||
packages/shared/src/orchestrationTiming.ts+13-6| ... | @@ -12,13 +12,20 @@ type SessionActivityState = { | ... | @@ -12,13 +12,20 @@ type SessionActivityState = { |
| 12 | export function formatDuration(durationMs: number): string { | 12 | export function formatDuration(durationMs: number): string { |
| 13 | if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; | 13 | if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; |
| 14 | if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; | 14 | if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; |
| 15 | if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`; | 15 | if (durationMs < 10_000) { |
| 16 | const tenths = Math.round(durationMs / 100) / 10; | ||
| 17 | return tenths >= 10 ? "10s" : `${tenths.toFixed(1)}s`; | ||
| 18 | } | ||
| 16 | if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; | 19 | if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; |
| 17 | const minutes = Math.floor(durationMs / 60_000); | 20 | const totalSeconds = Math.round(durationMs / 1_000); |
| 18 | const seconds = Math.round((durationMs % 60_000) / 1_000); | 21 | const hours = Math.floor(totalSeconds / 3_600); |
| 19 | if (seconds === 0) return `${minutes}m`; | 22 | const minutes = Math.floor((totalSeconds % 3_600) / 60); |
| 20 | if (seconds === 60) return `${minutes + 1}m`; | 23 | const seconds = totalSeconds % 60; |
| 21 | return `${minutes}m ${seconds}s`; | 24 | const parts: string[] = []; |
| 25 | if (hours > 0) parts.push(`${hours}h`); | ||
| 26 | if (minutes > 0) parts.push(`${minutes}m`); | ||
| 27 | if (seconds > 0) parts.push(`${seconds}s`); | ||
| 28 | return parts.join(" "); | ||
| 22 | } | 29 | } |
| 23 | 30 | ||
| 24 | export function formatElapsed(startIso: string, endIso: string | undefined): string | null { | 31 | export function formatElapsed(startIso: string, endIso: string | undefined): string | null { |