From 916c6a09b5339cb174cdc097d6f079986339fbf1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:32:08 -0400 Subject: [PATCH] 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. --- .../claude-account-selection.ts | 22 +- .../claude-account-service.test.ts | 9 +- .../ClaudeAccountSwitchDialog.test.tsx | 42 ++- .../components/ClaudeAccountSwitchDialog.tsx | 119 ++++--- .../ClaudeAccountSwitchSessionTree.tsx | 293 ++++++++++++------ ...ative-chat-restart-action-notifications.ts | 7 +- src/renderer/src/components/ui/sonner.tsx | 6 + src/renderer/src/i18n/locales/en.json | 19 +- src/renderer/src/i18n/locales/es.json | 19 +- src/renderer/src/i18n/locales/fr.json | 19 +- src/renderer/src/i18n/locales/ja.json | 19 +- src/renderer/src/i18n/locales/ko.json | 19 +- src/renderer/src/i18n/locales/pt.json | 19 +- src/renderer/src/i18n/locales/zh.json | 19 +- .../claude-account-switch-sessions.test.ts | 97 ++++-- .../src/lib/claude-account-switch-sessions.ts | 118 +++++-- .../src/lib/claude-pane-account-folder.ts | 81 +++++ .../lib/claude-session-account-move.test.ts | 135 ++++++-- .../src/lib/claude-session-account-move.ts | 242 ++++++++++----- src/shared/managed-account-types.ts | 5 + 20 files changed, 950 insertions(+), 359 deletions(-) create mode 100644 src/renderer/src/lib/claude-pane-account-folder.ts diff --git a/src/main/claude-accounts/claude-account-selection.ts b/src/main/claude-accounts/claude-account-selection.ts index c2726e36bfa..a676949d2b8 100644 --- a/src/main/claude-accounts/claude-account-selection.ts +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -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 { diff --git a/src/main/claude-accounts/claude-account-service.test.ts b/src/main/claude-accounts/claude-account-service.test.ts index da97c54e8ca..2990603f7e5 100644 --- a/src/main/claude-accounts/claude-account-service.test.ts +++ b/src/main/claude-accounts/claude-account-service.test.ts @@ -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() diff --git a/src/renderer/src/components/ClaudeAccountSwitchDialog.test.tsx b/src/renderer/src/components/ClaudeAccountSwitchDialog.test.tsx index 4fedda89cbf..3ef75e77d1e 100644 --- a/src/renderer/src/components/ClaudeAccountSwitchDialog.test.tsx +++ b/src/renderer/src/components/ClaudeAccountSwitchDialog.test.tsx @@ -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()), - 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 { - 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() + 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() await switchWith(1) diff --git a/src/renderer/src/components/ClaudeAccountSwitchDialog.tsx b/src/renderer/src/components/ClaudeAccountSwitchDialog.tsx index 0ebda09b4bb..722c2cb009f 100644 --- a/src/renderer/src/components/ClaudeAccountSwitchDialog.tsx +++ b/src/renderer/src/components/ClaudeAccountSwitchDialog.tsx @@ -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>(() => 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>( + () => 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 ( 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 })} - - {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 - )} - + {promptDescription(prompt)} - {count > 1 && ( + {(listedCount > 1 || prompt.review) && ( )} - + + )}