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
234 lines
7.2 KiB
TypeScript
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>
|
|
)
|
|
}
|