mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
e581d92429
commit
5cb0a6aec1
8 changed files with 151 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
109
apps/desktop/src/app/settings/searchable-select.tsx
Normal file
109
apps/desktop/src/app/settings/searchable-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -445,6 +445,8 @@ export interface Translations {
|
|||
builtinOnly: string
|
||||
notSet: string
|
||||
commaSeparated: string
|
||||
searchPlaceholder: string
|
||||
noResults: string
|
||||
loading: string
|
||||
emptyTitle: string
|
||||
emptyDesc: string
|
||||
|
|
|
|||
|
|
@ -748,6 +748,8 @@ export const zh: Translations = {
|
|||
builtinOnly: '仅内置',
|
||||
notSet: '未设置',
|
||||
commaSeparated: '逗号分隔的值',
|
||||
searchPlaceholder: '搜索…',
|
||||
noResults: '未找到结果',
|
||||
loading: '正在加载 Hermes 配置...',
|
||||
emptyTitle: '无可配置项',
|
||||
emptyDesc: '此分区没有可调整的设置。',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue