Merge remote-tracking branch 'origin/main' into codex/owner/ejclaw

This commit is contained in:
ejclaw
2026-05-23 13:15:15 +09:00
37 changed files with 3824 additions and 1927 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

@@ -174,6 +174,22 @@ export function _setRoomBindings(
runtimeState.setRoomBindings(groups);
}
async function tryDeliverRestartAnnouncement(
chatJid: string,
rawText: string,
): Promise<boolean> {
try {
await deliverFormattedCanonicalMessage(chatJid, rawText);
return true;
} catch (err) {
logger.warn(
{ err, chatJid },
'Skipped restart recovery announcement because no channel is connected',
);
return false;
}
}
async function announceRestartRecovery(
processStartedAtMs: number,
): Promise<RestartContext | null> {
@@ -188,14 +204,17 @@ async function announceRestartRecovery(
return explicitContext;
}
await deliverFormattedCanonicalMessage(
explicitContext.chatJid,
buildRestartAnnouncement(explicitContext),
);
logger.info(
{ chatJid: explicitContext.chatJid },
'Sent explicit restart recovery announcement',
);
if (
await tryDeliverRestartAnnouncement(
explicitContext.chatJid,
buildRestartAnnouncement(explicitContext),
)
) {
logger.info(
{ chatJid: explicitContext.chatJid },
'Sent explicit restart recovery announcement',
);
}
for (const interrupted of getRecoverableInterruptedGroups(
explicitContext,
@@ -204,7 +223,7 @@ async function announceRestartRecovery(
if (hasRecentRestartAnnouncement(interrupted.chatJid, dedupeSince)) {
continue;
}
await deliverFormattedCanonicalMessage(
await tryDeliverRestartAnnouncement(
interrupted.chatJid,
buildInterruptedRestartAnnouncement(interrupted),
);
@@ -226,14 +245,17 @@ async function announceRestartRecovery(
return null;
}
await deliverFormattedCanonicalMessage(
inferred.chatJid,
inferred.lines.join('\n'),
);
logger.info(
{ chatJid: inferred.chatJid },
'Sent inferred restart recovery announcement',
);
if (
await tryDeliverRestartAnnouncement(
inferred.chatJid,
inferred.lines.join('\n'),
)
) {
logger.info(
{ chatJid: inferred.chatJid },
'Sent inferred restart recovery announcement',
);
}
return null;
}
@@ -346,12 +368,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 });
}
}