* 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>
364 lines
11 KiB
TypeScript
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]
|
|
}
|