diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts index 79d58cbee..75d9ea474 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts @@ -183,7 +183,10 @@ export function resolveDisabledCreatePrHeaderAction( { value0: copy.reviewLabel } ) break - default: + case 'existing_review': + case 'fork_head_unsupported': + case 'unsupported_provider': + case null: title = translate( 'auto.components.right.sidebar.source.control.primary.action.f0c6e2a581', 'This branch is not ready for a {{value0}} yet.', diff --git a/src/renderer/src/components/right-sidebar/source-control-pull-policy-error-notice.tsx b/src/renderer/src/components/right-sidebar/source-control-pull-policy-error-notice.tsx index f20d726c7..a0b6f40ef 100644 --- a/src/renderer/src/components/right-sidebar/source-control-pull-policy-error-notice.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-pull-policy-error-notice.tsx @@ -3,23 +3,30 @@ import { useCallback, useEffect, useState } from 'react' import { Check, Copy, TriangleAlert } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' const PULL_POLICY_ERROR_PREFIX = 'Pull needs a Git pull policy for divergent branches.' const PULL_POLICY_OPTIONS = [ { - label: 'Merge', - description: 'Create a merge commit when local and remote both changed.', + labelKey: 'auto.components.right.sidebar.pull.policy.notice.merge', + labelFallback: 'Merge', + descriptionKey: 'auto.components.right.sidebar.pull.policy.notice.mergeDescription', + descriptionFallback: 'Create a merge commit when local and remote both changed.', command: 'git config pull.rebase false' }, { - label: 'Rebase', - description: 'Replay local commits on top of the remote branch.', + labelKey: 'auto.components.right.sidebar.pull.policy.notice.rebase', + labelFallback: 'Rebase', + descriptionKey: 'auto.components.right.sidebar.pull.policy.notice.rebaseDescription', + descriptionFallback: 'Replay local commits on top of the remote branch.', command: 'git config pull.rebase true' }, { - label: 'Fast-forward only', - description: 'Only pull when no merge or rebase is needed.', + labelKey: 'auto.components.right.sidebar.pull.policy.notice.fastForwardOnly', + labelFallback: 'Fast-forward only', + descriptionKey: 'auto.components.right.sidebar.pull.policy.notice.fastForwardOnlyDescription', + descriptionFallback: 'Only pull when no merge or rebase is needed.', command: 'git config pull.ff only' } ] as const @@ -65,20 +72,29 @@ export function PullPolicyRemoteActionNotice({
- Pull needs a policy + + {translate( + 'auto.components.right.sidebar.pull.policy.notice.title', + 'Pull needs a policy' + )} + - Diverged + {translate('auto.components.right.sidebar.pull.policy.notice.diverged', 'Diverged')}

- This branch has local and remote commits. Run one command in this worktree or on the - SSH host, then try Pull or Sync again. + {translate( + 'auto.components.right.sidebar.pull.policy.notice.body', + 'This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.' + )}

{PULL_POLICY_OPTIONS.map((option) => { const copied = copiedCommand === option.command + const label = translate(option.labelKey, option.labelFallback) + const description = translate(option.descriptionKey, option.descriptionFallback) return (
- {option.label} + {label}
-

- {option.description} -

+

{description}

@@ -100,7 +114,11 @@ export function PullPolicyRemoteActionNotice({ variant="ghost" size="icon-xs" className="mt-0.5 shrink-0" - aria-label={`Copy ${option.label.toLowerCase()} pull policy command`} + aria-label={translate( + 'auto.components.right.sidebar.pull.policy.notice.copyAria', + 'Copy {{value0}} pull policy command', + { value0: label.toLowerCase() } + )} onClick={() => handleCopyCommand(option.command)} > {copied ? ( @@ -111,7 +129,15 @@ export function PullPolicyRemoteActionNotice({ - {copied ? 'Copied' : 'Copy command'} + {copied + ? translate( + 'auto.components.right.sidebar.pull.policy.notice.copied', + 'Copied' + ) + : translate( + 'auto.components.right.sidebar.pull.policy.notice.copyCommand', + 'Copy command' + )}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 016f2b323..7890c4397 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8736,7 +8736,19 @@ "c39d0c75c3": "Linked review branch target is unavailable.", "8c6d15a07d": "Create PR", "d37e68f61d": "Preparing branch for review…", - "c72e5e65d1": "Prepare this branch and create a {{value0}}" + "c72e5e65d1": "Prepare this branch and create a {{value0}}", + "b8e4f2a901": "Wait for the remote operation to finish.", + "c9f3a1b802": "Resolve conflicts before creating a {{value0}}.", + "d2a8c4e703": "No changes on this branch to include in a {{value0}}.", + "e3b9d5f814": "Cannot create a {{value0}} from the default branch.", + "f4c0e6a925": "Commit changes before creating a {{value0}}.", + "a5d1f7b036": "Publish commits before creating a {{value0}}.", + "b6e2a8c147": "Push commits before creating a {{value0}}.", + "c7f3b9d258": "Sync this branch before creating a {{value0}}.", + "d8a4c0e369": "Authenticate before creating a {{value0}}.", + "e9b5d1f470": "Check out a branch before creating a {{value0}}.", + "f0c6e2a581": "This branch is not ready for a {{value0}} yet.", + "h3i4j5k607": "Checking whether this branch can create a {{value0}}…" } } } @@ -8911,6 +8923,24 @@ "8c2d5fab13": "Copy commit hash", "9d3e60bc24": "Copy commit message", "ae4f71cd35": "Explain changes" + }, + "pull": { + "policy": { + "notice": { + "merge": "Merge", + "mergeDescription": "Create a merge commit when local and remote both changed.", + "rebase": "Rebase", + "rebaseDescription": "Replay local commits on top of the remote branch.", + "fastForwardOnly": "Fast-forward only", + "fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.", + "title": "Pull needs a policy", + "diverged": "Diverged", + "body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.", + "copyAria": "Copy {{value0}} pull policy command", + "copied": "Copied", + "copyCommand": "Copy command" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 69a3c1c10..3111229e0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8736,7 +8736,19 @@ "c39d0c75c3": "El destino de la rama de revisión vinculada no está disponible.", "8c6d15a07d": "Crear PR", "d37e68f61d": "Preparando la rama para revisión…", - "c72e5e65d1": "Prepara esta rama y crea un {{value0}}" + "c72e5e65d1": "Prepara esta rama y crea un {{value0}}", + "b8e4f2a901": "Wait for the remote operation to finish.", + "c9f3a1b802": "Resolve conflicts before creating a {{value0}}.", + "d2a8c4e703": "No changes on this branch to include in a {{value0}}.", + "e3b9d5f814": "Cannot create a {{value0}} from the default branch.", + "f4c0e6a925": "Commit changes before creating a {{value0}}.", + "a5d1f7b036": "Publish commits before creating a {{value0}}.", + "b6e2a8c147": "Push commits before creating a {{value0}}.", + "c7f3b9d258": "Sync this branch before creating a {{value0}}.", + "d8a4c0e369": "Authenticate before creating a {{value0}}.", + "e9b5d1f470": "Check out a branch before creating a {{value0}}.", + "f0c6e2a581": "This branch is not ready for a {{value0}} yet.", + "h3i4j5k607": "Checking whether this branch can create a {{value0}}…" } } } @@ -8911,6 +8923,24 @@ "8c2d5fab13": "Copiar hash del commit", "9d3e60bc24": "Copiar mensaje del commit", "ae4f71cd35": "Explicar los cambios" + }, + "pull": { + "policy": { + "notice": { + "merge": "Merge", + "mergeDescription": "Create a merge commit when local and remote both changed.", + "rebase": "Rebase", + "rebaseDescription": "Replay local commits on top of the remote branch.", + "fastForwardOnly": "Fast-forward only", + "fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.", + "title": "Pull needs a policy", + "diverged": "Diverged", + "body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.", + "copyAria": "Copy {{value0}} pull policy command", + "copied": "Copied", + "copyCommand": "Copy command" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 930a0b270..8733797b5 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8736,7 +8736,19 @@ "c39d0c75c3": "リンクされたレビュー ブランチのターゲットを利用できません。", "8c6d15a07d": "PR を作成", "d37e68f61d": "レビュー用にブランチを準備中…", - "c72e5e65d1": "このブランチを準備して {{value0}} を作成" + "c72e5e65d1": "このブランチを準備して {{value0}} を作成", + "b8e4f2a901": "Wait for the remote operation to finish.", + "c9f3a1b802": "Resolve conflicts before creating a {{value0}}.", + "d2a8c4e703": "No changes on this branch to include in a {{value0}}.", + "e3b9d5f814": "Cannot create a {{value0}} from the default branch.", + "f4c0e6a925": "Commit changes before creating a {{value0}}.", + "a5d1f7b036": "Publish commits before creating a {{value0}}.", + "b6e2a8c147": "Push commits before creating a {{value0}}.", + "c7f3b9d258": "Sync this branch before creating a {{value0}}.", + "d8a4c0e369": "Authenticate before creating a {{value0}}.", + "e9b5d1f470": "Check out a branch before creating a {{value0}}.", + "f0c6e2a581": "This branch is not ready for a {{value0}} yet.", + "h3i4j5k607": "Checking whether this branch can create a {{value0}}…" } } } @@ -8911,6 +8923,24 @@ "8c2d5fab13": "コミットハッシュをコピー", "9d3e60bc24": "コミットメッセージをコピー", "ae4f71cd35": "変更を説明" + }, + "pull": { + "policy": { + "notice": { + "merge": "Merge", + "mergeDescription": "Create a merge commit when local and remote both changed.", + "rebase": "Rebase", + "rebaseDescription": "Replay local commits on top of the remote branch.", + "fastForwardOnly": "Fast-forward only", + "fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.", + "title": "Pull needs a policy", + "diverged": "Diverged", + "body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.", + "copyAria": "Copy {{value0}} pull policy command", + "copied": "Copied", + "copyCommand": "Copy command" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7986a8e9d..fd3c67ad4 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8736,7 +8736,19 @@ "c39d0c75c3": "연결된 검토 브랜치 대상을 사용할 수 없습니다.", "8c6d15a07d": "PR 생성", "d37e68f61d": "검토를 위해 브랜치를 준비하는 중…", - "c72e5e65d1": "이 브랜치를 준비하고 {{value0}} 생성" + "c72e5e65d1": "이 브랜치를 준비하고 {{value0}} 생성", + "b8e4f2a901": "Wait for the remote operation to finish.", + "c9f3a1b802": "Resolve conflicts before creating a {{value0}}.", + "d2a8c4e703": "No changes on this branch to include in a {{value0}}.", + "e3b9d5f814": "Cannot create a {{value0}} from the default branch.", + "f4c0e6a925": "Commit changes before creating a {{value0}}.", + "a5d1f7b036": "Publish commits before creating a {{value0}}.", + "b6e2a8c147": "Push commits before creating a {{value0}}.", + "c7f3b9d258": "Sync this branch before creating a {{value0}}.", + "d8a4c0e369": "Authenticate before creating a {{value0}}.", + "e9b5d1f470": "Check out a branch before creating a {{value0}}.", + "f0c6e2a581": "This branch is not ready for a {{value0}} yet.", + "h3i4j5k607": "Checking whether this branch can create a {{value0}}…" } } } @@ -8911,6 +8923,24 @@ "8c2d5fab13": "커밋 해시 복사", "9d3e60bc24": "커밋 메시지 복사", "ae4f71cd35": "변경 사항 설명" + }, + "pull": { + "policy": { + "notice": { + "merge": "Merge", + "mergeDescription": "Create a merge commit when local and remote both changed.", + "rebase": "Rebase", + "rebaseDescription": "Replay local commits on top of the remote branch.", + "fastForwardOnly": "Fast-forward only", + "fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.", + "title": "Pull needs a policy", + "diverged": "Diverged", + "body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.", + "copyAria": "Copy {{value0}} pull policy command", + "copied": "Copied", + "copyCommand": "Copy command" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b446cb3c5..09099aa45 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8736,7 +8736,19 @@ "c39d0c75c3": "已关联的评审分支目标不可用。", "8c6d15a07d": "创建 PR", "d37e68f61d": "正在准备分支以供评审…", - "c72e5e65d1": "准备此分支并创建 {{value0}}" + "c72e5e65d1": "准备此分支并创建 {{value0}}", + "b8e4f2a901": "Wait for the remote operation to finish.", + "c9f3a1b802": "Resolve conflicts before creating a {{value0}}.", + "d2a8c4e703": "No changes on this branch to include in a {{value0}}.", + "e3b9d5f814": "Cannot create a {{value0}} from the default branch.", + "f4c0e6a925": "Commit changes before creating a {{value0}}.", + "a5d1f7b036": "Publish commits before creating a {{value0}}.", + "b6e2a8c147": "Push commits before creating a {{value0}}.", + "c7f3b9d258": "Sync this branch before creating a {{value0}}.", + "d8a4c0e369": "Authenticate before creating a {{value0}}.", + "e9b5d1f470": "Check out a branch before creating a {{value0}}.", + "f0c6e2a581": "This branch is not ready for a {{value0}} yet.", + "h3i4j5k607": "Checking whether this branch can create a {{value0}}…" } } } @@ -8911,6 +8923,24 @@ "8c2d5fab13": "复制提交哈希", "9d3e60bc24": "复制提交信息", "ae4f71cd35": "解释更改" + }, + "pull": { + "policy": { + "notice": { + "merge": "Merge", + "mergeDescription": "Create a merge commit when local and remote both changed.", + "rebase": "Rebase", + "rebaseDescription": "Replay local commits on top of the remote branch.", + "fastForwardOnly": "Fast-forward only", + "fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.", + "title": "Pull needs a policy", + "diverged": "Diverged", + "body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.", + "copyAria": "Copy {{value0}} pull policy command", + "copied": "Copied", + "copyCommand": "Copy command" + } + } } } }, diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts index 3ae6f8e37..1d3913d94 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts @@ -58,6 +58,20 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) }) + it('skips live-checkpoint records — their restored pane owns recovery', () => { + const record = makeRecord({ origin: 'live' }) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(0) + expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) + }) + it('resumes legacy sleep records without an origin even when their tab still exists', () => { const record = makeRecord() useAppStore.setState({ diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 2128eb078..8dc7e99cf 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -81,11 +81,10 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): number { const records = Object.values(useAppStore.getState().sleepingAgentSessionsByPaneKey) .filter((record) => record.worktreeId === worktreeId) - // Why: quit-time captures (#5232) cover panes that still exist in the - // restored session. Those panes own their own recovery — warm reattach - // when the daemon kept the agent alive, or the pane-level cold-restore - // resume — so launching a separate tab here would duplicate the session. - .filter((record) => record.origin !== 'quit') + // Why: pane-owned captures (#5232/#5626) cover panes that still exist in + // the restored session. Those panes own their own recovery — warm reattach + // when the daemon kept the agent alive, or pane-level cold-restore resume. + .filter((record) => record.origin !== 'quit' && record.origin !== 'live') .sort((a, b) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt) let launched = 0 diff --git a/src/renderer/src/lib/session-write-subscriber.test.ts b/src/renderer/src/lib/session-write-subscriber.test.ts index 98135b398..703c54058 100644 --- a/src/renderer/src/lib/session-write-subscriber.test.ts +++ b/src/renderer/src/lib/session-write-subscriber.test.ts @@ -135,6 +135,58 @@ describe('createSessionWriteSubscriber', () => { cleanup() }) + it('writes a live agent recovery checkpoint when provider session metadata arrives', () => { + const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() + const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) + + useAppStore.setState({ + workspaceSessionReady: true, + hydrationSucceeded: true, + tabsByWorktree: { + 'wt-1': [ + { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } as never) + vi.advanceTimersByTime(200) + persist.mockClear() + + useAppStore.getState().setAgentStatus( + 'tab-1:leaf-1', + { + state: 'working', + prompt: 'Fix tests', + agentType: 'codex' + }, + 'Codex', + { updatedAt: 10, stateStartedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession: { key: 'session_id', id: 'codex-session-1' } } + ) + vi.advanceTimersByTime(200) + + expect(persist).toHaveBeenCalledWith({ + patch: { + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf-1': expect.objectContaining({ + providerSession: { key: 'session_id', id: 'codex-session-1' }, + origin: 'live' + }) + } + } + }) + cleanup() + }) + it('writes exactly once when a relevant field changes', () => { const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) diff --git a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts index a49fd479d..97addb030 100644 --- a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts +++ b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts @@ -24,6 +24,112 @@ function makeAgentEntry(overrides: { } describe('captureAllSleepingAgentSessions', () => { + it('checkpoints a live resumable provider session before quit-time capture', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) + + store.getState().setAgentStatus( + 'tab-1:leaf-1', + { + state: 'working', + prompt: 'finish the task', + agentType: 'codex' + }, + 'Codex', + { updatedAt: 10, stateStartedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession: { key: 'session_id', id: 'codex-session-1' } } + ) + + // Why: Windows update/reboot exits can miss beforeunload; the provider + // session handle must already be durable for pane-level cold restore. + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({ + agent: 'codex', + worktreeId: 'wt-1', + tabId: 'tab-1', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + origin: 'live' + }) + }) + + it('does not rewrite the live checkpoint for same-session status ticks', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) + + store.getState().setAgentStatus( + 'tab-1:leaf-1', + { + state: 'working', + prompt: 'first prompt', + agentType: 'codex' + }, + 'Codex', + { updatedAt: 10, stateStartedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession: { key: 'session_id', id: 'codex-session-1' } } + ) + const firstRecord = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1'] + + store.getState().setAgentStatus( + 'tab-1:leaf-1', + { + state: 'working', + prompt: 'second prompt', + agentType: 'codex' + }, + 'Codex', + { updatedAt: 20, stateStartedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession: { key: 'session_id', id: 'codex-session-1' } } + ) + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(firstRecord) + }) + + it('clears the live checkpoint when the agent finishes', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) + + store.getState().setAgentStatus( + 'tab-1:leaf-1', + { + state: 'working', + prompt: 'finish the task', + agentType: 'codex' + }, + 'Codex', + { updatedAt: 10, stateStartedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession: { key: 'session_id', id: 'codex-session-1' } } + ) + store.getState().setAgentStatus( + 'tab-1:leaf-1', + { + state: 'done', + prompt: 'finish the task', + agentType: 'codex' + }, + 'Codex', + { updatedAt: 20, stateStartedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession: { key: 'session_id', id: 'codex-session-1' } } + ) + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeUndefined() + }) + it('captures resumable agents across every worktree, not just one', () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index c484bcb2c..0fd7871c9 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -274,6 +274,23 @@ export function collectSleepingAgentSessionRecordsForWorktree( return records } +function recoveryRecordMatches( + existing: SleepingAgentSessionRecord | undefined, + next: SleepingAgentSessionRecord +): boolean { + if (!existing) { + return false + } + return ( + existing.origin === next.origin && + existing.agent === next.agent && + existing.worktreeId === next.worktreeId && + existing.tabId === next.tabId && + existing.providerSession.key === next.providerSession.key && + existing.providerSession.id === next.providerSession.id + ) +} + function pruneMigrationUnsupportedEntries( entries: Record, predicate: (entry: MigrationUnsupportedPtyEntry) => boolean @@ -611,11 +628,30 @@ export const createAgentStatusSlice: StateCreator entry.paneKey === paneKey ) - const hasSleepingRecord = paneKey in s.sleepingAgentSessionsByPaneKey - const nextSleepingAgentSessions = hasSleepingRecord - ? { ...s.sleepingAgentSessionsByPaneKey } - : s.sleepingAgentSessionsByPaneKey - if (hasSleepingRecord) { + const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] + const liveRecoveryWorktreeId = + entry.state === 'done' + ? null + : (entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey)) + const liveRecoveryRecord = liveRecoveryWorktreeId + ? sleepingRecordFromEntry({ + state: s, + entry, + worktreeId: liveRecoveryWorktreeId, + capturedAt: updatedAt, + origin: 'live' + }) + : null + let nextSleepingAgentSessions = s.sleepingAgentSessionsByPaneKey + if (liveRecoveryRecord) { + if (!recoveryRecordMatches(existingSleepingRecord, liveRecoveryRecord)) { + nextSleepingAgentSessions = { + ...s.sleepingAgentSessionsByPaneKey, + [paneKey]: liveRecoveryRecord + } + } + } else if (existingSleepingRecord) { + nextSleepingAgentSessions = { ...s.sleepingAgentSessionsByPaneKey } delete nextSleepingAgentSessions[paneKey] } return { diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index f5ba56529..d092a20aa 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -36,10 +36,10 @@ export type SleepingAgentSessionRecord = { connectionId?: string | null /** How the record was captured. Worktree-sleep records (legacy records have * no origin) are consumed by worktree activation, which opens a fresh tab. - * Quit records describe panes that still exist in the restored session, so - * only the pane's own cold-restore path may consume them — activation + * Quit/live records describe panes that still exist in the restored session, + * so only the pane's own cold-restore path may consume them — activation * launching a tab too would duplicate a warm-reattached session (#5232). */ - origin?: 'worktree-sleep' | 'quit' + origin?: 'worktree-sleep' | 'quit' | 'live' } const RESUMABLE_TUI_AGENT_SET: ReadonlySet = new Set(RESUMABLE_TUI_AGENTS) diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 879a996ab..800b28e0b 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -126,13 +126,15 @@ describe('parseWorkspaceSession', () => { capturedAt: 10, updatedAt: 9, terminalTitle: 'Codex', - lastAssistantMessage: 'done' + lastAssistantMessage: 'done', + origin: 'live' } } }) expect(result.ok).toBe(true) if (result.ok) { expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.agent).toBe('codex') + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.origin).toBe('live') } }) diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index b21c2a801..a8556f180 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -109,7 +109,8 @@ const sleepingAgentSessionRecordSchema = z.object({ updatedAt: z.number().finite().positive(), terminalTitle: z.string().optional(), lastAssistantMessage: z.string().optional(), - connectionId: z.string().nullable().optional() + connectionId: z.string().nullable().optional(), + origin: z.enum(['worktree-sleep', 'quit', 'live']).optional() }) const sleepingAgentSessionsByPaneKeySchema = z.preprocess((raw) => {