mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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 (
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user