4264 lines
132 KiB
TypeScript
4264 lines
132 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||
import ReactMarkdown from 'react-markdown';
|
||
import remarkGfm from 'remark-gfm';
|
||
import { Send } 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 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 {
|
||
LOCALES,
|
||
isLocale,
|
||
languageNames,
|
||
localeTags,
|
||
matchLocale,
|
||
messages,
|
||
type Locale,
|
||
type Messages,
|
||
} from './i18n';
|
||
import {
|
||
useSelectedRoomActivity,
|
||
type RoomActivityMap,
|
||
} from './useRoomActivity';
|
||
import {
|
||
SectionNav,
|
||
SideRail,
|
||
type DashboardFreshness,
|
||
type DashboardView,
|
||
} from './DashboardNav';
|
||
import {
|
||
buildRoomThreadEntries,
|
||
isInternalProtocolPayload,
|
||
isWatcherRoomMessage,
|
||
} from './roomThread';
|
||
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 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 = DashboardFreshness;
|
||
|
||
type BeforeInstallPromptEvent = Event & {
|
||
prompt: () => Promise<void>;
|
||
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(/(?<![*\w])\*([^*\n]+?)\*(?!\w)/g)) {
|
||
matches.push({
|
||
start: m.index ?? 0,
|
||
end: (m.index ?? 0) + m[0].length,
|
||
seg: { kind: 'italic', 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: 'strike', value: m[1] },
|
||
priority: 0,
|
||
});
|
||
}
|
||
for (const m of input.matchAll(URL_RE)) {
|
||
matches.push({
|
||
start: m.index ?? 0,
|
||
end: (m.index ?? 0) + m[0].length,
|
||
seg: { kind: 'url', href: m[0], label: m[0].replace(/^https?:\/\//, '') },
|
||
priority: 1,
|
||
});
|
||
}
|
||
for (const m of input.matchAll(MENTION_RE)) {
|
||
const idx = (m.index ?? 0) + m[1].length;
|
||
matches.push({
|
||
start: idx,
|
||
end: idx + m[2].length,
|
||
seg: { kind: 'mention', value: m[2] },
|
||
priority: 2,
|
||
});
|
||
}
|
||
for (const m of input.matchAll(PATH_RE)) {
|
||
const lead = m[0].startsWith(' ') ? 1 : 0;
|
||
const idx = (m.index ?? 0) + lead;
|
||
matches.push({
|
||
start: idx,
|
||
end: idx + m[1].length,
|
||
seg: { kind: 'path', value: m[1] },
|
||
priority: 3,
|
||
});
|
||
}
|
||
for (const m of input.matchAll(MARKER_RE)) {
|
||
const v = m[1];
|
||
const tone: 'done' | 'fail' | 'info' = /FAIL|ERROR/.test(v)
|
||
? 'fail'
|
||
: /DONE|OK|PASS/.test(v)
|
||
? 'done'
|
||
: 'info';
|
||
matches.push({
|
||
start: m.index ?? 0,
|
||
end: (m.index ?? 0) + v.length,
|
||
seg: { kind: 'marker', tone, value: v },
|
||
priority: 4,
|
||
});
|
||
}
|
||
matches.sort((a, b) =>
|
||
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(<span key={j}>{v}</span>);
|
||
return;
|
||
}
|
||
if (s.kind === 'inline-code') {
|
||
usedRef.used += s.value.length;
|
||
out.push(
|
||
<code className="parsed-icode" key={j}>
|
||
{s.value}
|
||
</code>,
|
||
);
|
||
return;
|
||
}
|
||
if (s.kind === 'bold') {
|
||
usedRef.used += s.value.length;
|
||
out.push(<strong key={j}>{s.value}</strong>);
|
||
return;
|
||
}
|
||
if (s.kind === 'italic') {
|
||
usedRef.used += s.value.length;
|
||
out.push(<em key={j}>{s.value}</em>);
|
||
return;
|
||
}
|
||
if (s.kind === 'strike') {
|
||
usedRef.used += s.value.length;
|
||
out.push(<s key={j}>{s.value}</s>);
|
||
return;
|
||
}
|
||
if (s.kind === 'url') {
|
||
usedRef.used += s.label.length;
|
||
out.push(
|
||
<a
|
||
className="parsed-url"
|
||
href={s.href}
|
||
key={j}
|
||
rel="noreferrer noopener"
|
||
target="_blank"
|
||
>
|
||
{s.label}
|
||
</a>,
|
||
);
|
||
return;
|
||
}
|
||
if (s.kind === 'mention') {
|
||
usedRef.used += s.value.length;
|
||
out.push(
|
||
<span className="parsed-mention" key={j}>
|
||
{s.value}
|
||
</span>,
|
||
);
|
||
return;
|
||
}
|
||
if (s.kind === 'path') {
|
||
usedRef.used += s.value.length;
|
||
out.push(
|
||
<code className="parsed-path" key={j}>
|
||
{s.value}
|
||
</code>,
|
||
);
|
||
return;
|
||
}
|
||
if (s.kind === 'marker') {
|
||
usedRef.used += s.value.length;
|
||
out.push(
|
||
<span className={`parsed-marker parsed-marker-${s.tone}`} key={j}>
|
||
{s.value}
|
||
</span>,
|
||
);
|
||
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 <span className="parsed-empty">—</span>;
|
||
}
|
||
|
||
return (
|
||
<div className="parsed-body">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{cleaned}</ReactMarkdown>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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=<redacted>')
|
||
.replace(SECRET_VALUE_RE, '<redacted-token>')
|
||
.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<string, RoomOption>();
|
||
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;
|
||
}
|
||
|
||
function EmptyState({ children }: { children: ReactNode }) {
|
||
return <div className="empty-state">{children}</div>;
|
||
}
|
||
|
||
function LanguageSelector({
|
||
locale,
|
||
onLocaleChange,
|
||
t,
|
||
}: {
|
||
locale: Locale;
|
||
onLocaleChange: (locale: Locale) => void;
|
||
t: Messages;
|
||
}) {
|
||
return (
|
||
<label className="language-select">
|
||
<span>{t.language.label}</span>
|
||
<select
|
||
aria-label={t.language.label}
|
||
onChange={(event) => onLocaleChange(event.target.value as Locale)}
|
||
value={locale}
|
||
>
|
||
{LOCALES.map((item) => (
|
||
<option key={item} value={item}>
|
||
{languageNames[item]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
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">
|
||
<section className="section-nav skeleton-topbar">
|
||
<span className="skeleton-button" />
|
||
<span className="skeleton-line skeleton-copy" />
|
||
<span className="skeleton-button" />
|
||
</section>
|
||
<section className="skeleton-grid" aria-label={t.app.loading}>
|
||
{Array.from({ length: 4 }, (_, index) => (
|
||
<div className="card skeleton-card" key={index}>
|
||
<span className="skeleton-line skeleton-short" />
|
||
<span className="skeleton-line skeleton-number" />
|
||
<span className="skeleton-line skeleton-copy" />
|
||
</div>
|
||
))}
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function ControlRail({
|
||
canInstall,
|
||
data,
|
||
installed,
|
||
locale,
|
||
offlineReady,
|
||
onInstall,
|
||
online,
|
||
secureContext,
|
||
t,
|
||
}: {
|
||
canInstall: boolean;
|
||
data: DashboardState;
|
||
installed: boolean;
|
||
locale: Locale;
|
||
offlineReady: boolean;
|
||
onInstall: () => void;
|
||
online: boolean;
|
||
secureContext: boolean;
|
||
t: Messages;
|
||
}) {
|
||
const queue = data.snapshots.reduce(
|
||
(acc, snapshot) => {
|
||
for (const entry of snapshot.entries) {
|
||
acc.pendingTasks += entry.pendingTasks;
|
||
if (entry.pendingMessages) acc.pendingMessageRooms += 1;
|
||
}
|
||
return acc;
|
||
},
|
||
{ pendingTasks: 0, pendingMessageRooms: 0 },
|
||
);
|
||
const freshness = dashboardFreshness(online, data.overview.generatedAt);
|
||
const age = dashboardAgeMs(data.overview.generatedAt);
|
||
|
||
return (
|
||
<section className="ops-strip" id="overview" aria-label={t.control.aria}>
|
||
<div className={`ops-tile-freshness ops-${freshness}`}>
|
||
<span>{t.pwa.updated}</span>
|
||
<strong>{freshnessLabel(freshness, t)}</strong>
|
||
<small>
|
||
{formatDate(data.overview.generatedAt, locale)}
|
||
{age === null ? '' : ` · ${formatDuration(age, t)}`}
|
||
</small>
|
||
</div>
|
||
<div className="ops-tile-pwa">
|
||
<span>{t.pwa.app}</span>
|
||
<strong>
|
||
{!secureContext
|
||
? t.pwa.secureRequired
|
||
: installed
|
||
? t.pwa.installed
|
||
: offlineReady
|
||
? t.pwa.ready
|
||
: t.pwa.app}
|
||
</strong>
|
||
{canInstall ? (
|
||
<button onClick={onInstall} type="button">
|
||
{t.pwa.install}
|
||
</button>
|
||
) : (
|
||
<small>
|
||
{!secureContext
|
||
? t.pwa.secureRequired
|
||
: offlineReady
|
||
? t.pwa.cached
|
||
: t.pwa.online}
|
||
</small>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<span>{t.metrics.rooms}</span>
|
||
<strong>
|
||
{data.overview.rooms.active + data.overview.rooms.waiting}/
|
||
{data.overview.rooms.total}
|
||
</strong>
|
||
<small>{t.control.activeRooms}</small>
|
||
</div>
|
||
<div>
|
||
<span>{t.control.queue}</span>
|
||
<strong>{queue.pendingTasks}</strong>
|
||
<small>
|
||
{queue.pendingMessageRooms} {t.control.pendingRooms}
|
||
</small>
|
||
</div>
|
||
<div>
|
||
<span>{t.metrics.agents}</span>
|
||
<strong>{data.overview.services.length}</strong>
|
||
<small>{t.panels.heartbeat}</small>
|
||
</div>
|
||
<div>
|
||
<span>{t.metrics.ciWatchers}</span>
|
||
<strong>{data.overview.tasks.watchers.active}</strong>
|
||
<small>
|
||
{data.overview.tasks.watchers.paused} {t.status.paused}
|
||
</small>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function InboxPanel({
|
||
overview,
|
||
tasks,
|
||
locale,
|
||
onInboxAction,
|
||
onTaskAction,
|
||
inboxActionKey,
|
||
taskActionKey,
|
||
t,
|
||
}: {
|
||
overview: DashboardOverview;
|
||
tasks: DashboardTask[];
|
||
locale: Locale;
|
||
onInboxAction: (item: InboxItem, action: DashboardInboxAction) => void;
|
||
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
|
||
inboxActionKey: InboxActionKey | null;
|
||
taskActionKey: TaskActionKey | null;
|
||
t: Messages;
|
||
}) {
|
||
const [filter, setFilter] = useState<InboxFilter>('all');
|
||
const items = overview.inbox ?? [];
|
||
const counts = useMemo(() => {
|
||
const next: Record<InboxFilter, number> = {
|
||
all: items.length,
|
||
'pending-room': 0,
|
||
'reviewer-request': 0,
|
||
approval: 0,
|
||
'arbiter-request': 0,
|
||
'ci-failure': 0,
|
||
mention: 0,
|
||
};
|
||
for (const item of items) next[item.kind] += 1;
|
||
return next;
|
||
}, [items]);
|
||
const filteredItems =
|
||
filter === 'all' ? items : items.filter((item) => item.kind === filter);
|
||
const severityCounts = items.reduce(
|
||
(acc, item) => {
|
||
acc[item.severity] += 1;
|
||
return acc;
|
||
},
|
||
{ error: 0, warn: 0, info: 0 },
|
||
);
|
||
|
||
if (items.length === 0) {
|
||
return <EmptyState>{t.inbox.empty}</EmptyState>;
|
||
}
|
||
|
||
return (
|
||
<div className="inbox-board">
|
||
<section className="inbox-summary" aria-label={t.inbox.summary}>
|
||
<div>
|
||
<span>{t.inbox.total}</span>
|
||
<strong>{items.length}</strong>
|
||
</div>
|
||
<div>
|
||
<span>{t.inbox.severity.error}</span>
|
||
<strong>{severityCounts.error}</strong>
|
||
</div>
|
||
<div>
|
||
<span>{t.inbox.severity.warn}</span>
|
||
<strong>{severityCounts.warn}</strong>
|
||
</div>
|
||
<div>
|
||
<span>{t.inbox.severity.info}</span>
|
||
<strong>{severityCounts.info}</strong>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="inbox-filters" aria-label={t.inbox.filters}>
|
||
{INBOX_FILTERS.map((item) => {
|
||
if (item !== 'all' && counts[item] === 0) return null;
|
||
const label = item === 'all' ? t.inbox.all : t.inbox.kinds[item];
|
||
return (
|
||
<button
|
||
aria-pressed={filter === item}
|
||
className={filter === item ? 'is-active' : undefined}
|
||
key={item}
|
||
onClick={() => setFilter(item)}
|
||
type="button"
|
||
>
|
||
{label}
|
||
<span>{counts[item]}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="inbox-list" aria-label={t.inbox.cardsAria}>
|
||
{filteredItems.map((item) => {
|
||
const href = inboxTargetHref(item);
|
||
const linkedTask =
|
||
item.source === 'scheduled-task' && item.taskId
|
||
? tasks.find((task) => task.id === item.taskId)
|
||
: undefined;
|
||
const linkedTaskActions = linkedTask
|
||
? taskActionsFor(linkedTask)
|
||
: [];
|
||
const inboxActions = inboxActionsFor(item);
|
||
return (
|
||
<article
|
||
className={`inbox-card inbox-${item.severity}`}
|
||
key={item.id}
|
||
>
|
||
<div className="inbox-card-head">
|
||
<div>
|
||
<span className="eyebrow">{t.inbox.kinds[item.kind]}</span>
|
||
<strong>{sanitizeInboxText(item.title) || item.title}</strong>
|
||
</div>
|
||
<div className="inbox-card-badges">
|
||
<span className={`pill pill-${item.severity}`}>
|
||
{t.inbox.severity[item.severity]}
|
||
</span>
|
||
{item.occurrences > 1 ? (
|
||
<span className="pill pill-info">x{item.occurrences}</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<p>{sanitizeInboxText(item.summary) || t.inbox.noSummary}</p>
|
||
<div className="inbox-meta">
|
||
<span>
|
||
<small>{t.inbox.occurred}</small>
|
||
<strong>{formatDate(item.occurredAt, locale)}</strong>
|
||
</span>
|
||
<span>
|
||
<small>{t.inbox.source}</small>
|
||
<strong>{item.source}</strong>
|
||
</span>
|
||
<span>
|
||
<small>{t.inbox.target}</small>
|
||
<strong>
|
||
{item.taskId ??
|
||
item.roomName ??
|
||
item.groupFolder ??
|
||
item.roomJid ??
|
||
'-'}
|
||
</strong>
|
||
</span>
|
||
</div>
|
||
{href ? (
|
||
<a className="inbox-target" href={href}>
|
||
{item.taskId ? t.inbox.openTask : t.inbox.openRoom}
|
||
</a>
|
||
) : null}
|
||
{linkedTask && linkedTaskActions.length > 0 ? (
|
||
<div className="task-actions inbox-actions">
|
||
{linkedTaskActions.map((action) => {
|
||
const actionKey: TaskActionKey = `${linkedTask.id}:${action}`;
|
||
const busy = taskActionKey === actionKey;
|
||
return (
|
||
<button
|
||
aria-busy={busy || undefined}
|
||
className={`task-action task-action-${action}${busy ? ' is-busy' : ''}`}
|
||
disabled={busy}
|
||
key={action}
|
||
onClick={() => onTaskAction(linkedTask, action)}
|
||
type="button"
|
||
>
|
||
{busy ? t.tasks.actions.busy : t.tasks.actions[action]}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
{inboxActions.length > 0 ? (
|
||
<div className="task-actions inbox-actions">
|
||
{inboxActions.map((action) => {
|
||
const actionKey: InboxActionKey = `${item.id}:${action}`;
|
||
const busy = inboxActionKey === actionKey;
|
||
return (
|
||
<button
|
||
aria-busy={busy || undefined}
|
||
className={`task-action task-action-${action}${busy ? ' is-busy' : ''}`}
|
||
disabled={busy}
|
||
key={action}
|
||
onClick={() => onInboxAction(item, action)}
|
||
type="button"
|
||
>
|
||
{busy
|
||
? t.inbox.actions.busy
|
||
: inboxActionLabel(item, action, t)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function HealthPanel({
|
||
data,
|
||
locale,
|
||
onRestartStack,
|
||
serviceActionKey,
|
||
t,
|
||
}: {
|
||
data: DashboardState;
|
||
locale: Locale;
|
||
onRestartStack: () => void;
|
||
serviceActionKey: ServiceActionKey | null;
|
||
t: Messages;
|
||
}) {
|
||
const services = data.overview.services;
|
||
const restarts = data.overview.operations?.serviceRestarts ?? [];
|
||
const serviceLevels = services.map((service) => ({
|
||
service,
|
||
level: serviceHealthLevel(service, data.overview.generatedAt),
|
||
age: serviceAgeMs(service, data.overview.generatedAt),
|
||
}));
|
||
const down = serviceLevels.filter((item) => item.level === 'down').length;
|
||
const stale = serviceLevels.filter((item) => item.level === 'stale').length;
|
||
const queue = data.snapshots.reduce(
|
||
(acc, snapshot) => {
|
||
for (const entry of snapshot.entries) {
|
||
acc.pendingTasks += entry.pendingTasks;
|
||
if (entry.pendingMessages) acc.pendingMessageRooms += 1;
|
||
}
|
||
return acc;
|
||
},
|
||
{ pendingTasks: 0, pendingMessageRooms: 0 },
|
||
);
|
||
const ciFailures = data.overview.inbox.reduce(
|
||
(count, item) =>
|
||
item.kind === 'ci-failure' ? count + item.occurrences : count,
|
||
0,
|
||
);
|
||
const healthLevel: HealthLevel =
|
||
down > 0 ? 'down' : stale > 0 || ciFailures > 0 ? 'stale' : 'ok';
|
||
const affectedServices = serviceLevels.filter((item) => item.level !== 'ok');
|
||
|
||
return (
|
||
<div className="health-board">
|
||
<section className={`health-overview health-${healthLevel}`}>
|
||
<span className="eyebrow">{t.health.system}</span>
|
||
<strong>{t.health.levels[healthLevel]}</strong>
|
||
</section>
|
||
|
||
<section className="health-signals" aria-label={t.health.signals}>
|
||
<div>
|
||
<span>{t.health.services}</span>
|
||
<strong>
|
||
{services.length - stale - down}/{services.length}
|
||
</strong>
|
||
<small>{t.health.fresh}</small>
|
||
</div>
|
||
<div>
|
||
<span>{t.health.stale}</span>
|
||
<strong>{stale + down}</strong>
|
||
<small>
|
||
{down} {t.health.levels.down}
|
||
</small>
|
||
</div>
|
||
<div>
|
||
<span>{t.health.queue}</span>
|
||
<strong>{queue.pendingTasks}</strong>
|
||
<small>
|
||
{queue.pendingMessageRooms} {t.control.pendingRooms}
|
||
</small>
|
||
</div>
|
||
<div>
|
||
<span>{t.health.ciFailures}</span>
|
||
<strong>{ciFailures}</strong>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="health-actions" aria-label={t.health.restart}>
|
||
<div>
|
||
<span className="eyebrow">{t.health.restart}</span>
|
||
<strong>{t.health.restartStack}</strong>
|
||
<small>{t.health.restartHint}</small>
|
||
</div>
|
||
<button
|
||
disabled={serviceActionKey === 'stack:restart'}
|
||
onClick={onRestartStack}
|
||
type="button"
|
||
>
|
||
{serviceActionKey === 'stack:restart'
|
||
? t.health.restarting
|
||
: t.health.restartStack}
|
||
</button>
|
||
</section>
|
||
|
||
{restarts.length > 0 ? (
|
||
<details className="health-restart-log">
|
||
<summary>
|
||
{t.health.restartLog}
|
||
<strong>{restarts.length}</strong>
|
||
</summary>
|
||
<div className="health-restart-list">
|
||
{restarts.map((restart) => {
|
||
const pill =
|
||
restart.status === 'success'
|
||
? 'ok'
|
||
: restart.status === 'failed'
|
||
? 'error'
|
||
: 'stale';
|
||
return (
|
||
<article className="health-restart-record" key={restart.id}>
|
||
<div>
|
||
<small>{t.health.restartTarget}</small>
|
||
<strong>{restart.target}</strong>
|
||
</div>
|
||
<span
|
||
aria-label={`${t.health.restartStatus}: ${restart.status}`}
|
||
className={`pill pill-${pill}`}
|
||
>
|
||
{restart.status}
|
||
</span>
|
||
<div>
|
||
<small>{t.health.restartRequested}</small>
|
||
<strong>{formatDate(restart.requestedAt, locale)}</strong>
|
||
</div>
|
||
<div>
|
||
<small>{t.health.restartServices}</small>
|
||
<strong>
|
||
{restart.services.length > 0
|
||
? restart.services.join(', ')
|
||
: '-'}
|
||
</strong>
|
||
</div>
|
||
{restart.error ? (
|
||
<p className="health-restart-error">{restart.error}</p>
|
||
) : null}
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
</details>
|
||
) : null}
|
||
|
||
{services.length === 0 ? (
|
||
<EmptyState>{t.service.empty}</EmptyState>
|
||
) : affectedServices.length === 0 ? null : (
|
||
<details className="health-service-details">
|
||
<summary>
|
||
{t.health.affectedServices}
|
||
<strong>{affectedServices.length}</strong>
|
||
</summary>
|
||
<div className="health-service-list">
|
||
{affectedServices.map(({ service, level, age }) => (
|
||
<article className="health-service" key={service.serviceId}>
|
||
<div>
|
||
<strong>{service.assistantName || service.serviceId}</strong>
|
||
</div>
|
||
<span className={`pill pill-${level}`}>
|
||
{t.health.levels[level]}
|
||
</span>
|
||
<div>
|
||
<small>{t.service.updated}</small>
|
||
<strong>{formatDate(service.updatedAt, locale)}</strong>
|
||
<em>{formatDuration(age, t)}</em>
|
||
</div>
|
||
<div>
|
||
<small>{t.service.rooms}</small>
|
||
<strong>
|
||
{service.activeRooms}/{service.totalRooms}
|
||
</strong>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</details>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RoomMessageForm({
|
||
busy,
|
||
onChange,
|
||
onSubmit,
|
||
t,
|
||
value,
|
||
}: {
|
||
busy: boolean;
|
||
onChange: (value: string) => void;
|
||
onSubmit: () => void;
|
||
t: Messages;
|
||
value: string;
|
||
}) {
|
||
return (
|
||
<form
|
||
className="room-compose"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
onSubmit();
|
||
}}
|
||
>
|
||
<textarea
|
||
aria-label={t.rooms.message}
|
||
maxLength={8000}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key !== 'Enter') return;
|
||
if (event.shiftKey || event.nativeEvent.isComposing) return;
|
||
if (busy || !value.trim()) return;
|
||
event.preventDefault();
|
||
onSubmit();
|
||
}}
|
||
placeholder={t.rooms.messagePlaceholder}
|
||
rows={1}
|
||
value={value}
|
||
/>
|
||
<button
|
||
aria-label={busy ? t.rooms.sending : t.rooms.send}
|
||
disabled={busy || !value.trim()}
|
||
title={busy ? t.rooms.sending : t.rooms.send}
|
||
type="submit"
|
||
>
|
||
<Send size={16} strokeWidth={2} aria-hidden />
|
||
</button>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
function RoomActivityPanel({
|
||
activity,
|
||
loading,
|
||
locale,
|
||
t,
|
||
}: {
|
||
activity: DashboardRoomActivity | undefined;
|
||
loading: boolean;
|
||
locale: Locale;
|
||
t: Messages;
|
||
}) {
|
||
if (!activity) {
|
||
return (
|
||
<div className="room-activity room-activity-empty">
|
||
{loading ? t.rooms.loadingActivity : t.rooms.noActivity}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const task = activity.pairedTask;
|
||
const turn = task?.currentTurn ?? null;
|
||
const outputs = task?.outputs ?? [];
|
||
const latestOutput = outputs.at(-1) ?? null;
|
||
const progressText = turn
|
||
? `${turn.role} · ${turn.intentKind} · ${t.rooms.attempt} ${turn.attemptNo}`
|
||
: t.rooms.noTurn;
|
||
|
||
return (
|
||
<div className="room-activity">
|
||
<div className="room-progress-strip">
|
||
{turn ? (
|
||
<span className={`pill pill-${turn.state}`}>
|
||
{statusLabel(turn.state, t)}
|
||
</span>
|
||
) : (
|
||
<span className="pill pill-inactive">{t.rooms.noTurn}</span>
|
||
)}
|
||
<strong>{progressText}</strong>
|
||
<span>{task ? `${t.rooms.round} ${task.roundTripCount}` : '-'}</span>
|
||
{turn?.executorServiceId ? <span>{turn.executorServiceId}</span> : null}
|
||
<time>{formatDate(turn?.updatedAt ?? task?.updatedAt, locale)}</time>
|
||
</div>
|
||
|
||
<section className="room-latest-output">
|
||
<header>
|
||
<strong>{t.rooms.latestOutput}</strong>
|
||
{latestOutput ? (
|
||
<span>
|
||
#{latestOutput.turnNumber} · {latestOutput.role}
|
||
{latestOutput.verdict ? ` · ${latestOutput.verdict}` : ''}
|
||
</span>
|
||
) : null}
|
||
</header>
|
||
{latestOutput ? (
|
||
<p>{latestOutput.outputText}</p>
|
||
) : (
|
||
<p className="room-muted">{t.rooms.noOutput}</p>
|
||
)}
|
||
</section>
|
||
|
||
<details className="room-activity-details">
|
||
<summary>{t.rooms.details}</summary>
|
||
<div className="room-detail-grid">
|
||
<section>
|
||
<strong>{t.rooms.task}</strong>
|
||
<p className="room-muted">
|
||
{task?.title || task?.id || t.rooms.noTask}
|
||
</p>
|
||
<div className="room-turn-line">
|
||
{turn?.activeRunId ? (
|
||
<span className="mono-chip">{turn.activeRunId}</span>
|
||
) : null}
|
||
{turn?.lastError ? (
|
||
<span className="room-activity-error">{turn.lastError}</span>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<strong>{t.rooms.outputHistory}</strong>
|
||
{outputs.length === 0 ? (
|
||
<p className="room-muted">{t.rooms.noOutput}</p>
|
||
) : (
|
||
<div className="room-output-list">
|
||
{[...outputs].reverse().map((output) => (
|
||
<article key={output.id} className="room-output-item">
|
||
<header>
|
||
<span>
|
||
#{output.turnNumber} · {output.role}
|
||
{output.verdict ? ` · ${output.verdict}` : ''}
|
||
</span>
|
||
<time>{formatDate(output.createdAt, locale)}</time>
|
||
</header>
|
||
<p>{output.outputText}</p>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
<section>
|
||
<strong>{t.rooms.messageHistory}</strong>
|
||
{activity.messages.length === 0 ? (
|
||
<p className="room-muted">{t.rooms.noMessages}</p>
|
||
) : (
|
||
<div className="room-message-list">
|
||
{activity.messages.map((message) => (
|
||
<article
|
||
className={`room-message-item ${
|
||
message.isBotMessage ? 'room-message-bot' : ''
|
||
}`}
|
||
key={message.id}
|
||
>
|
||
<header>
|
||
<span>{message.senderName}</span>
|
||
<time>{formatDate(message.timestamp, locale)}</time>
|
||
</header>
|
||
<p>{message.content}</p>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RoomComposeDetails({
|
||
busy,
|
||
onChange,
|
||
onSubmit,
|
||
t,
|
||
value,
|
||
}: {
|
||
busy: boolean;
|
||
onChange: (value: string) => void;
|
||
onSubmit: () => void;
|
||
t: Messages;
|
||
value: string;
|
||
}) {
|
||
return (
|
||
<details className="room-compose-details">
|
||
<summary>{t.rooms.message}</summary>
|
||
<RoomMessageForm
|
||
busy={busy}
|
||
onChange={onChange}
|
||
onSubmit={onSubmit}
|
||
t={t}
|
||
value={value}
|
||
/>
|
||
</details>
|
||
);
|
||
}
|
||
|
||
function RoomPanel({
|
||
onSendRoomMessage,
|
||
roomActivity,
|
||
roomActivityLoading,
|
||
roomMessageKey,
|
||
locale,
|
||
snapshots,
|
||
t,
|
||
}: {
|
||
onSendRoomMessage: (
|
||
roomJid: string,
|
||
text: string,
|
||
requestId: string,
|
||
) => Promise<boolean>;
|
||
roomActivity: RoomActivityMap;
|
||
roomActivityLoading: boolean;
|
||
roomMessageKey: string | null;
|
||
locale: Locale;
|
||
snapshots: StatusSnapshot[];
|
||
t: Messages;
|
||
}) {
|
||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||
const entries = snapshots.flatMap((snapshot) =>
|
||
snapshot.entries.map((entry) => ({
|
||
...entry,
|
||
serviceId: snapshot.serviceId,
|
||
})),
|
||
);
|
||
|
||
if (entries.length === 0) {
|
||
return <EmptyState>{t.rooms.empty}</EmptyState>;
|
||
}
|
||
|
||
function setDraft(jid: string, value: string) {
|
||
setDrafts((previous) => ({ ...previous, [jid]: value }));
|
||
}
|
||
|
||
async function submitRoomMessage(jid: string) {
|
||
const text = drafts[jid]?.trim();
|
||
if (!text) return;
|
||
const success = await onSendRoomMessage(jid, text, makeClientRequestId());
|
||
if (success) {
|
||
setDraft(jid, '');
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="table-wrap desktop-table">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>{t.rooms.room}</th>
|
||
<th>{t.rooms.status}</th>
|
||
<th>{t.rooms.queue}</th>
|
||
<th>{t.rooms.elapsed}</th>
|
||
<th>{t.rooms.activity}</th>
|
||
<th>{t.rooms.message}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{entries.map((entry) => (
|
||
<tr key={`${entry.serviceId}:${entry.jid}`}>
|
||
<td>
|
||
<strong>{entry.name}</strong>
|
||
<details className="record-details">
|
||
<summary>{t.rooms.details}</summary>
|
||
<span>
|
||
{entry.folder} · {entry.jid} · {entry.serviceId} ·{' '}
|
||
{entry.agentType}
|
||
</span>
|
||
</details>
|
||
</td>
|
||
<td>
|
||
<span className={`pill pill-${entry.status}`}>
|
||
{statusLabel(entry.status, t)}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
{queueLabel(entry.pendingTasks, entry.pendingMessages, t)}
|
||
</td>
|
||
<td>{formatDuration(entry.elapsedMs, t)}</td>
|
||
<td className="room-activity-cell">
|
||
<RoomActivityPanel
|
||
activity={roomActivity[entry.jid]}
|
||
loading={roomActivityLoading}
|
||
locale={locale}
|
||
t={t}
|
||
/>
|
||
</td>
|
||
<td className="room-message-cell">
|
||
<RoomComposeDetails
|
||
busy={roomMessageKey === entry.jid}
|
||
onChange={(value) => setDraft(entry.jid, value)}
|
||
onSubmit={() => void submitRoomMessage(entry.jid)}
|
||
t={t}
|
||
value={drafts[entry.jid] ?? ''}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="mobile-record-list" aria-label={t.rooms.cardsAria}>
|
||
{entries.map((entry) => (
|
||
<article
|
||
className="record-card"
|
||
key={`${entry.serviceId}:${entry.jid}`}
|
||
>
|
||
<div className="record-card-head">
|
||
<div>
|
||
<strong>{entry.name}</strong>
|
||
<span className="mono-chip">{entry.folder}</span>
|
||
</div>
|
||
<span className={`pill pill-${entry.status}`}>
|
||
{statusLabel(entry.status, t)}
|
||
</span>
|
||
</div>
|
||
<div className="record-card-grid">
|
||
<span>
|
||
<small>{t.rooms.queue}</small>
|
||
<strong>
|
||
{queueLabel(entry.pendingTasks, entry.pendingMessages, t)}
|
||
</strong>
|
||
</span>
|
||
<span>
|
||
<small>{t.rooms.elapsed}</small>
|
||
<strong>{formatDuration(entry.elapsedMs, t)}</strong>
|
||
</span>
|
||
</div>
|
||
<RoomActivityPanel
|
||
activity={roomActivity[entry.jid]}
|
||
loading={roomActivityLoading}
|
||
locale={locale}
|
||
t={t}
|
||
/>
|
||
<RoomComposeDetails
|
||
busy={roomMessageKey === entry.jid}
|
||
onChange={(value) => setDraft(entry.jid, value)}
|
||
onSubmit={() => void submitRoomMessage(entry.jid)}
|
||
t={t}
|
||
value={drafts[entry.jid] ?? ''}
|
||
/>
|
||
<details className="record-details">
|
||
<summary>{t.rooms.details}</summary>
|
||
<p className="record-id">
|
||
{entry.folder} · {entry.jid} · {entry.serviceId} ·{' '}
|
||
{entry.agentType}
|
||
</p>
|
||
</details>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
type RoomFilter = 'all' | 'processing' | 'waiting' | 'inactive';
|
||
type RoomSort = 'recent' | 'name' | 'queue';
|
||
|
||
const ROOM_FILTER_ORDER: RoomFilter[] = [
|
||
'all',
|
||
'processing',
|
||
'waiting',
|
||
'inactive',
|
||
];
|
||
|
||
function roomFilterLabel(f: RoomFilter, t: Messages): string {
|
||
if (f === 'all') return t.rooms.filterAll;
|
||
return statusLabel(f, t);
|
||
}
|
||
|
||
function roomSortLabel(s: RoomSort, t: Messages): string {
|
||
if (s === 'recent') return t.rooms.sortRecent;
|
||
if (s === 'queue') return t.rooms.sortQueue;
|
||
return t.rooms.sortName;
|
||
}
|
||
|
||
interface RoomEntryWithService {
|
||
jid: string;
|
||
name: string;
|
||
folder: string;
|
||
agentType: string;
|
||
status: 'processing' | 'waiting' | 'inactive';
|
||
elapsedMs: number | null;
|
||
pendingMessages: boolean;
|
||
pendingTasks: number;
|
||
serviceId: string;
|
||
}
|
||
|
||
function RoomBoardV2({
|
||
inbox,
|
||
onSendRoomMessage,
|
||
pendingMessages,
|
||
roomActivity,
|
||
roomActivityLoading,
|
||
roomMessageKey,
|
||
selectedJid,
|
||
locale,
|
||
onSelectedJidChange,
|
||
snapshots,
|
||
t,
|
||
}: {
|
||
inbox: InboxItem[];
|
||
onSendRoomMessage: (
|
||
roomJid: string,
|
||
text: string,
|
||
requestId: string,
|
||
) => Promise<boolean>;
|
||
pendingMessages: Record<
|
||
string,
|
||
Array<DashboardRoomActivity['messages'][number]>
|
||
>;
|
||
roomActivity: RoomActivityMap;
|
||
roomActivityLoading: boolean;
|
||
roomMessageKey: string | null;
|
||
selectedJid: string | null;
|
||
locale: Locale;
|
||
onSelectedJidChange: (jid: string | null) => void;
|
||
snapshots: StatusSnapshot[];
|
||
t: Messages;
|
||
}) {
|
||
const [filter, setFilter] = useState<RoomFilter>('all');
|
||
const [sort, setSort] = useState<RoomSort>('recent');
|
||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
|
||
|
||
const allEntries: RoomEntryWithService[] = snapshots.flatMap((snapshot) =>
|
||
snapshot.entries.map((entry) => ({
|
||
...entry,
|
||
serviceId: snapshot.serviceId,
|
||
})),
|
||
);
|
||
|
||
const counts = {
|
||
all: allEntries.length,
|
||
processing: allEntries.filter((e) => e.status === 'processing').length,
|
||
waiting: allEntries.filter((e) => e.status === 'waiting').length,
|
||
inactive: allEntries.filter((e) => e.status === 'inactive').length,
|
||
};
|
||
|
||
const filtered = allEntries.filter(
|
||
(e) => filter === 'all' || e.status === filter,
|
||
);
|
||
const sorted = [...filtered].sort((a, b) => {
|
||
if (sort === 'name') return a.name.localeCompare(b.name);
|
||
if (sort === 'queue') {
|
||
const aQ = a.pendingTasks + (a.pendingMessages ? 1 : 0);
|
||
const bQ = b.pendingTasks + (b.pendingMessages ? 1 : 0);
|
||
return bQ - aQ;
|
||
}
|
||
const aA = roomActivity[a.jid]?.pairedTask?.updatedAt;
|
||
const bA = roomActivity[b.jid]?.pairedTask?.updatedAt;
|
||
const aT = aA ? new Date(aA).getTime() : (a.elapsedMs ?? 0);
|
||
const bT = bA ? new Date(bA).getTime() : (b.elapsedMs ?? 0);
|
||
return bT - aT;
|
||
});
|
||
|
||
const selectedEntry =
|
||
sorted.find((e) => e.jid === selectedJid) ?? sorted[0] ?? null;
|
||
|
||
useEffect(() => {
|
||
const nextJid = selectedEntry?.jid ?? null;
|
||
if (nextJid !== selectedJid) {
|
||
onSelectedJidChange(nextJid);
|
||
setMobileDetailOpen(false);
|
||
}
|
||
}, [onSelectedJidChange, selectedEntry?.jid, selectedJid]);
|
||
|
||
if (allEntries.length === 0) {
|
||
return <EmptyState>{t.rooms.empty}</EmptyState>;
|
||
}
|
||
|
||
function setDraft(jid: string, value: string) {
|
||
setDrafts((previous) => ({ ...previous, [jid]: value }));
|
||
}
|
||
|
||
function scrollDetailToBottom() {
|
||
if (typeof window === 'undefined') return;
|
||
const detail = document.querySelector(
|
||
'.rooms-detail',
|
||
) as HTMLElement | null;
|
||
if (!detail) return;
|
||
const tick = (n: number) => {
|
||
detail.scrollTop = detail.scrollHeight;
|
||
if (n > 0) requestAnimationFrame(() => tick(n - 1));
|
||
};
|
||
requestAnimationFrame(() => tick(3));
|
||
}
|
||
|
||
async function submitRoomMessage(jid: string) {
|
||
const text = drafts[jid]?.trim();
|
||
if (!text) return;
|
||
scrollDetailToBottom();
|
||
const success = await onSendRoomMessage(jid, text, makeClientRequestId());
|
||
if (success) {
|
||
setDraft(jid, '');
|
||
scrollDetailToBottom();
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="rooms-v2">
|
||
<div className="rooms-toolbar" role="toolbar" aria-label="Rooms filters">
|
||
<div className="rooms-filters">
|
||
{ROOM_FILTER_ORDER.map((f) => (
|
||
<button
|
||
aria-pressed={filter === f}
|
||
className={filter === f ? 'is-active' : undefined}
|
||
key={f}
|
||
onClick={() => setFilter(f)}
|
||
type="button"
|
||
>
|
||
{roomFilterLabel(f, t)}
|
||
<span>{counts[f]}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<label className="rooms-sort">
|
||
<span>{t.rooms.sortLabel}</span>
|
||
<select
|
||
onChange={(e) => setSort(e.target.value as RoomSort)}
|
||
value={sort}
|
||
>
|
||
<option value="recent">{roomSortLabel('recent', t)}</option>
|
||
<option value="queue">{roomSortLabel('queue', t)}</option>
|
||
<option value="name">{roomSortLabel('name', t)}</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
{sorted.length === 0 ? (
|
||
<EmptyState>{t.rooms.empty}</EmptyState>
|
||
) : (
|
||
<div
|
||
className={`rooms-twopane${mobileDetailOpen ? ' is-detail-open' : ''}`}
|
||
>
|
||
<aside className="rooms-list" aria-label={t.rooms.cardsAria}>
|
||
{sorted.map((entry) => {
|
||
const items = inbox.filter((it) => it.roomJid === entry.jid);
|
||
const queue =
|
||
entry.pendingTasks + (entry.pendingMessages ? 1 : 0);
|
||
const active = (selectedEntry?.jid ?? null) === entry.jid;
|
||
return (
|
||
<button
|
||
aria-current={active ? 'page' : undefined}
|
||
className={`rooms-list-item status-${entry.status}${active ? ' is-active' : ''}`}
|
||
key={`${entry.serviceId}:${entry.jid}`}
|
||
onClick={() => {
|
||
onSelectedJidChange(entry.jid);
|
||
setMobileDetailOpen(true);
|
||
}}
|
||
type="button"
|
||
>
|
||
<span className={`room-pulse pulse-${entry.status}`}>
|
||
{entry.status === 'processing' ? (
|
||
<span className="pulse-dot" />
|
||
) : null}
|
||
</span>
|
||
<span className="rooms-list-text">
|
||
<strong>{entry.name}</strong>
|
||
<small>{entry.agentType}</small>
|
||
</span>
|
||
{items.length > 0 ? (
|
||
<span
|
||
className={`rooms-list-bell sev-${items[0].severity}`}
|
||
title={items.map((i) => t.inbox.kinds[i.kind]).join(', ')}
|
||
>
|
||
{items.length}
|
||
</span>
|
||
) : null}
|
||
{queue > 0 ? (
|
||
<span className="rooms-list-queue">{queue}</span>
|
||
) : null}
|
||
</button>
|
||
);
|
||
})}
|
||
</aside>
|
||
<main className="rooms-detail">
|
||
<button
|
||
className="rooms-mobile-back"
|
||
onClick={() => setMobileDetailOpen(false)}
|
||
type="button"
|
||
>
|
||
← {t.panels.rooms}
|
||
</button>
|
||
{selectedEntry ? (
|
||
<RoomCardV2
|
||
activity={roomActivity[selectedEntry.jid]}
|
||
activityLoading={roomActivityLoading}
|
||
busy={roomMessageKey === selectedEntry.jid}
|
||
draft={drafts[selectedEntry.jid] ?? ''}
|
||
entry={selectedEntry}
|
||
expanded={true}
|
||
inboxItems={inbox.filter(
|
||
(item) => item.roomJid === selectedEntry.jid,
|
||
)}
|
||
key={`${selectedEntry.serviceId}:${selectedEntry.jid}`}
|
||
locale={locale}
|
||
onDraftChange={(v) => setDraft(selectedEntry.jid, v)}
|
||
onSendMessage={() => void submitRoomMessage(selectedEntry.jid)}
|
||
onToggle={() => {}}
|
||
pendingMessages={pendingMessages[selectedEntry.jid] ?? []}
|
||
pinned={true}
|
||
t={t}
|
||
/>
|
||
) : (
|
||
<EmptyState>{t.rooms.empty}</EmptyState>
|
||
)}
|
||
</main>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RoomCardV2({
|
||
activity,
|
||
activityLoading,
|
||
busy,
|
||
draft,
|
||
entry,
|
||
expanded,
|
||
inboxItems,
|
||
locale,
|
||
onDraftChange,
|
||
onSendMessage,
|
||
onToggle,
|
||
pendingMessages = [],
|
||
pinned = false,
|
||
t,
|
||
}: {
|
||
activity: DashboardRoomActivity | undefined;
|
||
activityLoading: boolean;
|
||
busy: boolean;
|
||
draft: string;
|
||
entry: RoomEntryWithService;
|
||
expanded: boolean;
|
||
inboxItems: InboxItem[];
|
||
locale: Locale;
|
||
onDraftChange: (value: string) => void;
|
||
onSendMessage: () => void;
|
||
onToggle: () => void;
|
||
pendingMessages?: Array<DashboardRoomActivity['messages'][number]>;
|
||
pinned?: boolean;
|
||
t: Messages;
|
||
}) {
|
||
const task = activity?.pairedTask ?? null;
|
||
const turn = task?.currentTurn ?? null;
|
||
const outputs = task?.outputs ?? [];
|
||
const latestOutput = outputs.at(-1) ?? null;
|
||
const messages = activity?.messages ?? [];
|
||
const isProcessing = entry.status === 'processing';
|
||
const liveTurnStart =
|
||
turn && turn.completedAt === null
|
||
? new Date(turn.createdAt).getTime()
|
||
: null;
|
||
const [tick, setTick] = useState(() => Date.now());
|
||
useEffect(() => {
|
||
if (!isProcessing || liveTurnStart === null) return;
|
||
const id = window.setInterval(() => setTick(Date.now()), 1000);
|
||
return () => window.clearInterval(id);
|
||
}, [isProcessing, liveTurnStart]);
|
||
const liveElapsedMs =
|
||
liveTurnStart === null ? null : Math.max(0, tick - liveTurnStart);
|
||
|
||
// Discord live-edit messages are stored with this 3-character invisible
|
||
// prefix (TASK_STATUS_MESSAGE_PREFIX). When paired_turns.progress_text
|
||
// hasn't been written yet (codex runner timing), fall back to scanning
|
||
// the messages table for the most recent prefixed message — but only
|
||
// ones from the current turn (timestamp >= turn.createdAt) and matching
|
||
// the current turn's role to avoid showing a stale reviewer status while
|
||
// owner is now speaking.
|
||
const TASK_STATUS_PREFIX = '';
|
||
const liveStatusFallback = (() => {
|
||
if (!isProcessing) return null;
|
||
if (!turn) return null;
|
||
if (turn.progressText && turn.progressText.trim()) return null;
|
||
const turnStartMs = turn.createdAt ? new Date(turn.createdAt).getTime() : 0;
|
||
const turnRole = senderRoleClass(turn.role); // role-owner / -reviewer / -arbiter / -human
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const m = messages[i];
|
||
if (!m.content.startsWith(TASK_STATUS_PREFIX)) continue;
|
||
const ts = m.timestamp ? new Date(m.timestamp).getTime() : 0;
|
||
if (ts < turnStartMs) continue;
|
||
const senderRole = senderRoleClass(m.senderName);
|
||
if (senderRole !== turnRole) continue;
|
||
const body = m.content.slice(TASK_STATUS_PREFIX.length);
|
||
const stripped = body.replace(/\n\n\d+[초smhMSH]?$/, '').trim();
|
||
return stripped || null;
|
||
}
|
||
return null;
|
||
})();
|
||
const liveProgressDisplay =
|
||
turn?.progressText && turn.progressText.trim()
|
||
? turn.progressText
|
||
: liveStatusFallback;
|
||
|
||
const messagesLen = messages.length;
|
||
const outputsLen = outputs.length;
|
||
const pendingLen = pendingMessages.length;
|
||
const wasNearBottomRef = useRef(true);
|
||
useEffect(() => {
|
||
const detail = document.querySelector(
|
||
'.rooms-detail',
|
||
) as HTMLElement | null;
|
||
if (!detail) return;
|
||
if (!wasNearBottomRef.current) return;
|
||
const run = () => {
|
||
detail.scrollTop = detail.scrollHeight;
|
||
};
|
||
requestAnimationFrame(() => requestAnimationFrame(run));
|
||
}, [entry.jid, messagesLen, outputsLen, pendingLen]);
|
||
useEffect(() => {
|
||
const detail = document.querySelector(
|
||
'.rooms-detail',
|
||
) as HTMLElement | null;
|
||
if (!detail) return;
|
||
const onScroll = () => {
|
||
const distance =
|
||
detail.scrollHeight - detail.scrollTop - detail.clientHeight;
|
||
wasNearBottomRef.current = distance < 80;
|
||
};
|
||
detail.addEventListener('scroll', onScroll, { passive: true });
|
||
return () => detail.removeEventListener('scroll', onScroll);
|
||
}, [entry.jid]);
|
||
|
||
const queueChips: string[] = [];
|
||
if (entry.pendingTasks > 0)
|
||
queueChips.push(`${entry.pendingTasks} ${t.rooms.tasks}`);
|
||
if (entry.pendingMessages) queueChips.push(t.rooms.queueWaitingMessages);
|
||
const lastUpdated = turn?.updatedAt ?? task?.updatedAt ?? null;
|
||
const lastWatcher =
|
||
[...messages].reverse().find(isWatcherRoomMessage) ?? null;
|
||
const watcherCount = messages.filter(isWatcherRoomMessage).length;
|
||
const watcherMessages = messages.filter(isWatcherRoomMessage);
|
||
const agentMessages = buildRoomThreadEntries({
|
||
messages,
|
||
outputs,
|
||
pendingMessages,
|
||
});
|
||
|
||
return (
|
||
<article
|
||
aria-expanded={pinned ? undefined : expanded}
|
||
className={`room-card-v2 status-${entry.status}${expanded ? ' is-expanded' : ''}${pinned ? ' is-pinned' : ''}`}
|
||
onClick={pinned ? undefined : onToggle}
|
||
onKeyDown={
|
||
pinned
|
||
? undefined
|
||
: (e) => {
|
||
if (e.target !== e.currentTarget) return;
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
onToggle();
|
||
}
|
||
}
|
||
}
|
||
role={pinned ? undefined : 'button'}
|
||
tabIndex={pinned ? undefined : 0}
|
||
>
|
||
<div className="room-card-head">
|
||
<span className={`room-pulse pulse-${entry.status}`}>
|
||
{isProcessing ? <span className="pulse-dot" /> : null}
|
||
</span>
|
||
<span className="room-title-block">
|
||
<strong>{entry.name}</strong>
|
||
<span className="room-sub">
|
||
<em>{entry.agentType}</em>
|
||
{inboxItems.length > 0 ? (
|
||
<span className="room-inbox-badges" aria-label={t.panels.inbox}>
|
||
{inboxItems.slice(0, 3).map((item) => (
|
||
<span
|
||
className={`room-inbox-pip sev-${item.severity}`}
|
||
key={item.id}
|
||
title={t.inbox.kinds[item.kind]}
|
||
>
|
||
{t.inbox.kinds[item.kind]}
|
||
</span>
|
||
))}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
</span>
|
||
{!isProcessing ? (
|
||
<span className={`room-status pill pill-${entry.status}`}>
|
||
{statusLabel(entry.status, t)}
|
||
</span>
|
||
) : (
|
||
<span />
|
||
)}
|
||
{!pinned ? (
|
||
<span className="room-toggle" aria-hidden>
|
||
{expanded ? '▾' : '▸'}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
{turn && !isProcessing ? (
|
||
<div className="room-current-turn">
|
||
<span>
|
||
<strong className={senderRoleClass(turn.role)}>{turn.role}</strong>
|
||
<em>{turn.intentKind}</em>
|
||
{turn.attemptNo > 1 ? (
|
||
<small>
|
||
{t.rooms.attempt} {turn.attemptNo}
|
||
</small>
|
||
) : null}
|
||
</span>
|
||
{task && task.roundTripCount > 0 ? (
|
||
<small>
|
||
· {t.rooms.round} {task.roundTripCount}
|
||
</small>
|
||
) : null}
|
||
{lastUpdated ? (
|
||
<small style={{ marginLeft: 'auto' }}>
|
||
{formatDate(lastUpdated, locale)}
|
||
</small>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{queueChips.length > 0 ||
|
||
(entry.elapsedMs && entry.elapsedMs > 0 && isProcessing) ? (
|
||
<div className="room-card-meta">
|
||
{queueChips.length > 0 ? (
|
||
<span className="meta-cell">
|
||
<strong>{queueChips.join(' · ')}</strong>
|
||
</span>
|
||
) : null}
|
||
{isProcessing && entry.elapsedMs ? (
|
||
<span className="meta-cell">
|
||
<strong>{formatDuration(entry.elapsedMs, t)}</strong>
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{!expanded && isProcessing && turn ? (
|
||
<div className="room-live">
|
||
<header>
|
||
<span className="live-dot" aria-hidden />
|
||
<span className="live-label">LIVE</span>
|
||
<strong className={senderRoleClass(turn.role)}>{turn.role}</strong>
|
||
<em>{turn.intentKind}</em>
|
||
<span className="live-state pill pill-processing">
|
||
{turn.state}
|
||
</span>
|
||
{turn.executorServiceId ? (
|
||
<span className="live-exec">{turn.executorServiceId}</span>
|
||
) : null}
|
||
{turn.attemptNo > 1 ? (
|
||
<span className="live-attempt">
|
||
{t.rooms.attempt} {turn.attemptNo}
|
||
</span>
|
||
) : null}
|
||
<time>
|
||
{formatDate(turn.progressUpdatedAt ?? turn.updatedAt, locale)}
|
||
</time>
|
||
</header>
|
||
{turn.progressText && turn.progressText.trim() ? (
|
||
<pre className="live-progress">{turn.progressText}</pre>
|
||
) : turn.activeRunId ? (
|
||
<code className="live-run">{turn.activeRunId}</code>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{!expanded && latestOutput && !isProcessing ? (
|
||
<div className="room-preview">
|
||
<ParsedBody text={latestOutput.outputText} truncate={140} />
|
||
</div>
|
||
) : null}
|
||
|
||
{!expanded && watcherCount > 0 && lastWatcher ? (
|
||
<div className="room-watcher-strip">
|
||
<span className="watcher-tag">워쳐 {watcherCount}</span>
|
||
<span className="watcher-line">
|
||
<strong className={senderRoleClass(lastWatcher.senderName)}>
|
||
{lastWatcher.senderName}
|
||
</strong>
|
||
<span>{lastWatcher.content.slice(0, 90)}</span>
|
||
</span>
|
||
<time>{formatDate(lastWatcher.timestamp, locale)}</time>
|
||
</div>
|
||
) : null}
|
||
|
||
{!expanded && !latestOutput && !isProcessing && !activityLoading ? (
|
||
<p className="room-empty">{t.rooms.noActivity}</p>
|
||
) : null}
|
||
|
||
{!expanded && activityLoading && !activity ? (
|
||
<p className="room-empty">{t.rooms.loadingActivity}</p>
|
||
) : null}
|
||
|
||
{expanded ? (
|
||
<div
|
||
className="room-expanded"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onKeyDown={(e) => e.stopPropagation()}
|
||
>
|
||
{turn?.lastError ? (
|
||
<div className="room-alert">
|
||
<strong>{t.rooms.error}</strong>
|
||
<ParsedBody text={turn.lastError} />
|
||
</div>
|
||
) : null}
|
||
|
||
<section className="room-section room-thread-section">
|
||
<header>
|
||
<h4>{t.rooms.activity}</h4>
|
||
<small>{agentMessages.length}</small>
|
||
</header>
|
||
{agentMessages.length === 0 && !(isProcessing && turn) ? (
|
||
<p className="room-empty">{t.rooms.noActivity}</p>
|
||
) : (
|
||
<ol className="room-timeline">
|
||
{agentMessages.map((entry) => {
|
||
const verdict = entry.verdict;
|
||
const verdictTone = verdict
|
||
? /fail|error/i.test(verdict)
|
||
? 'fail'
|
||
: /done|ok|pass/i.test(verdict)
|
||
? 'done'
|
||
: 'info'
|
||
: null;
|
||
const sectionClass = senderRoleClass(
|
||
entry.senderName,
|
||
).replace('role-', 'role-section-');
|
||
return (
|
||
<li
|
||
className={`room-timeline-item ${sectionClass}`}
|
||
key={entry.id}
|
||
>
|
||
<header className="room-timeline-header">
|
||
<span
|
||
className={`role-chip ${senderRoleClass(entry.senderName)}`}
|
||
>
|
||
{entry.senderName}
|
||
</span>
|
||
<time>{formatDate(entry.timestamp, locale)}</time>
|
||
{entry.turnNumber !== undefined ? (
|
||
<span className="parsed-marker parsed-marker-info">
|
||
#{entry.turnNumber}
|
||
</span>
|
||
) : null}
|
||
{verdict ? (
|
||
<span
|
||
className={`parsed-marker parsed-marker-${verdictTone}`}
|
||
>
|
||
{verdict}
|
||
</span>
|
||
) : null}
|
||
</header>
|
||
<div className="room-timeline-body">
|
||
<ParsedBody text={entry.content} />
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
{turn &&
|
||
turn.completedAt === null &&
|
||
(isProcessing || liveProgressDisplay) ? (
|
||
<li
|
||
className={`room-timeline-item ${senderRoleClass(turn.role).replace('role-', 'role-section-')} ${isProcessing ? 'room-timeline-live' : 'room-timeline-paused'}`}
|
||
>
|
||
<header className="room-timeline-header">
|
||
{isProcessing ? (
|
||
<span
|
||
className="live-dot live-dot-inline"
|
||
aria-hidden
|
||
/>
|
||
) : (
|
||
<span className="paused-dot" aria-hidden />
|
||
)}
|
||
<span
|
||
className={`role-chip ${senderRoleClass(turn.role)}`}
|
||
>
|
||
{turn.role}
|
||
</span>
|
||
<time>
|
||
{formatDate(
|
||
turn.progressUpdatedAt ?? turn.updatedAt,
|
||
locale,
|
||
)}
|
||
</time>
|
||
{liveElapsedMs !== null ? (
|
||
<span className="live-elapsed">
|
||
+{formatLiveElapsed(liveElapsedMs, t)}
|
||
</span>
|
||
) : null}
|
||
{!isProcessing ? (
|
||
<span className="live-label paused">중단됨</span>
|
||
) : null}
|
||
</header>
|
||
<div className="room-timeline-body">
|
||
{liveProgressDisplay ? (
|
||
<pre className="live-progress">
|
||
{liveProgressDisplay}
|
||
</pre>
|
||
) : (
|
||
<p className="room-empty">{t.rooms.loadingActivity}</p>
|
||
)}
|
||
</div>
|
||
</li>
|
||
) : null}
|
||
</ol>
|
||
)}
|
||
</section>
|
||
|
||
{watcherMessages.length > 0 ? (
|
||
<details className="room-watcher-fold">
|
||
<summary>
|
||
<span className="watcher-tag">워쳐</span>
|
||
<small>{watcherMessages.length}</small>
|
||
</summary>
|
||
<ul className="room-watcher-list">
|
||
{watcherMessages.map((message) => (
|
||
<li className="room-watcher-item" key={message.id}>
|
||
<header>
|
||
<strong className={senderRoleClass(message.senderName)}>
|
||
{message.senderName}
|
||
</strong>
|
||
<time>{formatDate(message.timestamp, locale)}</time>
|
||
</header>
|
||
<ParsedBody text={message.content} truncate={200} />
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</details>
|
||
) : null}
|
||
|
||
<section className="room-section room-compose-section">
|
||
<RoomMessageForm
|
||
busy={busy}
|
||
onChange={onDraftChange}
|
||
onSubmit={onSendMessage}
|
||
t={t}
|
||
value={draft}
|
||
/>
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function UsageQuotaMeter({
|
||
row,
|
||
rowName,
|
||
window,
|
||
t,
|
||
}: {
|
||
row: UsageRow;
|
||
rowName: string;
|
||
window: UsageLimitWindow;
|
||
t: Messages;
|
||
}) {
|
||
const remaining = usageWindowRemaining(row, window);
|
||
const reset = usageWindowReset(row, window);
|
||
const tightest = usageLimitWindow(row) === window;
|
||
const label = t.usage.quota[window];
|
||
|
||
return (
|
||
<div className={`usage-quota ${tightest ? 'usage-quota-tight' : ''}`}>
|
||
<div>
|
||
<span>{label}</span>
|
||
<strong>{remaining === null ? '-' : formatPct(remaining)}</strong>
|
||
</div>
|
||
<progress
|
||
aria-label={`${rowName} ${label} ${
|
||
remaining === null ? '-' : formatPct(remaining)
|
||
}`}
|
||
max={100}
|
||
value={remaining ?? 0}
|
||
/>
|
||
<small>{reset ? `${t.usage.reset} ${reset}` : t.usage.noReset}</small>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UsageSpeed({ row, t }: { row: UsageRow; t: Messages }) {
|
||
const rate = usageBurnRate(row);
|
||
const level = usageSpeedLevel(rate);
|
||
|
||
return (
|
||
<div className={`usage-speed usage-speed-${level}`}>
|
||
<span>{t.usage.speed}</span>
|
||
<strong>{formatUsageRate(rate)}</strong>
|
||
<small>{t.usage.speedLabel[level]}</small>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UsagePanel({
|
||
overview,
|
||
t,
|
||
}: {
|
||
overview: DashboardOverview;
|
||
t: Messages;
|
||
}) {
|
||
const rows = useMemo(
|
||
() =>
|
||
[...overview.usage.rows].sort((a, b) => {
|
||
if (usageActive(a) !== usageActive(b)) return usageActive(a) ? -1 : 1;
|
||
return usagePeak(b) - usagePeak(a);
|
||
}),
|
||
[overview.usage.rows],
|
||
);
|
||
const watched = rows.filter((row) => usagePeak(row) >= 65).length;
|
||
|
||
if (rows.length === 0) {
|
||
return <EmptyState>{t.usage.empty}</EmptyState>;
|
||
}
|
||
|
||
const activeRows = rows.filter(usageActive);
|
||
const focusRows = activeRows.length > 0 ? activeRows : rows.slice(0, 1);
|
||
const focusLabel = activeRows.length > 0 ? t.usage.current : t.usage.tightest;
|
||
const focusValue = focusRows
|
||
.map((row) => {
|
||
const { account } = usageNameParts(row);
|
||
const h5Remaining = usageWindowRemaining(row, 'h5');
|
||
const d7Remaining = usageWindowRemaining(row, 'd7');
|
||
return `${account} ${t.usage.quota.h5} ${
|
||
h5Remaining === null ? '-' : formatPct(h5Remaining)
|
||
} · ${t.usage.quota.d7} ${
|
||
d7Remaining === null ? '-' : formatPct(d7Remaining)
|
||
}`;
|
||
})
|
||
.join(' · ');
|
||
const groups = [
|
||
{
|
||
key: 'primary' as const,
|
||
label: t.usage.groupPrimary,
|
||
rows: rows.filter((row) => usageGroup(row) === 'primary'),
|
||
},
|
||
{
|
||
key: 'codex' as const,
|
||
label: t.usage.groupCodex,
|
||
rows: rows.filter((row) => usageGroup(row) === 'codex'),
|
||
},
|
||
].filter((group) => group.rows.length > 0);
|
||
|
||
return (
|
||
<div className="usage-dashboard">
|
||
<div className="usage-summary">
|
||
<div>
|
||
<span>{focusLabel}</span>
|
||
<strong>{focusValue}</strong>
|
||
</div>
|
||
<div>
|
||
<span>{t.usage.watch}</span>
|
||
<strong>{watched}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="usage-matrix" role="table" aria-label={t.panels.usage}>
|
||
<div className="usage-matrix-head" role="row">
|
||
<span>{t.usage.usage}</span>
|
||
<span>{t.usage.quota.h5}</span>
|
||
<span>{t.usage.quota.d7}</span>
|
||
<span>{t.usage.speed}</span>
|
||
</div>
|
||
{groups.map((group) => (
|
||
<div className="usage-group" key={group.key} role="rowgroup">
|
||
<div className="usage-group-label" role="row">
|
||
<span>{group.label}</span>
|
||
</div>
|
||
{group.rows.map((row) => {
|
||
const risk = usageRiskLevel(row);
|
||
const { account, plan } = usageNameParts(row);
|
||
return (
|
||
<section className={`usage-row usage-${risk}`} key={row.name}>
|
||
<div className="usage-account">
|
||
<strong>{account}</strong>
|
||
<div>
|
||
{usageActive(row) ? (
|
||
<span className="pill pill-info">{t.usage.inUse}</span>
|
||
) : null}
|
||
{plan ? <span className="mono-chip">{plan}</span> : null}
|
||
{usageLimited(row) || risk !== 'ok' ? (
|
||
<span className={`pill pill-${risk}`}>
|
||
{t.usage.risk[risk]}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<UsageQuotaMeter
|
||
row={row}
|
||
rowName={account}
|
||
window="h5"
|
||
t={t}
|
||
/>
|
||
<UsageQuotaMeter
|
||
row={row}
|
||
rowName={account}
|
||
window="d7"
|
||
t={t}
|
||
/>
|
||
<UsageSpeed row={row} t={t} />
|
||
</section>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TaskPanel({
|
||
tasks,
|
||
rooms,
|
||
locale,
|
||
onTaskAction,
|
||
onTaskCreate,
|
||
onTaskUpdate,
|
||
taskActionKey,
|
||
t,
|
||
}: {
|
||
tasks: DashboardTask[];
|
||
rooms: RoomOption[];
|
||
locale: Locale;
|
||
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
|
||
onTaskCreate: (input: CreateScheduledTaskInput) => void;
|
||
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
|
||
taskActionKey: TaskActionKey | null;
|
||
t: Messages;
|
||
}) {
|
||
const taskGroups = useMemo(() => {
|
||
const groups: Record<TaskGroupKey, DashboardTask[]> = {
|
||
watchers: [],
|
||
scheduled: [],
|
||
paused: [],
|
||
completed: [],
|
||
};
|
||
|
||
for (const task of tasks) {
|
||
groups[taskGroupKey(task)].push(task);
|
||
}
|
||
|
||
for (const groupTasks of Object.values(groups)) {
|
||
groupTasks.sort((a, b) =>
|
||
(a.nextRun ?? a.lastRun ?? a.createdAt).localeCompare(
|
||
b.nextRun ?? b.lastRun ?? b.createdAt,
|
||
),
|
||
);
|
||
}
|
||
|
||
return [
|
||
{ key: 'watchers' as const, tasks: groups.watchers },
|
||
{ key: 'scheduled' as const, tasks: groups.scheduled },
|
||
{ key: 'paused' as const, tasks: groups.paused },
|
||
{ key: 'completed' as const, tasks: groups.completed },
|
||
];
|
||
}, [tasks]);
|
||
|
||
return (
|
||
<div className="task-board" aria-label={t.tasks.cardsAria}>
|
||
<form
|
||
className="task-create-form"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
const form = new FormData(event.currentTarget);
|
||
const input = readTaskForm(form, true);
|
||
if (!input) return;
|
||
onTaskCreate(input);
|
||
event.currentTarget.reset();
|
||
}}
|
||
>
|
||
<div className="task-create-head">
|
||
<span className="eyebrow">{t.tasks.createTitle}</span>
|
||
<strong>{t.tasks.createSubtitle}</strong>
|
||
</div>
|
||
<label>
|
||
<span>{t.tasks.room}</span>
|
||
<select name="roomJid" required>
|
||
<option value="">{t.tasks.selectRoom}</option>
|
||
{rooms.map((room) => (
|
||
<option key={room.jid} value={room.jid}>
|
||
{room.name} · {room.folder}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="task-form-wide">
|
||
<span>{t.tasks.prompt}</span>
|
||
<textarea
|
||
name="prompt"
|
||
placeholder={t.tasks.promptPlaceholder}
|
||
required
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>{t.tasks.scheduleType}</span>
|
||
<select name="scheduleType" required>
|
||
<option value="once">{t.tasks.scheduleTypes.once}</option>
|
||
<option value="interval">{t.tasks.scheduleTypes.interval}</option>
|
||
<option value="cron">{t.tasks.scheduleTypes.cron}</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>{t.tasks.scheduleValue}</span>
|
||
<input
|
||
name="scheduleValue"
|
||
placeholder={t.tasks.scheduleValueHint}
|
||
required
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>{t.tasks.context}</span>
|
||
<select name="contextMode" required>
|
||
<option value="isolated">{t.tasks.contextModes.isolated}</option>
|
||
<option value="group">{t.tasks.contextModes.group}</option>
|
||
</select>
|
||
</label>
|
||
<button disabled={taskActionKey === 'create'} type="submit">
|
||
{taskActionKey === 'create'
|
||
? t.tasks.actions.busy
|
||
: t.tasks.actions.create}
|
||
</button>
|
||
</form>
|
||
|
||
{tasks.length === 0 ? <EmptyState>{t.tasks.empty}</EmptyState> : null}
|
||
{taskGroups.map((group) => {
|
||
const label = t.tasks.groups[group.key];
|
||
const groupHead = (
|
||
<div className="task-group-head">
|
||
<div>
|
||
<span className="eyebrow">{label}</span>
|
||
<strong>
|
||
{group.tasks.length} {t.tasks.count}
|
||
</strong>
|
||
</div>
|
||
<span className={`pill pill-${group.key}`}>
|
||
{group.tasks.length}
|
||
</span>
|
||
</div>
|
||
);
|
||
const groupBody =
|
||
group.tasks.length === 0 ? (
|
||
<div className="task-group-empty">{t.tasks.groupEmpty}</div>
|
||
) : (
|
||
<div className="task-list">
|
||
{group.tasks.map((task) => {
|
||
const resultTone = taskResultTone(task);
|
||
const lastResult = safePreview(
|
||
task.lastResult,
|
||
t.tasks.noResult,
|
||
);
|
||
const taskActions = taskActionsFor(task);
|
||
return (
|
||
<article
|
||
className={`task-card task-card-${group.key}`}
|
||
key={task.id}
|
||
>
|
||
<div className="task-card-main">
|
||
<div className="task-title">
|
||
<strong>{taskDisplayName(task, t)}</strong>
|
||
<span className="mono-chip">{task.groupFolder}</span>
|
||
</div>
|
||
<div className="task-status-line">
|
||
<span className={`pill pill-${task.status}`}>
|
||
{statusLabel(task.status, t)}
|
||
</span>
|
||
{task.ciProvider ? (
|
||
<span className="task-provider">
|
||
{task.ciProvider}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{taskActions.length > 0 ? (
|
||
<div className="task-actions">
|
||
{taskActions.map((action) => {
|
||
const actionKey: TaskActionKey = `${task.id}:${action}`;
|
||
const busy = taskActionKey === actionKey;
|
||
return (
|
||
<button
|
||
aria-busy={busy || undefined}
|
||
className={`task-action task-action-${action}${busy ? ' is-busy' : ''}`}
|
||
disabled={busy}
|
||
key={action}
|
||
onClick={() => onTaskAction(task, action)}
|
||
type="button"
|
||
>
|
||
{busy
|
||
? t.tasks.actions.busy
|
||
: t.tasks.actions[action]}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="task-time-grid">
|
||
<span>
|
||
<small>{t.tasks.next}</small>
|
||
<strong>{formatTaskDate(task.nextRun, locale)}</strong>
|
||
<em>{formatRelativeDate(task.nextRun, locale, t)}</em>
|
||
</span>
|
||
<span>
|
||
<small>{t.tasks.last}</small>
|
||
<strong>{formatTaskDate(task.lastRun, locale)}</strong>
|
||
<em>{formatRelativeDate(task.lastRun, locale, t)}</em>
|
||
</span>
|
||
<span>
|
||
<small>{t.tasks.schedule}</small>
|
||
<strong>{task.scheduleType}</strong>
|
||
<em>{task.scheduleValue}</em>
|
||
</span>
|
||
</div>
|
||
|
||
{task.suspendedUntil ? (
|
||
<div className="task-suspended">
|
||
<span>{t.tasks.suspendedUntil}</span>
|
||
<strong>
|
||
{formatTaskDate(task.suspendedUntil, locale)}
|
||
</strong>
|
||
<em>
|
||
{formatRelativeDate(task.suspendedUntil, locale, t)}
|
||
</em>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className={`task-result result-${resultTone}`}>
|
||
<span>
|
||
{resultTone === 'fail'
|
||
? t.tasks.resultFail
|
||
: resultTone === 'ok'
|
||
? t.tasks.resultOk
|
||
: t.tasks.result}
|
||
</span>
|
||
<strong>{lastResult}</strong>
|
||
</div>
|
||
|
||
<details className="task-prompt">
|
||
<summary>{t.tasks.prompt}</summary>
|
||
<p>
|
||
{safePreview(task.promptPreview, t.tasks.emptyPrompt)}
|
||
</p>
|
||
<small>
|
||
{task.id} · {task.contextMode} · {task.promptLength}{' '}
|
||
{t.units.chars}
|
||
</small>
|
||
</details>
|
||
|
||
{!task.isWatcher && task.status !== 'completed' ? (
|
||
<details className="task-edit">
|
||
<summary>{t.tasks.actions.edit}</summary>
|
||
<form
|
||
className="task-edit-form"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
const form = new FormData(event.currentTarget);
|
||
const input = readTaskForm(form, false);
|
||
if (!input) return;
|
||
onTaskUpdate(task, input);
|
||
}}
|
||
>
|
||
<label className="task-form-wide">
|
||
<span>{t.tasks.prompt}</span>
|
||
<textarea
|
||
name="prompt"
|
||
placeholder={t.tasks.editPromptPlaceholder}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>{t.tasks.scheduleType}</span>
|
||
<select
|
||
name="scheduleType"
|
||
defaultValue={task.scheduleType}
|
||
required
|
||
>
|
||
<option value="once">
|
||
{t.tasks.scheduleTypes.once}
|
||
</option>
|
||
<option value="interval">
|
||
{t.tasks.scheduleTypes.interval}
|
||
</option>
|
||
<option value="cron">
|
||
{t.tasks.scheduleTypes.cron}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>{t.tasks.scheduleValue}</span>
|
||
<input
|
||
name="scheduleValue"
|
||
defaultValue={task.scheduleValue}
|
||
required
|
||
/>
|
||
</label>
|
||
<button
|
||
disabled={taskActionKey === `${task.id}:edit`}
|
||
type="submit"
|
||
>
|
||
{taskActionKey === `${task.id}:edit`
|
||
? t.tasks.actions.busy
|
||
: t.tasks.actions.save}
|
||
</button>
|
||
</form>
|
||
</details>
|
||
) : null}
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
|
||
if (group.key === 'completed') {
|
||
return (
|
||
<details
|
||
className="task-group task-group-completed"
|
||
key={group.key}
|
||
>
|
||
<summary className="task-group-head">
|
||
<div>
|
||
<span className="eyebrow">{label}</span>
|
||
<strong>
|
||
{group.tasks.length} {t.tasks.count}
|
||
</strong>
|
||
</div>
|
||
<span className={`pill pill-${group.key}`}>
|
||
{group.tasks.length}
|
||
</span>
|
||
</summary>
|
||
{groupBody}
|
||
</details>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<section
|
||
className={`task-group task-group-${group.key}`}
|
||
key={group.key}
|
||
>
|
||
{groupHead}
|
||
{groupBody}
|
||
</section>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DashboardErrorCard({
|
||
error,
|
||
onRetry,
|
||
refreshing,
|
||
t,
|
||
}: {
|
||
error: string;
|
||
onRetry: () => void;
|
||
refreshing: boolean;
|
||
t: Messages;
|
||
}) {
|
||
return (
|
||
<section className="error-card" role="alert" aria-live="polite">
|
||
<span>
|
||
<strong>{t.error.api}</strong>
|
||
<small>{humanizeError(error, t)}</small>
|
||
</span>
|
||
<button disabled={refreshing} onClick={onRetry}>
|
||
{t.actions.retry}
|
||
</button>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function App() {
|
||
const [data, setData] = useState<DashboardState | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [activeView, setActiveView] = useState<DashboardView>(readViewFromHash);
|
||
const [locale, setLocale] = useState<Locale>(readInitialLocale);
|
||
const [online, setOnline] = useState(() =>
|
||
typeof navigator === 'undefined' ? true : navigator.onLine,
|
||
);
|
||
const [offlineReady, setOfflineReady] = useState(false);
|
||
const [installPrompt, setInstallPrompt] =
|
||
useState<BeforeInstallPromptEvent | null>(null);
|
||
const [installed, setInstalled] = useState(isStandaloneDisplay);
|
||
const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>(
|
||
null,
|
||
);
|
||
const [inboxActionKey, setInboxActionKey] = useState<InboxActionKey | null>(
|
||
null,
|
||
);
|
||
const [serviceActionKey, setServiceActionKey] =
|
||
useState<ServiceActionKey | null>(null);
|
||
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
|
||
const [selectedRoomJid, setSelectedRoomJid] = useState<string | null>(null);
|
||
const {
|
||
refreshRoom: refreshRoomActivity,
|
||
roomActivity,
|
||
roomActivityLoading,
|
||
} = useSelectedRoomActivity({
|
||
active: activeView === 'rooms',
|
||
selectedRoomJid: selectedRoomJid,
|
||
});
|
||
const [pendingMessages, setPendingMessages] = useState<
|
||
Record<string, Array<DashboardRoomActivity['messages'][number]>>
|
||
>({});
|
||
const [nickname, setNicknameState] = useState<string>(() => {
|
||
if (typeof window === 'undefined') return '';
|
||
return window.localStorage.getItem('ejclaw-nickname') ?? '';
|
||
});
|
||
function setNickname(next: string) {
|
||
const trimmed = next.trim().slice(0, 32);
|
||
setNicknameState(trimmed);
|
||
if (typeof window !== 'undefined') {
|
||
if (trimmed) window.localStorage.setItem('ejclaw-nickname', trimmed);
|
||
else window.localStorage.removeItem('ejclaw-nickname');
|
||
}
|
||
}
|
||
const t = messages[locale];
|
||
const secureContext =
|
||
typeof window === 'undefined' ? true : window.isSecureContext;
|
||
|
||
function setDashboardLocale(nextLocale: Locale) {
|
||
setLocale(nextLocale);
|
||
window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale);
|
||
}
|
||
|
||
function navigateToView(view: DashboardView) {
|
||
setActiveView(view);
|
||
if (window.location.hash !== `#/${view}`) {
|
||
window.location.hash = `/${view}`;
|
||
}
|
||
}
|
||
|
||
async function refresh(showSpinner = false) {
|
||
if (showSpinner) setRefreshing(true);
|
||
try {
|
||
const nextData = await fetchDashboardData();
|
||
setData(nextData);
|
||
setError(null);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setLoading(false);
|
||
setRefreshing(false);
|
||
}
|
||
}
|
||
|
||
async function handleTaskAction(
|
||
task: DashboardTask,
|
||
action: DashboardTaskAction,
|
||
) {
|
||
if (action === 'cancel' && !window.confirm(t.tasks.actions.confirmCancel)) {
|
||
return;
|
||
}
|
||
|
||
const actionKey: TaskActionKey = `${task.id}:${action}`;
|
||
setTaskActionKey(actionKey);
|
||
try {
|
||
await runScheduledTaskAction(task.id, action);
|
||
await refresh(false);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setTaskActionKey(null);
|
||
}
|
||
}
|
||
|
||
async function handleTaskCreate(input: CreateScheduledTaskInput) {
|
||
setTaskActionKey('create');
|
||
try {
|
||
await createScheduledTask({ ...input, requestId: makeClientRequestId() });
|
||
await refresh(false);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setTaskActionKey(null);
|
||
}
|
||
}
|
||
|
||
async function handleTaskUpdate(
|
||
task: DashboardTask,
|
||
input: UpdateScheduledTaskInput,
|
||
) {
|
||
const actionKey: TaskActionKey = `${task.id}:edit`;
|
||
setTaskActionKey(actionKey);
|
||
try {
|
||
await updateScheduledTask(task.id, input);
|
||
await refresh(false);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setTaskActionKey(null);
|
||
}
|
||
}
|
||
|
||
async function handleInboxAction(
|
||
item: InboxItem,
|
||
action: DashboardInboxAction,
|
||
) {
|
||
if (
|
||
action === 'decline' &&
|
||
typeof window !== 'undefined' &&
|
||
!window.confirm(t.inbox.actions.confirmDecline)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const actionKey: InboxActionKey = `${item.id}:${action}`;
|
||
setInboxActionKey(actionKey);
|
||
try {
|
||
await runInboxAction(item.id, action, {
|
||
lastOccurredAt: item.lastOccurredAt,
|
||
requestId: makeClientRequestId(),
|
||
});
|
||
await refresh(false);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setInboxActionKey(null);
|
||
}
|
||
}
|
||
|
||
async function handleServiceRestart() {
|
||
if (
|
||
typeof window !== 'undefined' &&
|
||
!window.confirm(t.health.confirmRestart)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
setServiceActionKey('stack:restart');
|
||
try {
|
||
await runServiceAction('stack', 'restart', {
|
||
requestId: makeClientRequestId(),
|
||
});
|
||
await refresh(false);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setServiceActionKey(null);
|
||
}
|
||
}
|
||
|
||
async function handleRoomMessage(
|
||
roomJid: string,
|
||
text: string,
|
||
requestId: string,
|
||
) {
|
||
setRoomMessageKey(roomJid);
|
||
const optimisticId = `opt:${requestId}`;
|
||
const displayName = nickname || 'Web Dashboard';
|
||
const optimisticMsg = {
|
||
id: optimisticId,
|
||
sender: 'me',
|
||
senderName: displayName,
|
||
content: text,
|
||
timestamp: new Date().toISOString(),
|
||
isFromMe: true,
|
||
isBotMessage: false,
|
||
sourceKind: 'human' as const,
|
||
};
|
||
setPendingMessages((prev) => ({
|
||
...prev,
|
||
[roomJid]: [...(prev[roomJid] ?? []), optimisticMsg],
|
||
}));
|
||
try {
|
||
await sendRoomMessage(roomJid, text, requestId, nickname || null);
|
||
try {
|
||
await refreshRoomActivity(roomJid);
|
||
} catch {
|
||
/* refresh will retry on next poll */
|
||
}
|
||
void refresh(false);
|
||
return true;
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
setPendingMessages((prev) => {
|
||
const list = prev[roomJid];
|
||
if (!list) return prev;
|
||
const next = list.filter((m) => m.id !== optimisticId);
|
||
if (next.length === 0) {
|
||
const { [roomJid]: _drop, ...rest } = prev;
|
||
void _drop;
|
||
return rest;
|
||
}
|
||
return { ...prev, [roomJid]: next };
|
||
});
|
||
return false;
|
||
} finally {
|
||
setRoomMessageKey(null);
|
||
}
|
||
}
|
||
|
||
async function handleInstallApp() {
|
||
if (!installPrompt) return;
|
||
await installPrompt.prompt();
|
||
await installPrompt.userChoice;
|
||
setInstallPrompt(null);
|
||
setInstalled(isStandaloneDisplay());
|
||
}
|
||
|
||
useEffect(() => {
|
||
document.documentElement.lang = localeTags[locale];
|
||
}, [locale]);
|
||
|
||
useEffect(() => {
|
||
const debug =
|
||
typeof window !== 'undefined' &&
|
||
/[?&]debug=1/.test(window.location.search);
|
||
document.body.classList.toggle('debug-outlines', debug);
|
||
if (
|
||
typeof window !== 'undefined' &&
|
||
/[?&]measure=1/.test(window.location.search)
|
||
) {
|
||
const tick = () => {
|
||
const sels = [
|
||
'.rooms-detail .room-card-v2',
|
||
'.room-card-head',
|
||
'.room-thread-section',
|
||
'.room-section.room-compose-section',
|
||
];
|
||
const out = sels
|
||
.map((s) => {
|
||
const el = document.querySelector(s);
|
||
if (!el) return `${s}: NOT FOUND`;
|
||
const r = el.getBoundingClientRect();
|
||
const cs = getComputedStyle(el);
|
||
return `${s}:\n x=${Math.round(r.x)} w=${Math.round(r.width)}\n display=${cs.display} pl=${cs.paddingLeft} ml=${cs.marginLeft}`;
|
||
})
|
||
.join('\n');
|
||
let pre = document.getElementById('__measure');
|
||
if (!pre) {
|
||
pre = document.createElement('pre');
|
||
pre.id = '__measure';
|
||
pre.style.cssText =
|
||
'position:fixed;top:0;left:0;background:#fff;color:#000;font:11px monospace;padding:8px;z-index:99999;max-width:520px;white-space:pre;line-height:1.4;';
|
||
document.body.appendChild(pre);
|
||
}
|
||
pre.textContent = out;
|
||
};
|
||
const id = window.setTimeout(tick, 1500);
|
||
return () => window.clearTimeout(id);
|
||
}
|
||
return undefined;
|
||
});
|
||
|
||
useEffect(() => {
|
||
function handleOnline() {
|
||
setOnline(true);
|
||
}
|
||
|
||
function handleOffline() {
|
||
setOnline(false);
|
||
}
|
||
|
||
window.addEventListener('online', handleOnline);
|
||
window.addEventListener('offline', handleOffline);
|
||
return () => {
|
||
window.removeEventListener('online', handleOnline);
|
||
window.removeEventListener('offline', handleOffline);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!import.meta.env.PROD || !canUsePwaCore()) {
|
||
setOfflineReady(false);
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
void navigator.serviceWorker
|
||
.register('/sw.js')
|
||
.then((registration) => {
|
||
if (!cancelled) {
|
||
setOfflineReady(
|
||
Boolean(
|
||
registration.active ||
|
||
registration.waiting ||
|
||
registration.installing,
|
||
),
|
||
);
|
||
}
|
||
return navigator.serviceWorker.ready;
|
||
})
|
||
.then(() => {
|
||
if (!cancelled) setOfflineReady(true);
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setOfflineReady(false);
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
function handleBeforeInstallPrompt(event: Event) {
|
||
event.preventDefault();
|
||
setInstallPrompt(event as BeforeInstallPromptEvent);
|
||
}
|
||
|
||
function handleInstalled() {
|
||
setInstalled(true);
|
||
setInstallPrompt(null);
|
||
}
|
||
|
||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||
window.addEventListener('appinstalled', handleInstalled);
|
||
return () => {
|
||
window.removeEventListener(
|
||
'beforeinstallprompt',
|
||
handleBeforeInstallPrompt,
|
||
);
|
||
window.removeEventListener('appinstalled', handleInstalled);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
function handleHashChange() {
|
||
setActiveView(readViewFromHash());
|
||
}
|
||
|
||
handleHashChange();
|
||
window.addEventListener('hashchange', handleHashChange);
|
||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
const id = window.setInterval(() => {
|
||
void refresh();
|
||
}, REFRESH_INTERVAL_MS);
|
||
return () => window.clearInterval(id);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
setPendingMessages((prev) => {
|
||
const next: typeof prev = {};
|
||
let changed = false;
|
||
for (const [jid, list] of Object.entries(prev)) {
|
||
const fetched = roomActivity[jid]?.messages ?? [];
|
||
const confirmedKeys = new Set(
|
||
fetched.map((m) => `${m.senderName}${m.content}`),
|
||
);
|
||
const remaining = list.filter(
|
||
(m) => !confirmedKeys.has(`${m.senderName}${m.content}`),
|
||
);
|
||
if (remaining.length !== list.length) changed = true;
|
||
if (remaining.length > 0) next[jid] = remaining;
|
||
}
|
||
return changed ? next : prev;
|
||
});
|
||
}, [roomActivity]);
|
||
|
||
useEffect(() => {
|
||
if (!drawerOpen) return;
|
||
|
||
function handleKeyDown(event: KeyboardEvent) {
|
||
if (event.key === 'Escape') setDrawerOpen(false);
|
||
}
|
||
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||
}, [drawerOpen]);
|
||
|
||
if (loading && !data) {
|
||
return <LoadingSkeleton t={t} />;
|
||
}
|
||
|
||
const roomOptions = data ? buildRoomOptions(data.snapshots) : [];
|
||
const freshness = dashboardFreshness(online, data?.overview.generatedAt);
|
||
const canInstall = Boolean(secureContext && installPrompt && !installed);
|
||
|
||
return (
|
||
<div className="shell">
|
||
<SideRail
|
||
activeView={activeView}
|
||
canInstall={canInstall}
|
||
data={data}
|
||
installed={installed}
|
||
offlineReady={offlineReady}
|
||
online={online}
|
||
onInstall={() => void handleInstallApp()}
|
||
onNavigate={navigateToView}
|
||
onRefresh={() => void refresh(true)}
|
||
refreshing={refreshing}
|
||
secureContext={secureContext}
|
||
t={t}
|
||
/>
|
||
<main className="dashboard-content">
|
||
<SectionNav
|
||
activeView={activeView}
|
||
canInstall={canInstall}
|
||
data={data}
|
||
drawerOpen={drawerOpen}
|
||
freshness={freshness}
|
||
freshnessText={freshnessLabel(freshness, t)}
|
||
installed={installed}
|
||
onCloseDrawer={() => setDrawerOpen(false)}
|
||
onInstall={() => void handleInstallApp()}
|
||
onNavigate={navigateToView}
|
||
onOpenDrawer={() => setDrawerOpen(true)}
|
||
offlineReady={offlineReady}
|
||
onRefresh={() => void refresh(true)}
|
||
online={online}
|
||
refreshing={refreshing}
|
||
secureContext={secureContext}
|
||
t={t}
|
||
/>
|
||
|
||
{error ? (
|
||
<DashboardErrorCard
|
||
error={error}
|
||
onRetry={() => void refresh(true)}
|
||
refreshing={refreshing}
|
||
t={t}
|
||
/>
|
||
) : null}
|
||
|
||
{data ? (
|
||
<div className={`view-stack view-${activeView}`}>
|
||
{activeView === 'usage' ? (
|
||
<>
|
||
<section className="panel usage-first" id="usage">
|
||
<div className="panel-title">
|
||
<h2>{t.panels.usage}</h2>
|
||
<span>{t.panels.usageWindow}</span>
|
||
</div>
|
||
<UsagePanel overview={data.overview} t={t} />
|
||
</section>
|
||
<ControlRail
|
||
canInstall={canInstall}
|
||
data={data}
|
||
installed={installed}
|
||
locale={locale}
|
||
offlineReady={offlineReady}
|
||
online={online}
|
||
onInstall={() => void handleInstallApp()}
|
||
secureContext={secureContext}
|
||
t={t}
|
||
/>
|
||
</>
|
||
) : null}
|
||
|
||
{activeView === 'inbox' ? (
|
||
<section className="panel view-panel" id="inbox">
|
||
<div className="panel-title">
|
||
<h2>{t.panels.inbox}</h2>
|
||
<span>{t.panels.inboxQueue}</span>
|
||
</div>
|
||
<InboxPanel
|
||
inboxActionKey={inboxActionKey}
|
||
locale={locale}
|
||
onInboxAction={(item, action) =>
|
||
void handleInboxAction(item, action)
|
||
}
|
||
onTaskAction={(task, action) =>
|
||
void handleTaskAction(task, action)
|
||
}
|
||
overview={data.overview}
|
||
taskActionKey={taskActionKey}
|
||
tasks={data.tasks}
|
||
t={t}
|
||
/>
|
||
</section>
|
||
) : null}
|
||
|
||
{activeView === 'health' ? (
|
||
<section className="panel view-panel" id="health">
|
||
<div className="panel-title">
|
||
<h2>{t.panels.health}</h2>
|
||
<span>{t.panels.healthSignals}</span>
|
||
</div>
|
||
<HealthPanel
|
||
data={data}
|
||
locale={locale}
|
||
onRestartStack={() => void handleServiceRestart()}
|
||
serviceActionKey={serviceActionKey}
|
||
t={t}
|
||
/>
|
||
</section>
|
||
) : null}
|
||
|
||
{activeView === 'rooms' ? (
|
||
<section className="panel view-panel" id="rooms">
|
||
<div className="panel-title">
|
||
<h2>{t.panels.rooms}</h2>
|
||
<span>{t.panels.queue}</span>
|
||
</div>
|
||
<RoomBoardV2
|
||
inbox={data.overview.inbox}
|
||
onSendRoomMessage={handleRoomMessage}
|
||
pendingMessages={pendingMessages}
|
||
roomActivity={roomActivity}
|
||
roomActivityLoading={roomActivityLoading}
|
||
roomMessageKey={roomMessageKey}
|
||
locale={locale}
|
||
onSelectedJidChange={setSelectedRoomJid}
|
||
selectedJid={selectedRoomJid}
|
||
snapshots={data.snapshots}
|
||
t={t}
|
||
/>
|
||
</section>
|
||
) : null}
|
||
|
||
{activeView === 'scheduled' ? (
|
||
<section className="panel view-panel" id="scheduled">
|
||
<div className="panel-title">
|
||
<h2>{t.panels.scheduled}</h2>
|
||
<span>{t.panels.promptPreviews}</span>
|
||
</div>
|
||
<TaskPanel
|
||
locale={locale}
|
||
onTaskAction={(task, action) =>
|
||
void handleTaskAction(task, action)
|
||
}
|
||
onTaskCreate={(input) => void handleTaskCreate(input)}
|
||
onTaskUpdate={(task, input) =>
|
||
void handleTaskUpdate(task, input)
|
||
}
|
||
rooms={roomOptions}
|
||
taskActionKey={taskActionKey}
|
||
tasks={data.tasks}
|
||
t={t}
|
||
/>
|
||
</section>
|
||
) : null}
|
||
|
||
{activeView === 'settings' ? (
|
||
<section className="panel view-panel" id="settings">
|
||
<div className="panel-title">
|
||
<h2>{t.settings.title}</h2>
|
||
</div>
|
||
<SettingsPanel
|
||
locale={locale}
|
||
nickname={nickname}
|
||
onLocaleChange={setDashboardLocale}
|
||
onNicknameChange={setNickname}
|
||
onRestartStack={() => void handleServiceRestart()}
|
||
t={t}
|
||
/>
|
||
</section>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|