- {/* Why: columns share the window width up to a readable cap; mx-auto
+ // Why: the pop-out is its own React root with no app-level provider, and the
+ // card's repo tooltip needs one in both hosts. Nesting inside the main
+ // window's provider is harmless.
+
+
+
+
+ {translate('dashboardPopout.title', 'Agents')}
+
+
+ {translate('dashboardPopout.total', '{{count}} total', {
+ count: snapshot.cards.length
+ })}
+
+ {headerActions || onClose ? (
+
+ {headerActions}
+ {onClose ? (
+
+
+
+ ) : null}
+
+ ) : null}
+
+
+ {/* Why: columns share the window width up to a readable cap; mx-auto
centers the capped board so leftover space splits evenly instead of
pooling on the right. In overflow the auto margins collapse to 0,
keeping the left edge reachable while scrolling. */}
-
- {DASHBOARD_BUCKET_ORDER.map((bucket) => (
-
- ))}
+
+ {DASHBOARD_BUCKET_ORDER.map((bucket) => (
+
+ ))}
+
+
-
-
+
)
}
diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx
index ebcfee3d26b..16739e15d99 100644
--- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx
+++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx
@@ -4,7 +4,9 @@ import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
+import type { RepoIcon } from '../../../../shared/repo-icon'
import { i18n } from '@/i18n/i18n'
+import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentKanbanCard } from './AgentKanbanCard'
const agentIconRender = vi.fn()
@@ -42,6 +44,24 @@ function card(overrides: Partial
= {}): DashboardCard {
}
}
+function renderCard(props: {
+ card: DashboardCard
+ now: number
+ repoIcon?: RepoIcon | null
+ onOpenTerminal?: () => void
+}): ReturnType {
+ return render(
+
+
+
+ )
+}
+
describe('AgentKanbanCard', () => {
beforeEach(async () => {
await i18n.changeLanguage('en')
@@ -53,9 +73,7 @@ describe('AgentKanbanCard', () => {
})
it('does not render an invented age when the start time is unknown', () => {
- render(
-
- )
+ renderCard({ card: card({ startedAt: 0 }), now: 2_000_000_000 })
expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument()
})
@@ -66,47 +84,160 @@ describe('AgentKanbanCard', () => {
dotState: 'waiting',
askSummary: 'Approve deploy?'
})
- const { container, rerender } = render(
-
- )
+ const { container, rerender } = renderCard({ card: attentionCard, now: 2_000 })
expect(screen.queryByTestId('state-dot')).not.toBeInTheDocument()
expect(container.querySelectorAll('.lucide-message-circle-question-mark')).toHaveLength(1)
rerender(
-
+
+
+
)
expect(screen.getByTestId('state-dot')).toBeInTheDocument()
expect(container.querySelector('.lucide-message-circle-question-mark')).toBeNull()
})
+ it('tints attention amber and done green, leaving every other state neutral', () => {
+ const { container: attention } = renderCard({
+ card: card({ bucket: 'attention', dotState: 'waiting' }),
+ now: 2_000
+ })
+ expect(attention.querySelector('button')?.className).toContain('border-amber-500/40')
+
+ cleanup()
+ const { container: done } = renderCard({
+ card: card({ bucket: 'idle', dotState: 'done' }),
+ now: 2_000
+ })
+ expect(done.querySelector('button')?.className).toContain('border-emerald-500/40')
+
+ cleanup()
+ const { container: idle } = renderCard({
+ card: card({ bucket: 'idle', dotState: 'idle' }),
+ now: 2_000
+ })
+ const idleClassName = idle.querySelector('button')?.className ?? ''
+ expect(idleClassName).toContain('border-border/60')
+ expect(idleClassName).not.toContain('emerald')
+ expect(idleClassName).not.toContain('amber')
+ })
+
+ it('heads the card with the conversation name and drops the worktree to the footer', () => {
+ const { container } = renderCard({
+ card: card({ lastUserMessage: 'ship it', conversationName: 'Sparse-checkout parser' }),
+ now: 2_000
+ })
+
+ const [header, footer] = [
+ container.querySelector('button')!.firstElementChild!,
+ container.querySelector('button')!.lastElementChild!
+ ]
+ expect(header).toHaveTextContent('Sparse-checkout parser')
+ expect(header).not.toHaveTextContent('dashboard-review')
+ expect(footer).toHaveTextContent('dashboard-review')
+ // The message line is attributed to the user again — the name moved up.
+ expect(screen.getByText('You')).toBeInTheDocument()
+ })
+
+ it('heads the card with the worktree when no name resolves, without repeating it', () => {
+ const { container } = renderCard({ card: card({ lastUserMessage: 'ship it' }), now: 2_000 })
+
+ expect(screen.getAllByText('dashboard-review')).toHaveLength(1)
+ expect(container.querySelector('button')!.firstElementChild).toHaveTextContent(
+ 'dashboard-review'
+ )
+ })
+
+ it('shows the repo as an icon labelled with its name instead of inline text', () => {
+ renderCard({
+ card: card(),
+ now: 2_000,
+ repoIcon: { type: 'emoji', emoji: '🐳' }
+ })
+
+ expect(screen.getByLabelText('Orca')).toBeInTheDocument()
+ expect(screen.getByText('🐳')).toBeInTheDocument()
+ })
+
it('skips structured-clone rerenders until visible card data or its age changes', () => {
const onOpenTerminal = vi.fn()
const initial = card({ startedAt: 1_000 })
+ const repoIcon: RepoIcon = { type: 'lucide', name: 'Rocket' }
const { rerender } = render(
-
+
+
+
)
expect(agentIconRender).toHaveBeenCalledTimes(1)
expect(screen.getByText('1m')).toBeInTheDocument()
- rerender( )
+ // A fresh structured clone of identical data — including the repo icon.
+ rerender(
+
+
+
+ )
expect(agentIconRender).toHaveBeenCalledTimes(1)
rerender(
-
+
+
+
)
expect(agentIconRender).toHaveBeenCalledTimes(2)
expect(screen.getByText('2m')).toBeInTheDocument()
})
- it('updates the relative age when the UI language changes', async () => {
- render(
-
+ it('rerenders when the repo icon changes', () => {
+ const onOpenTerminal = vi.fn()
+ const initial = card({ startedAt: 1_000 })
+ const { rerender } = render(
+
+
+
)
+ expect(agentIconRender).toHaveBeenCalledTimes(1)
+
+ rerender(
+
+
+
+ )
+ expect(agentIconRender).toHaveBeenCalledTimes(2)
+ })
+
+ it('updates the relative age when the UI language changes', async () => {
+ renderCard({ card: card({ startedAt: 1_000 }), now: 121_500 })
expect(screen.getByText('2m')).toBeInTheDocument()
await act(async () => {
diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx
index e8d08b525d6..9262077e17d 100644
--- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx
+++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx
@@ -4,8 +4,11 @@ import { MessageCircleQuestion } from 'lucide-react'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import { AgentStateDot } from '@/components/AgentStateDot'
+import { RepoIconGlyph } from '@/components/repo/repo-icon'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
+import type { RepoIcon } from '../../../../shared/repo-icon'
import { translate } from '@/i18n/i18n'
/** Compact "started N ago" (the card is glanceable — coarse units are fine). */
@@ -53,12 +56,34 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean {
a.finishedAt === b.finishedAt &&
a.stateChangedAt === b.stateChangedAt &&
a.unseen === b.unseen &&
- a.askSummary === b.askSummary
+ a.askSummary === b.askSummary &&
+ a.conversationName === b.conversationName
)
}
+/** Structural — the icon arrives inside a fresh structured clone each publish,
+ * so identity alone would re-render every card several times a second. */
+function sameRepoIcon(a: RepoIcon | null | undefined, b: RepoIcon | null | undefined): boolean {
+ if (a === b) {
+ return true
+ }
+ if (!a || !b || a.type !== b.type) {
+ return false
+ }
+ if (a.type === 'lucide') {
+ return a.name === (b as typeof a).name
+ }
+ if (a.type === 'emoji') {
+ return a.emoji === (b as typeof a).emoji
+ }
+ const image = b as typeof a
+ return a.src === image.src && a.source === image.source && a.label === image.label
+}
+
type AgentKanbanCardProps = {
card: DashboardCard
+ /** The card repo's icon. null renders the default folder glyph. */
+ repoIcon?: RepoIcon | null
now: number
/** Opens the board-level terminal dialog. The dialog is NOT owned by the
* card: bucket moves remount the card, and an embedded dialog would close
@@ -68,8 +93,23 @@ type AgentKanbanCardProps = {
/** One agent on the kanban board. Clicking opens the board's live terminal dialog. */
export const AgentKanbanCard = memo(
- function AgentKanbanCard({ card, now, onOpenTerminal }: AgentKanbanCardProps): React.JSX.Element {
+ function AgentKanbanCard({
+ card,
+ repoIcon = null,
+ now,
+ onOpenTerminal
+ }: AgentKanbanCardProps): React.JSX.Element {
useTranslation()
+ // Why: the two outcomes worth scanning for get a tinted card — amber for
+ // "answer me", green for "finished, look at it". Everything else stays
+ // neutral so the tint keeps meaning something.
+ const needsYou = card.bucket === 'attention'
+ const isDone = card.dotState === 'done'
+ // Why: the session's own name heads the card. Without one the worktree is
+ // the best heading left — and then the footer drops it rather than say it
+ // twice.
+ const heading = card.conversationName ?? card.worktreeName
+ const worktreeInFooter = card.conversationName !== undefined
return (
@@ -99,7 +143,7 @@ export const AgentKanbanCard = memo(
card.unseen ? 'font-semibold text-foreground' : 'font-normal text-muted-foreground'
)}
>
- {card.worktreeName}
+ {heading}
{/* The summary pill already carries the attention glyph. */}
{card.askSummary ? null :
}
@@ -109,6 +153,7 @@ export const AgentKanbanCard = memo(
{card.lastUserMessage ? (
+ {/* Why: plain "You" again — the session's name now heads the card. */}
{translate('dashboardPopout.card.you', 'You')}
{' '}
@@ -128,17 +173,34 @@ export const AgentKanbanCard = memo(
{card.task}
) : null}
+ {/* Why: the card behind it is amber now, so the pill needs its own edge
+ to stay a distinct chip instead of a flat block of tint. */}
{card.askSummary ? (
-
+
{card.askSummary}
) : null}
-
-
{card.repoName}
+
+ {/* Why: the project reads as an icon so its name can't crowd the
+ worktree sitting next to it; the name lives in the tooltip. */}
+
+
+
+
+
+
+
+ {card.repoName}
+
+
+ {worktreeInFooter ?
{card.worktreeName} : null}
{displayTimestamp(card) > 0 ? (
-
+
{formatStartedAgo(displayTimestamp(card), now)}
) : null}
@@ -149,6 +211,7 @@ export const AgentKanbanCard = memo(
(previous, next) =>
previous.onOpenTerminal === next.onOpenTerminal &&
sameCard(previous.card, next.card) &&
+ sameRepoIcon(previous.repoIcon, next.repoIcon) &&
(displayTimestamp(previous.card) <= 0 ||
formatStartedAgo(displayTimestamp(previous.card), previous.now) ===
formatStartedAgo(displayTimestamp(next.card), next.now))
diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts
index fadd385afbb..6da673ecbe8 100644
--- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts
+++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts
@@ -114,6 +114,73 @@ describe('buildDashboardSnapshot', () => {
expect(card.unseen).toBe(true)
})
+ it('carries the tab conversation name and drops status-only titles', () => {
+ const named = buildDashboardSnapshot(
+ baseState({
+ agentStatusByPaneKey: { [PANE_KEY]: entry({}) },
+ tabsByWorktree: { w1: [{ ...tab(), customTitle: 'Sparse-checkout parser' }] }
+ }),
+ NOW
+ )
+ expect(named.cards[0].conversationName).toBe('Sparse-checkout parser')
+
+ // The fixture tab's title is the 'agent' placeholder — not a name.
+ const unnamed = buildDashboardSnapshot(
+ baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }),
+ NOW
+ )
+ expect(unnamed.cards[0].conversationName).toBeUndefined()
+ })
+
+ it('withholds generated titles until the setting enables them', () => {
+ const tabs = { w1: [{ ...tab(), generatedTitle: 'Fix the flaky pty test' }] }
+ const off = buildDashboardSnapshot(
+ baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) }, tabsByWorktree: tabs }),
+ NOW
+ )
+ expect(off.cards[0].conversationName).toBeUndefined()
+
+ const on = buildDashboardSnapshot(
+ baseState({
+ agentStatusByPaneKey: { [PANE_KEY]: entry({}) },
+ tabsByWorktree: tabs,
+ settings: { tabAutoGenerateTitle: true }
+ } as unknown as Partial),
+ NOW
+ )
+ expect(on.cards[0].conversationName).toBe('Fix the flaky pty test')
+ })
+
+ it('ships one icon per card-bearing repo, and none for repos without cards', () => {
+ const snapshot = buildDashboardSnapshot(
+ baseState({
+ repos: [
+ {
+ id: 'r1',
+ path: '/r1',
+ displayName: 'Repo One',
+ badgeColor: '#000',
+ repoIcon: { type: 'lucide', name: 'Rocket' }
+ },
+ { id: 'r2', path: '/r2', displayName: 'Repo Two', badgeColor: '#000' }
+ ],
+ worktreesByRepo: { r1: [worktree()], r2: [worktree('w2', 'wt-two')] },
+ agentStatusByPaneKey: { [PANE_KEY]: entry({}) }
+ } as unknown as Partial),
+ NOW
+ )
+ // r2 has a worktree but no agent card, so its icon never ships.
+ expect(snapshot.repoIconsByRepoId).toEqual({ r1: { type: 'lucide', name: 'Rocket' } })
+ })
+
+ it('records a null icon for a card-bearing repo that has none', () => {
+ const snapshot = buildDashboardSnapshot(
+ baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }),
+ NOW
+ )
+ expect(snapshot.repoIconsByRepoId).toEqual({ r1: null })
+ })
+
it('nulls ptyId when the layout entry points at a dead pty', () => {
const snapshot = buildDashboardSnapshot(
baseState({
diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts
index 8554cf85e74..d860f646fb0 100644
--- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts
+++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts
@@ -5,7 +5,9 @@ import type {
DashboardCardDotState,
DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
+import type { RepoIcon } from '../../../../shared/repo-icon'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
+import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { applyAgentRowLineage } from './agent-row-lineage'
import { lastEnteredDoneAt } from './agent-finished-timestamp'
@@ -43,6 +45,7 @@ export type DashboardSnapshotState = Pick<
| 'ptyIdsByTabId'
| 'runtimePaneTitlesByTabId'
| 'acknowledgedAgentsByPaneKey'
+ | 'settings'
>
function bucketForState(state: DashboardAgentRow['state']): DashboardBucket {
@@ -70,6 +73,24 @@ function nonEmpty(value: string | undefined): string | undefined {
return trimmed.length > 0 ? trimmed : undefined
}
+/** Mirrors useAgentRowConversationName so the board and the sidebar label the
+ * same agent with the same name. */
+function rowConversationName(
+ row: DashboardAgentRow,
+ generatedTitlesEnabled: boolean
+): string | undefined {
+ const parentPaneKey = row.entry.orchestration?.parentPaneKey
+ // Why: a child row rendered on its parent's tab does not own that tab's name.
+ if (
+ row.lineage?.depth === 1 &&
+ parentPaneKey !== undefined &&
+ parsePaneKey(parentPaneKey)?.tabId === row.tab.id
+ ) {
+ return undefined
+ }
+ return getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled) ?? undefined
+}
+
/**
* Derive the serializable dashboard snapshot from the live renderer store.
* Reuses the exact per-worktree row machinery the sidebar uses
@@ -82,6 +103,8 @@ export function buildDashboardSnapshot(
now: number
): DashboardSnapshot {
const cards: DashboardCard[] = []
+ const repoIconsByRepoId: Record = {}
+ const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true
const activeWorktrees: {
repo: AppState['repos'][number]
worktree: AppState['worktreesByRepo'][string][number]
@@ -169,6 +192,8 @@ export function buildDashboardSnapshot(
: null
const dotState = row.state as DashboardCardDotState
const bucket = bucketForState(row.state)
+ // Only repos that actually contribute a card ship their icon.
+ repoIconsByRepoId[repo.id] = repo.repoIcon ?? null
cards.push({
paneKey: row.paneKey,
@@ -193,10 +218,11 @@ export function buildDashboardSnapshot(
unseen:
!isTitleDerived &&
(state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt,
- askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined
+ askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined,
+ conversationName: rowConversationName(row, generatedTitlesEnabled)
})
}
}
- return { generatedAt: now, cards }
+ return { generatedAt: now, cards, repoIconsByRepoId }
}
diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts
index d302e77865e..bab421f2927 100644
--- a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts
+++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts
@@ -56,7 +56,10 @@ export function useAgentBucketCounts(): AgentBucketCounts {
ptyIdsByTabId,
runtimePaneTitlesByTabId,
// Counts do not render acknowledgement state, so avoid subscribing the sidebar to it.
- acknowledgedAgentsByPaneKey: {}
+ acknowledgedAgentsByPaneKey: {},
+ // Same: counts never render a card's conversation name, so the
+ // generated-title gate is moot and the sidebar stays off settings.
+ settings: null
},
Date.now()
)
diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx
index 6b388f95fbb..e816231e322 100644
--- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx
+++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx
@@ -56,6 +56,7 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState {
ptyIdsByTabId: {},
runtimePaneTitlesByTabId: {},
acknowledgedAgentsByPaneKey: {},
+ settings: null,
agentStatusEpoch: 0
}
}
diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts
index 3a8e8c2c04e..e1609f8666b 100644
--- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts
+++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts
@@ -26,6 +26,8 @@ export function dashboardSnapshotInputsChanged(
state.ptyIdsByTabId !== previousState.ptyIdsByTabId ||
state.runtimePaneTitlesByTabId !== previousState.runtimePaneTitlesByTabId ||
state.acknowledgedAgentsByPaneKey !== previousState.acknowledgedAgentsByPaneKey ||
+ // Why: tabAutoGenerateTitle decides whether cards may show generated names.
+ state.settings !== previousState.settings ||
// Why: freshness can change a bucket without replacing any backing map.
state.agentStatusEpoch !== previousState.agentStatusEpoch
)
diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts
new file mode 100644
index 00000000000..9ebaa72fb5e
--- /dev/null
+++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts
@@ -0,0 +1,126 @@
+// @vitest-environment happy-dom
+import { renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { useAppStore } from '@/store'
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
+import type { GlobalSettings, Repo, TerminalTab, Worktree } from '../../../../shared/types'
+import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot'
+
+const NOW = 1_000_000_000
+const TAB_ID = 'tab-1'
+const LEAF_ID = '11111111-1111-4111-8111-111111111111'
+const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
+
+const initialAppState = useAppStore.getInitialState()
+
+beforeEach(() => {
+ useAppStore.setState(initialAppState, true)
+})
+
+afterEach(() => {
+ useAppStore.setState(initialAppState, true)
+})
+
+function repo(): Repo {
+ return {
+ id: 'repo-1',
+ path: '/repo',
+ displayName: 'Repo One',
+ badgeColor: '#000',
+ repoIcon: { type: 'lucide', name: 'Rocket' },
+ addedAt: 1
+ }
+}
+
+function worktree(): Worktree {
+ return {
+ id: 'wt-1',
+ repoId: 'repo-1',
+ path: '/repo/wt-1',
+ head: 'abc123',
+ branch: 'main',
+ isBare: false,
+ isMainWorktree: false,
+ displayName: 'wt-one',
+ comment: '',
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: NOW
+ }
+}
+
+function tab(): TerminalTab {
+ return {
+ id: TAB_ID,
+ ptyId: 'pty-1',
+ worktreeId: 'wt-1',
+ title: 'agent',
+ customTitle: null,
+ generatedTitle: 'Fix the flaky pty test',
+ color: null,
+ sortOrder: 0,
+ createdAt: NOW
+ } as TerminalTab
+}
+
+function entry(): AgentStatusEntry {
+ return {
+ paneKey: PANE_KEY,
+ state: 'working',
+ prompt: 'do the thing',
+ updatedAt: Date.now(),
+ stateStartedAt: Date.now(),
+ stateHistory: [],
+ agentType: 'claude',
+ tabId: TAB_ID,
+ worktreeId: 'wt-1'
+ }
+}
+
+function seed(settings: Partial | null): void {
+ useAppStore.setState({
+ repos: [repo()],
+ worktreesByRepo: { 'repo-1': [worktree()] },
+ tabsByWorktree: { 'wt-1': [tab()] },
+ agentStatusByPaneKey: { [PANE_KEY]: entry() },
+ terminalLayoutsByTabId: {
+ [TAB_ID]: {
+ root: { type: 'leaf', leafId: LEAF_ID },
+ activeLeafId: LEAF_ID,
+ expandedLeafId: null,
+ ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
+ }
+ },
+ ptyIdsByTabId: { [TAB_ID]: ['pty-1'] },
+ settings: settings as GlobalSettings | null
+ })
+}
+
+// Why: the in-window drawer derives its own snapshot instead of receiving the
+// relayed one, so anything the builder reads has to be threaded in by hand —
+// a dropped slice silently blanks the field rather than failing loudly.
+describe('useLiveDashboardSnapshot', () => {
+ it('feeds the builder the settings that gate generated conversation names', () => {
+ seed({ tabAutoGenerateTitle: true })
+ const withTitles = renderHook(() => useLiveDashboardSnapshot())
+ expect(withTitles.result.current.cards[0].conversationName).toBe('Fix the flaky pty test')
+
+ seed({ tabAutoGenerateTitle: false })
+ const withoutTitles = renderHook(() => useLiveDashboardSnapshot())
+ expect(withoutTitles.result.current.cards[0].conversationName).toBeUndefined()
+ })
+
+ it('carries repo icons through to the drawer', () => {
+ seed({ tabAutoGenerateTitle: false })
+ const { result } = renderHook(() => useLiveDashboardSnapshot())
+ expect(result.current.repoIconsByRepoId).toEqual({
+ 'repo-1': { type: 'lucide', name: 'Rocket' }
+ })
+ })
+})
diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts
index c6880ab0cd7..2df84c984eb 100644
--- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts
+++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts
@@ -23,6 +23,8 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey)
+ // Why: gates generated tab titles in the cards' conversation names.
+ const settings = useAppStore((s) => s.settings)
// Why: freshness can flip a bucket without any backing map changing; the epoch
// ticks on the freshness boundary so the memo re-derives stale-decayed cards.
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
@@ -43,7 +45,8 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
terminalLayoutsByTabId,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
- acknowledgedAgentsByPaneKey
+ acknowledgedAgentsByPaneKey,
+ settings
},
Date.now()
),
@@ -60,6 +63,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
ptyIdsByTabId,
runtimePaneTitlesByTabId,
acknowledgedAgentsByPaneKey,
+ settings,
agentStatusEpoch
]
)
diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link-code-context.ts b/src/renderer/src/components/editor/rich-markdown-doc-link-code-context.ts
deleted file mode 100644
index 703f43bb4d4..00000000000
--- a/src/renderer/src/components/editor/rich-markdown-doc-link-code-context.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-type DocLinkTextNodeContext = {
- marks: readonly { type: { name: string } }[]
-}
-
-type DocLinkParentContext = {
- type: { spec: { code?: boolean } }
-}
-
-// Why: inline/fenced code content is literal markdown; auto-converting it would
-// corrupt examples and commands that intentionally contain [[...]] text.
-export function isDocLinkLiteralCodeTextNode(
- node: DocLinkTextNodeContext,
- parent: DocLinkParentContext | null
-): boolean {
- return parent?.type.spec.code === true || node.marks.some((mark) => mark.type.name === 'code')
-}
diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link-scan.ts b/src/renderer/src/components/editor/rich-markdown-doc-link-scan.ts
new file mode 100644
index 00000000000..cf7d03f3f14
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-doc-link-scan.ts
@@ -0,0 +1,32 @@
+export const DOC_LINK_PATTERN = /\[\[([^[\]\r\n]+)\]\]/g
+const DOC_LINK_OPEN = '[['
+
+type DocLinkTextNodeContext = {
+ type: { name: string }
+ text?: string
+ marks: readonly { type: { name: string } }[]
+}
+
+type DocLinkParentContext = {
+ type: { spec: { code?: boolean } }
+}
+
+export function isDocLinkLiteralCodeTextNode(
+ node: Pick,
+ parent: DocLinkParentContext | null
+): boolean {
+ return parent?.type.spec.code === true || node.marks.some((mark) => mark.type.name === 'code')
+}
+
+// A link match requires the exact ASCII opener, including for non-ASCII targets.
+export function canHoldDocLink(
+ node: DocLinkTextNodeContext,
+ parent: DocLinkParentContext | null
+): node is DocLinkTextNodeContext & { text: string } {
+ return (
+ node.type.name === 'text' &&
+ !!node.text &&
+ node.text.includes(DOC_LINK_OPEN) &&
+ !isDocLinkLiteralCodeTextNode(node, parent)
+ )
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link.test.ts b/src/renderer/src/components/editor/rich-markdown-doc-link.test.ts
index 4b95a1f5885..acbdc45b2df 100644
--- a/src/renderer/src/components/editor/rich-markdown-doc-link.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-doc-link.test.ts
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'
-import { isDocLinkLiteralCodeTextNode } from './rich-markdown-doc-link-code-context'
+import { canHoldDocLink, DOC_LINK_PATTERN } from './rich-markdown-doc-link-scan'
-function textContext(markNames: string[] = []) {
+function textContext(text: string, markNames: string[] = []) {
return {
+ type: { name: 'text' },
+ text,
marks: markNames.map((name) => ({ type: { name } }))
}
}
@@ -15,16 +17,30 @@ function parentContext(code: boolean) {
}
}
-describe('isDocLinkLiteralCodeTextNode', () => {
- it('allows doc link conversion in ordinary prose text', () => {
- expect(isDocLinkLiteralCodeTextNode(textContext(), parentContext(false))).toBe(false)
+function scan(text: string, markNames: string[] = [], parentCode = false): string[] {
+ const node = textContext(text, markNames)
+ if (!canHoldDocLink(node, parentContext(parentCode))) {
+ return []
+ }
+ return [...node.text.matchAll(DOC_LINK_PATTERN)].map((match) => match[1])
+}
+
+describe('production doc-link scan predicate', () => {
+ it('finds links in ordinary prose, including non-ASCII targets', () => {
+ expect(scan('See [[Guide]] and [[文档/😀]].')).toEqual(['Guide', '文档/😀'])
})
- it('skips doc link conversion for inline code marks', () => {
- expect(isDocLinkLiteralCodeTextNode(textContext(['code']), parentContext(false))).toBe(true)
+ it('rejects ordinary prose without the exact opener', () => {
+ expect(scan('See [Guide], plain prose, and closed brackets]].')).toEqual([])
})
- it('skips doc link conversion for fenced code block text', () => {
- expect(isDocLinkLiteralCodeTextNode(textContext(), parentContext(true))).toBe(true)
+ it('skips inline and fenced code', () => {
+ expect(scan('`[[inline]]`', ['code'])).toEqual([])
+ expect(scan('[[fenced]]', [], true)).toEqual([])
+ })
+
+ it('does not match links across CR or LF boundaries', () => {
+ expect(scan('[[carriage\rreturn]]')).toEqual([])
+ expect(scan('[[line\nfeed]]')).toEqual([])
})
})
diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link.ts b/src/renderer/src/components/editor/rich-markdown-doc-link.ts
index a35772e4eef..bbc1b26b294 100644
--- a/src/renderer/src/components/editor/rich-markdown-doc-link.ts
+++ b/src/renderer/src/components/editor/rich-markdown-doc-link.ts
@@ -9,16 +9,12 @@ import {
parseMarkdownDocLink,
resolveMarkdownDocLink
} from './markdown-doc-links'
-import { isDocLinkLiteralCodeTextNode } from './rich-markdown-doc-link-code-context'
import {
isReservedRichMarkdownTransportBody,
type RichMarkdownSourceTransport
} from './rich-markdown-source-transport'
import { renderRichMarkdownDocLinkHtml } from './rich-markdown-doc-link-dom'
-
-// Why: `.matchAll()` at each call site creates a fresh iterator so the shared
-// `/g` regex never leaks `lastIndex` state across nested or concurrent scans.
-const DOC_LINK_PATTERN = /\[\[([^[\]\r\n]+)\]\]/g
+import { canHoldDocLink, DOC_LINK_PATTERN } from './rich-markdown-doc-link-scan'
const docLinkDissolveKey = new PluginKey('docLinkDissolve')
const docLinkAutoConvertKey = new PluginKey('docLinkAutoConvert')
@@ -50,10 +46,7 @@ function buildPreviewDecorations(state: EditorState, storage: DocLinkStorage): D
const index = getDocIndex(storage)
const cursor = state.selection.from
state.doc.descendants((node, pos, parent) => {
- if (node.type.name !== 'text' || !node.text) {
- return
- }
- if (isDocLinkLiteralCodeTextNode(node, parent)) {
+ if (!canHoldDocLink(node, parent)) {
return
}
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
@@ -279,10 +272,7 @@ export function createMarkdownDocLink(transport: RichMarkdownSourceTransport) {
let modified = false
newState.doc.descendants((node, pos, parent) => {
- if (node.type.name !== 'text' || !node.text) {
- return
- }
- if (isDocLinkLiteralCodeTextNode(node, parent)) {
+ if (!canHoldDocLink(node, parent)) {
return
}
diff --git a/src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts b/src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts
index 36b2d27ca9a..95c1fa67b8d 100644
--- a/src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts
@@ -1,8 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { Editor } from '@tiptap/core'
+import type { DiffComment } from '../../../../shared/types'
import {
countRichMarkdownReviewMarkdownLines,
getRichMarkdownAnnotationButtonLeft,
- getRichMarkdownAnnotationButtonTop
+ getRichMarkdownAnnotationButtonTop,
+ getRichMarkdownAnnotationHighlightRanges,
+ getRichMarkdownCommentAtPos
} from './rich-markdown-review-annotations'
afterEach(() => {
@@ -47,3 +51,77 @@ describe('getRichMarkdownAnnotationButtonLeft', () => {
expect(getRichMarkdownAnnotationButtonLeft(72)).toBe(40)
})
})
+
+// Why count serializes: resolving a comment's block re-serializes the document,
+// so doing it per comment made these O(comments x document). A call count is
+// deterministic where a wall-clock threshold would be flaky.
+describe('rich markdown annotation block reuse', () => {
+ function makeEditor(nodeCount: number): { editor: Editor; serializeCalls: () => number } {
+ let serializeCalls = 0
+ const content = Array.from({ length: nodeCount }, (_value, index) => ({
+ type: 'paragraph',
+ content: [{ type: 'text', text: `paragraph ${index}` }]
+ }))
+ const doc = {
+ forEach(callback: (node: unknown, offset: number, index: number) => void): void {
+ content.forEach((node, index) => callback(node, index * 10, index))
+ },
+ // The text-range search walks the doc to locate the selected text; these
+ // fixtures never match, so it only needs to be traversable.
+ nodesBetween(): void {},
+ content: { size: nodeCount * 10 }
+ }
+ const editor = {
+ getJSON: () => ({ content }),
+ state: { doc },
+ markdown: {
+ serialize: (value: { content?: unknown[] }) => {
+ serializeCalls += 1
+ return (value.content ?? []).map((_node, index) => `line ${index}`).join('\n')
+ }
+ }
+ } as unknown as Editor
+ return { editor, serializeCalls: () => serializeCalls }
+ }
+
+ function makeComments(count: number): DiffComment[] {
+ return Array.from(
+ { length: count },
+ (_value, index) => ({ lineNumber: index + 1, selectedText: 'nothing-matches' }) as DiffComment
+ )
+ }
+
+ // One block build over NODE_COUNT nodes: each node serialized alone, plus each
+ // adjacent pair. Pinned absolutely so "both arms build twice" can't pass as equal.
+ const NODE_COUNT = 12
+ const ONE_BUILD_SERIALIZE_CALLS = NODE_COUNT + (NODE_COUNT - 1)
+
+ it('serializes the document once regardless of comment count', () => {
+ const single = makeEditor(NODE_COUNT)
+ getRichMarkdownAnnotationHighlightRanges(single.editor, makeComments(1), 0)
+
+ const many = makeEditor(NODE_COUNT)
+ getRichMarkdownAnnotationHighlightRanges(many.editor, makeComments(8), 0)
+
+ expect(single.serializeCalls()).toBe(ONE_BUILD_SERIALIZE_CALLS)
+ expect(many.serializeCalls()).toBe(ONE_BUILD_SERIALIZE_CALLS)
+ })
+
+ it('serializes the document once when locating the comment at a position', () => {
+ const single = makeEditor(NODE_COUNT)
+ getRichMarkdownCommentAtPos(single.editor, makeComments(1), 0, 5)
+
+ const many = makeEditor(NODE_COUNT)
+ getRichMarkdownCommentAtPos(many.editor, makeComments(8), 0, 5)
+
+ expect(single.serializeCalls()).toBe(ONE_BUILD_SERIALIZE_CALLS)
+ expect(many.serializeCalls()).toBe(ONE_BUILD_SERIALIZE_CALLS)
+ })
+
+ it('does no work at all with no comments', () => {
+ const none = makeEditor(12)
+ expect(getRichMarkdownAnnotationHighlightRanges(none.editor, [], 0)).toEqual([])
+ expect(getRichMarkdownCommentAtPos(none.editor, [], 0, 5)).toBeNull()
+ expect(none.serializeCalls()).toBe(0)
+ })
+})
diff --git a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts
index a45c3f26382..a7ef2307401 100644
--- a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts
+++ b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts
@@ -119,17 +119,29 @@ export function getRichMarkdownAnnotationHighlightRanges(
comments: readonly DiffComment[],
markdownSourceLineOffset: number
): RichMarkdownAnnotationHighlightRange[] {
+ if (comments.length === 0) {
+ return []
+ }
+ // Why once: block resolution re-serializes the doc; per comment it was O(n*doc).
+ const blocks = buildRichMarkdownCommentBlocks(editor)
return comments.flatMap((comment) =>
- getRichMarkdownAnnotationHighlightRangesForComment(editor, comment, markdownSourceLineOffset)
+ getRichMarkdownAnnotationHighlightRangesForComment(
+ editor,
+ comment,
+ markdownSourceLineOffset,
+ blocks
+ )
)
}
export function getRichMarkdownAnnotationHighlightRangesForComment(
editor: Editor,
comment: DiffComment,
- markdownSourceLineOffset: number
+ markdownSourceLineOffset: number,
+ // Why optional: callers looping over comments pass one shared build.
+ prebuiltBlocks?: RichMarkdownCommentBlock[]
): RichMarkdownAnnotationHighlightRange[] {
- const blocks = buildRichMarkdownCommentBlocks(editor)
+ const blocks = prebuiltBlocks ?? buildRichMarkdownCommentBlocks(editor)
const selectedText = comment.selectedText?.trim()
if (!selectedText) {
return []
@@ -158,12 +170,17 @@ export function getRichMarkdownCommentAtPos(
markdownSourceLineOffset: number,
pos: number
): DiffComment | null {
+ if (comments.length === 0) {
+ return null
+ }
+ const blocks = buildRichMarkdownCommentBlocks(editor)
return (
comments.find((comment) =>
getRichMarkdownAnnotationHighlightRangesForComment(
editor,
comment,
- markdownSourceLineOffset
+ markdownSourceLineOffset,
+ blocks
).some((range) => range.from <= pos && pos <= range.to)
) ?? null
)
diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
index e64a79a7e98..ebaf99d2237 100644
--- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
@@ -55,6 +55,7 @@ import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-st
import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu'
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
import { WorktreeParentPickerPopover } from './WorktreeParentPickerPopover'
+import { WorktreeDeveloperMenu } from './WorktreeDeveloperMenu'
import { getEligibleWorktreeParents } from './worktree-parent-candidates'
import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events'
import { translate } from '@/i18n/i18n'
@@ -924,6 +925,12 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
>
) : null}
+ {!isMultiContext ? (
+ <>
+
+
+ >
+ ) : null}
void
+}
+
+const menuItems = vi.hoisted(() => ({ values: [] as MenuItemProps[] }))
+const requestManualPark = vi.hoisted(() => vi.fn())
+
+vi.mock('@/components/ui/dropdown-menu', async () => {
+ const React_ = await import('react')
+ const passthrough = ({ children }: { children?: ReactNode }) =>
+ React_.createElement(React_.Fragment, null, children)
+ return {
+ DropdownMenuSub: passthrough,
+ DropdownMenuSubContent: passthrough,
+ DropdownMenuSubTrigger: passthrough,
+ DropdownMenuItem: (props: MenuItemProps) => {
+ menuItems.values.push(props)
+ return React_.createElement(React_.Fragment, null, props.children)
+ }
+ }
+})
+
+vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
+vi.mock('@/lib/manual-terminal-worktree-parking', () => ({
+ requestManualTerminalWorktreePark: requestManualPark
+}))
+
+describe('WorktreeDeveloperMenu', () => {
+ beforeEach(() => {
+ menuItems.values = []
+ requestManualPark.mockReset()
+ })
+
+ it('shows the developer submenu with the parking action', () => {
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('Developer')
+ expect(markup).toContain('Park terminal')
+ expect(markup).toContain('lucide-code-xml')
+ expect(markup).toContain('lucide-square-parking')
+ })
+
+ it('requests parking for the context worktree', () => {
+ renderToStaticMarkup( )
+
+ menuItems.values[0]?.onSelect?.()
+
+ expect(requestManualPark).toHaveBeenCalledWith('worktree-1')
+ })
+})
diff --git a/src/renderer/src/components/sidebar/WorktreeDeveloperMenu.tsx b/src/renderer/src/components/sidebar/WorktreeDeveloperMenu.tsx
new file mode 100644
index 00000000000..a0cb257c105
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorktreeDeveloperMenu.tsx
@@ -0,0 +1,32 @@
+import {
+ DropdownMenuItem,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger
+} from '@/components/ui/dropdown-menu'
+import { CodeXml, SquareParking } from 'lucide-react'
+import { translate } from '@/i18n/i18n'
+import { requestManualTerminalWorktreePark } from '@/lib/manual-terminal-worktree-parking'
+
+export function WorktreeDeveloperMenu({
+ worktreeId,
+ disabled
+}: {
+ worktreeId: string
+ disabled: boolean
+}): React.JSX.Element {
+ return (
+
+
+
+ {translate('auto.components.sidebar.WorktreeDeveloperMenu.developer', 'Developer')}
+
+
+ requestManualTerminalWorktreePark(worktreeId)}>
+
+ {translate('auto.components.sidebar.WorktreeDeveloperMenu.parkTerminal', 'Park terminal')}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.test.ts
new file mode 100644
index 00000000000..c619790fdb1
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.test.ts
@@ -0,0 +1,124 @@
+// Why: a parked tab has no xterm, so this byte sidecar is the only thing that
+// answers DECSET 2031. fish toggles 2031 on and off around every prompt, so
+// answering the sticky "an h appeared" flag writes `?997;1n` into a shell that
+// already handed the tty to a child (#9993).
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const ESC = '\x1b'
+const PTY_ID = 'pty-parked-2031'
+
+// One fish prompt cycle: subscribe, paint, hand off the tty.
+const FISH_PROMPT_HANDOFF = `${ESC}[?2031h${ESC}[0m~/orca ${ESC}[32m❯${ESC}[0m ${ESC}[?2031l`
+
+let sidecarWatcher: ((data: string) => void) | null = null
+const unsubscribe = vi.fn()
+
+vi.mock('./pty-data-sidecar-subscriptions', () => ({
+ subscribeToPtyData: (_ptyId: string, watcher: (data: string) => void) => {
+ sidecarWatcher = watcher
+ return unsubscribe
+ }
+}))
+
+vi.mock('@/store', () => ({
+ useAppStore: {
+ getState: () => ({ settings: { theme: 'dark' as const } })
+ }
+}))
+
+vi.mock('@/lib/terminal-theme', () => ({
+ getSystemPrefersDark: () => true
+}))
+
+const { startParkedTerminalMode2031Responder } =
+ await import('./parked-terminal-mode2031-responder')
+
+function startResponder(): { sendInput: ReturnType; feed: (data: string) => void } {
+ const sendInput = vi.fn()
+ startParkedTerminalMode2031Responder({ ptyId: PTY_ID, sendInput })
+ return {
+ sendInput,
+ feed: (data: string) => sidecarWatcher?.(data)
+ }
+}
+
+beforeEach(() => {
+ sidecarWatcher = null
+ vi.clearAllMocks()
+})
+
+describe('parked-tab DECSET 2031 responder honors the chunk-final state (#9993)', () => {
+ it('does not reply to a subscribe the same chunk withdrew', () => {
+ const { sendInput, feed } = startResponder()
+
+ feed(FISH_PROMPT_HANDOFF)
+
+ expect(sendInput).not.toHaveBeenCalled()
+ })
+
+ it('replies once to a TUI that subscribes and keeps listening', () => {
+ const { sendInput, feed } = startResponder()
+
+ feed(`${ESC}[?2031h`)
+
+ expect(sendInput).toHaveBeenCalledTimes(1)
+ expect(sendInput).toHaveBeenCalledWith(`${ESC}[?997;1n`)
+ })
+
+ it('stays silent across a run of fish prompts', () => {
+ const { sendInput, feed } = startResponder()
+
+ feed(FISH_PROMPT_HANDOFF)
+ feed(FISH_PROMPT_HANDOFF)
+ feed(FISH_PROMPT_HANDOFF)
+
+ expect(sendInput).not.toHaveBeenCalled()
+ })
+
+ it('replies when a TUI subscribes after a prompt cycle in the same chunk', () => {
+ const { sendInput, feed } = startResponder()
+
+ feed(`${FISH_PROMPT_HANDOFF}${ESC}[?2031h`)
+
+ expect(sendInput).toHaveBeenCalledTimes(1)
+ })
+
+ it('still answers a subscribe split across two chunks', () => {
+ const { sendInput, feed } = startResponder()
+
+ feed(`${ESC}[?20`)
+ feed('31h')
+
+ expect(sendInput).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not reply when the withdrawal is split across two chunks', () => {
+ const { sendInput, feed } = startResponder()
+
+ feed(`${ESC}[?2031h prompt ${ESC}[?20`)
+ expect(sendInput).not.toHaveBeenCalled()
+ feed('31l')
+
+ expect(sendInput).not.toHaveBeenCalled()
+ })
+
+ it('does not reply when an unrelated private mode appends a split withdrawal', () => {
+ const { sendInput, feed } = startResponder()
+
+ feed(`${ESC}[?2031h prompt ${ESC}[?25`)
+ expect(sendInput).not.toHaveBeenCalled()
+ feed(';2031l')
+
+ expect(sendInput).not.toHaveBeenCalled()
+ })
+
+ it('replies after an ambiguous tail resolves to another mode', () => {
+ const { sendInput, feed } = startResponder()
+
+ feed(`${ESC}[?2031h drawing ${ESC}[?20`)
+ expect(sendInput).not.toHaveBeenCalled()
+ feed('25h')
+
+ expect(sendInput).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts
index da804026de6..4c2ca568de9 100644
--- a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts
+++ b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts
@@ -15,9 +15,10 @@
* consumed while the gate is ON — this sidecar stays the only answerer here.
*/
import {
+ INITIAL_MODE_2031_REPLY_SCAN_STATE,
mode2031SequenceFor,
resolveTerminalColorSchemeMode,
- scanMode2031Sequences
+ scanMode2031ReplyDecision
} from '../../../../shared/terminal-color-scheme-protocol'
import { useAppStore } from '@/store'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
@@ -33,13 +34,11 @@ export function startParkedTerminalMode2031Responder(
options: ParkedTerminalMode2031ResponderOptions
): () => void {
const { ptyId, sendInput } = options
- // Why: a DECSET 2031 subscribe can be split across PTY chunks; the scan
- // carries a bounded tail between chunks so split sequences still match.
- let scanTail = ''
+ let scanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
return subscribeToPtyData(ptyId, (data) => {
- const scan = scanMode2031Sequences(scanTail, data)
- scanTail = scan.tail
- if (!scan.subscribe) {
+ const result = scanMode2031ReplyDecision(scanState, data)
+ scanState = result.state
+ if (result.decision !== 'subscribed') {
return
}
// Why: reply with the resolved theme so TUIs that subscribe while parked
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
index 5417a5529c4..d0a8789a951 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -6631,6 +6631,53 @@ describe('connectPanePty', () => {
expect(deps.updateTabPtyId).not.toHaveBeenCalledWith('tab-1', otherTabPtyId)
})
+ it('fresh-spawns a shell into any PTY-less tab, so agent launches must never publish one', async () => {
+ // Why: #2989 depends on PTY-less tabs taking this legitimate fresh-shell path.
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport()
+ transport.connect.mockImplementation(async (opts: { sessionId?: string }) => {
+ if (opts.sessionId) {
+ return { id: opts.sessionId }
+ }
+ const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
+ | ((ptyId: string) => void)
+ | undefined
+ onPtySpawn?.('stray-shell-pty')
+ return 'stray-shell-pty'
+ })
+ transportFactoryQueue.push(transport)
+ // Reproduce the pre-fix gap between createTab and PTY binding.
+ mockStoreState = {
+ ...mockStoreState,
+ tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
+ ptyIdsByTabId: { 'tab-1': [] },
+ terminalLayoutsByTabId: {
+ 'tab-1': {
+ root: { type: 'leaf', leafId: LEAF_1 },
+ activeLeafId: LEAF_1,
+ expandedLeafId: null
+ }
+ },
+ agentLaunchConfigByPaneKey: {
+ [`tab-1:${LEAF_1}`]: {
+ launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} },
+ identity: { agentType: 'claude' }
+ }
+ }
+ } as StoreState
+ const deps = createDeps()
+
+ const pane = createPane(1)
+ connectPanePty(pane as never, createManager(1) as never, deps as never)
+ await flushAsyncTicks()
+
+ // Launch registration alone cannot identify a PTY to attach.
+ expect(transport.connect).toHaveBeenCalledWith(
+ expect.objectContaining({ url: '', cols: expect.any(Number) })
+ )
+ expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'stray-shell-pty')
+ })
+
it('spawns a fresh PTY when a restored daemon split session cannot reattach', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
@@ -9923,14 +9970,62 @@ describe('connectPanePty', () => {
expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, 'dark')
})
- it('reports the gate-managed predicate on the binding for the xterm 2031 observer', async () => {
+ it('retires the fact-registered subscription when the TUI withdraws it', async () => {
+ // The counterpart to the test above. A gated pane never sees the withdrawal bytes
+ // (main drops them) and both the chunk scanner and xterm's CSI handler are disabled
+ // for it, so this fact is the only observer that can retire the subscription. Left
+ // registered, the next theme flip pushes CSI 997 at the shell that replaced the TUI.
+ enableMainAuthority()
+ const paneMode2031Ref = { current: new Map() }
+ const paneLastThemeModeRef = { current: new Map() }
+ const deps = createDeps({
+ isVisibleRef: { current: false },
+ paneMode2031Ref,
+ paneLastThemeModeRef,
+ // Exactly what use-terminal-pane-lifecycle wires up for this callback.
+ recordPaneMode2031Subscription: (paneId: number, repliedMode: 'dark' | 'light') => {
+ paneMode2031Ref.current.set(paneId, true)
+ paneLastThemeModeRef.current.set(paneId, repliedMode)
+ }
+ })
+ await connectHiddenPane(deps)
+ const transportOptions = createdTransportOptions.at(-1) as {
+ onPtySpawn?: (ptyId: string) => void
+ }
+ transportOptions.onPtySpawn?.('pty-id')
+ const factsHandler = await import('./terminal-side-effect-facts-handler')
+ factsHandler._dispatchTerminalSideEffectBatchForTest({
+ ptyId: 'pty-id',
+ seq: 12,
+ facts: [{ kind: '2031-subscribe' }]
+ })
+ expect(paneMode2031Ref.current.get(1)).toBe(true)
+
+ factsHandler._dispatchTerminalSideEffectBatchForTest({
+ ptyId: 'pty-id',
+ seq: 24,
+ facts: [{ kind: '2031-unsubscribe' }]
+ })
+
+ expect(paneMode2031Ref.current.get(1)).toBeUndefined()
+ expect(paneLastThemeModeRef.current.get(1)).toBeUndefined()
+ })
+
+ it('leaves the chunk scanner silent on a gate-managed PTY', async () => {
+ // Main's '2031-subscribe' fact already answers these; a chunk-boundary reply here
+ // would answer the same subscribe a second time.
enableMainAuthority()
const deps = createDeps({ isVisibleRef: { current: false } })
- const { binding } = await connectHiddenPane(deps)
- const bindingWithPredicate = binding as typeof binding & {
- isHiddenDeliveryGateManagedPty: () => boolean
- }
- expect(bindingWithPredicate.isHiddenDeliveryGateManagedPty()).toBe(true)
+ const { transport, dataCallback } = await connectHiddenPane(deps)
+ const before = transport.sendInput.mock.calls.length
+
+ dataCallback('\x1b[?2031h')
+
+ const replies = transport.sendInput.mock.calls
+ .slice(before)
+ .flat()
+ .filter((arg) => String(arg).includes('997'))
+ expect(replies).toEqual([])
})
it('declares hidden-at-spawn on connect for hidden panes', async () => {
@@ -9970,13 +10065,7 @@ describe('connectPanePty', () => {
terminalHiddenDeliveryGate: false
} as StoreState['settings']
const deps = createDeps({ isVisibleRef: { current: false } })
- const { transport, dataCallback, binding } = await connectHiddenPane(deps)
- // Why: the lifecycle's xterm CSI observer consults this predicate — kill switch off keeps the legacy xterm reply path.
- expect(
- (
- binding as typeof binding & { isHiddenDeliveryGateManagedPty: () => boolean }
- ).isHiddenDeliveryGateManagedPty()
- ).toBe(false)
+ const { transport, dataCallback } = await connectHiddenPane(deps)
const transportOptions = createdTransportOptions.at(-1) as {
onPtySpawn?: (ptyId: string) => void
}
@@ -10244,10 +10333,11 @@ describe('connectPanePty', () => {
pane: ReturnType
dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void
getMainBufferSnapshot: ReturnType
+ transport: MockTransport
}> {
enableMainAuthority()
const deps = createDeps({ isVisibleRef: { current: true } })
- const { pane, dataCallback } = await connectHiddenPane(deps)
+ const { pane, dataCallback, transport } = await connectHiddenPane(deps)
const transportOptions = createdTransportOptions.at(-1) as {
onPtySpawn?: (ptyId: string) => void
}
@@ -10275,7 +10365,7 @@ describe('connectPanePty', () => {
expect.any(Function)
)
pane.terminal.write.mockClear()
- return { pane, dataCallback, getMainBufferSnapshot }
+ return { pane, dataCallback, getMainBufferSnapshot, transport }
}
function writtenData(pane: ReturnType): string {
@@ -10296,6 +10386,26 @@ describe('connectPanePty', () => {
expect(writtenData(pane)).toContain('NEW')
})
+ it('answers a 2031 subscribe on a chunk the restored snapshot drops as duplicate', async () => {
+ // Why the scan runs before reconciliation: the snapshot restore replays bytes
+ // into xterm without answering queries, so this live delivery is the only
+ // chance to reply. Dropping the chunk as a duplicate must not drop the query.
+ // Gate off so the chunk scanner (not main's fact) owns this pane's reply.
+ enableMainAuthority()
+ mockStoreState.settings = {
+ ...mockStoreState.settings,
+ terminalHiddenDeliveryGate: false
+ } as StoreState['settings']
+ const { pane, dataCallback, transport } = await restoreVisiblePaneToBaseline()
+ transport.sendInput.mockClear()
+
+ dataCallback(`SUB\x1b[?2031h`, { seq: 60, rawLength: 11 })
+ await flushAsyncTicks(8)
+
+ expect(writtenData(pane)).not.toContain('SUB')
+ expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
+ })
+
it('slices a partial overlap when raw and clean lengths match', async () => {
const { pane, dataCallback } = await restoreVisiblePaneToBaseline()
@@ -10987,7 +11097,7 @@ describe('connectPanePty', () => {
binding.dispose()
})
- it('writes mode 2031 through hidden xterm instead of side-channel answering it', async () => {
+ it('answers a mode 2031 subscribe once, from the raw chunk boundary', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
@@ -11015,7 +11125,11 @@ describe('connectPanePty', () => {
capturedDataCallback.current?.('\x1b[?2031h')
vi.advanceTimersByTime(50)
- expect(transport.sendInput).not.toHaveBeenCalled()
+ // Why the scanner and not xterm's CSI handler: xterm batches PTY chunks into one
+ // parse, so only this layer knows the chunk ended still subscribed (#9993).
+ expect(transport.sendInput).toHaveBeenCalledTimes(1)
+ expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;2n')
+ // The bytes still reach xterm so the emulator tracks the mode itself.
expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?2031h')
} finally {
vi.useRealTimers()
@@ -11024,6 +11138,168 @@ describe('connectPanePty', () => {
binding.dispose()
})
+ // Why these three: fish 4.7.1 enables and disables 2031 around *every* prompt with no
+ // opt-out, so back-to-back chunks each carrying a toggle are the normal case, not an edge
+ // case. Each chunk owes exactly one decision, and xterm cannot make it — it parses several
+ // chunks in one synchronous batch, so a reply deferred to the parser is either dropped or
+ // answered against a subscription a later chunk already withdrew (#9993).
+ describe('mode 2031 replies are decided per raw PTY chunk', () => {
+ async function connectVisiblePane(): Promise<{
+ transport: ReturnType
+ deps: ReturnType
+ emit: (data: string, meta?: { droppedOutput?: boolean }) => void
+ dispose: () => void
+ }> {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport('pty-id')
+ const captured: {
+ current: ((data: string, meta?: { droppedOutput?: boolean }) => void) | null
+ } = { current: null }
+ transport.connect.mockImplementation(
+ async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
+ captured.current = callbacks.onData ?? null
+ return 'pty-id'
+ }
+ )
+ transportFactoryQueue.push(transport)
+ mockStoreState = {
+ ...mockStoreState,
+ settings: { ...mockStoreState.settings, theme: 'light' }
+ }
+ const pane = createPane(1)
+ const manager = createManager(1)
+ const deps = createDeps({ isVisibleRef: { current: true } })
+ const binding = connectPanePty(pane as never, manager as never, deps as never)
+ await flushAsyncTicks(6)
+ return {
+ transport,
+ deps,
+ emit: (data: string, meta?: { droppedOutput?: boolean }) => captured.current?.(data, meta),
+ dispose: () => binding.dispose()
+ }
+ }
+
+ const replies = (transport: { sendInput: { mock: { calls: unknown[][] } } }): unknown[] =>
+ transport.sendInput.mock.calls.flat().filter((arg) => String(arg).includes('997'))
+
+ it('answers a chunk that ends subscribed even when the next chunk withdraws', async () => {
+ const { transport, emit, dispose } = await connectVisiblePane()
+ // Two separate PTY chunks. xterm would parse both in one batch and see only the net
+ // result; the first chunk still owes a reply.
+ emit('\x1b[?2031h')
+ emit('\x1b[?2031l')
+ expect(replies(transport)).toEqual(['\x1b[?997;2n'])
+ dispose()
+ })
+
+ it('answers each chunk that ends subscribed', async () => {
+ const { transport, emit, dispose } = await connectVisiblePane()
+ emit('\x1b[?2031h')
+ emit('\x1b[?2031l\x1b[?2031h')
+ expect(replies(transport)).toHaveLength(2)
+ dispose()
+ })
+
+ it('stays silent when one chunk both subscribes and withdraws', async () => {
+ const { transport, emit, dispose } = await connectVisiblePane()
+ // The fish prompt case: the subscription is gone before the program could read a reply.
+ emit('\x1b[?2031h prompt \x1b[?2031l')
+ expect(replies(transport)).toEqual([])
+ dispose()
+ })
+
+ it('stays silent when the withdrawal is split across two chunks', async () => {
+ const { transport, emit, dispose } = await connectVisiblePane()
+ emit('\x1b[?2031h prompt \x1b[?20')
+ expect(replies(transport)).toEqual([])
+ emit('31l')
+ expect(replies(transport)).toEqual([])
+ dispose()
+ })
+
+ it('stays silent when an unrelated private mode appends a split withdrawal', async () => {
+ const { transport, emit, dispose } = await connectVisiblePane()
+ emit('\x1b[?2031h prompt \x1b[?25')
+ expect(replies(transport)).toEqual([])
+ emit(';2031l')
+ expect(replies(transport)).toEqual([])
+ dispose()
+ })
+
+ it('answers after an ambiguous tail resolves to another mode', async () => {
+ const { transport, emit, dispose } = await connectVisiblePane()
+ emit('\x1b[?2031h drawing \x1b[?20')
+ expect(replies(transport)).toEqual([])
+ emit('25h')
+ expect(replies(transport)).toEqual(['\x1b[?997;2n'])
+ dispose()
+ })
+
+ // One fish prompt cycle, exactly as fish's tty_handoff.rs emits it.
+ const FISH_PROMPT_HANDOFF = '\x1b[?2031h\x1b[0m~/orca \x1b[32m❯\x1b[0m \x1b[?2031l'
+
+ it('stays silent across three fish prompts', async () => {
+ const { transport, emit, dispose } = await connectVisiblePane()
+ emit(FISH_PROMPT_HANDOFF)
+ emit(FISH_PROMPT_HANDOFF)
+ emit(FISH_PROMPT_HANDOFF)
+ expect(replies(transport)).toEqual([])
+ dispose()
+ })
+
+ it('leaves no stale subscription behind after a fish prompt cycle', async () => {
+ const { deps, emit, dispose } = await connectVisiblePane()
+ emit(FISH_PROMPT_HANDOFF)
+ // A later theme flip must not push CSI 997 at a shell that unsubscribed.
+ expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined()
+ dispose()
+ })
+
+ it('answers once when a TUI subscribes at the end of a fish prompt chunk', async () => {
+ const { transport, emit, dispose } = await connectVisiblePane()
+ emit(`${FISH_PROMPT_HANDOFF}\x1b[?2031h`)
+ expect(replies(transport)).toHaveLength(1)
+ dispose()
+ })
+
+ it('answers a subscribe whose withdrawal never arrives', async () => {
+ const { transport, emit, dispose } = await connectVisiblePane()
+ // A real TUI: subscribe now, unsubscribe minutes later on exit.
+ emit('\x1b[?2031h')
+ emit('painting the ui')
+ expect(replies(transport)).toHaveLength(1)
+ dispose()
+ })
+
+ it('drops a live subscription when a later chunk withdraws it', async () => {
+ const { deps, emit, dispose } = await connectVisiblePane()
+ // A TUI that exits: the earlier chunk really did register, so the withdrawal has
+ // state to undo — otherwise a theme flip pushes CSI 997 at the shell that replaced it.
+ emit('\x1b[?2031h')
+ expect(deps.paneMode2031Ref.current.get(1)).toBe(true)
+ emit('\x1b[?2031l')
+ expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined()
+ expect(deps.paneLastThemeModeRef.current.get(1)).toBeUndefined()
+ dispose()
+ })
+
+ it('discards a half-read escape prefix when the pane swaps PTYs', async () => {
+ const { transport, emit, dispose } = await connectVisiblePane()
+ // Why: the tail is a byte range from the old stream. Splicing it onto the first
+ // chunk of a replacement PTY fabricates a subscribe no program ever sent.
+ transport.serializeBuffer = vi.fn().mockResolvedValue(null)
+ emit('\x1b[?20')
+ // droppedOutput latches the hidden-restore PTY id, which is what detects the swap.
+ emit('', { droppedOutput: true })
+ transport.getPtyId.mockReturnValue('pty-id-2')
+
+ emit('31h')
+
+ expect(replies(transport)).toEqual([])
+ dispose()
+ })
+ })
+
it('answers hidden Codex mode 2031 subscribes split across becoming visible', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
@@ -11605,10 +11881,11 @@ describe('connectPanePty', () => {
getMainBufferSnapshot.mockClear()
// Main hit the per-PTY pending cap and sent the droppedOutput sentinel: the stream has a gap, so repaint from the authoritative main-owned buffer.
- capturedDataCallback.current?.('', { droppedOutput: true })
+ capturedDataCallback.current?.('\x1b[?2031h', { droppedOutput: true })
await flushAsyncTicks(20)
expect(getMainBufferSnapshot).toHaveBeenCalled()
+ expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
expect(pane.terminal.write).toHaveBeenCalledWith(
expect.stringContaining('healed from snapshot'),
expect.any(Function)
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index 9103a1d40be..b39bb32d216 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -122,9 +122,10 @@ import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-sta
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import {
+ INITIAL_MODE_2031_REPLY_SCAN_STATE,
mode2031SequenceFor,
resolveTerminalColorSchemeMode,
- scanMode2031Sequences
+ scanMode2031ReplyDecision
} from '../../../../shared/terminal-color-scheme-protocol'
import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics'
import { subscribeToTerminalUserInput } from './terminal-user-input-signal'
@@ -664,11 +665,6 @@ type PanePtyBinding = IDisposable & {
requestDroidReconfirmation: () => void
reconcileIfSessionDead: (liveSessionIds: Set, snapshotRequestedAt?: number) => void
reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void
- /** True when the hidden-delivery gate structurally manages the pane's
- * current PTY. The lifecycle's xterm CSI ?2031h observer consults this to
- * stay silent — main's '2031-subscribe' fact is the sole responder for
- * gate-managed PTYs. */
- isHiddenDeliveryGateManagedPty: () => boolean
}
function isAgentTaskCompleteNotificationEnabled(): boolean {
@@ -2251,7 +2247,10 @@ export function connectPanePty(
// Why: gated hidden panes never see the subscribe bytes; the fact
// replaces the byte scan (and the old post-latch subscribe drop).
...(hiddenDeliveryGateActive
- ? { onMode2031Subscribe: handleHiddenMode2031SubscribeFact }
+ ? {
+ onMode2031Subscribe: handleHiddenMode2031SubscribeFact,
+ onMode2031Unsubscribe: handleHiddenMode2031UnsubscribeFact
+ }
: {})
},
restoreTitleOnRegister: true
@@ -3603,6 +3602,20 @@ export function connectPanePty(
deps.recordPaneMode2031Subscription?.(pane.id, mode)
recordHiddenMode2031Reply()
}
+ // Why (gate mode only): the counterpart to the subscribe fact. These panes never
+ // receive the withdrawal bytes — main drops them before delivery — and both the
+ // chunk scanner and the xterm CSI handler are disabled for them, so this fact is
+ // the ONLY observer that can retire the subscription. Without it a TUI that exits
+ // while hidden leaves paneMode2031 set, and the next theme flip pushes CSI 997
+ // into the shell that replaced it (#9993 via maybePushMode2031Flip). No reply is
+ // sent: a withdrawal is not a query.
+ const handleHiddenMode2031UnsubscribeFact = (): void => {
+ if (disposed || !isHiddenDeliveryGateManagedPty(transport.getPtyId())) {
+ return
+ }
+ deps.paneMode2031Ref.current.delete(pane.id)
+ deps.paneLastThemeModeRef.current.delete(pane.id)
+ }
deps.paneTransportsRef.current.set(pane.id, transport)
const terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({
terminal: pane.terminal,
@@ -5493,7 +5506,7 @@ export function connectPanePty(
let foregroundImmediateBudgetWindowStart = 0
let foregroundRewriteChunkEndedWithCarriageReturn = false
let foregroundRewriteCsiScanTail = ''
- let hiddenMode2031ScanTail = ''
+ let mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
const shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(paneStartup)
let hiddenStartupRendererQueryPending = ''
let hiddenRendererStateDirty = false
@@ -5815,19 +5828,30 @@ export function connectPanePty(
}
}
- function respondToSkippedMode2031Subscribe(data: string): void {
- const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data)
- hiddenMode2031ScanTail = scan.tail
- if (scan.finalState === 'unsubscribed') {
+ // Why here and not in xterm's CSI handler: xterm batches several PTY chunks into one
+ // synchronous parse, so a handler cannot tell where a chunk ended. fish enables and
+ // disables 2031 around every prompt, so answering a subscribe the same chunk withdraws
+ // pushes `?997;1n` into the prompt or a child's stdin as literal text (#9993). One raw
+ // chunk in, one order-aware decision out.
+ function observeLiveMode2031Chunk(data: string): void {
+ // Main's '2031-subscribe' fact is the sole responder for gate-managed PTYs; a second
+ // reply from here would answer one subscribe twice.
+ if (isHiddenDeliveryGateManagedPty(transport.getPtyId())) {
+ return
+ }
+ const result = scanMode2031ReplyDecision(mode2031ReplyScanState, data)
+ mode2031ReplyScanState = result.state
+ if (result.decision === 'unsubscribed') {
deps.paneMode2031Ref.current.delete(pane.id)
deps.paneLastThemeModeRef.current.delete(pane.id)
}
- if (scan.finalState !== 'subscribed') {
+ if (result.decision !== 'subscribed') {
return
}
const settings = useAppStore.getState().settings
const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())
- // Why: hidden snapshot-backed panes skip xterm.write for PTY bytes; answer immediately so the reply can't outlive the program's read window.
+ // Why immediate: the reply must land inside the program's read window, and hidden
+ // snapshot-backed panes skip xterm.write for PTY bytes entirely.
deps.paneMode2031Ref.current.set(pane.id, true)
sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode))
deps.paneLastThemeModeRef.current.set(pane.id, mode)
@@ -5905,9 +5929,6 @@ export function connectPanePty(
const synchronizedFrameLatencySensitive =
synchronizedForegroundOutput && synchronizedForegroundFrameInteractive
synchronizedForegroundOutputActive = nextSynchronizedForegroundOutputActive
- if (!foreground && hiddenMode2031ScanTail) {
- respondToSkippedMode2031Subscribe(data)
- }
writeTerminalOutput(pane.terminal, data, {
foreground: foregroundOutput,
beforeWrite: beforeTerminalOutputWrite,
@@ -6077,7 +6098,6 @@ export function connectPanePty(
function skipHiddenRendererOutput(data: string): void {
writeHiddenStartupRendererQueries(data)
- respondToSkippedMode2031Subscribe(data)
markHiddenOutputRestoreNeeded()
hiddenRendererStateDirty = true
if (hiddenOutputRestoreInFlight) {
@@ -6564,6 +6584,9 @@ export function connectPanePty(
function clearPaneMode2031State(): void {
deps.paneMode2031Ref.current.delete(pane.id)
deps.paneLastThemeModeRef.current.delete(pane.id)
+ // A partial CSI prefix belongs to the stream that produced it; carrying it into a
+ // replacement PTY would splice two unrelated byte ranges into one sequence.
+ mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
}
function pulseVisibleLocalPtySizeForTuiRepaint(ptyId: string): void {
@@ -6586,7 +6609,6 @@ export function connectPanePty(
function skipBackgroundAlternateScreenOutput(data: string): void {
writeHiddenStartupRendererQueries(data)
- respondToSkippedMode2031Subscribe(data)
resetSkippedHiddenRendererRiskState()
hiddenRendererStateDirty = true
recordHiddenRendererSkip(data.length)
@@ -7063,6 +7085,7 @@ export function connectPanePty(
}
observeStartupDraftPasteReadiness(data)
resetHiddenOutputRestoreIfPtyChanged()
+ observeLiveMode2031Chunk(data)
if (meta?.droppedOutput === true) {
// Why gated (rc.7.perf loop): a visible pane's cap-drop during its own restore is self-caused backpressure; defer to one post-flood repaint instead of re-arming per sentinel.
if (meta?.background !== true && isForegroundRestoreBackpressureContext()) {
@@ -7094,9 +7117,6 @@ export function connectPanePty(
if (!foreground) {
syncHiddenRendererPtyDelivery()
}
- if (foreground && hiddenMode2031ScanTail) {
- respondToSkippedMode2031Subscribe(data)
- }
// Post-restore reconciliation: drop chunks the snapshot covers, force a fresh restore for unmappable seq gaps; runs after byte observers, before any xterm write.
const reconciliation = reconcileChunkAgainstRestoredSnapshot(data, meta)
if (reconciliation.action === 'drop-duplicate') {
@@ -8261,9 +8281,6 @@ export function connectPanePty(
// Why: the hidden-delivery gate must follow every pane visibility flip.
syncHiddenRendererPtyDelivery()
},
- isHiddenDeliveryGateManagedPty() {
- return isHiddenDeliveryGateManagedPty(transport.getPtyId())
- },
// Why: visible-resume size readback repairs dropped hidden resizes without refitting against xterm's transient hidden DOM fallback.
noteVisibilityResume() {
ptySizeReassertion.request({ fit: false })
diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts
index 8a0e8842a32..62b43f0b158 100644
--- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts
@@ -1,16 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
-import { Terminal } from '@xterm/headless'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import { getDefaultSettings } from '../../../../shared/constants'
import {
applyTerminalAppearance,
hexToRgba,
- installMode2031Handlers,
publishTerminalViewAttributesAtAppStart
} from './terminal-appearance'
import { maybePushMode2031Flip } from './terminal-mode-2031-replies'
import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol'
-import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard'
import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher'
import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes'
@@ -138,219 +135,6 @@ describe('maybePushMode2031Flip', () => {
expect(last.get(2)).toBe('dark')
})
})
-describe('installMode2031Handlers', () => {
- // Regression coverage for the "random characters on restart" bug: a replayed `CSI ?2031h` pushed `CSI ?997;1n` into the fresh shell.
-
- function writeSync(term: Terminal, data: string): Promise {
- return new Promise((resolve) => term.write(data, resolve))
- }
-
- function makeReplayingRef(): ReplayingPanesRef {
- return { current: new Map() } as ReplayingPanesRef
- }
-
- function setup(paneId = 1): {
- term: Terminal
- pane: ManagedPane
- replayingPanesRef: ReplayingPanesRef
- onSubscribe: ReturnType
- paneMode2031: Map
- paneLastThemeMode: Map
- dispose: () => void
- } {
- const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
- const pane = { id: paneId, terminal: term } as unknown as ManagedPane
- const replayingPanesRef = makeReplayingRef()
- const paneMode2031 = new Map()
- const paneLastThemeMode = new Map()
- const onSubscribe = vi.fn()
- const disposables = installMode2031Handlers({
- paneId,
- parser: term.parser,
- onSubscribe,
- isReplaying: () => (replayingPanesRef.current.get(paneId) ?? 0) > 0,
- paneMode2031,
- paneLastThemeMode
- })
- return {
- term,
- pane,
- replayingPanesRef,
- onSubscribe,
- paneMode2031,
- paneLastThemeMode,
- dispose: () => {
- for (const d of disposables) {
- d.dispose()
- }
- term.dispose()
- }
- }
- }
-
- it('records subscribe and fires onSubscribe on a live `CSI ?2031h`', async () => {
- const h = setup()
- try {
- await writeSync(h.term, '\x1b[?2031h')
- expect(h.paneMode2031.get(1)).toBe(true)
- expect(h.onSubscribe).toHaveBeenCalledTimes(1)
- } finally {
- h.dispose()
- }
- })
-
- it('does NOT fire onSubscribe or record state when the sequence arrives during replay', async () => {
- // On cold restore the replay guard is set before xterm parses, so the handler must skip both the push and the bookkeeping.
- const h = setup()
- try {
- replayIntoTerminal(h.pane, h.replayingPanesRef, '\x1b[?2031h')
- // write() is async: the replay guard stays engaged until the write-completion callback fires.
- await new Promise((resolve) => h.term.write('', resolve))
-
- expect(h.onSubscribe).not.toHaveBeenCalled()
- expect(h.paneMode2031.has(1)).toBe(false)
- expect(h.paneLastThemeMode.has(1)).toBe(false)
- // Once the replay window closes, the pane is not marked replaying.
- expect(h.replayingPanesRef.current.get(1) ?? 0).toBe(0)
- } finally {
- h.dispose()
- }
- })
-
- it('still honors a real `CSI ?2031h` received after a replay window closes', async () => {
- // A real `?2031h` from a TUI relaunched after cold restore must take effect normally.
- const h = setup()
- try {
- replayIntoTerminal(h.pane, h.replayingPanesRef, '\x1b[?2031h')
- await new Promise((resolve) => h.term.write('', resolve))
- expect(h.onSubscribe).not.toHaveBeenCalled()
-
- await writeSync(h.term, '\x1b[?2031h')
- expect(h.paneMode2031.get(1)).toBe(true)
- expect(h.onSubscribe).toHaveBeenCalledTimes(1)
- } finally {
- h.dispose()
- }
- })
-
- it('clears subscribe state on `CSI ?2031l` regardless of replay state', async () => {
- // The `l` (unsubscribe) branch is intentionally not replay-guarded: clearing is harmless since we only send on subscribe.
- const h = setup()
- try {
- // Non-replay path: subscribe then unsubscribe clears state.
- await writeSync(h.term, '\x1b[?2031h')
- h.paneLastThemeMode.set(1, 'dark')
- expect(h.paneMode2031.get(1)).toBe(true)
-
- await writeSync(h.term, '\x1b[?2031l')
- expect(h.paneMode2031.has(1)).toBe(false)
- expect(h.paneLastThemeMode.has(1)).toBe(false)
-
- // Replay path: the `l` handler must still clear even during a replay window.
- await writeSync(h.term, '\x1b[?2031h')
- h.paneLastThemeMode.set(1, 'dark')
- expect(h.paneMode2031.get(1)).toBe(true)
-
- replayIntoTerminal(h.pane, h.replayingPanesRef, '\x1b[?2031l')
- await new Promise((resolve) => h.term.write('', resolve))
- expect(h.paneMode2031.has(1)).toBe(false)
- expect(h.paneLastThemeMode.has(1)).toBe(false)
- } finally {
- h.dispose()
- }
- })
-
- it('returns `false` so compound DEC private modes still reach xterm', async () => {
- // Why: handlers return `false` so compound sequences like `CSI ?25;2031h` still reach xterm's built-in DEC private mode handler.
- const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
- const paneMode2031 = new Map()
- const paneLastThemeMode = new Map()
- const onSubscribe = vi.fn()
- const returnValues: boolean[] = []
- // Cast: parser cb returns plain `boolean` but `Mode2031Parser` reflects xterm's `boolean | Promise` (handlers here are sync).
- const spyParser: Parameters[0]['parser'] = {
- registerCsiHandler: (id, cb) =>
- term.parser.registerCsiHandler(id, (params) => {
- const r = cb(params) as boolean
- returnValues.push(r)
- return r
- })
- }
- const disposables = installMode2031Handlers({
- paneId: 1,
- parser: spyParser,
- onSubscribe,
- isReplaying: () => false,
- paneMode2031,
- paneLastThemeMode
- })
- try {
- // Compound: ?25 (cursor show) + ?2031 (color-scheme subscribe).
- await writeSync(term, '\x1b[?25;2031h')
- // Our 2031 recording fired:
- expect(paneMode2031.get(1)).toBe(true)
- expect(onSubscribe).toHaveBeenCalledTimes(1)
- // Every handler invocation returned `false`, so xterm's built-in DEC private mode handler still processes the sequence.
- expect(returnValues.length).toBeGreaterThan(0)
- expect(returnValues.every((v) => v === false)).toBe(true)
- } finally {
- for (const d of disposables) {
- d.dispose()
- }
- term.dispose()
- }
- })
-
- it('keeps per-pane state isolated when two panes share the parser API', async () => {
- // The subscribe bookkeeping map is shared across panes, so a replay on pane 1 must not leak into pane 2's live subscribe.
- const shared2031 = new Map()
- const sharedLast = new Map()
- const replayingPanesRef = makeReplayingRef()
-
- const term1 = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
- const term2 = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
- const pane1 = { id: 1, terminal: term1 } as unknown as ManagedPane
- const onSub1 = vi.fn()
- const onSub2 = vi.fn()
-
- const d1 = installMode2031Handlers({
- paneId: 1,
- parser: term1.parser,
- onSubscribe: onSub1,
- isReplaying: () => (replayingPanesRef.current.get(1) ?? 0) > 0,
- paneMode2031: shared2031,
- paneLastThemeMode: sharedLast
- })
- const d2 = installMode2031Handlers({
- paneId: 2,
- parser: term2.parser,
- onSubscribe: onSub2,
- isReplaying: () => (replayingPanesRef.current.get(2) ?? 0) > 0,
- paneMode2031: shared2031,
- paneLastThemeMode: sharedLast
- })
-
- try {
- // Replay on pane 1 must not subscribe.
- replayIntoTerminal(pane1, replayingPanesRef, '\x1b[?2031h')
- await new Promise((resolve) => term1.write('', resolve))
- expect(onSub1).not.toHaveBeenCalled()
- expect(shared2031.has(1)).toBe(false)
-
- // Live on pane 2 must subscribe normally.
- await writeSync(term2, '\x1b[?2031h')
- expect(onSub2).toHaveBeenCalledTimes(1)
- expect(shared2031.get(2)).toBe(true)
- } finally {
- for (const d of [...d1, ...d2]) {
- d.dispose()
- }
- term1.dispose()
- term2.dispose()
- }
- })
-})
-
describe('applyTerminalAppearance theme assignment', () => {
// xterm rebuilds the palette on any new theme-object identity (wiping OSC color mutations), so the assignment must be value-gated.
function makePane(id: number): ManagedPane {
diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts
index fa93552388a..c1ff2c24f1a 100644
--- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts
@@ -1,4 +1,4 @@
-import type { IDisposable, IParser, ITheme } from '@xterm/xterm'
+import type { ITheme } from '@xterm/xterm'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { GlobalSettings } from '../../../../shared/types'
import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts'
@@ -9,7 +9,6 @@ import {
resolveEffectiveTerminalAppearance
} from '@/lib/terminal-theme'
import { buildFontFamily } from './layout-serialization'
-import { guardParserHandler } from './terminal-parser-handler-guard'
import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops'
import {
normalizeTerminalFastScrollSensitivity,
@@ -26,57 +25,6 @@ import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-he
import { maybePushMode2031Flip } from './terminal-mode-2031-replies'
import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction'
-// Why Pick over a hand-rolled type: stays tied to xterm's canonical signature so upstream tightening surfaces here.
-type Mode2031Parser = Pick
-
-type Mode2031HandlerDeps = {
- paneId: number
- parser: Mode2031Parser
- /** Called when a real (non-replayed) `CSI ?2031h` arrives, after the subscribe flag is set.
- * A callback so the lifecycle hook keeps its transport-aware `pushMode2031ForPane` closure. */
- onSubscribe: () => void
- isReplaying: () => boolean
- paneMode2031: Map
- paneLastThemeMode: Map
-}
-
-// Why a pure function: lets tests drive a real xterm parser end-to-end against the "random characters on restart" guard.
-export function installMode2031Handlers(deps: Mode2031HandlerDeps): IDisposable[] {
- const hasMode2031 = (params: (number | number[])[]): boolean =>
- params.some((p) => (Array.isArray(p) ? p.includes(2031) : p === 2031))
-
- // Why return false: we only observe mode 2031; false lets xterm's built-in DEC handler still process compound sequences.
- return [
- deps.parser.registerCsiHandler(
- { prefix: '?', final: 'h' },
- guardParserHandler('csi-mode2031-subscribe', (params) => {
- if (hasMode2031(params)) {
- // Why gate on isReplaying: a restored buffer's replayed `?2031h` would push `?997;1n` into a fresh shell with no
- // TUI, which echoes it as literal text; pty-connection's guard covers only xterm auto-replies, not handler sends.
- // Return early (before recording the subscribe bit) so a later theme flip won't push into a shell that isn't subscribed.
- if (deps.isReplaying()) {
- return false
- }
- deps.paneMode2031.set(deps.paneId, true)
- deps.onSubscribe()
- }
- return false
- })
- ),
- // Why no replay guard here: we only push CSI 997 on subscribe; unsubscribe just clears map entries, so replay is harmless.
- deps.parser.registerCsiHandler(
- { prefix: '?', final: 'l' },
- guardParserHandler('csi-mode2031-unsubscribe', (params) => {
- if (hasMode2031(params)) {
- deps.paneMode2031.delete(deps.paneId)
- deps.paneLastThemeMode.delete(deps.paneId)
- }
- return false
- })
- )
- ]
-}
-
export function hexToRgba(hex: string, alpha: number): string {
let clean = hex.replace('#', '')
if (clean.length === 3) {
diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts
index 5c8026b74d0..cb1c698fc15 100644
--- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts
@@ -7,6 +7,7 @@ import {
TERMINAL_TAB_HOT_RETAIN_MS,
TERMINAL_WORKTREE_HOT_RETAIN_MS,
TERMINAL_WORKTREE_PARK_DELAY_MS,
+ canManuallyParkTerminalWorktreeRenderers,
canParkTerminalTabRenderer,
canParkTerminalWorktreeRenderers,
getTerminalTabColdParkRecheckDelayMs,
@@ -181,6 +182,36 @@ describe('canParkTerminalTabRenderer', () => {
})
})
+describe('canManuallyParkTerminalWorktreeRenderers', () => {
+ const base = {
+ worktreeId: 'repo::/worktree',
+ terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }],
+ pendingStartupByTabId: {},
+ parkingEnabled: true
+ }
+
+ it('bypasses visibility timing for snapshot-backed local terminals', () => {
+ expect(canManuallyParkTerminalWorktreeRenderers(base)).toBe(true)
+ })
+
+ it('preserves safety gates for empty, pending, remote, and disabled terminals', () => {
+ expect(canManuallyParkTerminalWorktreeRenderers({ ...base, terminalTabs: [] })).toBe(false)
+ expect(
+ canManuallyParkTerminalWorktreeRenderers({
+ ...base,
+ pendingStartupByTabId: { 'tab-1': ['echo', 'pending'] }
+ })
+ ).toBe(false)
+ expect(
+ canManuallyParkTerminalWorktreeRenderers({
+ ...base,
+ terminalTabs: [{ id: 'tab-1', ptyId: 'ssh:ssh-1@@pty-1' }]
+ })
+ ).toBe(false)
+ expect(canManuallyParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false)
+ })
+})
+
describe('selectColdParkedTerminalWorktrees', () => {
const nowMs = 500_000
diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts
index f4b434c948b..4701ff727a3 100644
--- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts
@@ -107,6 +107,26 @@ export function canParkTerminalWorktreeRenderers(args: {
})
}
+export function canManuallyParkTerminalWorktreeRenderers(args: {
+ worktreeId: string
+ terminalTabs: readonly ColdParkableTerminalTab[]
+ pendingStartupByTabId: Readonly>
+ parkingEnabled: boolean
+}): boolean {
+ return (
+ args.terminalTabs.length > 0 &&
+ canParkTerminalWorktreeRenderers({
+ ...args,
+ isVisible: false,
+ shouldMeasureHiddenWorktree: false,
+ hasActivityTerminalPortal: false,
+ hiddenSinceMs: 0,
+ nowMs: 0,
+ coldParkDelayMs: 0
+ })
+ )
+}
+
export function canParkTerminalTabRenderer(args: {
worktreeId: string
terminalTab: TerminalTabColdParkCandidate
diff --git a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts
deleted file mode 100644
index f26cd149894..00000000000
--- a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { describe, expect, it, vi } from 'vitest'
-import { pushMode2031SeedReply } from './terminal-mode-2031-replies'
-
-describe('pushMode2031SeedReply', () => {
- function createHarness(connected: boolean): {
- connected: { current: boolean }
- subscribed: { current: boolean }
- sendInput: ReturnType boolean>>
- sendInputImmediate: ReturnType boolean>>
- scheduled: (() => void)[]
- recordMode: ReturnType
- push: () => void
- } {
- const connectedState = { current: connected }
- const subscribed = { current: true }
- const sendInput = vi.fn<(data: string) => boolean>(() => true)
- const sendInputImmediate = vi.fn<(data: string) => boolean>(() => true)
- const scheduled: (() => void)[] = []
- const recordMode = vi.fn()
- const currentAttempt = { current: Symbol() }
- const transport = {
- isConnected: () => connectedState.current,
- sendInput,
- sendInputImmediate
- }
- return {
- connected: connectedState,
- subscribed,
- sendInput,
- sendInputImmediate,
- scheduled,
- recordMode,
- push: () => {
- const attempt = Symbol()
- currentAttempt.current = attempt
- pushMode2031SeedReply(1, {
- hasPane: () => true,
- isSubscribed: () => subscribed.current,
- isCurrentAttempt: () => currentAttempt.current === attempt,
- getTransport: () => transport,
- getMode: () => 'dark',
- recordMode,
- schedule: (callback) => scheduled.push(callback)
- })
- }
- }
- }
-
- it('routes the color-scheme response through latency-critical input', () => {
- const harness = createHarness(true)
-
- harness.push()
-
- expect(harness.sendInputImmediate).toHaveBeenCalledWith('\x1b[?997;1n')
- expect(harness.sendInput).not.toHaveBeenCalled()
- expect(harness.recordMode).toHaveBeenCalledWith(1, 'dark')
- })
-
- it('cancels a pre-connect retry after the program unsubscribes', () => {
- const harness = createHarness(false)
-
- harness.push()
- expect(harness.scheduled).toHaveLength(1)
-
- harness.subscribed.current = false
- harness.connected.current = true
- harness.scheduled.shift()?.()
-
- expect(harness.sendInputImmediate).not.toHaveBeenCalled()
- expect(harness.sendInput).not.toHaveBeenCalled()
- expect(harness.scheduled).toHaveLength(0)
- })
-
- it('does not let an old retry answer a later subscription', () => {
- const harness = createHarness(false)
-
- harness.push()
- harness.subscribed.current = false
- harness.subscribed.current = true
- harness.push()
- expect(harness.scheduled).toHaveLength(2)
-
- harness.connected.current = true
- harness.scheduled.shift()?.()
- harness.scheduled.shift()?.()
-
- expect(harness.sendInputImmediate).toHaveBeenCalledTimes(1)
- expect(harness.recordMode).toHaveBeenCalledTimes(1)
- })
-})
diff --git a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts
index 66df12517b1..7e709dc51e9 100644
--- a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts
@@ -2,21 +2,8 @@ import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-pr
import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol'
import type { PtyTransport } from './pty-transport'
-const MODE_2031_CONNECT_RETRY_MS = 25
-const MODE_2031_CONNECT_ATTEMPTS = 8
-
type Mode2031ReplyTransport = Pick
-type Mode2031SeedReplyDeps = {
- hasPane: (paneId: number) => boolean
- isSubscribed: (paneId: number) => boolean
- isCurrentAttempt: (paneId: number) => boolean
- getTransport: (paneId: number) => Mode2031ReplyTransport | undefined
- getMode: () => TerminalColorSchemeMode | null
- recordMode: (paneId: number, mode: TerminalColorSchemeMode) => void
- schedule: (callback: () => void, delayMs: number) => void
-}
-
function sendMode2031Reply(
transport: Mode2031ReplyTransport,
mode: TerminalColorSchemeMode
@@ -26,30 +13,6 @@ function sendMode2031Reply(
return transport.sendInputImmediate(mode2031SequenceFor(mode))
}
-export function pushMode2031SeedReply(paneId: number, deps: Mode2031SeedReplyDeps): void {
- let attempts = 0
- const send = (): void => {
- // Why: a TUI can unsubscribe while the PTY is connecting; every delayed
- // attempt must revalidate intent or its color reply can land at a shell prompt.
- if (!deps.hasPane(paneId) || !deps.isSubscribed(paneId) || !deps.isCurrentAttempt(paneId)) {
- return
- }
- const transport = deps.getTransport(paneId)
- if (!transport?.isConnected()) {
- attempts += 1
- if (attempts < MODE_2031_CONNECT_ATTEMPTS) {
- deps.schedule(send, MODE_2031_CONNECT_RETRY_MS)
- }
- return
- }
- const mode = deps.getMode()
- if (mode && sendMode2031Reply(transport, mode)) {
- deps.recordMode(paneId, mode)
- }
- }
- send()
-}
-
// Appearance updates include font and opacity changes, so only report actual
// color-mode flips to programs that still have mode 2031 enabled.
export function maybePushMode2031Flip(
diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts
index d9f3c8d7d31..892e8977d72 100644
--- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts
@@ -86,6 +86,9 @@ export type TerminalSideEffectFactConsumerCallbacks = {
* hidden-delivery-gated consumers (their bytes never arrive); the theme
* reply is sent renderer-side — query authority stays with the view. */
onMode2031Subscribe?: () => void
+ /** DECSET 2031 withdrawal observed by main's tracker. Clears the pane's
+ * subscription registry so later theme flips stop pushing CSI 997. */
+ onMode2031Unsubscribe?: () => void
}
type ConsumerEntry = {
@@ -137,6 +140,9 @@ function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq:
return
case '2031-subscribe':
entry.callbacks.onMode2031Subscribe?.()
+ return
+ case '2031-unsubscribe':
+ entry.callbacks.onMode2031Unsubscribe?.()
}
}
diff --git a/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts
new file mode 100644
index 00000000000..d3d9b44d544
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest'
+import { combineTerminalWorktreeParkIds } from './use-manual-terminal-worktree-parking'
+
+describe('combineTerminalWorktreeParkIds', () => {
+ it('adds manually parked worktrees without dropping automatic parks', () => {
+ expect(combineTerminalWorktreeParkIds(new Set(['automatic']), new Set(['manual']))).toEqual(
+ new Set(['automatic', 'manual'])
+ )
+ })
+
+ it('keeps the automatic set reference when there are no manual parks', () => {
+ const automatic = new Set(['automatic'])
+
+ expect(combineTerminalWorktreeParkIds(automatic, new Set())).toBe(automatic)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts
new file mode 100644
index 00000000000..2d1ed09afce
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts
@@ -0,0 +1,99 @@
+import { useCallback, useEffect, useState } from 'react'
+import { toast } from 'sonner'
+import { useAppStore } from '@/store'
+import { translate } from '@/i18n/i18n'
+import {
+ MANUAL_TERMINAL_WORKTREE_PARK_EVENT,
+ takeAllPendingManualTerminalWorktreeParks,
+ takePendingManualTerminalWorktreePark,
+ type ManualTerminalWorktreeParkDetail
+} from '@/lib/manual-terminal-worktree-parking'
+import { canManuallyParkTerminalWorktreeRenderers } from './terminal-hidden-view-parking'
+import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers'
+
+function addWorktreeId(current: ReadonlySet, worktreeId: string): ReadonlySet {
+ if (current.has(worktreeId)) {
+ return current
+ }
+ return new Set([...current, worktreeId])
+}
+
+function removeWorktreeId(current: ReadonlySet, worktreeId: string): ReadonlySet {
+ if (!current.has(worktreeId)) {
+ return current
+ }
+ const next = new Set(current)
+ next.delete(worktreeId)
+ return next
+}
+
+export function combineTerminalWorktreeParkIds(
+ automaticIds: ReadonlySet,
+ manualIds: ReadonlySet
+): ReadonlySet {
+ if (manualIds.size === 0) {
+ return automaticIds
+ }
+ return new Set([...automaticIds, ...manualIds])
+}
+
+export function useManualTerminalWorktreeParking(args: {
+ activeView: string
+ renderedActiveWorktreeId: string | null
+}): ReadonlySet {
+ const [manuallyParkedWorktreeIds, setManuallyParkedWorktreeIds] = useState>(
+ () => new Set()
+ )
+
+ const parkWorktree = useCallback((worktreeId: string) => {
+ const state = useAppStore.getState()
+ const terminalTabs = state.tabsByWorktree[worktreeId] ?? []
+ const canPark =
+ canManuallyParkTerminalWorktreeRenderers({
+ worktreeId,
+ terminalTabs,
+ pendingStartupByTabId: state.pendingStartupByTabId,
+ parkingEnabled: state.settings?.terminalHiddenViewParking !== false
+ }) && terminalTabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab))
+ if (!canPark) {
+ toast.warning(
+ translate(
+ 'auto.components.terminalPane.useManualTerminalWorktreeParking.cannotPark',
+ 'These terminals cannot be parked safely.'
+ )
+ )
+ return
+ }
+ setManuallyParkedWorktreeIds((current) => addWorktreeId(current, worktreeId))
+ }, [])
+
+ useEffect(() => {
+ const handleParkRequest = (event: Event): void => {
+ const worktreeId = (event as CustomEvent).detail?.worktreeId
+ if (!worktreeId) {
+ return
+ }
+ takePendingManualTerminalWorktreePark(worktreeId)
+ parkWorktree(worktreeId)
+ }
+ window.addEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, handleParkRequest as EventListener)
+ for (const worktreeId of takeAllPendingManualTerminalWorktreeParks()) {
+ parkWorktree(worktreeId)
+ }
+ return () =>
+ window.removeEventListener(
+ MANUAL_TERMINAL_WORKTREE_PARK_EVENT,
+ handleParkRequest as EventListener
+ )
+ }, [parkWorktree])
+
+ useEffect(() => {
+ const revealedWorktreeId = args.renderedActiveWorktreeId
+ if (args.activeView !== 'terminal' || !revealedWorktreeId) {
+ return
+ }
+ setManuallyParkedWorktreeIds((current) => removeWorktreeId(current, revealedWorktreeId))
+ }, [args.activeView, args.renderedActiveWorktreeId])
+
+ return manuallyParkedWorktreeIds
+}
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
index bdc8ea6300b..ff49cc46800 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
@@ -66,8 +66,7 @@ import { scheduleTerminalInitialRenderSettled } from './terminal-initial-render-
import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse'
-import { applyTerminalAppearance, installMode2031Handlers } from './terminal-appearance'
-import { pushMode2031SeedReply } from './terminal-mode-2031-replies'
+import { applyTerminalAppearance } from './terminal-appearance'
import { createOsc52OscHandler } from './osc52-clipboard'
import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast'
import { parseOsc7 } from './parse-osc7'
@@ -98,7 +97,6 @@ import {
import type { PaneCwdMap } from './resolve-split-cwd'
import { installMouseHideWhileTyping } from './mouse-hide-while-typing'
import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt'
-import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { connectPanePty } from './pty-connection'
import type { PtyTransport } from './pty-transport'
import type { PtyTransportRecoveryState } from './pty-transport-types'
@@ -574,8 +572,6 @@ export function useTerminalPaneLifecycle({
// Why: read settingsRef at fire time so toggling "copy on select" applies without recreating panes.
const selectionDisposablesRef = useRef(new Map())
const selectionCaptureTimersRef = useRef(new Map())
- const mode2031DisposablesRef = useRef(new Map())
- const mode2031SeedAttemptTokensRef = useRef(new Map())
const osc52DisposablesRef = useRef(new Map())
const osc7DisposablesRef = useRef(new Map())
const mouseHideDisposablesRef = useRef(new Map())
@@ -601,30 +597,6 @@ export function useTerminalPaneLifecycle({
)
}
- const pushMode2031ForPane = (paneId: number): void => {
- const attemptToken = Symbol()
- mode2031SeedAttemptTokensRef.current.set(paneId, attemptToken)
- pushMode2031SeedReply(paneId, {
- hasPane: (candidateId) =>
- managerRef.current?.getPanes().some((pane) => pane.id === candidateId) === true,
- isSubscribed: (candidateId) => paneMode2031Ref.current.get(candidateId) === true,
- // Why: an older connect retry must not answer a later resubscription.
- isCurrentAttempt: (candidateId) =>
- mode2031SeedAttemptTokensRef.current.get(candidateId) === attemptToken,
- getTransport: (candidateId) => paneTransportsRef.current.get(candidateId),
- getMode: () => {
- const currentSettings = settingsRef.current
- return currentSettings
- ? resolveEffectiveTerminalAppearance(currentSettings, systemPrefersDarkRef.current).mode
- : null
- },
- recordMode: (candidateId, mode) => paneLastThemeModeRef.current.set(candidateId, mode),
- schedule: (callback, delayMs) => {
- window.setTimeout(callback, delayMs)
- }
- })
- }
-
// Initialize PaneManager instance once
useEffect(() => {
const container = containerRef.current
@@ -786,26 +758,6 @@ export function useTerminalPaneLifecycle({
const manager = new PaneManager(container, {
// `spawnHints.cwd` (from Split actions) lets the new PTY inherit the source pane's cwd — see docs/ssh-split-pane-inherit-cwd.md.
onPaneCreated: (pane, spawnHints) => {
- // Install mode 2031 handlers before PTY attach so the child's initial CSI ?2031h is captured.
- const mode2031Disposables = installMode2031Handlers({
- paneId: pane.id,
- parser: pane.terminal.parser,
- onSubscribe: () => {
- // Why: for hidden-delivery-gate PTYs, main's '2031-subscribe' fact is the sole responder — don't send a second reply.
- const binding = panePtyBindings.get(pane.id) as
- | (IDisposable & { isHiddenDeliveryGateManagedPty?: () => boolean })
- | undefined
- if (binding?.isHiddenDeliveryGateManagedPty?.()) {
- return
- }
- pushMode2031ForPane(pane.id)
- },
- isReplaying: () => isPaneReplaying(replayingPanesRef, pane.id),
- paneMode2031: paneMode2031Ref.current,
- paneLastThemeMode: paneLastThemeModeRef.current
- })
- mode2031DisposablesRef.current.set(pane.id, mode2031Disposables)
-
// OSC 52 — TUI-initiated clipboard writes (Zellij/tmux/nvim/fzf/ssh).
// Why: read settingsRef at fire time so mid-session gate toggles apply; return true in both paths so xterm doesn't fall through.
const osc52Disposable = pane.terminal.parser.registerOscHandler(
@@ -1185,15 +1137,7 @@ export function useTerminalPaneLifecycle({
window.clearTimeout(selectionCaptureTimer)
selectionCaptureTimersRef.current.delete(paneId)
}
- const mode2031Disposables = mode2031DisposablesRef.current.get(paneId)
- if (mode2031Disposables) {
- for (const d of mode2031Disposables) {
- d.dispose()
- }
- mode2031DisposablesRef.current.delete(paneId)
- }
paneMode2031Ref.current.delete(paneId)
- mode2031SeedAttemptTokensRef.current.delete(paneId)
paneKittyKeyboardModesRef.current.delete(paneId)
paneLastThemeModeRef.current.delete(paneId)
const osc52Disposable = osc52DisposablesRef.current.get(paneId)
diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts
index 562bbbbf25a..ae8f70d05dd 100644
--- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts
+++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts
@@ -12,6 +12,9 @@ const mockOnDispatchRequested = vi.fn()
const mockRendererReady = vi.fn()
const mockFinalizeTerminalOwnership = vi.fn()
const mockReleaseTerminalOwnership = vi.fn()
+const mockSshNeedsPassphrasePrompt = vi.fn()
+const mockSshGetState = vi.fn()
+const mockSshConnect = vi.fn()
const setupLaunch = {
runnerScriptPath: '/tmp/setup.sh',
@@ -25,15 +28,35 @@ const createdWorktree = {
path: '/repo/worktree'
}
type TestWorktree = typeof createdWorktree
+type TestRepo = {
+ id: string
+ connectionId: string | null
+ executionHostId: string | null
+ path: string
+}
const state = {
activeView: 'terminal' as const,
activeWorktreeId: 'wt-active',
activeTabId: 'tab-active',
activeTabType: 'terminal' as const,
- repos: [{ id: 'repo-1', connectionId: null }],
+ repos: [{ id: 'repo-1', connectionId: null, executionHostId: null, path: '/repo' }] as TestRepo[],
+ folderWorkspaces: [] as {
+ id: string
+ projectGroupId: string
+ folderPath: string
+ connectionId: string | null
+ }[],
+ projectGroups: [] as {
+ id: string
+ connectionId: string | null
+ executionHostId?: string | null
+ }[],
+ worktreesByRepo: {} as Record,
+ detectedWorktreesByRepo: {},
agentStatusByPaneKey: {},
allWorktrees: vi.fn<() => TestWorktree[]>(() => []),
+ getKnownWorktreeById: vi.fn<(worktreeId: string) => TestWorktree | undefined>(() => undefined),
createWorktree: mockCreateWorktree,
subscribe: vi.fn(() => () => {}),
setActiveView: vi.fn(),
@@ -146,9 +169,13 @@ describe('useAutomationDispatchEvents setup launch', () => {
state.activeWorktreeId = 'wt-active'
state.activeTabId = 'tab-active'
state.activeTabType = 'terminal'
- state.repos = [{ id: 'repo-1', connectionId: null }]
+ state.repos = [{ id: 'repo-1', connectionId: null, executionHostId: null, path: '/repo' }]
+ state.folderWorkspaces = []
+ state.projectGroups = []
+ state.worktreesByRepo = {}
state.agentStatusByPaneKey = {}
state.allWorktrees.mockReturnValue([])
+ state.getKnownWorktreeById.mockReturnValue(undefined)
mockCreateWorktree.mockResolvedValue({ worktree: createdWorktree, setup: setupLaunch })
mockLaunchWorktreeBackgroundTerminals.mockResolvedValue(undefined)
mockLaunchAgentBackgroundSession.mockResolvedValue({
@@ -162,6 +189,9 @@ describe('useAutomationDispatchEvents setup launch', () => {
}
})
mockOnDispatchRequested.mockReturnValue(() => {})
+ mockSshNeedsPassphrasePrompt.mockResolvedValue(false)
+ mockSshGetState.mockResolvedValue({ status: 'connected' })
+ mockSshConnect.mockResolvedValue({ status: 'connected' })
vi.stubGlobal('window', {
api: {
automations: {
@@ -172,9 +202,9 @@ describe('useAutomationDispatchEvents setup launch', () => {
listRuns: vi.fn().mockResolvedValue([])
},
ssh: {
- needsPassphrasePrompt: vi.fn().mockResolvedValue(false),
- getState: vi.fn().mockResolvedValue({ status: 'connected' }),
- connect: vi.fn()
+ needsPassphrasePrompt: mockSshNeedsPassphrasePrompt,
+ getState: mockSshGetState,
+ connect: mockSshConnect
}
},
dispatchEvent: vi.fn()
@@ -319,6 +349,145 @@ describe('useAutomationDispatchEvents setup launch', () => {
)
})
+ it('dispatches an existing SSH folder workspace on its resolved host', async () => {
+ const folderWorkspace = {
+ id: 'folder:fw-1',
+ repoId: 'folder-workspace:group-1',
+ displayName: 'SSH folder',
+ path: '/srv/project'
+ }
+ state.repos = [
+ {
+ id: 'repo-1',
+ connectionId: 'ssh-folder',
+ executionHostId: null,
+ path: '/srv/project/repo'
+ }
+ ]
+ state.folderWorkspaces = [
+ {
+ id: 'fw-1',
+ projectGroupId: 'group-1',
+ folderPath: '/srv/project',
+ connectionId: 'ssh-folder'
+ }
+ ]
+ state.projectGroups = [{ id: 'group-1', connectionId: 'ssh-folder' }]
+ state.getKnownWorktreeById.mockReturnValue(folderWorkspace)
+ mockSshGetState.mockResolvedValue({ status: 'disconnected' })
+
+ await registerAndDispatch(
+ makeAutomation({
+ workspaceMode: 'existing',
+ workspaceId: folderWorkspace.id,
+ setupDecision: 'skip',
+ runContext: { repoId: 'repo-1', hostId: 'ssh:ssh-folder' }
+ })
+ )
+
+ expect(state.allWorktrees).not.toHaveBeenCalled()
+ expect(mockSshConnect).toHaveBeenCalledWith({ targetId: 'ssh-folder' })
+ expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ worktreeId: folderWorkspace.id,
+ prompt: 'run this'
+ })
+ )
+ expect(mockMarkDispatchResult).toHaveBeenCalledWith(
+ expect.objectContaining({
+ status: 'dispatched',
+ workspaceId: folderWorkspace.id,
+ workspaceDisplayName: folderWorkspace.displayName
+ })
+ )
+ })
+
+ it('dispatches a local folder workspace without SSH', async () => {
+ const folderWorkspace = {
+ id: 'folder:fw-local',
+ repoId: 'folder-workspace:group-local',
+ displayName: 'Local folder',
+ path: '/project'
+ }
+ state.folderWorkspaces = [
+ {
+ id: 'fw-local',
+ projectGroupId: 'group-local',
+ folderPath: '/project',
+ connectionId: null
+ }
+ ]
+ state.projectGroups = [{ id: 'group-local', connectionId: null }]
+ state.getKnownWorktreeById.mockReturnValue(folderWorkspace)
+
+ await registerAndDispatch(
+ makeAutomation({
+ workspaceMode: 'existing',
+ workspaceId: folderWorkspace.id,
+ runContext: { repoId: 'repo-1', hostId: 'local' }
+ })
+ )
+
+ expect(mockSshNeedsPassphrasePrompt).not.toHaveBeenCalled()
+ expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
+ expect.objectContaining({ worktreeId: folderWorkspace.id })
+ )
+ })
+
+ it('skips a folder workspace owned by a different host', async () => {
+ const folderWorkspace = {
+ id: 'folder:fw-other',
+ repoId: 'folder-workspace:group-other',
+ displayName: 'Other host',
+ path: '/srv/other'
+ }
+ state.folderWorkspaces = [
+ {
+ id: 'fw-other',
+ projectGroupId: 'group-other',
+ folderPath: '/srv/other',
+ connectionId: 'ssh-other'
+ }
+ ]
+ state.projectGroups = [{ id: 'group-other', connectionId: 'ssh-other' }]
+ state.getKnownWorktreeById.mockReturnValue(folderWorkspace)
+
+ await registerAndDispatch(
+ makeAutomation({
+ workspaceMode: 'existing',
+ workspaceId: folderWorkspace.id,
+ runContext: { repoId: 'repo-1', hostId: 'local' }
+ })
+ )
+
+ expect(mockLaunchAgentBackgroundSession).not.toHaveBeenCalled()
+ expect(mockMarkDispatchResult).toHaveBeenCalledWith(
+ expect.objectContaining({ status: 'skipped_unavailable' })
+ )
+ })
+
+ it('keeps detected-only non-folder workspaces unavailable', async () => {
+ state.getKnownWorktreeById.mockReturnValue({
+ id: 'wt-detected',
+ repoId: 'repo-1',
+ displayName: 'Detected',
+ path: '/repo/detected'
+ })
+
+ await registerAndDispatch(
+ makeAutomation({
+ workspaceMode: 'existing',
+ workspaceId: 'wt-detected'
+ })
+ )
+
+ expect(state.getKnownWorktreeById).not.toHaveBeenCalled()
+ expect(mockLaunchAgentBackgroundSession).not.toHaveBeenCalled()
+ expect(mockMarkDispatchResult).toHaveBeenCalledWith(
+ expect.objectContaining({ status: 'skipped_unavailable' })
+ )
+ })
+
it('finalizes a fresh non-reuse terminal only after completed result persistence', async () => {
const order: string[] = []
let launchArgs: { onAgentStatus?: (payload: { state: string }) => void } = {}
diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts
index 1679aaf1af5..d543d639ddf 100644
--- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts
+++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts
@@ -24,6 +24,14 @@ import {
import { translate } from '@/i18n/i18n'
import { createBrowserUuid } from '@/lib/browser-uuid'
import type { AutomationTerminalOwnership } from '@/lib/automation-terminal-ownership'
+import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host'
+import {
+ getRepoExecutionHostId,
+ parseExecutionHostId,
+ toSshExecutionHostId
+} from '../../../shared/execution-host'
+import { parseWorkspaceKey } from '../../../shared/workspace-scope'
+import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection'
const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed'
const activeReuseDispatchTabIds = new Set()
@@ -63,8 +71,11 @@ export function useAutomationDispatchEvents(): void {
}
const runRepoId = getAutomationRunRepoId(automation)
const repo = state.repos.find((entry) => entry.id === runRepoId)
+ const automationWorkspaceScope = parseWorkspaceKey(automation.workspaceId ?? '')
const automationWorktree = automation.workspaceId
- ? state.allWorktrees().find((entry) => entry.id === automation.workspaceId)
+ ? automationWorkspaceScope?.type === 'folder'
+ ? state.getKnownWorktreeById(automation.workspaceId)
+ : state.allWorktrees().find((entry) => entry.id === automation.workspaceId)
: null
let dispatchWorkspaceId = automation.workspaceId
let dispatchWorkspaceDisplayName =
@@ -97,9 +108,49 @@ export function useAutomationDispatchEvents(): void {
}
try {
- if (repo.connectionId) {
+ const folderWorkspaceConnectionId =
+ automationWorkspaceScope?.type === 'folder'
+ ? getFolderWorkspaceConnectionId(state, automationWorkspaceScope.folderWorkspaceId)
+ : null
+ const folderWorkspaceHostId =
+ automationWorkspaceScope?.type === 'folder' && automationWorktree
+ ? folderWorkspaceConnectionId === undefined
+ ? null
+ : folderWorkspaceConnectionId
+ ? toSshExecutionHostId(folderWorkspaceConnectionId)
+ : getResolvedExecutionHostIdForWorktree(state, automationWorktree.id)
+ : null
+ const runHostId =
+ parseExecutionHostId(automation.runContext?.hostId)?.id ?? getRepoExecutionHostId(repo)
+ const workspaceMatchesRunTarget =
+ automationWorkspaceScope?.type === 'folder'
+ ? folderWorkspaceHostId !== null && folderWorkspaceHostId === runHostId
+ : !automation.runContext?.repoId ||
+ automationWorktree?.repoId === automation.runContext.repoId
+ if (
+ automation.workspaceMode === 'existing' &&
+ automationWorktree &&
+ !workspaceMatchesRunTarget
+ ) {
+ await markDispatchResult({
+ runId: run.id,
+ status: 'skipped_unavailable',
+ workspaceId: automation.workspaceId,
+ workspaceDisplayName: dispatchWorkspaceDisplayName,
+ error: translate(
+ 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57',
+ 'The target workspace is on a different host than this automation run target.'
+ )
+ })
+ return
+ }
+ const sshTargetId =
+ automationWorkspaceScope?.type === 'folder'
+ ? (folderWorkspaceConnectionId ?? null)
+ : (repo.connectionId ?? null)
+ if (sshTargetId) {
const needsPrompt = await window.api.ssh.needsPassphrasePrompt({
- targetId: repo.connectionId
+ targetId: sshTargetId
})
if (needsPrompt) {
await markDispatchResult({
@@ -114,10 +165,10 @@ export function useAutomationDispatchEvents(): void {
})
return
}
- const sshState = await window.api.ssh.getState({ targetId: repo.connectionId })
+ const sshState = await window.api.ssh.getState({ targetId: sshTargetId })
if (sshState?.status !== 'connected') {
try {
- const connected = await window.api.ssh.connect({ targetId: repo.connectionId })
+ const connected = await window.api.ssh.connect({ targetId: sshTargetId })
if (connected?.status !== 'connected') {
throw new Error('SSH target is unavailable.')
}
@@ -134,25 +185,6 @@ export function useAutomationDispatchEvents(): void {
}
}
- if (
- automation.workspaceMode === 'existing' &&
- automationWorktree &&
- automation.runContext?.repoId &&
- automationWorktree.repoId !== automation.runContext.repoId
- ) {
- await markDispatchResult({
- runId: run.id,
- status: 'skipped_unavailable',
- workspaceId: automation.workspaceId,
- workspaceDisplayName: dispatchWorkspaceDisplayName,
- error: translate(
- 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57',
- 'The target workspace is on a different host than this automation run target.'
- )
- })
- return
- }
-
if (automation.workspaceMode === 'existing' && !automationWorktree) {
await markDispatchResult({
runId: run.id,
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 1c0cd722cbb..7683df72d23 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -4947,6 +4947,10 @@
"image": "Image",
"expand": "Expand image",
"close": "Close"
+ },
+ "WorktreeDeveloperMenu": {
+ "developer": "Developer",
+ "parkTerminal": "Park terminal"
}
},
"shared": {
@@ -13941,6 +13945,11 @@
"searchLabel": "Search plugins",
"searchPlaceholder": "Search plugins, categories, or publishers"
}
+ },
+ "terminalPane": {
+ "useManualTerminalWorktreeParking": {
+ "cannotPark": "These terminals cannot be parked safely."
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 5ef93af97cf..24a920ecafb 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -4924,6 +4924,10 @@
"image": "Image",
"expand": "Expand image",
"close": "Close"
+ },
+ "WorktreeDeveloperMenu": {
+ "developer": "Developer",
+ "parkTerminal": "Park terminal"
}
},
"shared": {
@@ -13918,6 +13922,11 @@
"searchLabel": "Search plugins",
"searchPlaceholder": "Search plugins, categories, or publishers"
}
+ },
+ "terminalPane": {
+ "useManualTerminalWorktreeParking": {
+ "cannotPark": "These terminals cannot be parked safely."
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 89d1898489b..ff7aaececd4 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -4924,6 +4924,10 @@
"image": "Image",
"expand": "Expand image",
"close": "Close"
+ },
+ "WorktreeDeveloperMenu": {
+ "developer": "Developer",
+ "parkTerminal": "Park terminal"
}
},
"shared": {
@@ -13918,6 +13922,11 @@
"searchLabel": "Search plugins",
"searchPlaceholder": "Search plugins, categories, or publishers"
}
+ },
+ "terminalPane": {
+ "useManualTerminalWorktreeParking": {
+ "cannotPark": "These terminals cannot be parked safely."
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 29bc34eb9a7..6ff7c4a837b 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -4924,6 +4924,10 @@
"image": "Image",
"expand": "Expand image",
"close": "Close"
+ },
+ "WorktreeDeveloperMenu": {
+ "developer": "Developer",
+ "parkTerminal": "Park terminal"
}
},
"shared": {
@@ -13918,6 +13922,11 @@
"searchLabel": "Search plugins",
"searchPlaceholder": "Search plugins, categories, or publishers"
}
+ },
+ "terminalPane": {
+ "useManualTerminalWorktreeParking": {
+ "cannotPark": "These terminals cannot be parked safely."
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index b8bb717117c..1266a609695 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -4924,6 +4924,10 @@
"image": "Image",
"expand": "Expand image",
"close": "Close"
+ },
+ "WorktreeDeveloperMenu": {
+ "developer": "Developer",
+ "parkTerminal": "Park terminal"
}
},
"shared": {
@@ -13918,6 +13922,11 @@
"searchLabel": "Search plugins",
"searchPlaceholder": "Search plugins, categories, or publishers"
}
+ },
+ "terminalPane": {
+ "useManualTerminalWorktreeParking": {
+ "cannotPark": "These terminals cannot be parked safely."
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/lib/adopt-agent-background-session-tab.ts b/src/renderer/src/lib/adopt-agent-background-session-tab.ts
new file mode 100644
index 00000000000..ec604137645
--- /dev/null
+++ b/src/renderer/src/lib/adopt-agent-background-session-tab.ts
@@ -0,0 +1,113 @@
+import { useAppStore } from '@/store'
+import { makePaneKey, type PaneKey } from '../../../shared/stable-pane-id'
+import type { AgentType } from '../../../shared/agent-status-types'
+import { bindAutomationTerminal } from '@/lib/automation-terminal-ownership'
+import { createBrowserUuid } from '@/lib/browser-uuid'
+import { retireProvider, retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal'
+import { isTerminalTabPresent } from '@/store/slices/terminal-tab-retirement'
+import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
+
+type Store = ReturnType
+type RegisterArgs = Parameters
+
+/** Reserves env-stable tab and pane identities before spawning the PTY. */
+export function reserveAgentBackgroundSessionIdentity(args: {
+ store: Store
+ agentType: AgentType
+ worktreeId: string
+ launchConfig: RegisterArgs[1]
+ env: Record | undefined
+}): {
+ reservedTabId: string
+ leafId: string
+ paneKey: PaneKey
+ launchToken: string
+ launchRegistration: NonNullable
+ paneEnv: Record
+} {
+ const reservedTabId = createBrowserUuid()
+ const leafId = createBrowserUuid()
+ const paneKey = makePaneKey(reservedTabId, leafId)
+ const launchToken = createBrowserUuid()
+ const launchRegistration = {
+ agentType: args.agentType,
+ launchToken,
+ tabId: reservedTabId,
+ leafId
+ }
+ args.store.registerAgentLaunchConfig(paneKey, args.launchConfig, launchRegistration)
+ return {
+ reservedTabId,
+ leafId,
+ paneKey,
+ launchToken,
+ launchRegistration,
+ paneEnv: {
+ ...args.env,
+ ORCA_PANE_KEY: paneKey,
+ ORCA_TAB_ID: reservedTabId,
+ ORCA_WORKTREE_ID: args.worktreeId,
+ ORCA_AGENT_LAUNCH_TOKEN: launchToken
+ }
+ }
+}
+
+/** Publishes a hidden run tab already bound to its live PTY (#2989). */
+export async function adoptAgentBackgroundSessionTab(args: {
+ store: Store
+ worktreeId: string
+ reservedTabId: string
+ ptyId: string
+ paneKey: PaneKey
+ launchConfig: RegisterArgs[1]
+ launchRegistration: NonNullable
+ runtimeTarget: RuntimeClientTarget
+ runtimeTerminalHandle: string | null
+ onRetire: () => void
+ title?: string
+}): Promise<{
+ tab: ReturnType
+ paneKey: PaneKey
+ terminalOwnership: ReturnType
+} | null> {
+ const { store, reservedTabId, ptyId, launchRegistration } = args
+ // The worktree can disappear while its PTY spawn is pending.
+ if (
+ await retireUnownedTerminal({
+ owner: { worktreeId: args.worktreeId },
+ ptyId,
+ runtimeTarget: args.runtimeTarget,
+ runtimeTerminalHandle: args.runtimeTerminalHandle,
+ onRetire: args.onRetire
+ })
+ ) {
+ return null
+ }
+ // Re-keying would desynchronize env-baked routing identities; fail closed.
+ if (isTerminalTabPresent(useAppStore.getState(), reservedTabId)) {
+ store.clearAgentLaunchConfig(args.paneKey)
+ args.onRetire()
+ await retireProvider({
+ ptyId,
+ runtimeTarget: args.runtimeTarget,
+ runtimeTerminalHandle: args.runtimeTerminalHandle
+ })
+ return null
+ }
+ const tab = store.createTab(args.worktreeId, undefined, undefined, {
+ id: reservedTabId,
+ initialPtyId: ptyId,
+ activate: false,
+ recordInteraction: false
+ })
+ const paneKey = args.paneKey
+ store.registerAgentLaunchConfig(paneKey, args.launchConfig, launchRegistration)
+ const terminalOwnership = bindAutomationTerminal(
+ tab,
+ paneKey,
+ ptyId,
+ args.runtimeTarget.kind,
+ args.title
+ )
+ return { tab, paneKey, terminalOwnership }
+}
diff --git a/src/renderer/src/lib/agent-background-session-launch-host.test.ts b/src/renderer/src/lib/agent-background-session-launch-host.test.ts
new file mode 100644
index 00000000000..86fd8577fae
--- /dev/null
+++ b/src/renderer/src/lib/agent-background-session-launch-host.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from 'vitest'
+import { resolveAgentBackgroundLaunchHost } from './agent-background-session-launch-host'
+
+function makeFolderHostState(args: {
+ connectionId: string | null
+ folderPath: string
+ repos?: {
+ id: string
+ connectionId: string | null
+ path: string
+ projectGroupId: string
+ }[]
+}) {
+ return {
+ folderWorkspaces: [
+ {
+ id: 'folder-1',
+ projectGroupId: 'group-1',
+ folderPath: args.folderPath,
+ connectionId: args.connectionId
+ }
+ ],
+ projectGroups: [
+ {
+ id: 'group-1',
+ parentGroupId: null,
+ connectionId: args.connectionId
+ }
+ ],
+ repos: args.repos ?? []
+ }
+}
+
+describe('resolveAgentBackgroundLaunchHost', () => {
+ it('keeps an authoritative local folder owner local', () => {
+ const host = resolveAgentBackgroundLaunchHost({
+ store: makeFolderHostState({ connectionId: null, folderPath: '/project' }) as never,
+ worktreeId: 'folder:folder-1',
+ worktreePath: '/project',
+ repo: null
+ })
+
+ expect(host).toMatchObject({
+ connectionId: null,
+ isRemote: false,
+ expectedConnectionId: null
+ })
+ })
+
+ it('fails closed when folder ownership is ambiguous', () => {
+ const store = makeFolderHostState({
+ connectionId: 'ssh-1',
+ folderPath: '/project',
+ repos: [
+ {
+ id: 'repo-local',
+ connectionId: null,
+ path: '/project/repo',
+ projectGroupId: 'group-1'
+ }
+ ]
+ })
+
+ expect(() =>
+ resolveAgentBackgroundLaunchHost({
+ store: store as never,
+ worktreeId: 'folder:folder-1',
+ worktreePath: '/project',
+ repo: null
+ })
+ ).toThrow('unavailable or ambiguous')
+ })
+
+ it('uses Linux startup quoting for a local WSL folder', () => {
+ const folderPath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\project'
+ const host = resolveAgentBackgroundLaunchHost({
+ store: makeFolderHostState({ connectionId: null, folderPath }) as never,
+ worktreeId: 'folder:folder-1',
+ worktreePath: folderPath,
+ repo: null
+ })
+
+ expect(host.platform).toBe('linux')
+ })
+})
diff --git a/src/renderer/src/lib/agent-background-session-launch-host.ts b/src/renderer/src/lib/agent-background-session-launch-host.ts
new file mode 100644
index 00000000000..7919f9f3af5
--- /dev/null
+++ b/src/renderer/src/lib/agent-background-session-launch-host.ts
@@ -0,0 +1,71 @@
+import type { useAppStore } from '@/store'
+import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform'
+import { CLIENT_PLATFORM } from '@/lib/new-workspace'
+import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
+import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection'
+import { parseWorkspaceKey } from '../../../shared/workspace-scope'
+import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
+import { repoIsRemote } from '../../../shared/agent-launch-remote'
+import { isWslUncPath } from '../../../shared/wsl-paths'
+
+type LaunchStore = ReturnType
+type LaunchRepo = LaunchStore['repos'][number]
+
+export type AgentBackgroundLaunchHost = {
+ /** SSH connection to spawn on, or null for a local launch. */
+ connectionId: string | null
+ /** Platform whose shell quoting and CLI naming the startup plan must target. */
+ platform: NodeJS.Platform
+ isRemote: boolean
+ /** Accepted status connection; undefined preserves unknown-owner behavior. */
+ expectedConnectionId: string | null | undefined
+}
+
+function resolveFolderWorkspaceConnectionIdForLaunch(
+ store: LaunchStore,
+ worktreeId: string
+): string | null | undefined {
+ const parsed = parseWorkspaceKey(worktreeId)
+ if (parsed?.type !== 'folder') {
+ return undefined
+ }
+ return getFolderWorkspaceConnectionId(store, parsed.folderWorkspaceId)
+}
+
+/** Resolves folder launch ownership from workspace scope when no repo row exists. */
+export function resolveAgentBackgroundLaunchHost(args: {
+ store: LaunchStore
+ worktreeId: string
+ worktreePath: string | undefined
+ repo: LaunchRepo | null | undefined
+}): AgentBackgroundLaunchHost {
+ const { store, worktreeId, worktreePath, repo } = args
+ if (repo) {
+ return {
+ connectionId: repo.connectionId ?? null,
+ platform: getAgentLaunchPlatformForRepo(
+ repo,
+ repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId)
+ ),
+ isRemote: repoIsRemote(repo),
+ expectedConnectionId: repo.connectionId ?? null
+ }
+ }
+ const folderWorkspaceConnectionId = resolveFolderWorkspaceConnectionIdForLaunch(store, worktreeId)
+ const isFolderWorkspace = parseWorkspaceKey(worktreeId)?.type === 'folder'
+ if (isFolderWorkspace && folderWorkspaceConnectionId === undefined) {
+ throw new Error('The target folder workspace host is unavailable or ambiguous.')
+ }
+ return {
+ connectionId: folderWorkspaceConnectionId ?? null,
+ platform: folderWorkspaceConnectionId
+ ? isWindowsAbsolutePathLike(worktreePath ?? '')
+ ? 'win32'
+ : 'linux'
+ : isWslUncPath(worktreePath ?? '')
+ ? 'linux'
+ : CLIENT_PLATFORM,
+ isRemote: Boolean(folderWorkspaceConnectionId),
+ expectedConnectionId: isFolderWorkspace ? (folderWorkspaceConnectionId ?? null) : undefined
+ }
+}
diff --git a/src/renderer/src/lib/agent-background-session-test-state.ts b/src/renderer/src/lib/agent-background-session-test-state.ts
index b338493c411..85def0ffadb 100644
--- a/src/renderer/src/lib/agent-background-session-test-state.ts
+++ b/src/renderer/src/lib/agent-background-session-test-state.ts
@@ -24,7 +24,14 @@ export type AgentBackgroundSessionTestState = {
| { kind: 'windows-host' }
| { kind: 'wsl'; distro: string | null }
}[]
- repos: { id: string; connectionId: string | null; path: string }[]
+ repos: { id: string; connectionId: string | null; path: string; projectGroupId?: string | null }[]
+ folderWorkspaces: {
+ id: string
+ projectGroupId: string
+ folderPath: string
+ connectionId?: string | null
+ }[]
+ projectGroups: { id: string; parentGroupId?: string | null; connectionId?: string | null }[]
worktreesByRepo: Record<
string,
{ id: string; repoId: string; projectId: string; path: string; displayName: string }[]
@@ -35,6 +42,7 @@ export type AgentBackgroundSessionTestState = {
sshConnectionStates: Map
transientClearedAgentStatusConnectionIds: Record
allWorktrees: () => { id: string; repoId: string; path: string }[]
+ getKnownWorktreeById: (worktreeId: string) => { id: string; path: string } | undefined
createTab: TestMock
setTabCustomTitle: TestMock
updateTabPtyId: TestMock
@@ -87,6 +95,8 @@ export function createAgentBackgroundSessionTestState(mocks: {
}
]
},
+ folderWorkspaces: [] as AgentBackgroundSessionTestState['folderWorkspaces'],
+ projectGroups: [] as AgentBackgroundSessionTestState['projectGroups'],
tabsByWorktree: { 'wt-1': [] as { id: string; title: string }[] },
terminalLayoutsByTabId: {} as Record<
string,
@@ -96,6 +106,8 @@ export function createAgentBackgroundSessionTestState(mocks: {
sshConnectionStates: new Map(),
transientClearedAgentStatusConnectionIds: {} as Record,
allWorktrees: () => state.worktreesByRepo['repo-1'],
+ getKnownWorktreeById: (worktreeId: string) =>
+ state.worktreesByRepo['repo-1']?.find((worktree) => worktree.id === worktreeId),
createTab: mocks.createTab,
setTabCustomTitle: mocks.setTabCustomTitle,
updateTabPtyId: mocks.updateTabPtyId,
@@ -120,6 +132,8 @@ export function resetAgentBackgroundSessionTestState(state: AgentBackgroundSessi
}
state.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }]
state.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }]
+ state.folderWorkspaces = []
+ state.projectGroups = []
state.worktreesByRepo = {
'repo-1': [
{
@@ -136,6 +150,9 @@ export function resetAgentBackgroundSessionTestState(state: AgentBackgroundSessi
state.ptyIdsByTabId = {}
state.sshConnectionStates = new Map()
state.transientClearedAgentStatusConnectionIds = {}
+ // Why: restored here so a test that stubs folder-workspace lookup cannot leak it forward.
+ state.getKnownWorktreeById = (worktreeId: string) =>
+ state.worktreesByRepo['repo-1']?.find((worktree) => worktree.id === worktreeId)
}
export function useRemoteAgentBackgroundRuntime(state: AgentBackgroundSessionTestState): void {
@@ -146,6 +163,13 @@ export function useRemoteAgentBackgroundRuntime(state: AgentBackgroundSessionTes
}
}
+/** The tab id reserved before the spawn; the run tab adopts it once the PTY is live. */
+export function expectReservedAgentBackgroundTabId(spawn: TestMock): string {
+ const tabId = spawn.mock.calls[0]?.[0]?.tabId
+ expect(tabId).toMatch(AGENT_BACKGROUND_SESSION_UUID_RE)
+ return tabId
+}
+
export function expectStableAgentBackgroundPaneSpawn(spawn: TestMock): string {
const spawnArgs = spawn.mock.calls[0]?.[0]
const paneKey = spawnArgs?.env?.ORCA_PANE_KEY
@@ -153,7 +177,7 @@ export function expectStableAgentBackgroundPaneSpawn(spawn: TestMock): string {
expect(typeof paneKey).toBe('string')
expect(typeof leafId).toBe('string')
expect(leafId).toMatch(AGENT_BACKGROUND_SESSION_UUID_RE)
- expect(paneKey).toBe(`tab-1:${leafId}`)
+ expect(paneKey).toBe(`${expectReservedAgentBackgroundTabId(spawn)}:${leafId}`)
return paneKey
}
@@ -208,8 +232,9 @@ export function resetAgentBackgroundSessionTestHarness(args: {
(args.runtimeCall as unknown as (value: unknown) => unknown)(request)
)
resetAgentBackgroundSessionTestState(args.state)
- args.createTab.mockImplementation(() => {
- const tab = { id: 'tab-1', title: 'Terminal 1' }
+ // Why: production reserves the tab id before the spawn; honoring options.id mirrors createTab's adoption contract.
+ args.createTab.mockImplementation((_worktreeId, _groupId, _shellOverride, options) => {
+ const tab = { id: options?.id ?? 'tab-1', title: 'Terminal 1' }
args.state.tabsByWorktree['wt-1'].push(tab)
return tab
})
diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts
new file mode 100644
index 00000000000..6ae4071ba55
--- /dev/null
+++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts
@@ -0,0 +1,511 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ AGENT_BACKGROUND_SESSION_UUID_RE as UUID_RE,
+ createAgentBackgroundSessionTestState,
+ resetAgentBackgroundSessionTestHarness,
+ useRemoteAgentBackgroundRuntime
+} from '@/lib/agent-background-session-test-state'
+
+const mockSpawn = vi.fn()
+const mockKill = vi.fn()
+const mockWrite = vi.fn()
+const mockRuntimeEnvironmentCall = vi.fn()
+const mockRuntimeEnvironmentTransportCall = vi.fn()
+const mockRuntimeEnvironmentSubscribe = vi.fn()
+const mockCreateTab = vi.fn()
+const mockSetTabCustomTitle = vi.fn()
+const mockUpdateTabPtyId = vi.fn()
+const mockCloseTab = vi.fn()
+const mockSetTabLayout = vi.fn()
+const mockRegisterAgentLaunchConfig = vi.fn()
+const mockRegisterEagerPtyBuffer = vi.fn()
+const mockSubscribeToPtyData = vi.fn()
+const mockSubscribeToPtyExit = vi.fn()
+const mockPasteDraftWhenAgentReady = vi.fn()
+const mockMarkTrusted = vi.fn()
+const mockDispatchEvent = vi.fn()
+const mockGetAgentLaunchPlatformForRepo = vi.fn<() => NodeJS.Platform>()
+const state = createAgentBackgroundSessionTestState({
+ createTab: mockCreateTab,
+ setTabCustomTitle: mockSetTabCustomTitle,
+ updateTabPtyId: mockUpdateTabPtyId,
+ closeTab: mockCloseTab,
+ setTabLayout: mockSetTabLayout,
+ registerAgentLaunchConfig: mockRegisterAgentLaunchConfig
+})
+
+vi.mock('@/store', () => ({
+ useAppStore: {
+ getState: () => state,
+ subscribe: vi.fn(() => () => {})
+ }
+}))
+
+vi.mock('@/lib/telemetry', () => ({
+ track: vi.fn(),
+ tuiAgentToAgentKind: (agent: string) => agent
+}))
+
+vi.mock('@/lib/agent-paste-draft', () => ({
+ pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady
+}))
+
+vi.mock('@/lib/agent-launch-platform', () => ({
+ getAgentLaunchPlatformForRepo: mockGetAgentLaunchPlatformForRepo
+}))
+
+vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
+ registerEagerPtyBuffer: mockRegisterEagerPtyBuffer,
+ subscribeToPtyExit: mockSubscribeToPtyExit
+}))
+
+vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({
+ subscribeToPtyData: mockSubscribeToPtyData
+}))
+
+describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', () => {
+ beforeEach(() => {
+ resetAgentBackgroundSessionTestHarness({
+ state,
+ createTab: mockCreateTab,
+ closeTab: mockCloseTab,
+ getLaunchPlatform: mockGetAgentLaunchPlatformForRepo,
+ runtimeCall: mockRuntimeEnvironmentCall,
+ runtimeTransportCall: mockRuntimeEnvironmentTransportCall,
+ runtimeSubscribe: mockRuntimeEnvironmentSubscribe,
+ subscribeToData: mockSubscribeToPtyData,
+ subscribeToExit: mockSubscribeToPtyExit,
+ setTabLayout: mockSetTabLayout,
+ updateTabPtyId: mockUpdateTabPtyId,
+ dispatchEvent: mockDispatchEvent,
+ kill: mockKill,
+ markTrusted: mockMarkTrusted,
+ spawn: mockSpawn,
+ write: mockWrite
+ })
+ })
+
+ it('closes a runtime terminal when its worktree disappears before creation resolves', async () => {
+ useRemoteAgentBackgroundRuntime(state)
+ let resolveCreate!: (result: {
+ ok: true
+ result: { terminal: { handle: string; worktreeId: string; title: null } }
+ }) => void
+ const createResult = new Promise<{
+ ok: true
+ result: { terminal: { handle: string; worktreeId: string; title: null } }
+ }>((resolve) => {
+ resolveCreate = resolve
+ })
+ mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => {
+ if (args.method === 'terminal.createAgentSession') {
+ return createResult
+ }
+ return Promise.resolve({ ok: true, result: {} })
+ })
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ const launch = launchAgentBackgroundSession({
+ agent: 'claude',
+ worktreeId: 'wt-1',
+ prompt: 'run remotely'
+ })
+ await vi.waitFor(() =>
+ expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith(
+ expect.objectContaining({ method: 'terminal.createAgentSession' })
+ )
+ )
+ state.worktreesByRepo['repo-1'] = []
+ resolveCreate({
+ ok: true,
+ result: { terminal: { handle: 'terminal-after-close', worktreeId: 'wt-1', title: null } }
+ })
+
+ await expect(launch).resolves.toBeNull()
+ expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'terminal.close',
+ params: { terminal: 'terminal-after-close' }
+ })
+ )
+ expect(mockCreateTab).not.toHaveBeenCalled()
+ expect(mockUpdateTabPtyId).not.toHaveBeenCalled()
+ expect(mockRuntimeEnvironmentSubscribe).not.toHaveBeenCalled()
+ expect(mockDispatchEvent).not.toHaveBeenCalled()
+ })
+
+ it('forwards Hermes startup queries through SSH command transport', async () => {
+ state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ await launchAgentBackgroundSession({
+ agent: 'hermes',
+ worktreeId: 'wt-1',
+ prompt: 'remote automation prompt'
+ })
+
+ expect(mockSpawn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ command: expect.stringContaining('ORCA_HERMES_STARTUP_QUERY'),
+ connectionId: 'ssh-1',
+ env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'remote automation prompt' })
+ })
+ )
+ })
+
+ it('injects fast startup commands into SSH background sessions after shell output arrives', async () => {
+ vi.useFakeTimers()
+ try {
+ state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ await launchAgentBackgroundSession({
+ agent: 'claude',
+ worktreeId: 'wt-1',
+ prompt: 'run the automation',
+ title: 'Nightly audit'
+ })
+
+ expect(mockSpawn.mock.calls[0]?.[0]?.command).toBe(
+ "claude '--dangerously-skip-permissions' 'run the automation'"
+ )
+ expect(mockSpawn.mock.calls[0]?.[0]?.startupCommandDelivery).toBeUndefined()
+ const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
+ dataSidecar('user@remote repo % ')
+ vi.advanceTimersByTime(50)
+
+ expect(mockWrite).toHaveBeenCalledWith(
+ 'pty-1',
+ "claude '--dangerously-skip-permissions' 'run the automation'\r"
+ )
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('waits for shell-ready before injecting payload-bearing SSH background commands', async () => {
+ vi.useFakeTimers()
+ try {
+ state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ await launchAgentBackgroundSession({
+ agent: 'codex',
+ worktreeId: 'wt-1',
+ prompt: 'run the automation',
+ title: 'Nightly audit'
+ })
+
+ expect(mockSpawn.mock.calls[0]?.[0]).toEqual(
+ expect.objectContaining({
+ command: "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'",
+ startupCommandDelivery: 'shell-ready'
+ })
+ )
+ const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
+ dataSidecar('user@remote repo % ')
+ vi.advanceTimersByTime(50)
+ expect(mockWrite).not.toHaveBeenCalled()
+
+ dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ')
+ vi.advanceTimersByTime(50)
+
+ expect(mockWrite).toHaveBeenCalledWith(
+ 'pty-1',
+ "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r"
+ )
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('falls back when an SSH shell produces no observable startup data', async () => {
+ vi.useFakeTimers()
+ try {
+ state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ await launchAgentBackgroundSession({
+ agent: 'codex',
+ worktreeId: 'wt-1',
+ prompt: 'run the automation'
+ })
+ vi.advanceTimersByTime(1_550)
+
+ expect(mockWrite).toHaveBeenCalledWith(
+ 'pty-1',
+ "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r"
+ )
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('waits for shell-ready for SSH background Codex native prefill commands without a hint', async () => {
+ vi.useFakeTimers()
+ try {
+ state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
+ state.settings = {
+ agentCmdOverrides: { codex: "codex --prefill 'draft from override'" },
+ activeRuntimeEnvironmentId: null,
+ terminalMainSideEffectAuthority: undefined
+ }
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ await launchAgentBackgroundSession({
+ agent: 'codex',
+ worktreeId: 'wt-1',
+ title: 'Nightly audit'
+ })
+
+ expect(mockSpawn.mock.calls[0]?.[0]).toEqual(
+ expect.objectContaining({
+ command:
+ "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'"
+ })
+ )
+ expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('startupCommandDelivery')
+ const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
+ dataSidecar('user@remote repo % ')
+ vi.advanceTimersByTime(50)
+ expect(mockWrite).not.toHaveBeenCalled()
+
+ dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ')
+ vi.advanceTimersByTime(50)
+
+ expect(mockWrite).toHaveBeenCalledWith(
+ 'pty-1',
+ "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r"
+ )
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('does not rearm SSH background startup delivery after exit cleanup', async () => {
+ vi.useFakeTimers()
+ try {
+ state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ await launchAgentBackgroundSession({
+ agent: 'codex',
+ worktreeId: 'wt-1',
+ prompt: 'run the automation',
+ title: 'Nightly audit'
+ })
+
+ const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
+ const exitSidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void
+ exitSidecar(0)
+
+ dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ')
+ vi.advanceTimersByTime(50)
+
+ expect(mockWrite).not.toHaveBeenCalled()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('creates background sessions on the active runtime environment', async () => {
+ useRemoteAgentBackgroundRuntime(state)
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ const result = await launchAgentBackgroundSession({
+ agent: 'claude',
+ worktreeId: 'wt-1',
+ prompt: 'run the automation'
+ })
+
+ expect(mockSpawn).not.toHaveBeenCalled()
+ const params = mockRuntimeEnvironmentCall.mock.calls[0]?.[0]?.params
+ const leafId = params?.placement?.leafId
+ const tabId = params?.placement?.tabId
+ expect(leafId).toMatch(UUID_RE)
+ expect(tabId).toMatch(UUID_RE)
+ // Why: background launches have no explicit recipe override, so remote host settings win.
+ expect(params).not.toHaveProperty('agentArgs')
+ expect(mockRegisterAgentLaunchConfig).toHaveBeenCalledWith(
+ `${tabId}:${leafId}`,
+ {
+ agentCommand: "claude '--dangerously-skip-permissions'",
+ agentArgs: '--dangerously-skip-permissions',
+ agentEnv: {}
+ },
+ {
+ agentType: 'claude',
+ launchToken: expect.stringMatching(UUID_RE),
+ tabId,
+ leafId
+ }
+ )
+ expect(mockSetTabLayout).toHaveBeenCalledWith(
+ tabId,
+ expect.objectContaining({
+ root: { type: 'leaf', leafId },
+ activeLeafId: leafId,
+ ptyIdsByLeafId: { [leafId]: 'remote:env-1@@terminal-1' }
+ })
+ )
+ expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({
+ selector: 'env-1',
+ method: 'terminal.createAgentSession',
+ params: expect.objectContaining({
+ clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/),
+ worktree: 'id:wt-1',
+ agent: 'claude',
+ prompt: 'run the automation',
+ promptDelivery: 'auto-submit',
+ placement: { tabId, leafId },
+ presentation: 'background'
+ }),
+ timeoutMs: 15_000
+ })
+ expect(mockUpdateTabPtyId).toHaveBeenCalledWith(tabId, 'remote:env-1@@terminal-1')
+ expect(mockRegisterEagerPtyBuffer).not.toHaveBeenCalled()
+ expect(mockRuntimeEnvironmentSubscribe).toHaveBeenCalledWith(
+ expect.objectContaining({
+ selector: 'env-1',
+ method: 'terminal.multiplex',
+ params: {}
+ }),
+ expect.any(Object)
+ )
+ expect(result).toMatchObject({
+ tabId,
+ paneKey: `${tabId}:${leafId}`,
+ ptyId: 'remote:env-1@@terminal-1',
+ terminalOwnership: null
+ })
+ })
+
+ it('preserves the legacy background spawn on an old remote host', async () => {
+ useRemoteAgentBackgroundRuntime(state)
+ mockRuntimeEnvironmentTransportCall.mockImplementation((request: { method: string }) => {
+ if (request.method === 'status.get') {
+ return Promise.resolve({
+ id: 'status',
+ ok: true,
+ result: {
+ runtimeId: 'old-runtime',
+ graphStatus: 'ready',
+ runtimeProtocolVersion: 3,
+ minCompatibleRuntimeClientVersion: 2,
+ capabilities: []
+ }
+ })
+ }
+ return Promise.resolve({
+ id: 'create',
+ ok: true,
+ result: { terminal: { handle: 'legacy-terminal-1' } }
+ })
+ })
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ await expect(
+ launchAgentBackgroundSession({
+ agent: 'claude',
+ worktreeId: 'wt-1',
+ prompt: 'run remotely'
+ })
+ ).resolves.toMatchObject({ ptyId: 'remote:env-1@@legacy-terminal-1' })
+
+ expect(mockRuntimeEnvironmentTransportCall).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'terminal.create',
+ params: expect.objectContaining({
+ worktree: 'id:wt-1',
+ command: "claude '--dangerously-skip-permissions' 'run remotely'",
+ launchAgent: 'claude',
+ presentation: 'background'
+ })
+ })
+ )
+ })
+
+ it('closes a created runtime terminal when its data subscription fails', async () => {
+ useRemoteAgentBackgroundRuntime(state)
+ mockRuntimeEnvironmentSubscribe.mockRejectedValueOnce(new Error('subscription failed'))
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ await expect(
+ launchAgentBackgroundSession({
+ agent: 'claude',
+ worktreeId: 'wt-1',
+ prompt: 'run the automation'
+ })
+ ).rejects.toThrow('subscription failed')
+
+ expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({
+ selector: 'env-1',
+ method: 'terminal.close',
+ params: { terminal: 'terminal-1' },
+ timeoutMs: undefined
+ })
+ const tabId = mockRuntimeEnvironmentCall.mock.calls[0]?.[0]?.params?.placement?.tabId
+ expect(tabId).toMatch(UUID_RE)
+ expect(state.clearTabPtyId).toHaveBeenCalledWith(tabId, 'remote:env-1@@terminal-1')
+ expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith(
+ expect.stringMatching(new RegExp(`^${tabId}:`))
+ )
+ expect(mockCloseTab).toHaveBeenCalledWith(tabId, {
+ recordInteraction: false,
+ reason: 'cleanup'
+ })
+ expect(mockDispatchEvent).not.toHaveBeenCalled()
+ })
+
+ it('spawns an SSH folder-workspace automation on the owning host, not locally', async () => {
+ // Folder workspaces have no repo row, so launch ownership comes from their scope.
+ state.repos = [
+ { id: 'repo-1', connectionId: 'ssh-1', path: '/srv/proj/api', projectGroupId: 'grp-1' }
+ ]
+ state.projectGroups = [{ id: 'grp-1', parentGroupId: null, connectionId: 'ssh-1' }]
+ state.folderWorkspaces = [
+ { id: 'fw-1', projectGroupId: 'grp-1', folderPath: '/srv/proj', connectionId: 'ssh-1' }
+ ]
+ state.getKnownWorktreeById = (worktreeId: string) =>
+ worktreeId === 'folder:fw-1' ? { id: 'folder:fw-1', path: '/srv/proj' } : undefined
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ await launchAgentBackgroundSession({
+ agent: 'codex',
+ worktreeId: 'folder:fw-1',
+ prompt: 'run the automation'
+ })
+
+ expect(mockMarkTrusted).toHaveBeenCalledWith({
+ preset: 'codex',
+ workspacePath: '/srv/proj',
+ connectionId: 'ssh-1'
+ })
+ expect(mockSpawn).toHaveBeenCalledWith(
+ expect.objectContaining({ connectionId: 'ssh-1', cwd: '/srv/proj' })
+ )
+ })
+
+ it('keeps a local folder workspace on the local host', async () => {
+ state.repos = [
+ { id: 'repo-1', connectionId: null, path: '/home/me/proj/api', projectGroupId: 'grp-1' }
+ ]
+ state.projectGroups = [{ id: 'grp-1', parentGroupId: null, connectionId: null }]
+ state.folderWorkspaces = [
+ { id: 'fw-1', projectGroupId: 'grp-1', folderPath: '/home/me/proj', connectionId: null }
+ ]
+ state.getKnownWorktreeById = (worktreeId: string) =>
+ worktreeId === 'folder:fw-1' ? { id: 'folder:fw-1', path: '/home/me/proj' } : undefined
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ await launchAgentBackgroundSession({
+ agent: 'claude',
+ worktreeId: 'folder:fw-1',
+ prompt: 'run the automation'
+ })
+
+ expect(mockSpawn).toHaveBeenCalledWith(
+ expect.objectContaining({ connectionId: null, cwd: '/home/me/proj' })
+ )
+ })
+})
diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts
index a82996c97f7..78fd3a91f6e 100644
--- a/src/renderer/src/lib/launch-agent-background-session.test.ts
+++ b/src/renderer/src/lib/launch-agent-background-session.test.ts
@@ -4,9 +4,9 @@ import { toAppSshPtyId } from '../../../shared/ssh-pty-id'
import {
AGENT_BACKGROUND_SESSION_UUID_RE as UUID_RE,
createAgentBackgroundSessionTestState,
+ expectReservedAgentBackgroundTabId,
expectStableAgentBackgroundPaneSpawn,
- resetAgentBackgroundSessionTestHarness,
- useRemoteAgentBackgroundRuntime
+ resetAgentBackgroundSessionTestHarness
} from '@/lib/agent-background-session-test-state'
const mockSpawn = vi.fn()
@@ -36,10 +36,11 @@ const state = createAgentBackgroundSessionTestState({
setTabLayout: mockSetTabLayout,
registerAgentLaunchConfig: mockRegisterAgentLaunchConfig
})
+let currentStoreState = state
vi.mock('@/store', () => ({
useAppStore: {
- getState: () => state,
+ getState: () => currentStoreState,
subscribe: vi.fn(() => () => {})
}
}))
@@ -68,6 +69,7 @@ vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({
describe('launchAgentBackgroundSession', () => {
beforeEach(() => {
+ currentStoreState = state
resetAgentBackgroundSessionTestHarness({
state,
createTab: mockCreateTab,
@@ -88,7 +90,7 @@ describe('launchAgentBackgroundSession', () => {
})
})
- it('spawns a PTY immediately and adopts it in an inactive tab', async () => {
+ it('spawns a PTY first and creates the inactive tab already bound to it', async () => {
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
const result = await launchAgentBackgroundSession({
@@ -98,14 +100,21 @@ describe('launchAgentBackgroundSession', () => {
title: 'Nightly audit'
})
+ const tabId = expectReservedAgentBackgroundTabId(mockSpawn)
+ // A store-visible PTY-less run tab fresh-spawns a shell (#2989).
+ expect(mockSpawn.mock.invocationCallOrder[0]).toBeLessThan(
+ mockCreateTab.mock.invocationCallOrder[0] ?? 0
+ )
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
+ id: tabId,
+ initialPtyId: 'pty-1',
activate: false,
recordInteraction: false
})
expect(mockDispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
- detail: { worktreeId: 'wt-1', tabIds: ['tab-1'] }
+ detail: { worktreeId: 'wt-1', tabIds: [tabId] }
})
)
expect(mockUpdateTabPtyId.mock.invocationCallOrder[0]).toBeLessThan(
@@ -116,18 +125,18 @@ describe('launchAgentBackgroundSession', () => {
cwd: '/repo/worktree',
command: "claude '--dangerously-skip-permissions' 'run the automation'",
env: expect.objectContaining({
- ORCA_TAB_ID: 'tab-1',
+ ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: 'wt-1'
}),
connectionId: null,
worktreeId: 'wt-1',
- tabId: 'tab-1'
+ tabId
})
)
const paneKey = expectStableAgentBackgroundPaneSpawn(mockSpawn)
- const leafId = paneKey.slice('tab-1:'.length)
+ const leafId = paneKey.slice(`${tabId}:`.length)
expect(mockSetTabLayout).toHaveBeenCalledWith(
- 'tab-1',
+ tabId,
expect.objectContaining({
root: { type: 'leaf', leafId },
activeLeafId: leafId,
@@ -147,17 +156,17 @@ describe('launchAgentBackgroundSession', () => {
expect(mockSpawn.mock.calls[0]?.[0].launchToken).toBe(
mockSpawn.mock.calls[0]?.[0].env.ORCA_AGENT_LAUNCH_TOKEN
)
- expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Nightly audit', {
+ expect(mockSetTabCustomTitle).toHaveBeenCalledWith(tabId, 'Nightly audit', {
recordInteraction: false
})
- expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1')
+ expect(mockUpdateTabPtyId).toHaveBeenCalledWith(tabId, 'pty-1')
expect(mockRegisterEagerPtyBuffer).toHaveBeenCalledWith('pty-1', expect.any(Function))
expect(mockSubscribeToPtyData).toHaveBeenCalledWith('pty-1', expect.any(Function))
expect(mockSubscribeToPtyExit).toHaveBeenCalledWith('pty-1', expect.any(Function))
- expect(result).toMatchObject({ tabId: 'tab-1', paneKey, ptyId: 'pty-1' })
+ expect(result).toMatchObject({ tabId, paneKey, ptyId: 'pty-1' })
})
- it('does not mount the tab while the explicit PTY spawn is unresolved', async () => {
+ it('does not create or mount the tab while the explicit PTY spawn is unresolved', async () => {
let resolveSpawn!: (result: { id: string }) => void
mockSpawn.mockReturnValueOnce(
new Promise<{ id: string }>((resolve) => {
@@ -173,18 +182,24 @@ describe('launchAgentBackgroundSession', () => {
})
await Promise.resolve()
- expect(mockCreateTab).toHaveBeenCalled()
+ // Publishing a PTY-less tab here reproduces #2989.
+ expect(mockCreateTab).not.toHaveBeenCalled()
expect(mockDispatchEvent).not.toHaveBeenCalled()
resolveSpawn({ id: 'pty-slow' })
await expect(launch).resolves.toMatchObject({ ptyId: 'pty-slow' })
- expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-slow')
+ const tabId = expectReservedAgentBackgroundTabId(mockSpawn)
+ expect(mockCreateTab.mock.calls[0]?.[3]).toMatchObject({
+ id: tabId,
+ initialPtyId: 'pty-slow'
+ })
+ expect(mockUpdateTabPtyId).toHaveBeenCalledWith(tabId, 'pty-slow')
expect(mockDispatchEvent).toHaveBeenCalledWith(
- expect.objectContaining({ detail: { worktreeId: 'wt-1', tabIds: ['tab-1'] } })
+ expect.objectContaining({ detail: { worktreeId: 'wt-1', tabIds: [tabId] } })
)
})
- it('kills a local PTY when its tab closes before spawn resolves', async () => {
+ it('kills a local PTY when its worktree disappears before spawn resolves', async () => {
let resolveSpawn!: (result: { id: string }) => void
mockSpawn.mockReturnValueOnce(
new Promise<{ id: string }>((resolve) => {
@@ -198,64 +213,66 @@ describe('launchAgentBackgroundSession', () => {
worktreeId: 'wt-1',
prompt: 'run slowly'
})
- await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce())
- state.tabsByWorktree['wt-1'] = []
+ await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledOnce())
+ state.worktreesByRepo['repo-1'] = []
resolveSpawn({ id: 'pty-after-close' })
await expect(launch).resolves.toBeNull()
expect(mockKill).toHaveBeenCalledWith('pty-after-close')
+ expect(mockCreateTab).not.toHaveBeenCalled()
expect(mockUpdateTabPtyId).not.toHaveBeenCalled()
expect(mockSubscribeToPtyData).not.toHaveBeenCalled()
expect(mockDispatchEvent).not.toHaveBeenCalled()
})
- it('closes a runtime terminal when its tab closes before creation resolves', async () => {
- useRemoteAgentBackgroundRuntime(state)
- let resolveCreate!: (result: {
- ok: true
- result: { terminal: { handle: string; worktreeId: string; title: null } }
- }) => void
- const createResult = new Promise<{
- ok: true
- result: { terminal: { handle: string; worktreeId: string; title: null } }
- }>((resolve) => {
- resolveCreate = resolve
- })
- mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => {
- if (args.method === 'terminal.createAgentSession') {
- return createResult
- }
- return Promise.resolve({ ok: true, result: {} })
- })
+ it('launches into a folder workspace that is absent from worktreesByRepo throughout', async () => {
+ // Folder workspaces never appear in worktreesByRepo.
+ state.worktreesByRepo['repo-1'] = []
+ state.folderWorkspaces = [
+ { id: 'fw-1', projectGroupId: 'grp-1', folderPath: '/tmp/folder-workspace' }
+ ]
+ state.projectGroups = [{ id: 'grp-1', connectionId: null }]
+ state.getKnownWorktreeById = (worktreeId: string) =>
+ worktreeId === 'folder:fw-1'
+ ? { id: 'folder:fw-1', path: '/tmp/folder-workspace' }
+ : undefined
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
const launch = launchAgentBackgroundSession({
agent: 'claude',
- worktreeId: 'wt-1',
- prompt: 'run remotely'
- })
- await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce())
- await vi.waitFor(() =>
- expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith(
- expect.objectContaining({ method: 'terminal.createAgentSession' })
- )
- )
- state.tabsByWorktree['wt-1'] = []
- resolveCreate({
- ok: true,
- result: { terminal: { handle: 'terminal-after-close', worktreeId: 'wt-1', title: null } }
+ worktreeId: 'folder:fw-1',
+ prompt: 'run in a folder workspace'
})
- await expect(launch).resolves.toBeNull()
- expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith(
+ await expect(launch).resolves.toMatchObject({ ptyId: 'pty-1' })
+ expect(mockKill).not.toHaveBeenCalled()
+ expect(mockCreateTab).toHaveBeenCalledOnce()
+ })
+
+ it('launches a local WSL folder through wsl.exe', async () => {
+ const folderPath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\project'
+ state.worktreesByRepo['repo-1'] = []
+ state.folderWorkspaces = [
+ { id: 'fw-wsl', projectGroupId: 'grp-wsl', folderPath, connectionId: null }
+ ]
+ state.projectGroups = [{ id: 'grp-wsl', connectionId: null }]
+ state.getKnownWorktreeById = (worktreeId: string) =>
+ worktreeId === 'folder:fw-wsl' ? { id: worktreeId, path: folderPath } : undefined
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ await launchAgentBackgroundSession({
+ agent: 'claude',
+ worktreeId: 'folder:fw-wsl',
+ prompt: 'run the automation'
+ })
+
+ expect(mockSpawn).toHaveBeenCalledWith(
expect.objectContaining({
- method: 'terminal.close',
- params: { terminal: 'terminal-after-close' }
+ cwd: folderPath,
+ shellOverride: 'wsl.exe',
+ command: "claude '--dangerously-skip-permissions' 'run the automation'"
})
)
- expect(mockUpdateTabPtyId).not.toHaveBeenCalled()
- expect(mockRuntimeEnvironmentSubscribe).not.toHaveBeenCalled()
- expect(mockDispatchEvent).not.toHaveBeenCalled()
})
it('records effective launch config returned by local PTY spawn', async () => {
@@ -274,11 +291,12 @@ describe('launchAgentBackgroundSession', () => {
})
const paneKey = expectStableAgentBackgroundPaneSpawn(mockSpawn)
- const leafId = paneKey.slice('tab-1:'.length)
+ const tabId = expectReservedAgentBackgroundTabId(mockSpawn)
+ const leafId = paneKey.slice(`${tabId}:`.length)
expect(mockRegisterAgentLaunchConfig).toHaveBeenLastCalledWith(paneKey, effectiveLaunchConfig, {
agentType: 'claude',
launchToken: mockSpawn.mock.calls[0]?.[0].env.ORCA_AGENT_LAUNCH_TOKEN,
- tabId: 'tab-1',
+ tabId,
leafId
})
})
@@ -317,7 +335,7 @@ describe('launchAgentBackgroundSession', () => {
command: "claude '--dangerously-skip-permissions' 'don'\\''t use powershell quoting'",
connectionId: null,
worktreeId: 'wt-1',
- tabId: 'tab-1'
+ tabId: expect.stringMatching(UUID_RE)
})
)
})
@@ -440,13 +458,16 @@ describe('launchAgentBackgroundSession', () => {
const sidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void
sidecar(0)
- expect(state.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1')
- expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith(expect.stringMatching(/^tab-1:/))
+ const tabId = expectReservedAgentBackgroundTabId(mockSpawn)
+ expect(state.clearTabPtyId).toHaveBeenCalledWith(tabId, 'pty-1')
+ expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith(
+ expect.stringMatching(new RegExp(`^${tabId}:`))
+ )
expect(onExit).toHaveBeenCalledWith('pty-1', 0)
expect(unsubscribe).toHaveBeenCalled()
})
- it('removes the inactive tab if PTY spawn fails', async () => {
+ it('leaves no tab behind if PTY spawn fails', async () => {
mockSpawn.mockRejectedValueOnce(new Error('spawn failed'))
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
@@ -458,11 +479,63 @@ describe('launchAgentBackgroundSession', () => {
})
).rejects.toThrow('spawn failed')
- expect(mockCloseTab).toHaveBeenCalledWith('tab-1', {
+ // Why: the tab is only created once a PTY is live, so a failed spawn has nothing to close.
+ expect(mockCreateTab).not.toHaveBeenCalled()
+ expect(mockCloseTab).not.toHaveBeenCalled()
+ expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith(
+ expect.stringMatching(new RegExp(`^${expectReservedAgentBackgroundTabId(mockSpawn)}:`))
+ )
+ expect(mockUpdateTabPtyId).not.toHaveBeenCalled()
+ })
+
+ it('closes the adopted tab if binding fails after the PTY is live', async () => {
+ mockSubscribeToPtyData.mockImplementationOnce(() => {
+ throw new Error('subscribe failed')
+ })
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ await expect(
+ launchAgentBackgroundSession({
+ agent: 'claude',
+ worktreeId: 'wt-1',
+ prompt: 'run the automation'
+ })
+ ).rejects.toThrow('subscribe failed')
+
+ const tabId = expectReservedAgentBackgroundTabId(mockSpawn)
+ expect(mockCloseTab).toHaveBeenCalledWith(tabId, {
recordInteraction: false,
reason: 'cleanup'
})
- expect(mockUpdateTabPtyId).not.toHaveBeenCalled()
+ expect(mockKill).toHaveBeenCalledWith('pty-1')
+ })
+
+ it('retires the launch instead of adopting a colliding tab id', async () => {
+ mockSpawn.mockImplementationOnce((args: { tabId: string }) => {
+ currentStoreState = {
+ ...state,
+ tabsByWorktree: {
+ ...state.tabsByWorktree,
+ 'wt-1': [{ id: args.tabId, title: 'Squatter' }]
+ }
+ }
+ return Promise.resolve({ id: 'pty-1' })
+ })
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ await expect(
+ launchAgentBackgroundSession({
+ agent: 'claude',
+ worktreeId: 'wt-1',
+ prompt: 'run the automation'
+ })
+ ).resolves.toBeNull()
+
+ expect(mockCreateTab).not.toHaveBeenCalled()
+ expect(mockKill).toHaveBeenCalledWith('pty-1')
+ expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith(
+ expectStableAgentBackgroundPaneSpawn(mockSpawn)
+ )
})
it('submits prompts for stdin-after-start agents in background mode', async () => {
@@ -479,7 +552,7 @@ describe('launchAgentBackgroundSession', () => {
)
expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith(
expect.objectContaining({
- tabId: 'tab-1',
+ tabId: expectReservedAgentBackgroundTabId(mockSpawn),
content: 'run the automation',
agent: 'aider',
submit: true
@@ -523,299 +596,4 @@ describe('launchAgentBackgroundSession', () => {
})
)
})
-
- it('forwards Hermes startup queries through SSH command transport', async () => {
- state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
- const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
-
- await launchAgentBackgroundSession({
- agent: 'hermes',
- worktreeId: 'wt-1',
- prompt: 'remote automation prompt'
- })
-
- expect(mockSpawn).toHaveBeenCalledWith(
- expect.objectContaining({
- command: expect.stringContaining('ORCA_HERMES_STARTUP_QUERY'),
- connectionId: 'ssh-1',
- env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'remote automation prompt' })
- })
- )
- })
-
- it('injects fast startup commands into SSH background sessions after shell output arrives', async () => {
- vi.useFakeTimers()
- try {
- state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
- const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
-
- await launchAgentBackgroundSession({
- agent: 'claude',
- worktreeId: 'wt-1',
- prompt: 'run the automation',
- title: 'Nightly audit'
- })
-
- expect(mockSpawn.mock.calls[0]?.[0]?.command).toBe(
- "claude '--dangerously-skip-permissions' 'run the automation'"
- )
- expect(mockSpawn.mock.calls[0]?.[0]?.startupCommandDelivery).toBeUndefined()
- const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
- dataSidecar('user@remote repo % ')
- vi.advanceTimersByTime(50)
-
- expect(mockWrite).toHaveBeenCalledWith(
- 'pty-1',
- "claude '--dangerously-skip-permissions' 'run the automation'\r"
- )
- } finally {
- vi.useRealTimers()
- }
- })
-
- it('waits for shell-ready before injecting payload-bearing SSH background commands', async () => {
- vi.useFakeTimers()
- try {
- state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
- const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
-
- await launchAgentBackgroundSession({
- agent: 'codex',
- worktreeId: 'wt-1',
- prompt: 'run the automation',
- title: 'Nightly audit'
- })
-
- expect(mockSpawn.mock.calls[0]?.[0]).toEqual(
- expect.objectContaining({
- command: "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'",
- startupCommandDelivery: 'shell-ready'
- })
- )
- const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
- dataSidecar('user@remote repo % ')
- vi.advanceTimersByTime(50)
- expect(mockWrite).not.toHaveBeenCalled()
-
- dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ')
- vi.advanceTimersByTime(50)
-
- expect(mockWrite).toHaveBeenCalledWith(
- 'pty-1',
- "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r"
- )
- } finally {
- vi.useRealTimers()
- }
- })
-
- it('waits for shell-ready for SSH background Codex native prefill commands without a hint', async () => {
- vi.useFakeTimers()
- try {
- state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
- state.settings = {
- agentCmdOverrides: { codex: "codex --prefill 'draft from override'" },
- activeRuntimeEnvironmentId: null,
- terminalMainSideEffectAuthority: undefined
- }
- const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
-
- await launchAgentBackgroundSession({
- agent: 'codex',
- worktreeId: 'wt-1',
- title: 'Nightly audit'
- })
-
- expect(mockSpawn.mock.calls[0]?.[0]).toEqual(
- expect.objectContaining({
- command:
- "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'"
- })
- )
- expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('startupCommandDelivery')
- const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
- dataSidecar('user@remote repo % ')
- vi.advanceTimersByTime(50)
- expect(mockWrite).not.toHaveBeenCalled()
-
- dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ')
- vi.advanceTimersByTime(50)
-
- expect(mockWrite).toHaveBeenCalledWith(
- 'pty-1',
- "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r"
- )
- } finally {
- vi.useRealTimers()
- }
- })
-
- it('does not rearm SSH background startup delivery after exit cleanup', async () => {
- vi.useFakeTimers()
- try {
- state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
- const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
-
- await launchAgentBackgroundSession({
- agent: 'codex',
- worktreeId: 'wt-1',
- prompt: 'run the automation',
- title: 'Nightly audit'
- })
-
- const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
- const exitSidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void
- exitSidecar(0)
-
- dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ')
- vi.advanceTimersByTime(50)
-
- expect(mockWrite).not.toHaveBeenCalled()
- } finally {
- vi.useRealTimers()
- }
- })
-
- it('creates background sessions on the active runtime environment', async () => {
- useRemoteAgentBackgroundRuntime(state)
- const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
-
- const result = await launchAgentBackgroundSession({
- agent: 'claude',
- worktreeId: 'wt-1',
- prompt: 'run the automation'
- })
-
- expect(mockSpawn).not.toHaveBeenCalled()
- const params = mockRuntimeEnvironmentCall.mock.calls[0]?.[0]?.params
- const leafId = params?.placement?.leafId
- expect(leafId).toMatch(UUID_RE)
- // Why: background launches have no explicit recipe override, so remote host settings win.
- expect(params).not.toHaveProperty('agentArgs')
- expect(mockRegisterAgentLaunchConfig).toHaveBeenCalledWith(
- `tab-1:${leafId}`,
- {
- agentCommand: "claude '--dangerously-skip-permissions'",
- agentArgs: '--dangerously-skip-permissions',
- agentEnv: {}
- },
- {
- agentType: 'claude',
- launchToken: expect.stringMatching(UUID_RE),
- tabId: 'tab-1',
- leafId
- }
- )
- expect(mockSetTabLayout).toHaveBeenCalledWith(
- 'tab-1',
- expect.objectContaining({
- root: { type: 'leaf', leafId },
- activeLeafId: leafId,
- ptyIdsByLeafId: { [leafId]: 'remote:env-1@@terminal-1' }
- })
- )
- expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({
- selector: 'env-1',
- method: 'terminal.createAgentSession',
- params: expect.objectContaining({
- clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/),
- worktree: 'id:wt-1',
- agent: 'claude',
- prompt: 'run the automation',
- promptDelivery: 'auto-submit',
- placement: { tabId: 'tab-1', leafId },
- presentation: 'background'
- }),
- timeoutMs: 15_000
- })
- expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1')
- expect(mockRegisterEagerPtyBuffer).not.toHaveBeenCalled()
- expect(mockRuntimeEnvironmentSubscribe).toHaveBeenCalledWith(
- expect.objectContaining({
- selector: 'env-1',
- method: 'terminal.multiplex',
- params: {}
- }),
- expect.any(Object)
- )
- expect(result).toMatchObject({
- tabId: 'tab-1',
- paneKey: `tab-1:${leafId}`,
- ptyId: 'remote:env-1@@terminal-1',
- terminalOwnership: null
- })
- })
-
- it('preserves the legacy background spawn on an old remote host', async () => {
- useRemoteAgentBackgroundRuntime(state)
- mockRuntimeEnvironmentTransportCall.mockImplementation((request: { method: string }) => {
- if (request.method === 'status.get') {
- return Promise.resolve({
- id: 'status',
- ok: true,
- result: {
- runtimeId: 'old-runtime',
- graphStatus: 'ready',
- runtimeProtocolVersion: 3,
- minCompatibleRuntimeClientVersion: 2,
- capabilities: []
- }
- })
- }
- return Promise.resolve({
- id: 'create',
- ok: true,
- result: { terminal: { handle: 'legacy-terminal-1' } }
- })
- })
- const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
-
- await expect(
- launchAgentBackgroundSession({
- agent: 'claude',
- worktreeId: 'wt-1',
- prompt: 'run remotely'
- })
- ).resolves.toMatchObject({ ptyId: 'remote:env-1@@legacy-terminal-1' })
-
- expect(mockRuntimeEnvironmentTransportCall).toHaveBeenCalledWith(
- expect.objectContaining({
- method: 'terminal.create',
- params: expect.objectContaining({
- worktree: 'id:wt-1',
- command: "claude '--dangerously-skip-permissions' 'run remotely'",
- launchAgent: 'claude',
- presentation: 'background'
- })
- })
- )
- })
-
- it('closes a created runtime terminal when its data subscription fails', async () => {
- useRemoteAgentBackgroundRuntime(state)
- mockRuntimeEnvironmentSubscribe.mockRejectedValueOnce(new Error('subscription failed'))
- const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
-
- await expect(
- launchAgentBackgroundSession({
- agent: 'claude',
- worktreeId: 'wt-1',
- prompt: 'run the automation'
- })
- ).rejects.toThrow('subscription failed')
-
- expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({
- selector: 'env-1',
- method: 'terminal.close',
- params: { terminal: 'terminal-1' },
- timeoutMs: undefined
- })
- expect(state.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1')
- expect(state.clearAgentLaunchConfig).toHaveBeenCalledWith(expect.stringMatching(/^tab-1:/))
- expect(mockCloseTab).toHaveBeenCalledWith('tab-1', {
- recordInteraction: false,
- reason: 'cleanup'
- })
- expect(mockDispatchEvent).not.toHaveBeenCalled()
- })
})
diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts
index 9ab4a5e684d..f65204d6558 100644
--- a/src/renderer/src/lib/launch-agent-background-session.ts
+++ b/src/renderer/src/lib/launch-agent-background-session.ts
@@ -4,18 +4,15 @@ import type {
LaunchAgentBackgroundSessionArgs,
LaunchAgentBackgroundSessionResult
} from '@/lib/agent-background-session-contract'
-import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform'
-import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { scheduleAgentBackgroundDraft } from '@/lib/agent-background-draft-delivery'
-import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount'
import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
} from '../../../shared/tui-agent-launch-defaults'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
-import { repoIsRemote } from '../../../shared/agent-launch-remote'
+import { resolveAgentBackgroundLaunchHost } from '@/lib/agent-background-session-launch-host'
import { makePaneKey } from '../../../shared/stable-pane-id'
import {
registerEagerPtyBuffer,
@@ -25,9 +22,7 @@ import {
import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
-import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
-import { retireProvider, retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal'
-import { createBrowserUuid } from '@/lib/browser-uuid'
+import { retireProvider } from '@/lib/retire-unowned-background-terminal'
import { createRuntimeAgentBackgroundTerminal } from '@/lib/runtime-agent-background-create'
import {
subscribeToRuntimeTerminalData,
@@ -38,42 +33,48 @@ import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startu
import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler'
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
import { runBestEffortAgentBackgroundCleanups } from '@/lib/agent-background-session-cleanup'
-import { bindAutomationTerminal } from '@/lib/automation-terminal-ownership'
+import type { bindAutomationTerminal } from '@/lib/automation-terminal-ownership'
+import {
+ adoptAgentBackgroundSessionTab,
+ reserveAgentBackgroundSessionIdentity
+} from '@/lib/adopt-agent-background-session-tab'
import { createBackgroundAgentStatusConsumer } from '@/lib/background-agent-status-consumer'
+import { isWslUncPath } from '../../../shared/wsl-paths'
export async function launchAgentBackgroundSession(
args: LaunchAgentBackgroundSessionArgs
): Promise {
const { agent, worktreeId, prompt, launchSource, title, onData, onExit, onAgentStatus } = args
const store = useAppStore.getState()
- const worktree = store.allWorktrees().find((entry) => entry.id === worktreeId)
+ // Folder workspaces exist only in getKnownWorktreeById (#2989).
+ const worktree = store.getKnownWorktreeById(worktreeId)
const repo = worktree ? store.repos.find((entry) => entry.id === worktree.repoId) : null
if (!worktree) {
throw new Error('The target workspace is no longer available.')
}
+ const cmdOverrides = store.settings?.agentCmdOverrides ?? {}
+ const agentArgs = resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs)
+ const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv)
+ // Folder launch ownership cannot be derived from a repo row (#2989).
+ const launchHost = resolveAgentBackgroundLaunchHost({
+ store,
+ worktreeId,
+ worktreePath: worktree.path,
+ repo
+ })
const preflight = TUI_AGENT_CONFIG[agent].preflightTrust
if (preflight && worktree.path && window.api.agentTrust?.markTrusted) {
try {
await window.api.agentTrust.markTrusted({
preset: preflight,
- workspacePath: worktree.path
+ workspacePath: worktree.path,
+ ...(launchHost.connectionId ? { connectionId: launchHost.connectionId } : {})
})
} catch {
- // Best-effort: continue with launch. The user can still accept the trust menu.
+ // Best-effort: the user can still accept the trust prompt.
}
}
- const cmdOverrides = store.settings?.agentCmdOverrides ?? {}
- const agentArgs = resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs)
- const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv)
- const launchPlatform = repo
- ? getAgentLaunchPlatformForRepo(
- repo,
- repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId)
- )
- : CLIENT_PLATFORM
- // Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only
- // `orca-ide` rename must not be applied for remote launches.
- const isRemote = repo ? repoIsRemote(repo) : false
+ const { platform: launchPlatform, isRemote } = launchHost
const startupShell = resolveLocalWindowsAgentStartupShell({
platform: launchPlatform,
isRemote,
@@ -99,37 +100,17 @@ export async function launchAgentBackgroundSession(
return null
}
- // Why: automation runs should start without revealing the workspace.
- // Spawn the PTY immediately, then attach an inactive tab to the live session.
- const tab = store.createTab(worktreeId, undefined, undefined, {
- activate: false,
- recordInteraction: false
- })
- // Why: agent hook callbacks are keyed by pane, and background automation
- // tabs never mount a TerminalPane to inject this env for us. createBrowserUuid
- // (not crypto.randomUUID) because the latter is undefined in non-secure
- // browser contexts — the LAN web client served over plain HTTP.
- const leafId = createBrowserUuid()
- const paneKey = makePaneKey(tab.id, leafId)
- const launchToken = createBrowserUuid()
- const launchRegistration = {
- agentType: agent,
- launchToken,
- tabId: tab.id,
- leafId
- }
- store.registerAgentLaunchConfig(paneKey, startupPlan.launchConfig, launchRegistration)
- // Why: `title` labels the tab/worktree entry. Pane titles render as an
- // in-terminal title row, so background sessions must not persist it there.
- store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId))
- const paneEnv = {
- ...startupPlan.env,
- ORCA_PANE_KEY: paneKey,
- ORCA_TAB_ID: tab.id,
- ORCA_WORKTREE_ID: worktreeId,
- ORCA_AGENT_LAUNCH_TOKEN: launchToken
- }
- const sshConnectionId = repo?.connectionId ?? null
+ // A hidden run tab must never be store-visible without its PTY (#2989).
+ const { reservedTabId, leafId, launchToken, launchRegistration, paneEnv } =
+ reserveAgentBackgroundSessionIdentity({
+ store,
+ agentType: agent,
+ worktreeId,
+ launchConfig: startupPlan.launchConfig,
+ env: startupPlan.env
+ })
+ let paneKey = makePaneKey(reservedTabId, leafId)
+ const sshConnectionId = launchHost.connectionId
const sshStartupDelivery = createSshBackgroundStartupDelivery({
command: sshConnectionId ? startupPlan.launchCommand : null,
waitForShellReady:
@@ -147,6 +128,7 @@ export async function launchAgentBackgroundSession(
let ptyId = '',
runtimeTerminalHandle: string | null = null
let returnedLaunchConfig: typeof startupPlan.launchConfig | undefined
+ let tab: ReturnType | null = null
let exitHandled = false,
eagerPtyBuffer: EagerPtyHandle | null = null
let terminalOwnership: ReturnType = null
@@ -160,7 +142,9 @@ export async function launchAgentBackgroundSession(
unsubscribeExit()
unsubscribeData()
sshStartupDelivery.clear()
- useAppStore.getState().clearTabPtyId(tab.id, exitPtyId)
+ if (tab) {
+ useAppStore.getState().clearTabPtyId(tab.id, exitPtyId)
+ }
useAppStore.getState().clearAgentLaunchConfig(paneKey)
onExit?.(exitPtyId, code)
}
@@ -174,7 +158,7 @@ export async function launchAgentBackgroundSession(
paneKey,
launchToken,
mainOwnsAgentStatusWrites,
- expectedConnectionId: repo ? (repo.connectionId ?? null) : undefined,
+ expectedConnectionId: launchHost.expectedConnectionId,
runtimeEnvironmentId: runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null,
getPtyId: () => ptyId,
onAgentStatus
@@ -192,7 +176,7 @@ export async function launchAgentBackgroundSession(
const created = await createRuntimeAgentBackgroundTerminal({
environmentId: runtimeTarget.environmentId,
worktreeId,
- tabId: tab.id,
+ tabId: reservedTabId,
leafId,
agent,
...(hasPrompt && !isFollowupPath ? { prompt: trimmedPrompt } : {}),
@@ -216,6 +200,7 @@ export async function launchAgentBackgroundSession(
rows: 40,
cwd: worktree.path,
command: startupPlan.launchCommand,
+ ...(!sshConnectionId && isWslUncPath(worktree.path) ? { shellOverride: 'wsl.exe' } : {}),
...(!startupPlan.startupCommandDelivery
? {}
: { startupCommandDelivery: startupPlan.startupCommandDelivery }),
@@ -225,7 +210,7 @@ export async function launchAgentBackgroundSession(
launchAgent: agent,
connectionId: sshConnectionId,
worktreeId,
- tabId: tab.id,
+ tabId: reservedTabId,
leafId,
telemetry: {
agent_kind: tuiAgentToAgentKind(agent),
@@ -236,25 +221,29 @@ export async function launchAgentBackgroundSession(
ptyId = result.id
returnedLaunchConfig = result.launchConfig
}
- if (
- await retireUnownedTerminal({
- tabId: tab.id,
- ptyId,
- runtimeTarget,
- runtimeTerminalHandle,
- onRetire: () => {
- exitHandled = true
- sshStartupDelivery.clear()
- store.clearAgentLaunchConfig(paneKey)
- }
- })
- ) {
+ const adopted = await adoptAgentBackgroundSessionTab({
+ store,
+ worktreeId,
+ reservedTabId,
+ ptyId,
+ paneKey,
+ launchConfig: returnedLaunchConfig ?? startupPlan.launchConfig,
+ launchRegistration,
+ runtimeTarget,
+ runtimeTerminalHandle,
+ onRetire: () => {
+ exitHandled = true
+ sshStartupDelivery.clear()
+ store.clearAgentLaunchConfig(paneKey)
+ },
+ ...(title ? { title } : {})
+ })
+ if (!adopted) {
return null
}
- if (returnedLaunchConfig) {
- store.registerAgentLaunchConfig(paneKey, returnedLaunchConfig, launchRegistration)
- }
- terminalOwnership = bindAutomationTerminal(tab, paneKey, ptyId, runtimeTarget.kind, title)
+ tab = adopted.tab
+ paneKey = adopted.paneKey
+ terminalOwnership = adopted.terminalOwnership
if (agent === 'command-code' && hasPrompt && !isFollowupPath) {
// Why: Command Code does not expose a prompt-start hook; seed working for
// hidden prompt launches so sidebar/activity surfaces do not stay idle.
@@ -297,6 +286,7 @@ export async function launchAgentBackgroundSession(
// alive regardless of whether the tab is hidden or mounted.
unsubscribeExit = subscribeToPtyExit(ptyId, (code) => handleExit(ptyId, code))
}
+ sshStartupDelivery.armFallback(ptyId)
// Why: bind the explicit PTY and ownership before mount; an earlier mount
// can double-spawn, while later tracking can miss user takeover.
@@ -312,19 +302,23 @@ export async function launchAgentBackgroundSession(
// A failure between them must not strand an invisible runtime terminal.
exitHandled = true
terminalOwnership?.release()
+ const createdTab = tab
runBestEffortAgentBackgroundCleanups(unsubscribeExit, unsubscribeData)
runBestEffortAgentBackgroundCleanups(() => eagerPtyBuffer?.dispose())
runBestEffortAgentBackgroundCleanups(() => sshStartupDelivery.clear())
- runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(tab.id, ptyId))
+ if (createdTab) {
+ runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(createdTab.id, ptyId))
+ }
runBestEffortAgentBackgroundCleanups(() => store.clearAgentLaunchConfig(paneKey))
if (ptyId) {
await retireProvider({ ptyId, runtimeTarget, runtimeTerminalHandle })
}
- // Why: a launch-failure cleanup close is not a user close — keep it out of
- // the Cmd+Shift+T reopen stack.
- runBestEffortAgentBackgroundCleanups(() =>
- store.closeTab(tab.id, { recordInteraction: false, reason: 'cleanup' })
- )
+ if (createdTab) {
+ // Cleanup closes must not enter the reopen stack.
+ runBestEffortAgentBackgroundCleanups(() =>
+ store.closeTab(createdTab.id, { recordInteraction: false, reason: 'cleanup' })
+ )
+ }
throw error
}
}
diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts
index 85a02598484..4c4c1603d0b 100644
--- a/src/renderer/src/lib/launch-worktree-background-terminals.ts
+++ b/src/renderer/src/lib/launch-worktree-background-terminals.ts
@@ -183,7 +183,7 @@ async function createBackgroundTab(args: {
}
if (
await retireUnownedTerminal({
- tabId: tab.id,
+ owner: { tabId: tab.id },
ptyId,
runtimeTarget: { kind: 'local' }
})
@@ -215,7 +215,7 @@ async function addSetupSplit(args: {
})
if (
await retireUnownedTerminal({
- tabId: args.tab.tabId,
+ owner: { tabId: args.tab.tabId },
ptyId: setupPtyId,
runtimeTarget: { kind: 'local' }
})
diff --git a/src/renderer/src/lib/manual-terminal-worktree-parking.test.ts b/src/renderer/src/lib/manual-terminal-worktree-parking.test.ts
new file mode 100644
index 00000000000..e70ca180b01
--- /dev/null
+++ b/src/renderer/src/lib/manual-terminal-worktree-parking.test.ts
@@ -0,0 +1,42 @@
+// @vitest-environment happy-dom
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ MANUAL_TERMINAL_WORKTREE_PARK_EVENT,
+ requestManualTerminalWorktreePark,
+ takeAllPendingManualTerminalWorktreeParks,
+ takePendingManualTerminalWorktreePark,
+ type ManualTerminalWorktreeParkDetail
+} from './manual-terminal-worktree-parking'
+
+describe('manual terminal worktree parking requests', () => {
+ beforeEach(() => {
+ takeAllPendingManualTerminalWorktreeParks()
+ })
+
+ it('dispatches the target worktree and records it for a late Terminal mount', () => {
+ const listener = vi.fn((event: Event) => {
+ expect((event as CustomEvent).detail).toEqual({
+ worktreeId: 'worktree-1'
+ })
+ })
+ window.addEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener)
+
+ requestManualTerminalWorktreePark('worktree-1')
+
+ expect(listener).toHaveBeenCalledOnce()
+ expect(takePendingManualTerminalWorktreePark('worktree-1')).toBe(true)
+ window.removeEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener)
+ })
+
+ it('ignores an empty worktree id', () => {
+ const listener = vi.fn()
+ window.addEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener)
+
+ requestManualTerminalWorktreePark('')
+
+ expect(listener).not.toHaveBeenCalled()
+ expect(takeAllPendingManualTerminalWorktreeParks()).toEqual([])
+ window.removeEventListener(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, listener)
+ })
+})
diff --git a/src/renderer/src/lib/manual-terminal-worktree-parking.ts b/src/renderer/src/lib/manual-terminal-worktree-parking.ts
new file mode 100644
index 00000000000..19113da5767
--- /dev/null
+++ b/src/renderer/src/lib/manual-terminal-worktree-parking.ts
@@ -0,0 +1,29 @@
+export const MANUAL_TERMINAL_WORKTREE_PARK_EVENT = 'orca-manual-terminal-worktree-park'
+
+export type ManualTerminalWorktreeParkDetail = {
+ worktreeId: string
+}
+
+const pendingWorktreeIds = new Set()
+
+export function requestManualTerminalWorktreePark(worktreeId: string): void {
+ if (!worktreeId) {
+ return
+ }
+ pendingWorktreeIds.add(worktreeId)
+ window.dispatchEvent(
+ new CustomEvent(MANUAL_TERMINAL_WORKTREE_PARK_EVENT, {
+ detail: { worktreeId }
+ })
+ )
+}
+
+export function takePendingManualTerminalWorktreePark(worktreeId: string): boolean {
+ return pendingWorktreeIds.delete(worktreeId)
+}
+
+export function takeAllPendingManualTerminalWorktreeParks(): string[] {
+ const pending = [...pendingWorktreeIds]
+ pendingWorktreeIds.clear()
+ return pending
+}
diff --git a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts
index a01a49de988..073cc29e874 100644
--- a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts
+++ b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts
@@ -220,9 +220,12 @@ export function attachDividerDrag(
// Why: WSLg's RDP input path reports press/release as a `mouse` pointer but
// streams motion as a `pen` pointer with a different pointerId, so a strict
- // pointerId match drops every move. Any primary pointer continues the drag.
+ // pointerId match drops every move. Any primary pointer continues the drag,
+ // except touch: each pointer type has its own primary, so a stray finger
+ // would otherwise hijack an in-flight mouse/pen drag. A touch-started drag
+ // still matches by pointerId.
const isActiveDragPointer = (e: PointerEvent): boolean =>
- e.pointerId === activePointerId || e.isPrimary
+ e.pointerId === activePointerId || (e.isPrimary && e.pointerType !== 'touch')
const onPointerMove = (e: PointerEvent): void => {
if (!dragging || !isActiveDragPointer(e) || !prevEl || !nextEl) {
diff --git a/src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts b/src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts
new file mode 100644
index 00000000000..0a5d0c967e0
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts
@@ -0,0 +1,208 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createDivider } from './pane-divider'
+
+type PaneElement = HTMLElement & { style: Record }
+
+type DividerDragHarness = {
+ divider: HTMLElement
+ dividerListeners: Map
+ windowListeners: Map
+ previousPane: PaneElement
+ nextPane: PaneElement
+ onLayoutChanged: ReturnType
+ flushAnimationFrames: () => void
+}
+
+function createPaneElement(width: number): PaneElement {
+ return {
+ style: {},
+ classList: { contains: vi.fn(() => false) },
+ dispatchEvent: vi.fn(() => true),
+ getBoundingClientRect: vi.fn(() => ({
+ left: 0,
+ top: 0,
+ right: width,
+ bottom: 200,
+ width,
+ height: 200
+ })),
+ querySelectorAll: vi.fn(() => [])
+ } as unknown as PaneElement
+}
+
+function createPointerEvent(args: Partial): PointerEvent {
+ return {
+ preventDefault: vi.fn(),
+ pointerId: 1,
+ pointerType: 'mouse',
+ isPrimary: true,
+ clientX: 0,
+ clientY: 0,
+ ...args
+ } as unknown as PointerEvent
+}
+
+function createDividerDragHarness(): DividerDragHarness {
+ const dividerListeners = new Map()
+ const windowListeners = new Map()
+ const capturedPointerIds = new Set()
+ const animationFrames = new Map()
+ const previousPane = createPaneElement(100)
+ const nextPane = createPaneElement(300)
+ const divider = {
+ style: { setProperty: vi.fn() },
+ classList: { add: vi.fn(), remove: vi.fn() },
+ addEventListener: vi.fn((event: string, listener: EventListener) => {
+ dividerListeners.set(event, listener)
+ }),
+ removeEventListener: vi.fn((event: string, listener: EventListener) => {
+ if (dividerListeners.get(event) === listener) {
+ dividerListeners.delete(event)
+ }
+ }),
+ setPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.add(pointerId)),
+ hasPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.has(pointerId)),
+ releasePointerCapture: vi.fn((pointerId: number) => capturedPointerIds.delete(pointerId)),
+ previousElementSibling: previousPane,
+ nextElementSibling: nextPane
+ } as unknown as HTMLElement
+ vi.stubGlobal('document', { createElement: vi.fn(() => divider) })
+ vi.stubGlobal('window', {
+ addEventListener: vi.fn((event: string, listener: EventListener) => {
+ windowListeners.set(event, listener)
+ }),
+ removeEventListener: vi.fn((event: string, listener: EventListener) => {
+ if (windowListeners.get(event) === listener) {
+ windowListeners.delete(event)
+ }
+ })
+ })
+ let nextFrameId = 0
+ vi.stubGlobal(
+ 'requestAnimationFrame',
+ vi.fn((callback: FrameRequestCallback) => {
+ nextFrameId += 1
+ animationFrames.set(nextFrameId, callback)
+ return nextFrameId
+ })
+ )
+ vi.stubGlobal(
+ 'cancelAnimationFrame',
+ vi.fn((frameId: number) => animationFrames.delete(frameId))
+ )
+ const onLayoutChanged = vi.fn()
+ createDivider(true, {}, { refitPanesUnder: vi.fn(), onLayoutChanged })
+
+ return {
+ divider,
+ dividerListeners,
+ windowListeners,
+ previousPane,
+ nextPane,
+ onLayoutChanged,
+ flushAnimationFrames: () => {
+ for (const [frameId, callback] of animationFrames) {
+ animationFrames.delete(frameId)
+ callback(16)
+ }
+ }
+ }
+}
+
+// Each pointer type has its own primary pointer, so a finger on a touchscreen
+// arrives with isPrimary true while a mouse drag is already in flight.
+const STRAY_TOUCH = { pointerId: 42, pointerType: 'touch', isPrimary: true } as const
+
+function startMouseDrag(harness: DividerDragHarness): void {
+ harness.dividerListeners.get('pointerdown')?.(
+ createPointerEvent({ pointerId: 9, pointerType: 'mouse', isPrimary: true, clientX: 100 })
+ )
+ harness.windowListeners.get('pointermove')?.(
+ createPointerEvent({ pointerId: 9, pointerType: 'mouse', isPrimary: true, clientX: 180 })
+ )
+ harness.flushAnimationFrames()
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('divider drag pointer-type isolation', () => {
+ it('ignores stray touch motion during an active mouse drag', () => {
+ const harness = createDividerDragHarness()
+ startMouseDrag(harness)
+ expect(harness.previousPane.style.flex).toBe('180 1 0%')
+
+ harness.windowListeners.get('pointermove')?.(
+ createPointerEvent({ ...STRAY_TOUCH, clientX: 320 })
+ )
+ harness.flushAnimationFrames()
+
+ expect(harness.previousPane.style.flex).toBe('180 1 0%')
+ expect(harness.nextPane.style.flex).toBe('220 1 0%')
+ })
+
+ it('does not commit the layout when a stray touch lifts mid mouse drag', () => {
+ const harness = createDividerDragHarness()
+ startMouseDrag(harness)
+
+ harness.windowListeners.get('pointerup')?.(createPointerEvent({ ...STRAY_TOUCH, clientX: 320 }))
+
+ expect(harness.onLayoutChanged).not.toHaveBeenCalled()
+ expect(harness.divider.classList.remove).not.toHaveBeenCalledWith('is-dragging')
+ expect(harness.windowListeners.has('pointermove')).toBe(true)
+
+ harness.windowListeners.get('pointerup')?.(
+ createPointerEvent({ pointerId: 9, pointerType: 'mouse', isPrimary: true, clientX: 180 })
+ )
+
+ expect(harness.previousPane.style.flex).toBe('180 1 0%')
+ expect(harness.onLayoutChanged).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not revert the layout when a stray touch is cancelled mid mouse drag', () => {
+ const harness = createDividerDragHarness()
+ harness.previousPane.style.flex = '2 1 0%'
+ harness.nextPane.style.flex = '3 1 0%'
+ startMouseDrag(harness)
+
+ harness.windowListeners.get('pointercancel')?.(
+ createPointerEvent({ ...STRAY_TOUCH, clientX: 320 })
+ )
+
+ expect(harness.previousPane.style.flex).toBe('180 1 0%')
+ expect(harness.windowListeners.has('pointermove')).toBe(true)
+ })
+
+ it('still continues a mouse drag from a primary pen pointer (WSLg relay)', () => {
+ const harness = createDividerDragHarness()
+ harness.dividerListeners.get('pointerdown')?.(
+ createPointerEvent({ pointerId: 1, pointerType: 'mouse', isPrimary: true, clientX: 100 })
+ )
+ harness.windowListeners.get('pointermove')?.(
+ createPointerEvent({ pointerId: 19, pointerType: 'pen', isPrimary: true, clientX: 180 })
+ )
+ harness.windowListeners.get('pointerup')?.(
+ createPointerEvent({ pointerId: 1, pointerType: 'mouse', isPrimary: true, clientX: 180 })
+ )
+
+ expect(harness.previousPane.style.flex).toBe('180 1 0%')
+ expect(harness.nextPane.style.flex).toBe('220 1 0%')
+ expect(harness.onLayoutChanged).toHaveBeenCalledTimes(1)
+ })
+
+ it('drives a touch-started drag from its own pointerId', () => {
+ const harness = createDividerDragHarness()
+ const touchDrag = { pointerId: 7, pointerType: 'touch', isPrimary: true } as const
+ harness.dividerListeners.get('pointerdown')?.(
+ createPointerEvent({ ...touchDrag, clientX: 100 })
+ )
+ harness.windowListeners.get('pointermove')?.(createPointerEvent({ ...touchDrag, clientX: 180 }))
+ harness.flushAnimationFrames()
+ harness.windowListeners.get('pointerup')?.(createPointerEvent({ ...touchDrag, clientX: 180 }))
+
+ expect(harness.previousPane.style.flex).toBe('180 1 0%')
+ expect(harness.nextPane.style.flex).toBe('220 1 0%')
+ expect(harness.onLayoutChanged).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/renderer/src/lib/retire-unowned-background-terminal.ts b/src/renderer/src/lib/retire-unowned-background-terminal.ts
index 7a7f128125a..40a0b0afb60 100644
--- a/src/renderer/src/lib/retire-unowned-background-terminal.ts
+++ b/src/renderer/src/lib/retire-unowned-background-terminal.ts
@@ -3,17 +3,24 @@ import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-
import { isTerminalTabPresent } from '@/store/slices/terminal-tab-retirement'
export async function retireUnownedTerminal(args: {
- tabId: string
+ /** Present tab id, or `{ worktreeId }` for a launch whose tab is created after the spawn. */
+ owner: { tabId: string } | { worktreeId: string }
ptyId: string
runtimeTarget: RuntimeClientTarget
runtimeTerminalHandle?: string | null
onRetire?: () => void
}): Promise {
- if (isTerminalTabPresent(useAppStore.getState(), args.tabId)) {
+ const state = useAppStore.getState()
+ const owner = args.owner
+ const isOwned =
+ 'tabId' in owner
+ ? isTerminalTabPresent(state, owner.tabId)
+ : // Folder workspaces exist only in getKnownWorktreeById.
+ state.getKnownWorktreeById(owner.worktreeId) !== undefined
+ if (isOwned) {
return false
}
- // Why: close can win while provider creation is in flight, before the
- // returned handle is bindable to store state or visible to tab retirement.
+ // Close can win before the provider is bindable to store state.
args.onRetire?.()
await retireProvider(args)
return true
diff --git a/src/renderer/src/lib/ssh-background-startup-delivery.ts b/src/renderer/src/lib/ssh-background-startup-delivery.ts
index eb178fd5964..f117ca447b7 100644
--- a/src/renderer/src/lib/ssh-background-startup-delivery.ts
+++ b/src/renderer/src/lib/ssh-background-startup-delivery.ts
@@ -14,6 +14,7 @@ type SshBackgroundStartupDeliveryOptions = {
export type SshBackgroundStartupDelivery = {
handleData(data: string): string
+ armFallback(ptyId: string): void
schedule(ptyId: string): void
clear(): void
}
@@ -51,22 +52,28 @@ export function createSshBackgroundStartupDelivery(
}
}
+ const armFallback = (ptyId: string): void => {
+ lastPtyId = ptyId
+ if (!pendingCommand || fallbackTimer !== null) {
+ return
+ }
+ fallbackTimer = setTimeout(() => {
+ fallbackTimer = null
+ startupShellReady = true
+ schedule(ptyId)
+ }, SSH_SHELL_READY_STARTUP_FALLBACK_MS)
+ }
+
const schedule = (ptyId: string): void => {
lastPtyId = ptyId
if (!pendingCommand) {
return
}
if (!startupShellReady) {
- if (fallbackTimer === null) {
- // Why: hidden SSH sessions can use shells that cannot emit Orca's
- // marker. Prefer readiness, but never drop the startup command forever.
- fallbackTimer = setTimeout(() => {
- fallbackTimer = null
- markShellReady()
- }, SSH_SHELL_READY_STARTUP_FALLBACK_MS)
- }
+ armFallback(ptyId)
return
}
+ clearFallbackTimer()
clearInjectTimer()
injectTimer = setTimeout(() => {
injectTimer = null
@@ -102,6 +109,7 @@ export function createSshBackgroundStartupDelivery(
}
return scanned.output
},
+ armFallback,
schedule,
clear() {
clearInjectTimer()
diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts
index a3cfcabad93..2720ce9a520 100644
--- a/src/renderer/src/store/slices/terminals.ts
+++ b/src/renderer/src/store/slices/terminals.ts
@@ -34,6 +34,7 @@ import {
parsePaneKey
} from '../../../../shared/stable-pane-id'
import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
+import { buildByIdIndex, buildWorktreeByIdIndex } from './worktree-by-id-index'
import {
getRepoIdFromWorktreeId,
splitWorktreeIdForFilesystem
@@ -3277,13 +3278,13 @@ export const createTerminalSlice: StateCreator
// Why: preserve each tab's prior ptyId so reconnect passes it as sessionId to the daemon's createOrAttach, triggering reattach instead of a fresh spawn.
const pendingReconnectPtyIdByTabId: Record = {}
+ const placeholderWorktreeById = buildWorktreeByIdIndex(
+ runtimeSessionPlaceholders.worktreesByRepo
+ )
+ const placeholderRepoById = buildByIdIndex(runtimeSessionPlaceholders.repos)
for (const worktreeId of pendingReconnectWorktreeIds) {
- const worktree = Object.values(runtimeSessionPlaceholders.worktreesByRepo)
- .flat()
- .find((entry) => entry.id === worktreeId)
- const repo = worktree
- ? runtimeSessionPlaceholders.repos.find((entry) => entry.id === worktree.repoId)
- : null
+ const worktree = placeholderWorktreeById.get(worktreeId)
+ const repo = worktree ? placeholderRepoById.get(worktree.repoId) : null
if (repo?.connectionId) {
continue
}
@@ -3371,6 +3372,11 @@ export const createTerminalSlice: StateCreator
nextEverActivated.add(activeWorktreeId)
}
+ // Why indexed: the layout map below looks up a tab per persisted layout, and
+ // re-flattening tabsByWorktree per entry is O(tabs x layouts).
+ const allTabs = Object.values(tabsByWorktree).flat()
+ const tabById = buildByIdIndex(allTabs)
+
return {
activeRepoId,
activeWorktreeId,
@@ -3395,11 +3401,7 @@ export const createTerminalSlice: StateCreator
// Why: seed nav history with the hydrated active worktree so the first activation has a Back target; hydration bypasses recordWorktreeVisit, so otherwise Back stays disabled until a second click.
worktreeNavHistory: activeWorktreeId ? [activeWorktreeId] : [],
worktreeNavHistoryIndex: activeWorktreeId ? 0 : -1,
- ptyIdsByTabId: Object.fromEntries(
- Object.values(tabsByWorktree)
- .flat()
- .map((tab) => [tab.id, []] as const)
- ),
+ ptyIdsByTabId: Object.fromEntries(allTabs.map((tab) => [tab.id, []] as const)),
// Why: daemon ptyIds survive app restart; preserve ptyIdsByLeafId so reconnect can reattach each split-pane leaf to its own session, not just the tab-level ptyId.
terminalLayoutsByTabId: Object.fromEntries(
Object.entries(session.terminalLayoutsByTabId)
@@ -3407,9 +3409,7 @@ export const createTerminalSlice: StateCreator
.map(([tabId, layout]) => {
// Why: old sessions can contain renderer-local pane:1-style leaf ids; normalize before runtime/mobile surfaces read them.
const normalized = normalizeTerminalLayoutSnapshot(layout).snapshot
- const tab = Object.values(tabsByWorktree)
- .flat()
- .find((entry) => entry.id === tabId)
+ const tab = tabById.get(tabId)
const sanitized = tab ? sanitizeTerminalLayoutPaneTitles(normalized, tab) : normalized
const activeLeafId = sanitized.root
? resolvePtyBoundActiveLeafId({
@@ -3449,12 +3449,14 @@ export const createTerminalSlice: StateCreator
// Why: defer daemon createOrAttach to connectPanePty (real fitAddon dims) instead of eager-spawning at 80×24 and garbling on flush; this loop only records the session IDs to reattach.
let reconnectedTabsByWorktree: Record | null = null
let reconnectedPtyIdsByTabId: Record | null = null
+ // Why indexed: the loop neither sets state nor awaits, so one index over the
+ // whole store snapshot serves every iteration.
+ const worktreeById = buildWorktreeByIdIndex(get().worktreesByRepo)
+ const repoById = buildByIdIndex(get().repos)
for (const worktreeId of ids) {
const tabs = tabsByWorktree[worktreeId] ?? []
- const worktree = Object.values(get().worktreesByRepo)
- .flat()
- .find((entry) => entry.id === worktreeId)
- const repo = worktree ? get().repos.find((entry) => entry.id === worktree.repoId) : null
+ const worktree = worktreeById.get(worktreeId)
+ const repo = worktree ? (repoById.get(worktree.repoId) ?? null) : null
// Why: only allow deferred reattach when the SSH connection is active; reattaching to a not-yet-connected relay (deferred/passphrase targets) would fail.
const sshState = repo?.connectionId ? get().sshConnectionStates.get(repo.connectionId) : null
const sshConnected = repo?.connectionId != null && sshState?.status === 'connected'
@@ -3508,12 +3510,10 @@ export const createTerminalSlice: StateCreator
// Why: deferred SSH targets haven't connected yet, so their ptyIds weren't restored above; stash session IDs in a map that survives cleanup for pty-connection.ts's deferred reconnect.
const deferredSshSessionIdsByTabId: Record = {}
for (const worktreeId of ids) {
- const worktree = Object.values(get().worktreesByRepo)
- .flat()
- .find((entry) => entry.id === worktreeId)
+ const worktree = worktreeById.get(worktreeId)
// Why: SSH worktrees aren't in worktreesByRepo at cold start; fall back to the repo id in the composite worktree id so sessions still reach the deferred map.
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
- const repo = repoId ? get().repos.find((entry) => entry.id === repoId) : null
+ const repo = repoId ? (repoById.get(repoId) ?? null) : null
if (!repo?.connectionId) {
continue
}
diff --git a/src/renderer/src/store/slices/worktree-by-id-index.test.ts b/src/renderer/src/store/slices/worktree-by-id-index.test.ts
new file mode 100644
index 00000000000..1c5d28e6295
--- /dev/null
+++ b/src/renderer/src/store/slices/worktree-by-id-index.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from 'vitest'
+import type { Worktree } from '../../../../shared/types'
+import { buildByIdIndex, buildWorktreeByIdIndex } from './worktree-by-id-index'
+
+function worktree(id: string, repoId: string): Worktree {
+ return { id, repoId, path: `/repos/${repoId}/${id}`, branch: id } as Worktree
+}
+
+// Reference: the pre-change lookup these indexes replace.
+function findByFlatten(worktreesByRepo: Record, id: string): Worktree | null {
+ return (
+ Object.values(worktreesByRepo)
+ .flat()
+ .find((entry) => entry.id === id) ?? null
+ )
+}
+
+describe('buildWorktreeByIdIndex', () => {
+ const worktreesByRepo = {
+ repoA: [worktree('wt-1', 'repoA'), worktree('wt-2', 'repoA')],
+ repoB: [worktree('wt-3', 'repoB')],
+ repoC: []
+ }
+
+ // Why: the index replaces a per-iteration flatten+find, so the contract that
+ // matters is that it resolves exactly what that find resolved.
+ it('resolves every id identically to flatten-and-find', () => {
+ for (const id of ['wt-1', 'wt-2', 'wt-3', 'missing', '']) {
+ expect(buildWorktreeByIdIndex(worktreesByRepo).get(id) ?? null).toEqual(
+ findByFlatten(worktreesByRepo, id)
+ )
+ }
+ })
+
+ it('returns undefined for an id in no repo', () => {
+ expect(buildWorktreeByIdIndex(worktreesByRepo).get('nope')).toBeUndefined()
+ })
+
+ it('handles an empty map', () => {
+ expect(buildWorktreeByIdIndex({}).size).toBe(0)
+ })
+
+ it('skips repos with no worktrees', () => {
+ expect(buildWorktreeByIdIndex(worktreesByRepo).size).toBe(3)
+ })
+
+ // Why first-wins: Array.prototype.find returns the first match, and repo order is
+ // Object.values order. A duplicate id across repos must resolve the same way.
+ it('keeps the first entry when an id appears in two repos', () => {
+ const duplicated = {
+ repoA: [worktree('shared', 'repoA')],
+ repoB: [worktree('shared', 'repoB')]
+ }
+ expect(buildWorktreeByIdIndex(duplicated).get('shared')?.repoId).toBe('repoA')
+ expect(buildWorktreeByIdIndex(duplicated).get('shared')).toEqual(
+ findByFlatten(duplicated, 'shared')
+ )
+ })
+})
+
+describe('buildByIdIndex', () => {
+ it('resolves every id identically to find', () => {
+ const rows = [
+ { id: 'a', n: 1 },
+ { id: 'b', n: 2 },
+ { id: 'c', n: 3 }
+ ]
+ for (const id of ['a', 'b', 'c', 'zzz']) {
+ expect(buildByIdIndex(rows).get(id)).toEqual(rows.find((row) => row.id === id))
+ }
+ })
+
+ it('keeps the first row when ids repeat', () => {
+ const rows = [
+ { id: 'dup', n: 1 },
+ { id: 'dup', n: 2 }
+ ]
+ expect(buildByIdIndex(rows).get('dup')).toEqual(rows.find((row) => row.id === 'dup'))
+ expect(buildByIdIndex(rows).get('dup')?.n).toBe(1)
+ })
+
+ it('handles an empty list', () => {
+ expect(buildByIdIndex([]).size).toBe(0)
+ })
+})
diff --git a/src/renderer/src/store/slices/worktree-by-id-index.ts b/src/renderer/src/store/slices/worktree-by-id-index.ts
new file mode 100644
index 00000000000..a7eaf17239e
--- /dev/null
+++ b/src/renderer/src/store/slices/worktree-by-id-index.ts
@@ -0,0 +1,34 @@
+import type { Worktree } from '../../../../shared/types'
+
+/**
+ * Flattens worktreesByRepo into an id lookup.
+ *
+ * Why: session hydration and terminal reconnect loop over pending worktree ids and
+ * previously re-flattened + linear-searched the whole map per iteration, which is
+ * O(worktrees x ids) for O(worktrees + ids) distinct work. First entry wins, matching
+ * the `.find()` it replaces.
+ */
+export function buildWorktreeByIdIndex(
+ worktreesByRepo: Record
+): Map {
+ const index = new Map()
+ for (const worktrees of Object.values(worktreesByRepo)) {
+ for (const worktree of worktrees) {
+ if (!index.has(worktree.id)) {
+ index.set(worktree.id, worktree)
+ }
+ }
+ }
+ return index
+}
+
+/** Same first-wins contract as `buildWorktreeByIdIndex`, for any id-bearing row. */
+export function buildByIdIndex(rows: readonly T[]): Map {
+ const index = new Map()
+ for (const row of rows) {
+ if (!index.has(row.id)) {
+ index.set(row.id, row)
+ }
+ }
+ return index
+}
diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts
index 0f3296c4e1d..1091a911b55 100644
--- a/src/shared/dashboard-snapshot.ts
+++ b/src/shared/dashboard-snapshot.ts
@@ -1,4 +1,5 @@
import type { AgentType } from './agent-status-types'
+import type { RepoIcon } from './repo-icon'
/**
* Serializable contract for the pop-out agent dashboard. The main renderer owns
@@ -58,14 +59,26 @@ export type DashboardCard = {
unseen: boolean
/** Short summary of the pending question when bucket === 'attention'. */
askSummary?: string
+ /** The tab's conversation name, resolved exactly as the sidebar's agent rows
+ * resolve it. Undefined when no usable name exists (status-only titles). */
+ conversationName?: string
}
export type DashboardSnapshot = {
generatedAt: number
cards: DashboardCard[]
+ /** Icons for the repos the cards belong to. Keyed by repoId rather than
+ * carried per card: image icons are data URLs up to 400KB, and the snapshot
+ * is republished several times a second. Optional so a pop-out running
+ * pre-upgrade code still accepts the payload. */
+ repoIconsByRepoId?: Record
}
-export const EMPTY_DASHBOARD_SNAPSHOT: DashboardSnapshot = { generatedAt: 0, cards: [] }
+export const EMPTY_DASHBOARD_SNAPSHOT: DashboardSnapshot = {
+ generatedAt: 0,
+ cards: [],
+ repoIconsByRepoId: {}
+}
/** Routing payload for click-to-focus: reveal this agent's pane in the main
* window. leafId is null when the pane could not be resolved (best-effort:
diff --git a/src/shared/git-branch-compare-head.test.ts b/src/shared/git-branch-compare-head.test.ts
new file mode 100644
index 00000000000..263518112a3
--- /dev/null
+++ b/src/shared/git-branch-compare-head.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+import { readBranchCompareHead } from './git-branch-compare-head'
+
+function deferred(): { promise: Promise; resolve: (value: T) => void } {
+ let resolve!: (value: T) => void
+ const promise = new Promise((innerResolve) => {
+ resolve = innerResolve
+ })
+ return { promise, resolve }
+}
+
+describe('readBranchCompareHead', () => {
+ it('launches independent head reads before waiting for any result', async () => {
+ const compareRef = deferred()
+ const baseRef = deferred()
+ const headOid = deferred()
+ const calls: string[] = []
+
+ const pending = readBranchCompareHead({
+ readCompareRef: () => {
+ calls.push('compare-ref')
+ return compareRef.promise
+ },
+ resolveBaseRef: () => {
+ calls.push('base-probe')
+ return baseRef.promise
+ },
+ readHeadOid: () => {
+ calls.push('head-oid')
+ return headOid.promise
+ },
+ readBaseOid: () => Promise.resolve('base-oid')
+ })
+ await Promise.resolve()
+
+ expect(calls).toEqual(['compare-ref', 'base-probe', 'head-oid'])
+
+ compareRef.resolve('feature')
+ baseRef.resolve('refs/remotes/origin/main')
+ headOid.resolve('head-oid')
+ await expect(pending).resolves.toMatchObject({
+ compareRef: 'feature',
+ resolvedBaseRef: 'refs/remotes/origin/main',
+ headOidResult: { ok: true, oid: 'head-oid' },
+ baseOidResult: { ok: true, oid: 'base-oid' }
+ })
+ })
+})
diff --git a/src/shared/git-branch-compare-head.ts b/src/shared/git-branch-compare-head.ts
new file mode 100644
index 00000000000..8453bc28c82
--- /dev/null
+++ b/src/shared/git-branch-compare-head.ts
@@ -0,0 +1,40 @@
+export type BranchCompareOidResult = { ok: true; oid: string } | { ok: false; error: unknown }
+
+type BranchCompareHeadReaders = {
+ readCompareRef: () => Promise
+ resolveBaseRef: () => Promise
+ readHeadOid: () => Promise
+ readBaseOid: (resolvedBaseRef: string) => Promise
+}
+
+export type BranchCompareHead = {
+ compareRef: string
+ resolvedBaseRef: string
+ headOidResult: BranchCompareOidResult
+ baseOidResult: BranchCompareOidResult
+}
+
+function settleOid(read: Promise): Promise {
+ return read.then(
+ (oid) => ({ ok: true as const, oid }),
+ (error) => ({ ok: false as const, error })
+ )
+}
+
+export async function readBranchCompareHead(
+ readers: BranchCompareHeadReaders
+): Promise {
+ const compareRefPromise = readers.readCompareRef()
+ const resolvedBaseRefPromise = readers.resolveBaseRef()
+ const headOidResultPromise = settleOid(readers.readHeadOid())
+ const baseOidResultPromise = resolvedBaseRefPromise.then((resolvedBaseRef) =>
+ settleOid(readers.readBaseOid(resolvedBaseRef))
+ )
+ const [compareRef, resolvedBaseRef, headOidResult, baseOidResult] = await Promise.all([
+ compareRefPromise,
+ resolvedBaseRefPromise,
+ headOidResultPromise,
+ baseOidResultPromise
+ ])
+ return { compareRef, resolvedBaseRef, headOidResult, baseOidResult }
+}
diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json
index 8c2bb743b44..f4b1a07abfe 100644
--- a/src/shared/local-build-compatibility-contract.json
+++ b/src/shared/local-build-compatibility-contract.json
@@ -3,9 +3,9 @@
"appId": "com.stablyai.orca",
"stateSchemaVersion": 1,
"readableStateSchemaVersions": [1],
- "daemonProtocolVersion": 28,
+ "daemonProtocolVersion": 29,
"attachableDaemonProtocolVersions": [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
- 27, 28
+ 27, 28, 29
]
}
diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts
index f05be487dd2..ad80dd85808 100644
--- a/src/shared/local-build-compatibility-contract.ts
+++ b/src/shared/local-build-compatibility-contract.ts
@@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = {
appId: 'com.stablyai.orca',
stateSchemaVersion: 1,
readableStateSchemaVersions: [1],
- daemonProtocolVersion: 28,
+ daemonProtocolVersion: 29,
attachableDaemonProtocolVersions: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
- 27, 28
+ 27, 28, 29
]
} as const
diff --git a/src/shared/terminal-color-scheme-protocol.ts b/src/shared/terminal-color-scheme-protocol.ts
index 22c8c426fdb..1b6d680c2a1 100644
--- a/src/shared/terminal-color-scheme-protocol.ts
+++ b/src/shared/terminal-color-scheme-protocol.ts
@@ -24,13 +24,41 @@ export type Mode2031ScanResult = {
unsubscribe: boolean
finalState: 'subscribed' | 'unsubscribed' | null
tail: string
+ /**
+ * The retained tail is a private-mode sequence still capable of resolving to
+ * 2031 once the next chunk arrives — so `finalState` is provisional, not final.
+ */
+ tailMayResolveToMode2031: boolean
+}
+
+export type Mode2031ReplyScanState = {
+ tail: string
+ pendingSubscribe: boolean
+}
+
+export type Mode2031ReplyDecision = 'subscribed' | 'unsubscribed' | null
+
+export type Mode2031ReplyScanResult = {
+ decision: Mode2031ReplyDecision
+ state: Mode2031ReplyScanState
+}
+
+export const INITIAL_MODE_2031_REPLY_SCAN_STATE: Mode2031ReplyScanState = {
+ tail: '',
+ pendingSubscribe: false
+}
+
+const NO_MODE_2031_REPLY_DECISION: Mode2031ReplyScanResult = {
+ decision: null,
+ state: INITIAL_MODE_2031_REPLY_SCAN_STATE
}
const NO_MODE_2031_SEQUENCE: Mode2031ScanResult = {
subscribe: false,
unsubscribe: false,
finalState: null,
- tail: ''
+ tail: '',
+ tailMayResolveToMode2031: false
}
export function scanMode2031Sequences(previousTail: string, data: string): Mode2031ScanResult {
@@ -38,11 +66,13 @@ export function scanMode2031Sequences(previousTail: string, data: string): Mode2
return NO_MODE_2031_SEQUENCE
}
const input = `${previousTail}${data}`
+ const tail = extractPrivateModeScanTail(input)
const result: Mode2031ScanResult = {
subscribe: false,
unsubscribe: false,
finalState: null,
- tail: extractPrivateModeScanTail(input)
+ tail,
+ tailMayResolveToMode2031: tailCouldStillBeMode2031(tail)
}
// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
const privateModeRe = /\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g
@@ -63,6 +93,40 @@ export function scanMode2031Sequences(previousTail: string, data: string): Mode2
return result
}
+export function scanMode2031ReplyDecision(
+ previous: Mode2031ReplyScanState,
+ data: string
+): Mode2031ReplyScanResult {
+ if (
+ !previous.pendingSubscribe &&
+ !previous.tail &&
+ !data.includes('\x1b') &&
+ !data.includes('\x9b')
+ ) {
+ return NO_MODE_2031_REPLY_DECISION
+ }
+ const scan = scanMode2031Sequences(previous.tail, data)
+ let decision = scan.finalState
+ let pendingSubscribe = previous.pendingSubscribe
+
+ if (scan.finalState === 'unsubscribed') {
+ pendingSubscribe = false
+ } else if (scan.finalState === 'subscribed' || pendingSubscribe) {
+ if (scan.tailMayResolveToMode2031) {
+ decision = null
+ pendingSubscribe = true
+ } else {
+ decision = 'subscribed'
+ pendingSubscribe = false
+ }
+ }
+
+ return {
+ decision,
+ state: { tail: scan.tail, pendingSubscribe }
+ }
+}
+
function hasMode2031(params: string): boolean {
return params.split(';').some((param) => Number(param) === 2031)
}
@@ -91,3 +155,13 @@ function extractPrivateModeScanTail(input: string): string {
function isIncompletePrivateModeParams(params: string): boolean {
return /^[0-9;]*$/.test(params)
}
+
+/**
+ * Whether a retained (incomplete) private-mode tail could still turn out to be a
+ * 2031 toggle. Lets the caller hold a provisional decision for one chunk instead
+ * of answering a subscribe that the very next bytes withdraw (#9993).
+ */
+function tailCouldStillBeMode2031(tail: string): boolean {
+ // Any retained private-mode prefix can still append `;2031` before its final byte.
+ return tail.length > 0
+}
diff --git a/src/shared/terminal-mode-2031-final-state.test.ts b/src/shared/terminal-mode-2031-final-state.test.ts
new file mode 100644
index 00000000000..b8e04d9f1c0
--- /dev/null
+++ b/src/shared/terminal-mode-2031-final-state.test.ts
@@ -0,0 +1,133 @@
+// Why: fish enables and disables DEC mode 2031 around every prompt
+// (`src/tty_handoff.rs`), so a single PTY chunk routinely carries
+// `?2031h ... ?2031l`. Answering the sticky "an h appeared" flag replies to a
+// subscription the shell has already dropped, and the reply lands as literal
+// text at the prompt or in a child's stdin (#9993).
+import { describe, expect, it } from 'vitest'
+import { scanMode2031Sequences } from './terminal-color-scheme-protocol'
+import {
+ createTerminalTitleTracker,
+ type TerminalTitleTrackerCallbacks
+} from './terminal-output-side-effects'
+
+const ESC = '\x1b'
+
+// A fish prompt cycle: subscribe, paint the prompt, hand the tty to the child.
+const FISH_PROMPT_HANDOFF = `${ESC}[?2031h${ESC}[0m~/orca ${ESC}[32m❯${ESC}[0m ${ESC}[?2031l`
+
+function trackerRecording(overrides: TerminalTitleTrackerCallbacks = {}): {
+ subscribes: number
+ tracker: ReturnType
+} {
+ const state = { subscribes: 0 }
+ const tracker = createTerminalTitleTracker({
+ onMode2031Subscribe: () => {
+ state.subscribes += 1
+ },
+ ...overrides
+ })
+ return {
+ get subscribes() {
+ return state.subscribes
+ },
+ tracker
+ }
+}
+
+describe('DECSET 2031 replies follow the chunk-final state (#9993)', () => {
+ it('reports a subscribe-then-unsubscribe chunk as unsubscribed', () => {
+ const scan = scanMode2031Sequences('', FISH_PROMPT_HANDOFF)
+
+ // The sticky flags stay true — both toggles really did occur.
+ expect(scan.subscribe).toBe(true)
+ expect(scan.unsubscribe).toBe(true)
+ // But the shell is NOT listening by the end of the chunk.
+ expect(scan.finalState).toBe('unsubscribed')
+ })
+
+ it('does not emit a 2031-subscribe fact when the shell unsubscribed in the same chunk', () => {
+ const recorded = trackerRecording()
+
+ recorded.tracker.handleChunk(FISH_PROMPT_HANDOFF)
+
+ expect(recorded.subscribes).toBe(0)
+ })
+
+ it('still emits a fact when the chunk ends subscribed', () => {
+ const recorded = trackerRecording()
+
+ recorded.tracker.handleChunk(`${ESC}[?2031l${ESC}[?2031h`)
+
+ expect(recorded.subscribes).toBe(1)
+ })
+
+ it('emits once per chunk that ends subscribed, across a fish prompt loop', () => {
+ const recorded = trackerRecording()
+
+ // Three prompt cycles, then a TUI that subscribes and keeps listening.
+ recorded.tracker.handleChunk(FISH_PROMPT_HANDOFF)
+ recorded.tracker.handleChunk(FISH_PROMPT_HANDOFF)
+ recorded.tracker.handleChunk(FISH_PROMPT_HANDOFF)
+ recorded.tracker.handleChunk(`${ESC}[?2031h`)
+
+ expect(recorded.subscribes).toBe(1)
+ })
+
+ it('keeps answering a subscribe split across chunk boundaries', () => {
+ const recorded = trackerRecording()
+
+ recorded.tracker.handleChunk(`${ESC}[?20`)
+ recorded.tracker.handleChunk('31h')
+
+ expect(recorded.subscribes).toBe(1)
+ })
+
+ it('does not answer a subscribe whose withdrawal straddles the chunk boundary', () => {
+ const recorded = trackerRecording()
+
+ // Same bytes as FISH_PROMPT_HANDOFF, just cut mid-withdrawal by the kernel.
+ // A reply cannot be recalled, so chunk 1 must hold rather than answer-then-regret.
+ recorded.tracker.handleChunk(`${ESC}[?2031h prompt ${ESC}[?20`)
+ recorded.tracker.handleChunk('31l')
+
+ expect(recorded.subscribes).toBe(0)
+ })
+
+ it('does not defer for a partial sequence that cannot become a private mode', () => {
+ const recorded = trackerRecording()
+
+ recorded.tracker.handleChunk(`${ESC}[?2031h drawing ${ESC}[25`)
+
+ expect(recorded.subscribes).toBe(1)
+ })
+
+ it('defers an unrelated private-mode prefix that can append 2031', () => {
+ const recorded = trackerRecording()
+
+ recorded.tracker.handleChunk(`${ESC}[?2031h drawing ${ESC}[?25`)
+ expect(recorded.subscribes).toBe(0)
+ recorded.tracker.handleChunk(';2031l')
+
+ expect(recorded.subscribes).toBe(0)
+ })
+
+ it('answers a deferred subscribe when the ambiguous tail resolves to another mode', () => {
+ const recorded = trackerRecording()
+
+ recorded.tracker.handleChunk(`${ESC}[?2031h drawing ${ESC}[?20`)
+ expect(recorded.subscribes).toBe(0)
+ recorded.tracker.handleChunk('25h')
+
+ expect(recorded.subscribes).toBe(1)
+ })
+
+ it('answers the re-subscribe once when a split toggle resolves back to h', () => {
+ const recorded = trackerRecording()
+
+ recorded.tracker.handleChunk(`${ESC}[?2031h p ${ESC}[?2031l${ESC}[?20`)
+ recorded.tracker.handleChunk('31h')
+
+ // Chunk 1 ends unsubscribed-then-pending, chunk 2 resolves to subscribed.
+ expect(recorded.subscribes).toBe(1)
+ })
+})
diff --git a/src/shared/terminal-output-side-effects.test.ts b/src/shared/terminal-output-side-effects.test.ts
index d9da1b23928..876e4dc12cd 100644
--- a/src/shared/terminal-output-side-effects.test.ts
+++ b/src/shared/terminal-output-side-effects.test.ts
@@ -18,6 +18,7 @@ type RecordedEvent =
| ['finished', number | null]
| ['pr', string, number]
| ['2031-subscribe']
+ | ['2031-unsubscribe']
function createRecordingTracker(overrides: TerminalTitleTrackerCallbacks = {}): {
events: RecordedEvent[]
@@ -30,6 +31,7 @@ function createRecordingTracker(overrides: TerminalTitleTrackerCallbacks = {}):
onCommandFinished: (exitCode) => events.push(['finished', exitCode]),
onPrLink: (link) => events.push(['pr', link.url, link.number]),
onMode2031Subscribe: () => events.push(['2031-subscribe']),
+ onMode2031Unsubscribe: () => events.push(['2031-unsubscribe']),
...overrides
})
return { events, tracker }
@@ -134,16 +136,40 @@ describe('createTerminalTitleTracker 2031-subscribe facts', () => {
expect(events).toEqual([['2031-subscribe']])
})
- it('ignores DECSET 2031 unsubscribes', () => {
+ it('emits an unsubscribe fact so gated views can retire the subscription', () => {
+ // Gated views never see these bytes; without the fact their registry goes stale
+ // and a later theme flip pushes CSI 997 at a shell that already withdrew (#9993).
const { events, tracker } = createRecordingTracker()
tracker.handleChunk(`${ESC}[?2031l`)
+ expect(events).toEqual([['2031-unsubscribe']])
+ })
+
+ it('emits nothing for chunks that carry no 2031 bytes at all', () => {
+ // finalState is null here, not 'unsubscribed' — ordinary output must stay silent.
+ const { events, tracker } = createRecordingTracker()
+
+ tracker.handleChunk('plain build output\r\n')
+
expect(events).toEqual([])
})
+ it('reports only the chunk-final state when a chunk toggles 2031 both ways', () => {
+ // fish enables and disables 2031 around every prompt; one chunk, one decision.
+ const { events, tracker } = createRecordingTracker()
+
+ tracker.handleChunk(`${ESC}[?2031hprompt${ESC}[?2031l`)
+ tracker.handleChunk(`${ESC}[?2031lprompt${ESC}[?2031h`)
+
+ expect(events).toEqual([['2031-unsubscribe'], ['2031-subscribe']])
+ })
+
it('skips the 2031 scan entirely when no consumer is registered', () => {
- const { events, tracker } = createRecordingTracker({ onMode2031Subscribe: undefined })
+ const { events, tracker } = createRecordingTracker({
+ onMode2031Subscribe: undefined,
+ onMode2031Unsubscribe: undefined
+ })
tracker.handleChunk(`${ESC}[?2031h`)
@@ -219,4 +245,18 @@ describe('createTerminalTitleTracker transient-fact scanning suppression', () =>
expect(events).toEqual([['finished', 130]])
})
+
+ it('a handoff scan seed preserves a provisional 2031 subscribe', () => {
+ const { events, tracker } = createRecordingTracker()
+
+ tracker.setTransientFactScanningSuppressed(true)
+ tracker.setTransientFactScanningSuppressed(false)
+ tracker.handleChunk(`${ESC}[?`, {
+ titleScanData: '',
+ mode2031PendingSubscribe: true
+ })
+ tracker.handleChunk('25h')
+
+ expect(events).toEqual([['2031-subscribe']])
+ })
})
diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts
index 2b4b3ea81c5..cde876882c6 100644
--- a/src/shared/terminal-output-side-effects.ts
+++ b/src/shared/terminal-output-side-effects.ts
@@ -13,7 +13,10 @@ import {
normalizeTerminalTitle
} from './agent-detection'
import { createBellDetector } from './terminal-bell-detector'
-import { scanMode2031Sequences } from './terminal-color-scheme-protocol'
+import {
+ INITIAL_MODE_2031_REPLY_SCAN_STATE,
+ scanMode2031ReplyDecision
+} from './terminal-color-scheme-protocol'
import {
createTerminalGitHubPRLinkDetector,
type TerminalGitHubPRLink
@@ -40,6 +43,11 @@ export type TerminalTitleFactMeta = {
staleWorkingTitleClear?: boolean
}
+type TerminalTitleTrackerChunkOptions = {
+ titleScanData?: string
+ mode2031PendingSubscribe?: boolean
+}
+
export type TerminalTitleTrackerCallbacks = {
/** Fired once per observed OSC title, in byte order — including the synthesized cleared title when the stale-working timer fires. */
onTitle?: (normalizedTitle: string, rawTitle: string, meta?: TerminalTitleFactMeta) => void
@@ -60,11 +68,18 @@ export type TerminalTitleTrackerCallbacks = {
* hidden-delivery-gated renderer views answer the color-scheme query without byte access.
*/
onMode2031Subscribe?: () => void
+ /**
+ * Fired per chunk that ends *unsubscribed* after having carried 2031 bytes. Gated
+ * views never see the withdrawal (main drops their bytes), so without this fact
+ * their subscription registry goes stale and a later theme flip pushes CSI 997
+ * into a shell that already withdrew — #9993 through the theme-change door.
+ */
+ onMode2031Unsubscribe?: () => void
}
export type TerminalTitleTracker = {
/** Feed one raw PTY chunk; titles are applied synchronously in byte order. */
- handleChunk: (data: string, options?: { titleScanData?: string }) => void
+ handleChunk: (data: string, options?: TerminalTitleTrackerChunkOptions) => void
/**
* Apply a main-fabricated OSC title/BEL frame (agent hook spinner frames). Parsed statelessly,
* never through the chunk bell detector, so a synthetic tick can't corrupt cross-chunk escape state.
@@ -99,7 +114,8 @@ export function createTerminalTitleTracker(
onBell,
onCommandFinished,
onPrLink,
- onMode2031Subscribe
+ onMode2031Subscribe,
+ onMode2031Unsubscribe
} = callbacks
const bellDetector = onBell ? createBellDetector() : null
// Why: created only when a consumer exists so headless serve never pays the per-chunk 133/URL scans.
@@ -108,8 +124,7 @@ export function createTerminalTitleTracker(
: null
let prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null
let transientFactScanningSuppressed = false
- // Why: a DECSET 2031 subscribe can split across chunks; carry a bounded tail so split sequences still match.
- let mode2031ScanTail = ''
+ let mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
// Why: seed both so a mid-session tracker behaves as if it had observed the pane's last live title (renderer parity).
let lastEmittedTitle: string | null =
options.initialTitle !== undefined ? normalizeTerminalTitle(options.initialTitle) : null
@@ -148,7 +163,7 @@ export function createTerminalTitleTracker(
agentTracker?.handleTitle(rawTitle)
}
- function handleChunk(data: string, options: { titleScanData?: string } = {}): void {
+ function handleChunk(data: string, options: TerminalTitleTrackerChunkOptions = {}): void {
const titleScanData = options.titleScanData ?? data
// Why: hot path — scan for the OSC introducer once and share it with the bell detector's fast-path gate.
const containsOscIntroducer = data.includes('\x1b]')
@@ -195,11 +210,16 @@ export function createTerminalTitleTracker(
onPrLink?.(link)
}
}
- if (onMode2031Subscribe) {
- const mode2031Scan = scanMode2031Sequences(mode2031ScanTail, data)
- mode2031ScanTail = mode2031Scan.tail
- if (mode2031Scan.subscribe) {
- onMode2031Subscribe()
+ if (onMode2031Subscribe || onMode2031Unsubscribe) {
+ const previousMode2031ReplyScanState = options.mode2031PendingSubscribe
+ ? { ...mode2031ReplyScanState, pendingSubscribe: true }
+ : mode2031ReplyScanState
+ const result = scanMode2031ReplyDecision(previousMode2031ReplyScanState, data)
+ mode2031ReplyScanState = result.state
+ if (result.decision === 'subscribed') {
+ onMode2031Subscribe?.()
+ } else if (result.decision === 'unsubscribed') {
+ onMode2031Unsubscribe?.()
}
}
}
@@ -245,7 +265,7 @@ export function createTerminalTitleTracker(
// Cross-chunk carry predates the gapped span; reset it so stale state can't swallow real bells or mint phantom facts.
bellDetector?.reset()
commandFinishedScanner?.reset()
- mode2031ScanTail = ''
+ mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
if (prLinkDetector) {
prLinkDetector = createTerminalGitHubPRLinkDetector()
}
@@ -256,7 +276,7 @@ export function createTerminalTitleTracker(
agentTracker?.reset()
bellDetector?.reset()
commandFinishedScanner?.reset()
- mode2031ScanTail = ''
+ mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
}
}
}
diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts
index 8dee0e6242c..ce388f730fc 100644
--- a/src/shared/terminal-side-effect-facts.ts
+++ b/src/shared/terminal-side-effect-facts.ts
@@ -32,6 +32,10 @@ export type TerminalSideEffectFact =
* the theme reply — the reply stays renderer-side because query authority
* belongs to the view (model/view contract invariant 6). */
| { kind: '2031-subscribe' }
+ /** DECSET 2031 withdrawal observed in the byte stream. Gated views never see
+ * these bytes, so without this fact their subscription registry goes stale
+ * and a later theme flip pushes CSI 997 at a shell that already withdrew. */
+ | { kind: '2031-unsubscribe' }
export type TerminalSideEffectBatch = {
ptyId: string