diff --git a/README.md b/README.md index e7490324..aa3bbcff 100644 --- a/README.md +++ b/README.md @@ -522,6 +522,29 @@ accounts: Shared context with `standard` depth links project workspace data. `deeper` depth links additional continuity artifacts. Credentials remain isolated per account. +Resume is lane-scoped: + +- plain `ccs -r` resumes the lane that plain `ccs` currently uses +- `ccs -r` resumes that account's lane only +- those lanes can differ, even when an account is `shared + deeper` + +If you do most of your work with plain `ccs` and want future resumes to line up with an auth account: + +```bash +ccs auth default work +``` + +If you need to protect local continuity files before changing account sync settings: + +```bash +ccs auth backup work +ccs auth backup default +``` + +- `ccs auth backup work` backs up the local continuity lane for that auth account +- `ccs auth backup default` backs up the lane plain `ccs` would use right now +- this backs up local continuity artifacts only; Claude-hosted resume behavior still depends on upstream state + #### Cross-Profile Continuity Inheritance (Claude Target) You can map non-account profiles (API, CLIProxy, Copilot, or `default`) to reuse continuity artifacts from an account profile: @@ -532,6 +555,7 @@ continuity: glm: pro gemini: pro copilot: pro + default: pro ``` With this config, `ccs glm`, `ccs gemini`, and `ccs copilot` run with `pro`'s `CLAUDE_CONFIG_DIR` continuity context while keeping each profile's own provider credentials/settings. diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md index a42b7dff..551f6b42 100644 --- a/docs/session-sharing-technical-analysis.md +++ b/docs/session-sharing-technical-analysis.md @@ -1,6 +1,6 @@ # Session Sharing Technical Analysis -Last Updated: 2026-02-26 +Last Updated: 2026-04-04 ## Summary @@ -69,6 +69,30 @@ Behavior: - Reuses `CLAUDE_CONFIG_DIR` from mapped account profile after normal account context policy resolution - Invalid/missing mapped accounts are skipped safely +### Resume Lane Note + +Resume follows the active `CLAUDE_CONFIG_DIR`, not just the continuity group: + +- plain `ccs -r` resumes the lane plain `ccs` is using right now +- `ccs -r` resumes only that account lane +- those two commands can point at different continuity inventories + +That means `shared + deeper` on an account does **not** automatically make old plain-`ccs` resume history appear inside `ccs -r`. + +If you want future plain `ccs` sessions to use an account lane, either: + +```bash +ccs auth default work +``` + +or map the default profile explicitly: + +```yaml +continuity: + inherit_from_account: + default: work +``` + ## User Workflows ### New account with shared context @@ -88,6 +112,19 @@ ccs auth create backup2 --context-group sprint-a --deeper-continuity No account recreation required for this workflow. +### Backup Before Changing Sync + +CCS can back up local continuity artifacts before you change settings: + +```bash +ccs auth backup work +ccs auth backup default +``` + +- `ccs auth backup work` backs up the selected account lane +- `ccs auth backup default` backs up the lane plain `ccs` would use right now +- this is a local continuity backup, not a guaranteed export of all upstream Claude-hosted resume state + ## Current Limitations - Shared context is local filesystem sharing. It does not bypass remote provider permission models. diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index 85458288..6f757e1a 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -21,6 +21,7 @@ import packageJson from '../../package.json'; import { type CommandContext, handleCreate, + handleBackup, handleList, handleShow, handleRemove, @@ -68,6 +69,9 @@ class AuthCommands { console.log(''); console.log(subheader('Commands')); console.log(` ${color('create ', 'command')} Create new profile and login`); + console.log( + ` ${color('backup ', 'command')} Backup local continuity for an account` + ); console.log(` ${color('list', 'command')} List all saved profiles`); console.log(` ${color('show ', 'command')} Show profile details`); console.log(` ${color('remove ', 'command')} Remove saved profile`); @@ -97,6 +101,12 @@ class AuthCommands { console.log(` ${dim('# Set work as default')}`); console.log(` ${color('ccs auth default work', 'command')}`); console.log(''); + console.log(` ${dim('# Backup the local continuity lane for an account or plain ccs')}`); + console.log(` ${color('ccs auth backup work', 'command')}`); + console.log( + ` ${color('ccs auth backup default', 'command')} ${dim('# backup plain ccs lane')}` + ); + console.log(''); console.log(` ${dim('# Restore original CCS behavior')}`); console.log(` ${color('ccs auth reset-default', 'command')}`); console.log(''); @@ -162,6 +172,10 @@ class AuthCommands { return handleCreate(this.getContext(), args); } + async handleBackup(args: string[]): Promise { + return handleBackup(this.getContext(), args); + } + /** * List all profiles - delegates to list-command.ts */ @@ -227,6 +241,10 @@ class AuthCommands { await this.handleList(commandArgs); break; + case 'backup': + await this.handleBackup(commandArgs); + break; + case 'show': await this.handleShow(commandArgs); break; diff --git a/src/auth/commands/backup-command.ts b/src/auth/commands/backup-command.ts new file mode 100644 index 00000000..4a682367 --- /dev/null +++ b/src/auth/commands/backup-command.ts @@ -0,0 +1,139 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { initUI, color, dim, fail, info, ok } from '../../utils/ui'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { + createTimestampStamp, + getAuthBackupRoot, + getContinuityArtifactNames, + resolveRuntimePlainCcsResumeLane, +} from '../resume-lane-diagnostics'; +import { isAccountContextMetadata, resolveAccountContextPolicy } from '../account-context'; +import { CommandContext, parseArgs } from './types'; + +interface BackupManifest { + target: string; + source_config_dir: string; + created_at: string; + copied: string[]; + skipped: string[]; +} + +function copyDirectoryIfPresent(sourcePath: string, targetPath: string): boolean { + if (!fs.existsSync(sourcePath)) { + return false; + } + + fs.cpSync(sourcePath, targetPath, { + recursive: true, + dereference: true, + force: false, + errorOnExist: false, + }); + return true; +} + +export async function handleBackup(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const { profileName, json } = parseArgs(args); + + if (!profileName) { + console.log(fail('Profile name is required')); + console.log(''); + console.log(`Usage: ${color('ccs auth backup [--json]', 'command')}`); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); + } + + try { + let sourceConfigDir = ''; + let backupLabel = profileName; + let artifactNames: string[] = []; + + if (profileName === 'default') { + const lane = await resolveRuntimePlainCcsResumeLane(); + sourceConfigDir = lane.configDir; + backupLabel = lane.accountName ? `default-${lane.accountName}` : 'default'; + artifactNames = getContinuityArtifactNames('default'); + } else { + const profiles = ctx.registry.getAllProfilesMerged(); + const profile = profiles[profileName]; + if (!profile || profile.type !== 'account') { + exitWithError( + `Backup supports auth accounts or "default". Not found: ${profileName}`, + ExitCode.PROFILE_ERROR + ); + } + + const contextPolicy = resolveAccountContextPolicy( + isAccountContextMetadata(profile) ? profile : undefined + ); + sourceConfigDir = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, { + bare: profile.bare === true, + }); + artifactNames = getContinuityArtifactNames('account'); + } + + const backupRoot = path.join(getAuthBackupRoot(), backupLabel, createTimestampStamp()); + fs.mkdirSync(backupRoot, { recursive: true, mode: 0o700 }); + + const copied: string[] = []; + const skipped: string[] = []; + + for (const artifactName of artifactNames) { + const sourcePath = path.join(sourceConfigDir, artifactName); + const targetPath = path.join(backupRoot, artifactName); + if (copyDirectoryIfPresent(sourcePath, targetPath)) { + copied.push(artifactName); + } else { + skipped.push(artifactName); + } + } + + const manifest: BackupManifest = { + target: profileName, + source_config_dir: sourceConfigDir, + created_at: new Date().toISOString(), + copied, + skipped, + }; + fs.writeFileSync( + path.join(backupRoot, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + { + encoding: 'utf8', + mode: 0o600, + } + ); + + if (json) { + console.log( + JSON.stringify( + { + target: profileName, + backup_path: backupRoot, + copied, + skipped, + }, + null, + 2 + ) + ); + return; + } + + console.log(ok(`Continuity backup created: ${backupRoot}`)); + console.log(''); + console.log(info(`Source lane: ${sourceConfigDir}`)); + console.log(` ${dim(`Copied: ${copied.length > 0 ? copied.join(', ') : 'none'}`)}`); + if (skipped.length > 0) { + console.log(` ${dim(`Skipped: ${skipped.join(', ')}`)}`); + } + console.log(''); + } catch (error) { + exitWithError( + `Failed to create continuity backup: ${(error as Error).message}`, + ExitCode.GENERAL_ERROR + ); + } +} diff --git a/src/auth/commands/index.ts b/src/auth/commands/index.ts index 67a11be5..81d55688 100644 --- a/src/auth/commands/index.ts +++ b/src/auth/commands/index.ts @@ -16,6 +16,7 @@ export { // Command handlers export { handleCreate } from './create-command'; +export { handleBackup } from './backup-command'; export { handleList } from './list-command'; export { handleShow } from './show-command'; export { handleRemove } from './remove-command'; diff --git a/src/auth/profile-continuity-inheritance.ts b/src/auth/profile-continuity-inheritance.ts index 7ccded76..b2a775b3 100644 --- a/src/auth/profile-continuity-inheritance.ts +++ b/src/auth/profile-continuity-inheritance.ts @@ -100,6 +100,19 @@ function resolveMappedAccount( return undefined; } +export function resolveConfiguredContinuitySourceAccount( + profileName: string, + profileType: ProfileType +): string | undefined { + const inheritFromAccount = getContinuityInheritanceMap(); + return ( + resolveMappedAccount(profileName, profileType, inheritFromAccount) ?? + (!isUnifiedMode() + ? resolveMappedAccount(profileName, profileType, loadLegacyContinuityInheritanceMap()) + : undefined) + ); +} + /** * Resolve optional cross-profile continuity inheritance. * @@ -115,16 +128,10 @@ export async function resolveProfileContinuityInheritance( return {}; } - const inheritFromAccount = getContinuityInheritanceMap(); - const sourceAccount = - resolveMappedAccount(input.profileName, input.profileType, inheritFromAccount) ?? - (!isUnifiedMode() - ? resolveMappedAccount( - input.profileName, - input.profileType, - loadLegacyContinuityInheritanceMap() - ) - : undefined); + const sourceAccount = resolveConfiguredContinuitySourceAccount( + input.profileName, + input.profileType + ); if (!sourceAccount) { return {}; } diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index a67f8d98..bc7fbab1 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -462,6 +462,14 @@ class ProfileDetector { }; } + /** + * Public wrapper for diagnostics and tooling that need to inspect how + * plain `ccs` resolves without duplicating the default-profile logic. + */ + resolveDefaultProfileResult(): ProfileDetectionResult { + return this.resolveDefaultProfile(); + } + /** * List available profiles (for error messages) */ diff --git a/src/auth/resume-lane-diagnostics.ts b/src/auth/resume-lane-diagnostics.ts new file mode 100644 index 00000000..4919f157 --- /dev/null +++ b/src/auth/resume-lane-diagnostics.ts @@ -0,0 +1,163 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { getDefaultClaudeConfigDir } from '../utils/claude-config-path'; +import { getCcsDir } from '../utils/config-manager'; +import InstanceManager from '../management/instance-manager'; +import ProfileDetector from './profile-detector'; +import { resolveConfiguredContinuitySourceAccount } from './profile-continuity-inheritance'; +import type { ProfileType } from '../types/profile'; + +export type ResumeLaneKind = + | 'native' + | 'account-default' + | 'account-inherited' + | 'profile-default' + | 'ambient'; + +export interface ResumeLaneSummary { + kind: ResumeLaneKind; + label: string; + configDir: string; + accountName?: string; + profileName?: string; + projectCount: number; +} + +export interface ResumeFlagIntent { + implicit: boolean; + explicitSessionId?: string; +} + +function countTopLevelProjectDirs(projectsDir: string): number { + try { + return fs + .readdirSync(projectsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()).length; + } catch { + return 0; + } +} + +function resolveNativeLaneSummary( + kind: ResumeLaneKind = 'native', + profileName?: string +): ResumeLaneSummary { + const configDir = getDefaultClaudeConfigDir(); + const label = + kind === 'profile-default' && profileName + ? `profile "${profileName}" via native Claude lane` + : 'native Claude lane'; + + return { + kind, + label, + configDir, + profileName, + projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')), + }; +} + +function resolveAccountLaneSummary( + kind: Extract, + accountName: string +): ResumeLaneSummary { + const instanceMgr = new InstanceManager(); + const configDir = instanceMgr.getInstancePath(accountName); + + return { + kind, + label: + kind === 'account-inherited' + ? `plain ccs inherits from account "${accountName}"` + : `plain ccs defaults to account "${accountName}"`, + configDir, + accountName, + projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')), + }; +} + +export async function resolveConfiguredPlainCcsResumeLane(): Promise { + const detector = new ProfileDetector(); + const defaultProfile = detector.resolveDefaultProfileResult(); + + if (defaultProfile.type === 'account') { + return resolveAccountLaneSummary('account-default', defaultProfile.name); + } + + const inheritedAccount = resolveConfiguredContinuitySourceAccount( + defaultProfile.name, + defaultProfile.type + ); + if (inheritedAccount) { + return resolveAccountLaneSummary('account-inherited', inheritedAccount); + } + + if (defaultProfile.type !== 'default') { + return resolveNativeLaneSummary('profile-default', defaultProfile.name); + } + + return resolveNativeLaneSummary(); +} + +export async function resolveRuntimePlainCcsResumeLane(): Promise { + if (process.env.CLAUDE_CONFIG_DIR) { + const configDir = path.resolve(process.env.CLAUDE_CONFIG_DIR); + return { + kind: 'ambient', + label: 'current shell CLAUDE_CONFIG_DIR', + configDir, + projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')), + }; + } + + return resolveConfiguredPlainCcsResumeLane(); +} + +export function parseResumeFlagIntent(args: string[]): ResumeFlagIntent | null { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '-r') { + return { implicit: true }; + } + + if (arg === '--resume') { + const next = args[index + 1]; + if (next && !next.startsWith('-')) { + return { implicit: false, explicitSessionId: next }; + } + return { implicit: true }; + } + + if (arg.startsWith('--resume=')) { + const sessionId = arg.slice('--resume='.length).trim(); + return sessionId ? { implicit: false, explicitSessionId: sessionId } : { implicit: true }; + } + } + + return null; +} + +export function getAuthBackupRoot(): string { + return path.join(getCcsDir(), 'backups', 'auth-continuity'); +} + +export function createTimestampStamp(date = new Date()): string { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const hh = String(date.getHours()).padStart(2, '0'); + const min = String(date.getMinutes()).padStart(2, '0'); + const ss = String(date.getSeconds()).padStart(2, '0'); + return `${yyyy}${mm}${dd}_${hh}${min}${ss}`; +} + +export function getContinuityArtifactNames(profileType: ProfileType): string[] { + const sharedItems = ['projects']; + const deeperItems = ['session-env', 'file-history', 'shell-snapshots', 'todos']; + + if (profileType === 'default' || profileType === 'account') { + return [...sharedItems, ...deeperItems]; + } + + return sharedItems; +} diff --git a/src/auth/resume-lane-warning.ts b/src/auth/resume-lane-warning.ts new file mode 100644 index 00000000..ca59217c --- /dev/null +++ b/src/auth/resume-lane-warning.ts @@ -0,0 +1,64 @@ +import * as path from 'path'; +import { info, warn } from '../utils/ui'; +import { + parseResumeFlagIntent, + resolveRuntimePlainCcsResumeLane, + type ResumeLaneSummary, +} from './resume-lane-diagnostics'; + +interface ResumeLaneWarningDependencies { + resolvePlainLane?: () => Promise; + log?: (message: string) => void; + debug?: boolean; +} + +export async function maybeWarnAboutResumeLaneMismatch( + profileName: string, + accountConfigDir: string, + args: string[], + deps: ResumeLaneWarningDependencies = {} +): Promise { + const resumeIntent = parseResumeFlagIntent(args); + if (!resumeIntent) { + return; + } + + const log = deps.log ?? console.error; + + try { + const plainLane = await (deps.resolvePlainLane ?? resolveRuntimePlainCcsResumeLane)(); + if (path.resolve(plainLane.configDir) === path.resolve(accountConfigDir)) { + return; + } + + log( + warn( + `Resume for account "${profileName}" will search that account lane, not the current plain ccs lane.` + ) + ); + log(info(` Account lane: ${accountConfigDir}`)); + log(info(` Plain ccs lane: ${plainLane.label} (${plainLane.configDir})`)); + if (resumeIntent.explicitSessionId) { + log( + info( + ' This explicit session ID may have been created in a different lane, so Claude may not find it here.' + ) + ); + } + log(info(' Recover the original lane first: ccs -r')); + log(info(' Back it up before changing setup: ccs auth backup default')); + log( + info(` For future work, align plain ccs with this account: ccs auth default ${profileName}`) + ); + log(''); + } catch (error) { + log( + warn( + 'Resume lane guidance skipped because diagnostics failed; continuing with the account lane.' + ) + ); + if (deps.debug ?? Boolean(process.env.CCS_DEBUG)) { + log(info(` Diagnostic error: ${(error as Error).message}`)); + } + } +} diff --git a/src/ccs.ts b/src/ccs.ts index bdd8487e..2806235f 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -64,6 +64,7 @@ import { tryHandleRootCommand } from './commands/root-command-router'; // Import extracted utility functions import { execClaude } from './utils/shell-executor'; import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; +import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; // Import target adapter system import { @@ -1269,6 +1270,7 @@ async function main(): Promise { CCS_WEBSEARCH_SKIP: '1', CCS_IMAGE_ANALYSIS_SKIP: '1', }; + await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, remainingArgs); const launchArgs = resolveNativeClaudeLaunchArgs(remainingArgs, 'account', instancePath); execClaude(claudeCli, launchArgs, envVars); } else { diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 1ca8d7f1..28bf315d 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -215,6 +215,7 @@ export const ROOT_COMMAND_FLAGS = [ ] as const; export const AUTH_SUBCOMMANDS = [ 'create', + 'backup', 'list', 'show', 'remove', diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index 04cbdc83..4399d9c0 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -94,6 +94,8 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg return completeSubcommands(ROOT_HELP_TOPICS.map((topic) => topic.name)); case 'auth': if (!subcommand) return completeSubcommands(AUTH_SUBCOMMANDS); + if (subcommand === 'backup') + return completeSubcommands(['default', ...getProfileNames('accounts')], ['--json']); if (subcommand === 'show') return completeSubcommands(getProfileNames('accounts'), ['--json']); if (subcommand === 'remove') diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index cd98a259..07137831 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -32,6 +32,7 @@ import { type MergedAccountEntry, } from './account-route-helpers'; import type { AccountConfig } from '../../config/unified-config-types'; +import { resolveConfiguredPlainCcsResumeLane } from '../../auth/resume-lane-diagnostics'; const router = Router(); @@ -59,7 +60,7 @@ function hasAuthAccount(name: string): boolean { /** * GET /api/accounts - List accounts from both profiles.json and config.yaml */ -router.get('/', (_req: Request, res: Response): void => { +router.get('/', async (_req: Request, res: Response): Promise => { try { const registry = createProfileRegistry(); @@ -147,8 +148,19 @@ router.get('/', (_req: Request, res: Response): void => { // Get default from unified config first, fallback to legacy const defaultProfile = registry.getDefaultUnified() ?? registry.getDefaultProfile() ?? null; + const plainCcsLane = await resolveConfiguredPlainCcsResumeLane(); - res.json({ accounts, default: defaultProfile }); + res.json({ + accounts, + default: defaultProfile, + plain_ccs_lane: { + kind: plainCcsLane.kind, + label: plainCcsLane.label, + account_name: plainCcsLane.accountName ?? null, + profile_name: plainCcsLane.profileName ?? null, + project_count: plainCcsLane.projectCount, + }, + }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } diff --git a/tests/unit/auth/backup-command.test.ts b/tests/unit/auth/backup-command.test.ts new file mode 100644 index 00000000..476b7509 --- /dev/null +++ b/tests/unit/auth/backup-command.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import ProfileRegistry from '../../../src/auth/profile-registry'; +import { InstanceManager } from '../../../src/management/instance-manager'; +import { handleBackup } from '../../../src/auth/commands/backup-command'; + +describe('auth backup command', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalUnified: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auth-backup-')); + originalCcsHome = process.env.CCS_HOME; + originalUnified = process.env.CCS_UNIFIED_CONFIG; + process.env.CCS_HOME = tempHome; + process.env.CCS_UNIFIED_CONFIG = '1'; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnified; + else delete process.env.CCS_UNIFIED_CONFIG; + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + function writeConfig(): void { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'accounts:', + ' work:', + ' created: "2026-04-04T00:00:00.000Z"', + ' context_mode: shared', + ' context_group: default', + ' continuity_mode: deeper', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + } + + it('creates a JSON backup for an auth account continuity lane', async () => { + writeConfig(); + const instanceMgr = new InstanceManager(); + const registry = new ProfileRegistry(); + const instancePath = await instanceMgr.ensureInstance('work', { + mode: 'shared', + group: 'default', + continuityMode: 'deeper', + }); + + fs.mkdirSync(path.join(instancePath, 'projects', 'demo-project'), { recursive: true }); + fs.writeFileSync( + path.join(instancePath, 'projects', 'demo-project', 'history.jsonl'), + '{}\n', + 'utf8' + ); + fs.writeFileSync(path.join(instancePath, 'todos', 'todo.md'), '- keep this\n', 'utf8'); + + const consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + try { + await handleBackup( + { + registry, + instanceMgr, + version: 'test', + }, + ['work', '--json'] + ); + } finally { + consoleSpy.mockRestore(); + } + + const backupBase = path.join(tempHome, '.ccs', 'backups', 'auth-continuity', 'work'); + const [latestBackupDir] = fs.readdirSync(backupBase).sort().reverse(); + expect(latestBackupDir).toBeTruthy(); + const backupPath = path.join(backupBase, latestBackupDir as string); + expect(fs.existsSync(path.join(backupPath, 'manifest.json'))).toBe(true); + expect( + fs.existsSync(path.join(backupPath, 'projects', 'demo-project', 'history.jsonl')) + ).toBe(true); + expect(fs.existsSync(path.join(backupPath, 'todos', 'todo.md'))).toBe(true); + }); +}); diff --git a/tests/unit/auth/resume-lane-diagnostics.test.ts b/tests/unit/auth/resume-lane-diagnostics.test.ts new file mode 100644 index 00000000..938455b8 --- /dev/null +++ b/tests/unit/auth/resume-lane-diagnostics.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + parseResumeFlagIntent, + resolveConfiguredPlainCcsResumeLane, +} from '../../../src/auth/resume-lane-diagnostics'; + +describe('resume lane diagnostics', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalUnified: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-resume-lane-')); + originalCcsHome = process.env.CCS_HOME; + originalUnified = process.env.CCS_UNIFIED_CONFIG; + process.env.CCS_HOME = tempHome; + process.env.CCS_UNIFIED_CONFIG = '1'; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnified; + else delete process.env.CCS_UNIFIED_CONFIG; + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + function writeConfig(lines: string[]): void { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), `${lines.join('\n')}\n`, 'utf8'); + } + + it('parses implicit and explicit resume flags', () => { + expect(parseResumeFlagIntent(['-r'])).toEqual({ implicit: true }); + expect(parseResumeFlagIntent(['--resume'])).toEqual({ implicit: true }); + expect(parseResumeFlagIntent(['--resume', 'abc123'])).toEqual({ + implicit: false, + explicitSessionId: 'abc123', + }); + expect(parseResumeFlagIntent(['--resume=xyz'])).toEqual({ + implicit: false, + explicitSessionId: 'xyz', + }); + expect(parseResumeFlagIntent(['hello'])).toBeNull(); + }); + + it('defaults to the native lane when no account/default mapping exists', async () => { + writeConfig([ + 'version: 12', + 'accounts: {}', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ]); + + const lane = await resolveConfiguredPlainCcsResumeLane(); + expect(lane.kind).toBe('native'); + expect(lane.configDir).toBe(path.join(tempHome, '.claude')); + }); + + it('resolves the plain ccs lane to a default account when configured', async () => { + writeConfig([ + 'version: 12', + 'default: work', + 'accounts:', + ' work:', + ' created: "2026-04-04T00:00:00.000Z"', + ' context_mode: shared', + ' context_group: default', + ' continuity_mode: deeper', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ]); + + const lane = await resolveConfiguredPlainCcsResumeLane(); + expect(lane.kind).toBe('account-default'); + expect(lane.accountName).toBe('work'); + expect(lane.configDir).toBe(path.join(tempHome, '.ccs', 'instances', 'work')); + }); + + it('resolves the plain ccs lane to inherited account continuity when configured', async () => { + writeConfig([ + 'version: 12', + 'accounts:', + ' work:', + ' created: "2026-04-04T00:00:00.000Z"', + ' context_mode: shared', + ' context_group: default', + ' continuity_mode: deeper', + 'profiles: {}', + 'continuity:', + ' inherit_from_account:', + ' default: work', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ]); + + const lane = await resolveConfiguredPlainCcsResumeLane(); + expect(lane.kind).toBe('account-inherited'); + expect(lane.accountName).toBe('work'); + expect(lane.configDir).toBe(path.join(tempHome, '.ccs', 'instances', 'work')); + }); + + it('resolves a non-account default profile through continuity inheritance', async () => { + writeConfig([ + 'version: 12', + 'default: glm', + 'accounts:', + ' work:', + ' created: "2026-04-04T00:00:00.000Z"', + 'profiles:', + ' glm:', + ' type: api', + ' settings: ~/.ccs/glm.settings.json', + 'continuity:', + ' inherit_from_account:', + ' glm: work', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ]); + + const lane = await resolveConfiguredPlainCcsResumeLane(); + expect(lane.kind).toBe('account-inherited'); + expect(lane.accountName).toBe('work'); + }); +}); diff --git a/tests/unit/auth/resume-lane-warning.test.ts b/tests/unit/auth/resume-lane-warning.test.ts new file mode 100644 index 00000000..49eb3064 --- /dev/null +++ b/tests/unit/auth/resume-lane-warning.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'bun:test'; +import { maybeWarnAboutResumeLaneMismatch } from '../../../src/auth/resume-lane-warning'; + +describe('resume lane warning', () => { + it('prints guidance when the plain ccs lane differs from the account lane', async () => { + const logs: string[] = []; + + await maybeWarnAboutResumeLaneMismatch('work', '/tmp/account-lane', ['--resume'], { + log: (message) => logs.push(message), + resolvePlainLane: async () => ({ + kind: 'native', + label: 'native Claude lane', + configDir: '/tmp/native-lane', + projectCount: 12, + }), + }); + + expect(logs[0]).toContain('Resume for account "work" will search that account lane'); + expect(logs).toContain('[i] Account lane: /tmp/account-lane'); + expect(logs).toContain('[i] Plain ccs lane: native Claude lane (/tmp/native-lane)'); + expect(logs).toContain('[i] Recover the original lane first: ccs -r'); + }); + + it('does not log anything when resume is not requested', async () => { + const logs: string[] = []; + + await maybeWarnAboutResumeLaneMismatch('work', '/tmp/account-lane', ['hello'], { + log: (message) => logs.push(message), + resolvePlainLane: async () => { + throw new Error('should not be called'); + }, + }); + + expect(logs).toHaveLength(0); + }); + + it('swallows diagnostic failures and keeps the warning path non-fatal', async () => { + const logs: string[] = []; + + await expect( + maybeWarnAboutResumeLaneMismatch('work', '/tmp/account-lane', ['-r'], { + debug: true, + log: (message) => logs.push(message), + resolvePlainLane: async () => { + throw new Error('broken config'); + }, + }) + ).resolves.toBeUndefined(); + + expect(logs[0]).toContain('Resume lane guidance skipped because diagnostics failed'); + expect(logs[1]).toContain('Diagnostic error: broken config'); + }); +}); diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index a864c290..5bb12d0a 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -15,7 +15,9 @@ function suggestionValues( current = '', shell: 'bash' | 'fish' | 'powershell' | 'zsh' = 'bash' ): string[] { - return getCompletionSuggestions({ tokensBeforeCurrent, current, shell }).map((entry) => entry.value); + return getCompletionSuggestions({ tokensBeforeCurrent, current, shell }).map( + (entry) => entry.value + ); } beforeEach(() => { @@ -100,7 +102,9 @@ describe('completion backend', () => { test('suggests lifecycle subcommands for api', () => { const values = suggestionValues(['api']); - expect(values).toEqual(expect.arrayContaining(['create', 'discover', 'copy', 'export', 'import', 'remove'])); + expect(values).toEqual( + expect.arrayContaining(['create', 'discover', 'copy', 'export', 'import', 'remove']) + ); }); test('suggests account profiles for auth show', () => { @@ -109,6 +113,13 @@ describe('completion backend', () => { expect(values).toContain('--json'); }); + test('suggests default lane and account profiles for auth backup', () => { + const values = suggestionValues(['auth', 'backup']); + expect(values).toContain('default'); + expect(values).toContain('work'); + expect(values).toContain('--json'); + }); + test('suggests cliproxy variants for variant-scoped commands', () => { const values = suggestionValues(['cliproxy', 'edit']); expect(values).toContain('my-codex'); @@ -116,7 +127,9 @@ describe('completion backend', () => { test('suggests env format values after the format flag', () => { const values = suggestionValues(['env', '--format']); - expect(values).toEqual(expect.arrayContaining(['openai', 'anthropic', 'raw', 'claude-extension'])); + expect(values).toEqual( + expect.arrayContaining(['openai', 'anthropic', 'raw', 'claude-extension']) + ); }); test('includes live doctor and cliproxy flags from the shared catalog', () => { diff --git a/tests/unit/web-server/account-routes-context.test.ts b/tests/unit/web-server/account-routes-context.test.ts index 8e35607c..093a6b14 100644 --- a/tests/unit/web-server/account-routes-context.test.ts +++ b/tests/unit/web-server/account-routes-context.test.ts @@ -164,6 +164,104 @@ describe('web-server account-routes context normalization', () => { expect(work?.continuity_inferred).toBe(true); }); + it('reports the plain ccs lane as native when no account default or inheritance exists', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const payload = await getJson<{ + plain_ccs_lane?: { + kind: string; + account_name?: string | null; + }; + }>(baseUrl, '/api/accounts'); + + expect(payload.plain_ccs_lane?.kind).toBe('native'); + expect(payload.plain_ccs_lane?.account_name).toBeNull(); + }); + + it('reports the plain ccs lane as an account when default profile is an auth account', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'default: work', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' context_mode: shared', + ' context_group: default', + ' continuity_mode: deeper', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const payload = await getJson<{ + plain_ccs_lane?: { + kind: string; + account_name?: string | null; + }; + }>(baseUrl, '/api/accounts'); + + expect(payload.plain_ccs_lane?.kind).toBe('account-default'); + expect(payload.plain_ccs_lane?.account_name).toBe('work'); + }); + + it('does not initialize an account instance as a side effect of listing accounts', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'default: work', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' context_mode: shared', + ' context_group: default', + ' continuity_mode: deeper', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const instancePath = path.join(ccsDir, 'instances', 'work'); + expect(fs.existsSync(instancePath)).toBe(false); + + await getJson(baseUrl, '/api/accounts'); + + expect(fs.existsSync(instancePath)).toBe(false); + }); + it('does not delete metadata when instance deletion fails', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); diff --git a/ui/src/components/account/accounts-table.tsx b/ui/src/components/account/accounts-table.tsx index 395fab07..0a409a6f 100644 --- a/ui/src/components/account/accounts-table.tsx +++ b/ui/src/components/account/accounts-table.tsx @@ -14,6 +14,7 @@ import { TableRow, } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { AlertDialog, AlertDialogAction, @@ -33,23 +34,31 @@ import { useResetDefaultAccount, useUpdateAccountContext, } from '@/hooks/use-accounts'; -import type { Account } from '@/lib/api-client'; +import type { AuthAccountRow, SharedGroupSummary } from '@/lib/account-continuity'; +import type { PlainCcsLane } from '@/lib/api-client'; interface AccountsTableProps { - data: Account[]; + data: AuthAccountRow[]; defaultAccount: string | null; + groupSummaries: SharedGroupSummary[]; + plainCcsLane: PlainCcsLane | null; } -export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { +export function AccountsTable({ + data, + defaultAccount, + groupSummaries, + plainCcsLane, +}: AccountsTableProps) { const { t } = useTranslation(); const setDefaultMutation = useSetDefaultAccount(); const deleteMutation = useDeleteAccount(); const resetDefaultMutation = useResetDefaultAccount(); const updateContextMutation = useUpdateAccountContext(); const [deleteTarget, setDeleteTarget] = useState(null); - const [contextTarget, setContextTarget] = useState(null); + const [contextTarget, setContextTarget] = useState(null); - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = [ { accessorKey: 'name', header: t('accountsTable.name'), @@ -104,38 +113,55 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { const mode = row.original.context_mode || 'isolated'; if (mode === 'shared') { const group = row.original.context_group || 'default'; - if (row.original.continuity_mode === 'deeper') { - return ( - - {t('accountsTable.sharedGroupDeeper', { group })} - - ); - } - - if (row.original.continuity_inferred) { - return ( - - {t('accountsTable.sharedGroupLegacy', { group })} - - ); - } - + const isDeeper = row.original.continuity_mode === 'deeper'; return ( - - {t('accountsTable.sharedGroupStandard', { group })} - +
+
+ + {isDeeper ? t('accountsTable.badges.deeper') : t('accountsTable.badges.shared')} + + {group} +
+

