Redesign shortcuts settings UX (#4554)

- Remove the Groups nav rail; filter shortcuts by search + status only
- Fold keybindings.json path into the subsection subtitle and move the
  Edit File in Orca action into the header
- Replace the floating hover-card action popover with an inline recorder:
  keycaps (or an Add shortcut placeholder) click to record in place, and
  Reset/Disable reveal on row hover/focus
- Fix a grid align-content stretch bug that opened a large gap between
  shortcut groups in the scroll area

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-06-02 23:54:10 -07:00 committed by GitHub
parent 0f2b918773
commit 7834d3d297
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 222 additions and 303 deletions

View File

@ -126,84 +126,54 @@ export function KeybindingsFileActions(): React.JSX.Element {
return (
<div
ref={setActionsRootNode}
className="space-y-2 rounded-md border border-border bg-card px-3 py-2 text-card-foreground shadow-xs"
className="inline-flex shrink-0 overflow-hidden rounded-md border border-border bg-background shadow-xs"
>
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<p className="shrink-0 text-xs font-medium">Keybindings JSON</p>
</div>
<p className="truncate font-mono text-[11px] leading-4 text-muted-foreground">
{keybindingSnapshot?.path ?? '~/.orca/keybindings.json'}
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-1.5 sm:justify-end">
<div className="inline-flex overflow-hidden rounded-md border border-border bg-background shadow-xs">
<Button
type="button"
variant="ghost"
size="xs"
className="rounded-none border-0 shadow-none"
onClick={() => void editKeybindingsInOrca()}
>
<FileText className="size-3" />
Edit File in Orca
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="rounded-none border-l border-border"
aria-label="Open keybindings file menu"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onSelect={() => void openKeybindingsFile()}>
<ExternalLink className="size-3.5" />
Open with Default App
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void openKeybindingsInExternalEditor('code')}>
<Code2 className="size-3.5" />
Open in VS Code
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void openKeybindingsInExternalEditor('cursor')}>
<Code2 className="size-3.5" />
Open in Cursor
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void revealKeybindingsFile()}>
<FolderOpen className="size-3.5" />
Reveal in File Manager
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void reloadKeybindings()}>
<RefreshCw className="size-3.5" />
Reload from Disk
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{keybindingSnapshot?.diagnostics.length ? (
<div className="space-y-1 border-t border-border/50 pt-2">
{keybindingSnapshot.diagnostics.map((diagnostic, index) => (
<p
key={`${diagnostic.section ?? 'root'}-${diagnostic.actionId ?? index}`}
className={
diagnostic.severity === 'error'
? 'text-xs text-destructive'
: 'text-xs text-muted-foreground'
}
>
{diagnostic.message}
</p>
))}
</div>
) : null}
<Button
type="button"
variant="ghost"
size="xs"
className="rounded-none border-0 shadow-none"
onClick={() => void editKeybindingsInOrca()}
>
<FileText className="size-3" />
Edit File in Orca
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="rounded-none border-l border-border"
aria-label="Open keybindings file menu"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => void openKeybindingsFile()}>
<ExternalLink className="size-3.5" />
Open with Default App
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void openKeybindingsInExternalEditor('code')}>
<Code2 className="size-3.5" />
Open in VS Code
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void openKeybindingsInExternalEditor('cursor')}>
<Code2 className="size-3.5" />
Open in Cursor
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void revealKeybindingsFile()}>
<FolderOpen className="size-3.5" />
Reveal in File Manager
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void reloadKeybindings()}>
<RefreshCw className="size-3.5" />
Reload from Disk
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react'
import { Ban, Keyboard, RotateCcw, Terminal } from 'lucide-react'
import { Ban, Plus, RotateCcw, Terminal } from 'lucide-react'
import {
formatKeybinding,
type KeybindingActionId,
@ -10,7 +10,6 @@ import { cn } from '../../lib/utils'
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { SearchableSetting } from './SearchableSetting'
@ -37,29 +36,6 @@ export type ShortcutTerminalStatus = {
description: string
}
function BindingPreview({
bindings,
platform
}: {
bindings: readonly string[]
platform: NodeJS.Platform
}): React.JSX.Element {
if (bindings.length === 0) {
return (
<span className="flex min-h-7 items-center">
<span className="text-xs text-muted-foreground">Unassigned</span>
</span>
)
}
return (
<span className="flex min-h-7 flex-wrap items-center justify-start gap-1.5">
{bindings.map((binding) => (
<ShortcutKeyCombo key={binding} keys={formatKeybinding(binding, platform)} />
))}
</span>
)
}
export function ShortcutBindingRow({
item,
groupTitle,
@ -90,6 +66,7 @@ export function ShortcutBindingRow({
const statusMessage = error ?? (warnings.length > 0 ? warnings.join(' ') : '')
const recordingMessage = recording ? 'Listening for shortcut. Esc cancels recording.' : ''
const helperMessage = statusMessage || recordingMessage
const hasBinding = effective.length > 0
const handleRecordKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>): void => {
if (!recording) {
@ -120,108 +97,91 @@ export function ShortcutBindingRow({
})
}
// Why: the recorder is the row's primary control — clicking the keys (or the
// "Add shortcut" placeholder) records a new binding in place, so the whole
// affordance lives inline rather than in a detached popover.
const recorderLabel = recording
? `Press shortcut keys for ${item.title}. Escape cancels.`
: hasBinding
? `Change shortcut for ${item.title}`
: `Add shortcut for ${item.title}`
return (
<SearchableSetting
title={item.title}
description={`${groupTitle} shortcut`}
keywords={[...item.searchKeywords]}
className="group relative grid min-h-[54px] max-w-none grid-cols-1 gap-x-3 rounded-md px-2 py-1.5 transition-colors hover:bg-accent/40 lg:grid-cols-[minmax(0,1fr)_minmax(12rem,auto)] lg:grid-rows-[minmax(1.75rem,auto)_1rem] lg:items-start"
className="group/shortcut relative flex min-h-[44px] max-w-none items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-accent/40 focus-within:bg-accent/40"
>
<div className="flex min-w-0 items-center gap-2 lg:col-start-1 lg:row-start-1 lg:self-center">
<span className="truncate text-sm text-foreground">{item.title}</span>
{modified ? (
<Badge variant="outline" className="shrink-0 text-[11px]">
Modified
</Badge>
) : null}
{terminalStatus ? (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="shrink-0 gap-1 border-border/70 text-[11px] text-muted-foreground"
>
<Terminal className="size-3" />
{terminalStatus.label}
</Badge>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{terminalStatus.description}
</TooltipContent>
</Tooltip>
) : null}
</div>
<div
className={cn(
'h-[16px] overflow-hidden text-[11px] leading-4 lg:col-start-1 lg:row-start-2',
error ? 'text-destructive' : 'text-muted-foreground'
)}
aria-live="polite"
>
{helperMessage ? <span className="block truncate">{helperMessage}</span> : null}
</div>
<HoverCard openDelay={0} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
aria-label={`Shortcut actions for ${item.title}`}
className="mt-1 flex min-w-0 items-center rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 lg:col-start-2 lg:row-start-1 lg:mt-0 lg:self-center lg:justify-self-end"
>
<BindingPreview bindings={effective} platform={platform} />
</button>
</HoverCardTrigger>
<HoverCardContent
side="right"
align="center"
sideOffset={8}
collisionPadding={12}
className="w-auto max-w-[min(22rem,calc(100vw-2rem))] p-1"
>
<div className="flex items-center gap-1">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm text-foreground">{item.title}</span>
{modified ? (
<Badge variant="outline" className="shrink-0 text-[11px]">
Modified
</Badge>
) : null}
{terminalStatus ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={recordButtonRef}
type="button"
variant={recording ? 'secondary' : 'ghost'}
size="icon-sm"
aria-label={
recording
? `Press shortcut keys for ${item.title}. Escape cancels.`
: `Change shortcut for ${item.title}`
}
aria-invalid={Boolean(error)}
aria-pressed={recording}
data-shortcut-recorder=""
data-shortcut-recorder-active={recording ? '' : undefined}
onClick={() => {
if (recording) {
return
}
onStartRecording(item.id)
}}
onKeyDown={handleRecordKeyDown}
className={cn(
'text-muted-foreground hover:text-foreground',
recording &&
'border border-ring bg-accent text-accent-foreground ring-[3px] ring-ring/30'
)}
<Badge
variant="outline"
className="shrink-0 gap-1 border-border/70 text-[11px] text-muted-foreground"
>
<Keyboard className="size-3.5" />
</Button>
<Terminal className="size-3" />
{terminalStatus.label}
</Badge>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{recording ? 'Listening for shortcut' : 'Change shortcut'}
{terminalStatus.description}
</TooltipContent>
</Tooltip>
) : null}
</div>
{helperMessage ? (
<span
className={cn(
'block truncate text-[11px] leading-4',
error ? 'text-destructive' : 'text-muted-foreground'
)}
aria-live="polite"
>
{helperMessage}
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{/* Reset/Disable reveal on hover or keyboard focus to keep the row calm;
they stay reachable via focus-within for keyboard users. */}
{hasBinding ? (
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover/shortcut:opacity-100 group-focus-within/shortcut:opacity-100">
{modified ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
aria-label={`Reset ${item.title} to default`}
onClick={() => onReset(item.id)}
>
<RotateCcw className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Reset to default
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-destructive"
aria-label={`Disable ${item.title}`}
onClick={() => onDisable(item.id)}
>
@ -229,28 +189,58 @@ export function ShortcutBindingRow({
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Disable
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Reset ${item.title}`}
onClick={() => onReset(item.id)}
>
<RotateCcw className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Reset
Disable shortcut
</TooltipContent>
</Tooltip>
</div>
</HoverCardContent>
</HoverCard>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<button
ref={recordButtonRef}
type="button"
aria-label={recorderLabel}
aria-invalid={Boolean(error)}
aria-pressed={recording}
data-shortcut-recorder=""
data-shortcut-recorder-active={recording ? '' : undefined}
onClick={() => {
if (!recording) {
onStartRecording(item.id)
}
}}
onKeyDown={handleRecordKeyDown}
className={cn(
'flex min-h-7 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-md border px-2 py-1 text-xs outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
recording
? 'border-ring bg-accent text-accent-foreground ring-[3px] ring-ring/30'
: hasBinding
? 'border-transparent hover:border-border/70 hover:bg-background'
: 'border-dashed border-border/70 text-muted-foreground hover:border-border hover:text-foreground'
)}
>
{recording ? (
<span className="px-1 text-muted-foreground">Press keys</span>
) : hasBinding ? (
<span className="flex flex-wrap items-center justify-end gap-1.5">
{effective.map((binding) => (
<ShortcutKeyCombo key={binding} keys={formatKeybinding(binding, platform)} />
))}
</span>
) : (
<span className="flex items-center gap-1">
<Plus className="size-3" />
Add shortcut
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{recording ? 'Listening for shortcut' : hasBinding ? 'Change shortcut' : 'Add shortcut'}
</TooltipContent>
</Tooltip>
</div>
</SearchableSetting>
)
}

View File

@ -23,12 +23,6 @@ export type ShortcutRowsByGroup = {
rows: ShortcutRowModel[]
}
export type ShortcutGroupSummary = {
id: string
label: string
count: number
}
const SHORTCUT_FILTER_LABELS: Record<ShortcutFilter, string> = {
all: 'All',
modified: 'Modified',
@ -80,10 +74,7 @@ export function ShortcutFilterRail({
onQueryChange,
filter,
onFilterChange,
activeGroup,
onActiveGroupChange,
filterCounts,
groupSummaries,
visibleCount,
totalCount
}: {
@ -91,10 +82,7 @@ export function ShortcutFilterRail({
onQueryChange: (value: string) => void
filter: ShortcutFilter
onFilterChange: (value: ShortcutFilter) => void
activeGroup: string
onActiveGroupChange: (value: string) => void
filterCounts: Record<ShortcutFilter, number>
groupSummaries: ShortcutGroupSummary[]
visibleCount: number
totalCount: number
}): React.JSX.Element {
@ -162,34 +150,6 @@ export function ShortcutFilterRail({
))}
</div>
</nav>
<nav
aria-label="Shortcut groups"
className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1 scrollbar-sleek"
>
<p className="text-[11px] font-semibold tracking-[0.05em] text-muted-foreground uppercase">
Groups
</p>
<div className="grid gap-1">
{groupSummaries.map((group) => (
<button
key={group.id}
type="button"
onClick={() => onActiveGroupChange(group.id)}
className={cn(
'flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
activeGroup === group.id
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
group.count === 0 && activeGroup !== group.id ? 'opacity-55' : ''
)}
>
<span className="truncate">{group.label}</span>
<span className="text-[11px] tabular-nums opacity-80">{group.count}</span>
</button>
))}
</div>
</nav>
</aside>
)
}

View File

@ -43,7 +43,7 @@ export function ShortcutRowsList({
}
return (
<div className={cn('grid gap-8', className)}>
<div className={cn('flex flex-col gap-8', className)}>
{groups.map((group) => (
<div key={group.title} className="space-y-3">
<h3 className="border-b border-border/50 pb-2 text-sm font-medium text-muted-foreground">

View File

@ -26,7 +26,6 @@ import {
matchesShortcutLocalSearch,
ShortcutFilterRail,
type ShortcutFilter,
type ShortcutGroupSummary,
type ShortcutRowsByGroup
} from './ShortcutFilterRail'
import { ShortcutRowsList } from './ShortcutRowsList'
@ -137,7 +136,6 @@ export function ShortcutsPane(): React.JSX.Element {
const [recordingActionId, setRecordingActionId] = useState<KeybindingActionId | null>(null)
const [shortcutQuery, setShortcutQuery] = useState('')
const [shortcutFilter, setShortcutFilter] = useState<ShortcutFilter>('all')
const [activeShortcutGroup, setActiveShortcutGroup] = useState<string>('all')
const groups = useMemo(groupDefinitions, [])
const conflictByAction = useMemo(() => {
@ -192,29 +190,11 @@ export function ShortcutsPane(): React.JSX.Element {
unassigned: baseVisibleRows.filter((row) => row.effective.length === 0).length,
conflicts: baseVisibleRows.filter((row) => row.warnings.length > 0).length
}
const groupSummaries: ShortcutGroupSummary[] = [
{
id: 'all',
label: 'All shortcuts',
count: baseVisibleRows.filter((row) => matchesShortcutFilter(row, shortcutFilter)).length
},
...shortcutGroups.map((group) => ({
id: group.title,
label: group.title,
count: group.rows.filter(
(row) =>
matchesSettingsSearch(searchQuery, getShortcutSearchEntry(row)) &&
matchesShortcutLocalSearch(row, shortcutSearchQuery, platform) &&
matchesShortcutFilter(row, shortcutFilter)
).length
}))
]
const visibleShortcutGroups = shortcutGroups
.map((group) => ({
title: group.title,
rows: group.rows.filter(
(row) =>
(activeShortcutGroup === 'all' || row.groupTitle === activeShortcutGroup) &&
matchesSettingsSearch(searchQuery, getShortcutSearchEntry(row)) &&
matchesShortcutLocalSearch(row, shortcutSearchQuery, platform) &&
matchesShortcutFilter(row, shortcutFilter)
@ -342,59 +322,78 @@ export function ShortcutsPane(): React.JSX.Element {
return (
<div className="flex h-full min-h-0 flex-col gap-6 overflow-hidden">
<section className="flex min-h-0 flex-1 flex-col space-y-3">
{showPolicy ? (
<ShortcutTerminalPolicyControl
terminalShortcutPolicy={terminalShortcutPolicy}
keywords={TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY.keywords}
updateSettings={updateSettings}
/>
) : null}
<SettingsSubsectionHeader
title="Keyboard Shortcuts"
description="Customize shortcuts visually or edit the file directly."
description={
<>
Customize shortcuts visually or edit{' '}
<span className="font-mono text-[11px]">
{keybindingSnapshot?.path ?? '~/.orca/keybindings.json'}
</span>{' '}
directly.
</>
}
action={<KeybindingsFileActions />}
/>
{keybindingSnapshot?.diagnostics.length ? (
<div className="space-y-1">
{keybindingSnapshot.diagnostics.map((diagnostic, index) => (
<p
key={`${diagnostic.section ?? 'root'}-${diagnostic.actionId ?? index}`}
className={
diagnostic.severity === 'error'
? 'text-xs text-destructive'
: 'text-xs text-muted-foreground'
}
>
{diagnostic.message}
</p>
))}
</div>
) : null}
<div className="grid min-h-0 flex-1 gap-6 xl:grid-cols-[16rem_minmax(0,1fr)]">
<ShortcutFilterRail
query={shortcutQuery}
onQueryChange={setShortcutQuery}
filter={shortcutFilter}
onFilterChange={setShortcutFilter}
activeGroup={activeShortcutGroup}
onActiveGroupChange={setActiveShortcutGroup}
filterCounts={filterCounts}
groupSummaries={groupSummaries}
visibleCount={visibleShortcutCount}
totalCount={shortcutRows.length}
/>
<div className="flex min-h-0 min-w-0 flex-col gap-5">
{showPolicy ? (
<ShortcutTerminalPolicyControl
terminalShortcutPolicy={terminalShortcutPolicy}
keywords={TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY.keywords}
updateSettings={updateSettings}
/>
) : null}
<KeybindingsFileActions />
<ShortcutRowsList
className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-sleek"
groups={visibleShortcutGroups}
platform={platform}
errors={errors}
recordingActionId={recordingActionId}
onStartRecording={(actionId) => {
setRecordingActionId(actionId)
clearError(actionId)
}}
onCancelRecording={() => setRecordingActionId(null)}
onCapture={(actionId, input) => void captureBinding(actionId, input)}
onClearError={clearError}
onDisable={(actionId) => {
clearRecordingForAction(actionId)
void disableBinding(actionId)
}}
onReset={(actionId) => {
clearRecordingForAction(actionId)
void resetBinding(actionId)
}}
/>
</div>
<ShortcutRowsList
className="min-h-0 min-w-0 flex-1 overflow-y-auto pr-1 scrollbar-sleek"
groups={visibleShortcutGroups}
platform={platform}
errors={errors}
recordingActionId={recordingActionId}
onStartRecording={(actionId) => {
setRecordingActionId(actionId)
clearError(actionId)
}}
onCancelRecording={() => setRecordingActionId(null)}
onCapture={(actionId, input) => void captureBinding(actionId, input)}
onClearError={clearError}
onDisable={(actionId) => {
clearRecordingForAction(actionId)
void disableBinding(actionId)
}}
onReset={(actionId) => {
clearRecordingForAction(actionId)
void resetBinding(actionId)
}}
/>
</div>
</section>
</div>