authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-24 21:16:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 21:16:34-07:00
logab70c8943f48c08e67e3597f62941c169bc58c16
tree9067fe3809664fb9a31ff06bceefe958c6c2870d
parent29abbf9b424b122c52bb20dccffac68bee367c09
signature Signed by PGP key B5690EEEBB952194

fix(clients): sync status no longer flickers when opening running threads (#13551)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

7 files changed, 209 insertions(+), 12 deletions(-)

apps/mobile/src/features/threads/ThreadDetailScreen.tsx+5-1
...@@ -79,6 +79,7 @@ import { useEnvironmentQuery } from "../../state/query";...@@ -79,6 +79,7 @@ import { useEnvironmentQuery } from "../../state/query";
79import { threadDevicePreviews } from "../devices/threadDevicePreviews";79import { threadDevicePreviews } from "../devices/threadDevicePreviews";
80import type { QueuedThreadMessage } from "../../state/thread-outbox-model";80import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
81import { scopedThreadKey } from "../../lib/scopedEntities";81import { scopedThreadKey } from "../../lib/scopedEntities";
82import { useDelayedStatus } from "../../lib/useDelayedStatus";
82import type {83import type {
83 PendingApproval,84 PendingApproval,
84 PendingUserInput,85 PendingUserInput,
...@@ -354,7 +355,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -354,7 +355,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
354 // The raw sync status enters "synchronizing" on every full fetch, cached or355 // The raw sync status enters "synchronizing" on every full fetch, cached or
355 // not. Whether messages are already on screen decides the pill label: no356 // not. Whether messages are already on screen decides the pill label: no
356 // data yet → "Loading messages", cached data reconciling → "Syncing".357 // data yet → "Loading messages", cached data reconciling → "Syncing".
357 const threadSyncLabel = (() => {358 const realThreadSyncLabel = (() => {
358 switch (props.threadSyncStatus) {359 switch (props.threadSyncStatus) {
359 case "empty":360 case "empty":
360 case "cached":361 case "cached":
...@@ -367,6 +368,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -367,6 +368,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
367 return null;368 return null;
368 }369 }
369 })();370 })();
371 // Opening a running thread resyncs for a few frames. The pill shows the
372 // sync label only when the sync lasts, so it does not flash before the timer.
373 const threadSyncLabel = useDelayedStatus(selectedThreadKey, realThreadSyncLabel);
370 // One floating pill above the composer: it reads the connection phase while374 // One floating pill above the composer: it reads the connection phase while
371 // disconnected, the sync state while messages load, then the working timer375 // disconnected, the sync state while messages load, then the working timer
372 // once the feed is settled.376 // once the feed is settled.
apps/mobile/src/lib/useDelayedStatus.ts created+18
...@@ -0,0 +1,18 @@
1import { createDelayedStatus, type ShownStatus } from "@t3tools/client-runtime/delayed-status";
2import { useEffect, useState } from "react";
3
4/**
5 * Returns `value` only once it has lasted past the show delay, then holds it
6 * for a minimum time, so a short status never flashes. `key` is what the
7 * status belongs to (for example a thread). A new key drops it at once.
8 * Web has the same hook.
9 */
10export function useDelayedStatus<A>(key: string, value: A | null): A | null {
11 const [shown, setShown] = useState<ShownStatus<A> | null>(null);
12 const [status] = useState(() => createDelayedStatus<A>(setShown));
13 useEffect(() => () => status.dispose(), [status]);
14 useEffect(() => {
15 status.update(key, value);
16 }, [status, key, value]);
17 return shown?.key === key ? shown.value : null;
18}
apps/web/src/components/chat/ChatComposer.tsx+13-11
...@@ -977,6 +977,7 @@ import {...@@ -977,6 +977,7 @@ import {
977 resolveProviderSlashCommandsForCwd,977 resolveProviderSlashCommandsForCwd,
978} from "@t3tools/client-runtime/providerSkills";978} from "@t3tools/client-runtime/providerSkills";
979import { searchProviderSkills } from "../../providerSkillSearch";979import { searchProviderSkills } from "../../providerSkillSearch";
980import { useDelayedStatus } from "../../hooks/useDelayedStatus";
980import { useMediaQuery } from "../../hooks/useMediaQuery";981import { useMediaQuery } from "../../hooks/useMediaQuery";
981import { usePanelAnimationSettings } from "../../panelAnimations";982import { usePanelAnimationSettings } from "../../panelAnimations";
982import { useAtomCommand } from "../../state/use-atom-command";983import { useAtomCommand } from "../../state/use-atom-command";
...@@ -1578,8 +1579,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1578,8 +1579,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1578 onFileOpen,1579 onFileOpen,
1579 } = props;1580 } = props;
1580 const primaryEnvironmentId = usePrimaryEnvironmentId();1581 const primaryEnvironmentId = usePrimaryEnvironmentId();
1581 const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null;1582 const composerDraftTargetKey = composerTargetKey(composerDraftTarget);
1582 const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null;1583 // Opening a running thread resyncs for a few frames. Show the sync row, and
1584 // hide the tasks row for it, only when the sync lasts. Logic that depends on
1585 // the real phase keeps reading `props.threadSyncPhase`.
1586 const shownSyncPhase = useDelayedStatus(composerDraftTargetKey, props.threadSyncPhase);
1587 const activeTasksProgress = shownSyncPhase === null ? props.activeTasksProgress : null;
1588 const activeTaskSteps = shownSyncPhase === null ? props.activeTaskSteps : null;
1583 // ------------------------------------------------------------------1589 // ------------------------------------------------------------------
1584 // Store subscriptions (prompt / images / terminal contexts)1590 // Store subscriptions (prompt / images / terminal contexts)
1585 // ------------------------------------------------------------------1591 // ------------------------------------------------------------------
...@@ -1587,7 +1593,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1587,7 +1593,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1587 // Live target key, for async flows that must notice a thread switch that1593 // Live target key, for async flows that must notice a thread switch that
1588 // happened while they awaited.1594 // happened while they awaited.
1589 const composerDraftTargetKeyRef = useRef("");1595 const composerDraftTargetKeyRef = useRef("");
1590 composerDraftTargetKeyRef.current = composerTargetKey(composerDraftTarget);1596 composerDraftTargetKeyRef.current = composerDraftTargetKey;
1591 const questionAttachmentTarget =1597 const questionAttachmentTarget =
1592 pendingUserInputs[0] && activePendingProgress?.activeQuestion1598 pendingUserInputs[0] && activePendingProgress?.activeQuestion
1593 ? questionAttachmentDraftId(1599 ? questionAttachmentDraftId(
...@@ -5151,8 +5157,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5151,8 +5157,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5151 activeTaskSteps !== null &&5157 activeTaskSteps !== null &&
5152 activeTasksProgress.totalSteps > 0;5158 activeTasksProgress.totalSteps > 0;
5153 const activityStackContent = hasBannerItems ? (5159 const activityStackContent = hasBannerItems ? (
5154 props.threadSyncPhase ? (5160 shownSyncPhase ? (
5155 <ComposerActivityRow phase={props.threadSyncPhase} />5161 <ComposerActivityRow phase={shownSyncPhase} />
5156 ) : !hasBlockingComposerTopDrawer && activeTasksProgress && activeTaskSteps ? (5162 ) : !hasBlockingComposerTopDrawer && activeTasksProgress && activeTaskSteps ? (
5157 <ComposerTasksContent5163 <ComposerTasksContent
5158 expanded={isTasksDrawerOpen}5164 expanded={isTasksDrawerOpen}
...@@ -6186,14 +6192,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6186,14 +6192,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6186 className="relative z-0"6192 className="relative z-0"
6187 items={bannerStackItems}6193 items={bannerStackItems}
6188 />6194 />
6189 {!activityStackItem && (props.threadSyncPhase || inlineTasksBadge) ? (6195 {!activityStackItem && (shownSyncPhase || inlineTasksBadge) ? (
6190 <ComposerBanner.Attachment>6196 <ComposerBanner.Attachment>
6191 <ComposerBanner.Root data-chat-composer-activity-strip="true">6197 <ComposerBanner.Root data-chat-composer-activity-strip="true">
6192 {props.threadSyncPhase ? (6198 {shownSyncPhase ? <ComposerActivityRow phase={shownSyncPhase} /> : inlineTasksBadge}
6193 <ComposerActivityRow phase={props.threadSyncPhase} />
6194 ) : (
6195 inlineTasksBadge
6196 )}
6197 </ComposerBanner.Root>6199 </ComposerBanner.Root>
6198 </ComposerBanner.Attachment>6200 </ComposerBanner.Attachment>
6199 ) : null}6201 ) : null}
apps/web/src/hooks/useDelayedStatus.ts created+18
...@@ -0,0 +1,18 @@
1import { createDelayedStatus, type ShownStatus } from "@t3tools/client-runtime/delayed-status";
2import { useEffect, useState } from "react";
3
4/**
5 * Returns `value` only once it has lasted past the show delay, then holds it
6 * for a minimum time, so a short status never flashes. `key` is what the
7 * status belongs to (for example a thread). A new key drops it at once.
8 * Mobile has the same hook.
9 */
10export function useDelayedStatus<A>(key: string, value: A | null): A | null {
11 const [shown, setShown] = useState<ShownStatus<A> | null>(null);
12 const [status] = useState(() => createDelayedStatus<A>(setShown));
13 useEffect(() => () => status.dispose(), [status]);
14 useEffect(() => {
15 status.update(key, value);
16 }, [status, key, value]);
17 return shown?.key === key ? shown.value : null;
18}
packages/client-runtime/package.json+4
...@@ -119,6 +119,10 @@...@@ -119,6 +119,10 @@
119 "types": "./src/textPaste.ts",119 "types": "./src/textPaste.ts",
120 "default": "./src/textPaste.ts"120 "default": "./src/textPaste.ts"
121 },121 },
122 "./delayed-status": {
123 "types": "./src/delayedStatus.ts",
124 "default": "./src/delayedStatus.ts"
125 },
122 "./state/connections": {126 "./state/connections": {
123 "types": "./src/state/connections.ts",127 "types": "./src/state/connections.ts",
124 "default": "./src/state/connections.ts"128 "default": "./src/state/connections.ts"
packages/client-runtime/src/delayedStatus.test.ts created+65
...@@ -0,0 +1,65 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 createDelayedStatus,
5 STATUS_MIN_VISIBLE_MS,
6 STATUS_SHOW_DELAY_MS,
7 type ShownStatus,
8} from "./delayedStatus.ts";
9
10function track() {
11 const changes: Array<ShownStatus<string> | null> = [];
12 const status = createDelayedStatus<string>((shown) => changes.push(shown));
13 return { changes, status };
14}
15
16describe("createDelayedStatus", () => {
17 beforeEach(() => {
18 vi.useFakeTimers();
19 });
20 afterEach(() => {
21 vi.useRealTimers();
22 });
23
24 it("never shows a status that clears before the show delay", () => {
25 const { changes, status } = track();
26 status.update("a", "syncing");
27 vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS - 1);
28 status.update("a", null);
29 vi.runAllTimers();
30
31 expect(changes).toEqual([]);
32 });
33
34 it("holds each shown status for the minimum time, then hides it at once", () => {
35 const { changes, status } = track();
36 status.update("a", "loading");
37 vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS);
38 expect(changes).toEqual([{ key: "a", value: "loading" }]);
39
40 // A new label gets its own full hold, even during the first label's hold.
41 vi.advanceTimersByTime(1);
42 status.update("a", "syncing");
43 status.update("a", null);
44 vi.advanceTimersByTime(STATUS_MIN_VISIBLE_MS - 1);
45 expect(changes.at(-1)).toEqual({ key: "a", value: "syncing" });
46 vi.advanceTimersByTime(1);
47 expect(changes.at(-1)).toBeNull();
48
49 status.update("a", "syncing");
50 vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS + STATUS_MIN_VISIBLE_MS);
51 status.update("a", null);
52 expect(changes.at(-1)).toBeNull();
53 });
54
55 it("drops the shown status at once when the key changes", () => {
56 const { changes, status } = track();
57 status.update("a", "syncing");
58 vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS);
59 status.update("b", "syncing");
60 expect(changes.at(-1)).toBeNull();
61
62 vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS);
63 expect(changes.at(-1)).toEqual({ key: "b", value: "syncing" });
64 });
65});
packages/client-runtime/src/delayedStatus.ts created+86
...@@ -0,0 +1,86 @@
1// @effect-diagnostics globalTimers:off - Display timing for React hooks, outside an Effect runtime.
2
3/** How long a status must last before a client shows it. */
4export const STATUS_SHOW_DELAY_MS = 400;
5/** How long a shown status stays up, so it cannot flash at the show delay. */
6export const STATUS_MIN_VISIBLE_MS = 400;
7
8/** The status a client should show, and the key it belongs to. */
9export interface ShownStatus<A> {
10 readonly key: string;
11 readonly value: A;
12}
13
14export interface DelayedStatus<A> {
15 /** Reports the real status for `key`. A new key drops the shown status at once. */
16 readonly update: (key: string, value: A | null) => void;
17 /** Cancels pending timers. A later `update` starts again from the real status. */
18 readonly dispose: () => void;
19}
20
21/**
22 * Turns a real status (for example the thread sync phase) into the status a
23 * client shows. A status that clears within `STATUS_SHOW_DELAY_MS` is never
24 * shown. A shown status stays for at least `STATUS_MIN_VISIBLE_MS`. Values
25 * compare by identity, so use strings or other stable values.
26 *
27 * Web and mobile wrap this in a small `useDelayedStatus` hook.
28 */
29export function createDelayedStatus<A>(
30 onChange: (shown: ShownStatus<A> | null) => void,
31): DelayedStatus<A> {
32 let key = "";
33 let latest: A | null = null;
34 let shown: A | null = null;
35 // While hidden, this is the show delay. While shown, the minimum visible time.
36 let timer: ReturnType<typeof setTimeout> | undefined;
37
38 const clearTimer = () => {
39 clearTimeout(timer);
40 timer = undefined;
41 };
42 const hide = () => {
43 shown = null;
44 onChange(null);
45 };
46 // Every shown value, including a new label, gets the full minimum visible time.
47 const show = (value: A) => {
48 shown = value;
49 onChange({ key, value });
50 clearTimer();
51 timer = setTimeout(() => {
52 timer = undefined;
53 if (latest === null) hide();
54 }, STATUS_MIN_VISIBLE_MS);
55 };
56
57 return {
58 update: (nextKey, value) => {
59 if (nextKey !== key) {
60 key = nextKey;
61 clearTimer();
62 if (shown !== null) hide();
63 }
64 latest = value;
65
66 if (shown === null) {
67 if (value === null) {
68 clearTimer();
69 } else if (timer === undefined) {
70 timer = setTimeout(() => {
71 timer = undefined;
72 if (latest !== null) show(latest);
73 }, STATUS_SHOW_DELAY_MS);
74 }
75 return;
76 }
77
78 if (value !== null) {
79 if (value !== shown) show(value);
80 } else if (timer === undefined) {
81 hide();
82 }
83 },
84 dispose: clearTimer,
85 };
86}