From 57d6a44493d1003970afcf53103475cf1ed84d2e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 3 Apr 2026 22:06:43 -0400 Subject: [PATCH] feat(accounts): clarify continuity and resume guidance - add derived continuity readiness states for the accounts dashboard - explain same-group and deeper continuity requirements in the page and sync dialog - handle partial legacy-confirm updates without leaving stale UI state Refs #904 --- ui/src/components/account/accounts-table.tsx | 68 +-- .../account/continuity-readiness-card.tsx | 140 ++++++ .../account/edit-account-context-dialog.tsx | 65 ++- .../account/history-sync-learning-map.tsx | 20 + ui/src/hooks/use-accounts.ts | 101 +++-- ui/src/lib/account-continuity.ts | 124 +++++ ui/src/lib/i18n.ts | 423 +++++++++++++++++- ui/src/pages/accounts.tsx | 57 ++- .../unit/ui/i18n/language-switcher.test.tsx | 72 +++ .../unit/ui/lib/account-continuity.test.ts | 77 ++++ 10 files changed, 1053 insertions(+), 94 deletions(-) create mode 100644 ui/src/components/account/continuity-readiness-card.tsx create mode 100644 ui/src/lib/account-continuity.ts create mode 100644 ui/tests/unit/ui/lib/account-continuity.test.ts diff --git a/ui/src/components/account/accounts-table.tsx b/ui/src/components/account/accounts-table.tsx index 395fab07..300a1bb5 100644 --- a/ui/src/components/account/accounts-table.tsx +++ b/ui/src/components/account/accounts-table.tsx @@ -33,23 +33,24 @@ import { useResetDefaultAccount, useUpdateAccountContext, } from '@/hooks/use-accounts'; -import type { Account } from '@/lib/api-client'; +import type { AuthAccountRow, SharedGroupSummary } from '@/lib/account-continuity'; interface AccountsTableProps { - data: Account[]; + data: AuthAccountRow[]; defaultAccount: string | null; + groupSummaries: SharedGroupSummary[]; } -export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { +export function AccountsTable({ data, defaultAccount, groupSummaries }: AccountsTableProps) { const { t } = useTranslation(); const setDefaultMutation = useSetDefaultAccount(); const deleteMutation = useDeleteAccount(); const resetDefaultMutation = useResetDefaultAccount(); const updateContextMutation = useUpdateAccountContext(); const [deleteTarget, setDeleteTarget] = useState(null); - const [contextTarget, setContextTarget] = useState(null); + const [contextTarget, setContextTarget] = useState(null); - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = [ { accessorKey: 'name', header: t('accountsTable.name'), @@ -104,38 +105,43 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { const mode = row.original.context_mode || 'isolated'; if (mode === 'shared') { const group = row.original.context_group || 'default'; - if (row.original.continuity_mode === 'deeper') { - return ( - - {t('accountsTable.sharedGroupDeeper', { group })} - - ); - } - - if (row.original.continuity_inferred) { - return ( - - {t('accountsTable.sharedGroupLegacy', { group })} - - ); - } - + const 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'); return ( - - {t('accountsTable.sharedGroupStandard', { group })} - +
+

{mainLabel}

+

{peerLabel}

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

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

+

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

+
); } - return {t('accountsTable.isolated')}; + return ( +
+

{t('accountsTable.isolated')}

+

{t('accountsTable.noHandoff')}

+
+ ); }, }, { @@ -299,7 +305,11 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { {contextTarget && ( - setContextTarget(null)} /> + setContextTarget(null)} + /> )} {/* Delete confirmation dialog */} diff --git a/ui/src/components/account/continuity-readiness-card.tsx b/ui/src/components/account/continuity-readiness-card.tsx new file mode 100644 index 00000000..2cd029e5 --- /dev/null +++ b/ui/src/components/account/continuity-readiness-card.tsx @@ -0,0 +1,140 @@ +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' ? ( + + ) : readiness === 'shared-standard' ? ( + + ) : readiness === 'single' ? ( + + ) : ( + + ); + 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 ( + + +
+
+ {t('continuityReadiness.title')} + {t('continuityReadiness.description')} +
+ + {t(`continuityReadiness.state.${readiness}`)} + +
+
+ +
+
+

+ {t('continuityReadiness.metrics.isolated')} +

