Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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":
Expand All @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/lib/useDelayedStatus.ts
Original file line number Diff line number Diff line change
@@ -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<A>(key: string, value: A | null): A | null {
const [shown, setShown] = useState<ShownStatus<A> | null>(null);
const [status] = useState(() => createDelayedStatus<A>(setShown));
useEffect(() => () => status.dispose(), [status]);
useEffect(() => {
status.update(key, value);
}, [status, key, value]);
return shown?.key === key ? shown.value : null;
}
24 changes: 13 additions & 11 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1578,16 +1579,21 @@ 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)
// ------------------------------------------------------------------
const composerDraft = useComposerThreadDraft(composerDraftTarget);
// 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(
Expand Down Expand Up @@ -5151,8 +5157,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
activeTaskSteps !== null &&
activeTasksProgress.totalSteps > 0;
const activityStackContent = hasBannerItems ? (
props.threadSyncPhase ? (
<ComposerActivityRow phase={props.threadSyncPhase} />
shownSyncPhase ? (
<ComposerActivityRow phase={shownSyncPhase} />
) : !hasBlockingComposerTopDrawer && activeTasksProgress && activeTaskSteps ? (
<ComposerTasksContent
expanded={isTasksDrawerOpen}
Expand Down Expand Up @@ -6186,14 +6192,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
className="relative z-0"
items={bannerStackItems}
/>
{!activityStackItem && (props.threadSyncPhase || inlineTasksBadge) ? (
{!activityStackItem && (shownSyncPhase || inlineTasksBadge) ? (
<ComposerBanner.Attachment>
<ComposerBanner.Root data-chat-composer-activity-strip="true">
{props.threadSyncPhase ? (
<ComposerActivityRow phase={props.threadSyncPhase} />
) : (
inlineTasksBadge
)}
{shownSyncPhase ? <ComposerActivityRow phase={shownSyncPhase} /> : inlineTasksBadge}
</ComposerBanner.Root>
</ComposerBanner.Attachment>
) : null}
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/hooks/useDelayedStatus.ts
Original file line number Diff line number Diff line change
@@ -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<A>(key: string, value: A | null): A | null {
const [shown, setShown] = useState<ShownStatus<A> | null>(null);
const [status] = useState(() => createDelayedStatus<A>(setShown));
useEffect(() => () => status.dispose(), [status]);
useEffect(() => {
status.update(key, value);
}, [status, key, value]);
return shown?.key === key ? shown.value : null;
}
4 changes: 4 additions & 0 deletions packages/client-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
65 changes: 65 additions & 0 deletions packages/client-runtime/src/delayedStatus.test.ts
Original file line number Diff line number Diff line change
@@ -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<ShownStatus<string> | null> = [];
const status = createDelayedStatus<string>((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 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);
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" });
});
});
86 changes: 86 additions & 0 deletions packages/client-runtime/src/delayedStatus.ts
Original file line number Diff line number Diff line change
@@ -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<A> {
readonly key: string;
readonly value: A;
}

export interface DelayedStatus<A> {
/** 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<A>(
onChange: (shown: ShownStatus<A> | null) => void,
): DelayedStatus<A> {
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<typeof setTimeout> | 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,
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading