| 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 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | |
| 1 | 2 | import { GlassContainer, GlassView } from "expo-glass-effect"; |
| 2 | 3 | import { useEffect, useState } from "react"; |
| 3 | 4 | import { ActivityIndicator, Text as SystemText, View } from "react-native"; |
| ... | ... | @@ -235,6 +236,9 @@ function formatWorkingDuration(startedAt: string, nowMs: number): string { |
| 235 | 236 | if (totalSeconds < 60) { |
| 236 | 237 | return `${totalSeconds}s`; |
| 237 | 238 | } |
| 239 | if (totalSeconds >= 3_600) { | |
| 240 | return formatDuration(totalSeconds * 1_000); | |
| 241 | } | |
| 238 | 242 | |
| 239 | 243 | const minutes = Math.floor(totalSeconds / 60); |
| 240 | 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 | 27 | const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; |
| 28 | 28 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 29 | 29 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; |
| 30 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | |
| 30 | 31 | import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; |
| 31 | 32 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; |
| 32 | 33 | import { |
| ... | ... | @@ -1707,7 +1708,7 @@ function CompactingLabel() { |
| 1707 | 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 | 1712 | function WorkingTimer({ createdAt }: { createdAt: string }) { |
| 1712 | 1713 | const textRef = useRef<HTMLSpanElement>(null); |
| 1713 | 1714 | const initialText = formatWorkingTimerNow(createdAt); |
| ... | ... | @@ -2628,15 +2629,7 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null { |
| 2628 | 2629 | return `${elapsedSeconds}s`; |
| 2629 | 2630 | } |
| 2630 | 2631 | |
| 2631 | const hours = Math.floor(elapsedSeconds / 3600); | |
| 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`; | |
| 2632 | return formatDuration(elapsedSeconds * 1_000); | |
| 2640 | 2633 | } |
| 2641 | 2634 | |
| 2642 | 2635 | function formatWorkingTimerNow(startIso: string): string { |
apps/web/src/session-logic.ts+2-26| ... | ... | @@ -36,6 +36,8 @@ import { |
| 36 | 36 | type TurnDiffSummary, |
| 37 | 37 | } from "./types"; |
| 38 | 38 | |
| 39 | export { formatDuration, formatElapsed } from "@t3tools/shared/orchestrationTiming"; | |
| 40 | ||
| 39 | 41 | export type ProviderPickerKind = ProviderDriverKind; |
| 40 | 42 | |
| 41 | 43 | export const PROVIDER_OPTIONS: Array<{ |
| ... | ... | @@ -355,32 +357,6 @@ export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolea |
| 355 | 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 | 360 | type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">; |
| 385 | 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 | 12 | export function formatDuration(durationMs: number): string { |
| 13 | 13 | if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; |
| 14 | 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 | 19 | if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; |
| 17 | const minutes = Math.floor(durationMs / 60_000); | |
| 18 | const seconds = Math.round((durationMs % 60_000) / 1_000); | |
| 19 | if (seconds === 0) return `${minutes}m`; | |
| 20 | if (seconds === 60) return `${minutes + 1}m`; | |
| 21 | return `${minutes}m ${seconds}s`; | |
| 20 | const totalSeconds = Math.round(durationMs / 1_000); | |
| 21 | const hours = Math.floor(totalSeconds / 3_600); | |
| 22 | const minutes = Math.floor((totalSeconds % 3_600) / 60); | |
| 23 | const seconds = totalSeconds % 60; | |
| 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 | 31 | export function formatElapsed(startIso: string, endIso: string | undefined): string | null { |