From a594006e7a474d4c4f86cbea0a60de10ae2bbfdd Mon Sep 17 00:00:00 2001 From: Addis Date: Thu, 11 Jun 2026 08:27:34 -0500 Subject: [PATCH 1/2] Optimize thread loading and browser panels --- apps/web/src/components/ChatView.tsx | 55 ++++--- apps/web/src/components/Sidebar.tsx | 107 ++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 11 +- .../src/hooks/useDisposableThreadLifecycle.ts | 2 +- apps/web/src/index.css | 26 ++++ apps/web/src/routes/__root.tsx | 32 +++- apps/web/src/routes/_chat.$threadId.tsx | 88 ++++++++++- apps/web/src/splitViewStore.test.ts | 62 ++++++++ apps/web/src/splitViewStore.ts | 1 + apps/web/src/store.test.ts | 87 +++++++++++ apps/web/src/store.ts | 141 +++++++++++------- apps/web/src/wsNativeApi.ts | 40 ++++- 12 files changed, 564 insertions(+), 88 deletions(-) create mode 100644 apps/web/src/splitViewStore.test.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 34f6e830fb0..409bcba8c27 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1947,6 +1947,12 @@ export default function ChatView({ activeThread.session !== null), ); const shouldShowNewThreadLanding = isLocalDraftThread && !hasThreadStarted; + const shouldShowThreadBodyLoading = + activeThread !== undefined && + isServerThread && + activeThread.latestTurn !== null && + activeThread.messages.length === 0 && + optimisticUserMessages.length === 0; const isPromptEmpty = prompt.trim().length === 0 && selectedComposerExtensions.length === 0 && @@ -3500,9 +3506,11 @@ export default function ChatView({ const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; + const bottomScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight); scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); - lastKnownScrollTopRef.current = scrollContainer.scrollTop; + lastKnownScrollTopRef.current = bottomScrollTop; shouldAutoScrollRef.current = true; + pendingUserScrollUpIntentRef.current = false; setShowScrollToBottomPill(false); }, []); const cancelPendingStickToBottom = useCallback(() => { @@ -3572,33 +3580,35 @@ export default function ChatView({ if (!shouldAutoScrollRef.current && isNearBottom) { shouldAutoScrollRef.current = true; pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { - shouldAutoScrollRef.current = false; - } + } else if ( + shouldAutoScrollRef.current && + !isNearBottom && + (pendingUserScrollUpIntentRef.current || isPointerScrollActiveRef.current) + ) { + shouldAutoScrollRef.current = false; pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { - shouldAutoScrollRef.current = false; - } + cancelPendingStickToBottom(); } else if (shouldAutoScrollRef.current && !isNearBottom) { // Catch-all for keyboard/assistive scroll interactions. const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp) { shouldAutoScrollRef.current = false; + cancelPendingStickToBottom(); } } lastKnownScrollTopRef.current = currentScrollTop; setShowScrollToBottomPill((current) => (current === !isNearBottom ? current : !isNearBottom)); - }, []); - const onMessagesWheel = useCallback((event: React.WheelEvent) => { - if (event.deltaY < 0) { - pendingUserScrollUpIntentRef.current = true; - } - }, []); + }, [cancelPendingStickToBottom]); + const onMessagesWheel = useCallback( + (event: React.WheelEvent) => { + if (event.deltaY < 0) { + pendingUserScrollUpIntentRef.current = true; + cancelPendingStickToBottom(); + } + }, + [cancelPendingStickToBottom], + ); const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { isPointerScrollActiveRef.current = true; }, []); @@ -5266,7 +5276,7 @@ export default function ChatView({ await api.orchestration .getSnapshot({ mode: "bootstrap" }) .then((snapshot) => { - syncServerReadModel(snapshot); + syncServerReadModel(snapshot, { preserveThreadDetails: true }); }) .catch(() => undefined); toastManager.add({ @@ -5867,7 +5877,7 @@ export default function ChatView({ const snapshot = await api.orchestration.getSnapshot({ mode: "bootstrap" }).catch(() => null); if (snapshot) { - syncServerReadModel(snapshot); + syncServerReadModel(snapshot, { preserveThreadDetails: true }); } await onSelectNewThreadLandingProject(projectId); @@ -6393,6 +6403,13 @@ export default function ChatView({ ) : null} + ) : shouldShowThreadBodyLoading ? ( +
+ Loading conversation... +
) : ( 0 ? error.message : fallback; @@ -867,6 +868,7 @@ export default function Sidebar() { const threads = useStore((store) => store.threads); const threadsHydrated = useStore((store) => store.threadsHydrated); const hydrationError = useStore((store) => store.hydrationError); + const syncServerReadModel = useStore((store) => store.syncServerReadModel); const markThreadUnread = useStore((store) => store.markThreadUnread); const toggleProject = useStore((store) => store.toggleProject); const setAllProjectsExpanded = useStore((store) => store.setAllProjectsExpanded); @@ -925,6 +927,99 @@ export default function Sidebar() { const [draggedProjectId, setDraggedProjectId] = useState(null); const [dropTargetProjectId, setDropTargetProjectId] = useState(null); const [dropTargetPosition, setDropTargetPosition] = useState<"before" | "after" | null>(null); + const threadDetailPrefetchTimeoutsRef = useRef(new Map()); + const threadDetailPrefetchInFlightRef = useRef(new Set()); + const threadDetailPrefetchKeyByThreadIdRef = useRef(new Map()); + + const cancelThreadDetailPrefetch = useCallback((threadId: ThreadId) => { + const timeoutId = threadDetailPrefetchTimeoutsRef.current.get(threadId); + if (timeoutId === undefined) { + return; + } + window.clearTimeout(timeoutId); + threadDetailPrefetchTimeoutsRef.current.delete(threadId); + }, []); + + const scheduleThreadDetailPrefetch = useCallback( + (thread: Thread) => { + if ( + thread.id === routeThreadId || + thread.archivedAt !== null || + thread.latestTurn === null || + thread.messages.length > 0 + ) { + return; + } + + const prefetchKey = [ + thread.id, + thread.updatedAt, + thread.latestTurn.turnId, + thread.latestTurn.completedAt ?? "", + ].join("\u0000"); + if (threadDetailPrefetchKeyByThreadIdRef.current.get(thread.id) === prefetchKey) { + return; + } + if (threadDetailPrefetchInFlightRef.current.has(thread.id)) { + return; + } + if (threadDetailPrefetchTimeoutsRef.current.has(thread.id)) { + return; + } + + const timeoutId = window.setTimeout(() => { + threadDetailPrefetchTimeoutsRef.current.delete(thread.id); + const currentThread = useStore + .getState() + .threads.find((candidate) => candidate.id === thread.id); + if ( + currentThread === undefined || + currentThread.archivedAt !== null || + currentThread.latestTurn === null || + currentThread.messages.length > 0 + ) { + return; + } + + const api = readNativeApi(); + if (!api) { + return; + } + + threadDetailPrefetchInFlightRef.current.add(thread.id); + void api.orchestration + .getSnapshot({ + mode: "focused", + threadId: thread.id, + threadIds: [thread.id], + }) + .then((snapshot) => { + syncServerReadModel(snapshot, { + authoritativeThreadDetailIds: new Set([thread.id]), + preserveThreadDetails: true, + }); + threadDetailPrefetchKeyByThreadIdRef.current.set(thread.id, prefetchKey); + }) + .catch(() => undefined) + .finally(() => { + threadDetailPrefetchInFlightRef.current.delete(thread.id); + }); + }, THREAD_DETAIL_PREFETCH_DELAY_MS); + + threadDetailPrefetchTimeoutsRef.current.set(thread.id, timeoutId); + }, + [routeThreadId, syncServerReadModel], + ); + + useEffect( + () => () => { + for (const timeoutId of threadDetailPrefetchTimeoutsRef.current.values()) { + window.clearTimeout(timeoutId); + } + threadDetailPrefetchTimeoutsRef.current.clear(); + }, + [], + ); const openSearchPalette = useCallback((mode: SidebarSearchPaletteMode = "search") => { setSearchPaletteMode(mode); @@ -2569,12 +2664,20 @@ export default function Sidebar() { clearArchiveConfirm(thread.id)} + onMouseEnter={() => scheduleThreadDetailPrefetch(thread)} + onMouseLeave={() => { + cancelThreadDetailPrefetch(thread.id); + clearArchiveConfirm(thread.id); + }} + onFocusCapture={() => { + scheduleThreadDetailPrefetch(thread); + }} onBlurCapture={(event: FocusEvent) => { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { return; } + cancelThreadDetailPrefetch(thread.id); clearArchiveConfirm(thread.id); }} > @@ -2860,6 +2963,7 @@ export default function Sidebar() { [ archiveConfirmThreadId, cancelRename, + cancelThreadDetailPrefetch, clearArchiveConfirm, commitRename, handleInlineArchiveConfirm, @@ -2873,6 +2977,7 @@ export default function Sidebar() { renamingThreadId, renamingTitle, routeThreadId, + scheduleThreadDetailPrefetch, sidebarPreferences.threadSort, expandedSubagentParentIds, draftThreadsByThreadId, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 30eb6dfb060..08e08914dc6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3691,11 +3691,18 @@ export const MessagesTimeline = memo(function MessagesTimeline(props: MessagesTi }, [rowVirtualizer, timelineWidthPx]); useEffect(() => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { + rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; const scrollOffset = instance.scrollOffset ?? 0; const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); - return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; + const isReadingOlderHistory = remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; + const changedItemIsAboveViewport = item.start < scrollOffset; + + return ( + isReadingOlderHistory && + changedItemIsAboveViewport && + instance.scrollDirection !== "backward" + ); }; return () => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; diff --git a/apps/web/src/hooks/useDisposableThreadLifecycle.ts b/apps/web/src/hooks/useDisposableThreadLifecycle.ts index 04062ab5eac..50050b96e4e 100644 --- a/apps/web/src/hooks/useDisposableThreadLifecycle.ts +++ b/apps/web/src/hooks/useDisposableThreadLifecycle.ts @@ -91,7 +91,7 @@ export function useDisposableThreadLifecycle(activeThreadId: ThreadId | null): v .getSnapshot({ mode: "bootstrap" }) .catch(() => null); if (snapshot) { - syncServerReadModel(snapshot); + syncServerReadModel(snapshot, { preserveThreadDetails: true }); } } } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 24c7db2967d..f13a81c09af 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -418,11 +418,16 @@ label:has(> select#reasoning-effort) select { } .chat-markdown pre code { + display: block; + min-width: max-content; border: none; background: transparent; padding: 0; line-height: 1.5; font-size: 0.875rem; + white-space: pre; + overflow-wrap: normal; + word-break: normal; } .chat-markdown .chat-markdown-codeblock { @@ -471,6 +476,27 @@ label:has(> select#reasoning-effort) select { background: color-mix(in srgb, var(--muted) 78%, var(--background)) !important; } +.chat-markdown .chat-markdown-shiki { + min-width: 0; + max-width: 100%; + overflow-x: auto; +} + +.chat-markdown .chat-markdown-shiki code { + display: block; + min-width: max-content; + white-space: pre; +} + +.chat-markdown .chat-markdown-shiki .line { + display: block; + min-height: 1.5em; +} + +.chat-markdown .chat-markdown-shiki span { + line-height: inherit; +} + .file-viewer-shiki .shiki { background: transparent !important; margin: 0 !important; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index a984e1e05e8..ade478e592f 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -192,6 +192,20 @@ function describeSnapshotInput(input: OrchestrationGetSnapshotInput): string { return input.mode ?? "bootstrap"; } +function authoritativeThreadDetailIdsForSnapshotInput( + input: OrchestrationGetSnapshotInput, +): ReadonlySet | undefined { + if (input.mode !== "focused") { + return undefined; + } + + return new Set( + [input.threadId, ...(input.threadIds ?? [])].filter( + (threadId): threadId is ThreadId => threadId !== undefined, + ), + ); +} + function EventRouter() { const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setHydrationStatus = useStore((store) => store.setHydrationStatus); @@ -225,11 +239,16 @@ function EventRouter() { const flushSnapshotSync = async (): Promise => { const requestedSnapshotInput = snapshotInputForLocation(pathnameRef.current); + const snapshotInput = useStore.getState().threadsHydrated + ? requestedSnapshotInput + : ({ mode: "bootstrap" } satisfies OrchestrationGetSnapshotInput); + let preserveThreadDetails = snapshotInput.mode !== "full"; + let authoritativeThreadDetailIds = authoritativeThreadDetailIdsForSnapshotInput(snapshotInput); let snapshot; try { - snapshot = await api.orchestration.getSnapshot(requestedSnapshotInput); + snapshot = await api.orchestration.getSnapshot(snapshotInput); } catch (error) { - if (requestedSnapshotInput.mode === "bootstrap") { + if (snapshotInput.mode === "bootstrap") { throw error; } @@ -244,15 +263,20 @@ function EventRouter() { : "Focused snapshot failed before bootstrap fallback.", context: { route: pathnameRef.current, - requestedMode: describeSnapshotInput(requestedSnapshotInput), + requestedMode: describeSnapshotInput(snapshotInput), fallbackMode: "bootstrap", }, }); snapshot = await api.orchestration.getSnapshot({ mode: "bootstrap" }); + preserveThreadDetails = true; + authoritativeThreadDetailIds = undefined; } if (disposed) return; latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); - syncServerReadModel(snapshot); + syncServerReadModel(snapshot, { + ...(authoritativeThreadDetailIds ? { authoritativeThreadDetailIds } : {}), + preserveThreadDetails, + }); const draftThreadIds = Object.keys( useComposerDraftStore.getState().draftThreadsByThreadId, ) as ThreadId[]; diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index ebf15854efd..ce9fda96f5d 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -989,8 +989,11 @@ function SplitChatSurface(props: { splitViewId: SplitViewId; routeThreadId: Thre params: { threadId: nextThreadId }, replace: true, search: () => { - if (focusedPanelState.lastOpenPanel) { - return { panel: focusedPanelState.lastOpenPanel }; + if (focusedPanelState.panel === "browser") { + return { panel: "browser" as const }; + } + if (focusedPanelState.panel === "diff") { + return { panel: "diff" as const, diff: "1" as const }; } return {}; }, @@ -1066,8 +1069,18 @@ function SplitChatSurface(props: { splitViewId: SplitViewId; routeThreadId: Thre setFocusedPane(activeSplitView.id, pane); if (threadId !== currentPaneThreadId) { + const currentPaneProjectId = + currentPaneThreadId !== null + ? (threads.find((thread) => thread.id === currentPaneThreadId)?.projectId ?? null) + : null; + const nextPaneProjectId = threads.find((thread) => thread.id === threadId)?.projectId ?? null; + const projectChanged = + currentPaneProjectId !== null && + nextPaneProjectId !== null && + currentPaneProjectId !== nextPaneProjectId; replacePaneThread(activeSplitView.id, pane, threadId); setPanePanelState(activeSplitView.id, pane, { + ...(projectChanged ? { panel: null, filesOpen: false } : {}), diffTurnId: null, diffFilePath: null, }); @@ -1218,6 +1231,7 @@ function SingleChatSurface(props: { cwd: string | null; }; onSplitSurface?: () => void; + onBrowserPanelClosed?: () => void; }) { const navigate = useNavigate(); const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); @@ -1227,18 +1241,23 @@ function SingleChatSurface(props: { const [hasOpenedPanel, setHasOpenedPanel] = useState(panelOpen); const [lastOpenPanel, setLastOpenPanel] = useState(activePanel ?? "browser"); const [viewerExpanded, setViewerExpanded] = useState(false); + const onBrowserPanelClosed = props.onBrowserPanelClosed; + const threadId = props.threadId; const closePanel = useCallback(() => { + if (activePanel === "browser") { + onBrowserPanelClosed?.(); + } void navigate({ to: "/$threadId", - params: { threadId: props.threadId }, + params: { threadId }, search: (previous) => { const rest = stripDiffSearchParams(previous); const { panel: _, ...withoutPanel } = rest as Record; return withoutPanel; }, }); - }, [navigate, props.threadId]); + }, [activePanel, navigate, onBrowserPanelClosed, threadId]); const openPanel = useCallback(() => { void navigate({ @@ -1418,6 +1437,7 @@ function SingleChatSurface(props: { function ChatThreadRouteView() { const threadsHydrated = useStore((store) => store.threadsHydrated); + const hydrationStatus = useStore((store) => store.hydrationStatus); const hydrationError = useStore((store) => store.hydrationError); const navigate = useNavigate(); const threadId = Route.useParams({ @@ -1436,6 +1456,7 @@ function ChatThreadRouteView() { }); const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); + const syncServerReadModel = useStore((store) => store.syncServerReadModel); const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); const previousProjectIdRef = useRef(null); const previousThreadIdRef = useRef(null); @@ -1457,6 +1478,17 @@ function ChatThreadRouteView() { const splitView = useSplitViewStore(selectSplitView(search.splitViewId ?? null)); const removeThreadFromSplitViews = useSplitViewStore((store) => store.removeThreadFromSplitViews); const activeProjectId = threadBrowserContext.projectId; + const focusedSnapshotThreadIds = useMemo(() => { + const threadIds = + search.splitViewId && splitView + ? [threadId, splitView.leftThreadId, splitView.rightThreadId] + : [threadId]; + return [...new Set(threadIds.filter((candidate): candidate is ThreadId => candidate !== null))]; + }, [search.splitViewId, splitView, threadId]); + const focusedSnapshotNeedsThreadDetails = focusedSnapshotThreadIds.some((candidate) => { + const thread = threads.find((entry) => entry.id === candidate); + return thread !== undefined && thread.latestTurn !== null && thread.messages.length === 0; + }); const hasExplicitPanelSearchIntent = useMemo(() => { const params = new URLSearchParams(locationSearch); @@ -1565,6 +1597,51 @@ function ChatThreadRouteView() { threadsHydrated, ]); + useEffect(() => { + if ( + !threadsHydrated || + !threadExists || + hydrationStatus !== "ready" || + !focusedSnapshotNeedsThreadDetails + ) { + return; + } + + const api = readNativeApi(); + if (!api) { + return; + } + + let disposed = false; + void api.orchestration + .getSnapshot({ + mode: "focused", + threadId, + threadIds: focusedSnapshotThreadIds, + }) + .then((snapshot) => { + if (!disposed) { + syncServerReadModel(snapshot, { + authoritativeThreadDetailIds: new Set(focusedSnapshotThreadIds), + preserveThreadDetails: true, + }); + } + }) + .catch(() => undefined); + + return () => { + disposed = true; + }; + }, [ + focusedSnapshotNeedsThreadDetails, + focusedSnapshotThreadIds, + hydrationStatus, + syncServerReadModel, + threadExists, + threadId, + threadsHydrated, + ]); + useEffect(() => { const previousThreadId = previousThreadIdRef.current; previousThreadIdRef.current = threadId; @@ -1638,6 +1715,9 @@ function ChatThreadRouteView() { panelMode={panelMode} filesOpen={filesOpen} threadBrowserContext={threadBrowserContext} + onBrowserPanelClosed={() => { + suppressBrowserReopenRef.current = true; + }} /> ); } diff --git a/apps/web/src/splitViewStore.test.ts b/apps/web/src/splitViewStore.test.ts new file mode 100644 index 00000000000..9a8d163317b --- /dev/null +++ b/apps/web/src/splitViewStore.test.ts @@ -0,0 +1,62 @@ +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function createMemoryStorage(): Storage { + const values = new Map(); + + return { + get length() { + return values.size; + }, + clear() { + values.clear(); + }, + getItem(key: string) { + return values.get(key) ?? null; + }, + key(index: number) { + return [...values.keys()][index] ?? null; + }, + removeItem(key: string) { + values.delete(key); + }, + setItem(key: string, value: string) { + values.set(key, value); + }, + }; +} + +describe("useSplitViewStore", () => { + beforeEach(() => { + vi.resetModules(); + vi.stubGlobal("localStorage", createMemoryStorage()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("persists files-only panel changes", async () => { + const { useSplitViewStore } = await import("./splitViewStore"); + const splitViewId = useSplitViewStore.getState().createFromThread({ + sourceThreadId: ThreadId.makeUnsafe("thread-1"), + ownerProjectId: ProjectId.makeUnsafe("project-1"), + }); + + useSplitViewStore.getState().setPanePanelState(splitViewId, "left", { + filesOpen: true, + }); + + expect(useSplitViewStore.getState().splitViewsById[splitViewId]?.leftPanel.filesOpen).toBe( + true, + ); + + useSplitViewStore.getState().setPanePanelState(splitViewId, "left", { + filesOpen: false, + }); + + expect(useSplitViewStore.getState().splitViewsById[splitViewId]?.leftPanel.filesOpen).toBe( + false, + ); + }); +}); diff --git a/apps/web/src/splitViewStore.ts b/apps/web/src/splitViewStore.ts index d6b7d8c2da7..3d2b4b68a61 100644 --- a/apps/web/src/splitViewStore.ts +++ b/apps/web/src/splitViewStore.ts @@ -276,6 +276,7 @@ export const useSplitViewStore = create()( }; if ( splitView[key].panel === nextPanel.panel && + splitView[key].filesOpen === nextPanel.filesOpen && splitView[key].diffTurnId === nextPanel.diffTurnId && splitView[key].diffFilePath === nextPanel.diffFilePath && splitView[key].hasOpenedPanel === nextPanel.hasOpenedPanel && diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index e5a94f20a11..156f6daf9f7 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -317,6 +317,93 @@ describe("store read model sync", () => { expect(repeated).toBe(hydrated); }); + it("preserves loaded thread messages during lightweight snapshot sync", () => { + const initialState = makeState(makeThread()); + const loadedThread = makeReadModelThread({ + messages: [ + { + id: MessageId.makeUnsafe("message-1"), + turnId: null, + role: "assistant", + text: "Loaded message body", + streaming: false, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + attachments: [], + }, + ], + }); + const hydrated = syncServerReadModel(initialState, makeReadModel(loadedThread)); + const lightweightThread = makeReadModelThread({ + title: "Updated title", + updatedAt: "2026-02-27T00:01:00.000Z", + messages: [], + }); + + const next = syncServerReadModel(hydrated, makeReadModel(lightweightThread), { + preserveThreadDetails: true, + }); + + expect(next.threads[0]?.title).toBe("Updated title"); + expect(next.threads[0]?.messages).toBe(hydrated.threads[0]?.messages); + expect(next.threads[0]?.messages[0]?.text).toBe("Loaded message body"); + }); + + it("preserves omitted thread details while accepting empty authoritative focused details", () => { + const initialState = makeState(makeThread()); + const firstThread = makeReadModelThread({ + id: ThreadId.makeUnsafe("thread-1"), + messages: [ + { + id: MessageId.makeUnsafe("message-1"), + turnId: null, + role: "assistant", + text: "Keep this already loaded body", + streaming: false, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + attachments: [], + }, + ], + }); + const secondThread = makeReadModelThread({ + id: ThreadId.makeUnsafe("thread-2"), + title: "Focused thread", + messages: [ + { + id: MessageId.makeUnsafe("message-2"), + turnId: null, + role: "assistant", + text: "Replace this with the focused snapshot result", + streaming: false, + createdAt: "2026-02-27T00:00:01.000Z", + updatedAt: "2026-02-27T00:00:01.000Z", + attachments: [], + }, + ], + }); + const hydrated = syncServerReadModel(initialState, { + ...makeReadModel(firstThread), + threads: [firstThread, secondThread], + }); + const focusedSnapshot = { + ...makeReadModel(firstThread), + threads: [ + { ...firstThread, messages: [] }, + { ...secondThread, messages: [] }, + ], + } satisfies OrchestrationReadModel; + + const next = syncServerReadModel(hydrated, focusedSnapshot, { + authoritativeThreadDetailIds: new Set([ThreadId.makeUnsafe("thread-2")]), + preserveThreadDetails: true, + }); + + expect(next.threads[0]?.messages).toBe(hydrated.threads[0]?.messages); + expect(next.threads[0]?.messages[0]?.text).toBe("Keep this already loaded body"); + expect(next.threads[1]?.messages).toEqual([]); + }); + it("reuses unchanged threads and messages when another thread changes", () => { const initialState = makeState(makeThread()); const firstThread = makeReadModelThread({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index e6c6d0eddbd..1290c5333f3 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -39,6 +39,11 @@ export interface AppState { hydrationError: string | null; } +export interface SyncServerReadModelOptions { + readonly preserveThreadDetails?: boolean; + readonly authoritativeThreadDetailIds?: ReadonlySet; +} + const PERSISTED_STATE_KEY = "t3code:renderer-state:v8"; const LEGACY_PERSISTED_STATE_KEYS = [ "t3code:renderer-state:v6", @@ -501,7 +506,11 @@ function sanitizeSubagentThreadTitle(title: string, parentThreadId: ThreadId | n // ── Pure state transition functions ──────────────────────────────────── -export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { +export function syncServerReadModel( + state: AppState, + readModel: OrchestrationReadModel, + options: SyncServerReadModelOptions = {}, +): AppState { const projects = reuseArrayByIndex( state.projects, mapProjectsFromReadModel( @@ -549,62 +558,78 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea .map((thread) => { const existing = existingThreadById.get(thread.id); const parentThreadId = thread.parentThreadId ?? null; - const messages = reuseArrayByIndex( - existing?.messages ?? [], - thread.messages.map((message) => { - const attachments = message.attachments?.map((attachment) => - toPreviewableChatAttachment(attachment), - ); - const normalizedMessage: ChatMessage = { - id: message.id, - role: message.role, - text: message.text, - ...(message.turnId ? { turnId: message.turnId } : {}), - createdAt: message.createdAt, - streaming: message.streaming, - ...(message.streaming ? {} : { completedAt: message.updatedAt }), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - }; - return normalizedMessage; - }), - equalMessage, - ); - const proposedPlans = reuseArrayByIndex( - existing?.proposedPlans ?? [], - thread.proposedPlans.map((proposedPlan) => ({ - id: proposedPlan.id, - turnId: proposedPlan.turnId, - planMarkdown: proposedPlan.planMarkdown, - createdAt: proposedPlan.createdAt, - updatedAt: proposedPlan.updatedAt, - })), - equalProposedPlan, - ); + const preserveThreadDetails = + options.preserveThreadDetails === true && + existing !== undefined && + options.authoritativeThreadDetailIds?.has(thread.id) !== true; + const messages = + preserveThreadDetails && thread.messages.length === 0 + ? existing.messages + : reuseArrayByIndex( + existing?.messages ?? [], + thread.messages.map((message) => { + const attachments = message.attachments?.map((attachment) => + toPreviewableChatAttachment(attachment), + ); + const normalizedMessage: ChatMessage = { + id: message.id, + role: message.role, + text: message.text, + ...(message.turnId ? { turnId: message.turnId } : {}), + createdAt: message.createdAt, + streaming: message.streaming, + ...(message.streaming ? {} : { completedAt: message.updatedAt }), + ...(attachments && attachments.length > 0 ? { attachments } : {}), + }; + return normalizedMessage; + }), + equalMessage, + ); + const proposedPlans = + preserveThreadDetails && thread.proposedPlans.length === 0 + ? existing.proposedPlans + : reuseArrayByIndex( + existing?.proposedPlans ?? [], + thread.proposedPlans.map((proposedPlan) => ({ + id: proposedPlan.id, + turnId: proposedPlan.turnId, + planMarkdown: proposedPlan.planMarkdown, + createdAt: proposedPlan.createdAt, + updatedAt: proposedPlan.updatedAt, + })), + equalProposedPlan, + ); const existingTurnDiffSummariesByTurnId = new Map( existing?.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), ); - const turnDiffSummaries = reuseArrayByIndex( - existing?.turnDiffSummaries ?? [], - thread.checkpoints.map((checkpoint) => ({ - turnId: checkpoint.turnId, - completedAt: checkpoint.completedAt, - status: checkpoint.status, - assistantMessageId: checkpoint.assistantMessageId ?? undefined, - checkpointTurnCount: checkpoint.checkpointTurnCount, - checkpointRef: checkpoint.checkpointRef, - files: reuseArrayByIndex( - existingTurnDiffSummariesByTurnId.get(checkpoint.turnId)?.files ?? [], - checkpoint.files.map((file) => ({ ...file })), - equalTurnDiffFile, - ), - })), - equalTurnDiffSummary, - ); - const activities = reuseArrayByIndex( - existing?.activities ?? [], - thread.activities.map((activity) => ({ ...activity })), - equalActivity, - ); + const turnDiffSummaries = + preserveThreadDetails && thread.checkpoints.length === 0 + ? existing.turnDiffSummaries + : reuseArrayByIndex( + existing?.turnDiffSummaries ?? [], + thread.checkpoints.map((checkpoint) => ({ + turnId: checkpoint.turnId, + completedAt: checkpoint.completedAt, + status: checkpoint.status, + assistantMessageId: checkpoint.assistantMessageId ?? undefined, + checkpointTurnCount: checkpoint.checkpointTurnCount, + checkpointRef: checkpoint.checkpointRef, + files: reuseArrayByIndex( + existingTurnDiffSummariesByTurnId.get(checkpoint.turnId)?.files ?? [], + checkpoint.files.map((file) => ({ ...file })), + equalTurnDiffFile, + ), + })), + equalTurnDiffSummary, + ); + const activities = + preserveThreadDetails && thread.activities.length === 0 + ? existing.activities + : reuseArrayByIndex( + existing?.activities ?? [], + thread.activities.map((activity) => ({ ...activity })), + equalActivity, + ); const normalizedThread: Thread = { id: thread.id, codexThreadId: null, @@ -834,7 +859,10 @@ export function setThreadBranch( // ── Zustand store ──────────────────────────────────────────────────── interface AppStore extends AppState { - syncServerReadModel: (readModel: OrchestrationReadModel) => void; + syncServerReadModel: ( + readModel: OrchestrationReadModel, + options?: SyncServerReadModelOptions, + ) => void; setHydrationStatus: (status: AppState["hydrationStatus"]) => void; setHydrationError: (error: string | null) => void; syncErrorInbox: (entries: ReadonlyArray) => void; @@ -851,7 +879,8 @@ interface AppStore extends AppState { export const useStore = create((set) => ({ ...readPersistedState(), - syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), + syncServerReadModel: (readModel, options) => + set((state) => syncServerReadModel(state, readModel, options)), setHydrationStatus: (status) => set((state) => setHydrationStatus(state, status)), setHydrationError: (error) => set((state) => setHydrationError(state, error)), syncErrorInbox: (entries) => set((state) => syncErrorInbox(state, entries)), diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 49bc1fca645..fe236e86cac 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -2,6 +2,8 @@ import { type ClientOrchestrationCommand, OrchestrationEvent, type OrchestrationCommandReceiptResult, + type OrchestrationGetSnapshotInput, + type OrchestrationReadModel, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, type ContextMenuItem, @@ -113,6 +115,21 @@ async function dispatchCommandWithReceiptRecovery( } } +function snapshotRequestKey(input?: OrchestrationGetSnapshotInput): string { + if (input?.mode !== "focused") { + return JSON.stringify(input ?? {}); + } + + const threadIds = [ + ...new Set( + [input.threadId, ...(input.threadIds ?? [])].filter( + (threadId): threadId is NonNullable => threadId !== undefined, + ), + ), + ].toSorted(); + return JSON.stringify({ mode: "focused", threadIds }); +} + /** * Subscribe to the server welcome message. If a welcome was already received * before this call, the listener fires synchronously with the cached payload. @@ -201,6 +218,27 @@ export function createWsNativeApi(options?: { if (instance) return instance.api; const transport = new WsTransport(options?.url, options?.authProvider); + const inFlightSnapshotRequests = new Map>(); + + const getOrchestrationSnapshot = ( + input?: OrchestrationGetSnapshotInput, + ): Promise => { + const key = snapshotRequestKey(input); + const existing = inFlightSnapshotRequests.get(key); + if (existing) { + return existing; + } + + const request = transport + .request(ORCHESTRATION_WS_METHODS.getSnapshot, input) + .finally(() => { + if (inFlightSnapshotRequests.get(key) === request) { + inFlightSnapshotRequests.delete(key); + } + }); + inFlightSnapshotRequests.set(key, request); + return request; + }; // Listen for server welcome and forward to registered listeners. // Also cache it so late subscribers (React effects) get it immediately. @@ -383,7 +421,7 @@ export function createWsNativeApi(options?: { prewarmSession: (input) => transport.request(WS_METHODS.providerPrewarmSession, input), }, orchestration: { - getSnapshot: (input) => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot, input), + getSnapshot: getOrchestrationSnapshot, dispatchCommand: (command) => dispatchCommandWithReceiptRecovery(transport, command), getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input), getFullThreadDiff: (input) => From 7eaa733c438a8efe8cf942c9340677068c1ef598 Mon Sep 17 00:00:00 2001 From: Addis Date: Thu, 11 Jun 2026 09:09:29 -0500 Subject: [PATCH 2/2] Address CodeRabbit review feedback --- apps/web/src/components/ChatView.tsx | 27 +++++++++++++---- apps/web/src/components/Sidebar.tsx | 12 +++++++- .../src/components/chat/MessagesTimeline.tsx | 5 ++-- apps/web/src/routes/_chat.$threadId.tsx | 30 +++++++++++-------- apps/web/src/splitViewStore.test.ts | 4 +-- 5 files changed, 55 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 409bcba8c27..9cf7e7d1af4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -341,6 +341,7 @@ const SCRIPT_TERMINAL_ROWS = 30; const WORKTREE_BRANCH_PREFIX = "t3code"; const HEADER_COMPACT_BREAKPOINT = 480; const DESKTOP_APP_RESOLUTION_TIMEOUT_MS = 2_500; +const POINTER_SCROLL_INTENT_THRESHOLD_PX = 6; function isTerminalUserInputSubmitFailure(error: unknown): boolean { const message = @@ -1807,6 +1808,7 @@ export default function ChatView({ const shouldAutoScrollRef = useRef(true); const lastKnownScrollTopRef = useRef(0); const isPointerScrollActiveRef = useRef(false); + const pointerScrollStartYRef = useRef(null); const lastTouchClientYRef = useRef(null); const pendingUserScrollUpIntentRef = useRef(false); const pendingAutoScrollFrameRef = useRef(null); @@ -3609,15 +3611,23 @@ export default function ChatView({ }, [cancelPendingStickToBottom], ); - const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = true; - }, []); - const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { + const clearPointerScrollIntent = useCallback(() => { + pointerScrollStartYRef.current = null; isPointerScrollActiveRef.current = false; }, []); - const onMessagesPointerCancel = useCallback((_event: React.PointerEvent) => { + const onMessagesPointerDown = useCallback((event: React.PointerEvent) => { + pointerScrollStartYRef.current = event.clientY; isPointerScrollActiveRef.current = false; }, []); + const onMessagesPointerMove = useCallback((event: React.PointerEvent) => { + const startY = pointerScrollStartYRef.current; + if (startY === null) return; + if (Math.abs(event.clientY - startY) >= POINTER_SCROLL_INTENT_THRESHOLD_PX) { + isPointerScrollActiveRef.current = true; + } + }, []); + const onMessagesPointerUp = clearPointerScrollIntent; + const onMessagesPointerCancel = clearPointerScrollIntent; const onMessagesTouchStart = useCallback((event: React.TouchEvent) => { const touch = event.touches[0]; if (!touch) return; @@ -5259,7 +5269,10 @@ export default function ChatView({ ) .then(() => api.orchestration.getSnapshot({ mode: "focused", threadId: nextThreadId })) .then((snapshot) => { - syncServerReadModel(snapshot); + syncServerReadModel(snapshot, { + authoritativeThreadDetailIds: new Set([nextThreadId]), + preserveThreadDetails: true, + }); return navigate({ to: "/$threadId", params: { threadId: nextThreadId }, @@ -6338,8 +6351,10 @@ export default function ChatView({ onClickCapture={onMessagesClickCapture} onWheel={onMessagesWheel} onPointerDown={onMessagesPointerDown} + onPointerMove={onMessagesPointerMove} onPointerUp={onMessagesPointerUp} onPointerCancel={onMessagesPointerCancel} + onPointerLeave={onMessagesPointerCancel} onTouchStart={onMessagesTouchStart} onTouchMove={onMessagesTouchMove} onTouchEnd={onMessagesTouchEnd} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a91a76b2506..b6cafc5b13f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3226,12 +3226,20 @@ export default function Sidebar() { clearArchiveConfirm(thread.id)} + onMouseEnter={() => scheduleThreadDetailPrefetch(thread)} + onMouseLeave={() => { + cancelThreadDetailPrefetch(thread.id); + clearArchiveConfirm(thread.id); + }} + onFocusCapture={() => { + scheduleThreadDetailPrefetch(thread); + }} onBlurCapture={(event: FocusEvent) => { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { return; } + cancelThreadDetailPrefetch(thread.id); clearArchiveConfirm(thread.id); }} > @@ -3388,6 +3396,7 @@ export default function Sidebar() { }, [ archiveConfirmThreadId, + cancelThreadDetailPrefetch, clearArchiveConfirm, handleInlineArchiveConfirm, expandedSubagentParentIds, @@ -3397,6 +3406,7 @@ export default function Sidebar() { pendingApprovalByThreadId, pendingUserInputByThreadId, routeThreadId, + scheduleThreadDetailPrefetch, toggleSubagentParent, ], ); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 08e08914dc6..9e6bdfff62c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3691,10 +3691,11 @@ export const MessagesTimeline = memo(function MessagesTimeline(props: MessagesTi }, [rowVirtualizer, timelineWidthPx]); useEffect(() => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { + rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; const scrollOffset = instance.scrollOffset ?? 0; - const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); + const previousTotalSize = instance.getTotalSize() - delta; + const remainingDistance = previousTotalSize - (scrollOffset + viewportHeight); const isReadingOlderHistory = remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; const changedItemIsAboveViewport = item.start < scrollOffset; diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index ce9fda96f5d..a6e7187d012 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -989,13 +989,14 @@ function SplitChatSurface(props: { splitViewId: SplitViewId; routeThreadId: Thre params: { threadId: nextThreadId }, replace: true, search: () => { + const filesSearch = focusedPanelState.filesOpen ? { files: "1" as const } : {}; if (focusedPanelState.panel === "browser") { - return { panel: "browser" as const }; + return { ...filesSearch, panel: "browser" as const }; } if (focusedPanelState.panel === "diff") { - return { panel: "diff" as const, diff: "1" as const }; + return { ...filesSearch, panel: "diff" as const, diff: "1" as const }; } - return {}; + return filesSearch; }, }); }, [activeSplitView, focusedThreadId, navigate, removeSplitView]); @@ -1078,9 +1079,10 @@ function SplitChatSurface(props: { splitViewId: SplitViewId; routeThreadId: Thre currentPaneProjectId !== null && nextPaneProjectId !== null && currentPaneProjectId !== nextPaneProjectId; + const shouldResetPanelState = projectChanged || currentPaneThreadId === null; replacePaneThread(activeSplitView.id, pane, threadId); setPanePanelState(activeSplitView.id, pane, { - ...(projectChanged ? { panel: null, filesOpen: false } : {}), + ...(shouldResetPanelState ? { panel: null, filesOpen: false } : {}), diffTurnId: null, diffFilePath: null, }); @@ -1485,7 +1487,12 @@ function ChatThreadRouteView() { : [threadId]; return [...new Set(threadIds.filter((candidate): candidate is ThreadId => candidate !== null))]; }, [search.splitViewId, splitView, threadId]); - const focusedSnapshotNeedsThreadDetails = focusedSnapshotThreadIds.some((candidate) => { + const focusedSnapshotServerThreadIds = useMemo( + () => focusedSnapshotThreadIds.filter((candidate) => threads.some((entry) => entry.id === candidate)), + [focusedSnapshotThreadIds, threads], + ); + const focusedSnapshotPrimaryThreadId = focusedSnapshotServerThreadIds[0] ?? null; + const focusedSnapshotNeedsThreadDetails = focusedSnapshotServerThreadIds.some((candidate) => { const thread = threads.find((entry) => entry.id === candidate); return thread !== undefined && thread.latestTurn !== null && thread.messages.length === 0; }); @@ -1600,7 +1607,7 @@ function ChatThreadRouteView() { useEffect(() => { if ( !threadsHydrated || - !threadExists || + focusedSnapshotPrimaryThreadId === null || hydrationStatus !== "ready" || !focusedSnapshotNeedsThreadDetails ) { @@ -1616,13 +1623,13 @@ function ChatThreadRouteView() { void api.orchestration .getSnapshot({ mode: "focused", - threadId, - threadIds: focusedSnapshotThreadIds, + threadId: focusedSnapshotPrimaryThreadId, + threadIds: focusedSnapshotServerThreadIds, }) .then((snapshot) => { if (!disposed) { syncServerReadModel(snapshot, { - authoritativeThreadDetailIds: new Set(focusedSnapshotThreadIds), + authoritativeThreadDetailIds: new Set(focusedSnapshotServerThreadIds), preserveThreadDetails: true, }); } @@ -1634,11 +1641,10 @@ function ChatThreadRouteView() { }; }, [ focusedSnapshotNeedsThreadDetails, - focusedSnapshotThreadIds, + focusedSnapshotPrimaryThreadId, + focusedSnapshotServerThreadIds, hydrationStatus, syncServerReadModel, - threadExists, - threadId, threadsHydrated, ]); diff --git a/apps/web/src/splitViewStore.test.ts b/apps/web/src/splitViewStore.test.ts index 9a8d163317b..3296fc46d3d 100644 --- a/apps/web/src/splitViewStore.test.ts +++ b/apps/web/src/splitViewStore.test.ts @@ -1,7 +1,7 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -function createMemoryStorage(): Storage { +function createMemoryStorage() { const values = new Map(); return { @@ -23,7 +23,7 @@ function createMemoryStorage(): Storage { setItem(key: string, value: string) { values.set(key, value); }, - }; + } satisfies Storage; } describe("useSplitViewStore", () => {