Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const mocks = vi.hoisted(() => ({
onSaveAgentDefault: vi.fn(),
onLaunched: vi.fn(),
onStart: vi.fn(),
planSourceControlAgentActionLaunch: vi.fn(),
checkSourceControlAgentActionLaunch: vi.fn(),
toastError: vi.fn()
}))
vi.mock('@/components/agent/AgentCombobox', () => ({
Expand Down Expand Up @@ -51,8 +51,8 @@ vi.mock('@/components/ui/select', () => ({
vi.mock('../source-control/SourceControlActionVariableChips', () => ({
SourceControlActionVariableChips: () => React.createElement('div')
}))
vi.mock('@/lib/source-control-agent-action-plan', () => ({
planSourceControlAgentActionLaunch: mocks.planSourceControlAgentActionLaunch
vi.mock('@/lib/source-control-agent-action-launch-check', () => ({
checkSourceControlAgentActionLaunch: mocks.checkSourceControlAgentActionLaunch
}))
vi.mock('sonner', () => ({
toast: { error: mocks.toastError }
Expand Down Expand Up @@ -181,12 +181,7 @@ describe('SourceControlAgentActionDialog', () => {
mocks.ensureDetectedAgents.mockResolvedValue(['codex'])
mocks.ensureRemoteDetectedAgents.mockResolvedValue(['codex'])
mocks.onStart.mockResolvedValue(true)
mocks.planSourceControlAgentActionLaunch.mockReturnValue({
ok: true,
summary: 'Ready to launch.',
commandLabel: 'codex',
caveat: 'The prompt will be submitted after the agent is ready.'
})
mocks.checkSourceControlAgentActionLaunch.mockReturnValue({ ok: true })
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
import React from 'react'
import {
CheckCircle2,
Info,
RefreshCw,
RotateCcw,
Settings,
Sparkles,
TriangleAlert
} from 'lucide-react'
import { Info, RefreshCw, RotateCcw, Settings, Sparkles, TriangleAlert } from 'lucide-react'
import AgentCombobox from '@/components/agent/AgentCombobox'
import { Button } from '@/components/ui/button'
import { DialogFooter } from '@/components/ui/dialog'
Expand All @@ -32,9 +24,9 @@ import { sourceControlActionRecipeMatchesTarget } from './source-control-action-
import type { SourceControlAgentScopeNote } from './source-control-agent-action-dialog-result'
import { translate } from '@/i18n/i18n'

/** What the dialog shows before it starts an agent: only an error the user can fix. */
export type SourceControlAgentActionDeliveryPlanState =
| { status: 'idle' }
| { status: 'success'; summary: string; commandLabel: string; caveat: string }
| { status: 'error'; error: string }

type SourceControlAgentActionDialogFormProps = {
Expand Down Expand Up @@ -345,36 +337,12 @@ export function SourceControlAgentActionDialogForm({
</div>
) : null}

{deliveryPlan.status !== 'idle' ? (
<div
className={cn(
'rounded-md border px-3 py-2 text-xs',
deliveryPlan.status === 'error'
? 'border-destructive/30 bg-destructive/5 text-destructive'
: 'border-border bg-muted/30 text-muted-foreground'
)}
>
{deliveryPlan.status === 'error' ? (
<span className="inline-flex items-start gap-2">
<TriangleAlert className="mt-px size-3.5 shrink-0" />
{deliveryPlan.error}
</span>
) : (
<div className="space-y-1.5">
<div className="flex items-start gap-2 text-foreground">
<CheckCircle2 className="mt-px size-3.5 shrink-0 text-status-success" />
<span>{deliveryPlan.summary}</span>
</div>
<div className="truncate font-mono text-[11px]">
{translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e',
'Launch:'
)}{' '}
{deliveryPlan.commandLabel}
</div>
<div className="text-[11px]">{deliveryPlan.caveat}</div>
</div>
)}
{deliveryPlan.status === 'error' ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<span className="inline-flex items-start gap-2">
<TriangleAlert className="mt-px size-3.5 shrink-0" />
{deliveryPlan.error}
</span>
</div>
) : null}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,25 @@
import { planSourceControlAgentActionLaunch } from '@/lib/source-control-agent-action-plan'
import { checkSourceControlAgentActionLaunch } from '@/lib/source-control-agent-action-launch-check'
import { useAppStore } from '@/store'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm'
import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support'
import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options'

type BuildSourceControlAgentDeliveryPlanArgs = {
selectedAgent: TuiAgent | null
commandInput: string
agentArgs?: string | undefined
promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready'
detectedAgents: TuiAgent[]
connectionUnavailable: boolean
launchPlatform?: NodeJS.Platform
/** Why: keep the previewed command label in sync with the real remote launch,
* which omits the Linux-only `orca-ide` rename for SSH hosts. */
/** Why: SSH remotes run the plain `orca` shim, so the check builds the command they would. */
isRemote?: boolean
}

/** The dialog's check before it starts an agent: an error the user can fix, else nothing to show. */
export function buildSourceControlAgentDeliveryPlan({
selectedAgent,
commandInput,
agentArgs,
promptDelivery,
detectedAgents,
connectionUnavailable,
launchPlatform,
Expand All @@ -32,33 +29,16 @@ export function buildSourceControlAgentDeliveryPlan({
return buildSourceControlAgentConnectionErrorPlan()
}
const settings = useAppStore.getState().settings
const result = planSourceControlAgentActionLaunch({
const result = checkSourceControlAgentActionLaunch({
agent: selectedAgent,
commandInput,
agentArgs,
sessionOptions: selectedAgent
? resolveInitialNativeChatSessionOptions(settings, {
agent: selectedAgent,
promptDelivery,
launchDraftText: commandInput.trim(),
nativeChatTranscriptIsLocalReadable: !isRemote
})
: undefined,
promptDelivery,
detectedAgents,
disabledAgents: settings?.disabledTuiAgents,
cmdOverrides: settings?.agentCmdOverrides,
terminalWindowsShell: settings?.terminalWindowsShell,
platform: launchPlatform,
isRemote
})
if (!result.ok) {
return { status: 'error', error: result.error }
}
return {
status: 'success',
summary: result.summary,
commandLabel: result.commandLabel,
caveat: result.caveat
}
return result.ok ? { status: 'idle' } : { status: 'error', error: result.error }
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,8 @@ export function useSourceControlAgentActionStart({
return buildSourceControlAgentDeliveryPlan({
selectedAgent,
commandInput,
// Why: the previewed command must show what the launch will really apply.
// Why: the check must judge the arguments the launch will really apply.
agentArgs: agentArgsApply ? agentArgs : undefined,
promptDelivery,
detectedAgents: currentDetectedAgents,
connectionUnavailable,
launchPlatform,
Expand All @@ -124,7 +123,6 @@ export function useSourceControlAgentActionStart({
agentArgsApply,
commandInput,
connectionUnavailable,
promptDelivery,
refreshDetectedAgents,
selectedAgent,
launchPlatform,
Expand Down
9 changes: 9 additions & 0 deletions src/renderer/src/components/terminal-pane/ipc-pty-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { waitAtTerminalPtyPreSpawnE2EBarrier } from './terminal-pty-pre-spawn-e2
import type { IpcPtySessionHandlers } from './ipc-pty-session-handlers'
import { isSshSessionGoneError } from './pty-connection/pty-connect-limits'
import { spawnIpcPty } from './ipc-pty-spawn-request'
import { agentLaunchPaneSpawnHold } from '@/lib/agent-launch-pane-spawn-hold'
import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types'

const SSH_PTY_CONNECTION_MISMATCH_MARKER = 'belongs to SSH connection'
Expand Down Expand Up @@ -81,6 +82,14 @@ export async function connectIpcPty(
return
}
}
// A launch pane this window made spawns only once the host has taken it.
const launchPaneHold = agentLaunchPaneSpawnHold(transportOptions.tabId, transportOptions.leafId)
if (launchPaneHold) {
await launchPaneHold
if (context.isDestroyed()) {
return
}
}
if (options.shouldContinue && !options.shouldContinue()) {
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,34 @@ describe('createIpcPtyTransport', () => {
expect(spawn).not.toHaveBeenCalled()
})

it("spawns a launch pane this window made only once the host has taken it, and never once it's gone", async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const { holdAgentLaunchPaneSpawn, releaseAgentLaunchPaneSpawn } =
await import('@/lib/agent-launch-pane-spawn-hold')
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
const pane = { worktreeId: 'wt', tabId: 'tab-held', leafId: 'leaf-held' }

holdAgentLaunchPaneSpawn(pane.tabId, pane.leafId)
const transport = createIpcPtyTransport(pane)
const connecting = transport.connect({ url: '', callbacks: {} })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(spawn).not.toHaveBeenCalled()

releaseAgentLaunchPaneSpawn(pane.tabId, pane.leafId)
await connecting
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ tabId: 'tab-held' }))
transport.disconnect()

spawn.mockClear()
const release = holdAgentLaunchPaneSpawn(pane.tabId, pane.leafId)
const closed = createIpcPtyTransport(pane)
const abandoned = closed.connect({ url: '', callbacks: {} })
closed.destroy?.()
release()
await expect(abandoned).resolves.toBeUndefined()
expect(spawn).not.toHaveBeenCalled()
})

it('keeps the recovery hint and raw diagnostic from a wrapped spawn error', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
vi.mocked(window.api.pty.spawn).mockRejectedValueOnce(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ export type TerminalCreateSurfacingStore = {
clearAgentLaunchConfig: SpyMock
updateTabPtyId: Mock<(tabId: string, ptyId: string) => void>
setTabLayout: Mock<(tabId: string, layout: unknown) => void>
tabsByWorktree: Record<string, { id: string; ptyId?: string | null; title?: string }[]>
tabsByWorktree: Record<
string,
{ id: string; ptyId?: string | null; title?: string; agentLaunchPane?: { leafId: string } }[]
>
folderWorkspaces: unknown[]
projectGroups: unknown[]
repos: { id: string; connectionId: string | null; executionHostId: string }[]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { releaseAgentLaunchPaneSpawn } from '@/lib/agent-launch-pane-spawn-hold'
import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount'
import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph'
import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount'
Expand Down Expand Up @@ -88,6 +89,11 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v
throw new Error(`Terminal tab ${tabId} not found`)
}
const reusedTab = existingTab ?? splitTargetTab
if (ptyId && tabId && leafId && reusedTab?.agentLaunchPane?.leafId === leafId) {
// A launch pane this window made and the host never showed early: the host now holds it,
// so its spawn may attach instead of waiting out the whole launch.
releaseAgentLaunchPaneSpawn(tabId, leafId)
}
const tab =
reusedTab ??
(ptyId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,37 @@ describe('a launch tab while its agent is starting', () => {
)
})
})

describe('a launch tab this window made, which the host never showed early', () => {
it('takes the agent the host reveals under its own id, and lets its pane attach at once', async () => {
const scenario = await setupTerminalCreateSurfacing(() => false)
const { createTab, updateTabPtyId, createTerminalListenerRef, storeState } = scenario
if (!createTerminalListenerRef.current) {
throw new Error('Expected the create-terminal listener to be registered')
}
Object.assign(window.api, { pty: { kill: vi.fn(async () => {}) } })
// The harness resets modules; the bridge reads the instance it imported.
const { holdAgentLaunchPaneSpawn, agentLaunchPaneSpawnHold } =
await import('@/lib/agent-launch-pane-spawn-hold')
holdAgentLaunchPaneSpawn('tab-made', LEAF)
storeState.tabsByWorktree['wt-1'] = [
{ id: 'tab-made', ptyId: null, agentLaunchPane: { leafId: LEAF } }
]
storeState.terminalLayoutsByTabId['tab-made'] = { root: { type: 'leaf', leafId: LEAF } }
const held = agentLaunchPaneSpawnHold('tab-made', LEAF)

createTerminalListenerRef.current({
requestId: 'reveal-window-made-launch-tab',
worktreeId: 'wt-1',
ptyId: 'pty-agent',
tabId: 'tab-made',
leafId: LEAF
})

// Never a second tab under the same id, and the waiting pane is let go to attach.
expect(createTab).not.toHaveBeenCalled()
expect(updateTabPtyId).toHaveBeenCalledWith('tab-made', 'pty-agent')
await expect(held).resolves.toBeUndefined()
expect(agentLaunchPaneSpawnHold('tab-made', LEAF)).toBeNull()
})
})
1 change: 0 additions & 1 deletion src/renderer/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -13057,7 +13057,6 @@
"ea4788705e": "Cancel",
"7ec6abbf2a": "Reset",
"f4f3c9ca4a": "Prompt template",
"1bc0bdbb5e": "Launch:",
"fe119187bb": "--model sonnet",
"bc8dc39f4b": "CLI arguments",
"b99c33cec5": "Settings",
Expand Down
1 change: 0 additions & 1 deletion src/renderer/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -12838,7 +12838,6 @@
"ea4788705e": "Cancelar",
"7ec6abbf2a": "Reiniciar",
"f4f3c9ca4a": "Plantilla de prompt",
"1bc0bdbb5e": "Lanzamiento:",
"fe119187bb": "--model sonnet",
"bc8dc39f4b": "Argumentos CLI",
"b99c33cec5": "Ajustes",
Expand Down
1 change: 0 additions & 1 deletion src/renderer/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -12943,7 +12943,6 @@
"ea4788705e": "Annuler",
"7ec6abbf2a": "Réinitialiser",
"f4f3c9ca4a": "Modèle de prompt",
"1bc0bdbb5e": "Lancement :",
"fe119187bb": "--model sonnet",
"bc8dc39f4b": "Arguments CLI",
"b99c33cec5": "Paramètres",
Expand Down
1 change: 0 additions & 1 deletion src/renderer/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -12828,7 +12828,6 @@
"38b899cc02": "すべてのリポジトリ"
},
"SourceControlAgentActionDialogForm": {
"1bc0bdbb5e": "起動:",
"7ec6abbf2a": "リセット",
"f4f3c9ca4a": "コマンドテンプレート",
"fe119187bb": "--model sonnet",
Expand Down
1 change: 0 additions & 1 deletion src/renderer/src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -12828,7 +12828,6 @@
"38b899cc02": "모든 리포지토리"
},
"SourceControlAgentActionDialogForm": {
"1bc0bdbb5e": "실행:",
"7ec6abbf2a": "재설정",
"f4f3c9ca4a": "프롬프트 템플릿",
"fe119187bb": "--model sonnet",
Expand Down
1 change: 0 additions & 1 deletion src/renderer/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -12828,7 +12828,6 @@
"38b899cc02": "所有仓库"
},
"SourceControlAgentActionDialogForm": {
"1bc0bdbb5e": "启动:",
"7ec6abbf2a": "重置",
"f4f3c9ca4a": "提示词模板",
"fe119187bb": "--model sonnet",
Expand Down
Loading
Loading