Files
aiturk-hermes-ide/apps/desktop/src/app/settings/codex-link-settings.tsx
T
yilsemandClaude Opus 5.5 dea67fc153 feat(settings): Codex Bağlama, on by default
Settings → Codex Bağlama runs the bundled bridge for an OpenAI Codex install on
the same computer: the IDE's models and combos appear in Codex and switching
models keeps the conversation. The IDE points Codex's openai_base_url at the
bridge only after the bridge answers, keeps it running across logins, and
removes every trace when the setting is turned off. A Codex that already uses
another base URL is left untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDGW1LiKcDUZYUdKBoWYW
2026-10-09 15:59:39 +03:00

234 lines
7.2 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { type CodexLinkCopy, useCodexLinkCopy } from '@/i18n/codex-link'
import { triggerHaptic } from '@/lib/haptics'
import { AlertTriangle, Check, CircleIcon, Link, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { CodexLinkPatch, CodexLinkStatus } from '../../../electron/codex-link-types'
import { EmptyState, ListRow, ListRowSkeleton, SettingsContent, SettingsSection } from './primitives'
type Tone = 'muted' | 'ok' | 'warn'
interface StatusLine {
id: string
text: string
tone: Tone
}
/**
* What the user needs to know, top-down, stopping at the first fact that makes
* the rest moot (no Codex → nothing else to say; another bridge → ours is not
* involved; setup pending → the bridge cannot be running yet).
*/
export function codexLinkStatusLines(status: CodexLinkStatus, copy: CodexLinkCopy): StatusLine[] {
const lines: StatusLine[] = []
const error = status.lastError ? [{ id: 'error', text: copy.errors[status.lastError], tone: 'warn' as const }] : []
if (!status.codexFound) {
return [{ id: 'codex', text: copy.codexMissing, tone: 'muted' }, ...error]
}
lines.push({ id: 'codex', text: copy.codexFound, tone: 'ok' })
if (status.foreign) {
return [...lines, { id: 'foreign', text: copy.foreign, tone: 'warn' }, ...error]
}
if (!status.enabled) {
return [...lines, { id: 'off', text: copy.off, tone: 'muted' }, ...error]
}
if (!status.venvReady) {
return [...lines, { id: 'venv', text: copy.venvPending, tone: 'muted' }, ...error]
}
if (!status.bridgeRunning) {
return [...lines, { id: 'bridge', text: copy.bridgeStopped, tone: 'warn' }, ...error]
}
lines.push({ id: 'bridge', text: copy.bridgeRunning, tone: 'ok' })
if (status.ideDurum === 'anahtar-yok') {
lines.push({ id: 'ide', text: copy.signIn, tone: 'warn' })
} else if (status.ideDurum === 'tamam' && status.ideModel !== null) {
lines.push({ id: 'ide', text: copy.models(status.ideModel), tone: 'ok' })
}
return [...lines, ...error]
}
const TONE_ICON = { muted: CircleIcon, ok: Check, warn: AlertTriangle } as const
const TONE_CLASS: Record<Tone, string> = {
muted: 'text-(--ui-text-tertiary)',
ok: 'text-(--ui-text-secondary)',
warn: 'text-amber-500/90'
}
// Phone-sized hit area around the compact switch: the visible control keeps the
// design-system size, the label that wraps it is the 44px target.
const TOUCH_TARGET = 'flex min-h-11 min-w-11 cursor-pointer items-center @2xl:justify-end'
/**
* Settings → Codex Link. Main owns everything real (the preference file, the
* bridge, Codex's config, the login item); this page reads the state it reports
* and asks for changes, adopting whatever comes back as the truth.
*/
export function CodexLinkSettings() {
const copy = useCodexLinkCopy()
const bridge = typeof window === 'undefined' ? undefined : window.hermesDesktop?.codexLink
const [status, setStatus] = useState<CodexLinkStatus | null>(null)
const [loadFailed, setLoadFailed] = useState(false)
const [busy, setBusy] = useState<'sync' | 'toggle' | null>(null)
// A slow initial read must not overwrite the answer to a newer change.
const generation = useRef(0)
useEffect(() => {
if (!bridge) {
return
}
const ticket = generation.current
bridge.get().then(
next => {
if (generation.current === ticket) {
setStatus(next)
}
},
() => {
if (generation.current === ticket) {
setLoadFailed(true)
}
}
)
}, [bridge])
const change = useCallback(
async (patch: CodexLinkPatch, kind: 'sync' | 'toggle') => {
if (!bridge || busy) {
return
}
const before = status
generation.current += 1
setBusy(kind)
setLoadFailed(false)
// The switch moves at once; main's reply (or the rollback) has the last word.
if (typeof patch.enabled === 'boolean' && before) {
setStatus({ ...before, enabled: patch.enabled })
}
try {
setStatus(await bridge.set(patch))
} catch (error) {
setStatus(before)
notifyError(error, copy.saveFailed)
} finally {
setBusy(null)
}
},
[bridge, busy, copy.saveFailed, status]
)
if (!bridge) {
return (
<SettingsContent>
<EmptyState description={copy.unavailable} title={copy.title} />
</SettingsContent>
)
}
const lines = status ? codexLinkStatusLines(status, copy) : []
return (
<SettingsContent>
<SettingsSection icon={Link} title={copy.title}>
<ListRow
action={
<label className={TOUCH_TARGET}>
<Switch
aria-label={copy.title}
checked={status?.enabled ?? true}
disabled={!status || busy !== null}
onCheckedChange={enabled => {
triggerHaptic('selection')
void change({ enabled }, 'toggle')
}}
/>
</label>
}
description={copy.description}
title={copy.title}
/>
{!status && !loadFailed && <ListRowSkeleton wide />}
{loadFailed && !status && (
<p className="py-3 text-[length:var(--conversation-caption-font-size)] text-amber-500/90" role="alert">
{copy.loadFailed}
</p>
)}
{status && (
<ListRow
below={
<ul aria-live="polite" className="mt-1.5 grid gap-1.5">
{lines.map(line => {
const Icon = TONE_ICON[line.tone]
return (
<li
className={cn(
'flex min-w-0 items-start gap-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
TONE_CLASS[line.tone]
)}
data-tone={line.tone}
key={line.id}
>
<Icon aria-hidden className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 break-words">{line.text}</span>
</li>
)
})}
</ul>
}
title={copy.statusTitle}
wide
/>
)}
<ListRow
action={
<Button
className="min-h-11 w-full @2xl:w-auto"
disabled={!status || busy !== null}
onClick={() => {
triggerHaptic('open')
void change({ sync: true }, 'sync')
}}
type="button"
variant="secondary"
>
<RefreshCw />
{busy === 'sync' ? copy.syncing : copy.sync}
</Button>
}
description={copy.syncDesc}
title={copy.syncTitle}
/>
<p className="pt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.restartNote}
</p>
</SettingsSection>
</SettingsContent>
)
}