From 14dad26d3c046a5228ffe2ac81fbfefff26bf4f2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 20:37:56 -0700 Subject: [PATCH 1/3] fix(clients): sync status no longer flickers when opening running threads A running thread replays missed events on open, so the sync phase is set for a few frames. The web composer showed "Syncing messages..." and hid the tasks row for that time. The mobile pill showed the same label before the working timer. Add createDelayedStatus in client-runtime. A status shows only after it lasts 400ms, then stays for at least 400ms. Web and mobile wrap it in a small useDelayedStatus hook. Logic that reads the real phase is unchanged. 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 | 63 +++++++++++++++ packages/client-runtime/src/delayedStatus.ts | 79 +++++++++++++++++++ 7 files changed, 200 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 845d67483c83..e32240748861 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 000000000000..a96edfdf23df --- /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 c8f80c1851d6..fafe24e43973 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 000000000000..c777e3c61390 --- /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 a8cbdd54350d..80d36759e13b 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 000000000000..ea43b3282ec3 --- /dev/null +++ b/packages/client-runtime/src/delayedStatus.test.ts @@ -0,0 +1,63 @@ +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 a 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" }]); + + 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 000000000000..ec39ffc65c5e --- /dev/null +++ b/packages/client-runtime/src/delayedStatus.ts @@ -0,0 +1,79 @@ +// @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 setShown = (value: A | null) => { + shown = value; + onChange(value === null ? null : { key, value }); + }; + + return { + update: (nextKey, value) => { + if (nextKey !== key) { + key = nextKey; + clearTimer(); + if (shown !== null) setShown(null); + } + latest = value; + + if (shown === null) { + if (value === null) { + clearTimer(); + } else if (timer === undefined) { + timer = setTimeout(() => { + setShown(latest); + timer = setTimeout(() => { + timer = undefined; + if (latest === null) setShown(null); + }, STATUS_MIN_VISIBLE_MS); + }, STATUS_SHOW_DELAY_MS); + } + return; + } + + if (value !== null) { + if (value !== shown) setShown(value); + } else if (timer === undefined) { + setShown(null); + } + }, + dispose: clearTimer, + }; +} From 44c1f06ba976e3696be2dfde76bc19a002e68529 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 20:50:13 -0700 Subject: [PATCH 2/3] fix(clients): give a changed sync label its own minimum hold A label that changed after the first hold ended (for example loading to syncing) could hide one frame later. Each shown value now restarts the minimum visible time. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../client-runtime/src/delayedStatus.test.ts | 4 ++- packages/client-runtime/src/delayedStatus.ts | 27 ++++++++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/client-runtime/src/delayedStatus.test.ts b/packages/client-runtime/src/delayedStatus.test.ts index ea43b3282ec3..2d9677f484c3 100644 --- a/packages/client-runtime/src/delayedStatus.test.ts +++ b/packages/client-runtime/src/delayedStatus.test.ts @@ -31,12 +31,14 @@ describe("createDelayedStatus", () => { expect(changes).toEqual([]); }); - it("holds a shown status for the minimum time, then hides it at once", () => { + 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 hold, even after the first label's hold ended. + vi.advanceTimersByTime(STATUS_MIN_VISIBLE_MS); status.update("a", "syncing"); status.update("a", null); vi.advanceTimersByTime(STATUS_MIN_VISIBLE_MS - 1); diff --git a/packages/client-runtime/src/delayedStatus.ts b/packages/client-runtime/src/delayedStatus.ts index ec39ffc65c5e..faca3d2f9ff5 100644 --- a/packages/client-runtime/src/delayedStatus.ts +++ b/packages/client-runtime/src/delayedStatus.ts @@ -39,9 +39,19 @@ export function createDelayedStatus( clearTimeout(timer); timer = undefined; }; - const setShown = (value: A | null) => { + 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(value === null ? null : { key, value }); + onChange({ key, value }); + clearTimer(); + timer = setTimeout(() => { + timer = undefined; + if (latest === null) hide(); + }, STATUS_MIN_VISIBLE_MS); }; return { @@ -49,7 +59,7 @@ export function createDelayedStatus( if (nextKey !== key) { key = nextKey; clearTimer(); - if (shown !== null) setShown(null); + if (shown !== null) hide(); } latest = value; @@ -58,20 +68,17 @@ export function createDelayedStatus( clearTimer(); } else if (timer === undefined) { timer = setTimeout(() => { - setShown(latest); - timer = setTimeout(() => { - timer = undefined; - if (latest === null) setShown(null); - }, STATUS_MIN_VISIBLE_MS); + timer = undefined; + if (latest !== null) show(latest); }, STATUS_SHOW_DELAY_MS); } return; } if (value !== null) { - if (value !== shown) setShown(value); + if (value !== shown) show(value); } else if (timer === undefined) { - setShown(null); + hide(); } }, dispose: clearTimer, From decb11c9edc81f4fb6040da242400c3dac419048 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 20:55:43 -0700 Subject: [PATCH 3/3] test(clients): change the sync label during the first hold The test now changes the label during the first hold. It fails if a new label keeps the old hold timer. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/client-runtime/src/delayedStatus.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/delayedStatus.test.ts b/packages/client-runtime/src/delayedStatus.test.ts index 2d9677f484c3..4d48d2fe211f 100644 --- a/packages/client-runtime/src/delayedStatus.test.ts +++ b/packages/client-runtime/src/delayedStatus.test.ts @@ -37,8 +37,8 @@ describe("createDelayedStatus", () => { vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS); expect(changes).toEqual([{ key: "a", value: "loading" }]); - // A new label gets its own hold, even after the first label's hold ended. - vi.advanceTimersByTime(STATUS_MIN_VISIBLE_MS); + // 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);