Extract dashboard SettingsPanel component (#68)

* review: harden readonly git checks and lint verification

* test: satisfy readonly reviewer sandbox typing

* test: fix readonly reviewer sandbox assertions

* test: remove impossible readonly sandbox guards

* test: relax readonly sandbox assertions

* test: cast readonly sandbox expectation shape

* test: cast readonly sandbox expectation through unknown

* Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke

* Add STEP_DONE guards, verdict storage, and stale delivery suppression

* style: format paired stepdone telemetry files

* Route STEP_DONE through reviewer

* Add structured Discord attachments

* style: format structured attachment test

* Fix room thread bot output parity

* Remove duplicate work item attachment migration from owner branch

* Remove legacy dashboard rooms renderer

* Extract dashboard parsed body renderer

* Extract dashboard redaction helpers

* Extract dashboard RoomCardV2 component

* Include RoomCardV2 test in vitest suite

* Extract dashboard RoomBoardV2 component

* Extract dashboard EmptyState component

* Extract dashboard InboxPanel component

* Split dashboard InboxPanel card renderer

* Extract dashboard TaskPanel component

* Extract dashboard UsagePanel component

* Fix dashboard room thread chunk rendering

* Extract dashboard ServicePanel component

* Render dashboard live progress markdown

* Extract dashboard SettingsPanel component
This commit is contained in:
Eyejoker
2026-04-28 16:56:37 +09:00
committed by GitHub
parent e68ce6c616
commit 96d99f63e0
3 changed files with 739 additions and 607 deletions

View File

@@ -1,35 +1,20 @@
import { useEffect, useState } from 'react';
import {
type ClaudeAccountSummary,
type CodexAccountSummary,
type CreateScheduledTaskInput,
type DashboardInboxAction,
type DashboardRoomActivity,
type DashboardTaskAction,
type DashboardOverview,
type DashboardTask,
type FastModeSnapshot,
type ModelConfigSnapshot,
type ModelRoleConfig,
type UpdateScheduledTaskInput,
type StatusSnapshot,
addClaudeAccount,
createScheduledTask,
deleteAccount,
fetchAccounts,
fetchDashboardData,
fetchFastMode,
fetchModelConfig,
refreshAllCodexAccounts as refreshAllCodexAccountsApi,
refreshCodexAccount as refreshCodexAccountApi,
runInboxAction,
runServiceAction,
runScheduledTaskAction,
sendRoomMessage,
setCurrentCodexAccount as setCurrentCodexAccountApi,
updateFastMode,
updateModels,
updateScheduledTask,
} from './api';
import {
@@ -53,9 +38,9 @@ import { formatDate, statusLabel } from './dashboardHelpers';
import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel';
import { RoomBoardV2 } from './RoomBoardV2';
import { ServicePanel, type ServiceActionKey } from './ServicePanel';
import { SettingsPanel } from './SettingsPanel';
import { TaskPanel, type RoomOption, type TaskActionKey } from './TaskPanel';
import { UsagePanel } from './UsagePanel';
import { ParsedBody } from './ParsedBody';
import './styles.css';
interface DashboardState {
@@ -276,597 +261,6 @@ function LanguageSelector({
);
}
function SettingsPanel({
locale,
nickname,
onLocaleChange,
onNicknameChange,
onRestartStack,
t,
}: {
locale: Locale;
nickname: string;
onLocaleChange: (locale: Locale) => void;
onNicknameChange: (next: string) => void;
onRestartStack: () => void;
t: Messages;
}) {
return (
<div className="settings-panel">
<section className="settings-section">
<h3>일반</h3>
<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>
</section>
<ModelSettings onRestartStack={onRestartStack} />
<FastModeSettings />
<AccountSettings onRestartStack={onRestartStack} />
</div>
);
}
function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) {
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);
useEffect(() => {
let cancelled = false;
setBusy(true);
fetchModelConfig()
.then((c) => {
if (cancelled) return;
setConfig(c);
setDraft(c);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, []);
async function save() {
if (!draft || !config) return;
setBusy(true);
setError(null);
try {
const next = await updateModels(draft);
setConfig(next);
setDraft(next);
setSavedAt(Date.now());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
function setRole(
role: keyof ModelConfigSnapshot,
patch: Partial<ModelRoleConfig>,
) {
setDraft((prev) =>
prev
? {
...prev,
[role]: { ...prev[role], ...patch },
}
: prev,
);
}
const dirty =
draft !== null &&
config !== null &&
JSON.stringify(draft) !== JSON.stringify(config);
return (
<section className="settings-section">
<h3>모델</h3>
{error ? <p className="settings-error">{error}</p> : null}
{!draft ? (
<p className="settings-hint">
{busy ? '불러오는 중…' : '모델 정보 없음'}
</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}
<button
className="settings-restart"
disabled={busy}
onClick={() => {
if (
window.confirm(
'스택을 재시작하면 진행 중인 모든 에이전트 작업이 중단됩니다. 진행할까요?',
)
) {
onRestartStack();
}
}}
type="button"
>
스택 재시작
</button>
</div>
</>
)}
</section>
);
}
function FastModeSettings() {
const [state, setState] = useState<FastModeSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchFastMode()
.then((s) => {
if (cancelled) return;
setState(s);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
};
}, []);
async function toggle(provider: keyof FastModeSnapshot) {
if (!state) return;
const next = !state[provider];
const optimistic = { ...state, [provider]: next };
setState(optimistic);
setBusy(true);
setError(null);
try {
const fresh = await updateFastMode({ [provider]: next });
setState(fresh);
} catch (err) {
setState(state);
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<section className="settings-section">
<h3>패스트 모드</h3>
{error ? <p className="settings-error">{error}</p> : null}
{!state ? (
<p className="settings-hint">불러오는 중…</p>
) : (
<>
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">Codex (GPT)</span>
<small className="settings-hint">
~/.codex/config.toml [features].fast_mode — 사용량 더 소모하지만
응답이 빨라집니다.
</small>
</span>
<input
checked={state.codex}
disabled={busy}
onChange={() => void toggle('codex')}
type="checkbox"
/>
</label>
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">Claude</span>
<small className="settings-hint">
~/.claude/settings.json fastMode — 인터랙티브 세션의 /fast 와
동일 키. opus-4-6 한정으로 동작.
</small>
</span>
<input
checked={state.claude}
disabled={busy}
onChange={() => void toggle('claude')}
type="checkbox"
/>
</label>
</>
)}
</section>
);
}
function formatExpiry(
iso: string | null,
): { label: string; cls: string } | null {
if (!iso) return null;
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) return null;
const days = (dt.getTime() - Date.now()) / 86400000;
const dateStr = dt.toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit',
});
if (days < 0) {
const ago = Math.ceil(-days);
return {
label: `결제 만료 ${dateStr} (${ago}일 전)`,
cls: 'is-expired',
};
}
if (days < 7) {
return {
label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`,
cls: 'is-soon',
};
}
return {
label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`,
cls: 'is-active',
};
}
function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
const [data, setData] = useState<{
claude: ClaudeAccountSummary[];
codex: CodexAccountSummary[];
codexCurrentIndex?: number;
} | 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('');
async function refresh() {
setBusy(true);
setError(null);
try {
const fresh = await fetchAccounts();
setData(fresh);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void refresh();
}, []);
async function handleDelete(provider: 'claude' | 'codex', index: number) {
if (
!window.confirm(
`${provider} 계정 #${index} 디렉터리를 삭제합니다. 되돌릴 수 없습니다. 계속할까요?`,
)
) {
return;
}
setBusy(true);
setError(null);
try {
await deleteAccount(provider, index);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function handleAdd() {
const token = tokenInput.trim();
if (!token) return;
setBusy(true);
setError(null);
try {
await addClaudeAccount(token);
setTokenInput('');
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function handleCodexRefresh(index: number) {
setPerRowBusy(`refresh:${index}`);
setError(null);
try {
await refreshCodexAccountApi(index);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPerRowBusy(null);
}
}
async function handleRefreshAllCodex() {
setBusy(true);
setError(null);
try {
const result = await refreshAllCodexAccountsApi();
await refresh();
if (result.failed.length > 0) {
setError(
`일부 갱신 실패: ${result.failed.map((f) => `#${f.index}`).join(', ')}`,
);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function handleSwitchCodex(index: number) {
setPerRowBusy(`switch:${index}`);
setError(null);
try {
await setCurrentCodexAccountApi(index);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPerRowBusy(null);
}
}
return (
<section className="settings-section">
<h3>계정</h3>
{error ? <p className="settings-error">{error}</p> : null}
<div className="settings-account-group">
<h4>Claude</h4>
{!data ? (
<p className="settings-hint">{busy ? '불러오는 중…' : '없음'}</p>
) : data.claude.length === 0 ? (
<p className="settings-hint">계정 없음</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={() => void handleDelete('claude', acc.index)}
type="button"
>
삭제
</button>
) : (
<span className="settings-account-default">기본</span>
)}
</li>
))}
</ul>
)}
<div className="settings-add-token">
<textarea
onChange={(e) => setTokenInput(e.target.value)}
placeholder="Claude OAuth 토큰 (claude CLI 로그인 후 ~/.claude/.credentials.json 에서 accessToken 값을 페이스트)"
rows={2}
value={tokenInput}
/>
<button
disabled={!tokenInput.trim() || busy}
onClick={() => void handleAdd()}
type="button"
>
추가
</button>
</div>
</div>
<div className="settings-account-group">
<div className="settings-account-group-head">
<h4>Codex</h4>
<button
className="settings-secondary"
disabled={busy}
onClick={() => void handleRefreshAllCodex()}
type="button"
>
전체 갱신
</button>
</div>
{!data ? (
<p className="settings-hint">{busy ? '불러오는 중…' : '없음'}</p>
) : data.codex.length === 0 ? (
<p className="settings-hint">계정 없음</p>
) : (
<ul className="settings-account-list">
{data.codex.map((acc) => {
const expiry = formatExpiry(acc.subscriptionUntil);
const isActive = data.codexCurrentIndex === acc.index;
const refreshing = perRowBusy === `refresh:${acc.index}`;
const switching = perRowBusy === `switch:${acc.index}`;
return (
<li
key={acc.index}
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}
</span>
) : null}
<span className="settings-account-plan">
{acc.planType ?? 'unknown'}
</span>
{expiry ? (
<span
className={`settings-account-badge ${expiry.cls}`}
title={
acc.subscriptionLastChecked
? `last checked: ${acc.subscriptionLastChecked.slice(0, 10)}`
: undefined
}
>
{expiry.label}
</span>
) : null}
</div>
<div className="settings-account-actions">
<button
className="settings-secondary"
disabled={busy || perRowBusy !== null}
onClick={() => void handleCodexRefresh(acc.index)}
title="OAuth 토큰을 다시 받아 구독 상태를 갱신합니다"
type="button"
>
{refreshing ? '갱신중…' : '갱신'}
</button>
{!isActive ? (
<button
className="settings-secondary"
disabled={busy || perRowBusy !== null}
onClick={() => void handleSwitchCodex(acc.index)}
title="이 계정으로 즉시 전환합니다 (다음 codex 호출부터 적용)"
type="button"
>
{switching ? '전환중…' : '전환'}
</button>
) : (
<span className="settings-account-default">사용중</span>
)}
{acc.index > 0 ? (
<button
className="settings-delete"
disabled={busy || perRowBusy !== null}
onClick={() => void handleDelete('codex', acc.index)}
type="button"
>
삭제
</button>
) : null}
</div>
</li>
);
})}
</ul>
)}
<p className="settings-hint">
OAuth 토큰은 6시간마다 자동 갱신됩니다. plan 변경/해지가 즉시 반영되게
하려면 수동으로 “전체 갱신”을 누르세요.
</p>
</div>
<div className="settings-actions">
<button
className="settings-restart"
disabled={busy}
onClick={() => {
if (
window.confirm(
'스택을 재시작하면 진행 중인 모든 에이전트 작업이 중단됩니다. 진행할까요?',
)
) {
onRestartStack();
}
}}
type="button"
>
스택 재시작 (변경 적용)
</button>
</div>
</section>
);
}
function LoadingSkeleton({ t }: { t: Messages }) {
return (
<main className="shell shell-loading" aria-busy="true">

View File

@@ -0,0 +1,42 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { messages } from './i18n';
import { SettingsPanel, type SettingsPanelProps } from './SettingsPanel';
const t = messages.en;
const baseProps: SettingsPanelProps = {
locale: 'en',
nickname: 'Night Owl',
onLocaleChange: () => {},
onNicknameChange: () => {},
onRestartStack: () => {},
t,
};
describe('SettingsPanel', () => {
it('renders general nickname and language settings', () => {
const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps));
expect(html).toContain('settings-panel');
expect(html).toContain(t.settings.nicknameLabel);
expect(html).toContain('value="Night Owl"');
expect(html).toContain(t.settings.languageLabel);
expect(html).toContain('한국어');
expect(html).toContain('English');
});
it('renders model, fast mode, and account controls', () => {
const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps));
expect(html).toContain('모델');
expect(html).toContain('패스트 모드');
expect(html).toContain('불러오는 중');
expect(html).toContain('Claude');
expect(html).toContain('계정');
expect(html).toContain('전체 갱신');
expect(html).toContain('스택 재시작');
});
});

