Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/desktop/src/app/settings/config-settings.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ChangeEvent, ReactNode } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'

import { TimezoneCombobox } from '@/components/timezone-combobox'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
Expand Down Expand Up @@ -60,6 +61,10 @@ function ConfigField({
)
}

if (schemaKey === 'timezone') {
return row(<TimezoneCombobox onChange={onChange} value={String(value ?? '')} />)
}

const selectOptions = enumOptions ?? (schema.type === 'select' ? (schema.options ?? []).map(String) : undefined)

if (selectOptions) {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/settings/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = {
model_context_length: "Leave at 0 to use the selected model's detected context window.",
fallback_providers: 'Backup provider:model entries to try if the default model fails.',
'display.personality': 'Default assistant style for new sessions.',
timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.',
timezone: 'Used when Hermes needs local time context. "System default" uses the device timezone.',
'display.show_reasoning': 'Show reasoning sections when the backend provides them.',
'agent.image_input_mode': 'Controls how image attachments are sent to the model.',
'terminal.cwd': 'Default project folder for tool and terminal work.',
Expand Down
166 changes: 166 additions & 0 deletions apps/desktop/src/components/timezone-combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { useMemo, useState } from 'react'

import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Check, ChevronDown, World } from '@/lib/icons'
import { cn } from '@/lib/utils'

// Maximum rows rendered at once. cmdk isn't virtualized and the full IANA list
// is ~400 entries, so we cap the visible set and lean on the search box to
// narrow things down. A footer hint appears whenever the list is capped.
const MAX_VISIBLE = 150

interface TimezoneOption {
/** IANA identifier, e.g. "America/New_York". */
id: string
/** Human label including the current UTC offset, e.g. "America/New_York (GMT-5)". */
label: string
}

function offsetLabel(timeZone: string): string {
try {
const parts = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'shortOffset' }).formatToParts(new Date())

return parts.find(p => p.type === 'timeZoneName')?.value ?? ''
} catch {
return ''
}
}

function listTimeZones(): string[] {
// supportedValuesOf is available in modern Electron/Chromium; guard anyway so
// an older runtime degrades to a plain text entry rather than throwing.
const supported = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf

if (typeof supported === 'function') {
try {
return supported('timeZone')
} catch {
/* fall through */
}
}

return []

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not provide the comment's promised plain-text fallback: with no Intl.supportedValuesOf, the option list is empty and the user can only choose System default. Please retain a validated text-entry path or supply a fallback canonical zone list, and cover this branch with a test.

}

function toOption(id: string): TimezoneOption {
const offset = offsetLabel(id)

return { id, label: offset ? `${id} (${offset})` : id }
}

export function TimezoneCombobox({
value,
onChange,
className
}: {
value: string
onChange: (value: string) => void
className?: string
}) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')

// The IANA list never changes for the lifetime of the runtime, so build it
// (and its offset labels) once rather than on every selection.
const baseOptions = useMemo<TimezoneOption[]>(() => listTimeZones().map(toOption), [])

const options = useMemo<TimezoneOption[]>(() => {
// Surface a previously-saved value that the runtime doesn't enumerate
// (custom or deprecated zone) so it stays selectable.
if (value && !baseOptions.some(o => o.id === value)) {
return [toOption(value), ...baseOptions]
}

return baseOptions
}, [baseOptions, value])

const query = search.trim().toLowerCase()

const filtered = useMemo(() => {
if (!query) {
return options.slice(0, MAX_VISIBLE)
}

const hits: TimezoneOption[] = []

for (const option of options) {
if (option.label.toLowerCase().includes(query) || option.id.replace(/_/g, ' ').toLowerCase().includes(query)) {
hits.push(option)

if (hits.length >= MAX_VISIBLE) {
break
}
}
}

return hits
}, [options, query])

// Keep "System default" filterable so a non-matching query can reach the
// empty state instead of leaving a lone, always-present row.
const showSystemDefault = !query || 'system default'.includes(query)
const trimmed = filtered.length >= MAX_VISIBLE

const select = (next: string) => {
onChange(next)
setOpen(false)
setSearch('')
}

const triggerLabel = value ? (options.find(o => o.id === value)?.label ?? value) : 'System default'

return (
<Popover
onOpenChange={next => {
setOpen(next)

if (!next) {
setSearch('')
}
}}
open={open}
>
<PopoverTrigger
aria-expanded={open}
className={cn(
'flex h-8 w-full items-center justify-between gap-2 rounded-lg border border-input bg-background px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
role="combobox"
type="button"
>
<span className={cn('flex min-w-0 items-center gap-2', !value && 'text-muted-foreground')}>
<World className="size-3.5 shrink-0 opacity-60" />
<span className="truncate">{triggerLabel}</span>
</span>
<ChevronDown className="size-4 shrink-0 opacity-60" />
</PopoverTrigger>
<PopoverContent align="end" className="w-[min(22rem,90vw)] p-0">
<Command shouldFilter={false}>
<CommandInput onValueChange={setSearch} placeholder="Search timezones..." value={search} />

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please source this and the other new visible strings (System default, empty state, truncation hint) from the desktop i18n catalog. The desktop has Japanese and Chinese catalogs, whose existing timezone descriptions also need the System-default wording update.

<CommandList>
<CommandEmpty>No timezone found.</CommandEmpty>
{showSystemDefault && (
<CommandItem onSelect={() => select('')} value="__system_default__">
<Check className={cn('size-4 shrink-0', value ? 'opacity-0' : 'opacity-100')} />
<span className="truncate">System default</span>
</CommandItem>
)}
{filtered.map(option => (
<CommandItem className="font-mono" key={option.id} onSelect={() => select(option.id)} value={option.id}>
<Check className={cn('size-4 shrink-0', value === option.id ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">{option.label}</span>
</CommandItem>
))}
{trimmed && (
<div className="px-3 py-2 text-xs text-muted-foreground">
Showing the first {MAX_VISIBLE}. Keep typing to narrow results.
</div>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
40 changes: 40 additions & 0 deletions apps/desktop/src/components/ui/popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Popover as PopoverPrimitive } from 'radix-ui'
import * as React from 'react'

import { cn } from '@/lib/utils'

function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}

function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}

function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}

function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
className={cn(
'z-[140] w-72 origin-(--radix-popover-content-transform-origin) rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2',
className
)}
data-slot="popover-content"
sideOffset={sideOffset}
{...props}
/>
</PopoverPrimitive.Portal>
)
}

export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
IconVolume2 as Volume2Icon,
IconVolumeOff as VolumeX,
IconVolumeOff as VolumeXIcon,
IconWorld as World,
IconTool as Wrench,
IconX as X,
IconX as XIcon,
Expand Down Expand Up @@ -188,6 +189,7 @@ export {
Volume2Icon,
VolumeX,
VolumeXIcon,
World,
Wrench,
X,
XIcon,
Expand Down