mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 08:03:08 +00:00
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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user