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}