From 6d13c3aa492b2e91ecc23575c69d38c9e7c5cba6 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 28 Apr 2026 15:04:37 +0900 Subject: [PATCH] Extract dashboard InboxPanel component (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * review: harden readonly git checks and lint verification * test: satisfy readonly reviewer sandbox typing * test: fix readonly reviewer sandbox assertions * test: remove impossible readonly sandbox guards * test: relax readonly sandbox assertions * test: cast readonly sandbox expectation shape * test: cast readonly sandbox expectation through unknown * Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke * Add STEP_DONE guards, verdict storage, and stale delivery suppression * style: format paired stepdone telemetry files * Route STEP_DONE through reviewer * Add structured Discord attachments * style: format structured attachment test * Fix room thread bot output parity * Remove duplicate work item attachment migration from owner branch * Remove legacy dashboard rooms renderer * Extract dashboard parsed body renderer * Extract dashboard redaction helpers * Extract dashboard RoomCardV2 component * Include RoomCardV2 test in vitest suite * Extract dashboard RoomBoardV2 component * Extract dashboard EmptyState component * Extract dashboard InboxPanel component * Split dashboard InboxPanel card renderer --- apps/dashboard/src/App.tsx | 310 +------------------------ apps/dashboard/src/InboxPanel.test.ts | 82 +++++++ apps/dashboard/src/InboxPanel.tsx | 293 +++++++++++++++++++++++ apps/dashboard/src/dashboardHelpers.ts | 64 +++++ 4 files changed, 441 insertions(+), 308 deletions(-) create mode 100644 apps/dashboard/src/InboxPanel.test.ts create mode 100644 apps/dashboard/src/InboxPanel.tsx create mode 100644 apps/dashboard/src/dashboardHelpers.ts diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 0506cc8..14e9979 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -51,7 +51,8 @@ import { type DashboardFreshness, type DashboardView, } from './DashboardNav'; -import { isInternalProtocolPayload } from './roomThread'; +import { formatDate, taskActionsFor } from './dashboardHelpers'; +import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel'; import { RoomBoardV2 } from './RoomBoardV2'; import { EmptyState } from './EmptyState'; import { ParsedBody } from './ParsedBody'; @@ -65,7 +66,6 @@ interface DashboardState { } type UsageRow = DashboardOverview['usage']['rows'][number]; -type InboxItem = DashboardOverview['inbox'][number]; type RiskLevel = 'ok' | 'warn' | 'critical'; type UsageGroup = 'primary' | 'codex'; type UsageLimitWindow = 'h5' | 'd7'; @@ -75,9 +75,7 @@ type TaskActionKey = | 'create' | `${string}:edit` | `${string}:${DashboardTaskAction}`; -type InboxActionKey = `${string}:${DashboardInboxAction}`; type ServiceActionKey = 'stack:restart'; -type InboxFilter = 'all' | InboxItem['kind']; type HealthLevel = 'ok' | 'stale' | 'down'; type FreshnessLevel = DashboardFreshness; @@ -167,69 +165,6 @@ function senderRoleClass(value: string | null | undefined): string { return 'role-human'; } -function sanitizeInboxText(value: string | null | undefined): string { - if (!value) return ''; - return value - .replace(/<\/?internal[^>]*>/gi, '') - .replace(/<\/?intern\.{3}/gi, '') - .replace(/<\/?[a-z][a-z0-9-]*[^>]*>/gi, '') - .replace(/\s{2,}/g, ' ') - .trim(); -} - -function formatDate(value: string | null | undefined, locale: Locale): string { - if (!value) return '-'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - const now = Date.now(); - const ageMs = now - date.getTime(); - if (ageMs >= 0 && ageMs < 60_000) { - return locale === 'ko' - ? '방금' - : locale === 'ja' - ? 'たった今' - : locale === 'zh' - ? '刚刚' - : 'just now'; - } - if (ageMs >= 0 && ageMs < 3_600_000) { - const mins = Math.floor(ageMs / 60_000); - return locale === 'ko' - ? `${mins}분 전` - : locale === 'ja' - ? `${mins}分前` - : locale === 'zh' - ? `${mins} 分钟前` - : `${mins}m ago`; - } - const sameDay = new Date().toDateString() === date.toDateString(); - if (sameDay) { - return new Intl.DateTimeFormat(localeTags[locale], { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }).format(date); - } - const time = new Intl.DateTimeFormat(localeTags[locale], { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }).format(date); - if (locale === 'ko') - return `${date.getMonth() + 1}월 ${date.getDate()}일 ${time}`; - if (locale === 'ja') - return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`; - if (locale === 'zh') - return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`; - return new Intl.DateTimeFormat(localeTags[locale], { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false, - }).format(date); -} - function dashboardAgeMs(value: string | null | undefined): number | null { if (!value) return null; const date = new Date(value); @@ -482,12 +417,6 @@ function taskDisplayName(task: DashboardTask, t: Messages): string { return task.id; } -function taskActionsFor(task: DashboardTask): DashboardTaskAction[] { - if (task.status === 'active') return ['pause', 'cancel']; - if (task.status === 'paused') return ['resume', 'cancel']; - return []; -} - function buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] { const rooms = new Map(); for (const snapshot of snapshots) { @@ -567,41 +496,6 @@ function readTaskForm( }; } -function inboxActionsFor(item: InboxItem): DashboardInboxAction[] { - if ( - item.source === 'paired-task' && - (item.kind === 'reviewer-request' || - item.kind === 'approval' || - item.kind === 'arbiter-request') - ) { - return ['run', 'decline', 'dismiss']; - } - return ['dismiss']; -} - -function inboxActionLabel( - item: InboxItem, - action: DashboardInboxAction, - t: Messages, -): string { - if (action === 'dismiss') return t.inbox.actions.dismiss; - if (action === 'decline') return t.inbox.actions.decline; - if (item.kind === 'reviewer-request') return t.inbox.actions.runReview; - if (item.kind === 'approval') return t.inbox.actions.finalize; - if (item.kind === 'arbiter-request') return t.inbox.actions.runArbiter; - return t.inbox.actions.run; -} - -const INBOX_FILTERS: InboxFilter[] = [ - 'all', - 'ci-failure', - 'approval', - 'reviewer-request', - 'arbiter-request', - 'pending-room', - 'mention', -]; - function serviceAgeMs( service: DashboardOverview['services'][number], generatedAt: string, @@ -623,12 +517,6 @@ function serviceHealthLevel( return 'ok'; } -function inboxTargetHref(item: InboxItem): string | null { - if (item.taskId) return '#/scheduled'; - if (item.roomJid || item.groupFolder) return '#/rooms'; - return null; -} - function LanguageSelector({ locale, onLocaleChange, @@ -1368,200 +1256,6 @@ function ControlRail({ ); } -function InboxPanel({ - overview, - tasks, - locale, - onInboxAction, - onTaskAction, - inboxActionKey, - taskActionKey, - t, -}: { - overview: DashboardOverview; - tasks: DashboardTask[]; - locale: Locale; - onInboxAction: (item: InboxItem, action: DashboardInboxAction) => void; - onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void; - inboxActionKey: InboxActionKey | null; - taskActionKey: TaskActionKey | null; - t: Messages; -}) { - const [filter, setFilter] = useState('all'); - const items = overview.inbox ?? []; - const counts = useMemo(() => { - const next: Record = { - all: items.length, - 'pending-room': 0, - 'reviewer-request': 0, - approval: 0, - 'arbiter-request': 0, - 'ci-failure': 0, - mention: 0, - }; - for (const item of items) next[item.kind] += 1; - return next; - }, [items]); - const filteredItems = - filter === 'all' ? items : items.filter((item) => item.kind === filter); - const severityCounts = items.reduce( - (acc, item) => { - acc[item.severity] += 1; - return acc; - }, - { error: 0, warn: 0, info: 0 }, - ); - - if (items.length === 0) { - return {t.inbox.empty}; - } - - return ( -
-
-
- {t.inbox.total} - {items.length} -
-
- {t.inbox.severity.error} - {severityCounts.error} -
-
- {t.inbox.severity.warn} - {severityCounts.warn} -
-
- {t.inbox.severity.info} - {severityCounts.info} -
-
- -
- {INBOX_FILTERS.map((item) => { - if (item !== 'all' && counts[item] === 0) return null; - const label = item === 'all' ? t.inbox.all : t.inbox.kinds[item]; - return ( - - ); - })} -
- -
- {filteredItems.map((item) => { - const href = inboxTargetHref(item); - const linkedTask = - item.source === 'scheduled-task' && item.taskId - ? tasks.find((task) => task.id === item.taskId) - : undefined; - const linkedTaskActions = linkedTask - ? taskActionsFor(linkedTask) - : []; - const inboxActions = inboxActionsFor(item); - return ( -
-
-
- {t.inbox.kinds[item.kind]} - {sanitizeInboxText(item.title) || item.title} -
-
- - {t.inbox.severity[item.severity]} - - {item.occurrences > 1 ? ( - x{item.occurrences} - ) : null} -
-
-

{sanitizeInboxText(item.summary) || t.inbox.noSummary}

-
- - {t.inbox.occurred} - {formatDate(item.occurredAt, locale)} - - - {t.inbox.source} - {item.source} - - - {t.inbox.target} - - {item.taskId ?? - item.roomName ?? - item.groupFolder ?? - item.roomJid ?? - '-'} - - -
- {href ? ( - - {item.taskId ? t.inbox.openTask : t.inbox.openRoom} - - ) : null} - {linkedTask && linkedTaskActions.length > 0 ? ( -
- {linkedTaskActions.map((action) => { - const actionKey: TaskActionKey = `${linkedTask.id}:${action}`; - const busy = taskActionKey === actionKey; - return ( - - ); - })} -
- ) : null} - {inboxActions.length > 0 ? ( -
- {inboxActions.map((action) => { - const actionKey: InboxActionKey = `${item.id}:${action}`; - const busy = inboxActionKey === actionKey; - return ( - - ); - })} -
- ) : null} -
- ); - })} -
-
- ); -} - function HealthPanel({ data, locale, diff --git a/apps/dashboard/src/InboxPanel.test.ts b/apps/dashboard/src/InboxPanel.test.ts new file mode 100644 index 0000000..9831b34 --- /dev/null +++ b/apps/dashboard/src/InboxPanel.test.ts @@ -0,0 +1,82 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import type { DashboardOverview } from './api'; +import { InboxPanel, type InboxPanelProps } from './InboxPanel'; +import { messages } from './i18n'; + +const t = messages.en; + +function overview(inbox: DashboardOverview['inbox']): DashboardOverview { + return { + generatedAt: '2026-04-28T04:00:00.000Z', + inbox, + operations: { serviceRestarts: [] }, + rooms: { active: 0, inactive: 0, total: 0, waiting: 0 }, + services: [], + tasks: { + active: 0, + completed: 0, + paused: 0, + total: 0, + watchers: { active: 0, completed: 0, paused: 0 }, + }, + usage: { fetchedAt: null, rows: [] }, + }; +} + +const inboxItem: DashboardOverview['inbox'][number] = { + createdAt: '2026-04-28T03:55:00.000Z', + groupFolder: 'eyejokerdb', + groupKey: 'paired:room-1', + id: 'inbox-1', + kind: 'reviewer-request', + lastOccurredAt: '2026-04-28T03:59:00.000Z', + occurrences: 2, + occurredAt: '2026-04-28T03:58:00.000Z', + roomJid: 'room-1', + roomName: 'eyejokerdb-main', + severity: 'warn', + source: 'paired-task', + summary: 'hiddenReviewer needs action', + title: 'hiddenReview requested', +}; + +const baseProps: InboxPanelProps = { + inboxActionKey: null, + locale: 'en', + onInboxAction: () => {}, + onTaskAction: () => {}, + overview: overview([inboxItem]), + taskActionKey: null, + tasks: [], + t, +}; + +describe('InboxPanel', () => { + it('renders inbox summary, filters, and paired task actions', () => { + const html = renderToStaticMarkup( + createElement(InboxPanel, { ...baseProps }), + ); + + expect(html).toContain(t.inbox.summary); + expect(html).toContain('Review requested'); + expect(html).toContain('Reviewer needs action'); + expect(html).toContain(t.inbox.actions.runReview); + expect(html).toContain(t.inbox.actions.decline); + expect(html).toContain(t.inbox.actions.dismiss); + expect(html).not.toContain('internal'); + }); + + it('renders empty state when no inbox items exist', () => { + const html = renderToStaticMarkup( + createElement(InboxPanel, { + ...baseProps, + overview: overview([]), + }), + ); + + expect(html).toContain(t.inbox.empty); + }); +}); diff --git a/apps/dashboard/src/InboxPanel.tsx b/apps/dashboard/src/InboxPanel.tsx new file mode 100644 index 0000000..ed4b76d --- /dev/null +++ b/apps/dashboard/src/InboxPanel.tsx @@ -0,0 +1,293 @@ +import { useMemo, useState } from 'react'; + +import type { + DashboardInboxAction, + DashboardOverview, + DashboardTask, + DashboardTaskAction, +} from './api'; +import { formatDate, taskActionsFor } from './dashboardHelpers'; +import { EmptyState } from './EmptyState'; +import type { Locale, Messages } from './i18n'; + +export type InboxItem = DashboardOverview['inbox'][number]; +export type InboxActionKey = `${string}:${DashboardInboxAction}`; + +type InboxFilter = 'all' | InboxItem['kind']; + +export interface InboxPanelProps { + inboxActionKey: InboxActionKey | null; + locale: Locale; + onInboxAction: (item: InboxItem, action: DashboardInboxAction) => void; + onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void; + overview: DashboardOverview; + taskActionKey: string | null; + tasks: DashboardTask[]; + t: Messages; +} + +const INBOX_FILTERS: InboxFilter[] = [ + 'all', + 'ci-failure', + 'approval', + 'reviewer-request', + 'arbiter-request', + 'pending-room', + 'mention', +]; + +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 inboxActionsFor(item: InboxItem): DashboardInboxAction[] { + if ( + item.source === 'paired-task' && + (item.kind === 'reviewer-request' || + item.kind === 'approval' || + item.kind === 'arbiter-request') + ) { + return ['run', 'decline', 'dismiss']; + } + return ['dismiss']; +} + +function inboxActionLabel( + item: InboxItem, + action: DashboardInboxAction, + t: Messages, +): string { + if (action === 'dismiss') return t.inbox.actions.dismiss; + if (action === 'decline') return t.inbox.actions.decline; + if (item.kind === 'reviewer-request') return t.inbox.actions.runReview; + if (item.kind === 'approval') return t.inbox.actions.finalize; + if (item.kind === 'arbiter-request') return t.inbox.actions.runArbiter; + return t.inbox.actions.run; +} + +function inboxTargetHref(item: InboxItem): string | null { + if (item.taskId) return '#/scheduled'; + if (item.roomJid || item.groupFolder) return '#/rooms'; + return null; +} + +interface InboxCardProps { + inboxActionKey: InboxActionKey | null; + item: InboxItem; + locale: Locale; + onInboxAction: (item: InboxItem, action: DashboardInboxAction) => void; + onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void; + taskActionKey: string | null; + tasks: DashboardTask[]; + t: Messages; +} + +function InboxCard({ + inboxActionKey, + item, + locale, + onInboxAction, + onTaskAction, + taskActionKey, + tasks, + t, +}: InboxCardProps) { + const href = inboxTargetHref(item); + const linkedTask = + item.source === 'scheduled-task' && item.taskId + ? tasks.find((task) => task.id === item.taskId) + : undefined; + const linkedTaskActions = linkedTask ? taskActionsFor(linkedTask) : []; + const inboxActions = inboxActionsFor(item); + + return ( +
+
+
+ {t.inbox.kinds[item.kind]} + {sanitizeInboxText(item.title) || item.title} +
+
+ + {t.inbox.severity[item.severity]} + + {item.occurrences > 1 ? ( + x{item.occurrences} + ) : null} +
+
+

{sanitizeInboxText(item.summary) || t.inbox.noSummary}

+
+ + {t.inbox.occurred} + {formatDate(item.occurredAt, locale)} + + + {t.inbox.source} + {item.source} + + + {t.inbox.target} + + {item.taskId ?? + item.roomName ?? + item.groupFolder ?? + item.roomJid ?? + '-'} + + +
+ {href ? ( + + {item.taskId ? t.inbox.openTask : t.inbox.openRoom} + + ) : null} + {linkedTask && linkedTaskActions.length > 0 ? ( +
+ {linkedTaskActions.map((action) => { + const actionKey = `${linkedTask.id}:${action}`; + const busy = taskActionKey === actionKey; + return ( + + ); + })} +
+ ) : null} + {inboxActions.length > 0 ? ( +
+ {inboxActions.map((action) => { + const actionKey: InboxActionKey = `${item.id}:${action}`; + const busy = inboxActionKey === actionKey; + return ( + + ); + })} +
+ ) : null} +
+ ); +} + +export function InboxPanel({ + overview, + tasks, + locale, + onInboxAction, + onTaskAction, + inboxActionKey, + taskActionKey, + t, +}: InboxPanelProps) { + const [filter, setFilter] = useState('all'); + const items = overview.inbox ?? []; + const counts = useMemo(() => { + const next: Record = { + all: items.length, + 'pending-room': 0, + 'reviewer-request': 0, + approval: 0, + 'arbiter-request': 0, + 'ci-failure': 0, + mention: 0, + }; + for (const item of items) next[item.kind] += 1; + return next; + }, [items]); + const filteredItems = + filter === 'all' ? items : items.filter((item) => item.kind === filter); + const severityCounts = items.reduce( + (acc, item) => { + acc[item.severity] += 1; + return acc; + }, + { error: 0, warn: 0, info: 0 }, + ); + + if (items.length === 0) { + return {t.inbox.empty}; + } + + return ( +
+
+
+ {t.inbox.total} + {items.length} +
+
+ {t.inbox.severity.error} + {severityCounts.error} +
+
+ {t.inbox.severity.warn} + {severityCounts.warn} +
+
+ {t.inbox.severity.info} + {severityCounts.info} +
+
+ +
+ {INBOX_FILTERS.map((item) => { + if (item !== 'all' && counts[item] === 0) return null; + const label = item === 'all' ? t.inbox.all : t.inbox.kinds[item]; + return ( + + ); + })} +
+ +
+ {filteredItems.map((item) => ( + + ))} +
+
+ ); +} diff --git a/apps/dashboard/src/dashboardHelpers.ts b/apps/dashboard/src/dashboardHelpers.ts new file mode 100644 index 0000000..3a0a23c --- /dev/null +++ b/apps/dashboard/src/dashboardHelpers.ts @@ -0,0 +1,64 @@ +import type { DashboardTask, DashboardTaskAction } from './api'; +import { localeTags, type Locale } from './i18n'; + +export function formatDate( + value: string | null | undefined, + locale: Locale, +): string { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + const now = Date.now(); + const ageMs = now - date.getTime(); + if (ageMs >= 0 && ageMs < 60_000) { + return locale === 'ko' + ? '방금' + : locale === 'ja' + ? 'たった今' + : locale === 'zh' + ? '刚刚' + : 'just now'; + } + if (ageMs >= 0 && ageMs < 3_600_000) { + const mins = Math.floor(ageMs / 60_000); + return locale === 'ko' + ? `${mins}분 전` + : locale === 'ja' + ? `${mins}分前` + : locale === 'zh' + ? `${mins} 分钟前` + : `${mins}m ago`; + } + const sameDay = new Date().toDateString() === date.toDateString(); + if (sameDay) { + return new Intl.DateTimeFormat(localeTags[locale], { + hour: '2-digit', + hour12: false, + minute: '2-digit', + }).format(date); + } + const time = new Intl.DateTimeFormat(localeTags[locale], { + hour: '2-digit', + hour12: false, + minute: '2-digit', + }).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); +} + +export function taskActionsFor(task: DashboardTask): DashboardTaskAction[] { + if (task.status === 'active') return ['pause', 'cancel']; + if (task.status === 'paused') return ['resume', 'cancel']; + return []; +}