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) => {