+ {row.original.sameGroupPeerCount > 0 + ? t('accountsTable.sameGroupPeerCount', { + count: row.original.sameGroupPeerCount, + }) + : t('accountsTable.noSameGroupPeer')} +

+
); } if (row.original.context_inferred) { return ( - - {t('accountsTable.isolatedLegacy')} - +
+ + {t('accountsTable.badges.legacy')} + +

+ {t('accountsTable.legacyReview')} +

+
); } - return {t('accountsTable.isolated')}; + return ( +
+ + {t('accountsTable.badges.isolated')} + +
+ ); }, }, { @@ -299,7 +325,12 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { {contextTarget && ( - setContextTarget(null)} /> + setContextTarget(null)} + /> )} {/* Delete confirmation dialog */} diff --git a/ui/src/components/account/continuity-overview.tsx b/ui/src/components/account/continuity-overview.tsx new file mode 100644 index 00000000..539626bc --- /dev/null +++ b/ui/src/components/account/continuity-overview.tsx @@ -0,0 +1,324 @@ +import { + AlertTriangle, + CheckCircle2, + Info, + Link2, + ShieldAlert, + Unlink, + Waves, + ArrowRight, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { CopyButton } from '@/components/ui/copy-button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import type { PlainCcsLane } from '@/lib/api-client'; + +interface ContinuityOverviewProps { + totalAccounts: number; + primaryAccountName?: string | null; + isolatedCount: number; + sharedStandardCount: number; + deeperSharedCount: number; + sharedAloneCount: number; + sharedPeerAccountCount: number; // The accounts in a viable group + deeperReadyAccountCount: number; + sharedGroups: string[]; + sharedPeerGroups: string[]; + deeperReadyGroups: string[]; + legacyTargetCount: number; + cliproxyCount: number; + plainCcsLane: PlainCcsLane | null; +} + +type ReadinessState = + | 'single' + | 'isolated' + | 'shared-alone' + | 'shared-standard' + | 'partial' + | 'ready'; + +const InfoTooltip = ({ titleKey, descKey }: { titleKey: string; descKey: string }) => { + const { t } = useTranslation(); + return ( + + + + + +

{t(titleKey)}

+

{t(descKey)}

+
+
+ ); +}; + +export function ContinuityOverview({ + totalAccounts, + primaryAccountName, + isolatedCount, + sharedStandardCount, + deeperSharedCount, + sharedAloneCount, + sharedPeerAccountCount, + deeperReadyAccountCount, + sharedPeerGroups, + deeperReadyGroups, + legacyTargetCount, + cliproxyCount, + plainCcsLane, +}: ContinuityOverviewProps) { + const { t } = useTranslation(); + const laneLabel = plainCcsLane + ? plainCcsLane.kind === 'native' + ? t('continuityOverview.lane.native') + : plainCcsLane.kind === 'account-default' + ? t('continuityOverview.lane.accountDefault', { + name: plainCcsLane.account_name || '', + }) + : plainCcsLane.kind === 'account-inherited' + ? t('continuityOverview.lane.accountInherited', { + name: plainCcsLane.account_name || '', + }) + : plainCcsLane.kind === 'profile-default' + ? t('continuityOverview.lane.profileDefault', { + name: plainCcsLane.profile_name || 'default', + }) + : plainCcsLane.label + : ''; + + const readiness: ReadinessState = + totalAccounts < 2 + ? 'single' + : sharedPeerGroups.length === 0 + ? isolatedCount === totalAccounts + ? 'isolated' + : 'shared-alone' + : deeperReadyGroups.length === 0 + ? 'shared-standard' + : isolatedCount > 0 || + sharedAloneCount > 0 || + deeperReadyAccountCount < sharedPeerAccountCount || + deeperReadyGroups.length < sharedPeerGroups.length + ? 'partial' + : 'ready'; + + const highlightGroup = deeperReadyGroups[0] || sharedPeerGroups[0] || 'default'; + const hasMixedState = + deeperReadyGroups.length > 0 && + (isolatedCount > 0 || + sharedStandardCount > 0 || + sharedPeerGroups.length > deeperReadyGroups.length); + + const iconMap = { + ready: , + 'shared-standard': , + single: , + isolated: , + 'shared-alone': , + partial: , + }; + + const currentIcon = iconMap[readiness]; + const plainLaneUsesPrimaryAccount = + !!primaryAccountName && plainCcsLane?.account_name === primaryAccountName; + const showPlainLaneRecovery = + totalAccounts > 0 && !!plainCcsLane && (!primaryAccountName || !plainLaneUsesPrimaryAccount); + const showSharedRecoverySteps = totalAccounts > 1 && readiness !== 'ready'; + + return ( +
+ {/* Primary Status Bento Box */} + + +
+
+
+ {currentIcon} +
+
+
+

+ {t(`continuityReadiness.messages.${readiness}.title`, { + group: highlightGroup, + })} +

+ + {t(`continuityReadiness.state.${readiness}`)} + +
+

+ {t(`continuityReadiness.messages.${readiness}.description`, { + group: highlightGroup, + count: sharedAloneCount, + })} +

+
+
+
+ +
+ {cliproxyCount > 0 && ( + + {t('historySyncLearningMap.cliproxyManaged', { count: cliproxyCount })} + + )} + {legacyTargetCount > 0 && ( + + {t('historySyncLearningMap.legacyConfirmation', { count: legacyTargetCount })} + + )} + {sharedPeerGroups.length > 0 && deeperReadyGroups.length === 0 && ( + + {t('continuityOverview.recommendBadge', { group: highlightGroup })} + + )} + {hasMixedState && ( + + {t('continuityOverview.partialBadge', { group: highlightGroup })} + + )} +
+
+
+ + {(showPlainLaneRecovery || showSharedRecoverySteps) && ( + + + {showPlainLaneRecovery && ( +
+
+

+ {t('continuityOverview.plainLaneTitle')} +

+

+ {t('continuityOverview.plainLaneDescription', { + lane: laneLabel || 'plain ccs', + })} +

+
+
+
+ ccs -r + +
+
+ ccs auth backup default + +
+ {primaryAccountName ? ( +
+ + {`ccs auth default ${primaryAccountName}`} + + +
+ ) : ( +

+ {t('continuityOverview.setDefaultHint')} +

+ )} +
+
+ )} + + {showSharedRecoverySteps && ( +
+

+ {t('continuityReadiness.stepsTitle')} +

+
    +
  1. {t('continuityReadiness.steps.syncBoth')}
  2. +
  3. {t('continuityReadiness.steps.sameGroup', { group: highlightGroup })}
  4. +
  5. {t('continuityReadiness.steps.enableDeeper')}
  6. +
  7. {t('continuityReadiness.steps.resumeOriginal')}
  8. +
+
+ )} +
+
+ )} + + {/* Horizontal Progression Chain */} +
+
+
+ + + {t('historySyncLearningMap.isolated')} + + +
+ + {isolatedCount} + +
+ + + +
+
+ + + {t('historySyncLearningMap.shared')} + + +
+ + {sharedStandardCount} + +
+ + + +
+
+ + + {t('historySyncLearningMap.deeper')} + + +
+ + {deeperSharedCount} + +
+
+
+ ); +} diff --git a/ui/src/components/account/edit-account-context-dialog.tsx b/ui/src/components/account/edit-account-context-dialog.tsx index 650c76e0..c85ecbce 100644 --- a/ui/src/components/account/edit-account-context-dialog.tsx +++ b/ui/src/components/account/edit-account-context-dialog.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useState, type KeyboardEvent } from 'react'; import { Dialog, DialogContent, @@ -10,16 +10,13 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; + +import { Unlink, Link2, Waves, ShieldAlert, Info } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import type { Account } from '@/lib/api-client'; +import type { AuthAccountRow, SharedGroupSummary } from '@/lib/account-continuity'; +import type { PlainCcsLane } from '@/lib/api-client'; import { useUpdateAccountContext } from '@/hooks/use-accounts'; +import { CopyButton } from '@/components/ui/copy-button'; type ContextMode = 'isolated' | 'shared'; type ContinuityMode = 'standard' | 'deeper'; @@ -28,11 +25,18 @@ const MAX_CONTEXT_GROUP_LENGTH = 64; const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; interface EditAccountContextDialogProps { - account: Account; + account: AuthAccountRow; + groupSummaries: SharedGroupSummary[]; + plainCcsLane: PlainCcsLane | null; onClose: () => void; } -export function EditAccountContextDialog({ account, onClose }: EditAccountContextDialogProps) { +export function EditAccountContextDialog({ + account, + groupSummaries, + plainCcsLane, + onClose, +}: EditAccountContextDialogProps) { const { t } = useTranslation(); const updateContextMutation = useUpdateAccountContext(); const [mode, setMode] = useState( @@ -44,11 +48,72 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex ); const normalizedGroup = useMemo(() => group.trim().toLowerCase().replace(/\s+/g, '-'), [group]); + const matchingGroup = useMemo( + () => groupSummaries.find((summary) => summary.group === normalizedGroup), + [groupSummaries, normalizedGroup] + ); const isSharedGroupValid = normalizedGroup.length > 0 && normalizedGroup.length <= MAX_CONTEXT_GROUP_LENGTH && CONTEXT_GROUP_PATTERN.test(normalizedGroup); const canSubmit = mode === 'isolated' || isSharedGroupValid; + const sameGroupPeerCount = + mode === 'shared' + ? Math.max( + (matchingGroup?.sharedCount ?? 0) - + (account.context_mode === 'shared' && account.context_group === normalizedGroup + ? 1 + : 0), + 0 + ) + : 0; + const sameGroupDeeperPeerCount = + mode === 'shared' + ? Math.max( + (matchingGroup?.deeperCount ?? 0) - + (account.continuity_mode === 'deeper' && account.context_group === normalizedGroup + ? 1 + : 0), + 0 + ) + : 0; + const plainLaneUsesThisAccount = plainCcsLane?.account_name === account.name; + const showPlainLaneMismatch = !!plainCcsLane && !plainLaneUsesThisAccount; + const defaultCommand = `ccs auth default ${account.name}`; + const accountBackupCommand = `ccs auth backup ${account.name}`; + const handleRadioKeyDown = ( + event: KeyboardEvent, + current: T, + values: readonly T[], + setValue: (value: T) => void + ) => { + if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) { + return; + } + + event.preventDefault(); + const currentIndex = values.indexOf(current); + const direction = event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -1 : 1; + const nextIndex = (currentIndex + direction + values.length) % values.length; + setValue(values[nextIndex] as T); + }; + const laneLabel = plainCcsLane + ? plainCcsLane.kind === 'native' + ? t('continuityOverview.lane.native') + : plainCcsLane.kind === 'account-default' + ? t('continuityOverview.lane.accountDefault', { + name: plainCcsLane.account_name || '', + }) + : plainCcsLane.kind === 'account-inherited' + ? t('continuityOverview.lane.accountInherited', { + name: plainCcsLane.account_name || '', + }) + : plainCcsLane.kind === 'profile-default' + ? t('continuityOverview.lane.profileDefault', { + name: plainCcsLane.profile_name || 'default', + }) + : plainCcsLane.label + : ''; const handleSave = () => { if (!canSubmit) { @@ -88,20 +153,55 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
- - -

- {mode === 'shared' - ? t('editAccountContext.sharedModeHint') - : t('editAccountContext.isolatedModeHint')} +

+ +
+
+ + +
+

+ {mode === 'isolated' + ? t('editAccountContext.isolatedModeHint') + : t('editAccountContext.sharedModeHint')}

@@ -126,23 +226,67 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex {mode === 'shared' && (
- - -

- {continuityMode === 'deeper' - ? t('editAccountContext.deeperHint') - : t('editAccountContext.standardHint')} + + +

+

+ {continuityMode === 'standard' + ? t('editAccountContext.standardHint') + : t('editAccountContext.deeperHint')}

)} @@ -150,6 +294,88 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex

{t('editAccountContext.credentialsIsolated')}

+ + {showPlainLaneMismatch && ( +
+
+

+ {t('continuityOverview.plainLaneTitle')} +

+

+ {t('continuityOverview.plainLaneDescription', { + lane: laneLabel || 'plain ccs', + name: account.name, + })} +

+
+
+ ccs -r + +
+
+ ccs auth backup default + +
+
+ {accountBackupCommand} + +
+
+ {defaultCommand} + +
+
+
+
+ )} + +
+
+ {mode === 'isolated' ? ( + + ) : ( + + )} +
+ {mode === 'isolated' ? ( +

+ {t('editAccountContext.isolatedImplication')} +

+ ) : ( + <> +

+ + {t('editAccountContext.sameGroupRule', { group: normalizedGroup })} + {' '} + {sameGroupPeerCount > 0 + ? t('editAccountContext.sameGroupPeerCount', { count: sameGroupPeerCount }) + : t('editAccountContext.noSameGroupPeer')} +

+ {continuityMode === 'deeper' && ( +

+ {sameGroupDeeperPeerCount > 0 ? ( + t('editAccountContext.deeperReady', { + count: sameGroupDeeperPeerCount, + }) + ) : ( + + {t('editAccountContext.deeperNeedsPeers')} + + )} +

+ )} + + )} +

+ {t('editAccountContext.resumeOriginalWarning')} +

+
+
+
diff --git a/ui/src/components/account/history-sync-learning-map.tsx b/ui/src/components/account/history-sync-learning-map.tsx deleted file mode 100644 index cc8b31f5..00000000 --- a/ui/src/components/account/history-sync-learning-map.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { useState } from 'react'; -import { - ArrowRight, - ArrowRightLeft, - ChevronDown, - Layers3, - Link2, - Unlink, - Waves, - type LucideIcon, -} from 'lucide-react'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { useTranslation } from 'react-i18next'; -import { cn } from '@/lib/utils'; - -interface HistorySyncLearningMapProps { - isolatedCount: number; - sharedStandardCount: number; - deeperSharedCount: number; - sharedGroups: string[]; - legacyTargetCount: number; - cliproxyCount: number; -} - -type StageTone = 'isolated' | 'shared' | 'deeper'; - -function StageTile({ - title, - count, - icon: Icon, - tone, -}: { - title: string; - count: number; - icon: LucideIcon; - tone: StageTone; -}) { - const toneClasses: Record = { - isolated: { - border: 'border-blue-300/60 bg-blue-50/40 dark:border-blue-900/40 dark:bg-blue-900/10', - icon: 'text-blue-700 dark:text-blue-400', - count: 'text-blue-700 dark:text-blue-400', - }, - shared: { - border: - 'border-emerald-300/60 bg-emerald-50/40 dark:border-emerald-900/40 dark:bg-emerald-900/10', - icon: 'text-emerald-700 dark:text-emerald-400', - count: 'text-emerald-700 dark:text-emerald-400', - }, - deeper: { - border: - 'border-indigo-300/60 bg-indigo-50/40 dark:border-indigo-900/40 dark:bg-indigo-900/10', - icon: 'text-indigo-700 dark:text-indigo-400', - count: 'text-indigo-700 dark:text-indigo-400', - }, - }; - - return ( -
-
-

{title}

- -
-

{count}

-
- ); -} - -export function HistorySyncLearningMap({ - isolatedCount, - sharedStandardCount, - deeperSharedCount, - sharedGroups, - legacyTargetCount, - cliproxyCount, -}: HistorySyncLearningMapProps) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const groupsToShow = sharedGroups.length > 0 ? sharedGroups : ['default']; - - return ( - - -
-
- {t('historySyncLearningMap.title')} - - {t('historySyncLearningMap.description')} - -
- {t('historySyncLearningMap.learningMap')} -
-
- - - {cliproxyCount > 0 && ( -
- {t('historySyncLearningMap.cliproxyManaged', { count: cliproxyCount })} -
- )} - -
- -
- -
- -
- -
- -
- - - - - - -
-
-
- -

{t('historySyncLearningMap.modeSwitch')}

-
-

- {t('historySyncLearningMap.modeSwitchDesc')} -

-
- -
-
- -

{t('historySyncLearningMap.historySyncGroup')}

-
-

- {t('historySyncLearningMap.historySyncGroupDesc')} -

-
- {groupsToShow.map((group) => ( - - {group} - - ))} -
-
-
- - {legacyTargetCount > 0 && ( -
- {t('historySyncLearningMap.legacyConfirmation', { count: legacyTargetCount })} -
- )} -
-
-
-
- ); -} diff --git a/ui/src/hooks/use-accounts.ts b/ui/src/hooks/use-accounts.ts index 282d1aa3..83193022 100644 --- a/ui/src/hooks/use-accounts.ts +++ b/ui/src/hooks/use-accounts.ts @@ -5,11 +5,16 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '@/lib/api-client'; -import type { Account } from '@/lib/api-client'; +import type { Account, PlainCcsLane } from '@/lib/api-client'; +import { + summarizeAuthAccountContinuity, + type AuthAccountRow, + type SharedGroupSummary, +} from '@/lib/account-continuity'; import { toast } from 'sonner'; export interface AuthAccountsView { - accounts: Account[]; + accounts: AuthAccountRow[]; default: string | null; cliproxyCount: number; legacyContextCount: number; @@ -18,6 +23,14 @@ export interface AuthAccountsView { sharedStandardCount: number; deeperSharedCount: number; isolatedCount: number; + sharedAloneCount: number; + sharedPeerAccountCount: number; + deeperReadyAccountCount: number; + sharedPeerGroups: string[]; + deeperReadyGroups: string[]; + sharedGroups: string[]; + groupSummaries: SharedGroupSummary[]; + plainCcsLane: PlainCcsLane | null; } export function useAccounts() { @@ -26,36 +39,30 @@ export function useAccounts() { queryFn: () => api.accounts.list(), select: (data): AuthAccountsView => { const authAccounts = data.accounts.filter((account) => account.type !== 'cliproxy'); + const continuity = summarizeAuthAccountContinuity(authAccounts); const cliproxyCount = data.accounts.length - authAccounts.length; - const sharedCount = authAccounts.filter( - (account) => account.context_mode === 'shared' - ).length; - const deeperSharedCount = authAccounts.filter( - (account) => account.context_mode === 'shared' && account.continuity_mode === 'deeper' - ).length; - const sharedStandardCount = Math.max(sharedCount - deeperSharedCount, 0); - const isolatedCount = authAccounts.length - sharedCount; - const legacyContextCount = authAccounts.filter((account) => account.context_inferred).length; - const legacyContinuityCount = authAccounts.filter( - (account) => - account.context_mode === 'shared' && - account.continuity_mode !== 'deeper' && - account.continuity_inferred - ).length; - const defaultAccount = authAccounts.some((account) => account.name === data.default) + const defaultAccount = continuity.accounts.some((account) => account.name === data.default) ? data.default : null; return { - accounts: authAccounts, + accounts: continuity.accounts, default: defaultAccount, cliproxyCount, - legacyContextCount, - legacyContinuityCount, - sharedCount, - sharedStandardCount, - deeperSharedCount, - isolatedCount, + legacyContextCount: continuity.legacyContextCount, + legacyContinuityCount: continuity.legacyContinuityCount, + sharedCount: continuity.sharedCount, + sharedStandardCount: continuity.sharedStandardCount, + deeperSharedCount: continuity.deeperSharedCount, + isolatedCount: continuity.isolatedCount, + sharedAloneCount: continuity.sharedAloneCount, + sharedPeerAccountCount: continuity.sharedPeerAccountCount, + deeperReadyAccountCount: continuity.deeperReadyAccountCount, + sharedPeerGroups: continuity.sharedPeerGroups, + deeperReadyGroups: continuity.deeperReadyGroups, + sharedGroups: continuity.sharedGroups, + groupSummaries: continuity.groupSummaries, + plainCcsLane: data.plain_ccs_lane ?? null, }; }, }); @@ -146,23 +153,40 @@ export function useConfirmLegacyAccountPolicies() { (account) => account.context_inferred || account.continuity_inferred ); - for (const account of legacyTargets) { - const isShared = account.context_mode === 'shared'; - await api.accounts.updateContext(account.name, { - context_mode: isShared ? 'shared' : 'isolated', - context_group: isShared ? account.context_group || 'default' : undefined, - continuity_mode: isShared - ? account.continuity_mode === 'deeper' - ? 'deeper' - : 'standard' - : undefined, - }); + const results = await Promise.allSettled( + legacyTargets.map((account) => { + const isShared = account.context_mode === 'shared'; + return api.accounts.updateContext(account.name, { + context_mode: isShared ? 'shared' : 'isolated', + context_group: isShared ? account.context_group || 'default' : undefined, + continuity_mode: isShared + ? account.continuity_mode === 'deeper' + ? 'deeper' + : 'standard' + : undefined, + }); + }) + ); + + const failed = results.filter((result) => result.status === 'rejected').length; + return { updatedCount: legacyTargets.length - failed, failedCount: failed }; + }, + onSuccess: ({ updatedCount, failedCount }) => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + if (failedCount > 0 && updatedCount > 0) { + toast.error( + `Confirmed ${updatedCount} legacy account${updatedCount > 1 ? 's' : ''}, but ${failedCount} update${failedCount > 1 ? 's' : ''} failed. Refreshed account state.` + ); + return; + } + + if (failedCount > 0) { + toast.error( + `Failed to confirm ${failedCount} legacy account${failedCount > 1 ? 's' : ''}. Refreshed account state.` + ); + return; } - return { updatedCount: legacyTargets.length }; - }, - onSuccess: ({ updatedCount }) => { - queryClient.invalidateQueries({ queryKey: ['accounts'] }); if (updatedCount > 0) { toast.success( `Confirmed explicit sync mode for ${updatedCount} legacy account${updatedCount > 1 ? 's' : ''}` @@ -173,6 +197,7 @@ export function useConfirmLegacyAccountPolicies() { toast.info('No legacy accounts need confirmation'); }, onError: (error: Error) => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); toast.error(error.message); }, }); diff --git a/ui/src/lib/account-continuity.ts b/ui/src/lib/account-continuity.ts new file mode 100644 index 00000000..c1ac4f4c --- /dev/null +++ b/ui/src/lib/account-continuity.ts @@ -0,0 +1,124 @@ +import type { Account } from '@/lib/api-client'; + +export interface SharedGroupSummary { + group: string; + sharedCount: number; + deeperCount: number; + accountNames: string[]; +} + +export interface AuthAccountRow extends Account { + sameGroupPeerCount: number; + sameGroupDeeperPeerCount: number; +} + +export interface AuthAccountContinuitySummary { + accounts: AuthAccountRow[]; + sharedCount: number; + sharedStandardCount: number; + deeperSharedCount: number; + isolatedCount: number; + legacyContextCount: number; + legacyContinuityCount: number; + sharedAloneCount: number; + sharedPeerAccountCount: number; + deeperReadyAccountCount: number; + sharedPeerGroups: string[]; + deeperReadyGroups: string[]; + sharedGroups: string[]; + groupSummaries: SharedGroupSummary[]; +} + +function sortGroups(groups: Iterable): string[] { + return Array.from(groups).sort((left, right) => left.localeCompare(right)); +} + +export function summarizeAuthAccountContinuity(accounts: Account[]): AuthAccountContinuitySummary { + const groupMap = new Map(); + + for (const account of accounts) { + if (account.context_mode !== 'shared') { + continue; + } + + const group = account.context_group || 'default'; + const summary = groupMap.get(group) ?? { + group, + sharedCount: 0, + deeperCount: 0, + accountNames: [], + }; + summary.sharedCount += 1; + summary.accountNames.push(account.name); + if (account.continuity_mode === 'deeper') { + summary.deeperCount += 1; + } + groupMap.set(group, summary); + } + + const groupSummaries = Array.from(groupMap.values()).sort((left, right) => + left.group.localeCompare(right.group) + ); + + const derivedAccounts: AuthAccountRow[] = accounts.map((account) => { + if (account.context_mode !== 'shared') { + return { + ...account, + sameGroupPeerCount: 0, + sameGroupDeeperPeerCount: 0, + }; + } + + const group = account.context_group || 'default'; + const groupSummary = groupMap.get(group); + const sameGroupPeerCount = Math.max((groupSummary?.sharedCount ?? 1) - 1, 0); + const sameGroupDeeperPeerCount = Math.max( + (groupSummary?.deeperCount ?? 0) - (account.continuity_mode === 'deeper' ? 1 : 0), + 0 + ); + + return { + ...account, + sameGroupPeerCount, + sameGroupDeeperPeerCount, + }; + }); + + const sharedCount = derivedAccounts.filter((account) => account.context_mode === 'shared').length; + const deeperSharedCount = derivedAccounts.filter( + (account) => account.context_mode === 'shared' && account.continuity_mode === 'deeper' + ).length; + const legacyContextCount = derivedAccounts.filter((account) => account.context_inferred).length; + const legacyContinuityCount = derivedAccounts.filter( + (account) => + account.context_mode === 'shared' && + account.continuity_mode !== 'deeper' && + account.continuity_inferred + ).length; + + return { + accounts: derivedAccounts, + sharedCount, + sharedStandardCount: Math.max(sharedCount - deeperSharedCount, 0), + deeperSharedCount, + isolatedCount: derivedAccounts.length - sharedCount, + legacyContextCount, + legacyContinuityCount, + sharedAloneCount: derivedAccounts.filter( + (account) => account.context_mode === 'shared' && account.sameGroupPeerCount === 0 + ).length, + sharedPeerAccountCount: derivedAccounts.filter((account) => account.sameGroupPeerCount > 0) + .length, + deeperReadyAccountCount: derivedAccounts.filter( + (account) => account.continuity_mode === 'deeper' && account.sameGroupDeeperPeerCount > 0 + ).length, + sharedPeerGroups: sortGroups( + groupSummaries.filter((group) => group.sharedCount >= 2).map((group) => group.group) + ), + deeperReadyGroups: sortGroups( + groupSummaries.filter((group) => group.deeperCount >= 2).map((group) => group.group) + ), + sharedGroups: sortGroups(groupSummaries.map((group) => group.group)), + groupSummaries, + }; +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 24876be9..7c9c5e8a 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -733,6 +733,14 @@ export interface Account { displayName?: string; } +export interface PlainCcsLane { + kind: 'native' | 'account-default' | 'account-inherited' | 'profile-default' | 'ambient'; + label: string; + account_name?: string | null; + profile_name?: string | null; + project_count: number; +} + export interface UpdateAccountContext { context_mode: 'isolated' | 'shared'; context_group?: string; @@ -1120,7 +1128,10 @@ export const api = { }, }, accounts: { - list: () => request<{ accounts: Account[]; default: string | null }>('/accounts'), + list: () => + request<{ accounts: Account[]; default: string | null; plain_ccs_lane?: PlainCcsLane }>( + '/accounts' + ), setDefault: (name: string) => request('/accounts/default', { method: 'POST', diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 8038a835..36034951 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -401,12 +401,10 @@ const resources = { 'Configure how "{{name}}" shares history and continuity with other ccs auth accounts.', syncMode: 'Sync Mode', selectContextMode: 'Select context mode', - isolatedOption: 'isolated (no sync)', - sharedOption: 'shared (sync enabled)', - sharedModeHint: - 'Shared mode reuses workspace context for accounts in the same history sync group.', - isolatedModeHint: - 'Isolated mode keeps this account fully separate from other ccs auth accounts.', + isolatedOption: 'Isolated', + sharedOption: 'Shared', + sharedModeHint: 'Reuses workspace context across accounts in the same history group.', + isolatedModeHint: 'Keeps this account fully separate from other ccs auth accounts.', historySyncGroup: 'History Sync Group', groupPlaceholder: 'default', groupHint: @@ -414,19 +412,37 @@ const resources = { invalidGroup: 'Enter a valid group name that starts with a letter.', continuityDepth: 'Continuity Depth', selectContinuityDepth: 'Select continuity depth', - standardOption: 'standard (projects only)', - deeperOption: 'deeper continuity (advanced)', - deeperHint: - 'Advanced mode also syncs session-env, file-history, shell-snapshots, and todos.', + standardOption: 'Standard', + deeperOption: 'Deeper', + deeperHint: 'Syncs comprehensive session-env, file-history, shell-snapshots, and todos.', standardHint: 'Standard mode syncs project workspace context only.', credentialsIsolated: 'Credentials and .anthropic remain isolated per account in all modes.', + implicationTitle: 'What this means after save', + isolatedImplication: + 'This account stays separate. Other accounts will not be able to resume its continuity state.', + sameGroupRule: + 'Accounts must use the same group "{{group}}" before they can share continuity with each other.', + noSameGroupPeer: 'No other account currently shares this group.', + sameGroupPeerCount_one: '{{count}} other account already shares this group.', + sameGroupPeerCount_other: '{{count}} other accounts already share this group.', + deeperReady_one: + '{{count}} same-group account already uses deeper continuity. This is the strongest available handoff setup.', + deeperReady_other: + '{{count}} same-group accounts already use deeper continuity. This is the strongest available handoff setup.', + deeperNeedsPeers: + 'Deeper continuity is enabled here, but another account in this group still needs deeper continuity for stronger cross-account resume expectations.', + standardWarning: + 'Standard shared mode is good for project context only. Cross-account resume expectations are stronger when both accounts use deeper continuity.', + resumeOriginalWarning: + 'If the old conversation matters, resume it from the original account before you change the setup.', cancel: 'Cancel', save: 'Save', saving: 'Saving...', }, historySyncLearningMap: { title: 'How History Sync Works', - description: 'Isolated -> Shared -> Deeper. Use Sync per row for all changes.', + description: + 'Isolated -> Shared -> Deeper. Cross-account resume needs the same group on both accounts.', learningMap: 'Learning Map', isolated: 'Isolated', shared: 'Shared', @@ -438,13 +454,107 @@ const resources = { showDetails: 'Show details: groups, switching, and legacy policy', modeSwitch: 'Mode Switch', modeSwitchDesc: - 'Sync dialog lets users move between isolated/shared and choose deeper continuity.', + 'Use Sync on each account to move between isolated/shared and choose deeper continuity.', historySyncGroup: 'History Sync Group', historySyncGroupDesc: - 'Same group means shared project context lane. Default fallback is default.', + 'Same group is required before accounts can share continuity. Default fallback is default.', + sameGroupRule: + 'Right now, accounts only share continuity when both are shared in the same group.', + deeperRecommendation: + 'Group "{{group}}" is shared, but deeper continuity is still the safer choice if users expect cross-account resume.', + partialGroup: + 'Group "{{group}}" is in good shape, but other accounts or groups still need the same-group or deeper setup.', + readyGroup: + 'Group "{{group}}" already has deeper continuity on multiple accounts. This is the strongest available handoff setup.', legacyConfirmation_one: '{{count}} legacy account still needs explicit confirmation.', legacyConfirmation_other: '{{count}} legacy accounts still need explicit confirmation.', }, + continuityReadiness: { + title: 'Cross-Account Resume Check', + description: 'Use this card to see what is missing before users switch accounts.', + state: { + single: 'single account', + isolated: 'resume off', + 'shared-alone': 'shared but incomplete', + 'shared-standard': 'projects only', + partial: 'partially ready', + ready: 'stronger handoff ready', + }, + metrics: { + isolated: 'Isolated', + sharedPeers: 'Shared With Peer', + deeperReady: 'Deeper Ready', + }, + messages: { + single: { + title: 'Only one auth account is configured.', + description: + 'Cross-account handoff does not apply yet. Add another auth account before configuring shared continuity.', + }, + isolated: { + title: 'Cross-account resume is off right now.', + description: + 'All visible accounts are isolated, so a session created in one account will stay with that account.', + }, + 'shared-alone': { + title: 'Shared mode exists, but no group has a peer yet.', + description_one: + '{{count}} shared account is still waiting for another account in the same group.', + description_other: + '{{count}} shared accounts are still waiting for another account in the same group.', + }, + 'shared-standard': { + title: + 'Group "{{group}}" shares project context, but deeper continuity is not paired yet.', + description: + 'Users may still expect more than project sharing. Enable deeper continuity on both accounts in this group for stronger handoff expectations.', + }, + partial: { + title: 'One group is ready, but the overall setup is still mixed.', + description: + 'At least one group already uses deeper continuity, but other accounts are still isolated, alone in a shared group, or missing deeper continuity.', + }, + ready: { + title: 'Group "{{group}}" has the strongest available continuity setup.', + description: + 'Multiple accounts in this group already use deeper continuity. Resume behavior still depends on what Claude stores upstream.', + }, + }, + stepsTitle: 'What users should do next', + steps: { + syncBoth: 'Open Sync on both accounts, not just the one you are switching into.', + sameGroup: + 'Use the same History Sync Group on both accounts. "{{group}}" is the current recommended group.', + enableDeeper: + 'If users expect cross-account resume instead of project-only sharing, turn on deeper continuity on both accounts.', + resumeOriginal: + 'If the original conversation is important, resume it from the original account before changing continuity settings.', + }, + singleSteps: { + addAccount: 'Create a second ccs auth account before planning any cross-account handoff.', + sameGroupLater: + 'When that second account exists, put both accounts in the same History Sync Group.', + enableDeeperLater: + 'If users will expect cross-account resume instead of project-only sharing, enable deeper continuity on both accounts.', + resumeOriginal: + 'If the original conversation is important, keep resuming it from the original account until the second account is ready.', + }, + }, + continuityOverview: { + plainLaneTitle: 'Plain ccs is using a different resume lane', + plainLaneDescription: + 'Plain ccs currently resumes from {{lane}}, not this account lane. Recover the original lane first before changing sync settings.', + setDefaultHint: + 'Use the Set Default action on the account you want plain ccs to follow in future sessions.', + recommendBadge: 'Recommend {{group}}', + partialBadge: 'Partial sync {{group}}', + lane: { + native: 'native Claude (~/.claude)', + accountDefault: 'account {{name}}', + accountInherited: 'account {{name}} via continuity inheritance', + profileDefault: 'default profile {{name}} via native Claude lane', + }, + }, accountsTable: { name: 'Name', type: 'Type', @@ -468,11 +578,22 @@ const resources = { 'Are you sure you want to delete the account "{{name}}"? This will remove the profile and all its session data. This action cannot be undone.', cancel: 'Cancel', delete: 'Delete', - sharedGroupStandard: 'shared ({{group}}, standard)', - sharedGroupDeeper: 'shared ({{group}}, deeper)', - sharedGroupLegacy: 'shared ({{group}}, standard legacy)', + sharedGroupStandard: 'shared ({{group}}, projects only)', + sharedGroupDeeper: 'shared ({{group}}, deeper continuity)', + sharedGroupLegacy: 'shared ({{group}}, projects only, legacy)', isolatedLegacy: 'isolated (legacy default)', isolated: 'isolated', + noSameGroupPeer: 'No same-group peer yet', + sameGroupPeerCount_one: '{{count}} same-group peer', + sameGroupPeerCount_other: '{{count}} same-group peers', + legacyReview: 'Review and confirm this inferred default.', + noHandoff: 'Cross-account resume stays in the original account.', + badges: { + shared: 'Shared', + deeper: 'Deeper', + legacy: 'Legacy', + isolated: 'Isolated', + }, }, addAccountDialog: { title: 'Add {{displayName}} Account', @@ -908,15 +1029,18 @@ const resources = { continuityGuide: 'Continuity Guide', expandWhenNeeded: 'Expand only when needed.', sharedStandard: 'Shared Standard', - sharedStandardDesc: 'Project workspace sync only. Best default for most teams.', + sharedStandardDesc: + 'Shared workspace only. Put both accounts in the same group before expecting any handoff.', sharedDeeper: 'Shared Deeper', sharedDeeperPrefix: 'Adds', + sharedDeeperDesc: + 'Adds session-env, file-history, shell-snapshots, and todos. Recommended when users expect cross-account resume.', isolated: 'Isolated', - isolatedDesc: 'No link. Best for strict separation.', + isolatedDesc: 'No link. Conversations stay with the original account.', quickCommands: 'Quick Commands', quickCommandsDesc: 'Copy and run in terminal.', workspaceBadge: 'ccs auth Workspace', - historySyncBadge: 'History Sync Controls', + historySyncBadge: 'History & Resume Controls', authAccounts: 'Auth Accounts', tableScopePrefix: 'This table is intentionally scoped to', tableScopeMiddle: 'accounts. Use', @@ -1614,6 +1738,19 @@ const resources = { deeperHint: '高级模式还会同步 session-env、file-history、shell-snapshots 和 todos。', standardHint: '标准模式仅同步项目工作区上下文。', credentialsIsolated: '凭据与 .anthropic 在所有模式下均按账号隔离。', + implicationTitle: '保存后会发生什么', + isolatedImplication: '该账号会继续保持隔离,其他账号无法续接它的连续性状态。', + sameGroupRule: '只有账号都使用同一个分组“{{group}}”时,才会彼此共享连续性。', + noSameGroupPeer: '当前没有其他账号使用这个分组。', + sameGroupPeerCount_one: '已有 {{count}} 个其他账号使用这个分组。', + sameGroupPeerCount_other: '已有 {{count}} 个其他账号使用这个分组。', + deeperReady_one: '已有 {{count}} 个同组账号启用更深连续性。这是当前最强的交接设置。', + deeperReady_other: '已有 {{count}} 个同组账号启用更深连续性。这是当前最强的交接设置。', + deeperNeedsPeers: + '此账号已启用更深连续性,但同组中的其他账号也需要启用更深连续性,才能更好满足跨账号续接的预期。', + standardWarning: + '标准共享更适合项目上下文。如果用户期望跨账号续接,两个账号都应启用更深连续性。', + resumeOriginalWarning: '如果旧会话很重要,请先在原账号中恢复它,再调整设置。', cancel: '取消', save: '保存', saving: '保存中...', @@ -1633,9 +1770,91 @@ const resources = { modeSwitchDesc: '同步对话框可让用户在隔离/共享间切换并选择更深连续性。', historySyncGroup: '历史同步分组', historySyncGroupDesc: '同一分组即共享项目上下文。默认回退为 default。', + sameGroupRule: '只有两个账号都设为共享且使用同一分组时,才会共享连续性。', + deeperRecommendation: + '分组“{{group}}”已开启共享,但如果用户期望跨账号续接,更推荐同时启用更深连续性。', + partialGroup: + '分组“{{group}}”已经配置得不错,但其他账号或分组仍需要补齐同组或更深连续性设置。', + readyGroup: '分组“{{group}}”已有多个账号启用更深连续性。这是当前最强的交接设置。', legacyConfirmation_one: '{{count}} 个旧账号仍需要显式确认。', legacyConfirmation_other: '{{count}} 个旧账号仍需要显式确认。', }, + continuityReadiness: { + title: '跨账号续接检查', + description: '在切换账号前,用这张卡快速确认还缺什么。', + state: { + single: '单账号', + isolated: '续接关闭', + 'shared-alone': '已共享但未完成', + 'shared-standard': '仅项目共享', + partial: '部分就绪', + ready: '强交接已就绪', + }, + metrics: { + isolated: '隔离账号', + sharedPeers: '有同组伙伴', + deeperReady: '更深已就绪', + }, + messages: { + single: { + title: '目前只配置了一个 auth 账号。', + description: '现在还不涉及跨账号交接。请先再添加一个 auth 账号,再配置共享连续性。', + }, + isolated: { + title: '当前还没有开启跨账号续接。', + description: '当前可见账号都处于隔离模式,因此在一个账号中创建的会话仍会留在原账号。', + }, + 'shared-alone': { + title: '已经开启共享,但还没有任何分组拥有同组伙伴。', + description_one: '{{count}} 个共享账号仍在等待另一个账号加入同一分组。', + description_other: '{{count}} 个共享账号仍在等待另一个账号加入同一分组。', + }, + 'shared-standard': { + title: '分组“{{group}}”已共享项目上下文,但尚未形成更深连续性的配对。', + description: + '用户可能期望的不只是项目共享。如果希望更稳妥地支持跨账号续接,请让这个分组中的两个账号都启用更深连续性。', + }, + partial: { + title: '已有一个分组准备就绪,但整体配置仍是混合状态。', + description: + '至少有一个分组已经启用更深连续性,但其他账号仍可能处于隔离、单独共享分组,或尚未启用更深连续性。', + }, + ready: { + title: '分组“{{group}}”已经具备当前最强的连续性设置。', + description: + '这个分组中已有多个账号启用更深连续性。是否能够恢复仍取决于 Claude 上游实际保存了什么。', + }, + }, + stepsTitle: '下一步建议', + steps: { + syncBoth: '请在两个账号上都打开“同步”,而不是只调整要切换进去的那个账号。', + sameGroup: '两个账号都使用同一个历史同步分组。“{{group}}”是当前推荐的分组。', + enableDeeper: + '如果用户期望的是跨账号续接,而不是仅共享项目上下文,请在两个账号上都启用更深连续性。', + resumeOriginal: '如果原来的会话很重要,请先在原账号中恢复它,再修改连续性设置。', + }, + singleSteps: { + addAccount: '先再创建一个 ccs auth 账号,然后再考虑跨账号交接。', + sameGroupLater: '当第二个账号准备好后,再把两个账号放到同一个历史同步分组。', + enableDeeperLater: + '如果未来会期望跨账号续接而不是仅共享项目上下文,请在两个账号上都启用更深连续性。', + resumeOriginal: '在第二个账号配置完成之前,重要会话仍应从原账号继续恢复。', + }, + }, + continuityOverview: { + plainLaneTitle: '普通 ccs 正在使用不同的续接通道', + plainLaneDescription: + '普通 ccs 当前会从 {{lane}} 续接,而不是这个账号通道。请先恢复原通道中的会话,再修改同步设置。', + setDefaultHint: '如果希望以后普通 ccs 跟随某个账号,请使用该账号行上的“设为默认”。', + recommendBadge: '建议 {{group}}', + partialBadge: '部分同步 {{group}}', + lane: { + native: '原生 Claude(~/.claude)', + accountDefault: '账号 {{name}}', + accountInherited: '通过连续性继承使用账号 {{name}}', + profileDefault: '默认配置 {{name}}(仍走原生 Claude 通道)', + }, + }, accountsTable: { name: '名称', type: '类型', @@ -1664,6 +1883,17 @@ const resources = { sharedGroupLegacy: '共享({{group}},标准旧策略)', isolatedLegacy: '隔离(旧默认)', isolated: '隔离', + noSameGroupPeer: '同组中还没有其他账号', + sameGroupPeerCount_one: '{{count}} 个同组账号', + sameGroupPeerCount_other: '{{count}} 个同组账号', + legacyReview: '请检查并确认这个推断出的默认状态。', + noHandoff: '跨账号续接仍会回到原账号。', + badges: { + shared: '共享', + deeper: '更深', + legacy: '旧策略', + isolated: '隔离', + }, }, addAccountDialog: { title: '添加 {{displayName}} 账号', @@ -2079,6 +2309,8 @@ const resources = { sharedStandard: '共享标准', sharedStandardDesc: '仅同步项目工作区。适合大多数团队。', sharedDeeper: '共享更深', + sharedDeeperDesc: + '会额外同步 session-env、file-history、shell-snapshots 和 todos。适合需要跨账号续接的场景。', sharedDeeperPrefix: '额外包含', isolated: '隔离', isolatedDesc: '不建立链接。适合严格隔离场景。', @@ -2803,6 +3035,24 @@ const resources = { standardHint: 'Chế độ tiêu chuẩn chỉ đồng bộ hóa bối cảnh không gian làm việc của dự án.', credentialsIsolated: 'Thông tin xác thực và .anthropic vẫn được tách riêng cho mỗi tài khoản ở tất cả các chế độ.', + implicationTitle: 'Sau khi lưu sẽ như thế nào', + isolatedImplication: + 'Tài khoản này sẽ tiếp tục tách biệt. Tài khoản khác sẽ không thể resume trạng thái continuity của nó.', + sameGroupRule: + 'Các tài khoản phải dùng cùng nhóm "{{group}}" thì mới chia sẻ continuity cho nhau.', + noSameGroupPeer: 'Hiện chưa có tài khoản nào khác dùng nhóm này.', + sameGroupPeerCount_one: '{{count}} tài khoản khác đã dùng nhóm này.', + sameGroupPeerCount_other: '{{count}} tài khoản khác đã dùng nhóm này.', + deeperReady_one: + '{{count}} tài khoản cùng nhóm đã bật deeper continuity. Đây là thiết lập handoff mạnh nhất hiện có.', + deeperReady_other: + '{{count}} tài khoản cùng nhóm đã bật deeper continuity. Đây là thiết lập handoff mạnh nhất hiện có.', + deeperNeedsPeers: + 'Tài khoản này đã bật deeper continuity, nhưng tài khoản khác trong cùng nhóm vẫn cần bật deeper continuity để hỗ trợ resume xuyên tài khoản tốt hơn.', + standardWarning: + 'Chế độ shared tiêu chuẩn chỉ phù hợp cho ngữ cảnh project. Nếu người dùng mong resume xuyên tài khoản, cả hai tài khoản nên bật deeper continuity.', + resumeOriginalWarning: + 'Nếu cuộc trò chuyện cũ quan trọng, hãy resume nó từ tài khoản gốc trước khi đổi cấu hình.', cancel: 'Hủy bỏ', save: 'Lưu', saving: 'Đang lưu...', @@ -2826,9 +3076,104 @@ const resources = { historySyncGroup: 'Nhóm đồng bộ hóa lịch sử', historySyncGroupDesc: 'Cùng một nhóm nghĩa là chia sẻ cùng bối cảnh project. Nếu để trống sẽ dùng nhóm mặc định.', + sameGroupRule: + 'Hiện tại, các tài khoản chỉ chia sẻ continuity khi cả hai đều ở chế độ shared và dùng cùng một nhóm.', + deeperRecommendation: + 'Nhóm "{{group}}" đã được chia sẻ, nhưng deeper continuity vẫn là lựa chọn an toàn hơn nếu người dùng mong resume xuyên tài khoản.', + partialGroup: + 'Nhóm "{{group}}" đã ổn, nhưng các tài khoản hoặc nhóm khác vẫn cần hoàn thiện cấu hình cùng nhóm hoặc deeper continuity.', + readyGroup: + 'Nhóm "{{group}}" đã có nhiều tài khoản bật deeper continuity. Đây là thiết lập handoff mạnh nhất hiện có.', legacyConfirmation_one: '{{count}} tài khoản cũ vẫn cần xác nhận rõ ràng.', legacyConfirmation_other: '{{count}} tài khoản cũ vẫn cần xác nhận rõ ràng.', }, + continuityReadiness: { + title: 'Kiểm tra resume xuyên tài khoản', + description: 'Dùng thẻ này để biết còn thiếu gì trước khi người dùng chuyển tài khoản.', + state: { + single: 'một tài khoản', + isolated: 'resume tắt', + 'shared-alone': 'đã shared nhưng chưa đủ', + 'shared-standard': 'chỉ project', + partial: 'mới sẵn sàng một phần', + ready: 'handoff mạnh đã sẵn sàng', + }, + metrics: { + isolated: 'Tách biệt', + sharedPeers: 'Có bạn cùng nhóm', + deeperReady: 'Deeper sẵn sàng', + }, + messages: { + single: { + title: 'Hiện chỉ có một auth account.', + description: + 'Cross-account handoff chưa áp dụng. Hãy thêm một auth account khác trước khi cấu hình shared continuity.', + }, + isolated: { + title: 'Hiện tại cross-account resume đang tắt.', + description: + 'Tất cả tài khoản đang hiển thị đều ở chế độ isolated, nên phiên được tạo trong một tài khoản sẽ ở lại tài khoản đó.', + }, + 'shared-alone': { + title: 'Đã có shared mode, nhưng chưa có nhóm nào có bạn cùng nhóm.', + description_one: + '{{count}} shared account vẫn đang chờ một tài khoản khác vào cùng nhóm.', + description_other: + '{{count}} shared accounts vẫn đang chờ một tài khoản khác vào cùng nhóm.', + }, + 'shared-standard': { + title: + 'Nhóm "{{group}}" đang chia sẻ ngữ cảnh project nhưng chưa ghép deeper continuity.', + description: + 'Người dùng có thể mong nhiều hơn việc chia sẻ project. Hãy bật deeper continuity trên cả hai tài khoản trong nhóm này để có handoff mạnh hơn.', + }, + partial: { + title: 'Một nhóm đã sẵn sàng, nhưng cấu hình tổng thể vẫn còn lẫn lộn.', + description: + 'Ít nhất một nhóm đã dùng deeper continuity, nhưng các tài khoản khác vẫn còn isolated, ở một mình trong nhóm shared, hoặc chưa bật deeper continuity.', + }, + ready: { + title: 'Nhóm "{{group}}" đã có thiết lập continuity mạnh nhất hiện có.', + description: + 'Nhiều tài khoản trong nhóm này đã bật deeper continuity. Việc resume thực tế vẫn phụ thuộc vào dữ liệu mà Claude lưu ở upstream.', + }, + }, + stepsTitle: 'Người dùng nên làm gì tiếp theo', + steps: { + syncBoth: 'Mở Sync trên cả hai tài khoản, không chỉ tài khoản bạn sắp chuyển sang.', + sameGroup: + 'Dùng cùng một History Sync Group trên cả hai tài khoản. "{{group}}" là nhóm được đề xuất hiện tại.', + enableDeeper: + 'Nếu người dùng mong resume xuyên tài khoản thay vì chỉ chia sẻ project, hãy bật deeper continuity trên cả hai tài khoản.', + resumeOriginal: + 'Nếu cuộc trò chuyện gốc quan trọng, hãy resume nó từ tài khoản gốc trước khi đổi cài đặt continuity.', + }, + singleSteps: { + addAccount: + 'Hãy tạo thêm một ccs auth account thứ hai trước khi lên kế hoạch handoff xuyên tài khoản.', + sameGroupLater: + 'Khi tài khoản thứ hai đã sẵn sàng, đặt cả hai vào cùng một History Sync Group.', + enableDeeperLater: + 'Nếu sau này người dùng sẽ mong resume xuyên tài khoản, hãy bật deeper continuity trên cả hai tài khoản.', + resumeOriginal: + 'Cho đến khi tài khoản thứ hai sẵn sàng, các cuộc trò chuyện quan trọng vẫn nên được resume từ tài khoản gốc.', + }, + }, + continuityOverview: { + plainLaneTitle: 'Plain ccs đang dùng một lane resume khác', + plainLaneDescription: + 'Plain ccs hiện resume từ {{lane}}, không phải lane của tài khoản này. Hãy khôi phục lane gốc trước khi đổi cài đặt sync.', + setDefaultHint: + 'Nếu muốn plain ccs theo một tài khoản trong các phiên sau, hãy dùng hành động Set Default trên dòng tài khoản đó.', + recommendBadge: 'Đề xuất {{group}}', + partialBadge: 'Đồng bộ một phần {{group}}', + lane: { + native: 'Claude gốc (~/.claude)', + accountDefault: 'tài khoản {{name}}', + accountInherited: 'tài khoản {{name}} qua continuity inheritance', + profileDefault: 'profile mặc định {{name}} qua lane Claude gốc', + }, + }, accountsTable: { name: 'Tên', type: 'Kiểu', @@ -2858,6 +3203,17 @@ const resources = { sharedGroupLegacy: 'chia sẻ ({{group}}, kế thừa tiêu chuẩn)', isolatedLegacy: 'tách biệt (mặc định cũ)', isolated: 'tách biệt', + noSameGroupPeer: 'Chưa có tài khoản cùng nhóm', + sameGroupPeerCount_one: '{{count}} tài khoản cùng nhóm', + sameGroupPeerCount_other: '{{count}} tài khoản cùng nhóm', + legacyReview: 'Hãy xem lại và xác nhận trạng thái suy ra này.', + noHandoff: 'Resume xuyên tài khoản vẫn sẽ quay về tài khoản gốc.', + badges: { + shared: 'Chia sẻ', + deeper: 'Sâu hơn', + legacy: 'Kế thừa', + isolated: 'Tách biệt', + }, }, addAccountDialog: { title: 'Thêm tài khoản {{displayName}}', @@ -3304,6 +3660,8 @@ const resources = { sharedStandardDesc: 'Chỉ đồng bộ hóa không gian làm việc của dự án. Mặc định tốt nhất cho hầu hết các đội.', sharedDeeper: 'Chia sẻ sâu hơn', + sharedDeeperDesc: + 'Đồng bộ thêm session-env, file-history, shell-snapshots và todos. Nên dùng khi người dùng mong resume xuyên tài khoản.', sharedDeeperPrefix: 'Thêm', isolated: 'Tách biệt', isolatedDesc: 'Không có liên kết. Tốt nhất cho sự tách biệt nghiêm ngặt.', @@ -4034,6 +4392,24 @@ const resources = { standardHint: '標準モードでは、プロジェクトのワークスペースコンテキストのみ同期します。', credentialsIsolated: 'どのモードでも、認証情報と .anthropic はアカウントごとに分離されます。', + implicationTitle: '保存後の挙動', + isolatedImplication: + 'このアカウントは分離されたままです。他のアカウントからこの継続状態を再開できません。', + sameGroupRule: + 'アカウント同士で継続性を共有するには、両方が同じグループ「{{group}}」を使う必要があります。', + noSameGroupPeer: '現在このグループを使っている他のアカウントはありません。', + sameGroupPeerCount_one: 'このグループを使う他のアカウントが {{count}} 件あります。', + sameGroupPeerCount_other: 'このグループを使う他のアカウントが {{count}} 件あります。', + deeperReady_one: + '同じグループの {{count}} 件のアカウントがすでに拡張継続性を使っています。これは現状で最も強い引き継ぎ設定です。', + deeperReady_other: + '同じグループの {{count}} 件のアカウントがすでに拡張継続性を使っています。これは現状で最も強い引き継ぎ設定です。', + deeperNeedsPeers: + 'このアカウントでは拡張継続性が有効ですが、同じグループの他アカウントでも拡張継続性を有効にしないと、アカウント間再開の期待には十分ではありません。', + standardWarning: + '標準共有はプロジェクト文脈向けです。アカウント間再開を期待するなら、両方のアカウントで拡張継続性を有効にしてください。', + resumeOriginalWarning: + '古い会話が重要なら、設定を変える前に元のアカウントで再開してください。', cancel: 'キャンセル', save: '保存', saving: '保存中...', @@ -4056,9 +4432,105 @@ const resources = { historySyncGroup: '履歴同期グループ', historySyncGroupDesc: '同じグループのアカウントは、同じプロジェクトのコンテキストレーンを共有します。未設定時は default を使います。', + sameGroupRule: + '現在、継続性を共有できるのは、両方のアカウントが共有モードかつ同じグループを使っている場合だけです。', + deeperRecommendation: + 'グループ「{{group}}」は共有されていますが、アカウント間再開を期待するなら拡張継続性も有効にする方が安全です。', + partialGroup: + 'グループ「{{group}}」は良い状態ですが、他のアカウントやグループでは同グループ設定や拡張継続性がまだ不足しています。', + readyGroup: + 'グループ「{{group}}」では複数アカウントで拡張継続性が有効です。これは現状で最も強い引き継ぎ設定です。', legacyConfirmation_one: '{{count}} 件のレガシーアカウントで明示的な確認がまだ必要です。', legacyConfirmation_other: '{{count}} 件のレガシーアカウントで明示的な確認がまだ必要です。', }, + continuityReadiness: { + title: 'アカウント間再開チェック', + description: 'アカウントを切り替える前に、何が不足しているかをこのカードで確認できます。', + state: { + single: '単一アカウント', + isolated: '再開オフ', + 'shared-alone': '共有だが未完成', + 'shared-standard': 'プロジェクトのみ', + partial: '一部のみ準備完了', + ready: '強い引き継ぎ準備完了', + }, + metrics: { + isolated: '分離', + sharedPeers: '同グループあり', + deeperReady: '拡張準備完了', + }, + messages: { + single: { + title: '現在は auth アカウントが 1 つだけです。', + description: + 'アカウント間の引き継ぎはまだ関係ありません。共有継続性を設定する前に、もう 1 つ auth アカウントを追加してください。', + }, + isolated: { + title: '現在、アカウント間再開はオフです。', + description: + '表示中のアカウントはすべて分離モードのため、あるアカウントで作成したセッションはそのアカウントに残ります。', + }, + 'shared-alone': { + title: '共有モードはありますが、どのグループにも相手がいません。', + description_one: + '{{count}} 件の共有アカウントが、同じグループに入る別アカウントを待っています。', + description_other: + '{{count}} 件の共有アカウントが、同じグループに入る別アカウントを待っています。', + }, + 'shared-standard': { + title: + 'グループ「{{group}}」はプロジェクト文脈を共有していますが、拡張継続性のペアがまだありません。', + description: + 'ユーザーはプロジェクト共有以上を期待する可能性があります。より強い引き継ぎのため、このグループ内の両アカウントで拡張継続性を有効にしてください。', + }, + partial: { + title: '1 つのグループは準備できていますが、全体の設定はまだ混在しています。', + description: + '少なくとも 1 つのグループでは拡張継続性が使われていますが、他のアカウントはまだ分離状態、共有グループ内で単独、または拡張継続性が未設定です。', + }, + ready: { + title: 'グループ「{{group}}」は現状で最も強い継続性設定になっています。', + description: + 'このグループでは複数アカウントがすでに拡張継続性を使っています。実際に再開できるかどうかは、Claude が上流で何を保存しているかにも依存します。', + }, + }, + stepsTitle: '次にユーザーがやるべきこと', + steps: { + syncBoth: + '切り替え先のアカウントだけでなく、両方のアカウントで「同期」を開いてください。', + sameGroup: + '両方のアカウントで同じ履歴同期グループを使ってください。現在の推奨グループは「{{group}}」です。', + enableDeeper: + 'プロジェクト共有だけでなくアカウント間再開を期待するなら、両方のアカウントで拡張継続性を有効にしてください。', + resumeOriginal: + '元の会話が重要なら、継続性設定を変える前に元のアカウントで再開してください。', + }, + singleSteps: { + addAccount: + 'アカウント間引き継ぎを考える前に、まず 2 つ目の ccs auth アカウントを作成してください。', + sameGroupLater: + '2 つ目のアカウントができたら、両方を同じ履歴同期グループに入れてください。', + enableDeeperLater: + '将来アカウント間再開を期待するなら、両方のアカウントで拡張継続性を有効にしてください。', + resumeOriginal: + '2 つ目のアカウントが整うまでは、重要な会話は元のアカウントから再開し続けてください。', + }, + }, + continuityOverview: { + plainLaneTitle: '通常の ccs は別の再開レーンを使っています', + plainLaneDescription: + '通常の ccs は現在 {{lane}} から再開しており、このアカウントのレーンではありません。同期設定を変える前に元のレーンを先に復旧してください。', + setDefaultHint: + '今後の通常の ccs を特定のアカウントに合わせたい場合は、その行の「既定に設定」を使ってください。', + recommendBadge: '推奨 {{group}}', + partialBadge: '部分同期 {{group}}', + lane: { + native: 'ネイティブ Claude(~/.claude)', + accountDefault: 'アカウント {{name}}', + accountInherited: '継続性継承によるアカウント {{name}}', + profileDefault: '既定プロファイル {{name}}(ネイティブ Claude レーン)', + }, + }, accountsTable: { name: '名前', type: '種別', @@ -4088,6 +4560,17 @@ const resources = { sharedGroupLegacy: '共有({{group}}、標準・レガシー)', isolatedLegacy: '分離(旧既定値)', isolated: '分離', + noSameGroupPeer: '同じグループの相手がまだいません', + sameGroupPeerCount_one: '{{count}} 件の同グループアカウント', + sameGroupPeerCount_other: '{{count}} 件の同グループアカウント', + legacyReview: 'この推定された既定状態を確認して明示的に確定してください。', + noHandoff: 'アカウント間再開は元のアカウントに残ります。', + badges: { + shared: '共有', + deeper: '拡張', + legacy: 'レガシー', + isolated: '分離', + }, }, addAccountDialog: { title: '{{displayName}} アカウントを追加', @@ -4540,6 +5023,8 @@ const resources = { sharedStandardDesc: 'プロジェクトワークスペースの同期のみ。ほとんどのチームに最適な既定値です。', sharedDeeper: '共有拡張', + sharedDeeperDesc: + 'session-env、file-history、shell-snapshots、todos も同期します。アカウント間再開を期待するなら推奨です。', sharedDeeperPrefix: '追加で', isolated: '分離', isolatedDesc: 'リンクなし。厳密な分離に最適です。', diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx index 71de35b2..2f86ad2e 100644 --- a/ui/src/pages/accounts.tsx +++ b/ui/src/pages/accounts.tsx @@ -5,18 +5,16 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { AlertTriangle, ArrowRight, ChevronDown, Plus, Users, Zap } from 'lucide-react'; +import { AlertTriangle, ArrowRight, Plus, Users, Zap } from 'lucide-react'; import { AccountsTable } from '@/components/account/accounts-table'; +import { ContinuityOverview } from '@/components/account/continuity-overview'; import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog'; -import { HistorySyncLearningMap } from '@/components/account/history-sync-learning-map'; import { CopyButton } from '@/components/ui/copy-button'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useAccounts, useConfirmLegacyAccountPolicies } from '@/hooks/use-accounts'; -import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; export function AccountsPage() { @@ -25,7 +23,6 @@ export function AccountsPage() { const { data, isLoading } = useAccounts(); const confirmLegacyMutation = useConfirmLegacyAccountPolicies(); const [createDialogOpen, setCreateDialogOpen] = useState(false); - const [guideOpen, setGuideOpen] = useState(false); const authAccounts = data?.accounts || []; const cliproxyCount = data?.cliproxyCount || 0; @@ -35,13 +32,14 @@ export function AccountsPage() { const sharedStandardCount = data?.sharedStandardCount || 0; const deeperSharedCount = data?.deeperSharedCount || 0; const isolatedCount = data?.isolatedCount || 0; - const sharedGroups = Array.from( - new Set( - authAccounts - .filter((account) => account.context_mode === 'shared') - .map((account) => account.context_group || 'default') - ) - ).sort((a, b) => a.localeCompare(b)); + const sharedAloneCount = data?.sharedAloneCount || 0; + const sharedPeerAccountCount = data?.sharedPeerAccountCount || 0; + const deeperReadyAccountCount = data?.deeperReadyAccountCount || 0; + const sharedPeerGroups = data?.sharedPeerGroups || []; + const deeperReadyGroups = data?.deeperReadyGroups || []; + const sharedGroups = data?.sharedGroups || []; + const groupSummaries = data?.groupSummaries || []; + const plainCcsLane = data?.plainCcsLane || null; const legacyTargets = authAccounts.filter( (account) => account.context_inferred || account.continuity_inferred @@ -145,54 +143,6 @@ export function AccountsPage() { )} - - - - - - - - - -
-

- {t('accountsPage.sharedStandard')} -

-

{t('accountsPage.sharedStandardDesc')}

-
-
-

- {t('accountsPage.sharedDeeper')} -

-

- {t('accountsPage.sharedDeeperPrefix')} session-env,{' '} - file-history, shell-snapshots,{' '} - todos. -

-
-
-

- {t('accountsPage.isolated')} -

-

{t('accountsPage.isolatedDesc')}

-
-
-
-
-
- {t('accountsPage.quickCommands')} @@ -238,13 +188,21 @@ export function AccountsPage() {
- @@ -258,7 +216,12 @@ export function AccountsPage() { {isLoading ? (
{t('accountsPage.loadingAccounts')}
) : ( - + )}
@@ -303,13 +266,21 @@ export function AccountsPage() { - @@ -320,7 +291,12 @@ export function AccountsPage() { {isLoading ? (
{t('accountsPage.loadingAccounts')}
) : ( - + )}
diff --git a/ui/tests/unit/ui/i18n/language-switcher.test.tsx b/ui/tests/unit/ui/i18n/language-switcher.test.tsx index fc0d08b0..d8618b21 100644 --- a/ui/tests/unit/ui/i18n/language-switcher.test.tsx +++ b/ui/tests/unit/ui/i18n/language-switcher.test.tsx @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; import i18n from '@/lib/i18n'; +import { ContinuityOverview } from '@/components/account/continuity-overview'; import { LanguageSwitcher } from '@/components/layout/language-switcher'; -import { HistorySyncLearningMap } from '@/components/account/history-sync-learning-map'; import { TabNavigation } from '@/pages/settings/components/tab-navigation'; import { getInitialLocale, @@ -137,37 +137,61 @@ describe('Dashboard i18n', () => { expect(screen.getByText('思考')).toBeInTheDocument(); }); - it('renders Japanese history sync guidance without fallback English copy', async () => { - await i18n.changeLanguage('ja'); + it('uses single-account-specific next steps in the continuity overview', async () => { + await i18n.changeLanguage('en'); render( - ); - expect(screen.getByText('履歴同期の仕組み')).toBeInTheDocument(); expect( - screen.getByText('1 件の CLIProxy Claude Pool アカウントは、CLIProxy ページで管理します。') - ).toBeInTheDocument(); - - await userEvent.click( - screen.getByRole('button', { name: '詳細を表示: グループ、切り替え、レガシーポリシー' }) - ); - - expect( - screen.getByText('2 件のレガシーアカウントで明示的な確認がまだ必要です。') + screen.getByText( + 'Cross-account handoff does not apply yet. Add another auth account before configuring shared continuity.' + ) ).toBeInTheDocument(); }); - it.each(SUPPORTED_LOCALES.filter((locale) => locale !== 'en'))( + it('pluralizes the shared-alone readiness copy', async () => { + await i18n.changeLanguage('en'); + + render( + + ); + + expect( + screen.getByText('2 shared accounts are still waiting for another account in the same group.') + ).toBeInTheDocument(); + }); + + it.each(SUPPORTED_LOCALES.filter((locale: string) => locale !== 'en'))( 'keeps %s translation keys in parity with en and preserves placeholders', - (locale) => { + (locale: string) => { const resources = i18n.options.resources as | Record }> | undefined; diff --git a/ui/tests/unit/ui/lib/account-continuity.test.ts b/ui/tests/unit/ui/lib/account-continuity.test.ts new file mode 100644 index 00000000..b73c0ebd --- /dev/null +++ b/ui/tests/unit/ui/lib/account-continuity.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { summarizeAuthAccountContinuity } from '@/lib/account-continuity'; +import type { Account } from '@/lib/api-client'; + +function createAccount(name: string, overrides: Partial = {}): Account { + return { + name, + created: '2026-04-03T00:00:00.000Z', + ...overrides, + }; +} + +describe('summarizeAuthAccountContinuity', () => { + it('keeps isolated accounts out of shared readiness', () => { + const summary = summarizeAuthAccountContinuity([ + createAccount('acc1', { context_mode: 'isolated' }), + createAccount('acc2', { context_mode: 'isolated', context_inferred: true }), + ]); + + expect(summary.isolatedCount).toBe(2); + expect(summary.sharedCount).toBe(0); + expect(summary.sharedPeerGroups).toEqual([]); + expect(summary.deeperReadyGroups).toEqual([]); + expect(summary.accounts.map((account) => account.sameGroupPeerCount)).toEqual([0, 0]); + }); + + it('marks shared accounts without a peer as incomplete', () => { + const summary = summarizeAuthAccountContinuity([ + createAccount('acc1', { + context_mode: 'shared', + context_group: 'default', + continuity_mode: 'standard', + }), + createAccount('acc2', { context_mode: 'isolated' }), + ]); + + expect(summary.sharedCount).toBe(1); + expect(summary.sharedAloneCount).toBe(1); + expect(summary.sharedPeerGroups).toEqual([]); + expect(summary.accounts[0]?.sameGroupPeerCount).toBe(0); + }); + + it('detects same-group peers and deeper-ready groups separately', () => { + const summary = summarizeAuthAccountContinuity([ + createAccount('acc1', { + context_mode: 'shared', + context_group: 'sprint-a', + continuity_mode: 'deeper', + }), + createAccount('acc2', { + context_mode: 'shared', + context_group: 'sprint-a', + continuity_mode: 'deeper', + }), + createAccount('acc3', { + context_mode: 'shared', + context_group: 'sprint-b', + continuity_mode: 'standard', + }), + createAccount('acc4', { + context_mode: 'shared', + context_group: 'sprint-b', + continuity_mode: 'standard', + }), + ]); + + expect(summary.sharedPeerGroups).toEqual(['sprint-a', 'sprint-b']); + expect(summary.deeperReadyGroups).toEqual(['sprint-a']); + expect(summary.deeperReadyAccountCount).toBe(2); + expect( + summary.accounts.find((account) => account.name === 'acc1')?.sameGroupDeeperPeerCount + ).toBe(1); + expect( + summary.accounts.find((account) => account.name === 'acc3')?.sameGroupDeeperPeerCount + ).toBe(0); + }); +});