From dc5df5d6a40ef010fad7627be5e1acf380832719 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:08:38 -0700 Subject: [PATCH 1/3] refactor(web): browser panel tabs follow preview sessions without ChatView effects ChatView kept each thread's browser panel tabs and floating browser in step with its preview sessions through two effects, and useAgentOpenedPreviews found agent-revealed browsers by diffing session snapshots in a third effect with a per-thread baseline held in a ref. All three re-ran after every render that changed sessions, panel surfaces, or the active thread. The preview state store now announces the changes itself: it reports when a thread's sessions (or list-loaded flag) change, and which server tabs are freshly revealed (new tabs, or a new reveal request, after the first list loaded). agentOpenedPreviews.ts subscribes once at app start and reconciles panel tabs, closes a floating browser whose session ended, and floats or focuses revealed tabs. Effects removed (3): ChatView browser-tab reconcile, ChatView floating-browser close, useAgentOpenedPreviews server reveal. Kept: the two device effects in useAgentOpenedPreviews; device sessions come from a separate stream that a later change moves. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/appInit.tsx | 2 + apps/web/src/components/ChatView.tsx | 32 ------ .../preview/agentOpenedPreviews.test.ts | 108 ++++++++++++++++++ .../components/preview/agentOpenedPreviews.ts | 74 ++++++++++++ .../preview/useAgentOpenedPreviews.ts | 58 +--------- apps/web/src/previewStateStore.test.ts | 74 ++++++++++++ apps/web/src/previewStateStore.ts | 107 +++++++++++++++-- 7 files changed, 359 insertions(+), 96 deletions(-) create mode 100644 apps/web/src/components/preview/agentOpenedPreviews.test.ts create mode 100644 apps/web/src/components/preview/agentOpenedPreviews.ts diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 2b8416ba4aa55..3d29883887a79 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -3,6 +3,7 @@ import { createBrowserHistory, createHashHistory } from "@tanstack/react-router" import { applyAppearanceContrast } from "./appearanceContrast"; import { applyAppearanceFontVariables } from "./appearanceFonts"; +import { startAgentOpenedPreviews } from "./components/preview/agentOpenedPreviews"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "./branding"; import { resolveServerBackedAppDisplayName } from "./branding.logic"; import { isElectron } from "./env"; @@ -101,6 +102,7 @@ export function startAppServices(): AppRouter { startClientAppearance(); startDocumentTitle(); + startAgentOpenedPreviews(); return router; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7e81eb6124a69..2ccfce10f7b1d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -936,7 +936,6 @@ export default function ChatView(props: ChatViewProps) { ); const activePreviewState = useThreadPreviewState(activeThreadRef); const activePreviewServerEpoch = activePreviewState.serverEpoch; - const previewSessionsReady = !activeEnvironmentServerBrowser || activePreviewState.listLoaded; const resolvePreviewRuntimeTabId = activeThreadRef ? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId) : undefined; @@ -988,34 +987,6 @@ export default function ChatView(props: ChatViewProps) { ), ); - useEffect(() => { - if (!activeThreadRef || !previewSessionsReady) return; - const hiddenTabIds = new Set( - Object.values(activePreviewState.sessions) - .filter((session) => session.runtime === "server" && session.reveal === false) - .map((session) => session.tabId), - ); - useRightPanelStore - .getState() - .reconcileBrowserSurfaces( - activeThreadRef, - Object.keys(activePreviewState.sessions), - hiddenTabIds, - ); - }, [activePreviewState.sessions, activeThreadRef, previewSessionsReady]); - - useEffect(() => { - const source = activePreviewMiniPlayer?.source; - if ( - activeThreadRef && - previewSessionsReady && - source?.kind === "browser" && - !activePreviewState.sessions[source.tabId] - ) { - usePreviewMiniPlayerStore.getState().close(activeThreadRef); - } - }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef, previewSessionsReady]); - const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime); const activePlan = deriveActivePlanState(serverProjection, activeActivityRun?.runId); const activeActivityRunSettled = isLatestRunSettled(activeActivityRun, activeRuntime); @@ -1900,9 +1871,6 @@ export default function ChatView(props: ChatViewProps) { threadRef: activeThreadRef, deviceState, deviceStateLoaded, - previewState: activePreviewState, - serverBrowserAvailable: activeEnvironmentServerBrowser, - panelSurfaces: rightPanelState.surfaces, miniPlayerSource: activePreviewMiniPlayer?.source ?? null, sheetLayout: shouldUsePlanSidebarSheet, }); diff --git a/apps/web/src/components/preview/agentOpenedPreviews.test.ts b/apps/web/src/components/preview/agentOpenedPreviews.test.ts new file mode 100644 index 0000000000000..7a0a12ebdc227 --- /dev/null +++ b/apps/web/src/components/preview/agentOpenedPreviews.test.ts @@ -0,0 +1,108 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore"; +import { __testing as previewStateTesting } from "../../previewStateStore"; +import { useRightPanelStore } from "../../rightPanelStore"; +import { followPreviewSessions, revealServerTabs } from "./agentOpenedPreviews"; + +vi.mock("../../state/entities", () => ({ + readEnvironmentSupportsServerBrowser: () => true, +})); + +const ref = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-1")); +const threadKey = scopedThreadKey(ref); + +function setup() { + usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); + useRightPanelStore.setState({ byThreadKey: {} }); +} + +const miniPlayerSource = () => usePreviewMiniPlayerStore.getState().byThreadKey[threadKey]?.source; + +describe("revealServerTabs", () => { + const tab = (tabId: string, force?: boolean): PreviewSessionSnapshot => ({ + threadId: "thread-1", + tabId, + navStatus: { _tag: "Loading", url: "http://localhost:5173/", title: "" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + runtime: "server", + reveal: true, + ...(force === undefined ? {} : { revealRequest: { id: "r1", force } }), + }); + const floatingTabId = () => { + const source = miniPlayerSource(); + return source?.kind === "browser" ? source.tabId : null; + }; + + it("floats revealed tabs, and only forced ones when floating previews are off", () => { + setup(); + revealServerTabs(ref, [tab("tab_plain")], { autoShowFloatingPreview: false }); + expect(floatingTabId()).toBeNull(); + revealServerTabs(ref, [tab("tab_forced", true)], { autoShowFloatingPreview: false }); + expect(floatingTabId()).toBe("tab_forced"); + }); + + it("focuses a tab already in the panel instead of floating it when forced", () => { + setup(); + useRightPanelStore.getState().openBrowser(ref, "tab_a"); + const browserSurfaceId = useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId; + useRightPanelStore.getState().open(ref, "diff"); + + revealServerTabs(ref, [tab("tab_a", false)], { autoShowFloatingPreview: true }); + expect(useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId).not.toBe( + browserSurfaceId, + ); + revealServerTabs(ref, [tab("tab_a", true)], { autoShowFloatingPreview: true }); + expect(useRightPanelStore.getState().byThreadKey[threadKey]?.activeSurfaceId).toBe( + browserSurfaceId, + ); + expect(floatingTabId()).toBeNull(); + }); +}); + +describe("followPreviewSessions", () => { + const snapshot = (tabId: string, reveal = true): PreviewSessionSnapshot => ({ + threadId: "thread-1", + tabId, + navStatus: { _tag: "Loading", url: "http://localhost:5173/", title: "" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + runtime: "server", + reveal, + }); + const previewState = (sessions: ReadonlyArray, listLoaded = true) => ({ + ...previewStateTesting.EMPTY_THREAD_PREVIEW_STATE, + listLoaded, + sessions: Object.fromEntries(sessions.map((entry) => [entry.tabId, entry])), + }); + const browserTabs = () => + useRightPanelStore + .getState() + .byThreadKey[threadKey]?.surfaces.filter((surface) => surface.kind === "preview") + .map((surface) => surface.id); + + it("adds visible sessions as tabs and drops tabs whose session ended", () => { + setup(); + useRightPanelStore.getState().openBrowser(ref, "tab_gone"); + followPreviewSessions(ref, previewState([snapshot("tab_a"), snapshot("tab_hidden", false)])); + expect(browserTabs()).toEqual(["browser:tab_a"]); + }); + + it("waits for the first server list before treating missing sessions as closed", () => { + setup(); + useRightPanelStore.getState().openBrowser(ref, "tab_a"); + usePreviewMiniPlayerStore.getState().open(ref, browserMiniPlayerSource("tab_a")); + followPreviewSessions(ref, previewState([], false)); + expect(browserTabs()).toEqual(["browser:tab_a"]); + expect(miniPlayerSource()).toMatchObject({ kind: "browser", tabId: "tab_a" }); + + followPreviewSessions(ref, previewState([])); + expect(browserTabs()).toEqual([]); + expect(miniPlayerSource()).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/preview/agentOpenedPreviews.ts b/apps/web/src/components/preview/agentOpenedPreviews.ts new file mode 100644 index 0000000000000..2940cf042d60e --- /dev/null +++ b/apps/web/src/components/preview/agentOpenedPreviews.ts @@ -0,0 +1,74 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; + +import { getClientSettings } from "../../hooks/useSettings"; +import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore"; +import { + onPreviewSessionsChanged, + onServerTabsRevealed, + type ThreadPreviewState, +} from "../../previewStateStore"; +import { useRightPanelStore } from "../../rightPanelStore"; +import { readEnvironmentSupportsServerBrowser } from "../../state/entities"; + +/** + * Shows browsers an agent or another client revealed for a thread: floating + * over chat, or focusing an existing panel tab when the reveal is forced. + */ +export function revealServerTabs( + threadRef: ScopedThreadRef, + sessions: ReadonlyArray, + options: { readonly autoShowFloatingPreview: boolean }, +): void { + if (!readEnvironmentSupportsServerBrowser(threadRef.environmentId)) return; + for (const session of sessions) { + const force = session.revealRequest?.force === true; + if (!options.autoShowFloatingPreview && !force) continue; + const surface = useRightPanelStore + .getState() + .byThreadKey[scopedThreadKey(threadRef)]?.surfaces.find( + (surface) => surface.kind === "preview" && surface.resourceId === session.tabId, + ); + if (surface && force) { + useRightPanelStore.getState().activateSurface(threadRef, surface.id); + } else if (!surface) { + usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(session.tabId)); + } + } +} + +/** + * Keeps a thread's browser panel tabs and floating browser in step with its + * preview sessions. Server-hosted sessions are only complete once the first + * list has loaded; before that, a missing session is not a closed one. + */ +export function followPreviewSessions(threadRef: ScopedThreadRef, state: ThreadPreviewState): void { + if (readEnvironmentSupportsServerBrowser(threadRef.environmentId) && !state.listLoaded) return; + const sessions = Object.values(state.sessions); + useRightPanelStore.getState().reconcileBrowserSurfaces( + threadRef, + sessions.map((session) => session.tabId), + new Set( + sessions + .filter((session) => session.runtime === "server" && session.reveal === false) + .map((session) => session.tabId), + ), + ); + const miniPlayer = usePreviewMiniPlayerStore.getState(); + const source = miniPlayer.byThreadKey[scopedThreadKey(threadRef)]?.source; + if (source?.kind === "browser" && !state.sessions[source.tabId]) miniPlayer.close(threadRef); +} + +let started = false; + +/** Follows every thread's browser previews for the life of the page, not just the open thread's. */ +export function startAgentOpenedPreviews(): void { + if (started) return; + started = true; + onPreviewSessionsChanged(followPreviewSessions); + onServerTabsRevealed((threadRef, sessions) => + revealServerTabs(threadRef, sessions, { + autoShowFloatingPreview: getClientSettings().browserAutoShowFloatingPreview, + }), + ); +} diff --git a/apps/web/src/components/preview/useAgentOpenedPreviews.ts b/apps/web/src/components/preview/useAgentOpenedPreviews.ts index 79327db0321f3..7a5dbbd53689a 100644 --- a/apps/web/src/components/preview/useAgentOpenedPreviews.ts +++ b/apps/web/src/components/preview/useAgentOpenedPreviews.ts @@ -3,12 +3,10 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { useEffect, useRef } from "react"; import { useClientSettings } from "../../hooks/useSettings"; import { - browserMiniPlayerSource, usePreviewMiniPlayerStore, type PreviewMiniPlayerState, } from "../../previewMiniPlayerStore"; -import type { ThreadPreviewState } from "../../previewStateStore"; -import { useRightPanelStore, type RightPanelSurface } from "../../rightPanelStore"; +import { useRightPanelStore } from "../../rightPanelStore"; const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => settings.browserAutoShowFloatingPreview; @@ -17,23 +15,11 @@ export function useAgentOpenedPreviews(input: { readonly threadRef: ScopedThreadRef | null; readonly deviceState: DeviceServiceState; readonly deviceStateLoaded: boolean; - readonly previewState: ThreadPreviewState; - readonly serverBrowserAvailable: boolean; - readonly panelSurfaces: ReadonlyArray; readonly miniPlayerSource: PreviewMiniPlayerState["source"] | null; // Sheet layouts show neither floating previews nor auto-opened device tabs. readonly sheetLayout: boolean; }) { - const { - threadRef, - deviceState, - deviceStateLoaded, - previewState, - serverBrowserAvailable, - panelSurfaces, - miniPlayerSource, - sheetLayout, - } = input; + const { threadRef, deviceState, deviceStateLoaded, miniPlayerSource, sheetLayout } = input; const autoShowFloatingPreview = useClientSettings(selectAutoShowFloatingPreview); // Baseline sessions to preserve dismissals on reload; missing device summaries must remain eligible to open // later. @@ -88,46 +74,6 @@ export function useAgentOpenedPreviews(input: { deviceState.sessions, deviceState.devices, ]); - // Baseline loaded tabs so reloads never reopen previews the user dismissed. - const previousServerPreviewTabs = useRef(new Map>()); - useEffect(() => { - if (!threadRef || !serverBrowserAvailable || !previewState.listLoaded) return; - const threadKey = scopedThreadKey(threadRef); - const serverSessions = Object.values(previewState.sessions).filter( - (session) => session.runtime === "server", - ); - const previous = previousServerPreviewTabs.current.get(threadKey); - previousServerPreviewTabs.current.set( - threadKey, - new Map(serverSessions.map((session) => [session.tabId, session.revealRequest?.id])), - ); - if (!previous) return; - for (const session of serverSessions) { - const requested = session.revealRequest; - const fresh = requested - ? previous.get(session.tabId) !== requested.id - : !previous.has(session.tabId); - if (!fresh || session.reveal !== true) continue; - if (!autoShowFloatingPreview && requested?.force !== true) continue; - const surface = panelSurfaces.find( - (surface) => surface.kind === "preview" && surface.resourceId === session.tabId, - ); - if (surface && requested?.force === true) { - useRightPanelStore.getState().activateSurface(threadRef, surface.id); - } else if (!surface) { - usePreviewMiniPlayerStore - .getState() - .open(threadRef, browserMiniPlayerSource(session.tabId)); - } - } - }, [ - serverBrowserAvailable, - previewState.listLoaded, - previewState.sessions, - threadRef, - autoShowFloatingPreview, - panelSurfaces, - ]); useEffect(() => { if (!threadRef || !deviceStateLoaded) return; const source = miniPlayerSource; diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index d7775bbd6117b..986fe2615a1fa 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -19,6 +19,7 @@ import { reconcilePreviewServerSessions, resetPreviewStateForTests, setActivePreviewTab, + onServerTabsRevealed, } from "./previewStateStore"; import { appAtomRegistry } from "./rpc/atomRegistry"; @@ -418,3 +419,76 @@ describe("previewStateStore (single-tab)", () => { }); }); }); + +describe("previewStateStore server reveals", () => { + const collectReveals = () => { + const tabIds: string[] = []; + const stop = onServerTabsRevealed((_ref, sessions) => { + tabIds.push(...sessions.map((session) => session.tabId)); + }); + return { tabIds, [Symbol.dispose]: stop }; + }; + const serverTab = (overrides: Partial = {}) => + makeSnapshot({ runtime: "server", reveal: true, ...overrides }); + + it("treats the first list as a baseline and reveals tabs opened later", () => { + using reveals = collectReveals(); + const revealed = reveals.tabIds; + reconcilePreviewServerSessions(ref, { + sessions: [serverTab({ tabId: "tab_restored" })], + serverEpoch, + revision: 1, + }); + expect(revealed).toEqual([]); + + const opened = serverTab({ tabId: "tab_agent" }); + applyPreviewServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: opened.tabId, + createdAt: opened.updatedAt, + snapshot: opened, + }); + expect(revealed).toEqual(["tab_agent"]); + }); + + it("reveals an existing tab only for a new reveal request", () => { + using reveals = collectReveals(); + const revealed = reveals.tabIds; + const tab = serverTab({ revealRequest: { id: "r1", force: false } }); + reconcilePreviewServerSessions(ref, { sessions: [tab], serverEpoch, revision: 1 }); + + const navigated = { ...tab, updatedAt: "2026-01-01T00:00:01.000Z" }; + applyPreviewServerEvent(ref, { + type: "navigated", + threadId: "thread-1", + tabId: tab.tabId, + createdAt: navigated.updatedAt, + snapshot: navigated, + }); + expect(revealed).toEqual([]); + + const requested = { + ...navigated, + revealRequest: { id: "r2", force: true }, + updatedAt: "2026-01-01T00:00:02.000Z", + }; + applyPreviewServerEvent(ref, { + type: "navigated", + threadId: "thread-1", + tabId: tab.tabId, + createdAt: requested.updatedAt, + snapshot: requested, + }); + expect(revealed).toEqual([tab.tabId]); + }); + + it("never reveals tabs this client opened or desktop tabs", () => { + using reveals = collectReveals(); + const revealed = reveals.tabIds; + reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 1 }); + applyPreviewServerSnapshot(ref, serverTab({ tabId: "tab_own", reveal: undefined })); + applyPreviewServerSnapshot(ref, makeSnapshot({ tabId: "tab_desktop", reveal: true })); + expect(revealed).toEqual([]); + }); +}); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index d885f302209c4..f80464e8ebd2a 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -98,20 +98,111 @@ function syncActivePreviewThread(threadKey: string, state: ThreadPreviewState): }); } -function updateThreadPreviewState( +function writeThreadPreviewState( ref: ScopedThreadRef, update: (current: ThreadPreviewState) => ThreadPreviewState, -): void { +): { readonly previous: ThreadPreviewState; readonly next: ThreadPreviewState } | null { const threadKey = scopedThreadKey(ref); const atom = previewStateAtom(threadKey); - let nextState = appAtomRegistry.get(atom); + let previousState = appAtomRegistry.get(atom); + let nextState = previousState; const changed = appAtomRegistry.modify(atom, (current) => { + previousState = current; nextState = update(current); return [nextState !== current, nextState]; }); - if (!changed) return; + if (!changed) return null; changedPreviewThreadKeys.add(threadKey); syncActivePreviewThread(threadKey, nextState); + return { previous: previousState, next: nextState }; +} + +/** + * Server tabs that are new, or carry a new reveal request, since `previous`. + * Nothing is fresh until the first list has loaded: that list is the baseline, + * so a reload never reopens previews the user dismissed. + */ +function freshlyRevealedServerTabs( + previous: ThreadPreviewState, + next: ThreadPreviewState, +): ReadonlyArray { + if (!previous.listLoaded) return []; + return Object.values(next.sessions).filter((session) => { + if (session.runtime !== "server" || session.reveal !== true) return false; + const before = previous.sessions[session.tabId]; + const requested = session.revealRequest; + return requested ? before?.revealRequest?.id !== requested.id : before === undefined; + }); +} + +type PreviewSessionsChangedListener = (ref: ScopedThreadRef, next: ThreadPreviewState) => void; + +const previewSessionsChangedListeners = new Set(); + +/** + * Panel tabs and the mini player follow the sessions held here. They live in + * stores this one cannot import (rightPanelStore -> closedViewStore -> + * lib/utils -> composerDraftStore -> previewStateStore is a cycle). + */ +export function onPreviewSessionsChanged(listener: PreviewSessionsChangedListener): () => void { + previewSessionsChangedListeners.add(listener); + return () => { + previewSessionsChangedListeners.delete(listener); + }; +} + +function notifyPreviewSessionsChanged( + ref: ScopedThreadRef, + result: { readonly previous: ThreadPreviewState; readonly next: ThreadPreviewState }, +): void { + if ( + result.previous.sessions === result.next.sessions && + result.previous.listLoaded === result.next.listLoaded + ) { + return; + } + for (const listener of previewSessionsChangedListeners) listener(ref, result.next); +} + +function updateThreadPreviewState( + ref: ScopedThreadRef, + update: (current: ThreadPreviewState) => ThreadPreviewState, +): void { + const result = writeThreadPreviewState(ref, update); + if (result) notifyPreviewSessionsChanged(ref, result); +} + +type ServerTabsRevealedListener = ( + ref: ScopedThreadRef, + sessions: ReadonlyArray, +) => void; + +const serverTabsRevealedListeners = new Set(); + +/** + * Where a revealed tab shows depends on client settings, which this store + * cannot import: lib/utils -> composerDraftStore -> previewStateStore -> + * hooks/useSettings -> ui/toast -> lib/utils is a cycle. + */ +export function onServerTabsRevealed(listener: ServerTabsRevealedListener): () => void { + serverTabsRevealedListeners.add(listener); + return () => { + serverTabsRevealedListeners.delete(listener); + }; +} + +function updateThreadPreviewStateFromServer( + ref: ScopedThreadRef, + update: (current: ThreadPreviewState) => ThreadPreviewState, +): void { + const result = writeThreadPreviewState(ref, update); + if (!result) return; + // Reveals run first so a revealed tab floats before it gains a panel tab. + const revealed = freshlyRevealedServerTabs(result.previous, result.next); + if (revealed.length > 0) { + for (const listener of serverTabsRevealedListeners) listener(ref, revealed); + } + notifyPreviewSessionsChanged(ref, result); } const dedupeRecentUrls = (existing: string[], url: string): string[] => { @@ -166,7 +257,7 @@ export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState } export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void { - updateThreadPreviewState(ref, (current) => { + updateThreadPreviewStateFromServer(ref, (current) => { if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current; if (event.revision < current.serverRevision) return current; const next = (() => { @@ -237,7 +328,7 @@ export function applyPreviewServerSnapshot( ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null, ): void { - updateThreadPreviewState(ref, (current) => { + updateThreadPreviewStateFromServer(ref, (current) => { if (!snapshot && current.snapshot === null) return current; if (!snapshot) { return { @@ -269,7 +360,7 @@ export function updatePreviewServerSnapshot( ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot, ): void { - updateThreadPreviewState(ref, (current) => { + updateThreadPreviewStateFromServer(ref, (current) => { if (current.suppressedTabIds.has(snapshot.tabId)) return current; const existing = current.sessions[snapshot.tabId]; if (existing && existing.updatedAt > snapshot.updatedAt) return current; @@ -292,7 +383,7 @@ export function reconcilePreviewServerSessions( ref: ScopedThreadRef, result: PreviewListResult, ): void { - updateThreadPreviewState(ref, (current) => { + updateThreadPreviewStateFromServer(ref, (current) => { const sameServer = current.serverEpoch === result.serverEpoch; if (sameServer && result.revision < current.serverRevision) { return current; From cc794770de36971bffafe4dde3cda286ed45e644 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:14:46 -0700 Subject: [PATCH 2/3] chore: lower the effect budget for the removed effects Co-Authored-By: Claude Opus 5.5 --- scripts/effect-budget.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/effect-budget.json b/scripts/effect-budget.json index 008ba170fd03f..a01c966f8b2fa 100644 --- a/scripts/effect-budget.json +++ b/scripts/effect-budget.json @@ -191,7 +191,7 @@ "apps/web/src/components/BranchToolbar.tsx": 4, "apps/web/src/components/BranchToolbarBranchSelector.tsx": 1, "apps/web/src/components/ChatMarkdown.tsx": 1, - "apps/web/src/components/ChatView.tsx": 23, + "apps/web/src/components/ChatView.tsx": 21, "apps/web/src/components/CommandPalette.tsx": 5, "apps/web/src/components/CommandPaletteContent.tsx": 1, "apps/web/src/components/ComposerPromptEditorTiptap.tsx": 13, @@ -333,7 +333,7 @@ "apps/web/src/components/preview/PreviewView.tsx": 7, "apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx": 3, "apps/web/src/components/preview/ZoomIndicator.tsx": 1, - "apps/web/src/components/preview/useAgentOpenedPreviews.ts": 3, + "apps/web/src/components/preview/useAgentOpenedPreviews.ts": 2, "apps/web/src/components/preview/usePreviewBridge.ts": 2, "apps/web/src/components/projectScriptEditor.tsx": 1, "apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx": 1, From 71918e7251e52136db18dc02b768a5e21ac16a21 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:21:35 -0700 Subject: [PATCH 3/3] chore: leave the effect budget to a separate sweep Co-Authored-By: Claude Opus 5.5 --- scripts/effect-budget.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/effect-budget.json b/scripts/effect-budget.json index a01c966f8b2fa..008ba170fd03f 100644 --- a/scripts/effect-budget.json +++ b/scripts/effect-budget.json @@ -191,7 +191,7 @@ "apps/web/src/components/BranchToolbar.tsx": 4, "apps/web/src/components/BranchToolbarBranchSelector.tsx": 1, "apps/web/src/components/ChatMarkdown.tsx": 1, - "apps/web/src/components/ChatView.tsx": 21, + "apps/web/src/components/ChatView.tsx": 23, "apps/web/src/components/CommandPalette.tsx": 5, "apps/web/src/components/CommandPaletteContent.tsx": 1, "apps/web/src/components/ComposerPromptEditorTiptap.tsx": 13, @@ -333,7 +333,7 @@ "apps/web/src/components/preview/PreviewView.tsx": 7, "apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx": 3, "apps/web/src/components/preview/ZoomIndicator.tsx": 1, - "apps/web/src/components/preview/useAgentOpenedPreviews.ts": 2, + "apps/web/src/components/preview/useAgentOpenedPreviews.ts": 3, "apps/web/src/components/preview/usePreviewBridge.ts": 2, "apps/web/src/components/projectScriptEditor.tsx": 1, "apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx": 1,