import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Activity, Clock, Download, Gauge, Inbox as InboxIcon, MessageSquare, RefreshCw, Send, Settings, } from 'lucide-react'; import { type ClaudeAccountSummary, type CodexAccountSummary, type CreateScheduledTaskInput, type DashboardInboxAction, type DashboardRoomActivity, type DashboardTaskContextMode, type DashboardTaskScheduleType, type DashboardTaskAction, type DashboardOverview, type DashboardTask, type ModelConfigSnapshot, type ModelRoleConfig, type UpdateScheduledTaskInput, type StatusSnapshot, addClaudeAccount, createScheduledTask, deleteAccount, fetchAccounts, fetchDashboardData, fetchModelConfig, runInboxAction, runServiceAction, runScheduledTaskAction, sendRoomMessage, updateModels, updateScheduledTask, } from './api'; import { LOCALES, isLocale, languageNames, localeTags, matchLocale, messages, type Locale, type Messages, } from './i18n'; import { useSelectedRoomActivity, type RoomActivityMap, } from './useRoomActivity'; import './styles.css'; interface DashboardState { overview: DashboardOverview; snapshots: StatusSnapshot[]; tasks: DashboardTask[]; } type UsageRow = DashboardOverview['usage']['rows'][number]; type InboxItem = DashboardOverview['inbox'][number]; type RiskLevel = 'ok' | 'warn' | 'critical'; type UsageGroup = 'primary' | 'codex'; type UsageLimitWindow = 'h5' | 'd7'; type DashboardView = | 'usage' | 'inbox' | 'health' | 'rooms' | 'scheduled' | 'settings'; type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed'; type TaskResultTone = 'ok' | 'fail' | 'none'; type TaskActionKey = | 'create' | `${string}:edit` | `${string}:${DashboardTaskAction}`; type InboxActionKey = `${string}:${DashboardInboxAction}`; type ServiceActionKey = 'stack:restart'; type InboxFilter = 'all' | InboxItem['kind']; type HealthLevel = 'ok' | 'stale' | 'down'; type FreshnessLevel = 'fresh' | 'stale' | 'offline'; type BeforeInstallPromptEvent = Event & { prompt: () => Promise; userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>; }; interface RoomOption { jid: string; name: string; folder: string; } const REFRESH_INTERVAL_MS = 15_000; const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2'; const DEFAULT_VIEW: DashboardView = 'rooms'; const HEALTH_STALE_MS = 5 * 60_000; const HEALTH_DOWN_MS = 15 * 60_000; const DASHBOARD_STALE_MS = 75_000; function makeClientRequestId(): string { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } function isDashboardView( value: string | null | undefined, ): value is DashboardView { return ( value === 'usage' || value === 'inbox' || value === 'health' || value === 'rooms' || value === 'scheduled' || value === 'settings' ); } function readViewFromHash(): DashboardView { if (typeof window === 'undefined') return DEFAULT_VIEW; const raw = window.location.hash.replace(/^#\/?/, ''); return isDashboardView(raw) ? raw : DEFAULT_VIEW; } function readInitialLocale(): Locale { const stored = typeof window === 'undefined' ? null : window.localStorage.getItem(LOCALE_STORAGE_KEY); if (isLocale(stored)) return stored; const languages = typeof navigator === 'undefined' ? [] : [...(navigator.languages || []), navigator.language]; for (const language of languages) { const matched = matchLocale(language); if (matched) return matched; } return 'en'; } function humanizeError(raw: string, t: Messages): string { const lower = raw.toLowerCase(); if (/abort|timeout|timed out/.test(lower)) return t.error.timeout; if (/network|fetch failed|failed to fetch|networkerror|offline/.test(lower)) return t.error.network; const statusMatch = lower.match(/\b(\d{3})\b/); if (statusMatch) { const code = Number(statusMatch[1]); if (code === 401 || code === 403) return t.error.auth; if (code === 404) return t.error.notFound; if (code >= 500) return t.error.server; if (code >= 400) return t.error.unknown; } return raw || t.error.unknown; } type ParsedSegment = | { kind: 'text'; value: string } | { kind: 'code'; lang: string; value: string } | { kind: 'inline-code'; value: string } | { kind: 'bold'; value: string } | { kind: 'italic'; value: string } | { kind: 'strike'; value: string } | { kind: 'url'; href: string; label: string } | { kind: 'mention'; value: string } | { kind: 'marker'; tone: 'done' | 'fail' | 'info'; value: string } | { kind: 'path'; value: string }; const URL_RE = /\bhttps?:\/\/[^\s)<>]+/g; const MENTION_RE = /(^|\s)(@[A-Za-z0-9_-]+)/g; const MARKER_RE = /\b(TASK_DONE|TASK_FAILED|TASK_FAIL|TASK_OK|DONE|FAILED|FAIL|OK|ERROR|WARNING|RETRY|STATUS|PASS|PASSED)\b/g; const PATH_RE = /(?:^|\s)((?:\/|\.\/|\.\.\/)[^\s)<>]+)/g; function tokenizeInline(input: string): ParsedSegment[] { if (!input) return []; const segments: ParsedSegment[] = []; const matches: Array<{ start: number; end: number; seg: ParsedSegment; priority: number; }> = []; for (const m of input.matchAll(/`([^`\n]+)`/g)) { matches.push({ start: m.index ?? 0, end: (m.index ?? 0) + m[0].length, seg: { kind: 'inline-code', value: m[1] }, priority: 0, }); } for (const m of input.matchAll(/\*\*([^*\n]+)\*\*/g)) { matches.push({ start: m.index ?? 0, end: (m.index ?? 0) + m[0].length, seg: { kind: 'bold', value: m[1] }, priority: 0, }); } for (const m of input.matchAll(/(? a.start === b.start ? a.priority - b.priority : a.start - b.start, ); let cursor = 0; const used: Array<[number, number]> = []; for (const match of matches) { if (match.start < cursor) continue; const overlaps = used.some( ([s, e]) => !(match.end <= s || match.start >= e), ); if (overlaps) continue; if (match.start > cursor) { segments.push({ kind: 'text', value: input.slice(cursor, match.start) }); } segments.push(match.seg); used.push([match.start, match.end]); cursor = match.end; } if (cursor < input.length) { segments.push({ kind: 'text', value: input.slice(cursor) }); } return segments; } type ParsedBlock = | { kind: 'paragraph'; segments: ParsedSegment[] } | { kind: 'code'; lang: string; value: string } | { kind: 'heading'; level: 1 | 2 | 3 | 4; segments: ParsedSegment[] } | { kind: 'list'; items: ParsedSegment[][] } | { kind: 'olist'; items: ParsedSegment[][]; start: number } | { kind: 'quote'; segments: ParsedSegment[] }; function parseTextBlocks(raw: string): ParsedBlock[] { const blocks: ParsedBlock[] = []; const lines = raw.split(/\n/); let i = 0; while (i < lines.length) { const line = lines[i]; const trimmed = line.trim(); if (!trimmed) { i++; continue; } const heading = trimmed.match(/^(#{1,4})\s+(.*)$/); if (heading) { const level = Math.min(heading[1].length, 4) as 1 | 2 | 3 | 4; blocks.push({ kind: 'heading', level, segments: tokenizeInline(heading[2]), }); i++; continue; } if (/^>\s+/.test(trimmed)) { const quoteLines: string[] = []; while (i < lines.length && /^\s*>\s?/.test(lines[i])) { quoteLines.push(lines[i].replace(/^\s*>\s?/, '')); i++; } blocks.push({ kind: 'quote', segments: tokenizeInline(quoteLines.join('\n')), }); continue; } if (/^[-*]\s+/.test(trimmed)) { const items: ParsedSegment[][] = []; while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { const itemText = lines[i].replace(/^\s*[-*]\s+/, ''); items.push(tokenizeInline(itemText)); i++; } blocks.push({ kind: 'list', items }); continue; } const olistMatch = trimmed.match(/^(\d+)\.\s+/); if (olistMatch) { const items: ParsedSegment[][] = []; const start = Number(olistMatch[1]); while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { const itemText = lines[i].replace(/^\s*\d+\.\s+/, ''); items.push(tokenizeInline(itemText)); i++; } blocks.push({ kind: 'olist', items, start }); continue; } // Plain paragraph: gather consecutive non-empty lines const paraLines: string[] = []; while ( i < lines.length && lines[i].trim() && !/^(#{1,4})\s+/.test(lines[i].trim()) && !/^[-*]\s+/.test(lines[i].trim()) && !/^\d+\.\s+/.test(lines[i].trim()) && !/^>\s+/.test(lines[i].trim()) ) { paraLines.push(lines[i]); i++; } if (paraLines.length > 0) { blocks.push({ kind: 'paragraph', segments: tokenizeInline(paraLines.join('\n')), }); } } return blocks; } function parseMessageBody(raw: string | null | undefined): ParsedBlock[] { if (!raw) return []; const cleaned = raw .replace(/<\/?internal[^>]*>/gi, '') .replace(/<\/?intern\.{3}/gi, '') .trim(); if (!cleaned) return []; const blocks: ParsedBlock[] = []; const fenceRe = /```([A-Za-z0-9_+-]*)\n?([\s\S]*?)```/g; let cursor = 0; let m: RegExpExecArray | null; while ((m = fenceRe.exec(cleaned)) !== null) { if (m.index > cursor) { const pre = cleaned.slice(cursor, m.index).trim(); if (pre) blocks.push(...parseTextBlocks(pre)); } blocks.push({ kind: 'code', lang: m[1] || '', value: m[2] }); cursor = m.index + m[0].length; } if (cursor < cleaned.length) { const tail = cleaned.slice(cursor).trim(); if (tail) blocks.push(...parseTextBlocks(tail)); } return blocks; } function renderInlineSegments( segments: ParsedSegment[], truncate: number | undefined, usedRef: { used: number }, ): ReactNode[] { const out: ReactNode[] = []; segments.forEach((s, j) => { if (truncate && usedRef.used >= truncate) return; const remaining = truncate ? truncate - usedRef.used : Infinity; if (s.kind === 'text') { const v = s.value.length > remaining ? `${s.value.slice(0, remaining)}…` : s.value; usedRef.used += v.length; out.push({v}); return; } if (s.kind === 'inline-code') { usedRef.used += s.value.length; out.push( {s.value} , ); return; } if (s.kind === 'bold') { usedRef.used += s.value.length; out.push({s.value}); return; } if (s.kind === 'italic') { usedRef.used += s.value.length; out.push({s.value}); return; } if (s.kind === 'strike') { usedRef.used += s.value.length; out.push({s.value}); return; } if (s.kind === 'url') { usedRef.used += s.label.length; out.push( {s.label} , ); return; } if (s.kind === 'mention') { usedRef.used += s.value.length; out.push( {s.value} , ); return; } if (s.kind === 'path') { usedRef.used += s.value.length; out.push( {s.value} , ); return; } if (s.kind === 'marker') { usedRef.used += s.value.length; out.push( {s.value} , ); return; } }); return out; } function ParsedBody({ text, truncate, }: { text: string | null | undefined; truncate?: number; }): ReactNode { const cleaned = useMemo(() => { if (!text) return ''; let out = text .replace(SECRET_ASSIGNMENT_RE, (_m, key) => `${key}=***`) .replace(SECRET_VALUE_RE, '***'); if (truncate && out.length > truncate) { out = out.slice(0, truncate) + '…'; } return out; }, [text, truncate]); if (!cleaned.trim()) { return ; } return (
{cleaned}
); } function isInternalProtocolPayload( content: string | null | undefined, ): boolean { if (!content) return false; if (/<\/?(sub-agent[-\w]*|tool-call|internal)\b/i.test(content)) return true; if (/"author"\s*:\s*"[^"]+"\s*,\s*"recipient"\s*:\s*"[^"]+"/.test(content)) { return true; } return false; } function senderRoleClass(value: string | null | undefined): string { const v = (value ?? '').toLowerCase(); if (v.includes('오너') || v.includes('owner')) return 'role-owner'; if (v.includes('리뷰어') || v.includes('reviewer')) return 'role-reviewer'; if (v.includes('중재자') || v.includes('arbiter')) return 'role-arbiter'; if (v.includes('cron') || v.includes('sentry') || v.includes('webhook')) return 'role-cron'; return 'role-human'; } function sanitizeInboxText(value: string | null | undefined): string { if (!value) return ''; return value .replace(/<\/?internal[^>]*>/gi, '') .replace(/<\/?intern\.{3}/gi, '') .replace(/<\/?[a-z][a-z0-9-]*[^>]*>/gi, '') .replace(/\s{2,}/g, ' ') .trim(); } function formatDate(value: string | null | undefined, locale: Locale): string { if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; const now = Date.now(); const ageMs = now - date.getTime(); if (ageMs >= 0 && ageMs < 60_000) { return locale === 'ko' ? '방금' : locale === 'ja' ? 'たった今' : locale === 'zh' ? '刚刚' : 'just now'; } if (ageMs >= 0 && ageMs < 3_600_000) { const mins = Math.floor(ageMs / 60_000); return locale === 'ko' ? `${mins}분 전` : locale === 'ja' ? `${mins}分前` : locale === 'zh' ? `${mins} 分钟前` : `${mins}m ago`; } const sameDay = new Date().toDateString() === date.toDateString(); if (sameDay) { return new Intl.DateTimeFormat(localeTags[locale], { hour: '2-digit', minute: '2-digit', hour12: false, }).format(date); } const time = new Intl.DateTimeFormat(localeTags[locale], { hour: '2-digit', minute: '2-digit', hour12: false, }).format(date); if (locale === 'ko') return `${date.getMonth() + 1}월 ${date.getDate()}일 ${time}`; if (locale === 'ja') return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`; if (locale === 'zh') return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`; return new Intl.DateTimeFormat(localeTags[locale], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false, }).format(date); } function dashboardAgeMs(value: string | null | undefined): number | null { if (!value) return null; const date = new Date(value); if (Number.isNaN(date.getTime())) return null; return Math.max(0, Date.now() - date.getTime()); } function dashboardFreshness( online: boolean, generatedAt: string | null | undefined, ): FreshnessLevel { if (!online) return 'offline'; const age = dashboardAgeMs(generatedAt); if (age !== null && age > DASHBOARD_STALE_MS) return 'stale'; return 'fresh'; } function freshnessLabel(level: FreshnessLevel, t: Messages): string { if (level === 'offline') return t.pwa.offline; if (level === 'stale') return t.pwa.stale; 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' && window.isSecureContext && 'serviceWorker' in navigator ); } function formatTaskDate( value: string | null | undefined, locale: Locale, ): string { if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; const time = new Intl.DateTimeFormat(localeTags[locale], { hour: '2-digit', minute: '2-digit', hour12: false, }).format(date); if (locale === 'ko') return `${date.getMonth() + 1}월 ${date.getDate()}일 ${time}`; if (locale === 'ja' || locale === 'zh') return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`; return new Intl.DateTimeFormat(localeTags[locale], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false, }).format(date); } function formatRelativeDate( value: string | null | undefined, locale: Locale, t: Messages, ): string { if (!value) return t.tasks.noTime; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; const diffMs = date.getTime() - Date.now(); const absMs = Math.abs(diffMs); if (absMs < 45_000) return t.tasks.now; const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [ ['day', 86_400_000], ['hour', 3_600_000], ['minute', 60_000], ]; const [unit, unitMs] = units.find(([, threshold]) => absMs >= threshold) ?? units.at(-1)!; return new Intl.RelativeTimeFormat(localeTags[locale], { numeric: 'auto', style: 'short', }).format(Math.round(diffMs / unitMs), unit); } function formatPct(value: number): string { if (value < 0) return '-'; return `${Math.round(value)}%`; } function usagePeak(row: UsageRow): number { return Math.max(row.h5pct, row.d7pct); } function usageLimitWindow(row: UsageRow): UsageLimitWindow { return row.d7pct >= row.h5pct ? 'd7' : 'h5'; } function usageWindowRemaining( row: UsageRow, window: UsageLimitWindow, ): number | null { const pct = window === 'h5' ? row.h5pct : row.d7pct; if (pct < 0) return null; return Math.max(0, 100 - pct); } function usageRiskLevel(row: UsageRow): RiskLevel { const peak = usagePeak(row); if (peak >= 85) return 'critical'; if (peak >= 65) return 'warn'; return 'ok'; } function usageActive(row: UsageRow): boolean { return row.name.includes('*'); } function usageLimited(row: UsageRow): boolean { return row.name.includes('!'); } function usageNameParts(row: UsageRow): { account: string; plan: string | null; } { const cleaned = row.name.replace(/[*!]/g, '').replace(/\s+/g, ' ').trim(); const parts = cleaned.split(' '); const plan = parts.at(-1) ?? null; if (plan && ['max', 'mid', 'pro', 'team'].includes(plan.toLowerCase())) { return { account: parts.slice(0, -1).join(' ') || cleaned, plan }; } return { account: cleaned, plan: null }; } function usageWindowReset(row: UsageRow, window: UsageLimitWindow): string { return (window === 'd7' ? row.d7reset : row.h5reset).trim(); } function usageBurnRate(row: UsageRow): number | null { if (row.h5pct < 0) return null; return row.h5pct / 5; } function usageSpeedLevel(rate: number | null): RiskLevel { if (rate === null) return 'ok'; if (rate >= 12) return 'critical'; if (rate >= 7) return 'warn'; return 'ok'; } function formatUsageRate(rate: number | null): string { if (rate === null) return '-'; if (rate > 0 && rate < 1) return '<1%/h'; return `${Math.round(rate)}%/h`; } function usageGroup(row: UsageRow): UsageGroup { return row.name.toLowerCase().startsWith('codex') ? 'codex' : 'primary'; } function statusLabel(status: string, t: Messages): string { if (status in t.status) return t.status[status as keyof Messages['status']]; return status; } function formatDuration(value: number | null, t: Messages): string { if (value === null) return '-'; const seconds = Math.floor(value / 1000); if (seconds < 60) return `${seconds}${t.units.second}`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}${t.units.minute}`; const hours = Math.floor(minutes / 60); return `${hours}${t.units.hour} ${minutes % 60}${t.units.minute}`; } function formatLiveElapsed(value: number, t: Messages): string { const seconds = Math.max(0, Math.floor(value / 1000)); if (seconds < 60) return `${seconds}${t.units.second}`; const minutes = Math.floor(seconds / 60); const remSec = seconds % 60; if (minutes < 60) return `${minutes}${t.units.minute} ${remSec}${t.units.second}`; const hours = Math.floor(minutes / 60); const remMin = minutes % 60; return `${hours}${t.units.hour} ${remMin}${t.units.minute} ${remSec}${t.units.second}`; } function queueLabel( pendingTasks: number, pendingMessages: boolean, t: Messages, ) { const parts = [`${pendingTasks} ${t.units.task}`]; if (pendingMessages) parts.push(t.units.messageShort); return parts.join(' · '); } const SECRET_ASSIGNMENT_RE = /\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH|PRIVATE_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi; const SECRET_VALUE_RE = /\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,})\b/g; function safePreview( value: string | null | undefined, fallback: string, ): string { const cleaned = (value ?? '') .replace(SECRET_ASSIGNMENT_RE, '$1=') .replace(SECRET_VALUE_RE, '') .replace(/<\/?internal[^>]*>/gi, '') .replace(/\s+/g, ' ') .trim(); if (!cleaned) return fallback; return cleaned.length > 120 ? `${cleaned.slice(0, 120)}...` : cleaned; } function taskGroupKey(task: DashboardTask): TaskGroupKey { if (task.status === 'completed') return 'completed'; if (task.status === 'paused') return 'paused'; if (task.isWatcher) return 'watchers'; return 'scheduled'; } function taskResultTone(task: DashboardTask): TaskResultTone { if (!task.lastResult) return 'none'; const normalized = task.lastResult.toLowerCase(); if ( normalized.includes('fail') || normalized.includes('error') || normalized.includes('timeout') || normalized.includes('cancel') || normalized.includes('reject') ) { return 'fail'; } return 'ok'; } function taskDisplayName(task: DashboardTask, t: Messages): string { if (task.isWatcher) return t.tasks.ciWatch; if (task.scheduleType) return task.scheduleType; return task.id; } function taskActionsFor(task: DashboardTask): DashboardTaskAction[] { if (task.status === 'active') return ['pause', 'cancel']; if (task.status === 'paused') return ['resume', 'cancel']; return []; } function buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] { const rooms = new Map(); for (const snapshot of snapshots) { for (const entry of snapshot.entries) { if (!rooms.has(entry.jid)) { rooms.set(entry.jid, { jid: entry.jid, name: entry.name || entry.folder || entry.jid, folder: entry.folder, }); } } } return [...rooms.values()].sort((a, b) => `${a.name} ${a.folder}`.localeCompare(`${b.name} ${b.folder}`), ); } function isTaskScheduleType( value: FormDataEntryValue | null, ): value is DashboardTaskScheduleType { return value === 'cron' || value === 'interval' || value === 'once'; } function isTaskContextMode( value: FormDataEntryValue | null, ): value is DashboardTaskContextMode { return value === 'group' || value === 'isolated'; } function readRequiredText(form: FormData, name: string): string | null { const value = form.get(name); if (typeof value !== 'string') return null; const trimmed = value.trim(); return trimmed ? trimmed : null; } function readTaskForm( form: FormData, includeRoom: true, ): CreateScheduledTaskInput | null; function readTaskForm( form: FormData, includeRoom: false, ): UpdateScheduledTaskInput | null; function readTaskForm( form: FormData, includeRoom: boolean, ): CreateScheduledTaskInput | UpdateScheduledTaskInput | null { const prompt = readRequiredText(form, 'prompt'); const scheduleValue = readRequiredText(form, 'scheduleValue'); const scheduleTypeValue = form.get('scheduleType'); if (!scheduleValue || !isTaskScheduleType(scheduleTypeValue)) { return null; } const scheduleType = scheduleTypeValue; if (!includeRoom) { return prompt ? { prompt, scheduleType, scheduleValue } : { scheduleType, scheduleValue }; } if (!prompt) { return null; } const roomJid = readRequiredText(form, 'roomJid'); const contextMode = form.get('contextMode'); if (!roomJid || !isTaskContextMode(contextMode)) return null; return { contextMode, prompt, roomJid, scheduleType, scheduleValue, }; } function inboxActionsFor(item: InboxItem): DashboardInboxAction[] { if ( item.source === 'paired-task' && (item.kind === 'reviewer-request' || item.kind === 'approval' || item.kind === 'arbiter-request') ) { return ['run', 'decline', 'dismiss']; } return ['dismiss']; } function inboxActionLabel( item: InboxItem, action: DashboardInboxAction, t: Messages, ): string { if (action === 'dismiss') return t.inbox.actions.dismiss; if (action === 'decline') return t.inbox.actions.decline; if (item.kind === 'reviewer-request') return t.inbox.actions.runReview; if (item.kind === 'approval') return t.inbox.actions.finalize; if (item.kind === 'arbiter-request') return t.inbox.actions.runArbiter; return t.inbox.actions.run; } const INBOX_FILTERS: InboxFilter[] = [ 'all', 'ci-failure', 'approval', 'reviewer-request', 'arbiter-request', 'pending-room', 'mention', ]; function serviceAgeMs( service: DashboardOverview['services'][number], generatedAt: string, ): number | null { const updated = new Date(service.updatedAt).getTime(); const now = new Date(generatedAt).getTime(); if (Number.isNaN(updated) || Number.isNaN(now)) return null; return Math.max(0, now - updated); } function serviceHealthLevel( service: DashboardOverview['services'][number], generatedAt: string, ): HealthLevel { const age = serviceAgeMs(service, generatedAt); if (age === null) return 'stale'; if (age >= HEALTH_DOWN_MS) return 'down'; if (age >= HEALTH_STALE_MS) return 'stale'; return 'ok'; } function inboxTargetHref(item: InboxItem): string | null { if (item.taskId) return '#/scheduled'; if (item.roomJid || item.groupFolder) return '#/rooms'; return null; } const NAV_ICONS: Record = { usage: , inbox: , health: , rooms: , scheduled: , settings: , }; function navItems(t: Messages) { return [ { href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const }, { href: '#/inbox', label: t.nav.inbox, view: 'inbox' as const }, { href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const }, { href: '#/health', label: t.nav.health, view: 'health' as const }, { href: '#/usage', label: t.nav.usage, view: 'usage' as const }, { href: '#/settings', label: t.nav.settings, view: 'settings' as const }, ]; } function EmptyState({ children }: { children: ReactNode }) { return
{children}
; } function LanguageSelector({ locale, onLocaleChange, t, }: { locale: Locale; onLocaleChange: (locale: Locale) => void; t: Messages; }) { return ( ); } 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 (

모델

{error ?

{error}

: null} {!draft ? (

{busy ? '불러오는 중…' : '모델 정보 없음'}

) : ( <> {(['owner', 'reviewer', 'arbiter'] as const).map((role) => (
{role} setRole(role, { model: e.target.value })} placeholder="claude / codex / claude-opus-4-7 …" type="text" value={draft[role].model} /> setRole(role, { effort: e.target.value })} placeholder="effort" type="text" value={draft[role].effort} />
))}
{savedAt && !dirty ? ( 저장됨. 적용하려면 스택 재시작 필요. ) : null}
)}
); } 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[]; } | null>(null); const [busy, setBusy] = useState(false); 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); } } return (

계정

{error ?

{error}

: null}

Claude

{!data ? (

{busy ? '불러오는 중…' : '없음'}

) : data.claude.length === 0 ? (

계정 없음

) : (
    {data.claude.map((acc) => { const expiry = formatExpiry( acc.expiresAt ? new Date(acc.expiresAt).toISOString() : null, ); return (
  • #{acc.index} {acc.subscriptionType ?? 'unknown'} {acc.rateLimitTier ? ` · ${acc.rateLimitTier}` : ''} {expiry ? ( {expiry.label} ) : null}
    {acc.index > 0 ? ( ) : ( 기본 )}
  • ); })}
)}