| author | |
| committer | |
| log | ab70c8943f48c08e67e3597f62941c169bc58c16 |
| tree | 9067fe3809664fb9a31ff06bceefe958c6c2870d |
| parent | 29abbf9b424b122c52bb20dccffac68bee367c09 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 79 | import { threadDevicePreviews } from "../devices/threadDevicePreviews"; |
| 80 | 80 | import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; |
| 81 | 81 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 82 | import { useDelayedStatus } from "../../lib/useDelayedStatus"; | |
| 82 | 83 | import type { |
| 83 | 84 | PendingApproval, |
| 84 | 85 | PendingUserInput, |
| ... | ... | @@ -354,7 +355,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 354 | 355 | // The raw sync status enters "synchronizing" on every full fetch, cached or |
| 355 | 356 | // not. Whether messages are already on screen decides the pill label: no |
| 356 | 357 | // data yet → "Loading messages", cached data reconciling → "Syncing". |
| 357 | const threadSyncLabel = (() => { | |
| 358 | const realThreadSyncLabel = (() => { | |
| 358 | 359 | switch (props.threadSyncStatus) { |
| 359 | 360 | case "empty": |
| 360 | 361 | case "cached": |
| ... | ... | @@ -367,6 +368,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 367 | 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 | 374 | // One floating pill above the composer: it reads the connection phase while |
| 371 | 375 | // disconnected, the sync state while messages load, then the working timer |
| 372 | 376 | // once the feed is settled. |
apps/mobile/src/lib/useDelayedStatus.ts created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | import { createDelayedStatus, type ShownStatus } from "@t3tools/client-runtime/delayed-status"; | |
| 2 | import { 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 | */ | |
| 10 | export 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 | 977 | resolveProviderSlashCommandsForCwd, |
| 978 | 978 | } from "@t3tools/client-runtime/providerSkills"; |
| 979 | 979 | import { searchProviderSkills } from "../../providerSkillSearch"; |
| 980 | import { useDelayedStatus } from "../../hooks/useDelayedStatus"; | |
| 980 | 981 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 981 | 982 | import { usePanelAnimationSettings } from "../../panelAnimations"; |
| 982 | 983 | import { useAtomCommand } from "../../state/use-atom-command"; |
| ... | ... | @@ -1578,8 +1579,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1578 | 1579 | onFileOpen, |
| 1579 | 1580 | } = props; |
| 1580 | 1581 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 1581 | const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null; | |
| 1582 | const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null; | |
| 1582 | const composerDraftTargetKey = composerTargetKey(composerDraftTarget); | |
| 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 | 1590 | // Store subscriptions (prompt / images / terminal contexts) |
| 1585 | 1591 | // ------------------------------------------------------------------ |
| ... | ... | @@ -1587,7 +1593,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1587 | 1593 | // Live target key, for async flows that must notice a thread switch that |
| 1588 | 1594 | // happened while they awaited. |
| 1589 | 1595 | const composerDraftTargetKeyRef = useRef(""); |
| 1590 | composerDraftTargetKeyRef.current = composerTargetKey(composerDraftTarget); | |
| 1596 | composerDraftTargetKeyRef.current = composerDraftTargetKey; | |
| 1591 | 1597 | const questionAttachmentTarget = |
| 1592 | 1598 | pendingUserInputs[0] && activePendingProgress?.activeQuestion |
| 1593 | 1599 | ? questionAttachmentDraftId( |
| ... | ... | @@ -5151,8 +5157,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5151 | 5157 | activeTaskSteps !== null && |
| 5152 | 5158 | activeTasksProgress.totalSteps > 0; |
| 5153 | 5159 | const activityStackContent = hasBannerItems ? ( |
| 5154 | props.threadSyncPhase ? ( | |
| 5155 | <ComposerActivityRow phase={props.threadSyncPhase} /> | |
| 5160 | shownSyncPhase ? ( | |
| 5161 | <ComposerActivityRow phase={shownSyncPhase} /> | |
| 5156 | 5162 | ) : !hasBlockingComposerTopDrawer && activeTasksProgress && activeTaskSteps ? ( |
| 5157 | 5163 | <ComposerTasksContent |
| 5158 | 5164 | expanded={isTasksDrawerOpen} |
| ... | ... | @@ -6186,14 +6192,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6186 | 6192 | className="relative z-0" |
| 6187 | 6193 | items={bannerStackItems} |
| 6188 | 6194 | /> |
| 6189 | {!activityStackItem && (props.threadSyncPhase || inlineTasksBadge) ? ( | |
| 6195 | {!activityStackItem && (shownSyncPhase || inlineTasksBadge) ? ( | |
| 6190 | 6196 | <ComposerBanner.Attachment> |
| 6191 | 6197 | <ComposerBanner.Root data-chat-composer-activity-strip="true"> |
| 6192 | {props.threadSyncPhase ? ( | |
| 6193 | <ComposerActivityRow phase={props.threadSyncPhase} /> | |
| 6194 | ) : ( | |
| 6195 | inlineTasksBadge | |
| 6196 | )} | |
| 6198 | {shownSyncPhase ? <ComposerActivityRow phase={shownSyncPhase} /> : inlineTasksBadge} | |
| 6197 | 6199 | </ComposerBanner.Root> |
| 6198 | 6200 | </ComposerBanner.Attachment> |
| 6199 | 6201 | ) : null} |
apps/web/src/hooks/useDelayedStatus.ts created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | import { createDelayedStatus, type ShownStatus } from "@t3tools/client-runtime/delayed-status"; | |
| 2 | import { 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 | */ | |
| 10 | export 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 | 119 | "types": "./src/textPaste.ts", |
| 120 | 120 | "default": "./src/textPaste.ts" |
| 121 | 121 | }, |
| 122 | "./delayed-status": { | |
| 123 | "types": "./src/delayedStatus.ts", | |
| 124 | "default": "./src/delayedStatus.ts" | |
| 125 | }, | |
| 122 | 126 | "./state/connections": { |
| 123 | 127 | "types": "./src/state/connections.ts", |
| 124 | 128 | "default": "./src/state/connections.ts" |
packages/client-runtime/src/delayedStatus.test.ts created+65| ... | ... | @@ -0,0 +1,65 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | createDelayedStatus, | |
| 5 | STATUS_MIN_VISIBLE_MS, | |
| 6 | STATUS_SHOW_DELAY_MS, | |
| 7 | type ShownStatus, | |
| 8 | } from "./delayedStatus.ts"; | |
| 9 | ||
| 10 | function track() { | |
| 11 | const changes: Array<ShownStatus<string> | null> = []; | |
| 12 | const status = createDelayedStatus<string>((shown) => changes.push(shown)); | |
| 13 | return { changes, status }; | |
| 14 | } | |
| 15 | ||
| 16 | describe("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. */ | |
| 4 | export const STATUS_SHOW_DELAY_MS = 400; | |
| 5 | /** How long a shown status stays up, so it cannot flash at the show delay. */ | |
| 6 | export const STATUS_MIN_VISIBLE_MS = 400; | |
| 7 | ||
| 8 | /** The status a client should show, and the key it belongs to. */ | |
| 9 | export interface ShownStatus<A> { | |
| 10 | readonly key: string; | |
| 11 | readonly value: A; | |
| 12 | } | |
| 13 | ||
| 14 | export 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 | */ | |
| 29 | export 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 | } |