+

{isolatedCount}

+
+
+

+ {t('continuityReadiness.metrics.sharedPeers')} +

+

{sharedPeerAccountCount}

+
+
+

+ {t('continuityReadiness.metrics.deeperReady')} +

+

{deeperReadyAccountCount}

+
+
+ +
+
+
{icon}
+
+

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

+

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

+
+
+
+ +
+

{t('continuityReadiness.stepsTitle')}

+
    + {stepItems.map((item) => ( +
  1. {item}
  2. + ))} +
+
+
+
+ ); +} diff --git a/ui/src/components/account/edit-account-context-dialog.tsx b/ui/src/components/account/edit-account-context-dialog.tsx index 650c76e0..b10b9538 100644 --- a/ui/src/components/account/edit-account-context-dialog.tsx +++ b/ui/src/components/account/edit-account-context-dialog.tsx @@ -18,7 +18,7 @@ import { SelectValue, } from '@/components/ui/select'; import { useTranslation } from 'react-i18next'; -import type { Account } from '@/lib/api-client'; +import type { AuthAccountRow, SharedGroupSummary } from '@/lib/account-continuity'; import { useUpdateAccountContext } from '@/hooks/use-accounts'; type ContextMode = 'isolated' | 'shared'; @@ -28,11 +28,16 @@ const MAX_CONTEXT_GROUP_LENGTH = 64; const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; interface EditAccountContextDialogProps { - account: Account; + account: AuthAccountRow; + groupSummaries: SharedGroupSummary[]; onClose: () => void; } -export function EditAccountContextDialog({ account, onClose }: EditAccountContextDialogProps) { +export function EditAccountContextDialog({ + account, + groupSummaries, + onClose, +}: EditAccountContextDialogProps) { const { t } = useTranslation(); const updateContextMutation = useUpdateAccountContext(); const [mode, setMode] = useState( @@ -44,11 +49,35 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex ); const normalizedGroup = useMemo(() => group.trim().toLowerCase().replace(/\s+/g, '-'), [group]); + const matchingGroup = useMemo( + () => groupSummaries.find((summary) => summary.group === normalizedGroup), + [groupSummaries, normalizedGroup] + ); const isSharedGroupValid = normalizedGroup.length > 0 && normalizedGroup.length <= MAX_CONTEXT_GROUP_LENGTH && CONTEXT_GROUP_PATTERN.test(normalizedGroup); const canSubmit = mode === 'isolated' || isSharedGroupValid; + const sameGroupPeerCount = + mode === 'shared' + ? Math.max( + (matchingGroup?.sharedCount ?? 0) - + (account.context_mode === 'shared' && account.context_group === normalizedGroup + ? 1 + : 0), + 0 + ) + : 0; + const sameGroupDeeperPeerCount = + mode === 'shared' + ? Math.max( + (matchingGroup?.deeperCount ?? 0) - + (account.continuity_mode === 'deeper' && account.context_group === normalizedGroup + ? 1 + : 0), + 0 + ) + : 0; const handleSave = () => { if (!canSubmit) { @@ -150,6 +179,36 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex

{t('editAccountContext.credentialsIsolated')}

+ +
+

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

+
+ {mode === 'isolated' ? ( +

{t('editAccountContext.isolatedImplication')}

+ ) : ( + <> +

{t('editAccountContext.sameGroupRule', { group: normalizedGroup })}

+

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

+

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

+ + )} +

{t('editAccountContext.resumeOriginalWarning')}

+
+
diff --git a/ui/src/components/account/history-sync-learning-map.tsx b/ui/src/components/account/history-sync-learning-map.tsx index cc8b31f5..50071c02 100644 --- a/ui/src/components/account/history-sync-learning-map.tsx +++ b/ui/src/components/account/history-sync-learning-map.tsx @@ -21,6 +21,8 @@ interface HistorySyncLearningMapProps { sharedStandardCount: number; deeperSharedCount: number; sharedGroups: string[]; + sharedPeerGroups: string[]; + deeperReadyGroups: string[]; legacyTargetCount: number; cliproxyCount: number; } @@ -74,12 +76,20 @@ export function HistorySyncLearningMap({ 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 ( @@ -129,6 +139,16 @@ export function HistorySyncLearningMap({ /> +
+ {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 })} +
+