From 2b474f7df9fde418a4557feff01447d875f41207 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 2 Aug 2026 14:32:17 +0200 Subject: [PATCH] Keep workspaces responsive across app-wide routes (#2791) * perf(app): retain inactive workspace screens App-wide routes cleared the active workspace selection, which removed the retained workspace deck from the React tree and made returning rebuild every panel. Keep mounted deck entries inactive across those routes and suspend inactive panel subscriptions so state is preserved without background work. * perf(app): suspend remaining inactive queries Retained composers and commit diff panels still subscribed through query hooks that did not receive panel activity. Propagate the existing activity gate so app-wide routes leave those hidden surfaces dormant. * refactor(app): separate panel activity from connectivity A hidden retained panel is still connected to its host. Keep connectivity truthful, pause work inside query and stream owners, and preserve terminal emulator identity while its stream is detached. * test(app): target active retained controls * fix(app): retain compact and terminal route content * fix(app): hide inactive workspace overlays --- ...w-workspace-codex-mode-preferences.spec.ts | 30 ++-- .../new-workspace-mode-cycle-safety.spec.ts | 6 +- .../browser/workspace-pane-remount.spec.ts | 159 +++++++++++++++++- packages/app/src/app/_layout.tsx | 23 ++- .../workspace/[workspaceId]/index.tsx | 28 +-- .../components/desktop-sidebar-layout.test.ts | 13 ++ .../src/components/desktop-sidebar-layout.ts | 16 ++ packages/app/src/components/terminal-pane.tsx | 84 ++++----- packages/app/src/git/use-commits-query.ts | 7 +- packages/app/src/git/use-diff-files.ts | 7 +- packages/app/src/git/use-diff-query.ts | 7 +- .../app/src/hooks/use-agent-commands-query.ts | 5 +- .../app/src/hooks/use-providers-snapshot.ts | 4 +- packages/app/src/panels/terminal-panel.tsx | 5 - .../workspace-deck-retention.test.ts | 15 ++ .../workspace/workspace-deck-retention.ts | 18 ++ .../screens/workspace/workspace-screen.tsx | 33 +++- 17 files changed, 355 insertions(+), 105 deletions(-) diff --git a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts index 33247ab60..05f636007 100644 --- a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts +++ b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts @@ -88,7 +88,7 @@ async function readCodexModePreference(page: Page): Promise { } async function selectMode(page: Page, label: string): Promise { - const modeControl = page.getByTestId("mode-control").first(); + const modeControl = page.getByRole("button", { name: /^Select agent mode \(/ }); await expect(modeControl).toBeVisible({ timeout: 30_000 }); await modeControl.click(); @@ -169,12 +169,14 @@ test.describe("New workspace Codex mode preferences", () => { projectDisplayName: seeded.projectDisplayName, }); - await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", { - timeout: 30_000, - }); + await expect( + page.getByRole("button", { name: "Select agent mode (Default permissions)" }), + ).toBeVisible({ timeout: 30_000 }); await expectThinkingOptionsFit(page); await selectMode(page, "Full access"); - await expect(page.getByTestId("mode-control").first()).toContainText("Full access"); + await expect( + page.getByRole("button", { name: "Select agent mode (Full access)" }), + ).toBeVisible(); await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally."); const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest(); @@ -210,14 +212,14 @@ test.describe("New workspace Codex mode preferences", () => { workspaceId: seeded.workspaceId, agentId: agent.id, }); - await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", { - timeout: 30_000, - }); + await expect( + page.getByRole("button", { name: "Select agent mode (Default permissions)" }), + ).toBeVisible({ timeout: 30_000 }); await selectMode(page, "Full access"); - await expect(page.getByTestId("mode-control").first()).toContainText("Full access", { - timeout: 30_000, - }); + await expect( + page.getByRole("button", { name: "Select agent mode (Full access)" }), + ).toBeVisible({ timeout: 30_000 }); await openGlobalNewWorkspaceComposer(page); await selectNewWorkspaceProject(page, { @@ -225,9 +227,9 @@ test.describe("New workspace Codex mode preferences", () => { projectDisplayName: seeded.projectDisplayName, }); - await expect(page.getByTestId("mode-control").first()).toContainText("Full access", { - timeout: 30_000, - }); + await expect( + page.getByRole("button", { name: "Select agent mode (Full access)" }), + ).toBeVisible({ timeout: 30_000 }); } finally { await seeded.cleanup(); } diff --git a/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts b/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts index 4ba076efa..61a58fee8 100644 --- a/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts +++ b/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts @@ -121,9 +121,9 @@ test.describe("New Workspace mode cycle safety", () => { // Mount the live agent tab: its mode control registers a mode-cycle keyboard handler. await openAgentRoute(page, { workspaceId: seeded.workspaceId, agentId: agent.id }); - await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", { - timeout: 30_000, - }); + await expect( + page.getByRole("button", { name: "Select agent mode (Default permissions)" }), + ).toBeVisible({ timeout: 30_000 }); // Move to the New Workspace composer. The agent tab stays mounted in the background, // so its handler is still registered when we cycle here. diff --git a/packages/app/e2e/browser/workspace-pane-remount.spec.ts b/packages/app/e2e/browser/workspace-pane-remount.spec.ts index e440c7df2..ac5ad25cf 100644 --- a/packages/app/e2e/browser/workspace-pane-remount.spec.ts +++ b/packages/app/e2e/browser/workspace-pane-remount.spec.ts @@ -1,10 +1,38 @@ -import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; +import type { Locator } from "@playwright/test"; import { expect, test } from "../support/fixtures"; import { createIdleAgent } from "../support/helpers/archive-tab"; import { expectComposerVisible } from "../support/helpers/composer"; +import { clickNewTerminal, terminalSurfaceLocator } from "../support/helpers/launcher"; +import { renameModalInput } from "../support/helpers/rename"; import { seedWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; -import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; +import { clickSettingsBackToWorkspace, openCompactSettings } from "../support/helpers/settings"; +import { openSettings } from "../support/helpers/app"; +import { + clickFirstTerminalTab, + waitForWorkspaceTabsVisible, +} from "../support/helpers/workspace-tabs"; +import { expectTerminalSurfaceVisible } from "../support/helpers/terminal-perf"; + +async function captureRenderedNode(locator: Locator) { + await expect(locator).toBeVisible({ timeout: 30_000 }); + const node = await locator.elementHandle(); + if (!node) { + throw new Error("Expected rendered node"); + } + return node; +} + +async function expectSameRenderedNode( + original: Awaited>, + locator: Locator, +) { + const current = await captureRenderedNode(locator); + await expect(original.evaluate((node, candidate) => node === candidate, current)).resolves.toBe( + true, + ); +} test.describe("Workspace pane mounting", () => { test("opening the first split pane keeps the existing agent composer mounted", async ({ @@ -49,4 +77,131 @@ test.describe("Workspace pane mounting", () => { await workspace.cleanup(); } }); + + test("opening Settings hides workspace overlays and keeps the agent composer mounted", async ({ + page, + }) => { + test.setTimeout(90_000); + const serverId = getServerId(); + const workspace = await seedWorkspace({ repoPrefix: "settings-pane-retention-" }); + + try { + const agent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: `settings-pane-retention-${Date.now()}`, + }); + + await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId)); + await waitForWorkspaceTabsVisible(page); + const composer = page.getByTestId("message-input-root").filter({ visible: true }).first(); + const originalComposer = await captureRenderedNode(composer); + + const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first(); + await tab.click({ button: "right" }); + await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click(); + const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`); + await expect(renameInput).toBeVisible(); + + const settingsShortcut = await page.evaluate(() => + navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,", + ); + await page.keyboard.press(settingsShortcut); + await expect(page).toHaveURL(/\/settings\/general$/); + await expect(renameInput).not.toBeVisible(); + await clickSettingsBackToWorkspace(page); + await expectSameRenderedNode(originalComposer, composer); + } finally { + await workspace.cleanup(); + } + }); + + test("opening Settings on a compact layout keeps the agent composer mounted", async ({ + page, + }) => { + test.setTimeout(90_000); + await page.setViewportSize({ width: 480, height: 900 }); + const serverId = getServerId(); + const workspace = await seedWorkspace({ repoPrefix: "compact-settings-pane-retention-" }); + + try { + const agent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: `compact-settings-pane-retention-${Date.now()}`, + }); + const agentRoute = buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId); + + await page.goto(agentRoute); + await expectComposerVisible(page); + const composer = page.getByTestId("message-input-root").filter({ visible: true }).first(); + const originalComposer = await captureRenderedNode(composer); + + await openCompactSettings(page, buildHostWorkspaceRoute(serverId, workspace.workspaceId)); + await page.goBack(); + await page.waitForURL( + (url) => url.pathname === buildHostWorkspaceRoute(serverId, workspace.workspaceId), + ); + await expectSameRenderedNode(originalComposer, composer); + } finally { + await workspace.cleanup(); + } + }); + + test("switching away from a terminal tab keeps its emulator mounted", async ({ page }) => { + test.setTimeout(90_000); + const serverId = getServerId(); + const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" }); + + try { + const agent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: `terminal-pane-retention-${Date.now()}`, + }); + + await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId)); + await waitForWorkspaceTabsVisible(page); + await clickNewTerminal(page); + await expectTerminalSurfaceVisible(page); + const terminalSurface = terminalSurfaceLocator(page); + const originalTerminal = await captureRenderedNode(terminalSurface); + + await page.getByTestId(`workspace-tab-agent_${agent.id}`).click(); + await expectComposerVisible(page); + expect(await originalTerminal.evaluate((node) => node.isConnected)).toBe(true); + + await clickFirstTerminalTab(page); + await expectSameRenderedNode(originalTerminal, terminalSurface); + } finally { + await workspace.cleanup(); + } + }); + + test("opening Settings and returning keeps the terminal emulator mounted", async ({ page }) => { + test.setTimeout(90_000); + const serverId = getServerId(); + const workspace = await seedWorkspace({ repoPrefix: "settings-terminal-retention-" }); + + try { + const agent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: `settings-terminal-retention-${Date.now()}`, + }); + + await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId)); + await waitForWorkspaceTabsVisible(page); + await clickNewTerminal(page); + await expectTerminalSurfaceVisible(page); + const terminalSurface = terminalSurfaceLocator(page); + const originalTerminal = await captureRenderedNode(terminalSurface); + + await openSettings(page); + await clickSettingsBackToWorkspace(page); + await expectSameRenderedNode(originalTerminal, terminalSurface); + } finally { + await workspace.cleanup(); + } + }); }); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index babbdce14..12d8b289b 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -52,6 +52,7 @@ import { canDesktopAppSidebarShare, resolveDesktopAppChromeLayout, resolveDesktopAppContentMinimum, + resolveDesktopSidebarVisibility, } from "@/components/desktop-sidebar-layout"; import { isNative, isWeb } from "@/constants/platform"; import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context"; @@ -78,6 +79,7 @@ import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source"; import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source"; import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action"; import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action"; +import { useLatchedBoolean } from "@/hooks/use-latched-boolean"; import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style"; @@ -450,6 +452,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null; const isWorkspaceFocusModeEnabled = isWorkspaceRoute && isFocusModeEnabled; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; + const hasMountedDesktopSidebar = useLatchedBoolean(chromeEnabled); const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList; const toggleDesktopSidebars = useCallback(() => { const { desktop } = usePanelStore.getState(); @@ -491,16 +494,18 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon requestedExplorerWidth: explorerWidth, viewportWidth, }); - const desktopSidebarMounted = chromeEnabled && !isWorkspaceFocusModeEnabled; - const desktopSidebarVisible = - !isCompactLayout && - desktopSidebarMounted && - isDesktopAgentListOpen && - canDesktopAppSidebarShare({ + const desktopSidebarMounted = hasMountedDesktopSidebar && !isWorkspaceFocusModeEnabled; + const desktopSidebarVisible = resolveDesktopSidebarVisibility({ + chromeEnabled, + isCompactLayout, + isMounted: desktopSidebarMounted, + isOpen: isDesktopAgentListOpen, + canShare: canDesktopAppSidebarShare({ contentMinimumWidth: appContentMinimumWidth, requestedSidebarWidth: sidebarWidth, viewportWidth, - }); + }), + }); const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left"); const appChromeLayout = resolveDesktopAppChromeLayout({ desktopSidebarRendered: desktopSidebarVisible, @@ -521,9 +526,9 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon {sidebarChrome} ) : null} - {isCompactLayout && chromeEnabled ? ( + {isCompactLayout ? ( - + {children} diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index 6ecc445e7..26609caa1 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -18,6 +18,7 @@ import { getWorkspaceSelectionKey, orderWorkspaceSelectionsForStableRender, pruneMountedWorkspaceSelections, + resolveWorkspaceDeckEntries, shouldKeepWorkspaceDeckEntryMounted, WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES, } from "@/screens/workspace/workspace-deck-retention"; @@ -222,6 +223,10 @@ function WorkspaceDeck({ () => orderWorkspaceSelectionsForStableRender(nextMountedSelections), [nextMountedSelections], ); + const renderedEntries = useMemo( + () => resolveWorkspaceDeckEntries({ selections: renderedSelections, activeSelection }), + [activeSelection, renderedSelections], + ); useLayoutEffect(() => { if (!areWorkspaceSelectionListsEqual(mountedSelections, nextMountedSelections)) { @@ -229,18 +234,14 @@ function WorkspaceDeck({ } }, [mountedSelections, nextMountedSelections]); - if (!activeSelection) { - return null; - } - return ( - {renderedSelections.map((selection) => { + {renderedEntries.map(({ selection, active }) => { return ( void; }) { - const isActive = areWorkspaceSelectionsEqual(selection, activeSelection); const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId); const workspaceExists = useWorkspaceExists(selection.serverId, selection.workspaceId); const shouldKeepMounted = shouldKeepWorkspaceDeckEntryMounted({ - isActive, + isActive: active, hasHydratedWorkspaces, workspaceExists, }); @@ -285,15 +285,15 @@ function WorkspaceDeckEntry({ return ( ); diff --git a/packages/app/src/components/desktop-sidebar-layout.test.ts b/packages/app/src/components/desktop-sidebar-layout.test.ts index 98f226005..f307ccffd 100644 --- a/packages/app/src/components/desktop-sidebar-layout.test.ts +++ b/packages/app/src/components/desktop-sidebar-layout.test.ts @@ -4,10 +4,23 @@ import { resolveDesktopAppChromeLayout, resolveDesktopAppContentMinimum, resolveDesktopExplorerWidth, + resolveDesktopSidebarVisibility, resolveDesktopSidebarWidth, } from "@/components/desktop-sidebar-layout"; describe("desktop sidebar layout", () => { + it("keeps a retained sidebar hidden while app chrome is suppressed", () => { + expect( + resolveDesktopSidebarVisibility({ + chromeEnabled: false, + isCompactLayout: false, + isMounted: true, + isOpen: true, + canShare: true, + }), + ).toBe(false); + }); + it("keeps the sidebar toggle window-owned beside left window controls", () => { expect( resolveDesktopAppChromeLayout({ diff --git a/packages/app/src/components/desktop-sidebar-layout.ts b/packages/app/src/components/desktop-sidebar-layout.ts index fc0a07d82..b2d910120 100644 --- a/packages/app/src/components/desktop-sidebar-layout.ts +++ b/packages/app/src/components/desktop-sidebar-layout.ts @@ -8,6 +8,22 @@ import { export const MIN_DESKTOP_CENTER_WIDTH = 400; +export function resolveDesktopSidebarVisibility(input: { + chromeEnabled: boolean; + isCompactLayout: boolean; + isMounted: boolean; + isOpen: boolean; + canShare: boolean; +}): boolean { + return ( + input.chromeEnabled && + !input.isCompactLayout && + input.isMounted && + input.isOpen && + input.canShare + ); +} + export function resolveDesktopAppChromeLayout(input: { desktopSidebarRendered: boolean; hasTopLeftWindowControls: boolean; diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index bba561452..e32644673 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -1,4 +1,5 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; @@ -172,6 +173,7 @@ export function TerminalPane({ onOpenWorkspaceFile, }: TerminalPaneProps) { const { t } = useTranslation(); + const retainedPanelActive = useRetainedPanelActive(); const isAppActivelyVisible = useAppActivelyVisible(); const { theme } = useUnistyles(); const { settings } = useAppSettings(); @@ -191,6 +193,7 @@ export function TerminalPane({ const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); + const isTerminalActive = retainedPanelActive && isWorkspaceFocused; const supportsTerminalRestoreModes = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true, ); @@ -215,6 +218,8 @@ export function TerminalPane({ const [resizeRequestToken, setResizeRequestToken] = useState(0); const emulatorRef = useRef(null); const terminalIdRef = useRef(terminalId); + const terminalActiveRef = useRef(isTerminalActive); + terminalActiveRef.current = isTerminalActive; const inputModeRef = useRef({ kittyKeyboardFlags: 0, win32InputMode: false, @@ -356,7 +361,7 @@ export function TerminalPane({ ); useEffect(() => { - if (!client || !isConnected || !isWorkspaceFocused) { + if (!client || !isConnected || !isTerminalActive) { return; } @@ -379,7 +384,7 @@ export function TerminalPane({ }); setModifiers({ ...EMPTY_MODIFIERS }); }); - }, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]); + }, [client, isConnected, isTerminalActive, workspaceTerminalSession.snapshots]); useEffect(() => { measuredTerminalSizeRef.current = null; @@ -405,21 +410,21 @@ export function TerminalPane({ client, getPreferredSize: () => lastSentTerminalSizeRef.current, onOutput: ({ terminalId: outputTerminalId, data }) => { - if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) { + if (!terminalActiveRef.current || terminalIdRef.current !== outputTerminalId) { return; } emulatorRef.current?.writeOutput(data); }, onRestore: ({ terminalId: restoreTerminalId, data }) => { workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId }); - if (!isWorkspaceFocused || terminalIdRef.current !== restoreTerminalId) { + if (!terminalActiveRef.current || terminalIdRef.current !== restoreTerminalId) { return; } emulatorRef.current?.restoreOutput(data); }, onSnapshot: ({ terminalId: snapshotTerminalId, state }) => { workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state }); - if (!isWorkspaceFocused || terminalIdRef.current !== snapshotTerminalId) { + if (!terminalActiveRef.current || terminalIdRef.current !== snapshotTerminalId) { return; } emulatorRef.current?.renderSnapshot(state); @@ -435,7 +440,7 @@ export function TerminalPane({ streamControllerRef.current = controller; controller.setTerminal({ - terminalId: isWorkspaceFocused ? terminalIdRef.current : null, + terminalId: terminalActiveRef.current ? terminalIdRef.current : null, }); return () => { @@ -448,18 +453,17 @@ export function TerminalPane({ client, handleStreamControllerStatus, isConnected, - isWorkspaceFocused, supportsTerminalRestoreModes, workspaceTerminalSession.snapshots, ]); useEffect(() => { pendingTerminalInputRef.current = []; - const nextTerminalId = isWorkspaceFocused ? terminalId : null; + const nextTerminalId = isTerminalActive ? terminalId : null; streamControllerRef.current?.setTerminal({ terminalId: nextTerminalId, }); - }, [isWorkspaceFocused, terminalId]); + }, [isTerminalActive, terminalId]); const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => { const queue = pendingTerminalInputRef.current; @@ -778,7 +782,7 @@ export function TerminalPane({ onOpenFileExplorer(); }, [swipeGesturesEnabled, onOpenFileExplorer]); const showLoadingOverlay = shouldShowTerminalLoadingOverlay({ - isWorkspaceFocused, + isWorkspaceFocused: isTerminalActive, hasStreamError: Boolean(streamError), isAttaching, rendererReadyStreamKey, @@ -796,38 +800,34 @@ export function TerminalPane({ return ( - {isWorkspaceFocused ? ( - - - - ) : ( - - )} + + + {showLoadingOverlay ? ( diff --git a/packages/app/src/git/use-commits-query.ts b/packages/app/src/git/use-commits-query.ts index a1c5edbff..bb77f5094 100644 --- a/packages/app/src/git/use-commits-query.ts +++ b/packages/app/src/git/use-commits-query.ts @@ -1,5 +1,6 @@ import type { CheckoutCommit } from "@getpaseo/protocol/messages"; import invariant from "tiny-invariant"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { useFetchQuery } from "@/data/query"; import { checkoutCommitsQueryKey } from "@/git/query-keys"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; @@ -72,6 +73,8 @@ export function useCheckoutCommitsQuery({ cwd, enabled = true, }: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult { + const retainedPanelActive = useRetainedPanelActive(); + const queryEnabledByCaller = enabled && retainedPanelActive; const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); // COMPAT(commitsList): added in v0.1.110, remove after 2027-01-16. @@ -84,7 +87,7 @@ export function useCheckoutCommitsQuery({ ); const canFetch = Boolean(cwd) && Boolean(client) && isConnected; - const queryEnabled = enabled && capabilityPresent && canFetch; + const queryEnabled = queryEnabledByCaller && capabilityPresent && canFetch; const query = useFetchQuery({ queryKey: checkoutCommitsQueryKey(serverId, cwd), @@ -105,7 +108,7 @@ export function useCheckoutCommitsQuery({ }); return resolveCheckoutCommitsQueryResult({ - enabled, + enabled: queryEnabledByCaller, capabilityPresent, canFetch, data: query.data, diff --git a/packages/app/src/git/use-diff-files.ts b/packages/app/src/git/use-diff-files.ts index a154933a8..14a17edcb 100644 --- a/packages/app/src/git/use-diff-files.ts +++ b/packages/app/src/git/use-diff-files.ts @@ -1,5 +1,6 @@ import { useMemo } from "react"; import type { CheckoutCommitFile, ParsedDiffFile } from "@getpaseo/protocol/messages"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { useFetchQueries } from "@/data/query"; import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "@/git/query-keys"; import { useCheckoutCommitsQuery } from "@/git/use-commits-query"; @@ -57,9 +58,11 @@ export function resolveCommitDiffFiles( export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFilesResult { const { serverId, cwd, sha, enabled = true } = ctx; + const retainedPanelActive = useRetainedPanelActive(); + const queryEnabled = enabled && retainedPanelActive; const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - const commitsQuery = useCheckoutCommitsQuery({ serverId, cwd, enabled }); + const commitsQuery = useCheckoutCommitsQuery({ serverId, cwd, enabled: queryEnabled }); const commitsData = commitsQuery.status === "loaded" ? commitsQuery.data : null; const commitFiles = useMemo(() => { if (!sha || !commitsData) { @@ -69,7 +72,7 @@ export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFiles }, [commitsData, sha]); const fileDiffsEnabled = - enabled && + queryEnabled && commitsQuery.status === "loaded" && Boolean(cwd) && Boolean(sha) && diff --git a/packages/app/src/git/use-diff-query.ts b/packages/app/src/git/use-diff-query.ts index 93ea35c70..7002bbfc5 100644 --- a/packages/app/src/git/use-diff-query.ts +++ b/packages/app/src/git/use-diff-query.ts @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { useReplicaQuery } from "@/data/query"; import { checkoutDiffPushRoute } from "@/data/push-router"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; @@ -47,6 +48,8 @@ export function useCheckoutDiffQuery({ enabled = true, queryScope, }: UseCheckoutDiffQueryOptions) { + const retainedPanelActive = useRetainedPanelActive(); + const queryEnabled = enabled && retainedPanelActive; const isConnected = useHostRuntimeIsConnected(serverId); const normalizedCompare = useMemo( () => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }), @@ -67,7 +70,7 @@ export function useCheckoutDiffQuery({ return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey; }, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]); const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]); - const routeEnabled = Boolean(enabled && isConnected && cwd); + const routeEnabled = Boolean(queryEnabled && isConnected && cwd); const query = useReplicaQuery({ queryKey, @@ -93,7 +96,7 @@ export function useCheckoutDiffQuery({ files: payload?.files ?? [], payloadError, diffTooLarge: payload?.diffTooLarge === true, - isLoading: payload === null && enabled && isConnected, + isLoading: payload === null && queryEnabled && isConnected, isFetching: false, isError: Boolean(payloadError), error: null, diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index 22afaed3e..e57184f53 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { agentCommandsQueryKey, type AgentCommandsDraftConfig } from "@/hooks/agent-commands-query"; @@ -51,6 +52,8 @@ export function useAgentCommandsQuery({ draftConfig, }: UseAgentCommandsQueryOptions) { const { t } = useTranslation(); + const retainedPanelActive = useRetainedPanelActive(); + const queryEnabled = enabled && retainedPanelActive; const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -62,7 +65,7 @@ export function useAgentCommandsQuery({ } return fetchAgentCommands({ client, agentId, draftConfig }); }, - enabled: enabled && !!client && isConnected && (!!agentId || !!draftConfig), + enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig), staleTime: draftConfig ? DRAFT_COMMANDS_STALE_TIME : SESSION_COMMANDS_STALE_TIME, retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000), diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 676baf20c..a7597b07c 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -3,6 +3,7 @@ import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-q import { useTranslation } from "react-i18next"; import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { useReplicaQuery } from "@/data/query"; @@ -95,10 +96,11 @@ export function useProvidersSnapshot( options: UseProvidersSnapshotOptions = {}, ): UseProvidersSnapshotResult { const { t } = useTranslation(); + const retainedPanelActive = useRetainedPanelActive(); const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId ?? ""); + const enabled = (options.enabled ?? true) && retainedPanelActive; const isConnected = useHostRuntimeIsConnected(serverId ?? ""); - const enabled = options.enabled ?? true; const cwd = normalizeProvidersSnapshotCwd(options.cwd); const supportsSnapshot = useSessionStore( (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true, diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 29f116a9a..0eedd14d2 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -17,7 +17,6 @@ import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-stor type ListTerminalsPayload = ListTerminalsResponse["payload"]; -const FLEX_FILL_STYLE = { flex: 1 } as const; const CENTERED_PADDED_STYLE = { flex: 1, alignItems: "center", @@ -97,10 +96,6 @@ function TerminalPanel() { }, [isGitCheckout, openFileExplorerForCheckout, serverId, workspaceDirectory]); invariant(target.kind === "terminal", "TerminalPanel requires terminal target"); - if (!isWorkspaceFocused) { - return ; - } - if (!workspaceDirectory) { return ( diff --git a/packages/app/src/screens/workspace/workspace-deck-retention.test.ts b/packages/app/src/screens/workspace/workspace-deck-retention.test.ts index 454691f2e..0dfffe785 100644 --- a/packages/app/src/screens/workspace/workspace-deck-retention.test.ts +++ b/packages/app/src/screens/workspace/workspace-deck-retention.test.ts @@ -3,6 +3,7 @@ import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-worksp import { orderWorkspaceSelectionsForStableRender, pruneMountedWorkspaceSelections, + resolveWorkspaceDeckEntries, shouldKeepWorkspaceDeckEntryMounted, } from "@/screens/workspace/workspace-deck-retention"; @@ -92,6 +93,20 @@ describe("orderWorkspaceSelectionsForStableRender", () => { }); }); +describe("resolveWorkspaceDeckEntries", () => { + it("keeps retained workspaces rendered but inactive on an app-wide route", () => { + expect( + resolveWorkspaceDeckEntries({ + selections: [workspace("A"), workspace("B")], + activeSelection: null, + }), + ).toEqual([ + { selection: workspace("A"), active: false }, + { selection: workspace("B"), active: false }, + ]); + }); +}); + describe("shouldKeepWorkspaceDeckEntryMounted", () => { it("keeps the active workspace mounted even when it is missing from hydrated workspaces", () => { expect( diff --git a/packages/app/src/screens/workspace/workspace-deck-retention.ts b/packages/app/src/screens/workspace/workspace-deck-retention.ts index 7d0268d01..62d4ab478 100644 --- a/packages/app/src/screens/workspace/workspace-deck-retention.ts +++ b/packages/app/src/screens/workspace/workspace-deck-retention.ts @@ -14,6 +14,11 @@ interface WorkspaceDeckEntryMountInput { workspaceExists: boolean; } +interface ResolveWorkspaceDeckEntriesInput { + selections: ActiveWorkspaceSelection[]; + activeSelection: ActiveWorkspaceSelection | null; +} + export function getWorkspaceSelectionKey(selection: ActiveWorkspaceSelection): string { return `${selection.serverId}:${selection.workspaceId}`; } @@ -82,6 +87,19 @@ export function orderWorkspaceSelectionsForStableRender( ); } +export function resolveWorkspaceDeckEntries({ + selections, + activeSelection, +}: ResolveWorkspaceDeckEntriesInput): Array<{ + selection: ActiveWorkspaceSelection; + active: boolean; +}> { + return selections.map((selection) => ({ + selection, + active: areWorkspaceSelectionsEqual(selection, activeSelection), + })); +} + export function shouldKeepWorkspaceDeckEntryMounted({ isActive, hasHydratedWorkspaces, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 9126a4a3b..01abafadf 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -102,7 +102,7 @@ import { useHostRuntimeSnapshot, useHosts, } from "@/runtime/host-runtime"; -import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; @@ -1770,6 +1770,9 @@ function WorkspaceScreenContent({ const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); + const supportsProvidersSnapshot = useSessionStore( + (state) => state.sessions[normalizedServerId]?.serverInfo?.features?.providersSnapshot === true, + ); const workspaceDirectory = workspaceDescriptor?.workspaceDirectory || null; const isMissingWorkspaceDirectory = Boolean(workspaceDescriptor) && !workspaceDirectory; const [isImportSheetVisible, setIsImportSheetVisible] = useState(false); @@ -1781,11 +1784,25 @@ function WorkspaceScreenContent({ setIsImportSheetVisible(false); }, []); - // Warm the workspace-scoped provider snapshot so the model picker is ready when opened. - useProvidersSnapshot(normalizedServerId, { - cwd: workspaceDirectory, - enabled: isRouteFocused, - }); + useEffect(() => { + if ( + !isRouteFocused || + !isConnected || + !client || + !workspaceDirectory || + !supportsProvidersSnapshot + ) { + return; + } + prefetchProvidersSnapshot(normalizedServerId, client, { cwd: workspaceDirectory }); + }, [ + client, + isConnected, + isRouteFocused, + normalizedServerId, + supportsProvidersSnapshot, + workspaceDirectory, + ]); const persistenceKey = useMemo( () => @@ -3870,7 +3887,7 @@ function WorkspaceScreenContent({ {workspaceCenterColumn}