Files
aiturk-hermes-ide/apps/desktop/src/app/settings/index.tsx
T
yilsemandClaude Opus 5.5 03d3803dfc feat(aiturk): phone link for AITURK Mobil (1.0.0-beta.15)
Settings gets a Phone link window: when enabled, the IDE main process runs a
small TLS proxy on the local network (self-signed certificate, pinned by its
fingerprint) in front of the loopback backend and pairs a phone by QR code
or an 8-character code. Each phone gets its own revocable token; the backend
session token never leaves the computer. The root page, terminal sockets and
secret or settings-changing endpoints are not reachable from a phone. The
old WebRTC pairing window is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-04 06:02:02 +03:00

458 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useStore } from '@nanostores/react'
import { IconDeviceMobile } from '@tabler/icons-react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router'
import { codiconIcon } from '@/components/ui/codicon'
import { KbdCombo } from '@/components/ui/kbd'
import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { useAiturkCopy } from '@/i18n/aiturk'
import { triggerHaptic } from '@/lib/haptics'
import {
Archive,
BarChart3,
Bell,
Cpu,
Download,
Globe,
Info,
Keyboard,
KeyRound,
Package,
RefreshCw,
Search,
Settings2,
Upload,
Wrench,
Zap
} from '@/lib/icons'
import { isEditableTarget } from '@/lib/keybinds/combo'
import { typeToFocusChar } from '@/lib/keybinds/composer-focus-keys'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, openCommandPalettePage } from '@/store/command-palette'
import { confirm } from '@/store/confirm'
import { bindingsFor } from '@/store/keybinds'
import { $localModelsEnabled } from '@/store/local-models-flag'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { OverlayIconButton } from '../overlays/overlay-chrome'
import { OverlayMain, OverlayNav, type OverlayNavGroup, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import { SKILLS_ROUTE } from '../routes'
import { AboutSettings } from './about-settings'
import { AppearanceSettings } from './appearance-settings'
import { BillingSettings } from './billing'
import { ConfigSettings } from './config-settings'
import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KeybindSettings } from './keybind-settings'
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
import { NotificationsSettings } from './notifications-settings'
import { PluginsSettings } from './plugins-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
'providers',
'gateway',
// Legacy alias: the Connections page merged into Gateways. Kept in the enum
// so saved `?tab=connections` deep links still resolve (redirected below).
'connections',
'keybinds',
'keys',
'notifications',
'billing',
'plugins',
'sessions',
'about'
]
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const { t } = useI18n()
const aiturkCopy = useAiturkCopy()
const navigate = useNavigate()
const { hash, pathname, search } = useLocation()
// MCP moved out of Settings into Capabilities (/skills?tab=mcp). Keep old
// `/settings?tab=mcp` deep links working — `useRouteEnumParam` would silently
// coerce the unknown tab to the default view otherwise. Preserve `server=` so
// an old bookmark still lands on (and highlights) the selected server.
useEffect(() => {
const params = new URLSearchParams(search)
if (params.get('tab') === 'mcp') {
const server = params.get('server')
const suffix = server ? `&server=${encodeURIComponent(server)}` : ''
navigate(`${SKILLS_ROUTE}?tab=mcp${suffix}`, { replace: true })
}
}, [navigate, search])
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
// Connections merged into the unified Gateways page: land old
// `?tab=connections` routes/bookmarks there instead of a dead entry.
useEffect(() => {
if (activeView === 'connections') {
setActiveView('gateway')
}
}, [activeView, setActiveView])
// Providers subnav (Accounts vs API keys) lives in its own param so each
// sub-view is deep-linkable and survives a refresh.
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
const [keysView] = useRouteEnumParam<KeysView>('kview', KEYS_VIEWS, 'tools')
// Jump to a section + its sub-view in one navigate. Two sequential setters
// would each read the same stale `search` and the second would clobber the
// first's `tab` — so the sub-view never opened on narrow screens.
const openSubView = useCallback(
(tab: SettingsViewId, param: string, value: string, fallback: string) => {
const params = new URLSearchParams(search)
params.set('tab', tab)
if (value === fallback) {
params.delete(param)
} else {
params.set(param, value)
}
const qs = params.toString()
navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true })
},
[hash, navigate, pathname, search]
)
const openProviderView = useCallback(
(view: ProviderView) => openSubView('providers', 'pview', view, 'accounts'),
[openSubView]
)
const openKeysView = useCallback((view: KeysView) => openSubView('keys', 'kview', view, 'tools'), [openSubView])
const importInputRef = useRef<HTMLInputElement | null>(null)
const exportConfig = async () => {
try {
const cfg = await getHermesConfigRecord()
const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'hermes-config.json'
a.click()
URL.revokeObjectURL(url)
triggerHaptic('success')
} catch (err) {
notifyError(err, t.settings.exportFailed)
}
}
const resetConfig = async () => {
const ok = await confirm({
confirmLabel: t.settings.resetToDefaults,
destructive: true,
title: t.settings.resetConfirm
})
if (!ok) {
return
}
try {
await saveHermesConfig(await getHermesConfigDefaults())
triggerHaptic('success')
onConfigSaved?.()
} catch (err) {
notifyError(err, t.settings.resetFailed)
}
}
const navGroups: OverlayNavGroup[] = useMemo(
() => [
...SECTIONS.map(s => {
const view = `config:${s.id}` as SettingsViewId
return {
active: activeView === view,
icon: s.icon,
id: view,
label: t.settings.sections[s.id] ?? s.label,
onSelect: () => setActiveView(view)
}
}),
{
active: activeView === 'notifications',
icon: Bell,
id: 'notifications',
label: t.settings.nav.notifications,
onSelect: () => setActiveView('notifications')
},
{
active: activeView === 'billing',
icon: BarChart3,
id: 'billing',
label: t.settings.nav.billing,
onSelect: () => setActiveView('billing')
},
{
active: activeView === 'providers',
children: [
{
active: activeView === 'providers' && providerView === 'accounts',
icon: codiconIcon('account'),
id: 'pview:accounts',
label: t.settings.nav.providerAccounts,
onSelect: () => openProviderView('accounts')
},
{
active: activeView === 'providers' && providerView === 'keys',
icon: KeyRound,
id: 'pview:keys',
label: t.settings.nav.providerApiKeys,
onSelect: () => openProviderView('keys')
},
{
active: activeView === 'providers' && providerView === 'custom-endpoints',
icon: Globe,
id: 'pview:custom-endpoints',
label: t.settings.nav.providerCustomEndpoints,
onSelect: () => openProviderView('custom-endpoints')
},
// Local models ships behind the --local launch flag: no flag, no
// nav entry (the pane itself also refuses to render, so a stale
// ?pview=local deep link falls back to accounts-shaped emptiness
// rather than a hidden feature).
...($localModelsEnabled.get()
? [
{
active: activeView === 'providers' && providerView === 'local',
icon: Cpu,
id: 'pview:local',
label: t.settings.nav.providerLocalModels,
onSelect: () => openProviderView('local')
}
]
: [])
],
gapBefore: true,
icon: Zap,
id: 'providers',
label: t.settings.nav.providers,
onSelect: () => setActiveView('providers')
},
{
active: activeView === 'gateway',
icon: Globe,
id: 'gateway',
label: t.settings.nav.gateway,
onSelect: () => setActiveView('gateway')
},
// "Telefon bağlantısı" lives in its own small window (QR + code); this
// entry only opens it. Present only where the desktop bridge offers it.
...(window.hermesDesktop?.aiturkMobile
? [
{
active: false,
icon: IconDeviceMobile,
id: 'aiturk-phone',
label: aiturkCopy.mobile.nav,
onSelect: () => {
void window.hermesDesktop?.aiturkMobile?.open().catch(error => notifyError(error, aiturkCopy.mobile.openFailed))
}
}
]
: []),
{
active: activeView === 'keybinds',
icon: Keyboard,
id: 'keybinds',
label: t.settings.nav.keybinds,
onSelect: () => setActiveView('keybinds')
},
{
active: activeView === 'keys',
children: [
{
active: activeView === 'keys' && keysView === 'tools',
icon: Wrench,
id: 'kview:tools',
label: t.settings.nav.keysTools,
onSelect: () => openKeysView('tools')
},
{
active: activeView === 'keys' && keysView === 'settings',
icon: Settings2,
id: 'kview:settings',
label: t.settings.nav.keysSettings,
onSelect: () => openKeysView('settings')
}
],
icon: KeyRound,
id: 'keys',
label: t.settings.nav.apiKeys,
onSelect: () => setActiveView('keys')
},
{
active: activeView === 'plugins',
icon: Package,
id: 'plugins',
label: t.settings.nav.plugins,
onSelect: () => setActiveView('plugins')
},
{
active: activeView === 'sessions',
icon: Archive,
id: 'sessions',
label: t.settings.nav.archivedChats,
onSelect: () => setActiveView('sessions')
},
{
active: activeView === 'about',
gapBefore: true,
icon: Info,
id: 'about',
label: t.settings.nav.about,
onSelect: () => setActiveView('about')
}
],
[activeView, keysView, providerView, t, aiturkCopy, setActiveView, openProviderView, openKeysView]
)
// Type-to-search: printable keystrokes on the Settings surface (outside any
// field) open the settings-scoped palette, seeded with the character — same
// reflex as the chat surface's type-to-focus, pointed at search instead.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if ($commandPaletteOpen.get() || isEditableTarget(event.target)) {
return
}
const char = typeToFocusChar(event)
if (char === null || char === ' ') {
return
}
event.preventDefault()
openCommandPalettePage('settings', char)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
// Fake search pill riding the card's top edge, dead-center and half off it.
// Clicking (or just typing) opens the ⌘K palette scoped to settings; while
// the palette is up the pill hands over to it — grows slightly and fades,
// then fades back when the palette closes. It renders as chrome, not an
// input — no border, recessed fill, live ⌘K hint.
const searchCombo = bindingsFor('nav.commandPalette')[0]
const paletteOpen = useStore($commandPaletteOpen)
const searchPill = (
<button
className={cn(
'flex h-(--titlebar-control-height) items-center gap-1.5 rounded-full border border-(--ui-stroke-secondary) bg-(--ui-chat-surface-background) px-2.5 text-(--ui-text-tertiary) shadow-sm transition-all duration-200 ease-out hover:text-foreground motion-reduce:transition-none',
paletteOpen && 'pointer-events-none scale-110 opacity-0'
)}
onClick={() => {
triggerHaptic('open')
openCommandPalettePage('settings')
}}
tabIndex={paletteOpen ? -1 : undefined}
type="button"
>
<Search className="size-3" />
<span className="text-xs">{t.settings.search.pill}</span>
{searchCombo && <KbdCombo combo={searchCombo} size="sm" variant="ghost" />}
</button>
)
const navFooter = (
<>
<Tip label={t.settings.exportConfig}>
<OverlayIconButton onClick={() => void exportConfig()}>
<Download />
</OverlayIconButton>
</Tip>
<Tip label={t.settings.importConfig}>
<OverlayIconButton
onClick={() => {
triggerHaptic('open')
importInputRef.current?.click()
}}
>
<Upload />
</OverlayIconButton>
</Tip>
<Tip label={t.settings.resetToDefaults}>
<OverlayIconButton
className="hover:text-destructive"
onClick={() => {
triggerHaptic('warning')
void resetConfig()
}}
>
<RefreshCw />
</OverlayIconButton>
</Tip>
</>
)
const activeSettingsContent =
activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'about' ? (
<AboutSettings />
) : activeView === 'gateway' || activeView === 'connections' ? (
// 'connections' renders the unified page too so the frame before
// the alias redirect lands doesn't flash the fallback view.
<GatewaySettings />
) : activeView === 'keybinds' ? (
<KeybindSettings />
) : activeView.startsWith('config:') ? (
<ConfigSettings
activeSectionId={activeView.slice('config:'.length)}
importInputRef={importInputRef}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
/>
) : activeView === 'providers' ? (
<ProvidersSettings
onClose={onClose}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
onViewChange={setProviderView}
view={providerView}
/>
) : activeView === 'keys' ? (
<KeysSettings view={keysView} />
) : activeView === 'notifications' ? (
<NotificationsSettings />
) : activeView === 'billing' ? (
<BillingSettings />
) : activeView === 'plugins' ? (
<PluginsSettings />
) : (
<SessionsSettings />
)
return (
<OverlayView closeLabel={t.settings.closeSettings} edgeBadge={searchPill} onClose={onClose}>
<OverlaySplitLayout>
<OverlayNav footer={navFooter} groups={navGroups} />
<OverlayMain className="px-0 pb-0">{activeSettingsContent}</OverlayMain>
</OverlaySplitLayout>
</OverlayView>
)
}
export { SettingsView as SettingsPage }