Extract dashboard RoomBoardV2 component (#60)

* 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
This commit is contained in:
Eyejoker
2026-04-28 14:23:43 +09:00
committed by GitHub
parent 05865197c7
commit d99c0bc6be
3 changed files with 507 additions and 259 deletions

View File

@@ -44,10 +44,7 @@ import {
type Locale,
type Messages,
} from './i18n';
import {
useSelectedRoomActivity,
type RoomActivityMap,
} from './useRoomActivity';
import { useSelectedRoomActivity } from './useRoomActivity';
import {
SectionNav,
SideRail,
@@ -55,7 +52,7 @@ import {
type DashboardView,
} from './DashboardNav';
import { isInternalProtocolPayload } from './roomThread';
import { RoomCardV2, type RoomEntryWithService } from './RoomCardV2';
import { RoomBoardV2 } from './RoomBoardV2';
import { ParsedBody } from './ParsedBody';
import { redactSecretsForPreview } from './redaction';
import './styles.css';
@@ -426,6 +423,14 @@ function formatLiveElapsed(value: number, t: Messages): string {
return `${hours}${t.units.hour} ${remMin}${t.units.minute} ${remSec}${t.units.second}`;
}
const ROOM_BOARD_FORMATTERS = {
formatDate,
formatDuration,
formatLiveElapsed,
senderRoleClass,
statusLabel,
};
function queueLabel(
pendingTasks: number,
pendingMessages: boolean,
@@ -1738,258 +1743,6 @@ function HealthPanel({
);
}
type RoomFilter = 'all' | 'processing' | 'waiting' | 'inactive';
type RoomSort = 'recent' | 'name' | 'queue';
const ROOM_FILTER_ORDER: RoomFilter[] = [
'all',
'processing',
'waiting',
'inactive',
];
function roomFilterLabel(f: RoomFilter, t: Messages): string {
if (f === 'all') return t.rooms.filterAll;
return statusLabel(f, t);
}
function roomSortLabel(s: RoomSort, t: Messages): string {
if (s === 'recent') return t.rooms.sortRecent;
if (s === 'queue') return t.rooms.sortQueue;
return t.rooms.sortName;
}
function RoomBoardV2({
inbox,
onSendRoomMessage,
pendingMessages,
roomActivity,
roomActivityLoading,
roomMessageKey,
selectedJid,
locale,
onSelectedJidChange,
snapshots,
t,
}: {
inbox: InboxItem[];
onSendRoomMessage: (
roomJid: string,
text: string,
requestId: string,
) => Promise<boolean>;
pendingMessages: Record<
string,
Array<DashboardRoomActivity['messages'][number]>
>;
roomActivity: RoomActivityMap;
roomActivityLoading: boolean;
roomMessageKey: string | null;
selectedJid: string | null;
locale: Locale;
onSelectedJidChange: (jid: string | null) => void;
snapshots: StatusSnapshot[];
t: Messages;
}) {
const [filter, setFilter] = useState<RoomFilter>('all');
const [sort, setSort] = useState<RoomSort>('recent');
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const allEntries: RoomEntryWithService[] = snapshots.flatMap((snapshot) =>
snapshot.entries.map((entry) => ({
...entry,
serviceId: snapshot.serviceId,
})),
);
const counts = {
all: allEntries.length,
processing: allEntries.filter((e) => e.status === 'processing').length,
waiting: allEntries.filter((e) => e.status === 'waiting').length,
inactive: allEntries.filter((e) => e.status === 'inactive').length,
};
const filtered = allEntries.filter(
(e) => filter === 'all' || e.status === filter,
);
const sorted = [...filtered].sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'queue') {
const aQ = a.pendingTasks + (a.pendingMessages ? 1 : 0);
const bQ = b.pendingTasks + (b.pendingMessages ? 1 : 0);
return bQ - aQ;
}
const aA = roomActivity[a.jid]?.pairedTask?.updatedAt;
const bA = roomActivity[b.jid]?.pairedTask?.updatedAt;
const aT = aA ? new Date(aA).getTime() : (a.elapsedMs ?? 0);
const bT = bA ? new Date(bA).getTime() : (b.elapsedMs ?? 0);
return bT - aT;
});
const selectedEntry =
sorted.find((e) => e.jid === selectedJid) ?? sorted[0] ?? null;
useEffect(() => {
const nextJid = selectedEntry?.jid ?? null;
if (nextJid !== selectedJid) {
onSelectedJidChange(nextJid);
setMobileDetailOpen(false);
}
}, [onSelectedJidChange, selectedEntry?.jid, selectedJid]);
if (allEntries.length === 0) {
return <EmptyState>{t.rooms.empty}</EmptyState>;
}
function setDraft(jid: string, value: string) {
setDrafts((previous) => ({ ...previous, [jid]: value }));
}
function scrollDetailToBottom() {
if (typeof window === 'undefined') return;
const detail = document.querySelector(
'.rooms-detail',
) as HTMLElement | null;
if (!detail) return;
const tick = (n: number) => {
detail.scrollTop = detail.scrollHeight;
if (n > 0) requestAnimationFrame(() => tick(n - 1));
};
requestAnimationFrame(() => tick(3));
}
async function submitRoomMessage(jid: string) {
const text = drafts[jid]?.trim();
if (!text) return;
scrollDetailToBottom();
const success = await onSendRoomMessage(jid, text, makeClientRequestId());
if (success) {
setDraft(jid, '');
scrollDetailToBottom();
}
}
return (
<div className="rooms-v2">
<div className="rooms-toolbar" role="toolbar" aria-label="Rooms filters">
<div className="rooms-filters">
{ROOM_FILTER_ORDER.map((f) => (
<button
aria-pressed={filter === f}
className={filter === f ? 'is-active' : undefined}
key={f}
onClick={() => setFilter(f)}
type="button"
>
{roomFilterLabel(f, t)}
<span>{counts[f]}</span>
</button>
))}
</div>
<label className="rooms-sort">
<span>{t.rooms.sortLabel}</span>
<select
onChange={(e) => setSort(e.target.value as RoomSort)}
value={sort}
>
<option value="recent">{roomSortLabel('recent', t)}</option>
<option value="queue">{roomSortLabel('queue', t)}</option>
<option value="name">{roomSortLabel('name', t)}</option>
</select>
</label>
</div>
{sorted.length === 0 ? (
<EmptyState>{t.rooms.empty}</EmptyState>
) : (
<div
className={`rooms-twopane${mobileDetailOpen ? ' is-detail-open' : ''}`}
>
<aside className="rooms-list" aria-label={t.rooms.cardsAria}>
{sorted.map((entry) => {
const items = inbox.filter((it) => it.roomJid === entry.jid);
const queue =
entry.pendingTasks + (entry.pendingMessages ? 1 : 0);
const active = (selectedEntry?.jid ?? null) === entry.jid;
return (
<button
aria-current={active ? 'page' : undefined}
className={`rooms-list-item status-${entry.status}${active ? ' is-active' : ''}`}
key={`${entry.serviceId}:${entry.jid}`}
onClick={() => {
onSelectedJidChange(entry.jid);
setMobileDetailOpen(true);
}}
type="button"
>
<span className={`room-pulse pulse-${entry.status}`}>
{entry.status === 'processing' ? (
<span className="pulse-dot" />
) : null}
</span>
<span className="rooms-list-text">
<strong>{entry.name}</strong>
<small>{entry.agentType}</small>
</span>
{items.length > 0 ? (
<span
className={`rooms-list-bell sev-${items[0].severity}`}
title={items.map((i) => t.inbox.kinds[i.kind]).join(', ')}
>
{items.length}
</span>
) : null}
{queue > 0 ? (
<span className="rooms-list-queue">{queue}</span>
) : null}
</button>
);
})}
</aside>
<main className="rooms-detail">
<button
className="rooms-mobile-back"
onClick={() => setMobileDetailOpen(false)}
type="button"
>
{t.panels.rooms}
</button>
{selectedEntry ? (
<RoomCardV2
activity={roomActivity[selectedEntry.jid]}
activityLoading={roomActivityLoading}
busy={roomMessageKey === selectedEntry.jid}
draft={drafts[selectedEntry.jid] ?? ''}
entry={selectedEntry}
expanded={true}
formatDate={formatDate}
formatDuration={formatDuration}
formatLiveElapsed={formatLiveElapsed}
inboxItems={inbox.filter(
(item) => item.roomJid === selectedEntry.jid,
)}
key={`${selectedEntry.serviceId}:${selectedEntry.jid}`}
locale={locale}
onDraftChange={(v) => setDraft(selectedEntry.jid, v)}
onSendMessage={() => void submitRoomMessage(selectedEntry.jid)}
onToggle={() => {}}
pendingMessages={pendingMessages[selectedEntry.jid] ?? []}
pinned={true}
senderRoleClass={senderRoleClass}
statusLabel={statusLabel}
t={t}
/>
) : (
<EmptyState>{t.rooms.empty}</EmptyState>
)}
</main>
</div>
)}
</div>
);
}
function UsageQuotaMeter({
row,
rowName,
@@ -3036,14 +2789,16 @@ function App() {
<span>{t.panels.queue}</span>
</div>
<RoomBoardV2
{...ROOM_BOARD_FORMATTERS}
createRequestId={makeClientRequestId}
inbox={data.overview.inbox}
locale={locale}
onSelectedJidChange={setSelectedRoomJid}
onSendRoomMessage={handleRoomMessage}
pendingMessages={pendingMessages}
roomActivity={roomActivity}
roomActivityLoading={roomActivityLoading}
roomMessageKey={roomMessageKey}
locale={locale}
onSelectedJidChange={setSelectedRoomJid}
selectedJid={selectedRoomJid}
snapshots={data.snapshots}
t={t}

View File

@@ -0,0 +1,92 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { DashboardRoomActivity, StatusSnapshot } from './api';
import { messages } from './i18n';
import { RoomBoardV2, type RoomBoardV2Props } from './RoomBoardV2';
const t = messages.ko;
const snapshot: StatusSnapshot = {
serviceId: 'svc-1',
assistantName: 'codex',
agentType: 'codex',
updatedAt: '2026-04-28T04:00:00.000Z',
entries: [
{
jid: 'room-1',
name: 'eyejokerdb-main',
folder: 'eyejokerdb',
agentType: 'codex',
status: 'processing',
elapsedMs: 180_000,
pendingMessages: false,
pendingTasks: 3,
},
],
};
const activity: DashboardRoomActivity = {
serviceId: 'svc-1',
jid: 'room-1',
name: 'eyejokerdb-main',
folder: 'eyejokerdb',
agentType: 'codex',
status: 'processing',
elapsedMs: 180_000,
pendingMessages: false,
pendingTasks: 3,
messages: [
{
id: 'bot-1',
sender: 'bot-1',
senderName: 'owner',
content: 'TASK_DONE\n\nprod 배포 완료',
timestamp: '2026-04-28T02:00:00.000Z',
isFromMe: false,
isBotMessage: true,
sourceKind: 'bot',
},
],
pairedTask: null,
};
const baseProps: RoomBoardV2Props = {
createRequestId: () => 'request-1',
formatDate: (value) => value ?? '-',
formatDuration: (value) => (value === null ? '-' : `${value}`),
formatLiveElapsed: (value) => `${value}`,
inbox: [],
locale: 'ko',
onSelectedJidChange: () => {},
onSendRoomMessage: async () => true,
pendingMessages: {},
roomActivity: { 'room-1': activity },
roomActivityLoading: false,
roomMessageKey: null,
selectedJid: 'room-1',
senderRoleClass: (value) =>
(value ?? '').toLowerCase().includes('owner') ? 'role-owner' : 'role-human',
snapshots: [snapshot],
statusLabel: (status) => status,
t,
};
describe('RoomBoardV2', () => {
it('renders room list and selected detail thread', () => {
const html = renderToStaticMarkup(createElement(RoomBoardV2, baseProps));
expect(html).toContain('eyejokerdb-main');
expect(html).toContain('TASK_DONE');
expect(html).toContain('prod 배포 완료');
});
it('renders an empty state without snapshots', () => {
const html = renderToStaticMarkup(
createElement(RoomBoardV2, { ...baseProps, snapshots: [] }),
);
expect(html).toContain(t.rooms.empty);
});
});

View File

@@ -0,0 +1,401 @@
import { useEffect, useState, type ReactNode } from 'react';
import type {
DashboardOverview,
DashboardRoomActivity,
StatusSnapshot,
} from './api';
import type { Locale, Messages } from './i18n';
import { RoomCardV2, type RoomEntryWithService } from './RoomCardV2';
import type { RoomActivityMap } from './useRoomActivity';
type InboxItem = DashboardOverview['inbox'][number];
type RoomFilter = 'all' | 'processing' | 'waiting' | 'inactive';
type RoomSort = 'recent' | 'name' | 'queue';
const ROOM_FILTER_ORDER: RoomFilter[] = [
'all',
'processing',
'waiting',
'inactive',
];
export interface RoomBoardV2Props {
createRequestId: () => string;
formatDate: (value: string | null | undefined, locale: Locale) => string;
formatDuration: (value: number | null, t: Messages) => string;
formatLiveElapsed: (value: number, t: Messages) => string;
inbox: InboxItem[];
locale: Locale;
onSelectedJidChange: (jid: string | null) => void;
onSendRoomMessage: (
roomJid: string,
text: string,
requestId: string,
) => Promise<boolean>;
pendingMessages: Record<
string,
Array<DashboardRoomActivity['messages'][number]>
>;
roomActivity: RoomActivityMap;
roomActivityLoading: boolean;
roomMessageKey: string | null;
selectedJid: string | null;
senderRoleClass: (value: string | null | undefined) => string;
snapshots: StatusSnapshot[];
statusLabel: (status: string, t: Messages) => string;
t: Messages;
}
function EmptyState({ children }: { children: ReactNode }) {
return <div className="empty-state">{children}</div>;
}
function roomFilterLabel(
filter: RoomFilter,
statusLabel: (status: string, t: Messages) => string,
t: Messages,
): string {
if (filter === 'all') return t.rooms.filterAll;
return statusLabel(filter, t);
}
function roomSortLabel(sort: RoomSort, t: Messages): string {
if (sort === 'recent') return t.rooms.sortRecent;
if (sort === 'queue') return t.rooms.sortQueue;
return t.rooms.sortName;
}
function scrollDetailToBottom() {
if (typeof window === 'undefined') return;
const detail = document.querySelector('.rooms-detail') as HTMLElement | null;
if (!detail) return;
const tick = (n: number) => {
detail.scrollTop = detail.scrollHeight;
if (n > 0) requestAnimationFrame(() => tick(n - 1));
};
requestAnimationFrame(() => tick(3));
}
interface RoomsToolbarProps {
counts: Record<RoomFilter, number>;
filter: RoomFilter;
onFilterChange: (filter: RoomFilter) => void;
onSortChange: (sort: RoomSort) => void;
sort: RoomSort;
statusLabel: (status: string, t: Messages) => string;
t: Messages;
}
function RoomsToolbar({
counts,
filter,
onFilterChange,
onSortChange,
sort,
statusLabel,
t,
}: RoomsToolbarProps) {
return (
<div className="rooms-toolbar" role="toolbar" aria-label="Rooms filters">
<div className="rooms-filters">
{ROOM_FILTER_ORDER.map((filterKey) => (
<button
aria-pressed={filter === filterKey}
className={filter === filterKey ? 'is-active' : undefined}
key={filterKey}
onClick={() => onFilterChange(filterKey)}
type="button"
>
{roomFilterLabel(filterKey, statusLabel, t)}
<span>{counts[filterKey]}</span>
</button>
))}
</div>
<label className="rooms-sort">
<span>{t.rooms.sortLabel}</span>
<select
onChange={(event) => onSortChange(event.target.value as RoomSort)}
value={sort}
>
<option value="recent">{roomSortLabel('recent', t)}</option>
<option value="queue">{roomSortLabel('queue', t)}</option>
<option value="name">{roomSortLabel('name', t)}</option>
</select>
</label>
</div>
);
}
interface RoomsListProps {
entries: RoomEntryWithService[];
inbox: InboxItem[];
onSelect: (jid: string) => void;
selectedJid: string | null;
t: Messages;
}
function RoomsList({
entries,
inbox,
onSelect,
selectedJid,
t,
}: RoomsListProps) {
return (
<aside className="rooms-list" aria-label={t.rooms.cardsAria}>
{entries.map((entry) => {
const items = inbox.filter((item) => item.roomJid === entry.jid);
const queue = entry.pendingTasks + (entry.pendingMessages ? 1 : 0);
const active = selectedJid === entry.jid;
return (
<button
aria-current={active ? 'page' : undefined}
className={`rooms-list-item status-${entry.status}${active ? ' is-active' : ''}`}
key={`${entry.serviceId}:${entry.jid}`}
onClick={() => onSelect(entry.jid)}
type="button"
>
<span className={`room-pulse pulse-${entry.status}`}>
{entry.status === 'processing' ? (
<span className="pulse-dot" />
) : null}
</span>
<span className="rooms-list-text">
<strong>{entry.name}</strong>
<small>{entry.agentType}</small>
</span>
{items.length > 0 ? (
<span
className={`rooms-list-bell sev-${items[0].severity}`}
title={items.map((item) => t.inbox.kinds[item.kind]).join(', ')}
>
{items.length}
</span>
) : null}
{queue > 0 ? (
<span className="rooms-list-queue">{queue}</span>
) : null}
</button>
);
})}
</aside>
);
}
interface RoomsDetailProps extends Pick<
RoomBoardV2Props,
| 'formatDate'
| 'formatDuration'
| 'formatLiveElapsed'
| 'locale'
| 'pendingMessages'
| 'roomActivity'
| 'roomActivityLoading'
| 'roomMessageKey'
| 'senderRoleClass'
| 'statusLabel'
| 't'
> {
drafts: Record<string, string>;
inbox: InboxItem[];
onBack: () => void;
onDraftChange: (jid: string, value: string) => void;
onSubmit: (jid: string) => void;
selectedEntry: RoomEntryWithService | null;
}
function RoomsDetail({
drafts,
formatDate,
formatDuration,
formatLiveElapsed,
inbox,
locale,
onBack,
onDraftChange,
onSubmit,
pendingMessages,
roomActivity,
roomActivityLoading,
roomMessageKey,
selectedEntry,
senderRoleClass,
statusLabel,
t,
}: RoomsDetailProps) {
return (
<main className="rooms-detail">
<button className="rooms-mobile-back" onClick={onBack} type="button">
{t.panels.rooms}
</button>
{selectedEntry ? (
<RoomCardV2
activity={roomActivity[selectedEntry.jid]}
activityLoading={roomActivityLoading}
busy={roomMessageKey === selectedEntry.jid}
draft={drafts[selectedEntry.jid] ?? ''}
entry={selectedEntry}
expanded={true}
formatDate={formatDate}
formatDuration={formatDuration}
formatLiveElapsed={formatLiveElapsed}
inboxItems={inbox.filter(
(item) => item.roomJid === selectedEntry.jid,
)}
key={`${selectedEntry.serviceId}:${selectedEntry.jid}`}
locale={locale}
onDraftChange={(value) => onDraftChange(selectedEntry.jid, value)}
onSendMessage={() => onSubmit(selectedEntry.jid)}
onToggle={() => {}}
pendingMessages={pendingMessages[selectedEntry.jid] ?? []}
pinned={true}
senderRoleClass={senderRoleClass}
statusLabel={statusLabel}
t={t}
/>
) : (
<EmptyState>{t.rooms.empty}</EmptyState>
)}
</main>
);
}
export function RoomBoardV2({
createRequestId,
formatDate,
formatDuration,
formatLiveElapsed,
inbox,
onSendRoomMessage,
pendingMessages,
roomActivity,
roomActivityLoading,
roomMessageKey,
selectedJid,
locale,
onSelectedJidChange,
senderRoleClass,
snapshots,
statusLabel,
t,
}: RoomBoardV2Props) {
const [filter, setFilter] = useState<RoomFilter>('all');
const [sort, setSort] = useState<RoomSort>('recent');
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const allEntries: RoomEntryWithService[] = snapshots.flatMap((snapshot) =>
snapshot.entries.map((entry) => ({
...entry,
serviceId: snapshot.serviceId,
})),
);
const counts = {
all: allEntries.length,
processing: allEntries.filter((entry) => entry.status === 'processing')
.length,
waiting: allEntries.filter((entry) => entry.status === 'waiting').length,
inactive: allEntries.filter((entry) => entry.status === 'inactive').length,
};
const filtered = allEntries.filter(
(entry) => filter === 'all' || entry.status === filter,
);
const sorted = [...filtered].sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'queue') {
const aQ = a.pendingTasks + (a.pendingMessages ? 1 : 0);
const bQ = b.pendingTasks + (b.pendingMessages ? 1 : 0);
return bQ - aQ;
}
const aA = roomActivity[a.jid]?.pairedTask?.updatedAt;
const bA = roomActivity[b.jid]?.pairedTask?.updatedAt;
const aT = aA ? new Date(aA).getTime() : (a.elapsedMs ?? 0);
const bT = bA ? new Date(bA).getTime() : (b.elapsedMs ?? 0);
return bT - aT;
});
const selectedEntry =
sorted.find((entry) => entry.jid === selectedJid) ?? sorted[0] ?? null;
useEffect(() => {
const nextJid = selectedEntry?.jid ?? null;
if (nextJid !== selectedJid) {
onSelectedJidChange(nextJid);
setMobileDetailOpen(false);
}
}, [onSelectedJidChange, selectedEntry?.jid, selectedJid]);
if (allEntries.length === 0) {
return <EmptyState>{t.rooms.empty}</EmptyState>;
}
function setDraft(jid: string, value: string) {
setDrafts((previous) => ({ ...previous, [jid]: value }));
}
async function submitRoomMessage(jid: string) {
const text = drafts[jid]?.trim();
if (!text) return;
scrollDetailToBottom();
const success = await onSendRoomMessage(jid, text, createRequestId());
if (success) {
setDraft(jid, '');
scrollDetailToBottom();
}
}
return (
<div className="rooms-v2">
<RoomsToolbar
counts={counts}
filter={filter}
onFilterChange={setFilter}
onSortChange={setSort}
sort={sort}
statusLabel={statusLabel}
t={t}
/>
{sorted.length === 0 ? (
<EmptyState>{t.rooms.empty}</EmptyState>
) : (
<div
className={`rooms-twopane${mobileDetailOpen ? ' is-detail-open' : ''}`}
>
<RoomsList
entries={sorted}
inbox={inbox}
onSelect={(jid) => {
onSelectedJidChange(jid);
setMobileDetailOpen(true);
}}
selectedJid={selectedEntry?.jid ?? null}
t={t}
/>
<RoomsDetail
drafts={drafts}
formatDate={formatDate}
formatDuration={formatDuration}
formatLiveElapsed={formatLiveElapsed}
inbox={inbox}
locale={locale}
onBack={() => setMobileDetailOpen(false)}
onDraftChange={setDraft}
onSubmit={(jid) => void submitRoomMessage(jid)}
pendingMessages={pendingMessages}
roomActivity={roomActivity}
roomActivityLoading={roomActivityLoading}
roomMessageKey={roomMessageKey}
selectedEntry={selectedEntry}
senderRoleClass={senderRoleClass}
statusLabel={statusLabel}
t={t}
/>
</div>
)}
</div>
);
}