fix(claude-accounts): restart System default sessions on the outgoing login when switching (#33793)

* fix(claude-accounts): restart System default sessions on the outgoing login when switching

A Claude account switch only restarted sessions in the outgoing account's
own folder. A session on System default (~/.claude) signed in to that
account's exact login, as older Orca's copied login leaves it, was skipped
silently. Match it by email and organization, read by main at switch time;
never compare managed folders by login. Sessions in the user's own
CLAUDE_CONFIG_DIR are no longer treated as System default, and legacy
accounts match their current launch folder.

The ask dialog groups sessions into Running on X, Not on X and Can't move,
and the Always restart toast names sessions it left and offers Review.

* refactor(claude-accounts): simplify the switch login match, scan and toast styling

Inline the single-use login adapters, carry each pane's foreground result with
its row, keep the header-pin listener local to its effect, move the inline
toast action classes to the Sonner module, and stop the Review dialog
promising a restart for sessions that cannot move.
This commit is contained in:
Jinwoo Hong
2026-10-26 14:32:08 -04:00
committed by GitHub
parent bc48268616
commit 916c6a09b5
20 changed files with 950 additions and 359 deletions
@@ -47,7 +47,11 @@ export type ClaudeAccountRuntime = Pick<
> & {
router: Pick<
ClaudeRuntimeAuthService['router'],
'accountHome' | 'userConfigDir' | 'copiedLoginIntoSystemDefault' | 'coveredBySystemDefault'
| 'accountHome'
| 'userConfigDir'
| 'systemDefaultHome'
| 'copiedLoginIntoSystemDefault'
| 'coveredBySystemDefault'
>
}
@@ -85,7 +89,13 @@ export class ClaudeAccountSelection {
accounts,
activeAccountId: selection.host,
activeAccountIdsByRuntime: selection,
...(accounts.length > 0
? { systemDefaultHome: this.runtimeAuth.router.systemDefaultHome() }
: {}),
...(systemDefault ? { systemDefaultEmail: systemDefault.email } : {}),
...(systemDefault?.organizationUuid
? { systemDefaultOrganizationUuid: systemDefault.organizationUuid }
: {}),
...(systemDefault && this.runtimeAuth.router.copiedLoginIntoSystemDefault()
? { systemDefaultMayBeCopied: true }
: {})
@@ -98,12 +108,12 @@ export class ClaudeAccountSelection {
// Avoid starting stopped distros; only legacy layouts are known to need sign-in.
return savedWslClaudeAccountHome(account) ? summary : { ...summary, needsSignIn: true }
}
const login = readClaudeFolderLogin(
claudeStateFile(this.runtimeAuth.router.accountHome(account.id))
)
const home = this.runtimeAuth.router.accountHome(account.id)
const login = readClaudeFolderLogin(claudeStateFile(home))
if (login) {
return {
...summary,
home,
email: login.email,
organizationUuid: login.organizationUuid,
organizationName: login.organizationName
@@ -111,8 +121,8 @@ export class ClaudeAccountSelection {
}
// Allow System default to supply the login until the account signs in separately.
return this.runtimeAuth.router.coveredBySystemDefault(account.id)
? { ...summary, runsOnSystemDefault: true }
: { ...summary, needsSignIn: true }
? { ...summary, home, runsOnSystemDefault: true }
: { ...summary, home, needsSignIn: true }
}
private savedSummary(account: ClaudeManagedAccount): ClaudeManagedAccountSummary {
@@ -55,6 +55,7 @@ function fixture(accounts: ClaudeManagedAccount[] = [account('a'), account('b')]
router: {
accountHome: home,
userConfigDir: () => join(root, 'personal'),
systemDefaultHome: () => join(root, 'personal'),
copiedLoginIntoSystemDefault: () => existsSync(join(root, 'claude-runtime-auth')),
coveredBySystemDefault: (id: string) => covered.has(id)
},
@@ -142,10 +143,14 @@ describe('ClaudeAccountService', () => {
mkdirSync(join(f.root, 'personal'))
writeFileSync(
join(f.root, 'personal', '.claude.json'),
JSON.stringify({ oauthAccount: { emailAddress: 'me@example.test' } })
JSON.stringify({
oauthAccount: { emailAddress: 'me@example.test', organizationUuid: 'me-org' }
})
)
expect(f.service.listAccounts()).toMatchObject({
systemDefaultEmail: 'me@example.test'
systemDefaultEmail: 'me@example.test',
systemDefaultOrganizationUuid: 'me-org',
systemDefaultHome: join(f.root, 'personal')
})
expect(f.service.listAccounts()).not.toHaveProperty('userClaudeConfigDir')
expect(f.service.listAccounts().systemDefaultMayBeCopied).toBeUndefined()
@@ -4,7 +4,7 @@ import { act, cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { findMovableClaudeSessions } from '@/lib/claude-session-account-move'
import { findClaudeSwitchSessions } from '@/lib/claude-session-account-move'
import {
handleRunningClaudeSessionsAfterSwitch,
resetClaudeAccountSwitchSessionsForTests
@@ -14,7 +14,7 @@ import type * as AccountMove from '@/lib/claude-session-account-move'
vi.mock('@/lib/claude-session-account-move', async (importOriginal) => ({
...(await importOriginal<typeof AccountMove>()),
findMovableClaudeSessions: vi.fn(),
findClaudeSwitchSessions: vi.fn(),
findQueuedClaudeRestartsOnAccount: () => [],
isClaudePaneMidTurn: () => false
}))
@@ -38,9 +38,11 @@ const ROSTER = { accounts: [row('account-a'), row('account-b')] }
const updateSettings = vi.fn(async () => {})
async function switchWith(count: number, from = 'account-a', to = 'account-b'): Promise<void> {
vi.mocked(findMovableClaudeSessions).mockResolvedValue(
Array.from({ length: count }, (_, index) => candidate(index + 1))
)
vi.mocked(findClaudeSwitchSessions).mockResolvedValue({
onAccount: Array.from({ length: count }, (_, index) => candidate(index + 1)),
elsewhere: [],
cantMove: []
})
await act(async () => {
await handleRunningClaudeSessionsAfterSwitch({
owner: { kind: 'local' },
@@ -171,6 +173,36 @@ describe('ClaudeAccountSwitchDialog', () => {
expect(screen.getByRole('button', { name: 'Restart 3 sessions' })).toBeTruthy()
})
it('ticks only the outgoing account, ticks a group from its header, and lists unmovable sessions without boxes', async () => {
const user = userEvent.setup()
vi.mocked(findClaudeSwitchSessions).mockResolvedValue({
onAccount: [candidate(1)],
elsewhere: [{ ...candidate(2), accountId: null }],
cantMove: [{ ...candidate(3), reason: 'own-folder' }]
})
render(<ClaudeAccountSwitchDialog />)
await act(async () => {
await handleRunningClaudeSessionsAfterSwitch({
owner: { kind: 'local' },
target: { runtime: 'host' },
before: { state: ROSTER, accountId: 'account-a' },
after: { state: ROSTER, accountId: 'account-b' }
})
})
expect(screen.getByText('Uses your own Claude folder')).toBeTruthy()
expect(screen.getAllByRole('checkbox', { name: 'Claude session' })).toHaveLength(2)
expect(screen.getByRole('button', { name: 'Restart session' })).toBeTruthy()
await user.click(
screen.getByRole('checkbox', {
name: 'Select all sessions in Not on account-a@example.com'
})
)
await user.click(screen.getByRole('button', { name: 'Restart 2 sessions' }))
expect(queued()).toEqual({ 'pty-1': 'claude', 'pty-2': 'claude' })
})
it('shows no session list for a single session', async () => {
render(<ClaudeAccountSwitchDialog />)
await switchWith(1)
@@ -31,28 +31,26 @@ export default function ClaudeAccountSwitchDialog(): React.JSX.Element | null {
function SwitchPromptDialog({ prompt }: { prompt: ClaudeAccountSwitchPrompt }): React.JSX.Element {
const [dontAskAgain, setDontAskAgain] = useState(false)
// Track exclusions because every offered session starts selected.
const [unticked, setUnticked] = useState<ReadonlySet<string>>(() => new Set())
const count = prompt.sessions.length
const chosen = prompt.sessions
const { onAccount, elsewhere, cantMove } = prompt.sessions
// Only sessions on the outgoing account start ticked.
const [ticked, setTicked] = useState<ReadonlySet<string>>(
() => new Set(onAccount.map((session) => session.ptyId))
)
const listedCount = onAccount.length + elsewhere.length + cantMove.length
const chosen = [...onAccount, ...elsewhere]
.map((session) => session.ptyId)
.filter((ptyId) => !unticked.has(ptyId))
.filter((ptyId) => ticked.has(ptyId))
const toggle = (ptyId: string, checked: boolean): void => {
setUnticked((current) => {
setTicked((current) => {
const next = new Set(current)
if (checked) {
next.delete(ptyId)
} else {
next.add(ptyId)
} else {
next.delete(ptyId)
}
return next
})
}
const accounts = {
count,
previous: prompt.previousAccountLabel,
next: prompt.nextAccountLabel
}
return (
<Dialog
open
@@ -64,7 +62,7 @@ function SwitchPromptDialog({ prompt }: { prompt: ClaudeAccountSwitchPrompt }):
}}
>
<DialogContent
data-session-list={count > 1}
data-session-list={listedCount > 1}
className="sm:max-w-md sm:data-[session-list=true]:max-w-lg"
showCloseButton={false}
>
@@ -74,48 +72,40 @@ function SwitchPromptDialog({ prompt }: { prompt: ClaudeAccountSwitchPrompt }):
account: prompt.nextAccountLabel
})}
</DialogTitle>
<DialogDescription>
{count === 1
? translate(
'accounts.claude.switchSessions.bodyOne',
'1 Claude session is still running on {{previous}}. Restarting continues the conversation on {{next}}. Busy sessions wait for idle before restarting.',
accounts
)
: translate(
'accounts.claude.switchSessions.bodyOther',
'{{count}} Claude sessions are still running on {{previous}}. Restarting continues each conversation on {{next}}. Busy sessions wait for idle before restarting.',
accounts
)}
</DialogDescription>
<DialogDescription>{promptDescription(prompt)}</DialogDescription>
</DialogHeader>
{count > 1 && (
{(listedCount > 1 || prompt.review) && (
<ClaudeAccountSwitchSessionTree
sessions={prompt.sessions}
selected={new Set(chosen)}
previousAccountLabel={prompt.previousAccountLabel}
selected={ticked}
onToggle={toggle}
/>
)}
<label className="flex min-w-0 items-start gap-2.5">
<Checkbox
checked={dontAskAgain}
onCheckedChange={(checked) => setDontAskAgain(checked === true)}
className="mt-0.5"
/>
<span className="min-w-0 space-y-0.5">
<span className="block text-sm">
{translate(
'accounts.claude.switchSessions.dontAskAgain',
"Don't ask again (restart automatically)"
)}
{/* Why hidden on review: restarting automatically is already the setting. */}
{!prompt.review && (
<label className="flex min-w-0 items-start gap-2.5">
<Checkbox
checked={dontAskAgain}
onCheckedChange={(checked) => setDontAskAgain(checked === true)}
className="mt-0.5"
/>
<span className="min-w-0 space-y-0.5">
<span className="block text-sm">
{translate(
'accounts.claude.switchSessions.dontAskAgain',
"Don't ask again (restart automatically)"
)}
</span>
<span className="block text-xs text-muted-foreground">
{translate(
'accounts.claude.switchSessions.dontAskAgainHint',
'You can turn this off in Settings → AI Provider Accounts.'
)}
</span>
</span>
<span className="block text-xs text-muted-foreground">
{translate(
'accounts.claude.switchSessions.dontAskAgainHint',
'You can turn this off in Settings → AI Provider Accounts.'
)}
</span>
</span>
</label>
</label>
)}
<DialogFooter className="sm:justify-between">
<Button
type="button"
@@ -148,3 +138,34 @@ function SwitchPromptDialog({ prompt }: { prompt: ClaudeAccountSwitchPrompt }):
</Dialog>
)
}
function promptDescription(prompt: ClaudeAccountSwitchPrompt): string {
const { onAccount, elsewhere, cantMove } = prompt.sessions
const next = prompt.nextAccountLabel
if (prompt.review) {
const count = elsewhere.length + cantMove.length
return count === 1
? translate(
'accounts.claude.switchSessions.reviewBodyOne',
"1 other Claude session wasn't moved to {{next}}.",
{ next }
)
: translate(
'accounts.claude.switchSessions.reviewBodyOther',
"{{count}} other Claude sessions weren't moved to {{next}}.",
{ count, next }
)
}
const accounts = { count: onAccount.length, previous: prompt.previousAccountLabel, next }
return onAccount.length === 1
? translate(
'accounts.claude.switchSessions.bodyOne',
'1 Claude session is still running on {{previous}}. Restarting continues the conversation on {{next}}. Busy sessions wait for idle before restarting.',
accounts
)
: translate(
'accounts.claude.switchSessions.bodyOther',
'{{count}} Claude sessions are still running on {{previous}}. Restarting continues each conversation on {{next}}. Busy sessions wait for idle before restarting.',
accounts
)
}
@@ -1,9 +1,12 @@
import { Fragment, useLayoutEffect, useRef, useState } from 'react'
import { Folder, GitBranch } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { AgentIcon } from '@/lib/agent-catalog'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import type { ClaudeAccountSwitchPromptSessions } from '@/lib/claude-account-switch-sessions'
import {
isClaudePaneMidTurn,
type ClaudeSessionCantMoveReason,
type ClaudeSessionMoveCandidate
} from '@/lib/claude-session-account-move'
import {
@@ -12,141 +15,251 @@ import {
} from '@/lib/worktree-default-display-name'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { useAppStore } from '../store'
import { ResumeTreeCount, ResumeTreeRow } from './NativeChatResumeTreeRow'
import { ResumeTreeRow } from './NativeChatResumeTreeRow'
import {
resumeSelectionState,
toggleResumeSelection
} from './native-chat-resume-on-restart-grouping'
import { moveInTree } from './native-chat-resume-tree-keys'
type SessionRowData = ClaudeSessionMoveCandidate & { reason?: string }
type SessionGroup = {
key: string
title: string
sessions: SessionRowData[]
/** Listed for information only: no checkboxes. */
fixed?: true
}
type SessionTreeProps = {
selected: ReadonlySet<string>
onToggle: (ptyId: string, checked: boolean) => void
}
/** Reuse native-chat resume rows to keep workspace-grouped restart sessions consistent. */
export function ClaudeAccountSwitchSessionTree({
sessions,
previousAccountLabel,
selected,
onToggle
}: SessionTreeProps & { sessions: readonly ClaudeSessionMoveCandidate[] }): React.JSX.Element {
const ptyIds = sessions.map((session) => session.ptyId)
const all = resumeSelectionState(ptyIds, selected)
const selectAllLabel = translate('accounts.claude.switchSessions.selectAll', 'Select all')
}: SessionTreeProps & {
sessions: ClaudeAccountSwitchPromptSessions
previousAccountLabel: string
}): React.JSX.Element {
const groups = sessionGroups(sessions, previousAccountLabel)
const treeRef = useRef<HTMLDivElement>(null)
const pinnedIndex = useNextGroupBelowFold(treeRef, groups.length)
return (
<div
ref={treeRef}
role="tree"
tabIndex={0}
aria-label={translate(
'accounts.claude.switchSessions.listLabel',
'Claude sessions to restart'
)}
className="max-h-64 min-h-0 overflow-y-auto scrollbar-sleek rounded-md bg-worktree-sidebar p-1.5 pb-2"
className="relative max-h-64 min-h-0 scroll-py-8 overflow-y-auto scrollbar-sleek rounded-md bg-worktree-sidebar p-1.5 pb-2"
onKeyDown={moveInTree}
>
<div className="mb-1 border-b border-worktree-sidebar-border pb-0.5">
<ResumeTreeRow
depth={0}
name={selectAllLabel}
checked={all.checked}
disabled={false}
onCheckedChange={() => toggleResumeSelection(ptyIds, all, onToggle)}
checkboxLabel={translate(
'accounts.claude.switchSessions.selectAllSessions',
'Select all sessions'
)}
>
<span className="min-w-0 truncate text-xs font-semibold text-muted-foreground">
{selectAllLabel}
</span>
<span className="ml-auto shrink-0 pl-2 text-[11px] tabular-nums text-muted-foreground">
{translate(
'accounts.claude.switchSessions.selectedCount',
'{{selected}} of {{total}} selected',
{ selected: all.selectedCount, total: all.total }
)}
</span>
</ResumeTreeRow>
</div>
{groupByWorkspace(sessions).map(([worktreeId, group]) => (
<WorkspaceGroup
key={worktreeId}
worktreeId={worktreeId}
sessions={group}
selected={selected}
onToggle={onToggle}
/>
{groups.map((group, index) => (
<Fragment key={group.key}>
{/* Unstuck position of the header below, to find the next group under the fold. */}
<div data-group-anchor aria-hidden="true" />
<GroupHeader
group={group}
pinnedToBottom={index === pinnedIndex}
selected={selected}
onToggle={onToggle}
/>
{groupByWorkspace(group.sessions).map(([worktreeId, workspaceSessions]) => (
<Fragment key={worktreeId}>
<WorkspaceRow worktreeId={worktreeId} fixed={group.fixed} />
{workspaceSessions.map((session) => (
<SessionRow
key={session.ptyId}
session={session}
fixed={group.fixed}
checked={selected.has(session.ptyId)}
onToggle={onToggle}
/>
))}
</Fragment>
))}
</Fragment>
))}
</div>
)
}
function groupByWorkspace(
sessions: readonly ClaudeSessionMoveCandidate[]
): [string, ClaudeSessionMoveCandidate[]][] {
const groups = new Map<string, ClaudeSessionMoveCandidate[]>()
/** Pin only the first group header below the visible area, so that whole group is one click away. */
function useNextGroupBelowFold(
treeRef: React.RefObject<HTMLDivElement | null>,
groupCount: number
): number {
const [pinned, setPinned] = useState(-1)
useLayoutEffect(() => {
const tree = treeRef.current
if (!tree) {
return
}
const update = (): void => {
const viewBottom = tree.scrollTop + tree.clientHeight
const anchors = [...tree.querySelectorAll<HTMLElement>('[data-group-anchor]')]
// Why the header height: a header that is only partly visible still needs pinning.
setPinned(
anchors.findIndex(
(anchor) => anchor.offsetTop + (anchor.nextElementSibling?.clientHeight ?? 0) > viewBottom
)
)
}
update()
tree.addEventListener('scroll', update, { passive: true })
return () => tree.removeEventListener('scroll', update)
}, [treeRef, groupCount])
return pinned
}
function sessionGroups(
sessions: ClaudeAccountSwitchPromptSessions,
previousAccountLabel: string
): SessionGroup[] {
const groups: SessionGroup[] = [
{
key: 'on-account',
title: translate('accounts.claude.switchSessions.groupOnAccount', 'Running on {{account}}', {
account: previousAccountLabel
}),
sessions: sessions.onAccount
},
{
key: 'elsewhere',
title: translate('accounts.claude.switchSessions.groupElsewhere', 'Not on {{account}}', {
account: previousAccountLabel
}),
sessions: sessions.elsewhere.map((session) => ({ ...session, reason: session.accountLabel }))
},
{
key: 'cant-move',
title: translate('accounts.claude.switchSessions.groupCantMove', "Can't move"),
sessions: sessions.cantMove.map((session) => ({
...session,
reason: cantMoveReasonLabel(session.reason)
})),
fixed: true
}
]
return groups.filter((group) => group.sessions.length > 0)
}
function cantMoveReasonLabel(reason: ClaudeSessionCantMoveReason): string {
switch (reason) {
case 'wsl-terminal':
return translate('accounts.claude.switchSessions.reasonWsl', 'WSL terminal')
case 'own-folder':
return translate(
'accounts.claude.switchSessions.reasonOwnFolder',
'Uses your own Claude folder'
)
case 'unknown-account':
return translate('accounts.claude.switchSessions.reasonUnknownAccount', 'Account unknown')
case 'no-session':
return translate('accounts.claude.switchSessions.reasonNoSession', "Can't resume")
}
}
function groupByWorkspace(sessions: readonly SessionRowData[]): [string, SessionRowData[]][] {
const groups = new Map<string, SessionRowData[]>()
for (const session of sessions) {
groups.set(session.worktreeId, [...(groups.get(session.worktreeId) ?? []), session])
}
return [...groups]
}
function WorkspaceGroup({
worktreeId,
sessions,
/** No checkbox, in the checkbox's column, so text lines up with rows that have one. */
const EMPTY_CHECKBOX_SLOT = <span aria-hidden="true" />
function GroupHeader({
group,
pinnedToBottom,
selected,
onToggle
}: SessionTreeProps & {
}: SessionTreeProps & { group: SessionGroup; pinnedToBottom: boolean }): React.JSX.Element {
const ptyIds = group.sessions.map((session) => session.ptyId)
const selection = resumeSelectionState(ptyIds, selected)
return (
<div
data-pinned={pinnedToBottom}
className="sticky -top-1.5 z-10 border-b border-worktree-sidebar-border bg-worktree-sidebar data-[pinned=true]:-bottom-2"
>
<ResumeTreeRow
depth={0}
name={group.title}
checked={selection.checked}
disabled={false}
checkboxSlot={group.fixed ? EMPTY_CHECKBOX_SLOT : undefined}
onCheckedChange={() => toggleResumeSelection(ptyIds, selection, onToggle)}
checkboxLabel={translate(
'accounts.claude.switchSessions.selectGroup',
'Select all sessions in {{group}}',
{ group: group.title }
)}
>
<span className="min-w-0 truncate text-xs font-semibold text-muted-foreground">
{group.title}
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
({group.sessions.length})
</span>
</ResumeTreeRow>
</div>
)
}
function WorkspaceRow({
worktreeId,
fixed
}: {
worktreeId: string
sessions: readonly ClaudeSessionMoveCandidate[]
fixed?: true
}): React.JSX.Element {
const worktree = useAppStore((store) => store.getKnownWorktreeById(worktreeId))
const name = (worktree && resolveWorktreeDisplayName(worktree)) || worktreeId
const branch = worktree ? resolveWorktreeBranchLabel(worktree) : ''
const KindGlyph = branch ? GitBranch : Folder
const ptyIds = sessions.map((session) => session.ptyId)
const selection = resumeSelectionState(ptyIds, selected)
return (
<>
<ResumeTreeRow
depth={0}
name={name}
checked={selection.checked}
disabled={false}
onCheckedChange={() => toggleResumeSelection(ptyIds, selection, onToggle)}
checkboxLabel={translate(
'accounts.claude.switchSessions.selectWorkspace',
'Select all sessions in {{workspace}}',
{ workspace: name }
)}
<ResumeTreeRow
depth={1}
name={name}
checked={false}
disabled
checkboxSlot={EMPTY_CHECKBOX_SLOT}
onCheckedChange={() => {}}
checkboxLabel={name}
>
<KindGlyph className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span
data-fixed={Boolean(fixed)}
className="min-w-0 truncate text-[13px] font-medium text-foreground data-[fixed=true]:text-muted-foreground"
>
<KindGlyph className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 truncate text-[13px] font-medium text-foreground">{name}</span>
{branch && branch !== name && (
<span className="min-w-0 shrink-2 truncate text-[11px] text-muted-foreground">
{branch}
</span>
)}
<ResumeTreeCount selectedCount={selection.selectedCount} total={selection.total} />
</ResumeTreeRow>
{sessions.map((session) => (
<SessionRow
key={session.ptyId}
session={session}
checked={selected.has(session.ptyId)}
onToggle={onToggle}
/>
))}
</>
{name}
</span>
{branch && branch !== name && (
<span className="min-w-0 shrink-2 truncate text-[11px] text-muted-foreground">
{branch}
</span>
)}
</ResumeTreeRow>
)
}
function SessionRow({
session,
fixed,
checked,
onToggle
}: {
session: ClaudeSessionMoveCandidate
session: SessionRowData
fixed?: true
checked: boolean
onToggle: (ptyId: string, checked: boolean) => void
}): React.JSX.Element {
@@ -163,18 +276,23 @@ function SessionRow({
lastPrompt || tabTitle || translate('accounts.claude.switchSessions.session', 'Claude session')
return (
<ResumeTreeRow
depth={1}
depth={2}
compact
name={title}
checked={checked}
disabled={false}
checkboxSlot={fixed ? EMPTY_CHECKBOX_SLOT : undefined}
onCheckedChange={(next) => onToggle(session.ptyId, next)}
checkboxLabel={title}
>
<span role="img" aria-label={formatAgentTypeLabel('claude')} className="inline-flex shrink-0">
<AgentIcon agent="claude" size={13} />
</span>
<span className="min-w-0 flex-1 truncate text-xs text-foreground/90" title={title}>
<span
data-fixed={Boolean(fixed)}
className="min-w-0 flex-1 truncate text-xs text-foreground/90 data-[fixed=true]:text-muted-foreground"
title={title}
>
{title}
</span>
{lastPrompt && tabTitle && (
@@ -182,10 +300,11 @@ function SessionRow({
{tabTitle}
</span>
)}
<span className="shrink-0 text-[11px] text-muted-foreground">
{busy
? translate('accounts.claude.switchSessions.busy', 'Working')
: translate('accounts.claude.switchSessions.idle', 'Idle')}
<span className="max-w-40 shrink-0 truncate text-[11px] text-muted-foreground">
{session.reason ??
(busy
? translate('accounts.claude.switchSessions.busy', 'Working')
: translate('accounts.claude.switchSessions.idle', 'Idle'))}
</span>
</ResumeTreeRow>
)
@@ -2,6 +2,7 @@ import { createElement, Fragment, type ReactNode } from 'react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import type { ResumeFailure } from './native-chat-resume-on-restart-grouping'
import { INLINE_ACTION_CLASS_NAMES } from './ui/sonner'
export type RestartContinuationOutcome = {
sessionId: string
@@ -69,12 +70,6 @@ function descriptionFrom(lines: readonly string[]): ReactNode {
)
}
// Why: a short outcome reads best with its action beside it; the toaster's default wraps actions below.
const INLINE_ACTION_CLASS_NAMES = {
content: '!basis-0',
actionButton: '!m-0 !self-center'
}
// A lone title centres with the taller button; a description keeps the icon on the title's line.
const ONE_LINE_INLINE_ACTION_CLASS_NAMES = {
...INLINE_ACTION_CLASS_NAMES,
@@ -9,6 +9,12 @@ import type { SyntheticEvent } from 'react'
import { Toaster as Sonner, type ToasterProps } from 'sonner'
import { useAppStore } from '@/store'
// Why: a short outcome reads best with its action beside it; the toaster's default wraps actions below.
export const INLINE_ACTION_CLASS_NAMES = {
content: '!basis-0',
actionButton: '!m-0 !self-center'
}
// Why: scope to real modals; Radix menus/selects also set body pointer-events:none.
function isModalOverlayOpen(element: Element): boolean {
return (
+14 -5
View File
@@ -20935,10 +20935,6 @@
"restartOne": "Restart session",
"restartOther": "Restart {{count}} sessions",
"listLabel": "Claude sessions to restart",
"selectAll": "Select all",
"selectAllSessions": "Select all sessions",
"selectedCount": "{{selected}} of {{total}} selected",
"selectWorkspace": "Select all sessions in {{workspace}}",
"session": "Claude session",
"busy": "Working",
"idle": "Idle",
@@ -20954,7 +20950,20 @@
"restartingOne": "Restarting 1 Claude session on {{account}}",
"restartingOther": "Restarting {{count}} Claude sessions on {{account}}",
"uncheckedOne": "Couldn't check 1 terminal for Claude. If Claude is running there, it stays on {{previous}} until restarted.",
"uncheckedOther": "Couldn't check {{count}} terminals for Claude. Any Claude running there stays on {{previous}} until restarted."
"uncheckedOther": "Couldn't check {{count}} terminals for Claude. Any Claude running there stays on {{previous}} until restarted.",
"groupOnAccount": "Running on {{account}}",
"groupElsewhere": "Not on {{account}}",
"groupCantMove": "Can't move",
"selectGroup": "Select all sessions in {{group}}",
"reasonWsl": "WSL terminal",
"reasonOwnFolder": "Uses your own Claude folder",
"reasonUnknownAccount": "Account unknown",
"reasonNoSession": "Can't resume",
"reviewBodyOne": "1 other Claude session wasn't moved to {{next}}.",
"reviewBodyOther": "{{count}} other Claude sessions weren't moved to {{next}}.",
"notMovedOne": "1 other session wasn't moved.",
"notMovedOther": "{{count}} other sessions weren't moved.",
"review": "Review"
}
},
"managedData": {
+14 -5
View File
@@ -20473,7 +20473,6 @@
"signInLinkCopied": "Enlace de inicio de sesión copiado. Ábrelo en una ventana privada para iniciar sesión con otra cuenta.",
"signInLinkCopyFailed": "No se pudo copiar el enlace.",
"switchSessions": {
"selectAll": "Seleccionar todo",
"busy": "Trabajando",
"idle": "Inactivo",
"keyword": {
@@ -20487,19 +20486,29 @@
"restartOne": "Reiniciar sesión",
"title": "Cambiado a {{account}}",
"keep": "Mantener como está",
"selectAllSessions": "Seleccionar todas las sesiones",
"restartOther": "Reiniciar {{count}} sesiones",
"listLabel": "Sesiones de Claude que se reiniciarán",
"selectedCount": "{{selected}} de {{total}} seleccionadas",
"dontAskAgainHint": "Puede desactivar esto en Configuración → Cuentas de proveedores de IA.",
"selectWorkspace": "Seleccionar todas las sesiones en {{workspace}}",
"session": "Sesión de Claude",
"settingKeep": "Nunca reiniciar",
"settingRestart": "Reiniciar siempre",
"settingTitle": "Cuando cambias de cuenta mientras Claude se está ejecutando",
"settingDescription": "El reinicio continúa con cada conversación en ejecución en la nueva cuenta.",
"restartingOther": "Reiniciando {{count}} sesiones de Claude con {{account}}",
"restartingOne": "Reiniciando 1 sesión de Claude con {{account}}"
"restartingOne": "Reiniciando 1 sesión de Claude con {{account}}",
"groupOnAccount": "En ejecución con {{account}}",
"groupElsewhere": "No está con {{account}}",
"groupCantMove": "No se pueden mover",
"selectGroup": "Seleccionar todas las sesiones de {{group}}",
"reasonWsl": "Terminal de WSL",
"reasonOwnFolder": "Usa tu propia carpeta de Claude",
"reasonUnknownAccount": "Cuenta desconocida",
"reasonNoSession": "No se puede reanudar",
"reviewBodyOne": "Otra sesión de Claude no se movió a {{next}}.",
"reviewBodyOther": "Otras {{count}} sesiones de Claude no se movieron a {{next}}.",
"notMovedOne": "Otra sesión no se movió.",
"notMovedOther": "Otras {{count}} sesiones no se movieron.",
"review": "Revisar"
}
},
"hostSignIn": {
+14 -5
View File
@@ -20623,7 +20623,6 @@
"signInLinkCopied": "Lien de connexion copié. Ouvrez-le dans une fenêtre privée pour vous connecter avec un autre compte.",
"signInLinkCopyFailed": "Impossible de copier le lien.",
"switchSessions": {
"selectAll": "Tout sélectionner",
"busy": "En activité",
"idle": "Inactif",
"keyword": {
@@ -20639,17 +20638,27 @@
"bodyOther": "{{count}} sessions Claude sont toujours en cours d’exécution sur {{previous}}. Le redémarrage poursuit chaque conversation sur {{next}}. Les sessions occupées attendent d’être inactives avant de redémarrer.",
"restartOne": "Redémarrer la session",
"restartOther": "Redémarrer {{count}} sessions",
"selectAllSessions": "Sélectionner toutes les sessions",
"session": "Session Claude",
"settingTitle": "Lorsque vous changez de compte alors que Claude est en cours d'exécution",
"selectedCount": "{{selected}} sur {{total}} sélectionnées",
"selectWorkspace": "Sélectionner toutes les sessions dans {{workspace}}",
"listLabel": "Sessions Claude à redémarrer",
"settingRestart": "Toujours redémarrer",
"settingKeep": "Ne jamais redémarrer",
"settingDescription": "Le redémarrage continue chaque conversation en cours sur le nouveau compte.",
"restartingOne": "Redémarrage d’une session Claude sur {{account}}",
"restartingOther": "Redémarrage de {{count}} sessions Claude sur {{account}}"
"restartingOther": "Redémarrage de {{count}} sessions Claude sur {{account}}",
"groupOnAccount": "En cours sur {{account}}",
"groupElsewhere": "Pas sur {{account}}",
"groupCantMove": "Impossible à déplacer",
"selectGroup": "Sélectionner toutes les sessions de {{group}}",
"reasonWsl": "Terminal WSL",
"reasonOwnFolder": "Utilise votre propre dossier Claude",
"reasonUnknownAccount": "Compte inconnu",
"reasonNoSession": "Reprise impossible",
"reviewBodyOne": "1 autre session Claude n'a pas été déplacée vers {{next}}.",
"reviewBodyOther": "{{count}} autres sessions Claude n'ont pas été déplacées vers {{next}}.",
"notMovedOne": "1 autre session n'a pas été déplacée.",
"notMovedOther": "{{count}} autres sessions n'ont pas été déplacées.",
"review": "Vérifier"
}
},
"hostSignIn": {
+14 -5
View File
@@ -20622,7 +20622,6 @@
"signInLinkCopied": "サインインリンクをコピーしました。別のアカウントでサインインするには、プライベートウィンドウで開いてください。",
"signInLinkCopyFailed": "リンクをコピーできませんでした。",
"switchSessions": {
"selectAll": "すべて選択",
"busy": "作業中",
"idle": "アイドル",
"keyword": {
@@ -20636,10 +20635,7 @@
"bodyOne": "{{previous}} で Claude セッションが 1 件まだ実行中です。再起動すると、{{next}} で会話が続きます。作業中のセッションは、アイドル状態になってから再起動します。",
"dontAskAgain": "今後は確認しない(自動で再起動する)",
"listLabel": "再起動する Claude セッション",
"selectWorkspace": "{{workspace}} のすべてのセッションを選択します",
"selectedCount": "{{selected}}/{{total}} が選択されました",
"restartOne": "セッションを再起動",
"selectAllSessions": "すべてのセッションを選択します",
"keep": "そのままにする",
"session": "Claude セッション",
"restartOther": "{{count}} 件のセッションを再起動",
@@ -20648,7 +20644,20 @@
"restartingOne": "{{account}} で Claude セッションを 1 件再起動中",
"restartingOther": "{{account}} で Claude セッションを {{count}} 件再起動中",
"settingRestart": "常に再起動する",
"settingKeep": "再起動しない"
"settingKeep": "再起動しない",
"groupOnAccount": "{{account}} で実行中",
"groupElsewhere": "{{account}} 以外",
"groupCantMove": "移動できません",
"selectGroup": "{{group}} のセッションをすべて選択",
"reasonWsl": "WSL ターミナル",
"reasonOwnFolder": "独自の Claude フォルダーを使用",
"reasonUnknownAccount": "アカウント不明",
"reasonNoSession": "再開できません",
"reviewBodyOne": "他の Claude セッション 1 件は {{next}} に移動されませんでした。",
"reviewBodyOther": "他の Claude セッション {{count}} 件は {{next}} に移動されませんでした。",
"notMovedOne": "他の 1 件のセッションは移動されませんでした。",
"notMovedOther": "他の {{count}} 件のセッションは移動されませんでした。",
"review": "確認"
}
},
"hostSignIn": {
+14 -5
View File
@@ -20622,7 +20622,6 @@
"signInLinkCopied": "로그인 링크를 복사했습니다. 다른 계정으로 로그인하려면 비공개 창에서 여세요.",
"signInLinkCopyFailed": "링크를 복사할 수 없습니다.",
"switchSessions": {
"selectAll": "모두 선택",
"busy": "작업 중",
"idle": "유휴",
"keyword": {
@@ -20638,17 +20637,27 @@
"session": "Claude 세션",
"restartOne": "세션 다시 시작",
"restartOther": "{{count}} 세션 다시 시작",
"selectAllSessions": "모든 세션 선택",
"keep": "그대로 유지",
"selectedCount": "{{total}} 중 {{selected}}이(가) 선택됨",
"listLabel": "다시 시작할 Claude 세션",
"selectWorkspace": "{{workspace}}의 모든 세션을 선택하세요.",
"settingTitle": "Claude가 실행되는 동안 계정을 전환하는 경우",
"settingDescription": "다시 시작하면 새 계정에서 실행 중인 각 대화가 계속됩니다.",
"settingRestart": "항상 다시 시작",
"settingKeep": "다시 시작하지 않음",
"restartingOther": "{{account}} 계정에서 Claude 세션 {{count}}개를 다시 시작하는 중",
"restartingOne": "{{account}} 계정에서 Claude 세션 1개를 다시 시작하는 중"
"restartingOne": "{{account}} 계정에서 Claude 세션 1개를 다시 시작하는 중",
"groupOnAccount": "{{account}}에서 실행 중",
"groupElsewhere": "{{account}} 외",
"groupCantMove": "옮길 수 없음",
"selectGroup": "{{group}}의 세션 모두 선택",
"reasonWsl": "WSL 터미널",
"reasonOwnFolder": "자체 Claude 폴더 사용",
"reasonUnknownAccount": "계정 알 수 없음",
"reasonNoSession": "재개할 수 없음",
"reviewBodyOne": "다른 Claude 세션 1개가 {{next}}(으)로 옮겨지지 않았습니다.",
"reviewBodyOther": "다른 Claude 세션 {{count}}개가 {{next}}(으)로 옮겨지지 않았습니다.",
"notMovedOne": "다른 세션 1개는 옮겨지지 않았습니다.",
"notMovedOther": "다른 세션 {{count}}개는 옮겨지지 않았습니다.",
"review": "검토"
}
},
"hostSignIn": {
+14 -5
View File
@@ -20761,10 +20761,6 @@
"restartOne": "Reiniciar sessão",
"restartOther": "Reiniciar {{count}} sessões",
"listLabel": "Sessões do Claude para reiniciar",
"selectAll": "Selecionar tudo",
"selectAllSessions": "Selecionar todas as sessões",
"selectedCount": "{{selected}} de {{total}} selecionadas",
"selectWorkspace": "Selecionar todas as sessões em {{workspace}}",
"session": "Sessão do Claude",
"busy": "Trabalhando",
"idle": "Ociosa",
@@ -20778,7 +20774,20 @@
"settingRestart": "Sempre reiniciar",
"settingKeep": "Nunca reiniciar",
"restartingOne": "Reiniciando 1 sessão do Claude em {{account}}",
"restartingOther": "Reiniciando {{count}} sessões do Claude em {{account}}"
"restartingOther": "Reiniciando {{count}} sessões do Claude em {{account}}",
"groupOnAccount": "Em execução em {{account}}",
"groupElsewhere": "Fora de {{account}}",
"groupCantMove": "Não podem ser movidas",
"selectGroup": "Selecionar todas as sessões em {{group}}",
"reasonWsl": "Terminal WSL",
"reasonOwnFolder": "Usa sua própria pasta do Claude",
"reasonUnknownAccount": "Conta desconhecida",
"reasonNoSession": "Não é possível retomar",
"reviewBodyOne": "1 outra sessão do Claude não foi movida para {{next}}.",
"reviewBodyOther": "{{count}} outras sessões do Claude não foram movidas para {{next}}.",
"notMovedOne": "1 outra sessão não foi movida.",
"notMovedOther": "{{count}} outras sessões não foram movidas.",
"review": "Revisar"
}
},
"managedData": {
+14 -5
View File
@@ -20622,7 +20622,6 @@
"signInLinkCopied": "已复制登录链接。请在隐私窗口中打开它以使用其他账户登录。",
"signInLinkCopyFailed": "无法复制链接。",
"switchSessions": {
"selectAll": "选择全部",
"busy": "工作中",
"idle": "空闲",
"keyword": {
@@ -20641,14 +20640,24 @@
"settingTitle": "Claude 运行期间切换账户时",
"settingDescription": "重启后,每个正在进行的对话都会在新账户上继续。",
"settingRestart": "总是重新启动",
"selectAllSessions": "选择所有会话",
"restartOther": "重启 {{count}} 个会话",
"session": "Claude 会话",
"selectWorkspace": "选择 {{workspace}} 中的所有会话",
"selectedCount": "已选择 {{selected}} / {{total}}",
"restartingOther": "正在 {{account}} 账户上重启 {{count}} 个 Claude 会话",
"restartingOne": "正在 {{account}} 账户上重启 1 个 Claude 会话",
"settingKeep": "永不重启"
"settingKeep": "永不重启",
"groupOnAccount": "在 {{account}} 上运行",
"groupElsewhere": "不在 {{account}} 上",
"groupCantMove": "无法移动",
"selectGroup": "选择 {{group}} 中的所有会话",
"reasonWsl": "WSL 终端",
"reasonOwnFolder": "使用你自己的 Claude 文件夹",
"reasonUnknownAccount": "账户未知",
"reasonNoSession": "无法恢复",
"reviewBodyOne": "另有 1 个 Claude 会话未移至 {{next}}。",
"reviewBodyOther": "另有 {{count}} 个 Claude 会话未移至 {{next}}。",
"notMovedOne": "另有 1 个会话未移动。",
"notMovedOther": "另有 {{count}} 个会话未移动。",
"review": "查看"
}
},
"hostSignIn": {
@@ -11,10 +11,11 @@ import {
resolveClaudeStatusAccountState
} from '../components/status-bar/status-bar-claude-accounts'
import {
findMovableClaudeSessions,
findClaudeSwitchSessions,
findQueuedClaudeRestartsOnAccount
} from './claude-session-account-move'
import type * as AccountMove from './claude-session-account-move'
import type { ClaudeSwitchSessions } from './claude-session-account-move'
import {
answerClaudeAccountSwitchPrompt,
getClaudeAccountSwitchPrompt,
@@ -45,7 +46,7 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
vi.mock('sonner', () => ({ toast: { success: toastSuccess } }))
vi.mock('./claude-session-account-move', async (importOriginal) => ({
...(await importOriginal<typeof AccountMove>()),
findMovableClaudeSessions: vi.fn(),
findClaudeSwitchSessions: vi.fn(),
findQueuedClaudeRestartsOnAccount: vi.fn(() => [])
}))
@@ -55,6 +56,10 @@ const candidate = (n: number) => ({
tabId: `tab-${n}`,
leafId: `leaf-${n}`
})
const scan = (
onAccount: ReturnType<typeof candidate>[],
others: Partial<ClaudeSwitchSessions> = {}
): ClaudeSwitchSessions => ({ onAccount, elsewhere: [], cantMove: [], ...others })
const row = (id: string, extra: Partial<ClaudeManagedAccountSummary> = {}) => ({
id,
email: `${id}@example.com`,
@@ -90,17 +95,17 @@ describe('handleRunningClaudeSessionsAfterSwitch', () => {
})
it('asks nothing when no Claude session runs on the outgoing account', async () => {
vi.mocked(findMovableClaudeSessions).mockResolvedValue([])
vi.mocked(findClaudeSwitchSessions).mockResolvedValue(scan([]))
expect(await handleRunningClaudeSessionsAfterSwitch(SWITCH)).toBe('show-confirmation')
expect(getClaudeAccountSwitchPrompt()).toBeNull()
expect(findMovableClaudeSessions).toHaveBeenCalledWith(
expect(findClaudeSwitchSessions).toHaveBeenCalledWith(
expect.objectContaining({ fromAccountId: 'account-a' })
)
})
it('keeps earlier queued restarts except those already on the new account', async () => {
vi.mocked(findMovableClaudeSessions).mockResolvedValue([])
vi.mocked(findClaudeSwitchSessions).mockResolvedValue(scan([]))
vi.mocked(findQueuedClaudeRestartsOnAccount).mockReturnValueOnce(['pty-home'])
await handleRunningClaudeSessionsAfterSwitch(SWITCH)
@@ -114,7 +119,7 @@ describe('handleRunningClaudeSessionsAfterSwitch', () => {
})
it('leaves local questions and queued restarts alone on a server account switch', async () => {
vi.mocked(findMovableClaudeSessions).mockResolvedValue([candidate(1)])
vi.mocked(findClaudeSwitchSessions).mockResolvedValue(scan([candidate(1)]))
await handleRunningClaudeSessionsAfterSwitch(SWITCH)
expect(
@@ -124,19 +129,18 @@ describe('handleRunningClaudeSessionsAfterSwitch', () => {
})
).toBe('show-confirmation')
expect(getClaudeAccountSwitchPrompt()?.sessions.map((session) => session.ptyId)).toEqual([
'pty-1'
])
expect(
getClaudeAccountSwitchPrompt()?.sessions.onAccount.map((session) => session.ptyId)
).toEqual(['pty-1'])
expect(findQueuedClaudeRestartsOnAccount).toHaveBeenCalledTimes(1)
})
it('restarts on the new account when the user picks Restart', async () => {
vi.mocked(findMovableClaudeSessions).mockResolvedValue([candidate(1), candidate(2)])
vi.mocked(findClaudeSwitchSessions).mockResolvedValue(scan([candidate(1), candidate(2)]))
expect(await handleRunningClaudeSessionsAfterSwitch(SWITCH)).toBe('handled')
expect(getClaudeAccountSwitchPrompt()?.sessions.map((session) => session.ptyId)).toEqual([
'pty-1',
'pty-2'
])
expect(
getClaudeAccountSwitchPrompt()?.sessions.onAccount.map((session) => session.ptyId)
).toEqual(['pty-1', 'pty-2'])
answerClaudeAccountSwitchPrompt('restart')
@@ -146,7 +150,7 @@ describe('handleRunningClaudeSessionsAfterSwitch', () => {
})
it('restarts only the ticked sessions, and only ones this question listed', async () => {
vi.mocked(findMovableClaudeSessions).mockResolvedValue([candidate(1), candidate(2)])
vi.mocked(findClaudeSwitchSessions).mockResolvedValue(scan([candidate(1), candidate(2)]))
await handleRunningClaudeSessionsAfterSwitch(SWITCH)
answerClaudeAccountSwitchPrompt('restart', { ptyIds: ['pty-2', 'pty-unlisted'] })
@@ -155,7 +159,7 @@ describe('handleRunningClaudeSessionsAfterSwitch', () => {
})
it('restarts a pane on the PTY it has now, and skips a pane that closed', async () => {
vi.mocked(findMovableClaudeSessions).mockResolvedValue([candidate(1), candidate(2)])
vi.mocked(findClaudeSwitchSessions).mockResolvedValue(scan([candidate(1), candidate(2)]))
await handleRunningClaudeSessionsAfterSwitch(SWITCH)
store.terminalLayoutsByTabId = { 'tab-1': { ptyIdsByLeafId: { 'leaf-1': 'pty-1-reattached' } } }
@@ -165,14 +169,14 @@ describe('handleRunningClaudeSessionsAfterSwitch', () => {
})
it('asks nothing when launches run on the same folder before and after the switch', async () => {
vi.mocked(findMovableClaudeSessions).mockResolvedValue([candidate(1)])
vi.mocked(findClaudeSwitchSessions).mockResolvedValue(scan([candidate(1)]))
expect(await handleRunningClaudeSessionsAfterSwitch(SAME_FOLDER)).toBe('show-confirmation')
expect(findMovableClaudeSessions).not.toHaveBeenCalled()
expect(findClaudeSwitchSessions).not.toHaveBeenCalled()
})
it('leaves sessions alone when the user keeps them', async () => {
vi.mocked(findMovableClaudeSessions).mockResolvedValue([candidate(1)])
vi.mocked(findClaudeSwitchSessions).mockResolvedValue(scan([candidate(1)]))
await handleRunningClaudeSessionsAfterSwitch(SWITCH)
answerClaudeAccountSwitchPrompt('keep')
@@ -184,7 +188,7 @@ describe('handleRunningClaudeSessionsAfterSwitch', () => {
it.each(['restart', 'keep'] as const)(
'turns on automatic restarts on %s when told not to ask again',
async (answer) => {
vi.mocked(findMovableClaudeSessions).mockResolvedValue([candidate(1)])
vi.mocked(findClaudeSwitchSessions).mockResolvedValue(scan([candidate(1)]))
await handleRunningClaudeSessionsAfterSwitch(SWITCH)
answerClaudeAccountSwitchPrompt(answer, { dontAskAgain: true })
@@ -197,7 +201,7 @@ describe('handleRunningClaudeSessionsAfterSwitch', () => {
it('restarts without asking when the setting is Always restart', async () => {
store.settings = { claudeAccountSwitchRunningSessions: 'restart' }
vi.mocked(findMovableClaudeSessions).mockResolvedValue([candidate(1), candidate(2)])
vi.mocked(findClaudeSwitchSessions).mockResolvedValue(scan([candidate(1), candidate(2)]))
expect(await handleRunningClaudeSessionsAfterSwitch(SWITCH)).toBe('handled')
expect(getClaudeAccountSwitchPrompt()).toBeNull()
@@ -207,21 +211,48 @@ describe('handleRunningClaudeSessionsAfterSwitch', () => {
)
})
it('names sessions an automatic restart left behind, and reviews only those', async () => {
store.settings = { claudeAccountSwitchRunningSessions: 'restart' }
vi.mocked(findClaudeSwitchSessions).mockResolvedValue(
scan([candidate(1)], {
elsewhere: [{ ...candidate(2), accountId: null }],
cantMove: [{ ...candidate(3), reason: 'own-folder' }]
})
)
await handleRunningClaudeSessionsAfterSwitch(SWITCH)
const [, options] = toastSuccess.mock.lastCall!
expect(options.description).toBe("2 other sessions weren't moved.")
options.action.onClick()
expect(getClaudeAccountSwitchPrompt()).toMatchObject({
review: true,
sessions: {
onAccount: [],
elsewhere: [expect.objectContaining({ ptyId: 'pty-2', accountLabel: 'System default' })],
cantMove: [expect.objectContaining({ ptyId: 'pty-3' })]
}
})
answerClaudeAccountSwitchPrompt('restart')
expect(store.queuePaneAccountRestarts).toHaveBeenNthCalledWith(1, ['pty-1'], 'claude')
expect(store.queuePaneAccountRestarts).toHaveBeenLastCalledWith(['pty-2'], 'claude')
})
it('keeps sessions without scanning when the setting is Never restart', async () => {
store.settings = { claudeAccountSwitchRunningSessions: 'keep' }
vi.mocked(findQueuedClaudeRestartsOnAccount).mockReturnValueOnce(['pty-home'])
expect(await handleRunningClaudeSessionsAfterSwitch(SWITCH)).toBe('show-confirmation')
expect(findMovableClaudeSessions).not.toHaveBeenCalled()
expect(findClaudeSwitchSessions).not.toHaveBeenCalled()
expect(store.consumePendingPaneAccountRestart).toHaveBeenCalledWith('pty-home')
expect(getClaudeAccountSwitchPrompt()).toBeNull()
expect(store.queuePaneAccountRestarts).not.toHaveBeenCalled()
})
it('applies Never restart chosen while the scan runs', async () => {
vi.mocked(findMovableClaudeSessions).mockImplementation(async () => {
vi.mocked(findClaudeSwitchSessions).mockImplementation(async () => {
store.settings = { claudeAccountSwitchRunningSessions: 'keep' }
return [candidate(1)]
return scan([candidate(1)])
})
expect(await handleRunningClaudeSessionsAfterSwitch(SWITCH)).toBe('show-confirmation')
@@ -229,9 +260,9 @@ describe('handleRunningClaudeSessionsAfterSwitch', () => {
})
it('keeps only the newer question when an older scan resolves last', async () => {
const older = Promise.withResolvers<ReturnType<typeof candidate>[]>()
const newer = Promise.withResolvers<ReturnType<typeof candidate>[]>()
vi.mocked(findMovableClaudeSessions)
const older = Promise.withResolvers<ClaudeSwitchSessions>()
const newer = Promise.withResolvers<ClaudeSwitchSessions>()
vi.mocked(findClaudeSwitchSessions)
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise)
const first = handleRunningClaudeSessionsAfterSwitch(SWITCH)
@@ -241,13 +272,13 @@ describe('handleRunningClaudeSessionsAfterSwitch', () => {
after: SWITCH.before
})
newer.resolve([candidate(2)])
newer.resolve(scan([candidate(2)]))
expect(await second).toBe('handled')
older.resolve([candidate(1)])
older.resolve(scan([candidate(1)]))
expect(await first).toBe('handled')
expect(getClaudeAccountSwitchPrompt()).toMatchObject({
sessions: [expect.objectContaining({ ptyId: 'pty-2' })],
sessions: { onAccount: [expect.objectContaining({ ptyId: 'pty-2' })] },
nextAccountLabel: 'account-a@example.com'
})
})
@@ -275,7 +306,7 @@ describe('switching from a saved account that runs on System default, as the sta
vi.clearAllMocks()
resetClaudeAccountSwitchSessionsForTests()
store.settings = {}
vi.mocked(findMovableClaudeSessions).mockResolvedValue([])
vi.mocked(findClaudeSwitchSessions).mockResolvedValue(scan([]))
})
it('looks for its sessions on System default when switching to another account', async () => {
@@ -288,7 +319,7 @@ describe('switching from a saved account that runs on System default, as the sta
after: { state: after, accountId: getClaudeStatusActiveId(after, hostTarget) }
})
expect(findMovableClaudeSessions).toHaveBeenCalledWith(
expect(findClaudeSwitchSessions).toHaveBeenCalledWith(
expect.objectContaining({ fromAccountId: null })
)
})
@@ -304,6 +335,6 @@ describe('switching from a saved account that runs on System default, as the sta
after: { state: after, accountId: getClaudeStatusActiveId(after, hostTarget) }
})
).toBe('show-confirmation')
expect(findMovableClaudeSessions).not.toHaveBeenCalled()
expect(findClaudeSwitchSessions).not.toHaveBeenCalled()
})
})
@@ -4,18 +4,28 @@ import { useAppStore } from '@/store'
import type { CodexAccountSelectionTarget } from '../../../shared/codex-selection-lane'
import type { ClaudeAccountSwitchRunningSessions } from '../../../shared/claude-account-switch-settings'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import type { ClaudeRateLimitAccountsState } from '../../../shared/managed-account-types'
import { INLINE_ACTION_CLASS_NAMES } from '@/components/ui/sonner'
import { getClaudeAccountLabel } from './claude-account-label'
import type { ClaudeHostFolders } from './claude-pane-account-folder'
import {
claudeLaunchAccountId,
findMovableClaudeSessions,
findClaudeSwitchSessions,
findQueuedClaudeRestartsOnAccount,
type ClaudeSessionMoveCandidate
type ClaudeSessionMoveCandidate,
type ClaudeSwitchSessions
} from './claude-session-account-move'
export type ClaudeAccountSwitchPromptSessions = {
onAccount: ClaudeSessionMoveCandidate[]
elsewhere: (ClaudeSessionMoveCandidate & { accountLabel: string })[]
cantMove: ClaudeSwitchSessions['cantMove']
}
export type ClaudeAccountSwitchPrompt = {
id: number
sessions: ClaudeSessionMoveCandidate[]
sessions: ClaudeAccountSwitchPromptSessions
/** Opened from the restart toast: the outgoing account's sessions are already restarting. */
review?: true
previousAccountLabel: string
nextAccountLabel: string
}
@@ -70,9 +80,8 @@ export function answerClaudeAccountSwitchPrompt(
if (answer === 'restart') {
const chosen = options.ptyIds
// Why filtered: only sessions this question listed may restart.
const picked = chosen
? request.sessions.filter((session) => chosen.includes(session.ptyId))
: request.sessions
const listed = [...request.sessions.onAccount, ...request.sessions.elsewhere]
const picked = chosen ? listed.filter((session) => chosen.includes(session.ptyId)) : listed
queueClaudeRestarts(currentPtyIds(picked))
}
if (options.dontAskAgain) {
@@ -81,7 +90,7 @@ export function answerClaudeAccountSwitchPrompt(
}
export type ClaudeAccountSnapshot = {
state: Pick<ClaudeRateLimitAccountsState, 'accounts'>
state: ClaudeHostFolders
accountId: string | null
}
@@ -100,12 +109,15 @@ export async function handleRunningClaudeSessionsAfterSwitch(args: {
}
const generation = ++switchGeneration
setPrompt(null)
// Why the new list: main read its folders and logins as it applied the switch.
const host = args.after.state
// Why: an accepted restart relaunches on whatever is selected when it runs, so it outlives switches.
const store = useAppStore.getState()
for (const ptyId of findQueuedClaudeRestartsOnAccount({
owner: args.owner,
target: args.target,
accountId: toAccountId
accountId: toAccountId,
host
})) {
store.consumePendingPaneAccountRestart(ptyId)
}
@@ -113,10 +125,12 @@ export async function handleRunningClaudeSessionsAfterSwitch(args: {
return 'show-confirmation'
}
let uncheckedCount = 0
const found = await findMovableClaudeSessions({
const found = await findClaudeSwitchSessions({
owner: args.owner,
target: args.target,
fromAccountId,
toAccountId,
host,
onUnchecked: (sessions) => {
uncheckedCount = sessions.length
console.warn(
@@ -126,7 +140,7 @@ export async function handleRunningClaudeSessionsAfterSwitch(args: {
}
}).catch((error: unknown) => {
console.warn('[claude-account-switch] session scan failed:', error)
return []
return null
})
if (generation !== switchGeneration) {
return 'handled'
@@ -142,36 +156,76 @@ export async function handleRunningClaudeSessionsAfterSwitch(args: {
getClaudeAccountLabel(args.before.state, args.before.accountId)
)
}
if (found.length === 0) {
// Why: other sessions are listed only alongside a move, so parallel-account users get no prompt.
if (!found || found.onAccount.length === 0) {
return 'show-confirmation'
}
const nextAccountLabel = getClaudeAccountLabel(args.after.state, args.after.accountId)
if (policy === 'restart') {
queueClaudeRestarts(found.map((candidate) => candidate.ptyId))
toast.success(
found.length === 1
? translate(
'accounts.claude.switchSessions.restartingOne',
'Restarting 1 Claude session on {{account}}',
{ account: nextAccountLabel }
)
: translate(
'accounts.claude.switchSessions.restartingOther',
'Restarting {{count}} Claude sessions on {{account}}',
{ count: found.length, account: nextAccountLabel }
)
)
return 'handled'
const sessions: ClaudeAccountSwitchPromptSessions = {
...found,
elsewhere: found.elsewhere.map((session) => ({
...session,
accountLabel: getClaudeAccountLabel(args.after.state, session.accountId)
}))
}
setPrompt({
id: generation,
sessions: found,
const labels = {
previousAccountLabel: getClaudeAccountLabel(args.before.state, args.before.accountId),
nextAccountLabel
})
}
if (policy === 'restart') {
queueClaudeRestarts(found.onAccount.map((candidate) => candidate.ptyId))
const others = found.elsewhere.length + found.cantMove.length
announceRestart(found.onAccount.length, nextAccountLabel, others, () => {
if (generation === switchGeneration) {
setPrompt({
id: generation,
review: true,
sessions: { ...sessions, onAccount: [] },
...labels
})
}
})
return 'handled'
}
setPrompt({ id: generation, sessions, ...labels })
return 'handled'
}
/** Name the sessions left behind and offer the same list, without the ones already restarting. */
function announceRestart(count: number, account: string, others: number, review: () => void): void {
const title =
count === 1
? translate(
'accounts.claude.switchSessions.restartingOne',
'Restarting 1 Claude session on {{account}}',
{ account }
)
: translate(
'accounts.claude.switchSessions.restartingOther',
'Restarting {{count}} Claude sessions on {{account}}',
{ count, account }
)
if (others === 0) {
toast.success(title)
return
}
toast.success(title, {
description:
others === 1
? translate('accounts.claude.switchSessions.notMovedOne', "1 other session wasn't moved.")
: translate(
'accounts.claude.switchSessions.notMovedOther',
"{{count}} other sessions weren't moved.",
{ count: others }
),
classNames: INLINE_ACTION_CLASS_NAMES,
action: {
label: translate('accounts.claude.switchSessions.review', 'Review'),
onClick: review
}
})
}
/** Why a toast: these sessions keep the previous account, and only a manual restart moves them. */
function warnUncheckedSessions(count: number, previous: string): void {
toast.warning(
@@ -0,0 +1,81 @@
import type {
ClaudeManagedAccount,
ClaudeRateLimitAccountsState
} from '../../../shared/managed-account-types'
import { getRendererAppPlatform } from './renderer-app-platform'
function normalizeFolder(path: string): string {
const slashed = path.replace(/\\/g, '/').replace(/\/+$/, '')
return getRendererAppPlatform() === 'win32' ? slashed.toLowerCase() : slashed
}
/** The host's Claude folders and logins as main read them at the switch; WSL guests' are never read. */
export type ClaudeHostFolders = Pick<
ClaudeRateLimitAccountsState,
'accounts' | 'systemDefaultHome' | 'systemDefaultEmail' | 'systemDefaultOrganizationUuid'
>
/** Where a pane's Claude keeps its config, read from its transcript path. */
export type ClaudePaneFolder =
| { kind: 'account'; accountId: string }
| { kind: 'system-default' }
| { kind: 'own-folder' }
/** Undefined means no hook evidence; without the host's default home every unmanaged folder is System default. */
export function resolveClaudePaneFolder(
transcriptPath: string | undefined,
saved: readonly ClaudeManagedAccount[],
host?: ClaudeHostFolders
): ClaudePaneFolder | undefined {
const transcript = transcriptPath ? normalizeFolder(transcriptPath) : ''
const projectsIndex = transcript.lastIndexOf('/projects/')
if (projectsIndex <= 0) {
return undefined
}
const configDir = transcript.slice(0, projectsIndex)
const isConfigDir = (folder: string | null | undefined): boolean =>
!!folder && normalizeFolder(folder) === configDir
const account =
saved.find((candidate) =>
[candidate.managedAuthPath, candidate.wslLinuxAuthPath].some(isConfigDir)
) ?? host?.accounts.find((candidate) => isConfigDir(candidate.home))
if (account) {
return { kind: 'account', accountId: account.id }
}
return !host?.systemDefaultHome || isConfigDir(host.systemDefaultHome)
? { kind: 'system-default' }
: { kind: 'own-folder' }
}
/**
* Whether a pane runs on an account (null is System default). A System default pane also counts
* when that folder is signed in to the account's own login, as an older Orca's copied login is.
* Managed folders are never compared by login: two saved accounts may share one on purpose.
*/
export function isClaudePaneOnAccount(
folder: ClaudePaneFolder | undefined,
accountId: string | null,
host?: ClaudeHostFolders
): boolean {
if (folder?.kind === 'account') {
return folder.accountId === accountId
}
if (folder?.kind !== 'system-default') {
return false
}
if (accountId === null) {
return true
}
const account = host?.accounts.find((candidate) => candidate.id === accountId)
// Why strict: a missing organization never matches, since one email can hold personal and team logins.
return (
!!host?.systemDefaultEmail &&
!!host.systemDefaultOrganizationUuid &&
!!account &&
// Why: only a signed-in folder's summary carries that folder's login rather than the saved one.
!account.needsSignIn &&
!account.runsOnSystemDefault &&
account.organizationUuid === host.systemDefaultOrganizationUuid &&
account.email.trim().toLowerCase() === host.systemDefaultEmail.trim().toLowerCase()
)
}
@@ -14,11 +14,11 @@ import {
} from '@/runtime/runtime-terminal-inspection'
import {
claudeLaunchAccountId,
findMovableClaudeSessions,
findClaudeSwitchSessions,
findQueuedClaudeRestartsOnAccount,
isClaudePaneMidTurn,
resolveClaudeSessionAccountId
isClaudePaneMidTurn
} from './claude-session-account-move'
import { resolveClaudePaneFolder, type ClaudeHostFolders } from './claude-pane-account-folder'
const laneByPtyId: Record<string, string> = {}
let projectRuntime: ProjectExecutionRuntimeResolution | undefined
@@ -138,13 +138,15 @@ function seedPanes(panes: SeededPane[]): void {
})
}
const movable = async (
const scan = (
fromAccountId: string | null = 'acct-a',
target: { runtime: 'host' | 'wsl'; wslDistro?: string } = HOST
) =>
(await findMovableClaudeSessions({ owner: LOCAL, target, fromAccountId })).map(
(pane) => pane.ptyId
)
target: { runtime: 'host' | 'wsl'; wslDistro?: string } = HOST,
host?: ClaudeHostFolders,
toAccountId: string | null = 'acct-c'
) => findClaudeSwitchSessions({ owner: LOCAL, target, fromAccountId, toAccountId, host })
const movable = async (...args: Parameters<typeof scan>) =>
(await scan(...args)).onAccount.map((pane) => pane.ptyId)
beforeEach(() => {
projectRuntime = undefined
@@ -202,12 +204,13 @@ describe('findMovableClaudeSessions', () => {
it('never offers a move for a remote-server switch or SSH panes', async () => {
seedPanes([{ n: 1, agentType: 'claude' }])
expect(
await findMovableClaudeSessions({
await findClaudeSwitchSessions({
owner: { kind: 'environment', environmentId: 'env-1' },
target: HOST,
fromAccountId: 'acct-a'
fromAccountId: 'acct-a',
toAccountId: 'acct-b'
})
).toEqual([])
).toEqual({ onAccount: [], elsewhere: [], cantMove: [] })
laneByPtyId[ptyId(1)] = 'ssh-connection'
expect(await movable()).toEqual([])
@@ -261,14 +264,15 @@ describe('findMovableClaudeSessions', () => {
vi.mocked(confirmRuntimeTerminalForegroundProcess).mockResolvedValue(null)
const onUnchecked = vi.fn()
const found = await findMovableClaudeSessions({
const found = await findClaudeSwitchSessions({
owner: LOCAL,
target: HOST,
fromAccountId: 'acct-a',
toAccountId: 'acct-b',
onUnchecked
})
expect(found).toEqual([])
expect(found.onAccount).toEqual([])
expect(onUnchecked.mock.calls[0][0].map((pane: { ptyId: string }) => pane.ptyId)).toEqual([
ptyId(1)
])
@@ -323,27 +327,28 @@ describe('findMovableClaudeSessions', () => {
})
})
describe('resolveClaudeSessionAccountId', () => {
describe('resolveClaudePaneFolder', () => {
const accounts = [account('acct-a', FOLDER_A)]
it('reads the account from the transcript folder, with System default as the rest', () => {
expect(resolveClaudeSessionAccountId(`${FOLDER_A}/projects/-repo/s.jsonl`, accounts)).toBe(
'acct-a'
)
expect(
resolveClaudeSessionAccountId(`${SYSTEM_FOLDER}/projects/-repo/s.jsonl`, accounts)
).toBeNull()
expect(resolveClaudeSessionAccountId(undefined, accounts)).toBeUndefined()
expect(resolveClaudePaneFolder(`${FOLDER_A}/projects/-repo/s.jsonl`, accounts)).toEqual({
kind: 'account',
accountId: 'acct-a'
})
expect(resolveClaudePaneFolder(`${SYSTEM_FOLDER}/projects/-repo/s.jsonl`, accounts)).toEqual({
kind: 'system-default'
})
expect(resolveClaudePaneFolder(undefined, accounts)).toBeUndefined()
})
it('matches Windows paths whatever their separators', () => {
const windowsAccounts = [account('acct-w', 'C:\\Users\\dev\\claude\\acct-w')]
expect(
resolveClaudeSessionAccountId(
resolveClaudePaneFolder(
'C:\\Users\\dev\\claude\\acct-w\\projects\\-repo\\s.jsonl',
windowsAccounts
)
).toBe('acct-w')
).toEqual({ kind: 'account', accountId: 'acct-w' })
})
})
@@ -385,6 +390,88 @@ describe('findQueuedClaudeRestartsOnAccount', () => {
findQueuedClaudeRestartsOnAccount({ owner: LOCAL, target: HOST, accountId: 'acct-b' })
).toEqual([ptyId(1)])
})
it("cancels a System default session's queued restart when switching back to the login it holds", () => {
seedPanes([{ n: 1, agentType: 'claude', configDir: SYSTEM_FOLDER }])
useAppStore.setState({ pendingPaneAccountRestarts: { [ptyId(1)]: 'claude' } })
const queuedOn = (accountId: string, host: ClaudeHostFolders) =>
findQueuedClaudeRestartsOnAccount({ owner: LOCAL, target: HOST, accountId, host })
expect(queuedOn('acct-a', hostFolders())).toEqual([ptyId(1)])
expect(queuedOn('acct-b', hostFolders())).toEqual([])
})
})
/** System default signed in to acct-a's login, as an older Orca's copy left it. */
function hostFolders(overrides: Partial<ClaudeHostFolders> = {}): ClaudeHostFolders {
const summary = (id: string, organizationUuid: string | null) => ({
...account(id, ''),
organizationUuid,
home: `/data/claude-profiles/${id}/home`
})
return {
accounts: [summary('acct-a', 'org-a'), summary('acct-b', 'org-b')],
systemDefaultHome: SYSTEM_FOLDER,
systemDefaultEmail: 'ACCT-A@example.com',
systemDefaultOrganizationUuid: 'org-a',
...overrides
}
}
describe('a switch away from the login System default holds', () => {
beforeEach(() => {
vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({
foregroundProcess: 'claude',
hasChildProcesses: true
})
})
it('moves System default sessions with that exact login, and never compares managed folders', async () => {
seedPanes([
{ n: 1, agentType: 'claude', configDir: FOLDER_A },
{ n: 2, agentType: 'claude', configDir: SYSTEM_FOLDER },
{ n: 3, agentType: 'claude', configDir: FOLDER_B }
])
const sameLoginB = hostFolders({
accounts: hostFolders().accounts.map((entry) => ({
...entry,
email: 'acct-a@example.com',
organizationUuid: 'org-a'
}))
})
expect(await movable('acct-a', HOST, hostFolders())).toEqual([ptyId(1), ptyId(2)])
expect(await movable('acct-a', HOST, sameLoginB)).toEqual([ptyId(1), ptyId(2)])
})
it.each([
['another organization', { systemDefaultOrganizationUuid: 'org-other' }],
['no organization', { systemDefaultOrganizationUuid: undefined }],
['a WSL switch', null]
])('leaves System default sessions alone for %s', async (_, overrides) => {
seedPanes([{ n: 1, agentType: 'claude', configDir: SYSTEM_FOLDER }])
laneByPtyId[ptyId(1)] = overrides ? 'host' : 'wsl:Ubuntu'
const target = overrides ? HOST : ({ runtime: 'wsl', wslDistro: 'Ubuntu' } as const)
const found = await scan('acct-a', target, hostFolders(overrides ?? {}))
expect(found.onAccount).toEqual([])
expect(found.elsewhere).toEqual([expect.objectContaining({ ptyId: ptyId(1), accountId: null })])
})
it("lists a session in the user's own folder as one a restart cannot move", async () => {
seedPanes([{ n: 1, agentType: 'claude', configDir: '/home/dev/my-claude' }])
expect((await scan(null, HOST, hostFolders())).cantMove).toEqual([
expect.objectContaining({ ptyId: ptyId(1), reason: 'own-folder' })
])
})
it("finds a legacy account's sessions in the folder its launches use now", async () => {
seedPanes([{ n: 1, agentType: 'claude', configDir: '/data/claude-profiles/acct-a/home' }])
expect(await movable('acct-a', HOST, hostFolders())).toEqual([ptyId(1)])
})
})
describe('claudeLaunchAccountId', () => {
@@ -14,6 +14,7 @@ import { normalizeAgentProviderSession } from '../../../shared/agent-session-res
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
import { isClientOnlyUnverifiableInspection } from '../../../shared/terminal-process-inspection'
import {
getCodexSelectionLaneKey,
normalizeCodexAccountSelectionTarget,
type CodexAccountSelectionTarget
} from '../../../shared/codex-selection-lane'
@@ -25,10 +26,15 @@ import type {
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import { resolvePaneAgentSessionId } from '@/components/terminal-pane/pane-agent-session-id'
import { resolveAgentAccountRestartResumeStartup } from './agent-account-restart-startup'
import { getRendererAppPlatform } from './renderer-app-platform'
import {
isClaudePaneOnAccount,
resolveClaudePaneFolder,
type ClaudeHostFolders
} from './claude-pane-account-folder'
import {
getCodexAccountSwitchLaneMatcher,
isForeignMachineCodexPtyId,
isLocalCodexSelectionLaneKey,
resolveCodexPaneSelectionLane
} from './codex-pane-selection-lane'
@@ -69,30 +75,6 @@ export function isClaudePaneMidTurn(state: AppState, paneKey: string): boolean {
return entry?.agentType === 'claude' && entry.state !== 'done'
}
function normalizeFolder(path: string): string {
const slashed = path.replace(/\\/g, '/').replace(/\/+$/, '')
return getRendererAppPlatform() === 'win32' ? slashed.toLowerCase() : slashed
}
/** Infer account from transcript config directory: managed folder or null/System default; undefined means no hook evidence. */
export function resolveClaudeSessionAccountId(
transcriptPath: string | undefined,
accounts: readonly ClaudeManagedAccount[]
): string | null | undefined {
const transcript = transcriptPath ? normalizeFolder(transcriptPath) : ''
const projectsIndex = transcript.lastIndexOf('/projects/')
if (projectsIndex <= 0) {
return undefined
}
const configDir = transcript.slice(0, projectsIndex)
const account = accounts.find((candidate) =>
[candidate.managedAuthPath, candidate.wslLinuxAuthPath].some(
(folder) => folder && normalizeFolder(folder) === configDir
)
)
return account ? account.id : null
}
/** Resolve the account actually used at launch; an account without its own login may use System default (null). */
export function claudeLaunchAccountId(
state: Pick<ClaudeRateLimitAccountsState, 'accounts'>,
@@ -102,15 +84,14 @@ export function claudeLaunchAccountId(
return account?.runsOnSystemDefault ? null : accountId
}
type LocalClaudePane = {
worktreeId: string
type LocalClaudePane = ClaudeSessionMoveCandidate & {
tab: TerminalTab
leafId: string
ptyId: string
accountId: string | null | undefined
laneKey: string
inLane: boolean
transcriptPath: string | undefined
}
function* localClaudePanesInLane(
function* localClaudePanes(
state: AppState,
owner: RuntimeClientTarget,
target: CodexAccountSelectionTarget
@@ -133,14 +114,17 @@ function* localClaudePanesInLane(
if (entry?.agentType !== 'claude' || entry.structuredHostOwned) {
continue
}
if (!isLaneInScope(resolveCodexPaneSelectionLane({ state, tab, ptyId }).laneKey)) {
continue
const { laneKey } = resolveCodexPaneSelectionLane({ state, tab, ptyId })
yield {
ptyId,
worktreeId,
tabId: tab.id,
leafId,
tab,
laneKey,
inLane: isLaneInScope(laneKey),
transcriptPath: normalizeAgentProviderSession(entry.providerSession)?.transcriptPath
}
const accountId = resolveClaudeSessionAccountId(
normalizeAgentProviderSession(entry.providerSession)?.transcriptPath,
state.settings?.claudeManagedAccounts ?? []
)
yield { worktreeId, tab, leafId, ptyId, accountId }
}
}
}
@@ -150,13 +134,21 @@ export function findQueuedClaudeRestartsOnAccount(args: {
owner: RuntimeClientTarget
target: CodexAccountSelectionTarget
accountId: string | null
host?: ClaudeHostFolders
}): string[] {
const state = useAppStore.getState()
const saved = state.settings?.claudeManagedAccounts ?? []
const host = isHostTarget(args.target) ? args.host : undefined
const ptyIds: string[] = []
for (const pane of localClaudePanesInLane(state, args.owner, args.target)) {
for (const pane of localClaudePanes(state, args.owner, args.target)) {
if (
pane.inLane &&
state.pendingPaneAccountRestarts[pane.ptyId] === 'claude' &&
pane.accountId === args.accountId
isClaudePaneOnAccount(
resolveClaudePaneFolder(pane.transcriptPath, saved, host),
args.accountId,
host
)
) {
ptyIds.push(pane.ptyId)
}
@@ -164,57 +156,147 @@ export function findQueuedClaudeRestartsOnAccount(args: {
return ptyIds
}
/** Find local resumable sessions in the switch’s lane; SSH/relay machines keep independent logins. */
export async function findMovableClaudeSessions(args: {
function isHostTarget(target: CodexAccountSelectionTarget): boolean {
return normalizeCodexAccountSelectionTarget(target).runtime === 'host'
}
export type ClaudeSessionCantMoveReason =
| 'wsl-terminal'
| 'own-folder'
| 'unknown-account'
| 'no-session'
/** Running Claude sessions a switch can see, by what a restart would do for them. */
export type ClaudeSwitchSessions = {
/** On the account being left. */
onAccount: ClaudeSessionMoveCandidate[]
/** On another account, or System default (null), that a restart could also move. */
elsewhere: (ClaudeSessionMoveCandidate & { accountId: string | null })[]
cantMove: (ClaudeSessionMoveCandidate & { reason: ClaudeSessionCantMoveReason })[]
}
type ScannedPane =
| { group: 'onAccount'; pane: LocalClaudePane }
| { group: 'elsewhere'; pane: LocalClaudePane; accountId: string | null }
| { group: 'cantMove'; pane: LocalClaudePane; reason: ClaudeSessionCantMoveReason }
function scanPane(
pane: LocalClaudePane,
args: {
saved: readonly ClaudeManagedAccount[]
host: ClaudeHostFolders | undefined
hostSwitch: boolean
fromAccountId: string | null
toAccountId: string | null
}
): ScannedPane | null {
if (!pane.inLane) {
// Why: a host switch leaves WSL alone; SSH and server panes keep independent logins and are not listed.
return args.hostSwitch && isWslLane(pane.laneKey)
? { group: 'cantMove', pane, reason: 'wsl-terminal' }
: null
}
const folder = resolveClaudePaneFolder(pane.transcriptPath, args.saved, args.host)
if (!folder) {
return { group: 'cantMove', pane, reason: 'unknown-account' }
}
// Why: the shell function keeps a pane's own CLAUDE_CONFIG_DIR, so a restart lands in the same folder.
if (folder.kind === 'own-folder') {
return { group: 'cantMove', pane, reason: 'own-folder' }
}
if (isClaudePaneOnAccount(folder, args.toAccountId, args.host)) {
return null
}
const resumable = resolveAgentAccountRestartResumeStartup('claude', {
tabId: pane.tabId,
leafId: pane.leafId,
worktreeId: pane.worktreeId,
shellOverride: pane.tab.shellOverride
})
if (!resumable) {
return { group: 'cantMove', pane, reason: 'no-session' }
}
return isClaudePaneOnAccount(folder, args.fromAccountId, args.host)
? { group: 'onAccount', pane }
: {
group: 'elsewhere',
pane,
accountId: folder.kind === 'account' ? folder.accountId : null
}
}
function isWslLane(laneKey: string): boolean {
return isLocalCodexSelectionLaneKey(laneKey) && laneKey !== getCodexSelectionLaneKey(null)
}
async function isClaudeRunning(
state: AppState,
pane: LocalClaudePane
): Promise<boolean | 'unchecked'> {
// Why: a Windows host cannot see processes inside WSL, so the guest's hooks and shell marks decide.
if (isWslLane(pane.laneKey)) {
return resolvePaneAgentSessionId(state, makePaneKey(pane.tabId, pane.leafId)) !== null
}
return inspectRuntimeTerminalProcess(pane.ptyId).then(
(inspection) => isClaudeInForeground(pane.ptyId, inspection),
() => false
)
}
function toCandidate(pane: LocalClaudePane): ClaudeSessionMoveCandidate {
return { ptyId: pane.ptyId, worktreeId: pane.worktreeId, tabId: pane.tabId, leafId: pane.leafId }
}
/** Find local Claude sessions a switch affects; SSH/relay machines keep independent logins. */
export async function findClaudeSwitchSessions(args: {
owner: RuntimeClientTarget
target: CodexAccountSelectionTarget
/** Only sessions known to run on this account count; null is System default. */
/** Null is System default. */
fromAccountId: string | null
/** Sessions left out because their foreground could not be read. */
toAccountId: string | null
host?: ClaudeHostFolders
/** Sessions on the outgoing account left out because their foreground could not be read. */
onUnchecked?: (sessions: ClaudeSessionMoveCandidate[]) => void
}): Promise<ClaudeSessionMoveCandidate[]> {
}): Promise<ClaudeSwitchSessions> {
const found: ClaudeSwitchSessions = { onAccount: [], elsewhere: [], cantMove: [] }
if (args.owner.kind !== 'local') {
return []
return found
}
const candidates: ClaudeSessionMoveCandidate[] = []
for (const pane of localClaudePanesInLane(useAppStore.getState(), args.owner, args.target)) {
if (pane.accountId !== args.fromAccountId) {
const state = useAppStore.getState()
const hostSwitch = isHostTarget(args.target)
const scanArgs = {
saved: state.settings?.claudeManagedAccounts ?? [],
host: hostSwitch ? args.host : undefined,
hostSwitch,
fromAccountId: args.fromAccountId,
toAccountId: args.toAccountId
}
const scanned = [...localClaudePanes(state, args.owner, args.target)].flatMap((pane) => {
const row = scanPane(pane, scanArgs)
return row ? [row] : []
})
const inspected = await Promise.all(
scanned.map(async (row) => ({ ...row, running: await isClaudeRunning(state, row.pane) }))
)
const unchecked: ClaudeSessionMoveCandidate[] = []
for (const row of inspected) {
const candidate = toCandidate(row.pane)
if (row.running === 'unchecked' && row.group === 'onAccount') {
unchecked.push(candidate)
}
if (row.running !== true) {
continue
}
const resumeStartup = resolveAgentAccountRestartResumeStartup('claude', {
tabId: pane.tab.id,
leafId: pane.leafId,
worktreeId: pane.worktreeId,
shellOverride: pane.tab.shellOverride
})
if (resumeStartup) {
candidates.push({
ptyId: pane.ptyId,
worktreeId: pane.worktreeId,
tabId: pane.tab.id,
leafId: pane.leafId
})
if (row.group === 'onAccount') {
found.onAccount.push(candidate)
} else if (row.group === 'elsewhere') {
found.elsewhere.push({ ...candidate, accountId: row.accountId })
} else {
found.cantMove.push({ ...candidate, reason: row.reason })
}
}
// Why: a Windows host cannot see processes inside WSL, so the guest's hooks and shell marks decide.
if (normalizeCodexAccountSelectionTarget(args.target).runtime === 'wsl') {
const state = useAppStore.getState()
return candidates.filter(
(candidate) =>
resolvePaneAgentSessionId(state, makePaneKey(candidate.tabId, candidate.leafId)) !== null
)
}
const inForeground = await Promise.all(
candidates.map((candidate) =>
inspectRuntimeTerminalProcess(candidate.ptyId).then(
(inspection) => isClaudeInForeground(candidate.ptyId, inspection),
() => false
)
)
)
const unchecked = candidates.filter((_, index) => inForeground[index] === 'unchecked')
if (unchecked.length > 0) {
args.onUnchecked?.(unchecked)
}
return candidates.filter((_, index) => inForeground[index] === true)
return found
}
+5
View File
@@ -96,6 +96,8 @@ export type ClaudeManagedAccountSummary = {
needsSignIn?: true
/** Its folder holds no login, so its launches run on System default, signed in to it. */
runsOnSystemDefault?: true
/** The host folder its launches use; a legacy account's saved path may name an older one. */
home?: string
}
/** A sign-in to a new account folder, or to a saved account's own when `accountId` is given. */
@@ -119,6 +121,9 @@ export type ClaudeRateLimitAccountsState = {
activeAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection
/** The login System default holds, when the host can read it and accounts exist. */
systemDefaultEmail?: string
systemDefaultOrganizationUuid?: string
/** The host folder System default launches use, when accounts exist. */
systemDefaultHome?: string
/** An older Orca's copy-based switching once wrote an account's login into System default. */
systemDefaultMayBeCopied?: boolean
}