refactor(desktop): formalize row-as-button primitive (RowButton)

Finding 2 of the desktop UI-consistency pass. Several surfaces intentionally
make an entire row/cell the click target while hosting nested layout inside a
raw <button> (each re-justifying the pattern in a local comment). Introduce a
zero-style RowButton primitive (components/ui/row-button.tsx) that bakes in the
shared semantics — type="button" + a stable data-slot — without imposing any
styling, then migrate every genuine row-button onto it:

- app/overlays/panel.tsx
- app/artifacts/index.tsx
- app/chat/sidebar/chrome.tsx (SidebarRowBody, SidebarRowLink)
- app/settings/providers-settings.tsx
- components/desktop-onboarding-overlay.tsx (PROVIDER_ROW_CLASS rows)

Fully behavior-preserving: RowButton adds no classes, so each row keeps its
exact layout/look (verified by a unit test asserting className passthrough).

Left as-is (not row-buttons; converting would risk visual regressions): the
compact bespoke buttons in shell/statusbar-controls.tsx (STATUSBAR_ACTION_CLASS,
also a nested DropdownMenuTrigger asChild) and pet-generate/reference-chip.tsx.
This commit is contained in:
Brooklyn Nicholson 2026-06-30 01:56:57 -05:00
parent d6396e6a41
commit c2fb651c5e
7 changed files with 77 additions and 15 deletions

View file

@ -16,6 +16,7 @@ import {
PaginationNext,
PaginationPrevious
} from '@/components/ui/pagination'
import { RowButton } from '@/components/ui/row-button'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
@ -761,13 +762,12 @@ function ArtifactCellAction({
}
return (
<button
<RowButton
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline"
onClick={onClick}
type="button"
>
{children}
</button>
</RowButton>
)
}

View file

