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:
@@ -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">
|
||||
|
||||
42
apps/dashboard/src/SettingsPanel.test.ts
Normal file
42
apps/dashboard/src/SettingsPanel.test.ts
Normal 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('스택 재시작');
|
||||
});
|
||||
});
|
||||
696
apps/dashboard/src/SettingsPanel.tsx
Normal file
696
apps/dashboard/src/SettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user