feat(desktop): searchable timezone dropdown in Settings → Chat

The timezone field was a free-text input with no guidance on format.
Users had to know the exact IANA identifier (e.g. America/New_York)
to configure it. Replace it with a searchable combobox built on
Popover + cmdk Command — the same stack as Shadcn's Combobox.

Backend:
- Add `_timezone_options()` to web_server.py (cached at import time,
  returns sorted zoneinfo.available_timezones() — ~598 identifiers)
- Add `"timezone"` to `_SCHEMA_OVERRIDES` with `type: "select"`,
  `options`, and `searchable: true`

Frontend:
- New `SearchableSelect` component (Popover + cmdk Command)
  — closed-world filterable dropdown for large option lists
- `ConfigField` routes to `SearchableSelect` when
  `schema.searchable === true` (explicit opt-in, no threshold)
- Add `searchable?: boolean` to `ConfigFieldSchema` type
- Add i18n keys: `searchPlaceholder`, `noResults`

The `searchable` flag is deterministic — no existing field is
affected unless explicitly opted in. Future large-list fields
can adopt the same pattern by adding `searchable: true` to their
schema override.
This commit is contained in:
David Metcalfe 2026-07-21 15:46:04 -07:00 committed by Teknium
parent e581d92429
commit 5cb0a6aec1
8 changed files with 151 additions and 1 deletions

View file

@ -13,6 +13,7 @@ import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, FRE
import { FallbackModelsField } from './fallback-models-field'
import { fieldCopyForSchemaKey } from './field-copy'
import { ListRow } from './primitives'
import { SearchableSelect } from './searchable-select'
/**
* One generic config row: label + description resolved from the i18n field
@ -93,6 +94,22 @@ export function ConfigField({
const selectOptions = enumOptions ?? (schema.type === 'select' ? (schema.options ?? []).map(String) : undefined)
// Large closed-world lists (e.g. ~590 IANA timezones) get a searchable
// Popover + cmdk combobox instead of a closed Select dropdown. The schema
// opt-in via `searchable: true` keeps this deterministic — no field
// accidentally triggers based on dynamic option count.
if (selectOptions && schema.searchable) {
return row(
<SearchableSelect
emptyMessage={c.noResults}
onChange={next => onChange(next)}
options={selectOptions.filter(o => o !== '')}
placeholder={c.searchPlaceholder}
value={String(value ?? '')}
/>
)
}
// Voice/model name fields are open-world (custom voice IDs, cloned voices,
// brand-new model names) — render a free-input combobox where the known
// options are datalist suggestions instead of a closed Select gate.

View file

@ -564,7 +564,7 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
repoScanRoots: 'Folders to scan. Leave empty to scan your home directory.',
repoScanExcludePaths: 'Folders and their descendants to skip during repository discovery.'
},
timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.',
timezone: 'IANA timezone identifier. Blank uses the system timezone.',
agent: {
imageInputMode: 'Controls how image attachments are sent to the model.',
maxTurns: 'Upper bound for tool-calling turns before Hermes stops a run.'

View file

@ -0,0 +1,109 @@
import { useCallback, useRef, useState } from 'react'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Codicon } from '@/components/ui/codicon'
import { controlVariants } from '@/components/ui/control'
import { cn } from '@/lib/utils'
/**
* Searchable select for large option lists (e.g. ~590 IANA timezones).
* Built on Popover + cmdk Command the same stack as Shadcn's Combobox.
*
* The trigger renders like the existing closed `<Select>` but opens into a
* searchable Command palette. Closed-world only: the user must pick from the
* list; arbitrary text entry is not supported.
*
* `ConfigField` routes here when `schema.searchable === true`.
*/
export function SearchableSelect({
value,
onChange,
options,
placeholder = 'Search…',
emptyMessage = 'No results found.'
}: {
value: string
onChange: (value: string) => void
options: string[]
placeholder?: string
emptyMessage?: string
}) {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const handleSelect = useCallback(
(selected: string) => {
onChange(selected === value ? '' : selected)
setOpen(false)
},
[onChange, value]
)
const displayValue = value || placeholder
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<button
className={cn(
controlVariants(),
'flex items-center justify-between gap-2 whitespace-nowrap',
!value && 'text-muted-foreground'
)}
data-slot="searchable-select-trigger"
ref={triggerRef}
role="combobox"
type="button"
aria-expanded={open}
>
<span className="truncate">{displayValue}</span>
<Codicon className="shrink-0 opacity-60" name={open ? 'chevron-up' : 'chevron-down'} size="1rem" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
onOpenAutoFocus={e => {
// Let cmdk's CommandInput own focus, not the popover container.
e.preventDefault()
}}
>
<Command
filter={(value, search) => {
// cmdk's default filter is case-insensitive substring match on
// the item's value. For timezone-like values we also want to
// match segments after "/" so "york" matches "America/New_York".
const lower = search.toLowerCase()
const itemLower = value.toLowerCase()
if (itemLower.includes(lower)) return 1
// Match the segment after the last "/" (city name).
const slash = itemLower.lastIndexOf('/')
if (slash !== -1 && itemLower.slice(slash + 1).includes(lower)) return 1
return 0
}}
>
<CommandInput placeholder={placeholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map(option => (
<CommandItem
key={option}
onSelect={() => handleSelect(option)}
value={option}
>
<Codicon
className={cn('mr-2 size-4', option === value ? 'opacity-100' : 'opacity-0')}
name="check"
/>
{option}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View file

@ -538,6 +538,8 @@ export const en: Translations = {
builtinOnly: 'Built-in only',
notSet: 'Not set',
commaSeparated: 'comma-separated values',
searchPlaceholder: 'Search…',
noResults: 'No results found',
loading: 'Loading Hermes configuration...',
emptyTitle: 'Nothing to configure',
emptyDesc: 'This section has no adjustable settings.',

View file

@ -445,6 +445,8 @@ export interface Translations {
builtinOnly: string
notSet: string
commaSeparated: string
searchPlaceholder: string
noResults: string
loading: string
emptyTitle: string
emptyDesc: string

View file

@ -748,6 +748,8 @@ export const zh: Translations = {
builtinOnly: '仅内置',
notSet: '未设置',
commaSeparated: '逗号分隔的值',
searchPlaceholder: '搜索…',
noResults: '未找到结果',
loading: '正在加载 Hermes 配置...',
emptyTitle: '无可配置项',
emptyDesc: '此分区没有可调整的设置。',

View file

@ -2,6 +2,9 @@ export interface ConfigFieldSchema {
category?: string
description?: string
options?: unknown[]
/** When true, renders a SearchableSelect (Popover + cmdk) instead of the
* closed `<Select>` dropdown. For large option lists like IANA timezones. */
searchable?: boolean
type?: 'boolean' | 'list' | 'number' | 'select' | 'string' | 'text'
}

View file

@ -799,7 +799,22 @@ def _memory_provider_options() -> List[str]:
return list(dict.fromkeys(options))
def _timezone_options() -> List[str]:
"""Return sorted IANA timezone identifiers, cached at import time."""
try:
import zoneinfo
return sorted(zoneinfo.available_timezones())
except Exception: # pragma: no cover
return ["UTC"]
_SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
"timezone": {
"type": "select",
"description": "IANA timezone (e.g. America/New_York). Blank uses the system timezone.",
"options": _timezone_options(),
"searchable": True,
},
"memory.provider": {
"type": "select",
"description": "Memory provider plugin",