mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +00:00
Merge pull request #641 from kaitranntt/kai/fix/issue-624-context-edit-discoverability
fix(accounts): improve shared-context editing and discoverability
This commit is contained in:
@@ -269,6 +269,11 @@ Account profiles are isolated by default.
|
||||
| `isolated` | Yes | No `context_group` required |
|
||||
| `shared` | No (explicit opt-in) | Valid non-empty `context_group` |
|
||||
|
||||
Shared mode continuity depth:
|
||||
|
||||
- `standard` (default): shares project workspace context only
|
||||
- `deeper` (advanced opt-in): additionally syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
|
||||
|
||||
Opt in to shared context when needed:
|
||||
|
||||
```bash
|
||||
@@ -277,8 +282,17 @@ ccs auth create backup --share-context
|
||||
|
||||
# Share context only within named group
|
||||
ccs auth create backup2 --context-group sprint-a
|
||||
|
||||
# Advanced deeper continuity mode (requires shared mode)
|
||||
ccs auth create backup3 --context-group sprint-a --deeper-continuity
|
||||
```
|
||||
|
||||
Update existing accounts without recreating login:
|
||||
|
||||
1. Run `ccs config`
|
||||
2. Open `Accounts`
|
||||
3. Click the pencil icon in Actions and set `isolated` or `shared` mode + continuity depth
|
||||
|
||||
Shared mode metadata in `~/.ccs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
@@ -288,6 +302,7 @@ accounts:
|
||||
last_used: null
|
||||
context_mode: "shared"
|
||||
context_group: "team-alpha"
|
||||
continuity_mode: "standard"
|
||||
```
|
||||
|
||||
`context_group` rules:
|
||||
@@ -298,7 +313,13 @@ accounts:
|
||||
- non-empty after normalization
|
||||
- normalized by trim + lowercase + whitespace collapse (`" Team Alpha "` -> `"team-alpha"`)
|
||||
|
||||
Shared context links project workspace data only. Credentials remain isolated per account.
|
||||
Shared context with `standard` depth links project workspace data. `deeper` depth links additional continuity artifacts. Credentials remain isolated per account.
|
||||
|
||||
Alternative path for lower manual switching:
|
||||
|
||||
- Use CLIProxy Claude pool (`ccs cliproxy auth claude`) and manage pool behavior in `ccs config` -> `CLIProxy Plus`.
|
||||
|
||||
Technical details: [`docs/session-sharing-technical-analysis.md`](docs/session-sharing-technical-analysis.md)
|
||||
|
||||
<br>
|
||||
|
||||
|
||||
@@ -203,15 +203,16 @@ src/
|
||||
|
||||
### Account Context Metadata Flow
|
||||
|
||||
- Source fields: `accounts.<name>.context_mode` and `accounts.<name>.context_group` in `~/.ccs/config.yaml`.
|
||||
- Source fields: `accounts.<name>.context_mode`, `accounts.<name>.context_group`, `accounts.<name>.continuity_mode` in `~/.ccs/config.yaml`.
|
||||
- Runtime policy resolver: `src/auth/account-context.ts`.
|
||||
- Metadata storage normalization: `src/auth/profile-registry.ts`.
|
||||
- API write validation: `PUT /api/config` in `src/web-server/routes/config-routes.ts`.
|
||||
- Rules:
|
||||
- mode is isolation-first (`isolated` default, `shared` opt-in)
|
||||
- shared mode requires non-empty valid `context_group`
|
||||
- shared mode continuity depth is `standard` by default, optional `deeper`
|
||||
- `context_group` is normalized (trim + lowercase + whitespace collapse to `-`)
|
||||
- API route rejects `context_group` when mode is not `shared`
|
||||
- API route rejects `context_group`/`continuity_mode` when mode is not `shared`
|
||||
- registry normalization drops malformed persisted `context_group` values
|
||||
|
||||
### Target Adapter Module
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dashboard Authentication CLI
|
||||
|
||||
Last Updated: 2026-02-24
|
||||
Last Updated: 2026-02-26
|
||||
|
||||
CLI commands for managing CCS dashboard authentication.
|
||||
|
||||
@@ -24,6 +24,11 @@ Account context is isolation-first:
|
||||
| `isolated` | Yes | No `context_group` required |
|
||||
| `shared` | No (opt-in) | Valid non-empty `context_group` |
|
||||
|
||||
Shared continuity depth:
|
||||
|
||||
- `standard` (default): shares project workspace context only
|
||||
- `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
|
||||
|
||||
`context_group` normalization and validation:
|
||||
|
||||
- trim + lowercase + collapse internal whitespace to `-`
|
||||
@@ -31,13 +36,23 @@ Account context is isolation-first:
|
||||
- must start with a letter
|
||||
- max length: 64
|
||||
- shared mode requires non-empty value after normalization
|
||||
- `continuity_mode` is only valid when mode is `shared`
|
||||
|
||||
`PUT /api/config` behavior for account context:
|
||||
|
||||
- rejects invalid unified payloads
|
||||
- rejects explicit `context_mode: shared` with invalid/empty `context_group`
|
||||
- rejects invalid `continuity_mode` values
|
||||
- normalizes valid shared `context_group` before save
|
||||
- defaults missing shared `continuity_mode` to `standard`
|
||||
- rejects `context_group` when mode is not `shared`
|
||||
- rejects `continuity_mode` when mode is not `shared`
|
||||
|
||||
Dashboard accounts context editing:
|
||||
|
||||
- `PUT /api/accounts/:name/context` updates context mode/group/continuity for existing auth accounts
|
||||
- rejects CLIProxy OAuth account keys for this route
|
||||
- applies normalization/validation rules above
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Session Sharing Technical Analysis
|
||||
|
||||
Last Updated: 2026-02-26
|
||||
|
||||
## Summary
|
||||
|
||||
CCS supports practical cross-account continuity by sharing workspace context files between selected accounts, while keeping credentials isolated per account.
|
||||
|
||||
This is implemented as a context policy per account:
|
||||
|
||||
- `isolated` (default): account keeps its own workspace context
|
||||
- `shared` + `standard` (default): account workspace context is linked to a shared context group
|
||||
- `shared` + `deeper` (advanced opt-in): account also shares continuity artifacts
|
||||
|
||||
## Why This Is Safe Enough
|
||||
|
||||
CCS only shares workspace context paths (project/session context files). It does **not** merge or copy authentication credentials between accounts.
|
||||
|
||||
Credential storage remains per account instance.
|
||||
|
||||
## Implementation Model
|
||||
|
||||
Account metadata is stored in `~/.ccs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
accounts:
|
||||
work:
|
||||
created: "2026-02-24T00:00:00.000Z"
|
||||
last_used: null
|
||||
context_mode: "shared"
|
||||
context_group: "team-alpha"
|
||||
continuity_mode: "deeper"
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `context_mode` must be `isolated` or `shared`
|
||||
- `context_group` is required when `context_mode=shared`
|
||||
- `continuity_mode` is valid only when `context_mode=shared` (`standard` or `deeper`)
|
||||
- group normalization: trim, lowercase, internal spaces -> `-`
|
||||
- group must start with a letter and only include `[a-zA-Z0-9_-]`
|
||||
- max length: `64`
|
||||
|
||||
Deeper continuity links these directories per context group:
|
||||
|
||||
- `session-env`
|
||||
- `file-history`
|
||||
- `shell-snapshots`
|
||||
- `todos`
|
||||
|
||||
`.anthropic` and account credentials remain isolated.
|
||||
|
||||
## User Workflows
|
||||
|
||||
### New account with shared context
|
||||
|
||||
```bash
|
||||
ccs auth create work2 --share-context
|
||||
ccs auth create backup --context-group sprint-a
|
||||
ccs auth create backup2 --context-group sprint-a --deeper-continuity
|
||||
```
|
||||
|
||||
### Existing account
|
||||
|
||||
- Open `ccs config`
|
||||
- Go to `Accounts`
|
||||
- Click the pencil icon (`Edit History Sync`)
|
||||
- Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity
|
||||
|
||||
No account recreation required for this workflow.
|
||||
|
||||
## Current Limitations
|
||||
|
||||
- Shared context is local filesystem sharing. It does not bypass remote provider permission models.
|
||||
- Session continuity still depends on what the upstream tool/provider stores and allows.
|
||||
- Context sharing should only be enabled for accounts you intentionally trust to share workspace history.
|
||||
|
||||
## Alternative: CLIProxy Claude Pool
|
||||
|
||||
For users who prefer lower manual account switching, use CLIProxy Claude pool instead:
|
||||
|
||||
- Authenticate pool accounts via `ccs cliproxy auth claude`
|
||||
- Manage account pool behavior in `ccs config` -> `CLIProxy Plus`
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- Confirm account row shows `shared (<group>)` in Dashboard Accounts table
|
||||
- Switch between accounts in the same group and verify workspace continuity
|
||||
- Run `ccs doctor` if symlink/context health looks inconsistent
|
||||
@@ -6,20 +6,24 @@
|
||||
*/
|
||||
|
||||
export type AccountContextMode = 'isolated' | 'shared';
|
||||
export type AccountContinuityMode = 'standard' | 'deeper';
|
||||
|
||||
export interface AccountContextMetadata {
|
||||
context_mode?: AccountContextMode;
|
||||
context_group?: string;
|
||||
continuity_mode?: AccountContinuityMode;
|
||||
}
|
||||
|
||||
export interface AccountContextPolicy {
|
||||
mode: AccountContextMode;
|
||||
group?: string;
|
||||
continuityMode?: AccountContinuityMode;
|
||||
}
|
||||
|
||||
export interface CreateAccountContextInput {
|
||||
shareContext: boolean;
|
||||
contextGroup?: string;
|
||||
deeperContinuity?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedCreateAccountContext {
|
||||
@@ -29,6 +33,7 @@ export interface ResolvedCreateAccountContext {
|
||||
|
||||
export const DEFAULT_ACCOUNT_CONTEXT_MODE: AccountContextMode = 'isolated';
|
||||
export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default';
|
||||
export const DEFAULT_ACCOUNT_CONTINUITY_MODE: AccountContinuityMode = 'standard';
|
||||
export const MAX_CONTEXT_GROUP_LENGTH = 64;
|
||||
export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
||||
|
||||
@@ -66,11 +71,22 @@ export function isAccountContextMetadata(value: unknown): value is AccountContex
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const mode = candidate['context_mode'];
|
||||
const group = candidate['context_group'];
|
||||
const continuity = candidate['continuity_mode'];
|
||||
|
||||
const modeValid = mode === undefined || mode === 'isolated' || mode === 'shared';
|
||||
const groupValid = group === undefined || typeof group === 'string';
|
||||
const continuityValid =
|
||||
continuity === undefined || continuity === 'standard' || continuity === 'deeper';
|
||||
|
||||
return modeValid && groupValid;
|
||||
if (!modeValid || !groupValid || !continuityValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mode !== 'shared' && continuity !== undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +96,15 @@ export function resolveCreateAccountContext(
|
||||
input: CreateAccountContextInput
|
||||
): ResolvedCreateAccountContext {
|
||||
const hasGroupFlag = input.contextGroup !== undefined;
|
||||
const continuityMode: AccountContinuityMode = input.deeperContinuity ? 'deeper' : 'standard';
|
||||
|
||||
if (input.deeperContinuity && !input.shareContext && !hasGroupFlag) {
|
||||
return {
|
||||
policy: { mode: 'isolated' },
|
||||
error:
|
||||
'Advanced deeper continuity requires shared context (--share-context or --context-group).',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasGroupFlag) {
|
||||
if (!input.contextGroup || input.contextGroup.trim().length === 0) {
|
||||
@@ -101,6 +126,7 @@ export function resolveCreateAccountContext(
|
||||
policy: {
|
||||
mode: 'shared',
|
||||
group: normalizedGroup,
|
||||
continuityMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -110,6 +136,7 @@ export function resolveCreateAccountContext(
|
||||
policy: {
|
||||
mode: 'shared',
|
||||
group: DEFAULT_ACCOUNT_CONTEXT_GROUP,
|
||||
continuityMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -128,15 +155,21 @@ export function resolveAccountContextPolicy(
|
||||
const mode: AccountContextMode = metadata?.context_mode === 'shared' ? 'shared' : 'isolated';
|
||||
|
||||
if (mode === 'shared') {
|
||||
const continuityMode: AccountContinuityMode =
|
||||
metadata?.continuity_mode === 'deeper' ? 'deeper' : 'standard';
|
||||
const rawGroup = metadata?.context_group;
|
||||
if (rawGroup && rawGroup.trim().length > 0) {
|
||||
const normalized = normalizeContextGroupName(rawGroup);
|
||||
if (isValidContextGroupName(normalized)) {
|
||||
return { mode: 'shared', group: normalized };
|
||||
return { mode: 'shared', group: normalized, continuityMode };
|
||||
}
|
||||
}
|
||||
|
||||
return { mode: 'shared', group: DEFAULT_ACCOUNT_CONTEXT_GROUP };
|
||||
return {
|
||||
mode: 'shared',
|
||||
group: DEFAULT_ACCOUNT_CONTEXT_GROUP,
|
||||
continuityMode,
|
||||
};
|
||||
}
|
||||
|
||||
return { mode: 'isolated' };
|
||||
@@ -152,6 +185,8 @@ export function policyToAccountContextMetadata(
|
||||
return {
|
||||
context_mode: 'shared',
|
||||
context_group: policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP,
|
||||
continuity_mode:
|
||||
policy.continuityMode === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,7 +200,8 @@ export function policyToAccountContextMetadata(
|
||||
*/
|
||||
export function formatAccountContextPolicy(policy: AccountContextPolicy): string {
|
||||
if (policy.mode === 'shared') {
|
||||
return `shared (${policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP})`;
|
||||
const continuity = policy.continuityMode === 'deeper' ? 'deeper continuity' : 'standard';
|
||||
return `shared (${policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP}, ${continuity})`;
|
||||
}
|
||||
|
||||
return 'isolated';
|
||||
|
||||
@@ -86,6 +86,11 @@ class AuthCommands {
|
||||
console.log(` ${dim('# Share context only within a specific group')}`);
|
||||
console.log(` ${color('ccs auth create backup --context-group sprint-a', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Advanced: deeper shared continuity for session history artifacts')}`);
|
||||
console.log(
|
||||
` ${color('ccs auth create backup --context-group sprint-a --deeper-continuity', 'command')}`
|
||||
);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Set work as default')}`);
|
||||
console.log(` ${color('ccs auth default work', 'command')}`);
|
||||
console.log('');
|
||||
@@ -108,6 +113,9 @@ class AuthCommands {
|
||||
console.log(
|
||||
` ${color('--context-group <name>', 'command')} Share context only within a named group`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--deeper-continuity', 'command')} Advanced shared mode: sync additional continuity artifacts`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
|
||||
);
|
||||
@@ -128,6 +136,12 @@ class AuthCommands {
|
||||
console.log(
|
||||
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--deeper-continuity', 'command')} requires shared mode and syncs session-env/file-history/todos/shell-snapshots.`
|
||||
);
|
||||
console.log(
|
||||
` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.`
|
||||
);
|
||||
console.log(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`);
|
||||
console.log(
|
||||
` ${color('context_group', 'path')} must be non-empty and <= ${MAX_CONTEXT_GROUP_LENGTH} chars in shared mode.`
|
||||
|
||||
@@ -31,14 +31,15 @@ function sanitizeProfileNameForInstance(name: string): string {
|
||||
*/
|
||||
export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const { profileName, force, shareContext, contextGroup, unknownFlags } = parseArgs(args);
|
||||
const { profileName, force, shareContext, contextGroup, deeperContinuity, unknownFlags } =
|
||||
parseArgs(args);
|
||||
|
||||
if (unknownFlags && unknownFlags.length > 0) {
|
||||
const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
|
||||
console.log(fail(`Unknown option(s): ${unknownList}`));
|
||||
console.log('');
|
||||
console.log(
|
||||
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>]', 'command')}`
|
||||
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
|
||||
);
|
||||
console.log(`Help: ${color('ccs auth --help', 'command')}`);
|
||||
console.log('');
|
||||
@@ -49,7 +50,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
console.log(fail('Profile name is required'));
|
||||
console.log('');
|
||||
console.log(
|
||||
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>]', 'command')}`
|
||||
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
|
||||
);
|
||||
console.log('');
|
||||
console.log('Example:');
|
||||
@@ -89,6 +90,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
const resolvedContext = resolveCreateAccountContext({
|
||||
shareContext: !!shareContext,
|
||||
contextGroup,
|
||||
deeperContinuity: !!deeperContinuity,
|
||||
});
|
||||
|
||||
if (resolvedContext.error) {
|
||||
@@ -212,7 +214,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
console.log('');
|
||||
const launchDescription =
|
||||
contextPolicy.mode === 'shared'
|
||||
? `Starting Claude with shared context group "${contextPolicy.group || 'default'}"...`
|
||||
? contextPolicy.continuityMode === 'deeper'
|
||||
? `Starting Claude with shared context group "${contextPolicy.group || 'default'}" (deeper continuity)...`
|
||||
: `Starting Claude with shared context group "${contextPolicy.group || 'default'}"...`
|
||||
: 'Starting Claude in isolated instance...';
|
||||
console.log(warn(launchDescription));
|
||||
console.log(warn('You will be prompted to login with your account.'));
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
||||
last_used: account.last_used,
|
||||
context_mode: account.context_mode,
|
||||
context_group: account.context_group,
|
||||
continuity_mode: account.continuity_mode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +57,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
||||
last_used: profile.last_used || null,
|
||||
context_mode: contextPolicy.mode,
|
||||
context_group: contextPolicy.group || null,
|
||||
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
|
||||
instance_path: instancePath,
|
||||
};
|
||||
}),
|
||||
@@ -134,7 +136,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
||||
console.log(
|
||||
table(rows, {
|
||||
head: headers,
|
||||
colWidths: verbose ? [15, 12, 15, 12, 18] : [15, 12, 15],
|
||||
colWidths: verbose ? [15, 12, 15, 12, 34] : [15, 12, 15],
|
||||
})
|
||||
);
|
||||
console.log('');
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
||||
last_used: profile.last_used || null,
|
||||
context_mode: contextPolicy.mode,
|
||||
context_group: contextPolicy.group || null,
|
||||
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
|
||||
instance_path: instancePath,
|
||||
session_count: sessionCount,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface AuthCommandArgs {
|
||||
yes?: boolean;
|
||||
shareContext?: boolean;
|
||||
contextGroup?: string;
|
||||
deeperContinuity?: boolean;
|
||||
unknownFlags?: string[];
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ export interface ProfileOutput {
|
||||
last_used: string | null;
|
||||
context_mode?: 'isolated' | 'shared';
|
||||
context_group?: string | null;
|
||||
continuity_mode?: 'standard' | 'deeper' | null;
|
||||
instance_path?: string;
|
||||
session_count?: number;
|
||||
}
|
||||
@@ -70,6 +72,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
|
||||
'--yes',
|
||||
'-y',
|
||||
'--share-context',
|
||||
'--deeper-continuity',
|
||||
]);
|
||||
const knownValueFlags = new Set(['--context-group']);
|
||||
|
||||
@@ -121,6 +124,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
|
||||
json: args.includes('--json'),
|
||||
yes: args.includes('--yes') || args.includes('-y'),
|
||||
shareContext: args.includes('--share-context'),
|
||||
deeperContinuity: args.includes('--deeper-continuity'),
|
||||
contextGroup,
|
||||
unknownFlags: [...unknownFlags],
|
||||
};
|
||||
|
||||
@@ -202,6 +202,7 @@ class ProfileDetector {
|
||||
last_used: account.last_used,
|
||||
context_mode: account.context_mode,
|
||||
context_group: account.context_group,
|
||||
continuity_mode: account.continuity_mode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { isValidContextGroupName, normalizeContextGroupName } from './account-co
|
||||
* last_used: <ISO timestamp or null> // Last usage time
|
||||
* context_mode?: 'isolated' | 'shared' // Workspace context policy
|
||||
* context_group?: <string> // Shared context group when mode=shared
|
||||
* continuity_mode?: 'standard' | 'deeper' // Shared continuity depth
|
||||
* }
|
||||
*
|
||||
* Removed fields from v2.x:
|
||||
@@ -43,6 +44,7 @@ interface CreateMetadata {
|
||||
last_used?: string | null;
|
||||
context_mode?: 'isolated' | 'shared';
|
||||
context_group?: string;
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
}
|
||||
|
||||
export class ProfileRegistry {
|
||||
@@ -70,6 +72,7 @@ export class ProfileRegistry {
|
||||
|
||||
if (normalized.context_mode !== 'shared') {
|
||||
delete normalized.context_group;
|
||||
delete normalized.continuity_mode;
|
||||
} else {
|
||||
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
|
||||
if (normalizedGroup) {
|
||||
@@ -77,6 +80,8 @@ export class ProfileRegistry {
|
||||
} else {
|
||||
delete normalized.context_group;
|
||||
}
|
||||
|
||||
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
|
||||
}
|
||||
|
||||
return normalized;
|
||||
@@ -87,6 +92,7 @@ export class ProfileRegistry {
|
||||
|
||||
if (normalized.context_mode !== 'shared') {
|
||||
delete normalized.context_group;
|
||||
delete normalized.continuity_mode;
|
||||
} else {
|
||||
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
|
||||
if (normalizedGroup) {
|
||||
@@ -94,6 +100,8 @@ export class ProfileRegistry {
|
||||
} else {
|
||||
delete normalized.context_group;
|
||||
}
|
||||
|
||||
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
|
||||
}
|
||||
|
||||
return normalized;
|
||||
@@ -164,6 +172,7 @@ export class ProfileRegistry {
|
||||
last_used: metadata.last_used || null,
|
||||
context_mode: metadata.context_mode,
|
||||
context_group: metadata.context_group,
|
||||
continuity_mode: metadata.continuity_mode,
|
||||
});
|
||||
|
||||
// Note: No longer auto-set as default
|
||||
@@ -311,6 +320,7 @@ export class ProfileRegistry {
|
||||
last_used: null,
|
||||
context_mode: metadata.context_mode,
|
||||
context_group: metadata.context_group,
|
||||
continuity_mode: metadata.continuity_mode,
|
||||
});
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
@@ -438,6 +448,7 @@ export class ProfileRegistry {
|
||||
last_used: account.last_used,
|
||||
context_mode: account.context_mode,
|
||||
context_group: account.context_group,
|
||||
continuity_mode: account.continuity_mode,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -150,10 +150,15 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['Run multiple Claude accounts concurrently'],
|
||||
[
|
||||
['ccs auth --help', 'Show account management commands'],
|
||||
['ccs auth create <name>', 'Create account profile (supports context sharing flags)'],
|
||||
[
|
||||
'ccs auth create <name>',
|
||||
'Create account profile (supports shared groups + --deeper-continuity)',
|
||||
],
|
||||
['ccs config', 'Dashboard: Accounts table can edit context mode/group/continuity depth'],
|
||||
['ccs auth list', 'List all account profiles'],
|
||||
['ccs auth default <name>', 'Set default profile'],
|
||||
['ccs auth reset-default', 'Restore original CCS default'],
|
||||
['ccs cliproxy auth claude', 'Alternative: authenticate Claude account pool via CLIProxy'],
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -151,7 +151,10 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
|
||||
const metadata = meta as Record<string, unknown>;
|
||||
const rawContextMode = metadata.context_mode;
|
||||
const rawContextGroup = metadata.context_group;
|
||||
const rawContinuityMode = metadata.continuity_mode;
|
||||
const contextMode = rawContextMode === 'shared' ? 'shared' : 'isolated';
|
||||
const continuityMode =
|
||||
contextMode === 'shared' && rawContinuityMode === 'deeper' ? 'deeper' : 'standard';
|
||||
let contextGroup: string | undefined;
|
||||
if (typeof rawContextGroup === 'string' && rawContextGroup.trim().length > 0) {
|
||||
const normalizedGroup = normalizeContextGroupName(rawContextGroup);
|
||||
@@ -168,6 +171,7 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
|
||||
last_used: (metadata.last_used as string) || null,
|
||||
context_mode: contextMode,
|
||||
context_group: contextMode === 'shared' ? contextGroup : undefined,
|
||||
continuity_mode: contextMode === 'shared' ? continuityMode : undefined,
|
||||
};
|
||||
unifiedConfig.accounts[name] = account;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface AccountConfig {
|
||||
context_mode?: 'isolated' | 'shared';
|
||||
/** Context-sharing group when context_mode='shared' */
|
||||
context_group?: string;
|
||||
/** Shared continuity depth when context_mode='shared' */
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,6 +48,7 @@ class InstanceManager {
|
||||
|
||||
// Apply context policy (isolated by default, optional shared group).
|
||||
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
|
||||
await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy);
|
||||
});
|
||||
|
||||
return instancePath;
|
||||
|
||||
@@ -27,6 +27,12 @@ class SharedManager {
|
||||
private readonly claudeDir: string;
|
||||
private readonly instancesDir: string;
|
||||
private readonly sharedItems: SharedItem[];
|
||||
private readonly advancedContinuityItems: readonly string[] = [
|
||||
'session-env',
|
||||
'file-history',
|
||||
'shell-snapshots',
|
||||
'todos',
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.homeDir = os.homedir();
|
||||
@@ -314,6 +320,133 @@ class SharedManager {
|
||||
await this.ensureDirectory(projectsPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync advanced continuity artifacts for shared deeper mode.
|
||||
*
|
||||
* - shared + deeper: artifacts are linked per context group.
|
||||
* - shared + standard / isolated: artifacts stay local to instance.
|
||||
*/
|
||||
async syncAdvancedContinuityArtifacts(
|
||||
instancePath: string,
|
||||
policy: AccountContextPolicy
|
||||
): Promise<void> {
|
||||
const instanceName = path.basename(instancePath);
|
||||
const useSharedContinuity = policy.mode === 'shared' && policy.continuityMode === 'deeper';
|
||||
const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP;
|
||||
|
||||
for (const artifactName of this.advancedContinuityItems) {
|
||||
const instanceArtifactPath = path.join(instancePath, artifactName);
|
||||
|
||||
if (useSharedContinuity) {
|
||||
const sharedArtifactPath = path.join(
|
||||
this.sharedDir,
|
||||
'context-groups',
|
||||
contextGroup,
|
||||
'continuity',
|
||||
artifactName
|
||||
);
|
||||
|
||||
await this.ensureDirectory(sharedArtifactPath);
|
||||
await this.ensureDirectory(path.dirname(instanceArtifactPath));
|
||||
|
||||
const currentStats = await this.getLstat(instanceArtifactPath);
|
||||
if (!currentStats) {
|
||||
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentStats.isSymbolicLink()) {
|
||||
if (await this.isSymlinkTarget(instanceArtifactPath, sharedArtifactPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentTarget = await this.resolveSymlinkTargetPath(instanceArtifactPath);
|
||||
if (
|
||||
currentTarget &&
|
||||
path.resolve(currentTarget) !== path.resolve(sharedArtifactPath) &&
|
||||
this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) &&
|
||||
(await this.pathExists(currentTarget))
|
||||
) {
|
||||
await this.mergeDirectoryWithConflictCopies(
|
||||
currentTarget,
|
||||
sharedArtifactPath,
|
||||
instanceName
|
||||
);
|
||||
} else if (
|
||||
currentTarget &&
|
||||
!this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName)
|
||||
) {
|
||||
console.log(
|
||||
warn(
|
||||
`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await fs.promises.unlink(instanceArtifactPath);
|
||||
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentStats.isDirectory()) {
|
||||
await this.mergeDirectoryWithConflictCopies(
|
||||
instanceArtifactPath,
|
||||
sharedArtifactPath,
|
||||
instanceName
|
||||
);
|
||||
await fs.promises.rm(instanceArtifactPath, { recursive: true, force: true });
|
||||
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
await fs.promises.rm(instanceArtifactPath, { force: true });
|
||||
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentStats = await this.getLstat(instanceArtifactPath);
|
||||
if (!currentStats) {
|
||||
await this.ensureDirectory(instanceArtifactPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentStats.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentStats.isSymbolicLink()) {
|
||||
const currentTarget = await this.resolveSymlinkTargetPath(instanceArtifactPath);
|
||||
await fs.promises.unlink(instanceArtifactPath);
|
||||
await this.ensureDirectory(instanceArtifactPath);
|
||||
|
||||
if (
|
||||
currentTarget &&
|
||||
path.resolve(currentTarget) !== path.resolve(instanceArtifactPath) &&
|
||||
this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) &&
|
||||
(await this.pathExists(currentTarget))
|
||||
) {
|
||||
await this.mergeDirectoryWithConflictCopies(
|
||||
currentTarget,
|
||||
instanceArtifactPath,
|
||||
instanceName
|
||||
);
|
||||
} else if (
|
||||
currentTarget &&
|
||||
!this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName)
|
||||
) {
|
||||
console.log(
|
||||
warn(`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`)
|
||||
);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
await fs.promises.rm(instanceArtifactPath, { force: true });
|
||||
await this.ensureDirectory(instanceArtifactPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all project memory directories for an instance are shared.
|
||||
*
|
||||
@@ -712,6 +845,38 @@ class SharedManager {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard advanced continuity merge operations to known CCS-managed roots only.
|
||||
*/
|
||||
private isSafeContinuityMergeSource(
|
||||
sourcePath: string,
|
||||
instanceName: string,
|
||||
artifactName: string
|
||||
): boolean {
|
||||
const resolvedSource = this.resolveCanonicalPath(sourcePath);
|
||||
const sharedContextRoot = this.resolveCanonicalPath(
|
||||
path.join(this.sharedDir, 'context-groups')
|
||||
);
|
||||
const instanceArtifactRoot = this.resolveCanonicalPath(
|
||||
path.join(this.instancesDir, instanceName, artifactName)
|
||||
);
|
||||
|
||||
const normalizedSource =
|
||||
process.platform === 'win32' ? resolvedSource.toLowerCase() : resolvedSource;
|
||||
const continuitySegment =
|
||||
process.platform === 'win32'
|
||||
? `${path.sep}continuity${path.sep}`.toLowerCase()
|
||||
: `${path.sep}continuity${path.sep}`;
|
||||
|
||||
const withinSharedContinuity =
|
||||
this.isPathWithinDirectory(resolvedSource, sharedContextRoot) &&
|
||||
normalizedSource.includes(continuitySegment);
|
||||
|
||||
return (
|
||||
withinSharedContinuity || this.isPathWithinDirectory(resolvedSource, instanceArtifactRoot)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link directory with Windows fallback to recursive copy.
|
||||
*/
|
||||
|
||||
@@ -96,6 +96,8 @@ export interface ProfileMetadata {
|
||||
context_mode?: 'isolated' | 'shared';
|
||||
/** Context-sharing group when context_mode='shared' */
|
||||
context_group?: string;
|
||||
/** Shared continuity depth when context_mode='shared' */
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
}
|
||||
|
||||
export interface ProfilesRegistry {
|
||||
|
||||
@@ -7,6 +7,9 @@ export interface MergedAccountEntry {
|
||||
last_used: string | null;
|
||||
context_mode?: 'isolated' | 'shared';
|
||||
context_group?: string;
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
context_inferred?: boolean;
|
||||
continuity_inferred?: boolean;
|
||||
provider?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
soloAccount,
|
||||
} from '../../cliproxy/account-manager';
|
||||
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
|
||||
import { resolveAccountContextPolicy } from '../../auth/account-context';
|
||||
import {
|
||||
DEFAULT_ACCOUNT_CONTINUITY_MODE,
|
||||
isValidContextGroupName,
|
||||
normalizeContextGroupName,
|
||||
resolveAccountContextPolicy,
|
||||
} from '../../auth/account-context';
|
||||
import {
|
||||
buildCliproxyAccountKey,
|
||||
parseCliproxyKey,
|
||||
@@ -52,24 +57,40 @@ router.get('/', (_req: Request, res: Response): void => {
|
||||
// Add legacy profiles first
|
||||
for (const [name, meta] of Object.entries(legacyProfiles)) {
|
||||
const contextPolicy = resolveAccountContextPolicy(meta);
|
||||
const hasExplicitContextMode =
|
||||
meta.context_mode === 'isolated' || meta.context_mode === 'shared';
|
||||
const hasExplicitContinuityMode =
|
||||
meta.continuity_mode === 'standard' || meta.continuity_mode === 'deeper';
|
||||
merged[name] = {
|
||||
type: meta.type || 'account',
|
||||
created: meta.created,
|
||||
last_used: meta.last_used || null,
|
||||
context_mode: contextPolicy.mode,
|
||||
context_group: contextPolicy.group,
|
||||
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : undefined,
|
||||
context_inferred: !hasExplicitContextMode,
|
||||
continuity_inferred:
|
||||
contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Override with unified config accounts (takes precedence)
|
||||
for (const [name, account] of Object.entries(unifiedAccounts)) {
|
||||
const contextPolicy = resolveAccountContextPolicy(account);
|
||||
const hasExplicitContextMode =
|
||||
account.context_mode === 'isolated' || account.context_mode === 'shared';
|
||||
const hasExplicitContinuityMode =
|
||||
account.continuity_mode === 'standard' || account.continuity_mode === 'deeper';
|
||||
merged[name] = {
|
||||
type: 'account',
|
||||
created: account.created,
|
||||
last_used: account.last_used,
|
||||
context_mode: contextPolicy.mode,
|
||||
context_group: contextPolicy.group,
|
||||
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : undefined,
|
||||
context_inferred: !hasExplicitContextMode,
|
||||
continuity_inferred:
|
||||
contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,6 +170,140 @@ router.post('/default', (req: Request, res: Response): void => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/accounts/:name/context - Update account context mode/group
|
||||
*/
|
||||
router.put('/:name/context', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'Missing account name' });
|
||||
return;
|
||||
}
|
||||
|
||||
// CLIProxy OAuth accounts do not support local account context metadata.
|
||||
const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null;
|
||||
if (cliproxyKey) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: `Context mode is not supported for CLIProxy account: ${name}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const existsUnified = isUnifiedMode() && registry.hasAccountUnified(name);
|
||||
const existsLegacy = registry.hasProfile(name);
|
||||
if (!existsUnified && !existsLegacy) {
|
||||
res.status(404).json({ error: `Account not found: ${name}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = req.body?.context_mode;
|
||||
const rawGroup = req.body?.context_group;
|
||||
const rawContinuityMode = req.body?.continuity_mode;
|
||||
|
||||
if (mode !== 'isolated' && mode !== 'shared') {
|
||||
res.status(400).json({ error: 'Missing or invalid context_mode: expected isolated|shared' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode !== 'shared' && rawGroup !== undefined) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid payload: context_group requires context_mode=shared' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode !== 'shared' && rawContinuityMode !== undefined) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid payload: continuity_mode requires context_mode=shared' });
|
||||
return;
|
||||
}
|
||||
|
||||
let normalizedGroup: string | undefined;
|
||||
let continuityMode: 'standard' | 'deeper' | undefined;
|
||||
if (mode === 'shared') {
|
||||
if (typeof rawGroup !== 'string' || rawGroup.trim().length === 0) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid payload: shared context_mode requires non-empty context_group' });
|
||||
return;
|
||||
}
|
||||
|
||||
normalizedGroup = normalizeContextGroupName(rawGroup);
|
||||
if (!isValidContextGroupName(normalizedGroup)) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'Invalid context_group. Use letters/numbers/dash/underscore, start with a letter, max 64 chars.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
rawContinuityMode !== undefined &&
|
||||
rawContinuityMode !== 'standard' &&
|
||||
rawContinuityMode !== 'deeper'
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid continuity_mode: expected standard|deeper',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
continuityMode = rawContinuityMode === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE;
|
||||
}
|
||||
|
||||
const metadata =
|
||||
mode === 'shared'
|
||||
? {
|
||||
context_mode: 'shared' as const,
|
||||
context_group: normalizedGroup,
|
||||
continuity_mode: continuityMode,
|
||||
}
|
||||
: {
|
||||
context_mode: 'isolated' as const,
|
||||
};
|
||||
const policy = resolveAccountContextPolicy(metadata);
|
||||
|
||||
const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined;
|
||||
const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined;
|
||||
|
||||
try {
|
||||
if (existsUnified) {
|
||||
registry.updateAccountUnified(name, metadata);
|
||||
}
|
||||
if (existsLegacy) {
|
||||
registry.updateProfile(name, metadata);
|
||||
}
|
||||
|
||||
await instanceMgr.ensureInstance(name, policy);
|
||||
} catch (error) {
|
||||
if (existsUnified && previousUnified) {
|
||||
registry.updateAccountUnified(name, previousUnified);
|
||||
}
|
||||
if (existsLegacy && previousLegacy) {
|
||||
registry.updateProfile(name, previousLegacy);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
res.json({
|
||||
name,
|
||||
context_mode: policy.mode,
|
||||
context_group: policy.group ?? null,
|
||||
continuity_mode:
|
||||
policy.mode === 'shared'
|
||||
? (policy.continuityMode ?? DEFAULT_ACCOUNT_CONTINUITY_MODE)
|
||||
: null,
|
||||
context_inferred: false,
|
||||
continuity_inferred: false,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/accounts/reset-default - Reset to CCS default
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
getBackupDirectories,
|
||||
} from '../../config/migration-manager';
|
||||
import { isUnifiedConfig } from '../../config/unified-config-types';
|
||||
import { isValidContextGroupName, normalizeContextGroupName } from '../../auth/account-context';
|
||||
import {
|
||||
DEFAULT_ACCOUNT_CONTINUITY_MODE,
|
||||
isValidContextGroupName,
|
||||
normalizeContextGroupName,
|
||||
} from '../../auth/account-context';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -45,6 +49,7 @@ function validateAndNormalizeAccountContextMetadata(config: unknown): string | n
|
||||
const account = accountValue as Record<string, unknown>;
|
||||
const mode = account.context_mode;
|
||||
const group = account.context_group;
|
||||
const continuity = account.continuity_mode;
|
||||
|
||||
if (mode !== undefined && mode !== 'isolated' && mode !== 'shared') {
|
||||
return `Invalid config.accounts.${accountName}.context_mode: expected isolated|shared`;
|
||||
@@ -54,10 +59,18 @@ function validateAndNormalizeAccountContextMetadata(config: unknown): string | n
|
||||
return `Invalid config.accounts.${accountName}.context_group: expected string`;
|
||||
}
|
||||
|
||||
if (continuity !== undefined && continuity !== 'standard' && continuity !== 'deeper') {
|
||||
return `Invalid config.accounts.${accountName}.continuity_mode: expected standard|deeper`;
|
||||
}
|
||||
|
||||
if (mode !== 'shared' && group !== undefined) {
|
||||
return `Invalid config.accounts.${accountName}: context_group requires context_mode=shared`;
|
||||
}
|
||||
|
||||
if (mode !== 'shared' && continuity !== undefined) {
|
||||
return `Invalid config.accounts.${accountName}: continuity_mode requires context_mode=shared`;
|
||||
}
|
||||
|
||||
if (mode === 'shared' && typeof group === 'string' && group.trim().length > 0) {
|
||||
const normalizedGroup = normalizeContextGroupName(group);
|
||||
if (!isValidContextGroupName(normalizedGroup)) {
|
||||
@@ -66,6 +79,11 @@ function validateAndNormalizeAccountContextMetadata(config: unknown): string | n
|
||||
account.context_group = normalizedGroup;
|
||||
}
|
||||
|
||||
if (mode === 'shared') {
|
||||
account.continuity_mode =
|
||||
continuity === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE;
|
||||
}
|
||||
|
||||
if (mode === 'shared' && typeof group === 'string' && group.trim().length === 0) {
|
||||
return `Invalid config.accounts.${accountName}.context_group: shared mode requires a non-empty value`;
|
||||
}
|
||||
@@ -73,6 +91,10 @@ function validateAndNormalizeAccountContextMetadata(config: unknown): string | n
|
||||
if (mode === 'isolated' && group !== undefined) {
|
||||
delete account.context_group;
|
||||
}
|
||||
|
||||
if (mode === 'isolated' && continuity !== undefined) {
|
||||
delete account.continuity_mode;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -51,4 +51,35 @@ describe('account context helpers', () => {
|
||||
expect(result.policy.mode).toBe('shared');
|
||||
expect(result.policy.group).toBe('team-alpha');
|
||||
});
|
||||
|
||||
it('supports deeper continuity for shared create flows', () => {
|
||||
const result = resolveCreateAccountContext({
|
||||
shareContext: true,
|
||||
deeperContinuity: true,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.policy.mode).toBe('shared');
|
||||
expect(result.policy.continuityMode).toBe('deeper');
|
||||
});
|
||||
|
||||
it('rejects deeper continuity without shared context flags', () => {
|
||||
const result = resolveCreateAccountContext({
|
||||
shareContext: false,
|
||||
deeperContinuity: true,
|
||||
});
|
||||
|
||||
expect(result.error).toContain('requires shared context');
|
||||
});
|
||||
|
||||
it('defaults shared continuity mode to standard for legacy metadata', () => {
|
||||
const resolved = resolveAccountContextPolicy({
|
||||
context_mode: 'shared',
|
||||
context_group: 'team-alpha',
|
||||
});
|
||||
|
||||
expect(resolved.mode).toBe('shared');
|
||||
expect(resolved.group).toBe('team-alpha');
|
||||
expect(resolved.continuityMode).toBe('standard');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,14 @@ describe('auth command args parsing', () => {
|
||||
expect(parsed.contextGroup).toBe('');
|
||||
});
|
||||
|
||||
it('parses deeper continuity flag for create command', () => {
|
||||
const parsed = parseArgs(['work', '--share-context', '--deeper-continuity']);
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.shareContext).toBe(true);
|
||||
expect(parsed.deeperContinuity).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks unknown flags and keeps positional profile intact', () => {
|
||||
const parsed = parseArgs(['--foo', 'bar', 'work']);
|
||||
|
||||
|
||||
@@ -77,13 +77,19 @@ describe('auth list context metadata', () => {
|
||||
}
|
||||
|
||||
const payload = JSON.parse(lines.join('\n')) as {
|
||||
profiles: Array<{ name: string; context_mode?: string; context_group?: string | null }>;
|
||||
profiles: Array<{
|
||||
name: string;
|
||||
context_mode?: string;
|
||||
context_group?: string | null;
|
||||
continuity_mode?: string | null;
|
||||
}>;
|
||||
};
|
||||
const work = payload.profiles.find((profile) => profile.name === 'work');
|
||||
|
||||
expect(work).toBeTruthy();
|
||||
expect(work?.context_mode).toBe('shared');
|
||||
expect(work?.context_group).toBe('sprint-a');
|
||||
expect(work?.continuity_mode).toBe('standard');
|
||||
});
|
||||
|
||||
it('prefers unified context metadata over legacy when profile names overlap', async () => {
|
||||
@@ -151,12 +157,18 @@ describe('auth list context metadata', () => {
|
||||
}
|
||||
|
||||
const payload = JSON.parse(lines.join('\n')) as {
|
||||
profiles: Array<{ name: string; context_mode?: string; context_group?: string | null }>;
|
||||
profiles: Array<{
|
||||
name: string;
|
||||
context_mode?: string;
|
||||
context_group?: string | null;
|
||||
continuity_mode?: string | null;
|
||||
}>;
|
||||
};
|
||||
const work = payload.profiles.find((profile) => profile.name === 'work');
|
||||
|
||||
expect(work).toBeTruthy();
|
||||
expect(work?.context_mode).toBe('shared');
|
||||
expect(work?.context_group).toBe('sprint-a');
|
||||
expect(work?.continuity_mode).toBe('standard');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('profile-registry context normalization', () => {
|
||||
|
||||
expect(profile.context_mode).toBe('shared');
|
||||
expect(profile.context_group).toBeUndefined();
|
||||
expect(profile.continuity_mode).toBe('standard');
|
||||
});
|
||||
|
||||
it('drops non-string unified context_group values without throwing', () => {
|
||||
@@ -88,5 +89,6 @@ describe('profile-registry context normalization', () => {
|
||||
|
||||
expect(accounts.work.context_mode).toBe('shared');
|
||||
expect(accounts.work.context_group).toBeUndefined();
|
||||
expect(accounts.work.continuity_mode).toBe('standard');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,8 +166,10 @@ describe('migration-manager legacy kimi compatibility', () => {
|
||||
expect(unified).toBeTruthy();
|
||||
expect(unified?.accounts.work.context_mode).toBe('shared');
|
||||
expect(unified?.accounts.work.context_group).toBe('sprint-a');
|
||||
expect(unified?.accounts.work.continuity_mode).toBe('standard');
|
||||
expect(unified?.accounts.personal.context_mode).toBe('isolated');
|
||||
expect(unified?.accounts.personal.context_group).toBeUndefined();
|
||||
expect(unified?.accounts.personal.continuity_mode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes valid legacy shared groups and drops invalid ones during migration', async () => {
|
||||
@@ -210,5 +212,7 @@ describe('migration-manager legacy kimi compatibility', () => {
|
||||
expect(unified?.accounts.work.context_group).toBe('sprint-a');
|
||||
expect(unified?.accounts.broken.context_mode).toBe('shared');
|
||||
expect(unified?.accounts.broken.context_group).toBeUndefined();
|
||||
expect(unified?.accounts.work.continuity_mode).toBe('standard');
|
||||
expect(unified?.accounts.broken.continuity_mode).toBe('standard');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,6 +64,20 @@ describe('SharedManager context policy', () => {
|
||||
return { instancePath, ccsDir };
|
||||
}
|
||||
|
||||
async function applyPolicyWithContinuity(
|
||||
policy: AccountContextPolicy
|
||||
): Promise<{ instancePath: string; ccsDir: string }> {
|
||||
const ccsDir = getTestCcsDir();
|
||||
const instancePath = path.join(ccsDir, 'instances', 'work');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
|
||||
const manager = new SharedManager();
|
||||
await manager.syncProjectContext(instancePath, policy);
|
||||
await manager.syncAdvancedContinuityArtifacts(instancePath, policy);
|
||||
|
||||
return { instancePath, ccsDir };
|
||||
}
|
||||
|
||||
it('keeps projects isolated by default', async () => {
|
||||
const { instancePath } = await applyPolicy({ mode: 'isolated' });
|
||||
const projectsPath = path.join(instancePath, 'projects');
|
||||
@@ -141,6 +155,62 @@ describe('SharedManager context policy', () => {
|
||||
expect(stats.isDirectory() || stats.isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('links advanced continuity artifacts for shared deeper mode', async () => {
|
||||
const { instancePath, ccsDir } = await applyPolicyWithContinuity({
|
||||
mode: 'shared',
|
||||
group: 'sprint-a',
|
||||
continuityMode: 'deeper',
|
||||
});
|
||||
|
||||
const artifactPath = path.join(instancePath, 'session-env');
|
||||
const targetFile = path.join(
|
||||
ccsDir,
|
||||
'shared',
|
||||
'context-groups',
|
||||
'sprint-a',
|
||||
'continuity',
|
||||
'session-env',
|
||||
'session.json'
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
|
||||
fs.writeFileSync(targetFile, '{"id":"shared"}', 'utf8');
|
||||
|
||||
expect(fs.lstatSync(artifactPath).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readFileSync(path.join(artifactPath, 'session.json'), 'utf8')).toContain('shared');
|
||||
});
|
||||
|
||||
it('detaches advanced continuity artifacts when moving from deeper to standard shared mode', async () => {
|
||||
const { instancePath, ccsDir } = await applyPolicyWithContinuity({
|
||||
mode: 'shared',
|
||||
group: 'sprint-a',
|
||||
continuityMode: 'deeper',
|
||||
});
|
||||
|
||||
const sharedTodo = path.join(
|
||||
ccsDir,
|
||||
'shared',
|
||||
'context-groups',
|
||||
'sprint-a',
|
||||
'continuity',
|
||||
'todos',
|
||||
'todo.md'
|
||||
);
|
||||
fs.mkdirSync(path.dirname(sharedTodo), { recursive: true });
|
||||
fs.writeFileSync(sharedTodo, '- shared todo', 'utf8');
|
||||
|
||||
const manager = new SharedManager();
|
||||
await manager.syncAdvancedContinuityArtifacts(instancePath, {
|
||||
mode: 'shared',
|
||||
group: 'sprint-a',
|
||||
continuityMode: 'standard',
|
||||
});
|
||||
|
||||
const localTodoDir = path.join(instancePath, 'todos');
|
||||
expect(fs.lstatSync(localTodoDir).isDirectory()).toBe(true);
|
||||
expect(fs.readFileSync(path.join(localTodoDir, 'todo.md'), 'utf8')).toContain('shared todo');
|
||||
});
|
||||
|
||||
it('skips merge when projects symlink target is outside canonical CCS roots', async () => {
|
||||
const ccsDir = getTestCcsDir();
|
||||
const instancePath = path.join(ccsDir, 'instances', 'work');
|
||||
|
||||
@@ -18,6 +18,14 @@ async function deletePath(baseUrl: string, routePath: string): Promise<Response>
|
||||
return fetch(`${baseUrl}${routePath}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async function putJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> {
|
||||
return fetch(`${baseUrl}${routePath}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe('web-server account-routes context normalization', () => {
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
@@ -27,6 +35,7 @@ describe('web-server account-routes context normalization', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/accounts', accountRoutes);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -95,13 +104,21 @@ describe('web-server account-routes context normalization', () => {
|
||||
);
|
||||
|
||||
const payload = await getJson<{
|
||||
accounts: Array<{ name: string; context_mode?: string; context_group?: string }>;
|
||||
accounts: Array<{
|
||||
name: string;
|
||||
context_mode?: string;
|
||||
context_group?: string;
|
||||
continuity_mode?: string;
|
||||
context_inferred?: boolean;
|
||||
}>;
|
||||
}>(baseUrl, '/api/accounts');
|
||||
|
||||
const work = payload.accounts.find((account) => account.name === 'work');
|
||||
expect(work).toBeTruthy();
|
||||
expect(work?.context_mode).toBe('isolated');
|
||||
expect(work?.context_inferred).toBe(true);
|
||||
expect(work && 'context_group' in work).toBe(false);
|
||||
expect(work && 'continuity_mode' in work).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back shared accounts with invalid groups to default shared group', async () => {
|
||||
@@ -128,13 +145,23 @@ describe('web-server account-routes context normalization', () => {
|
||||
);
|
||||
|
||||
const payload = await getJson<{
|
||||
accounts: Array<{ name: string; context_mode?: string; context_group?: string }>;
|
||||
accounts: Array<{
|
||||
name: string;
|
||||
context_mode?: string;
|
||||
context_group?: string;
|
||||
continuity_mode?: string;
|
||||
context_inferred?: boolean;
|
||||
continuity_inferred?: boolean;
|
||||
}>;
|
||||
}>(baseUrl, '/api/accounts');
|
||||
|
||||
const work = payload.accounts.find((account) => account.name === 'work');
|
||||
expect(work).toBeTruthy();
|
||||
expect(work?.context_mode).toBe('shared');
|
||||
expect(work?.context_group).toBe('default');
|
||||
expect(work?.continuity_mode).toBe('standard');
|
||||
expect(work?.context_inferred).toBe(false);
|
||||
expect(work?.continuity_inferred).toBe(true);
|
||||
});
|
||||
|
||||
it('does not delete metadata when instance deletion fails', async () => {
|
||||
@@ -173,4 +200,133 @@ describe('web-server account-routes context normalization', () => {
|
||||
InstanceManager.prototype.deleteInstance = originalDeleteInstance;
|
||||
}
|
||||
});
|
||||
|
||||
it('updates existing account context metadata and normalizes shared group', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' context_mode: isolated',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const response = await putJson(baseUrl, '/api/accounts/work/context', {
|
||||
context_mode: 'shared',
|
||||
context_group: ' Team Alpha ',
|
||||
continuity_mode: 'deeper',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const payload = (await response.json()) as {
|
||||
context_mode: string;
|
||||
context_group: string | null;
|
||||
continuity_mode?: string | null;
|
||||
context_inferred?: boolean;
|
||||
continuity_inferred?: boolean;
|
||||
};
|
||||
expect(payload.context_mode).toBe('shared');
|
||||
expect(payload.context_group).toBe('team-alpha');
|
||||
expect(payload.continuity_mode).toBe('deeper');
|
||||
expect(payload.context_inferred).toBe(false);
|
||||
expect(payload.continuity_inferred).toBe(false);
|
||||
|
||||
const accountsPayload = await getJson<{
|
||||
accounts: Array<{
|
||||
name: string;
|
||||
context_mode?: string;
|
||||
context_group?: string;
|
||||
continuity_mode?: string;
|
||||
}>;
|
||||
}>(baseUrl, '/api/accounts');
|
||||
const work = accountsPayload.accounts.find((account) => account.name === 'work');
|
||||
expect(work?.context_mode).toBe('shared');
|
||||
expect(work?.context_group).toBe('team-alpha');
|
||||
expect(work?.continuity_mode).toBe('deeper');
|
||||
});
|
||||
|
||||
it('rejects shared mode updates without context_group', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const response = await putJson(baseUrl, '/api/accounts/work/context', {
|
||||
context_mode: 'shared',
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('context_group');
|
||||
});
|
||||
|
||||
it('rejects context updates for CLIProxy account identifiers', async () => {
|
||||
const response = await putJson(baseUrl, '/api/accounts/gemini:test/context', {
|
||||
context_mode: 'shared',
|
||||
context_group: 'default',
|
||||
continuity_mode: 'deeper',
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('CLIProxy');
|
||||
});
|
||||
|
||||
it('rejects invalid continuity mode updates', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const response = await putJson(baseUrl, '/api/accounts/work/context', {
|
||||
context_mode: 'shared',
|
||||
context_group: 'default',
|
||||
continuity_mode: 'extreme',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('continuity_mode');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,6 +115,47 @@ describe('web-server config-routes account context validation', () => {
|
||||
expect(payload.error).toContain('context_group requires context_mode=shared');
|
||||
});
|
||||
|
||||
it('rejects continuity_mode when mode is not shared', async () => {
|
||||
const response = await putJson(baseUrl, '/api/config', {
|
||||
version: 8,
|
||||
accounts: {
|
||||
work: {
|
||||
created: '2026-01-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
context_mode: 'isolated',
|
||||
continuity_mode: 'deeper',
|
||||
},
|
||||
},
|
||||
profiles: {},
|
||||
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('continuity_mode requires context_mode=shared');
|
||||
});
|
||||
|
||||
it('rejects invalid shared continuity_mode values', async () => {
|
||||
const response = await putJson(baseUrl, '/api/config', {
|
||||
version: 8,
|
||||
accounts: {
|
||||
work: {
|
||||
created: '2026-01-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
context_mode: 'shared',
|
||||
context_group: 'team-alpha',
|
||||
continuity_mode: 'extreme',
|
||||
},
|
||||
},
|
||||
profiles: {},
|
||||
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('continuity_mode');
|
||||
});
|
||||
|
||||
it('rejects invalid shared context_group names', async () => {
|
||||
const response = await putJson(baseUrl, '/api/config', {
|
||||
version: 8,
|
||||
@@ -162,6 +203,7 @@ describe('web-server config-routes account context validation', () => {
|
||||
last_used: null,
|
||||
context_mode: 'shared',
|
||||
context_group: 'Sprint-A',
|
||||
continuity_mode: 'deeper',
|
||||
};
|
||||
const response = await putJson(baseUrl, '/api/config', config);
|
||||
|
||||
@@ -171,6 +213,7 @@ describe('web-server config-routes account context validation', () => {
|
||||
|
||||
const savedConfig = loadUnifiedConfig();
|
||||
expect(savedConfig?.accounts.work.context_group).toBe('sprint-a');
|
||||
expect(savedConfig?.accounts.work.continuity_mode).toBe('deeper');
|
||||
});
|
||||
|
||||
it('returns alreadyMigrated when migration is not needed', async () => {
|
||||
|
||||
@@ -24,25 +24,28 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Check, Trash2, RotateCcw } from 'lucide-react';
|
||||
import { Check, CheckCheck, Pencil, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { EditAccountContextDialog } from '@/components/account/edit-account-context-dialog';
|
||||
import {
|
||||
useSetDefaultAccount,
|
||||
useDeleteAccount,
|
||||
useResetDefaultAccount,
|
||||
useUpdateAccountContext,
|
||||
} from '@/hooks/use-accounts';
|
||||
import type { Account } from '@/lib/api-client';
|
||||
|
||||
interface AccountsTableProps {
|
||||
data: Account[];
|
||||
defaultAccount: string | null;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||
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 columns: ColumnDef<Account>[] = [
|
||||
{
|
||||
@@ -89,7 +92,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||
},
|
||||
{
|
||||
id: 'context',
|
||||
header: 'Context',
|
||||
header: 'History Sync',
|
||||
size: 170,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.type === 'cliproxy') {
|
||||
@@ -99,7 +102,25 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||
const mode = row.original.context_mode || 'isolated';
|
||||
if (mode === 'shared') {
|
||||
const group = row.original.context_group || 'default';
|
||||
return <span className="text-muted-foreground">shared ({group})</span>;
|
||||
if (row.original.continuity_mode === 'deeper') {
|
||||
return <span className="text-muted-foreground">shared ({group}, deeper)</span>;
|
||||
}
|
||||
|
||||
if (row.original.continuity_inferred) {
|
||||
return (
|
||||
<span className="text-amber-700 dark:text-amber-400">
|
||||
shared ({group}, standard legacy)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-muted-foreground">shared ({group}, standard)</span>;
|
||||
}
|
||||
|
||||
if (row.original.context_inferred) {
|
||||
return (
|
||||
<span className="text-amber-700 dark:text-amber-400">isolated (legacy default)</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-muted-foreground">isolated</span>;
|
||||
@@ -108,13 +129,60 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
size: 180,
|
||||
size: 220,
|
||||
cell: ({ row }) => {
|
||||
const isDefault = row.original.name === defaultAccount;
|
||||
const isPending = setDefaultMutation.isPending || deleteMutation.isPending;
|
||||
const isPending =
|
||||
setDefaultMutation.isPending ||
|
||||
deleteMutation.isPending ||
|
||||
updateContextMutation.isPending;
|
||||
const isCliproxy = row.original.type === 'cliproxy';
|
||||
const hasLegacyInference =
|
||||
row.original.context_inferred || row.original.continuity_inferred;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{!isCliproxy && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
disabled={isPending}
|
||||
onClick={() => setContextTarget(row.original)}
|
||||
title="Edit sync mode, group, and continuity depth"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 mr-1" />
|
||||
Sync
|
||||
</Button>
|
||||
)}
|
||||
{!isCliproxy && hasLegacyInference && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-amber-700 hover:text-amber-700 hover:bg-amber-500/10 dark:text-amber-400 dark:hover:text-amber-400"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
updateContextMutation.mutate({
|
||||
name: row.original.name,
|
||||
context_mode: row.original.context_mode === 'shared' ? 'shared' : 'isolated',
|
||||
context_group:
|
||||
row.original.context_mode === 'shared'
|
||||
? row.original.context_group || 'default'
|
||||
: undefined,
|
||||
continuity_mode:
|
||||
row.original.context_mode === 'shared'
|
||||
? row.original.continuity_mode === 'deeper'
|
||||
? 'deeper'
|
||||
: 'standard'
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
title="Confirm this legacy account's current mode as explicit"
|
||||
>
|
||||
<CheckCheck className="w-3 h-3 mr-1" />
|
||||
Confirm
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={isDefault ? 'secondary' : 'default'}
|
||||
size="sm"
|
||||
@@ -151,7 +219,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No accounts found. Use{' '}
|
||||
No CCS auth accounts found. Use{' '}
|
||||
<code className="text-sm bg-muted px-1 rounded">ccs auth create</code> to add accounts.
|
||||
</div>
|
||||
);
|
||||
@@ -173,7 +241,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||
created: 'w-[150px]',
|
||||
last_used: 'w-[150px]',
|
||||
context: 'w-[170px]',
|
||||
actions: 'w-[180px]',
|
||||
actions: 'w-[290px]',
|
||||
}[header.id] || 'w-auto';
|
||||
|
||||
return (
|
||||
@@ -217,6 +285,10 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contextTarget && (
|
||||
<EditAccountContextDialog account={contextTarget} onClose={() => setContextTarget(null)} />
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -28,11 +28,12 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
||||
const [profileName, setProfileName] = useState('');
|
||||
const [shareContext, setShareContext] = useState(false);
|
||||
const [contextGroup, setContextGroup] = useState('');
|
||||
const [deeperContinuity, setDeeperContinuity] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Validate profile name: alphanumeric, dash, underscore only
|
||||
const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName);
|
||||
const normalizedGroup = contextGroup.trim().toLowerCase();
|
||||
const normalizedGroup = contextGroup.trim().toLowerCase().replace(/\s+/g, '-');
|
||||
const isValidContextGroup =
|
||||
normalizedGroup.length === 0 ||
|
||||
(normalizedGroup.length <= MAX_CONTEXT_GROUP_LENGTH &&
|
||||
@@ -47,6 +48,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
||||
? `--context-group ${normalizedGroup}`
|
||||
: '--share-context'
|
||||
: '',
|
||||
shareContext && deeperContinuity ? '--deeper-continuity' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
@@ -63,6 +65,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
||||
setProfileName('');
|
||||
setShareContext(false);
|
||||
setContextGroup('');
|
||||
setDeeperContinuity(false);
|
||||
setCopied(false);
|
||||
onClose();
|
||||
};
|
||||
@@ -73,7 +76,8 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Account</DialogTitle>
|
||||
<DialogDescription>
|
||||
Auth profiles require Claude CLI login. Run the command below in your terminal.
|
||||
Auth profiles require Claude CLI login. Run the command below in your terminal. You can
|
||||
edit sync mode, group, and continuity depth later from the Accounts table.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -103,13 +107,13 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
||||
onCheckedChange={(checked) => setShareContext(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="share-context" className="cursor-pointer">
|
||||
Share project context with other accounts
|
||||
Enable shared history sync with other ccs auth accounts
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{shareContext && (
|
||||
<div className="space-y-2 pl-6">
|
||||
<Label htmlFor="context-group">Context Group (optional)</Label>
|
||||
<Label htmlFor="context-group">History Sync Group (optional)</Label>
|
||||
<Input
|
||||
id="context-group"
|
||||
value={contextGroup}
|
||||
@@ -118,7 +122,21 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the default shared group.
|
||||
Leave empty to use the default shared group. Spaces are normalized to dashes.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Checkbox
|
||||
id="deeper-continuity"
|
||||
checked={deeperContinuity}
|
||||
onCheckedChange={(checked) => setDeeperContinuity(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="deeper-continuity" className="cursor-pointer">
|
||||
Advanced: deeper continuity mode
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Adds sync for <code>session-env</code>, <code>file-history</code>,{' '}
|
||||
<code>shell-snapshots</code>, and <code>todos</code>. Credentials stay isolated.
|
||||
</p>
|
||||
{contextGroup.trim().length > 0 && !isValidContextGroup && (
|
||||
<p className="text-xs text-destructive">
|
||||
@@ -157,6 +175,10 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
||||
<li>Complete the Claude login in your browser</li>
|
||||
<li>Return here and refresh to see the new account</li>
|
||||
</ol>
|
||||
<p className="pt-1">
|
||||
Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts
|
||||
page action button.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
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 type { Account } from '@/lib/api-client';
|
||||
import { useUpdateAccountContext } from '@/hooks/use-accounts';
|
||||
|
||||
type ContextMode = 'isolated' | 'shared';
|
||||
type ContinuityMode = 'standard' | 'deeper';
|
||||
|
||||
const MAX_CONTEXT_GROUP_LENGTH = 64;
|
||||
const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
||||
|
||||
interface EditAccountContextDialogProps {
|
||||
account: Account;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EditAccountContextDialog({ account, onClose }: EditAccountContextDialogProps) {
|
||||
const updateContextMutation = useUpdateAccountContext();
|
||||
const [mode, setMode] = useState<ContextMode>(
|
||||
account.context_mode === 'shared' ? 'shared' : 'isolated'
|
||||
);
|
||||
const [group, setGroup] = useState(account.context_group || 'default');
|
||||
const [continuityMode, setContinuityMode] = useState<ContinuityMode>(
|
||||
account.continuity_mode === 'deeper' ? 'deeper' : 'standard'
|
||||
);
|
||||
|
||||
const normalizedGroup = useMemo(() => group.trim().toLowerCase().replace(/\s+/g, '-'), [group]);
|
||||
const isSharedGroupValid =
|
||||
normalizedGroup.length > 0 &&
|
||||
normalizedGroup.length <= MAX_CONTEXT_GROUP_LENGTH &&
|
||||
CONTEXT_GROUP_PATTERN.test(normalizedGroup);
|
||||
const canSubmit = mode === 'isolated' || isSharedGroupValid;
|
||||
|
||||
const handleSave = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateContextMutation.mutate(
|
||||
{
|
||||
name: account.name,
|
||||
context_mode: mode,
|
||||
context_group: mode === 'shared' ? normalizedGroup : undefined,
|
||||
continuity_mode: mode === 'shared' ? continuityMode : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit History Sync</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure how "{account.name}" shares history and continuity with other
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
||||
accounts.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="context-mode">Sync Mode</Label>
|
||||
<Select value={mode} onValueChange={(value) => setMode(value as ContextMode)}>
|
||||
<SelectTrigger id="context-mode">
|
||||
<SelectValue placeholder="Select context mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="isolated">isolated (no sync)</SelectItem>
|
||||
<SelectItem value="shared">shared (sync enabled)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{mode === 'shared'
|
||||
? 'Shared mode reuses workspace context for accounts in the same history sync group.'
|
||||
: 'Isolated mode keeps this account fully separate from other ccs auth accounts.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mode === 'shared' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="context-group">History Sync Group</Label>
|
||||
<Input
|
||||
id="context-group"
|
||||
value={group}
|
||||
onChange={(event) => setGroup(event.target.value)}
|
||||
placeholder="default"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Normalized to lowercase (spaces become dashes). Allowed: letters, numbers, `_`, `-`
|
||||
(max {MAX_CONTEXT_GROUP_LENGTH} chars).
|
||||
</p>
|
||||
{!isSharedGroupValid && (
|
||||
<p className="text-xs text-destructive">
|
||||
Enter a valid group name that starts with a letter.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'shared' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="continuity-mode">Continuity Depth</Label>
|
||||
<Select
|
||||
value={continuityMode}
|
||||
onValueChange={(value) => setContinuityMode(value as ContinuityMode)}
|
||||
>
|
||||
<SelectTrigger id="continuity-mode">
|
||||
<SelectValue placeholder="Select continuity depth" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">standard (projects only)</SelectItem>
|
||||
<SelectItem value="deeper">deeper continuity (advanced)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{continuityMode === 'deeper'
|
||||
? 'Advanced mode also syncs session-env, file-history, shell-snapshots, and todos.'
|
||||
: 'Standard mode syncs project workspace context only.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Credentials and `.anthropic` remain isolated per account in all modes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={updateContextMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!canSubmit || updateContextMutation.isPending}>
|
||||
{updateContextMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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 { 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 [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">How History Sync Works</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Isolated -> Shared -> Deeper. Use <code>Sync</code> per row for all changes.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">Learning Map</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">
|
||||
{cliproxyCount} CLIProxy Claude pool account{cliproxyCount > 1 ? 's are' : ' is'}
|
||||
managed in Action Center / CLIProxy page.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr_auto_1fr] sm:items-center">
|
||||
<StageTile title="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="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="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>Show details: groups, switching, and legacy policy</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">Mode Switch</p>
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Sync dialog lets users move between isolated/shared and choose deeper continuity.
|
||||
</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">History Sync Group</p>
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Same group means shared project context lane. Default fallback is{' '}
|
||||
<code>default</code>.
|
||||
</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">
|
||||
{legacyTargetCount} legacy account
|
||||
{legacyTargetCount > 1 ? 's still need' : ' still needs'} explicit confirmation.
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
export { AccountsTable } from './accounts-table';
|
||||
export { AddAccountDialog } from './add-account-dialog';
|
||||
export { CreateAuthProfileDialog } from './create-auth-profile-dialog';
|
||||
export { EditAccountContextDialog } from './edit-account-context-dialog';
|
||||
|
||||
// Flow visualization (from subdirectory)
|
||||
export { AccountFlowViz } from './flow-viz';
|
||||
|
||||
@@ -1,16 +1,63 @@
|
||||
/**
|
||||
* React Query hooks for accounts (profiles.json)
|
||||
* React Query hooks for account management
|
||||
* Dashboard parity: Full CRUD operations for auth profiles
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { Account } from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface AuthAccountsView {
|
||||
accounts: Account[];
|
||||
default: string | null;
|
||||
cliproxyCount: number;
|
||||
legacyContextCount: number;
|
||||
legacyContinuityCount: number;
|
||||
sharedCount: number;
|
||||
sharedStandardCount: number;
|
||||
deeperSharedCount: number;
|
||||
isolatedCount: number;
|
||||
}
|
||||
|
||||
export function useAccounts() {
|
||||
return useQuery({
|
||||
queryKey: ['accounts'],
|
||||
queryFn: () => api.accounts.list(),
|
||||
select: (data): AuthAccountsView => {
|
||||
const authAccounts = data.accounts.filter((account) => account.type !== 'cliproxy');
|
||||
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)
|
||||
? data.default
|
||||
: null;
|
||||
|
||||
return {
|
||||
accounts: authAccounts,
|
||||
default: defaultAccount,
|
||||
cliproxyCount,
|
||||
legacyContextCount,
|
||||
legacyContinuityCount,
|
||||
sharedCount,
|
||||
sharedStandardCount,
|
||||
deeperSharedCount,
|
||||
isolatedCount,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,3 +105,75 @@ export function useDeleteAccount() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAccountContext() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
name,
|
||||
context_mode,
|
||||
context_group,
|
||||
continuity_mode,
|
||||
}: {
|
||||
name: string;
|
||||
context_mode: 'isolated' | 'shared';
|
||||
context_group?: string;
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
}) => api.accounts.updateContext(name, { context_mode, context_group, continuity_mode }),
|
||||
onSuccess: (_data, vars) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
const contextSummary =
|
||||
vars.context_mode === 'shared'
|
||||
? vars.continuity_mode === 'deeper'
|
||||
? `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')}, deeper continuity)`
|
||||
: `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')}, standard)`
|
||||
: 'isolated';
|
||||
toast.success(`Updated "${vars.name}" context to ${contextSummary}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useConfirmLegacyAccountPolicies() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (accounts: Account[]) => {
|
||||
const legacyTargets = accounts.filter(
|
||||
(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,
|
||||
});
|
||||
}
|
||||
|
||||
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' : ''}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.info('No legacy accounts need confirmation');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -455,6 +455,17 @@ export interface Account {
|
||||
last_used?: string | null;
|
||||
context_mode?: 'isolated' | 'shared';
|
||||
context_group?: string;
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
context_inferred?: boolean;
|
||||
continuity_inferred?: boolean;
|
||||
provider?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAccountContext {
|
||||
context_mode: 'isolated' | 'shared';
|
||||
context_group?: string;
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
}
|
||||
|
||||
// Unified config types
|
||||
@@ -785,6 +796,11 @@ export const api = {
|
||||
}),
|
||||
resetDefault: () => request('/accounts/reset-default', { method: 'DELETE' }),
|
||||
delete: (name: string) => request(`/accounts/${name}`, { method: 'DELETE' }),
|
||||
updateContext: (name: string, data: UpdateAccountContext) =>
|
||||
request(`/accounts/${encodeURIComponent(name)}/context`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
},
|
||||
// Unified config API
|
||||
config: {
|
||||
|
||||
+302
-23
@@ -4,42 +4,321 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AlertTriangle, ArrowRight, ChevronDown, Plus, Users, Zap } from 'lucide-react';
|
||||
import { AccountsTable } from '@/components/account/accounts-table';
|
||||
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 { useAccounts } from '@/hooks/use-accounts';
|
||||
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';
|
||||
|
||||
export function AccountsPage() {
|
||||
const { data, isLoading, refetch } = useAccounts();
|
||||
const navigate = useNavigate();
|
||||
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;
|
||||
const legacyContextCount = data?.legacyContextCount || 0;
|
||||
const legacyContinuityCount = data?.legacyContinuityCount || 0;
|
||||
const sharedCount = data?.sharedCount || 0;
|
||||
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 legacyTargets = authAccounts.filter(
|
||||
(account) => account.context_inferred || account.continuity_inferred
|
||||
);
|
||||
const legacyTargetCount = legacyTargets.length;
|
||||
const hasLegacyFollowUp = legacyTargetCount > 0;
|
||||
|
||||
const handleOpenClaudePool = () => navigate('/cliproxy?provider=claude');
|
||||
const handleOpenClaudePoolAuth = () => navigate('/cliproxy?provider=claude&action=auth');
|
||||
const handleConfirmLegacy = () => confirmLegacyMutation.mutate(legacyTargets);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Accounts</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage multi-account Claude sessions (profiles.json)
|
||||
</p>
|
||||
<>
|
||||
<div className="h-[calc(100vh-100px)] hidden lg:flex">
|
||||
{/* Left action column */}
|
||||
<div className="w-80 border-r flex flex-col bg-muted/20 shrink-0">
|
||||
<div className="p-4 border-b bg-background space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<h1 className="font-semibold">Accounts</h1>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Manage
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
||||
accounts and pool onboarding from one panel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Primary Actions
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Account
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={handleOpenClaudePoolAuth}
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Authenticate Claude in Pool
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={handleOpenClaudePool}
|
||||
>
|
||||
Open Claude Pool Settings
|
||||
<ArrowRight className="w-4 h-4 ml-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasLegacyFollowUp ? (
|
||||
<section className="space-y-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Migration Follow-up
|
||||
</p>
|
||||
<div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 text-amber-700 dark:text-amber-400 shrink-0" />
|
||||
<div className="space-y-1 text-xs">
|
||||
{legacyContextCount > 0 && (
|
||||
<p className="text-amber-800 dark:text-amber-300">
|
||||
{legacyContextCount} account
|
||||
{legacyContextCount > 1 ? 's still need' : ' still needs'} first-time
|
||||
mode confirmation.
|
||||
</p>
|
||||
)}
|
||||
{legacyContinuityCount > 0 && (
|
||||
<p className="text-amber-800 dark:text-amber-300">
|
||||
{legacyContinuityCount} shared account
|
||||
{legacyContinuityCount > 1 ? 's remain' : ' remains'} on standard legacy
|
||||
continuity depth.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={handleConfirmLegacy}
|
||||
disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0}
|
||||
>
|
||||
{confirmLegacyMutation.isPending
|
||||
? 'Confirming Legacy Policies...'
|
||||
: `Confirm Legacy Policies (${legacyTargetCount})`}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<div className="rounded-md border bg-background px-3 py-2 text-xs text-muted-foreground">
|
||||
No legacy follow-up pending.
|
||||
</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">Continuity Guide</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Expand only when needed.
|
||||
</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">Shared Standard</p>
|
||||
<p className="mt-1">
|
||||
Project workspace sync only. Best default for most teams.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-2.5">
|
||||
<p className="font-semibold text-foreground">Shared Deeper</p>
|
||||
<p className="mt-1">
|
||||
Adds <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">Isolated</p>
|
||||
<p className="mt-1">No link. Best for strict separation.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Quick Commands</CardTitle>
|
||||
<CardDescription>Copy and run in terminal.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent 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 auth create work --context-group sprint-a --deeper-continuity
|
||||
</span>
|
||||
<CopyButton
|
||||
value="ccs auth create work --context-group sprint-a --deeper-continuity"
|
||||
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 cliproxy auth claude</span>
|
||||
<CopyButton value="ccs cliproxy auth claude" size="icon" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Main workspace */}
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-background">
|
||||
<div className="px-5 py-4 border-b bg-background">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">ccs auth Workspace</Badge>
|
||||
<Badge variant="secondary">History Sync Controls</Badge>
|
||||
</div>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">Auth Accounts</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
This table is intentionally scoped to
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
||||
accounts. Use
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">Sync</code>
|
||||
for mode/group/depth changes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 p-5 space-y-4 overflow-y-auto">
|
||||
<HistorySyncLearningMap
|
||||
isolatedCount={isolatedCount}
|
||||
sharedStandardCount={sharedStandardCount}
|
||||
deeperSharedCount={deeperSharedCount}
|
||||
sharedGroups={sharedGroups}
|
||||
legacyTargetCount={legacyTargetCount}
|
||||
cliproxyCount={cliproxyCount}
|
||||
/>
|
||||
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">Account Matrix</CardTitle>
|
||||
<CardDescription>
|
||||
Shared total: {sharedCount}. Actions include Sync settings and legacy
|
||||
confirmation.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground">Loading accounts...</div>
|
||||
) : (
|
||||
<AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground">Loading accounts...</div>
|
||||
) : (
|
||||
<AccountsTable
|
||||
data={data?.accounts || []}
|
||||
defaultAccount={data?.default ?? null}
|
||||
onRefresh={refetch}
|
||||
{/* Mobile fallback */}
|
||||
<div className="p-4 space-y-4 lg:hidden">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Accounts</CardTitle>
|
||||
<CardDescription>
|
||||
Manage
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
||||
continuity per account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Button className="w-full" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Account
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full" onClick={handleOpenClaudePool}>
|
||||
Open CLIProxy Claude Pool
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full" onClick={handleOpenClaudePoolAuth}>
|
||||
Authenticate Claude in Pool
|
||||
<Zap className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleConfirmLegacy}
|
||||
disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0}
|
||||
>
|
||||
{confirmLegacyMutation.isPending
|
||||
? 'Confirming Legacy Policies...'
|
||||
: `Confirm Legacy Policies (${legacyTargetCount})`}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<HistorySyncLearningMap
|
||||
isolatedCount={isolatedCount}
|
||||
sharedStandardCount={sharedStandardCount}
|
||||
deeperSharedCount={deeperSharedCount}
|
||||
sharedGroups={sharedGroups}
|
||||
legacyTargetCount={legacyTargetCount}
|
||||
cliproxyCount={cliproxyCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Account Matrix</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground">Loading accounts...</div>
|
||||
) : (
|
||||
<AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<CreateAuthProfileDialog open={createDialogOpen} onClose={() => setCreateDialogOpen(false)} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Right panel: Provider Editor with split-view (settings + code editor)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import type { AuthStatus, Variant } from '@/lib/api-client';
|
||||
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||
import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Sidebar provider item
|
||||
@@ -198,9 +199,14 @@ export function CliproxyPage() {
|
||||
const deleteMutation = useDeleteVariant();
|
||||
|
||||
// Selection state: either a provider or a variant
|
||||
// Initialize from localStorage if available
|
||||
// Initialize from URL provider deep-link, fallback to localStorage.
|
||||
const [selectedProvider, setSelectedProviderState] = useState<string | null>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const queryProvider = query.get('provider')?.trim().toLowerCase();
|
||||
if (queryProvider && isValidProvider(queryProvider)) {
|
||||
return queryProvider;
|
||||
}
|
||||
return localStorage.getItem('cliproxy-selected-provider');
|
||||
}
|
||||
return null;
|
||||
@@ -211,7 +217,25 @@ export function CliproxyPage() {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
isFirstAccount: boolean;
|
||||
} | null>(null);
|
||||
} | null>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const queryProvider = query.get('provider')?.trim().toLowerCase();
|
||||
const action = query.get('action');
|
||||
|
||||
if (action !== 'auth' || !queryProvider || !isValidProvider(queryProvider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: queryProvider,
|
||||
displayName: getProviderDisplayName(queryProvider),
|
||||
isFirstAccount: false,
|
||||
};
|
||||
});
|
||||
|
||||
const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]);
|
||||
const isRemoteMode = authData?.source === 'remote';
|
||||
|
||||
Reference in New Issue
Block a user