Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improve the Accounts settings tab: account rows now use proper radio-group semantics with arrow-key navigation and account-specific accessible names for the Remove/Rename/Edit-API actions; the slot grids collapse on narrow widths; and several action buttons gain the app's standard press-scale feedback.
65 changes: 41 additions & 24 deletions src/mainview/components/global-settings/AgentAccountsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,28 @@ function AccountRow({
className={`flex flex-wrap items-center gap-2.5 px-3 py-2 bg-elevated border rounded-lg transition-colors ${
isActive ? "border-accent/50" : "border-edge"
} ${onActivate && !isActive ? "cursor-pointer hover:bg-elevated-hover" : ""}`}
role={onActivate ? "button" : undefined}
tabIndex={onActivate ? 0 : undefined}
role="radio"
aria-checked={isActive}
// One tab stop for the group (the checked row); the rest come in via the arrows.
tabIndex={isActive ? 0 : -1}
onClick={onActivate}
onKeyDown={(event) => {
if (onActivate && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onActivate();
if (event.key === "Enter" || event.key === " ") {
if (onActivate) {
event.preventDefault();
onActivate();
}
return;
}
if (!["ArrowDown", "ArrowUp"].includes(event.key)) return;
event.preventDefault();
const radios = Array.from(
event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="radio"]') ?? [],
);
if (!radios.length) return;
const idx = radios.indexOf(event.currentTarget);
const next = radios[(idx + (event.key === "ArrowDown" ? 1 : -1) + radios.length) % radios.length];
next?.focus();
}}
>
<span
Expand Down Expand Up @@ -212,9 +226,9 @@ function AccountRow({
event.stopPropagation();
onEditApi();
}}
className="p-1 rounded text-fg-muted hover:text-fg hover:bg-raised-hover transition-colors shrink-0"
className="p-1 rounded text-fg-muted hover:text-fg hover:bg-raised-hover transition-[opacity,color,background-color,transform] duration-150 ease-out motion-safe:active:scale-[0.96] shrink-0"
title={t("settings.accountsEditApi")}
aria-label={t("settings.accountsEditApi")}
aria-label={t("settings.accountsEditApiFor", { label })}
>
<span className="text-[0.75rem] leading-none" style={{ fontFamily: "'JetBrainsMono Nerd Font Mono'" }}>
{"\uf044"}
Expand All @@ -228,9 +242,9 @@ function AccountRow({
setDraft(label);
setEditing(true);
}}
className="p-1 rounded text-fg-muted hover:text-fg hover:bg-raised-hover transition-colors shrink-0"
className="p-1 rounded text-fg-muted hover:text-fg hover:bg-raised-hover transition-[opacity,color,background-color,transform] duration-150 ease-out motion-safe:active:scale-[0.96] shrink-0"
title={t("settings.accountsRename")}
aria-label={t("settings.accountsRename")}
aria-label={t("settings.accountsRenameFor", { label })}
>
<span className="text-[0.75rem] leading-none" style={{ fontFamily: "'JetBrainsMono Nerd Font Mono'" }}>
{"\uf044"}
Expand All @@ -244,7 +258,8 @@ function AccountRow({
event.stopPropagation();
onRemove();
}}
className="text-danger text-xs hover:bg-danger/10 px-1.5 py-0.5 rounded transition-colors shrink-0"
className="text-danger text-xs hover:bg-danger/10 px-1.5 py-0.5 rounded transition-[opacity,color,background-color,transform] duration-150 ease-out motion-safe:active:scale-[0.96] shrink-0"
aria-label={t("settings.accountsRemoveFor", { label })}
>
{t("settings.accountsRemove")}
</button>
Expand Down Expand Up @@ -281,7 +296,7 @@ function LoginFlowCard({
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="px-2.5 py-1.5 rounded bg-elevated border border-edge text-fg-2 text-xs hover:bg-elevated-hover transition-colors shrink-0"
className="px-2.5 py-1.5 rounded bg-elevated border border-edge text-fg-2 text-xs hover:bg-elevated-hover transition-[opacity,color,background-color,transform] duration-150 ease-out motion-safe:active:scale-[0.96] shrink-0"
>
{copied ? t("settings.accountsCopied") : t("settings.accountsCopy")}
</button>
Expand All @@ -291,15 +306,15 @@ function LoginFlowCard({
type="button"
onClick={onVerify}
disabled={flow.verifying}
className="px-3 py-1.5 rounded-lg bg-accent-fill text-white text-xs font-medium hover:bg-accent-fill-hover disabled:opacity-50 transition-colors"
className="px-3 py-1.5 rounded-lg bg-accent-fill text-white text-xs font-medium hover:bg-accent-fill-hover disabled:opacity-50 transition-[opacity,color,background-color,transform] duration-150 ease-out motion-safe:active:scale-[0.96]"
>
{flow.verifying ? t("settings.accountsVerifying") : t("settings.accountsVerify")}
</button>
<button
type="button"
onClick={onCancel}
disabled={flow.verifying}
className="px-3 py-1.5 rounded-lg text-fg-3 text-xs hover:text-fg hover:bg-elevated transition-colors disabled:opacity-50"
className="px-3 py-1.5 rounded-lg text-fg-3 text-xs hover:text-fg hover:bg-elevated transition-[opacity,color,background-color,transform] duration-150 ease-out motion-safe:active:scale-[0.96] disabled:opacity-50"
>
{t("settings.accountsCancelAdd")}
</button>
Expand Down Expand Up @@ -415,7 +430,7 @@ function SlotOverrideCard({
}}
className={API_INPUT_CLASS}
/>
<div className="grid grid-cols-2 gap-1.5">
<div className="grid grid-cols-1 [@container_(min-width:34rem)]:grid-cols-2 gap-1.5">
<input
type="text"
value={draft.name}
Expand Down Expand Up @@ -475,7 +490,7 @@ function ApiProfileFormCard({
}

return (
<div className="bg-base border border-accent/30 rounded-lg p-3 space-y-2.5">
<div className="bg-base border border-accent/30 rounded-lg p-3 space-y-2.5 [container-type:inline-size]">
<p className="text-fg-2 text-xs">{t("settings.accountsApiHint")}</p>
{field(t("settings.accountsApiLabel"), "label", "OpenRouter", t("settings.accountsApiLabelHint"))}
{field(t("settings.accountsApiBaseUrl"), "baseUrl", "https://openrouter.ai/api", t("settings.accountsApiBaseUrlHint"))}
Expand All @@ -492,7 +507,7 @@ function ApiProfileFormCard({
<button
type="button"
onClick={() => setShowKey((v) => !v)}
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-fg-muted hover:text-fg transition-colors"
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-fg-muted hover:text-fg transition-[opacity,color,background-color,transform] duration-150 ease-out motion-safe:active:scale-[0.96]"
title={showKey ? t("settings.accountsApiKeyHide") : t("settings.accountsApiKeyShow")}
aria-label={showKey ? t("settings.accountsApiKeyHide") : t("settings.accountsApiKeyShow")}
>
Expand All @@ -517,7 +532,7 @@ function ApiProfileFormCard({
text={masterActive ? t("settings.accountsApiSlotsDisabled") : t("settings.accountsApiSlotsTitle")}
body={t("settings.accountsApiSlotsHint")}
/>
<div className="grid grid-cols-2 gap-1.5">
<div className="grid grid-cols-1 [@container_(min-width:34rem)]:grid-cols-2 gap-1.5">
{CLAUDE_MODEL_SLOTS.map((slot) => (
<SlotOverrideCard
key={slot}
Expand Down Expand Up @@ -545,15 +560,15 @@ function ApiProfileFormCard({
type="button"
onClick={onSave}
disabled={!canSave}
className="px-3 py-1.5 rounded-lg bg-accent-fill text-white text-xs font-medium hover:bg-accent-fill-hover disabled:opacity-50 transition-colors"
className="px-3 py-1.5 rounded-lg bg-accent-fill text-white text-xs font-medium hover:bg-accent-fill-hover disabled:opacity-50 transition-[opacity,color,background-color,transform] duration-150 ease-out motion-safe:active:scale-[0.96]"
>
{editing ? t("settings.accountsApiSave") : t("settings.accountsApiCreate")}
</button>
<button
type="button"
onClick={onCancel}
disabled={saving}
className="px-3 py-1.5 rounded-lg text-fg-3 text-xs hover:text-fg hover:bg-elevated transition-colors disabled:opacity-50"
className="px-3 py-1.5 rounded-lg text-fg-3 text-xs hover:text-fg hover:bg-elevated transition-[opacity,color,background-color,transform] duration-150 ease-out motion-safe:active:scale-[0.96] disabled:opacity-50"
>
{t("settings.accountsCancelAdd")}
</button>
Expand Down Expand Up @@ -745,30 +760,31 @@ export default function AgentAccountsSection({ t }: { t: TFunction }) {
type="button"
onClick={() => handleImport(kind)}
disabled={busy}
className="px-2.5 py-1 text-accent text-xs font-medium hover:bg-accent/10 rounded-lg transition-colors disabled:opacity-50"
className="px-2.5 py-1 text-accent text-xs font-medium hover:bg-accent/10 rounded-lg transition-[opacity,color,background-color,transform] duration-150 ease-out motion-safe:active:scale-[0.96] disabled:opacity-50"
>
{t("settings.accountsImportCurrent")}
</button>
<button
type="button"
onClick={() => handleStartAdd(kind)}
disabled={busy || addFlow !== null || apiForm !== null}
className="px-2.5 py-1 text-accent text-xs font-medium hover:bg-accent/10 rounded-lg transition-colors disabled:opacity-50"
className="px-2.5 py-1 text-accent text-xs font-medium hover:bg-accent/10 rounded-lg transition-[opacity,color,background-color,transform] duration-150 ease-out motion-safe:active:scale-[0.96] disabled:opacity-50"
>
+ {t("settings.accountsAdd")}
{t("settings.accountsAdd")}
</button>
{kind === "claude" ? (
<button
type="button"
onClick={() => setApiForm(EMPTY_API_FORM)}
disabled={busy || addFlow !== null || apiForm !== null}
className="px-2.5 py-1 text-accent text-xs font-medium hover:bg-accent/10 rounded-lg transition-colors disabled:opacity-50"
className="px-2.5 py-1 text-accent text-xs font-medium hover:bg-accent/10 rounded-lg transition-[opacity,color,background-color,transform] duration-150 ease-out motion-safe:active:scale-[0.96] disabled:opacity-50"
>
+ {t("settings.accountsAddApi")}
{t("settings.accountsAddApiButton")}
</button>
) : null}
</div>
<div className="space-y-1.5">
<div role="radiogroup" aria-label={title} className="space-y-1.5">
{extraRows}
{accounts.map((account) => (
<AccountRow
Expand All @@ -785,6 +801,7 @@ export default function AgentAccountsSection({ t }: { t: TFunction }) {
t={t}
/>
))}
</div>
{accounts.length === 0 && !extraRows && !emptyHint ? (
<p className="text-fg-muted text-xs">{t("settings.accountsNoneYet")}</p>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ describe("AgentAccountsSection", () => {
renderSection();
await screen.findByText("System login (~/.claude)");

const addButtons = screen.getAllByText("+ Add account");
const addButtons = screen.getAllByText("Add account");
await user.click(addButtons[0]);

expect(await screen.findByText("CLAUDE_CONFIG_DIR='/x' claude /login")).toBeTruthy();
Expand All @@ -179,7 +179,7 @@ describe("AgentAccountsSection", () => {
const user = userEvent.setup();
renderSection();
await screen.findByText("System login (~/.claude)");
await user.click(screen.getAllByText("+ Add account")[0]);
await user.click(screen.getAllByText("Add account")[0]);
await screen.findByText("CLAUDE_CONFIG_DIR='/x' claude /login");

await user.click(screen.getByText("Cancel"));
Expand Down Expand Up @@ -221,7 +221,7 @@ describe("AgentAccountsSection", () => {
renderSection();
await screen.findByText("System login (~/.claude)");

await user.click(screen.getByText("+ API profile"));
await user.click(screen.getByText("Add API profile"));
await user.type(screen.getByPlaceholderText("https://openrouter.ai/api"), "https://openrouter.ai/api");
await user.type(screen.getByPlaceholderText("sk-ant-…"), "sk-or-123");
await user.type(screen.getByPlaceholderText(/CLAUDE_CODE_USE_BEDROCK/), "AWS_REGION=us-east-1");
Expand All @@ -245,7 +245,7 @@ describe("AgentAccountsSection", () => {
renderSection();
await screen.findByText("System login (~/.claude)");

await user.click(screen.getByText("+ API profile"));
await user.click(screen.getByText("Add API profile"));
expect((screen.getByText("Add profile") as HTMLButtonElement).disabled).toBe(true);
expect(mockedApi.request.addAgentApiProfile).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -318,7 +318,7 @@ describe("AgentAccountsSection", () => {
renderSection();

await screen.findByText("OpenRouter");
await user.click(screen.getByLabelText("Edit API profile"));
await user.click(screen.getByLabelText("Edit API profile — OpenRouter"));

// Form is prefilled from the draft, including the (masked) key value.
// Master field is the one carrying the current model value (its placeholder
Expand Down Expand Up @@ -354,7 +354,7 @@ describe("AgentAccountsSection", () => {
renderSection();
await screen.findByText("System login (~/.claude)");

await user.click(screen.getByText("+ API profile"));
await user.click(screen.getByText("Add API profile"));
// The Haiku slot's Model ID placeholder is a deepseek example.
const haikuId = screen.getByPlaceholderText("deepseek/deepseek-v4-flash");
await user.type(haikuId, "provider/my-fast-model");
Expand Down Expand Up @@ -396,7 +396,7 @@ describe("AgentAccountsSection", () => {
const user = userEvent.setup();
renderSection();
await screen.findByText("OpenRouter");
await user.click(screen.getByLabelText("Edit API profile"));
await user.click(screen.getByLabelText("Edit API profile — OpenRouter"));

const keyInput = (await screen.findByPlaceholderText("sk-ant-…")) as HTMLInputElement;
expect(keyInput.type).toBe("password");
Expand Down
4 changes: 4 additions & 0 deletions src/mainview/i18n/translations/en/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,9 @@ const settings = {
"settings.accountsVerifying": "Verifying…",
"settings.accountsCancelAdd": "Cancel",
"settings.accountsRename": "Rename account",
"settings.accountsRenameFor": "Rename {label}",
"settings.accountsRemove": "Remove",
"settings.accountsRemoveFor": "Remove {label}",
"settings.accountsRemoveConfirmTitle": "Remove account?",
"settings.accountsRemoveConfirmMessage": "This removes the stored credentials snapshot “{label}” from dev3. The account itself is not affected.",
"settings.accountsUnmanaged": "Unmanaged login",
Expand All @@ -363,6 +365,7 @@ const settings = {
"settings.accountsNoneYet": "No accounts added yet.",
"settings.accountsNewSessionsHint": "The default account is the preselect for new launches — each launch can pick a different one; running sessions keep their current login.",
"settings.accountsAddApi": "API profile",
"settings.accountsAddApiButton": "Add API profile",
"settings.accountsApiHint": "Direct API access instead of a subscription login: the Anthropic API or any Anthropic-compatible endpoint (OpenRouter, a LiteLLM proxy, …). For Bedrock, add CLAUDE_CODE_USE_BEDROCK=1 and AWS_* variables below.",
"settings.accountsApiLabel": "Name",
"settings.accountsApiLabelHint": "Display name shown in this list only. Purely cosmetic — pick whatever helps you recognize the profile.",
Expand All @@ -384,6 +387,7 @@ const settings = {
"settings.accountsApiCreate": "Add profile",
"settings.accountsApiSave": "Save changes",
"settings.accountsEditApi": "Edit API profile",
"settings.accountsEditApiFor": "Edit API profile — {label}",
"settings.accountsSwitchConfirmTitle": "Switch active account?",
"settings.accountsSwitchConfirmMessage": "Every NEW agent session — new tasks, spawned agents, team agents, bug hunters, auto-review runs — will start under “{name}” and be billed to that account. Sessions already running are not affected and keep their current login. The switch applies machine-wide until you change it back — this is your call.\n\nThe account only applies to agents dev3 launches for you — use Spawn agent, Find bugs, Create task and the other in-app buttons. If you run “claude” yourself in a terminal, dev3 can't inject the account and it will use your default login instead.",

Expand Down
4 changes: 4 additions & 0 deletions src/mainview/i18n/translations/es/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,9 @@ const settings = {
"settings.accountsVerifying": "Verificando…",
"settings.accountsCancelAdd": "Cancelar",
"settings.accountsRename": "Renombrar cuenta",
"settings.accountsRenameFor": "Renombrar {label}",
"settings.accountsRemove": "Eliminar",
"settings.accountsRemoveFor": "Eliminar {label}",
"settings.accountsRemoveConfirmTitle": "¿Eliminar cuenta?",
"settings.accountsRemoveConfirmMessage": "Se elimina la copia de credenciales “{label}” de dev3. La cuenta en sí no se ve afectada.",
"settings.accountsUnmanaged": "Sesión no gestionada",
Expand All @@ -364,6 +366,7 @@ const settings = {
"settings.accountsNoneYet": "Aún no hay cuentas añadidas.",
"settings.accountsNewSessionsHint": "La cuenta predeterminada es la preselección para los nuevos lanzamientos — cada lanzamiento puede elegir otra; las sesiones en ejecución mantienen su login actual.",
"settings.accountsAddApi": "Perfil API",
"settings.accountsAddApiButton": "Añadir perfil API",
"settings.accountsApiHint": "Acceso directo por API en lugar de un inicio de sesión de suscripción: la API de Anthropic o cualquier endpoint compatible (OpenRouter, un proxy LiteLLM, …). Para Bedrock, añade CLAUDE_CODE_USE_BEDROCK=1 y variables AWS_* abajo.",
"settings.accountsApiLabel": "Nombre",
"settings.accountsApiLabelHint": "Nombre mostrado solo en esta lista. Puramente cosmético — elige lo que te ayude a reconocer el perfil.",
Expand All @@ -385,6 +388,7 @@ const settings = {
"settings.accountsApiCreate": "Añadir perfil",
"settings.accountsApiSave": "Guardar cambios",
"settings.accountsEditApi": "Editar perfil de API",
"settings.accountsEditApiFor": "Editar perfil de API — {label}",
"settings.accountsSwitchConfirmTitle": "¿Cambiar la cuenta activa?",
"settings.accountsSwitchConfirmMessage": "Cada sesión de agente NUEVA — nuevas tareas, agentes generados, agentes de equipo, bug hunters, revisiones automáticas — se iniciará y facturará con «{name}». Las sesiones ya en ejecución no se ven afectadas y conservan su inicio de sesión actual. El cambio se aplica a toda la máquina hasta que lo reviertas — es tu responsabilidad.\n\nLa cuenta solo se aplica a los agentes que lanza dev3 por ti — usa Spawn agent, Find bugs, Create task y los demás botones de la app. Si ejecutas «claude» tú mismo en una terminal, dev3 no puede inyectar la cuenta y usará tu login por defecto.",

Expand Down
Loading