| 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,6 +79,7 @@ import { useEnvironmentQuery } from "../../state/query"; |
| 79 | import { threadDevicePreviews } from "../devices/threadDevicePreviews"; | 79 | import { threadDevicePreviews } from "../devices/threadDevicePreviews"; |
| 80 | import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; | 80 | import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; |
| 81 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 81 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 82 | import { useDelayedStatus } from "../../lib/useDelayedStatus"; | ||
| 82 | import type { | 83 | import 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 or | 355 | // The raw sync status enters "synchronizing" on every full fetch, cached or |
| 355 | // not. Whether messages are already on screen decides the pill label: no | 356 | // 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 while | 374 | // One floating pill above the composer: it reads the connection phase while |
| 371 | // disconnected, the sync state while messages load, then the working timer | 375 | // 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 @@ | ||
| 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,6 +977,7 @@ import { |
| 977 | resolveProviderSlashCommandsForCwd, | 977 | resolveProviderSlashCommandsForCwd, |
| 978 | } from "@t3tools/client-runtime/providerSkills"; | 978 | } from "@t3tools/client-runtime/providerSkills"; |
| 979 | import { searchProviderSkills } from "../../providerSkillSearch"; | 979 | import { searchProviderSkills } from "../../providerSkillSearch"; |
| 980 | import { useDelayedStatus } from "../../hooks/useDelayedStatus"; | ||
| 980 | import { useMediaQuery } from "../../hooks/useMediaQuery"; | 981 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 981 | import { usePanelAnimationSettings } from "../../panelAnimations"; | 982 | import { usePanelAnimationSettings } from "../../panelAnimations"; |
| 982 | import { useAtomCommand } from "../../state/use-atom-command"; | 983 | import { 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 that | 1593 | // 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?.activeQuestion | 1598 | 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 | <ComposerTasksContent | 5163 | <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 @@ | ||
| 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,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 @@ | ||
| 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 | } | ||