Merge remote-tracking branch 'origin/main' into codex/owner/ejclaw
This commit is contained in:
@@ -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=...
|
||||
```
|
||||
|
||||
12
.env.example
12
.env.example
@@ -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)
|
||||
#
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@@ -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`
|
||||
|
||||
### 설치
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
219
apps/dashboard/src/SettingsModelFields.tsx
Normal file
219
apps/dashboard/src/SettingsModelFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.x(4.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.x(4.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',
|
||||
|
||||
31
apps/dashboard/src/settings-options.test.ts
Normal file
31
apps/dashboard/src/settings-options.test.ts
Normal 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('기본값');
|
||||
});
|
||||
});
|
||||
70
apps/dashboard/src/settings-options.ts
Normal file
70
apps/dashboard/src/settings-options.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
39
src/claude-session-settings.test.ts
Normal file
39
src/claude-session-settings.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
68
src/claude-session-settings.ts
Normal file
68
src/claude-session-settings.ts
Normal 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`);
|
||||
}
|
||||
30
src/codex-config-features.test.ts
Normal file
30
src/codex-config-features.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
84
src/codex-config-features.ts
Normal file
84
src/codex-config-features.ts
Normal 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);
|
||||
}
|
||||
77
src/index.ts
77
src/index.ts
@@ -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)
|
||||
|
||||
20
src/settings-effort.test.ts
Normal file
20
src/settings-effort.test.ts
Normal 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
64
src/settings-effort.ts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user