| author | |
| committer | |
| log | eb17e0db2bd075e71b4015dc57588e7f838cbcdc |
| tree | 6b14e3be5d18deafd8ff8dadee26feb943b01214 |
| parent | 28f5516a9acfcfc1e203487e78869a406fd23b76 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>6 files changed, 241 insertions(+), 12 deletions(-)
apps/mobile/package.json+1| ... | @@ -135,6 +135,7 @@ | ... | @@ -135,6 +135,7 @@ |
| 135 | "@types/react": "~19.3.0", | 135 | "@types/react": "~19.3.0", |
| 136 | "@types/react-dom": "~19.2.3", | 136 | "@types/react-dom": "~19.2.3", |
| 137 | "babel-preset-expo": "~58.0.8", | 137 | "babel-preset-expo": "~58.0.8", |
| 138 | "jsdom": "^30.0.1", | ||
| 138 | "tailwindcss": "^4.0.0", | 139 | "tailwindcss": "^4.0.0", |
| 139 | "typescript": "catalog:" | 140 | "typescript": "catalog:" |
| 140 | }, | 141 | }, |
apps/mobile/src/features/threads/ProviderSubagentBar.tsx+2-7| ... | @@ -3,12 +3,12 @@ import { | ... | @@ -3,12 +3,12 @@ import { |
| 3 | type ProviderSubagentStatus, | 3 | type ProviderSubagentStatus, |
| 4 | } from "@t3tools/client-runtime/state/thread-execution"; | 4 | } from "@t3tools/client-runtime/state/thread-execution"; |
| 5 | import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; | 5 | import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; |
| 6 | import { useEffect, useState } from "react"; | ||
| 7 | import { View } from "react-native"; | 6 | import { View } from "react-native"; |
| 8 | 7 | ||
| 9 | import { AppText as Text } from "../../components/AppText"; | 8 | import { AppText as Text } from "../../components/AppText"; |
| 10 | import { ProviderIcon } from "../../components/ProviderIcon"; | 9 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 11 | import { RequestActionButton } from "./RequestActionButton"; | 10 | import { RequestActionButton } from "./RequestActionButton"; |
| 11 | import { useVisibleSecondClock } from "./use-visible-second-clock"; | ||
| 12 | 12 | ||
| 13 | /** | 13 | /** |
| 14 | * Replaces the composer on a provider-native subagent thread. The provider | 14 | * Replaces the composer on a provider-native subagent thread. The provider |
| ... | @@ -26,12 +26,7 @@ export function ProviderSubagentBar(props: { | ... | @@ -26,12 +26,7 @@ export function ProviderSubagentBar(props: { |
| 26 | readonly onOpenParent: (() => void) | null; | 26 | readonly onOpenParent: (() => void) | null; |
| 27 | }) { | 27 | }) { |
| 28 | const live = props.status !== null && isOrchestrationV2WorkActive(props.status.status); | 28 | const live = props.status !== null && isOrchestrationV2WorkActive(props.status.status); |
| 29 | const [nowMs, setNowMs] = useState(() => Date.now()); | 29 | const nowMs = useVisibleSecondClock(live); |
| 30 | useEffect(() => { | ||
| 31 | if (!live) return; | ||
| 32 | const id = setInterval(() => setNowMs(Date.now()), 1_000); | ||
| 33 | return () => clearInterval(id); | ||
| 34 | }, [live]); | ||
| 35 | const statusLabel = formatProviderSubagentStatus(props.status, nowMs); | 30 | const statusLabel = formatProviderSubagentStatus(props.status, nowMs); |
| 36 | const modelDescription = | 31 | const modelDescription = |
| 37 | props.effortLabel === null ? props.modelLabel : `${props.modelLabel}, ${props.effortLabel}`; | 32 | props.effortLabel === null ? props.modelLabel : `${props.modelLabel}, ${props.effortLabel}`; |
apps/mobile/src/features/threads/floating-working-control.tsx+2-5| ... | @@ -30,6 +30,7 @@ import { BrowserPreviewButton } from "../browser/browser-preview-button"; | ... | @@ -30,6 +30,7 @@ import { BrowserPreviewButton } from "../browser/browser-preview-button"; |
| 30 | import { DevicePreviewButton } from "../devices/device-preview-button"; | 30 | import { DevicePreviewButton } from "../devices/device-preview-button"; |
| 31 | import type { FloatingWorkingStatus } from "./floating-working-status"; | 31 | import type { FloatingWorkingStatus } from "./floating-working-status"; |
| 32 | import { ShimmeringWorkContent } from "./thread-work-log"; | 32 | import { ShimmeringWorkContent } from "./thread-work-log"; |
| 33 | import { useVisibleSecondClock } from "./use-visible-second-clock"; | ||
| 33 | 34 | ||
| 34 | const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem | 35 | const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem |
| 35 | // The collapsed composer capsule starts 6 below its overlay's top edge, so | 36 | // The collapsed composer capsule starts 6 below its overlay's top edge, so |
| ... | @@ -510,11 +511,7 @@ function WorkingDuration(props: { | ... | @@ -510,11 +511,7 @@ function WorkingDuration(props: { |
| 510 | } | 511 | } |
| 511 | 512 | ||
| 512 | export function WorkingTimer(props: { readonly startedAt: string }) { | 513 | export function WorkingTimer(props: { readonly startedAt: string }) { |
| 513 | const [nowMs, setNowMs] = useState(() => Date.now()); | 514 | const nowMs = useVisibleSecondClock(true); |
| 514 | useEffect(() => { | ||
| 515 | const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); | ||
| 516 | return () => clearInterval(intervalId); | ||
| 517 | }, []); | ||
| 518 | return ( | 515 | return ( |
| 519 | <SystemText | 516 | <SystemText |
| 520 | className="shrink text-xs text-foreground" | 517 | className="shrink text-xs text-foreground" |
apps/mobile/src/features/threads/use-visible-second-clock.test.tsx created+199| ... | @@ -0,0 +1,199 @@ | ||
| 1 | // @vitest-environment jsdom | ||
| 2 | import { act, useEffect, useLayoutEffect, useSyncExternalStore } from "react"; | ||
| 3 | import { createRoot, type Root } from "react-dom/client"; | ||
| 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | const harness = vi.hoisted(() => { | ||
| 7 | const listeners = new Set<() => void>(); | ||
| 8 | const appStateListeners = new Set<(state: string) => void>(); | ||
| 9 | const state = { focused: true, appState: "active" }; | ||
| 10 | return { | ||
| 11 | state, | ||
| 12 | appStateListeners, | ||
| 13 | subscribe(listener: () => void) { | ||
| 14 | listeners.add(listener); | ||
| 15 | return () => listeners.delete(listener); | ||
| 16 | }, | ||
| 17 | setFocused(focused: boolean) { | ||
| 18 | state.focused = focused; | ||
| 19 | for (const listener of listeners) listener(); | ||
| 20 | }, | ||
| 21 | setAppState(next: string) { | ||
| 22 | state.appState = next; | ||
| 23 | for (const listener of appStateListeners) listener(next); | ||
| 24 | }, | ||
| 25 | }; | ||
| 26 | }); | ||
| 27 | |||
| 28 | vi.mock("@react-navigation/native", () => ({ | ||
| 29 | useIsFocused: () => useSyncExternalStore(harness.subscribe, () => harness.state.focused), | ||
| 30 | })); | ||
| 31 | vi.mock("react-native", () => ({ | ||
| 32 | AppState: { | ||
| 33 | get currentState() { | ||
| 34 | return harness.state.appState; | ||
| 35 | }, | ||
| 36 | addEventListener: (_event: "change", listener: (state: string) => void) => { | ||
| 37 | harness.appStateListeners.add(listener); | ||
| 38 | return { remove: () => harness.appStateListeners.delete(listener) }; | ||
| 39 | }, | ||
| 40 | }, | ||
| 41 | })); | ||
| 42 | |||
| 43 | import { useVisibleSecondClock } from "./use-visible-second-clock"; | ||
| 44 | |||
| 45 | const START_MS = Date.UTC(2026, 9, 3, 12, 0, 0); | ||
| 46 | let root: Root | null = null; | ||
| 47 | // Clock value of every committed render, the work a tick costs on device. | ||
| 48 | let renders: number[] = []; | ||
| 49 | |||
| 50 | function Probe(props: { readonly enabled: boolean }) { | ||
| 51 | const nowMs = useVisibleSecondClock(props.enabled); | ||
| 52 | useEffect(() => { | ||
| 53 | renders.push(nowMs); | ||
| 54 | }); | ||
| 55 | return null; | ||
| 56 | } | ||
| 57 | |||
| 58 | function mount(enabled: boolean) { | ||
| 59 | (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; | ||
| 60 | root = createRoot(document.createElement("div")); | ||
| 61 | act(() => root!.render(<Probe enabled={enabled} />)); | ||
| 62 | } | ||
| 63 | |||
| 64 | // One act per second, so each tick commits its own render as it would on device. | ||
| 65 | function advance(ms: number) { | ||
| 66 | for (let elapsed = 0; elapsed < ms; elapsed += 1_000) { | ||
| 67 | act(() => vi.advanceTimersByTime(Math.min(1_000, ms - elapsed))); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | function latest() { | ||
| 72 | return renders.at(-1); | ||
| 73 | } | ||
| 74 | |||
| 75 | beforeEach(() => { | ||
| 76 | vi.useFakeTimers(); | ||
| 77 | vi.setSystemTime(START_MS); | ||
| 78 | harness.state.focused = true; | ||
| 79 | harness.state.appState = "active"; | ||
| 80 | renders = []; | ||
| 81 | }); | ||
| 82 | |||
| 83 | afterEach(() => { | ||
| 84 | act(() => root?.unmount()); | ||
| 85 | root = null; | ||
| 86 | vi.useRealTimers(); | ||
| 87 | }); | ||
| 88 | |||
| 89 | describe("useVisibleSecondClock", () => { | ||
| 90 | it("ticks once a second while enabled, focused and active", () => { | ||
| 91 | mount(true); | ||
| 92 | const before = renders.length; | ||
| 93 | advance(60_000); | ||
| 94 | expect(renders.length - before).toBe(60); | ||
| 95 | expect(latest()).toBe(START_MS + 60_000); | ||
| 96 | }); | ||
| 97 | |||
| 98 | it("does not wake while the app is in the background", () => { | ||
| 99 | harness.state.appState = "background"; | ||
| 100 | mount(true); | ||
| 101 | const before = renders.length; | ||
| 102 | advance(60_000); | ||
| 103 | expect(renders.length - before).toBe(0); | ||
| 104 | expect(vi.getTimerCount()).toBe(0); | ||
| 105 | }); | ||
| 106 | |||
| 107 | it("does not wake while the screen is unfocused", () => { | ||
| 108 | harness.state.focused = false; | ||
| 109 | mount(true); | ||
| 110 | const before = renders.length; | ||
| 111 | advance(60_000); | ||
| 112 | expect(renders.length - before).toBe(0); | ||
| 113 | expect(vi.getTimerCount()).toBe(0); | ||
| 114 | }); | ||
| 115 | |||
| 116 | it("does not wake when disabled, even when visible", () => { | ||
| 117 | mount(false); | ||
| 118 | const before = renders.length; | ||
| 119 | advance(60_000); | ||
| 120 | expect(renders.length - before).toBe(0); | ||
| 121 | }); | ||
| 122 | |||
| 123 | it("notices an AppState change between first render and subscribing", () => { | ||
| 124 | // Layout effects run before the hook's passive subscription, so this change has no listener. | ||
| 125 | function BackgroundBeforeSubscribe() { | ||
| 126 | useLayoutEffect(() => harness.setAppState("background"), []); | ||
| 127 | return null; | ||
| 128 | } | ||
| 129 | (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; | ||
| 130 | root = createRoot(document.createElement("div")); | ||
| 131 | act(() => | ||
| 132 | root!.render( | ||
| 133 | <> | ||
| 134 | <BackgroundBeforeSubscribe /> | ||
| 135 | <Probe enabled /> | ||
| 136 | </>, | ||
| 137 | ), | ||
| 138 | ); | ||
| 139 | const before = renders.length; | ||
| 140 | advance(60_000); | ||
| 141 | expect(renders.length - before).toBe(0); | ||
| 142 | expect(vi.getTimerCount()).toBe(0); | ||
| 143 | }); | ||
| 144 | |||
| 145 | it("pauses on blur and shows wall-clock elapsed time the moment it refocuses", () => { | ||
| 146 | mount(true); | ||
| 147 | advance(5_000); | ||
| 148 | act(() => harness.setFocused(false)); | ||
| 149 | const paused = renders.length; | ||
| 150 | advance(120_000); | ||
| 151 | expect(renders.length).toBe(paused); | ||
| 152 | |||
| 153 | act(() => harness.setFocused(true)); | ||
| 154 | expect(latest()).toBe(START_MS + 125_000); | ||
| 155 | advance(1_000); | ||
| 156 | expect(latest()).toBe(START_MS + 126_000); | ||
| 157 | }); | ||
| 158 | |||
| 159 | it("pauses while backgrounded and catches up on the first active render", () => { | ||
| 160 | mount(true); | ||
| 161 | act(() => harness.setAppState("background")); | ||
| 162 | const paused = renders.length; | ||
| 163 | advance(600_000); | ||
| 164 | expect(renders.length).toBe(paused); | ||
| 165 | expect(vi.getTimerCount()).toBe(0); | ||
| 166 | |||
| 167 | act(() => harness.setAppState("inactive")); | ||
| 168 | advance(10_000); | ||
| 169 | expect(renders.length).toBe(paused); | ||
| 170 | |||
| 171 | act(() => harness.setAppState("active")); | ||
| 172 | expect(latest()).toBe(START_MS + 610_000); | ||
| 173 | const resumed = renders.length; | ||
| 174 | advance(60_000); | ||
| 175 | expect(renders.length - resumed).toBe(60); | ||
| 176 | }); | ||
| 177 | |||
| 178 | it("stops when the work it measures settles and refreshes when new work starts", () => { | ||
| 179 | mount(true); | ||
| 180 | advance(3_000); | ||
| 181 | act(() => root!.render(<Probe enabled={false} />)); | ||
| 182 | const settled = renders.length; | ||
| 183 | advance(60_000); | ||
| 184 | expect(renders.length).toBe(settled); | ||
| 185 | |||
| 186 | act(() => root!.render(<Probe enabled />)); | ||
| 187 | expect(latest()).toBe(START_MS + 63_000); | ||
| 188 | }); | ||
| 189 | |||
| 190 | it("clears its interval and AppState subscription on unmount", () => { | ||
| 191 | mount(true); | ||
| 192 | expect(vi.getTimerCount()).toBe(1); | ||
| 193 | expect(harness.appStateListeners.size).toBe(1); | ||
| 194 | act(() => root!.unmount()); | ||
| 195 | root = null; | ||
| 196 | expect(vi.getTimerCount()).toBe(0); | ||
| 197 | expect(harness.appStateListeners.size).toBe(0); | ||
| 198 | }); | ||
| 199 | }); | ||
apps/mobile/src/features/threads/use-visible-second-clock.ts created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | import { useIsFocused } from "@react-navigation/native"; | ||
| 2 | import { useEffect, useLayoutEffect, useState } from "react"; | ||
| 3 | import { AppState } from "react-native"; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * Wall clock for elapsed-time labels, advanced once a second only while | ||
| 7 | * `enabled`, the screen is focused, and the app is active. Retained routes stay | ||
| 8 | * mounted when hidden, so an ungated interval would keep re-rendering them. | ||
| 9 | * The clock re-reads the time before paint when ticking resumes, so a label | ||
| 10 | * never shows the moment it paused. | ||
| 11 | */ | ||
| 12 | export function useVisibleSecondClock(enabled: boolean): number { | ||
| 13 | const focused = useIsFocused(); | ||
| 14 | const [appActive, setAppActive] = useState(() => AppState.currentState === "active"); | ||
| 15 | const [nowMs, setNowMs] = useState(() => Date.now()); | ||
| 16 | useEffect(() => { | ||
| 17 | const subscription = AppState.addEventListener("change", (state) => | ||
| 18 | setAppActive(state === "active"), | ||
| 19 | ); | ||
| 20 | // A change between the first render and this subscription has no event to replay. | ||
| 21 | // oxlint-disable-next-line react/set-state-in-effect -- Syncs state the listener missed. | ||
| 22 | setAppActive(AppState.currentState === "active"); | ||
| 23 | return () => subscription.remove(); | ||
| 24 | }, []); | ||
| 25 | const ticking = enabled && focused && appActive; | ||
| 26 | useLayoutEffect(() => { | ||
| 27 | if (!ticking) return; | ||
| 28 | // oxlint-disable-next-line react/set-state-in-effect -- Resuming reads the clock that stood still. | ||
| 29 | setNowMs(Date.now()); | ||
| 30 | const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); | ||
| 31 | return () => clearInterval(intervalId); | ||
| 32 | }, [ticking]); | ||
| 33 | return nowMs; | ||
| 34 | } | ||
pnpm-lock.yaml+3| ... | @@ -512,6 +512,9 @@ importers: | ... | @@ -512,6 +512,9 @@ importers: |
| 512 | babel-preset-expo: | 512 | babel-preset-expo: |
| 513 | specifier: ~58.0.8 | 513 | specifier: ~58.0.8 |
| 514 | version: 58.0.8(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@58.0.11)(expo@58.0.2)(react-refresh@0.14.2) | 514 | version: 58.0.8(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@58.0.11)(expo@58.0.2)(react-refresh@0.14.2) |
| 515 | jsdom: | ||
| 516 | specifier: ^30.0.1 | ||
| 517 | version: 30.1.0(@noble/hashes@1.8.0) | ||
| 515 | tailwindcss: | 518 | tailwindcss: |
| 516 | specifier: 4.3.3 | 519 | specifier: 4.3.3 |
| 517 | version: 4.3.3 | 520 | version: 4.3.3 |