Files
hermes-agent/apps/desktop/src/app/settings/credential-key-ui.tsx
Austin Pickett dfd6bcf1ff fix(desktop): restore accordion expand for credential settings rows (#39327)
* fix(desktop): restore accordion expand for credential settings rows

Reintroduce collapsible provider and tool key rows so descriptions, docs
links, and advanced fields stay hidden until a row is expanded.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(desktop): add credential settings accordion screenshots for PR 39327

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 19:10:44 -04:00

364 lines
11 KiB
TypeScript

import { type ChangeEvent, type KeyboardEvent } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ChevronDown, ExternalLink, Loader2, Save } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { prettyName, withoutKey } from './helpers'
import { ListRow } from './primitives'
import type { EnvRowProps } from './types'
export type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
/** Matches Advanced / config field controls (ListRow + Input). */
export const CREDENTIAL_CONTROL_CLASS = cn('h-8', CONTROL_TEXT)
export const isKeyVar = (key: string, info: EnvVarInfo) =>
info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
export const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
info.description?.trim() ||
key
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, c => c.toUpperCase())
export const credentialPlaceholder = (key: string, info: EnvVarInfo, label: string): string =>
isKeyVar(key, info) ? `Paste ${label} key` : /URL$/i.test(key) ? 'https://…' : 'Optional'
// A single credential field: a set key shows as a filled read-only input
// (redacted value) that edits in place on click. Save appears once typed; a set
// key also offers Remove, and Esc cancels without closing the overlay.
export function KeyField({
info,
placeholder,
rowProps,
varKey
}: {
info: EnvVarInfo
placeholder?: string
rowProps: KeyRowProps
varKey: string
}) {
const { edits, onClear, onSave, saving, setEdits } = rowProps
const editing = edits[varKey] !== undefined
const draft = edits[varKey] ?? ''
const dirty = draft.trim().length > 0
const busy = saving === varKey
const masked = info.redacted_value ?? '••••••••'
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
const cancel = () => setEdits(c => withoutKey(c, varKey))
const update = (e: ChangeEvent<HTMLInputElement>) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
const keydown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && dirty) {
void onSave(varKey)
} else if (e.key === 'Escape' && editing) {
e.preventDefault()
e.stopPropagation()
cancel()
}
}
const editType = info.is_password ? 'password' : 'text'
if (info.is_set && !editing) {
return (
<Input
className={cn(CREDENTIAL_CONTROL_CLASS, 'cursor-pointer text-muted-foreground')}
onFocus={startEdit}
readOnly
value={masked}
/>
)
}
return (
<div className="grid gap-1">
<div className="flex items-center gap-2">
<Input
autoFocus={editing}
className={cn(CREDENTIAL_CONTROL_CLASS, 'min-w-0 flex-1')}
onChange={update}
onKeyDown={keydown}
placeholder={placeholder ?? 'Paste key'}
type={editType}
value={draft}
/>
{dirty && (
<Button className="h-8 shrink-0" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
{busy ? 'Saving' : 'Save'}
</Button>
)}
</div>
{editing && (
<div className="flex items-center gap-1 text-[0.6875rem]">
{info.is_set && (
<>
<Button
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
type="button"
variant="text"
>
Remove
</Button>
<span className="text-muted-foreground">or</span>
</>
)}
<span className="text-muted-foreground">esc to cancel</span>
</div>
)}
</div>
)
}
function CredentialDocsLink({ href }: { href: string }) {
return (
<a
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
href={href}
onClick={e => e.stopPropagation()}
rel="noreferrer"
target="_blank"
>
Get a key
<ExternalLink className="size-3" />
</a>
)
}
/** One credential row — collapsible; description and docs link expand on click. */
export function CredentialKeyCard({
expanded,
info,
label,
onExpand,
onToggle,
placeholder,
rowProps,
varKey
}: CredentialKeyCardProps) {
const docsUrl = info.url?.trim()
const description = info.description?.trim()
const expandable = Boolean(description || docsUrl)
return (
<div
className={cn(
'group/card rounded-[6px] px-2 py-1 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
onKeyDown={
expandable
? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
'size-2 shrink-0 rounded-full',
info.is_set ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
)}
/>
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{label}
</span>
{expandable && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition',
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
)}
/>
)}
</div>
<div
className="min-w-0 sm:justify-self-end"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
onExpand()
}
}}
>
<KeyField info={info} placeholder={placeholder} rowProps={rowProps} varKey={varKey} />
</div>
</div>
{expandable && expanded && (
<div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}>
{description && (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{docsUrl && <CredentialDocsLink href={docsUrl} />}
</div>
)}
</div>
)
}
/** Provider API key group — collapsible card; description, docs link, and advanced fields expand on click. */
export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) {
const docsUrl = group.docsUrl?.trim()
const description = group.description?.trim()
const expandable = Boolean(description || docsUrl || group.advanced.length > 0)
return (
<div
className={cn(
'group/card rounded-[6px] px-2 py-1 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
onKeyDown={
expandable
? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
'size-2 shrink-0 rounded-full',
group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
)}
/>
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{group.name}
</span>
{expandable && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition',
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
)}
/>
)}
</div>
<div
className="min-w-0 sm:justify-self-end"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
onExpand()
}
}}
>
<KeyField
info={group.primary[1]}
placeholder={`Paste ${group.name} key`}
rowProps={rowProps}
varKey={group.primary[0]}
/>
</div>
</div>
{expandable && expanded && (
<div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}>
{description && (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{group.advanced.map(([key, info]) => {
const fieldLabel = isKeyVar(key, info)
? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
: friendlyFieldLabel(key, info)
return (
<ListRow
action={
<KeyField
info={info}
placeholder={credentialPlaceholder(key, info, fieldLabel)}
rowProps={rowProps}
varKey={key}
/>
}
key={key}
title={fieldLabel}
/>
)
})}
{docsUrl && <CredentialDocsLink href={docsUrl} />}
</div>
)}
</div>
)
}
export function credentialRowLabel(varKey: string, info: EnvVarInfo): string {
if (isKeyVar(varKey, info)) {
return prettyName(varKey.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
}
return prettyName(varKey)
}
interface CredentialKeyCardProps {
expanded: boolean
info: EnvVarInfo
label: string
onExpand: () => void
onToggle: () => void
placeholder: string
rowProps: KeyRowProps
varKey: string
}
interface ProviderKeyRowsProps {
expanded: boolean
group: ProviderKeyRowGroup
onExpand: () => void
onToggle: () => void
rowProps: KeyRowProps
}
export interface ProviderKeyRowGroup {
advanced: [string, EnvVarInfo][]
description?: string
docsUrl?: string
hasAnySet: boolean
name: string
primary: [string, EnvVarInfo]
}