View File

@@ -0,0 +1,696 @@
import { useEffect, useState } from 'react';
import {
type ClaudeAccountSummary,
type CodexAccountSummary,
type FastModeSnapshot,
type ModelConfigSnapshot,
type ModelRoleConfig,
addClaudeAccount,
deleteAccount,
fetchAccounts,
fetchFastMode,
fetchModelConfig,
refreshAllCodexAccounts as refreshAllCodexAccountsApi,
refreshCodexAccount as refreshCodexAccountApi,
setCurrentCodexAccount as setCurrentCodexAccountApi,
updateFastMode,
updateModels,
} from './api';
import { LOCALES, languageNames, type Locale, type Messages } from './i18n';
type AccountProvider = 'claude' | 'codex';
interface AccountData {
claude: ClaudeAccountSummary[];
codex: CodexAccountSummary[];
codexCurrentIndex?: number;
}
export interface SettingsPanelProps {
locale: Locale;
nickname: string;
onLocaleChange: (locale: Locale) => void;
onNicknameChange: (next: string) => void;
onRestartStack: () => void;
t: Messages;
}
export function SettingsPanel({
locale,
nickname,
onLocaleChange,
onNicknameChange,
onRestartStack,
t,
}: SettingsPanelProps) {
return (
<div className="settings-panel">
<section className="settings-section">
<h3>일반</h3>
<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>
</section>
<ModelSettings onRestartStack={onRestartStack} />
<FastModeSettings />
<AccountSettings onRestartStack={onRestartStack} />
</div>
);
}
function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) {
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);
useEffect(() => {
let cancelled = false;
setBusy(true);
fetchModelConfig()
.then((c) => {
if (cancelled) return;
setConfig(c);
setDraft(c);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, []);
async function save() {
if (!draft || !config) return;
setBusy(true);
setError(null);
try {
const next = await updateModels(draft);
setConfig(next);
setDraft(next);
setSavedAt(Date.now());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
function setRole(
role: keyof ModelConfigSnapshot,
patch: Partial<ModelRoleConfig>,
) {
setDraft((prev) =>
prev
? {
...prev,
[role]: { ...prev[role], ...patch },
}
: prev,
);
}
const dirty =
draft !== null &&
config !== null &&
JSON.stringify(draft) !== JSON.stringify(config);
return (
<section className="settings-section">
<h3>모델</h3>
{error ? <p className="settings-error">{error}</p> : null}
{!draft ? (
<p className="settings-hint">
{busy ? '불러오는 중…' : '모델 정보 없음'}
</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}
<button
className="settings-restart"
disabled={busy}
onClick={() => {
if (
window.confirm(
'스택을 재시작하면 진행 중인 모든 에이전트 작업이 중단됩니다. 진행할까요?',
)
) {
onRestartStack();
}
}}
type="button"
>
스택 재시작
</button>
</div>
</>
)}
</section>
);
}
function FastModeSettings() {
const [state, setState] = useState<FastModeSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchFastMode()
.then((s) => {
if (cancelled) return;
setState(s);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
};
}, []);
async function toggle(provider: keyof FastModeSnapshot) {
if (!state) return;
const next = !state[provider];
const optimistic = { ...state, [provider]: next };
setState(optimistic);
setBusy(true);
setError(null);
try {
const fresh = await updateFastMode({ [provider]: next });
setState(fresh);
} catch (err) {
setState(state);
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<section className="settings-section">
<h3>패스트 모드</h3>
{error ? <p className="settings-error">{error}</p> : null}
{!state ? (
<p className="settings-hint">불러오는 중…</p>
) : (
<>
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">Codex (GPT)</span>
<small className="settings-hint">
~/.codex/config.toml [features].fast_mode — 사용량 더 소모하지만
응답이 빨라집니다.
</small>
</span>
<input
checked={state.codex}
disabled={busy}
onChange={() => void toggle('codex')}
type="checkbox"
/>
</label>
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">Claude</span>
<small className="settings-hint">
~/.claude/settings.json fastMode — 인터랙티브 세션의 /fast 와
동일 키. opus-4-6 한정으로 동작.
</small>
</span>
<input
checked={state.claude}
disabled={busy}
onChange={() => void toggle('claude')}
type="checkbox"
/>
</label>
</>
)}
</section>
);
}
function formatExpiry(
iso: string | null,
): { label: string; cls: string } | null {
if (!iso) return null;
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) return null;
const days = (dt.getTime() - Date.now()) / 86400000;
const dateStr = dt.toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit',
});
if (days < 0) {
const ago = Math.ceil(-days);
return {
label: `결제 만료 ${dateStr} (${ago}일 전)`,
cls: 'is-expired',
};
}
if (days < 7) {
return {
label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`,
cls: 'is-soon',
};
}
return {
label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`,
cls: 'is-active',
};
}
function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
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('');
async function refresh() {
setBusy(true);
setError(null);
try {
const fresh = await fetchAccounts();
setData(fresh);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void refresh();
}, []);
async function handleDelete(provider: AccountProvider, index: number) {
if (
!window.confirm(
`${provider} 계정 #${index} 디렉터리를 삭제합니다. 되돌릴 수 없습니다. 계속할까요?`,
)
) {
return;
}
setBusy(true);
setError(null);
try {
await deleteAccount(provider, index);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function handleAdd() {
const token = tokenInput.trim();
if (!token) return;
setBusy(true);
setError(null);
try {
await addClaudeAccount(token);
setTokenInput('');
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function handleCodexRefresh(index: number) {
setPerRowBusy(`refresh:${index}`);
setError(null);
try {
await refreshCodexAccountApi(index);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPerRowBusy(null);
}
}
async function handleRefreshAllCodex() {
setBusy(true);
setError(null);
try {
const result = await refreshAllCodexAccountsApi();
await refresh();
if (result.failed.length > 0) {
setError(
`일부 갱신 실패: ${result.failed.map((f) => `#${f.index}`).join(', ')}`,
);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function handleSwitchCodex(index: number) {
setPerRowBusy(`switch:${index}`);
setError(null);
try {
await setCurrentCodexAccountApi(index);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPerRowBusy(null);
}
}
return (
<section className="settings-section">
<h3>계정</h3>
{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-actions">
<button
className="settings-restart"
disabled={busy}
onClick={() => {
if (
window.confirm(
'스택을 재시작하면 진행 중인 모든 에이전트 작업이 중단됩니다. 진행할까요?',
)
) {
onRestartStack();
}
}}
type="button"
>
스택 재시작 (변경 적용)
</button>
</div>
</section>
);
}
function ClaudeAccounts({
busy,
data,
onAdd,
onDelete,
onTokenInputChange,
tokenInput,
}: {
busy: boolean;
data: AccountData | null;
onAdd: () => void;
onDelete: (index: number) => void;
onTokenInputChange: (value: string) => void;
tokenInput: string;
}) {
return (
<div className="settings-account-group">
<h4>Claude</h4>
{!data ? (
<p className="settings-hint">{busy ? '불러오는 중…' : '없음'}</p>
) : data.claude.length === 0 ? (
<p className="settings-hint">계정 없음</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>
))}
</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}
/>
<button
disabled={!tokenInput.trim() || busy}
onClick={onAdd}
type="button"
>
추가
</button>
</div>
</div>
);
}
function CodexAccounts({
busy,
data,
onDelete,
onRefresh,
onRefreshAll,
onSwitch,
perRowBusy,
}: {
busy: boolean;
data: AccountData | null;
onDelete: (index: number) => void;
onRefresh: (index: number) => void;
onRefreshAll: () => void;
onSwitch: (index: number) => void;
perRowBusy: string | null;
}) {
return (
<div className="settings-account-group">
<div className="settings-account-group-head">
<h4>Codex</h4>
<button
className="settings-secondary"
disabled={busy}
onClick={onRefreshAll}
type="button"
>
전체 갱신
</button>
</div>
{!data ? (
<p className="settings-hint">{busy ? '불러오는 중…' : '없음'}</p>
) : data.codex.length === 0 ? (
<p className="settings-hint">계정 없음</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}
/>
))}
</ul>
)}
<p className="settings-hint">
OAuth 토큰은 6시간마다 자동 갱신됩니다. plan 변경/해지가 즉시 반영되게
하려면 수동으로 “전체 갱신”을 누르세요.
</p>
</div>
);
}
function CodexAccountRow({
acc,
busy,
isActive,
onDelete,
onRefresh,
onSwitch,
perRowBusy,
}: {
acc: CodexAccountSummary;
busy: boolean;
isActive: boolean;
onDelete: (index: number) => void;
onRefresh: (index: number) => void;
onSwitch: (index: number) => void;
perRowBusy: string | null;
}) {
const expiry = formatExpiry(acc.subscriptionUntil);
const refreshing = perRowBusy === `refresh:${acc.index}`;
const switching = perRowBusy === `switch:${acc.index}`;
return (
<li
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}
</span>
) : null}
<span className="settings-account-plan">
{acc.planType ?? 'unknown'}
</span>
{expiry ? (
<span
className={`settings-account-badge ${expiry.cls}`}
title={
acc.subscriptionLastChecked
? `last checked: ${acc.subscriptionLastChecked.slice(0, 10)}`
: undefined
}
>
{expiry.label}
</span>
) : null}
</div>
<div className="settings-account-actions">
<button
className="settings-secondary"
disabled={busy || perRowBusy !== null}
onClick={() => onRefresh(acc.index)}
title="OAuth 토큰을 다시 받아 구독 상태를 갱신합니다"
type="button"
>
{refreshing ? '갱신중…' : '갱신'}
</button>
{!isActive ? (
<button
className="settings-secondary"
disabled={busy || perRowBusy !== null}
onClick={() => onSwitch(acc.index)}
title="이 계정으로 즉시 전환합니다 (다음 codex 호출부터 적용)"
type="button"
>
{switching ? '전환중…' : '전환'}
</button>
) : (
<span className="settings-account-default">사용중</span>
)}
{acc.index > 0 ? (
<button
className="settings-delete"
disabled={busy || perRowBusy !== null}
onClick={() => onDelete(acc.index)}
type="button"
>
삭제
</button>
) : null}
</div>
</li>
);
}