Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 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);
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