diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 11e92ca..864702d 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -36,7 +36,7 @@ type HealthLevel = 'ok' | 'stale' | 'down'; const REFRESH_INTERVAL_MS = 15_000; const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale'; -const DEFAULT_VIEW: DashboardView = 'usage'; +const DEFAULT_VIEW: DashboardView = 'inbox'; const HEALTH_STALE_MS = 5 * 60_000; const HEALTH_DOWN_MS = 15 * 60_000; @@ -139,6 +139,12 @@ function usagePeak(row: UsageRow): number { return Math.max(row.h5pct, row.d7pct); } +function usageRemaining(row: UsageRow): number | null { + const peak = usagePeak(row); + if (peak < 0) return null; + return Math.max(0, 100 - peak); +} + function usageRiskLevel(row: UsageRow): RiskLevel { const peak = usagePeak(row); if (peak >= 85) return 'critical'; @@ -146,6 +152,49 @@ function usageRiskLevel(row: UsageRow): RiskLevel { return 'ok'; } +function usageActive(row: UsageRow): boolean { + return row.name.includes('*'); +} + +function usageLimited(row: UsageRow): boolean { + return row.name.includes('!'); +} + +function usageNameParts(row: UsageRow): { + account: string; + plan: string | null; +} { + const cleaned = row.name.replace(/[*!]/g, '').replace(/\s+/g, ' ').trim(); + const parts = cleaned.split(' '); + const plan = parts.at(-1) ?? null; + if (plan && ['max', 'mid', 'pro', 'team'].includes(plan.toLowerCase())) { + return { account: parts.slice(0, -1).join(' ') || cleaned, plan }; + } + return { account: cleaned, plan: null }; +} + +function usageLimitReset(row: UsageRow): string { + return (row.d7pct >= row.h5pct ? row.d7reset : row.h5reset).trim(); +} + +function usageBurnRate(row: UsageRow): number | null { + if (row.h5pct < 0) return null; + return row.h5pct / 5; +} + +function usageSpeedLevel(rate: number | null): RiskLevel { + if (rate === null) return 'ok'; + if (rate >= 12) return 'critical'; + if (rate >= 7) return 'warn'; + return 'ok'; +} + +function formatUsageRate(rate: number | null): string { + if (rate === null) return '-'; + if (rate > 0 && rate < 1) return '<1%/h'; + return `${Math.round(rate)}%/h`; +} + function usageGroup(row: UsageRow): UsageGroup { return row.name.toLowerCase().startsWith('codex') ? 'codex' : 'primary'; } @@ -322,7 +371,6 @@ function LoadingSkeleton({ t }: { t: Messages }) { function SideRail({ activeView, - lastRefreshed, locale, onNavigate, onLocaleChange, @@ -331,7 +379,6 @@ function SideRail({ t, }: { activeView: DashboardView; - lastRefreshed: string | null; locale: Locale; onNavigate: (view: DashboardView) => void; onLocaleChange: (locale: Locale) => void; @@ -368,10 +415,6 @@ function SideRail({ > {refreshing ? t.actions.refreshing : t.actions.refresh} -
- {t.nav.updated} - {formatDate(lastRefreshed, locale)} -
); } @@ -379,7 +422,6 @@ function SideRail({ function SectionNav({ activeView, drawerOpen, - lastRefreshed, locale, onCloseDrawer, onLocaleChange, @@ -391,7 +433,6 @@ function SectionNav({ }: { activeView: DashboardView; drawerOpen: boolean; - lastRefreshed: string | null; locale: Locale; onCloseDrawer: () => void; onLocaleChange: (locale: Locale) => void; @@ -430,9 +471,6 @@ function SectionNav({ > {refreshing ? '...' : t.actions.refresh} - - {t.nav.updated} {formatDate(lastRefreshed, locale)} - {drawerOpen ? ( @@ -484,10 +522,6 @@ function SectionNav({ onLocaleChange={onLocaleChange} t={t} /> -
- {t.nav.updated} - {formatDate(lastRefreshed, locale)} -
) : null} @@ -631,9 +665,14 @@ function InboxPanel({ {t.inbox.kinds[item.kind]} {item.title} - - {t.inbox.severity[item.severity]} - +
+ + {t.inbox.severity[item.severity]} + + {item.occurrences > 1 ? ( + x{item.occurrences} + ) : null} +

{item.summary || t.inbox.noSummary}

@@ -696,9 +735,11 @@ function HealthPanel({ }, { pendingTasks: 0, pendingMessageRooms: 0 }, ); - const ciFailures = data.overview.inbox.filter( - (item) => item.kind === 'ci-failure', - ).length; + const ciFailures = data.overview.inbox.reduce( + (count, item) => + item.kind === 'ci-failure' ? count + item.occurrences : count, + 0, + ); const healthLevel: HealthLevel = down > 0 ? 'down' : stale > 0 || ciFailures > 0 ? 'stale' : 'ok'; const affectedServices = serviceLevels.filter((item) => item.level !== 'ok'); @@ -735,9 +776,6 @@ function HealthPanel({
{t.health.ciFailures} {ciFailures} - - {data.overview.tasks.watchers.paused} {t.status.paused} -
@@ -803,10 +841,9 @@ function RoomPanel({ {t.rooms.room} - {t.rooms.service} - {t.rooms.agent} {t.rooms.status} {t.rooms.queue} + {t.rooms.elapsed} @@ -814,21 +851,23 @@ function RoomPanel({ {entry.name} - - {entry.folder} · {entry.jid} - +
+ {t.rooms.details} + + {entry.folder} · {entry.jid} · {entry.serviceId} ·{' '} + {entry.agentType} + +
- {entry.serviceId} - {entry.agentType} {statusLabel(entry.status, t)} - {formatDuration(entry.elapsedMs, t)} {queueLabel(entry.pendingTasks, entry.pendingMessages, t)} + {formatDuration(entry.elapsedMs, t)} ))} @@ -857,20 +896,18 @@ function RoomPanel({ {queueLabel(entry.pendingTasks, entry.pendingMessages, t)} - - {t.rooms.agent} - {entry.agentType} - - - {t.rooms.service} - {entry.serviceId} - {t.rooms.elapsed} {formatDuration(entry.elapsedMs, t)}
-

{entry.jid}

+
+ {t.rooms.details} +

+ {entry.folder} · {entry.jid} · {entry.serviceId} ·{' '} + {entry.agentType} +

+
))} @@ -878,54 +915,80 @@ function RoomPanel({ ); } -function UsageMeter({ - label, - pct, - reset, +function UsageRemainingMeter({ + row, rowName, t, }: { - label: string; - pct: number; - reset: string; + row: UsageRow; rowName: string; t: Messages; }) { + const remaining = usageRemaining(row); + const reset = usageLimitReset(row); + return ( -
+
- {label} - {formatPct(pct)} + {t.usage.remaining} + {remaining === null ? '-' : formatPct(remaining)}
- - {t.usage.reset} {reset || '-'} - + {reset ? `${t.usage.reset} ${reset}` : t.usage.noReset} +
+ ); +} + +function UsageSpeed({ row, t }: { row: UsageRow; t: Messages }) { + const rate = usageBurnRate(row); + const level = usageSpeedLevel(rate); + + return ( +
+ {t.usage.speed} + {formatUsageRate(rate)} + {t.usage.speedLabel[level]}
); } function UsagePanel({ overview, - locale, t, }: { overview: DashboardOverview; - locale: Locale; t: Messages; }) { - const rows = useMemo(() => [...overview.usage.rows], [overview.usage.rows]); + const rows = useMemo( + () => + [...overview.usage.rows].sort((a, b) => { + if (usageActive(a) !== usageActive(b)) return usageActive(a) ? -1 : 1; + return usagePeak(b) - usagePeak(a); + }), + [overview.usage.rows], + ); const watched = rows.filter((row) => usagePeak(row) >= 65).length; if (rows.length === 0) { return {t.usage.empty}; } - const highest = rows[0]; + const activeRows = rows.filter(usageActive); + const focusRows = activeRows.length > 0 ? activeRows : rows.slice(0, 1); + const focusLabel = activeRows.length > 0 ? t.usage.current : t.usage.tightest; + const focusValue = focusRows + .map((row) => { + const { account } = usageNameParts(row); + const remaining = usageRemaining(row); + return `${account} ${remaining === null ? '-' : formatPct(remaining)}`; + }) + .join(' · '); const groups = [ { key: 'primary' as const, @@ -943,26 +1006,20 @@ function UsagePanel({
- {t.usage.highest} - - {highest.name} · {formatPct(usagePeak(highest))} - + {focusLabel} + {focusValue}
{t.usage.watch} {watched}
-
- {t.usage.updated} - {formatDate(overview.usage.fetchedAt, locale)} -
{t.usage.usage} - {t.usage.window5h} - {t.usage.window7d} + {t.usage.remaining} + {t.usage.speed}
{groups.map((group) => (
@@ -971,28 +1028,25 @@ function UsagePanel({
{group.rows.map((row) => { const risk = usageRiskLevel(row); + const { account, plan } = usageNameParts(row); return (
- {row.name} - - {t.usage.risk[risk]} - + {account} +
+ {usageActive(row) ? ( + {t.usage.inUse} + ) : null} + {plan ? {plan} : null} + {usageLimited(row) || risk !== 'ok' ? ( + + {t.usage.risk[risk]} + + ) : null} +
- - + +
); })} @@ -1192,7 +1246,6 @@ function App() { const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - const [lastRefreshed, setLastRefreshed] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [activeView, setActiveView] = useState(readViewFromHash); const [locale, setLocale] = useState(readInitialLocale); @@ -1215,7 +1268,6 @@ function App() { try { const nextData = await fetchDashboardData(); setData(nextData); - setLastRefreshed(new Date().toISOString()); setError(null); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -1266,7 +1318,6 @@ function App() {
setDrawerOpen(false)} onLocaleChange={setDashboardLocale} @@ -1309,7 +1359,7 @@ function App() {

{t.panels.usage}

{t.panels.usageWindow}
- + diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index 67c8646..546406b 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -37,6 +37,7 @@ export interface DashboardOverview { }; inbox: Array<{ id: string; + groupKey: string; kind: | 'pending-room' | 'reviewer-request' @@ -48,7 +49,9 @@ export interface DashboardOverview { title: string; summary: string; occurredAt: string; + lastOccurredAt: string; createdAt: string; + occurrences: number; source: 'status-snapshot' | 'paired-task' | 'scheduled-task'; roomJid?: string; roomName?: string; diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts index b51769a..0f96e07 100644 --- a/apps/dashboard/src/i18n.ts +++ b/apps/dashboard/src/i18n.ts @@ -114,17 +114,19 @@ export interface Messages { status: string; queue: string; elapsed: string; + details: string; }; usage: { empty: string; - highest: string; + current: string; + tightest: string; watch: string; - updated: string; - peak: string; + remaining: string; + speed: string; + inUse: string; reset: string; + noReset: string; usage: string; - window5h: string; - window7d: string; groupPrimary: string; groupCodex: string; risk: { @@ -132,6 +134,11 @@ export interface Messages { warn: string; critical: string; }; + speedLabel: { + ok: string; + warn: string; + critical: string; + }; }; tasks: { empty: string; @@ -244,7 +251,7 @@ export const messages = { inbox: '인입', inboxQueue: '대기 항목', usage: '사용량', - usageWindow: '5시간 / 7일', + usageWindow: '남음 / 속도', rooms: '룸', queue: '큐', scheduled: '예약', @@ -308,17 +315,19 @@ export const messages = { status: '상태', queue: '큐', elapsed: '경과', + details: '세부', }, usage: { empty: '사용량 스냅샷 없음. 수집기 확인.', - highest: '최고', + current: '사용중', + tightest: '가장 적음', watch: '주의', - updated: '갱신', - peak: '피크', + remaining: '남음', + speed: '속도', + inUse: '사용중', reset: '리셋', + noReset: '리셋 없음', usage: '사용량', - window5h: '5시간', - window7d: '7일', groupPrimary: 'Claude / Kimi', groupCodex: 'Codex', risk: { @@ -326,6 +335,11 @@ export const messages = { warn: '주의', critical: '위험', }, + speedLabel: { + ok: '보통', + warn: '빠름', + critical: '너무 빠름', + }, }, tasks: { empty: '예약 작업 없음.', @@ -422,7 +436,7 @@ export const messages = { inbox: 'Inbox', inboxQueue: 'Work intake', usage: 'Usage', - usageWindow: '5h / 7d', + usageWindow: 'Remaining / speed', rooms: 'Rooms', queue: 'Queue', scheduled: 'Scheduled', @@ -486,17 +500,19 @@ export const messages = { status: 'status', queue: 'queue', elapsed: 'elapsed', + details: 'details', }, usage: { empty: 'No usage snapshot. Check collector.', - highest: 'Highest', + current: 'In use', + tightest: 'Lowest', watch: 'Watch', - updated: 'Updated', - peak: 'Peak', + remaining: 'Left', + speed: 'Speed', + inUse: 'in use', reset: 'reset', + noReset: 'no reset', usage: 'usage', - window5h: '5h', - window7d: '7d', groupPrimary: 'Claude / Kimi', groupCodex: 'Codex', risk: { @@ -504,6 +520,11 @@ export const messages = { warn: 'Watch', critical: 'Limit risk', }, + speedLabel: { + ok: 'Normal', + warn: 'Fast', + critical: 'Too fast', + }, }, tasks: { empty: 'No scheduled work.', @@ -600,7 +621,7 @@ export const messages = { inbox: '收件', inboxQueue: '待处理', usage: '用量', - usageWindow: '5小时 / 7天', + usageWindow: '剩余 / 速度', rooms: '房间', queue: '队列', scheduled: '计划', @@ -617,7 +638,7 @@ export const messages = { system: '系统', signals: '健康信号', services: '服务', - fresh: '心跳正常', + fresh: '正常', stale: '延迟', queue: '队列', ciFailures: 'CI 失败', @@ -664,17 +685,19 @@ export const messages = { status: '状态', queue: '队列', elapsed: '耗时', + details: '详情', }, usage: { empty: '暂无用量快照。检查采集器。', - highest: '最高', + current: '使用中', + tightest: '剩余最少', watch: '关注', - updated: '更新', - peak: '峰值', + remaining: '剩余', + speed: '速度', + inUse: '使用中', reset: '重置', + noReset: '无重置', usage: '用量', - window5h: '5小时', - window7d: '7天', groupPrimary: 'Claude / Kimi', groupCodex: 'Codex', risk: { @@ -682,6 +705,11 @@ export const messages = { warn: '关注', critical: '接近上限', }, + speedLabel: { + ok: '正常', + warn: '较快', + critical: '过快', + }, }, tasks: { empty: '暂无计划任务。', @@ -778,7 +806,7 @@ export const messages = { inbox: '受信', inboxQueue: '対応待ち', usage: '使用量', - usageWindow: '5時間 / 7日', + usageWindow: '残量 / 速度', rooms: 'ルーム', queue: 'キュー', scheduled: '予定', @@ -842,17 +870,19 @@ export const messages = { status: '状態', queue: 'キュー', elapsed: '経過', + details: '詳細', }, usage: { empty: '使用量スナップショットなし。収集器を確認。', - highest: '最高', + current: '使用中', + tightest: '残量最少', watch: '注意', - updated: '更新', - peak: 'ピーク', + remaining: '残量', + speed: '速度', + inUse: '使用中', reset: 'リセット', + noReset: 'リセットなし', usage: '使用量', - window5h: '5時間', - window7d: '7日', groupPrimary: 'Claude / Kimi', groupCodex: 'Codex', risk: { @@ -860,6 +890,11 @@ export const messages = { warn: '注意', critical: '上限注意', }, + speedLabel: { + ok: '通常', + warn: '速い', + critical: '速すぎ', + }, }, tasks: { empty: '予定作業なし。', diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 03685ba..dc939c8 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -206,17 +206,6 @@ button:disabled { white-space: nowrap; } -.section-nav span { - min-width: 0; - overflow: hidden; - color: var(--muted); - font-size: 13px; - font-weight: 750; - text-align: right; - text-overflow: ellipsis; - white-space: nowrap; -} - .side-rail { position: sticky; top: 18px; @@ -383,19 +372,6 @@ button:disabled { content: '↘'; } -.drawer-meta { - display: grid; - gap: 4px; - padding: 14px; - border-radius: 18px; - color: var(--muted); - background: rgba(43, 55, 38, 0.06); -} - -.drawer-meta strong { - color: var(--bg-ink); -} - .error-card, .card, .panel { @@ -627,7 +603,7 @@ td small { .usage-summary { display: grid; - grid-template-columns: 1.1fr 0.46fr 0.9fr; + grid-template-columns: minmax(0, 1fr) minmax(90px, 0.28fr); gap: 8px; } @@ -646,7 +622,7 @@ td small { } .usage-summary span, -.usage-window small { +.usage-remaining small { color: var(--muted); } @@ -666,7 +642,10 @@ td small { .usage-matrix-head, .usage-row { display: grid; - grid-template-columns: minmax(120px, 0.72fr) minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: minmax(132px, 0.78fr) minmax(0, 1fr) minmax( + 96px, + 0.46fr + ); gap: 8px; align-items: center; } @@ -714,7 +693,7 @@ td small { } .usage-account, -.usage-window div { +.usage-remaining div { display: flex; min-width: 0; gap: 8px; @@ -723,7 +702,16 @@ td small { } .usage-account { - justify-content: flex-start; + display: grid; + gap: 5px; + justify-content: stretch; +} + +.usage-account div { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 5px; } .usage-account strong { @@ -735,7 +723,8 @@ td small { white-space: nowrap; } -.usage-window span { +.usage-remaining span, +.usage-speed span { color: var(--muted); font-size: 11px; font-weight: 850; @@ -743,12 +732,38 @@ td small { text-transform: uppercase; } -.usage-window { +.usage-remaining, +.usage-speed { display: grid; min-width: 0; gap: 5px; } +.usage-speed { + min-height: 54px; + align-content: center; + padding: 8px; + border-radius: 14px; + background: rgba(43, 55, 38, 0.045); +} + +.usage-speed strong { + font-size: 18px; + line-height: 1; +} + +.usage-speed small { + color: var(--muted); +} + +.usage-speed-warn { + background: rgba(181, 138, 34, 0.16); +} + +.usage-speed-critical { + background: rgba(183, 71, 52, 0.16); +} + progress { width: 100%; height: 8px; @@ -904,6 +919,13 @@ progress::-moz-progress-bar { align-items: start; } +.inbox-card-badges { + display: flex; + flex-wrap: wrap; + gap: 5px; + justify-content: flex-end; +} + .inbox-card-head strong, .inbox-card p, .inbox-meta strong, @@ -1028,6 +1050,23 @@ progress::-moz-progress-bar { font-style: normal; } +.record-details { + margin-top: 4px; +} + +.record-details summary { + display: inline-flex; + cursor: pointer; + color: var(--muted); + font-size: 12px; + font-weight: 850; + list-style: none; +} + +.record-details summary::-webkit-details-marker { + display: none; +} + .task-board { display: grid; gap: 12px; @@ -1462,10 +1501,6 @@ progress::-moz-progress-bar { font-size: 12px; } - .section-nav span { - display: none; - } - .section-nav .menu-button span { display: block; } @@ -1520,11 +1555,7 @@ progress::-moz-progress-bar { } .usage-summary { - grid-template-columns: minmax(0, 1fr) minmax(78px, 0.55fr); - } - - .usage-summary div:last-child { - grid-column: 1 / -1; + grid-template-columns: minmax(0, 1fr) minmax(78px, 0.4fr); } .usage-matrix-head { @@ -1532,7 +1563,7 @@ progress::-moz-progress-bar { } .usage-row { - grid-template-columns: minmax(0, 0.9fr) minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1fr) minmax(90px, 0.5fr); gap: 6px; padding: 9px; } @@ -1542,15 +1573,16 @@ progress::-moz-progress-bar { gap: 5px; } - .usage-window { + .usage-remaining, + .usage-speed { gap: 4px; } - .usage-window div { + .usage-remaining div { gap: 6px; } - .usage-window small { + .usage-remaining small { overflow: hidden; font-size: 11px; text-overflow: ellipsis; @@ -1650,7 +1682,6 @@ progress::-moz-progress-bar { .usage-account { grid-column: 1 / -1; - grid-template-columns: minmax(0, 1fr) auto; } .skeleton-grid, diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts index 41fc815..1831e68 100644 --- a/src/web-dashboard-data.test.ts +++ b/src/web-dashboard-data.test.ts @@ -291,6 +291,14 @@ describe('web dashboard data', () => { last_result: 'Error: BOT_TOKEN=plain-secret-value failed', status: 'paused', }), + makeTask({ + id: 'ci-2', + prompt: + '[BACKGROUND CI WATCH]\nWatch target:\nPR #22\n\nCheck instructions:\nwatch', + last_run: '2026-04-26T05:07:00.000Z', + last_result: 'Error: BOT_TOKEN=plain-secret-value failed', + status: 'active', + }), makeTask({ id: 'cron-1', prompt: 'regular cron', @@ -329,10 +337,12 @@ describe('web dashboard data', () => { ); const ciFailure = overview.inbox.find((item) => item.kind === 'ci-failure'); expect(ciFailure).toMatchObject({ - id: 'ci:ci-1', + id: 'ci:ci-2', severity: 'error', + occurrences: 2, source: 'scheduled-task', - taskId: 'ci-1', + taskId: 'ci-2', + lastOccurredAt: '2026-04-26T05:07:00.000Z', }); expect(ciFailure?.summary).toContain('BOT_TOKEN='); expect(ciFailure?.summary).not.toContain('plain-secret-value'); diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts index ad2a64e..239c5c7 100644 --- a/src/web-dashboard-data.ts +++ b/src/web-dashboard-data.ts @@ -69,12 +69,15 @@ export type InboxItemSeverity = 'info' | 'warn' | 'error'; export interface InboxItem { id: string; + groupKey: string; kind: InboxItemKind; severity: InboxItemSeverity; title: string; summary: string; occurredAt: string; + lastOccurredAt: string; createdAt: string; + occurrences: number; source: 'status-snapshot' | 'paired-task' | 'scheduled-task'; roomJid?: string; roomName?: string; @@ -108,6 +111,14 @@ function buildInboxPreview(value: string): string { return truncateText(redactSensitiveText(value).replace(/\s+/g, ' ').trim()); } +function stableHash(value: string): string { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) | 0; + } + return Math.abs(hash).toString(36); +} + function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] { const rows: UsageRowSnapshot[] = []; const seen = new Set(); @@ -174,12 +185,15 @@ function collectInboxItems(args: { items.push({ id: `room:${snapshot.serviceId}:${entry.jid}`, + groupKey: `room:${snapshot.serviceId}:${entry.jid}`, kind: 'pending-room', severity: entry.pendingTasks > 0 ? 'warn' : 'info', title: entry.name || entry.folder || entry.jid, summary: parts.join(' · '), occurredAt: snapshot.updatedAt, + lastOccurredAt: snapshot.updatedAt, createdAt: args.createdAt, + occurrences: 1, source: 'status-snapshot', roomJid: entry.jid, roomName: entry.name, @@ -195,9 +209,9 @@ function collectInboxItems(args: { items.push({ id: `paired:${task.id}:${task.status}`, + groupKey: `paired:${task.id}:${task.status}`, kind, - severity: - kind === 'approval' || kind === 'arbiter-request' ? 'error' : 'warn', + severity: kind === 'arbiter-request' ? 'error' : 'warn', title: task.title || task.group_folder, summary: task.status, occurredAt: @@ -206,7 +220,14 @@ function collectInboxItems(args: { : kind === 'reviewer-request' ? (task.review_requested_at ?? task.updated_at) : task.updated_at, + lastOccurredAt: + kind === 'arbiter-request' + ? (task.arbiter_requested_at ?? task.updated_at) + : kind === 'reviewer-request' + ? (task.review_requested_at ?? task.updated_at) + : task.updated_at, createdAt: args.createdAt, + occurrences: 1, source: 'paired-task', roomJid: task.chat_jid, groupFolder: task.group_folder, @@ -226,12 +247,15 @@ function collectInboxItems(args: { items.push({ id: `ci:${task.id}`, + groupKey: `ci:${stableHash(result.toLowerCase())}`, kind: 'ci-failure', severity: task.status === 'paused' ? 'error' : 'warn', title: 'CI watcher failed', summary: result, occurredAt: task.last_run ?? task.created_at, + lastOccurredAt: task.last_run ?? task.created_at, createdAt: args.createdAt, + occurrences: 1, source: 'scheduled-task', roomJid: task.chat_jid, groupFolder: task.group_folder, @@ -247,11 +271,40 @@ function collectInboxItems(args: { info: 2, }; - return items + const groupedItems = new Map(); + for (const item of items) { + const existing = groupedItems.get(item.groupKey); + if (!existing) { + groupedItems.set(item.groupKey, item); + continue; + } + + const occurrences = existing.occurrences + item.occurrences; + const severity = + severityRank[item.severity] < severityRank[existing.severity] + ? item.severity + : existing.severity; + const latest = + item.lastOccurredAt.localeCompare(existing.lastOccurredAt) > 0 + ? item + : existing; + + groupedItems.set(item.groupKey, { + ...latest, + severity, + occurrences, + lastOccurredAt: + item.lastOccurredAt.localeCompare(existing.lastOccurredAt) > 0 + ? item.lastOccurredAt + : existing.lastOccurredAt, + }); + } + + return [...groupedItems.values()] .sort((a, b) => { const severityDelta = severityRank[a.severity] - severityRank[b.severity]; if (severityDelta !== 0) return severityDelta; - return b.occurredAt.localeCompare(a.occurredAt); + return b.lastOccurredAt.localeCompare(a.lastOccurredAt); }) .slice(0, 50); }