From ab70c8943f48c08e67e3597f62941c169bc58c16 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 21:16:34 -0700 Subject: [PATCH] fix(clients): sync status no longer flickers when opening running threads (#13551) Co-authored-by: Claude Opus 5.5 (1M context) --- .../features/threads/ThreadDetailScreen.tsx | 6 +- apps/mobile/src/lib/useDelayedStatus.ts | 18 ++++ apps/web/src/components/chat/ChatComposer.tsx | 24 +++--- apps/web/src/hooks/useDelayedStatus.ts | 18 ++++ packages/client-runtime/package.json | 4 + .../client-runtime/src/delayedStatus.test.ts | 65 ++++++++++++++ packages/client-runtime/src/delayedStatus.ts | 86 +++++++++++++++++++ 7 files changed, 209 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/lib/useDelayedStatus.ts create mode 100644 apps/web/src/hooks/useDelayedStatus.ts create mode 100644 packages/client-runtime/src/delayedStatus.test.ts create mode 100644 packages/client-runtime/src/delayedStatus.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 845d67483c833139eae0d88ec2601d87b5710c6a..e322407488610ef0e26531587d0e942af6257f75 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -79,6 +79,7 @@ import { useEnvironmentQuery } from "../../state/query"; import { threadDevicePreviews } from "../devices/threadDevicePreviews"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { useDelayedStatus } from "../../lib/useDelayedStatus"; import type { PendingApproval, PendingUserInput, @@ -354,7 +355,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // The raw sync status enters "synchronizing" on every full fetch, cached or // not. Whether messages are already on screen decides the pill label: no // data yet → "Loading messages", cached data reconciling → "Syncing". - const threadSyncLabel = (() => { + const realThreadSyncLabel = (() => { switch (props.threadSyncStatus) { case "empty": case "cached": @@ -367,6 +368,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return null; } })(); + // Opening a running thread resyncs for a few frames. The pill shows the + // sync label only when the sync lasts, so it does not flash before the timer. + const threadSyncLabel = useDelayedStatus(selectedThreadKey, realThreadSyncLabel); // One floating pill above the composer: it reads the connection phase while // disconnected, the sync state while messages load, then the working timer // once the feed is settled. diff --git a/apps/mobile/src/lib/useDelayedStatus.ts b/apps/mobile/src/lib/useDelayedStatus.ts new file mode 100644 index 0000000000000000000000000000000000000000..a96edfdf23df49872d47745d10e37f70c95b678a --- /dev/null +++ b/apps/mobile/src/lib/useDelayedStatus.ts @@ -0,0 +1,18 @@ +import { createDelayedStatus, type ShownStatus } from "@t3tools/client-runtime/delayed-status"; +import { useEffect, useState } from "react"; + +/** + * Returns `value` only once it has lasted past the show delay, then holds it + * for a minimum time, so a short status never flashes. `key` is what the + * status belongs to (for example a thread). A new key drops it at once. + * Web has the same hook. + */ +export function useDelayedStatus(key: string, value: A | null): A | null { + const [shown, setShown] = useState | null>(null); + const [status] = useState(() => createDelayedStatus(setShown)); + useEffect(() => () => status.dispose(), [status]); + useEffect(() => { + status.update(key, value); + }, [status, key, value]); + return shown?.key === key ? shown.value : null; +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c8f80c1851d61b405be925e653e9c2ef31534b46..fafe24e439734d270aaf5f8a8eb1a8f94cf3abc5 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -977,6 +977,7 @@ import { resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; import { searchProviderSkills } from "../../providerSkillSearch"; +import { useDelayedStatus } from "../../hooks/useDelayedStatus"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { usePanelAnimationSettings } from "../../panelAnimations"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -1578,8 +1579,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onFileOpen, } = props; const primaryEnvironmentId = usePrimaryEnvironmentId(); - const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null; - const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null; + const composerDraftTargetKey = composerTargetKey(composerDraftTarget); + // Opening a running thread resyncs for a few frames. Show the sync row, and + // hide the tasks row for it, only when the sync lasts. Logic that depends on + // the real phase keeps reading `props.threadSyncPhase`. + const shownSyncPhase = useDelayedStatus(composerDraftTargetKey, props.threadSyncPhase); + const activeTasksProgress = shownSyncPhase === null ? props.activeTasksProgress : null; + const activeTaskSteps = shownSyncPhase === null ? props.activeTaskSteps : null; // ------------------------------------------------------------------ // Store subscriptions (prompt / images / terminal contexts) // ------------------------------------------------------------------ @@ -1587,7 +1593,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Live target key, for async flows that must notice a thread switch that // happened while they awaited. const composerDraftTargetKeyRef = useRef(""); - composerDraftTargetKeyRef.current = composerTargetKey(composerDraftTarget); + composerDraftTargetKeyRef.current = composerDraftTargetKey; const questionAttachmentTarget = pendingUserInputs[0] && activePendingProgress?.activeQuestion ? questionAttachmentDraftId( @@ -5151,8 +5157,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeTaskSteps !== null && activeTasksProgress.totalSteps > 0; const activityStackContent = hasBannerItems ? ( - props.threadSyncPhase ? ( - + shownSyncPhase ? ( + ) : !hasBlockingComposerTopDrawer && activeTasksProgress && activeTaskSteps ? ( - {!activityStackItem && (props.threadSyncPhase || inlineTasksBadge) ? ( + {!activityStackItem && (shownSyncPhase || inlineTasksBadge) ? ( - {props.threadSyncPhase ? ( - - ) : ( - inlineTasksBadge - )} + {shownSyncPhase ? : inlineTasksBadge} ) : null} diff --git a/apps/web/src/hooks/useDelayedStatus.ts b/apps/web/src/hooks/useDelayedStatus.ts new file mode 100644 index 0000000000000000000000000000000000000000..c777e3c6139088940e892bfc0e8c37b8cb02764f --- /dev/null +++ b/apps/web/src/hooks/useDelayedStatus.ts @@ -0,0 +1,18 @@ +import { createDelayedStatus, type ShownStatus } from "@t3tools/client-runtime/delayed-status"; +import { useEffect, useState } from "react"; + +/** + * Returns `value` only once it has lasted past the show delay, then holds it + * for a minimum time, so a short status never flashes. `key` is what the + * status belongs to (for example a thread). A new key drops it at once. + * Mobile has the same hook. + */ +export function useDelayedStatus(key: string, value: A | null): A | null { + const [shown, setShown] = useState | null>(null); + const [status] = useState(() => createDelayedStatus(setShown)); + useEffect(() => () => status.dispose(), [status]); + useEffect(() => { + status.update(key, value); + }, [status, key, value]); + return shown?.key === key ? shown.value : null; +} diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index a8cbdd54350d68932f0a6484f08d1ac2b6098627..80d36759e13ba259791575fa24fd14af5654c1d0 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -119,6 +119,10 @@ "types": "./src/textPaste.ts", "default": "./src/textPaste.ts" }, + "./delayed-status": { + "types": "./src/delayedStatus.ts", + "default": "./src/delayedStatus.ts" + }, "./state/connections": { "types": "./src/state/connections.ts", "default": "./src/state/connections.ts" diff --git a/packages/client-runtime/src/delayedStatus.test.ts b/packages/client-runtime/src/delayedStatus.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d48d2fe211fbd4dda5428156fdde2c4a5dffa1b --- /dev/null +++ b/packages/client-runtime/src/delayedStatus.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + createDelayedStatus, + STATUS_MIN_VISIBLE_MS, + STATUS_SHOW_DELAY_MS, + type ShownStatus, +} from "./delayedStatus.ts"; + +function track() { + const changes: Array | null> = []; + const status = createDelayedStatus((shown) => changes.push(shown)); + return { changes, status }; +} + +describe("createDelayedStatus", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("never shows a status that clears before the show delay", () => { + const { changes, status } = track(); + status.update("a", "syncing"); + vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS - 1); + status.update("a", null); + vi.runAllTimers(); + + expect(changes).toEqual([]); + }); + + it("holds each shown status for the minimum time, then hides it at once", () => { + const { changes, status } = track(); + status.update("a", "loading"); + vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS); + expect(changes).toEqual([{ key: "a", value: "loading" }]); + + // A new label gets its own full hold, even during the first label's hold. + vi.advanceTimersByTime(1); + status.update("a", "syncing"); + status.update("a", null); + vi.advanceTimersByTime(STATUS_MIN_VISIBLE_MS - 1); + expect(changes.at(-1)).toEqual({ key: "a", value: "syncing" }); + vi.advanceTimersByTime(1); + expect(changes.at(-1)).toBeNull(); + + status.update("a", "syncing"); + vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS + STATUS_MIN_VISIBLE_MS); + status.update("a", null); + expect(changes.at(-1)).toBeNull(); + }); + + it("drops the shown status at once when the key changes", () => { + const { changes, status } = track(); + status.update("a", "syncing"); + vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS); + status.update("b", "syncing"); + expect(changes.at(-1)).toBeNull(); + + vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS); + expect(changes.at(-1)).toEqual({ key: "b", value: "syncing" }); + }); +}); diff --git a/packages/client-runtime/src/delayedStatus.ts b/packages/client-runtime/src/delayedStatus.ts new file mode 100644 index 0000000000000000000000000000000000000000..faca3d2f9ff5abe8aa6f0550736baf9fa3262e77 --- /dev/null +++ b/packages/client-runtime/src/delayedStatus.ts @@ -0,0 +1,86 @@ +// @effect-diagnostics globalTimers:off - Display timing for React hooks, outside an Effect runtime. + +/** How long a status must last before a client shows it. */ +export const STATUS_SHOW_DELAY_MS = 400; +/** How long a shown status stays up, so it cannot flash at the show delay. */ +export const STATUS_MIN_VISIBLE_MS = 400; + +/** The status a client should show, and the key it belongs to. */ +export interface ShownStatus { + readonly key: string; + readonly value: A; +} + +export interface DelayedStatus { + /** Reports the real status for `key`. A new key drops the shown status at once. */ + readonly update: (key: string, value: A | null) => void; + /** Cancels pending timers. A later `update` starts again from the real status. */ + readonly dispose: () => void; +} + +/** + * Turns a real status (for example the thread sync phase) into the status a + * client shows. A status that clears within `STATUS_SHOW_DELAY_MS` is never + * shown. A shown status stays for at least `STATUS_MIN_VISIBLE_MS`. Values + * compare by identity, so use strings or other stable values. + * + * Web and mobile wrap this in a small `useDelayedStatus` hook. + */ +export function createDelayedStatus( + onChange: (shown: ShownStatus | null) => void, +): DelayedStatus { + let key = ""; + let latest: A | null = null; + let shown: A | null = null; + // While hidden, this is the show delay. While shown, the minimum visible time. + let timer: ReturnType | undefined; + + const clearTimer = () => { + clearTimeout(timer); + timer = undefined; + }; + const hide = () => { + shown = null; + onChange(null); + }; + // Every shown value, including a new label, gets the full minimum visible time. + const show = (value: A) => { + shown = value; + onChange({ key, value }); + clearTimer(); + timer = setTimeout(() => { + timer = undefined; + if (latest === null) hide(); + }, STATUS_MIN_VISIBLE_MS); + }; + + return { + update: (nextKey, value) => { + if (nextKey !== key) { + key = nextKey; + clearTimer(); + if (shown !== null) hide(); + } + latest = value; + + if (shown === null) { + if (value === null) { + clearTimer(); + } else if (timer === undefined) { + timer = setTimeout(() => { + timer = undefined; + if (latest !== null) show(latest); + }, STATUS_SHOW_DELAY_MS); + } + return; + } + + if (value !== null) { + if (value !== shown) show(value); + } else if (timer === undefined) { + hide(); + } + }, + dispose: clearTimer, + }; +} -- 2.54.0