diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index abfdadda238..e08bf42c142 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -294,20 +294,16 @@ export interface SidebarHoverPrewarmController { export function createSidebarHoverPrewarmController(input: { delayMs: number; onPrewarmTargetChange: (threadKey: string | null) => void; - setTimeoutFn?: typeof globalThis.setTimeout; - clearTimeoutFn?: typeof globalThis.clearTimeout; }): SidebarHoverPrewarmController { - const setTimeoutFn = input.setTimeoutFn ?? globalThis.setTimeout; - const clearTimeoutFn = input.clearTimeoutFn ?? globalThis.clearTimeout; let target: string | null = null; let pendingKey: string | null = null; - let timeoutId: NodeJS.Timeout | null = null; + let timeoutId: ReturnType | null = null; const clearPending = () => { if (timeoutId === null) { return; } - clearTimeoutFn(timeoutId); + clearTimeout(timeoutId); timeoutId = null; pendingKey = null; }; @@ -334,7 +330,7 @@ export function createSidebarHoverPrewarmController(input: { clearPending(); pendingKey = threadKey; - timeoutId = setTimeoutFn(() => { + timeoutId = setTimeout(() => { timeoutId = null; pendingKey = null; target = threadKey; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1c4de4480c6..18497970e9c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -250,7 +250,8 @@ function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: Scope } // Self-contained so hover changes re-render only this component, never the -// sidebar tree. Delegated `pointerover` avoids per-row handler props. +// sidebar tree. Delegated `pointerover` avoids threading a hover callback +// through the memoized row tree. function SidebarHoverThreadPrewarmer() { const [prewarmThreadKey, setPrewarmThreadKey] = useState(null); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 81974009f7e..db156beeb48 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1815,14 +1815,6 @@ function migratePersistedComposerDraftStoreState( }; } -// Runs on every store update (each prompt keystroke); drafts are immutable -// snapshots, so unchanged drafts reuse their previously partialized form -// instead of re-cloning nested contexts/annotations/comments per update. -const partializedDraftCache = new WeakMap< - ComposerThreadDraftState, - PersistedComposerThreadDraftState | null ->(); - function partializeComposerDraftStoreState( state: ComposerDraftStoreState, ): PersistedComposerDraftStoreState { @@ -1833,16 +1825,7 @@ function partializeComposerDraftStoreState( if (typeof threadKey !== "string" || threadKey.length === 0) { continue; } - const cached = partializedDraftCache.get(draft); - if (cached !== undefined) { - if (cached !== null) { - persistedDraftsByThreadKey[threadKey] = - cached as DeepMutable; - } - continue; - } const persisted = partializeComposerThreadDraft(draft); - partializedDraftCache.set(draft, persisted); if (persisted !== null) { persistedDraftsByThreadKey[threadKey] = persisted as DeepMutable; diff --git a/apps/web/src/connection/useDesktopLocalBootstraps.ts b/apps/web/src/connection/useDesktopLocalBootstraps.ts index 3db1708d0f1..fb511bba799 100644 --- a/apps/web/src/connection/useDesktopLocalBootstraps.ts +++ b/apps/web/src/connection/useDesktopLocalBootstraps.ts @@ -1,5 +1,5 @@ import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; -import { useSyncExternalStore } from "react"; +import { useEffect, useState } from "react"; import { readDesktopSecondaryBootstraps } from "./desktopLocal"; @@ -26,53 +26,27 @@ function bootstrapsEqual( }); } -// One shared poller for all consumers (sidebar, command palette, ...): a single -// interval runs only while someone is subscribed, and listeners are notified -// only when the topology actually changed — each poll returns a fresh array, -// so publishing it unconditionally would re-render every consumer per tick. -const listeners = new Set<() => void>(); -let currentSnapshot: ReadonlyArray | null = null; -let pollInterval: ReturnType | null = null; - -function poll(): void { - const next = readDesktopSecondaryBootstraps(); - if (currentSnapshot !== null && bootstrapsEqual(currentSnapshot, next)) { - return; - } - currentSnapshot = next; - for (const listener of listeners) { - listener(); - } -} - -function subscribe(listener: () => void): () => void { - listeners.add(listener); - if (pollInterval === null) { - poll(); - pollInterval = setInterval(poll, DESKTOP_LOCAL_BOOTSTRAP_POLL_MS); - } - return () => { - listeners.delete(listener); - if (listeners.size === 0 && pollInterval !== null) { - clearInterval(pollInterval); - pollInterval = null; - } - }; -} - -function getSnapshot(): ReadonlyArray { - // First read happens during render, before subscribe() has polled. - currentSnapshot ??= readDesktopSecondaryBootstraps(); - return currentSnapshot; -} - /** * Reactively track the desktop's secondary local backends (e.g. a parallel WSL - * backend). The bridge exposes no change event, so we re-read on an interval; - * failed reads retain the latest successful snapshot, while a successful empty - * read clears it. Use this instead of polling the bridge ad hoc so every - * renderer consumer reads the same topology. + * backend). The bridge exposes no change event, so each hook instance re-reads + * on its own interval; a poll returns a fresh array, so the previous reference + * is kept when the topology is unchanged to avoid re-rendering the consumer + * every tick. Use this instead of polling the bridge ad hoc. */ export function useDesktopLocalBootstraps(): ReadonlyArray { - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const [bootstraps, setBootstraps] = useState>( + readDesktopSecondaryBootstraps, + ); + + useEffect(() => { + const read = () => { + const next = readDesktopSecondaryBootstraps(); + setBootstraps((previous) => (bootstrapsEqual(previous, next) ? previous : next)); + }; + read(); + const interval = setInterval(read, DESKTOP_LOCAL_BOOTSTRAP_POLL_MS); + return () => clearInterval(interval); + }, []); + + return bootstraps; } diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index bd8fd2f5e47..f2746e23fa6 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1366,42 +1366,6 @@ function compareActivityLifecycleRank(kind: string): number { return 1; } -function isSortedByCreatedAt(rows: ReadonlyArray): boolean { - for (let index = 1; index < rows.length; index += 1) { - if (rows[index - 1]!.createdAt.localeCompare(rows[index]!.createdAt) > 0) { - return false; - } - } - return true; -} - -// Stable k-way merge of per-kind rows that are each already in createdAt order -// (the reducers maintain that invariant). Equal timestamps keep the same order -// a stable sort of the concatenation would produce. -function mergeTimelineRows(sources: ReadonlyArray>): TimelineEntry[] { - const merged: TimelineEntry[] = []; - const cursors = sources.map(() => 0); - const total = sources.reduce((sum, rows) => sum + rows.length, 0); - while (merged.length < total) { - let nextSource = -1; - for (let index = 0; index < sources.length; index += 1) { - const candidate = sources[index]![cursors[index]!]; - if (candidate === undefined) { - continue; - } - if ( - nextSource === -1 || - candidate.createdAt.localeCompare(sources[nextSource]![cursors[nextSource]!]!.createdAt) < 0 - ) { - nextSource = index; - } - } - merged.push(sources[nextSource]![cursors[nextSource]!]!); - cursors[nextSource] = cursors[nextSource]! + 1; - } - return merged; -} - export function deriveTimelineEntries( messages: ReadonlyArray, proposedPlans: ReadonlyArray, @@ -1425,10 +1389,9 @@ export function deriveTimelineEntries( createdAt: entry.createdAt, entry, })); - const sources = [messageRows, proposedPlanRows, workRows]; - return sources.every(isSortedByCreatedAt) - ? mergeTimelineRows(sources) - : sources.flat().toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); + return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => + a.createdAt.localeCompare(b.createdAt), + ); } export function inferCheckpointTurnCountByTurnId(