refactor(accounts): consolidate continuity guidance layout

- replace separate readiness and learning cards with one continuity overview surface

- simplify row labels and dialog controls around continuity mode changes

- keep localized continuity guidance aligned with the new component structure

Refs #904
This commit is contained in:
Tam Nhu Tran
2026-04-04 12:17:32 -04:00
parent 57d6a44493
commit d9f78ad490
8 changed files with 432 additions and 568 deletions
+34 -21
View File
@@ -14,6 +14,7 @@ import {
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
@@ -105,31 +106,39 @@ export function AccountsTable({ data, defaultAccount, groupSummaries }: Accounts
const mode = row.original.context_mode || 'isolated';
if (mode === 'shared') {
const group = row.original.context_group || 'default';
const mainLabel =
row.original.continuity_mode === 'deeper'
? t('accountsTable.sharedGroupDeeper', { group })
: row.original.continuity_inferred
? t('accountsTable.sharedGroupLegacy', { group })
: t('accountsTable.sharedGroupStandard', { group });
const peerLabel =
row.original.sameGroupPeerCount > 0
? t('accountsTable.sameGroupPeerCount', { count: row.original.sameGroupPeerCount })
: t('accountsTable.noSameGroupPeer');
const isDeeper = row.original.continuity_mode === 'deeper';
return (
<div className="space-y-0.5">
<p className="text-sm text-muted-foreground">{mainLabel}</p>
<p className="text-xs text-muted-foreground/80">{peerLabel}</p>
<div className="flex flex-col items-start gap-1">
<div className="flex items-center gap-1.5 flex-wrap">
<Badge
variant="outline"
className={`font-mono text-[10px] uppercase px-1.5 py-0 border ${isDeeper ? 'text-indigo-700 border-indigo-300/60 bg-indigo-50/50 dark:text-indigo-300 dark:border-indigo-900/40 dark:bg-indigo-900/20' : 'text-emerald-700 border-emerald-300/60 bg-emerald-50/50 dark:text-emerald-300 dark:border-emerald-900/40 dark:bg-emerald-900/20'}`}
>
{isDeeper ? 'Deeper' : 'Shared'}
</Badge>
<span className="text-xs font-semibold text-foreground/80">{group}</span>
</div>
<p className="text-[10px] text-muted-foreground whitespace-nowrap">
{row.original.sameGroupPeerCount > 0
? t('accountsTable.sameGroupPeerCount', {
count: row.original.sameGroupPeerCount,
})
: t('accountsTable.noSameGroupPeer')}
</p>
</div>
);
}
if (row.original.context_inferred) {
return (
<div className="space-y-0.5">
<p className="text-sm text-amber-700 dark:text-amber-400">
{t('accountsTable.isolatedLegacy')}
</p>
<p className="text-xs text-amber-700/80 dark:text-amber-400/80">
<div className="flex flex-col items-start gap-1">
<Badge
variant="outline"
className="text-amber-700 border-amber-300/60 bg-amber-50/50 dark:text-amber-400 dark:border-amber-900/40 dark:bg-amber-900/20 font-mono text-[10px] uppercase px-1.5 py-0"
>
Legacy
</Badge>
<p className="text-[10px] text-amber-700/80 dark:text-amber-400/80 whitespace-nowrap">
{t('accountsTable.legacyReview')}
</p>
</div>
@@ -137,9 +146,13 @@ export function AccountsTable({ data, defaultAccount, groupSummaries }: Accounts
}
return (
<div className="space-y-0.5">
<p className="text-sm text-muted-foreground">{t('accountsTable.isolated')}</p>
<p className="text-xs text-muted-foreground/80">{t('accountsTable.noHandoff')}</p>
<div className="flex flex-col items-start gap-1.5">
<Badge
variant="secondary"
className="font-mono text-[10px] uppercase px-1.5 py-0 text-muted-foreground bg-muted/60 border-transparent shadow-none"
>
Isolated
</Badge>
</div>
);
},
@@ -0,0 +1,238 @@
import {
AlertTriangle,
CheckCircle2,
Info,
Link2,
ShieldAlert,
Unlink,
Waves,
ArrowRight,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
interface ContinuityOverviewProps {
totalAccounts: number;
isolatedCount: number;
sharedStandardCount: number;
deeperSharedCount: number;
sharedAloneCount: number;
sharedPeerAccountCount: number; // The accounts in a viable group
deeperReadyAccountCount: number;
sharedGroups: string[];
sharedPeerGroups: string[];
deeperReadyGroups: string[];
legacyTargetCount: number;
cliproxyCount: number;
}
type ReadinessState =
| 'single'
| 'isolated'
| 'shared-alone'
| 'shared-standard'
| 'partial'
| 'ready';
const InfoTooltip = ({ titleKey, descKey }: { titleKey: string; descKey: string }) => {
const { t } = useTranslation();
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 rounded-full hover:bg-muted text-muted-foreground/70 transition-colors"
>
<Info className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-72 p-4 rounded-xl shadow-lg border-border/50 text-sm"
side="top"
align="center"
>
<p className="font-semibold tracking-tight">{t(titleKey)}</p>
<p className="mt-1.5 text-muted-foreground leading-relaxed">{t(descKey)}</p>
</PopoverContent>
</Popover>
);
};
export function ContinuityOverview({
totalAccounts,
isolatedCount,
sharedStandardCount,
deeperSharedCount,
sharedAloneCount,
sharedPeerAccountCount,
deeperReadyAccountCount,
sharedPeerGroups,
deeperReadyGroups,
legacyTargetCount,
cliproxyCount,
}: ContinuityOverviewProps) {
const { t } = useTranslation();
const readiness: ReadinessState =
totalAccounts < 2
? 'single'
: sharedPeerGroups.length === 0
? isolatedCount === totalAccounts
? 'isolated'
: 'shared-alone'
: deeperReadyGroups.length === 0
? 'shared-standard'
: isolatedCount > 0 ||
sharedAloneCount > 0 ||
deeperReadyAccountCount < sharedPeerAccountCount ||
deeperReadyGroups.length < sharedPeerGroups.length
? 'partial'
: 'ready';
const highlightGroup = deeperReadyGroups[0] || sharedPeerGroups[0] || 'default';
const hasMixedState =
deeperReadyGroups.length > 0 &&
(isolatedCount > 0 ||
sharedStandardCount > 0 ||
sharedPeerGroups.length > deeperReadyGroups.length);
const iconMap = {
ready: <CheckCircle2 className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />,
'shared-standard': <Link2 className="h-6 w-6 text-blue-600 dark:text-blue-400" />,
single: <ShieldAlert className="h-6 w-6 text-stone-400" />,
isolated: <Unlink className="h-6 w-6 text-amber-600 dark:text-amber-400" />,
'shared-alone': <AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" />,
partial: <AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" />,
};
const currentIcon = iconMap[readiness];
return (
<div className="flex flex-col gap-4">
{/* Primary Status Bento Box */}
<Card className="flex flex-col justify-between overflow-hidden relative group p-0 border-border bg-card shadow-sm hover:shadow-md transition-shadow duration-300">
<CardContent className="p-6 flex flex-col h-full bg-gradient-to-br from-card to-muted/20 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<div className="rounded-2xl bg-background p-3 shadow-sm ring-1 ring-border/50 self-start shrink-0">
{currentIcon}
</div>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold tracking-tight">
{t(`continuityReadiness.messages.${readiness}.title`, {
group: highlightGroup,
})}
</h3>
<Badge
variant={readiness === 'ready' ? 'default' : 'secondary'}
className="rounded-full px-2.5 py-0.5 font-medium shadow-sm"
>
{t(`continuityReadiness.state.${readiness}`)}
</Badge>
</div>
<p className="text-sm text-muted-foreground max-w-lg leading-relaxed">
{t(`continuityReadiness.messages.${readiness}.description`, {
group: highlightGroup,
count: sharedAloneCount,
})}
</p>
</div>
</div>
</div>
<div className="mt-auto pt-4 flex flex-wrap items-center gap-2">
{cliproxyCount > 0 && (
<Badge
variant="outline"
className="text-blue-700 bg-blue-50/50 border-blue-200/60 dark:border-blue-900/40 dark:bg-blue-900/20 dark:text-blue-300"
>
{t('historySyncLearningMap.cliproxyManaged', { count: cliproxyCount })}
</Badge>
)}
{legacyTargetCount > 0 && (
<Badge
variant="outline"
className="text-amber-700 bg-amber-50/50 border-amber-200/60 dark:border-amber-900/40 dark:bg-amber-900/20 dark:text-amber-300"
>
{t('historySyncLearningMap.legacyConfirmation', { count: legacyTargetCount })}
</Badge>
)}
{sharedPeerGroups.length > 0 && deeperReadyGroups.length === 0 && (
<Badge
variant="secondary"
className="font-mono text-[11px] px-2 bg-muted/50 text-muted-foreground border-transparent"
>
Recommend: {highlightGroup}
</Badge>
)}
{hasMixedState && (
<Badge
variant="secondary"
className="font-mono text-[11px] px-2 bg-muted/50 text-muted-foreground border-transparent"
>
Partial sync ({highlightGroup})
</Badge>
)}
</div>
</CardContent>
</Card>
{/* Horizontal Progression Chain */}
<div className="flex flex-col md:flex-row items-center gap-3">
<div className="flex-1 w-full flex items-center justify-between p-3.5 rounded-xl border border-blue-300/40 bg-blue-50/50 dark:border-blue-900/30 dark:bg-blue-900/10 shadow-sm transition-colors hover:bg-blue-100/40 dark:hover:bg-blue-900/20">
<div className="flex items-center gap-2">
<Unlink className="h-4 w-4 text-blue-700/80 dark:text-blue-400/80" />
<span className="text-xs font-semibold uppercase tracking-wider text-blue-900 dark:text-blue-200">
{t('historySyncLearningMap.isolated')}
</span>
<InfoTooltip titleKey="accountsPage.isolated" descKey="accountsPage.isolatedDesc" />
</div>
<span className="text-lg font-mono font-bold text-blue-900 dark:text-blue-200">
{isolatedCount}
</span>
</div>
<ArrowRight className="hidden md:block h-4 w-4 text-muted-foreground/40 shrink-0" />
<div className="flex-1 w-full flex items-center justify-between p-3.5 rounded-xl border border-emerald-300/40 bg-emerald-50/50 dark:border-emerald-900/30 dark:bg-emerald-900/10 shadow-sm transition-colors hover:bg-emerald-100/40 dark:hover:bg-emerald-900/20">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-emerald-700/80 dark:text-emerald-400/80" />
<span className="text-xs font-semibold uppercase tracking-wider text-emerald-900 dark:text-emerald-200">
{t('historySyncLearningMap.shared')}
</span>
<InfoTooltip
titleKey="accountsPage.sharedStandard"
descKey="accountsPage.sharedStandardDesc"
/>
</div>
<span className="text-lg font-mono font-bold text-emerald-900 dark:text-emerald-200">
{sharedStandardCount}
</span>
</div>
<ArrowRight className="hidden md:block h-4 w-4 text-muted-foreground/40 shrink-0" />
<div className="flex-1 w-full flex items-center justify-between p-3.5 rounded-xl border border-indigo-300/40 bg-indigo-50/50 dark:border-indigo-900/30 dark:bg-indigo-900/10 shadow-sm transition-colors hover:bg-indigo-100/40 dark:hover:bg-indigo-900/20">
<div className="flex items-center gap-2">
<Waves className="h-4 w-4 text-indigo-700/80 dark:text-indigo-400/80" />
<span className="text-xs font-semibold uppercase tracking-wider text-indigo-900 dark:text-indigo-200">
{t('historySyncLearningMap.deeper')}
</span>
<InfoTooltip
titleKey="accountsPage.sharedDeeper"
descKey="accountsPage.sharedDeeperDesc"
/>
</div>
<span className="text-lg font-mono font-bold text-indigo-900 dark:text-indigo-200">
{deeperSharedCount}
</span>
</div>
</div>
</div>
);
}
@@ -1,140 +0,0 @@
import { AlertTriangle, CheckCircle2, Link2, ShieldAlert } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
interface ContinuityReadinessCardProps {
totalAccounts: number;
isolatedCount: number;
sharedAloneCount: number;
sharedPeerAccountCount: number;
deeperReadyAccountCount: number;
sharedPeerGroups: string[];
deeperReadyGroups: string[];
}
type ReadinessState =
| 'single'
| 'isolated'
| 'shared-alone'
| 'shared-standard'
| 'partial'
| 'ready';
export function ContinuityReadinessCard({
totalAccounts,
isolatedCount,
sharedAloneCount,
sharedPeerAccountCount,
deeperReadyAccountCount,
sharedPeerGroups,
deeperReadyGroups,
}: ContinuityReadinessCardProps) {
const { t } = useTranslation();
const readiness: ReadinessState =
totalAccounts < 2
? 'single'
: sharedPeerGroups.length === 0
? isolatedCount === totalAccounts
? 'isolated'
: 'shared-alone'
: deeperReadyGroups.length === 0
? 'shared-standard'
: isolatedCount > 0 ||
sharedAloneCount > 0 ||
deeperReadyAccountCount < sharedPeerAccountCount ||
deeperReadyGroups.length < sharedPeerGroups.length
? 'partial'
: 'ready';
const highlightGroup = deeperReadyGroups[0] || sharedPeerGroups[0] || 'default';
const icon =
readiness === 'ready' ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
) : readiness === 'shared-standard' ? (
<Link2 className="h-4 w-4 text-blue-600 dark:text-blue-400" />
) : readiness === 'single' ? (
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
);
const stepItems =
readiness === 'single'
? [
t('continuityReadiness.singleSteps.addAccount'),
t('continuityReadiness.singleSteps.sameGroupLater'),
t('continuityReadiness.singleSteps.enableDeeperLater'),
t('continuityReadiness.singleSteps.resumeOriginal'),
]
: [
t('continuityReadiness.steps.syncBoth'),
t('continuityReadiness.steps.sameGroup', { group: highlightGroup }),
t('continuityReadiness.steps.enableDeeper'),
t('continuityReadiness.steps.resumeOriginal'),
];
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-lg">{t('continuityReadiness.title')}</CardTitle>
<CardDescription>{t('continuityReadiness.description')}</CardDescription>
</div>
<Badge variant={readiness === 'ready' ? 'default' : 'secondary'}>
{t(`continuityReadiness.state.${readiness}`)}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t('continuityReadiness.metrics.isolated')}
</p>
<p className="mt-1 text-2xl font-semibold">{isolatedCount}</p>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t('continuityReadiness.metrics.sharedPeers')}
</p>
<p className="mt-1 text-2xl font-semibold">{sharedPeerAccountCount}</p>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t('continuityReadiness.metrics.deeperReady')}
</p>
<p className="mt-1 text-2xl font-semibold">{deeperReadyAccountCount}</p>
</div>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<div className="flex items-start gap-2">
<div className="mt-0.5">{icon}</div>
<div className="space-y-1">
<p className="text-sm font-medium">
{t(`continuityReadiness.messages.${readiness}.title`, { group: highlightGroup })}
</p>
<p className="text-sm text-muted-foreground">
{t(`continuityReadiness.messages.${readiness}.description`, {
group: highlightGroup,
count: sharedAloneCount,
})}
</p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t('continuityReadiness.stepsTitle')}</p>
<ol className="space-y-2 pl-5 text-sm text-muted-foreground">
{stepItems.map((item) => (
<li key={item}>{item}</li>
))}
</ol>
</div>
</CardContent>
</Card>
);
}
@@ -10,13 +10,8 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Unlink, Link2, Waves, ShieldAlert, Info } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { AuthAccountRow, SharedGroupSummary } from '@/lib/account-continuity';
import { useUpdateAccountContext } from '@/hooks/use-accounts';
@@ -117,20 +112,47 @@ export function EditAccountContextDialog({
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="context-mode">{t('editAccountContext.syncMode')}</Label>
<Select value={mode} onValueChange={(value) => setMode(value as ContextMode)}>
<SelectTrigger id="context-mode">
<SelectValue placeholder={t('editAccountContext.selectContextMode')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="isolated">{t('editAccountContext.isolatedOption')}</SelectItem>
<SelectItem value="shared">{t('editAccountContext.sharedOption')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{mode === 'shared'
? t('editAccountContext.sharedModeHint')
: t('editAccountContext.isolatedModeHint')}
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold">{t('editAccountContext.syncMode')}</Label>
</div>
<div
className="flex p-0.5 bg-muted/60 hover:bg-muted/80 transition-colors rounded-xl border border-border/40 w-full"
role="radiogroup"
aria-label={t('editAccountContext.syncMode')}
>
<button
type="button"
role="radio"
aria-checked={mode === 'isolated'}
onClick={() => setMode('isolated')}
className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${
mode === 'isolated'
? 'bg-background text-blue-600 dark:text-blue-400 shadow-sm ring-1 ring-border/50'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<Unlink className="h-4 w-4 shrink-0" />
<span className="truncate">{t('editAccountContext.isolatedOption')}</span>
</button>
<button
type="button"
role="radio"
aria-checked={mode === 'shared'}
onClick={() => setMode('shared')}
className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${
mode === 'shared'
? 'bg-background text-emerald-600 dark:text-emerald-400 shadow-sm ring-1 ring-border/50'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<Link2 className="h-4 w-4 shrink-0" />
<span className="truncate">{t('editAccountContext.sharedOption')}</span>
</button>
</div>
<p className="text-xs text-muted-foreground pt-1 px-1">
{mode === 'isolated'
? t('editAccountContext.isolatedModeHint')
: t('editAccountContext.sharedModeHint')}
</p>
</div>
@@ -155,23 +177,49 @@ export function EditAccountContextDialog({
{mode === 'shared' && (
<div className="space-y-2">
<Label htmlFor="continuity-mode">{t('editAccountContext.continuityDepth')}</Label>
<Select
value={continuityMode}
onValueChange={(value) => setContinuityMode(value as ContinuityMode)}
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold">
{t('editAccountContext.continuityDepth')}
</Label>
</div>
<div
className="flex p-0.5 bg-muted/60 hover:bg-muted/80 transition-colors rounded-xl border border-border/40 w-full"
role="radiogroup"
aria-label={t('editAccountContext.continuityDepth')}
>
<SelectTrigger id="continuity-mode">
<SelectValue placeholder={t('editAccountContext.selectContinuityDepth')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">{t('editAccountContext.standardOption')}</SelectItem>
<SelectItem value="deeper">{t('editAccountContext.deeperOption')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{continuityMode === 'deeper'
? t('editAccountContext.deeperHint')
: t('editAccountContext.standardHint')}
<button
type="button"
role="radio"
aria-checked={continuityMode === 'standard'}
onClick={() => setContinuityMode('standard')}
className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${
continuityMode === 'standard'
? 'bg-background text-emerald-600 dark:text-emerald-400 shadow-sm ring-1 ring-border/50'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<Link2 className="h-4 w-4 shrink-0" />
<span className="truncate">{t('editAccountContext.standardOption')}</span>
</button>
<button
type="button"
role="radio"
aria-checked={continuityMode === 'deeper'}
onClick={() => setContinuityMode('deeper')}
className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${
continuityMode === 'deeper'
? 'bg-background text-indigo-600 dark:text-indigo-400 shadow-sm ring-1 ring-border/50'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<Waves className="h-4 w-4 shrink-0" />
<span className="truncate">{t('editAccountContext.deeperOption')}</span>
</button>
</div>
<p className="text-xs text-muted-foreground pt-1 px-1">
{continuityMode === 'standard'
? t('editAccountContext.standardHint')
: t('editAccountContext.deeperHint')}
</p>
</div>
)}
@@ -180,33 +228,51 @@ export function EditAccountContextDialog({
{t('editAccountContext.credentialsIsolated')}
</p>
<div className="rounded-md border bg-muted/20 p-3 text-xs">
<p className="font-medium text-foreground">
{t('editAccountContext.implicationTitle')}
</p>
<div className="mt-2 space-y-1.5 text-muted-foreground">
<div
className={`rounded-[14px] border p-4 text-xs shadow-sm transition-colors ${mode === 'isolated' ? 'bg-blue-50/50 border-blue-200 dark:bg-blue-900/10 dark:border-blue-800/40' : 'bg-muted/40 border-border/60'}`}
>
<div className="flex items-start gap-3">
{mode === 'isolated' ? (
<p>{t('editAccountContext.isolatedImplication')}</p>
<ShieldAlert className="h-4 w-4 text-blue-500 mt-0.5 shrink-0" />
) : (
<>
<p>{t('editAccountContext.sameGroupRule', { group: normalizedGroup })}</p>
<p>
{sameGroupPeerCount > 0
? t('editAccountContext.sameGroupPeerCount', { count: sameGroupPeerCount })
: t('editAccountContext.noSameGroupPeer')}
<Info className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
)}
<div className="space-y-1.5 flex-1 text-muted-foreground leading-relaxed">
{mode === 'isolated' ? (
<p className="text-foreground font-medium selection:bg-blue-200">
{t('editAccountContext.isolatedImplication')}
</p>
<p>
{continuityMode === 'deeper'
? sameGroupDeeperPeerCount > 0
? t('editAccountContext.deeperReady', {
) : (
<>
<p>
<span className="text-foreground font-medium">
{t('editAccountContext.sameGroupRule', { group: normalizedGroup })}
</span>{' '}
{sameGroupPeerCount > 0
? t('editAccountContext.sameGroupPeerCount', { count: sameGroupPeerCount })
: t('editAccountContext.noSameGroupPeer')}
</p>
{continuityMode === 'deeper' && (
<p>
{sameGroupDeeperPeerCount > 0 ? (
t('editAccountContext.deeperReady', {
count: sameGroupDeeperPeerCount,
})
: t('editAccountContext.deeperNeedsPeers')
: t('editAccountContext.standardWarning')}
</p>
</>
)}
<p>{t('editAccountContext.resumeOriginalWarning')}</p>
) : (
<span className="text-amber-600 dark:text-amber-500">
{t('editAccountContext.deeperNeedsPeers')}
</span>
)}
</p>
)}
</>
)}
<p
className={`pt-1.5 text-[11px] ${mode === 'isolated' ? 'text-blue-700/70 dark:text-blue-300/60' : 'text-muted-foreground/70'}`}
>
{t('editAccountContext.resumeOriginalWarning')}
</p>
</div>
</div>
</div>
</div>
@@ -1,202 +0,0 @@
import { useState } from 'react';
import {
ArrowRight,
ArrowRightLeft,
ChevronDown,
Layers3,
Link2,
Unlink,
Waves,
type LucideIcon,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
interface HistorySyncLearningMapProps {
isolatedCount: number;
sharedStandardCount: number;
deeperSharedCount: number;
sharedGroups: string[];
sharedPeerGroups: string[];
deeperReadyGroups: 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,
sharedPeerGroups,
deeperReadyGroups,
legacyTargetCount,
cliproxyCount,
}: HistorySyncLearningMapProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const groupsToShow = sharedGroups.length > 0 ? sharedGroups : ['default'];
const highlightGroup = deeperReadyGroups[0] || sharedPeerGroups[0] || groupsToShow[0];
const hasMixedState =
deeperReadyGroups.length > 0 &&
(isolatedCount > 0 ||
sharedStandardCount > 0 ||
sharedPeerGroups.length > deeperReadyGroups.length);
return (
<Card className="border-dashed">
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle className="text-base">{t('historySyncLearningMap.title')}</CardTitle>
<CardDescription className="mt-1">
{t('historySyncLearningMap.description')}
</CardDescription>
</div>
<Badge variant="outline">{t('historySyncLearningMap.learningMap')}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
{cliproxyCount > 0 && (
<div className="rounded-md border border-blue-300/60 bg-blue-50/40 px-3 py-2 text-xs text-blue-800 dark:border-blue-900/40 dark:bg-blue-900/10 dark:text-blue-300">
{t('historySyncLearningMap.cliproxyManaged', { count: cliproxyCount })}
</div>
)}
<div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr_auto_1fr] sm:items-center">
<StageTile
title={t('historySyncLearningMap.isolated')}
count={isolatedCount}
icon={Unlink}
tone="isolated"
/>
<div className="hidden sm:flex justify-center text-muted-foreground">
<ArrowRight className="h-4 w-4" />
</div>
<StageTile
title={t('historySyncLearningMap.shared')}
count={sharedStandardCount}
icon={Link2}
tone="shared"
/>
<div className="hidden sm:flex justify-center text-muted-foreground">
<ArrowRight className="h-4 w-4" />
</div>
<StageTile
title={t('historySyncLearningMap.deeper')}
count={deeperSharedCount}
icon={Waves}
tone="deeper"
/>
</div>
<div className="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{sharedPeerGroups.length === 0
? t('historySyncLearningMap.sameGroupRule')
: deeperReadyGroups.length === 0
? t('historySyncLearningMap.deeperRecommendation', { group: highlightGroup })
: hasMixedState
? t('historySyncLearningMap.partialGroup', { group: highlightGroup })
: t('historySyncLearningMap.readyGroup', { group: highlightGroup })}
</div>
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="h-8 w-full justify-between rounded-md px-2 text-xs text-muted-foreground hover:bg-muted/40 hover:text-foreground"
>
<span>{t('historySyncLearningMap.showDetails')}</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="grid gap-2 lg:grid-cols-2">
<div className="rounded-md border bg-muted/20 p-2.5 text-xs">
<div className="flex items-center gap-2">
<ArrowRightLeft className="h-3.5 w-3.5 text-muted-foreground" />
<p className="font-semibold">{t('historySyncLearningMap.modeSwitch')}</p>
</div>
<p className="mt-1 text-muted-foreground">
{t('historySyncLearningMap.modeSwitchDesc')}
</p>
</div>
<div className="rounded-md border bg-muted/20 p-2.5 text-xs">
<div className="flex items-center gap-2">
<Layers3 className="h-3.5 w-3.5 text-muted-foreground" />
<p className="font-semibold">{t('historySyncLearningMap.historySyncGroup')}</p>
</div>
<p className="mt-1 text-muted-foreground">
{t('historySyncLearningMap.historySyncGroupDesc')}
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{groupsToShow.map((group) => (
<Badge key={group} variant="outline" className="font-mono text-[10px]">
{group}
</Badge>
))}
</div>
</div>
</div>
{legacyTargetCount > 0 && (
<div className="mt-2 rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-300">
{t('historySyncLearningMap.legacyConfirmation', { count: legacyTargetCount })}
</div>
)}
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
);
}
+7 -10
View File
@@ -401,12 +401,10 @@ const resources = {
'Configure how "{{name}}" shares history and continuity with other ccs auth accounts.',
syncMode: 'Sync Mode',
selectContextMode: 'Select context mode',
isolatedOption: 'isolated (no sync)',
sharedOption: 'shared (sync enabled)',
sharedModeHint:
'Shared mode reuses workspace context for accounts in the same history sync group.',
isolatedModeHint:
'Isolated mode keeps this account fully separate from other ccs auth accounts.',
isolatedOption: 'Isolated',
sharedOption: 'Shared',
sharedModeHint: 'Reuses workspace context across accounts in the same history group.',
isolatedModeHint: 'Keeps this account fully separate from other ccs auth accounts.',
historySyncGroup: 'History Sync Group',
groupPlaceholder: 'default',
groupHint:
@@ -414,10 +412,9 @@ const resources = {
invalidGroup: 'Enter a valid group name that starts with a letter.',
continuityDepth: 'Continuity Depth',
selectContinuityDepth: 'Select continuity depth',
standardOption: 'standard (projects only)',
deeperOption: 'deeper continuity (advanced)',
deeperHint:
'Advanced mode also syncs session-env, file-history, shell-snapshots, and todos.',
standardOption: 'Standard',
deeperOption: 'Deeper',
deeperHint: 'Syncs comprehensive session-env, file-history, shell-snapshots, and todos.',
standardHint: 'Standard mode syncs project workspace context only.',
credentialsIsolated: 'Credentials and .anthropic remain isolated per account in all modes.',
implicationTitle: 'What this means after save',
+11 -71
View File
@@ -5,19 +5,16 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { AlertTriangle, ArrowRight, ChevronDown, Plus, Users, Zap } from 'lucide-react';
import { AlertTriangle, ArrowRight, Plus, Users, Zap } from 'lucide-react';
import { AccountsTable } from '@/components/account/accounts-table';
import { ContinuityReadinessCard } from '@/components/account/continuity-readiness-card';
import { ContinuityOverview } from '@/components/account/continuity-overview';
import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog';
import { HistorySyncLearningMap } from '@/components/account/history-sync-learning-map';
import { CopyButton } from '@/components/ui/copy-button';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useAccounts, useConfirmLegacyAccountPolicies } from '@/hooks/use-accounts';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
export function AccountsPage() {
@@ -26,7 +23,6 @@ export function AccountsPage() {
const { data, isLoading } = useAccounts();
const confirmLegacyMutation = useConfirmLegacyAccountPolicies();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [guideOpen, setGuideOpen] = useState(false);
const authAccounts = data?.accounts || [];
const cliproxyCount = data?.cliproxyCount || 0;
@@ -146,50 +142,6 @@ export function AccountsPage() {
</div>
)}
<Collapsible open={guideOpen} onOpenChange={setGuideOpen}>
<Card>
<CardHeader className="pb-2">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="h-auto w-full justify-between px-0 py-0">
<div className="text-left">
<CardTitle className="text-sm">
{t('accountsPage.continuityGuide')}
</CardTitle>
<CardDescription className="mt-1">
{t('accountsPage.expandWhenNeeded')}
</CardDescription>
</div>
<ChevronDown
className={cn('h-4 w-4 transition-transform', guideOpen && 'rotate-180')}
/>
</Button>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="space-y-3 text-xs text-muted-foreground">
<div className="rounded-md border p-2.5">
<p className="font-semibold text-foreground">
{t('accountsPage.sharedStandard')}
</p>
<p className="mt-1">{t('accountsPage.sharedStandardDesc')}</p>
</div>
<div className="rounded-md border p-2.5">
<p className="font-semibold text-foreground">
{t('accountsPage.sharedDeeper')}
</p>
<p className="mt-1">{t('accountsPage.sharedDeeperDesc')}</p>
</div>
<div className="rounded-md border p-2.5">
<p className="font-semibold text-foreground">
{t('accountsPage.isolated')}
</p>
<p className="mt-1">{t('accountsPage.isolatedDesc')}</p>
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t('accountsPage.quickCommands')}</CardTitle>
@@ -235,20 +187,14 @@ export function AccountsPage() {
</div>
<div className="flex-1 min-h-0 p-5 space-y-4 overflow-y-auto">
<ContinuityReadinessCard
<ContinuityOverview
totalAccounts={authAccounts.length}
isolatedCount={isolatedCount}
sharedAloneCount={sharedAloneCount}
sharedPeerAccountCount={sharedPeerAccountCount}
deeperReadyAccountCount={deeperReadyAccountCount}
sharedPeerGroups={sharedPeerGroups}
deeperReadyGroups={deeperReadyGroups}
/>
<HistorySyncLearningMap
isolatedCount={isolatedCount}
sharedStandardCount={sharedStandardCount}
deeperSharedCount={deeperSharedCount}
sharedAloneCount={sharedAloneCount}
sharedPeerAccountCount={sharedPeerAccountCount}
deeperReadyAccountCount={deeperReadyAccountCount}
sharedGroups={sharedGroups}
sharedPeerGroups={sharedPeerGroups}
deeperReadyGroups={deeperReadyGroups}
@@ -316,10 +262,14 @@ export function AccountsPage() {
</CardContent>
</Card>
<HistorySyncLearningMap
<ContinuityOverview
totalAccounts={authAccounts.length}
isolatedCount={isolatedCount}
sharedStandardCount={sharedStandardCount}
deeperSharedCount={deeperSharedCount}
sharedAloneCount={sharedAloneCount}
sharedPeerAccountCount={sharedPeerAccountCount}
deeperReadyAccountCount={deeperReadyAccountCount}
sharedGroups={sharedGroups}
sharedPeerGroups={sharedPeerGroups}
deeperReadyGroups={deeperReadyGroups}
@@ -327,16 +277,6 @@ export function AccountsPage() {
cliproxyCount={cliproxyCount}
/>
<ContinuityReadinessCard
totalAccounts={authAccounts.length}
isolatedCount={isolatedCount}
sharedAloneCount={sharedAloneCount}
sharedPeerAccountCount={sharedPeerAccountCount}
deeperReadyAccountCount={deeperReadyAccountCount}
sharedPeerGroups={sharedPeerGroups}
deeperReadyGroups={deeperReadyGroups}
/>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('accountsPage.accountMatrix')}</CardTitle>
@@ -1,9 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
import i18n from '@/lib/i18n';
import { ContinuityReadinessCard } from '@/components/account/continuity-readiness-card';
import { ContinuityOverview } from '@/components/account/continuity-overview';
import { LanguageSwitcher } from '@/components/layout/language-switcher';
import { HistorySyncLearningMap } from '@/components/account/history-sync-learning-map';
import { TabNavigation } from '@/pages/settings/components/tab-navigation';
import {
getInitialLocale,
@@ -138,97 +137,50 @@ describe('Dashboard i18n', () => {
expect(screen.getByText('思考')).toBeInTheDocument();
});
it('renders Japanese history sync guidance without fallback English copy', async () => {
await i18n.changeLanguage('ja');
render(
<HistorySyncLearningMap
isolatedCount={1}
sharedStandardCount={2}
deeperSharedCount={3}
sharedGroups={['default']}
sharedPeerGroups={['default']}
deeperReadyGroups={['default']}
legacyTargetCount={2}
cliproxyCount={1}
/>
);
expect(screen.getByText('履歴同期の仕組み')).toBeInTheDocument();
expect(
screen.getByText(
'グループ「default」は良い状態ですが、他のアカウントやグループでは同グループ設定や拡張継続性がまだ不足しています。'
)
).toBeInTheDocument();
expect(
screen.getByText('1 件の CLIProxy Claude Pool アカウントは、CLIProxy ページで管理します。')
).toBeInTheDocument();
await userEvent.click(
screen.getByRole('button', { name: '詳細を表示: グループ、切り替え、レガシーポリシー' })
);
expect(
screen.getByText('2 件のレガシーアカウントで明示的な確認がまだ必要です。')
).toBeInTheDocument();
});
it('renders Japanese continuity readiness card without fallback English copy', async () => {
await i18n.changeLanguage('ja');
render(
<ContinuityReadinessCard
totalAccounts={2}
isolatedCount={2}
sharedAloneCount={0}
sharedPeerAccountCount={0}
deeperReadyAccountCount={0}
sharedPeerGroups={[]}
deeperReadyGroups={[]}
/>
);
expect(screen.getByText('アカウント間再開チェック')).toBeInTheDocument();
expect(screen.getByText('現在、アカウント間再開はオフです。')).toBeInTheDocument();
});
it('uses single-account-specific next steps in the readiness card', async () => {
it('uses single-account-specific next steps in the continuity overview', async () => {
await i18n.changeLanguage('en');
render(
<ContinuityReadinessCard
<ContinuityOverview
totalAccounts={1}
isolatedCount={1}
sharedStandardCount={0}
deeperSharedCount={0}
sharedAloneCount={0}
sharedPeerAccountCount={0}
deeperReadyAccountCount={0}
sharedGroups={[]}
sharedPeerGroups={[]}
deeperReadyGroups={[]}
legacyTargetCount={0}
cliproxyCount={0}
/>
);
expect(
screen.getByText(
'Create a second ccs auth account before planning any cross-account handoff.'
'Cross-account handoff does not apply yet. Add another auth account before configuring shared continuity.'
)
).toBeInTheDocument();
expect(
screen.queryByText('Open Sync on both accounts, not just the one you are switching into.')
).not.toBeInTheDocument();
});
it('pluralizes the shared-alone readiness copy', async () => {
await i18n.changeLanguage('en');
render(
<ContinuityReadinessCard
<ContinuityOverview
totalAccounts={3}
isolatedCount={1}
sharedStandardCount={2}
deeperSharedCount={0}
sharedAloneCount={2}
sharedPeerAccountCount={0}
deeperReadyAccountCount={0}
sharedGroups={[]}
sharedPeerGroups={[]}
deeperReadyGroups={[]}
legacyTargetCount={0}
cliproxyCount={0}
/>
);
@@ -237,9 +189,9 @@ describe('Dashboard i18n', () => {
).toBeInTheDocument();
});
it.each(SUPPORTED_LOCALES.filter((locale) => locale !== 'en'))(
it.each(SUPPORTED_LOCALES.filter((locale: string) => locale !== 'en'))(
'keeps %s translation keys in parity with en and preserves placeholders',
(locale) => {
(locale: string) => {
const resources = i18n.options.resources as
| Record<string, { translation: Record<string, unknown> }>
| undefined;