Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/site/content/docs/agents/claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Orca reads your local `~/.claude` usage state and shows current usage plus rate-

Orca supports multiple Claude accounts and can swap between them in one click, matching the Codex flow. Switching accounts works even with live Claude sessions running — Orca holds the in-progress switch behind a guard so you don't trigger overlapping auth refreshes. See [Hot-swap Codex accounts](/docs/agents/codex-hot-swap) — the Claude flow is identical in shape.

A Claude session that is already running keeps the account it started on. After a switch, the Claude account menu shows how many sessions are still on the old account; **Move Sessions** reopens each one's conversation on the selected account (`claude --resume`) once it finishes its turn and holds no unsent text. **Settings → Accounts → Claude → Move idle sessions to the selected account** does that on its own for sessions idle for a minute in a tab you are not looking at. It is off by default.

## Subagents and teams

Background subagents and Agent Teams teammates can show as expandable child rows under the lead agent in the worktree agent list and [Agent Dashboard](/docs/model/agents-sessions#agent-dashboard). Selecting a child focuses the lead terminal.
Expand Down
3 changes: 3 additions & 0 deletions src/main/claude-accounts/claude-account-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export class ClaudeAccountSelection {
activeAccountId: selection.host,
activeAccountIdsByRuntime: selection,
...(systemDefault ? { systemDefaultEmail: systemDefault.email } : {}),
...(systemDefault?.organizationUuid
? { systemDefaultOrganizationUuid: systemDefault.organizationUuid }
: {}),
...(systemDefault && this.runtimeAuth.router.copiedLoginIntoSystemDefault()
? { systemDefaultMayBeCopied: true }
: {})
Expand Down
7 changes: 5 additions & 2 deletions src/main/claude-accounts/claude-account-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,13 @@ 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: 'org-personal' }
})
)
expect(f.service.listAccounts()).toMatchObject({
systemDefaultEmail: 'me@example.test'
systemDefaultEmail: 'me@example.test',
systemDefaultOrganizationUuid: 'org-personal'
})
expect(f.service.listAccounts()).not.toHaveProperty('userClaudeConfigDir')
expect(f.service.listAccounts().systemDefaultMayBeCopied).toBeUndefined()
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/app-shell/AppBackgroundServices.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Suspense } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { AgentHibernationGate } from '../components/AgentHibernationGate'
import { ClaudeSessionMoveGate } from '../components/ClaudeSessionMoveGate'
import { AiVaultTabTitleSyncGate } from '../components/AiVaultTabTitleSyncGate'
import RetainedAgentsSyncGate from '../components/dashboard/RetainedAgentsSyncGate'
import { WorkspacePortScanner } from '../components/ports/WorkspacePortScanner'
Expand Down Expand Up @@ -37,6 +38,7 @@ export function AppBackgroundServices(): React.JSX.Element {
</Suspense>
) : null}
<AgentHibernationGate />
<ClaudeSessionMoveGate />
<StructuredAgentSessionStatusBridge />
{/* Why here and not in the chat pane: a backgrounded chat has no mounted pane, and that is
exactly the completion the user needs the dot for. */}
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/src/components/ClaudeSessionMoveGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useEffect } from 'react'
import { installClaudeSessionMoveCoordinator } from '@/lib/claude-session-move-coordinator'

