an account with no saved sign-in gets a chip you can read, not one you can use
Three chip states now, and the third is a different question rather than a broken version of the second: filled the account you are plain an account you can become — click switches dashed outline an account you can only look at — click shows its last known usage "Has my other allowance come back yet" is answerable from a snapshot, and the machinery for it was already right: a reset time is an absolute instant, and currentLimit rolls a past window forward and zeroes it instead of showing a stale percentage. Phase one was hiding a correct answer. What you cannot do is act on it without a /login, so the chip says that before it is clicked (in its title and in its border) and the panel says it again while you are reading one. Naming the fix rather than the problem: "No saved sign-in — run /login to use this one". Viewing is deliberately NOT a general tab selection. Only an account with no credential can be viewed, because for any other one the useful gesture is switching to it and then reading live numbers — which keeps phase one's invariant intact: if the panel shows a signed-in account, it is THE signed-in account. Switching clears the view for the same reason, so the numbers and the chips can never disagree. Replaces the count line, which was a fact about somewhere else. The staleness line that was already in this file — "Not signed in · as of 20 minutes ago" — now finally has a case that reaches it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -132,61 +132,67 @@ const Models = ({ account }: { account: UsageAccount }) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* One chip per Anthropic account, and clicking one BECOMES it.
|
||||
* A chip per Anthropic account this machine has seen, and clicking one does one of two things.
|
||||
*
|
||||
* This used to select which account's stored numbers you were reading, which turned out to be the less
|
||||
* useful half of the same gesture: having established that the other account has room, what you want is to
|
||||
* use it. So the chip is now an action, and the panel below it always shows the account you are actually
|
||||
* signed into.
|
||||
* switch — a stored credential exists, so the click BECOMES that account
|
||||
* view — only a stored reading exists, so the click shows its cached numbers and nothing changes
|
||||
*
|
||||
* The spinner is not decoration. A switch rewrites a credential on disk and changes nothing you can see —
|
||||
* without an indicator the honest reading of a click is "nothing happened", and the second click is somebody
|
||||
* checking.
|
||||
* The second kind is not a degraded version of the first, it is a different question. An account whose
|
||||
* sign-in is gone still has percentages and a reset time worth reading — "has my other allowance come back
|
||||
* yet" is answerable from a snapshot, because a reset time is an absolute instant and `currentLimit` rolls a
|
||||
* past window forward rather than showing a stale one. What you cannot do is act on the answer without a
|
||||
* `/login`, and that is what the chip has to make obvious BEFORE it is clicked.
|
||||
*
|
||||
* So: filled for the account you are, plain for one you can become, and dashed-outline for one you can only
|
||||
* look at.
|
||||
*/
|
||||
const AccountSwitcher = ({
|
||||
accounts,
|
||||
type Chip = {
|
||||
key: string;
|
||||
email: string;
|
||||
title: string;
|
||||
kind: 'current' | 'switch' | 'view';
|
||||
busy: boolean;
|
||||
};
|
||||
|
||||
const AccountChips = ({
|
||||
chips,
|
||||
isSwitching,
|
||||
switchingTo,
|
||||
onSwitch,
|
||||
viewing,
|
||||
onClick,
|
||||
}: {
|
||||
accounts: SwitchableAccount[];
|
||||
chips: Chip[];
|
||||
isSwitching: boolean;
|
||||
switchingTo: string | null;
|
||||
onSwitch: (accountUuid: string) => void;
|
||||
viewing: string | null;
|
||||
onClick: (chip: Chip) => void;
|
||||
}) => (
|
||||
<div className="flex flex-wrap items-center gap-1 border-t border-border px-2 py-2">
|
||||
{accounts.map((account) => {
|
||||
const busy = isSwitching && switchingTo === account.accountUuid;
|
||||
{chips.map((chip) => {
|
||||
const looking = chip.kind === 'view' && viewing === chip.email;
|
||||
return (
|
||||
<button
|
||||
key={account.accountUuid}
|
||||
key={chip.key}
|
||||
type="button"
|
||||
// An expired refresh token cannot be switched to, so the chip says why instead of failing on click.
|
||||
disabled={!account.usable || isSwitching || account.current}
|
||||
onClick={() => onSwitch(account.accountUuid)}
|
||||
title={
|
||||
account.current
|
||||
? `${account.emailAccount} — signed in`
|
||||
: account.usable
|
||||
? `Switch to ${account.emailAccount}${account.orgName ? ` (${account.orgName})` : ''}`
|
||||
: `${account.emailAccount} — sign-in expired, run /login`
|
||||
}
|
||||
disabled={isSwitching || (chip.kind === 'current' && !viewing)}
|
||||
onClick={() => onClick(chip)}
|
||||
title={chip.title}
|
||||
className={`inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
account.current
|
||||
chip.kind === 'current' && !viewing
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: account.usable
|
||||
? 'cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground'
|
||||
: 'text-muted-foreground/40 line-through'
|
||||
} ${isSwitching && !busy ? 'opacity-50' : ''}`}
|
||||
: chip.kind === 'view'
|
||||
? // Dashed and unfilled: the border says "not a real destination" without greying it out to
|
||||
// the point of looking broken, because it IS clickable — just not for switching.
|
||||
`cursor-pointer border border-dashed border-border ${
|
||||
looking ? 'bg-foreground/10 text-foreground' : 'text-muted-foreground/70 hover:text-foreground'
|
||||
}`
|
||||
: 'cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground'
|
||||
} ${isSwitching && !chip.busy ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{busy && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{chip.busy && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{/* The WHOLE address. This showed the local part alone, on the reasoning that the domain is what
|
||||
accounts have in common — which is wrong for the case that matters: a personal and a work
|
||||
account belonging to one person are `andre.padez@gmail.com` and `andre.padez@pertento.ai`, so
|
||||
the local part is the half they SHARE and the domain is the half that tells them apart. Two
|
||||
chips read as the same account. The strip wraps instead of scrolling, so a long address is
|
||||
readable rather than clipped off the edge. */}
|
||||
{account.emailAccount}
|
||||
account belonging to one person share the local part, so the domain is the half that tells
|
||||
them apart. Two chips read as the same account. */}
|
||||
{chip.email}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -241,7 +247,21 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
A selection would let the numbers and the chips disagree — reading one account's percentages with
|
||||
another's chip lit — and that disagreement is the exact confusion this whole change set out to remove.
|
||||
*/
|
||||
const selected = accounts[0] ?? null;
|
||||
/*
|
||||
`viewing` is null for the normal case — the account you are signed into — and an email when you have
|
||||
clicked an archived chip to read its cached numbers.
|
||||
|
||||
It is deliberately NOT a general tab selection. Only accounts with no stored credential can be viewed,
|
||||
because for any other one the useful gesture is switching to it and then reading live numbers. That keeps
|
||||
the invariant that made phase one worth doing: if the panel shows a signed-in account, it is THE
|
||||
signed-in account.
|
||||
*/
|
||||
const [viewing, setViewing] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) setViewing(null);
|
||||
}, [open]);
|
||||
|
||||
const selected = (viewing ? allAccounts.find((a) => a.email === viewing) : accounts[0]) ?? accounts[0] ?? null;
|
||||
/*
|
||||
Accounts with a stored READING but no stored credential — an old login that can be looked at and not
|
||||
returned to. That is what is worth saying out loud.
|
||||
@@ -249,7 +269,28 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
This counted `allAccounts - accounts` until the switcher existed, which then double-counted: it called
|
||||
an account "not shown" while its chip sat two lines below.
|
||||
*/
|
||||
const unreachable = allAccounts.filter((a) => !switchable.some((s) => s.emailAccount === a.email)).length;
|
||||
const archived = allAccounts.filter((a) => !switchable.some((s) => s.emailAccount === a.email));
|
||||
|
||||
const chips: Chip[] = [
|
||||
...switchable.map((a) => ({
|
||||
key: a.accountUuid,
|
||||
email: a.emailAccount,
|
||||
kind: (a.current ? 'current' : 'switch') as Chip['kind'],
|
||||
busy: isSwitching && switchingTo === a.accountUuid,
|
||||
title: a.current
|
||||
? `${a.emailAccount} — signed in`
|
||||
: a.usable
|
||||
? `Switch to ${a.emailAccount}${a.orgName ? ` (${a.orgName})` : ''}`
|
||||
: `${a.emailAccount} — sign-in expired, run /login`,
|
||||
})),
|
||||
...archived.map((a) => ({
|
||||
key: `archived:${a.email}`,
|
||||
email: a.email,
|
||||
kind: 'view' as Chip['kind'],
|
||||
busy: false,
|
||||
title: `${a.email} — no saved sign-in. Click to read its last known usage; run /login to use it.`,
|
||||
})),
|
||||
];
|
||||
|
||||
// The countdowns tick. Only while the panel is open — an interval running behind a closed popover is a
|
||||
// re-render a minute for something nobody is looking at.
|
||||
@@ -298,10 +339,11 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
{switchError instanceof Error ? switchError.message : 'could not switch account'}
|
||||
</div>
|
||||
)}
|
||||
{unreachable > 0 && (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{unreachable} older account{unreachable === 1 ? '' : 's'} with no saved sign-in
|
||||
</div>
|
||||
{/* Said on the account being LOOKED AT rather than as a count somewhere else. The numbers below are
|
||||
real and the reset times are still correct; the only thing missing is the ability to act on
|
||||
them, and naming the fix beats naming the problem. */}
|
||||
{viewing && (
|
||||
<div className="mt-0.5 text-[10px] text-warning">No saved sign-in — run /login to use this one</div>
|
||||
)}
|
||||
{/*
|
||||
How old this reading is, and it earns its line only when it is not live.
|
||||
@@ -320,12 +362,23 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{switchable.length > 1 && (
|
||||
<AccountSwitcher
|
||||
accounts={switchable}
|
||||
{chips.length > 1 && (
|
||||
<AccountChips
|
||||
chips={chips}
|
||||
isSwitching={isSwitching}
|
||||
switchingTo={switchingTo}
|
||||
onSwitch={(uuid) => void switchTo(uuid).catch(() => {})}
|
||||
viewing={viewing}
|
||||
onClick={(chip) => {
|
||||
if (chip.kind === 'view') {
|
||||
setViewing(chip.email);
|
||||
return;
|
||||
}
|
||||
// Switching returns the panel to live numbers: the account you just became is the one whose
|
||||
// percentages you want, and leaving an archived snapshot on screen after a switch is how the
|
||||
// numbers and the chips end up disagreeing.
|
||||
setViewing(null);
|
||||
const target = switchable.find((a) => a.emailAccount === chip.email);
|
||||
if (target && !target.current) void switchTo(target.accountUuid).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user