Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions src/renderer/src/components/settings/AgentsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { cn } from '@/lib/utils'
import { AgentAwakeSetting } from './AgentAwakeSetting'
import { CreatedAgentReopenSetting } from './CreatedAgentReopenSetting'
import { AgentCacheTimerSection } from './AgentCacheTimerSection'
import { AgentRuntimeSetting } from './AgentRuntimeSetting'
import {
Expand Down Expand Up @@ -869,6 +870,8 @@ export function AgentsPane({

<AgentAwakeSetting settings={settings} updateSettings={updateSettings} />

<CreatedAgentReopenSetting settings={settings} updateSettings={updateSettings} />

<AgentCacheTimerSection settings={settings} updateSettings={updateSettings} />

<AgentPermissionsSetting mode={agentPermissionMode} onChange={saveAgentPermissionMode} />
Expand Down
43 changes: 43 additions & 0 deletions src/renderer/src/components/settings/CreatedAgentReopenSetting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { GlobalSettings } from '../../../../shared/types'
import {
getCreatedAgentReopenDescription,
getCreatedAgentReopenSearchKeywords,
getCreatedAgentReopenTitle
} from './created-agent-reopen-copy'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSwitchRow } from './SettingsFormControls'

type CreatedAgentReopenSettingProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}

export function CreatedAgentReopenSetting({
settings,
updateSettings
}: CreatedAgentReopenSettingProps): React.JSX.Element {
const title = getCreatedAgentReopenTitle()
const description = getCreatedAgentReopenDescription()
const enabled = settings.reopenWorkspacesWithCreatedAgent !== false

return (
<section className="space-y-3">
<SearchableSetting
title={title}
description={description}
keywords={getCreatedAgentReopenSearchKeywords()}
>
<SettingsSwitchRow
label={title}
description={description}
checked={enabled}
onChange={() =>
updateSettings({
reopenWorkspacesWithCreatedAgent: !enabled
})
}
/>
</SearchableSetting>
</section>
)
}
10 changes: 10 additions & 0 deletions src/renderer/src/components/settings/agents-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import {
getAgentAwakeSearchKeywords,
getAgentAwakeTitle
} from './agent-awake-copy'
import {
getCreatedAgentReopenDescription,
getCreatedAgentReopenSearchKeywords,
getCreatedAgentReopenTitle
} from './created-agent-reopen-copy'
import {
getAgentGeneratedTabTitlesDescription,
getAgentGeneratedTabTitlesSearchKeywords,
Expand Down Expand Up @@ -116,6 +121,11 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [
description: getAgentAwakeDescription(),
keywords: getAgentAwakeSearchKeywords()
},
{
title: getCreatedAgentReopenTitle(),
description: getCreatedAgentReopenDescription(),
keywords: getCreatedAgentReopenSearchKeywords()
},
{
title: translate(
'auto.components.settings.agents.search.agentPermissions',
Expand Down
29 changes: 29 additions & 0 deletions src/renderer/src/components/settings/created-agent-reopen-copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { translate } from '@/i18n/i18n'
import { searchKeywords } from './settings-search-keywords'

export function getCreatedAgentReopenTitle(): string {
return translate(
'auto.components.settings.created-agent-reopen-copy.title',
'Reopen workspaces with the agent they were created with'
)
}

export function getCreatedAgentReopenDescription(): string {
return translate(
'auto.components.settings.created-agent-reopen-copy.description',
'When a workspace was created with an agent and has no open tabs, reopening it from the sidebar launches that agent again. Turn this off to open a blank terminal instead.'
)
}

export function getCreatedAgentReopenSearchKeywords(): string[] {
return searchKeywords([
{ key: 'auto.components.settings.agents.search.96ba2373b6', fallback: 'agent' },
{ key: 'auto.components.settings.agents.search.reopen', fallback: 'reopen' },
{ key: 'auto.components.settings.agents.search.created', fallback: 'created' },
{ key: 'auto.components.settings.agents.search.workspace', fallback: 'workspace' },
{ key: 'auto.components.settings.agents.search.sidebar', fallback: 'sidebar' },
{ key: 'auto.components.settings.agents.search.blank', fallback: 'blank' },
{ key: 'auto.components.settings.agents.search.resume', fallback: 'resume' },
{ key: 'auto.components.settings.agents.search.relaunch', fallback: 'relaunch' }
])
}
3 changes: 2 additions & 1 deletion src/renderer/src/components/settings/setting-labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ export const SETTING_LABELS: Partial<Record<keyof GlobalSettings, string>> = {
terminalDividerColorDark: 'Divider Color (Dark)',
terminalDividerColorLight: 'Divider Color (Light)',
terminalInactivePaneOpacity: 'Inactive Pane Opacity',
windowBackgroundBlur: 'Window Background Blur'
windowBackgroundBlur: 'Window Background Blur',
reopenWorkspacesWithCreatedAgent: 'Reopen workspaces with the agent they were created with'
}
137 changes: 110 additions & 27 deletions src/renderer/src/lib/worktree-activation-created-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,53 +71,118 @@ describe('activateAndRevealWorktree', () => {
expect(recordWorktreeVisit).toHaveBeenCalledWith(worktree.id)
})

it('does not relaunch the creation-time agent when reopening an empty worktree', () => {
it('reopens an empty worktree with the agent selected at creation time', () => {
const worktree = makeWorktree()
const { revealWorktreeInSidebar } = seedEmptyActivatableWorktree(worktree)

const result = activateAndRevealWorktree(worktree.id)
const state = useAppStore.getState()
const reopenedTab = state.tabsByWorktree[worktree.id]?.[0]

// A focusable surface still appears — it is just a plain shell, with no queued agent launch.
expect(result).toEqual({ primaryTabId: reopenedTab?.id })
expect(reopenedTab).toBeDefined()
expect(state.pendingStartupByTabId[reopenedTab!.id]).toBeUndefined()
expect(state.pendingStartupByTabId[reopenedTab!.id]).toEqual({
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
env: {},
launchAgent: 'codex',
launchConfig: {
agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'",
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
agentEnv: {}
},
launchToken: expect.any(String),
sessionOptions: undefined,
telemetry: {
agent_kind: 'codex',
launch_source: 'sidebar',
request_kind: 'new'
}
})
expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id)
})

it('does not relaunch on repeated activate/close cycles', () => {
it('does not relaunch the creation agent when the reopen setting is off', () => {
const worktree = makeWorktree()
seedEmptyActivatableWorktree(worktree)
const { revealWorktreeInSidebar } = seedEmptyActivatableWorktree(worktree)
useAppStore.setState({
settings: {
...useAppStore.getState().settings,
reopenWorkspacesWithCreatedAgent: false
} as ReturnType<typeof useAppStore.getState>['settings']
})

for (let cycle = 0; cycle < 3; cycle += 1) {
activateAndExpectNoRelaunch(worktree.id)
const result = activateAndRevealWorktree(worktree.id)
const state = useAppStore.getState()
const reopenedTab = state.tabsByWorktree[worktree.id]?.[0]

// Return to zero tabs, the state that used to re-arm the relaunch. Sets state
// directly rather than via closeTab — the sleeping-record purge is covered in
// worktree-reactivation-tab-forkbomb.test.ts.
useAppStore.setState({ tabsByWorktree: {}, activeTabIdByWorktree: {} })
}
expect(result).toEqual({ primaryTabId: reopenedTab?.id })
expect(reopenedTab).toBeDefined()
// Blank shell — no agent startup payload from createdWithAgent.
expect(state.pendingStartupByTabId[reopenedTab!.id]).toBeUndefined()
expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id)
})

it('does not relaunch when activating a sibling worktree the user never opened', () => {
const sibling = makeWorktree()
const target = { ...makeWorktree(), id: 'wt-handoff', displayName: 'handoff' }
seedEmptyActivatableWorktree(target, { extraWorktrees: [sibling] })
it('uses WSL launch quoting when reopening a Windows-path WSL project agent', () => {
const worktree = {
...makeWorktree(),
path: 'C:\\Users\\jinwo\\repo\\feature'
}

// The shape post-delete focus handoff produces. That caller passes no opts at all —
// asserted directly in active-worktree-focus-after-delete.test.ts.
activateAndExpectNoRelaunch(target.id)
})
useAppStore.setState({
projects: [
{
id: 'repo-1',
displayName: 'repo',
badgeColor: '#000000',
sourceRepoIds: ['repo-1'],
createdAt: 0,
updatedAt: 0,
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
}
],
repos: [
{
id: 'repo-1',
path: 'C:\\Users\\jinwo\\repo',
displayName: 'repo',
badgeColor: '#000000',
addedAt: 0
}
],
worktreesByRepo: { 'repo-1': [worktree] },
activeRepoId: 'repo-1',
activeView: 'terminal',
tabsByWorktree: {},
unifiedTabsByWorktree: {},
groupsByWorktree: {},
layoutByWorktree: {},
activeGroupIdByWorktree: {},
openFiles: [],
browserTabsByWorktree: {},
activeFileIdByWorktree: {},
activeBrowserTabIdByWorktree: {},
activeTabTypeByWorktree: {},
activeTabIdByWorktree: {},
tabBarOrderByWorktree: {},
pendingStartupByTabId: {},
settings: {
agentCmdOverrides: {},
agentDefaultArgs: { codex: '--profile "don\'t"' },
setupScriptLaunchMode: 'new-tab'
} as unknown as ReturnType<typeof useAppStore.getState>['settings'],
markWorktreeVisited: vi.fn(),
recordWorktreeVisit: vi.fn(),
refreshGitHubForWorktreeIfStale: vi.fn(),
revealWorktreeInSidebar: vi.fn()
})

it('does not relaunch when activation opts carry no startup payload', () => {
const worktree = makeWorktree()
seedEmptyActivatableWorktree(worktree)
const result = activateAndRevealWorktree(worktree.id)
const state = useAppStore.getState()
const reopenedTab = state.tabsByWorktree[worktree.id]?.[0]

// The opts shape CLI/relay navigation and notification clicks arrive with; those
// callers are asserted in useIpcEvents.test.ts. The host's `didSpawnStartup` leg is
// a main-process concern and is not reachable from here.
activateAndExpectNoRelaunch(worktree.id, { notifyHostRuntime: false })
expect(result).toEqual({ primaryTabId: reopenedTab?.id })
expect(state.pendingStartupByTabId[reopenedTab!.id]?.command).toContain("'don'\\''t'")
expect(state.pendingStartupByTabId[reopenedTab!.id]?.command).not.toContain("'don''t'")
})

it('still queues an explicit startup supplied by the caller', () => {
Expand All @@ -136,6 +201,24 @@ describe('activateAndRevealWorktree', () => {
)
})

it('does not relaunch when the reopen setting is off across activate/close cycles', () => {
const worktree = makeWorktree()
seedEmptyActivatableWorktree(worktree)
useAppStore.setState({
settings: {
...useAppStore.getState().settings,
reopenWorkspacesWithCreatedAgent: false
} as ReturnType<typeof useAppStore.getState>['settings']
})

for (let cycle = 0; cycle < 3; cycle += 1) {
activateAndExpectNoRelaunch(worktree.id)

// Return to zero tabs — opt-out must stay blank even after repeated empty reopens.
useAppStore.setState({ tabsByWorktree: {}, activeTabIdByWorktree: {} })
}
})

it('does not duplicate a sleeping agent session owned by a preserved slept pane', () => {
const worktree = makeWorktree()
const revealWorktreeInSidebar = vi.fn()
Expand Down
Loading