diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx
index 84a2903..5e250dd 100644
--- a/apps/dashboard/src/App.tsx
+++ b/apps/dashboard/src/App.tsx
@@ -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 (
-
- );
-}
-
-function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) {
- const [config, setConfig] = useState(null);
- const [draft, setDraft] = useState(null);
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
- const [savedAt, setSavedAt] = useState(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,
- ) {
- setDraft((prev) =>
- prev
- ? {
- ...prev,
- [role]: { ...prev[role], ...patch },
- }
- : prev,
- );
- }
-
- const dirty =
- draft !== null &&
- config !== null &&
- JSON.stringify(draft) !== JSON.stringify(config);
-
- return (
-
- );
-}
-
-function FastModeSettings() {
- const [state, setState] = useState(null);
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(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 (
-
- );
-}
-
-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(null);
- const [error, setError] = useState(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 (
-
- 계정
- {error ? {error}
: null}
-
-
-
Claude
- {!data ? (
-
{busy ? '불러오는 중…' : '없음'}
- ) : data.claude.length === 0 ? (
-
계정 없음
- ) : (
-
- )}
-
-
-
-
-
-
-
Codex
-
-
- {!data ? (
-
{busy ? '불러오는 중…' : '없음'}
- ) : data.codex.length === 0 ? (
-
계정 없음
- ) : (
-
- {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 (
- -
-
-
- {isActive ? '●' : ''}#{acc.index}
-
- {acc.email ? (
-
- {acc.email}
-
- ) : null}
-
- {acc.planType ?? 'unknown'}
-
- {expiry ? (
-
- {expiry.label}
-
- ) : null}
-
-
-
- {!isActive ? (
-
- ) : (
- 사용중
- )}
- {acc.index > 0 ? (
-
- ) : null}
-
-
- );
- })}
-
- )}
-
- OAuth 토큰은 6시간마다 자동 갱신됩니다. plan 변경/해지가 즉시 반영되게
- 하려면 수동으로 “전체 갱신”을 누르세요.
-
-
-
-
-
-
-
- );
-}
-
function LoadingSkeleton({ t }: { t: Messages }) {
return (
diff --git a/apps/dashboard/src/SettingsPanel.test.ts b/apps/dashboard/src/SettingsPanel.test.ts
new file mode 100644
index 0000000..90be85e
--- /dev/null
+++ b/apps/dashboard/src/SettingsPanel.test.ts
@@ -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('스택 재시작');
+ });
+});
diff --git a/apps/dashboard/src/SettingsPanel.tsx b/apps/dashboard/src/SettingsPanel.tsx
new file mode 100644
index 0000000..ec4c377
--- /dev/null
+++ b/apps/dashboard/src/SettingsPanel.tsx
@@ -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 (
+
+ );
+}
+
+function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) {
+ const [config, setConfig] = useState(null);
+ const [draft, setDraft] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [savedAt, setSavedAt] = useState(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,
+ ) {
+ setDraft((prev) =>
+ prev
+ ? {
+ ...prev,
+ [role]: { ...prev[role], ...patch },
+ }
+ : prev,
+ );
+ }
+
+ const dirty =
+ draft !== null &&
+ config !== null &&
+ JSON.stringify(draft) !== JSON.stringify(config);
+
+ return (
+
+ );
+}
+
+function FastModeSettings() {
+ const [state, setState] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(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 (
+
+ );
+}
+
+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(null);
+ const [busy, setBusy] = useState(false);
+ const [perRowBusy, setPerRowBusy] = useState(null);
+ const [error, setError] = useState(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 (
+
+ 계정
+ {error ? {error}
: null}
+ void handleAdd()}
+ onDelete={(index) => void handleDelete('claude', index)}
+ onTokenInputChange={setTokenInput}
+ tokenInput={tokenInput}
+ />
+ void handleDelete('codex', index)}
+ onRefresh={(index) => void handleCodexRefresh(index)}
+ onRefreshAll={() => void handleRefreshAllCodex()}
+ onSwitch={(index) => void handleSwitchCodex(index)}
+ perRowBusy={perRowBusy}
+ />
+
+
+
+
+ );
+}
+
+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 (
+
+
Claude
+ {!data ? (
+
{busy ? '불러오는 중…' : '없음'}
+ ) : data.claude.length === 0 ? (
+
계정 없음
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+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 (
+
+
+
Codex
+
+
+ {!data ? (
+
{busy ? '불러오는 중…' : '없음'}
+ ) : data.codex.length === 0 ? (
+
계정 없음
+ ) : (
+
+ {data.codex.map((acc) => (
+
+ ))}
+
+ )}
+
+ OAuth 토큰은 6시간마다 자동 갱신됩니다. plan 변경/해지가 즉시 반영되게
+ 하려면 수동으로 “전체 갱신”을 누르세요.
+
+
+ );
+}
+
+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 (
+
+
+
+ {isActive ? '●' : ''}#{acc.index}
+
+ {acc.email ? (
+
+ {acc.email}
+
+ ) : null}
+
+ {acc.planType ?? 'unknown'}
+
+ {expiry ? (
+
+ {expiry.label}
+
+ ) : null}
+
+
+
+ {!isActive ? (
+
+ ) : (
+ 사용중
+ )}
+ {acc.index > 0 ? (
+
+ ) : null}
+
+
+ );
+}