@ -1,6 +1,7 @@
import type * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { RowButton } from '@/components/ui/row-button'
import { cn } from '@/lib/utils'
// Shared, content-agnostic sidebar chrome — used by both the flat session
@ -64,7 +65,7 @@ export function SidebarRowCluster({ className, ...props }: React.ComponentProps<
/** Session row main tap target. */
export function SidebarRowBody({ className, ...props }: React.ComponentProps<'button'>) {
return <button className={cn(rowInset, 'bg-transparent text-left', className)} type="button" {...props} />
return <RowButton className={cn(rowInset, 'bg-transparent text-left', className)} {...props} />
}
/** Tappable label — underline/truncate live on the inner span, not the button. */
@ -75,9 +76,9 @@ export function SidebarRowLink({
...props
}: React.ComponentProps<'button'> & { labelClassName?: string }) {
return (
<button className={cn('min-w-0 shrink bg-transparent p-0 text-left', className)} type="button" {...props}>
<RowButton className={cn('min-w-0 shrink bg-transparent p-0 text-left', className)} {...props}>
<span className={cn(rowLabel, labelClassName)}>{children}</span>
</button>
</RowButton>
)
}

View file

@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { RowButton } from '@/components/ui/row-button'
import { SearchField } from '@/components/ui/search-field'
import { translateNow } from '@/i18n'
import { cn } from '@/lib/utils'
@ -162,10 +163,9 @@ export function PanelListRow({
)}
data-panel-row={rowKey}
>
<button
<RowButton
className="flex h-full min-w-0 flex-1 items-center gap-2 rounded-md pl-2 pr-1 text-left"
onClick={onSelect}
type="button"
>
{lead ??
(dotClassName ? (
@ -174,7 +174,7 @@ export function PanelListRow({
<Codicon className="shrink-0 text-muted-foreground/55" name={icon} size="0.85rem" />
) : null)}
<span className="min-w-0 flex-1 truncate font-medium text-foreground/85">{title}</span>
</button>
</RowButton>
{meta ? <span className="shrink-0 pr-2 text-[0.62rem] tabular-nums text-muted-foreground/45">{meta}</span> : null}
{menu ? <div className="shrink-0 pr-1">{menu}</div> : null}
</div>

View file

@ -12,6 +12,7 @@ import {
sortProviders
} from '@/components/desktop-onboarding-overlay'
import { Button } from '@/components/ui/button'
import { RowButton } from '@/components/ui/row-button'
import { SearchField } from '@/components/ui/search-field'
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
import { useI18n } from '@/i18n'
@ -237,7 +238,7 @@ function ConnectedProviderRow({
return (
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
<button className="min-w-0 px-3 py-2.5 text-left" onClick={() => onSelect(provider)} type="button">
<RowButton className="min-w-0 px-3 py-2.5 text-left" onClick={() => onSelect(provider)}>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
@ -251,7 +252,7 @@ function ConnectedProviderRow({
{provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}
</p>
)}
</button>
</RowButton>
<div className="flex items-center gap-1 pr-2">
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
{canDisconnect && (

View file

@ -8,6 +8,7 @@ import { Codicon } from '@/components/ui/codicon'
import { ErrorIcon } from '@/components/ui/error-state'
import { Input } from '@/components/ui/input'
import { Loader } from '@/components/ui/loader'
import { RowButton } from '@/components/ui/row-button'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, KeyRound, Loader2, Terminal } from '@/lib/icons'
@ -575,13 +576,13 @@ export function KeyProviderRow({ onClick }: { onClick: () => void }) {
const { t } = useI18n()
return (
<button className={PROVIDER_ROW_CLASS} onClick={onClick} type="button">
<RowButton className={PROVIDER_ROW_CLASS} onClick={onClick}>
<div className="min-w-0">
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.openRouterPitch}</p>
</div>
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
</button>
</RowButton>
)
}
@ -597,7 +598,7 @@ export function ProviderRow({
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
return (
<button className={PROVIDER_ROW_CLASS} onClick={() => onSelect(provider)} type="button">
<RowButton className={PROVIDER_ROW_CLASS} onClick={() => onSelect(provider)}>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
@ -608,7 +609,7 @@ export function ProviderRow({
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
</div>
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
</button>
</RowButton>
)
}

View file

@ -0,0 +1,39 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RowButton } from './row-button'
afterEach(cleanup)
describe('RowButton', () => {
it('renders a real <button> with type=button and the row-button slot', () => {
const { getByText } = render(<RowButton>Row</RowButton>)
const el = getByText('Row')
expect(el.tagName).toBe('BUTTON')
expect(el.getAttribute('type')).toBe('button')
expect(el.getAttribute('data-slot')).toBe('row-button')
})
it('imposes no styling of its own — only the caller class is applied', () => {
const onClick = vi.fn()
const { getByText } = render(
<RowButton className="custom-row" onClick={onClick}>
Hit
</RowButton>
)
const el = getByText('Hit')
expect(el.className).toBe('custom-row')
el.click()
expect(onClick).toHaveBeenCalledTimes(1)
})
it('allows the native button type to be overridden', () => {
const { getByText } = render(<RowButton type="submit">Go</RowButton>)
expect(getByText('Go').getAttribute('type')).toBe('submit')
})
})

View file

@ -0,0 +1,20 @@
import * as React from 'react'
/**
* A full-row / full-region click target rendered as a real `<button>`.
*
* Several surfaces intentionally make an entire row (or cell) the click target
* while hosting nested layout and controls inside it sidebar rows, overlay /
* panel list rows, settings + onboarding provider rows, the artifacts cell.
* This primitive bakes in the shared semantics (`type="button"` plus a stable
* `data-slot`) WITHOUT imposing any visual styling, so each row keeps its own
* layout classes and nothing changes visually.
*
* Use `RowButton` for these row/region targets; reach for `Button`
* (`components/ui/button.tsx`) for ordinary compact actions.
*/
function RowButton({ className, type = 'button', ...props }: React.ComponentProps<'button'>) {
return <button className={className} data-slot="row-button" type={type} {...props} />
}
export { RowButton }