fix: improve dashboard react doctor score (#215)

This commit is contained in:
Eyejoker
2026-06-03 08:30:27 +08:00
committed by GitHub
parent 2dab27f443
commit 32468e0214
10 changed files with 309 additions and 141 deletions

View File

@@ -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({
<strong>{t.error.api}</strong>
<small>{humanizeError(error, t)}</small>
</span>
<button disabled={refreshing} onClick={onRetry}>
<button disabled={refreshing} onClick={onRetry} type="button">
{t.actions.retry}
</button>
</section>

View File

@@ -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 <EmptyState>{t.rooms.empty}</EmptyState>;
}
@@ -359,9 +360,7 @@ export function RoomBoardV2({
{sorted.length === 0 ? (
<EmptyState>{t.rooms.empty}</EmptyState>
) : (
<div
className={`rooms-twopane${mobileDetailOpen ? ' is-detail-open' : ''}`}
>
<div className={`rooms-twopane${detailOpen ? ' is-detail-open' : ''}`}>
<RoomsList
entries={sorted}
inbox={inbox}

View File

@@ -91,6 +91,56 @@ interface TaskEditFormProps {
t: Messages;
}
const TASK_TIME_FORMATTERS: Record<Locale, Intl.DateTimeFormat> = {
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<Locale, Intl.RelativeTimeFormat> = {
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(

View File

@@ -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) {
</div>
</div>
<div className="usage-matrix" role="table" aria-label={t.panels.usage}>
<div className="usage-matrix-head" role="row">
<span>{t.usage.usage}</span>
<span>{t.usage.quota.h5}</span>
<span>{t.usage.quota.d7}</span>
<span>{t.usage.speed}</span>
</div>
<table className="usage-matrix" aria-label={t.panels.usage}>
<thead>
<tr className="usage-matrix-head">
<th scope="col">{t.usage.usage}</th>
<th scope="col">{t.usage.quota.h5}</th>
<th scope="col">{t.usage.quota.d7}</th>
<th scope="col">{t.usage.speed}</th>
</tr>
</thead>
{groups.map((group) => (
<div className="usage-group" key={group.key} role="rowgroup">
<div className="usage-group-label" role="row">
<span>{group.label}</span>
</div>
<tbody className="usage-group" key={group.key}>
<tr className="usage-group-label">
<th colSpan={4} scope="colgroup">
{group.label}
</th>
</tr>
{group.rows.map((row) => {
const risk = usageRiskLevel(row);
const { account, plan } = usageNameParts(row);
return (
<section className={`usage-row usage-${risk}`} key={row.name}>
<div className="usage-account">
<tr className={`usage-row usage-${risk}`} key={row.name}>
<th className="usage-account" scope="row">
<strong>{account}</strong>
<div>
{usageActive(row) ? (
@@ -224,26 +228,32 @@ export function UsagePanel({ overview, t }: UsagePanelProps) {
</span>
) : null}
</div>
</div>
<UsageQuotaMeter
row={row}
rowName={account}
window="h5"
t={t}
/>
<UsageQuotaMeter
row={row}
rowName={account}
window="d7"
t={t}
/>
<UsageSpeed row={row} t={t} />
</section>
</th>
<td>
<UsageQuotaMeter
row={row}
rowName={account}
window="h5"
t={t}
/>
</td>
<td>
<UsageQuotaMeter
row={row}
rowName={account}
window="d7"
t={t}
/>
</td>
<td>
<UsageSpeed row={row} t={t} />
</td>
</tr>
);
})}
</div>
</tbody>
))}
</div>
</table>
</div>
);
}

View File

@@ -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<Locale, Intl.DateTimeFormat> = {
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<Locale, Intl.DateTimeFormat> = {
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[] {

View File

@@ -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<string>,
): 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);
}

View File

@@ -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;

View File

@@ -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,
};
}

View File

@@ -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));
}

View File

@@ -254,7 +254,7 @@ function normalizeStructuredVisibleContent(value: string): {
function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
const rows: UsageRowSnapshot[] = [];
const seen = new Set<string>();
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<string> {
const ids = new Set<string>();
for (const item of outboundItems ?? []) {
if (item.delivery_message_id) ids.add(item.delivery_message_id);
}
return ids;
}
function collectRecentMessages(args: {
canonicalDeliveryMessageIds: Set<string>;
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,