diff --git a/change-logs/2026/08/02/fix-accounts-tab-a11y-and-consistency.md b/change-logs/2026/08/02/fix-accounts-tab-a11y-and-consistency.md new file mode 100644 index 000000000..e2d42091e --- /dev/null +++ b/change-logs/2026/08/02/fix-accounts-tab-a11y-and-consistency.md @@ -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. diff --git a/src/mainview/components/global-settings/AgentAccountsSection.tsx b/src/mainview/components/global-settings/AgentAccountsSection.tsx index 7b530b9fe..326af01c8 100644 --- a/src/mainview/components/global-settings/AgentAccountsSection.tsx +++ b/src/mainview/components/global-settings/AgentAccountsSection.tsx @@ -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('[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(); }} > {"\uf044"} @@ -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 })} > {"\uf044"} @@ -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")} @@ -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")} @@ -291,7 +306,7 @@ 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")} @@ -299,7 +314,7 @@ function LoginFlowCard({ 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")} @@ -415,7 +430,7 @@ function SlotOverrideCard({ }} className={API_INPUT_CLASS} /> -
+
+

{t("settings.accountsApiHint")}

{field(t("settings.accountsApiLabel"), "label", "OpenRouter", t("settings.accountsApiLabelHint"))} {field(t("settings.accountsApiBaseUrl"), "baseUrl", "https://openrouter.ai/api", t("settings.accountsApiBaseUrlHint"))} @@ -492,7 +507,7 @@ function ApiProfileFormCard({ @@ -553,7 +568,7 @@ function ApiProfileFormCard({ 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")} @@ -745,7 +760,7 @@ 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")} @@ -753,22 +768,23 @@ export default function AgentAccountsSection({ t }: { t: TFunction }) { 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")} {kind === "claude" ? ( ) : null}
+
{extraRows} {accounts.map((account) => ( ))} +
{accounts.length === 0 && !extraRows && !emptyHint ? (

{t("settings.accountsNoneYet")}

) : null} diff --git a/src/mainview/components/global-settings/__tests__/AgentAccountsSection.test.tsx b/src/mainview/components/global-settings/__tests__/AgentAccountsSection.test.tsx index 28174f52b..a653ddb59 100644 --- a/src/mainview/components/global-settings/__tests__/AgentAccountsSection.test.tsx +++ b/src/mainview/components/global-settings/__tests__/AgentAccountsSection.test.tsx @@ -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(); @@ -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")); @@ -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"); @@ -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(); }); @@ -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 @@ -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"); @@ -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"); diff --git a/src/mainview/i18n/translations/en/settings.ts b/src/mainview/i18n/translations/en/settings.ts index f327c2a7d..1f17328d1 100644 --- a/src/mainview/i18n/translations/en/settings.ts +++ b/src/mainview/i18n/translations/en/settings.ts @@ -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", @@ -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.", @@ -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.", diff --git a/src/mainview/i18n/translations/es/settings.ts b/src/mainview/i18n/translations/es/settings.ts index f66c6aa49..7efa33840 100644 --- a/src/mainview/i18n/translations/es/settings.ts +++ b/src/mainview/i18n/translations/es/settings.ts @@ -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", @@ -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.", @@ -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.", diff --git a/src/mainview/i18n/translations/ru/settings.ts b/src/mainview/i18n/translations/ru/settings.ts index e2a677817..d96d06b84 100644 --- a/src/mainview/i18n/translations/ru/settings.ts +++ b/src/mainview/i18n/translations/ru/settings.ts @@ -356,7 +356,9 @@ const settings = { "settings.accountsVerifying": "Проверка…", "settings.accountsCancelAdd": "Отмена", "settings.accountsRename": "Переименовать аккаунт", + "settings.accountsRenameFor": "Переименовать {label}", "settings.accountsRemove": "Удалить", + "settings.accountsRemoveFor": "Удалить {label}", "settings.accountsRemoveConfirmTitle": "Удалить аккаунт?", "settings.accountsRemoveConfirmMessage": "Снимок учётных данных «{label}» будет удалён из dev3. Сам аккаунт не пострадает.", "settings.accountsUnmanaged": "Неуправляемый логин", @@ -365,6 +367,7 @@ const settings = { "settings.accountsNoneYet": "Аккаунты пока не добавлены.", "settings.accountsNewSessionsHint": "Аккаунт по умолчанию — это преселект для новых запусков; каждый запуск может выбрать другой; запущенные сессии сохраняют текущий логин.", "settings.accountsAddApi": "API-профиль", + "settings.accountsAddApiButton": "Добавить API-профиль", "settings.accountsApiHint": "Прямой доступ по API вместо логина по подписке: Anthropic API или любой совместимый endpoint (OpenRouter, LiteLLM-прокси, …). Для Bedrock добавьте CLAUDE_CODE_USE_BEDROCK=1 и переменные AWS_* ниже.", "settings.accountsApiLabel": "Название", "settings.accountsApiLabelHint": "Отображаемое имя только в этом списке. Чисто косметика — назовите так, чтобы вам было удобно узнавать профиль.", @@ -386,6 +389,7 @@ const settings = { "settings.accountsApiCreate": "Добавить профиль", "settings.accountsApiSave": "Сохранить изменения", "settings.accountsEditApi": "Редактировать API-профиль", + "settings.accountsEditApiFor": "Редактировать API-профиль — {label}", "settings.accountsSwitchConfirmTitle": "Сменить активный аккаунт?", "settings.accountsSwitchConfirmMessage": "Каждая НОВАЯ сессия агента — новые таски, spawn-агенты, team-агенты, bug hunters, авто-ревью — будет запускаться и тарифицироваться под «{name}». Уже запущенные сессии не затрагиваются и продолжают работать под текущим логином. Переключение действует на всю машину, пока вы не смените его обратно — ответственность на вас.\n\nАккаунт применяется только к агентам, которых запускает сам dev3 — используйте Spawn agent, Find bugs, Create task и другие кнопки в приложении. Если вы вручную запустите «claude» в терминале, dev3 не сможет подставить аккаунт, и будет использован ваш логин по умолчанию.",