/** Moves Claude sessions to the selected account once they can go, as the move setting says. */
export function ClaudeSessionMoveGate(): null {
useEffect(() => installClaudeSessionMoveCoordinator(), [])
return null
}
2 changes: 1 addition & 1 deletion src/renderer/src/components/settings/AccountsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import {
getProviderAccountsOwnerKey,
watchProviderAccounts
} from '@/runtime/runtime-provider-accounts-client'
import { getAccountsClaudeSearchEntries } from './accounts-claude-search'
import {
getAccountsClaudeSearchEntries,
getAccountsCodexSearchEntries,
getAccountsGeminiSearchEntries,
getAccountsCursorSearchEntries,
Expand Down
40 changes: 40 additions & 0 deletions src/renderer/src/components/settings/accounts-claude-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { translate } from '@/i18n/i18n'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { translateSearchKeyword } from './settings-search-keywords'

export const getAccountsClaudeSearchEntries = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.accounts.search.75682e1b62', 'Claude Accounts'),
description: translate(
'auto.components.settings.accounts.search.dd75a73991',
'Optional account switching for Claude while preserving shared chat context.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.accounts.search.e14049e1a8', 'claude'),
...translateSearchKeyword('auto.components.settings.accounts.search.06662af91e', 'account'),
...translateSearchKeyword('auto.components.settings.accounts.search.5b3f18ef4a', 'switch'),
...translateSearchKeyword('auto.components.settings.accounts.search.8b06729e0f', 'active'),
...translateSearchKeyword(
'auto.components.settings.accounts.search.86edc96bc9',
'status bar'
),
...translateSearchKeyword('auto.components.settings.accounts.search.c759741d77', 'quota'),
...translateSearchKeyword('auto.components.settings.accounts.search.f2d666a886', 'optional')
]
},
{
title: translate(
'accounts.claude.moveIdleSessions',
'Move idle sessions to the selected account'
),
description: translate(
'accounts.claude.moveIdleSessionsDescription',
'After you switch accounts, a Claude session still on the old one reopens its conversation on the selected account once it has sat idle for a minute in a tab you are not looking at.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.accounts.search.e14049e1a8', 'claude'),
...translateSearchKeyword('accounts.claude.keyword.move', 'move'),
...translateSearchKeyword('accounts.claude.keyword.resume', 'resume')
]
}
])
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ButtonGroup } from '../ui/button-group'
import { Label } from '../ui/label'
import { ClaudeIcon } from '../status-bar/icons'
import { SearchableSetting } from './SearchableSetting'
import { renderClaudeSessionMoveSetting } from './accounts-pane-claude-session-moves'
import {
getProviderAccountRuntime,
providerAccountIsActiveInView
Expand Down Expand Up @@ -364,6 +365,7 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re
)}
</div>
</SearchableSetting>
{renderClaudeSessionMoveSetting(model)}
</section>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { ClaudeRateLimitAccountsState } from '../../../../shared/managed-account-types'
import { renderClaudeSessionMoveSetting } from './accounts-pane-claude-session-moves'

const NAME = 'Move idle sessions to the selected account'

function accounts(count: number): ClaudeRateLimitAccountsState {
return {
accounts: Array.from({ length: count }, (_, index) => ({
id: `acct-${index}`,
email: `${index}@example.test`,
authMethod: 'subscription-oauth' as const,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
})),
activeAccountId: null
}
}

function renderSetting(overrides: { remote?: boolean; count?: number; on?: boolean } = {}) {
const updateSettings = vi.fn()
const element = renderClaudeSessionMoveSetting({
claudeAccounts: accounts(overrides.count ?? 1),
isRemoteAccountScope: overrides.remote ?? false,
settings: { ...getDefaultSettings('/tmp'), claudeMoveIdleSessions: overrides.on === true },
updateSettings
})
render(<>{element}</>)
return updateSettings
}

