feat: refresh dashboard settings UX and Codex feature toggles (#153)

* feat: refresh dashboard settings UX and Codex feature toggles

Improve settings/tasks mobile layout, model effort validation by agent type, and preset models for GPT 5.5 and Opus 4.7. Store Codex fast mode and goals in config.toml with Claude fastMode session sync and updated docs for Codex 0.133.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style: apply pre-commit formatting after dashboard settings commit

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Eyejoker
2026-05-23 12:18:42 +09:00
committed by GitHub
parent 58e5197dc6
commit fd3145e2a7
37 changed files with 3785 additions and 1910 deletions

View File

@@ -56,7 +56,7 @@ Codex 관련 선택 설정은 **별도 서비스 파일이 아니라 같은 `.en
```bash
# 같은 .env 또는 서비스 Environment=에 추가 가능
CODEX_MODEL=gpt-5.4
CODEX_MODEL=gpt-5.5
CODEX_EFFORT=xhigh
OPENAI_API_KEY=...
```

View File

@@ -23,13 +23,17 @@ GROQ_API_KEY= # Voice transcription (Groq Whisper, free:
ASSISTANT_NAME=claude # Trigger name (@claude)
# --- Claude model ---
CLAUDE_MODEL=claude-opus-4-6 # Claude model
CLAUDE_MODEL=claude-opus-4-7 # Claude model
CLAUDE_EFFORT=high # Claude effort level
CLAUDE_THINKING=adaptive # Claude thinking mode
# --- Codex model ---
CODEX_MODEL=gpt-5.4 # Codex model
CODEX_MODEL=gpt-5.5 # Codex model
CODEX_EFFORT=xhigh # Codex reasoning effort
# Codex fast mode / goals: dashboard Settings → Codex, or ~/.codex/config.toml:
# [features]
# fast_mode = true
# goals = true # /goal — opt-in, default OFF on Codex 0.133+
# --- Usage dashboard ---
STATUS_CHANNEL_ID= # Discord channel ID for live status updates
@@ -69,11 +73,11 @@ STATUS_CHANNEL_ID= # Discord channel ID for live status updat
# --- Per-role model overrides (paired rooms) ---
# Override global CLAUDE_MODEL/CODEX_MODEL per role.
# Model is injected based on each role's agent type (claude-code → CLAUDE_MODEL, codex → CODEX_MODEL).
# OWNER_MODEL=gpt-5.4 # Owner model override
# OWNER_MODEL=gpt-5.5 # Owner model override
# OWNER_EFFORT=xhigh # Owner effort override
# OWNER_FALLBACK_ENABLED=true # Fall back to codex on Claude failure (default: true)
#
# REVIEWER_MODEL=claude-opus-4-6 # Reviewer model override
# REVIEWER_MODEL=claude-opus-4-7 # Reviewer model override
# REVIEWER_EFFORT=high # Reviewer effort override
# REVIEWER_FALLBACK_ENABLED=true # Fall back to codex on Claude failure (default: true)
#

View File

@@ -2,7 +2,7 @@
![Version](https://img.shields.io/badge/version-0.2.3-blue)
![Claude Agent SDK](https://img.shields.io/badge/Claude_Agent_SDK-0.2.126-blueviolet)
![Codex SDK](https://img.shields.io/badge/Codex_SDK-0.128.0-green)
![Codex SDK](https://img.shields.io/badge/Codex_SDK-0.133.0-green)
![Bun](https://img.shields.io/badge/Bun-1.3+-f9f1e1?logo=bun&logoColor=black)
![Discord](https://img.shields.io/badge/Discord-Tribunal-5865F2?logo=discord&logoColor=white)
@@ -118,7 +118,7 @@ Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (ho
현재 runner 번들 기준 버전:
- Claude Agent SDK: `@anthropic-ai/claude-agent-sdk@0.2.126`
- Codex SDK/CLI: `@openai/codex@0.128.0`
- Codex SDK/CLI: `@openai/codex@0.133.0`
### 설치

View File

@@ -46,11 +46,6 @@ interface DashboardState {
type FreshnessLevel = DashboardFreshness;
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
};
const REFRESH_INTERVAL_MS = 15_000;
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2';
const DEFAULT_VIEW: DashboardView = 'rooms';
@@ -158,16 +153,6 @@ function freshnessLabel(level: FreshnessLevel, t: Messages): string {
return t.pwa.fresh;
}
function isStandaloneDisplay(): boolean {
if (typeof window === 'undefined') return false;
const standaloneNavigator = navigator as Navigator & { standalone?: boolean };
return (
standaloneNavigator.standalone === true ||
(typeof window.matchMedia === 'function' &&
window.matchMedia('(display-mode: standalone)').matches)
);
}
function canUsePwaCore(): boolean {
return (
typeof window !== 'undefined' &&
@@ -280,10 +265,6 @@ function App() {
const [online, setOnline] = useState(() =>
typeof navigator === 'undefined' ? true : navigator.onLine,
);
const [offlineReady, setOfflineReady] = useState(false);
const [installPrompt, setInstallPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [installed, setInstalled] = useState(isStandaloneDisplay);
const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>(
null,
);
@@ -311,8 +292,6 @@ function App() {
persistNickname(trimmed);
}
const t = messages[locale];
const secureContext =
typeof window === 'undefined' ? true : window.isSecureContext;
function setDashboardLocale(nextLocale: Locale) {
setLocale(nextLocale);
@@ -459,14 +438,6 @@ function App() {
}
}
async function handleInstallApp() {
if (!installPrompt) return;
await installPrompt.prompt();
await installPrompt.userChoice;
setInstallPrompt(null);
setInstalled(isStandaloneDisplay());
}
useEffect(() => {
document.documentElement.lang = localeTags[locale];
}, [locale]);
@@ -530,58 +501,11 @@ function App() {
}, []);
useEffect(() => {
if (!import.meta.env.PROD || !canUsePwaCore()) {
setOfflineReady(false);
return;
}
if (!import.meta.env.PROD || !canUsePwaCore()) return;
let cancelled = false;
void navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
if (!cancelled) {
setOfflineReady(
Boolean(
registration.active ||
registration.waiting ||
registration.installing,
),
);
}
return navigator.serviceWorker.ready;
})
.then(() => {
if (!cancelled) setOfflineReady(true);
})
.catch(() => {
if (!cancelled) setOfflineReady(false);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
function handleBeforeInstallPrompt(event: Event) {
event.preventDefault();
setInstallPrompt(event as BeforeInstallPromptEvent);
}
function handleInstalled() {
setInstalled(true);
setInstallPrompt(null);
}
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleInstalled);
return () => {
window.removeEventListener(
'beforeinstallprompt',
handleBeforeInstallPrompt,
);
window.removeEventListener('appinstalled', handleInstalled);
};
void navigator.serviceWorker.register('/sw.js').catch(() => {
/* ignore registration failures */
});
}, []);
useEffect(() => {
@@ -640,42 +564,27 @@ function App() {
const roomOptions = data ? buildRoomOptions(data.snapshots) : [];
const freshness = dashboardFreshness(online, data?.overview.generatedAt);
const canInstall = Boolean(secureContext && installPrompt && !installed);
return (
<div className="shell">
<SideRail
activeView={activeView}
canInstall={canInstall}
data={data}
installed={installed}
offlineReady={offlineReady}
online={online}
onInstall={() => void handleInstallApp()}
onNavigate={navigateToView}
onRefresh={() => void refresh(true)}
refreshing={refreshing}
secureContext={secureContext}
t={t}
/>
<main className="dashboard-content">
<SectionNav
activeView={activeView}
canInstall={canInstall}
data={data}
drawerOpen={drawerOpen}
freshness={freshness}
freshnessText={freshnessLabel(freshness, t)}
installed={installed}
onCloseDrawer={() => setDrawerOpen(false)}
onInstall={() => void handleInstallApp()}
onNavigate={navigateToView}
onOpenDrawer={() => setDrawerOpen(true)}
offlineReady={offlineReady}
onRefresh={() => void refresh(true)}
online={online}
refreshing={refreshing}
secureContext={secureContext}
t={t}
/>
@@ -729,7 +638,6 @@ function App() {
<section className="panel view-panel" id="scheduled">
<div className="panel-title">
<h2>{t.panels.scheduled}</h2>
<span>{t.panels.promptPreviews}</span>
</div>
<TaskPanel
locale={locale}

View File

@@ -1,12 +1,5 @@
import { type ReactNode } from 'react';
import {
Clock,
Download,
Gauge,
MessageSquare,
RefreshCw,
Settings,
} from 'lucide-react';
import { Clock, Gauge, MessageSquare, Settings } from 'lucide-react';
import { type DashboardOverview, type StatusSnapshot } from './api';
import { type Messages } from './i18n';
@@ -61,23 +54,6 @@ function navBadge(
return null;
}
function pwaStateLabel({
installed,
offlineReady,
secureContext,
t,
}: {
installed: boolean;
offlineReady: boolean;
secureContext: boolean;
t: Messages;
}) {
if (!secureContext) return t.pwa.secureRequired;
if (installed) return t.pwa.installed;
if (offlineReady) return t.pwa.ready;
return t.pwa.app;
}
function DashboardBrandLink({
onNavigate,
}: {
@@ -138,95 +114,15 @@ function DashboardNavLinks({
);
}
function DashboardNavActions({
canInstall,
installed,
offlineReady,
onInstall,
onRefresh,
online,
refreshing,
secureContext,
t,
}: {
canInstall: boolean;
installed: boolean;
offlineReady: boolean;
onInstall: () => void;
onRefresh: () => void;
online: boolean;
refreshing: boolean;
secureContext: boolean;
t: Messages;
}) {
const pwaState = pwaStateLabel({ installed, offlineReady, secureContext, t });
const status = `${online ? t.pwa.online : t.pwa.offline} · ${pwaState}`;
return (
<div className="rail-foot">
<div className="rail-status-line" title={status}>
<span
className={`rail-status-dot ${online ? 'is-online' : 'is-offline'}`}
aria-label={online ? t.pwa.online : t.pwa.offline}
/>
<span className="rail-foot-label">{status}</span>
</div>
<div className="rail-actions">
{canInstall ? (
<button
className="rail-btn"
onClick={onInstall}
title={t.pwa.install}
aria-label={t.pwa.install}
type="button"
>
<Download size={16} strokeWidth={2} aria-hidden />
<span className="rail-btn-label">{t.pwa.install}</span>
</button>
) : null}
<button
aria-busy={refreshing}
aria-label={t.actions.refresh}
className={`rail-btn${refreshing ? ' is-spinning' : ''}`}
disabled={refreshing}
onClick={onRefresh}
title={refreshing ? t.actions.refreshing : t.actions.refresh}
type="button"
>
<RefreshCw size={16} strokeWidth={2} aria-hidden />
<span className="rail-btn-label">
{refreshing ? t.actions.refreshing : t.actions.refresh}
</span>
</button>
</div>
</div>
);
}
export function SideRail({
activeView,
canInstall,
data,
installed,
offlineReady,
onInstall,
onNavigate,
onRefresh,
online,
refreshing,
secureContext,
t,
}: {
activeView: DashboardView;
canInstall: boolean;
data: DashboardNavData | null;
installed: boolean;
offlineReady: boolean;
onInstall: () => void;
onNavigate: (view: DashboardView) => void;
onRefresh: () => void;
online: boolean;
refreshing: boolean;
secureContext: boolean;
t: Messages;
}) {
return (
@@ -238,60 +134,38 @@ export function SideRail({
onNavigate={onNavigate}
t={t}
/>
<DashboardNavActions
canInstall={canInstall}
installed={installed}
offlineReady={offlineReady}
online={online}
onInstall={onInstall}
onRefresh={onRefresh}
refreshing={refreshing}
secureContext={secureContext}
t={t}
/>
</aside>
);
}
export function SectionNav({
activeView,
canInstall,
data,
drawerOpen,
freshness,
freshnessText,
installed,
onCloseDrawer,
onInstall,
onNavigate,
onOpenDrawer,
offlineReady,
onRefresh,
online,
refreshing,
secureContext,
t,
}: {
activeView: DashboardView;
canInstall: boolean;
data: DashboardNavData | null;
drawerOpen: boolean;
freshness: DashboardFreshness;
freshnessText: string;
installed: boolean;
onCloseDrawer: () => void;
onInstall: () => void;
onNavigate: (view: DashboardView) => void;
onOpenDrawer: () => void;
offlineReady: boolean;
onRefresh: () => void;
online: boolean;
refreshing: boolean;
secureContext: boolean;
t: Messages;
}) {
const activeLabel =
navItems(t).find((item) => item.view === activeView)?.label ?? t.nav.rooms;
const showManualRefresh = freshness !== 'fresh';
return (
<>
@@ -312,16 +186,18 @@ export function SectionNav({
<span className={`topbar-status topbar-status-${freshness}`}>
{freshnessText}
</span>
<button
aria-busy={refreshing}
aria-label={refreshing ? t.actions.refreshing : t.actions.refresh}
className="refresh-button"
disabled={refreshing}
onClick={onRefresh}
type="button"
>
{refreshing ? '...' : t.actions.refresh}
</button>
{showManualRefresh ? (
<button
aria-busy={refreshing}
aria-label={refreshing ? t.actions.refreshing : t.actions.refresh}
className="refresh-button"
disabled={refreshing}
onClick={onRefresh}
type="button"
>
{refreshing ? '...' : t.actions.refresh}
</button>
) : null}
</nav>
{drawerOpen ? (
@@ -361,17 +237,6 @@ export function SectionNav({
onNavigate={onNavigate}
t={t}
/>
<DashboardNavActions
canInstall={canInstall}
installed={installed}
offlineReady={offlineReady}
online={online}
onInstall={onInstall}
onRefresh={onRefresh}
refreshing={refreshing}
secureContext={secureContext}
t={t}
/>
</aside>
</>
) : null}

View File

@@ -8,8 +8,10 @@ import {
fetchMoaSettings,
updateMoaSettings,
} from './api';
import { type Messages } from './i18n';
import { SettingsSaveBar, SettingsSectionHeading } from './SettingsPanelChrome';
export function MoaSettingsPanel() {
export function MoaSettingsPanel({ t }: { t: Messages }) {
const [config, setConfig] = useState<MoaSettingsSnapshot | null>(null);
const [draft, setDraft] = useState<MoaSettingsSnapshot | null>(null);
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
@@ -17,6 +19,7 @@ export function MoaSettingsPanel() {
const [checking, setChecking] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(null);
const section = t.settings.sections.moa;
useEffect(() => {
let cancelled = false;
@@ -119,15 +122,15 @@ export function MoaSettingsPanel() {
id="settings-moa"
role="tabpanel"
>
<header className="settings-section-head">
<span>Arbiter references</span>
<h3>MoA </h3>
<p>Kimi, GLM .</p>
</header>
<SettingsSectionHeading
description={section.description}
detail={section.kicker}
title={section.title}
/>
{error ? <p className="settings-error">{error}</p> : null}
{!draft ? (
<p className="settings-hint">
{busy ? '불러오는 중…' : 'MoA 설정 없음'}
{busy ? t.settings.common.loading : t.settings.moa.empty}
</p>
) : (
<MoaSettingsContent
@@ -148,6 +151,7 @@ export function MoaSettingsPanel() {
)
}
savedAt={savedAt}
t={t}
/>
)}
</section>
@@ -166,6 +170,7 @@ function MoaSettingsContent({
onTest,
onToggle,
savedAt,
t,
}: {
apiKeys: Record<string, string>;
busy: boolean;
@@ -181,6 +186,7 @@ function MoaSettingsContent({
onTest: (name: string) => Promise<void>;
onToggle: () => void;
savedAt: number | null;
t: Messages;
}) {
return (
<>
@@ -188,8 +194,9 @@ function MoaSettingsContent({
busy={busy}
enabled={draft.enabled}
onToggle={onToggle}
t={t}
/>
<ul className="settings-account-list">
<ul className="settings-moa-list">
{draft.models.map((model) => (
<MoaModelRow
apiKeyValue={apiKeys[model.name] ?? ''}
@@ -201,47 +208,54 @@ function MoaSettingsContent({
onApiKeyChange={onApiKeyChange}
onModelChange={onModelChange}
onTest={onTest}
t={t}
/>
))}
</ul>
<MoaSettingsActions
busy={busy}
checking={checking}
<SettingsSaveBar
busy={busy || checking !== null}
dirty={dirty}
onSave={onSave}
savedAt={savedAt}
label={t.settings.moa.save}
onSave={() => void onSave()}
savedHint={t.settings.common.savedRestartHint}
savingLabel={t.settings.common.saving}
showSavedHint={savedAt !== null && !dirty}
/>
</>
);
}
function formatMoaStatus(status: MoaReferenceStatus | null): string {
if (!status) return '연결 테스트 전';
function formatMoaStatus(
status: MoaReferenceStatus | null,
t: Messages,
): string {
if (!status) return t.settings.moa.notTested;
const at = new Date(status.checkedAt);
const checkedAt = Number.isNaN(at.getTime())
? status.checkedAt
: at.toLocaleString('ko-KR');
if (status.ok) return `정상 · ${checkedAt}`;
return `실패 · ${checkedAt} · ${status.error ?? 'unknown error'}`;
if (status.ok) {
return `${t.settings.moa.statusOk} · ${checkedAt}`;
}
return `${t.settings.moa.statusFail} · ${checkedAt} · ${status.error ?? 'unknown error'}`;
}
function MoaMasterToggle({
busy,
enabled,
onToggle,
t,
}: {
busy: boolean;
enabled: boolean;
onToggle: () => void;
t: Messages;
}) {
return (
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">MoA </span>
<small className="settings-hint">
Arbiter .
.
</small>
<span className="settings-toggle-title">{t.settings.moa.master}</span>
<small className="settings-hint">{t.settings.moa.masterHint}</small>
</span>
<input
checked={enabled}
@@ -253,38 +267,6 @@ function MoaMasterToggle({
);
}
function MoaSettingsActions({
busy,
checking,
dirty,
onSave,
savedAt,
}: {
busy: boolean;
checking: string | null;
dirty: boolean;
onSave: () => Promise<void>;
savedAt: number | null;
}) {
return (
<div className="settings-actions">
<button
className="settings-save"
disabled={!dirty || busy || checking !== null}
onClick={() => void onSave()}
type="button"
>
{busy ? '저장 중…' : 'MoA 저장'}
</button>
{savedAt && !dirty ? (
<small className="settings-hint">
. .
</small>
) : null}
</div>
);
}
function MoaModelRow({
apiKeyValue,
busy,
@@ -294,6 +276,7 @@ function MoaModelRow({
onApiKeyChange,
onModelChange,
onTest,
t,
}: {
apiKeyValue: string;
busy: boolean;
@@ -306,10 +289,11 @@ function MoaModelRow({
patch: Partial<MoaModelSettingsSnapshot>,
) => void;
onTest: (name: string) => Promise<void>;
t: Messages;
}) {
return (
<li className="settings-account-row settings-moa-row">
<div className="settings-moa-grid">
<li className="settings-moa-card">
<header className="settings-moa-card-head">
<label className="settings-moa-name">
<input
checked={model.enabled}
@@ -321,49 +305,69 @@ function MoaModelRow({
/>
<span className="settings-account-tag">{model.name}</span>
</label>
<input
aria-label={`${model.name} MoA model`}
onChange={(e) => onModelChange(model.name, { model: e.target.value })}
placeholder="model"
type="text"
value={model.model}
/>
<input
aria-label={`${model.name} MoA base URL`}
onChange={(e) =>
onModelChange(model.name, { baseUrl: e.target.value })
}
placeholder="base URL"
type="text"
value={model.baseUrl}
/>
<select
aria-label={`${model.name} MoA API format`}
onChange={(e) =>
onModelChange(model.name, {
apiFormat: e.target.value as 'openai' | 'anthropic',
})
}
value={model.apiFormat}
>
<option value="anthropic">anthropic</option>
<option value="openai">openai</option>
</select>
<input
aria-label={`${model.name} MoA API key`}
onChange={(e) => onApiKeyChange(model.name, e.target.value)}
placeholder={model.apiKeyConfigured ? 'API key set' : 'new API key'}
type="password"
value={apiKeyValue}
/>
<span
className={`settings-account-badge ${
model.lastStatus?.ok === false ? 'is-expired' : 'is-active'
}`}
title={model.lastStatus?.error ?? undefined}
>
{formatMoaStatus(model.lastStatus)}
{formatMoaStatus(model.lastStatus, t)}
</span>
</header>
<div className="settings-moa-fields">
<label className="settings-row">
<span className="settings-label">{t.settings.moa.modelLabel}</span>
<input
aria-label={`${model.name} MoA model`}
onChange={(e) =>
onModelChange(model.name, { model: e.target.value })
}
placeholder="model"
type="text"
value={model.model}
/>
</label>
<label className="settings-row">
<span className="settings-label">{t.settings.moa.baseUrlLabel}</span>
<input
aria-label={`${model.name} MoA base URL`}
onChange={(e) =>
onModelChange(model.name, { baseUrl: e.target.value })
}
placeholder="https://…"
type="text"
value={model.baseUrl}
/>
</label>
<label className="settings-row">
<span className="settings-label">{t.settings.moa.formatLabel}</span>
<select
aria-label={`${model.name} MoA API format`}
onChange={(e) =>
onModelChange(model.name, {
apiFormat: e.target.value as 'openai' | 'anthropic',
})
}
value={model.apiFormat}
>
<option value="anthropic">anthropic</option>
<option value="openai">openai</option>
</select>
</label>
<label className="settings-row">
<span className="settings-label">API key</span>
<input
aria-label={`${model.name} MoA API key`}
onChange={(e) => onApiKeyChange(model.name, e.target.value)}
placeholder={
model.apiKeyConfigured
? t.settings.moa.apiKeySet
: t.settings.moa.apiKeyPlaceholder
}
type="password"
value={apiKeyValue}
/>
</label>
</div>
<div className="settings-account-actions">
<button
@@ -375,10 +379,10 @@ function MoaModelRow({
type="button"
>
{checking === model.name
? '테스트중…'
? t.settings.moa.testing
: dirty
? '저장 후 테스트'
: '연결 테스트'}
? t.settings.moa.testAfterSave
: t.settings.moa.test}
</button>
</div>
</li>

View File

@@ -1,226 +0,0 @@
.runtime-inventory {
display: grid;
gap: 18px;
}
.runtime-summary-card,
.runtime-agent-card,
.runtime-skill-card {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 20px;
background: rgba(15, 23, 42, 0.48);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.runtime-summary-card {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
padding: 18px;
}
.runtime-summary-card > div {
display: grid;
gap: 6px;
min-width: 0;
}
.runtime-summary-card strong {
color: var(--text);
font-size: 1rem;
}
.runtime-summary-card code,
.runtime-path-row code,
.runtime-skill-card code {
color: var(--muted);
font-size: 0.78rem;
overflow-wrap: anywhere;
}
.runtime-agent-card {
padding: 18px;
}
.runtime-card-head,
.runtime-skill-card header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.runtime-card-head h4 {
margin: 0;
}
.runtime-path-list,
.runtime-skill-list {
display: grid;
gap: 10px;
margin: 14px 0 0;
padding: 0;
list-style: none;
}
.runtime-path-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
border-radius: 14px;
background: rgba(15, 23, 42, 0.38);
padding: 12px;
}
.runtime-path-row > span {
display: grid;
gap: 4px;
min-width: 0;
}
.runtime-skill-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.runtime-skill-card {
padding: 14px;
}
.runtime-skill-card header > div {
display: grid;
gap: 4px;
min-width: 0;
}
.runtime-skill-list li {
display: grid;
gap: 3px;
border-top: 1px solid rgba(148, 163, 184, 0.12);
padding-top: 9px;
}
.runtime-skill-list li:first-child {
border-top: 0;
padding-top: 0;
}
.runtime-skill-list span {
color: var(--muted);
font-size: 0.82rem;
line-height: 1.4;
}
.runtime-room-skill-list {
display: grid;
gap: 12px;
margin-top: 14px;
}
.runtime-room-skill-card {
display: grid;
gap: 12px;
border-radius: 16px;
background: rgba(15, 23, 42, 0.38);
padding: 14px;
}
.runtime-room-skill-card > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.runtime-room-skill-card > header > div {
display: grid;
gap: 3px;
min-width: 0;
}
.runtime-room-skill-card span,
.runtime-room-agent-policy small {
color: var(--muted);
}
.runtime-room-skill-card code {
color: var(--muted);
font-size: 0.72rem;
overflow-wrap: anywhere;
}
.runtime-room-agent-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.runtime-room-agent-policy {
display: grid;
gap: 4px;
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 14px;
padding: 12px;
}
.runtime-room-agent-policy > span {
color: var(--text);
font-size: 0.86rem;
}
.runtime-room-skill-toggles {
display: grid;
gap: 8px;
margin-top: 8px;
}
.runtime-room-skill-toggle {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 44px;
border-radius: 12px;
background: rgba(15, 23, 42, 0.38);
padding: 8px 10px;
}
.runtime-room-skill-toggle input {
width: 18px;
height: 18px;
}
.runtime-room-skill-toggle > span {
display: grid;
gap: 2px;
min-width: 0;
}
.runtime-room-skill-toggle strong {
color: var(--text);
font-size: 0.84rem;
}
.runtime-room-skill-toggle small {
color: var(--muted);
}
@media (max-width: 980px) {
.runtime-summary-card,
.runtime-skill-grid,
.runtime-room-agent-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.runtime-card-head,
.runtime-skill-card header,
.runtime-path-row,
.runtime-room-skill-card > header {
flex-direction: column;
}
}

View File

@@ -1,258 +1,85 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
fetchRoomSkillSettings,
fetchRuntimeInventory,
updateRoomSkillSetting,
type RuntimeAgentInventory,
type RuntimeInventorySnapshot,
type RuntimePathSnapshot,
type RuntimeSkillDirSnapshot,
type RoomSkillCatalogItem,
type RoomSkillSettingUpdateInput,
type RoomSkillSettingsSnapshot,
} from './api';
import { SettingsSectionHeading } from './SettingsPanelChrome';
import './RuntimeInventorySettings.css';
import { SettingsCard, SettingsSectionHeading } from './SettingsPanelChrome';
import { type Messages } from './i18n';
function ExistsBadge({ exists }: { exists: boolean }) {
return (
<span className={`settings-account-badge ${exists ? 'is-active' : ''}`}>
{exists ? '감지됨' : '없음'}
</span>
);
function agentLabel(agentType: 'claude-code' | 'codex', t: Messages): string {
return agentType === 'codex'
? t.settings.runtime.agentCodex
: t.settings.runtime.agentClaude;
}
function PathRow({ item }: { item: RuntimePathSnapshot }) {
return (
<li className="runtime-path-row">
<span>
<strong>{item.label}</strong>
<code>{item.path}</code>
</span>
<ExistsBadge exists={item.exists} />
</li>
);
function scopeLabel(scope: RoomSkillCatalogItem['scope'], t: Messages): string {
if (scope === 'codex-user') return t.settings.runtime.scopeCodexUser;
if (scope === 'claude-user') return t.settings.runtime.scopeClaudeUser;
return t.settings.runtime.scopeRunner;
}
function SkillDirCard({ dir }: { dir: RuntimeSkillDirSnapshot }) {
const preview = dir.skills.slice(0, 6);
return (
<article className="runtime-skill-card">
<header>
<div>
<strong>{dir.label}</strong>
<code>{dir.path}</code>
</div>
<span className="settings-account-badge is-active">
{dir.count} skills
</span>
</header>
{preview.length === 0 ? (
<p className="settings-hint"> SKILL.md </p>
) : (
<ul className="runtime-skill-list">
{preview.map((skill) => (
<li key={skill.path}>
<strong>{skill.name}</strong>
{skill.description ? <span>{skill.description}</span> : null}
</li>
))}
</ul>
)}
{dir.count > preview.length ? (
<small className="settings-hint">
{dir.count - preview.length}
</small>
) : null}
</article>
);
function applyOptimisticToggle(
snapshot: RoomSkillSettingsSnapshot,
input: RoomSkillSettingUpdateInput,
): RoomSkillSettingsSnapshot {
const { roomJid, agentType, skillId, enabled } = input;
return {
...snapshot,
rooms: snapshot.rooms.map((room) => {
if (room.jid !== roomJid) return room;
return {
...room,
agents: room.agents.map((agent) => {
if (agent.agentType !== agentType) return agent;
const disabledSkillIds = enabled
? agent.disabledSkillIds.filter((id) => id !== skillId)
: agent.disabledSkillIds.includes(skillId)
? agent.disabledSkillIds
: [...agent.disabledSkillIds, skillId];
const effectiveEnabledSkillIds = enabled
? agent.availableSkillIds.filter(
(id) => !disabledSkillIds.includes(id),
)
: agent.availableSkillIds.filter(
(id) => id !== skillId && !disabledSkillIds.includes(id),
);
return {
...agent,
mode: disabledSkillIds.length === 0 ? 'all-enabled' : 'custom',
disabledSkillIds,
effectiveEnabledSkillIds,
};
}),
};
}),
};
}
function AgentInventoryCard({
title,
inventory,
}: {
title: string;
inventory: RuntimeAgentInventory;
}) {
return (
<article className="runtime-agent-card">
<header className="runtime-card-head">
<h4>{title}</h4>
<span className="settings-account-badge is-active">
MCP {inventory.mcp.ejclawConfigured ? '연결' : '미감지'}
</span>
</header>
<ul className="runtime-path-list">
{inventory.configFiles.map((item) => (
<PathRow item={item} key={item.path} />
))}
<PathRow item={inventory.mcp.configPath} />
</ul>
<p className="settings-hint">
MCP servers {inventory.mcp.serverCount} · EJClaw section{' '}
{inventory.mcp.ejclawConfigured ? '있음' : '없음'}
</p>
<div className="runtime-skill-grid">
{inventory.skillDirs.map((dir) => (
<SkillDirCard dir={dir} key={dir.path} />
))}
</div>
</article>
);
}
function agentLabel(agentType: 'claude-code' | 'codex') {
return agentType === 'codex' ? 'Codex' : 'Claude Code';
}
function RoomSkillPolicyCard({
onToggle,
savingKey,
snapshot,
}: {
onToggle: (input: RoomSkillSettingUpdateInput) => void;
savingKey: string | null;
snapshot: RoomSkillSettingsSnapshot | null;
}) {
if (!snapshot) {
return (
<article className="runtime-agent-card">
<header className="runtime-card-head">
<h4> </h4>
</header>
<p className="settings-hint"> </p>
</article>
);
}
const catalogById = new Map<string, RoomSkillCatalogItem>(
snapshot.catalog.map((skill) => [skill.id, skill]),
);
const roomPreview = snapshot.rooms.slice(0, 8);
return (
<article className="runtime-agent-card">
<header className="runtime-card-head">
<div>
<h4> </h4>
<p className="settings-hint">
. PR에서
enable/disable .
</p>
</div>
<span className="settings-account-badge is-active">
{snapshot.catalog.length} skills · {snapshot.rooms.length} rooms
</span>
</header>
{roomPreview.length === 0 ? (
<p className="settings-hint"> .</p>
) : (
<div className="runtime-room-skill-list">
{roomPreview.map((room) => (
<section className="runtime-room-skill-card" key={room.jid}>
<header>
<div>
<strong>{room.name}</strong>
<span>{room.folder}</span>
</div>
<code>{room.jid}</code>
</header>
<div className="runtime-room-agent-grid">
{room.agents.map((agent) => {
const disabledNames = agent.disabledSkillIds
.map((id) => catalogById.get(id)?.displayName ?? id)
.slice(0, 3);
const enabledIds = new Set(agent.effectiveEnabledSkillIds);
return (
<div
className="runtime-room-agent-policy"
key={`${room.jid}:${agent.agentType}`}
>
<strong>{agentLabel(agent.agentType)}</strong>
<span>
{agent.mode === 'all-enabled'
? '기본 전체 ON'
: `${agent.disabledSkillIds.length}개 OFF`}
</span>
<small>
{agent.effectiveEnabledSkillIds.length} / {' '}
{agent.availableSkillIds.length}
</small>
{disabledNames.length > 0 ? (
<small>OFF: {disabledNames.join(', ')}</small>
) : null}
<div className="runtime-room-skill-toggles">
{agent.availableSkillIds.map((skillId) => {
const skill = catalogById.get(skillId);
const key = `${room.jid}:${agent.agentType}:${skillId}`;
return (
<label
className="runtime-room-skill-toggle"
key={skillId}
>
<input
checked={enabledIds.has(skillId)}
disabled={savingKey !== null}
onChange={(event) =>
onToggle({
roomJid: room.jid,
agentType: agent.agentType,
skillId,
enabled: event.currentTarget.checked,
})
}
type="checkbox"
/>
<span>
<strong>{skill?.displayName ?? skillId}</strong>
<small>
{skill?.scope ?? 'unknown'}
{savingKey === key ? ' · 저장 중' : ''}
</small>
</span>
</label>
);
})}
</div>
</div>
);
})}
</div>
</section>
))}
</div>
)}
{snapshot.rooms.length > roomPreview.length ? (
<small className="settings-hint">
{snapshot.rooms.length - roomPreview.length}
</small>
) : null}
</article>
);
}
export function RuntimeInventorySettings() {
const [snapshot, setSnapshot] = useState<RuntimeInventorySnapshot | null>(
export function RuntimeInventorySettings({ t }: { t: Messages }) {
const [snapshot, setSnapshot] = useState<RoomSkillSettingsSnapshot | null>(
null,
);
const [roomSkillSnapshot, setRoomSkillSnapshot] =
useState<RoomSkillSettingsSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [roomSkillError, setRoomSkillError] = useState<string | null>(null);
const [savingRoomSkillKey, setSavingRoomSkillKey] = useState<string | null>(
null,
);
const [savingKey, setSavingKey] = useState<string | null>(null);
const [selectedRoomJid, setSelectedRoomJid] = useState<string>('');
const [selectedAgentType, setSelectedAgentType] = useState<
'claude-code' | 'codex'
>('codex');
useEffect(() => {
let cancelled = false;
Promise.all([fetchRuntimeInventory(), fetchRoomSkillSettings()])
.then(([value, roomSkills]) => {
fetchRoomSkillSettings()
.then((value) => {
if (cancelled) return;
setSnapshot(value);
setRoomSkillSnapshot(roomSkills);
setError(null);
})
.catch((err) => {
@@ -264,17 +91,65 @@ export function RuntimeInventorySettings() {
};
}, []);
async function handleRoomSkillToggle(input: RoomSkillSettingUpdateInput) {
const selectedRoom = useMemo(
() => snapshot?.rooms.find((room) => room.jid === selectedRoomJid) ?? null,
[snapshot, selectedRoomJid],
);
const selectedAgent = useMemo(
() =>
selectedRoom?.agents.find(
(agent) => agent.agentType === selectedAgentType,
) ?? null,
[selectedRoom, selectedAgentType],
);
useEffect(() => {
if (!snapshot || snapshot.rooms.length === 0) return;
if (!snapshot.rooms.some((room) => room.jid === selectedRoomJid)) {
setSelectedRoomJid(snapshot.rooms[0]?.jid ?? '');
}
}, [snapshot, selectedRoomJid]);
useEffect(() => {
if (!selectedRoom || selectedRoom.agents.length === 0) return;
if (
!selectedRoom.agents.some(
(agent) => agent.agentType === selectedAgentType,
)
) {
setSelectedAgentType(selectedRoom.agents[0]?.agentType ?? 'codex');
}
}, [selectedRoom, selectedAgentType]);
const catalogById = useMemo(
() =>
new Map<string, RoomSkillCatalogItem>(
snapshot?.catalog.map((skill) => [skill.id, skill]) ?? [],
),
[snapshot],
);
async function handleToggle(input: RoomSkillSettingUpdateInput) {
const key = `${input.roomJid}:${input.agentType}:${input.skillId}`;
setSavingRoomSkillKey(key);
setRoomSkillError(null);
setError(null);
setSnapshot((current) =>
current ? applyOptimisticToggle(current, input) : current,
);
setSavingKey(key);
try {
const next = await updateRoomSkillSetting(input);
setRoomSkillSnapshot(next);
setSnapshot(next);
} catch (err) {
setRoomSkillError(err instanceof Error ? err.message : String(err));
setError(err instanceof Error ? err.message : String(err));
try {
setSnapshot(await fetchRoomSkillSettings());
} catch {
/* keep optimistic state if refetch also fails */
}
} finally {
setSavingRoomSkillKey(null);
setSavingKey(null);
}
}
@@ -286,55 +161,112 @@ export function RuntimeInventorySettings() {
role="tabpanel"
>
<SettingsSectionHeading
detail="Runtime inventory"
title="런타임"
description="Codex/Claude Code 설정, 스킬, MCP 연결 상태를 읽기 전용으로 확인합니다."
description={t.settings.sections.runtime.description}
detail={t.settings.sections.runtime.kicker}
title={t.settings.sections.runtime.title}
/>
<p className="settings-hint settings-skill-default-hint">
{t.settings.runtime.defaultHint}
</p>
{error ? <p className="settings-error">{error}</p> : null}
{!snapshot ? (
<p className="settings-hint"> </p>
<p className="settings-hint">{t.settings.common.loading}</p>
) : snapshot.rooms.length === 0 ? (
<SettingsCard title={t.settings.runtime.selectRoomLabel}>
<p className="settings-hint">{t.settings.runtime.emptyRooms}</p>
</SettingsCard>
) : (
<div className="runtime-inventory">
<section className="runtime-summary-card">
<div>
<span className="settings-kicker">Current service</span>
<strong>{snapshot.service.id}</strong>
<small>
{snapshot.service.agentType} · session{' '}
{snapshot.service.sessionScope}
</small>
</div>
<div>
<span className="settings-kicker">Project</span>
<code>{snapshot.projectRoot}</code>
<small>data {snapshot.dataDir}</small>
</div>
</section>
<SettingsCard title={t.settings.runtime.selectRoomLabel}>
<div className="settings-form-grid settings-skill-controls">
<label className="settings-row">
<span className="settings-label">
{t.settings.runtime.selectRoomLabel}
</span>
<select
onChange={(event) => setSelectedRoomJid(event.target.value)}
value={selectedRoomJid}
>
{snapshot.rooms.map((room) => (
<option key={room.jid} value={room.jid}>
{room.name} ({room.folder})
</option>
))}
</select>
</label>
<AgentInventoryCard title="Codex" inventory={snapshot.codex} />
<AgentInventoryCard title="Claude Code" inventory={snapshot.claude} />
{roomSkillError ? (
<p className="settings-error">{roomSkillError}</p>
) : null}
<RoomSkillPolicyCard
onToggle={(input) => {
void handleRoomSkillToggle(input);
}}
savingKey={savingRoomSkillKey}
snapshot={roomSkillSnapshot}
/>
{selectedRoom && selectedRoom.agents.length > 1 ? (
<div className="settings-row">
<span className="settings-label">
{t.settings.runtime.selectAgentLabel}
</span>
<div className="settings-skill-agent-tabs" role="tablist">
{selectedRoom.agents.map((agent) => (
<button
aria-selected={selectedAgentType === agent.agentType}
className={
selectedAgentType === agent.agentType
? 'is-active'
: undefined
}
key={agent.agentType}
onClick={() => setSelectedAgentType(agent.agentType)}
role="tab"
type="button"
>
{agentLabel(agent.agentType, t)}
</button>
))}
</div>
</div>
) : null}
</div>
<article className="runtime-agent-card">
<header className="runtime-card-head">
<h4>EJClaw bridge</h4>
<ExistsBadge exists={snapshot.ejclaw.mcpServer.exists} />
</header>
<ul className="runtime-path-list">
<PathRow item={snapshot.ejclaw.mcpServer} />
<PathRow item={snapshot.ejclaw.runnerSkillDir} />
</ul>
</article>
</div>
{!selectedAgent || selectedAgent.availableSkillIds.length === 0 ? (
<p className="settings-hint">{t.settings.runtime.emptySkills}</p>
) : (
<div className="settings-toggle-stack">
{selectedAgent.availableSkillIds.map((skillId) => {
const skill = catalogById.get(skillId);
const key = `${selectedRoomJid}:${selectedAgent.agentType}:${skillId}`;
const enabled =
selectedAgent.effectiveEnabledSkillIds.includes(skillId);
return (
<label
aria-busy={savingKey === key}
className={`settings-toggle-row${savingKey === key ? ' is-busy' : ''}`}
key={skillId}
>
<span className="settings-toggle-label">
<span className="settings-toggle-title">
{skill?.displayName ?? skillId}
</span>
<small>
{skill ? scopeLabel(skill.scope, t) : null}
{skill?.description ? ` · ${skill.description}` : null}
</small>
</span>
<input
checked={enabled}
disabled={savingKey === key}
onChange={(event) =>
void handleToggle({
roomJid: selectedRoomJid,
agentType: selectedAgent.agentType,
skillId,
enabled: event.currentTarget.checked,
})
}
type="checkbox"
/>
</label>
);
})}
</div>
)}
</SettingsCard>
)}
</section>
);

View File

@@ -0,0 +1,219 @@
import { type ModelConfigSnapshot, type ModelRoleConfig } from './api';
import { type Messages } from './i18n';
import {
type AgentType,
type EffortValue,
effortValuesForAgent,
formatEffortOption,
isEffortSupported,
isPresetModel,
PRESET_MODELS,
} from './settings-options';
const MODEL_ROLES = ['owner', 'reviewer', 'arbiter'] as const;
export type ModelRole = (typeof MODEL_ROLES)[number];
function modelRoleLabel(role: ModelRole, t: Messages): string {
if (role === 'owner') return t.settings.models.roleOwner;
if (role === 'reviewer') return t.settings.models.roleReviewer;
return t.settings.models.roleArbiter;
}
function modelRoleHint(role: ModelRole, t: Messages): string {
if (role === 'owner') return t.settings.models.roleOwnerHint;
if (role === 'reviewer') return t.settings.models.roleReviewerHint;
return t.settings.models.roleArbiterHint;
}
function agentTypeLabel(agentType: AgentType, t: Messages): string {
return agentType === 'codex'
? t.settings.models.agentTypeCodex
: t.settings.models.agentTypeClaude;
}
function effortLabel(value: EffortValue, t: Messages): string {
if (value === '') return t.settings.models.effortDefault;
const key = value as keyof typeof t.settings.models.effortOptions;
const localized = t.settings.models.effortOptions[key] ?? value;
return formatEffortOption(value, localized);
}
function modelGroupLabel(model: string, t: Messages): string {
if ((PRESET_MODELS.codex as readonly string[]).includes(model)) {
return t.settings.models.groupCodex;
}
if ((PRESET_MODELS.claude as readonly string[]).includes(model)) {
return t.settings.models.groupClaude;
}
return t.settings.models.groupCustom;
}
function effortOptionsForRole(
draft: ModelConfigSnapshot,
role: ModelRole,
): readonly EffortValue[] {
const agentType = draft.agentTypes[role];
if (agentType) return effortValuesForAgent(agentType);
return effortValuesForAgent('codex');
}
export function hasUnsupportedModelEffort(draft: ModelConfigSnapshot): boolean {
for (const role of MODEL_ROLES) {
const agentType = draft.agentTypes[role];
if (!agentType) continue;
if (!isEffortSupported(agentType, draft[role].effort)) return true;
}
return false;
}
export function ModelRoleFields({
draft,
onChange,
t,
}: {
draft: ModelConfigSnapshot;
onChange: (role: ModelRole, patch: Partial<ModelRoleConfig>) => void;
t: Messages;
}) {
return (
<div className="settings-model-stack">
{MODEL_ROLES.map((role) => {
const roleConfig = draft[role];
const agentType = draft.agentTypes[role];
const effortOptions = effortOptionsForRole(draft, role);
const effortInvalid =
agentType !== null &&
roleConfig.effort !== '' &&
!isEffortSupported(agentType, roleConfig.effort);
const usingCustom =
roleConfig.model.trim() !== '' && !isPresetModel(roleConfig.model);
return (
<article className="settings-model-card" key={role}>
<header className="settings-model-card-head">
<span className="settings-kicker">{role}</span>
<strong>{modelRoleLabel(role, t)}</strong>
<p className="settings-hint">{modelRoleHint(role, t)}</p>
{agentType ? (
<p className="settings-inline-meta">
{t.settings.models.agentTypeLabel}:{' '}
{agentTypeLabel(agentType, t)}
</p>
) : null}
</header>
<div className="settings-model-fields">
<label className="settings-row">
<span className="settings-label">
{t.settings.models.modelLabel}
</span>
<select
aria-label={`${modelRoleLabel(role, t)} ${t.settings.models.modelLabel}`}
onChange={(event) => {
const value = event.target.value;
if (value === '__custom__') {
onChange(role, {
model: usingCustom ? roleConfig.model : '',
});
return;
}
onChange(role, { model: value });
}}
value={usingCustom ? '__custom__' : roleConfig.model}
>
<option value="">{t.settings.models.modelDefault}</option>
<optgroup label={t.settings.models.groupCodex}>
{PRESET_MODELS.codex.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</optgroup>
<optgroup label={t.settings.models.groupClaude}>
{PRESET_MODELS.claude.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</optgroup>
<option value="__custom__">
{t.settings.models.modelCustom}
</option>
</select>
</label>
{usingCustom ? (
<label className="settings-row">
<span className="settings-label">
{t.settings.models.modelCustomLabel}
</span>
<input
onChange={(event) =>
onChange(role, { model: event.target.value })
}
placeholder={t.settings.models.modelPlaceholder}
type="text"
value={roleConfig.model}
/>
</label>
) : null}
<label className="settings-row">
<span className="settings-label">
{t.settings.models.effortLabel}
</span>
<select
aria-describedby={
effortInvalid ? `settings-effort-warn-${role}` : undefined
}
aria-invalid={effortInvalid || undefined}
aria-label={`${modelRoleLabel(role, t)} ${t.settings.models.effortLabel}`}
onChange={(event) =>
onChange(role, { effort: event.target.value })
}
value={roleConfig.effort}
>
{effortOptions.map((value) => (
<option key={value || 'default'} value={value}>
{effortLabel(value, t)}
</option>
))}
{effortInvalid ? (
<option value={roleConfig.effort}>
{effortLabel(roleConfig.effort as EffortValue, t)}
</option>
) : null}
</select>
</label>
{effortInvalid && agentType ? (
<p
className="settings-effort-warn"
id={`settings-effort-warn-${role}`}
role="alert"
>
{t.settings.models.effortInvalid
.replace('{value}', roleConfig.effort)
.replace('{agent}', agentTypeLabel(agentType, t))}
</p>
) : null}
{roleConfig.model || roleConfig.effort ? (
<p className="settings-inline-meta">
{roleConfig.model
? modelGroupLabel(roleConfig.model, t)
: t.settings.models.modelDefault}
{' · '}
{roleConfig.effort
? effortLabel(roleConfig.effort as EffortValue, t)
: t.settings.models.effortDefault}
</p>
) : null}
</div>
</article>
);
})}
</div>
);
}

View File

@@ -47,11 +47,13 @@ describe('SettingsPanel', () => {
expect(html).not.toContain('href="#settings-codex"');
expect(html).toContain('settings-apply-card');
expect(html).not.toContain('settings-apply-bar');
expect(html).toContain('저장 후 재시작');
expect(html).toContain('런타임');
expect(html).toContain('Claude');
expect(html).toContain('계정');
expect(html).toContain('스택 재시작');
expect(html).toContain(t.settings.apply.title);
expect(html).toContain(t.settings.sections.runtime.title);
expect(html).toContain(t.settings.accounts.claude);
expect(html).toContain(t.settings.nav.accounts.title);
expect(html).toContain(t.settings.apply.restart);
expect(html.match(/class="settings-restart"/g)).toHaveLength(1);
expect(html).toContain('settings-card');
expect(html).toContain('settings-section-stack');
});
});

View File

@@ -29,10 +29,17 @@ import {
import { type Locale, type Messages } from './i18n';
import { MoaSettingsPanel } from './MoaSettingsPanel';
import { RuntimeInventorySettings } from './RuntimeInventorySettings';
import {
ModelRoleFields,
hasUnsupportedModelEffort,
type ModelRole,
} from './SettingsModelFields';
import {
GeneralSettings,
SettingsApplyCard,
SettingsCard,
SettingsNav,
SettingsSaveBar,
SettingsSectionHeading,
type SettingsSectionId,
} from './SettingsPanelChrome';
@@ -68,14 +75,15 @@ export function SettingsPanel({
return (
<div className="settings-panel">
<div className="settings-layout">
<aside className="settings-sidebar" aria-label="설정 탐색과 적용">
<aside className="settings-sidebar" aria-label={t.settings.sidebarAria}>
<SettingsNav
activeSection={activeSection}
onSelect={setActiveSection}
t={t}
/>
<SettingsApplyCard onRestartStack={onRestartStack} />
<SettingsApplyCard onRestartStack={onRestartStack} t={t} />
</aside>
<main className="settings-content" aria-label="설정 항목">
<main className="settings-content" aria-label={t.settings.contentAria}>
<div hidden={activeSection !== 'settings-general'}>
<GeneralSettings
locale={locale}
@@ -87,23 +95,23 @@ export function SettingsPanel({
</div>
<div hidden={activeSection !== 'settings-models'}>
<ModelSettings />
<ModelSettings t={t} />
</div>
<div hidden={activeSection !== 'settings-runtime'}>
<RuntimeInventorySettings />
<RuntimeInventorySettings t={t} />
</div>
<div hidden={activeSection !== 'settings-moa'}>
<MoaSettingsPanel />
<MoaSettingsPanel t={t} />
</div>
<div hidden={activeSection !== 'settings-codex'}>
<CodexRuntimeSettings />
<CodexRuntimeSettings t={t} />
</div>
<div hidden={activeSection !== 'settings-accounts'}>
<AccountSettings />
<AccountSettings t={t} />
</div>
</main>
</div>
@@ -111,12 +119,13 @@ export function SettingsPanel({
);
}
function ModelSettings() {
function ModelSettings({ t }: { t: Messages }) {
const [config, setConfig] = useState<ModelConfigSnapshot | null>(null);
const [draft, setDraft] = useState<ModelConfigSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(null);
const section = t.settings.sections.models;
useEffect(() => {
let cancelled = false;
@@ -142,10 +151,18 @@ function ModelSettings() {
async function save() {
if (!draft || !config) return;
if (hasUnsupportedModelEffort(draft)) {
setError(t.settings.models.effortSaveBlocked);
return;
}
setBusy(true);
setError(null);
try {
const next = await updateModels(draft);
const next = await updateModels({
owner: draft.owner,
reviewer: draft.reviewer,
arbiter: draft.arbiter,
});
setConfig(next);
setDraft(next);
setSavedAt(Date.now());
@@ -156,10 +173,7 @@ function ModelSettings() {
}
}
function setRole(
role: keyof ModelConfigSnapshot,
patch: Partial<ModelRoleConfig>,
) {
function setRole(role: ModelRole, patch: Partial<ModelRoleConfig>) {
setDraft((prev) =>
prev
? {
@@ -174,6 +188,7 @@ function ModelSettings() {
draft !== null &&
config !== null &&
JSON.stringify(draft) !== JSON.stringify(config);
const effortBlocked = draft !== null && hasUnsupportedModelEffort(draft);
return (
<section
@@ -183,59 +198,41 @@ function ModelSettings() {
role="tabpanel"
>
<SettingsSectionHeading
detail="Agent routing"
title="모델"
description="역할별 모델과 reasoning effort를 지정합니다."
description={section.description}
detail={section.kicker}
title={section.title}
/>
{error ? <p className="settings-error">{error}</p> : null}
{!draft ? (
<p className="settings-hint">
{busy ? '불러오는 중…' : '모델 정보 없음'}
{busy ? t.settings.common.loading : t.settings.models.empty}
</p>
) : (
<>
{(['owner', 'reviewer', 'arbiter'] as const).map((role) => (
<div className="settings-row settings-row-inline" key={role}>
<span className="settings-label">{role}</span>
<input
aria-label={`${role} model`}
onChange={(e) => setRole(role, { model: e.target.value })}
placeholder="claude / codex / claude-opus-4-7 …"
type="text"
value={draft[role].model}
/>
<input
aria-label={`${role} effort`}
className="settings-input-narrow"
onChange={(e) => setRole(role, { effort: e.target.value })}
placeholder="effort"
type="text"
value={draft[role].effort}
/>
</div>
))}
<div className="settings-actions">
<button
className="settings-save"
disabled={!dirty || busy}
onClick={() => void save()}
type="button"
>
{busy ? '저장 중…' : '모델 저장'}
</button>
{savedAt && !dirty ? (
<small className="settings-hint">
. .
</small>
) : null}
</div>
<ModelRoleFields
draft={draft}
onChange={(role, patch) => setRole(role, patch)}
t={t}
/>
<SettingsSaveBar
busy={busy}
dirty={dirty}
label={t.settings.models.save}
onSave={() => void save()}
saveDisabled={effortBlocked}
savedHint={t.settings.common.savedRestartHint}
savingLabel={t.settings.common.saving}
showSavedHint={savedAt !== null && !dirty}
/>
</>
)}
</section>
);
}
function CodexRuntimeSettings() {
function CodexRuntimeSettings({ t }: { t: Messages }) {
const section = t.settings.sections.codex;
return (
<section
aria-labelledby="settings-codex-tab"
@@ -244,19 +241,19 @@ function CodexRuntimeSettings() {
role="tabpanel"
>
<SettingsSectionHeading
detail="Codex runtime"
title="Codex 옵션"
description="빠른 응답과 실험 기능을 관리합니다. /goal은 여기에서 찾을 수 있습니다."
description={section.description}
detail={section.kicker}
title={section.title}
/>
<div className="settings-section-stack">
<FastModeSettings />
<CodexFeatureSettings />
<FastModeSettings t={t} />
<CodexFeatureSettings t={t} />
</div>
</section>
);
}
function FastModeSettings() {
function FastModeSettings({ t }: { t: Messages }) {
const [state, setState] = useState<FastModeSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -297,19 +294,19 @@ function FastModeSettings() {
}
return (
<section className="settings-subsection">
<h4> </h4>
<SettingsCard title={t.settings.codex.fastMode}>
{error ? <p className="settings-error">{error}</p> : null}
{!state ? (
<p className="settings-hint"> </p>
<p className="settings-hint">{t.settings.common.loading}</p>
) : (
<>
<div className="settings-toggle-stack">
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">Codex (GPT)</span>
<span className="settings-toggle-title">
{t.settings.codex.codexFast}
</span>
<small className="settings-hint">
~/.codex/config.toml [features].fast_mode
.
{t.settings.codex.codexFastHint}
</small>
</span>
<input
@@ -321,10 +318,11 @@ function FastModeSettings() {
</label>
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">Claude</span>
<span className="settings-toggle-title">
{t.settings.codex.claudeFast}
</span>
<small className="settings-hint">
~/.claude/settings.json fastMode /fast
. opus-4-6 .
{t.settings.codex.claudeFastHint}
</small>
</span>
<input
@@ -334,17 +332,19 @@ function FastModeSettings() {
type="checkbox"
/>
</label>
</>
<small className="settings-hint">
{t.settings.codex.fastModeApplyHint}
</small>
</div>
)}
</section>
</SettingsCard>
);
}
function CodexFeatureSettings() {
function CodexFeatureSettings({ t }: { t: Messages }) {
const [state, setState] = useState<CodexFeatureSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
@@ -373,7 +373,6 @@ function CodexFeatureSettings() {
try {
const fresh = await updateCodexFeatures({ goals: optimistic.goals });
setState(fresh);
setSavedAt(Date.now());
} catch (err) {
setState(previous);
setError(err instanceof Error ? err.message : String(err));
@@ -383,19 +382,19 @@ function CodexFeatureSettings() {
}
return (
<section className="settings-subsection">
<h4>Codex </h4>
<SettingsCard title={t.settings.codex.features}>
{error ? <p className="settings-error">{error}</p> : null}
{!state ? (
<p className="settings-hint"> </p>
<p className="settings-hint">{t.settings.common.loading}</p>
) : (
<>
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">/goal</span>
<span className="settings-toggle-title">
{t.settings.codex.goal}
</span>
<small className="settings-hint">
CODEX_GOALS=true Codex 0.128 under-development goals
. OFF이며, .
{t.settings.codex.goalHint}
</small>
</span>
<input
@@ -405,23 +404,22 @@ function CodexFeatureSettings() {
type="checkbox"
/>
</label>
{savedAt ? (
<small className="settings-hint">
. .
</small>
) : null}
<small className="settings-hint">
{t.settings.codex.goalApplyHint}
</small>
</>
)}
</section>
</SettingsCard>
);
}
function AccountSettings() {
function AccountSettings({ t }: { t: Messages }) {
const [data, setData] = useState<AccountData | null>(null);
const [busy, setBusy] = useState(false);
const [perRowBusy, setPerRowBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [tokenInput, setTokenInput] = useState('');
const section = t.settings.sections.accounts;
async function refresh() {
setBusy(true);
@@ -443,7 +441,9 @@ function AccountSettings() {
async function handleDelete(provider: AccountProvider, index: number) {
if (
!window.confirm(
`${provider} 계정 #${index} 디렉터리를 삭제합니다. 되돌릴 수 없습니다. 계속할까요?`,
t.settings.accounts.deleteConfirm
.replace('{provider}', provider)
.replace('{index}', String(index)),
)
) {
return;
@@ -497,7 +497,10 @@ function AccountSettings() {
await refresh();
if (result.failed.length > 0) {
setError(
`일부 갱신 실패: ${result.failed.map((f) => `#${f.index}`).join(', ')}`,
t.settings.accounts.refreshFailed.replace(
'{indexes}',
result.failed.map((f) => `#${f.index}`).join(', '),
),
);
}
} catch (err) {
@@ -528,28 +531,32 @@ function AccountSettings() {
role="tabpanel"
>
<SettingsSectionHeading
detail="Credentials"
title="계정"
description="Claude OAuth와 Codex 계정 상태를 확인하고 전환합니다."
description={section.description}
detail={section.kicker}
title={section.title}
/>
{error ? <p className="settings-error">{error}</p> : null}
<ClaudeAccounts
busy={busy}
data={data}
onAdd={() => void handleAdd()}
onDelete={(index) => void handleDelete('claude', index)}
onTokenInputChange={setTokenInput}
tokenInput={tokenInput}
/>
<CodexAccounts
busy={busy}
data={data}
onDelete={(index) => void handleDelete('codex', index)}
onRefresh={(index) => void handleCodexRefresh(index)}
onRefreshAll={() => void handleRefreshAllCodex()}
onSwitch={(index) => void handleSwitchCodex(index)}
perRowBusy={perRowBusy}
/>
<div className="settings-section-stack">
<ClaudeAccounts
busy={busy}
data={data}
onAdd={() => void handleAdd()}
onDelete={(index) => void handleDelete('claude', index)}
onTokenInputChange={setTokenInput}
t={t}
tokenInput={tokenInput}
/>
<CodexAccounts
busy={busy}
data={data}
onDelete={(index) => void handleDelete('codex', index)}
onRefresh={(index) => void handleCodexRefresh(index)}
onRefreshAll={() => void handleRefreshAllCodex()}
onSwitch={(index) => void handleSwitchCodex(index)}
perRowBusy={perRowBusy}
t={t}
/>
</div>
</section>
);
}
@@ -561,6 +568,7 @@ function ClaudeAccounts({
onDelete,
onTokenInputChange,
tokenInput,
t,
}: {
busy: boolean;
data: AccountData | null;
@@ -568,61 +576,78 @@ function ClaudeAccounts({
onDelete: (index: number) => void;
onTokenInputChange: (value: string) => void;
tokenInput: string;
t: Messages;
}) {
return (
<div className="settings-account-group">
<h4>Claude</h4>
<SettingsCard title={t.settings.accounts.claude}>
{!data ? (
<p className="settings-hint">{busy ? '불러오는 중…' : '없음'}</p>
<p className="settings-hint">
{busy ? t.settings.common.loading : t.settings.common.none}
</p>
) : data.claude.length === 0 ? (
<p className="settings-hint"> </p>
<p className="settings-hint">{t.settings.accounts.noAccounts}</p>
) : (
<ul className="settings-account-list">
{data.claude.map((acc) => (
<li key={acc.index} className="settings-account-row">
<div className="settings-account-main">
<span className="settings-account-tag">#{acc.index}</span>
<span className="settings-account-email">
{acc.subscriptionType ?? 'unknown'}
{acc.rateLimitTier ? ` · ${acc.rateLimitTier}` : ''}
</span>
<span className="settings-account-plan">claude</span>
<span className="settings-account-badge is-active">
</span>
</div>
{acc.index > 0 ? (
<button
className="settings-delete"
disabled={busy}
onClick={() => onDelete(acc.index)}
type="button"
>
</button>
) : (
<span className="settings-account-default"></span>
)}
<li key={acc.index}>
<article
className={`settings-account-row${acc.index === 0 ? ' is-primary' : ''}`}
>
<div className="settings-account-row-main">
<span className="settings-account-index">#{acc.index}</span>
<div className="settings-account-row-copy">
<strong>{acc.subscriptionType ?? 'unknown'}</strong>
<span className="settings-hint">
{acc.rateLimitTier ?? 'claude-code'}
{' · '}
{t.settings.accounts.autoRefresh}
</span>
</div>
{acc.index === 0 ? (
<span className="settings-pill is-primary is-compact">
{t.settings.accounts.primaryAccount}
</span>
) : null}
</div>
{acc.index > 0 ? (
<div className="settings-account-actions">
<button
className="settings-delete"
disabled={busy}
onClick={() => onDelete(acc.index)}
type="button"
>
{t.settings.common.delete}
</button>
</div>
) : null}
</article>
</li>
))}
</ul>
)}
<div className="settings-add-token">
<textarea
onChange={(e) => onTokenInputChange(e.target.value)}
placeholder="Claude OAuth 토큰 (claude CLI 로그인 후 ~/.claude/.credentials.json 에서 accessToken 값을 페이스트)"
rows={2}
value={tokenInput}
/>
<label className="settings-row">
<span className="settings-label">
{t.settings.accounts.addTokenLabel}
</span>
<textarea
onChange={(e) => onTokenInputChange(e.target.value)}
placeholder={t.settings.accounts.tokenPlaceholder}
rows={2}
value={tokenInput}
/>
</label>
<button
className="settings-save"
disabled={!tokenInput.trim() || busy}
onClick={onAdd}
type="button"
>
{t.settings.common.add}
</button>
</div>
</div>
</SettingsCard>
);
}
@@ -634,6 +659,7 @@ function CodexAccounts({
onRefreshAll,
onSwitch,
perRowBusy,
t,
}: {
busy: boolean;
data: AccountData | null;
@@ -642,46 +668,48 @@ function CodexAccounts({
onRefreshAll: () => void;
onSwitch: (index: number) => void;
perRowBusy: string | null;
t: Messages;
}) {
return (
<div className="settings-account-group">
<div className="settings-account-group-head">
<h4>Codex</h4>
<SettingsCard
actions={
<button
className="settings-secondary"
disabled={busy}
onClick={onRefreshAll}
type="button"
>
{t.settings.common.refreshAll}
</button>
</div>
}
description={t.settings.accounts.codexRefreshHint}
title={t.settings.accounts.codex}
>
{!data ? (
<p className="settings-hint">{busy ? '불러오는 중…' : '없음'}</p>
<p className="settings-hint">
{busy ? t.settings.common.loading : t.settings.common.none}
</p>
) : data.codex.length === 0 ? (
<p className="settings-hint"> </p>
<p className="settings-hint">{t.settings.accounts.noAccounts}</p>
) : (
<ul className="settings-account-list">
{data.codex.map((acc) => (
<CodexAccountRow
acc={acc}
busy={busy}
isActive={data.codexCurrentIndex === acc.index}
key={acc.index}
onDelete={onDelete}
onRefresh={onRefresh}
onSwitch={onSwitch}
perRowBusy={perRowBusy}
/>
<li key={acc.index}>
<CodexAccountRow
acc={acc}
busy={busy}
isActive={data.codexCurrentIndex === acc.index}
onDelete={onDelete}
onRefresh={onRefresh}
onSwitch={onSwitch}
perRowBusy={perRowBusy}
t={t}
/>
</li>
))}
</ul>
)}
<p className="settings-hint">
chatgpt.com wham/usage live plan·rate limit·
. JWT live fallback으로
.
</p>
</div>
</SettingsCard>
);
}
@@ -693,6 +721,7 @@ function CodexAccountRow({
onRefresh,
onSwitch,
perRowBusy,
t,
}: {
acc: CodexAccountSummary;
busy: boolean;
@@ -701,6 +730,7 @@ function CodexAccountRow({
onRefresh: (index: number) => void;
onSwitch: (index: number) => void;
perRowBusy: string | null;
t: Messages;
}) {
const liveBadge = formatLiveStatusBadge(acc.liveStatus);
const usageBadge = formatUsageBadge(acc.liveStatus);
@@ -714,54 +744,57 @@ function CodexAccountRow({
const switching = perRowBusy === `switch:${acc.index}`;
return (
<li
<article
className={`settings-account-row${isActive ? ' is-active-account' : ''}`}
>
<div className="settings-account-main">
<span className="settings-account-tag">
{isActive ? '●' : ''}#{acc.index}
</span>
{acc.email ? (
<span className="settings-account-email" title={acc.email}>
{acc.email}
<div className="settings-account-row-main">
<span className="settings-account-index">#{acc.index}</span>
<div className="settings-account-row-copy">
<strong>{acc.email ?? acc.planType ?? 'Codex account'}</strong>
<span className="settings-account-row-meta">
<span className="settings-account-plan">
{acc.planType ?? 'unknown'}
</span>
{liveBadge ? (
<span
className={`settings-account-badge ${liveBadge.cls}`}
title={liveBadge.title}
>
{liveBadge.label}
</span>
) : cachedExpiry ? (
<span
className={`settings-account-badge ${cachedExpiry.cls}`}
title={cachedExpiry.title}
>
{cachedExpiry.label}
</span>
) : null}
{usageBadge ? (
<span
className="settings-account-badge is-muted"
title={usageBadge.title}
>
{usageBadge.label}
</span>
) : null}
{checkedAt ? (
<span
className="settings-account-badge is-muted"
title={
acc.liveStatus
? 'wham/usage live checked_at'
: 'JWT subscription_last_checked cache'
}
>
{t.settings.common.refresh} {checkedAt}
</span>
) : null}
</span>
) : null}
<span className="settings-account-plan">
{acc.planType ?? 'unknown'}
</span>
{liveBadge ? (
<span
className={`settings-account-badge ${liveBadge.cls}`}
title={liveBadge.title}
>
{liveBadge.label}
</span>
) : cachedExpiry ? (
<span
className={`settings-account-badge ${cachedExpiry.cls}`}
title={cachedExpiry.title}
>
{cachedExpiry.label}
</span>
) : null}
{usageBadge ? (
<span
className="settings-account-badge is-muted"
title={usageBadge.title}
>
{usageBadge.label}
</span>
) : null}
{checkedAt ? (
<span
className="settings-account-badge is-muted"
title={
acc.liveStatus
? 'wham/usage live checked_at'
: 'JWT subscription_last_checked cache'
}
>
{checkedAt}
</div>
{isActive ? (
<span className="settings-pill is-active is-compact">
{t.settings.accounts.activeAccount}
</span>
) : null}
</div>
@@ -770,24 +803,24 @@ function CodexAccountRow({
className="settings-secondary"
disabled={busy || perRowBusy !== null}
onClick={() => onRefresh(acc.index)}
title="OAuth 토큰을 다시 받고 wham/usage live plan·limit 상태를 갱신합니다"
title={t.settings.accounts.refreshTitle}
type="button"
>
{refreshing ? '갱신중…' : '갱신'}
{refreshing
? t.settings.common.refreshing
: t.settings.common.refresh}
</button>
{!isActive ? (
<button
className="settings-secondary"
disabled={busy || perRowBusy !== null}
onClick={() => onSwitch(acc.index)}
title="이 계정으로 즉시 전환합니다 (다음 codex 호출부터 적용)"
title={t.settings.accounts.switchTitle}
type="button"
>
{switching ? '전환중…' : '전환'}
{switching ? t.settings.common.switching : t.settings.common.switch}
</button>
) : (
<span className="settings-account-default"></span>
)}
) : null}
{acc.index > 0 ? (
<button
className="settings-delete"
@@ -795,10 +828,10 @@ function CodexAccountRow({
onClick={() => onDelete(acc.index)}
type="button"
>
{t.settings.common.delete}
</button>
) : null}
</div>
</li>
</article>
);
}

View File

@@ -1,48 +1,59 @@
import { useState, type ReactNode } from 'react';
import { LOCALES, languageNames, type Locale, type Messages } from './i18n';
export const SETTINGS_NAV_ITEMS = [
{ targetId: 'settings-general', title: '일반', detail: '표시 · 언어' },
{
targetId: 'settings-models',
title: '모델',
detail: 'owner · reviewer · arbiter',
},
{
targetId: 'settings-runtime',
title: '런타임',
detail: 'skills · MCP · config',
},
{ targetId: 'settings-moa', title: 'MoA', detail: '참조 모델 · 연결 테스트' },
{ targetId: 'settings-codex', title: 'Codex', detail: 'fast mode · /goal' },
{ targetId: 'settings-accounts', title: '계정', detail: 'Claude · Codex' },
{ targetId: 'settings-general', navKey: 'general' },
{ targetId: 'settings-models', navKey: 'models' },
{ targetId: 'settings-runtime', navKey: 'runtime' },
{ targetId: 'settings-moa', navKey: 'moa' },
{ targetId: 'settings-codex', navKey: 'codex' },
{ targetId: 'settings-accounts', navKey: 'accounts' },
] as const;
export type SettingsSectionId = (typeof SETTINGS_NAV_ITEMS)[number]['targetId'];
type SettingsNavKey = (typeof SETTINGS_NAV_ITEMS)[number]['navKey'];
function navItem(t: Messages, navKey: SettingsNavKey) {
return t.settings.nav[navKey];
}
export function SettingsNav({
activeSection,
onSelect,
t,
}: {
activeSection: SettingsSectionId;
onSelect: (section: SettingsSectionId) => void;
t: Messages;
}) {
return (
<div className="settings-nav" aria-label="설정 섹션" role="tablist">
{SETTINGS_NAV_ITEMS.map((item) => (
<button
aria-controls={item.targetId}
aria-selected={activeSection === item.targetId}
data-settings-target={item.targetId}
id={`${item.targetId}-tab`}
key={item.targetId}
onClick={() => onSelect(item.targetId)}
role="tab"
type="button"
>
<strong>{item.title}</strong>
<small>{item.detail}</small>
</button>
))}
<div className="settings-nav-scroll">
<div
className="settings-nav"
aria-label={t.settings.navAria}
role="tablist"
>
{SETTINGS_NAV_ITEMS.map((item) => {
const copy = navItem(t, item.navKey);
return (
<button
aria-controls={item.targetId}
aria-selected={activeSection === item.targetId}
data-settings-target={item.targetId}
id={`${item.targetId}-tab`}
key={item.targetId}
onClick={() => onSelect(item.targetId)}
role="tab"
type="button"
>
<strong>{copy.title}</strong>
<small>{copy.detail}</small>
</button>
);
})}
</div>
</div>
);
}
@@ -60,6 +71,8 @@ export function GeneralSettings({
onNicknameChange: (next: string) => void;
t: Messages;
}) {
const section = t.settings.sections.general;
return (
<section
aria-labelledby="settings-general-tab"
@@ -68,36 +81,38 @@ export function GeneralSettings({
role="tabpanel"
>
<SettingsSectionHeading
detail="Dashboard identity"
title="일반"
description="브라우저에서 보이는 이름과 언어만 즉시 바뀝니다."
description={section.description}
detail={section.kicker}
title={section.title}
/>
<div className="settings-form-grid">
<label className="settings-row">
<span className="settings-label">{t.settings.nicknameLabel}</span>
<input
maxLength={32}
onChange={(event) => onNicknameChange(event.target.value)}
placeholder={t.settings.nicknamePlaceholder}
type="text"
value={nickname}
/>
<small className="settings-hint">{t.settings.nicknameHelp}</small>
</label>
<label className="settings-row">
<span className="settings-label">{t.settings.languageLabel}</span>
<select
aria-label={t.settings.languageLabel}
onChange={(event) => onLocaleChange(event.target.value as Locale)}
value={locale}
>
{LOCALES.map((item) => (
<option key={item} value={item}>
{languageNames[item]}
</option>
))}
</select>
</label>
<div className="settings-card">
<div className="settings-form-grid">
<label className="settings-row">
<span className="settings-label">{t.settings.nicknameLabel}</span>
<input
maxLength={32}
onChange={(event) => onNicknameChange(event.target.value)}
placeholder={t.settings.nicknamePlaceholder}
type="text"
value={nickname}
/>
<small className="settings-hint">{t.settings.nicknameHelp}</small>
</label>
<label className="settings-row">
<span className="settings-label">{t.settings.languageLabel}</span>
<select
aria-label={t.settings.languageLabel}
onChange={(event) => onLocaleChange(event.target.value as Locale)}
value={locale}
>
{LOCALES.map((item) => (
<option key={item} value={item}>
{languageNames[item]}
</option>
))}
</select>
</label>
</div>
</div>
</section>
);
@@ -121,24 +136,131 @@ export function SettingsSectionHeading({
);
}
export function SettingsApplyCard({
onRestartStack,
export function SettingsCard({
title,
description,
actions,
children,
}: {
onRestartStack: () => void;
title?: string;
description?: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<section className="settings-apply-card" aria-label="변경 적용">
<section className="settings-card">
{title || description || actions ? (
<header className="settings-card-head">
<div>
{title ? <h4>{title}</h4> : null}
{description ? (
<p className="settings-hint">{description}</p>
) : null}
</div>
{actions ? (
<div className="settings-card-actions">{actions}</div>
) : null}
</header>
) : null}
{children}
</section>
);
}
export function SettingsCollapsible({
title,
description,
defaultOpen = false,
children,
}: {
title: string;
description?: string;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<section className={`settings-collapsible${open ? ' is-open' : ''}`}>
<button
aria-expanded={open}
className="settings-collapsible-trigger"
onClick={() => setOpen((value) => !value)}
type="button"
>
<span className="settings-collapsible-text">
<strong>{title}</strong>
{description ? <small>{description}</small> : null}
</span>
<span aria-hidden="true" className="settings-collapsible-chevron">
{open ? '▾' : '▸'}
</span>
</button>
{open ? (
<div className="settings-collapsible-body">{children}</div>
) : null}
</section>
);
}
export function SettingsSaveBar({
busy,
dirty,
label,
savingLabel,
onSave,
savedHint,
showSavedHint,
saveDisabled = false,
}: {
busy: boolean;
dirty: boolean;
label: string;
savingLabel: string;
onSave: () => void;
savedHint?: string;
showSavedHint?: boolean;
saveDisabled?: boolean;
}) {
return (
<div className="settings-actions">
<button
className="settings-save"
disabled={!dirty || busy || saveDisabled}
onClick={onSave}
type="button"
>
{busy ? savingLabel : label}
</button>
{showSavedHint && savedHint ? (
<small className="settings-hint">{savedHint}</small>
) : null}
</div>
);
}
export function SettingsApplyCard({
onRestartStack,
t,
}: {
onRestartStack: () => void;
t: Messages;
}) {
const apply = t.settings.apply;
return (
<section aria-label={apply.aria} className="settings-apply-card">
<div>
<span className="settings-kicker">Apply</span>
<strong> </strong>
<small>, MoA, Codex, .</small>
<span className="settings-kicker">{apply.kicker}</span>
<strong>{apply.title}</strong>
<small>{apply.hint}</small>
</div>
<button
className="settings-restart"
onClick={onRestartStack}
type="button"
>
{apply.restart}
</button>
</section>
);

File diff suppressed because it is too large Load Diff

View File

@@ -46,13 +46,14 @@ describe('TaskPanel', () => {
expect(html).toContain(t.tasks.groups.scheduled);
expect(html).toContain('eyejokerdb-main');
expect(html).toContain(t.panels.scheduled);
expect(html).toContain(t.tasks.next);
expect(html).toContain('10m');
expect(html).toContain(t.tasks.actions.pause);
expect(html).toContain(t.tasks.actions.cancel);
expect(html).toContain('Task completed');
expect(html).toContain('Run production check');
expect(html).toContain('task-row');
expect(html).toContain('task-filter-tabs');
expect(html).not.toContain('internal');
});

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import type {
CreateScheduledTaskInput,
@@ -18,6 +18,7 @@ import './TaskPanel.css';
export type { TaskActionKey } from './TaskActionButtons';
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
type TaskFilterKey = TaskGroupKey | 'all';
type TaskResultTone = 'ok' | 'fail' | 'none';
export interface RoomOption {
@@ -317,16 +318,7 @@ function buildTaskSummary(tasks: DashboardTask[]): TaskSummary {
return summary;
}
function TaskSummaryMetric({ label, value }: { label: string; value: number }) {
return (
<span className="task-summary-metric">
<strong>{value}</strong>
<small>{label}</small>
</span>
);
}
function TaskBoardSummary({
function TaskSummaryBar({
locale,
summary,
t,
@@ -337,51 +329,82 @@ function TaskBoardSummary({
}) {
const nextTask = summary.nextTask;
return (
<section className="task-command-center" aria-label={t.panels.scheduled}>
<div className="task-command-copy">
<span className="eyebrow">{t.panels.scheduled}</span>
<strong>
{summary.total} {t.tasks.count}
</strong>
<p>
{nextTask
? `${t.tasks.next}: ${taskHeadline(nextTask, t)}`
: t.tasks.empty}
</p>
</div>
<div className="task-command-next">
<section className="task-summary-bar" aria-label={t.panels.scheduled}>
<div className="task-summary-next">
<small>{t.tasks.next}</small>
<strong>
{nextTask ? formatRelativeDate(nextTask.nextRun, locale, t) : '-'}
</strong>
<span>
{nextTask
? `${nextTask.groupFolder} · ${taskScheduleText(nextTask, t)}`
: t.tasks.noTime}
? taskHeadline(nextTask, t)
: summary.total > 0
? `${summary.total} ${t.tasks.count}`
: t.tasks.empty}
</span>
</div>
<div className="task-command-metrics">
<TaskSummaryMetric
label={t.tasks.groups.scheduled}
value={summary.scheduled}
/>
<TaskSummaryMetric
label={t.tasks.groups.watchers}
value={summary.watchers}
/>
<TaskSummaryMetric
label={t.tasks.groups.paused}
value={summary.paused}
/>
<TaskSummaryMetric
label={t.tasks.groups.completed}
value={summary.completed}
/>
<div className="task-summary-chips" aria-hidden="true">
<span className="task-chip">
{t.tasks.groups.scheduled} {summary.scheduled}
</span>
<span className="task-chip">
{t.tasks.groups.watchers} {summary.watchers}
</span>
<span className="task-chip">
{t.tasks.groups.paused} {summary.paused}
</span>
<span className="task-chip">
{t.tasks.groups.completed} {summary.completed}
</span>
</div>
</section>
);
}
function TaskFilterTabs({
activeFilter,
counts,
onSelect,
t,
}: {
activeFilter: TaskFilterKey;
counts: Record<TaskFilterKey, number>;
onSelect: (filter: TaskFilterKey) => void;
t: Messages;
}) {
const tabs: Array<{ key: TaskFilterKey; label: string }> = [
{ key: 'all', label: t.tasks.filterAll },
{ key: 'scheduled', label: t.tasks.groups.scheduled },
{ key: 'watchers', label: t.tasks.groups.watchers },
{ key: 'paused', label: t.tasks.groups.paused },
{ key: 'completed', label: t.tasks.groups.completed },
];
return (
<div className="task-filter-scroll">
<div
aria-label={t.panels.scheduled}
className="task-filter-tabs"
role="tablist"
>
{tabs.map((tab) => (
<button
aria-selected={activeFilter === tab.key}
className="task-filter-tab"
key={tab.key}
onClick={() => onSelect(tab.key)}
role="tab"
type="button"
>
<strong>{tab.label}</strong>
<small>{counts[tab.key]}</small>
</button>
))}
</div>
</div>
);
}
function TaskCreateForm({
rooms,
onTaskCreate,
@@ -389,32 +412,30 @@ function TaskCreateForm({
t,
}: TaskCreateFormProps) {
return (
<form
className="task-create-form"
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const input = readTaskForm(form, true);
if (!input) return;
onTaskCreate(input);
event.currentTarget.reset();
}}
>
<div className="task-create-head">
<span className="eyebrow">{t.tasks.createTitle}</span>
<strong>{t.tasks.createSubtitle}</strong>
<p>{t.tasks.scheduleValueHint}</p>
</div>
<div className="task-create-body">
<details className="task-create-panel">
<summary>{t.tasks.createTitle}</summary>
<form
className="task-create-form"
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const input = readTaskForm(form, true);
if (!input) return;
onTaskCreate(input);
event.currentTarget.reset();
}}
>
<p className="task-create-hint">{t.tasks.scheduleValueHint}</p>
<label className="task-form-wide">
<span>{t.tasks.prompt}</span>
<textarea
name="prompt"
placeholder={t.tasks.promptPlaceholder}
required
rows={3}
/>
</label>
<div className="task-form-controls">
<div className="task-form-grid">
<label>
<span>{t.tasks.room}</span>
<select name="roomJid" required>
@@ -450,16 +471,13 @@ function TaskCreateForm({
</select>
</label>
</div>
<div className="task-form-footer">
<span>{t.tasks.schedule}</span>
<button disabled={taskActionKey === 'create'} type="submit">
{taskActionKey === 'create'
? t.tasks.actions.busy
: t.tasks.actions.create}
</button>
</div>
</div>
</form>
<button disabled={taskActionKey === 'create'} type="submit">
{taskActionKey === 'create'
? t.tasks.actions.busy
: t.tasks.actions.create}
</button>
</form>
</details>
);
}
@@ -515,16 +533,16 @@ function TaskResult({ task, t }: { task: DashboardTask; t: Messages }) {
);
}
function TaskPromptDetails({ task, t }: { task: DashboardTask; t: Messages }) {
function TaskPromptBlock({ task, t }: { task: DashboardTask; t: Messages }) {
return (
<details className="task-prompt">
<summary>{t.tasks.prompt}</summary>
<div className="task-prompt-block">
<span>{t.tasks.prompt}</span>
<p>{safePreview(task.promptPreview, t.tasks.emptyPrompt)}</p>
<small>
{task.id} · {taskScheduleText(task, t)} · {task.promptLength}{' '}
{t.units.chars}
</small>
</details>
</div>
);
}
@@ -599,35 +617,50 @@ function TaskCard({
t,
}: TaskCardProps) {
return (
<article className={`task-card task-card-${groupKey}`}>
<div className="task-card-main">
<div className="task-title">
<span className="task-kind">{taskDisplayName(task, t)}</span>
<strong>{taskHeadline(task, t)}</strong>
<TaskMetaStrip task={task} t={t} />
</div>
<div className="task-status-line">
<article className={`task-row task-row-${groupKey}`}>
<div className="task-row-head">
<div className="task-row-lead">
<span className={`pill pill-${task.status}`}>
{statusLabel(task.status, t)}
</span>
<div className="task-row-copy">
<span className="task-kind">{taskDisplayName(task, t)}</span>
<strong title={taskHeadline(task, t)}>
{taskHeadline(task, t)}
</strong>
<TaskMetaStrip task={task} t={t} />
</div>
</div>
<div className="task-row-aside">
<div className="task-row-next">
<small>{t.tasks.next}</small>
<strong>{formatRelativeDate(task.nextRun, locale, t)}</strong>
<em>{formatTaskDate(task.nextRun, locale)}</em>
</div>
<TaskActionButtons
className="task-row-actions"
onTaskAction={onTaskAction}
task={task}
taskActionKey={taskActionKey}
t={t}
/>
</div>
</div>
<TaskActionButtons
onTaskAction={onTaskAction}
task={task}
taskActionKey={taskActionKey}
t={t}
/>
<TaskTimeGrid locale={locale} t={t} task={task} />
<TaskSuspendedUntil locale={locale} t={t} task={task} />
<TaskResult task={task} t={t} />
<TaskPromptDetails task={task} t={t} />
<TaskEditForm
onTaskUpdate={onTaskUpdate}
task={task}
taskActionKey={taskActionKey}
t={t}
/>
<details className="task-row-details">
<summary>{t.tasks.details}</summary>
<div className="task-row-details-body">
<TaskTimeGrid locale={locale} t={t} task={task} />
<TaskSuspendedUntil locale={locale} t={t} task={task} />
<TaskResult task={task} t={t} />
<TaskPromptBlock task={task} t={t} />
<TaskEditForm
onTaskUpdate={onTaskUpdate}
task={task}
taskActionKey={taskActionKey}
t={t}
/>
</div>
</details>
</article>
);
}
@@ -673,34 +706,41 @@ function TaskGroupHead({
}) {
return (
<div className="task-group-head">
<div>
<span className="eyebrow">{label}</span>
<strong>
{group.tasks.length} {countLabel}
</strong>
</div>
<span className={`pill pill-${group.key}`}>{group.tasks.length}</span>
<strong>
{label} · {group.tasks.length} {countLabel}
</strong>
</div>
);
}
function TaskGroupSection(props: TaskGroupSectionProps) {
const { group, t } = props;
function TaskGroupSection({
group,
locale,
onTaskAction,
onTaskUpdate,
showHead,
taskActionKey,
t,
}: TaskGroupSectionProps & { showHead: boolean }) {
if (group.tasks.length === 0) return null;
const label = t.tasks.groups[group.key];
const body = <TaskGroupBody {...props} />;
const body = (
<TaskGroupBody
group={group}
locale={locale}
onTaskAction={onTaskAction}
onTaskUpdate={onTaskUpdate}
taskActionKey={taskActionKey}
t={t}
/>
);
if (group.key === 'completed') {
return (
<details className="task-group task-group-completed">
<summary className="task-group-head">
<div>
<span className="eyebrow">{label}</span>
<strong>
{group.tasks.length} {t.tasks.count}
</strong>
</div>
<strong>{label}</strong>
<span className={`pill pill-${group.key}`}>{group.tasks.length}</span>
</summary>
{body}
@@ -708,6 +748,12 @@ function TaskGroupSection(props: TaskGroupSectionProps) {
);
}
if (!showHead) {
return (
<section className={`task-group task-group-${group.key}`}>{body}</section>
);
}
return (
<section className={`task-group task-group-${group.key}`}>
<TaskGroupHead countLabel={t.tasks.count} group={group} label={label} />
@@ -716,6 +762,21 @@ function TaskGroupSection(props: TaskGroupSectionProps) {
);
}
function buildFilterCounts(groups: TaskGroup[]): Record<TaskFilterKey, number> {
const counts: Record<TaskFilterKey, number> = {
all: 0,
watchers: 0,
scheduled: 0,
paused: 0,
completed: 0,
};
for (const group of groups) {
counts[group.key] = group.tasks.length;
counts.all += group.tasks.length;
}
return counts;
}
export function TaskPanel({
tasks,
rooms,
@@ -728,26 +789,48 @@ export function TaskPanel({
}: TaskPanelProps) {
const taskGroups = useMemo(() => buildTaskGroups(tasks), [tasks]);
const taskSummary = useMemo(() => buildTaskSummary(tasks), [tasks]);
const filterCounts = useMemo(
() => buildFilterCounts(taskGroups),
[taskGroups],
);
const [activeFilter, setActiveFilter] = useState<TaskFilterKey>('all');
const visibleGroups = useMemo(() => {
if (activeFilter === 'all') {
return taskGroups.filter((group) => group.tasks.length > 0);
}
return taskGroups.filter((group) => group.key === activeFilter);
}, [activeFilter, taskGroups]);
return (
<div className="task-board" aria-label={t.tasks.cardsAria}>
<TaskBoardSummary locale={locale} summary={taskSummary} t={t} />
<TaskSummaryBar locale={locale} summary={taskSummary} t={t} />
<TaskFilterTabs
activeFilter={activeFilter}
counts={filterCounts}
onSelect={setActiveFilter}
t={t}
/>
<TaskCreateForm
rooms={rooms}
onTaskCreate={onTaskCreate}
rooms={rooms}
taskActionKey={taskActionKey}
t={t}
/>
{tasks.length === 0 ? <EmptyState>{t.tasks.empty}</EmptyState> : null}
{tasks.length > 0 && visibleGroups.length === 0 ? (
<EmptyState>{t.tasks.groupEmpty}</EmptyState>
) : null}
<div className="task-lanes">
{taskGroups.map((group) => (
{visibleGroups.map((group) => (
<TaskGroupSection
group={group}
key={group.key}
locale={locale}
onTaskAction={onTaskAction}
onTaskUpdate={onTaskUpdate}
showHead={activeFilter === 'all'}
taskActionKey={taskActionKey}
t={t}
/>

View File

@@ -421,10 +421,17 @@ export interface ModelRoleConfig {
effort: string;
}
export interface ModelAgentTypes {
owner: 'claude-code' | 'codex';
reviewer: 'claude-code' | 'codex';
arbiter: 'claude-code' | 'codex' | null;
}
export interface ModelConfigSnapshot {
owner: ModelRoleConfig;
reviewer: ModelRoleConfig;
arbiter: ModelRoleConfig;
agentTypes: ModelAgentTypes;
}
export interface FastModeSnapshot {

View File

@@ -38,6 +38,140 @@ export interface Messages {
nicknamePlaceholder: string;
nicknameHelp: string;
languageLabel: string;
navAria: string;
contentAria: string;
sidebarAria: string;
nav: {
general: { title: string; detail: string };
models: { title: string; detail: string };
runtime: { title: string; detail: string };
moa: { title: string; detail: string };
codex: { title: string; detail: string };
accounts: { title: string; detail: string };
};
apply: {
aria: string;
kicker: string;
title: string;
hint: string;
restart: string;
};
sections: {
general: { kicker: string; title: string; description: string };
models: { kicker: string; title: string; description: string };
runtime: { kicker: string; title: string; description: string };
moa: { kicker: string; title: string; description: string };
codex: { kicker: string; title: string; description: string };
accounts: { kicker: string; title: string; description: string };
};
common: {
loading: string;
none: string;
saving: string;
saved: string;
savedRestartHint: string;
save: string;
delete: string;
refresh: string;
refreshing: string;
refreshAll: string;
switch: string;
switching: string;
default: string;
inUse: string;
add: string;
};
models: {
roleOwner: string;
roleReviewer: string;
roleArbiter: string;
roleOwnerHint: string;
roleReviewerHint: string;
roleArbiterHint: string;
modelLabel: string;
effortLabel: string;
modelDefault: string;
modelCustom: string;
modelCustomLabel: string;
modelPlaceholder: string;
groupCodex: string;
groupClaude: string;
groupCustom: string;
effortDefault: string;
effortOptions: {
low: string;
medium: string;
high: string;
xhigh: string;
max: string;
};
agentTypeLabel: string;
agentTypeCodex: string;
agentTypeClaude: string;
effortInvalid: string;
effortSaveBlocked: string;
save: string;
empty: string;
};
codex: {
fastMode: string;
features: string;
codexFast: string;
codexFastHint: string;
claudeFast: string;
claudeFastHint: string;
fastModeApplyHint: string;
goal: string;
goalHint: string;
goalApplyHint: string;
};
accounts: {
claude: string;
codex: string;
noAccounts: string;
autoRefresh: string;
codexRefreshHint: string;
tokenPlaceholder: string;
deleteConfirm: string;
refreshFailed: string;
paymentExpired: string;
paymentUntil: string;
paymentUntilDays: string;
primaryAccount: string;
activeAccount: string;
addTokenLabel: string;
refreshTitle: string;
switchTitle: string;
};
moa: {
master: string;
masterHint: string;
save: string;
empty: string;
test: string;
testing: string;
testAfterSave: string;
notTested: string;
statusOk: string;
statusFail: string;
apiKeyPlaceholder: string;
apiKeySet: string;
modelLabel: string;
baseUrlLabel: string;
formatLabel: string;
};
runtime: {
defaultHint: string;
selectRoomLabel: string;
selectAgentLabel: string;
scopeCodexUser: string;
scopeClaudeUser: string;
scopeRunner: string;
emptyRooms: string;
emptySkills: string;
agentCodex: string;
agentClaude: string;
};
};
error: {
api: string;
@@ -83,7 +217,6 @@ export interface Messages {
rooms: string;
queue: string;
scheduled: string;
promptPreviews: string;
};
service: {
empty: string;
@@ -218,6 +351,8 @@ export interface Messages {
now: string;
createTitle: string;
createSubtitle: string;
filterAll: string;
details: string;
room: string;
selectRoom: string;
scheduleType: string;
@@ -312,6 +447,180 @@ export const messages = {
nicknamePlaceholder: 'Web Dashboard',
nicknameHelp: '룸에서 메시지 보낼 때 표시될 이름',
languageLabel: '언어',
navAria: '설정 섹션',
contentAria: '설정 항목',
sidebarAria: '설정 탐색과 적용',
nav: {
general: { title: '일반', detail: '표시 · 언어' },
models: { title: '모델', detail: 'owner · reviewer · arbiter' },
runtime: { title: '스킬', detail: '방별 ON · OFF' },
moa: { title: 'MoA', detail: '참조 모델 · 연결 테스트' },
codex: { title: 'Codex', detail: 'fast mode · /goal' },
accounts: { title: '계정', detail: 'Claude · Codex' },
},
apply: {
aria: '변경 적용',
kicker: 'Apply',
title: '저장 후 재시작',
hint: '모델, MoA, Codex, 계정 변경은 스택 재시작 후 반영됩니다.',
restart: '스택 재시작',
},
sections: {
general: {
kicker: 'Dashboard identity',
title: '일반',
description: '브라우저에서 보이는 이름과 언어만 즉시 바뀝니다.',
},
models: {
kicker: 'Agent routing',
title: '모델',
description:
'역할별 모델과 추론 강도를 선택합니다. 저장 후 스택 재시작이 필요합니다.',
},
runtime: {
kicker: 'Agent skills',
title: '스킬',
description:
'방마다 에이전트가 쓸 스킬을 켜거나 끕니다. 전역 일괄 설정은 없고, 기본값은 모두 켜짐입니다.',
},
moa: {
kicker: 'Arbiter references',
title: 'MoA 참조 모델',
description:
'Kimi, GLM 같은 외부 참조 모델을 켜고 연결 상태를 바로 확인합니다.',
},
codex: {
kicker: 'Codex runtime',
title: 'Codex 옵션',
description:
'빠른 응답과 실험 기능을 관리합니다. /goal은 여기에서 찾을 수 있습니다.',
},
accounts: {
kicker: 'Credentials',
title: '계정',
description: 'Claude OAuth와 Codex 계정 상태를 확인하고 전환합니다.',
},
},
common: {
loading: '불러오는 중…',
none: '없음',
saving: '저장 중…',
saved: '저장됨',
savedRestartHint:
'저장됨. 적용하려면 사이드바의 스택 재시작을 눌러 주세요.',
save: '저장',
delete: '삭제',
refresh: '갱신',
refreshing: '갱신중…',
refreshAll: '전체 갱신',
switch: '전환',
switching: '전환중…',
default: '기본',
inUse: '사용중',
add: '추가',
},
models: {
roleOwner: 'Owner',
roleReviewer: 'Reviewer',
roleArbiter: 'Arbiter',
roleOwnerHint: '작업을 직접 수행하는 에이전트',
roleReviewerHint: '변경 사항을 검토하는 에이전트',
roleArbiterHint: '최종 판단을 내리는 에이전트',
modelLabel: '모델',
effortLabel: '추론 강도',
modelDefault: '기본값 (.env 상속)',
modelCustom: '직접 입력…',
modelCustomLabel: '모델 ID',
modelPlaceholder: '예: gpt-5.5, claude-opus-4-7',
groupCodex: 'Codex',
groupClaude: 'Claude',
groupCustom: '사용자 지정',
effortDefault: '기본값 (.env 상속)',
effortOptions: {
low: '낮음',
medium: '보통',
high: '높음',
xhigh: '매우 높음',
max: '최대',
},
agentTypeLabel: '에이전트',
agentTypeCodex: 'Codex',
agentTypeClaude: 'Claude Code',
effortInvalid:
'"{value}"는 {agent}에서 지원하지 않는 추론 강도입니다. 다른 값을 선택해 주세요.',
effortSaveBlocked:
'지원하지 않는 추론 강도가 있습니다. 저장하기 전에 수정해 주세요.',
save: '모델 저장',
empty: '모델 정보 없음',
},
codex: {
fastMode: '패스트 모드',
features: 'Codex 기능',
codexFast: 'Codex (GPT)',
codexFastHint:
'~/.codex/config.toml [features].fast_mode — GPT 5.5 등에서 응답 속도를 높입니다. 사용량이 더 듭니다.',
claudeFast: 'Claude',
claudeFastHint:
'~/.claude/settings.json fastMode — Opus 4.x(4.6·4.7 등)에서 세션 settings.json으로 동기화됩니다.',
fastModeApplyHint:
'스택 재시작 없이 다음 Codex/Claude 작업부터 적용됩니다.',
goal: 'Goals (/goal)',
goalHint:
'~/.codex/config.toml [features].goals — Codex 0.133 기준 opt-in 기능입니다. 기본값은 꺼짐(OFF)이며, 켜면 /goal 장기 목표 추적을 쓸 수 있습니다.',
goalApplyHint: '스택 재시작 없이 다음 Codex 작업부터 적용됩니다.',
},
accounts: {
claude: 'Claude',
codex: 'Codex',
noAccounts: '계정 없음',
autoRefresh: '토큰 자동갱신',
codexRefreshHint:
'OAuth 토큰은 6시간마다 자동 갱신됩니다. plan 변경/해지가 즉시 반영되게 하려면 수동으로 “전체 갱신”을 누르세요.',
tokenPlaceholder:
'Claude OAuth 토큰 (claude CLI 로그인 후 ~/.claude/.credentials.json 에서 accessToken 값을 페이스트)',
deleteConfirm:
'{provider} 계정 #{index} 디렉터리를 삭제합니다. 되돌릴 수 없습니다. 계속할까요?',
refreshFailed: '일부 갱신 실패: {indexes}',
paymentExpired: '결제 만료 {date} ({days}일 전)',
paymentUntil: '결제 {date}까지 ({days}일)',
paymentUntilDays: '결제 {date}까지 ({days}일)',
primaryAccount: '기본 계정',
activeAccount: '사용 중',
addTokenLabel: 'OAuth 토큰 추가',
refreshTitle: '구독 상태를 다시 조회합니다',
switchTitle: '다음 Codex 호출부터 이 계정을 사용합니다',
},
moa: {
master: 'MoA 사용',
masterHint:
'Arbiter 호출 전에 외부 참조 모델 의견을 수집합니다. 저장 후 스택 재시작이 필요합니다.',
save: 'MoA 저장',
empty: 'MoA 설정 없음',
test: '연결 테스트',
testing: '테스트중…',
testAfterSave: '저장 후 테스트',
notTested: '연결 테스트 전',
statusOk: '정상',
statusFail: '실패',
apiKeyPlaceholder: 'new API key',
apiKeySet: 'API key set',
modelLabel: '모델 ID',
baseUrlLabel: 'Base URL',
formatLabel: 'API 형식',
},
runtime: {
defaultHint:
'기본값은 모든 스킬이 켜져 있습니다. 여기서 끈 스킬만 해당 방·에이전트에 저장됩니다. 전체(모든 방) 일괄 설정은 아직 없습니다.',
selectRoomLabel: '방 선택',
selectAgentLabel: '에이전트',
scopeCodexUser: 'Codex 스킬',
scopeClaudeUser: 'Claude 스킬',
scopeRunner: '공통 러너 스킬',
emptyRooms: '등록된 Discord 방이 없습니다.',
emptySkills: '이 방·에이전트에 연결된 스킬이 없습니다.',
agentCodex: 'Codex',
agentClaude: 'Claude Code',
},
},
error: {
api: '문제 발생',
@@ -357,7 +666,6 @@ export const messages = {
rooms: '룸',
queue: '큐',
scheduled: '예약',
promptPreviews: '프롬프트 미리보기',
},
service: {
empty: '하트비트 없음. 서비스 로그 확인.',
@@ -492,6 +800,8 @@ export const messages = {
now: '지금',
createTitle: '작업 예약',
createSubtitle: '룸에 반복 작업 걸기',
filterAll: '전체',
details: '상세',
room: '방',
selectRoom: '방 선택',
scheduleType: '방식',
@@ -570,6 +880,181 @@ export const messages = {
nicknamePlaceholder: 'Web Dashboard',
nicknameHelp: 'Name shown when sending messages from this dashboard',
languageLabel: 'Language',
navAria: 'Settings sections',
contentAria: 'Settings items',
sidebarAria: 'Settings navigation and apply',
nav: {
general: { title: 'General', detail: 'Display · language' },
models: { title: 'Models', detail: 'owner · reviewer · arbiter' },
runtime: { title: 'Skills', detail: 'per-room on/off' },
moa: { title: 'MoA', detail: 'reference models · test' },
codex: { title: 'Codex', detail: 'fast mode · /goal' },
accounts: { title: 'Accounts', detail: 'Claude · Codex' },
},
apply: {
aria: 'Apply changes',
kicker: 'Apply',
title: 'Restart after save',
hint: 'Model, MoA, Codex, and account changes apply after a stack restart.',
restart: 'Restart stack',
},
sections: {
general: {
kicker: 'Dashboard identity',
title: 'General',
description:
'Only the display name and language change immediately in the browser.',
},
models: {
kicker: 'Agent routing',
title: 'Models',
description:
'Pick a model and inference depth for each role. Restart the stack after saving.',
},
runtime: {
kicker: 'Agent skills',
title: 'Skills',
description:
'Turn skills on or off per room and agent. Default is all on; there is no global bulk setting yet.',
},
moa: {
kicker: 'Arbiter references',
title: 'MoA reference models',
description:
'Enable external reference models like Kimi or GLM and verify connectivity.',
},
codex: {
kicker: 'Codex runtime',
title: 'Codex options',
description:
'Manage fast responses and experimental features. /goal lives here.',
},
accounts: {
kicker: 'Credentials',
title: 'Accounts',
description: 'Review and switch Claude OAuth and Codex accounts.',
},
},
common: {
loading: 'Loading…',
none: 'None',
saving: 'Saving…',
saved: 'Saved',
savedRestartHint: 'Saved. Use Restart stack in the sidebar to apply.',
save: 'Save',
delete: 'Delete',
refresh: 'Refresh',
refreshing: 'Refreshing…',
refreshAll: 'Refresh all',
switch: 'Switch',
switching: 'Switching…',
default: 'Default',
inUse: 'In use',
add: 'Add',
},
models: {
roleOwner: 'Owner',
roleReviewer: 'Reviewer',
roleArbiter: 'Arbiter',
roleOwnerHint: 'Runs the main work',
roleReviewerHint: 'Reviews proposed changes',
roleArbiterHint: 'Makes the final call',
modelLabel: 'Model',
effortLabel: 'Inference depth',
modelDefault: 'Default (.env)',
modelCustom: 'Custom…',
modelCustomLabel: 'Model ID',
modelPlaceholder: 'e.g. gpt-5.5, claude-opus-4-7',
groupCodex: 'Codex',
groupClaude: 'Claude',
groupCustom: 'Custom',
effortDefault: 'Default (.env)',
effortOptions: {
low: 'Low',
medium: 'Medium',
high: 'High',
xhigh: 'Very high',
max: 'Max',
},
agentTypeLabel: 'Agent',
agentTypeCodex: 'Codex',
agentTypeClaude: 'Claude Code',
effortInvalid:
'"{value}" is not supported for {agent}. Choose another effort level.',
effortSaveBlocked:
'Unsupported effort levels are set. Fix them before saving.',
save: 'Save models',
empty: 'No model config',
},
codex: {
fastMode: 'Fast mode',
features: 'Codex features',
codexFast: 'Codex (GPT)',
codexFastHint:
'~/.codex/config.toml [features].fast_mode — faster responses on GPT 5.5 and newer; higher usage.',
claudeFast: 'Claude',
claudeFastHint:
'~/.claude/settings.json fastMode — synced into session settings.json for Opus 4.x (4.6, 4.7, …).',
fastModeApplyHint:
'Applies on the next Codex/Claude run. No stack restart required.',
goal: 'Goals (/goal)',
goalHint:
'~/.codex/config.toml [features].goals — opt-in on Codex 0.133. Off by default; enables /goal long-running objectives when on.',
goalApplyHint:
'Applies on the next Codex run. No stack restart required.',
},
accounts: {
claude: 'Claude',
codex: 'Codex',
noAccounts: 'No accounts',
autoRefresh: 'Auto token refresh',
codexRefreshHint:
'OAuth tokens refresh every 6 hours. Use Refresh all for immediate plan changes.',
tokenPlaceholder:
'Claude OAuth token (paste accessToken from ~/.claude/.credentials.json after claude CLI login)',
deleteConfirm:
'Delete {provider} account #{index} directory. This cannot be undone. Continue?',
refreshFailed: 'Some refreshes failed: {indexes}',
paymentExpired: 'Expired {date} ({days}d ago)',
paymentUntil: 'Paid until {date} ({days}d)',
paymentUntilDays: 'Paid until {date} ({days}d)',
primaryAccount: 'Primary',
activeAccount: 'Active',
addTokenLabel: 'Add OAuth token',
refreshTitle: 'Refresh subscription status',
switchTitle: 'Use this account for the next Codex call',
},
moa: {
master: 'Enable MoA',
masterHint:
'Collect external reference opinions before arbiter calls. Stack restart required after save.',
save: 'Save MoA',
empty: 'No MoA settings',
test: 'Test connection',
testing: 'Testing…',
testAfterSave: 'Save to test',
notTested: 'Not tested yet',
statusOk: 'OK',
statusFail: 'Failed',
apiKeyPlaceholder: 'new API key',
apiKeySet: 'API key set',
modelLabel: 'Model ID',
baseUrlLabel: 'Base URL',
formatLabel: 'API format',
},
runtime: {
defaultHint:
'All skills start enabled. Only per-room, per-agent disables are saved. Global bulk settings are not available yet.',
selectRoomLabel: 'Room',
selectAgentLabel: 'Agent',
scopeCodexUser: 'Codex skill',
scopeClaudeUser: 'Claude skill',
scopeRunner: 'Shared runner skill',
emptyRooms: 'No registered Discord rooms.',
emptySkills: 'No skills for this room and agent.',
agentCodex: 'Codex',
agentClaude: 'Claude Code',
},
},
error: {
api: 'Something went wrong',
@@ -615,7 +1100,6 @@ export const messages = {
rooms: 'Rooms',
queue: 'Queue',
scheduled: 'Scheduled',
promptPreviews: 'Prompt previews',
},
service: {
empty: 'No heartbeat yet. Check service logs.',
@@ -750,6 +1234,8 @@ export const messages = {
now: 'now',
createTitle: 'Create task',
createSubtitle: 'Schedule work from web',
filterAll: 'All',
details: 'Details',
room: 'Room',
selectRoom: 'Select room',
scheduleType: 'Type',
@@ -828,6 +1314,170 @@ export const messages = {
nicknamePlaceholder: 'Web Dashboard',
nicknameHelp: '从此面板发送消息时显示的名称',
languageLabel: '语言',
navAria: '设置分区',
contentAria: '设置项',
sidebarAria: '设置导航与应用',
nav: {
general: { title: '常规', detail: '显示 · 语言' },
models: { title: '模型', detail: 'owner · reviewer · arbiter' },
runtime: { title: '技能', detail: '按房间开关' },
moa: { title: 'MoA', detail: '参考模型 · 连接测试' },
codex: { title: 'Codex', detail: 'fast mode · /goal' },
accounts: { title: '账户', detail: 'Claude · Codex' },
},
apply: {
aria: '应用更改',
kicker: 'Apply',
title: '保存后重启',
hint: '模型、MoA、Codex、账户更改需在栈重启后生效。',
restart: '重启栈',
},
sections: {
general: {
kicker: 'Dashboard identity',
title: '常规',
description: '仅浏览器中的显示名称和语言会立即更改。',
},
models: {
kicker: 'Agent routing',
title: '模型',
description: '为各角色选择模型和推理强度。保存后需重启栈。',
},
runtime: {
kicker: 'Agent skills',
title: '技能',
description: '按房间和代理开关技能。默认全部开启,尚无全局批量设置。',
},
moa: {
kicker: 'Arbiter references',
title: 'MoA 参考模型',
description: '启用 Kimi、GLM 等外部参考模型并验证连接。',
},
codex: {
kicker: 'Codex runtime',
title: 'Codex 选项',
description: '管理快速响应和实验功能。/goal 在此设置。',
},
accounts: {
kicker: 'Credentials',
title: '账户',
description: '查看并切换 Claude OAuth 和 Codex 账户。',
},
},
common: {
loading: '加载中…',
none: '无',
saving: '保存中…',
saved: '已保存',
savedRestartHint: '已保存。请在侧边栏点击重启栈以应用。',
save: '保存',
delete: '删除',
refresh: '刷新',
refreshing: '刷新中…',
refreshAll: '全部刷新',
switch: '切换',
switching: '切换中…',
default: '默认',
inUse: '使用中',
add: '添加',
},
models: {
roleOwner: 'Owner',
roleReviewer: 'Reviewer',
roleArbiter: 'Arbiter',
roleOwnerHint: '执行主要工作的代理',
roleReviewerHint: '审查变更的代理',
roleArbiterHint: '做最终裁决的代理',
modelLabel: '模型',
effortLabel: '推理强度',
modelDefault: '默认 (.env)',
modelCustom: '自定义…',
modelCustomLabel: '模型 ID',
modelPlaceholder: '例如 gpt-5.5, claude-opus-4-7',
groupCodex: 'Codex',
groupClaude: 'Claude',
groupCustom: '自定义',
effortDefault: '默认 (.env)',
effortOptions: {
low: '低',
medium: '中',
high: '高',
xhigh: '很高',
max: '最大',
},
agentTypeLabel: '代理',
agentTypeCodex: 'Codex',
agentTypeClaude: 'Claude Code',
effortInvalid: '“{value}” 不适用于 {agent},请选择其他推理强度。',
effortSaveBlocked: '存在不支持的推理强度,请先修正再保存。',
save: '保存模型',
empty: '无模型配置',
},
codex: {
fastMode: '快速模式',
features: 'Codex 功能',
codexFast: 'Codex (GPT)',
codexFastHint:
'~/.codex/config.toml [features].fast_mode — GPT 5.5 等更快响应,用量更高。',
claudeFast: 'Claude',
claudeFastHint:
'~/.claude/settings.json fastMode — 同步到会话 settings.json适用于 Opus 4.x4.6、4.7 等)。',
fastModeApplyHint: '无需重启栈,下次 Codex/Claude 任务起生效。',
goal: 'Goals (/goal)',
goalHint:
'~/.codex/config.toml [features].goals — Codex 0.133 可选功能,默认关闭;开启后可使用 /goal。',
goalApplyHint: '无需重启栈,下次 Codex 任务起生效。',
},
accounts: {
claude: 'Claude',
codex: 'Codex',
noAccounts: '无账户',
autoRefresh: '自动刷新令牌',
codexRefreshHint:
'OAuth 令牌每 6 小时自动刷新。计划变更请手动全部刷新。',
tokenPlaceholder:
'Claude OAuth 令牌claude CLI 登录后从 ~/.claude/.credentials.json 粘贴 accessToken',
deleteConfirm: '将删除 {provider} 账户 #{index} 目录,无法撤销。继续?',
refreshFailed: '部分刷新失败: {indexes}',
paymentExpired: '已过期 {date}{days} 天前)',
paymentUntil: '付费至 {date}{days} 天)',
paymentUntilDays: '付费至 {date}{days} 天)',
primaryAccount: '主账户',
activeAccount: '使用中',
addTokenLabel: '添加 OAuth 令牌',
refreshTitle: '重新查询订阅状态',
switchTitle: '下次 Codex 调用起使用此账户',
},
moa: {
master: '启用 MoA',
masterHint: 'Arbiter 调用前收集外部参考意见。保存后需重启栈。',
save: '保存 MoA',
empty: '无 MoA 设置',
test: '连接测试',
testing: '测试中…',
testAfterSave: '保存后测试',
notTested: '尚未测试',
statusOk: '正常',
statusFail: '失败',
apiKeyPlaceholder: 'new API key',
apiKeySet: 'API key set',
modelLabel: '模型 ID',
baseUrlLabel: 'Base URL',
formatLabel: 'API 格式',
},
runtime: {
defaultHint:
'默认所有技能开启。仅保存按房间、按代理的关闭项。尚无全局批量设置。',
selectRoomLabel: '选择房间',
selectAgentLabel: '代理',
scopeCodexUser: 'Codex 技能',
scopeClaudeUser: 'Claude 技能',
scopeRunner: '共享 runner 技能',
emptyRooms: '没有已注册的 Discord 房间。',
emptySkills: '此房间和代理没有可用技能。',
agentCodex: 'Codex',
agentClaude: 'Claude Code',
},
},
error: {
api: '发生问题',
@@ -873,7 +1523,6 @@ export const messages = {
rooms: '房间',
queue: '队列',
scheduled: '计划',
promptPreviews: '提示预览',
},
service: {
empty: '暂无心跳。检查服务日志。',
@@ -1008,6 +1657,8 @@ export const messages = {
now: '现在',
createTitle: 'Create task',
createSubtitle: 'Schedule work from web',
filterAll: 'All',
details: 'Details',
room: 'Room',
selectRoom: 'Select room',
scheduleType: 'Type',
@@ -1086,6 +1737,180 @@ export const messages = {
nicknamePlaceholder: 'Web Dashboard',
nicknameHelp: 'このダッシュボードからメッセージを送る時に表示される名前',
languageLabel: '言語',
navAria: '設定セクション',
contentAria: '設定項目',
sidebarAria: '設定ナビと適用',
nav: {
general: { title: '一般', detail: '表示 · 言語' },
models: { title: 'モデル', detail: 'owner · reviewer · arbiter' },
runtime: { title: 'スキル', detail: 'ルーム別 ON/OFF' },
moa: { title: 'MoA', detail: '参照モデル · 接続テスト' },
codex: { title: 'Codex', detail: 'fast mode · /goal' },
accounts: { title: 'アカウント', detail: 'Claude · Codex' },
},
apply: {
aria: '変更を適用',
kicker: 'Apply',
title: '保存後に再起動',
hint: 'モデル、MoA、Codex、アカウントの変更はスタック再起動後に反映されます。',
restart: 'スタック再起動',
},
sections: {
general: {
kicker: 'Dashboard identity',
title: '一般',
description: 'ブラウザ上の表示名と言語だけがすぐに変わります。',
},
models: {
kicker: 'Agent routing',
title: 'モデル',
description:
'役割ごとにモデルと推論強度を選びます。保存後にスタック再起動が必要です。',
},
runtime: {
kicker: 'Agent skills',
title: 'スキル',
description:
'ルームとエージェントごとにスキルを ON/OFF します。デフォルトはすべて ON で、全体一括設定はまだありません。',
},
moa: {
kicker: 'Arbiter references',
title: 'MoA 参照モデル',
description:
'Kimi や GLM などの外部参照モデルを有効化し接続を確認します。',
},
codex: {
kicker: 'Codex runtime',
title: 'Codex オプション',
description:
'高速応答と実験機能を管理します。/goal はここにあります。',
},
accounts: {
kicker: 'Credentials',
title: 'アカウント',
description: 'Claude OAuth と Codex アカウントの状態確認と切り替え。',
},
},
common: {
loading: '読み込み中…',
none: 'なし',
saving: '保存中…',
saved: '保存済み',
savedRestartHint:
'保存済み。サイドバーのスタック再起動で適用してください。',
save: '保存',
delete: '削除',
refresh: '更新',
refreshing: '更新中…',
refreshAll: '一括更新',
switch: '切替',
switching: '切替中…',
default: 'デフォルト',
inUse: '使用中',
add: '追加',
},
models: {
roleOwner: 'Owner',
roleReviewer: 'Reviewer',
roleArbiter: 'Arbiter',
roleOwnerHint: '作業を実行するエージェント',
roleReviewerHint: '変更をレビューするエージェント',
roleArbiterHint: '最終判断を下すエージェント',
modelLabel: 'モデル',
effortLabel: '推論強度',
modelDefault: 'デフォルト (.env)',
modelCustom: 'カスタム…',
modelCustomLabel: 'モデル ID',
modelPlaceholder: '例: gpt-5.5, claude-opus-4-7',
groupCodex: 'Codex',
groupClaude: 'Claude',
groupCustom: 'カスタム',
effortDefault: 'デフォルト (.env)',
effortOptions: {
low: '低',
medium: '中',
high: '高',
xhigh: '最高',
max: '最大',
},
agentTypeLabel: 'エージェント',
agentTypeCodex: 'Codex',
agentTypeClaude: 'Claude Code',
effortInvalid:
'「{value}」は {agent} では使えない推論強度です。別の値を選んでください。',
effortSaveBlocked:
'サポート外の推論強度があります。保存前に修正してください。',
save: 'モデル保存',
empty: 'モデル情報なし',
},
codex: {
fastMode: 'ファストモード',
features: 'Codex 機能',
codexFast: 'Codex (GPT)',
codexFastHint:
'~/.codex/config.toml [features].fast_mode — GPT 5.5 などで応答を高速化。使用量は増えます。',
claudeFast: 'Claude',
claudeFastHint:
'~/.claude/settings.json fastMode — セッション settings.json に同期。Opus 4.x4.6・4.7 等)向け。',
fastModeApplyHint:
'スタック再起動不要。次の Codex/Claude 実行から反映されます。',
goal: 'Goals (/goal)',
goalHint:
'~/.codex/config.toml [features].goals — Codex 0.133 の opt-in 機能。デフォルト OFF。ON で /goal を利用。',
goalApplyHint: 'スタック再起動不要。次の Codex 実行から反映されます。',
},
accounts: {
claude: 'Claude',
codex: 'Codex',
noAccounts: 'アカウントなし',
autoRefresh: 'トークン自動更新',
codexRefreshHint:
'OAuth トークンは 6 時間ごとに自動更新されます。プラン変更は「一括更新」を押してください。',
tokenPlaceholder:
'Claude OAuth トークンclaude CLI ログイン後 ~/.claude/.credentials.json の accessToken を貼り付け)',
deleteConfirm:
'{provider} アカウント #{index} ディレクトリを削除します。元に戻せません。続行しますか?',
refreshFailed: '一部の更新に失敗: {indexes}',
paymentExpired: '期限切れ {date}{days} 日前)',
paymentUntil: '支払い {date} まで({days} 日)',
paymentUntilDays: '支払い {date} まで({days} 日)',
primaryAccount: 'プライマリ',
activeAccount: '使用中',
addTokenLabel: 'OAuth トークン追加',
refreshTitle: 'サブスクリプション状態を再取得',
switchTitle: '次回 Codex 呼び出しからこのアカウントを使用',
},
moa: {
master: 'MoA を使用',
masterHint:
'Arbiter 呼び出し前に外部参照モデルの意見を集めます。保存後にスタック再起動が必要です。',
save: 'MoA 保存',
empty: 'MoA 設定なし',
test: '接続テスト',
testing: 'テスト中…',
testAfterSave: '保存後にテスト',
notTested: '未テスト',
statusOk: '正常',
statusFail: '失敗',
apiKeyPlaceholder: 'new API key',
apiKeySet: 'API key set',
modelLabel: 'モデル ID',
baseUrlLabel: 'Base URL',
formatLabel: 'API 形式',
},
runtime: {
defaultHint:
'デフォルトですべてのスキルは ON です。ルーム・エージェント単位の OFF のみ保存されます。全体一括設定はまだありません。',
selectRoomLabel: 'ルーム',
selectAgentLabel: 'エージェント',
scopeCodexUser: 'Codex スキル',
scopeClaudeUser: 'Claude スキル',
scopeRunner: '共通 runner スキル',
emptyRooms: '登録済み Discord ルームがありません。',
emptySkills: 'このルームとエージェントに利用可能なスキルがありません。',
agentCodex: 'Codex',
agentClaude: 'Claude Code',
},
},
error: {
api: '問題が発生しました',
@@ -1133,7 +1958,6 @@ export const messages = {
rooms: 'ルーム',
queue: 'キュー',
scheduled: '予定',
promptPreviews: 'プロンプトプレビュー',
},
service: {
empty: 'ハートビートなし。サービスログを確認。',
@@ -1268,6 +2092,8 @@ export const messages = {
now: '今',
createTitle: 'Create task',
createSubtitle: 'Schedule work from web',
filterAll: 'All',
details: 'Details',
room: 'Room',
selectRoom: 'Select room',
scheduleType: 'Type',

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import {
effortValuesForAgent,
formatEffortOption,
isEffortSupported,
} from './settings-options';
describe('settings-options effort', () => {
it('limits Claude agents to low through max without xhigh', () => {
expect(effortValuesForAgent('claude-code')).toEqual([
'',
'low',
'medium',
'high',
'max',
]);
expect(isEffortSupported('claude-code', 'xhigh')).toBe(false);
expect(isEffortSupported('claude-code', 'high')).toBe(true);
});
it('allows xhigh for Codex agents', () => {
expect(effortValuesForAgent('codex')).toContain('xhigh');
expect(isEffortSupported('codex', 'xhigh')).toBe(true);
});
it('shows raw effort keys beside localized labels', () => {
expect(formatEffortOption('high', '높음')).toBe('높음 (high)');
expect(formatEffortOption('', '기본값')).toBe('기본값');
});
});

View File

@@ -0,0 +1,70 @@
export const PRESET_MODELS = {
codex: ['gpt-5.5', 'gpt-5.5-pro', 'gpt-5.4', 'gpt-5.3-codex'],
claude: ['claude-opus-4-7', 'claude-opus-4-6', 'claude-sonnet-4-6'],
} as const;
export type AgentType = 'claude-code' | 'codex';
export const CODEX_EFFORT_VALUES = [
'',
'low',
'medium',
'high',
'xhigh',
'max',
] as const;
export const CLAUDE_EFFORT_VALUES = [
'',
'low',
'medium',
'high',
'max',
] as const;
/** @deprecated Use agent-specific lists via effortValuesForAgent */
export const EFFORT_VALUES = CODEX_EFFORT_VALUES;
export type EffortValue = (typeof CODEX_EFFORT_VALUES)[number];
export function effortValuesForAgent(
agentType: AgentType,
): readonly EffortValue[] {
return agentType === 'claude-code'
? CLAUDE_EFFORT_VALUES
: CODEX_EFFORT_VALUES;
}
export function isEffortSupported(
agentType: AgentType | null | undefined,
effort: string,
): boolean {
if (!effort) return true;
if (!agentType) return true;
return effortValuesForAgent(agentType).includes(effort as EffortValue);
}
export function formatEffortOption(
value: EffortValue,
localizedLabel: string,
): string {
if (value === '') return localizedLabel;
return `${localizedLabel} (${value})`;
}
export function buildModelOptions(current: string): string[] {
const presets = [...PRESET_MODELS.codex, ...PRESET_MODELS.claude];
const trimmed = current.trim();
if (!trimmed || presets.includes(trimmed as (typeof presets)[number])) {
return [...presets];
}
return [trimmed, ...presets.filter((model) => model !== trimmed)];
}
export function isPresetModel(model: string): boolean {
const trimmed = model.trim();
return (
(PRESET_MODELS.codex as readonly string[]).includes(trimmed) ||
(PRESET_MODELS.claude as readonly string[]).includes(trimmed)
);
}

View File

@@ -539,47 +539,6 @@ button:disabled {
line-height: 1;
}
.rail-foot {
display: grid;
gap: 4px;
justify-items: center;
align-content: end;
padding-top: 4px;
border-top: 1px solid var(--divider);
}
.rail-status-line {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.rail-status-dot {
display: inline-block;
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted-soft);
margin: 4px 0;
}
.rail-status-dot.is-online {
background: var(--green);
box-shadow: 0 0 6px rgba(108, 176, 135, 0.5);
}
.rail-status-dot.is-offline {
background: var(--accent);
}
.rail-actions {
display: grid;
gap: 4px;
justify-items: center;
}
.rail-btn {
display: inline-flex;
width: 32px;
@@ -610,26 +569,16 @@ button:disabled {
height: 16px;
}
@keyframes rail-spin {
to {
transform: rotate(360deg);
}
}
.rail-btn.is-spinning svg {
animation: rail-spin 0.9s linear infinite;
}
.rail-label,
.rail-btn-label,
.rail-foot-label {
.rail-btn-label {
display: none;
}
.settings-panel {
display: grid;
max-width: 1160px;
width: 100%;
max-width: 1160px;
margin-inline: auto;
}
.settings-kicker {
@@ -648,10 +597,16 @@ button:disabled {
}
.settings-sidebar {
position: sticky;
top: 72px;
align-self: start;
display: grid;
gap: 10px;
min-width: 0;
width: 100%;
}
.settings-nav-scroll {
min-width: 0;
width: 100%;
}
.settings-nav {
@@ -685,15 +640,22 @@ button:disabled {
transform 140ms ease;
}
.settings-nav button {
border-left: 3px solid transparent;
}
.settings-nav button:hover,
.settings-nav button:focus-visible,
.settings-nav button[aria-selected='true'] {
transform: translateX(2px);
border-color: rgba(214, 130, 88, 0.24);
background: rgba(214, 130, 88, 0.08);
color: var(--bg-ink);
}
.settings-nav button[aria-selected='true'] {
border-left-color: rgba(214, 130, 88, 0.72);
}
.settings-nav button:focus-visible {
outline: 2px solid rgba(214, 130, 88, 0.34);
outline-offset: 2px;
@@ -813,6 +775,209 @@ button:disabled {
.settings-section-stack {
display: grid;
gap: 12px;
}
.settings-card {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.045);
border-radius: 14px;
background:
linear-gradient(160deg, rgba(255, 255, 255, 0.04), transparent 58%),
rgba(255, 255, 255, 0.018);
}
.settings-card-head {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
}
.settings-card-head > div {
display: grid;
gap: 4px;
min-width: 0;
}
.settings-card-head h4 {
margin: 0;
font-size: 13px;
font-weight: 800;
letter-spacing: -0.01em;
color: var(--bg-ink);
}
.settings-card-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex-shrink: 0;
}
.settings-collapsible {
display: grid;
gap: 10px;
border: 1px solid rgba(255, 255, 255, 0.045);
border-radius: 14px;
background: rgba(255, 255, 255, 0.012);
overflow: hidden;
}
.settings-collapsible-trigger {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
width: 100%;
padding: 12px 14px;
border: 0;
background: transparent;
color: var(--bg-ink);
font: inherit;
text-align: left;
cursor: pointer;
}
.settings-collapsible-trigger:hover,
.settings-collapsible-trigger:focus-visible {
background: rgba(255, 255, 255, 0.03);
outline: none;
}
.settings-collapsible-trigger:focus-visible {
box-shadow: inset 0 0 0 2px rgba(214, 130, 88, 0.28);
}
.settings-collapsible-text {
display: grid;
gap: 3px;
min-width: 0;
}
.settings-collapsible-text strong {
font-size: 13px;
}
.settings-collapsible-text small {
color: var(--muted);
font-size: 11px;
line-height: 1.45;
}
.settings-collapsible-chevron {
color: var(--accent-strong);
font-size: 14px;
}
.settings-collapsible-body {
display: grid;
gap: 10px;
padding: 0 10px 10px;
}
.settings-model-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.settings-model-stack {
display: grid;
gap: 12px;
}
.settings-model-card {
display: grid;
gap: 14px;
padding: 16px;
border: 1px solid var(--panel-border);
border-radius: 16px;
background:
linear-gradient(160deg, rgba(255, 255, 255, 0.035), transparent 58%),
rgba(255, 255, 255, 0.018);
}
.settings-model-card-head {
display: grid;
gap: 4px;
}
.settings-model-card-head strong {
font-size: 16px;
letter-spacing: -0.02em;
}
.settings-model-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.settings-inline-meta {
grid-column: 1 / -1;
margin: 0;
color: var(--muted-soft);
font-size: 12px;
}
.settings-field-card {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--panel-border);
border-radius: 14px;
background: rgba(255, 255, 255, 0.02);
}
.settings-field-card-head {
display: grid;
gap: 2px;
padding-bottom: 8px;
border-bottom: 1px solid var(--divider);
}
.settings-field-card-head strong {
font-size: 14px;
letter-spacing: -0.02em;
}
.settings-toggle-stack {
display: grid;
gap: 8px;
}
.settings-moa-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.settings-moa-card {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid var(--panel-border);
border-radius: 14px;
background: rgba(255, 255, 255, 0.02);
}
.settings-moa-card-head {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
padding-bottom: 10px;
border-bottom: 1px solid var(--divider);
}
.settings-moa-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
@@ -929,6 +1094,43 @@ button:disabled {
accent-color: var(--accent);
}
.settings-toggle-row.is-busy {
opacity: 0.72;
pointer-events: none;
}
.settings-skill-default-hint {
margin: 0 0 12px;
}
.settings-skill-controls {
margin-bottom: 4px;
}
.settings-skill-agent-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.settings-skill-agent-tabs button {
min-height: 40px;
padding: 0 14px;
border: 1px solid var(--panel-border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.02);
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.settings-skill-agent-tabs button.is-active,
.settings-skill-agent-tabs button[aria-selected='true'] {
border-color: rgba(214, 130, 88, 0.42);
background: rgba(214, 130, 88, 0.14);
color: var(--bg-ink);
}
.settings-save,
.settings-restart {
min-height: 44px;
@@ -965,6 +1167,17 @@ button:disabled {
font-size: 12px;
}
.settings-effort-warn {
margin: 0;
padding: 6px 8px;
border: 1px solid rgba(224, 100, 75, 0.28);
border-radius: 6px;
background: rgba(224, 100, 75, 0.06);
color: var(--status-critical);
font-size: 12px;
line-height: 1.45;
}
.settings-account-group {
display: grid;
gap: 10px;
@@ -975,7 +1188,136 @@ button:disabled {
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.settings-account-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.settings-account-row {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--panel-border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
}
.settings-account-row.is-primary,
.settings-account-row.is-active-account {
border-color: rgba(80, 180, 130, 0.42);
background: rgba(80, 180, 130, 0.06);
}
.settings-account-row-main {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
flex: 1 1 auto;
}
.settings-account-row-copy {
display: grid;
gap: 1px;
min-width: 0;
flex: 1 1 auto;
}
.settings-account-row-copy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.settings-account-row-copy .settings-hint {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-account-row-meta {
display: inline-flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
min-width: 0;
}
.settings-account-card {
display: grid;
gap: 12px;
min-height: 100%;
padding: 14px;
border: 1px solid var(--panel-border);
border-radius: 16px;
background: rgba(255, 255, 255, 0.02);
}
.settings-account-card.is-primary,
.settings-account-card.is-active-account {
border-color: rgba(80, 180, 130, 0.42);
background: rgba(80, 180, 130, 0.06);
}
.settings-account-card-head,
.settings-account-card-foot {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.settings-account-card-body {
display: grid;
gap: 6px;
min-width: 0;
}
.settings-account-card-body strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-account-index {
color: var(--muted);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.04em;
}
.settings-pill {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.02em;
}
.settings-pill.is-primary,
.settings-pill.is-active {
border: 1px solid rgba(80, 180, 130, 0.34);
background: rgba(80, 180, 130, 0.12);
color: #d7f5e7;
}
.settings-pill.is-compact {
min-height: 20px;
padding: 0 7px;
font-size: 10px;
flex: none;
}
.settings-account-group-head {
@@ -985,36 +1327,23 @@ button:disabled {
justify-content: space-between;
}
.settings-account-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--panel-border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
}
.settings-account-row.is-active-account {
border-color: rgba(80, 180, 130, 0.45);
background: rgba(80, 180, 130, 0.06);
}
.settings-account-actions {
display: flex;
gap: 6px;
gap: 4px;
align-items: center;
flex: none;
flex-wrap: wrap;
justify-content: flex-end;
}
.settings-secondary {
min-height: 36px;
padding: 0 10px;
min-height: 32px;
padding: 0 8px;
border: 1px solid var(--panel-border-strong);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: var(--bg-ink);
font-size: 11px;
font-size: 10px;
font-weight: 700;
cursor: pointer;
}
@@ -1153,13 +1482,13 @@ button:disabled {
}
.settings-delete {
min-height: 36px;
padding: 0 10px;
min-height: 32px;
padding: 0 8px;
border: 1px solid rgba(224, 100, 75, 0.32);
border-radius: 999px;
background: rgba(224, 100, 75, 0.06);
color: var(--status-critical);
font-size: 11px;
font-size: 10px;
font-weight: 700;
cursor: pointer;
}
@@ -1171,9 +1500,9 @@ button:disabled {
.settings-add-token {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: end;
gap: 10px;
padding-top: 8px;
border-top: 1px solid var(--divider);
}
.settings-add-token textarea {
@@ -1189,7 +1518,7 @@ button:disabled {
}
.settings-add-token button {
align-self: end;
justify-self: start;
min-height: 44px;
padding: 0 14px;
border: 0;
@@ -1208,55 +1537,224 @@ button:disabled {
@media (max-width: 980px) {
.settings-layout {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
.settings-sidebar {
position: static;
width: 100%;
min-width: 0;
}
.settings-nav-scroll {
width: 100%;
min-width: 0;
border-bottom: 1px solid var(--panel-border);
}
.settings-nav {
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
display: flex;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
scroll-padding-inline: 0;
-webkit-overflow-scrolling: touch;
grid-template-columns: unset;
margin: 0;
padding: 6px 0 10px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.settings-nav button {
flex: 0 0 auto;
min-width: 118px;
min-height: 54px;
scroll-snap-align: start;
border: 1px solid var(--panel-border);
border-radius: 12px;
background: rgba(19, 24, 22, 0.72);
border-left: 1px solid var(--panel-border);
padding: 8px 10px;
align-content: start;
}
.settings-nav button[aria-selected='true'] {
border-color: rgba(214, 130, 88, 0.38);
border-left-color: rgba(214, 130, 88, 0.72);
background: rgba(214, 130, 88, 0.12);
margin-bottom: 0;
padding-bottom: 8px;
}
.settings-nav button small {
line-height: 1.35;
white-space: normal;
}
.settings-apply-card {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
.settings-model-grid,
.settings-model-fields {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.settings-nav {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-nav button {
min-height: 58px;
min-height: 52px;
min-width: 108px;
padding: 7px 9px;
}
.settings-apply-card {
align-items: stretch;
grid-template-columns: 1fr;
padding: 10px;
}
.settings-restart {
width: 100%;
}
.settings-form-grid,
.settings-row-inline,
.settings-account-row,
.settings-add-token {
.settings-moa-fields {
grid-template-columns: 1fr;
}
.settings-moa-grid {
grid-template-columns: 1fr;
.settings-card-head,
.settings-moa-card-head {
flex-direction: column;
align-items: stretch;
}
.settings-account-main {
grid-template-columns: 28px minmax(0, 1fr);
.settings-card-actions {
width: 100%;
}
.settings-card-actions .settings-secondary {
width: 100%;
}
.settings-account-row {
flex-wrap: wrap;
align-items: flex-start;
padding: 8px;
}
.settings-account-row-main {
width: 100%;
}
.settings-account-actions {
justify-content: flex-start;
flex-wrap: wrap;
width: 100%;
justify-content: stretch;
}
.settings-account-actions .settings-secondary,
.settings-account-actions .settings-delete {
flex: 1 1 0;
min-width: 72px;
}
.settings-add-token .settings-save {
width: 100%;
}
}
@media (max-width: 640px) {
.dashboard-content {
padding: 6px 10px calc(16px + env(safe-area-inset-bottom));
gap: 6px;
}
.panel {
padding: 10px;
border-radius: 14px;
}
.panel-title {
margin-bottom: 6px;
}
.panel-title h2 {
font-size: 20px;
}
.error-card {
flex-direction: column;
align-items: stretch;
padding: 12px;
gap: 10px;
margin-top: 8px;
border-radius: 14px;
}
.error-card button {
width: 100%;
min-width: 0;
}
.settings-section {
padding: 12px;
border-radius: 12px;
gap: 10px;
scroll-margin-top: 68px;
}
.settings-section-head h3 {
font-size: 16px;
}
.settings-section-head p {
font-size: 11px;
}
.settings-card {
padding: 10px;
gap: 8px;
border-radius: 12px;
}
.settings-toggle-row {
gap: 10px;
padding: 8px 10px;
}
.settings-model-card {
padding: 10px;
}
.settings-nav button strong {
font-size: 12px;
}
.settings-nav button small {
font-size: 10px;
}
.settings-account-index {
font-size: 11px;
min-width: 24px;
}
.settings-account-badge {
font-size: 10px;
padding: 2px 6px;
}
.settings-account-plan {
font-size: 10px;
padding: 1px 6px;
}
}
@@ -1405,8 +1903,7 @@ button:disabled {
}
.nav-drawer .rail-label,
.nav-drawer .rail-btn-label,
.nav-drawer .rail-foot-label {
.nav-drawer .rail-btn-label {
display: inline;
}
@@ -1424,23 +1921,6 @@ button:disabled {
margin-left: auto;
}
.nav-drawer .rail-foot {
justify-items: stretch;
padding-top: 10px;
}
.nav-drawer .rail-status-line {
padding: 0 8px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.nav-drawer .rail-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
justify-items: stretch;
}
.nav-drawer .rail-btn {
width: 100%;
min-height: 40px;
@@ -1559,15 +2039,6 @@ button:disabled {
border-color: rgba(183, 71, 52, 0.28);
}
.ops-tile-pwa button {
min-height: 30px;
width: fit-content;
padding: 0 10px;
box-shadow: none;
font-size: 12px;
font-weight: 800;
}
.skeleton-card {
display: grid;
gap: 8px;

View File

@@ -30,18 +30,18 @@ REVIEWER_AGENT_TYPE=claude-code
# ARBITER_AGENT_TYPE=claude-code
# provider 기본 모델
CLAUDE_MODEL=claude-opus-4-6
CLAUDE_MODEL=claude-opus-4-7
CLAUDE_EFFORT=high
CLAUDE_THINKING=adaptive
CODEX_MODEL=gpt-5.4
CODEX_MODEL=gpt-5.5
CODEX_EFFORT=xhigh
# 역할별 override
OWNER_MODEL=gpt-5.4
OWNER_MODEL=gpt-5.5
OWNER_EFFORT=xhigh
OWNER_FALLBACK_ENABLED=true
REVIEWER_MODEL=claude-opus-4-6
REVIEWER_MODEL=claude-opus-4-7
REVIEWER_EFFORT=high
REVIEWER_FALLBACK_ENABLED=true

View File

@@ -52,18 +52,15 @@ async function main() {
await openSettingsSection(page, 'settings-runtime');
assert.equal(page.url(), originalUrl);
await assertVisible(page.locator('#settings-runtime'));
await page.getByText('Runtime inventory').waitFor();
await page.getByText('EJClaw bridge').waitFor();
const codexPolicy = page
.locator('.runtime-room-agent-policy')
.filter({ hasText: 'Codex' })
await page.getByRole('heading', { name: '방 선택' }).waitFor();
const skillToggle = page
.locator(
'#settings-runtime .settings-toggle-row input[type="checkbox"]',
)
.first();
const codexBrowserToggle = codexPolicy
.locator('input[type="checkbox"]')
.first();
assert.equal(await codexBrowserToggle.isChecked(), true);
await codexBrowserToggle.click();
await codexPolicy.getByText('1개 OFF').waitFor();
assert.equal(await skillToggle.isChecked(), true);
await skillToggle.click();
assert.equal(await skillToggle.isChecked(), false);
assert.equal(state.roomSkillUpdates, 1);
await page
@@ -76,6 +73,14 @@ async function main() {
await assertVisible(page.locator('.settings-panel'));
await assertVisible(page.locator('#settings-accounts'));
const sidebar = page.locator('.settings-sidebar');
const navBoxBefore = await sidebar.boundingBox();
await openSettingsSection(page, 'settings-general');
await page.waitForTimeout(150);
const navBoxAfter = await sidebar.boundingBox();
assert.ok(navBoxBefore && navBoxAfter);
assert.equal(navBoxBefore.y, navBoxAfter.y);
await assertNoSeriousA11yViolations(page);
},
);
@@ -94,7 +99,7 @@ async function main() {
await goalToggle.click();
await page
.getByText('저장됨. 적용하려면 상단의 스택 재시작을 눌러 주세요.')
.getByText('저장됨. 적용하려면 사이드바의 스택 재시작을 눌러 주세요.')
.waitFor();
assert.equal(await goalToggle.isChecked(), true);
@@ -191,6 +196,110 @@ async function main() {
},
);
await runMobileScenario(
'mobile settings keeps nav and content aligned without horizontal scroll',
browser,
baseUrl,
async (page) => {
await openSettings(page, baseUrl);
await assertNoHorizontalOverflow(page);
const nav = page.locator('.settings-nav-scroll');
const section = page.locator('.settings-section').first();
const navBox = await nav.boundingBox();
const sectionBox = await section.boundingBox();
assert.ok(navBox && sectionBox);
assert.equal(Math.round(navBox.x), Math.round(sectionBox.x));
assert.equal(Math.round(navBox.width), Math.round(sectionBox.width));
const selectedTab = page.locator(
'.settings-nav button[aria-selected="true"]',
);
const tabBox = await selectedTab.boundingBox();
const subtitleBox = await selectedTab.locator('small').boundingBox();
assert.ok(tabBox && subtitleBox);
assert.ok(
subtitleBox.y + subtitleBox.height <= tabBox.y + tabBox.height + 1,
'tab subtitle should not be clipped',
);
await openSettingsSection(page, 'settings-models');
await assertNoHorizontalOverflow(page);
await assertNoSeriousA11yViolations(page);
},
);
await runMobileScenario(
'mobile accounts use compact rows for multiple entries',
browser,
baseUrl,
async (page) => {
await openSettings(page, baseUrl);
await openSettingsSection(page, 'settings-accounts');
await assertVisible(page.locator('#settings-accounts'));
assert.equal(await page.locator('.settings-account-list').count(), 2);
assert.equal(await page.locator('.settings-account-row').count(), 6);
assert.equal(await page.locator('.settings-account-card').count(), 0);
const listBox = await page
.locator('.settings-account-list')
.first()
.boundingBox();
const rowBox = await page
.locator('.settings-account-row')
.first()
.boundingBox();
assert.ok(listBox && rowBox);
assert.ok(rowBox.height <= 72, 'account row should stay compact');
await assertNoHorizontalOverflow(page);
},
);
await runMobileScenario(
'mobile primary views render without layout overflow',
browser,
baseUrl,
async (page) => {
for (const hash of [
'#/rooms',
'#/scheduled',
'#/usage',
'#/settings',
]) {
await page.goto(new URL(hash, baseUrl).toString(), {
waitUntil: 'networkidle',
});
await assertNoHorizontalOverflow(page);
}
await page.goto(new URL('/#/scheduled', baseUrl).toString(), {
waitUntil: 'networkidle',
});
await assertVisible(page.locator('#scheduled .task-summary-bar'));
await assertVisible(page.locator('#scheduled .task-filter-tabs'));
},
);
await runMobileScenario(
'mobile scheduled board uses compact rows and filter tabs',
browser,
baseUrl,
async (page) => {
await page.goto(new URL('/#/scheduled', baseUrl).toString(), {
waitUntil: 'networkidle',
});
await assertVisible(page.locator('#scheduled .task-summary-bar'));
await assertVisible(page.locator('#scheduled .task-filter-tabs'));
assert.equal(await page.locator('#scheduled .task-row').count(), 3);
await page.getByRole('tab', { name: /예약|Scheduled/i }).click();
assert.equal(await page.locator('#scheduled .task-row').count(), 1);
await assertNoHorizontalOverflow(page);
},
);
console.log('dashboard:ux passed');
} finally {
await browser.close();
@@ -208,11 +317,10 @@ async function runScheduledBoardScenario(browser: Browser, baseUrl: string) {
waitUntil: 'networkidle',
});
await assertVisible(page.locator('#scheduled .task-command-center'));
await assertVisible(page.locator('#scheduled .task-create-form'));
await assertVisible(page.locator('#scheduled .task-summary-bar'));
await assertVisible(page.locator('#scheduled .task-create-panel'));
await assertVisible(page.getByText('Nightly cleanup').first());
await assertVisible(page.getByText('*/15 * * * *').first());
assert.equal(await page.locator('#scheduled .task-card').count(), 3);
assert.equal(await page.locator('#scheduled .task-row').count(), 3);
assert.equal(
await page.locator('#scheduled .task-group-empty').count(),
0,
@@ -240,9 +348,46 @@ async function runScenario(
browser: Browser,
baseUrl: string,
run: (page: Page, state: MockApiState) => Promise<void>,
) {
await runScenarioWithViewport(
name,
browser,
baseUrl,
{ width: 1280, height: 720 },
run,
);
}
async function runMobileScenario(
name: string,
browser: Browser,
baseUrl: string,
run: (page: Page, state: MockApiState) => Promise<void>,
) {
await runScenarioWithViewport(
name,
browser,
baseUrl,
{ width: 390, height: 844 },
run,
);
}
async function runScenarioWithViewport(
name: string,
browser: Browser,
baseUrl: string,
viewport: { width: number; height: number },
run: (page: Page, state: MockApiState) => Promise<void>,
) {
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
locale: 'ko-KR',
viewport,
isMobile: viewport.width <= 640,
hasTouch: viewport.width <= 640,
});
await context.addInitScript(() => {
window.localStorage.setItem('ejclaw.dashboard.locale.v2', 'ko');
});
const state = createMockApiState();
await context.route('**/api/**', (route) => handleMockApi(route, state));
@@ -312,6 +457,23 @@ async function assertNoSeriousA11yViolations(page: Page) {
);
}
async function assertNoHorizontalOverflow(page: Page) {
const overflow = await page.evaluate(() => {
const doc = document.documentElement;
const tolerance = 1;
return {
document: doc.scrollWidth - doc.clientWidth > tolerance,
body: document.body.scrollWidth - document.body.clientWidth > tolerance,
};
});
assert.equal(
overflow.document,
false,
'document should not scroll horizontally',
);
assert.equal(overflow.body, false, 'body should not scroll horizontally');
}
async function handleMockApi(route: Route, state: MockApiState) {
const request = route.request();
const url = new URL(request.url());
@@ -414,10 +576,26 @@ async function handleMockApi(route: Route, state: MockApiState) {
index: 0,
expiresAt: null,
scopes: [],
subscriptionType: 'max',
rateLimitTier: 'default',
exists: true,
},
{
index: 1,
expiresAt: null,
scopes: [],
subscriptionType: 'pro',
rateLimitTier: 'default',
exists: true,
},
{
index: 2,
expiresAt: null,
scopes: [],
subscriptionType: 'team',
rateLimitTier: 'default',
exists: true,
},
],
codex: [
{
@@ -451,6 +629,24 @@ async function handleMockApi(route: Route, state: MockApiState) {
},
exists: true,
},
{
index: 1,
accountId: 'acct_backup',
email: 'backup@example.com',
planType: 'plus',
subscriptionUntil: '2026-06-01T00:00:00.000Z',
subscriptionLastChecked: '2026-01-01T00:00:00.000Z',
exists: true,
},
{
index: 2,
accountId: 'acct_sandbox',
email: 'sandbox@example.com',
planType: 'free',
subscriptionUntil: null,
subscriptionLastChecked: '2026-01-01T00:00:00.000Z',
exists: true,
},
],
codexCurrentIndex: 0,
});

View File

@@ -1,4 +1,5 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -577,6 +578,24 @@ describe('prepareGroupEnvironment Codex goals handling', () => {
expect(prepared.env.CODEX_GOALS).toBe('true');
});
it('enables goals from host ~/.codex/config.toml [features]', () => {
mockReadEnvFile.mockReturnValue({});
const homedirSpy = vi
.spyOn(os, 'homedir')
.mockReturnValue(process.env.EJ_TEST_HOME!);
fs.writeFileSync(
path.join(process.env.EJ_TEST_HOME!, '.codex', 'config.toml'),
'[features]\ngoals = true\n',
);
try {
const prepared = prepareGroupEnvironment(group, false, 'dc:test');
expect(prepared.env.CODEX_GOALS).toBe('true');
} finally {
homedirSpy.mockRestore();
}
});
});
describe('prepareReadonlySessionEnvironment codex compatibility', () => {

View File

@@ -12,6 +12,8 @@ import {
import { logger } from './logger.js';
import { readEnvFile } from './env.js';
import { getActiveCodexAuthPath } from './codex-token-rotation.js';
import { readCodexFeatureFromFile } from './codex-config-features.js';
import { ensureClaudeSessionSettings } from './claude-session-settings.js';
import {
getConfiguredClaudeTokens,
getCurrentToken,
@@ -130,27 +132,7 @@ function readOptionalPromptFile(
return prompt || undefined;
}
function ensureClaudeSessionSettings(groupSessionsDir: string): void {
const settingsFile = path.join(groupSessionsDir, 'settings.json');
if (fs.existsSync(settingsFile)) return;
fs.writeFileSync(
settingsFile,
JSON.stringify(
{
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
},
},
null,
2,
) + '\n',
);
}
export function ensureClaudeGlobalSettingsFile(sessionDir: string): void {
function ensureClaudeGlobalSettingsFile(sessionDir: string): void {
const settingsFile = path.join(sessionDir, '.claude.json');
if (fs.existsSync(settingsFile)) return;
@@ -387,15 +369,6 @@ function prepareCodexSessionEnvironment(args: {
process.env.CODEX_EFFORT;
if (codexEffort) args.env.CODEX_EFFORT = codexEffort;
const codexGoals =
args.group.agentConfig?.codexGoals ??
(args.envVars.CODEX_GOALS === 'true' || process.env.CODEX_GOALS === 'true');
if (codexGoals) {
args.env.CODEX_GOALS = 'true';
} else {
delete args.env.CODEX_GOALS;
}
const sessionCodexDir = path.join(args.sessionRootDir, '.codex');
syncHostCodexSessionFiles(sessionCodexDir);
@@ -414,6 +387,18 @@ function prepareCodexSessionEnvironment(args: {
}
}
const goalsFromConfig = readCodexFeatureFromFile(sessionConfigPath, 'goals');
const codexGoals =
args.group.agentConfig?.codexGoals ??
(goalsFromConfig ||
args.envVars.CODEX_GOALS === 'true' ||
process.env.CODEX_GOALS === 'true');
if (codexGoals) {
args.env.CODEX_GOALS = 'true';
} else {
delete args.env.CODEX_GOALS;
}
const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md');
const sessionAgents = (
args.useFailoverPromptPack

View File

@@ -53,6 +53,7 @@ vi.mock('../service-routing.js', () => ({
type Handler = (...args: any[]) => any;
const clientRef = vi.hoisted(() => ({ current: null as any }));
const loginShouldRejectRef = vi.hoisted(() => ({ value: false }));
vi.mock('discord.js', () => {
const Events = {
@@ -89,6 +90,9 @@ vi.mock('discord.js', () => {
}
async login(_token: string) {
if (loginShouldRejectRef.value) {
throw new Error('An invalid token was provided.');
}
this._ready = true;
// Fire the ready event
const readyHandlers = this.eventHandlers.get('ready') || [];
@@ -233,6 +237,7 @@ describe('DiscordChannel', () => {
beforeEach(() => {
vi.clearAllMocks();
hasReviewerLeaseMock.mockReturnValue(false);
loginShouldRejectRef.value = false;
});
afterEach(() => {
@@ -263,6 +268,17 @@ describe('DiscordChannel', () => {
expect(channel.isConnected()).toBe(true);
});
it('rejects connect() when login fails', async () => {
loginShouldRejectRef.value = true;
const opts = createTestOpts();
const channel = new DiscordChannel('bad-token', opts);
await expect(channel.connect()).rejects.toThrow(
'An invalid token was provided.',
);
expect(channel.isConnected()).toBe(false);
});
it('registers message handlers on connect', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);

View File

@@ -239,7 +239,7 @@ export class DiscordChannel implements Channel {
logger.error({ err: err.message }, 'Discord client error');
});
return new Promise<void>((resolve) => {
return new Promise<void>((resolve, reject) => {
this.client!.once(Events.ClientReady, (readyClient) => {
logger.info(
{ username: readyClient.user.tag, id: readyClient.user.id },
@@ -252,7 +252,7 @@ export class DiscordChannel implements Channel {
resolve();
});
this.client!.login(this.botToken);
void this.client!.login(this.botToken).catch(reject);
});
}

View File

@@ -0,0 +1,39 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ensureClaudeSessionSettings } from './claude-session-settings.js';
describe('claude-session-settings', () => {
let tempDir: string;
let hostSettingsPath: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-claude-settings-'));
hostSettingsPath = path.join(tempDir, 'host-settings.json');
process.env.EJCLAW_CLAUDE_SETTINGS_PATH = hostSettingsPath;
});
afterEach(() => {
delete process.env.EJCLAW_CLAUDE_SETTINGS_PATH;
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('syncs host fastMode into the Claude session settings file', () => {
fs.writeFileSync(
hostSettingsPath,
`${JSON.stringify({ fastMode: true }, null, 2)}\n`,
);
const sessionDir = path.join(tempDir, 'session');
ensureClaudeSessionSettings(sessionDir);
const session = JSON.parse(
fs.readFileSync(path.join(sessionDir, 'settings.json'), 'utf-8'),
) as { fastMode?: boolean; env?: Record<string, string> };
expect(session.fastMode).toBe(true);
expect(session.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1');
});
});

View File

@@ -0,0 +1,68 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const DEFAULT_CLAUDE_SESSION_ENV = {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
} as const;
function settingsHomeDir(): string {
return process.env.EJCLAW_SETTINGS_HOME || os.homedir();
}
export function claudeHostSettingsPath(): string {
const override = process.env.EJCLAW_CLAUDE_SETTINGS_PATH?.trim();
if (override) return override;
return path.join(settingsHomeDir(), '.claude', 'settings.json');
}
export function readHostClaudeFastMode(): boolean {
const file = claudeHostSettingsPath();
if (!fs.existsSync(file)) return false;
try {
const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<
string,
unknown
>;
return data.fastMode === true;
} catch {
return false;
}
}
export function ensureClaudeSessionSettings(groupSessionsDir: string): void {
const settingsFile = path.join(groupSessionsDir, 'settings.json');
let data: Record<string, unknown> = {
env: { ...DEFAULT_CLAUDE_SESSION_ENV },
};
if (fs.existsSync(settingsFile)) {
try {
const existing = JSON.parse(
fs.readFileSync(settingsFile, 'utf-8'),
) as Record<string, unknown>;
data = {
...existing,
env: {
...DEFAULT_CLAUDE_SESSION_ENV,
...(typeof existing.env === 'object' && existing.env !== null
? (existing.env as Record<string, unknown>)
: {}),
},
};
} catch {
data = { env: { ...DEFAULT_CLAUDE_SESSION_ENV } };
}
}
if (readHostClaudeFastMode()) {
data.fastMode = true;
} else {
delete data.fastMode;
}
fs.mkdirSync(groupSessionsDir, { recursive: true });
fs.writeFileSync(settingsFile, `${JSON.stringify(data, null, 2)}\n`);
}

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import {
readCodexFeatureFromContent,
writeCodexFeatureInContent,
} from './codex-config-features.js';
describe('codex-config-features', () => {
it('reads feature flags from the [features] section', () => {
const toml = `
model = "gpt-5.5"
[features]
fast_mode = true
goals = false
`;
expect(readCodexFeatureFromContent(toml, 'fast_mode')).toBe(true);
expect(readCodexFeatureFromContent(toml, 'goals')).toBe(false);
});
it('writes missing feature flags into [features]', () => {
const updated = writeCodexFeatureInContent(
'model = "gpt-5.5"\n',
'goals',
true,
);
expect(updated).toContain('[features]');
expect(updated).toContain('goals = true');
});
});

View File

@@ -0,0 +1,84 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
export type CodexConfigFeature = 'fast_mode' | 'goals';
export function codexConfigPath(): string {
const override = process.env.EJCLAW_CODEX_CONFIG_PATH?.trim();
if (override) return override;
const home = process.env.EJCLAW_SETTINGS_HOME || os.homedir();
return path.join(home, '.codex', 'config.toml');
}
export function readCodexFeatureFromContent(
content: string,
feature: CodexConfigFeature,
): boolean {
const lines = content.split('\n');
let inFeatures = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '[features]') {
inFeatures = true;
continue;
}
if (inFeatures && trimmed.startsWith('[') && trimmed.endsWith(']')) {
break;
}
if (!inFeatures) continue;
const match = trimmed.match(
new RegExp(`^${feature}\\s*=\\s*(true|false)$`),
);
if (match) return match[1] === 'true';
}
return false;
}
export function readCodexFeatureFromFile(
filePath: string,
feature: CodexConfigFeature,
): boolean {
if (!fs.existsSync(filePath)) return false;
return readCodexFeatureFromContent(
fs.readFileSync(filePath, 'utf-8'),
feature,
);
}
export function writeCodexFeatureInContent(
content: string,
feature: CodexConfigFeature,
value: boolean,
): string {
const line = `${feature} = ${value}`;
const re = new RegExp(`^\\s*${feature}\\s*=\\s*(true|false)\\s*$`, 'm');
if (re.test(content)) {
return content.replace(re, line);
}
if (/^\[features\]/m.test(content)) {
return content.replace(/^\[features\]\s*$/m, `[features]\n${line}`);
}
const trimmed = content.replace(/\s*$/, '');
if (!trimmed) {
return `[features]\n${line}\n`;
}
return `${trimmed}\n\n[features]\n${line}\n`;
}
export function writeCodexFeatureToFile(
filePath: string,
feature: CodexConfigFeature,
value: boolean,
): void {
const content = fs.existsSync(filePath)
? fs.readFileSync(filePath, 'utf-8')
: '';
const updated = writeCodexFeatureInContent(content, feature, value);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.tmp`;
fs.writeFileSync(tempPath, updated, { mode: 0o600 });
fs.renameSync(tempPath, filePath);
}

View File

@@ -346,12 +346,25 @@ async function main(): Promise<void> {
);
continue;
}
channels.push(channel);
await channel.connect();
try {
await channel.connect();
channels.push(channel);
} catch (err) {
logger.error(
{ channel: channelName, err },
'Channel connect failed — skipping',
);
}
}
if (channels.length === 0) {
logger.fatal('No channels connected');
process.exit(1);
if (WEB_DASHBOARD.enabled) {
logger.warn(
'No channels connected; continuing in web-dashboard-only mode',
);
} else {
logger.fatal('No channels connected');
process.exit(1);
}
}
// Start subsystems (independently of connection handler)

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { agentTypeForRole, isEffortSupported } from './settings-effort.js';
describe('settings-effort', () => {
it('maps roles to agent types with defaults', () => {
expect(agentTypeForRole('owner', {})).toBe('codex');
expect(agentTypeForRole('reviewer', {})).toBe('claude-code');
expect(agentTypeForRole('arbiter', {})).toBeNull();
expect(agentTypeForRole('owner', { OWNER_AGENT_TYPE: 'claude-code' })).toBe(
'claude-code',
);
});
it('rejects xhigh for Claude agents', () => {
expect(isEffortSupported('claude-code', 'xhigh')).toBe(false);
expect(isEffortSupported('codex', 'xhigh')).toBe(true);
expect(isEffortSupported('claude-code', '')).toBe(true);
});
});

64
src/settings-effort.ts Normal file
View File

@@ -0,0 +1,64 @@
export type SettingsAgentType = 'claude-code' | 'codex';
export const CODEX_EFFORT_VALUES = [
'',
'low',
'medium',
'high',
'xhigh',
'max',
] as const;
export const CLAUDE_EFFORT_VALUES = [
'',
'low',
'medium',
'high',
'max',
] as const;
export type EffortValue = (typeof CODEX_EFFORT_VALUES)[number];
export function effortValuesForAgent(
agentType: SettingsAgentType,
): readonly EffortValue[] {
return agentType === 'claude-code'
? CLAUDE_EFFORT_VALUES
: CODEX_EFFORT_VALUES;
}
export function isEffortSupported(
agentType: SettingsAgentType | null | undefined,
effort: string,
): boolean {
if (!effort) return true;
if (!agentType) return true;
return effortValuesForAgent(agentType).includes(effort as EffortValue);
}
export function readSettingsAgentType(
value: string | undefined,
): SettingsAgentType | null {
if (value === 'claude-code' || value === 'codex') return value;
return null;
}
export function agentTypeForRole(
role: 'owner' | 'reviewer' | 'arbiter',
env:
| Record<string, string | undefined>
| ((key: string) => string | undefined),
): SettingsAgentType | null {
const read = typeof env === 'function' ? env : (key: string) => env[key];
const key =
role === 'owner'
? 'OWNER_AGENT_TYPE'
: role === 'reviewer'
? 'REVIEWER_AGENT_TYPE'
: 'ARBITER_AGENT_TYPE';
const raw = read(key);
if (raw !== undefined) return readSettingsAgentType(raw);
if (role === 'owner') return 'codex';
if (role === 'reviewer') return 'claude-code';
return null;
}

View File

@@ -29,6 +29,7 @@ describe('settings-store Codex features', () => {
delete process.env.CODEX_GOALS;
process.env.HOME = tempDir;
process.env.EJCLAW_SETTINGS_HOME = tempDir;
fs.mkdirSync(path.join(tempDir, '.codex'), { recursive: true });
process.chdir(tempDir);
});
@@ -59,17 +60,28 @@ describe('settings-store Codex features', () => {
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.`;
}
it('stores the Codex goals opt-in in the EJClaw .env file', () => {
it('stores Codex goals in ~/.codex/config.toml [features]', () => {
expect(getCodexFeatures()).toEqual({ goals: false });
expect(updateCodexFeatures({ goals: true })).toEqual({ goals: true });
expect(fs.readFileSync(path.join(tempDir, '.env'), 'utf-8')).toContain(
'CODEX_GOALS=true',
);
expect(
fs.readFileSync(path.join(tempDir, '.codex', 'config.toml'), 'utf-8'),
).toContain('goals = true');
expect(updateCodexFeatures({ goals: false })).toEqual({ goals: false });
expect(fs.readFileSync(path.join(tempDir, '.env'), 'utf-8')).toContain(
'CODEX_GOALS=false',
expect(
fs.readFileSync(path.join(tempDir, '.codex', 'config.toml'), 'utf-8'),
).toContain('goals = false');
});
it('still honors legacy CODEX_GOALS=true until migrated', () => {
fs.writeFileSync(path.join(tempDir, '.env'), 'CODEX_GOALS=true\n');
expect(getCodexFeatures()).toEqual({ goals: true });
updateCodexFeatures({ goals: false });
expect(getCodexFeatures()).toEqual({ goals: false });
expect(fs.readFileSync(path.join(tempDir, '.env'), 'utf-8')).not.toContain(
'CODEX_GOALS',
);
});

View File

@@ -26,6 +26,12 @@ import {
type CodexLiveStatus,
type CodexLiveStatusSummary,
} from './codex-live-status.js';
import {
readCodexFeatureFromFile,
writeCodexFeatureToFile,
} from './codex-config-features.js';
import { readHostClaudeFastMode } from './claude-session-settings.js';
import { agentTypeForRole, isEffortSupported } from './settings-effort.js';
export type {
CodexAdditionalRateLimitSummary,
@@ -62,10 +68,17 @@ export interface ModelRoleConfig {
effort: string;
}
export interface ModelAgentTypes {
owner: 'claude-code' | 'codex';
reviewer: 'claude-code' | 'codex';
arbiter: 'claude-code' | 'codex' | null;
}
export interface ModelConfigSnapshot {
owner: ModelRoleConfig;
reviewer: ModelRoleConfig;
arbiter: ModelRoleConfig;
agentTypes: ModelAgentTypes;
}
export interface FastModeSnapshot {
@@ -269,7 +282,19 @@ function readEnvOrProcess(key: string): string | undefined {
}
export function getModelConfig(): ModelConfigSnapshot {
const out: Partial<ModelConfigSnapshot> = {};
const env = readEnvFile();
const envLookup = (key: string): string | undefined => {
const fromFile = pickEnvValue(env, key);
if (fromFile !== undefined) return fromFile;
return process.env[key];
};
const out: Partial<ModelConfigSnapshot> = {
agentTypes: {
owner: agentTypeForRole('owner', envLookup) ?? 'codex',
reviewer: agentTypeForRole('reviewer', envLookup) ?? 'claude-code',
arbiter: agentTypeForRole('arbiter', envLookup),
},
};
for (const role of ROLE_KEYS) {
const model = readEnvOrProcess(`${role}_MODEL`) ?? '';
const effort = readEnvOrProcess(`${role}_EFFORT`) ?? '';
@@ -308,6 +333,8 @@ export function updateModelConfig(
if (fs.existsSync(file)) {
content = fs.readFileSync(file, 'utf-8');
}
const envLookup = (key: string): string | undefined =>
pickEnvValue(content, key) ?? process.env[key];
for (const role of ROLE_KEYS) {
const update = (
@@ -318,6 +345,19 @@ export function updateModelConfig(
content = setOrInsertEnvLine(content, `${role}_MODEL`, update.model);
}
if (update.effort !== undefined) {
const agentType = agentTypeForRole(
role.toLowerCase() as 'owner' | 'reviewer' | 'arbiter',
envLookup,
);
if (
update.effort &&
agentType &&
!isEffortSupported(agentType, update.effort)
) {
throw new Error(
`${role}_EFFORT=${update.effort} is not supported for ${agentType} agents`,
);
}
content = setOrInsertEnvLine(content, `${role}_EFFORT`, update.effort);
}
}
@@ -564,7 +604,7 @@ export function stopCodexAccountRefreshLoop(): void {
}
}
function codexConfigPath(): string {
function codexConfigFile(): string {
return path.join(settingsHomeDir(), '.codex', 'config.toml');
}
@@ -573,51 +613,15 @@ function claudeSettingsPath(): string {
}
function readCodexFastMode(): boolean {
const file = codexConfigPath();
if (!fs.existsSync(file)) return false;
const content = fs.readFileSync(file, 'utf-8');
// [features] section, look for fast_mode = true|false
const featuresMatch = content.match(/\[features\][\s\S]*?(?=^\[|$)/m);
const block = featuresMatch ? featuresMatch[0] : content;
const m = block.match(/^\s*fast_mode\s*=\s*(true|false)\s*$/m);
return m ? m[1] === 'true' : false;
return readCodexFeatureFromFile(codexConfigFile(), 'fast_mode');
}
function writeCodexFastMode(value: boolean): void {
const file = codexConfigPath();
let content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
if (/^\s*fast_mode\s*=\s*(true|false)\s*$/m.test(content)) {
content = content.replace(
/^\s*fast_mode\s*=\s*(true|false)\s*$/m,
`fast_mode = ${value}`,
);
} else if (/^\[features\]/m.test(content)) {
content = content.replace(
/^\[features\]\s*$/m,
`[features]\nfast_mode = ${value}`,
);
} else {
const trimmed = content.replace(/\s*$/, '');
content = `${trimmed}\n\n[features]\nfast_mode = ${value}\n`;
}
const tempPath = `${file}.tmp`;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(tempPath, content, { mode: 0o600 });
fs.renameSync(tempPath, file);
writeCodexFeatureToFile(codexConfigFile(), 'fast_mode', value);
}
function readClaudeFastMode(): boolean {
const file = claudeSettingsPath();
if (!fs.existsSync(file)) return false;
try {
const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<
string,
unknown
>;
return data.fastMode === true;
} catch {
return false;
}
return readHostClaudeFastMode();
}
function writeClaudeFastMode(value: boolean): void {
@@ -658,19 +662,26 @@ export function updateFastMode(
}
function readCodexGoals(): boolean {
const fromConfig = readCodexFeatureFromFile(codexConfigFile(), 'goals');
if (fromConfig) return true;
// Legacy .env opt-in kept for backward compatibility until migrated.
return readEnvOrProcess('CODEX_GOALS') === 'true';
}
function writeCodexGoals(value: boolean): void {
writeCodexFeatureToFile(codexConfigFile(), 'goals', value);
const file = envFilePath();
const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
const updated = setOrInsertEnvLine(
content,
'CODEX_GOALS',
value ? 'true' : 'false',
);
if (!fs.existsSync(file)) return;
const content = fs.readFileSync(file, 'utf-8');
if (!/^CODEX_GOALS=.*$/m.test(content)) return;
const updated = content
.split('\n')
.filter((line) => !/^CODEX_GOALS=.*$/.test(line))
.join('\n')
.replace(/\n+$/, '');
const tempPath = `${file}.tmp`;
fs.writeFileSync(tempPath, updated, { mode: 0o600 });
fs.writeFileSync(tempPath, updated ? `${updated}\n` : '', { mode: 0o600 });
fs.renameSync(tempPath, file);
}

View File

@@ -42,6 +42,11 @@ const modelConfig: ModelConfigSnapshot = {
owner: { model: 'gpt-5.4', effort: 'medium' },
reviewer: { model: 'claude-sonnet', effort: 'high' },
arbiter: { model: 'gpt-5.4', effort: 'high' },
agentTypes: {
owner: 'codex',
reviewer: 'claude-code',
arbiter: null,
},
};
const fastMode: FastModeSnapshot = { codex: true, claude: false };

View File

@@ -149,7 +149,9 @@ async function handleModelSettingsRoute(
try {
return jsonResponse(deps.updateModelConfig(body));
} catch (err) {
return jsonResponse({ error: errorMessage(err) }, { status: 500 });
const message = errorMessage(err);
const status = message.includes('not supported') ? 400 : 500;
return jsonResponse({ error: message }, { status });
}
}