authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 21:11:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 21:11:22-07:00
log94cc8152fff5fd06baafd25964f54d7ec1a34f8d
tree43fe83d0e6d9cbb9c49125b20c0ba60837feaae1
parent931d41f933b2536f699975412169f5318e6adad5
signature Signed by PGP key B5690EEEBB952194

fix(chat): show hours for long runs (#9894)

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 @@
1import { formatDuration } from "@t3tools/shared/orchestrationTiming";
12import { GlassContainer, GlassView } from "expo-glass-effect";
23import { useEffect, useState } from "react";
34import { ActivityIndicator, Text as SystemText, View } from "react-native";
......@@ -235,6 +236,9 @@ function formatWorkingDuration(startedAt: string, nowMs: number): string {
235236 if (totalSeconds < 60) {
236237 return `${totalSeconds}s`;
237238 }
239 if (totalSeconds >= 3_600) {
240 return formatDuration(totalSeconds * 1_000);
241 }
238242
239243 const minutes = Math.floor(totalSeconds / 60);
240244 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 = () => {};
2727const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
2828import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
2929import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
30import { formatDuration } from "@t3tools/shared/orchestrationTiming";
3031import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon";
3132import { observeVisibleAnimation } from "../../lib/visibleAnimation";
3233import {
......@@ -1707,7 +1708,7 @@ function CompactingLabel() {
17071708// does not create a React commit every second while a response is streaming.
17081709// ---------------------------------------------------------------------------
17091710
1710/** Live "Working for Xs" label. */
1711/** Live elapsed time for the "Working for" label. */
17111712function WorkingTimer({ createdAt }: { createdAt: string }) {
17121713 const textRef = useRef<HTMLSpanElement>(null);
17131714 const initialText = formatWorkingTimerNow(createdAt);
......@@ -2628,15 +2629,7 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null {
26282629 return `${elapsedSeconds}s`;
26292630 }
26302631
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);
26402633}
26412634
26422635function formatWorkingTimerNow(startIso: string): string {
apps/web/src/session-logic.ts+2-26
......@@ -36,6 +36,8 @@ import {
3636 type TurnDiffSummary,
3737} from "./types";
3838
39export { formatDuration, formatElapsed } from "@t3tools/shared/orchestrationTiming";
40
3941export type ProviderPickerKind = ProviderDriverKind;
4042
4143export const PROVIDER_OPTIONS: Array<{
......@@ -355,32 +357,6 @@ export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolea
355357 return true;
356358}
357359
358export 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
374export 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
384360type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">;
385361type SessionActivityState = Pick<NonNullable<Thread["session"]>, "status" | "activeTurnId">;
386362
packages/shared/src/orchestrationTiming.test.ts created+37
......@@ -0,0 +1,37 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { formatDuration, formatElapsed } from "./orchestrationTiming.ts";
4
5describe("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
33describe("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 = {
1212export function formatDuration(durationMs: number): string {
1313 if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";
1414 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 }
1619 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(" ");
2229}
2330
2431export function formatElapsed(startIso: string, endIso: string | undefined): string | null {