diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx
index a8cc36a..ba961af 100644
--- a/apps/dashboard/src/App.tsx
+++ b/apps/dashboard/src/App.tsx
@@ -1,6 +1,21 @@
-import { useEffect, useMemo, useState, type ReactNode } from 'react';
+import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import {
+ Activity,
+ Clock,
+ Download,
+ Gauge,
+ Inbox as InboxIcon,
+ MessageSquare,
+ RefreshCw,
+ Send,
+ Settings,
+} from 'lucide-react';
import {
+ type ClaudeAccountSummary,
+ type CodexAccountSummary,
type CreateScheduledTaskInput,
type DashboardInboxAction,
type DashboardRoomActivity,
@@ -9,15 +24,22 @@ import {
type DashboardTaskAction,
type DashboardOverview,
type DashboardTask,
+ type ModelConfigSnapshot,
+ type ModelRoleConfig,
type UpdateScheduledTaskInput,
type StatusSnapshot,
+ addClaudeAccount,
createScheduledTask,
+ deleteAccount,
+ fetchAccounts,
fetchDashboardData,
- fetchRoomTimeline,
+ fetchModelConfig,
+ fetchRoomsTimelineBatch,
runInboxAction,
runServiceAction,
runScheduledTaskAction,
sendRoomMessage,
+ updateModels,
updateScheduledTask,
} from './api';
import {
@@ -45,7 +67,13 @@ type InboxItem = DashboardOverview['inbox'][number];
type RiskLevel = 'ok' | 'warn' | 'critical';
type UsageGroup = 'primary' | 'codex';
type UsageLimitWindow = 'h5' | 'd7';
-type DashboardView = 'usage' | 'inbox' | 'health' | 'rooms' | 'scheduled';
+type DashboardView =
+ | 'usage'
+ | 'inbox'
+ | 'health'
+ | 'rooms'
+ | 'scheduled'
+ | 'settings';
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
type TaskResultTone = 'ok' | 'fail' | 'none';
type TaskActionKey =
@@ -71,7 +99,7 @@ interface RoomOption {
const REFRESH_INTERVAL_MS = 15_000;
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2';
-const DEFAULT_VIEW: DashboardView = 'inbox';
+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;
@@ -88,7 +116,8 @@ function isDashboardView(
value === 'inbox' ||
value === 'health' ||
value === 'rooms' ||
- value === 'scheduled'
+ value === 'scheduled' ||
+ value === 'settings'
);
}
@@ -117,16 +146,457 @@ function readInitialLocale(): Locale {
return 'en';
}
+function humanizeError(raw: string, t: Messages): string {
+ const lower = raw.toLowerCase();
+ if (/abort|timeout|timed out/.test(lower)) return t.error.timeout;
+ if (/network|fetch failed|failed to fetch|networkerror|offline/.test(lower))
+ return t.error.network;
+ const statusMatch = lower.match(/\b(\d{3})\b/);
+ if (statusMatch) {
+ const code = Number(statusMatch[1]);
+ if (code === 401 || code === 403) return t.error.auth;
+ if (code === 404) return t.error.notFound;
+ if (code >= 500) return t.error.server;
+ if (code >= 400) return t.error.unknown;
+ }
+ return raw || t.error.unknown;
+}
+
+type ParsedSegment =
+ | { kind: 'text'; value: string }
+ | { kind: 'code'; lang: string; value: string }
+ | { kind: 'inline-code'; value: string }
+ | { kind: 'bold'; value: string }
+ | { kind: 'italic'; value: string }
+ | { kind: 'strike'; value: string }
+ | { kind: 'url'; href: string; label: string }
+ | { kind: 'mention'; value: string }
+ | { kind: 'marker'; tone: 'done' | 'fail' | 'info'; value: string }
+ | { kind: 'path'; value: string };
+
+const URL_RE = /\bhttps?:\/\/[^\s)<>]+/g;
+const MENTION_RE = /(^|\s)(@[A-Za-z0-9_-]+)/g;
+const MARKER_RE =
+ /\b(TASK_DONE|TASK_FAILED|TASK_FAIL|TASK_OK|DONE|FAILED|FAIL|OK|ERROR|WARNING|RETRY|STATUS|PASS|PASSED)\b/g;
+const PATH_RE = /(?:^|\s)((?:\/|\.\/|\.\.\/)[^\s)<>]+)/g;
+
+function tokenizeInline(input: string): ParsedSegment[] {
+ if (!input) return [];
+ const segments: ParsedSegment[] = [];
+ const matches: Array<{
+ start: number;
+ end: number;
+ seg: ParsedSegment;
+ priority: number;
+ }> = [];
+ for (const m of input.matchAll(/`([^`\n]+)`/g)) {
+ matches.push({
+ start: m.index ?? 0,
+ end: (m.index ?? 0) + m[0].length,
+ seg: { kind: 'inline-code', value: m[1] },
+ priority: 0,
+ });
+ }
+ for (const m of input.matchAll(/\*\*([^*\n]+)\*\*/g)) {
+ matches.push({
+ start: m.index ?? 0,
+ end: (m.index ?? 0) + m[0].length,
+ seg: { kind: 'bold', value: m[1] },
+ priority: 0,
+ });
+ }
+ for (const m of input.matchAll(/(?
+ a.start === b.start ? a.priority - b.priority : a.start - b.start,
+ );
+ let cursor = 0;
+ const used: Array<[number, number]> = [];
+ for (const match of matches) {
+ if (match.start < cursor) continue;
+ const overlaps = used.some(
+ ([s, e]) => !(match.end <= s || match.start >= e),
+ );
+ if (overlaps) continue;
+ if (match.start > cursor) {
+ segments.push({ kind: 'text', value: input.slice(cursor, match.start) });
+ }
+ segments.push(match.seg);
+ used.push([match.start, match.end]);
+ cursor = match.end;
+ }
+ if (cursor < input.length) {
+ segments.push({ kind: 'text', value: input.slice(cursor) });
+ }
+ return segments;
+}
+
+type ParsedBlock =
+ | { kind: 'paragraph'; segments: ParsedSegment[] }
+ | { kind: 'code'; lang: string; value: string }
+ | { kind: 'heading'; level: 1 | 2 | 3 | 4; segments: ParsedSegment[] }
+ | { kind: 'list'; items: ParsedSegment[][] }
+ | { kind: 'olist'; items: ParsedSegment[][]; start: number }
+ | { kind: 'quote'; segments: ParsedSegment[] };
+
+function parseTextBlocks(raw: string): ParsedBlock[] {
+ const blocks: ParsedBlock[] = [];
+ const lines = raw.split(/\n/);
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i];
+ const trimmed = line.trim();
+ if (!trimmed) {
+ i++;
+ continue;
+ }
+ const heading = trimmed.match(/^(#{1,4})\s+(.*)$/);
+ if (heading) {
+ const level = Math.min(heading[1].length, 4) as 1 | 2 | 3 | 4;
+ blocks.push({
+ kind: 'heading',
+ level,
+ segments: tokenizeInline(heading[2]),
+ });
+ i++;
+ continue;
+ }
+ if (/^>\s+/.test(trimmed)) {
+ const quoteLines: string[] = [];
+ while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
+ quoteLines.push(lines[i].replace(/^\s*>\s?/, ''));
+ i++;
+ }
+ blocks.push({
+ kind: 'quote',
+ segments: tokenizeInline(quoteLines.join('\n')),
+ });
+ continue;
+ }
+ if (/^[-*]\s+/.test(trimmed)) {
+ const items: ParsedSegment[][] = [];
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
+ const itemText = lines[i].replace(/^\s*[-*]\s+/, '');
+ items.push(tokenizeInline(itemText));
+ i++;
+ }
+ blocks.push({ kind: 'list', items });
+ continue;
+ }
+ const olistMatch = trimmed.match(/^(\d+)\.\s+/);
+ if (olistMatch) {
+ const items: ParsedSegment[][] = [];
+ const start = Number(olistMatch[1]);
+ while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
+ const itemText = lines[i].replace(/^\s*\d+\.\s+/, '');
+ items.push(tokenizeInline(itemText));
+ i++;
+ }
+ blocks.push({ kind: 'olist', items, start });
+ continue;
+ }
+ // Plain paragraph: gather consecutive non-empty lines
+ const paraLines: string[] = [];
+ while (
+ i < lines.length &&
+ lines[i].trim() &&
+ !/^(#{1,4})\s+/.test(lines[i].trim()) &&
+ !/^[-*]\s+/.test(lines[i].trim()) &&
+ !/^\d+\.\s+/.test(lines[i].trim()) &&
+ !/^>\s+/.test(lines[i].trim())
+ ) {
+ paraLines.push(lines[i]);
+ i++;
+ }
+ if (paraLines.length > 0) {
+ blocks.push({
+ kind: 'paragraph',
+ segments: tokenizeInline(paraLines.join('\n')),
+ });
+ }
+ }
+ return blocks;
+}
+
+function parseMessageBody(raw: string | null | undefined): ParsedBlock[] {
+ if (!raw) return [];
+ const cleaned = raw
+ .replace(/<\/?internal[^>]*>/gi, '')
+ .replace(/<\/?intern\.{3}/gi, '')
+ .trim();
+ if (!cleaned) return [];
+ const blocks: ParsedBlock[] = [];
+ const fenceRe = /```([A-Za-z0-9_+-]*)\n?([\s\S]*?)```/g;
+ let cursor = 0;
+ let m: RegExpExecArray | null;
+ while ((m = fenceRe.exec(cleaned)) !== null) {
+ if (m.index > cursor) {
+ const pre = cleaned.slice(cursor, m.index).trim();
+ if (pre) blocks.push(...parseTextBlocks(pre));
+ }
+ blocks.push({ kind: 'code', lang: m[1] || '', value: m[2] });
+ cursor = m.index + m[0].length;
+ }
+ if (cursor < cleaned.length) {
+ const tail = cleaned.slice(cursor).trim();
+ if (tail) blocks.push(...parseTextBlocks(tail));
+ }
+ return blocks;
+}
+
+function renderInlineSegments(
+ segments: ParsedSegment[],
+ truncate: number | undefined,
+ usedRef: { used: number },
+): ReactNode[] {
+ const out: ReactNode[] = [];
+ segments.forEach((s, j) => {
+ if (truncate && usedRef.used >= truncate) return;
+ const remaining = truncate ? truncate - usedRef.used : Infinity;
+ if (s.kind === 'text') {
+ const v =
+ s.value.length > remaining
+ ? `${s.value.slice(0, remaining)}…`
+ : s.value;
+ usedRef.used += v.length;
+ out.push({v});
+ return;
+ }
+ if (s.kind === 'inline-code') {
+ usedRef.used += s.value.length;
+ out.push(
+
+ {s.value}
+ ,
+ );
+ return;
+ }
+ if (s.kind === 'bold') {
+ usedRef.used += s.value.length;
+ out.push({s.value});
+ return;
+ }
+ if (s.kind === 'italic') {
+ usedRef.used += s.value.length;
+ out.push({s.value});
+ return;
+ }
+ if (s.kind === 'strike') {
+ usedRef.used += s.value.length;
+ out.push({s.value});
+ return;
+ }
+ if (s.kind === 'url') {
+ usedRef.used += s.label.length;
+ out.push(
+
+ {s.label}
+ ,
+ );
+ return;
+ }
+ if (s.kind === 'mention') {
+ usedRef.used += s.value.length;
+ out.push(
+
+ {s.value}
+ ,
+ );
+ return;
+ }
+ if (s.kind === 'path') {
+ usedRef.used += s.value.length;
+ out.push(
+
+ {s.value}
+ ,
+ );
+ return;
+ }
+ if (s.kind === 'marker') {
+ usedRef.used += s.value.length;
+ out.push(
+
+ {s.value}
+ ,
+ );
+ return;
+ }
+ });
+ return out;
+}
+
+function ParsedBody({
+ text,
+ truncate,
+}: {
+ text: string | null | undefined;
+ truncate?: number;
+}): ReactNode {
+ const cleaned = useMemo(() => {
+ if (!text) return '';
+ let out = text
+ .replace(SECRET_ASSIGNMENT_RE, (_m, key) => `${key}=***`)
+ .replace(SECRET_VALUE_RE, '***');
+ if (truncate && out.length > truncate) {
+ out = out.slice(0, truncate) + '…';
+ }
+ return out;
+ }, [text, truncate]);
+
+ if (!cleaned.trim()) {
+ return —;
+ }
+
+ return (
+
+ {cleaned}
+
+ );
+}
+
+function isInternalProtocolPayload(
+ content: string | null | undefined,
+): boolean {
+ if (!content) return false;
+ if (/<\/?(sub-agent[-\w]*|tool-call|internal)\b/i.test(content)) return true;
+ if (/"author"\s*:\s*"[^"]+"\s*,\s*"recipient"\s*:\s*"[^"]+"/.test(content)) {
+ return true;
+ }
+ return false;
+}
+
+function senderRoleClass(value: string | null | undefined): string {
+ const v = (value ?? '').toLowerCase();
+ if (v.includes('오너') || v.includes('owner')) return 'role-owner';
+ if (v.includes('리뷰어') || v.includes('reviewer')) return 'role-reviewer';
+ if (v.includes('중재자') || v.includes('arbiter')) return 'role-arbiter';
+ if (v.includes('cron') || v.includes('sentry') || v.includes('webhook'))
+ return 'role-cron';
+ return 'role-human';
+}
+
+function sanitizeInboxText(value: string | null | undefined): string {
+ if (!value) return '';
+ return value
+ .replace(/<\/?internal[^>]*>/gi, '')
+ .replace(/<\/?intern\.{3}/gi, '')
+ .replace(/<\/?[a-z][a-z0-9-]*[^>]*>/gi, '')
+ .replace(/\s{2,}/g, ' ')
+ .trim();
+}
+
function formatDate(value: string | null | undefined, locale: Locale): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
- return new Intl.DateTimeFormat(localeTags[locale], {
- month: '2-digit',
- day: '2-digit',
+ 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',
- second: '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);
}
@@ -178,11 +648,21 @@ function formatTaskDate(
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
- return new Intl.DateTimeFormat(localeTags[locale], {
- month: '2-digit',
- day: '2-digit',
+ 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);
}
@@ -302,6 +782,18 @@ function formatDuration(value: number | null, t: Messages): string {
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,
@@ -324,6 +816,7 @@ function safePreview(
const cleaned = (value ?? '')
.replace(SECRET_ASSIGNMENT_RE, '$1=')
.replace(SECRET_VALUE_RE, '')
+ .replace(/<\/?internal[^>]*>/gi, '')
.replace(/\s+/g, ' ')
.trim();
if (!cleaned) return fallback;
@@ -505,13 +998,23 @@ function inboxTargetHref(item: InboxItem): string | null {
return null;
}
+const NAV_ICONS: Record = {
+ usage: ,
+ inbox: ,
+ health: ,
+ rooms: ,
+ scheduled: ,
+ settings: ,
+};
+
function navItems(t: Messages) {
return [
- { href: '#/usage', label: t.nav.usage, view: 'usage' as const },
- { href: '#/inbox', label: t.nav.inbox, view: 'inbox' as const },
- { href: '#/health', label: t.nav.health, view: 'health' as const },
{ href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const },
+ { href: '#/inbox', label: t.nav.inbox, view: 'inbox' as const },
{ href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const },
+ { href: '#/health', label: t.nav.health, view: 'health' as const },
+ { href: '#/usage', label: t.nav.usage, view: 'usage' as const },
+ { href: '#/settings', label: t.nav.settings, view: 'settings' as const },
];
}
@@ -546,6 +1049,368 @@ function LanguageSelector({
);
}
+function SettingsPanel({
+ locale,
+ nickname,
+ onLocaleChange,
+ onNicknameChange,
+ onRestartStack,
+ t,
+}: {
+ locale: Locale;
+ nickname: string;
+ onLocaleChange: (locale: Locale) => void;
+ onNicknameChange: (next: string) => void;
+ onRestartStack: () => void;
+ t: Messages;
+}) {
+ return (
+
+ );
+}
+
+function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) {
+ const [config, setConfig] = useState(null);
+ const [draft, setDraft] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [savedAt, setSavedAt] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setBusy(true);
+ fetchModelConfig()
+ .then((c) => {
+ if (cancelled) return;
+ setConfig(c);
+ setDraft(c);
+ setError(null);
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ setError(err instanceof Error ? err.message : String(err));
+ })
+ .finally(() => {
+ if (!cancelled) setBusy(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ async function save() {
+ if (!draft || !config) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const next = await updateModels(draft);
+ setConfig(next);
+ setDraft(next);
+ setSavedAt(Date.now());
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ function setRole(
+ role: keyof ModelConfigSnapshot,
+ patch: Partial,
+ ) {
+ setDraft((prev) =>
+ prev
+ ? {
+ ...prev,
+ [role]: { ...prev[role], ...patch },
+ }
+ : prev,
+ );
+ }
+
+ const dirty =
+ draft !== null &&
+ config !== null &&
+ JSON.stringify(draft) !== JSON.stringify(config);
+
+ return (
+
+ );
+}
+
+function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
+ const [data, setData] = useState<{
+ claude: ClaudeAccountSummary[];
+ codex: CodexAccountSummary[];
+ } | null>(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [tokenInput, setTokenInput] = useState('');
+
+ async function refresh() {
+ setBusy(true);
+ setError(null);
+ try {
+ const fresh = await fetchAccounts();
+ setData(fresh);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ useEffect(() => {
+ void refresh();
+ }, []);
+
+ async function handleDelete(provider: 'claude' | 'codex', index: number) {
+ if (
+ !window.confirm(
+ `${provider} 계정 #${index} 디렉터리를 삭제합니다. 되돌릴 수 없습니다. 계속할까요?`,
+ )
+ ) {
+ return;
+ }
+ setBusy(true);
+ setError(null);
+ try {
+ await deleteAccount(provider, index);
+ await refresh();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function handleAdd() {
+ const token = tokenInput.trim();
+ if (!token) return;
+ setBusy(true);
+ setError(null);
+ try {
+ await addClaudeAccount(token);
+ setTokenInput('');
+ await refresh();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+ 계정
+ {error ? {error}
: null}
+
+
+
Claude
+ {!data ? (
+
{busy ? '불러오는 중…' : '없음'}
+ ) : data.claude.length === 0 ? (
+
계정 없음
+ ) : (
+
+ {data.claude.map((acc) => (
+ -
+ #{acc.index}
+
+ {acc.subscriptionType ?? 'unknown'}
+ {acc.expiresAt
+ ? ` · 만료 ${new Date(acc.expiresAt).toLocaleDateString()}`
+ : ''}
+
+ {acc.index > 0 ? (
+
+ ) : (
+ 기본
+ )}
+
+ ))}
+
+ )}
+
+
+
+
+
+
Codex
+ {!data ? (
+
{busy ? '불러오는 중…' : '없음'}
+ ) : data.codex.length === 0 ? (
+
계정 없음
+ ) : (
+
+ {data.codex.map((acc) => (
+ -
+ #{acc.index}
+
+ {acc.planType ?? 'unknown'}
+ {acc.subscriptionUntil
+ ? ` · ${acc.subscriptionUntil}까지`
+ : ''}
+
+ {acc.index > 0 ? (
+
+ ) : (
+ 기본
+ )}
+
+ ))}
+
+ )}
+
+ Codex 계정 추가는 codex login CLI로 진행한 뒤
+ ~/.codex-accounts/N/ 디렉터리에 auth.json 파일이 생성되면 됩니다.
+
+
+
+
+
+
+
+ );
+}
+
function LoadingSkeleton({ t }: { t: Messages }) {
return (
@@ -570,11 +1435,10 @@ function LoadingSkeleton({ t }: { t: Messages }) {
function SideRail({
activeView,
canInstall,
+ data,
installed,
- locale,
onNavigate,
onInstall,
- onLocaleChange,
onRefresh,
online,
offlineReady,
@@ -584,11 +1448,10 @@ function SideRail({
}: {
activeView: DashboardView;
canInstall: boolean;
+ data: DashboardState | null;
installed: boolean;
- locale: Locale;
onNavigate: (view: DashboardView) => void;
onInstall: () => void;
- onLocaleChange: (locale: Locale) => void;
onRefresh: () => void;
online: boolean;
offlineReady: boolean;
@@ -604,44 +1467,94 @@ function SideRail({
? t.pwa.ready
: t.pwa.app;
+ const stats = data
+ ? (() => {
+ const queue = data.snapshots.reduce(
+ (acc, snapshot) => {
+ for (const entry of snapshot.entries) {
+ acc.pendingTasks += entry.pendingTasks;
+ if (entry.pendingMessages) acc.pendingMessageRooms += 1;
+ if (entry.status === 'processing') acc.processing += 1;
+ }
+ return acc;
+ },
+ { pendingTasks: 0, pendingMessageRooms: 0, processing: 0 },
+ );
+ return {
+ rooms: data.overview.rooms,
+ inbox: data.overview.inbox.length,
+ processing: queue.processing,
+ pendingTasks: queue.pendingTasks,
+ pendingMessageRooms: queue.pendingMessageRooms,
+ };
+ })()
+ : null;
+
return (
-
);
}
@@ -682,7 +1595,7 @@ function SectionNav({
t: Messages;
}) {
const activeLabel =
- navItems(t).find((item) => item.view === activeView)?.label ?? t.nav.usage;
+ navItems(t).find((item) => item.view === activeView)?.label ?? t.nav.rooms;
return (
<>
@@ -759,11 +1672,6 @@ function SectionNav({
))}
-
{!secureContext
@@ -994,7 +1902,7 @@ function InboxPanel({
{t.inbox.kinds[item.kind]}
- {item.title}
+ {sanitizeInboxText(item.title) || item.title}
@@ -1005,7 +1913,7 @@ function InboxPanel({
) : null}
- {item.summary || t.inbox.noSummary}
+ {sanitizeInboxText(item.summary) || t.inbox.noSummary}
{t.inbox.occurred}
@@ -1038,7 +1946,8 @@ function InboxPanel({
const busy = taskActionKey === actionKey;
return (
-
) : null}
+
+ {activeView === 'settings' ? (
+
+
+
{t.settings.title}
+
+ void handleServiceRestart()}
+ t={t}
+ />
+
+ ) : null}
) : null}
diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts
index dab4a71..01cf643 100644
--- a/apps/dashboard/src/api.ts
+++ b/apps/dashboard/src/api.ts
@@ -131,6 +131,8 @@ export interface DashboardRoomActivity {
updatedAt: string;
completedAt: string | null;
lastError: string | null;
+ progressText: string | null;
+ progressUpdatedAt: string | null;
} | null;
outputs: Array<{
id: number;
@@ -239,6 +241,14 @@ export async function fetchRoomTimeline(
);
}
+export async function fetchRoomsTimelineBatch(): Promise<
+ Record
+> {
+ return fetchJson>(
+ '/api/rooms-timeline',
+ );
+}
+
export async function runScheduledTaskAction(
taskId: string,
action: DashboardTaskAction,
@@ -324,9 +334,108 @@ export async function sendRoomMessage(
roomJid: string,
text: string,
requestId: string,
+ nickname?: string | null,
): Promise<{ ok: true; id: string; queued: boolean }> {
return postJson(`/api/rooms/${encodeURIComponent(roomJid)}/messages`, {
requestId,
text,
+ nickname: nickname ?? undefined,
});
}
+
+export interface ClaudeAccountSummary {
+ index: number;
+ expiresAt: number | null;
+ scopes: string[];
+ subscriptionType?: string;
+ rateLimitTier?: string;
+ exists: boolean;
+}
+
+export interface CodexAccountSummary {
+ index: number;
+ accountId: string | null;
+ planType: string | null;
+ subscriptionUntil: string | null;
+ exists: boolean;
+}
+
+export interface ModelRoleConfig {
+ model: string;
+ effort: string;
+}
+
+export interface ModelConfigSnapshot {
+ owner: ModelRoleConfig;
+ reviewer: ModelRoleConfig;
+ arbiter: ModelRoleConfig;
+}
+
+export async function fetchAccounts(): Promise<{
+ claude: ClaudeAccountSummary[];
+ codex: CodexAccountSummary[];
+}> {
+ return fetchJson('/api/settings/accounts');
+}
+
+export async function fetchModelConfig(): Promise {
+ return fetchJson('/api/settings/models');
+}
+
+export async function updateModels(
+ input: Partial<{
+ owner: Partial;
+ reviewer: Partial;
+ arbiter: Partial;
+ }>,
+): Promise {
+ const response = await fetch('/api/settings/models', {
+ method: 'PUT',
+ headers: {
+ accept: 'application/json',
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify(input),
+ });
+ if (!response.ok) {
+ let msg = `update models failed: ${response.status}`;
+ try {
+ const payload = (await response.json()) as { error?: string };
+ if (payload.error) msg = payload.error;
+ } catch {
+ /* ignore */
+ }
+ throw new Error(msg);
+ }
+ return (await response.json()) as ModelConfigSnapshot;
+}
+
+export async function deleteAccount(
+ provider: 'claude' | 'codex',
+ index: number,
+): Promise<{ ok: true; provider: string; index: number }> {
+ const response = await fetch(`/api/settings/accounts/${provider}/${index}`, {
+ method: 'DELETE',
+ });
+ if (!response.ok) {
+ let msg = `delete account failed: ${response.status}`;
+ try {
+ const payload = (await response.json()) as { error?: string };
+ if (payload.error) msg = payload.error;
+ } catch {
+ /* ignore */
+ }
+ throw new Error(msg);
+ }
+ return (await response.json()) as {
+ ok: true;
+ provider: string;
+ index: number;
+ };
+}
+
+export async function addClaudeAccount(
+ token: string,
+): Promise<{ ok: true; index: number; accountId: string | null }> {
+ return postJson('/api/settings/accounts/claude', { token });
+}
diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts
index 0c9fab8..97fe4de 100644
--- a/apps/dashboard/src/i18n.ts
+++ b/apps/dashboard/src/i18n.ts
@@ -25,12 +25,26 @@ export interface Messages {
usage: string;
rooms: string;
scheduled: string;
+ settings: string;
};
language: {
label: string;
};
+ settings: {
+ title: string;
+ nicknameLabel: string;
+ nicknamePlaceholder: string;
+ nicknameHelp: string;
+ languageLabel: string;
+ };
error: {
api: string;
+ network: string;
+ timeout: string;
+ server: string;
+ notFound: string;
+ auth: string;
+ unknown: string;
};
control: {
aria: string;
@@ -146,6 +160,8 @@ export interface Messages {
agent: string;
status: string;
queue: string;
+ queueWaitingMessages: string;
+ tasks: string;
elapsed: string;
activity: string;
loadingActivity: string;
@@ -169,6 +185,12 @@ export interface Messages {
messagePlaceholder: string;
send: string;
sending: string;
+ error: string;
+ filterAll: string;
+ sortRecent: string;
+ sortName: string;
+ sortQueue: string;
+ sortLabel: string;
};
usage: {
empty: string;
@@ -316,12 +338,26 @@ export const messages = {
usage: '사용량',
rooms: '룸',
scheduled: '예약',
+ settings: '설정',
},
language: {
label: '언어',
},
+ settings: {
+ title: '설정',
+ nicknameLabel: '닉네임',
+ nicknamePlaceholder: 'Web Dashboard',
+ nicknameHelp: '룸에서 메시지 보낼 때 표시될 이름',
+ languageLabel: '언어',
+ },
error: {
- api: 'API 오류',
+ api: '문제 발생',
+ network: '네트워크 연결 확인 후 다시 시도해주세요.',
+ timeout: '응답 시간이 초과됐어요. 잠시 후 다시 시도해주세요.',
+ server: '서버에 일시적인 문제가 있어요. 잠시 후 다시 시도해주세요.',
+ notFound: '요청한 정보를 찾을 수 없어요.',
+ auth: '권한이 없어요. 다시 로그인해주세요.',
+ unknown: '알 수 없는 오류가 발생했어요.',
},
control: {
aria: '컨트롤 플레인 요약',
@@ -376,9 +412,9 @@ export const messages = {
queue: '큐',
ciFailures: 'CI 실패',
affectedServices: '이상 서비스',
- restart: 'Restart',
- restartStack: 'Restart stack',
- restarting: 'Restarting...',
+ restart: '재시작',
+ restartStack: '스택 재시작',
+ restarting: '재시작 중...',
restartHint: '서비스 전체 재시작',
confirmRestart: 'Restart stack now?',
restartLog: 'Restart log',
@@ -437,6 +473,8 @@ export const messages = {
agent: '에이전트',
status: '상태',
queue: '큐',
+ queueWaitingMessages: '대기 메시지',
+ tasks: '태스크',
elapsed: '경과',
activity: '진행',
loadingActivity: '진행 로딩 중',
@@ -456,10 +494,16 @@ export const messages = {
messageHistory: '메시지 기록',
noMessages: '메시지 없음',
details: '세부',
- message: 'Message',
- messagePlaceholder: 'Type request...',
- send: 'Send',
- sending: 'Sending',
+ message: '메시지',
+ messagePlaceholder: '요청 입력...',
+ send: '전송',
+ sending: '전송 중...',
+ error: '오류',
+ filterAll: '전체',
+ sortRecent: '최근 활동',
+ sortName: '이름순',
+ sortQueue: '큐 많은 순',
+ sortLabel: '정렬',
},
usage: {
empty: '사용량 스냅샷 없음. 수집기 확인.',
@@ -591,12 +635,26 @@ export const messages = {
usage: 'Usage',
rooms: 'Rooms',
scheduled: 'Scheduled',
+ settings: 'Settings',
},
language: {
label: 'Language',
},
+ settings: {
+ title: 'Settings',
+ nicknameLabel: 'Nickname',
+ nicknamePlaceholder: 'Web Dashboard',
+ nicknameHelp: 'Name shown when sending messages from this dashboard',
+ languageLabel: 'Language',
+ },
error: {
- api: 'API error',
+ api: 'Something went wrong',
+ network: 'Check your network connection and try again.',
+ timeout: 'The request timed out. Try again in a moment.',
+ server: 'Server hiccup. Try again in a bit.',
+ notFound: "We couldn't find what you asked for.",
+ auth: 'Sign in again to continue.',
+ unknown: 'An unexpected error occurred.',
},
control: {
aria: 'Control plane summary',
@@ -712,6 +770,8 @@ export const messages = {
agent: 'agent',
status: 'status',
queue: 'queue',
+ queueWaitingMessages: 'pending messages',
+ tasks: 'tasks',
elapsed: 'elapsed',
activity: 'activity',
loadingActivity: 'Loading activity',
@@ -735,6 +795,12 @@ export const messages = {
messagePlaceholder: 'Type request...',
send: 'Send',
sending: 'Sending',
+ error: 'Error',
+ filterAll: 'All',
+ sortRecent: 'Recent activity',
+ sortName: 'Name',
+ sortQueue: 'Queue size',
+ sortLabel: 'Sort',
},
usage: {
empty: 'No usage snapshot. Check collector.',
@@ -866,12 +932,26 @@ export const messages = {
usage: '用量',
rooms: '房间',
scheduled: '计划',
+ settings: '设置',
},
language: {
label: '语言',
},
+ settings: {
+ title: '设置',
+ nicknameLabel: '昵称',
+ nicknamePlaceholder: 'Web Dashboard',
+ nicknameHelp: '从此面板发送消息时显示的名称',
+ languageLabel: '语言',
+ },
error: {
- api: 'API 错误',
+ api: '发生问题',
+ network: '请检查网络连接后重试。',
+ timeout: '请求超时,请稍后重试。',
+ server: '服务器暂时出现问题,请稍后重试。',
+ notFound: '找不到所请求的信息。',
+ auth: '没有权限,请重新登录。',
+ unknown: '发生未知错误。',
},
control: {
aria: '控制平面摘要',
@@ -987,6 +1067,8 @@ export const messages = {
agent: '代理',
status: '状态',
queue: '队列',
+ queueWaitingMessages: '待处理消息',
+ tasks: '任务',
elapsed: '耗时',
activity: '进展',
loadingActivity: '正在加载进展',
@@ -1007,9 +1089,15 @@ export const messages = {
noMessages: '暂无消息',
details: '详情',
message: 'Message',
- messagePlaceholder: 'Type request...',
- send: 'Send',
- sending: 'Sending',
+ messagePlaceholder: '输入请求...',
+ send: '发送',
+ sending: '发送中...',
+ error: '错误',
+ filterAll: '全部',
+ sortRecent: '最近活动',
+ sortName: '名称',
+ sortQueue: '队列大小',
+ sortLabel: '排序',
},
usage: {
empty: '暂无用量快照。检查采集器。',
@@ -1141,12 +1229,28 @@ export const messages = {
usage: '使用量',
rooms: 'ルーム',
scheduled: '予定',
+ settings: '設定',
},
language: {
label: '言語',
},
+ settings: {
+ title: '設定',
+ nicknameLabel: 'ニックネーム',
+ nicknamePlaceholder: 'Web Dashboard',
+ nicknameHelp: 'このダッシュボードからメッセージを送る時に表示される名前',
+ languageLabel: '言語',
+ },
error: {
- api: 'API エラー',
+ api: '問題が発生しました',
+ network: 'ネットワーク接続を確認してから再試行してください。',
+ timeout:
+ 'リクエストがタイムアウトしました。少し経ってから再試行してください。',
+ server:
+ 'サーバーに一時的な問題があります。少し経ってから再試行してください。',
+ notFound: 'お探しの情報が見つかりませんでした。',
+ auth: '権限がありません。再ログインしてください。',
+ unknown: '不明なエラーが発生しました。',
},
control: {
aria: 'コントロールプレーン概要',
@@ -1262,6 +1366,8 @@ export const messages = {
agent: 'エージェント',
status: '状態',
queue: 'キュー',
+ queueWaitingMessages: '待機メッセージ',
+ tasks: 'タスク',
elapsed: '経過',
activity: '進行',
loadingActivity: '進行を読み込み中',
@@ -1281,10 +1387,16 @@ export const messages = {
messageHistory: 'メッセージ履歴',
noMessages: 'メッセージなし',
details: '詳細',
- message: 'Message',
- messagePlaceholder: 'Type request...',
- send: 'Send',
- sending: 'Sending',
+ message: 'メッセージ',
+ messagePlaceholder: '依頼を入力...',
+ send: '送信',
+ sending: '送信中...',
+ error: 'エラー',
+ filterAll: '全て',
+ sortRecent: '最近の活動',
+ sortName: '名前順',
+ sortQueue: 'キュー順',
+ sortLabel: '並び替え',
},
usage: {
empty: '使用量スナップショットなし。収集器を確認。',
diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css
index 53ab875..0c41bc2 100644
--- a/apps/dashboard/src/styles.css
+++ b/apps/dashboard/src/styles.css
@@ -1,18 +1,86 @@
+.debug-outlines .shell {
+ outline: 2px dashed red;
+}
+.debug-outlines .dashboard-content {
+ outline: 2px dashed orange;
+}
+.debug-outlines .view-stack.view-rooms {
+ outline: 2px dashed yellow;
+}
+.debug-outlines .view-stack.view-rooms .view-panel {
+ outline: 2px dashed lime;
+}
+.debug-outlines .rooms-v2 {
+ outline: 2px dashed cyan;
+}
+.debug-outlines .rooms-twopane {
+ outline: 2px dashed deepskyblue;
+}
+.debug-outlines .rooms-list {
+ outline: 2px dashed magenta;
+}
+.debug-outlines .rooms-detail {
+ outline: 2px dashed pink;
+}
+.debug-outlines .room-card-v2 {
+ outline: 2px dotted white;
+}
+.debug-outlines .room-card-head {
+ outline: 2px dotted gold;
+}
+.debug-outlines .room-thread-section {
+ outline: 2px dotted lime;
+}
+.debug-outlines .room-compose-section {
+ outline: 2px dotted red;
+}
+.debug-outlines .room-compose textarea {
+ outline: 2px solid white;
+}
+.debug-outlines .room-timeline-item {
+ outline: 1px dotted aqua;
+}
+
:root {
- color-scheme: light;
- --bg: #f3efe4;
- --bg-ink: #1c211c;
- --panel: rgba(255, 252, 242, 0.9);
- --panel-solid: #fffaf0;
- --panel-border: rgba(43, 55, 38, 0.16);
- --muted: #6d735f;
- --accent: #bf5f2c;
- --accent-strong: #8f351b;
- --green: #3f7f51;
- --yellow: #b58a22;
- --red: #b74734;
- --drawer: rgba(26, 31, 26, 0.72);
- --shadow: 0 24px 70px rgba(44, 39, 25, 0.18);
+ color-scheme: dark;
+ --bg: #0c1110;
+ --bg-elevated: #131816;
+ --bg-card: #161b18;
+ --bg-card-hover: #1b211d;
+ --bg-ink: #e6e4dc;
+ --ink: #e6e4dc;
+ --panel: #131816;
+ --panel-solid: #131816;
+ --panel-border: rgba(255, 255, 255, 0.06);
+ --panel-border-strong: rgba(255, 255, 255, 0.12);
+ --divider: rgba(255, 255, 255, 0.05);
+ --card-shadow: 0 1px 0 rgba(0, 0, 0, 0.3), 0 4px 12px rgba(0, 0, 0, 0.18);
+ --card-shadow-strong:
+ 0 1px 0 rgba(0, 0, 0, 0.4), 0 12px 36px rgba(0, 0, 0, 0.32);
+ --muted: #8d9189;
+ --muted-soft: #6a6e68;
+ --faint: #4d514c;
+ --accent: #d68258;
+ --accent-strong: #e89870;
+ --green: #6cb087;
+ --yellow: #d4a04a;
+ --red: #e0644b;
+ --blue: #6ba0e0;
+ --status-ok: #6cb087;
+ --status-warn: #d4a04a;
+ --status-critical: #e0644b;
+ --status-info: #6ba0e0;
+ --status-offline: #e0644b;
+ --drawer: rgba(0, 0, 0, 0.78);
+ --shadow: 0 24px 70px rgba(0, 0, 0, 0.4);
+ --weight-bold: 800;
+ --weight-semibold: 700;
+ --weight-medium: 600;
+ --text-xs: 11px;
+ --text-sm: 12px;
+ --text-base: 14px;
+ --text-md: 16px;
+ --text-lg: 20px;
font-family:
'Avenir Next', 'Segoe UI Variable', 'Noto Sans KR', ui-sans-serif,
sans-serif;
@@ -22,7 +90,7 @@
html {
scroll-behavior: smooth;
- scroll-padding-top: 92px;
+ scroll-padding-top: 76px;
}
* {
@@ -32,20 +100,21 @@ html {
body {
min-width: 320px;
min-height: 100vh;
+ max-width: 100vw;
margin: 0;
- overflow-x: hidden;
+ overflow-x: clip;
background:
radial-gradient(
circle at 18% 12%,
- rgba(214, 127, 58, 0.22),
- transparent 28rem
+ rgba(214, 127, 58, 0.04),
+ transparent 32rem
),
radial-gradient(
circle at 88% 8%,
- rgba(74, 123, 87, 0.18),
- transparent 24rem
+ rgba(74, 123, 87, 0.03),
+ transparent 28rem
),
- linear-gradient(135deg, #f4ead5 0%, #edf1df 46%, #f5f0e5 100%);
+ var(--bg);
}
button,
@@ -56,12 +125,14 @@ table {
.shell {
display: grid;
- grid-template-columns: 184px minmax(0, 1fr);
- gap: 18px;
- align-items: start;
- width: min(1480px, calc(100% - 40px));
- margin: 0 auto;
- padding: 18px 0 calc(36px + env(safe-area-inset-bottom));
+ grid-template-columns: 56px minmax(0, 1fr);
+ gap: 0;
+ align-items: stretch;
+ width: 100%;
+ max-width: 100%;
+ margin: 0;
+ padding: 0 0 env(safe-area-inset-bottom);
+ overflow-x: clip;
}
.shell-loading {
@@ -71,32 +142,169 @@ table {
.dashboard-content {
display: grid;
align-content: start;
- gap: 12px;
+ gap: 8px;
min-width: 0;
- min-height: calc(100vh - 36px);
+ max-width: 100%;
+ min-height: 100vh;
+ padding: 8px 12px calc(20px + env(safe-area-inset-bottom));
+ overflow-x: clip;
}
.view-stack {
display: grid;
- gap: 12px;
+ gap: 8px;
min-width: 0;
}
.view-panel {
- min-height: calc(100vh - 36px);
+ min-height: calc(100vh - 24px);
+}
+
+body:has(.view-stack.view-rooms) {
+ overflow: hidden;
+ height: 100vh;
+}
+
+body:has(.view-stack.view-rooms) .shell {
+ height: 100vh;
+ align-items: stretch;
+}
+
+body:has(.view-stack.view-rooms) .dashboard-content {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ min-height: 0;
+ padding: 0;
+}
+
+body:has(.view-stack.view-rooms) .section-nav {
+ position: static;
+ flex: none;
+ margin: 6px 8px 0;
+}
+
+body:has(.view-stack.view-rooms) .error-card {
+ flex: none;
+ margin: 6px 8px;
+}
+
+.view-stack.view-rooms {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.view-stack.view-rooms .view-panel {
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ box-shadow: none;
+ margin: 6px 8px;
+}
+
+.view-stack.view-rooms .panel-title {
+ display: none;
+}
+
+.view-stack.view-rooms .rooms-v2 {
+ flex: 1;
+ min-height: 0;
+ gap: 0;
+}
+
+.view-stack.view-rooms .rooms-toolbar {
+ padding: 6px 12px;
+ border-bottom: 1px solid var(--divider);
+ margin: 0;
+}
+
+.view-stack.view-rooms .rooms-twopane {
+ flex: 1;
+ min-height: 0;
+ height: 100%;
+ width: 100%;
+ gap: 8px;
+ grid-template-columns: clamp(180px, 16vw, 240px) minmax(0, 1fr);
+ grid-template-rows: minmax(0, 1fr);
+}
+
+.view-stack.view-rooms .rooms-list {
+ border: 1px solid var(--panel-border);
+ border-radius: 8px;
+ background: var(--panel);
+ padding: 4px;
+}
+
+.view-stack.view-rooms .rooms-detail {
+ width: 100%;
+ border: 1px solid var(--panel-border);
+ border-radius: 8px;
+ background: var(--panel);
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.view-stack.view-rooms .rooms-detail .room-card-v2.is-pinned {
+ display: block;
+ width: 100%;
+ padding: 0 !important;
+ margin: 0 !important;
+}
+
+.view-stack.view-rooms .rooms-detail .room-card-v2.is-pinned > * {
+ margin-left: 0 !important;
+ margin-right: 0 !important;
+}
+
+.view-stack.view-rooms .rooms-detail .room-card-v2.is-pinned > .room-section {
+ padding-left: 0 !important;
+}
+
+.view-stack.view-rooms .rooms-detail .room-card-v2.is-pinned .room-expanded {
+ padding: 0 !important;
+ margin: 0 !important;
+ border-top: 0 !important;
+ gap: 0 !important;
+}
+
+.view-stack.view-rooms .rooms-detail .room-card-head {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--divider);
+ background: var(--panel);
+}
+
+.view-stack.view-rooms .rooms-detail .room-current-turn,
+.view-stack.view-rooms .rooms-detail .room-meta-row,
+.view-stack.view-rooms .rooms-detail .room-alert {
+ padding: 8px 14px;
+}
+
+.view-stack.view-rooms .rooms-detail .room-thread-section {
+ padding: 4px 12px 4px 0;
+}
+
+.view-stack.view-rooms .rooms-detail .room-card-head {
+ padding: 10px 14px 10px 10px;
+}
+
+.view-stack.view-rooms .rooms-detail .room-compose-section {
+ position: sticky;
+ bottom: 0;
+ z-index: 2;
}
.shell::before {
- position: fixed;
- inset: 0;
- z-index: -1;
- pointer-events: none;
- content: '';
- background-image:
- linear-gradient(rgba(28, 33, 28, 0.055) 1px, transparent 1px),
- linear-gradient(90deg, rgba(28, 33, 28, 0.045) 1px, transparent 1px);
- background-size: 44px 44px;
- mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), transparent 82%);
+ display: none;
}
.loading {
@@ -104,14 +312,14 @@ table {
min-height: 100vh;
place-items: center;
font-size: clamp(24px, 5vw, 54px);
- font-weight: 900;
+ font-weight: 800;
letter-spacing: -0.05em;
}
.eyebrow {
color: var(--accent-strong);
font-size: 12px;
- font-weight: 850;
+ font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
@@ -121,10 +329,14 @@ button {
padding: 0 22px;
border: 0;
border-radius: 999px;
- color: #fffaf0;
- background: linear-gradient(135deg, var(--accent), var(--accent-strong));
- box-shadow: 0 14px 30px rgba(143, 53, 27, 0.24);
+ color: var(--bg);
+ background: var(--accent);
cursor: pointer;
+ transition: background 0.15s ease;
+}
+
+button:hover:not(:disabled) {
+ background: var(--accent-strong);
}
button,
@@ -153,11 +365,11 @@ button:disabled {
align-items: center;
margin: 0;
padding: 10px;
- border: 1px solid rgba(43, 55, 38, 0.14);
- border-radius: 22px;
- background: rgba(255, 250, 240, 0.82);
- box-shadow: 0 18px 48px rgba(44, 39, 25, 0.12);
- backdrop-filter: blur(18px);
+ border: 1px solid var(--panel-border);
+ border-radius: 14px;
+ background: rgba(19, 24, 22, 0.92);
+ box-shadow: var(--card-shadow);
+ backdrop-filter: blur(12px);
}
.section-nav button {
@@ -213,9 +425,9 @@ button:disabled {
padding: 0 11px;
border-radius: 999px;
color: var(--muted);
- background: rgba(43, 55, 38, 0.075);
+ background: rgba(255, 255, 255, 0.04);
font-size: 12px;
- font-weight: 900;
+ font-weight: 800;
white-space: nowrap;
}
@@ -231,63 +443,216 @@ button:disabled {
.side-rail {
position: sticky;
- top: 18px;
+ top: 0;
display: grid;
- min-height: calc(100vh - 36px);
- align-content: start;
- gap: 14px;
- padding: 16px;
- border: 1px solid rgba(43, 55, 38, 0.14);
- border-radius: 28px;
- background: rgba(255, 250, 240, 0.78);
- box-shadow: 0 18px 48px rgba(44, 39, 25, 0.1);
- backdrop-filter: blur(18px);
+ grid-template-rows: auto 1fr auto;
+ height: 100vh;
+ min-height: 100vh;
+ gap: 8px;
+ padding: 8px 6px;
+ border: 0;
+ border-right: 1px solid var(--panel-border);
+ border-radius: 0;
+ background: var(--panel);
+ box-shadow: none;
}
-.side-rail-brand strong {
- display: block;
- margin-top: 6px;
- font-size: 24px;
- letter-spacing: -0.05em;
-}
-
-.side-rail nav {
- display: grid;
- gap: 7px;
-}
-
-.side-rail nav a {
- display: flex;
- min-height: 44px;
+.rail-brand {
+ display: inline-flex;
+ width: 36px;
+ height: 36px;
+ margin: 0 auto;
align-items: center;
- padding: 0 12px;
- border-radius: 15px;
- color: var(--bg-ink);
- background: rgba(43, 55, 38, 0.055);
+ justify-content: center;
+ border-radius: 10px;
+ background: var(--accent);
+ color: #1a0f0a;
+ font-size: 13px;
font-weight: 900;
+ letter-spacing: -0.02em;
text-decoration: none;
}
-.side-rail nav a:hover {
- background: rgba(191, 95, 44, 0.12);
+.rail-nav {
+ display: grid;
+ gap: 4px;
+ align-content: start;
+ justify-items: center;
+}
+
+.rail-item {
+ position: relative;
+ display: inline-flex;
+ width: 40px;
+ height: 40px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 10px;
+ color: var(--muted);
+ background: transparent;
+ text-decoration: none;
+ transition:
+ background 0.12s ease,
+ color 0.12s ease;
+}
+
+.rail-item:hover {
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--bg-ink);
+}
+
+.rail-item.is-active {
+ color: var(--bg-ink);
+ background: rgba(214, 130, 88, 0.16);
+ box-shadow: inset 2px 0 0 var(--accent);
+}
+
+.rail-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+}
+
+.rail-icon svg {
+ width: 100%;
+ height: 100%;
+}
+
+.rail-badge {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 14px;
+ height: 14px;
+ padding: 0 3px;
+ border-radius: 7px;
+ background: var(--accent);
+ color: #1a0f0a;
+ font-size: 9px;
+ font-weight: 800;
+ line-height: 1;
+}
+
+.rail-foot {
+ display: grid;
+ gap: 4px;
+ justify-items: center;
+ align-content: end;
+ padding-top: 4px;
+ border-top: 1px solid var(--divider);
+}
+
+.rail-status-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--muted-soft);
+ margin: 4px 0;
+}
+
+.rail-status-dot.is-online {
+ background: var(--green);
+ box-shadow: 0 0 6px rgba(108, 176, 135, 0.5);
+}
+
+.rail-status-dot.is-offline {
+ background: var(--accent);
+}
+
+.rail-btn {
+ display: inline-flex;
+ width: 32px;
+ height: 32px;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--muted);
+ box-shadow: none;
+ cursor: pointer;
+}
+
+.rail-btn:hover {
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--bg-ink);
+}
+
+.rail-btn[disabled] {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.rail-btn svg {
+ width: 16px;
+ height: 16px;
+}
+
+@keyframes rail-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.rail-btn.is-spinning svg {
+ animation: rail-spin 0.9s linear infinite;
}
-.side-rail nav a.is-active,
-.side-rail nav a[aria-current='page'],
.nav-drawer nav a.is-active,
.nav-drawer nav a[aria-current='page'] {
- color: #fffaf0;
- background: linear-gradient(135deg, #2b3726, #1c211c);
- box-shadow: inset 0 0 0 1px rgba(255, 250, 240, 0.18);
+ color: var(--bg);
+ background: var(--bg-ink);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
+}
+
+.side-stats {
+ display: grid;
+ gap: 1px;
+ border: 1px solid var(--panel-border);
+ border-radius: 10px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.side-stat {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 7px 10px;
+ background: rgba(0, 0, 0, 0.15);
+}
+
+.side-stat span {
+ color: var(--muted-soft);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.side-stat strong {
+ color: var(--bg-ink);
+ font-size: 14px;
+ font-variant-numeric: tabular-nums;
}
.side-refresh {
width: 100%;
- min-height: 44px;
+ min-height: 32px;
+ padding: 0 10px;
+ border-radius: 8px;
color: var(--bg-ink);
- background: rgba(43, 55, 38, 0.08);
+ background: rgba(255, 255, 255, 0.05);
box-shadow: none;
- font-weight: 900;
+ font-size: 12px;
+ font-weight: 700;
}
.side-refresh:hover {
@@ -296,42 +661,326 @@ button:disabled {
.side-install {
width: 100%;
- min-height: 44px;
+ min-height: 32px;
+ padding: 0 10px;
+ border-radius: 8px;
background: linear-gradient(135deg, #2b3726, #1c211c);
- box-shadow: 0 12px 26px rgba(28, 33, 28, 0.18);
- font-weight: 950;
+ box-shadow: 0 6px 16px rgba(28, 33, 28, 0.18);
+ font-size: 12px;
+ font-weight: 700;
}
+.side-pwa,
.pwa-card {
display: grid;
- gap: 3px;
+ gap: 2px;
min-width: 0;
- padding: 11px 12px;
- border: 1px solid rgba(43, 55, 38, 0.1);
- border-radius: 17px;
- background: rgba(43, 55, 38, 0.045);
+ padding: 7px 10px;
+ border: 1px solid var(--panel-border);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.025);
+}
+
+.side-pwa span,
+.pwa-card span {
+ color: var(--muted-soft);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.side-pwa strong,
+.pwa-card strong {
+ font-size: 12px;
+ color: var(--bg-ink);
+}
+
+.side-pwa.is-online strong,
+.pwa-card.is-online strong {
+ color: var(--green);
+}
+
+.side-pwa.is-offline strong,
+.pwa-card.is-offline strong {
+ color: var(--accent);
}
-.pwa-card span,
.drawer-pwa-row span {
color: var(--muted);
font-size: 11px;
- font-weight: 900;
+ font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
-.pwa-card strong {
- min-width: 0;
+.side-pwa.is-offline,
+.pwa-card.is-offline {
+ border-color: rgba(183, 71, 52, 0.24);
+ background: rgba(183, 71, 52, 0.08);
+}
+
+.settings-panel {
+ display: grid;
+ gap: 14px;
+ max-width: 480px;
+}
+
+.settings-row {
+ display: grid;
+ gap: 6px;
+}
+
+.settings-label {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.settings-row input,
+.settings-row select {
+ min-height: 36px;
+ padding: 0 12px;
+ border: 1px solid var(--panel-border);
+ border-radius: 8px;
+ background: var(--bg-card);
+ color: var(--bg-ink);
+ font: inherit;
+ font-size: 14px;
+}
+
+.settings-row input:focus-visible,
+.settings-row select:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+ border-color: var(--accent);
+}
+
+.settings-hint {
+ color: var(--muted-soft);
+ font-size: 11px;
+}
+
+.settings-section {
+ display: grid;
+ gap: 10px;
+ margin-bottom: 24px;
+ padding: 12px;
+ border: 1px solid var(--panel-border);
+ border-radius: 8px;
+ background: var(--panel);
+}
+
+.settings-section h3 {
+ margin: 0 0 4px;
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+
+.settings-section h4 {
+ margin: 0 0 4px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.settings-row-inline {
+ grid-template-columns: 80px minmax(0, 1fr) 100px;
+ display: grid;
+ gap: 8px;
+ align-items: center;
+}
+
+.settings-row-inline > .settings-label {
+ margin: 0;
+ text-transform: uppercase;
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.settings-input-narrow {
+ width: 100% !important;
+}
+
+.settings-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+ margin-top: 8px;
+}
+
+.settings-save,
+.settings-restart {
+ min-height: 32px;
+ padding: 0 14px;
+ border: 0;
+ border-radius: 6px;
+ background: var(--accent);
+ color: var(--bg);
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.settings-save:disabled,
+.settings-restart:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.settings-restart {
+ background: rgba(224, 100, 75, 0.16);
+ color: var(--status-critical);
+ border: 1px solid rgba(224, 100, 75, 0.32);
+}
+
+.settings-error {
+ margin: 0;
+ padding: 6px 8px;
+ border: 1px solid rgba(224, 100, 75, 0.32);
+ border-radius: 6px;
+ background: rgba(224, 100, 75, 0.08);
+ color: var(--status-critical);
+ font-size: 12px;
+}
+
+.settings-account-group {
+ display: grid;
+ gap: 8px;
+}
+
+.settings-account-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 4px;
+}
+
+.settings-account-row {
+ display: grid;
+ grid-template-columns: 40px minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: center;
+ padding: 6px 8px;
+ border: 1px solid var(--panel-border);
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.settings-account-tag {
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.settings-account-meta {
+ font-size: 12px;
+ color: var(--bg-ink);
overflow: hidden;
- font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
-.pwa-card.is-offline {
- border-color: rgba(183, 71, 52, 0.24);
- background: rgba(183, 71, 52, 0.08);
+.settings-account-default {
+ font-size: 11px;
+ color: var(--muted-soft);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.settings-delete {
+ min-height: 28px;
+ padding: 0 10px;
+ border: 1px solid rgba(224, 100, 75, 0.32);
+ border-radius: 6px;
+ background: rgba(224, 100, 75, 0.06);
+ color: var(--status-critical);
+ font-size: 11px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.settings-delete:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.settings-add-token {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: end;
+}
+
+.settings-add-token textarea {
+ width: 100%;
+ border: 1px solid var(--panel-border-strong);
+ border-radius: 6px;
+ padding: 8px 10px;
+ background: var(--bg-card);
+ color: var(--bg-ink);
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ font-size: 11px;
+ resize: vertical;
+}
+
+.settings-add-token button {
+ align-self: end;
+ min-height: 36px;
+ padding: 0 14px;
+ border: 0;
+ border-radius: 6px;
+ background: var(--accent);
+ color: var(--bg);
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.settings-add-token button:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.room-inbox-badges {
+ display: inline-flex;
+ gap: 4px;
+ margin-left: 6px;
+ flex-wrap: wrap;
+}
+
+.room-inbox-pip {
+ display: inline-flex;
+ align-items: center;
+ height: 16px;
+ padding: 0 6px;
+ border: 1px solid currentColor;
+ border-radius: 8px;
+ font-size: 9px;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.room-inbox-pip.sev-info {
+ color: var(--status-info);
+ background: rgba(107, 160, 224, 0.08);
+}
+
+.room-inbox-pip.sev-warn {
+ color: var(--status-warn);
+ background: rgba(212, 160, 74, 0.08);
+}
+
+.room-inbox-pip.sev-error {
+ color: var(--status-critical);
+ background: rgba(224, 100, 75, 0.1);
}
.language-select {
@@ -339,7 +988,7 @@ button:disabled {
gap: 8px;
color: var(--muted);
font-size: 12px;
- font-weight: 850;
+ font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
@@ -348,10 +997,10 @@ button:disabled {
min-height: 44px;
width: 100%;
padding: 0 12px;
- border: 1px solid rgba(43, 55, 38, 0.14);
+ border: 1px solid var(--panel-border-strong);
border-radius: 15px;
color: var(--bg-ink);
- background: rgba(255, 250, 240, 0.78);
+ background: var(--bg-card);
font: inherit;
font-size: 14px;
letter-spacing: 0;
@@ -406,7 +1055,7 @@ button:disabled {
min-height: 44px;
padding: 0 14px;
color: var(--bg-ink);
- background: rgba(43, 55, 38, 0.08);
+ background: rgba(255, 255, 255, 0.05);
box-shadow: none;
}
@@ -420,7 +1069,7 @@ button:disabled {
min-height: 44px;
padding: 0 14px;
font-size: 13px;
- font-weight: 950;
+ font-weight: 800;
}
.nav-drawer nav {
@@ -435,11 +1084,11 @@ button:disabled {
align-items: center;
justify-content: space-between;
padding: 0 16px;
- border: 1px solid rgba(43, 55, 38, 0.08);
+ border: 1px solid var(--panel-border);
border-radius: 18px;
color: var(--bg-ink);
- background: rgba(43, 55, 38, 0.045);
- font-weight: 900;
+ background: rgba(255, 255, 255, 0.025);
+ font-weight: 800;
text-decoration: none;
}
@@ -453,24 +1102,40 @@ button:disabled {
.panel {
border: 1px solid var(--panel-border);
background: var(--panel);
- box-shadow: 0 12px 44px rgba(44, 39, 25, 0.09);
- backdrop-filter: blur(14px);
+ box-shadow: var(--card-shadow);
}
.error-card {
display: flex;
- gap: 12px;
+ gap: 14px;
align-items: center;
justify-content: space-between;
margin-top: 18px;
- padding: 18px 20px;
+ padding: 16px 20px;
border-color: rgba(183, 71, 52, 0.35);
border-radius: 22px;
- color: #7c2518;
- background: rgba(255, 235, 225, 0.82);
+ color: #621a0d;
+ background: rgba(255, 235, 225, 0.92);
+ font-weight: 700;
+}
+
+.error-card span {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+}
+
+.error-card strong {
+ font-size: var(--text-base);
font-weight: 800;
}
+.error-card small {
+ color: #7c2518;
+ font-size: var(--text-sm);
+ font-weight: 500;
+}
+
.error-card button {
min-width: 112px;
flex: none;
@@ -490,47 +1155,38 @@ button:disabled {
margin: 0;
overflow: hidden;
border: 1px solid var(--panel-border);
- border-radius: 28px;
- background: rgba(43, 55, 38, 0.12);
- box-shadow: 0 18px 50px rgba(44, 39, 25, 0.08);
+ border-radius: 12px;
+ background: var(--panel-border);
+ box-shadow: var(--card-shadow);
}
.ops-strip div {
display: grid;
- gap: 5px;
- min-height: 74px;
- padding: 12px 14px;
- background:
- linear-gradient(
- 145deg,
- rgba(255, 250, 240, 0.9),
- rgba(255, 250, 240, 0.68)
- ),
- radial-gradient(
- circle at 18% 18%,
- rgba(191, 95, 44, 0.1),
- transparent 16rem
- );
+ gap: 2px;
+ min-height: 52px;
+ padding: 8px 10px;
+ background: var(--panel);
}
.ops-strip span {
- color: var(--muted);
- font-size: 11px;
- font-weight: 900;
- letter-spacing: 0.08em;
+ color: var(--muted-soft);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
text-transform: uppercase;
}
.ops-strip strong {
- font-size: clamp(18px, 2vw, 28px);
- line-height: 1;
- letter-spacing: -0.05em;
+ font-size: 18px;
+ line-height: 1.1;
+ letter-spacing: -0.03em;
+ color: var(--bg-ink);
}
.ops-strip small {
- color: var(--muted);
- font-size: 12px;
- line-height: 1.35;
+ color: var(--muted-soft);
+ font-size: 11px;
+ line-height: 1.3;
}
.ops-fresh {
@@ -551,7 +1207,7 @@ button:disabled {
padding: 0 10px;
box-shadow: none;
font-size: 12px;
- font-weight: 950;
+ font-weight: 800;
}
.skeleton-card {
@@ -572,35 +1228,42 @@ dd,
}
.panel {
- padding: 24px;
- border-radius: 30px;
- scroll-margin-top: 92px;
+ padding: 14px;
+ border-radius: 12px;
+ scroll-margin-top: 76px;
}
.usage-first {
- padding: 18px;
+ padding: 12px;
}
.panel-title {
display: flex;
- gap: 16px;
+ gap: 10px;
align-items: baseline;
justify-content: space-between;
- margin-bottom: 18px;
+ margin-bottom: 10px;
}
.panel-title h2 {
margin: 0;
font-family: 'Georgia', 'Noto Serif KR', serif;
- font-size: clamp(26px, 3vw, 40px);
- letter-spacing: -0.05em;
+ font-size: clamp(16px, 1.8vw, 20px);
+ letter-spacing: -0.03em;
+}
+
+.panel-title span {
+ color: var(--muted-soft);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
}
.table-wrap {
overflow: auto;
- border: 1px solid rgba(43, 55, 38, 0.1);
- border-radius: 22px;
- background: rgba(255, 250, 240, 0.56);
+ border: 1px solid var(--panel-border);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.03);
}
.mobile-record-list {
@@ -626,7 +1289,7 @@ th {
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
- background: rgba(255, 250, 240, 0.7);
+ background: rgba(255, 255, 255, 0.04);
}
tr:last-child td {
@@ -656,41 +1319,47 @@ td small {
padding: 5px 10px;
border-radius: 999px;
font-size: 12px;
- font-weight: 850;
+ font-weight: 700;
white-space: nowrap;
}
.pill-processing,
.pill-active {
- color: #173e23;
- background: rgba(63, 127, 81, 0.18);
+ color: var(--status-ok);
+ background: rgba(108, 176, 135, 0.14);
+ border: 1px solid rgba(108, 176, 135, 0.28);
}
.pill-waiting,
.pill-paused {
- color: #6e4a05;
- background: rgba(181, 138, 34, 0.2);
+ color: var(--status-warn);
+ background: rgba(212, 160, 74, 0.14);
+ border: 1px solid rgba(212, 160, 74, 0.28);
}
.pill-inactive,
.pill-completed {
- color: #625d52;
- background: rgba(109, 115, 95, 0.16);
+ color: var(--muted);
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.08);
}
.pill-ok {
- color: #173e23;
- background: rgba(63, 127, 81, 0.18);
+ color: var(--status-ok);
+ background: rgba(108, 176, 135, 0.14);
+ border: 1px solid rgba(108, 176, 135, 0.28);
}
.pill-warn {
- color: #6e4a05;
- background: rgba(181, 138, 34, 0.2);
+ color: var(--status-warn);
+ background: rgba(212, 160, 74, 0.14);
+ border: 1px solid rgba(212, 160, 74, 0.28);
}
.pill-critical {
- color: #7c2518;
- background: rgba(183, 71, 52, 0.18);
+ color: var(--status-critical);
+ background: rgba(224, 100, 75, 0.14);
+ border: 1px solid rgba(224, 100, 75, 0.32);
}
.usage-dashboard {
@@ -706,9 +1375,9 @@ td small {
.usage-summary div,
.usage-matrix {
- border: 1px solid rgba(43, 55, 38, 0.1);
+ border: 1px solid var(--panel-border);
border-radius: 18px;
- background: rgba(255, 250, 240, 0.62);
+ background: rgba(255, 255, 255, 0.03);
}
.usage-summary div {
@@ -750,10 +1419,10 @@ td small {
padding: 9px 12px;
color: var(--muted);
font-size: 11px;
- font-weight: 900;
+ font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
- background: rgba(43, 55, 38, 0.055);
+ background: rgba(255, 255, 255, 0.03);
}
.usage-group {
@@ -768,7 +1437,7 @@ td small {
padding: 7px 10px;
color: var(--accent-strong);
font-size: 11px;
- font-weight: 950;
+ font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
background: rgba(191, 95, 44, 0.07);
@@ -823,7 +1492,7 @@ td small {
.usage-speed span {
color: var(--muted);
font-size: 11px;
- font-weight: 850;
+ font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
@@ -838,7 +1507,7 @@ td small {
.usage-quota {
padding: 6px;
border-radius: 13px;
- background: rgba(43, 55, 38, 0.035);
+ background: rgba(255, 255, 255, 0.02);
}
.usage-quota-tight {
@@ -865,7 +1534,7 @@ td small {
align-content: center;
padding: 6px;
border-radius: 14px;
- background: rgba(43, 55, 38, 0.045);
+ background: rgba(255, 255, 255, 0.025);
}
.usage-speed strong {
@@ -891,11 +1560,11 @@ progress {
overflow: hidden;
border: 0;
border-radius: 999px;
- background: rgba(43, 55, 38, 0.1);
+ background: rgba(255, 255, 255, 0.06);
}
progress::-webkit-progress-bar {
- background: rgba(43, 55, 38, 0.1);
+ background: rgba(255, 255, 255, 0.06);
}
progress::-webkit-progress-value {
@@ -909,27 +1578,30 @@ progress::-moz-progress-bar {
}
.empty-state {
- padding: 24px;
- border: 1px dashed rgba(43, 55, 38, 0.2);
- border-radius: 22px;
+ padding: 14px;
+ border: 1px dashed rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
color: var(--muted);
- background: rgba(255, 250, 240, 0.45);
+ background: rgba(255, 255, 255, 0.02);
}
.pill-info {
- color: #625d52;
- background: rgba(109, 115, 95, 0.16);
+ color: var(--status-info);
+ background: rgba(107, 160, 224, 0.14);
+ border: 1px solid rgba(107, 160, 224, 0.28);
}
.pill-error,
.pill-down {
- color: #7c2518;
- background: rgba(183, 71, 52, 0.18);
+ color: var(--status-critical);
+ background: rgba(224, 100, 75, 0.14);
+ border: 1px solid rgba(224, 100, 75, 0.32);
}
.pill-stale {
- color: #6e4a05;
- background: rgba(181, 138, 34, 0.2);
+ color: var(--status-warn);
+ background: rgba(212, 160, 74, 0.14);
+ border: 1px solid rgba(212, 160, 74, 0.28);
}
.inbox-board,
@@ -943,6 +1615,8 @@ progress::-moz-progress-bar {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
+ min-width: 0;
+ max-width: 100%;
}
.inbox-summary div,
@@ -954,9 +1628,13 @@ progress::-moz-progress-bar {
.health-service-details,
.health-restart-log,
.health-restart-record {
- border: 1px solid rgba(43, 55, 38, 0.1);
- border-radius: 18px;
- background: rgba(255, 250, 240, 0.64);
+ border: 1px solid var(--panel-border);
+ border-radius: 14px;
+ background: var(--bg-card);
+}
+
+.inbox-card {
+ box-shadow: var(--card-shadow);
}
.inbox-summary div,
@@ -990,16 +1668,16 @@ progress::-moz-progress-bar {
.inbox-filters button {
display: inline-flex;
- min-height: 38px;
+ min-height: 44px;
flex: none;
gap: 7px;
align-items: center;
- padding: 0 12px;
+ padding: 0 14px;
color: var(--bg-ink);
- background: rgba(43, 55, 38, 0.08);
+ background: rgba(255, 255, 255, 0.05);
box-shadow: none;
font-size: 13px;
- font-weight: 900;
+ font-weight: 800;
}
.inbox-filters button.is-active,
@@ -1015,24 +1693,48 @@ progress::-moz-progress-bar {
.inbox-list {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
- gap: 10px;
+ grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
+ gap: 12px;
}
.inbox-card {
display: grid;
gap: 10px;
min-width: 0;
- padding: 13px;
+ padding: 14px 14px 14px 18px;
+ position: relative;
+ overflow: hidden;
+}
+
+.inbox-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 4px;
+ background: rgba(255, 255, 255, 0.12);
}
.inbox-error {
- border-color: rgba(183, 71, 52, 0.26);
- background: rgba(255, 244, 238, 0.72);
+ border-color: rgba(183, 71, 52, 0.32);
+ background: rgba(255, 244, 238, 0.78);
+}
+
+.inbox-error::before {
+ background: var(--status-critical);
}
.inbox-warn {
- border-color: rgba(181, 138, 34, 0.24);
+ border-color: rgba(181, 138, 34, 0.3);
+}
+
+.inbox-warn::before {
+ background: var(--status-warn);
+}
+
+.inbox-info::before {
+ background: var(--status-info);
}
.inbox-card-head,
@@ -1051,8 +1753,6 @@ progress::-moz-progress-bar {
}
.inbox-card-head strong,
-.inbox-card p,
-.inbox-meta strong,
.health-service strong,
.health-service small {
min-width: 0;
@@ -1061,16 +1761,45 @@ progress::-moz-progress-bar {
white-space: nowrap;
}
+.inbox-meta strong {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--text-sm);
+}
+
+.inbox-meta span:first-child strong {
+ font-variant-numeric: tabular-nums;
+ white-space: normal;
+ word-spacing: -0.05em;
+}
+
+.inbox-card-head strong {
+ font-size: var(--text-md);
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+
.inbox-card p {
margin: 0;
color: var(--bg-ink);
+ font-size: var(--text-base);
+ line-height: 1.5;
overflow-wrap: anywhere;
white-space: normal;
}
+.inbox-meta small {
+ color: var(--muted-soft);
+ font-size: var(--text-xs);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
.inbox-meta {
display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 7px;
}
@@ -1078,7 +1807,7 @@ progress::-moz-progress-bar {
min-width: 0;
padding: 8px;
border-radius: 12px;
- background: rgba(43, 55, 38, 0.045);
+ background: rgba(255, 255, 255, 0.025);
}
.inbox-meta small,
@@ -1096,7 +1825,7 @@ progress::-moz-progress-bar {
color: var(--accent-strong);
background: rgba(191, 95, 44, 0.11);
font-size: 13px;
- font-weight: 900;
+ font-weight: 800;
text-decoration: none;
}
@@ -1161,7 +1890,7 @@ progress::-moz-progress-bar {
justify-content: space-between;
color: var(--bg-ink);
font-size: 13px;
- font-weight: 900;
+ font-weight: 800;
list-style: none;
}
@@ -1237,7 +1966,7 @@ progress::-moz-progress-bar {
justify-content: space-between;
color: var(--bg-ink);
font-size: 13px;
- font-weight: 900;
+ font-weight: 800;
list-style: none;
}
@@ -1281,7 +2010,7 @@ progress::-moz-progress-bar {
cursor: pointer;
color: var(--muted);
font-size: 12px;
- font-weight: 850;
+ font-weight: 700;
list-style: none;
}
@@ -1291,40 +2020,97 @@ progress::-moz-progress-bar {
.room-compose {
display: grid;
- min-width: min(240px, 100%);
- gap: 8px;
+ grid-template-columns: minmax(0, 1fr) auto;
+ column-gap: 6px;
+ align-items: end;
+ width: 100%;
+ padding: 3px 4px 3px 14px;
+ border: 1px solid var(--panel-border-strong);
+ border-radius: 12px;
+ background: var(--bg-card);
+ transition:
+ border-color 0.15s ease,
+ box-shadow 0.15s ease;
+}
+
+.room-compose:focus-within {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(214, 130, 88, 0.18);
}
.room-compose textarea {
+ display: block;
width: 100%;
- min-height: 54px;
- resize: vertical;
- border: 1px solid rgba(43, 55, 38, 0.14);
- border-radius: 14px;
- padding: 10px 11px;
- color: var(--ink);
- background: rgba(255, 250, 240, 0.68);
+ min-height: 22px;
+ max-height: 200px;
+ resize: none;
+ overflow-y: auto;
+ margin: 0;
+ border: 0;
+ border-radius: 0;
+ padding: 4px 0;
+ color: var(--bg-ink);
+ background: transparent;
font: inherit;
- line-height: 1.3;
+ font-size: 14px;
+ line-height: 1.45;
+ vertical-align: middle;
+}
+
+.room-compose textarea::placeholder {
+ color: var(--muted-soft);
+ opacity: 1;
}
.room-compose textarea:focus-visible {
- outline: 3px solid rgba(191, 95, 44, 0.22);
+ outline: 0;
}
.room-compose button {
- min-height: 38px;
+ display: inline-flex;
+ align-self: end;
+ width: 28px;
+ height: 28px;
+ min-height: 0;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ margin: 0;
border: 0;
border-radius: 999px;
- color: #fffaf0;
- background: var(--ink);
- font-weight: 900;
+ color: var(--muted-soft);
+ background: transparent;
cursor: pointer;
+ transition:
+ background 0.15s ease,
+ color 0.15s ease,
+ transform 0.05s ease;
+}
+
+.room-compose button:not(:disabled) {
+ color: #1a0f0a;
+ background: var(--accent);
+}
+
+.room-compose button:hover:not(:disabled) {
+ background: var(--accent-strong);
+}
+
+.room-compose button:active:not(:disabled) {
+ transform: scale(0.96);
}
.room-compose button:disabled {
cursor: not-allowed;
- opacity: 0.44;
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--faint);
+}
+
+.room-section.room-compose-section {
+ display: block;
+ padding: 8px 12px 14px;
+ border-top: 1px solid var(--divider);
+ background: var(--panel);
}
.room-activity-cell {
@@ -1336,9 +2122,9 @@ progress::-moz-progress-bar {
min-width: min(300px, 100%);
gap: 7px;
padding: 8px;
- border: 1px solid rgba(43, 55, 38, 0.1);
+ border: 1px solid var(--panel-border);
border-radius: 15px;
- background: rgba(255, 250, 240, 0.58);
+ background: rgba(255, 255, 255, 0.03);
}
.room-activity-empty,
@@ -1356,7 +2142,7 @@ progress::-moz-progress-bar {
min-width: 0;
color: var(--muted);
font-size: 11px;
- font-weight: 850;
+ font-weight: 700;
}
.room-progress-strip strong,
@@ -1390,7 +2176,7 @@ progress::-moz-progress-bar {
.room-activity-error {
color: #7c2518;
font-size: 12px;
- font-weight: 850;
+ font-weight: 700;
overflow-wrap: anywhere;
}
@@ -1400,7 +2186,7 @@ progress::-moz-progress-bar {
min-width: 0;
padding: 8px;
border-radius: 12px;
- background: rgba(43, 55, 38, 0.045);
+ background: rgba(255, 255, 255, 0.025);
}
.room-latest-output header {
@@ -1410,7 +2196,7 @@ progress::-moz-progress-bar {
justify-content: space-between;
color: var(--muted);
font-size: 11px;
- font-weight: 850;
+ font-weight: 700;
}
.room-latest-output strong,
@@ -1449,7 +2235,7 @@ progress::-moz-progress-bar {
min-height: 32px;
color: var(--accent);
font-size: 12px;
- font-weight: 900;
+ font-weight: 800;
cursor: pointer;
}
@@ -1478,7 +2264,7 @@ progress::-moz-progress-bar {
min-width: 0;
padding: 8px;
border-radius: 13px;
- background: rgba(43, 55, 38, 0.045);
+ background: rgba(255, 255, 255, 0.025);
}
.room-message-bot {
@@ -1493,7 +2279,7 @@ progress::-moz-progress-bar {
justify-content: space-between;
color: var(--muted);
font-size: 11px;
- font-weight: 850;
+ font-weight: 700;
}
.room-output-item header span,
@@ -1548,9 +2334,9 @@ progress::-moz-progress-bar {
gap: 8px;
align-items: end;
padding: 12px;
- border: 1px solid rgba(43, 55, 38, 0.11);
+ border: 1px solid var(--panel-border);
border-radius: 22px;
- background: rgba(255, 250, 240, 0.72);
+ background: rgba(255, 255, 255, 0.04);
}
.task-edit-form {
@@ -1580,7 +2366,7 @@ progress::-moz-progress-bar {
gap: 4px;
color: var(--muted);
font-size: 11px;
- font-weight: 850;
+ font-weight: 700;
}
.task-create-form input,
@@ -1591,11 +2377,11 @@ progress::-moz-progress-bar {
.task-edit-form textarea {
width: 100%;
min-height: 38px;
- border: 1px solid rgba(43, 55, 38, 0.14);
+ border: 1px solid var(--panel-border-strong);
border-radius: 13px;
padding: 8px 10px;
color: var(--ink);
- background: rgba(255, 250, 240, 0.86);
+ background: rgba(255, 255, 255, 0.04);
font: inherit;
font-size: 13px;
}
@@ -1621,11 +2407,11 @@ progress::-moz-progress-bar {
border: 0;
border-radius: 999px;
padding: 0 14px;
- color: #fffaf0;
- background: var(--ink);
+ color: var(--bg);
+ background: var(--accent);
font: inherit;
font-size: 12px;
- font-weight: 950;
+ font-weight: 800;
cursor: pointer;
}
@@ -1643,9 +2429,9 @@ progress::-moz-progress-bar {
display: grid;
gap: 10px;
padding: 12px;
- border: 1px solid rgba(43, 55, 38, 0.11);
+ border: 1px solid var(--panel-border);
border-radius: 22px;
- background: rgba(255, 250, 240, 0.54);
+ background: rgba(255, 255, 255, 0.02);
}
.task-group-completed {
@@ -1689,10 +2475,10 @@ progress::-moz-progress-bar {
display: grid;
gap: 9px;
min-width: 0;
- padding: 12px;
- border: 1px solid rgba(43, 55, 38, 0.1);
- border-radius: 18px;
- background: rgba(255, 250, 240, 0.72);
+ padding: 14px;
+ border: 1px solid var(--panel-border);
+ border-radius: 14px;
+ background: var(--bg-card);
}
.task-card-main,
@@ -1738,7 +2524,7 @@ progress::-moz-progress-bar {
color: var(--muted);
font-size: 12px;
font-weight: 800;
- background: rgba(43, 55, 38, 0.08);
+ background: rgba(255, 255, 255, 0.05);
}
.task-actions {
@@ -1748,15 +2534,15 @@ progress::-moz-progress-bar {
}
.task-action {
- min-height: 34px;
- padding: 0 12px;
- border: 1px solid rgba(43, 55, 38, 0.12);
+ min-height: 40px;
+ padding: 0 14px;
+ border: 1px solid var(--panel-border-strong);
border-radius: 999px;
color: var(--ink);
font: inherit;
font-size: 12px;
- font-weight: 900;
- background: rgba(255, 250, 240, 0.84);
+ font-weight: 800;
+ background: rgba(255, 255, 255, 0.04);
cursor: pointer;
}
@@ -1772,6 +2558,34 @@ progress::-moz-progress-bar {
opacity: 0.55;
}
+.task-action.is-busy {
+ display: inline-flex;
+ gap: 7px;
+ align-items: center;
+ justify-content: center;
+ cursor: progress;
+ opacity: 0.85;
+ background: rgba(191, 95, 44, 0.12);
+ border-color: rgba(191, 95, 44, 0.32);
+}
+
+.task-action.is-busy::before {
+ content: '';
+ display: block;
+ width: 12px;
+ height: 12px;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: ej-spin 0.7s linear infinite;
+}
+
+@keyframes ej-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
.task-action-cancel {
color: #8f2f22;
border-color: rgba(183, 71, 52, 0.22);
@@ -1788,9 +2602,9 @@ progress::-moz-progress-bar {
.task-suspended,
.task-result {
padding: 9px;
- border: 1px solid rgba(43, 55, 38, 0.08);
+ border: 1px solid var(--panel-border);
border-radius: 14px;
- background: rgba(43, 55, 38, 0.04);
+ background: rgba(255, 255, 255, 0.025);
}
.task-time-grid small,
@@ -1801,7 +2615,7 @@ progress::-moz-progress-bar {
color: var(--muted);
font-size: 11px;
font-style: normal;
- font-weight: 780;
+ font-weight: 600;
}
.task-time-grid small,
@@ -1856,7 +2670,7 @@ progress::-moz-progress-bar {
min-height: 32px;
color: var(--accent-strong);
font-size: 12px;
- font-weight: 900;
+ font-weight: 800;
cursor: pointer;
}
@@ -1879,7 +2693,7 @@ progress::-moz-progress-bar {
padding: 12px;
border-radius: 14px;
color: var(--muted);
- background: rgba(43, 55, 38, 0.04);
+ background: rgba(255, 255, 255, 0.025);
}
.pill-watchers {
@@ -1906,9 +2720,9 @@ progress::-moz-progress-bar {
display: grid;
gap: 14px;
padding: 16px;
- border: 1px solid rgba(43, 55, 38, 0.12);
+ border: 1px solid var(--panel-border-strong);
border-radius: 22px;
- background: rgba(255, 250, 240, 0.68);
+ background: rgba(255, 255, 255, 0.03);
}
.record-card-head {
@@ -1932,9 +2746,9 @@ progress::-moz-progress-bar {
.record-card-grid > span {
min-width: 0;
padding: 11px;
- border: 1px solid rgba(43, 55, 38, 0.08);
+ border: 1px solid var(--panel-border);
border-radius: 16px;
- background: rgba(43, 55, 38, 0.045);
+ background: rgba(255, 255, 255, 0.025);
}
.record-card-grid small,
@@ -1972,7 +2786,7 @@ progress::-moz-progress-bar {
overflow: hidden;
border-radius: 999px;
color: var(--muted);
- background: rgba(43, 55, 38, 0.08);
+ background: rgba(255, 255, 255, 0.05);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -2079,13 +2893,23 @@ progress::-moz-progress-bar {
}
.shell {
- width: min(100% - 20px, 1480px);
+ width: min(100% - 16px, 1480px);
padding: 10px 0 calc(24px + env(safe-area-inset-bottom));
}
.panel {
border-radius: 22px;
- padding: 18px;
+ padding: 14px;
+ }
+
+ .inbox-summary div,
+ .health-signals div {
+ padding: 10px;
+ }
+
+ .inbox-summary strong,
+ .health-signals strong {
+ font-size: clamp(20px, 5vw, 26px);
}
.section-nav {
@@ -2093,11 +2917,13 @@ progress::-moz-progress-bar {
top: max(8px, env(safe-area-inset-top));
right: 10px;
left: 10px;
- grid-template-columns: 52px minmax(0, 1fr) auto 54px;
+ grid-template-columns: 48px minmax(0, 1fr) minmax(0, auto) 48px;
gap: 6px;
margin: 0;
padding: 8px;
border-radius: 24px;
+ max-width: 100%;
+ overflow: hidden;
}
.section-nav button {
@@ -2126,15 +2952,18 @@ progress::-moz-progress-bar {
.topbar-status {
min-height: 30px;
+ max-width: 100%;
padding: 0 8px;
+ overflow: hidden;
font-size: 10px;
+ text-overflow: ellipsis;
}
.section-nav .refresh-button::before {
color: #fffaf0;
content: '↻';
font-size: 20px;
- font-weight: 900;
+ font-weight: 800;
}
.section-nav .refresh-button:disabled::before {
@@ -2298,8 +3127,8 @@ progress::-moz-progress-bar {
}
.task-action {
- min-height: 32px;
- padding-inline: 10px;
+ min-height: 44px;
+ padding-inline: 12px;
}
.task-card-main {
@@ -2405,3 +3234,1360 @@ progress::-moz-progress-bar {
transition-duration: 0.01ms !important;
}
}
+
+/* ============================================================
+ Rooms v3 — minimal dark list
+ ============================================================ */
+
+.rooms-v2 {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-height: 0;
+}
+
+.rooms-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 2px 6px;
+ border-bottom: 1px solid var(--divider);
+}
+
+.rooms-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.rooms-filters button {
+ display: inline-flex;
+ min-height: 32px;
+ align-items: center;
+ gap: 6px;
+ padding: 0 12px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--muted);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition:
+ color 0.12s ease,
+ background 0.12s ease;
+}
+
+.rooms-filters button span {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.06);
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--muted-soft);
+}
+
+.rooms-filters button:hover {
+ color: var(--bg-ink);
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.rooms-filters button.is-active {
+ color: var(--bg-ink);
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.rooms-filters button.is-active span {
+ background: rgba(255, 255, 255, 0.1);
+ color: var(--bg-ink);
+}
+
+.rooms-sort {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 11px;
+ color: var(--muted-soft);
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.rooms-sort select {
+ min-height: 32px;
+ padding: 0 24px 0 10px;
+ border: 0;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--bg-ink);
+ font: inherit;
+ font-size: 13px;
+ font-weight: 500;
+ text-transform: none;
+ letter-spacing: 0;
+ cursor: pointer;
+}
+
+.rooms-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.rooms-twopane {
+ display: grid;
+ grid-template-columns: 240px minmax(0, 1fr);
+ gap: 8px;
+ align-items: stretch;
+ min-height: 0;
+}
+
+@media (max-width: 900px) {
+ .rooms-twopane {
+ grid-template-columns: 1fr;
+ }
+}
+
+.rooms-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ padding: 4px;
+ border: 1px solid var(--panel-border);
+ border-radius: 8px;
+ background: var(--bg-elevated);
+ overflow-y: auto;
+ min-height: 0;
+}
+
+.rooms-list-item {
+ display: grid;
+ grid-template-columns: 14px minmax(0, 1fr) auto auto;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--bg-ink);
+ cursor: pointer;
+ text-align: left;
+ font: inherit;
+ transition: background 0.12s ease;
+}
+
+.rooms-list-item:hover {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.rooms-list-item.is-active {
+ background: rgba(214, 130, 88, 0.12);
+ box-shadow: inset 2px 0 0 var(--accent);
+}
+
+.rooms-list-text {
+ display: grid;
+ gap: 1px;
+ min-width: 0;
+}
+
+.rooms-list-text strong {
+ font-size: 13px;
+ font-weight: 700;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+
+.rooms-list-text small {
+ font-size: 10px;
+ color: var(--muted-soft);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+
+.rooms-list-bell {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: 9px;
+ font-size: 10px;
+ font-weight: 800;
+ color: #1a0f0a;
+}
+
+.rooms-list-bell.sev-info {
+ background: var(--status-info);
+}
+
+.rooms-list-bell.sev-warn {
+ background: var(--status-warn);
+}
+
+.rooms-list-bell.sev-error {
+ background: var(--status-critical);
+}
+
+.rooms-list-queue {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: 9px;
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+
+.rooms-detail {
+ min-width: 0;
+ min-height: 0;
+ border: 1px solid var(--panel-border);
+ border-radius: 8px;
+ background: var(--panel);
+ padding: 0;
+ overflow-y: auto;
+}
+
+.rooms-detail .room-card-v2 {
+ border-bottom: 0;
+ padding: 12px 14px;
+}
+
+.rooms-detail .room-card-v2.is-expanded {
+ padding-bottom: 14px;
+}
+
+.live-elapsed {
+ color: var(--accent-strong);
+ font-variant-numeric: tabular-nums;
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.room-card-v2 {
+ display: grid;
+ gap: 0;
+ padding: 8px 12px;
+ border: 0;
+ border-bottom: 1px solid var(--divider);
+ border-radius: 0;
+ background: transparent;
+ box-shadow: none;
+ transition: background 0.12s ease;
+ position: relative;
+ cursor: pointer;
+}
+
+.room-card-v2:hover {
+ background: rgba(255, 255, 255, 0.025);
+}
+
+.room-card-v2:focus-visible {
+ outline: 0;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.room-card-v2.is-expanded {
+ cursor: default;
+}
+
+.room-card-v2.is-expanded .room-card-head {
+ cursor: pointer;
+}
+
+.room-card-v2:last-child {
+ border-bottom: 0;
+}
+
+.room-card-v2.is-expanded {
+ background: rgba(255, 255, 255, 0.02);
+ padding-bottom: 12px;
+}
+
+.room-card-head {
+ display: grid;
+ grid-template-columns: 14px minmax(0, 1fr) auto auto;
+ gap: 12px;
+ align-items: center;
+ width: 100%;
+}
+
+.room-pulse {
+ position: relative;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.12);
+ margin-left: 3px;
+}
+
+.room-pulse.pulse-processing {
+ background: var(--status-ok);
+}
+
+.room-pulse.pulse-waiting {
+ background: var(--status-warn);
+}
+
+.room-pulse.pulse-inactive {
+ background: rgba(255, 255, 255, 0.18);
+}
+
+.room-pulse.pulse-processing .pulse-dot {
+ position: absolute;
+ inset: -2px;
+ border-radius: 50%;
+ background: rgba(108, 176, 135, 0.4);
+ animation: pulse-ring 1.6s ease-out infinite;
+}
+
+@keyframes pulse-ring {
+ 0% {
+ transform: scale(0.7);
+ opacity: 0.7;
+ }
+ 100% {
+ transform: scale(2.4);
+ opacity: 0;
+ }
+}
+
+.room-title-block {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: baseline;
+ min-width: 0;
+}
+
+.room-title-block strong {
+ font-size: 15px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ color: var(--bg-ink);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.room-sub {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: baseline;
+ font-size: 12px;
+ color: var(--muted-soft);
+}
+
+.room-sub code,
+.room-sub em {
+ background: transparent;
+ padding: 0;
+ border-radius: 0;
+ font-family: inherit;
+ font-style: normal;
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+.room-status {
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ padding: 2px 8px;
+}
+
+.room-toggle {
+ font-size: 11px;
+ color: var(--faint);
+ font-weight: 600;
+ width: 18px;
+ text-align: center;
+}
+
+/* meta inline (simplified) */
+.room-card-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0;
+ margin-top: 6px;
+ padding-left: 26px;
+ font-size: 12px;
+ color: var(--muted-soft);
+}
+
+.meta-cell {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 4px;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ min-width: 0;
+}
+
+.meta-cell + .meta-cell::before {
+ content: '·';
+ margin: 0 8px;
+ color: var(--faint);
+}
+
+.meta-cell small {
+ color: var(--faint);
+ font-size: 11px;
+ font-weight: 500;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.meta-cell strong {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.room-current-turn {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: baseline;
+ padding: 0;
+ margin-top: 8px;
+ padding-left: 26px;
+ border: 0;
+ background: transparent;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.room-current-turn .pill {
+ padding: 1px 7px;
+ font-size: 10px;
+}
+
+.room-current-turn > span {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: baseline;
+ min-width: 0;
+}
+
+.room-current-turn strong + em {
+ margin-left: 0;
+ color: var(--muted-soft);
+ font-size: 11px;
+}
+
+.room-live > header strong + em {
+ color: var(--muted-soft);
+ font-size: 11px;
+}
+
+.room-current-turn strong {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--bg-ink);
+}
+
+.room-current-turn em {
+ font-style: normal;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.room-current-turn small {
+ font-size: 11px;
+ color: var(--muted-soft);
+ font-weight: 500;
+}
+
+.room-executor {
+ font-size: 11px;
+ color: var(--muted-soft);
+ font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
+ padding: 0;
+ background: transparent;
+}
+
+.room-preview {
+ display: grid;
+ gap: 4px;
+ padding: 0;
+ margin-top: 10px;
+ padding-left: 26px;
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+}
+
+.room-preview > header {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ font-size: 11px;
+ color: var(--muted-soft);
+}
+
+.room-preview > header em {
+ font-style: normal;
+}
+
+.room-preview > header time {
+ margin-left: auto;
+ font-variant-numeric: tabular-nums;
+}
+
+.room-preview .parsed-body {
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.room-live {
+ display: grid;
+ gap: 4px;
+ padding: 8px 12px;
+ margin-top: 10px;
+ margin-left: 26px;
+ background: linear-gradient(
+ 90deg,
+ rgba(108, 176, 135, 0.06) 0%,
+ rgba(108, 176, 135, 0.015) 70%,
+ transparent 100%
+ );
+ border-left: 2px solid var(--status-ok);
+ border-radius: 0 6px 6px 0;
+}
+
+.room-live > header {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.room-live > header strong {
+ font-size: 12px;
+ color: var(--bg-ink);
+ font-weight: 600;
+}
+
+.room-live > header em {
+ font-style: normal;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.room-live > header .live-state {
+ font-size: 10px;
+ padding: 1px 7px;
+}
+
+.room-live > header .live-exec {
+ font-size: 11px;
+ color: var(--muted-soft);
+ font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
+}
+
+.room-live > header .live-attempt {
+ font-size: 11px;
+ color: var(--status-warn);
+ font-weight: 600;
+}
+
+.room-live > header time {
+ margin-left: auto;
+ font-variant-numeric: tabular-nums;
+ color: var(--muted-soft);
+ font-size: 11px;
+}
+
+.room-live .live-run {
+ display: inline-block;
+ font-size: 10px;
+ font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
+ color: var(--faint);
+ background: transparent;
+ padding: 0;
+ border: 0;
+ word-break: break-all;
+}
+
+.live-progress {
+ margin: 0;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ font-family: inherit;
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--bg-ink);
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+}
+
+.room-timeline-paused .live-progress {
+ opacity: 0.7;
+}
+
+.paused-dot {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--muted-soft);
+}
+
+.live-label.paused {
+ color: var(--muted-soft);
+}
+
+.live-dot {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--status-ok);
+ position: relative;
+ box-shadow: 0 0 0 0 rgba(108, 176, 135, 0.7);
+ animation: live-pulse 1.6s ease-out infinite;
+}
+
+@keyframes live-pulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(108, 176, 135, 0.55);
+ }
+ 60% {
+ box-shadow: 0 0 0 7px rgba(108, 176, 135, 0);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(108, 176, 135, 0);
+ }
+}
+
+.live-label {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ color: var(--status-ok);
+}
+
+.room-live .parsed-body {
+ font-size: 13px;
+ color: var(--bg-ink);
+}
+
+.room-live .parsed-body .parsed-p:last-child::after {
+ content: '';
+ display: inline-block;
+ width: 6px;
+ height: 14px;
+ margin-left: 3px;
+ background: var(--status-ok);
+ vertical-align: text-bottom;
+ animation: live-cursor 1s steps(2) infinite;
+ opacity: 0.8;
+}
+
+@keyframes live-cursor {
+ 0%,
+ 50% {
+ opacity: 0.85;
+ }
+ 51%,
+ 100% {
+ opacity: 0;
+ }
+}
+
+/* Watcher (CI bot etc.) — separated, minimal */
+.room-watcher-strip {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 8px;
+ margin-top: 8px;
+ padding-left: 26px;
+ font-size: 11px;
+ color: var(--muted-soft);
+}
+
+.watcher-tag {
+ display: inline-flex;
+ align-items: center;
+ font-size: 10px;
+ font-weight: 700;
+ padding: 1px 7px;
+ border-radius: 4px;
+ background: rgba(107, 160, 224, 0.1);
+ border: 1px solid rgba(107, 160, 224, 0.22);
+ color: var(--status-info);
+ letter-spacing: 0.06em;
+}
+
+.watcher-line {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 6px;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.watcher-line strong {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--muted);
+}
+
+.watcher-line span {
+ font-size: 11px;
+ color: var(--muted-soft);
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.room-watcher-strip time {
+ margin-left: auto;
+ font-size: 10px;
+ color: var(--faint);
+ font-variant-numeric: tabular-nums;
+}
+
+.room-watcher-fold {
+ margin-top: 4px;
+ padding: 6px 10px;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.02);
+ font-size: 12px;
+}
+
+.room-watcher-fold summary {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ cursor: pointer;
+ list-style: none;
+ color: var(--muted-soft);
+}
+
+.room-watcher-fold summary::-webkit-details-marker {
+ display: none;
+}
+
+.room-watcher-fold summary::before {
+ content: '▸';
+ display: inline-block;
+ font-size: 10px;
+ color: var(--faint);
+ transition: transform 0.15s ease;
+}
+
+.room-watcher-fold[open] summary::before {
+ transform: rotate(90deg);
+}
+
+.room-watcher-fold summary small {
+ font-size: 10px;
+ color: var(--faint);
+}
+
+.room-watcher-list {
+ list-style: none;
+ margin: 8px 0 0 0;
+ padding: 0;
+ display: grid;
+ gap: 10px;
+}
+
+.room-watcher-item {
+ display: grid;
+ gap: 4px;
+ padding: 8px 10px;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.02);
+ border-left: 2px solid rgba(107, 160, 224, 0.4);
+}
+
+.room-watcher-item header {
+ display: flex;
+ gap: 8px;
+ align-items: baseline;
+ font-size: 11px;
+}
+
+.room-watcher-item header strong {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--muted);
+}
+
+.room-watcher-item header time {
+ margin-left: auto;
+ color: var(--muted-soft);
+ font-variant-numeric: tabular-nums;
+}
+
+.room-watcher-item .parsed-body {
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.room-empty {
+ margin: 6px 0 0 26px;
+ padding: 0;
+ color: var(--muted-soft);
+ font-size: 12px;
+ text-align: left;
+}
+
+/* expanded */
+.room-expanded {
+ display: grid;
+ gap: 18px;
+ padding: 14px 0 4px 26px;
+ margin-top: 10px;
+ border-top: 1px solid var(--divider);
+}
+
+.room-alert {
+ display: grid;
+ gap: 4px;
+ padding: 10px 12px;
+ border-radius: 8px;
+ background: rgba(224, 100, 75, 0.08);
+ border: 1px solid rgba(224, 100, 75, 0.2);
+ color: var(--status-critical);
+}
+
+.room-alert strong {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--status-critical);
+}
+
+.room-section {
+ display: grid;
+ gap: 8px;
+}
+
+.room-section > header {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ padding: 0;
+ border-bottom: 0;
+}
+
+.room-section > header h4 {
+ margin: 0;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--faint);
+}
+
+.room-section > header small {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--faint);
+ background: transparent;
+ padding: 0;
+ border-radius: 0;
+}
+
+/* timeline — flat list, no boxes */
+.room-timeline {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 0;
+}
+
+.room-timeline-item {
+ display: grid;
+ gap: 6px;
+ padding: 8px 0 8px 8px;
+ border: 0;
+ border-left: 2px solid var(--panel-border-strong);
+ border-radius: 0;
+ background: transparent;
+}
+
+.room-timeline-item + .room-timeline-item {
+ margin-top: 2px;
+ border-top: 1px solid var(--divider);
+}
+
+.room-timeline-item.role-section-owner {
+ border-left-color: var(--blue);
+}
+
+.room-timeline-item.role-section-reviewer {
+ border-left-color: var(--accent);
+}
+
+.room-timeline-item.role-section-arbiter {
+ border-left-color: var(--green);
+}
+
+.room-timeline-item.role-section-human {
+ border-left-color: var(--yellow);
+}
+
+.room-timeline-header {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: baseline;
+ font-size: 11px;
+}
+
+.room-timeline-header time {
+ color: var(--muted-soft);
+ font-variant-numeric: tabular-nums;
+ font-size: 10px;
+}
+
+.role-chip {
+ display: inline-flex;
+ align-items: baseline;
+ font-size: 12px;
+ font-weight: 800;
+ padding: 0;
+ border-radius: 0;
+ background: transparent;
+ color: var(--muted-soft);
+ width: fit-content;
+}
+
+.room-timeline-body {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.room-timeline-body .parsed-body,
+.room-timeline-body .parsed-body p,
+.room-timeline-body .parsed-body li,
+.room-timeline-body .parsed-body blockquote {
+ display: block;
+ overflow: visible;
+ -webkit-line-clamp: unset;
+ -webkit-box-orient: unset;
+ max-height: none;
+}
+
+/* thread — flat */
+.room-thread {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 14px;
+}
+
+.room-msg {
+ display: grid;
+ gap: 4px;
+ padding: 0;
+ border-radius: 0;
+ background: transparent;
+ border: 0;
+ max-width: 100%;
+}
+
+.room-msg.is-me header strong {
+ color: var(--accent);
+}
+
+.room-msg.is-bot header strong {
+ color: var(--status-ok);
+}
+
+.room-msg header {
+ display: flex;
+ gap: 8px;
+ align-items: baseline;
+ font-size: 11px;
+}
+
+.room-msg header strong {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--bg-ink);
+}
+
+.room-msg header small {
+ color: var(--faint);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.room-msg header time {
+ margin-left: auto;
+ color: var(--muted-soft);
+ font-variant-numeric: tabular-nums;
+}
+
+.room-detail-fold {
+ font-size: 11px;
+ color: var(--muted-soft);
+}
+
+.room-detail-fold summary {
+ cursor: pointer;
+ padding: 6px 0;
+}
+
+.room-id {
+ margin: 0;
+ padding: 8px 10px;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.03);
+ font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
+ font-size: 11px;
+ word-break: break-all;
+ color: var(--muted-soft);
+}
+
+/* ============================================================
+ Parsed body — message/output rendering
+ ============================================================ */
+
+.parsed-body {
+ display: grid;
+ gap: 6px;
+ font-size: 13px;
+ line-height: 1.55;
+}
+
+.parsed-empty {
+ color: var(--muted);
+}
+
+.parsed-p {
+ margin: 0;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
+
+.parsed-body {
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--bg-ink);
+ word-break: break-word;
+ overflow-wrap: anywhere;
+}
+
+.parsed-body > :first-child {
+ margin-top: 0;
+}
+
+.parsed-body > :last-child {
+ margin-bottom: 0;
+}
+
+.parsed-body p {
+ margin: 4px 0;
+ white-space: pre-wrap;
+}
+
+.parsed-body h1,
+.parsed-body h2,
+.parsed-body h3,
+.parsed-body h4,
+.parsed-body h5,
+.parsed-body h6 {
+ margin: 8px 0 4px;
+ letter-spacing: -0.01em;
+ color: var(--bg-ink);
+ font-weight: 700;
+}
+
+.parsed-body h1 {
+ font-size: 17px;
+}
+
+.parsed-body h2 {
+ font-size: 15px;
+}
+
+.parsed-body h3 {
+ font-size: 14px;
+}
+
+.parsed-body h4,
+.parsed-body h5,
+.parsed-body h6 {
+ font-size: 13px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--muted);
+ font-weight: 700;
+}
+
+.parsed-body ul,
+.parsed-body ol {
+ margin: 4px 0;
+ padding-left: 20px;
+}
+
+.parsed-body li {
+ margin: 2px 0;
+}
+
+.parsed-body blockquote {
+ margin: 6px 0;
+ padding: 4px 0 4px 10px;
+ border-left: 2px solid var(--panel-border-strong);
+ color: var(--muted);
+}
+
+.parsed-body strong {
+ font-weight: 700;
+ color: var(--bg-ink);
+}
+
+.parsed-body em {
+ font-style: italic;
+}
+
+.parsed-body del,
+.parsed-body s {
+ text-decoration: line-through;
+ opacity: 0.7;
+}
+
+.parsed-body a {
+ color: var(--accent-strong);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.parsed-body code {
+ padding: 1px 5px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.06);
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ font-size: 12px;
+}
+
+.parsed-body pre {
+ margin: 6px 0;
+ padding: 8px 10px;
+ border: 1px solid var(--panel-border);
+ border-radius: 6px;
+ background: rgba(0, 0, 0, 0.3);
+ overflow-x: auto;
+}
+
+.parsed-body pre code {
+ padding: 0;
+ background: transparent;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.parsed-body table {
+ margin: 6px 0;
+ border-collapse: collapse;
+ font-size: 12px;
+ width: auto;
+}
+
+.parsed-body th,
+.parsed-body td {
+ padding: 4px 8px;
+ border: 1px solid var(--panel-border);
+ text-align: left;
+}
+
+.parsed-body th {
+ background: rgba(255, 255, 255, 0.04);
+ font-weight: 700;
+}
+
+.parsed-body hr {
+ margin: 8px 0;
+ border: 0;
+ border-top: 1px solid var(--divider);
+}
+
+.parsed-icode {
+ padding: 1px 6px;
+ border-radius: 5px;
+ background: rgba(255, 255, 255, 0.05);
+ font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
+ font-size: 12px;
+}
+
+.parsed-code {
+ position: relative;
+ margin: 4px 0;
+ padding: 12px 14px;
+ border-radius: 12px;
+ background: #1c211c;
+ color: #e8e2cf;
+ font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
+ font-size: 12.5px;
+ line-height: 1.55;
+ overflow-x: auto;
+ white-space: pre;
+}
+
+.parsed-code em {
+ position: absolute;
+ top: 8px;
+ right: 12px;
+ color: rgba(232, 226, 207, 0.55);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ font-style: normal;
+ font-weight: 600;
+}
+
+.parsed-url {
+ color: var(--accent-strong);
+ text-decoration: underline;
+ text-decoration-color: rgba(143, 53, 27, 0.4);
+ text-underline-offset: 2px;
+ word-break: break-all;
+}
+
+.parsed-mention {
+ color: var(--accent-strong);
+ font-weight: 700;
+ background: rgba(191, 95, 44, 0.1);
+ padding: 0 4px;
+ border-radius: 4px;
+}
+
+.parsed-path {
+ padding: 1px 5px;
+ border-radius: 4px;
+ background: rgba(43, 55, 38, 0.05);
+ border: 1px solid var(--panel-border);
+ font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
+ font-size: 12px;
+ color: var(--bg-ink);
+ word-break: break-all;
+}
+
+.parsed-marker {
+ display: inline-flex;
+ align-items: center;
+ padding: 1px 6px;
+ border-radius: 5px;
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ vertical-align: middle;
+ text-transform: uppercase;
+}
+
+.parsed-marker-done {
+ background: rgba(47, 106, 66, 0.18);
+ color: var(--status-ok);
+ border: 1px solid rgba(47, 106, 66, 0.32);
+}
+
+.parsed-marker-fail {
+ background: rgba(155, 55, 36, 0.18);
+ color: var(--status-critical);
+ border: 1px solid rgba(155, 55, 36, 0.32);
+}
+
+.parsed-marker-info {
+ background: rgba(31, 79, 136, 0.16);
+ color: var(--status-info);
+ border: 1px solid rgba(31, 79, 136, 0.32);
+}
+
+/* Sender role colors */
+.role-owner {
+ color: var(--blue) !important;
+}
+.role-reviewer {
+ color: var(--accent) !important;
+}
+.role-arbiter {
+ color: var(--green) !important;
+}
+.role-human {
+ color: var(--yellow) !important;
+}
+.role-cron {
+ color: var(--muted) !important;
+}
+
+/* Override role-chip backgrounds to match role colors */
+.role-chip.role-owner {
+ background: rgba(107, 160, 224, 0.12);
+}
+.role-chip.role-reviewer {
+ background: rgba(214, 130, 88, 0.12);
+}
+.role-chip.role-arbiter {
+ background: rgba(108, 176, 135, 0.12);
+}
+.role-chip.role-human {
+ background: rgba(212, 160, 74, 0.12);
+}
+.role-chip.role-cron {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.room-timeline-item.role-section-cron {
+ border-left-color: var(--muted-soft);
+ opacity: 0.85;
+}
+
+.room-timeline-item.role-section-cron .parsed-body {
+ font-size: 12px;
+ color: var(--muted);
+}
+
+@media (max-width: 760px) {
+ .rooms-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .room-card-meta {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts
index 2f02257..7e2f7ea 100644
--- a/apps/dashboard/vite.config.ts
+++ b/apps/dashboard/vite.config.ts
@@ -9,10 +9,10 @@ export default defineConfig({
emptyOutDir: true,
},
server: {
- host: '127.0.0.1',
+ host: process.env.VITE_DEV_HOST ?? '127.0.0.1',
port: 5174,
proxy: {
- '/api': 'http://127.0.0.1:8734',
+ '/api': process.env.VITE_API_TARGET ?? 'http://127.0.0.1:8734',
},
},
});
diff --git a/bun.lock b/bun.lock
index d9060f2..08adc2d 100644
--- a/bun.lock
+++ b/bun.lock
@@ -9,10 +9,13 @@
"cron-parser": "^5.5.0",
"discord.js": "^14.18.0",
"ejclaw-runners-shared": "file:./runners/shared",
+ "lucide-react": "^1.11.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
+ "react-markdown": "^10.1.0",
+ "remark-gfm": "^4.0.1",
"yaml": "^2.8.2",
"zod": "^4.3.6",
},
@@ -26,6 +29,7 @@
"@vitest/coverage-v8": "^4.0.18",
"better-sqlite3": "^12.8.0",
"husky": "^9.1.7",
+ "playwright": "^1.59.1",
"prettier": "^3.8.1",
"typescript": "^5.7.0",
"vite": "^8.0.10",
@@ -228,18 +232,32 @@
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
+ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
+
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
+
+ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
+
+ "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
+
+ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
+
"@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
+
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="],
@@ -272,6 +290,8 @@
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
+ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
+
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"better-sqlite3": ["better-sqlite3@12.8.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ=="],
@@ -292,12 +312,24 @@
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
+ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
+
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
+ "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
+
+ "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
+
+ "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
+
+ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
+
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
+ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
+
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
@@ -318,14 +350,20 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
+
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
+
"discord-api-types": ["discord-api-types@0.38.41", "", {}, "sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ=="],
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
@@ -352,6 +390,10 @@
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
+ "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
+
+ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
+
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
@@ -368,6 +410,8 @@
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
+ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
+
"fast-copy": ["fast-copy@4.0.2", "", {}, "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -388,7 +432,7 @@
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
- "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+ "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -408,12 +452,18 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+ "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
+
+ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
+
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
+ "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
+
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"husky": ["husky@9.1.7", "", { "bin": "bin.js" }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
@@ -426,10 +476,22 @@
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
+ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
+
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
+ "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
+
+ "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
+
+ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
+
+ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
+
+ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@@ -478,6 +540,10 @@
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
+ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
+
+ "lucide-react": ["lucide-react@1.11.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g=="],
+
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
@@ -488,12 +554,100 @@
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
+ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
+
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+ "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
+
+ "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
+
+ "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
+
+ "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
+
+ "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
+
+ "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
+
+ "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
+
+ "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
+
+ "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
+
+ "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
+
+ "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
+
+ "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
+
+ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
+
+ "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
+
+ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
+
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
+ "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
+
+ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
+
+ "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
+
+ "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
+
+ "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
+
+ "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
+
+ "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
+
+ "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
+
+ "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
+
+ "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
+
+ "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
+
+ "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
+
+ "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
+
+ "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
+
+ "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
+
+ "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
+
+ "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
+
+ "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
+
+ "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
+
+ "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
+
+ "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
+
+ "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
+
+ "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
+
+ "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
+
+ "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
+
+ "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
+
+ "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
+
+ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
+
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
@@ -526,6 +680,8 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
+ "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
+
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -548,6 +704,10 @@
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
+ "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
+
+ "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
+
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
@@ -556,6 +716,8 @@
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
+ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
+
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
@@ -574,10 +736,20 @@
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
+ "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
+
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
+ "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
+
+ "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
+
+ "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
+
+ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
+
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
@@ -628,6 +800,8 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
+
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -638,8 +812,14 @@
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
+ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
+
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
+ "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
+
+ "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
+
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
@@ -658,6 +838,10 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
+ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
+
+ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
+
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -674,12 +858,28 @@
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
+
+ "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
+
+ "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
+
+ "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
+
+ "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
+
+ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
+
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
+ "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
+
+ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
+
"vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="],
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
@@ -698,22 +898,34 @@
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
+ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
+ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+
"pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="],
+ "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
"vitest/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"vitest/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
+ "vitest/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
"vitest/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
}
}
diff --git a/package.json b/package.json
index a647dfe..e70e16b 100644
--- a/package.json
+++ b/package.json
@@ -32,10 +32,13 @@
"cron-parser": "^5.5.0",
"discord.js": "^14.18.0",
"ejclaw-runners-shared": "file:./runners/shared",
+ "lucide-react": "^1.11.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
+ "react-markdown": "^10.1.0",
+ "remark-gfm": "^4.0.1",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
@@ -49,6 +52,7 @@
"@vitest/coverage-v8": "^4.0.18",
"better-sqlite3": "^12.8.0",
"husky": "^9.1.7",
+ "playwright": "^1.59.1",
"prettier": "^3.8.1",
"typescript": "^5.7.0",
"vite": "^8.0.10",
diff --git a/scripts/dash-inspect.ts b/scripts/dash-inspect.ts
new file mode 100644
index 0000000..61ca7fa
--- /dev/null
+++ b/scripts/dash-inspect.ts
@@ -0,0 +1,141 @@
+#!/usr/bin/env bun
+/**
+ * Dashboard inspector via Playwright.
+ *
+ * Usage:
+ * bun scripts/dash-inspect.ts shot # full-page screenshot
+ * bun scripts/dash-inspect.ts shot rooms # screenshot of #/rooms
+ * bun scripts/dash-inspect.ts measure '' # bounding box + computed style
+ * bun scripts/dash-inspect.ts click '' # click and screenshot after
+ * bun scripts/dash-inspect.ts eval '' # evaluate JS in page
+ * bun scripts/dash-inspect.ts dom '' # outerHTML of selector
+ * bun scripts/dash-inspect.ts route # navigate to #/
+ *
+ * Output: writes to /tmp/dash-shot.png + prints data to stdout.
+ */
+
+import { chromium, type BrowserContext, type Page } from 'playwright';
+
+const BASE = process.env.DASH_URL ?? 'http://100.101.210.95:5174';
+const SHOT = '/tmp/dash-shot.png';
+
+async function withPage(
+ fn: (page: Page, ctx: BrowserContext) => Promise,
+): Promise {
+ const browser = await chromium.launch({ headless: true });
+ const ctx = await browser.newContext({
+ viewport: { width: 1600, height: 1000 },
+ deviceScaleFactor: 1,
+ });
+ const page = await ctx.newPage();
+ try {
+ return await fn(page, ctx);
+ } finally {
+ await browser.close();
+ }
+}
+
+async function gotoRoute(page: Page, route?: string) {
+ const url = route ? `${BASE}/#/${route.replace(/^#?\/?/, '')}` : BASE;
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 });
+ await page.waitForTimeout(1500);
+}
+
+async function shot(page: Page) {
+ await page.screenshot({ path: SHOT, fullPage: false });
+ console.log(`screenshot saved: ${SHOT}`);
+}
+
+async function measure(page: Page, sel: string) {
+ const all = await page.$$(sel);
+ if (all.length === 0) {
+ console.log(`NOT FOUND: ${sel}`);
+ return;
+ }
+ for (let i = 0; i < Math.min(all.length, 6); i++) {
+ const el = all[i];
+ const box = await el.boundingBox();
+ const style = await el.evaluate((node: object) => {
+ const cs = (
+ globalThis as unknown as {
+ getComputedStyle: (e: object) => Record;
+ }
+ ).getComputedStyle(node);
+ return {
+ display: cs.display,
+ position: cs.position,
+ margin: cs.margin,
+ padding: cs.padding,
+ width: cs.width,
+ height: cs.height,
+ boxSizing: cs.boxSizing,
+ gridTemplateColumns: cs.gridTemplateColumns,
+ gridTemplateRows: cs.gridTemplateRows,
+ };
+ });
+ console.log(
+ `[${i}] ${sel}\n rect: ${box ? `x=${Math.round(box.x)} y=${Math.round(box.y)} w=${Math.round(box.width)} h=${Math.round(box.height)}` : 'none'}\n ${JSON.stringify(style)}`,
+ );
+ }
+}
+
+async function evalExpr(page: Page, expr: string) {
+ const result = await page.evaluate((e) => {
+ // eslint-disable-next-line no-eval
+ return eval(e);
+ }, expr);
+ console.log(JSON.stringify(result, null, 2));
+}
+
+async function dom(page: Page, sel: string) {
+ const el = await page.$(sel);
+ if (!el) {
+ console.log(`NOT FOUND: ${sel}`);
+ return;
+ }
+ const html = await el.evaluate(
+ (node: object) => (node as unknown as { outerHTML: string }).outerHTML,
+ );
+ console.log(
+ html.length > 4000 ? `${html.slice(0, 4000)}\n…(truncated)` : html,
+ );
+}
+
+async function clickAndShot(page: Page, sel: string) {
+ await page.click(sel);
+ await page.waitForTimeout(500);
+ await shot(page);
+}
+
+const [, , cmd = 'shot', ...rest] = process.argv;
+
+await withPage(async (page) => {
+ // Default route from rest[0] when shot/measure/etc; otherwise look for explicit `route` arg.
+ const routeArg =
+ cmd === 'shot' && rest[0] && !rest[0].startsWith('.') ? rest[0] : 'rooms';
+ await gotoRoute(page, routeArg);
+
+ switch (cmd) {
+ case 'shot':
+ await shot(page);
+ break;
+ case 'measure':
+ await measure(page, rest[0]);
+ break;
+ case 'click':
+ await clickAndShot(page, rest[0]);
+ break;
+ case 'eval':
+ await evalExpr(page, rest[0]);
+ break;
+ case 'dom':
+ await dom(page, rest[0]);
+ break;
+ case 'route':
+ await shot(page);
+ break;
+ default:
+ console.error(`unknown command: ${cmd}`);
+ process.exit(1);
+ }
+});
diff --git a/src/db.ts b/src/db.ts
index a9566bb..c460b80 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -22,6 +22,7 @@ export {
getOpenWorkItem,
getOpenWorkItemForChat,
getRecentChatMessages,
+ getRecentChatMessagesBatch,
hasMessage,
hasRecentRestartAnnouncement,
markWorkItemDelivered,
@@ -101,7 +102,10 @@ export {
getPairedTurnAttempts,
getPairedTurnById,
getPairedTurnOutputs,
+ getRecentPairedTurnOutputsForChat,
getPairedTurnsForTask,
+ getLatestPairedTurnForTask,
+ updatePairedTurnProgressText,
getPairedWorkspace,
getPendingServiceHandoffs,
insertPairedTurnOutput,
diff --git a/src/db/base-schema.ts b/src/db/base-schema.ts
index 4cae88e..7806d61 100644
--- a/src/db/base-schema.ts
+++ b/src/db/base-schema.ts
@@ -24,6 +24,7 @@ export function applyBaseSchema(database: Database): void {
FOREIGN KEY (chat_jid) REFERENCES chats(jid)
);
CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
+ CREATE INDEX IF NOT EXISTS idx_messages_chat_timestamp ON messages(chat_jid, timestamp DESC);
CREATE TABLE IF NOT EXISTS message_sequence (
id INTEGER PRIMARY KEY AUTOINCREMENT
);
diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts
index b8d3c3c..f0c20ce 100644
--- a/src/db/bootstrap.test.ts
+++ b/src/db/bootstrap.test.ts
@@ -39,6 +39,7 @@ function getExpectedSchemaMigrations(): Array<{
{ version: 12, name: 'paired_verdict_and_step_telemetry' },
{ version: 13, name: 'message_source_kind' },
{ version: 14, name: 'work_item_attachments' },
+ { version: 15, name: 'turn_progress_text' },
];
}
diff --git a/src/db/messages.ts b/src/db/messages.ts
index d172c02..8d08b88 100644
--- a/src/db/messages.ts
+++ b/src/db/messages.ts
@@ -319,22 +319,31 @@ export function getMessagesSinceSeqFromDatabase(
return rows.map(normalizeMessageRow);
}
+const recentChatMessagesStmtCache = new WeakMap<
+ Database,
+ ReturnType
+>();
+
export function getRecentChatMessagesFromDatabase(
database: Database,
chatJid: string,
limit: number = 20,
): NewMessage[] {
- const sql = `
- SELECT * FROM (
- SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, message_source_kind
- FROM messages
- WHERE chat_jid = ?
- AND content != '' AND content IS NOT NULL
- ORDER BY timestamp DESC
- LIMIT ?
- ) ORDER BY timestamp
- `;
- const rows = database.prepare(sql).all(chatJid, limit) as Array<
+ let stmt = recentChatMessagesStmtCache.get(database);
+ if (!stmt) {
+ stmt = database.prepare(`
+ SELECT * FROM (
+ SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, message_source_kind
+ FROM messages
+ WHERE chat_jid = ?
+ AND content != '' AND content IS NOT NULL
+ ORDER BY timestamp DESC
+ LIMIT ?
+ ) ORDER BY timestamp
+ `);
+ recentChatMessagesStmtCache.set(database, stmt);
+ }
+ const rows = stmt.all(chatJid, limit) as Array<
NewMessage & {
is_from_me?: boolean | number;
is_bot_message?: boolean | number;
@@ -343,6 +352,44 @@ export function getRecentChatMessagesFromDatabase(
return rows.map(normalizeMessageRow);
}
+export function getRecentChatMessagesBatchFromDatabase(
+ database: Database,
+ chatJids: string[],
+ limit: number = 8,
+): Map {
+ const out = new Map();
+ if (chatJids.length === 0) return out;
+ const placeholders = chatJids.map(() => '?').join(',');
+ const sql = `
+ WITH ranked AS (
+ SELECT id, chat_jid, sender, sender_name, content, timestamp,
+ is_from_me, is_bot_message, message_source_kind,
+ ROW_NUMBER() OVER (PARTITION BY chat_jid ORDER BY timestamp DESC) AS rn
+ FROM messages
+ WHERE chat_jid IN (${placeholders})
+ AND content != '' AND content IS NOT NULL
+ )
+ SELECT id, chat_jid, sender, sender_name, content, timestamp,
+ is_from_me, is_bot_message, message_source_kind
+ FROM ranked
+ WHERE rn <= ?
+ ORDER BY chat_jid, timestamp ASC
+ `;
+ const rows = database.prepare(sql).all(...chatJids, limit) as Array<
+ NewMessage & {
+ is_from_me?: boolean | number;
+ is_bot_message?: boolean | number;
+ }
+ >;
+ for (const row of rows) {
+ const normalized = normalizeMessageRow(row);
+ const existing = out.get(normalized.chat_jid);
+ if (existing) existing.push(normalized);
+ else out.set(normalized.chat_jid, [normalized]);
+ }
+ return out;
+}
+
export function getLastHumanMessageTimestampFromDatabase(
database: Database,
chatJid: string,
diff --git a/src/db/migrations/015_turn-progress-text.ts b/src/db/migrations/015_turn-progress-text.ts
new file mode 100644
index 0000000..b4b9111
--- /dev/null
+++ b/src/db/migrations/015_turn-progress-text.ts
@@ -0,0 +1,19 @@
+import type { Database } from 'bun:sqlite';
+
+import { tableHasColumn } from './helpers.js';
+import type { SchemaMigrationDefinition } from './types.js';
+
+export const TURN_PROGRESS_TEXT_MIGRATION: SchemaMigrationDefinition = {
+ version: 15,
+ name: 'turn_progress_text',
+ apply(database: Database) {
+ if (!tableHasColumn(database, 'paired_turns', 'progress_text')) {
+ database.exec(`ALTER TABLE paired_turns ADD COLUMN progress_text TEXT`);
+ }
+ if (!tableHasColumn(database, 'paired_turns', 'progress_updated_at')) {
+ database.exec(
+ `ALTER TABLE paired_turns ADD COLUMN progress_updated_at TEXT`,
+ );
+ }
+ },
+};
diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts
index ddd4bb2..f3e8fee 100644
--- a/src/db/migrations/index.ts
+++ b/src/db/migrations/index.ts
@@ -14,6 +14,7 @@ import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js';
import { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdict-and-step-telemetry.js';
import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js';
import { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js';
+import { TURN_PROGRESS_TEXT_MIGRATION } from './015_turn-progress-text.js';
import type {
SchemaMigrationArgs,
SchemaMigrationDefinition,
@@ -36,6 +37,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION,
MESSAGE_SOURCE_KIND_MIGRATION,
WORK_ITEM_ATTACHMENTS_MIGRATION,
+ TURN_PROGRESS_TEXT_MIGRATION,
];
function ensureSchemaMigrationsTable(database: Database): void {
diff --git a/src/db/paired-state.ts b/src/db/paired-state.ts
index 05536eb..0252c3e 100644
--- a/src/db/paired-state.ts
+++ b/src/db/paired-state.ts
@@ -229,21 +229,27 @@ export function getPairedTaskByIdFromDatabase(
return row ? hydratePairedTaskRow(database, row) : undefined;
}
+const latestPairedTaskStmtCache = new WeakMap<
+ Database,
+ ReturnType
+>();
+
export function getLatestPairedTaskForChatFromDatabase(
database: Database,
chatJid: string,
): PairedTask | undefined {
- const row = database
- .prepare(
- `
- SELECT *
- FROM paired_tasks
- WHERE chat_jid = ?
- ORDER BY updated_at DESC
- LIMIT 1
- `,
- )
- .get(chatJid) as StoredPairedTaskRow | undefined;
+ let stmt = latestPairedTaskStmtCache.get(database);
+ if (!stmt) {
+ stmt = database.prepare(`
+ SELECT *
+ FROM paired_tasks
+ WHERE chat_jid = ?
+ ORDER BY updated_at DESC
+ LIMIT 1
+ `);
+ latestPairedTaskStmtCache.set(database, stmt);
+ }
+ const row = stmt.get(chatJid) as StoredPairedTaskRow | undefined;
return row ? hydratePairedTaskRow(database, row) : undefined;
}
diff --git a/src/db/paired-turn-outputs.ts b/src/db/paired-turn-outputs.ts
index 01eceda..2450fa2 100644
--- a/src/db/paired-turn-outputs.ts
+++ b/src/db/paired-turn-outputs.ts
@@ -56,6 +56,32 @@ export function getPairedTurnOutputsFromDatabase(
.all(taskId) as PairedTurnOutput[];
}
+const recentOutputsForChatStmtCache = new WeakMap<
+ Database,
+ ReturnType
+>();
+
+export function getRecentPairedTurnOutputsForChatFromDatabase(
+ database: Database,
+ chatJid: string,
+ limit: number = 8,
+): PairedTurnOutput[] {
+ let stmt = recentOutputsForChatStmtCache.get(database);
+ if (!stmt) {
+ stmt = database.prepare(`
+ SELECT o.*
+ FROM paired_turn_outputs o
+ INNER JOIN paired_tasks t ON o.task_id = t.id
+ WHERE t.chat_jid = ?
+ ORDER BY o.created_at DESC
+ LIMIT ?
+ `);
+ recentOutputsForChatStmtCache.set(database, stmt);
+ }
+ const rows = stmt.all(chatJid, limit) as PairedTurnOutput[];
+ return rows.reverse();
+}
+
export function getLatestTurnNumberFromDatabase(
database: Database,
taskId: string,
diff --git a/src/db/paired-turns.ts b/src/db/paired-turns.ts
index 299fbe7..b8d9390 100644
--- a/src/db/paired-turns.ts
+++ b/src/db/paired-turns.ts
@@ -33,6 +33,8 @@ export interface PairedTurnRecord {
updated_at: string;
completed_at: string | null;
last_error: string | null;
+ progress_text?: string | null;
+ progress_updated_at?: string | null;
}
interface StoredPairedTurnRow {
@@ -43,6 +45,8 @@ interface StoredPairedTurnRow {
intent_kind: PairedTurnIdentity['intentKind'];
created_at: string;
updated_at: string;
+ progress_text?: string | null;
+ progress_updated_at?: string | null;
}
function hydratePairedTurnRecord(
@@ -493,6 +497,56 @@ export function getPairedTurnByIdFromDatabase(
);
}
+const updateProgressTextStmtCache = new WeakMap<
+ Database,
+ ReturnType
+>();
+
+export function updatePairedTurnProgressTextFromDatabase(
+ database: Database,
+ turnId: string,
+ progressText: string | null,
+): void {
+ let stmt = updateProgressTextStmtCache.get(database);
+ if (!stmt) {
+ stmt = database.prepare(`
+ UPDATE paired_turns
+ SET progress_text = ?, progress_updated_at = ?
+ WHERE turn_id = ?
+ `);
+ updateProgressTextStmtCache.set(database, stmt);
+ }
+ stmt.run(progressText, new Date().toISOString(), turnId);
+}
+
+const latestPairedTurnStmtCache = new WeakMap<
+ Database,
+ ReturnType
+>();
+
+export function getLatestPairedTurnForTaskFromDatabase(
+ database: Database,
+ taskId: string,
+): PairedTurnRecord | null {
+ let stmt = latestPairedTurnStmtCache.get(database);
+ if (!stmt) {
+ stmt = database.prepare(`
+ SELECT *
+ FROM paired_turns
+ WHERE task_id = ?
+ ORDER BY updated_at DESC, turn_id DESC
+ LIMIT 1
+ `);
+ latestPairedTurnStmtCache.set(database, stmt);
+ }
+ const row = stmt.get(taskId) as StoredPairedTurnRow | undefined;
+ if (!row) return null;
+ return hydratePairedTurnRecord(
+ row,
+ getCurrentPairedTurnAttemptForTurnFromDatabase(database, row.turn_id),
+ );
+}
+
export function getPairedTurnsForTaskFromDatabase(
database: Database,
taskId: string,
diff --git a/src/db/runtime-memory-messages.ts b/src/db/runtime-memory-messages.ts
index 1af2edc..8bf2a37 100644
--- a/src/db/runtime-memory-messages.ts
+++ b/src/db/runtime-memory-messages.ts
@@ -26,6 +26,7 @@ import {
getNewMessagesBySeqFromDatabase,
getNewMessagesFromDatabase,
getRecentChatMessagesFromDatabase,
+ getRecentChatMessagesBatchFromDatabase,
hasMessageInDatabase,
hasRecentRestartAnnouncementInDatabase,
storeChatMetadataInDatabase,
@@ -224,6 +225,17 @@ export function getRecentChatMessages(
return getRecentChatMessagesFromDatabase(requireDatabase(), chatJid, limit);
}
+export function getRecentChatMessagesBatch(
+ chatJids: string[],
+ limit: number = 8,
+): Map {
+ return getRecentChatMessagesBatchFromDatabase(
+ requireDatabase(),
+ chatJids,
+ limit,
+ );
+}
+
export function getLastHumanMessageTimestamp(chatJid: string): string | null {
return getLastHumanMessageTimestampFromDatabase(requireDatabase(), chatJid);
}
diff --git a/src/db/runtime-paired.ts b/src/db/runtime-paired.ts
index 56bc6f8..e6c76b5 100644
--- a/src/db/runtime-paired.ts
+++ b/src/db/runtime-paired.ts
@@ -49,6 +49,7 @@ import {
import {
getLatestTurnNumberFromDatabase,
getPairedTurnOutputsFromDatabase,
+ getRecentPairedTurnOutputsForChatFromDatabase,
insertPairedTurnOutputInDatabase,
} from './paired-turn-outputs.js';
import {
@@ -58,6 +59,8 @@ import {
failPairedTurnInDatabase,
getPairedTurnByIdFromDatabase,
getPairedTurnsForTaskFromDatabase,
+ getLatestPairedTurnForTaskFromDatabase,
+ updatePairedTurnProgressTextFromDatabase,
markPairedTurnRunningInDatabase,
type PairedTurnRecord,
} from './paired-turns.js';
@@ -211,6 +214,23 @@ export function getPairedTurnsForTask(taskId: string): PairedTurnRecord[] {
return getPairedTurnsForTaskFromDatabase(requireDatabase(), taskId);
}
+export function getLatestPairedTurnForTask(
+ taskId: string,
+): PairedTurnRecord | null {
+ return getLatestPairedTurnForTaskFromDatabase(requireDatabase(), taskId);
+}
+
+export function updatePairedTurnProgressText(
+ turnId: string,
+ progressText: string | null,
+): void {
+ updatePairedTurnProgressTextFromDatabase(
+ requireDatabase(),
+ turnId,
+ progressText,
+ );
+}
+
export function getPairedTurnAttempts(
turnId: string,
): PairedTurnAttemptRecord[] {
@@ -333,6 +353,17 @@ export function getPairedTurnOutputs(taskId: string): PairedTurnOutput[] {
return getPairedTurnOutputsFromDatabase(requireDatabase(), taskId);
}
+export function getRecentPairedTurnOutputsForChat(
+ chatJid: string,
+ limit: number = 8,
+): PairedTurnOutput[] {
+ return getRecentPairedTurnOutputsForChatFromDatabase(
+ requireDatabase(),
+ chatJid,
+ limit,
+ );
+}
+
export function getLatestTurnNumber(taskId: string): number {
return getLatestTurnNumberFromDatabase(requireDatabase(), taskId);
}
diff --git a/src/message-runtime-turns.ts b/src/message-runtime-turns.ts
index 237de10..7b4eae1 100644
--- a/src/message-runtime-turns.ts
+++ b/src/message-runtime-turns.ts
@@ -119,6 +119,7 @@ interface CreateExecuteTurnDeps {
deliveryRole: PairedRoomRole | null;
pairedRoom: boolean;
}) => Promise;
+ recordTurnProgress: (turnId: string, progressText: string) => void;
}
export function createRunAgent(deps: {
@@ -190,6 +191,8 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn {
deliveryRole: resolvedDeliveryRole,
deliveryServiceId: resolvedDeliveryServiceId,
pairedTurnIdentity: args.pairedTurnIdentity ?? null,
+ recordTurnProgress: (turnId, progressText) =>
+ deps.recordTurnProgress(turnId, progressText),
canDeliverFinalText: () => {
if (!args.pairedTurnIdentity) {
return true;
diff --git a/src/message-runtime.ts b/src/message-runtime.ts
index b2c197a..f139436 100644
--- a/src/message-runtime.ts
+++ b/src/message-runtime.ts
@@ -6,6 +6,7 @@ import {
getLatestOpenPairedTaskForChat,
markWorkItemDelivered,
getPairedTaskById,
+ updatePairedTurnProgressText,
} from './db.js';
import {
isSessionCommandSenderAllowed,
@@ -262,6 +263,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
continuationTracker.open(targetChatJid),
});
},
+ recordTurnProgress: (turnId, progressText) => {
+ updatePairedTurnProgressText(turnId, progressText);
+ },
afterDeliverySuccess: async ({
chatJid,
runId,
diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts
index 6e48f8d..c7cfe5b 100644
--- a/src/message-turn-controller.ts
+++ b/src/message-turn-controller.ts
@@ -49,6 +49,7 @@ interface MessageTurnControllerOptions {
deliveryRole?: PairedRoomRole | null;
deliveryServiceId?: string | null;
pairedTurnIdentity?: PairedTurnIdentity | null;
+ recordTurnProgress?: (turnId: string, progressText: string) => void;
}
export class MessageTurnController {
@@ -398,6 +399,52 @@ export class MessageTurnController {
return this.visiblePhase === 'final';
}
+ private composeProgressBody(text: string): string {
+ if (this.subagents.size > 1) {
+ const lines: string[] = [];
+ for (const [, track] of this.subagents) {
+ const latest = track.activities[track.activities.length - 1];
+ lines.push(latest ? `${track.label} · ${latest}` : track.label);
+ }
+ return lines.join('\n');
+ }
+ if (this.subagents.size === 1) {
+ const [, track] = this.subagents.entries().next().value!;
+ const lines: string[] = [track.label];
+ for (let i = 0; i < track.activities.length; i++) {
+ const isLast = i === track.activities.length - 1;
+ lines.push(`${isLast ? '└' : '├'} ${track.activities[i]}`);
+ }
+ return lines.join('\n');
+ }
+ const activityLines =
+ this.toolActivities.length > 0
+ ? '\n' +
+ this.toolActivities
+ .map((a, i) => {
+ const isLast = i === this.toolActivities.length - 1;
+ const connector = isLast ? '└' : '├';
+ const isSummary = a.startsWith('📋');
+ return isSummary ? `${connector} ${a}` : `${connector} ${a}`;
+ })
+ .join('\n')
+ : '';
+ return text + activityLines;
+ }
+
+ private persistProgressBody(body: string): void {
+ const turnId = this.options.pairedTurnIdentity?.turnId;
+ if (!turnId || !this.options.recordTurnProgress) return;
+ try {
+ this.options.recordTurnProgress(turnId, body);
+ } catch (err) {
+ this.log.warn(
+ { err, turnId, bodyLength: body.length },
+ 'Failed to persist progress body',
+ );
+ }
+ }
+
private renderProgressMessage(text: string): string {
const elapsedMs =
this.progressStartedAt === null
@@ -405,41 +452,9 @@ export class MessageTurnController {
: Math.floor((Date.now() - this.progressStartedAt) / 5_000) * 5000;
const suffix = `\n\n${formatElapsedKorean(elapsedMs)}`;
- let body: string;
+ const body = this.composeProgressBody(text);
- if (this.subagents.size > 1) {
- // Compact: one line per subagent with latest activity
- const lines: string[] = [];
- for (const [, track] of this.subagents) {
- const latest = track.activities[track.activities.length - 1];
- lines.push(latest ? `${track.label} · ${latest}` : track.label);
- }
- body = lines.join('\n');
- } else if (this.subagents.size === 1) {
- // Single subagent: detailed view with activity sub-lines
- const [, track] = this.subagents.entries().next().value!;
- const lines: string[] = [track.label];
- for (let i = 0; i < track.activities.length; i++) {
- const isLast = i === track.activities.length - 1;
- lines.push(`${isLast ? '└' : '├'} ${track.activities[i]}`);
- }
- body = lines.join('\n');
- } else {
- // Single agent rendering
- const activityLines =
- this.toolActivities.length > 0
- ? '\n' +
- this.toolActivities
- .map((a, i) => {
- const isLast = i === this.toolActivities.length - 1;
- const connector = isLast ? '└' : '├';
- const isSummary = a.startsWith('📋');
- return isSummary ? `${connector} ${a}` : `${connector} ${a}`;
- })
- .join('\n')
- : '';
- body = text + activityLines;
- }
+ this.persistProgressBody(body);
const maxBody = 2000 - TASK_STATUS_MESSAGE_PREFIX.length - suffix.length;
const truncated =
@@ -465,6 +480,14 @@ export class MessageTurnController {
this.progressMessageId = null;
this.progressStartedAt = null;
this.progressEditFailCount = 0;
+ const turnId = this.options.pairedTurnIdentity?.turnId;
+ if (turnId && this.options.recordTurnProgress) {
+ try {
+ this.options.recordTurnProgress(turnId, '');
+ } catch {
+ /* clearing progress is best-effort */
+ }
+ }
}
/**
diff --git a/src/settings-store.ts b/src/settings-store.ts
new file mode 100644
index 0000000..95826e3
--- /dev/null
+++ b/src/settings-store.ts
@@ -0,0 +1,330 @@
+/**
+ * Settings store for the web dashboard:
+ * - lists Claude / Codex accounts (safe metadata only, no tokens)
+ * - reads/writes model role configuration via .env
+ * - removes account directories on the filesystem
+ *
+ * Account directory layout (existing convention):
+ * Claude default: ~/.claude/.credentials.json
+ * Claude index N≥1: ~/.claude-accounts/{N}/.credentials.json
+ * Codex default: ~/.codex/auth.json
+ * Codex index N≥1: ~/.codex-accounts/{N}/auth.json
+ */
+
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+export interface ClaudeAccountSummary {
+ index: number;
+ expiresAt: number | null;
+ scopes: string[];
+ subscriptionType?: string;
+ rateLimitTier?: string;
+ exists: boolean;
+}
+
+export interface CodexAccountSummary {
+ index: number;
+ accountId: string | null;
+ planType: string | null;
+ subscriptionUntil: string | null;
+ exists: boolean;
+}
+
+export interface ModelRoleConfig {
+ model: string;
+ effort: string;
+}
+
+export interface ModelConfigSnapshot {
+ owner: ModelRoleConfig;
+ reviewer: ModelRoleConfig;
+ arbiter: ModelRoleConfig;
+}
+
+const ROLE_KEYS = ['OWNER', 'REVIEWER', 'ARBITER'] as const;
+type RoleKey = (typeof ROLE_KEYS)[number];
+
+function envFilePath(): string {
+ return path.join(process.cwd(), '.env');
+}
+
+function readJson(file: string): T | null {
+ try {
+ if (!fs.existsSync(file)) return null;
+ return JSON.parse(fs.readFileSync(file, 'utf-8')) as T;
+ } catch {
+ return null;
+ }
+}
+
+function claudeCredsPath(index: number): string {
+ if (index === 0) {
+ return path.join(os.homedir(), '.claude', '.credentials.json');
+ }
+ return path.join(
+ os.homedir(),
+ '.claude-accounts',
+ String(index),
+ '.credentials.json',
+ );
+}
+
+function codexAuthPath(index: number): string {
+ if (index === 0) {
+ return path.join(os.homedir(), '.codex', 'auth.json');
+ }
+ return path.join(os.homedir(), '.codex-accounts', String(index), 'auth.json');
+}
+
+function readClaudeAccount(index: number): ClaudeAccountSummary | null {
+ const file = claudeCredsPath(index);
+ if (!fs.existsSync(file)) return null;
+ const data = readJson<{ claudeAiOauth?: Record }>(file);
+ const oauth = data?.claudeAiOauth ?? {};
+ return {
+ index,
+ expiresAt: typeof oauth.expiresAt === 'number' ? oauth.expiresAt : null,
+ scopes: Array.isArray(oauth.scopes) ? (oauth.scopes as string[]) : [],
+ subscriptionType:
+ typeof oauth.subscriptionType === 'string'
+ ? oauth.subscriptionType
+ : undefined,
+ rateLimitTier:
+ typeof oauth.rateLimitTier === 'string' ? oauth.rateLimitTier : undefined,
+ exists: true,
+ };
+}
+
+function readCodexAccount(index: number): CodexAccountSummary | null {
+ const file = codexAuthPath(index);
+ if (!fs.existsSync(file)) return null;
+ const data = readJson<{
+ OPENAI_API_KEY?: string;
+ tokens?: { id_token?: string; access_token?: string };
+ }>(file);
+ let accountId: string | null = null;
+ let planType: string | null = null;
+ let subscriptionUntil: string | null = null;
+ const idToken = data?.tokens?.id_token;
+ if (idToken && typeof idToken === 'string') {
+ const parts = idToken.split('.');
+ if (parts.length >= 2) {
+ try {
+ const payload = JSON.parse(
+ Buffer.from(parts[1], 'base64').toString('utf-8'),
+ ) as Record;
+ if (typeof payload.sub === 'string') accountId = payload.sub;
+ const auth = payload['https://api.openai.com/auth'] as
+ | Record
+ | undefined;
+ if (auth) {
+ if (typeof auth.chatgpt_plan_type === 'string') {
+ planType = auth.chatgpt_plan_type;
+ }
+ if (typeof auth.chatgpt_subscription_active_until === 'string') {
+ subscriptionUntil = auth.chatgpt_subscription_active_until;
+ }
+ }
+ } catch {
+ /* ignore parse errors */
+ }
+ }
+ }
+ return {
+ index,
+ accountId,
+ planType,
+ subscriptionUntil,
+ exists: true,
+ };
+}
+
+export function listClaudeAccounts(): ClaudeAccountSummary[] {
+ const out: ClaudeAccountSummary[] = [];
+ const def = readClaudeAccount(0);
+ if (def) out.push(def);
+ const dir = path.join(os.homedir(), '.claude-accounts');
+ if (fs.existsSync(dir)) {
+ const entries = fs.readdirSync(dir);
+ const indices = entries
+ .map((e) => Number.parseInt(e, 10))
+ .filter((n) => Number.isFinite(n) && n >= 1)
+ .sort((a, b) => a - b);
+ for (const i of indices) {
+ const acc = readClaudeAccount(i);
+ if (acc) out.push(acc);
+ }
+ }
+ return out;
+}
+
+export function listCodexAccounts(): CodexAccountSummary[] {
+ const out: CodexAccountSummary[] = [];
+ const def = readCodexAccount(0);
+ if (def) out.push(def);
+ const dir = path.join(os.homedir(), '.codex-accounts');
+ if (fs.existsSync(dir)) {
+ const entries = fs.readdirSync(dir);
+ const indices = entries
+ .map((e) => Number.parseInt(e, 10))
+ .filter((n) => Number.isFinite(n) && n >= 1)
+ .sort((a, b) => a - b);
+ for (const i of indices) {
+ const acc = readCodexAccount(i);
+ if (acc) out.push(acc);
+ }
+ }
+ return out;
+}
+
+function pickEnvValue(content: string, key: string): string | undefined {
+ const re = new RegExp(`^${key}=(.*)$`, 'm');
+ const match = content.match(re);
+ if (!match) return undefined;
+ return match[1].trim().replace(/^['"]|['"]$/g, '');
+}
+
+function readEnvFile(): string {
+ const file = envFilePath();
+ if (!fs.existsSync(file)) return '';
+ return fs.readFileSync(file, 'utf-8');
+}
+
+function readEnvOrProcess(key: string): string | undefined {
+ const fromFile = pickEnvValue(readEnvFile(), key);
+ if (fromFile !== undefined) return fromFile;
+ const fromProc = process.env[key];
+ return fromProc;
+}
+
+export function getModelConfig(): ModelConfigSnapshot {
+ const out: Partial = {};
+ for (const role of ROLE_KEYS) {
+ const model = readEnvOrProcess(`${role}_MODEL`) ?? '';
+ const effort = readEnvOrProcess(`${role}_EFFORT`) ?? '';
+ (out as Record)[role.toLowerCase()] = {
+ model,
+ effort,
+ };
+ }
+ return out as ModelConfigSnapshot;
+}
+
+export interface ModelUpdateInput {
+ owner?: Partial;
+ reviewer?: Partial;
+ arbiter?: Partial;
+}
+
+function setOrInsertEnvLine(
+ content: string,
+ key: string,
+ value: string,
+): string {
+ const re = new RegExp(`^${key}=.*$`, 'm');
+ if (re.test(content)) {
+ return content.replace(re, `${key}=${value}`);
+ }
+ const trimmed = content.replace(/\s*$/, '');
+ return `${trimmed}\n${key}=${value}\n`;
+}
+
+export function updateModelConfig(
+ input: ModelUpdateInput,
+): ModelConfigSnapshot {
+ const file = envFilePath();
+ let content = '';
+ if (fs.existsSync(file)) {
+ content = fs.readFileSync(file, 'utf-8');
+ }
+
+ for (const role of ROLE_KEYS) {
+ const update = (
+ input as Record | undefined>
+ )[role.toLowerCase()];
+ if (!update) continue;
+ if (update.model !== undefined) {
+ content = setOrInsertEnvLine(content, `${role}_MODEL`, update.model);
+ }
+ if (update.effort !== undefined) {
+ content = setOrInsertEnvLine(content, `${role}_EFFORT`, update.effort);
+ }
+ }
+
+ const tempPath = `${file}.tmp`;
+ fs.writeFileSync(tempPath, content, { mode: 0o600 });
+ fs.renameSync(tempPath, file);
+
+ return getModelConfig();
+}
+
+export function removeAccountDirectory(
+ provider: 'claude' | 'codex',
+ index: number,
+): void {
+ if (!Number.isFinite(index) || index < 1) {
+ throw new Error('cannot remove default account (index 0)');
+ }
+ const baseDir =
+ provider === 'claude'
+ ? path.join(os.homedir(), '.claude-accounts', String(index))
+ : path.join(os.homedir(), '.codex-accounts', String(index));
+ if (!fs.existsSync(baseDir)) {
+ throw new Error(`account directory not found: ${baseDir}`);
+ }
+ fs.rmSync(baseDir, { recursive: true, force: true });
+}
+
+export function addClaudeAccountFromToken(token: string): {
+ index: number;
+ accountId: string | null;
+} {
+ const trimmed = token.trim();
+ if (!trimmed) throw new Error('empty token');
+
+ const parts = trimmed.split('.');
+ if (parts.length < 2) {
+ throw new Error('invalid OAuth token: expected JWT format');
+ }
+ let payload: Record = {};
+ try {
+ payload = JSON.parse(
+ Buffer.from(parts[1], 'base64').toString('utf-8'),
+ ) as Record;
+ } catch {
+ throw new Error('invalid OAuth token: payload not parseable');
+ }
+ const exp = typeof payload.exp === 'number' ? payload.exp * 1000 : 0;
+ const accountId = typeof payload.sub === 'string' ? payload.sub : null;
+
+ const baseDir = path.join(os.homedir(), '.claude-accounts');
+ if (!fs.existsSync(baseDir)) {
+ fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 });
+ }
+
+ let nextIndex = 1;
+ while (
+ fs.existsSync(path.join(baseDir, String(nextIndex), '.credentials.json'))
+ ) {
+ nextIndex += 1;
+ }
+ const dir = path.join(baseDir, String(nextIndex));
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
+
+ const creds = {
+ claudeAiOauth: {
+ accessToken: trimmed,
+ refreshToken: '',
+ expiresAt: exp,
+ scopes: ['user:profile', 'user:inference'],
+ },
+ };
+ const credsPath = path.join(dir, '.credentials.json');
+ const tempPath = `${credsPath}.tmp`;
+ fs.writeFileSync(tempPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
+ fs.renameSync(tempPath, credsPath);
+
+ return { index: nextIndex, accountId };
+}
diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts
index 06b9029..f2c0edb 100644
--- a/src/web-dashboard-data.ts
+++ b/src/web-dashboard-data.ts
@@ -87,6 +87,8 @@ export interface WebDashboardRoomTurn {
updatedAt: string;
completedAt: string | null;
lastError: string | null;
+ progressText: string | null;
+ progressUpdatedAt: string | null;
}
export interface WebDashboardRoomTurnOutput {
@@ -419,22 +421,82 @@ function sanitizeRoomMessage(message: NewMessage): WebDashboardRoomMessage {
};
}
+/**
+ * SSOT for live progress: scan the `messages` table for the most recent
+ * TASK_STATUS_MESSAGE_PREFIX-prefixed message that belongs to the current
+ * turn (sender role match + timestamp >= turn.created_at). The Discord
+ * bridge ingests every edit of the bot's live message back into messages,
+ * so this single source replaces the legacy paired_turns.progress_text
+ * column for derivation.
+ */
+const TASK_STATUS_PREFIX = '';
+
+function turnRoleFromSenderName(name: string | null | undefined): string {
+ const v = (name ?? '').toLowerCase();
+ if (v.includes('오너') || v.includes('owner')) return 'owner';
+ if (v.includes('리뷰어') || v.includes('reviewer')) return 'reviewer';
+ if (v.includes('중재자') || v.includes('arbiter')) return 'arbiter';
+ return 'human';
+}
+
+function deriveLiveProgress(
+ turnRole: string,
+ turnCreatedAt: string,
+ messages: NewMessage[],
+): { progressText: string | null; progressUpdatedAt: string | null } {
+ const startMs = new Date(turnCreatedAt).getTime();
+ const targetRole = turnRoleFromSenderName(turnRole);
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const m = messages[i];
+ const content = m.content ?? '';
+ if (!content.startsWith(TASK_STATUS_PREFIX)) continue;
+ const ts = m.timestamp ? new Date(m.timestamp).getTime() : 0;
+ if (Number.isFinite(startMs) && ts < startMs) continue;
+ const role = turnRoleFromSenderName(m.sender_name);
+ if (role !== targetRole) continue;
+ const body = content.slice(TASK_STATUS_PREFIX.length);
+ const stripped = body.replace(/\n\n\d+[초smhMSH]?$/, '').trim();
+ if (!stripped) continue;
+ return { progressText: stripped, progressUpdatedAt: m.timestamp };
+ }
+ return { progressText: null, progressUpdatedAt: null };
+}
+
function sanitizeRoomTurn(
turn: PairedTurnRecord,
attempt: PairedTurnAttemptRecord | null,
+ messages: NewMessage[] = [],
): WebDashboardRoomTurn {
+ const role = attempt?.role ?? turn.role;
+ const createdAt = attempt?.created_at ?? turn.created_at;
+ const completedAt = attempt?.completed_at ?? turn.completed_at;
+
+ // Read paired_turns.progress_text first (written by recordTurnProgress
+ // when the controller is in the loop). If that's empty AND the turn is
+ // active, fall back to scanning the messages table for the most recent
+ // TASK_STATUS-prefixed message — same content the Discord bridge ingests.
+ let progressText = turn.progress_text ?? null;
+ let progressUpdatedAt = turn.progress_updated_at ?? null;
+ if ((!progressText || !progressText.trim()) && completedAt === null) {
+ const live = deriveLiveProgress(role, createdAt, messages);
+ if (live.progressText) {
+ progressText = live.progressText;
+ progressUpdatedAt = live.progressUpdatedAt;
+ }
+ }
+
return {
turnId: turn.turn_id,
- role: attempt?.role ?? turn.role,
+ role,
intentKind: attempt?.intent_kind ?? turn.intent_kind,
state: attempt?.state ?? turn.state,
attemptNo: attempt?.attempt_no ?? turn.attempt_no,
executorServiceId: attempt?.executor_service_id ?? turn.executor_service_id,
executorAgentType: attempt?.executor_agent_type ?? turn.executor_agent_type,
activeRunId: attempt?.active_run_id ?? null,
- createdAt: attempt?.created_at ?? turn.created_at,
+ createdAt,
updatedAt: attempt?.updated_at ?? turn.updated_at,
- completedAt: attempt?.completed_at ?? turn.completed_at,
+ completedAt,
lastError:
(attempt?.last_error ?? turn.last_error)
? buildRoomPreview(
@@ -442,6 +504,8 @@ function sanitizeRoomTurn(
ROOM_MESSAGE_PREVIEW_MAX_LENGTH,
)
: null,
+ progressText,
+ progressUpdatedAt,
};
}
@@ -506,7 +570,7 @@ export function buildWebDashboardRoomActivity(args: {
roundTripCount: args.pairedTask.round_trip_count,
updatedAt: args.pairedTask.updated_at,
currentTurn: currentTurn
- ? sanitizeRoomTurn(currentTurn, currentAttempt)
+ ? sanitizeRoomTurn(currentTurn, currentAttempt, args.messages)
: null,
outputs: args.outputs.slice(-outputLimit).map(sanitizeRoomTurnOutput),
}
diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts
index ddf2d79..34bf1e6 100644
--- a/src/web-dashboard-server.ts
+++ b/src/web-dashboard-server.ts
@@ -14,8 +14,11 @@ import {
getPairedTaskById,
getPairedTurnAttempts,
getPairedTurnOutputs,
+ getRecentPairedTurnOutputsForChat,
getPairedTurnsForTask,
+ getLatestPairedTurnForTask,
getRecentChatMessages,
+ getRecentChatMessagesBatch,
getTaskById,
hasMessage,
storeChatMetadata,
@@ -43,6 +46,14 @@ import {
buildWebDashboardOverview,
sanitizeScheduledTask,
} from './web-dashboard-data.js';
+import {
+ addClaudeAccountFromToken,
+ getModelConfig,
+ listClaudeAccounts,
+ listCodexAccounts,
+ removeAccountDirectory,
+ updateModelConfig,
+} from './settings-store.js';
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000;
const ROOM_MESSAGE_ID_CACHE_LIMIT = 500;
@@ -80,6 +91,7 @@ export interface ServiceRestartRecord {
export interface WebDashboardHandlerOptions {
staticDir?: string;
statusMaxAgeMs?: number;
+ startBackgroundCacheRefresh?: boolean;
readStatusSnapshots?: (maxAgeMs: number) => StatusSnapshot[];
getTasks?: () => ScheduledTask[];
getTaskById?: (id: string) => ScheduledTask | undefined;
@@ -114,8 +126,10 @@ export interface WebDashboardHandlerOptions {
getPairedTasks?: () => PairedTask[];
getLatestPairedTaskForChat?: (chatJid: string) => PairedTask | undefined;
getPairedTurnsForTask?: typeof getPairedTurnsForTask;
+ getLatestPairedTurnForTask?: typeof getLatestPairedTurnForTask;
getPairedTurnAttempts?: typeof getPairedTurnAttempts;
getPairedTurnOutputs?: typeof getPairedTurnOutputs;
+ getRecentPairedTurnOutputsForChat?: typeof getRecentPairedTurnOutputsForChat;
getRecentChatMessages?: typeof getRecentChatMessages;
getPairedTaskById?: (id: string) => PairedTask | undefined;
updatePairedTaskIfUnchanged?: (
@@ -150,13 +164,40 @@ export interface StartedWebDashboardServer {
stop: () => void;
}
-function jsonResponse(value: unknown, init?: ResponseInit): Response {
- return new Response(JSON.stringify(value, null, 2), {
+const ROOMS_TIMELINE_BG_INTERVAL_MS = 2000;
+let roomsTimelineCache: {
+ key: string;
+ builtAt: number;
+ rawJson: string;
+ gzipBuffer: Uint8Array;
+} | null = null;
+
+function jsonResponse(
+ value: unknown,
+ init?: ResponseInit,
+ request?: Request,
+): Response {
+ const body = JSON.stringify(value);
+ const acceptsGzip =
+ request?.headers.get('accept-encoding')?.includes('gzip') ?? false;
+ const baseHeaders: Record = {
+ 'content-type': 'application/json; charset=utf-8',
+ ...(init?.headers as Record | undefined),
+ };
+ if (acceptsGzip && body.length > 1024) {
+ const compressed = Bun.gzipSync(new TextEncoder().encode(body));
+ return new Response(compressed, {
+ ...init,
+ headers: {
+ ...baseHeaders,
+ 'content-encoding': 'gzip',
+ vary: 'accept-encoding',
+ },
+ });
+ }
+ return new Response(body, {
...init,
- headers: {
- 'content-type': 'application/json; charset=utf-8',
- ...(init?.headers ?? {}),
- },
+ headers: baseHeaders,
});
}
@@ -509,20 +550,29 @@ function sanitizeRoomMessageRequestId(value: unknown): string | null {
return safe || null;
}
-async function readRoomMessageBody(
- request: Request,
-): Promise<{ text: string; requestId: string | null } | null> {
+async function readRoomMessageBody(request: Request): Promise<{
+ text: string;
+ requestId: string | null;
+ nickname: string | null;
+} | null> {
try {
const body = (await request.json()) as {
text?: unknown;
requestId?: unknown;
+ nickname?: unknown;
};
if (typeof body.text !== 'string') return null;
const text = body.text.trim();
if (!text) return null;
+ let nickname: string | null = null;
+ if (typeof body.nickname === 'string') {
+ const trimmed = body.nickname.trim().slice(0, 32);
+ if (trimmed) nickname = trimmed;
+ }
return {
text: text.length > 8000 ? text.slice(0, 8000) : text,
requestId: sanitizeRoomMessageRequestId(body.requestId),
+ nickname,
};
} catch {
return null;
@@ -673,8 +723,12 @@ export function createWebDashboardHandler(
opts.getLatestPairedTaskForChat ?? getLatestPairedTaskForChat;
const loadPairedTurnsForTask =
opts.getPairedTurnsForTask ?? getPairedTurnsForTask;
+ const loadLatestPairedTurnForTask =
+ opts.getLatestPairedTurnForTask ?? getLatestPairedTurnForTask;
const loadPairedTurnOutputs =
opts.getPairedTurnOutputs ?? getPairedTurnOutputs;
+ const loadRecentPairedTurnOutputsForChat =
+ opts.getRecentPairedTurnOutputsForChat ?? getRecentPairedTurnOutputsForChat;
const loadPairedTurnAttempts =
opts.getPairedTurnAttempts ?? getPairedTurnAttempts;
const loadRecentChatMessages =
@@ -690,6 +744,99 @@ export function createWebDashboardHandler(
const restartServiceStack =
opts.restartServiceStack ?? restartEjclawStackServices;
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
+
+ function buildRoomsTimelineResult(): Record<
+ string,
+ ReturnType
+ > {
+ const snapshots = readSnapshots(statusMaxAgeMs);
+ const uniqueByJid = new Map<
+ string,
+ {
+ snapshot: (typeof snapshots)[number];
+ entry: (typeof snapshots)[number]['entries'][number];
+ }
+ >();
+ for (const snapshot of snapshots) {
+ for (const entry of snapshot.entries) {
+ const existing = uniqueByJid.get(entry.jid);
+ if (
+ !existing ||
+ existing.snapshot.updatedAt.localeCompare(snapshot.updatedAt) < 0
+ ) {
+ uniqueByJid.set(entry.jid, { snapshot, entry });
+ }
+ }
+ }
+ const result: Record<
+ string,
+ ReturnType
+ > = {};
+ for (const [jid, { snapshot, entry }] of uniqueByJid) {
+ const pairedTask = loadLatestPairedTaskForChat(jid) ?? null;
+ const messages = loadRecentChatMessages(jid, 8);
+ const outputs = loadRecentPairedTurnOutputsForChat(jid, 8);
+ if (!pairedTask && messages.length === 0 && outputs.length === 0)
+ continue;
+ const latestTurn = pairedTask
+ ? loadLatestPairedTurnForTask(pairedTask.id)
+ : null;
+ const turns = latestTurn ? [latestTurn] : [];
+ result[jid] = buildWebDashboardRoomActivity({
+ serviceId: snapshot.serviceId,
+ entry,
+ pairedTask,
+ turns,
+ attempts: [],
+ outputs,
+ messages,
+ outputLimit: 8,
+ });
+ }
+ return result;
+ }
+
+ function computeRoomsCacheKey(): string {
+ return readSnapshots(statusMaxAgeMs)
+ .map((s) => s.updatedAt)
+ .sort()
+ .join('|');
+ }
+
+ function ensureRoomsTimelineCache(): NonNullable {
+ const key = computeRoomsCacheKey();
+ if (roomsTimelineCache && roomsTimelineCache.key === key) {
+ return roomsTimelineCache;
+ }
+ const result = buildRoomsTimelineResult();
+ const rawJson = JSON.stringify(result);
+ const gzipBuffer = Bun.gzipSync(new TextEncoder().encode(rawJson));
+ roomsTimelineCache = {
+ key,
+ builtAt: Date.now(),
+ rawJson,
+ gzipBuffer,
+ };
+ return roomsTimelineCache;
+ }
+
+ if (opts.startBackgroundCacheRefresh !== false) {
+ setTimeout(() => {
+ try {
+ ensureRoomsTimelineCache();
+ } catch {
+ /* warm-up failure is non-fatal */
+ }
+ }, 0);
+ setInterval(() => {
+ try {
+ ensureRoomsTimelineCache();
+ } catch {
+ /* refresh failure is non-fatal */
+ }
+ }, ROOMS_TIMELINE_BG_INTERVAL_MS).unref();
+ }
+
const seenRoomMessageIds: string[] = [];
const seenRoomMessageIdSet = new Set();
const dismissedInboxKeys = new Set();
@@ -969,7 +1116,7 @@ export function createWebDashboardHandler(
id,
chat_jid: messageRoomJid,
sender: 'web-dashboard',
- sender_name: 'Web Dashboard',
+ sender_name: body.nickname ?? 'Web Dashboard',
content: body.text,
timestamp,
is_from_me: false,
@@ -1099,6 +1246,73 @@ export function createWebDashboardHandler(
}
}
+ if (
+ url.pathname === '/api/settings/models' &&
+ (request.method === 'PUT' || request.method === 'PATCH')
+ ) {
+ let body: unknown = null;
+ try {
+ body = await request.json();
+ } catch {
+ return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
+ }
+ if (!body || typeof body !== 'object') {
+ return jsonResponse(
+ { error: 'Body must be a JSON object' },
+ { status: 400 },
+ );
+ }
+ try {
+ const next = updateModelConfig(body as Record);
+ return jsonResponse(next);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return jsonResponse({ error: message }, { status: 500 });
+ }
+ }
+
+ {
+ const accountAddMatch = url.pathname.match(
+ /^\/api\/settings\/accounts\/(claude)$/,
+ );
+ if (accountAddMatch && request.method === 'POST') {
+ let body: { token?: unknown } | null = null;
+ try {
+ body = (await request.json()) as { token?: unknown };
+ } catch {
+ return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
+ }
+ const token = typeof body?.token === 'string' ? body.token.trim() : '';
+ if (!token) {
+ return jsonResponse({ error: 'token is required' }, { status: 400 });
+ }
+ try {
+ const result = addClaudeAccountFromToken(token);
+ return jsonResponse({ ok: true, ...result });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return jsonResponse({ error: message }, { status: 400 });
+ }
+ }
+ }
+
+ {
+ const accountDelMatch = url.pathname.match(
+ /^\/api\/settings\/accounts\/(claude|codex)\/(\d+)$/,
+ );
+ if (accountDelMatch && request.method === 'DELETE') {
+ const provider = accountDelMatch[1] as 'claude' | 'codex';
+ const index = Number.parseInt(accountDelMatch[2], 10);
+ try {
+ removeAccountDirectory(provider, index);
+ return jsonResponse({ ok: true, provider, index });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return jsonResponse({ error: message }, { status: 400 });
+ }
+ }
+ }
+
if (url.pathname === '/api/tasks' && request.method === 'POST') {
if (!loadRoomBindings) {
return jsonResponse(
@@ -1293,6 +1507,106 @@ export function createWebDashboardHandler(
return jsonResponse(loadTasks().map(sanitizeScheduledTask));
}
+ if (url.pathname === '/api/settings/accounts') {
+ return jsonResponse({
+ claude: listClaudeAccounts(),
+ codex: listCodexAccounts(),
+ });
+ }
+
+ if (url.pathname === '/api/settings/models') {
+ return jsonResponse(getModelConfig());
+ }
+
+ if (url.pathname === '/api/stream') {
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ let lastBuiltAt = 0;
+ let closed = false;
+
+ const enqueue = (chunk: string) => {
+ if (closed) return;
+ try {
+ controller.enqueue(encoder.encode(chunk));
+ } catch {
+ closed = true;
+ }
+ };
+
+ // Initial: send retry hint and seed event
+ enqueue(`retry: 3000\n\n`);
+ try {
+ const cache = ensureRoomsTimelineCache();
+ lastBuiltAt = cache.builtAt;
+ enqueue(`event: rooms-timeline\ndata: ${cache.rawJson}\n\n`);
+ } catch {
+ /* warm-up failure is non-fatal */
+ }
+
+ const tick = () => {
+ if (closed) return;
+ try {
+ const cache = ensureRoomsTimelineCache();
+ if (cache.builtAt !== lastBuiltAt) {
+ lastBuiltAt = cache.builtAt;
+ enqueue(`event: rooms-timeline\ndata: ${cache.rawJson}\n\n`);
+ } else {
+ // heartbeat to keep connection alive through proxies
+ enqueue(`: ping ${Date.now()}\n\n`);
+ }
+ } catch {
+ /* skip this tick */
+ }
+ };
+
+ const interval = setInterval(tick, 1500);
+ const close = () => {
+ if (closed) return;
+ closed = true;
+ clearInterval(interval);
+ try {
+ controller.close();
+ } catch {
+ /* already closed */
+ }
+ };
+ request.signal.addEventListener('abort', close);
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ 'content-type': 'text/event-stream; charset=utf-8',
+ 'cache-control': 'no-cache, no-transform',
+ connection: 'keep-alive',
+ 'x-accel-buffering': 'no',
+ },
+ });
+ }
+
+ if (url.pathname === '/api/rooms-timeline') {
+ const cache = ensureRoomsTimelineCache();
+ const acceptsGzip =
+ request.headers.get('accept-encoding')?.includes('gzip') ?? false;
+ if (acceptsGzip) {
+ return new Response(cache.gzipBuffer, {
+ headers: {
+ 'content-type': 'application/json; charset=utf-8',
+ 'content-encoding': 'gzip',
+ vary: 'accept-encoding',
+ 'x-cache-age': String(Date.now() - cache.builtAt),
+ },
+ });
+ }
+ return new Response(cache.rawJson, {
+ headers: {
+ 'content-type': 'application/json; charset=utf-8',
+ 'x-cache-age': String(Date.now() - cache.builtAt),
+ },
+ });
+ }
+
if (url.pathname.startsWith('/api/')) {
return jsonResponse({ error: 'Not found' }, { status: 404 });
}