diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 506e78a..824178c 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -204,7 +204,7 @@ function buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] { } } } - return [...rooms.values()].sort((a, b) => + return Array.from(rooms.values()).sort((a, b) => `${a.name} ${a.folder}`.localeCompare(`${b.name} ${b.folder}`), ); } @@ -247,7 +247,7 @@ function DashboardErrorCard({ {t.error.api} {humanizeError(error, t)} - diff --git a/apps/dashboard/src/RoomBoardV2.tsx b/apps/dashboard/src/RoomBoardV2.tsx index 1b93bfe..cb850fd 100644 --- a/apps/dashboard/src/RoomBoardV2.tsx +++ b/apps/dashboard/src/RoomBoardV2.tsx @@ -300,7 +300,7 @@ export function RoomBoardV2({ const filtered = allEntries.filter( (entry) => filter === 'all' || entry.status === filter, ); - const sorted = [...filtered].sort((a, b) => { + const sorted = Array.from(filtered).sort((a, b) => { if (sort === 'name') return a.name.localeCompare(b.name); if (sort === 'queue') { const aQ = a.pendingTasks + (a.pendingMessages ? 1 : 0); @@ -321,10 +321,11 @@ export function RoomBoardV2({ const nextJid = selectedEntry?.jid ?? null; if (nextJid !== selectedJid) { onSelectedJidChange(nextJid); - setMobileDetailOpen(false); } }, [onSelectedJidChange, selectedEntry?.jid, selectedJid]); + const detailOpen = mobileDetailOpen && selectedEntry?.jid === selectedJid; + if (allEntries.length === 0) { return {t.rooms.empty}; } @@ -359,9 +360,7 @@ export function RoomBoardV2({ {sorted.length === 0 ? ( {t.rooms.empty} ) : ( -
+
= { + en: new Intl.DateTimeFormat(localeTags.en, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }), + ja: new Intl.DateTimeFormat(localeTags.ja, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }), + ko: new Intl.DateTimeFormat(localeTags.ko, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }), + zh: new Intl.DateTimeFormat(localeTags.zh, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }), +}; + +const EN_TASK_DATE_TIME_FORMATTER = new Intl.DateTimeFormat(localeTags.en, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, +}); + +const RELATIVE_TIME_FORMATTERS: Record = { + en: new Intl.RelativeTimeFormat(localeTags.en, { + numeric: 'auto', + style: 'short', + }), + ja: new Intl.RelativeTimeFormat(localeTags.ja, { + numeric: 'auto', + style: 'short', + }), + ko: new Intl.RelativeTimeFormat(localeTags.ko, { + numeric: 'auto', + style: 'short', + }), + zh: new Intl.RelativeTimeFormat(localeTags.zh, { + numeric: 'auto', + style: 'short', + }), +}; + function formatTaskDate( value: string | null | undefined, locale: Locale, @@ -98,22 +148,12 @@ function formatTaskDate( if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; - const time = new Intl.DateTimeFormat(localeTags[locale], { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }).format(date); + const time = TASK_TIME_FORMATTERS[locale].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); + return EN_TASK_DATE_TIME_FORMATTER.format(date); } function formatRelativeDate( @@ -135,10 +175,10 @@ function formatRelativeDate( ]; const [unit, unitMs] = units.find(([, threshold]) => absMs >= threshold) ?? units.at(-1)!; - return new Intl.RelativeTimeFormat(localeTags[locale], { - numeric: 'auto', - style: 'short', - }).format(Math.round(diffMs / unitMs), unit); + return RELATIVE_TIME_FORMATTERS[locale].format( + Math.round(diffMs / unitMs), + unit, + ); } function safePreview( diff --git a/apps/dashboard/src/UsagePanel.tsx b/apps/dashboard/src/UsagePanel.tsx index 2cc74e7..8b640f4 100644 --- a/apps/dashboard/src/UsagePanel.tsx +++ b/apps/dashboard/src/UsagePanel.tsx @@ -141,7 +141,7 @@ function UsageSpeed({ row, t }: { row: UsageRow; t: Messages }) { export function UsagePanel({ overview, t }: UsagePanelProps) { const rows = useMemo( () => - [...overview.usage.rows].sort((a, b) => { + Array.from(overview.usage.rows).sort((a, b) => { if (usageActive(a) !== usageActive(b)) return usageActive(a) ? -1 : 1; return usagePeak(b) - usagePeak(a); }), @@ -194,24 +194,28 @@ export function UsagePanel({ overview, t }: UsagePanelProps) {
-
-
- {t.usage.usage} - {t.usage.quota.h5} - {t.usage.quota.d7} - {t.usage.speed} -
+ + + + + + + + + {groups.map((group) => ( -
-
- {group.label} -
+
+ + + {group.rows.map((row) => { const risk = usageRiskLevel(row); const { account, plan } = usageNameParts(row); return ( -
-
+
+ + + + + ); })} - + ))} - +
{t.usage.usage}{t.usage.quota.h5}{t.usage.quota.d7}{t.usage.speed}
+ {group.label} +
{account}
{usageActive(row) ? ( @@ -224,26 +228,32 @@ export function UsagePanel({ overview, t }: UsagePanelProps) { ) : null}
- - - - - +
+ + + + + +
); } diff --git a/apps/dashboard/src/dashboardHelpers.ts b/apps/dashboard/src/dashboardHelpers.ts index 231394e..7660b91 100644 --- a/apps/dashboard/src/dashboardHelpers.ts +++ b/apps/dashboard/src/dashboardHelpers.ts @@ -1,6 +1,34 @@ import type { DashboardTask, DashboardTaskAction } from './api'; import { localeTags, type Locale, type Messages } from './i18n'; +const SHORT_TIME_FORMAT_OPTIONS = { + hour: '2-digit', + hour12: false, + minute: '2-digit', +} as const; + +const MONTH_DAY_TIME_FORMAT_OPTIONS = { + day: 'numeric', + hour: '2-digit', + hour12: false, + minute: '2-digit', + month: 'short', +} as const; + +const SHORT_TIME_FORMATTERS: Record = { + en: new Intl.DateTimeFormat(localeTags.en, SHORT_TIME_FORMAT_OPTIONS), + ja: new Intl.DateTimeFormat(localeTags.ja, SHORT_TIME_FORMAT_OPTIONS), + ko: new Intl.DateTimeFormat(localeTags.ko, SHORT_TIME_FORMAT_OPTIONS), + zh: new Intl.DateTimeFormat(localeTags.zh, SHORT_TIME_FORMAT_OPTIONS), +}; + +const MONTH_DAY_TIME_FORMATTERS: Record = { + en: new Intl.DateTimeFormat(localeTags.en, MONTH_DAY_TIME_FORMAT_OPTIONS), + ja: new Intl.DateTimeFormat(localeTags.ja, MONTH_DAY_TIME_FORMAT_OPTIONS), + ko: new Intl.DateTimeFormat(localeTags.ko, MONTH_DAY_TIME_FORMAT_OPTIONS), + zh: new Intl.DateTimeFormat(localeTags.zh, MONTH_DAY_TIME_FORMAT_OPTIONS), +}; + export function formatDate( value: string | null | undefined, locale: Locale, @@ -31,30 +59,16 @@ export function formatDate( } const sameDay = new Date().toDateString() === date.toDateString(); if (sameDay) { - return new Intl.DateTimeFormat(localeTags[locale], { - hour: '2-digit', - hour12: false, - minute: '2-digit', - }).format(date); + return SHORT_TIME_FORMATTERS[locale].format(date); } - const time = new Intl.DateTimeFormat(localeTags[locale], { - hour: '2-digit', - hour12: false, - minute: '2-digit', - }).format(date); + const time = SHORT_TIME_FORMATTERS[locale].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], { - day: 'numeric', - hour: '2-digit', - hour12: false, - minute: '2-digit', - month: 'short', - }).format(date); + return MONTH_DAY_TIME_FORMATTERS[locale].format(date); } export function taskActionsFor(task: DashboardTask): DashboardTaskAction[] { diff --git a/apps/dashboard/src/roomThread.ts b/apps/dashboard/src/roomThread.ts index 4bdba8f..4a04a12 100644 --- a/apps/dashboard/src/roomThread.ts +++ b/apps/dashboard/src/roomThread.ts @@ -169,6 +169,41 @@ function mergeAdjacentBotChunks(entries: RoomThreadEntry[]): RoomThreadEntry[] { return merged; } +function collectOutputEntries(outputs: RoomOutput[]): RoomThreadEntry[] { + const entries: RoomThreadEntry[] = []; + for (const output of outputs) { + const entry = toOutputEntry(output); + if (entry) entries.push(entry); + } + return entries; +} + +function collectChatEntries( + messages: RoomMessage[], + outputEntries: RoomThreadEntry[], +): RoomThreadEntry[] { + const entries: RoomThreadEntry[] = []; + for (const message of messages) { + if (!isThreadChatMessage(message)) continue; + const entry = toMessageEntry(message); + if (!isDuplicateOutputMessage(entry, outputEntries)) entries.push(entry); + } + return entries; +} + +function collectOptimisticPending( + pendingMessages: RoomMessage[], + confirmedSet: Set, +): RoomThreadEntry[] { + const entries: RoomThreadEntry[] = []; + for (const message of pendingMessages) { + if (confirmedSet.has(messageKey(message))) continue; + if (isInternalProtocolPayload(message.content)) continue; + entries.push(toMessageEntry(message)); + } + return entries; +} + export function buildRoomThreadEntries({ messages, outputs, @@ -179,20 +214,14 @@ export function buildRoomThreadEntries({ pendingMessages?: RoomMessage[]; }): RoomThreadEntry[] { const confirmedSet = new Set(messages.map(messageKey)); - const outputEntries = outputs - .map(toOutputEntry) - .filter((entry): entry is RoomThreadEntry => Boolean(entry)); - const chatEntries = messages - .filter(isThreadChatMessage) - .map(toMessageEntry) - .filter((entry) => !isDuplicateOutputMessage(entry, outputEntries)); - const optimisticPending = pendingMessages - .filter((message) => !confirmedSet.has(messageKey(message))) - .filter((message) => !isInternalProtocolPayload(message.content)) - .map(toMessageEntry); - - const entries = [...chatEntries, ...optimisticPending, ...outputEntries].sort( - (a, b) => a.timestamp.localeCompare(b.timestamp), + const outputEntries = collectOutputEntries(outputs); + const chatEntries = collectChatEntries(messages, outputEntries); + const optimisticPending = collectOptimisticPending( + pendingMessages, + confirmedSet, ); + const entries = chatEntries + .concat(optimisticPending, outputEntries) + .sort((a, b) => a.timestamp.localeCompare(b.timestamp)); return mergeAdjacentBotChunks(entries); } diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 1875976..abd57c7 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -2227,6 +2227,19 @@ dd, .usage-matrix { display: grid; overflow: hidden; + border-spacing: 0; +} + +.usage-matrix thead, +.usage-matrix tbody { + display: grid; +} + +.usage-matrix th, +.usage-matrix td { + padding: 0; + border: 0; + text-align: inherit; } .usage-matrix-head, @@ -2258,7 +2271,6 @@ dd, } .usage-group-label { - padding: 7px 10px; color: var(--accent-strong); font-size: 11px; font-weight: 800; @@ -2267,6 +2279,14 @@ dd, background: rgba(191, 95, 44, 0.07); } +.usage-group-label th { + padding: 7px 10px; + color: inherit; + font-weight: inherit; + letter-spacing: inherit; + text-transform: inherit; +} + .usage-row { --meter: var(--green); padding: 8px 10px; diff --git a/apps/dashboard/src/useRoomActivity.ts b/apps/dashboard/src/useRoomActivity.ts index 800df46..f713188 100644 --- a/apps/dashboard/src/useRoomActivity.ts +++ b/apps/dashboard/src/useRoomActivity.ts @@ -33,7 +33,6 @@ export function useSelectedRoomActivity({ useEffect(() => { if (!active || !selectedRoomJid) { - setRoomActivityLoading(false); return undefined; } @@ -64,5 +63,10 @@ export function useSelectedRoomActivity({ }; }, [active, pollMs, refreshRoom, selectedRoomJid]); - return { refreshRoom, roomActivity, roomActivityLoading }; + return { + refreshRoom, + roomActivity, + roomActivityLoading: + active && selectedRoomJid !== null ? roomActivityLoading : false, + }; } diff --git a/scripts/dash-inspect.ts b/scripts/dash-inspect.ts index 34ae196..e08be71 100644 --- a/scripts/dash-inspect.ts +++ b/scripts/dash-inspect.ts @@ -80,9 +80,7 @@ async function measure(page: Page, sel: string) { } async function evalExpr(page: Page, expr: string) { - const result = await page.evaluate((e) => { - return eval(e); - }, expr); + const result = await page.evaluate(expr); console.log(JSON.stringify(result, null, 2)); } diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts index edae145..f45e117 100644 --- a/src/web-dashboard-data.ts +++ b/src/web-dashboard-data.ts @@ -254,7 +254,7 @@ function normalizeStructuredVisibleContent(value: string): { function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] { const rows: UsageRowSnapshot[] = []; const seen = new Set(); - const sortedSnapshots = [...snapshots].sort((a, b) => + const sortedSnapshots = Array.from(snapshots).sort((a, b) => (b.usageRowsFetchedAt ?? b.updatedAt).localeCompare( a.usageRowsFetchedAt ?? a.updatedAt, ), @@ -343,7 +343,7 @@ function collectInboxItems(args: { }); } - return [...groupedItems.values()] + return Array.from(groupedItems.values()) .sort((a, b) => { const severityDelta = severityRank[a.severity] - severityRank[b.severity]; if (severityDelta !== 0) return severityDelta; @@ -567,6 +567,91 @@ function sanitizeRoomTurnOutput( }; } +function getCurrentRoomTurn( + turns: PairedTurnRecord[], +): PairedTurnRecord | null { + let currentTurn: PairedTurnRecord | null = null; + let currentTimestamp = ''; + for (const turn of turns) { + const timestamp = getRoomTurnActivityTimestamp(turn); + if (!currentTurn || timestamp.localeCompare(currentTimestamp) > 0) { + currentTurn = turn; + currentTimestamp = timestamp; + } + } + return currentTurn; +} + +function collectSanitizedRecentMessages( + messages: NewMessage[], +): WebDashboardRoomMessage[] { + const sanitizedMessages: WebDashboardRoomMessage[] = []; + for (const message of messages) { + if (isTaskStatusMessage(message)) continue; + const sanitized = sanitizeRoomMessage(message); + if (sanitized) sanitizedMessages.push(sanitized); + } + return sanitizedMessages; +} + +function collectCanonicalOutboundMessages( + outboundItems: WorkItem[] | undefined, + sanitizedRecentMessages: WebDashboardRoomMessage[], +): WebDashboardRoomMessage[] { + const messages: WebDashboardRoomMessage[] = []; + for (const item of outboundItems ?? []) { + const message = sanitizeCanonicalOutboundMessage(item); + if (!message) continue; + if ( + item.delivery_message_id || + hasDiscordEchoForCanonicalOutbound(message, sanitizedRecentMessages) + ) { + messages.push(message); + } + } + return messages; +} + +function collectCanonicalDeliveryMessageIds( + outboundItems: WorkItem[] | undefined, +): Set { + const ids = new Set(); + for (const item of outboundItems ?? []) { + if (item.delivery_message_id) ids.add(item.delivery_message_id); + } + return ids; +} + +function collectRecentMessages(args: { + canonicalDeliveryMessageIds: Set; + canonicalOutboundMessages: WebDashboardRoomMessage[]; + sanitizedRecentMessages: WebDashboardRoomMessage[]; +}): WebDashboardRoomMessage[] { + const messages: WebDashboardRoomMessage[] = []; + for (const message of args.sanitizedRecentMessages) { + if (args.canonicalDeliveryMessageIds.has(message.id)) continue; + if ( + isDuplicateOfCanonicalOutbound(message, args.canonicalOutboundMessages) + ) { + continue; + } + messages.push(message); + } + return messages; +} + +function collectRecentOutputs( + outputs: PairedTurnOutput[], + outputLimit: number, +): WebDashboardRoomTurnOutput[] { + const recentOutputs: WebDashboardRoomTurnOutput[] = []; + for (const output of outputs.slice(-outputLimit)) { + const sanitized = sanitizeRoomTurnOutput(output); + if (sanitized) recentOutputs.push(sanitized); + } + return recentOutputs; +} + export function buildWebDashboardRoomActivity(args: { serviceId: string; entry: StatusSnapshot['entries'][number]; @@ -585,50 +670,24 @@ export function buildWebDashboardRoomActivity(args: { latestAttemptByTurnId.set(attempt.turn_id, attempt); } } - const currentTurn = - [...args.turns].sort((a, b) => - getRoomTurnActivityTimestamp(b).localeCompare( - getRoomTurnActivityTimestamp(a), - ), - )[0] ?? null; + const currentTurn = getCurrentRoomTurn(args.turns); const currentAttempt = currentTurn ? (latestAttemptByTurnId.get(currentTurn.turn_id) ?? null) : null; const outputLimit = args.outputLimit ?? 4; - const sanitizedRecentMessages = args.messages - .filter((message) => !isTaskStatusMessage(message)) - .map(sanitizeRoomMessage) - .filter((message): message is WebDashboardRoomMessage => Boolean(message)); - const canonicalOutboundMessages = (args.outboundItems ?? []) - .map((item) => ({ - item, - message: sanitizeCanonicalOutboundMessage(item), - })) - .filter( - ( - candidate, - ): candidate is { - item: WorkItem; - message: WebDashboardRoomMessage; - } => Boolean(candidate.message), - ) - .filter( - ({ item, message }) => - Boolean(item.delivery_message_id) || - hasDiscordEchoForCanonicalOutbound(message, sanitizedRecentMessages), - ) - .map(({ message }) => message); - const canonicalDeliveryMessageIds = new Set( - (args.outboundItems ?? []) - .map((item) => item.delivery_message_id) - .filter((id): id is string => Boolean(id)), + const sanitizedRecentMessages = collectSanitizedRecentMessages(args.messages); + const canonicalOutboundMessages = collectCanonicalOutboundMessages( + args.outboundItems, + sanitizedRecentMessages, ); - const recentMessages = sanitizedRecentMessages - .filter((message) => !canonicalDeliveryMessageIds.has(message.id)) - .filter( - (message) => - !isDuplicateOfCanonicalOutbound(message, canonicalOutboundMessages), - ); + const canonicalDeliveryMessageIds = collectCanonicalDeliveryMessageIds( + args.outboundItems, + ); + const recentMessages = collectRecentMessages({ + canonicalDeliveryMessageIds, + canonicalOutboundMessages, + sanitizedRecentMessages, + }); const shouldExposeExecutionOutputs = args.outboundItems === undefined; return { @@ -641,9 +700,9 @@ export function buildWebDashboardRoomActivity(args: { elapsedMs: args.entry.elapsedMs, pendingMessages: args.entry.pendingMessages, pendingTasks: args.entry.pendingTasks, - messages: [...recentMessages, ...canonicalOutboundMessages].sort( - compareRoomMessagesByTimestamp, - ), + messages: recentMessages + .concat(canonicalOutboundMessages) + .sort(compareRoomMessagesByTimestamp), pairedTask: args.pairedTask ? { id: args.pairedTask.id, @@ -655,12 +714,7 @@ export function buildWebDashboardRoomActivity(args: { ? sanitizeRoomTurn(currentTurn, currentAttempt) : null, outputs: shouldExposeExecutionOutputs - ? args.outputs - .slice(-outputLimit) - .map(sanitizeRoomTurnOutput) - .filter((output): output is WebDashboardRoomTurnOutput => - Boolean(output), - ) + ? collectRecentOutputs(args.outputs, outputLimit) : [], } : null,