describe('Claude session move setting', () => {
afterEach(cleanup)

it('turns automatic moves on', () => {
const updateSettings = renderSetting()

fireEvent.click(screen.getByRole('switch', { name: NAME }))

expect(updateSettings).toHaveBeenCalledWith({ claudeMoveIdleSessions: true })
})

it('shows the saved choice', () => {
renderSetting({ on: true })

expect(screen.getByRole('switch', { name: NAME }).getAttribute('aria-checked')).toBe('true')
})

it("is offered only for this machine's accounts, with an account to switch to", () => {
renderSetting({ remote: true })
expect(screen.queryByRole('switch', { name: NAME })).toBeNull()
cleanup()
renderSetting({ count: 0 })
expect(screen.queryByRole('switch', { name: NAME })).toBeNull()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { translate } from '@/i18n/i18n'
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'
import { SearchableSetting } from './SearchableSetting'
import type { AccountsPaneSectionModel } from './accounts-pane-types'

export function renderClaudeSessionMoveSetting(
model: Pick<
AccountsPaneSectionModel,
'claudeAccounts' | 'isRemoteAccountScope' | 'settings' | 'updateSettings'
>
): React.JSX.Element | null {
// Why this machine only: a move restarts panes running here, so it follows this machine's setting.
if (model.isRemoteAccountScope || model.claudeAccounts.accounts.length === 0) {
return null
}
const title = translate(
'accounts.claude.moveIdleSessions',
'Move idle sessions to the selected account'
)
const description = translate(
'accounts.claude.moveIdleSessionsDescription',
'After you switch accounts, a Claude session still on the old one reopens its conversation on the selected account once it has sat idle for a minute in a tab you are not looking at.'
)
return (
<SearchableSetting
title={title}
description={description}
keywords={['claude', 'account', 'move', 'session', 'resume', 'idle', 'switch']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="space-y-0.5">
<Label>{title}</Label>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<Switch
aria-label={title}
checked={model.settings.claudeMoveIdleSessions === true}
onCheckedChange={(enabled) => model.updateSettings({ claudeMoveIdleSessions: enabled })}
/>
</SearchableSetting>
)
}
11 changes: 11 additions & 0 deletions src/renderer/src/components/settings/accounts-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ vi.mock('./settings-search-keywords', () => ({
translateSearchKeyword: (_key: string, fallback: string) => [fallback]
}))

import { getAccountsClaudeSearchEntries } from './accounts-claude-search'
import {
getAccountsMiniMaxSearchEntries,
getAccountsOpencodeSearchEntries,
Expand Down Expand Up @@ -80,3 +81,13 @@ describe('getAccountsOpencodeSearchEntries', () => {
)
})
})

describe('getAccountsClaudeSearchEntries', () => {
it('finds the setting that moves idle sessions by its own words', () => {
expect(
getAccountsClaudeSearchEntries().some((entry) =>
entry.title.toLowerCase().includes('move idle sessions')
)
).toBe(true)
})
})
23 changes: 1 addition & 22 deletions src/renderer/src/components/settings/accounts-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { SettingsSearchEntry } from './settings-search'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { getAccountsClaudeSearchEntries } from './accounts-claude-search'

export const getAccountsLocationSearchEntries = createLocalizedCatalog(() => [
{
Expand All @@ -22,28 +23,6 @@ export const getAccountsLocationSearchEntries = createLocalizedCatalog(() => [
}
])

export const getAccountsClaudeSearchEntries = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.accounts.search.75682e1b62', 'Claude Accounts'),
description: translate(
'auto.components.settings.accounts.search.dd75a73991',
'Optional account switching for Claude while preserving shared chat context.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.accounts.search.e14049e1a8', 'claude'),
...translateSearchKeyword('auto.components.settings.accounts.search.06662af91e', 'account'),
...translateSearchKeyword('auto.components.settings.accounts.search.5b3f18ef4a', 'switch'),
...translateSearchKeyword('auto.components.settings.accounts.search.8b06729e0f', 'active'),
...translateSearchKeyword(
'auto.components.settings.accounts.search.86edc96bc9',
'status bar'
),
...translateSearchKeyword('auto.components.settings.accounts.search.c759741d77', 'quota'),
...translateSearchKeyword('auto.components.settings.accounts.search.f2d666a886', 'optional')
]
}
])

export const getAccountsCodexSearchEntries = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.accounts.search.17c5d244eb', 'Codex Accounts'),
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
import { ProviderDetailsMenu } from './ProviderDetailsMenu'
import { getClaudeAccountSyncKey } from './provider-account-sync-key'
import { signInToClaudeAccount } from '@/lib/claude-account-sign-in'
import { ClaudeSessionMovePrompt } from './claude-session-move-prompt'

// Exported so its account-switch/reset logic is preserved for row drill-in even
// though the footer now opens the consolidated UsageRosterPanel first.
Expand Down Expand Up @@ -382,6 +383,7 @@ export function ClaudeSwitcherMenu({
</div>
</div>
) : null}
{hasActiveRuntimeEnvironment ? null : <ClaudeSessionMovePrompt />}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ClaudeSessionMovePrompt } from './claude-session-move-prompt'

const onOldAccount = vi.hoisted(() => ({ count: 0 }))
const moveClaudeSessionsToSelectedAccount = vi.hoisted(() => vi.fn(async () => {}))
vi.mock('@/lib/claude-session-move-coordinator', () => ({
refreshClaudeMoveAccounts: vi.fn(async () => {}),
listClaudeSessionsOnOtherLogin: () =>
Array.from({ length: onOldAccount.count }, (_, index) => ({ paneKey: `pane-${index}` })),
moveClaudeSessionsToSelectedAccount
}))

describe('ClaudeSessionMovePrompt', () => {
beforeEach(() => {
moveClaudeSessionsToSelectedAccount.mockClear()
})
afterEach(cleanup)

it('stays out of the way while every session runs on the selected account', async () => {
onOldAccount.count = 0
const { container } = render(<ClaudeSessionMovePrompt />)

expect(container.textContent).toBe('')
})

it('says how many sessions are still on the old account and moves them', async () => {
onOldAccount.count = 2
render(<ClaudeSessionMovePrompt />)

expect(await screen.findByText('2 Claude sessions are still on the old account.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Move 2 Sessions' }))

expect(moveClaudeSessionsToSelectedAccount).toHaveBeenCalledOnce()
})

it('words a single session as one', async () => {
onOldAccount.count = 1
render(<ClaudeSessionMovePrompt />)

expect(await screen.findByRole('button', { name: 'Move Session' })).toBeTruthy()
})
})
Loading