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 <cursoragent@cursor.com>
This commit is contained in:
Jinwoo-H
2026-06-07 13:02:05 -07:00
co-authored by Cursor
parent 5cfce7510f
commit a7eaba4bbf
9 changed files with 237 additions and 98 deletions
+4 -68
View File
@@ -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) => {
@@ -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 ? (
<span className="truncate max-w-[72px] mr-1">{displayTitle}</span>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate max-w-[72px] mr-1">{displayTitle}</span>
</TooltipTrigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="max-w-80 whitespace-normal break-words text-left"
>
{displayTitle}
</TooltipContent>
</Tooltip>
)}
{tab.color && !isEditing && (
<span
@@ -370,7 +383,7 @@ export default function SortableTab({
)}
{!isEditing && !isPinned && (
<button
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
className={`relative z-10 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
isActive
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
@@ -403,20 +416,7 @@ export default function SortableTab({
setMenuOpen(true)
}}
>
{isEditing || menuOpen ? (
tabRoot
) : (
<Tooltip>
<TooltipTrigger asChild>{tabRoot}</TooltipTrigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="max-w-80 whitespace-normal break-words text-left"
>
{displayTitle}
</TooltipContent>
</Tooltip>
)}
{tabRoot}
</div>
<SortableTabContextMenu
@@ -243,8 +243,8 @@ describe('tab title tooltips', () => {
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('<span class="truncate max-w-[72px] mr-1">Claude Code</span>')
expect(markup).toContain('data-tooltip-trigger="true"')
expect(markup).toContain('>Claude Code</span>')
expect(markup).not.toContain('data-shell-icon="generic"')
expect(markup).not.toContain('>✳ Claude Code</span>')
})
@@ -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 {
@@ -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
@@ -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'
})
)
})
})
@@ -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,
@@ -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',
@@ -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) => {