mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
fix(native-chat): reconcile backend draft launch tabs
This commit is contained in:
@@ -297,6 +297,7 @@ async function spawnLocalStartupAndSetupTerminals(args: {
|
||||
env: sequencedStartup.env,
|
||||
...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
|
||||
...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}),
|
||||
...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}),
|
||||
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
|
||||
telemetry: sequencedStartup.telemetry,
|
||||
activate: true
|
||||
|
||||
@@ -1134,6 +1134,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
startup: {
|
||||
command: 'claude --prefill test',
|
||||
env: { ORCA_AGENT_MODE: 'direct' },
|
||||
viewMode: 'chat',
|
||||
telemetry: {
|
||||
agent_kind: 'claude',
|
||||
launch_source: 'new_workspace_composer',
|
||||
@@ -1154,6 +1155,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
command: 'claude --prefill test',
|
||||
env: { ORCA_AGENT_MODE: 'direct' },
|
||||
launchAgent: 'claude',
|
||||
viewMode: 'chat',
|
||||
startupCommandDelivery: undefined,
|
||||
telemetry: {
|
||||
agent_kind: 'claude',
|
||||
|
||||
@@ -3752,16 +3752,34 @@ describe('OrcaRuntimeService', () => {
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
|
||||
getLocalProvider: () => localProvider as never
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn(),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue({
|
||||
handle: 'term_folder_startup',
|
||||
tabId: 'tab-folder-startup',
|
||||
worktreeId: '',
|
||||
title: null,
|
||||
surface: 'background'
|
||||
})
|
||||
const notifier = { worktreesChanged: vi.fn() }
|
||||
runtime.setNotifier(notifier as never)
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:folder-repo',
|
||||
name: 'folder-session',
|
||||
createdWithAgent: 'codex'
|
||||
createdWithAgent: 'codex',
|
||||
startup: { command: 'codex', viewMode: 'chat' }
|
||||
})
|
||||
|
||||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(createTerminal).toHaveBeenCalledWith(
|
||||
`id:${result.worktree.id}`,
|
||||
expect.objectContaining({ command: 'codex', viewMode: 'chat' })
|
||||
)
|
||||
expect(result.worktree).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^folder-repo::\/workspace\/folder::workspace:[0-9a-f-]{36}$/),
|
||||
@@ -5411,16 +5429,21 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
const createTerminal = vi.spyOn(runtime, 'createTerminal')
|
||||
try {
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: TEST_REPO_ID,
|
||||
name: 'mobile-setup',
|
||||
setupDecision: 'run',
|
||||
startup: { command: 'claude' }
|
||||
startup: { command: 'claude', viewMode: 'chat' }
|
||||
})
|
||||
|
||||
// Why: runtime provisions setup itself (fire-and-forget) and omits it from the RPC result so the caller doesn't double-spawn.
|
||||
expect(result.setup).toBeUndefined()
|
||||
expect(createTerminal).toHaveBeenCalledWith(
|
||||
`path:${result.worktree.path}`,
|
||||
expect.objectContaining({ viewMode: 'chat' })
|
||||
)
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -33053,6 +33076,7 @@ describe('OrcaRuntimeService', () => {
|
||||
getRepo: (id: string) => (id === 'repo-1' ? waitRepo : undefined)
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const createTerminal = vi.spyOn(runtime, 'createTerminal')
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-startup' })
|
||||
const spawn = vi
|
||||
.fn()
|
||||
@@ -33109,11 +33133,15 @@ describe('OrcaRuntimeService', () => {
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-headless-startup-setup',
|
||||
setupDecision: 'run',
|
||||
startup: { command: 'claude' }
|
||||
startup: { command: 'claude', viewMode: 'chat' }
|
||||
})
|
||||
|
||||
expect(createSetupRunnerScript).toHaveBeenCalled()
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
expect(createTerminal).toHaveBeenCalledWith(
|
||||
`id:${result.worktree.id}`,
|
||||
expect.objectContaining({ viewMode: 'chat' })
|
||||
)
|
||||
// Why: setup is provisioned fire-and-forget; the wait-for-setup guarantee comes from the shell nonce/marker, not JS spawn ordering.
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
|
||||
const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command
|
||||
|
||||
@@ -18821,6 +18821,7 @@ export class OrcaRuntimeService {
|
||||
? { launchConfig: effectiveStartup.launchConfig }
|
||||
: {}),
|
||||
...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}),
|
||||
...(effectiveStartup.viewMode ? { viewMode: effectiveStartup.viewMode } : {}),
|
||||
startupCommandDelivery: effectiveStartup.startupCommandDelivery,
|
||||
telemetry: effectiveStartup.telemetry
|
||||
})
|
||||
@@ -19546,6 +19547,7 @@ export class OrcaRuntimeService {
|
||||
env: sequencedStartup.env,
|
||||
...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
|
||||
...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}),
|
||||
...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}),
|
||||
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
|
||||
telemetry: sequencedStartup.telemetry
|
||||
})
|
||||
@@ -19888,6 +19890,7 @@ export class OrcaRuntimeService {
|
||||
env: sequencedStartup.env,
|
||||
...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
|
||||
...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}),
|
||||
...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}),
|
||||
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
|
||||
telemetry: sequencedStartup.telemetry
|
||||
})
|
||||
|
||||
@@ -22,6 +22,9 @@ import { decideInitialAgentTabViewMode } from './native-chat-initial-view-mode'
|
||||
const DRAFT_TEXTS = [
|
||||
'https://github.com/o/r/issues/12',
|
||||
'Reproduce on Windows first\n\nhttps://github.com/o/r/issues/12',
|
||||
'Reproduce on Windows first\rhttps://github.com/o/r/issues/12',
|
||||
'Reproduce first\u2028https://github.com/o/r/issues/12',
|
||||
'Reproduce first\u2029https://github.com/o/r/issues/12',
|
||||
'ORC-123: Restore linked quick-create\nhttps://linear.app/o/issue/ORC-123',
|
||||
'ORC-123 https://linear.app/o/issue/ORC-123\n',
|
||||
' spaced but single line ',
|
||||
@@ -68,9 +71,12 @@ describe('launch draft mirrorability', () => {
|
||||
|
||||
// The only test that pins the rule itself; the agreement tests above adapt on
|
||||
// their own. Multi-line send work updates the predicate body and this test.
|
||||
it('accepts single-line text and rejects anything with a newline', () => {
|
||||
it('accepts single-line text and rejects any line separator', () => {
|
||||
expect(canMirrorLaunchDraftToNativeChat('https://github.com/o/r/issues/12')).toBe(true)
|
||||
expect(canMirrorLaunchDraftToNativeChat('one\ntwo')).toBe(false)
|
||||
expect(canMirrorLaunchDraftToNativeChat('one\rtwo')).toBe(false)
|
||||
expect(canMirrorLaunchDraftToNativeChat('one\u2028two')).toBe(false)
|
||||
expect(canMirrorLaunchDraftToNativeChat('one\u2029two')).toBe(false)
|
||||
expect(canMirrorLaunchDraftToNativeChat('trailing\n')).toBe(false)
|
||||
expect(canMirrorLaunchDraftToNativeChat(' ')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
* predicate to accept multi-line flips seeding and view mode together.
|
||||
*/
|
||||
export function canMirrorLaunchDraftToNativeChat(text: string): boolean {
|
||||
return text.trim().length > 0 && !text.includes('\n')
|
||||
return text.trim().length > 0 && !/[\r\n\u2028\u2029]/.test(text)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { seedAgentTabStateAfterWorktreeCreate } from './worktree-creation-agent-
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
seedNativeChatAppliedSessionOptions: vi.fn(),
|
||||
seedNativeChatLaunchDraftForAgentTab: vi.fn()
|
||||
seedNativeChatLaunchDraftForAgentTab: vi.fn(),
|
||||
setWebRuntimeTabProps: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({
|
||||
@@ -14,11 +15,18 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({
|
||||
vi.mock('@/lib/agent-launch-prompt-delivery', () => ({
|
||||
seedNativeChatLaunchDraftForAgentTab: mocks.seedNativeChatLaunchDraftForAgentTab
|
||||
}))
|
||||
vi.mock('@/runtime/web-runtime-session', () => ({
|
||||
setWebRuntimeTabProps: mocks.setWebRuntimeTabProps
|
||||
}))
|
||||
|
||||
type AppState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
const initialTabsByWorktree = useAppStore.getState().tabsByWorktree
|
||||
const initialUnifiedTabsByWorktree = useAppStore.getState().unifiedTabsByWorktree
|
||||
const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById
|
||||
const initialSettings = useAppStore.getState().settings!
|
||||
const initialWorktreesByRepo = useAppStore.getState().worktreesByRepo
|
||||
const initialRepos = useAppStore.getState().repos
|
||||
|
||||
const DRAFT = 'https://github.com/o/r/issues/12'
|
||||
|
||||
@@ -28,9 +36,40 @@ const request = {
|
||||
launchDraftPrompt: DRAFT
|
||||
}
|
||||
|
||||
function setTabs(tabs: { id: string; launchAgent?: string }[]): void {
|
||||
function setTabs(
|
||||
tabs: { id: string; launchAgent?: string; viewMode?: 'terminal' | 'chat' }[],
|
||||
runtimeOwnerEnvironmentId?: string
|
||||
): void {
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: { 'wt-1': tabs },
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/wt-1',
|
||||
...(runtimeOwnerEnvironmentId ? { runtimeOwnerEnvironmentId } : {})
|
||||
}
|
||||
]
|
||||
},
|
||||
repos: [{ id: 'repo-1', path: '/repo', connectionId: null }],
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': tabs.map((tab, index) => ({
|
||||
id: tab.id,
|
||||
entityId: tab.id,
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'terminal' as const,
|
||||
label: `Terminal ${index + 1}`,
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: index,
|
||||
createdAt: index,
|
||||
isPreview: false,
|
||||
isPinned: false,
|
||||
...(tab.viewMode ? { viewMode: tab.viewMode } : {})
|
||||
}))
|
||||
},
|
||||
getKnownWorktreeById: ((id: string) =>
|
||||
id === 'wt-1' ? { id } : undefined) as unknown as AppState['getKnownWorktreeById']
|
||||
} as unknown as Partial<AppState>)
|
||||
@@ -40,15 +79,31 @@ function seededTabIds(): string[] {
|
||||
return mocks.seedNativeChatLaunchDraftForAgentTab.mock.calls.map((call) => call[0].tabId)
|
||||
}
|
||||
|
||||
function tabViewMode(tabId: string): 'terminal' | 'chat' | undefined {
|
||||
return useAppStore.getState().unifiedTabsByWorktree['wt-1']?.find((tab) => tab.id === tabId)
|
||||
?.viewMode
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAppStore.setState({
|
||||
settings: {
|
||||
...initialSettings,
|
||||
experimentalNativeChat: true,
|
||||
openAgentTabsInChatByDefault: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetHookCommandDelayedDeliveryForTests()
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: initialTabsByWorktree,
|
||||
getKnownWorktreeById: initialGetKnownWorktreeById
|
||||
unifiedTabsByWorktree: initialUnifiedTabsByWorktree,
|
||||
getKnownWorktreeById: initialGetKnownWorktreeById,
|
||||
settings: initialSettings,
|
||||
worktreesByRepo: initialWorktreesByRepo,
|
||||
repos: initialRepos
|
||||
} as Partial<AppState>)
|
||||
})
|
||||
|
||||
@@ -74,6 +129,60 @@ describe('seedAgentTabStateAfterWorktreeCreate', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('moves a backend-spawned non-mirrorable draft out of an inherited chat view', () => {
|
||||
setTabs([{ id: 'agent-tab', launchAgent: 'claude', viewMode: 'chat' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request: { ...request, launchDraftPrompt: 'note\rhttps://github.com/o/r/issues/12' },
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'agent-tab',
|
||||
startupTerminalTabId: 'agent-tab',
|
||||
backendSpawned: true
|
||||
})
|
||||
|
||||
expect(tabViewMode('agent-tab')).toBe('terminal')
|
||||
})
|
||||
|
||||
it('opens a backend-spawned mirrorable draft in chat after host reconciliation', () => {
|
||||
setTabs([{ id: 'agent-tab', launchAgent: 'claude', viewMode: 'terminal' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'agent-tab',
|
||||
startupTerminalTabId: 'agent-tab',
|
||||
backendSpawned: true
|
||||
})
|
||||
|
||||
expect(tabViewMode('agent-tab')).toBe('chat')
|
||||
})
|
||||
|
||||
it('keys a raw backend tab id and updates the host before its tab mirror lands', async () => {
|
||||
setTabs([], 'runtime-1')
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: null,
|
||||
startupTerminalTabId: 'host-agent-tab',
|
||||
backendSpawned: true
|
||||
})
|
||||
|
||||
expect(seededTabIds()).toEqual(['web-terminal-host-agent-tab'])
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.setWebRuntimeTabProps).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'web-terminal-host-agent-tab',
|
||||
viewMode: 'chat'
|
||||
})
|
||||
)
|
||||
setTabs(
|
||||
[{ id: 'web-terminal-host-agent-tab', launchAgent: 'claude', viewMode: 'chat' }],
|
||||
'runtime-1'
|
||||
)
|
||||
expect(seededTabIds()).toEqual(['web-terminal-host-agent-tab'])
|
||||
})
|
||||
|
||||
it('seeds the launchAgent-stamped tab when the renderer owns startup', () => {
|
||||
setTabs([{ id: 'dev-server' }, { id: 'agent-tab', launchAgent: 'claude' }])
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ import { useAppStore } from '@/store'
|
||||
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
|
||||
import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery'
|
||||
import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery'
|
||||
import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { toWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
|
||||
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
|
||||
@@ -26,18 +31,69 @@ function resolveLaunchAgentTabId(
|
||||
backendSpawned: boolean
|
||||
}
|
||||
): string | null {
|
||||
const worktreeTabs = state.tabsByWorktree[args.worktreeId] ?? []
|
||||
// Main spawned the agent itself, so its startup tab is authoritative.
|
||||
if (args.backendSpawned && args.startupTerminalTabId) {
|
||||
return args.startupTerminalTabId
|
||||
return getRuntimeEnvironmentIdForWorktree(state, args.worktreeId)
|
||||
? toWebTerminalSurfaceTabId(args.startupTerminalTabId)
|
||||
: args.startupTerminalTabId
|
||||
}
|
||||
const worktreeTabs = state.tabsByWorktree[args.worktreeId] ?? []
|
||||
const stamped = worktreeTabs.find((tab) => tab.launchAgent === args.agent)?.id
|
||||
// Why: when the renderer owns startup, `ensureAgentStartupInTerminal` queues it
|
||||
// on primaryTabId, so that tab is the agent's tab by construction.
|
||||
return stamped ?? args.primaryTabId ?? args.startupTerminalTabId ?? null
|
||||
}
|
||||
|
||||
function applyAgentTabSeeds(request: SeedRequest, agent: TuiAgent, tabId: string): void {
|
||||
function applyBackendSpawnedDraftViewMode(args: {
|
||||
state: AppStoreSnapshot
|
||||
request: SeedRequest
|
||||
agent: TuiAgent
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
backendSpawned: boolean
|
||||
}): void {
|
||||
const { state, request, agent, tabId, worktreeId, backendSpawned } = args
|
||||
if (!backendSpawned || !request.launchDraftPrompt) {
|
||||
return
|
||||
}
|
||||
const desiredViewMode =
|
||||
decideInitialAgentTabViewMode({
|
||||
experimentalNativeChat: state.settings?.experimentalNativeChat,
|
||||
openAgentTabsInChatByDefault: state.settings?.openAgentTabsInChatByDefault,
|
||||
agent,
|
||||
promptDelivery: 'draft',
|
||||
launchDraftText: request.launchDraftPrompt,
|
||||
...(agent === 'grok'
|
||||
? {
|
||||
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(
|
||||
getConnectionIdFromState(state, worktreeId)
|
||||
)
|
||||
}
|
||||
: {})
|
||||
}) ?? 'terminal'
|
||||
const tab = state.unifiedTabsByWorktree?.[worktreeId]?.find((tab) => tab.id === tabId)
|
||||
if (!tab && getRuntimeEnvironmentIdForWorktree(state, worktreeId)) {
|
||||
void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) =>
|
||||
setWebRuntimeTabProps({ worktreeId, tabId, viewMode: desiredViewMode })
|
||||
)
|
||||
return
|
||||
}
|
||||
const currentViewMode = tab?.viewMode ?? 'terminal'
|
||||
if (currentViewMode !== desiredViewMode) {
|
||||
state.setTabViewMode(tabId, desiredViewMode)
|
||||
}
|
||||
}
|
||||
|
||||
function applyAgentTabSeeds(args: {
|
||||
state: AppStoreSnapshot
|
||||
request: SeedRequest
|
||||
agent: TuiAgent
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
backendSpawned: boolean
|
||||
}): void {
|
||||
const { request, agent, tabId } = args
|
||||
applyBackendSpawnedDraftViewMode(args)
|
||||
seedNativeChatAppliedSessionOptions(tabId, agent, request.startupPlan?.sessionOptions)
|
||||
// Why: draft launch context reaches only the TUI input; seed the
|
||||
// chat-composer copy so it isn't invisible in the chat view.
|
||||
@@ -57,7 +113,7 @@ export function seedAgentTabStateAfterWorktreeCreate(args: {
|
||||
startupTerminalTabId: string | null | undefined
|
||||
backendSpawned: boolean
|
||||
}): void {
|
||||
const { request, worktreeId } = args
|
||||
const { request, worktreeId, backendSpawned } = args
|
||||
const agent = request.agent
|
||||
if (!request.startupPlan || !agent) {
|
||||
return
|
||||
@@ -65,7 +121,7 @@ export function seedAgentTabStateAfterWorktreeCreate(args: {
|
||||
const state = useAppStore.getState()
|
||||
const tabId = resolveLaunchAgentTabId(state, { ...args, agent })
|
||||
if (tabId) {
|
||||
applyAgentTabSeeds(request, agent, tabId)
|
||||
applyAgentTabSeeds({ state, request, agent, tabId, worktreeId, backendSpawned })
|
||||
return
|
||||
}
|
||||
if ((state.tabsByWorktree[worktreeId] ?? []).length > 0) {
|
||||
@@ -81,7 +137,14 @@ export function seedAgentTabStateAfterWorktreeCreate(args: {
|
||||
deliver: (state, firstTerminalTabId) => {
|
||||
const mirroredTabId = resolveLaunchAgentTabId(state, { ...args, agent })
|
||||
if (mirroredTabId) {
|
||||
applyAgentTabSeeds(request, agent, mirroredTabId)
|
||||
applyAgentTabSeeds({
|
||||
state,
|
||||
request,
|
||||
agent,
|
||||
tabId: mirroredTabId,
|
||||
worktreeId,
|
||||
backendSpawned
|
||||
})
|
||||
return
|
||||
}
|
||||
// Why: same invariant as the synchronous branch — never seed a tab that
|
||||
@@ -89,7 +152,14 @@ export function seedAgentTabStateAfterWorktreeCreate(args: {
|
||||
// so accept the first mirrored tab only when it is the worktree's only
|
||||
// one and therefore unambiguously the agent's.
|
||||
if ((state.tabsByWorktree[worktreeId] ?? []).length === 1) {
|
||||
applyAgentTabSeeds(request, agent, firstTerminalTabId)
|
||||
applyAgentTabSeeds({
|
||||
state,
|
||||
request,
|
||||
agent,
|
||||
tabId: firstTerminalTabId,
|
||||
worktreeId,
|
||||
backendSpawned
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,7 +13,11 @@ const { prepareEphemeralVmWorkspaceTargetMock } = vi.hoisted(() => ({
|
||||
type TestActiveView = 'terminal' | 'tasks'
|
||||
|
||||
const store = {
|
||||
settings: { activeRuntimeEnvironmentId: null as string | null },
|
||||
settings: {
|
||||
activeRuntimeEnvironmentId: null as string | null,
|
||||
experimentalNativeChat: undefined as boolean | undefined,
|
||||
openAgentTabsInChatByDefault: undefined as boolean | undefined
|
||||
},
|
||||
activeView: 'terminal' as TestActiveView,
|
||||
activePendingCreationId: 'creation-1' as string | null,
|
||||
repos: [{ id: 'repo-runtime', connectionId: null }],
|
||||
@@ -41,7 +45,9 @@ const store = {
|
||||
setupProjectExistingFolder: vi.fn(),
|
||||
refreshRuntimeEnvironmentStatus: vi.fn(),
|
||||
seedNativeChatLaunchDraft: vi.fn(),
|
||||
tabsByWorktree: {} as Record<string, { id: string; launchAgent?: string }[]>
|
||||
setTabViewMode: vi.fn(),
|
||||
tabsByWorktree: {} as Record<string, { id: string; launchAgent?: string }[]>,
|
||||
unifiedTabsByWorktree: {}
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
@@ -94,12 +100,15 @@ const FLOW_SOURCE = readFileSync(join(__dirname, 'worktree-creation-flow.ts'), '
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
store.settings.activeRuntimeEnvironmentId = null
|
||||
store.settings.experimentalNativeChat = undefined
|
||||
store.settings.openAgentTabsInChatByDefault = undefined
|
||||
store.activeView = 'terminal'
|
||||
store.activePendingCreationId = 'creation-1'
|
||||
store.repos = []
|
||||
store.pendingWorktreeCreations = { 'creation-1': makePendingCreation(makeRequest()) }
|
||||
store.createWorktree.mockImplementation(() => new Promise(() => {}))
|
||||
store.tabsByWorktree = {}
|
||||
store.unifiedTabsByWorktree = {}
|
||||
vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1')
|
||||
})
|
||||
|
||||
@@ -697,6 +706,38 @@ describe('staged background worktree creation', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['mirrorable local Grok', 'grok', 'https://github.com/o/r/issues/12', 'chat'],
|
||||
['multi-line Claude', 'claude', 'note\nhttps://github.com/o/r/issues/12', 'terminal']
|
||||
] as const)('passes %s draft mode to backend startup', async (_label, agent, draft, viewMode) => {
|
||||
store.settings.experimentalNativeChat = true
|
||||
store.settings.openAgentTabsInChatByDefault = true
|
||||
store.repos = [{ id: 'repo-1', connectionId: null }]
|
||||
continueBackgroundWorktreeCreation(
|
||||
'creation-1',
|
||||
makeRequest({
|
||||
agent,
|
||||
startup: { command: `${agent} --prefill x`, launchAgent: agent },
|
||||
startupPlan: {
|
||||
agent,
|
||||
launchCommand: `${agent} --prefill x`,
|
||||
expectedProcess: agent,
|
||||
followupPrompt: null,
|
||||
launchConfig: { agentArgs: '', agentEnv: {} }
|
||||
},
|
||||
launchDraftPrompt: draft
|
||||
})
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(store.createWorktree).toHaveBeenCalled())
|
||||
const createCall = store.createWorktree.mock.calls[0] as unknown[] | undefined
|
||||
expect(createCall?.[16]).toEqual({
|
||||
command: `${agent} --prefill x`,
|
||||
launchAgent: agent,
|
||||
viewMode
|
||||
})
|
||||
})
|
||||
|
||||
it('carries launchDraftText into activation for an argv-prefill launch', async () => {
|
||||
// Why: the draft rides inside `launchCommand` here, so the plan sets no
|
||||
// draftPrompt — without launchDraftText the initial view-mode decision
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
} from '@/lib/pending-worktree-creation'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds'
|
||||
import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode'
|
||||
|
||||
type ContinueBackgroundWorktreeCreationOptions = {
|
||||
revealCreationSurface?: boolean
|
||||
@@ -141,6 +142,7 @@ async function executeWorktreeCreation(
|
||||
|
||||
let result: CreateWorktreeResult
|
||||
try {
|
||||
const backendStartup = resolveBackendDraftStartup(preparedRequest)
|
||||
result = await useAppStore
|
||||
.getState()
|
||||
.createWorktree(
|
||||
@@ -160,7 +162,7 @@ async function executeWorktreeCreation(
|
||||
preparedRequest.workspaceStatus,
|
||||
preparedRequest.linkedGitLabMR,
|
||||
preparedRequest.linkedGitLabIssue,
|
||||
preparedRequest.startup,
|
||||
backendStartup,
|
||||
preparedRequest.pendingFirstAgentMessageRename,
|
||||
creationId,
|
||||
preparedRequest.linkedLinearIssueWorkspaceId,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode'
|
||||
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
|
||||
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
|
||||
|
||||
export function resolveBackendDraftStartup(
|
||||
request: WorktreeCreationRequest
|
||||
): WorktreeCreationRequest['startup'] {
|
||||
if (!request.startup || !request.agent || !request.launchDraftPrompt) {
|
||||
return request.startup
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const repo = state.repos.find((entry) => entry.id === request.repoId)
|
||||
const connectionId = repo ? (repo.connectionId ?? null) : undefined
|
||||
const viewMode =
|
||||
decideInitialAgentTabViewMode({
|
||||
experimentalNativeChat: state.settings?.experimentalNativeChat,
|
||||
openAgentTabsInChatByDefault: state.settings?.openAgentTabsInChatByDefault,
|
||||
agent: request.agent,
|
||||
promptDelivery: 'draft',
|
||||
launchDraftText: request.launchDraftPrompt,
|
||||
...(request.agent === 'grok'
|
||||
? {
|
||||
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId)
|
||||
}
|
||||
: {})
|
||||
}) ?? 'terminal'
|
||||
return { ...request.startup, viewMode }
|
||||
}
|
||||
@@ -2148,6 +2148,7 @@ export type WorktreeStartupLaunch = {
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
viewMode?: 'terminal' | 'chat'
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user