Merge pull request #905 from kaitranntt/kai/feat/904-account-continuity-resume-guidance

feat(accounts): clarify account continuity and resume guidance
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-04 12:46:13 -04:00
committed by GitHub
29 changed files with 2418 additions and 413 deletions
+24
View File
@@ -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 <account> -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.
+38 -1
View File
@@ -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 <account> -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 <account> -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.
+18
View File
@@ -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 <profile>', 'command')} Create new profile and login`);
console.log(
` ${color('backup <profile>', 'command')} Backup local continuity for an account`
);
console.log(` ${color('list', 'command')} List all saved profiles`);
console.log(` ${color('show <profile>', 'command')} Show profile details`);
console.log(` ${color('remove <profile>', '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<void> {
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;
+139
View File
@@ -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<void> {
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 <profile|default> [--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
);
}
}
+1
View File
@@ -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';
+17 -10
View File
@@ -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 {};
}
+8
View File
@@ -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)
*/
+163
View File
@@ -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<ResumeLaneKind, 'account-default' | 'account-inherited'>,
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<ResumeLaneSummary> {
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<ResumeLaneSummary> {
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;
}
+64
View File
@@ -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<ResumeLaneSummary>;
log?: (message: string) => void;
debug?: boolean;
}
export async function maybeWarnAboutResumeLaneMismatch(
profileName: string,
accountConfigDir: string,
args: string[],
deps: ResumeLaneWarningDependencies = {}
): Promise<void> {
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}`));
}
}
}
+2
View File
@@ -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<void> {
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 {
+1
View File
@@ -215,6 +215,7 @@ export const ROOT_COMMAND_FLAGS = [
] as const;
export const AUTH_SUBCOMMANDS = [
'create',
'backup',
'list',
'show',
'remove',
+2
View File
@@ -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')
+14 -2
View File
@@ -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<void> => {
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 });
}
+99
View File
@@ -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);
});
});
@@ -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');
});
});
@@ -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');
});
});
+16 -3
View File
@@ -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', () => {
@@ -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 });
+60 -29
View File
@@ -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<string | null>(null);
const [contextTarget, setContextTarget] = useState<Account | null>(null);
const [contextTarget, setContextTarget] = useState<AuthAccountRow | null>(null);
const columns: ColumnDef<Account>[] = [
const columns: ColumnDef<AuthAccountRow>[] = [
{
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 (
<span className="text-muted-foreground">
{t('accountsTable.sharedGroupDeeper', { group })}
</span>
);
}
if (row.original.continuity_inferred) {
return (
<span className="text-amber-700 dark:text-amber-400">
{t('accountsTable.sharedGroupLegacy', { group })}
</span>
);
}
const isDeeper = row.original.continuity_mode === 'deeper';
return (
<span className="text-muted-foreground">
{t('accountsTable.sharedGroupStandard', { group })}
</span>
<div className="flex flex-col items-start gap-1">
<div className="flex items-center gap-1.5 flex-wrap">
<Badge
variant="outline"
className={`font-mono text-[10px] uppercase px-1.5 py-0 border ${isDeeper ? 'text-indigo-700 border-indigo-300/60 bg-indigo-50/50 dark:text-indigo-300 dark:border-indigo-900/40 dark:bg-indigo-900/20' : 'text-emerald-700 border-emerald-300/60 bg-emerald-50/50 dark:text-emerald-300 dark:border-emerald-900/40 dark:bg-emerald-900/20'}`}
>
{isDeeper ? t('accountsTable.badges.deeper') : t('accountsTable.badges.shared')}
</Badge>
<span className="text-xs font-semibold text-foreground/80">{group}</span>
</div>
<p className="text-[10px] text-muted-foreground whitespace-nowrap">
{row.original.sameGroupPeerCount > 0
? t('accountsTable.sameGroupPeerCount', {
count: row.original.sameGroupPeerCount,
})
: t('accountsTable.noSameGroupPeer')}
</p>
</div>
);
}
if (row.original.context_inferred) {
return (
<span className="text-amber-700 dark:text-amber-400">
{t('accountsTable.isolatedLegacy')}
</span>
<div className="flex flex-col items-start gap-1">
<Badge
variant="outline"
className="text-amber-700 border-amber-300/60 bg-amber-50/50 dark:text-amber-400 dark:border-amber-900/40 dark:bg-amber-900/20 font-mono text-[10px] uppercase px-1.5 py-0"
>
{t('accountsTable.badges.legacy')}
</Badge>
<p className="text-[10px] text-amber-700/80 dark:text-amber-400/80 whitespace-nowrap">
{t('accountsTable.legacyReview')}
</p>
</div>
);
}
return <span className="text-muted-foreground">{t('accountsTable.isolated')}</span>;
return (
<div className="flex flex-col items-start gap-1.5">
<Badge
variant="secondary"
className="font-mono text-[10px] uppercase px-1.5 py-0 text-muted-foreground bg-muted/60 border-transparent shadow-none"
>
{t('accountsTable.badges.isolated')}
</Badge>
</div>
);
},
},
{
@@ -299,7 +325,12 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
</div>
{contextTarget && (
<EditAccountContextDialog account={contextTarget} onClose={() => setContextTarget(null)} />
<EditAccountContextDialog
account={contextTarget}
groupSummaries={groupSummaries}
plainCcsLane={plainCcsLane}
onClose={() => setContextTarget(null)}
/>
)}
{/* Delete confirmation dialog */}
@@ -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 (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t(titleKey)}
className="h-5 w-5 rounded-full hover:bg-muted text-muted-foreground/70 transition-colors"
>
<Info className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-72 p-4 rounded-xl shadow-lg border-border/50 text-sm"
side="top"
align="center"
>
<p className="font-semibold tracking-tight">{t(titleKey)}</p>
<p className="mt-1.5 text-muted-foreground leading-relaxed">{t(descKey)}</p>
</PopoverContent>
</Popover>
);
};
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: <CheckCircle2 className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />,
'shared-standard': <Link2 className="h-6 w-6 text-blue-600 dark:text-blue-400" />,
single: <ShieldAlert className="h-6 w-6 text-stone-400" />,
isolated: <Unlink className="h-6 w-6 text-amber-600 dark:text-amber-400" />,
'shared-alone': <AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" />,
partial: <AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" />,
};
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 (
<div className="flex flex-col gap-4">
{/* Primary Status Bento Box */}
<Card className="flex flex-col justify-between overflow-hidden relative group p-0 border-border bg-card shadow-sm hover:shadow-md transition-shadow duration-300">
<CardContent className="p-6 flex flex-col h-full bg-gradient-to-br from-card to-muted/20 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<div className="rounded-2xl bg-background p-3 shadow-sm ring-1 ring-border/50 self-start shrink-0">
{currentIcon}
</div>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold tracking-tight">
{t(`continuityReadiness.messages.${readiness}.title`, {
group: highlightGroup,
})}
</h3>
<Badge
variant={readiness === 'ready' ? 'default' : 'secondary'}
className="rounded-full px-2.5 py-0.5 font-medium shadow-sm"
>
{t(`continuityReadiness.state.${readiness}`)}
</Badge>
</div>
<p className="text-sm text-muted-foreground max-w-lg leading-relaxed">
{t(`continuityReadiness.messages.${readiness}.description`, {
group: highlightGroup,
count: sharedAloneCount,
})}
</p>
</div>
</div>
</div>
<div className="mt-auto pt-4 flex flex-wrap items-center gap-2">
{cliproxyCount > 0 && (
<Badge
variant="outline"
className="text-blue-700 bg-blue-50/50 border-blue-200/60 dark:border-blue-900/40 dark:bg-blue-900/20 dark:text-blue-300"
>
{t('historySyncLearningMap.cliproxyManaged', { count: cliproxyCount })}
</Badge>
)}
{legacyTargetCount > 0 && (
<Badge
variant="outline"
className="text-amber-700 bg-amber-50/50 border-amber-200/60 dark:border-amber-900/40 dark:bg-amber-900/20 dark:text-amber-300"
>
{t('historySyncLearningMap.legacyConfirmation', { count: legacyTargetCount })}
</Badge>
)}
{sharedPeerGroups.length > 0 && deeperReadyGroups.length === 0 && (
<Badge
variant="secondary"
className="font-mono text-[11px] px-2 bg-muted/50 text-muted-foreground border-transparent"
>
{t('continuityOverview.recommendBadge', { group: highlightGroup })}
</Badge>
)}
{hasMixedState && (
<Badge
variant="secondary"
className="font-mono text-[11px] px-2 bg-muted/50 text-muted-foreground border-transparent"
>
{t('continuityOverview.partialBadge', { group: highlightGroup })}
</Badge>
)}
</div>
</CardContent>
</Card>
{(showPlainLaneRecovery || showSharedRecoverySteps) && (
<Card className="border-dashed">
<CardContent className="p-5 space-y-4">
{showPlainLaneRecovery && (
<div className="space-y-3">
<div>
<p className="text-sm font-semibold text-foreground">
{t('continuityOverview.plainLaneTitle')}
</p>
<p className="text-sm text-muted-foreground">
{t('continuityOverview.plainLaneDescription', {
lane: laneLabel || 'plain ccs',
})}
</p>
</div>
<div className="space-y-2">
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">ccs -r</span>
<CopyButton value="ccs -r" size="icon" />
</div>
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">ccs auth backup default</span>
<CopyButton value="ccs auth backup default" size="icon" />
</div>
{primaryAccountName ? (
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">
{`ccs auth default ${primaryAccountName}`}
</span>
<CopyButton value={`ccs auth default ${primaryAccountName}`} size="icon" />
</div>
) : (
<p className="text-xs text-muted-foreground">
{t('continuityOverview.setDefaultHint')}
</p>
)}
</div>
</div>
)}
{showSharedRecoverySteps && (
<div className="space-y-2">
<p className="text-sm font-semibold text-foreground">
{t('continuityReadiness.stepsTitle')}
</p>
<ol className="space-y-1 pl-5 text-sm text-muted-foreground">
<li>{t('continuityReadiness.steps.syncBoth')}</li>
<li>{t('continuityReadiness.steps.sameGroup', { group: highlightGroup })}</li>
<li>{t('continuityReadiness.steps.enableDeeper')}</li>
<li>{t('continuityReadiness.steps.resumeOriginal')}</li>
</ol>
</div>
)}
</CardContent>
</Card>
)}
{/* Horizontal Progression Chain */}
<div className="flex flex-col md:flex-row items-center gap-3">
<div className="flex-1 w-full flex items-center justify-between p-3.5 rounded-xl border border-blue-300/40 bg-blue-50/50 dark:border-blue-900/30 dark:bg-blue-900/10 shadow-sm transition-colors hover:bg-blue-100/40 dark:hover:bg-blue-900/20">
<div className="flex items-center gap-2">
<Unlink className="h-4 w-4 text-blue-700/80 dark:text-blue-400/80" />
<span className="text-xs font-semibold uppercase tracking-wider text-blue-900 dark:text-blue-200">
{t('historySyncLearningMap.isolated')}
</span>
<InfoTooltip titleKey="accountsPage.isolated" descKey="accountsPage.isolatedDesc" />
</div>
<span className="text-lg font-mono font-bold text-blue-900 dark:text-blue-200">
{isolatedCount}
</span>
</div>
<ArrowRight className="hidden md:block h-4 w-4 text-muted-foreground/40 shrink-0" />
<div className="flex-1 w-full flex items-center justify-between p-3.5 rounded-xl border border-emerald-300/40 bg-emerald-50/50 dark:border-emerald-900/30 dark:bg-emerald-900/10 shadow-sm transition-colors hover:bg-emerald-100/40 dark:hover:bg-emerald-900/20">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-emerald-700/80 dark:text-emerald-400/80" />
<span className="text-xs font-semibold uppercase tracking-wider text-emerald-900 dark:text-emerald-200">
{t('historySyncLearningMap.shared')}
</span>
<InfoTooltip
titleKey="accountsPage.sharedStandard"
descKey="accountsPage.sharedStandardDesc"
/>
</div>
<span className="text-lg font-mono font-bold text-emerald-900 dark:text-emerald-200">
{sharedStandardCount}
</span>
</div>
<ArrowRight className="hidden md:block h-4 w-4 text-muted-foreground/40 shrink-0" />
<div className="flex-1 w-full flex items-center justify-between p-3.5 rounded-xl border border-indigo-300/40 bg-indigo-50/50 dark:border-indigo-900/30 dark:bg-indigo-900/10 shadow-sm transition-colors hover:bg-indigo-100/40 dark:hover:bg-indigo-900/20">
<div className="flex items-center gap-2">
<Waves className="h-4 w-4 text-indigo-700/80 dark:text-indigo-400/80" />
<span className="text-xs font-semibold uppercase tracking-wider text-indigo-900 dark:text-indigo-200">
{t('historySyncLearningMap.deeper')}
</span>
<InfoTooltip
titleKey="accountsPage.sharedDeeper"
descKey="accountsPage.sharedDeeperDesc"
/>
</div>
<span className="text-lg font-mono font-bold text-indigo-900 dark:text-indigo-200">
{deeperSharedCount}
</span>
</div>
</div>
</div>
);
}
@@ -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<ContextMode>(
@@ -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 = <T extends string>(
event: KeyboardEvent<HTMLButtonElement>,
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
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="context-mode">{t('editAccountContext.syncMode')}</Label>
<Select value={mode} onValueChange={(value) => setMode(value as ContextMode)}>
<SelectTrigger id="context-mode">
<SelectValue placeholder={t('editAccountContext.selectContextMode')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="isolated">{t('editAccountContext.isolatedOption')}</SelectItem>
<SelectItem value="shared">{t('editAccountContext.sharedOption')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{mode === 'shared'
? t('editAccountContext.sharedModeHint')
: t('editAccountContext.isolatedModeHint')}
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold">{t('editAccountContext.syncMode')}</Label>
</div>
<div
className="flex p-0.5 bg-muted/60 hover:bg-muted/80 transition-colors rounded-xl border border-border/40 w-full"
role="radiogroup"
aria-label={t('editAccountContext.syncMode')}
>
<button
type="button"
role="radio"
aria-checked={mode === 'isolated'}
tabIndex={mode === 'isolated' ? 0 : -1}
onClick={() => setMode('isolated')}
onKeyDown={(event) =>
handleRadioKeyDown(event, mode, ['isolated', 'shared'] as const, setMode)
}
className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${
mode === 'isolated'
? 'bg-background text-blue-600 dark:text-blue-400 shadow-sm ring-1 ring-border/50'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<Unlink className="h-4 w-4 shrink-0" />
<span className="truncate">{t('editAccountContext.isolatedOption')}</span>
</button>
<button
type="button"
role="radio"
aria-checked={mode === 'shared'}
tabIndex={mode === 'shared' ? 0 : -1}
onClick={() => setMode('shared')}
onKeyDown={(event) =>
handleRadioKeyDown(event, mode, ['isolated', 'shared'] as const, setMode)
}
className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${
mode === 'shared'
? 'bg-background text-emerald-600 dark:text-emerald-400 shadow-sm ring-1 ring-border/50'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<Link2 className="h-4 w-4 shrink-0" />
<span className="truncate">{t('editAccountContext.sharedOption')}</span>
</button>
</div>
<p className="text-xs text-muted-foreground pt-1 px-1">
{mode === 'isolated'
? t('editAccountContext.isolatedModeHint')
: t('editAccountContext.sharedModeHint')}
</p>
</div>
@@ -126,23 +226,67 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
{mode === 'shared' && (
<div className="space-y-2">
<Label htmlFor="continuity-mode">{t('editAccountContext.continuityDepth')}</Label>
<Select
value={continuityMode}
onValueChange={(value) => setContinuityMode(value as ContinuityMode)}
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold">
{t('editAccountContext.continuityDepth')}
</Label>
</div>
<div
className="flex p-0.5 bg-muted/60 hover:bg-muted/80 transition-colors rounded-xl border border-border/40 w-full"
role="radiogroup"
aria-label={t('editAccountContext.continuityDepth')}
>
<SelectTrigger id="continuity-mode">
<SelectValue placeholder={t('editAccountContext.selectContinuityDepth')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">{t('editAccountContext.standardOption')}</SelectItem>
<SelectItem value="deeper">{t('editAccountContext.deeperOption')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{continuityMode === 'deeper'
? t('editAccountContext.deeperHint')
: t('editAccountContext.standardHint')}
<button
type="button"
role="radio"
aria-checked={continuityMode === 'standard'}
tabIndex={continuityMode === 'standard' ? 0 : -1}
onClick={() => setContinuityMode('standard')}
onKeyDown={(event) =>
handleRadioKeyDown(
event,
continuityMode,
['standard', 'deeper'] as const,
setContinuityMode
)
}
className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${
continuityMode === 'standard'
? 'bg-background text-emerald-600 dark:text-emerald-400 shadow-sm ring-1 ring-border/50'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<Link2 className="h-4 w-4 shrink-0" />
<span className="truncate">{t('editAccountContext.standardOption')}</span>
</button>
<button
type="button"
role="radio"
aria-checked={continuityMode === 'deeper'}
tabIndex={continuityMode === 'deeper' ? 0 : -1}
onClick={() => setContinuityMode('deeper')}
onKeyDown={(event) =>
handleRadioKeyDown(
event,
continuityMode,
['standard', 'deeper'] as const,
setContinuityMode
)
}
className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${
continuityMode === 'deeper'
? 'bg-background text-indigo-600 dark:text-indigo-400 shadow-sm ring-1 ring-border/50'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<Waves className="h-4 w-4 shrink-0" />
<span className="truncate">{t('editAccountContext.deeperOption')}</span>
</button>
</div>
<p className="text-xs text-muted-foreground pt-1 px-1">
{continuityMode === 'standard'
? t('editAccountContext.standardHint')
: t('editAccountContext.deeperHint')}
</p>
</div>
)}
@@ -150,6 +294,88 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
<p className="text-xs text-muted-foreground">
{t('editAccountContext.credentialsIsolated')}
</p>
{showPlainLaneMismatch && (
<div className="rounded-[14px] border border-amber-200 bg-amber-50/50 p-4 text-xs shadow-sm dark:border-amber-900/40 dark:bg-amber-900/10">
<div className="space-y-2">
<p className="font-medium text-foreground">
{t('continuityOverview.plainLaneTitle')}
</p>
<p className="text-muted-foreground leading-relaxed">
{t('continuityOverview.plainLaneDescription', {
lane: laneLabel || 'plain ccs',
name: account.name,
})}
</p>
<div className="space-y-2">
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">ccs -r</span>
<CopyButton value="ccs -r" size="icon" />
</div>
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">ccs auth backup default</span>
<CopyButton value="ccs auth backup default" size="icon" />
</div>
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">{accountBackupCommand}</span>
<CopyButton value={accountBackupCommand} size="icon" />
</div>
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">{defaultCommand}</span>
<CopyButton value={defaultCommand} size="icon" />
</div>
</div>
</div>
</div>
)}
<div
className={`rounded-[14px] border p-4 text-xs shadow-sm transition-colors ${mode === 'isolated' ? 'bg-blue-50/50 border-blue-200 dark:bg-blue-900/10 dark:border-blue-800/40' : 'bg-muted/40 border-border/60'}`}
>
<div className="flex items-start gap-3">
{mode === 'isolated' ? (
<ShieldAlert className="h-4 w-4 text-blue-500 mt-0.5 shrink-0" />
) : (
<Info className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
)}
<div className="space-y-1.5 flex-1 text-muted-foreground leading-relaxed">
{mode === 'isolated' ? (
<p className="text-foreground font-medium selection:bg-blue-200">
{t('editAccountContext.isolatedImplication')}
</p>
) : (
<>
<p>
<span className="text-foreground font-medium">
{t('editAccountContext.sameGroupRule', { group: normalizedGroup })}
</span>{' '}
{sameGroupPeerCount > 0
? t('editAccountContext.sameGroupPeerCount', { count: sameGroupPeerCount })
: t('editAccountContext.noSameGroupPeer')}
</p>
{continuityMode === 'deeper' && (
<p>
{sameGroupDeeperPeerCount > 0 ? (
t('editAccountContext.deeperReady', {
count: sameGroupDeeperPeerCount,
})
) : (
<span className="text-amber-600 dark:text-amber-500">
{t('editAccountContext.deeperNeedsPeers')}
</span>
)}
</p>
)}
</>
)}
<p
className={`pt-1.5 text-[11px] ${mode === 'isolated' ? 'text-blue-700/70 dark:text-blue-300/60' : 'text-muted-foreground/70'}`}
>
{t('editAccountContext.resumeOriginalWarning')}
</p>
</div>
</div>
</div>
</div>
<DialogFooter>
@@ -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<StageTone, { border: string; icon: string; count: string }> = {
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 (
<div className={cn('rounded-md border p-2.5', toneClasses[tone].border)}>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold">{title}</p>
<Icon className={cn('h-3.5 w-3.5', toneClasses[tone].icon)} />
</div>
<p className={cn('mt-1 text-lg font-mono font-semibold', toneClasses[tone].count)}>{count}</p>
</div>
);
}
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 (
<Card className="border-dashed">
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle className="text-base">{t('historySyncLearningMap.title')}</CardTitle>
<CardDescription className="mt-1">
{t('historySyncLearningMap.description')}
</CardDescription>
</div>
<Badge variant="outline">{t('historySyncLearningMap.learningMap')}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
{cliproxyCount > 0 && (
<div className="rounded-md border border-blue-300/60 bg-blue-50/40 px-3 py-2 text-xs text-blue-800 dark:border-blue-900/40 dark:bg-blue-900/10 dark:text-blue-300">
{t('historySyncLearningMap.cliproxyManaged', { count: cliproxyCount })}
</div>
)}
<div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr_auto_1fr] sm:items-center">
<StageTile
title={t('historySyncLearningMap.isolated')}
count={isolatedCount}
icon={Unlink}
tone="isolated"
/>
<div className="hidden sm:flex justify-center text-muted-foreground">
<ArrowRight className="h-4 w-4" />
</div>
<StageTile
title={t('historySyncLearningMap.shared')}
count={sharedStandardCount}
icon={Link2}
tone="shared"
/>
<div className="hidden sm:flex justify-center text-muted-foreground">
<ArrowRight className="h-4 w-4" />
</div>
<StageTile
title={t('historySyncLearningMap.deeper')}
count={deeperSharedCount}
icon={Waves}
tone="deeper"
/>
</div>
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="h-8 w-full justify-between rounded-md px-2 text-xs text-muted-foreground hover:bg-muted/40 hover:text-foreground"
>
<span>{t('historySyncLearningMap.showDetails')}</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="grid gap-2 lg:grid-cols-2">
<div className="rounded-md border bg-muted/20 p-2.5 text-xs">
<div className="flex items-center gap-2">
<ArrowRightLeft className="h-3.5 w-3.5 text-muted-foreground" />
<p className="font-semibold">{t('historySyncLearningMap.modeSwitch')}</p>
</div>
<p className="mt-1 text-muted-foreground">
{t('historySyncLearningMap.modeSwitchDesc')}
</p>
</div>
<div className="rounded-md border bg-muted/20 p-2.5 text-xs">
<div className="flex items-center gap-2">
<Layers3 className="h-3.5 w-3.5 text-muted-foreground" />
<p className="font-semibold">{t('historySyncLearningMap.historySyncGroup')}</p>
</div>
<p className="mt-1 text-muted-foreground">
{t('historySyncLearningMap.historySyncGroupDesc')}
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{groupsToShow.map((group) => (
<Badge key={group} variant="outline" className="font-mono text-[10px]">
{group}
</Badge>
))}
</div>
</div>
</div>
{legacyTargetCount > 0 && (
<div className="mt-2 rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-300">
{t('historySyncLearningMap.legacyConfirmation', { count: legacyTargetCount })}
</div>
)}
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
);
}
+65 -40
View File
@@ -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);
},
});
+124
View File
@@ -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>): string[] {
return Array.from(groups).sort((left, right) => left.localeCompare(right));
}
export function summarizeAuthAccountContinuity(accounts: Account[]): AuthAccountContinuitySummary {
const groupMap = new Map<string, SharedGroupSummary>();
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,
};
}
+12 -1
View File
@@ -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',
+504 -19
View File
@@ -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: 'リンクなし。厳密な分離に最適です。',
+40 -64
View File
@@ -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() {
</div>
)}
<Collapsible open={guideOpen} onOpenChange={setGuideOpen}>
<Card>
<CardHeader className="pb-2">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="h-auto w-full justify-between px-0 py-0">
<div className="text-left">
<CardTitle className="text-sm">
{t('accountsPage.continuityGuide')}
</CardTitle>
<CardDescription className="mt-1">
{t('accountsPage.expandWhenNeeded')}
</CardDescription>
</div>
<ChevronDown
className={cn('h-4 w-4 transition-transform', guideOpen && 'rotate-180')}
/>
</Button>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="space-y-3 text-xs text-muted-foreground">
<div className="rounded-md border p-2.5">
<p className="font-semibold text-foreground">
{t('accountsPage.sharedStandard')}
</p>
<p className="mt-1">{t('accountsPage.sharedStandardDesc')}</p>
</div>
<div className="rounded-md border p-2.5">
<p className="font-semibold text-foreground">
{t('accountsPage.sharedDeeper')}
</p>
<p className="mt-1">
{t('accountsPage.sharedDeeperPrefix')} <code>session-env</code>,{' '}
<code>file-history</code>, <code>shell-snapshots</code>,{' '}
<code>todos</code>.
</p>
</div>
<div className="rounded-md border p-2.5">
<p className="font-semibold text-foreground">
{t('accountsPage.isolated')}
</p>
<p className="mt-1">{t('accountsPage.isolatedDesc')}</p>
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t('accountsPage.quickCommands')}</CardTitle>
@@ -238,13 +188,21 @@ export function AccountsPage() {
</div>
<div className="flex-1 min-h-0 p-5 space-y-4 overflow-y-auto">
<HistorySyncLearningMap
<ContinuityOverview
totalAccounts={authAccounts.length}
primaryAccountName={authAccounts.length === 1 ? authAccounts[0]?.name : null}
isolatedCount={isolatedCount}
sharedStandardCount={sharedStandardCount}
deeperSharedCount={deeperSharedCount}
sharedAloneCount={sharedAloneCount}
sharedPeerAccountCount={sharedPeerAccountCount}
deeperReadyAccountCount={deeperReadyAccountCount}
sharedGroups={sharedGroups}
sharedPeerGroups={sharedPeerGroups}
deeperReadyGroups={deeperReadyGroups}
legacyTargetCount={legacyTargetCount}
cliproxyCount={cliproxyCount}
plainCcsLane={plainCcsLane}
/>
<Card className="flex flex-col">
@@ -258,7 +216,12 @@ export function AccountsPage() {
{isLoading ? (
<div className="text-muted-foreground">{t('accountsPage.loadingAccounts')}</div>
) : (
<AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} />
<AccountsTable
data={authAccounts}
defaultAccount={data?.default ?? null}
groupSummaries={groupSummaries}
plainCcsLane={plainCcsLane}
/>
)}
</CardContent>
</Card>
@@ -303,13 +266,21 @@ export function AccountsPage() {
</CardContent>
</Card>
<HistorySyncLearningMap
<ContinuityOverview
totalAccounts={authAccounts.length}
primaryAccountName={authAccounts.length === 1 ? authAccounts[0]?.name : null}
isolatedCount={isolatedCount}
sharedStandardCount={sharedStandardCount}
deeperSharedCount={deeperSharedCount}
sharedAloneCount={sharedAloneCount}
sharedPeerAccountCount={sharedPeerAccountCount}
deeperReadyAccountCount={deeperReadyAccountCount}
sharedGroups={sharedGroups}
sharedPeerGroups={sharedPeerGroups}
deeperReadyGroups={deeperReadyGroups}
legacyTargetCount={legacyTargetCount}
cliproxyCount={cliproxyCount}
plainCcsLane={plainCcsLane}
/>
<Card>
@@ -320,7 +291,12 @@ export function AccountsPage() {
{isLoading ? (
<div className="text-muted-foreground">{t('accountsPage.loadingAccounts')}</div>
) : (
<AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} />
<AccountsTable
data={authAccounts}
defaultAccount={data?.default ?? null}
groupSummaries={groupSummaries}
plainCcsLane={plainCcsLane}
/>
)}
</CardContent>
</Card>
@@ -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(
<HistorySyncLearningMap
<ContinuityOverview
totalAccounts={1}
isolatedCount={1}
sharedStandardCount={2}
deeperSharedCount={3}
sharedGroups={['default']}
legacyTargetCount={2}
cliproxyCount={1}
sharedStandardCount={0}
deeperSharedCount={0}
sharedAloneCount={0}
sharedPeerAccountCount={0}
deeperReadyAccountCount={0}
sharedGroups={[]}
sharedPeerGroups={[]}
deeperReadyGroups={[]}
legacyTargetCount={0}
cliproxyCount={0}
/>
);
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(
<ContinuityOverview
totalAccounts={3}
isolatedCount={1}
sharedStandardCount={2}
deeperSharedCount={0}
sharedAloneCount={2}
sharedPeerAccountCount={0}
deeperReadyAccountCount={0}
sharedGroups={[]}
sharedPeerGroups={[]}
deeperReadyGroups={[]}
legacyTargetCount={0}
cliproxyCount={0}
/>
);
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<string, { translation: Record<string, unknown> }>
| undefined;
@@ -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> = {}): 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);
});
});