From be73f95fa01e32885f28d1c6395fcb8f8f369c9a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 08:27:02 +0000 Subject: [PATCH 1/3] fix(web): keep thread switches correct and warm lazy panels Rebuild the interface work from PR #18979 against current main. Preserve author credit for the original performance exploration. Co-authored-by: maria-rcks --- .../web/src/components/ChatView.logic.test.ts | 68 ++------- apps/web/src/components/ChatView.logic.ts | 123 +++------------ apps/web/src/components/ChatView.tsx | 143 +++++------------- .../components/ComposerPromptEditorTiptap.tsx | 42 +++-- apps/web/src/components/DiffPanel.tsx | 4 + .../src/components/DiffWorkerPoolProvider.tsx | 30 ++-- .../components/PersistentThreadTerminals.tsx | 11 +- apps/web/src/components/Sidebar.logic.test.ts | 16 +- apps/web/src/components/Sidebar.logic.ts | 18 ++- apps/web/src/components/Sidebar.tsx | 33 ++-- apps/web/src/components/chat/ChatComposer.tsx | 10 ++ .../src/components/chat/MessagesTimeline.tsx | 54 +++---- .../components/chat/PanelLayoutControls.tsx | 3 + .../chat/RightPanelSurfaceContent.tsx | 17 +-- .../chat/useComposerMultilinePrompt.ts | 11 +- apps/web/src/components/lazySurfaces.ts | 63 ++++++++ apps/web/src/lib/chunkReloadGuard.test.ts | 80 +++++++++- apps/web/src/lib/chunkReloadGuard.ts | 64 +++++++- apps/web/src/main.tsx | 29 ++-- apps/web/src/markdown-incremental.test.tsx | 14 +- apps/web/src/markdown-incremental.ts | 6 +- apps/web/src/terminal/ghostty/surface.ts | 16 +- 22 files changed, 468 insertions(+), 387 deletions(-) create mode 100644 apps/web/src/components/lazySurfaces.ts diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index b4cc66e2b2e74..8115a4ac72c17 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -59,13 +59,10 @@ import { resolveDraftHeroState, resolveExplicitDiffOpenOnActivate, resolveWorktreeSetupProgress, - isPaintOnlyThreadTimeline, - peekHeldThreadTimeline, peekRememberedThreadTimeline, rememberReadyThreadTimeline, - resetHeldThreadTimeline, + resetRememberedThreadTimelines, resolveThreadSwitchTimeline, - threadKeysShareEnvironment, timelineHasEphemeralPreviewUrls, scheduleEnvironmentReconnectWarning, codexArtifactTemplatePromptToAppend, @@ -1038,31 +1035,31 @@ describe("draft hero submission transition", () => { describe("resolveThreadSwitchTimeline", () => { afterEach(() => { - resetHeldThreadTimeline(); + resetRememberedThreadTimelines(); }); const held = { threadKey: "env-1:thread-a", entries: ["a1", "a2"] }; - it("keeps the previous thread's entries while the next thread is loading", () => { + it("leaves the destination empty while its first snapshot is loading", () => { + rememberReadyThreadTimeline(held); expect( resolveThreadSwitchTimeline({ loading: true, activeThreadKey: "env-1:thread-b", nextEntries: [], - lastReady: held, }), - ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); + ).toEqual([]); }); it("shows the new thread once its detail is ready", () => { + rememberReadyThreadTimeline(held); expect( resolveThreadSwitchTimeline({ loading: false, activeThreadKey: "env-1:thread-b", nextEntries: ["b1"], - lastReady: held, }), - ).toEqual({ entries: ["b1"], displayThreadKey: "env-1:thread-b" }); + ).toEqual(["b1"]); }); it("does not invent a timeline on the first open of a thread", () => { @@ -1071,34 +1068,8 @@ describe("resolveThreadSwitchTimeline", () => { loading: true, activeThreadKey: "env-1:thread-a", nextEntries: [], - lastReady: null, - }), - ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); - }); - - it("keeps the held thread workspace cwd with the snapshot", () => { - rememberReadyThreadTimeline({ - ...held, - markdownCwd: "/repo/a", - workspaceRoot: "/repo/a", - }); - expect(peekHeldThreadTimeline()).toEqual({ - ...held, - markdownCwd: "/repo/a", - workspaceRoot: "/repo/a", - }); - }); - - it("survives a ChatView remount by remembering the last ready timeline", () => { - rememberReadyThreadTimeline(held); - expect(peekHeldThreadTimeline()).toEqual(held); - expect( - resolveThreadSwitchTimeline({ - loading: true, - activeThreadKey: "env-1:thread-b", - nextEntries: [], }), - ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); + ).toEqual([]); }); it("paints a remembered destination instead of the last-viewed thread", () => { @@ -1111,7 +1082,7 @@ describe("resolveThreadSwitchTimeline", () => { activeThreadKey: "env-1:thread-a", nextEntries: [], }), - ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); + ).toEqual(["a1", "a2"]); }); it("prefers live entries over a remembered snapshot", () => { @@ -1122,7 +1093,7 @@ describe("resolveThreadSwitchTimeline", () => { activeThreadKey: "env-1:thread-b", nextEntries: ["fresh-b"], }), - ).toEqual({ entries: ["fresh-b"], displayThreadKey: "env-1:thread-b" }); + ).toEqual(["fresh-b"]); }); it("does not keep a remembered snapshot on a resolved empty thread", () => { @@ -1133,24 +1104,7 @@ describe("resolveThreadSwitchTimeline", () => { activeThreadKey: "env-1:thread-a", nextEntries: [], }), - ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); - }); - - it("does not hold another environment's timeline across a jump", () => { - expect(threadKeysShareEnvironment("env-1:thread-a", "env-2:thread-b")).toBe(false); - expect( - resolveThreadSwitchTimeline({ - loading: true, - activeThreadKey: "env-2:thread-b", - nextEntries: [], - lastReady: held, - }), - ).toEqual({ entries: [], displayThreadKey: "env-2:thread-b" }); - }); - - it("treats a foreign held timeline as paint-only", () => { - expect(isPaintOnlyThreadTimeline("env-1:thread-a", "env-1:thread-b")).toBe(true); - expect(isPaintOnlyThreadTimeline("env-1:thread-b", "env-1:thread-b")).toBe(false); + ).toEqual([]); }); it("does not remember a timeline that still has handoff blob previews", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index f14ce8cf71ab8..5f9a1a291b35f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -28,7 +28,6 @@ import { } from "@t3tools/contracts"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import * as DateTime from "effect/DateTime"; -import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure, @@ -320,126 +319,40 @@ export function resolveDraftHeroState(input: { ); } -/** - * Remounting LegendList (or handing it an empty first paint) punches a hole - * through the chat pane — white in light mode — so cmd+1/2/3 spam flashes even - * when the destination is already cached. - */ -export type HeldThreadTimeline = { - threadKey: string | null; - entries: T; - markdownCwd?: string | null; - workspaceRoot?: string | null; -}; - const MAX_REMEMBERED_THREAD_TIMELINES = 16; +const rememberedThreadTimelines = new Map(); -let rememberedThreadTimelines = new Map>(); -let rememberedThreadTimelineOrder: string[] = []; -let lastReadyThreadKey: string | null = null; - -function rememberThreadTimelineEntries(held: HeldThreadTimeline): void { - if (held.threadKey === null) { - return; - } - rememberedThreadTimelines.set(held.threadKey, held); - rememberedThreadTimelineOrder = [ - ...rememberedThreadTimelineOrder.filter((key) => key !== held.threadKey), - held.threadKey, - ]; - while (rememberedThreadTimelineOrder.length > MAX_REMEMBERED_THREAD_TIMELINES) { - const evicted = rememberedThreadTimelineOrder.shift(); - if (evicted !== undefined) { - rememberedThreadTimelines.delete(evicted); - } - } - lastReadyThreadKey = held.threadKey; -} - -export function rememberReadyThreadTimeline( - held: HeldThreadTimeline, -): void { - if (held.threadKey === null || held.entries.length === 0) { - return; +export function rememberReadyThreadTimeline(input: { + threadKey: string | null; + entries: T; +}): void { + if (input.threadKey === null || input.entries.length === 0) return; + rememberedThreadTimelines.delete(input.threadKey); + rememberedThreadTimelines.set(input.threadKey, input.entries); + if (rememberedThreadTimelines.size > MAX_REMEMBERED_THREAD_TIMELINES) { + const oldest = rememberedThreadTimelines.keys().next().value; + if (oldest !== undefined) rememberedThreadTimelines.delete(oldest); } - rememberThreadTimelineEntries(held); } export function peekRememberedThreadTimeline( threadKey: string | null, ): T | null { - if (threadKey === null) { - return null; - } - return (rememberedThreadTimelines.get(threadKey)?.entries as T | undefined) ?? null; + if (threadKey === null) return null; + return (rememberedThreadTimelines.get(threadKey) as T | undefined) ?? null; } -export function peekHeldThreadTimeline< - T extends readonly unknown[], ->(): HeldThreadTimeline | null { - if (lastReadyThreadKey === null) { - return null; - } - const held = rememberedThreadTimelines.get(lastReadyThreadKey); - if (held === undefined || held.entries.length === 0) { - return null; - } - return held as HeldThreadTimeline; -} - -export function resetHeldThreadTimeline(): void { - rememberedThreadTimelines = new Map(); - rememberedThreadTimelineOrder = []; - lastReadyThreadKey = null; -} - -export function threadKeysShareEnvironment(left: string | null, right: string | null): boolean { - if (left === null || right === null) { - return false; - } - const leftRef = parseScopedThreadKey(left); - const rightRef = parseScopedThreadKey(right); - return leftRef !== null && rightRef !== null && leftRef.environmentId === rightRef.environmentId; -} - -export function isPaintOnlyThreadTimeline( - displayThreadKey: string | null, - activeThreadKey: string | null, -): boolean { - return ( - displayThreadKey !== null && activeThreadKey !== null && displayThreadKey !== activeThreadKey - ); +export function resetRememberedThreadTimelines(): void { + rememberedThreadTimelines.clear(); } export function resolveThreadSwitchTimeline(input: { loading: boolean; activeThreadKey: string | null; nextEntries: T; - rememberedForActive?: T | null; - lastReady?: HeldThreadTimeline | null; -}): { entries: T; displayThreadKey: string | null } { - if (input.nextEntries.length > 0) { - return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; - } - - const rememberedForActive = - input.rememberedForActive ?? peekRememberedThreadTimeline(input.activeThreadKey); - if (input.loading && rememberedForActive !== null && rememberedForActive.length > 0) { - return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey }; - } - - const lastReady = input.lastReady ?? peekHeldThreadTimeline(); - if ( - input.loading && - lastReady !== null && - lastReady.threadKey !== null && - lastReady.threadKey !== input.activeThreadKey && - lastReady.entries.length > 0 && - threadKeysShareEnvironment(lastReady.threadKey, input.activeThreadKey) - ) { - return { entries: lastReady.entries, displayThreadKey: lastReady.threadKey }; - } - return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; +}): T { + if (input.nextEntries.length > 0 || !input.loading) return input.nextEntries; + return peekRememberedThreadTimeline(input.activeThreadKey) ?? input.nextEntries; } export function resolveDraftPromotionNavigationTarget(input: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 98b3172d0c6e0..f37f04b471aa1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -13,9 +13,6 @@ import { restorePlanFollowUpComposer } from "./ChatView.logic"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { useQueuedRunEdit } from "./chat/useQueuedRunEdit"; import { - isPaintOnlyThreadTimeline, - peekHeldThreadTimeline, - peekRememberedThreadTimeline, rememberReadyThreadTimeline, resolveExplicitDiffOpenOnActivate, resolveThreadSwitchTimeline, @@ -962,10 +959,6 @@ function chatActionErrorMessage(error: unknown): string { } const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; -const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = []; -const noopHeldTurnDiff = (_turnId: RunId, _filePath?: string) => {}; -const noopHeldRevert = (_targetTurnCount: number) => {}; -const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; function releaseChatTimelineAnchor( current: T, @@ -2826,18 +2819,11 @@ export default function ChatView(props: ChatViewProps) { const timelineMessages = timelineEntries.flatMap((entry) => entry.kind === "message" ? [entry.message] : [], ); - const displayedTimeline = resolveThreadSwitchTimeline({ + const displayedTimelineEntries = resolveThreadSwitchTimeline({ loading: timelineEntries.length === 0 && threadSyncPhase !== null, activeThreadKey, nextEntries: timelineEntries, - rememberedForActive: peekRememberedThreadTimeline(activeThreadKey), }); - const displayedTimelineKey = displayedTimeline.displayThreadKey ?? routeThreadKey; - const paintOnlyDisplayedTimeline = isPaintOnlyThreadTimeline( - displayedTimeline.displayThreadKey, - activeThreadKey, - ); - const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey); const { worktreeSetup, isPreparingWorktree, @@ -3004,13 +2990,8 @@ export default function ChatView(props: ChatViewProps) { rememberReadyThreadTimeline({ threadKey: activeThreadKey, entries: timelineEntries, - markdownCwd: gitCwd, - workspaceRoot: activeWorkspaceRoot ?? null, }); - }, [activeThreadKey, activeWorkspaceRoot, gitCwd, threadDetailLoading, timelineEntries]); - const heldPaintContext = paintOnlyDisplayedTimeline - ? peekHeldThreadTimeline() - : null; + }, [activeThreadKey, threadDetailLoading, timelineEntries]); const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; const liveIsGitRepo = gitStatusQuery.data?.isRepo; @@ -7660,7 +7641,7 @@ export default function ChatView(props: ChatViewProps) { readonly checkpointId: string; readonly scopeId: string; }) => { - if (!paintOnlyDisplayedTimeline) void onRollbackCheckpoint(input); + void onRollbackCheckpoint(input); }; const onTimelineRollbackCheckpointStable = useStableCallback(onTimelineRollbackCheckpoint); const onRevertTimelineTurn = (targetTurnCount: number, messageId: MessageId) => { @@ -7897,8 +7878,7 @@ export default function ChatView(props: ChatViewProps) { skills: timelineSkills, progressive: serverConfig?.threadFindProgressive === true, thread: activeThreadRef, - enabled: - isServerThread && serverConfig?.threadFind === true && !paintOnlyDisplayedTimeline, + enabled: isServerThread && serverConfig?.threadFind === true, content: serverProjection ?? undefined, }} controlsRef={threadFindControlsRef} @@ -7948,43 +7928,32 @@ export default function ChatView(props: ChatViewProps) {
@@ -8000,65 +7969,37 @@ export default function ChatView(props: ChatViewProps) { ) } listRef={legendListRef} - timelineEntries={displayedTimeline.entries} - runs={paintOnlyDisplayedTimeline ? [] : (serverProjection?.runs ?? [])} - latestRun={paintOnlyDisplayedTimeline ? null : activeActivityRun} - runningRunId={paintOnlyDisplayedTimeline ? null : activeRunningTurnId} - turnDiffSummaries={ - paintOnlyDisplayedTimeline ? EMPTY_HELD_TURN_DIFF_SUMMARIES : turnDiffSummaries - } - activeThreadEnvironmentId={ - displayedThreadRef?.environmentId ?? activeThread.environmentId - } - routeThreadKey={displayedTimelineKey} - displayThreadKey={displayedTimelineKey} - paintOnly={paintOnlyDisplayedTimeline} - onOpenTurnDiff={ - paintOnlyDisplayedTimeline ? noopHeldTurnDiff : onOpenTurnDiffStable - } + timelineEntries={displayedTimelineEntries} + runs={serverProjection?.runs ?? []} + latestRun={activeActivityRun} + runningRunId={activeRunningTurnId} + turnDiffSummaries={turnDiffSummaries} + activeThreadEnvironmentId={activeThread.environmentId} + routeThreadKey={activeThreadKey ?? routeThreadKey} + onOpenTurnDiff={onOpenTurnDiffStable} onOpenThread={onOpenRelatedThreadStable} - parentThreadLink={paintOnlyDisplayedTimeline ? null : parentThreadLink} - workflowOpening={paintOnlyDisplayedTimeline ? null : workflowOpening} - onForkFromRun={paintOnlyDisplayedTimeline ? async () => {} : onForkFromRunStable} + parentThreadLink={parentThreadLink} + workflowOpening={workflowOpening} + onForkFromRun={onForkFromRunStable} onRollbackCheckpoint={onTimelineRollbackCheckpointStable} - supportsConversationRollback={ - !paintOnlyDisplayedTimeline && supportsConversationRollback - } - onRevertToTurnCount={ - paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurnStable - } - {...(!paintOnlyDisplayedTimeline - ? { - onUseArtifactTemplate: insertArtifactTemplateStable, - onSendAppMessage: sendAppMessageStable, - } - : {})} + supportsConversationRollback={supportsConversationRollback} + onRevertToTurnCount={onRevertTimelineTurnStable} + onUseArtifactTemplate={insertArtifactTemplateStable} + onSendAppMessage={sendAppMessageStable} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImageStable} - onFileOpen={ - paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachmentStable - } - onFileDownload={ - paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachmentStable - } - markdownCwd={ - paintOnlyDisplayedTimeline - ? (heldPaintContext?.markdownCwd ?? undefined) - : (gitCwd ?? undefined) - } + onFileOpen={openFileAttachmentStable} + onFileDownload={downloadFileAttachmentStable} + markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} - workspaceRoot={ - paintOnlyDisplayedTimeline - ? (heldPaintContext?.workspaceRoot ?? undefined) - : activeWorkspaceRoot - } + workspaceRoot={activeWorkspaceRoot} skills={timelineSkills} - anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId} + anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReadyStable} onAnchorSizeChanged={onTimelineAnchorSizeChangedStable} contentInsetEndAdjustment={composerTimelineInset} - liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} + liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChangeStable} onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEndStable} @@ -8066,7 +8007,7 @@ export default function ChatView(props: ChatViewProps) { cancelPositionRestoreRef={cancelPositionRestoreRef} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} - {...(paintOnlyDisplayedTimeline || threadHistoryControls === undefined + {...(threadHistoryControls === undefined ? {} : { historyControls: threadHistoryControls })} /> diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 14562f73cd473..151ca4c3dd934 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -910,13 +910,20 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { editorRef, } = props; const richText = !literalText && (richTextEnabled ?? false); - const clampEditorCursor = (text: string, position: number) => - literalText - ? Math.max( - 0, - Math.min(text.length, Number.isFinite(position) ? Math.floor(position) : text.length), - ) - : clampCollapsedComposerCursor(text, position); + const cursorBoundsRef = useRef({ value: "", length: 0 }); + const clampEditorCursor = (text: string, position: number) => { + if (!literalText && cursorBoundsRef.current.value !== text) { + // Selection reads revisit the same prompt; only inline tokens change its cursor bound. + cursorBoundsRef.current = { + value: text, + length: /[@[\p{Sc}]/u.test(text) + ? clampCollapsedComposerCursor(text, Infinity) + : text.length, + }; + } + const length = literalText ? text.length : cursorBoundsRef.current.length; + return Math.max(0, Math.min(length, Number.isFinite(position) ? Math.floor(position) : length)); + }; const expandEditorCursor = (text: string, position: number) => literalText ? position : expandCollapsedComposerCursor(text, position); @@ -965,15 +972,20 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { }; }); - const initialCursor = clampEditorCursor(value, cursor); - const initialExpandedCursor = expandEditorCursor(value, initialCursor); - const snapshotRef = useRef({ - value, - cursor: initialCursor, - expandedCursor: initialExpandedCursor, - contextIds: literalText ? [] : collectInlineContextIds(value), + const [initialSnapshot] = useState(() => { + const initialCursor = clampCollapsedComposerCursor(value, cursor, literalText); + return { + value, + cursor: initialCursor, + expandedCursor: expandEditorCursor(value, initialCursor), + contextIds: literalText ? [] : collectInlineContextIds(value), + }; + }); + const snapshotRef = useRef(initialSnapshot); + const selectionRangeRef = useRef({ + start: initialSnapshot.expandedCursor, + end: initialSnapshot.expandedCursor, }); - const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); const isApplyingControlledUpdateRef = useRef(false); const hasAppliedControlledSelectionRef = useRef(false); const citationRequestRef = useRef(null); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index b4083eac66b13..392bfcae6ccc5 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,3 +1,4 @@ +import { preloadDiffWorkerPool } from "./DiffWorkerPoolProvider"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import type { FileDiffContentsLoader, FileDiffMetadata } from "@pierre/diffs"; @@ -196,6 +197,9 @@ export default function DiffPanel({ workspaceMutationId, }: DiffPanelProps) { const { resolvedTheme } = useTheme(); + useEffect(() => { + void preloadDiffWorkerPool(resolveDiffThemeName(resolvedTheme)).catch(() => undefined); + }, [resolvedTheme]); const settings = useClientSettings((settings) => ({ diffFilesCollapsed: settings.diffFilesCollapsed, diffIgnoreWhitespace: settings.diffIgnoreWhitespace, diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 71c3997dfbb0e..f4dd29dba363e 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -62,6 +62,26 @@ function acquireDiffWorkerPool(themeName: DiffThemeName, poolSize: number) { return entry; } +function releaseDiffWorkerPool(entry: NonNullable) { + entry.consumers -= 1; + if (entry.consumers !== 0) return; + entry.idleTimer = setTimeout(() => { + entry.idleTimer = undefined; + entry.pool.terminate(); + if (sharedWorkerPool === entry) sharedWorkerPool = undefined; + }, DIFF_WORKER_IDLE_TTL_MS); +} + +export async function preloadDiffWorkerPool(themeName: DiffThemeName): Promise { + if (typeof window === "undefined") return; + const entry = acquireDiffWorkerPool(themeName, resolveDiffWorkerPoolSize()); + try { + await entry.pool.initialize(); + } finally { + releaseDiffWorkerPool(entry); + } +} + function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { const workerPool = useWorkerPool(); @@ -134,15 +154,7 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { if (typeof window === "undefined") return () => {}; const entry = acquireDiffWorkerPool(diffThemeName, workerPoolSize); onStoreChange(); - return () => { - entry.consumers -= 1; - if (entry.consumers !== 0) return; - entry.idleTimer = setTimeout(() => { - entry.idleTimer = undefined; - entry.pool.terminate(); - if (sharedWorkerPool === entry) sharedWorkerPool = undefined; - }, DIFF_WORKER_IDLE_TTL_MS); - }; + return () => releaseDiffWorkerPool(entry); }); const workerPool = useSyncExternalStore( subscribe, diff --git a/apps/web/src/components/PersistentThreadTerminals.tsx b/apps/web/src/components/PersistentThreadTerminals.tsx index 44b40f83e7535..315c5e2dfd3ee 100644 --- a/apps/web/src/components/PersistentThreadTerminals.tsx +++ b/apps/web/src/components/PersistentThreadTerminals.tsx @@ -8,7 +8,7 @@ import { import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; -import { memo, useEffect, useState } from "react"; +import { type ComponentProps, memo, useEffect, useState } from "react"; import { useComposerDraftStore } from "../composerDraftStore"; import { useStableCallback } from "../hooks/useStableCallback"; import type { TerminalContextSelection } from "../lib/terminalContext"; @@ -23,7 +23,14 @@ import { useEnvironmentScope } from "../state/session"; import { useKnownTerminalSessions } from "../state/terminalSessions"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useThreadTerminalActions } from "./terminal/useThreadTerminalActions"; -import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; +import { terminalDrawerSurface } from "./lazySurfaces"; + +const LazyThreadTerminalDrawer = terminalDrawerSurface.Component; +function ThreadTerminalDrawer(props: ComponentProps) { + // Hidden sessions stay mounted; an empty closed drawer has nothing to synchronize. + if (props.visible === false && props.terminalIds.length === 0) return null; + return ; +} /** Where a drawer or panel opens new terminals when a script run overrides the thread's own. */ export type PersistentTerminalLaunchContext = { diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 4883c690bb3df..7737afc16f875 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -999,11 +999,17 @@ describe("pullRequestMatchesSearchQuery", () => { url: "https://github.com/pingdotgg/t3code/pull/123", }; - it("matches when every query token appears in the PR's terms", () => { - expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "123")).toBe(true); - expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "pingdotgg/t3code #123")).toBe( - true, - ); + it("matches every query token and follows replacement PR snapshots", () => { + const original = { branchPullRequest }; + expect(pullRequestMatchesSearchQuery(original, "123")).toBe(true); + expect(pullRequestMatchesSearchQuery(original, "pingdotgg/t3code #123")).toBe(true); + + const updated = { + branchPullRequest: { ...branchPullRequest, number: 456, url: "https://example.com/pull/456" }, + }; + expect(pullRequestMatchesSearchQuery(updated, "#123")).toBe(false); + expect(pullRequestMatchesSearchQuery(updated, "#456")).toBe(true); + expect(pullRequestMatchesSearchQuery(original, "#123")).toBe(true); }); it("rejects queries with a token the PR does not carry, and blank queries", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f10c58fa0de16..08cb939eb31b5 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1004,6 +1004,18 @@ export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3t const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet = new Set(); +type ThreadPullRequestSearchInput = Parameters[0]; +const pullRequestSearchIndex = new WeakMap(); + +function indexedPullRequestSearch(thread: ThreadPullRequestSearchInput) { + let indexed = pullRequestSearchIndex.get(thread); + if (indexed === undefined) { + indexed = threadPullRequestSearchTerms(thread).map((term) => term.toLowerCase()); + pullRequestSearchIndex.set(thread, indexed); + } + return indexed; +} + export function searchSidebarThreads< T extends { readonly environmentId: EnvironmentId; @@ -1020,9 +1032,9 @@ export function searchSidebarThreads< const titleMatches: T[] = []; const contentMatches: T[] = []; for (const thread of threads) { - const matchesTitle = [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => - term.toLowerCase().includes(normalizedQuery), - ); + const matchesTitle = + thread.title.toLowerCase().includes(normalizedQuery) || + indexedPullRequestSearch(thread).some((term) => term.includes(normalizedQuery)); if (matchesTitle) { titleMatches.push(thread); } else if ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 81eb57b37ddc3..14c71e32b3500 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { useShallow } from "zustand/react/shallow"; import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard"; @@ -1408,6 +1409,16 @@ interface SidebarDraftRowData { composer: ComposerThreadDraftState; } +function readSidebarDraftRow(draftId: DraftId): SidebarDraftRowData | null { + // The subscription excludes the active draft; route changes capture it from the full store. + const store = useComposerDraftStore.getState(); + const session = store.draftThreadsByThreadKey[draftId]; + const composer = store.draftsByThreadKey[draftId]; + return session && session.promotedTo == null && composer && composerDraftHasUserContent(composer) + ? { draftId, session, composer } + : null; +} + const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectByKey: ReadonlyMap; projectDisplayNameByKey: ReadonlyMap; @@ -1417,22 +1428,22 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; }) { const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); + const draftsByThreadKey = useComposerDraftStore( + useShallow((store) => + Object.fromEntries( + Object.keys(store.draftThreadsByThreadKey) + .filter((key) => key !== props.routeDraftId) + .map((key) => [key, store.draftsByThreadKey[key]] as const), + ), + ), + ); const [frozenActive, setFrozenActive] = useState<{ routeDraftId: string | null; row: SidebarDraftRowData | null; }>({ routeDraftId: null, row: null }); if (frozenActive.routeDraftId !== props.routeDraftId) { - let row: SidebarDraftRowData | null = null; - if (props.routeDraftId !== null) { - const draftId = DraftId.make(props.routeDraftId); - const session = draftThreadsByThreadKey[draftId]; - const composer = draftsByThreadKey[draftId]; - row = - session && session.promotedTo == null && composer && composerDraftHasUserContent(composer) - ? { draftId, session, composer } - : null; - } + const row = + props.routeDraftId === null ? null : readSidebarDraftRow(DraftId.make(props.routeDraftId)); setFrozenActive({ routeDraftId: props.routeDraftId, row }); } const drafts = (() => { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ebc30a4a1e708..7ad5afc248ba5 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -4158,6 +4158,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toggleInteractionMode(); return true; } + if ( + submissionIntent === null && + key !== "Escape" && + key !== "ArrowDown" && + key !== "ArrowUp" && + key !== "Enter" && + key !== "Tab" + ) { + return false; + } const { trigger } = resolveActiveComposerTrigger(); const menuIsActive = !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e8551c8de652d..d814bca4c8498 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -330,7 +330,6 @@ interface TimelineRowSharedState { onRunShellCommand: ((command: string) => void) | undefined; findActive: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; - displayThreadKey?: string; onOpenTurnDiff: (runId: RunId, filePath?: string) => void; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; workflowOpening: WorkflowOpening | null; @@ -468,8 +467,6 @@ interface MessagesTimelineProps { runningRunId?: RunId | null; turnDiffSummaries: ReadonlyArray; routeThreadKey: string; - displayThreadKey?: string; - paintOnly?: boolean; onOpenTurnDiff: (runId: RunId, filePath?: string) => void; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; parentThreadLink?: { @@ -574,8 +571,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ runningRunId = null, turnDiffSummaries, routeThreadKey, - displayThreadKey, - paintOnly = false, onOpenTurnDiff, onOpenThread, parentThreadLink = null, @@ -613,10 +608,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ historyControls, loadEarlier = null, }: MessagesTimelineProps) { - const listIdentityKey = displayThreadKey ?? routeThreadKey; // The remembered position is a snapshot taken when the list identity changes; // scrolling keeps rewriting the stored entry, which must not move the restore. - const [listState, setListState] = useState(() => timelineListStateFor(listIdentityKey)); + const [listState, setListState] = useState(() => timelineListStateFor(routeThreadKey)); const [expandedRunIds, setExpandedRunIds] = useState>( () => listState.rememberedPosition?.disclosures?.runs ?? new Set(), ); @@ -627,22 +621,22 @@ const ConversationTimeline = memo(function ConversationTimeline({ () => listState.rememberedPosition?.disclosures?.attempts ?? new Set(), ); const [positionedThreadKey, setPositionedThreadKey] = useState(() => - listState.rememberedPosition?.atEnd === false ? null : listIdentityKey, + listState.rememberedPosition?.atEnd === false ? null : routeThreadKey, ); - const restoringThreadPosition = positionedThreadKey !== listIdentityKey; + const restoringThreadPosition = positionedThreadKey !== routeThreadKey; const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); - const [settlingListIdentity, setSettlingListIdentity] = useState(listIdentityKey); + const [settlingListIdentity, setSettlingListIdentity] = useState(routeThreadKey); let paintedExpandedRunIds = expandedRunIds; let paintedExpandedWorkGroupIds = expandedWorkGroupIds; let paintedExpandedAttemptIds = expandedAttemptIds; - const identityChanged = listState.identity !== listIdentityKey; + const identityChanged = listState.identity !== routeThreadKey; const { rememberedPosition, workGroupViewState } = identityChanged - ? timelineListStateFor(listIdentityKey) + ? timelineListStateFor(routeThreadKey) : listState; if (identityChanged) { - setListState({ identity: listIdentityKey, rememberedPosition, workGroupViewState }); + setListState({ identity: routeThreadKey, rememberedPosition, workGroupViewState }); setPositionedThreadKey(null); - setSettlingListIdentity(listIdentityKey); + setSettlingListIdentity(routeThreadKey); paintedExpandedRunIds = rememberedPosition?.disclosures?.runs ?? new Set(); paintedExpandedWorkGroupIds = rememberedPosition?.disclosures?.workGroups ?? new Set(); paintedExpandedAttemptIds = rememberedPosition?.disclosures?.attempts ?? new Set(); @@ -773,11 +767,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ }); }; - const previousLatestRunRef = useRef({ identity: listIdentityKey, run: latestRun }); + const previousLatestRunRef = useRef({ identity: routeThreadKey, run: latestRun }); useEffect(() => { const { identity, run: previous } = previousLatestRunRef.current; - previousLatestRunRef.current = { identity: listIdentityKey, run: latestRun }; - if (identity !== listIdentityKey || !latestRun || previous?.runId === undefined) { + previousLatestRunRef.current = { identity: routeThreadKey, run: latestRun }; + if (identity !== routeThreadKey || !latestRun || previous?.runId === undefined) { return; } if (latestRun.runId === previous.runId) { @@ -798,7 +792,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ next.delete(previous.runId); return next; }); - }, [latestRun, listIdentityKey]); + }, [latestRun, routeThreadKey]); // Imported turns fold under synthetic keys rather than the entries' own run ids. const findFoldRunIds = findActive @@ -843,7 +837,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ ? new Set(paintedExpandedAttemptIds).add(activeFindAttemptId) : paintedExpandedAttemptIds; - const rawRows = useIncrementalTimelineRows(`${listIdentityKey}\0${workspaceRoot ?? ""}`, { + const rawRows = useIncrementalTimelineRows(`${routeThreadKey}\0${workspaceRoot ?? ""}`, { timelineEntries, latestRun, runningRunId, @@ -857,7 +851,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ supportsConversationRollback, worktreeSetup, }); - const rows = useStableRows(rawRows, listIdentityKey); + const rows = useStableRows(rawRows, routeThreadKey); // Run status/timestamps churn on every stream event; the shared row context // must not change with them or every timeline row re-renders per event. const runs = useStableHandoffRuns(runsProp); @@ -874,7 +868,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ if (!list) return; if (citationRequest !== null) { // oxlint-disable-next-line react/set-state-in-effect -- The citation scroll owns positioning once the list has mounted. - setPositionedThreadKey(listIdentityKey); + setPositionedThreadKey(routeThreadKey); return; } let cancelled = false; @@ -885,7 +879,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ cancelled = true; if (settleFrame !== null) cancelAnimationFrame(settleFrame); if (viewport) void list.scrollToOffset({ offset: viewport.scrollTop, animated: false }); - setPositionedThreadKey(listIdentityKey); + setPositionedThreadKey(routeThreadKey); }; const cancelForNavigation = () => { cancelRestoration(); @@ -923,7 +917,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ void Promise.resolve(scrolling).then(() => { if (cancelled) return; if (position?.atEnd !== false || index < 0) { - setPositionedThreadKey(listIdentityKey); + setPositionedThreadKey(routeThreadKey); return; } // Index scrolling starts from estimates. Keep the saved row mounted @@ -955,7 +949,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ } stableFrames += 1; if (stableFrames >= 2) { - setPositionedThreadKey(listIdentityKey); + setPositionedThreadKey(routeThreadKey); } else { settleFrame = requestAnimationFrame(reconcile); } @@ -976,7 +970,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ }, [ citationRequest, cancelPositionRestoreRef, - listIdentityKey, + routeThreadKey, listRef, onManualNavigation, rememberedPosition, @@ -1113,13 +1107,13 @@ const ConversationTimeline = memo(function ConversationTimeline({ if (restoringThreadPosition || state?.data !== rows) return; const isAtEnd = resolveTimelineIsAtEnd(state); const position = state?.data?.length ? resolveWorkGroupScrollAnchor(state) : undefined; - if (position && state && isAtEnd !== undefined && !paintOnly) { + if (position && state && isAtEnd !== undefined) { const index = state.indexByKey(position.rowId); const row = index === undefined ? undefined : state.elementAtIndex(index); const element = listRef.current?.getScrollableNode(); if (row && element) { rememberTimelinePosition( - listIdentityKey, + routeThreadKey, { ...position, offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, @@ -1409,7 +1403,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
); - const listExtraData = { identity: listIdentityKey, rowCount: rows.length, sharedState }; + const listExtraData = { identity: routeThreadKey, rowCount: rows.length, sharedState }; const forwardMinimapWheel = (event: WheelEvent) => { const scrollNode = listRef.current?.getScrollableNode(); @@ -1465,7 +1459,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ !findActive && citationRequest === null && rememberedPosition?.atEnd !== false } // Legend needs a data refresh to mount new pins without a scroll event. - dataVersion={readyCitationRequest?.key ?? listIdentityKey} + dataVersion={readyCitationRequest?.key ?? routeThreadKey} {...(alwaysRender ? { alwaysRender } : {})} onLoad={handleListLoad} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} @@ -3981,7 +3975,6 @@ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection( turnSummary: TurnDiffSummary | undefined; routeThreadKey: string; resolvedTheme: "light" | "dark"; - displayThreadKey?: string; onOpenTurnDiff: (runId: RunId, filePath?: string) => void; }) { if (!turnSummary) return null; @@ -4010,7 +4003,6 @@ function AssistantChangedFilesSectionInner({ checkpointFiles: TurnDiffSummary["files"]; routeThreadKey: string; resolvedTheme: "light" | "dark"; - displayThreadKey?: string; onOpenTurnDiff: (runId: RunId, filePath?: string) => void; }) { const ctx = use(TimelineRowCtx); diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index 3e148a3728a31..d7c72df102ab7 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -3,6 +3,7 @@ import { Maximize2, Minimize2 } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; import { memo, type ReactElement } from "react"; +import { preloadTerminalDrawer } from "../lazySurfaces"; import type { ThreadPanelPresentation } from "../../rightPanelLayout"; import { PopoverCreateHandle, PopoverTrigger } from "../ui/popover"; import { Toggle } from "../ui/toggle"; @@ -90,6 +91,8 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ className="shrink-0 [-webkit-app-region:no-drag]" pressed={terminalOpen} onPressedChange={onToggleTerminal} + onPointerEnter={() => void preloadTerminalDrawer()} + onFocus={() => void preloadTerminalDrawer()} aria-label="Toggle terminal drawer" variant="ghost" size="sm" diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index 2fc588777a77a..06aaa19b7e0c1 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -10,6 +10,7 @@ import type { } from "@t3tools/contracts"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { lazy, Suspense } from "react"; +import { diffPanelSurface } from "../lazySurfaces"; import type { ComposerImageAttachment, DraftId } from "../../composerDraftStore"; import type { ShortcutMatchContext } from "../../keybindings"; import { type RightPanelSurface, useRightPanelStore } from "../../rightPanelStore"; @@ -26,7 +27,7 @@ import { ThreadPullRequestsPanel } from "../pullRequest/ThreadPullRequestsPanel" const PreviewPanel = lazy(() => import("../preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); -const DiffPanel = lazy(() => import("../DiffPanel")); +const DiffPanel = diffPanelSurface.Component; const DevicePanel = lazy(() => import("../device/DevicePanel").then((module) => ({ default: module.DevicePanel })), ); @@ -105,14 +106,12 @@ export function RightPanelSurfaceContent(props: { ); case "diff": return ( - - - + ); case "pull-request": { const pullRequest = props.pullRequest; diff --git a/apps/web/src/components/chat/useComposerMultilinePrompt.ts b/apps/web/src/components/chat/useComposerMultilinePrompt.ts index 10353cf5ff267..e3011b8bae9fc 100644 --- a/apps/web/src/components/chat/useComposerMultilinePrompt.ts +++ b/apps/web/src/components/chat/useComposerMultilinePrompt.ts @@ -1,10 +1,13 @@ -import { useLayoutEffect, useState } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import { observeResize } from "../../lib/observeResize"; export function measureComposerMultilinePrompt(body: HTMLElement): boolean | null { const editor = body.querySelector('[data-testid="composer-editor"]'); if (!editor || editor.clientWidth === 0) return null; + // Scrolling already proves the prompt spans more than one line. + if (editor.scrollHeight > editor.clientHeight + 1) return true; + const bodyStyle = getComputedStyle(body); const expandedWidth = body.clientWidth - @@ -23,12 +26,16 @@ export function measureComposerMultilinePrompt(body: HTMLElement): boolean | nul export function useComposerMultilinePrompt(body: HTMLElement | null): boolean { const [isMultiline, setIsMultiline] = useState(false); + const measuredRef = useRef(false); useLayoutEffect(() => { if (!body) return; const measure = () => { const next = measureComposerMultilinePrompt(body); - if (next !== null) setIsMultiline(next); + if (next !== null && next !== measuredRef.current) { + measuredRef.current = next; + setIsMultiline(next); + } }; measure(); const editor = body.querySelector('[data-testid="composer-editor"]'); diff --git a/apps/web/src/components/lazySurfaces.ts b/apps/web/src/components/lazySurfaces.ts new file mode 100644 index 0000000000000..3f70278b4a351 --- /dev/null +++ b/apps/web/src/components/lazySurfaces.ts @@ -0,0 +1,63 @@ +import { createElement, useEffect, useState, type ComponentProps, type ComponentType } from "react"; +import { loadOnDemand, preloadSpeculatively } from "../lib/chunkReloadGuard"; + +export const terminalDrawerSurface: ReturnType< + typeof loadableComponent> +> = loadableComponent(() => import("./ThreadTerminalDrawer")); +export const diffPanelSurface: ReturnType< + typeof loadableComponent> +> = loadableComponent(() => import("./DiffPanel")); + +/** Warm assets without mounting a terminal or opening a shell. */ +export async function preloadTerminalDrawer(): Promise { + await Promise.all([ + terminalDrawerSurface.preload(), + preloadSpeculatively(() => + import("../terminal/ghostty/surface").then(({ preloadTerminalAssets }) => + preloadTerminalAssets(), + ), + ), + ]); +} + +/** Load on demand without Suspense delaying the reveal after the module arrives. */ +function loadableComponent( + load: () => Promise<{ default: ComponentType }>, +) { + let loaded: ComponentType | null = null; + let loading: Promise> | null = null; + const resolve = () => { + if (loaded !== null) return Promise.resolve(loaded); + loading ??= load() + .then((module) => (loaded = module.default)) + .finally(() => { + loading = null; + }); + return loading; + }; + + function Loadable(props: Props) { + const [result, setResult] = useState< + { component: ComponentType } | { error: unknown } | null + >(() => (loaded === null ? null : { component: loaded })); + useEffect(() => { + if (result !== null) return; + let mounted = true; + void loadOnDemand(resolve).then( + (component) => { + if (mounted) setResult({ component }); + }, + (error: unknown) => { + if (mounted) setResult({ error }); + }, + ); + return () => { + mounted = false; + }; + }, [result]); + if (result !== null && "error" in result) throw result.error; + return result === null ? null : createElement(result.component, props); + } + + return { Component: Loadable, preload: () => preloadSpeculatively(resolve) }; +} diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts index 632e8ec8cf0dc..7b275dce240d0 100644 --- a/apps/web/src/lib/chunkReloadGuard.test.ts +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./chunkReloadGuard"; +import { + clearChunkReloadGuard, + clearChunkReloadGuardAfterStartup, + isChunkReloadScheduled, + loadOnDemand, + preloadSpeculatively, + reloadOnceForChunkLoadError, +} from "./chunkReloadGuard"; function createStorageStub(): Storage { const store = new Map(); @@ -21,18 +28,77 @@ function createStorageStub(): Storage { } describe("reloadOnceForChunkLoadError", () => { - it("reloads on the first failure and lets the second one surface", () => { + it("recovers once when a real import shares a hover preload's rejection", async () => { + const reload = vi.fn(); + vi.stubGlobal("window", { + sessionStorage: createStorageStub(), + location: { reload }, + }); + try { + expect( + await preloadSpeculatively(() => Promise.reject(new Error("Hover preload failed"))), + ).toBeUndefined(); + expect(reload).not.toHaveBeenCalled(); + + const failure = new Error("Failed to fetch dynamically imported module"); + let rejectImport!: (reason: unknown) => void; + const sharedImport = new Promise((_, reject) => { + rejectImport = reject; + }); + const demand = loadOnDemand(() => sharedImport); + const hover = preloadSpeculatively(() => sharedImport); + const outcomes = Promise.allSettled([hover, demand]); + rejectImport(failure); + + expect(await outcomes).toEqual([ + { status: "fulfilled", value: undefined }, + { status: "rejected", reason: failure }, + ]); + await expect(demand).rejects.toBe(failure); + expect(reload).toHaveBeenCalledTimes(1); + expect(isChunkReloadScheduled()).toBe(true); + clearChunkReloadGuardAfterStartup(); + expect(reloadOnceForChunkLoadError()).toBe(false); + + await expect(loadOnDemand(() => Promise.reject(failure))).rejects.toBe(failure); + expect(reload).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("retains recent recovery across a fresh boot and recovers after the cooldown", async () => { const storage = createStorageStub(); const reload = vi.fn(); + const startedAt = new Date("2026-10-19T00:00:00Z"); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(startedAt); + vi.stubGlobal("window", { sessionStorage: storage, location: { reload } }); + try { + expect(reloadOnceForChunkLoadError()).toBe(true); + expect(reload).toHaveBeenCalledTimes(1); - expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); - expect(reload).toHaveBeenCalledTimes(1); + expect(reloadOnceForChunkLoadError()).toBe(false); + expect(reload).toHaveBeenCalledTimes(1); + + vi.setSystemTime(startedAt.getTime() + 1_000); + vi.resetModules(); + const freshPage = await import("./chunkReloadGuard"); + freshPage.clearChunkReloadGuardAfterStartup(); + expect(freshPage.reloadOnceForChunkLoadError()).toBe(false); + expect(reload).toHaveBeenCalledTimes(1); - expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(false); - expect(reload).toHaveBeenCalledTimes(1); + vi.setSystemTime(startedAt.getTime() + 31_000); + expect(freshPage.reloadOnceForChunkLoadError()).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.resetModules(); + } }); - it("reloads again after a successful boot cleared the guard", () => { + it("reloads again after manually clearing the guard", () => { const storage = createStorageStub(); const reload = vi.fn(); diff --git a/apps/web/src/lib/chunkReloadGuard.ts b/apps/web/src/lib/chunkReloadGuard.ts index 2b8b2838bd51c..964a0fccd6913 100644 --- a/apps/web/src/lib/chunkReloadGuard.ts +++ b/apps/web/src/lib/chunkReloadGuard.ts @@ -2,6 +2,7 @@ // page load and a later fetch can 404 the old hashed assets. One reload picks // up the fresh index.html. const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloaded"; +const CHUNK_RELOAD_COOLDOWN_MS = 30_000; export function reloadOnceForChunkLoadError( getStorage: () => Storage = () => window.sessionStorage, @@ -10,8 +11,10 @@ export function reloadOnceForChunkLoadError( let alreadyReloaded: boolean; try { const storage = getStorage(); - alreadyReloaded = storage.getItem(CHUNK_RELOAD_GUARD_KEY) === "1"; - if (!alreadyReloaded) storage.setItem(CHUNK_RELOAD_GUARD_KEY, "1"); + const now = Date.now(); + const elapsed = now - Number(storage.getItem(CHUNK_RELOAD_GUARD_KEY)); + alreadyReloaded = elapsed >= 0 && elapsed < CHUNK_RELOAD_COOLDOWN_MS; + if (!alreadyReloaded) storage.setItem(CHUNK_RELOAD_GUARD_KEY, String(now)); } catch { // Without storage the guard cannot survive a reload, so a persistent // failure would loop forever. Let the error surface instead. @@ -27,3 +30,60 @@ export function clearChunkReloadGuard(getStorage: () => Storage = () => window.s getStorage().removeItem(CHUNK_RELOAD_GUARD_KEY); } catch {} } + +let chunkLoadFailed = false; +let reloadScheduled = false; + +export function recoverChunkLoadError(): boolean { + if (reloadScheduled) return false; + chunkLoadFailed = true; + reloadScheduled = reloadOnceForChunkLoadError(); + return reloadScheduled; +} + +export function isChunkReloadScheduled(): boolean { + return reloadScheduled; +} + +export function clearChunkReloadGuardAfterStartup(): void { + if (chunkLoadFailed) return; + try { + // Restored lazy surfaces can fail after a healthy boot; keep recent recovery. + const elapsed = Date.now() - Number(window.sessionStorage.getItem(CHUNK_RELOAD_GUARD_KEY)); + if (!(elapsed >= 0 && elapsed < CHUNK_RELOAD_COOLDOWN_MS)) clearChunkReloadGuard(); + } catch {} +} + +export async function loadOnDemand(load: () => Promise): Promise { + try { + return await load(); + } catch (error) { + // Hover and real use can share the same rejected import and error object. + recoverChunkLoadError(); + throw error; + } +} + +let speculativeLoads = 0; +const speculativeFailures = new WeakSet(); + +// A failed hover preload must not trigger stale-chunk recovery. +export async function preloadSpeculatively(load: () => Promise): Promise { + speculativeLoads += 1; + try { + return await load(); + } catch (error) { + if (typeof error === "object" && error !== null) speculativeFailures.add(error); + return undefined; + } finally { + speculativeLoads -= 1; + } +} + +export function isSpeculativeLoadPending(): boolean { + return speculativeLoads > 0; +} + +export function isSpeculativeLoadFailure(error: unknown): boolean { + return typeof error === "object" && error !== null && speculativeFailures.has(error); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index e2dfab60d81a0..5526a437738f7 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -13,7 +13,13 @@ import { syncDocumentWindowControlsOverlayClass, } from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; -import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; +import { + clearChunkReloadGuardAfterStartup, + isChunkReloadScheduled, + isSpeculativeLoadFailure, + isSpeculativeLoadPending, + recoverChunkLoadError, +} from "./lib/chunkReloadGuard"; prepareProviderAuthDelivery(); @@ -29,16 +35,15 @@ if (isElectron) { const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; -// A failed split-chunk fetch usually means the hashed assets went stale under -// a deploy; one guarded reload picks up the fresh index.html. -let chunkLoadFailed = false; -let reloadScheduled = false; window.addEventListener("vite:preloadError", (event) => { - chunkLoadFailed = true; - if (reloadOnceForChunkLoadError()) { - reloadScheduled = true; - event.preventDefault(); + if (!isSpeculativeLoadPending()) { + if (recoverChunkLoadError()) event.preventDefault(); + return; } + // The import rejects after this event. Wait for its owner to identify a failed preload. + setTimeout(() => { + if (!isSpeculativeLoadFailure(event.payload)) recoverChunkLoadError(); + }, 0); }); const app = ; @@ -60,8 +65,8 @@ export const startup = Promise.all([ .then(([ManagedAuthShell]) => { // A route chunk failure still resolves router.load(): the error is parked in // the lazy component and surfaces through the route error boundary. - if (reloadScheduled) return; - if (!chunkLoadFailed) clearChunkReloadGuard(); + if (isChunkReloadScheduled()) return; + clearChunkReloadGuardAfterStartup(); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( {ManagedAuthShell && clerkPublishableKey ? ( @@ -73,6 +78,6 @@ export const startup = Promise.all([ ); }) .catch((error: unknown) => { - if (reloadScheduled) return; + if (isChunkReloadScheduled()) return; throw error; }); diff --git a/apps/web/src/markdown-incremental.test.tsx b/apps/web/src/markdown-incremental.test.tsx index 3682deb281fa9..8058e7c0bfdab 100644 --- a/apps/web/src/markdown-incremental.test.tsx +++ b/apps/web/src/markdown-incremental.test.tsx @@ -1,15 +1,14 @@ -import { CHAT_MARKDOWN_REHYPE_PLUGINS } from "@t3tools/shared/markdownPipeline"; +import { + CHAT_MARKDOWN_REHYPE_PLUGINS, + CHAT_MARKDOWN_REMARK_PLUGINS, +} from "@t3tools/shared/markdownPipeline"; import type { Root } from "mdast"; import { renderToStaticMarkup } from "react-dom/server"; import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; import type { Plugin } from "unified"; import { describe, expect, it } from "vite-plus/test"; -import { remarkCodexDirectives } from "@t3tools/shared/codexMarkdownDirectives"; -import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts"; import { createIncrementalMarkdownPlugin } from "./markdown-incremental"; -import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation"; function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) { let tree: Root | undefined; @@ -30,10 +29,7 @@ function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: remarkPlugins={[ observeParsing, capture, - remarkGfm, - remarkGithubAlerts, - remarkNormalizeListItemIndentation, - remarkCodexDirectives, + ...CHAT_MARKDOWN_REMARK_PLUGINS, ...(incremental ? [incremental] : []), ]} rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS} diff --git a/apps/web/src/markdown-incremental.ts b/apps/web/src/markdown-incremental.ts index 3fffa35d3adb6..30fdf91c7ee4f 100644 --- a/apps/web/src/markdown-incremental.ts +++ b/apps/web/src/markdown-incremental.ts @@ -30,9 +30,8 @@ function shiftPositions(node: Root | RootContent, offset: number, lines: number) } } -/** Keep the full document pipeline while avoiding parsing a completed code-heavy - * prefix on every token. A closed top-level fence followed by a blank line is a - * parsing boundary. Definitions are document-wide, so they require a full parse. +/** Reparse only the tail after a closed top-level fence and blank line. + * Definitions are document-wide and still require the full parse. */ function createIncrementalMarkdownParser(parse: Parser): Parser { let cached: ParsedPrefix | undefined; @@ -43,6 +42,7 @@ function createIncrementalMarkdownParser(parse: Parser): Parser { if (source.includes("\r") || source.includes("\uFEFF")) return parse(source, file); const prefix = cached && source.startsWith(cached.source) ? cached : undefined; + if (!prefix) cached = undefined; let root: Root; if (prefix) { root = parse(source.slice(prefix.offset), file); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 7ebf0932ad7bd..39a9a60115dbe 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -17,6 +17,7 @@ import { import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; import { isMonospaceFamily } from "../../appearanceFonts"; import { observeResize } from "../../lib/observeResize"; +import { loadGhosttyRuntime } from "./runtime"; export const DEFAULT_TERMINAL_FONT_SIZE = 12; const MIN_TERMINAL_FONT_SIZE = 6; @@ -50,15 +51,22 @@ let symbolsFontLoad: Promise | null = null; function ensureTerminalSymbolsFont(): Promise { if (symbolsFontLoad !== null) return symbolsFontLoad; - symbolsFontLoad = (async () => { - try { + symbolsFontLoad = Promise.resolve() + .then(async () => { const face = new FontFace("Symbols Nerd Font Mono", `url(${symbolsFontUrl})`); document.fonts.add(await face.load()); - } catch {} - })(); + }) + .catch(() => { + // Installed fallback faces still apply; a later opening can retry the bundled font. + symbolsFontLoad = null; + }); return symbolsFontLoad; } +export function preloadTerminalAssets() { + return Promise.all([ensureTerminalSymbolsFont(), loadGhosttyRuntime()]); +} + function quoteTerminalFontFamilies(list: string): string { return list .split(",") From 776ec7002c72db114f080a404eda020a1a091dd4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 10:30:18 +0000 Subject: [PATCH 2/3] test(web): await workflow script highlighting inside act --- .../chat/WorkflowOpeningBlock.browser.test.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx b/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx index 78abe0651ba9e..c722d64a9b44f 100644 --- a/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx +++ b/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; import "~/index.css"; +import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting"; import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; import { MarkdownFindContext } from "./markdownFindContext"; import { useThreadFindHighlights } from "./threadFindHighlights"; @@ -99,8 +100,17 @@ const foldToggle = () => it("shows a short script as highlighted code with nothing to open", async () => { await render(SHORT_SCRIPT); + await act(async () => { + await getSyntaxHighlighterPromise("javascript"); + }); expect(host.querySelector('[data-language="javascript"]')?.textContent).toContain("SCRIPTONLY"); - await vi.waitFor(() => expect(host.querySelector(".chat-markdown-shiki")).not.toBeNull()); + expect(host.querySelector(".chat-markdown-shiki")?.textContent).toContain(SHORT_SCRIPT); + const tokenColors = new Set( + [...host.querySelectorAll(".chat-markdown-shiki code span[style]")].map( + (token) => getComputedStyle(token).color, + ), + ); + expect(tokenColors.size).toBeGreaterThan(1); expect(foldToggle()).toBeUndefined(); }); From 075cdcbc8bf5154ece8d80d7bbcac91d5db412b2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 11:01:01 +0000 Subject: [PATCH 3/3] test(web): await attachment source preview before asserting --- .../files/AttachmentFilePreview.browser.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 14a24dbc9c6b7..cd7a24a954d6f 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -70,9 +70,10 @@ describe("attachment HTML preview recovery", () => { now = 61 * 60_000; await toggleMode("Show HTML source"); expect(fetch).toHaveBeenCalledExactlyOnceWith(renewedUrl, expect.any(Object)); - await vi.waitFor(() => - expect(container.querySelector("pre")?.textContent).toBe("

Captured HTML

"), - ); + await act(async () => { + await import("./ReadOnlySourcePreview"); + }); + expect(container.querySelector("pre")?.textContent).toBe("

Captured HTML

"); await toggleMode("Show rendered page"); expect(container.querySelector("iframe")?.getAttribute("src")).toBe(renewedUrl);