From a7eaba4bbfe2af87eeb35ea98459c9db404b1261 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 7 Jun 2026 13:02:05 -0700 Subject: [PATCH] Fix web tab close and respawn terminals when waking slept workspaces. Route tab close through host-backed vs local fallback logic, scope title tooltips away from the close button, and create host terminals when woke workspaces still have dead PTY rows. Co-authored-by: Cursor --- src/renderer/src/components/Terminal.tsx | 72 +------------- .../src/components/tab-bar/SortableTab.tsx | 32 +++---- .../tab-bar/tab-title-tooltip.test.tsx | 5 +- .../tab-group/useTabGroupWorkspaceModel.ts | 19 ++-- .../terminal/terminal-tab-actions.ts | 8 +- .../worktree-activation-created-agent.test.ts | 93 ++++++++++++++++++- src/renderer/src/lib/worktree-activation.ts | 36 +++++++ .../src/runtime/web-session-tabs-sync.test.ts | 20 ++++ .../src/runtime/web-session-tabs-sync.ts | 50 +++++++++- 9 files changed, 237 insertions(+), 98 deletions(-) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 7f5e4e89150..d703384e886 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -91,6 +91,7 @@ import { matchesRecentTabSwitcherChord } from '../../../shared/window-shortcut-p import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { useContextualTour } from './contextual-tours/use-contextual-tour' import { openTabBarEntry, type TabCreateEntryArgs } from './tab-bar/tab-create-entry-action' +import { closeTerminalTab } from './terminal/terminal-tab-actions' const EditorPanel = lazy(() => import('./editor/EditorPanel')) @@ -898,74 +899,9 @@ function Terminal(): React.JSX.Element | null { await openNewMarkdownInActiveWorkspace(targetGroupId) }, [activeWorktreeId, openNewMarkdownInActiveWorkspace]) - const handleCloseTab = useCallback( - (tabId: string) => { - const state = useAppStore.getState() - const owningWorktreeEntry = Object.entries(state.tabsByWorktree).find(([, worktreeTabs]) => - worktreeTabs.some((tab) => tab.id === tabId) - ) - const owningWorktreeId = owningWorktreeEntry?.[0] ?? null - - if (!owningWorktreeId) { - return - } - if (isPinnedVisibleTab(state, owningWorktreeId, tabId)) { - return - } - - if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { - void closeWebRuntimeSessionTab({ - worktreeId: owningWorktreeId, - tabId, - environmentId: activeRuntimeEnvironmentId - }) - return - } - - const currentTabs = state.tabsByWorktree[owningWorktreeId] ?? [] - if (currentTabs.length <= 1) { - closeTab(tabId) - if (state.activeWorktreeId === owningWorktreeId) { - // Why: only deactivate the worktree when no tabs of any kind remain. - // Editor files are a separate tab type; closing the last terminal tab - // should switch to the editor view instead of tearing down the workspace. - const worktreeFile = state.openFiles.find((f) => f.worktreeId === owningWorktreeId) - if (worktreeFile) { - setActiveFile(worktreeFile.id) - setActiveTabType('editor') - } else { - const browserTab = (state.browserTabsByWorktree[owningWorktreeId] ?? [])[0] - if (browserTab) { - setActiveBrowserTab(browserTab.id) - setActiveTabType('browser') - } else { - setActiveWorktree(null) - } - } - } - return - } - - // If closing the active tab in the active worktree, switch to a neighbor. - if (state.activeWorktreeId === owningWorktreeId && tabId === state.activeTabId) { - const idx = currentTabs.findIndex((t) => t.id === tabId) - const nextTab = currentTabs[idx + 1] ?? currentTabs[idx - 1] - if (nextTab) { - setActiveTab(nextTab.id) - } - } - closeTab(tabId) - }, - [ - activeRuntimeEnvironmentId, - closeTab, - setActiveBrowserTab, - setActiveTab, - setActiveFile, - setActiveTabType, - setActiveWorktree - ] - ) + const handleCloseTab = useCallback((tabId: string) => { + closeTerminalTab(tabId) + }, []) const handleCloseBrowserTab = useCallback( (tabId: string) => { diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 212dabc7291..c1b38c67775 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -341,8 +341,21 @@ export default function SortableTab({ className="h-5 w-[72px] min-w-[72px] max-w-[72px] mr-1 px-1 py-0 text-xs" spellCheck={false} /> - ) : ( + ) : isEditing || menuOpen ? ( {displayTitle} + ) : ( + + + {displayTitle} + + + {displayTitle} + + )} {tab.color && !isEditing && ( - {isEditing || menuOpen ? ( - tabRoot - ) : ( - - {tabRoot} - - {displayTitle} - - - )} + {tabRoot} { expectTooltipContent(markup, 'Custom terminal title') expect(markup).not.toContain('Runtime terminal title') + expect(markup).toContain('data-tooltip-trigger="true"') const root = openingTag(markup, 'data-testid', 'sortable-tab') - expect(root).toContain('data-tooltip-trigger="true"') expect(root).toContain('role="tab"') expect(root).toContain('tabindex="0"') }) @@ -274,7 +274,8 @@ describe('tab title tooltips', () => { expect(markup).toContain('data-agent-icon="claude"') expectTooltipContent(markup, 'Claude Code') - expect(markup).toContain('Claude Code') + expect(markup).toContain('data-tooltip-trigger="true"') + expect(markup).toContain('>Claude Code') expect(markup).not.toContain('data-shell-icon="generic"') expect(markup).not.toContain('>✳ Claude Code') }) diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index ebd50aa211e..5f12449102b 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -23,6 +23,7 @@ import { createWebRuntimeSessionTerminal, isWebRuntimeSessionActive } from '../../runtime/web-runtime-session' +import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { openTabBarEntry, type TabCreateEntryArgs } from '../tab-bar/tab-create-entry-action' export function recordTerminalTabGroupSplit(createdTerminal: TerminalTab | null | undefined): void { @@ -234,22 +235,24 @@ export function useTabGroupWorkspaceModel({ const runtimeEnvironmentId = useAppStore .getState() .settings?.activeRuntimeEnvironmentId?.trim() - if ( - (item.contentType === 'terminal' || item.contentType === 'browser') && - isWebRuntimeSessionActive(runtimeEnvironmentId) - ) { + if (item.contentType === 'terminal') { + closeTerminalTab(item.entityId) + if (!opts?.skipEmptyCheck) { + leaveWorktreeIfEmpty() + } + return + } + if (item.contentType === 'browser' && isWebRuntimeSessionActive(runtimeEnvironmentId)) { // Why: paired web clients mirror host-owned tabs. Closing locally races // the host session snapshot and leaves stale terminal/browser handles. void closeWebRuntimeSessionTab({ worktreeId, - tabId: item.contentType === 'browser' ? item.id : item.entityId, + tabId: item.id, environmentId: runtimeEnvironmentId }) return } - if (item.contentType === 'terminal') { - closeTab(item.entityId) - } else if (item.contentType === 'browser') { + if (item.contentType === 'browser') { destroyWorkspaceWebviews(useAppStore.getState().browserPagesByWorkspace, item.entityId) closeBrowserTab(item.entityId) } else { diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 5eba2fd7e39..f74b6626dda 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -117,7 +117,13 @@ export function closeTerminalTab(tabId: string): void { state.setActiveFile(worktreeFile.id) state.setActiveTabType('editor') } else { - state.setActiveWorktree(null) + const browserTab = (state.browserTabsByWorktree[owningWorktreeId] ?? [])[0] + if (browserTab) { + state.setActiveBrowserTab(browserTab.id) + state.setActiveTabType('browser') + } else { + state.setActiveWorktree(null) + } } } return diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index ef7da92e9c8..3e723801aa8 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Worktree } from '../../../shared/types' import { getDefaultSettings } from '../../../shared/constants' import { useAppStore } from '@/store' -import { activateAndRevealWorktree } from './worktree-activation' +import { + activateAndRevealWorktree, + ensureWebRuntimeWorktreeTerminalAfterWake +} from './worktree-activation' const initialAppStoreState = useAppStore.getState() @@ -380,4 +383,92 @@ describe('activateAndRevealWorktree created agent reopen', () => { expect(useAppStore.getState().activeWorktreeId).toBe(worktree.id) expect(callRuntimeEnvironment).not.toHaveBeenCalled() }) + + it('respawns a host terminal when waking a slept web workspace with dead local PTYs', async () => { + const worktree = makeWorktree() + const callRuntimeEnvironment = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { repoId: worktree.repoId, worktreeId: worktree.id, activated: true } + }) + .mockResolvedValueOnce({ + ok: true, + result: { tabId: 'host-tab-1', terminal: 'term_host' } + }) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: callRuntimeEnvironment, + subscribe: vi.fn() + } + } + }) + + useAppStore.setState({ + repos: [ + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { 'repo-1': [worktree] }, + activeRepoId: 'repo-1', + activeView: 'terminal', + activeWorktreeId: null, + tabsByWorktree: { + [worktree.id]: [ + { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: worktree.id, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'tab-1': [] }, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + activeTabTypeByWorktree: {}, + activeTabIdByWorktree: {}, + tabBarOrderByWorktree: {}, + settings: { + ...getDefaultSettings('/workspace/.orca-workspaces'), + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: 'web-runtime-1', + setupScriptLaunchMode: 'new-tab' + }, + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar: vi.fn(), + reconcileWorktreeTabModel: vi.fn(() => ({ + renderableTabCount: 1, + activeRenderableTabId: 'tab-1' + })) + }) + + ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(callRuntimeEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'web-runtime-1', + method: 'session.tabs.createTerminal' + }) + ) + }) }) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 045da895d7c..2bba0b482b2 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -15,8 +15,10 @@ import { tuiAgentToAgentKind } from './telemetry' import { agentKindToTuiAgent } from '../../../shared/agent-kind' import { useAppStore } from '@/store' import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' +import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { activateWebRuntimeSessionWorktree, + createWebRuntimeSessionTerminal, isWebRuntimeSessionActive } from '@/runtime/web-runtime-session' import { @@ -233,9 +235,43 @@ export function activateAndRevealWorktree( state.revealWorktreeInSidebar(worktreeId) } + ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId) + return { primaryTabId } } +export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): void { + const state = useAppStore.getState() + const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + if (!runtimeEnvironmentId || !isWebRuntimeSessionActive(runtimeEnvironmentId)) { + return + } + + const tabs = state.tabsByWorktree[worktreeId] ?? [] + if (tabs.length === 0) { + return + } + + const hasLivePty = tabs.some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) + if (hasLivePty) { + return + } + + const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId) + if (renderableTabCount === 0) { + return + } + + // Why: sleep keeps local tab rows but terminal.stop clears host PTYs, so + // activation alone can leave a woke workspace with tab chrome but no surface. + void createWebRuntimeSessionTerminal({ + worktreeId, + environmentId: runtimeEnvironmentId, + activate: true, + selectWorktree: false + }) +} + export function ensureWorktreeHasInitialTerminal( store: WorktreeActivationStore, worktreeId: string, diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 26173709104..3f6994394c8 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -16,6 +16,7 @@ import { resetWebSessionTabsSnapshotFreshnessForTests, shouldApplyWebSessionTabsSnapshot, shouldBootstrapInitialWebRuntimeTerminal, + shouldRespawnWebRuntimeTerminalAfterWake, type WebSessionTabsSyncState } from './web-session-tabs-sync' @@ -151,6 +152,25 @@ describe('applyWebSessionTabsSnapshot', () => { ).toBe(false) }) + it('respawns a terminal after wake when local slept tabs exist but the host snapshot is empty', () => { + const freshEmpty = makeSnapshot([], { + activeGroupId: null, + activeTabId: null, + activeTabType: null + }) + + expect( + shouldRespawnWebRuntimeTerminalAfterWake({ + event: { type: 'snapshot', ...freshEmpty }, + activeWorktreeId: WT, + requestedRespawnAfterWake: false, + snapshotIsFresh: true, + localTerminalCount: 1, + hasLiveLocalPty: false + }) + ).toBe(true) + }) + it('clears web session tracking maps when the host removes a worktree snapshot', () => { const workspace: BrowserWorkspace = { id: 'local-browser-workspace', diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 9110f0f2ab2..3840ee805e9 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -174,6 +174,30 @@ export function shouldBootstrapInitialWebRuntimeTerminal(args: { ) } +export function shouldRespawnWebRuntimeTerminalAfterWake(args: { + event: SessionTabsStreamEvent + activeWorktreeId: string + requestedRespawnAfterWake: boolean + snapshotIsFresh: boolean + localTerminalCount: number + hasLiveLocalPty: boolean +}): boolean { + if ( + !args.snapshotIsFresh || + args.requestedRespawnAfterWake || + args.localTerminalCount === 0 || + args.hasLiveLocalPty || + (args.event.type !== 'snapshot' && args.event.type !== 'updated') + ) { + return false + } + if (args.activeWorktreeId !== args.event.worktree) { + return false + } + const hostTerminalTabCount = args.event.tabs.filter((tab) => tab.type === 'terminal').length + return hostTerminalTabCount === 0 +} + export function resetWebSessionTabsSnapshotFreshnessForTests(): void { latestSessionTabsSnapshotByWorktree.clear() hostSessionTabIdByLocalKey.clear() @@ -2172,6 +2196,7 @@ export function useWebSessionTabsSync(): void { let disposed = false let requestedInitialTerminal = false + let requestedRespawnAfterWake = false let unsubscribe: (() => void) | null = null void window.api.runtimeEnvironments .subscribe( @@ -2195,13 +2220,26 @@ export function useWebSessionTabsSync(): void { return } const fresh = shouldApplyWebSessionTabsSnapshot(event, environmentId) + const syncState = useAppStore.getState() + const localWorktreeTabs = syncState.tabsByWorktree[activeWorktreeId] ?? [] + const localTerminalCount = localWorktreeTabs.length + const hasLiveLocalPty = localWorktreeTabs.some( + (tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0 + ) const shouldBootstrapInitialTerminal = shouldBootstrapInitialWebRuntimeTerminal({ event, activeWorktreeId, requestedInitialTerminal, snapshotIsFresh: fresh, - localTerminalCount: - useAppStore.getState().tabsByWorktree[activeWorktreeId]?.length ?? 0 + localTerminalCount + }) + const shouldRespawnAfterWake = shouldRespawnWebRuntimeTerminalAfterWake({ + event, + activeWorktreeId, + requestedRespawnAfterWake, + snapshotIsFresh: fresh, + localTerminalCount, + hasLiveLocalPty }) if (fresh) { useAppStore.setState((state) => @@ -2215,6 +2253,14 @@ export function useWebSessionTabsSync(): void { environmentId, activate: true }) + } else if (!disposed && shouldRespawnAfterWake) { + requestedRespawnAfterWake = true + void createWebRuntimeSessionTerminal({ + worktreeId: activeWorktreeId, + environmentId, + activate: true, + selectWorktree: false + }) } }, onError: (error) => {