feat(settings): Agent accounts takes parts and links them from a jump row

renderAccountsSettingsSection now takes additionalParts ({ id, title, description, content }) and
titles each with AgentAccountsSubsection. When the page has more than one part, a jump row under the
description links to each one ('This computer · …'); a link scrolls the settings pane to the part and
moves focus to its heading.
This commit is contained in:
Neil
2026-11-10 17:43:31 -08:00
parent 62aedf90f0
commit 6f2ee281ce
5 changed files with 150 additions and 16 deletions
@@ -0,0 +1,44 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AgentAccountsJumpRow } from './AgentAccountsJumpRow'
import { AgentAccountsSubsection } from './AgentAccountsSubsection'
const originalScrollIntoView = Element.prototype.scrollIntoView
afterEach(() => {
cleanup()
Element.prototype.scrollIntoView = originalScrollIntoView
})
describe('AgentAccountsJumpRow', () => {
it('stays hidden when the page has only one part', () => {
render(<AgentAccountsJumpRow links={[{ id: 'accounts-this-host', title: 'This computer' }]} />)
expect(screen.queryByRole('navigation')).toBeNull()
})
it('links every part in order and moves focus to the chosen heading', () => {
const scrollIntoView = vi.fn()
Element.prototype.scrollIntoView = scrollIntoView
render(
<>
<AgentAccountsJumpRow
links={[
{ id: 'accounts-this-host', title: 'This computer' },
{ id: 'accounts-hosts', title: 'Hosts' }
]}
/>
<AgentAccountsSubsection id="accounts-this-host" title="This computer">
<p>local</p>
</AgentAccountsSubsection>
<AgentAccountsSubsection id="accounts-hosts" title="Hosts">
<p>hosts</p>
</AgentAccountsSubsection>
</>
)
const links = screen.getAllByRole('link')
expect(links.map((link) => link.textContent)).toEqual(['This computer', 'Hosts'])
fireEvent.click(screen.getByRole('link', { name: 'Hosts' }))
expect(scrollIntoView).toHaveBeenCalled()
expect(document.activeElement).toBe(screen.getByRole('heading', { name: 'Hosts' }))
})
})
@@ -0,0 +1,51 @@
import type React from 'react'
import { useTranslate } from '@/i18n/use-translate'
import { scrollSubsectionIntoView } from '../navigation/settings-navigation-foundations'
import { useSettingsContentScrollElement } from '../page/settings-content-scroll-element'
import { agentAccountsSubsectionTitleId } from './AgentAccountsSubsection'
export type AgentAccountsJumpLink = { id: string; title: React.ReactNode }
/** "This computer · Hosts · …" under the Agent accounts description, so later parts are one click
* away from the long provider list. Each link scrolls to its part and moves focus to the heading. */
export function AgentAccountsJumpRow({
links
}: {
links: readonly AgentAccountsJumpLink[]
}): React.JSX.Element | null {
const t = useTranslate()
const scroller = useSettingsContentScrollElement()
if (links.length < 2) {
return null
}
const jump = (id: string): void => {
scrollSubsectionIntoView(id, scroller)
document.getElementById(agentAccountsSubsectionTitleId(id))?.focus({ preventScroll: true })
}
return (
<nav
aria-label={t('settings.agentAccounts.jumpLabel', 'Parts of this page')}
className="-mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm"
>
{links.map((link, index) => (
<span key={link.id} className="flex items-center gap-2">
{index > 0 ? (
<span aria-hidden="true" className="text-muted-foreground">
·
</span>
) : null}
<a
href={`#${link.id}`}
className="rounded-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(event) => {
event.preventDefault()
jump(link.id)
}}
>
{link.title}
</a>
</span>
))}
</nav>
)
}
@@ -1,5 +1,18 @@
import type React from 'react'
/** One part of Settings → Agent accounts beyond "This computer"; builds pass these to
* renderAccountsSettingsSection, which titles each one and lists it in the jump row. */
export type AgentAccountsPart = {
id: string
title: string
description?: React.ReactNode
content: React.ReactNode
}
export function agentAccountsSubsectionTitleId(id: string): string {
return `${id}-title`
}
type AgentAccountsSubsectionProps = {
id: string
title: React.ReactNode
@@ -7,19 +20,23 @@ type AgentAccountsSubsectionProps = {
children: React.ReactNode
}
/** One part of Settings → Agent accounts (this computer, other hosts, …). Builds can add their own
* parts through renderAccountsSettingsSection's `additionalSections`. */
/** One part of Settings → Agent accounts (this computer, other hosts, …). */
export function AgentAccountsSubsection({
id,
title,
description,
children
}: AgentAccountsSubsectionProps): React.JSX.Element {
const titleId = `${id}-title`
const titleId = agentAccountsSubsectionTitleId(id)
return (
<section id={id} aria-labelledby={titleId} className="scroll-mt-8 space-y-5">
<div className="space-y-1 border-b border-border/60 pb-3">
<h3 id={titleId} className="text-lg font-semibold leading-tight text-foreground">
{/* tabIndex -1: the jump row moves focus here without adding a tab stop. */}
<h3
id={titleId}
tabIndex={-1}
className="text-lg font-semibold leading-tight text-foreground focus:outline-none"
>
{title}
</h3>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
@@ -1,5 +1,9 @@
import { AccountsPane } from '../accounts/AccountsPane'
import { AgentAccountsSubsection } from '../accounts/AgentAccountsSubsection'
import { AgentAccountsJumpRow } from '../accounts/AgentAccountsJumpRow'
import {
AgentAccountsSubsection,
type AgentAccountsPart
} from '../accounts/AgentAccountsSubsection'
import { getProviderAccountsOwnerKey } from '@/runtime/provider-accounts/observations/runtime-provider-accounts-client'
import { AgentsPane } from '../agents/AgentsPane'
import { AgentDefaultSurfaceSection } from '../agents/surfaces/AgentDefaultSurfaceSection'
@@ -15,6 +19,9 @@ import { translate } from '@/i18n/i18n'
import type { Translate } from '@/i18n/use-translate'
import type { SettingsRenderContext } from './settings-render-context'
/** The "This computer" / "Selected host" part of Settings → Agent accounts. */
export const AGENT_ACCOUNTS_HOST_PART_ID = 'accounts-this-host'
export function renderAgentsSettingsSection(
context: SettingsRenderContext,
t: Translate = translate
@@ -53,16 +60,19 @@ export function renderAgentsSettingsSection(
/**
* Settings → Agent accounts: one page for agent sign-ins. "This computer" (or the host picked in the
* scope picker) holds provider accounts, switching and usage; `additionalSections` lets a build add
* more parts below it, each an AgentAccountsSubsection.
* scope picker) holds provider accounts, switching and usage; `additionalParts` lets a build add
* more parts below it. With more than one part, a jump row under the description links to each.
*/
export function renderAccountsSettingsSection(
context: SettingsRenderContext,
t: Translate = translate,
additionalSections?: React.ReactNode
additionalParts: readonly AgentAccountsPart[] = []
): React.JSX.Element {
const { model, navigation, view } = context
const isLocalScope = model.settingsHostScope.target.kind === 'local'
const hostPartTitle = isLocalScope
? t('settings.agentAccounts.thisComputer', 'This computer')
: t('settings.agentAccounts.selectedHost', 'Selected host')
return (
<SettingsSection
id="accounts"
@@ -76,13 +86,15 @@ export function renderAccountsSettingsSection(
>
{view.isSectionMounted('accounts') ? (
<>
<AgentAccountsJumpRow
links={[
{ id: AGENT_ACCOUNTS_HOST_PART_ID, title: hostPartTitle },
...additionalParts.map(({ id, title }) => ({ id, title }))
]}
/>
<AgentAccountsSubsection
id="accounts-this-host"
title={
isLocalScope
? t('settings.agentAccounts.thisComputer', 'This computer')
: t('settings.agentAccounts.selectedHost', 'Selected host')
}
id={AGENT_ACCOUNTS_HOST_PART_ID}
title={hostPartTitle}
description={t(
'auto.components.settings.Settings.21f09426ea',
'Optional. Orca works with your existing provider logins; add accounts only if you want Orca to help switch between them.'
@@ -90,7 +102,16 @@ export function renderAccountsSettingsSection(
>
{renderProviderAccountsPane(context)}
</AgentAccountsSubsection>
{additionalSections}
{additionalParts.map((part) => (
<AgentAccountsSubsection
key={part.id}
id={part.id}
title={part.title}
description={part.description}
>
{part.content}
</AgentAccountsSubsection>
))}
</>
) : null}
</SettingsSection>
+2 -1
View File
@@ -1346,7 +1346,8 @@
"agentAccounts": {
"pageDescription": "Sign-ins, account switching and usage for your agents.",
"thisComputer": "This computer",
"selectedHost": "Selected host"
"selectedHost": "Selected host",
"jumpLabel": "Parts of this page"
}
},
"menu": {