Extract dashboard RoomCardV2 component (#59)

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

View File

@@ -1,5 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { Send } from 'lucide-react';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import {
type ClaudeAccountSummary,
@@ -55,11 +54,8 @@ import {
type DashboardFreshness,
type DashboardView,
} from './DashboardNav';
import {
buildRoomThreadEntries,
isInternalProtocolPayload,
isWatcherRoomMessage,
} from './roomThread';
import { isInternalProtocolPayload } from './roomThread';
import { RoomCardV2, type RoomEntryWithService } from './RoomCardV2';
import { ParsedBody } from './ParsedBody';
import { redactSecretsForPreview } from './redaction';
import './styles.css';
@@ -1742,54 +1738,6 @@ function HealthPanel({
);
}
function RoomMessageForm({
busy,
onChange,
onSubmit,
t,
value,
}: {
busy: boolean;
onChange: (value: string) => void;
onSubmit: () => void;
t: Messages;
value: string;
}) {
return (
<form
className="room-compose"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<textarea
aria-label={t.rooms.message}
maxLength={8000}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== 'Enter') return;
if (event.shiftKey || event.nativeEvent.isComposing) return;
if (busy || !value.trim()) return;
event.preventDefault();
onSubmit();
}}
placeholder={t.rooms.messagePlaceholder}
rows={1}
value={value}
/>
<button
aria-label={busy ? t.rooms.sending : t.rooms.send}
disabled={busy || !value.trim()}
title={busy ? t.rooms.sending : t.rooms.send}
type="submit"
>
<Send size={16} strokeWidth={2} aria-hidden />
</button>
</form>
);
}
type RoomFilter = 'all' | 'processing' | 'waiting' | 'inactive';
type RoomSort = 'recent' | 'name' | 'queue';
@@ -1811,18 +1759,6 @@ function roomSortLabel(s: RoomSort, t: Messages): string {
return t.rooms.sortName;
}
interface RoomEntryWithService {
jid: string;
name: string;
folder: string;
agentType: string;
status: 'processing' | 'waiting' | 'inactive';
elapsedMs: number | null;
pendingMessages: boolean;
pendingTasks: number;
serviceId: string;
}
function RoomBoardV2({
inbox,
onSendRoomMessage,
@@ -2027,6 +1963,9 @@ function RoomBoardV2({
draft={drafts[selectedEntry.jid] ?? ''}
entry={selectedEntry}
expanded={true}
formatDate={formatDate}
formatDuration={formatDuration}
formatLiveElapsed={formatLiveElapsed}
inboxItems={inbox.filter(
(item) => item.roomJid === selectedEntry.jid,
)}
@@ -2037,6 +1976,8 @@ function RoomBoardV2({
onToggle={() => {}}
pendingMessages={pendingMessages[selectedEntry.jid] ?? []}
pinned={true}
senderRoleClass={senderRoleClass}
statusLabel={statusLabel}
t={t}
/>
) : (
@@ -2049,437 +1990,6 @@ function RoomBoardV2({
);
}
function RoomCardV2({
activity,
activityLoading,
busy,
draft,
entry,
expanded,
inboxItems,
locale,
onDraftChange,
onSendMessage,
onToggle,
pendingMessages = [],
pinned = false,
t,
}: {
activity: DashboardRoomActivity | undefined;
activityLoading: boolean;
busy: boolean;
draft: string;
entry: RoomEntryWithService;
expanded: boolean;
inboxItems: InboxItem[];
locale: Locale;
onDraftChange: (value: string) => void;
onSendMessage: () => void;
onToggle: () => void;
pendingMessages?: Array<DashboardRoomActivity['messages'][number]>;
pinned?: boolean;
t: Messages;
}) {
const task = activity?.pairedTask ?? null;
const turn = task?.currentTurn ?? null;
const outputs = task?.outputs ?? [];
const latestOutput = outputs.at(-1) ?? null;
const messages = activity?.messages ?? [];
const isProcessing = entry.status === 'processing';
const liveTurnStart =
turn && turn.completedAt === null
? new Date(turn.createdAt).getTime()
: null;
const [tick, setTick] = useState(() => Date.now());
useEffect(() => {
if (!isProcessing || liveTurnStart === null) return;
const id = window.setInterval(() => setTick(Date.now()), 1000);
return () => window.clearInterval(id);
}, [isProcessing, liveTurnStart]);
const liveElapsedMs =
liveTurnStart === null ? null : Math.max(0, tick - liveTurnStart);
// Discord live-edit messages are stored with this 3-character invisible
// prefix (TASK_STATUS_MESSAGE_PREFIX). When paired_turns.progress_text
// hasn't been written yet (codex runner timing), fall back to scanning
// the messages table for the most recent prefixed message — but only
// ones from the current turn (timestamp >= turn.createdAt) and matching
// the current turn's role to avoid showing a stale reviewer status while
// owner is now speaking.
const TASK_STATUS_PREFIX = '';
const liveStatusFallback = (() => {
if (!isProcessing) return null;
if (!turn) return null;
if (turn.progressText && turn.progressText.trim()) return null;
const turnStartMs = turn.createdAt ? new Date(turn.createdAt).getTime() : 0;
const turnRole = senderRoleClass(turn.role); // role-owner / -reviewer / -arbiter / -human
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (!m.content.startsWith(TASK_STATUS_PREFIX)) continue;
const ts = m.timestamp ? new Date(m.timestamp).getTime() : 0;
if (ts < turnStartMs) continue;
const senderRole = senderRoleClass(m.senderName);
if (senderRole !== turnRole) continue;
const body = m.content.slice(TASK_STATUS_PREFIX.length);
const stripped = body.replace(/\n\n\d+[초smhMSH]?$/, '').trim();
return stripped || null;
}
return null;
})();
const liveProgressDisplay =
turn?.progressText && turn.progressText.trim()
? turn.progressText
: liveStatusFallback;
const messagesLen = messages.length;
const outputsLen = outputs.length;
const pendingLen = pendingMessages.length;
const wasNearBottomRef = useRef(true);
useEffect(() => {
const detail = document.querySelector(
'.rooms-detail',
) as HTMLElement | null;
if (!detail) return;
if (!wasNearBottomRef.current) return;
const run = () => {
detail.scrollTop = detail.scrollHeight;
};
requestAnimationFrame(() => requestAnimationFrame(run));
}, [entry.jid, messagesLen, outputsLen, pendingLen]);
useEffect(() => {
const detail = document.querySelector(
'.rooms-detail',
) as HTMLElement | null;
if (!detail) return;
const onScroll = () => {
const distance =
detail.scrollHeight - detail.scrollTop - detail.clientHeight;
wasNearBottomRef.current = distance < 80;
};
detail.addEventListener('scroll', onScroll, { passive: true });
return () => detail.removeEventListener('scroll', onScroll);
}, [entry.jid]);
const queueChips: string[] = [];
if (entry.pendingTasks > 0)
queueChips.push(`${entry.pendingTasks} ${t.rooms.tasks}`);
if (entry.pendingMessages) queueChips.push(t.rooms.queueWaitingMessages);
const lastUpdated = turn?.updatedAt ?? task?.updatedAt ?? null;
const lastWatcher =
[...messages].reverse().find(isWatcherRoomMessage) ?? null;
const watcherCount = messages.filter(isWatcherRoomMessage).length;
const watcherMessages = messages.filter(isWatcherRoomMessage);
const agentMessages = buildRoomThreadEntries({
messages,
outputs,
pendingMessages,
});
return (
<article
aria-expanded={pinned ? undefined : expanded}
className={`room-card-v2 status-${entry.status}${expanded ? ' is-expanded' : ''}${pinned ? ' is-pinned' : ''}`}
onClick={pinned ? undefined : onToggle}
onKeyDown={
pinned
? undefined
: (e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggle();
}
}
}
role={pinned ? undefined : 'button'}
tabIndex={pinned ? undefined : 0}
>
<div className="room-card-head">
<span className={`room-pulse pulse-${entry.status}`}>
{isProcessing ? <span className="pulse-dot" /> : null}
</span>
<span className="room-title-block">
<strong>{entry.name}</strong>
<span className="room-sub">
<em>{entry.agentType}</em>
{inboxItems.length > 0 ? (
<span className="room-inbox-badges" aria-label={t.panels.inbox}>
{inboxItems.slice(0, 3).map((item) => (
<span
className={`room-inbox-pip sev-${item.severity}`}
key={item.id}
title={t.inbox.kinds[item.kind]}
>
{t.inbox.kinds[item.kind]}
</span>
))}
</span>
) : null}
</span>
</span>
{!isProcessing ? (
<span className={`room-status pill pill-${entry.status}`}>
{statusLabel(entry.status, t)}
</span>
) : (
<span />
)}
{!pinned ? (
<span className="room-toggle" aria-hidden>
{expanded ? '▾' : '▸'}
</span>
) : null}
</div>
{turn && !isProcessing ? (
<div className="room-current-turn">
<span>
<strong className={senderRoleClass(turn.role)}>{turn.role}</strong>
<em>{turn.intentKind}</em>
{turn.attemptNo > 1 ? (
<small>
{t.rooms.attempt} {turn.attemptNo}
</small>
) : null}
</span>
{task && task.roundTripCount > 0 ? (
<small>
· {t.rooms.round} {task.roundTripCount}
</small>
) : null}
{lastUpdated ? (
<small style={{ marginLeft: 'auto' }}>
{formatDate(lastUpdated, locale)}
</small>
) : null}
</div>
) : null}
{queueChips.length > 0 ||
(entry.elapsedMs && entry.elapsedMs > 0 && isProcessing) ? (
<div className="room-card-meta">
{queueChips.length > 0 ? (
<span className="meta-cell">
<strong>{queueChips.join(' · ')}</strong>
</span>
) : null}
{isProcessing && entry.elapsedMs ? (
<span className="meta-cell">
<strong>{formatDuration(entry.elapsedMs, t)}</strong>
</span>
) : null}
</div>
) : null}
{!expanded && isProcessing && turn ? (
<div className="room-live">
<header>
<span className="live-dot" aria-hidden />
<span className="live-label">LIVE</span>
<strong className={senderRoleClass(turn.role)}>{turn.role}</strong>
<em>{turn.intentKind}</em>
<span className="live-state pill pill-processing">
{turn.state}
</span>
{turn.executorServiceId ? (
<span className="live-exec">{turn.executorServiceId}</span>
) : null}
{turn.attemptNo > 1 ? (
<span className="live-attempt">
{t.rooms.attempt} {turn.attemptNo}
</span>
) : null}
<time>
{formatDate(turn.progressUpdatedAt ?? turn.updatedAt, locale)}
</time>
</header>
{turn.progressText && turn.progressText.trim() ? (
<pre className="live-progress">{turn.progressText}</pre>
) : turn.activeRunId ? (
<code className="live-run">{turn.activeRunId}</code>
) : null}
</div>
) : null}
{!expanded && latestOutput && !isProcessing ? (
<div className="room-preview">
<ParsedBody text={latestOutput.outputText} truncate={140} />
</div>
) : null}
{!expanded && watcherCount > 0 && lastWatcher ? (
<div className="room-watcher-strip">
<span className="watcher-tag"> {watcherCount}</span>
<span className="watcher-line">
<strong className={senderRoleClass(lastWatcher.senderName)}>
{lastWatcher.senderName}
</strong>
<span>{lastWatcher.content.slice(0, 90)}</span>
</span>
<time>{formatDate(lastWatcher.timestamp, locale)}</time>
</div>
) : null}
{!expanded && !latestOutput && !isProcessing && !activityLoading ? (
<p className="room-empty">{t.rooms.noActivity}</p>
) : null}
{!expanded && activityLoading && !activity ? (
<p className="room-empty">{t.rooms.loadingActivity}</p>
) : null}
{expanded ? (
<div
className="room-expanded"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{turn?.lastError ? (
<div className="room-alert">
<strong>{t.rooms.error}</strong>
<ParsedBody text={turn.lastError} />
</div>
) : null}
<section className="room-section room-thread-section">
<header>
<h4>{t.rooms.activity}</h4>
<small>{agentMessages.length}</small>
</header>
{agentMessages.length === 0 && !(isProcessing && turn) ? (
<p className="room-empty">{t.rooms.noActivity}</p>
) : (
<ol className="room-timeline">
{agentMessages.map((entry) => {
const verdict = entry.verdict;
const verdictTone = verdict
? /fail|error/i.test(verdict)
? 'fail'
: /done|ok|pass/i.test(verdict)
? 'done'
: 'info'
: null;
const sectionClass = senderRoleClass(
entry.senderName,
).replace('role-', 'role-section-');
return (
<li
className={`room-timeline-item ${sectionClass}`}
key={entry.id}
>
<header className="room-timeline-header">
<span
className={`role-chip ${senderRoleClass(entry.senderName)}`}
>
{entry.senderName}
</span>
<time>{formatDate(entry.timestamp, locale)}</time>
{entry.turnNumber !== undefined ? (
<span className="parsed-marker parsed-marker-info">
#{entry.turnNumber}
</span>
) : null}
{verdict ? (
<span
className={`parsed-marker parsed-marker-${verdictTone}`}
>
{verdict}
</span>
) : null}
</header>
<div className="room-timeline-body">
<ParsedBody text={entry.content} />
</div>
</li>
);
})}
{turn &&
turn.completedAt === null &&
(isProcessing || liveProgressDisplay) ? (
<li
className={`room-timeline-item ${senderRoleClass(turn.role).replace('role-', 'role-section-')} ${isProcessing ? 'room-timeline-live' : 'room-timeline-paused'}`}
>
<header className="room-timeline-header">
{isProcessing ? (
<span
className="live-dot live-dot-inline"
aria-hidden
/>
) : (
<span className="paused-dot" aria-hidden />
)}
<span
className={`role-chip ${senderRoleClass(turn.role)}`}
>
{turn.role}
</span>
<time>
{formatDate(
turn.progressUpdatedAt ?? turn.updatedAt,
locale,
)}
</time>
{liveElapsedMs !== null ? (
<span className="live-elapsed">
+{formatLiveElapsed(liveElapsedMs, t)}
</span>
) : null}
{!isProcessing ? (
<span className="live-label paused"></span>
) : null}
</header>
<div className="room-timeline-body">
{liveProgressDisplay ? (
<pre className="live-progress">
{liveProgressDisplay}
</pre>
) : (
<p className="room-empty">{t.rooms.loadingActivity}</p>
)}
</div>
</li>
) : null}
</ol>
)}
</section>
{watcherMessages.length > 0 ? (
<details className="room-watcher-fold">
<summary>
<span className="watcher-tag"></span>
<small>{watcherMessages.length}</small>
</summary>
<ul className="room-watcher-list">
{watcherMessages.map((message) => (
<li className="room-watcher-item" key={message.id}>
<header>
<strong className={senderRoleClass(message.senderName)}>
{message.senderName}
</strong>
<time>{formatDate(message.timestamp, locale)}</time>
</header>
<ParsedBody text={message.content} truncate={200} />
</li>
))}
</ul>
</details>
) : null}
<section className="room-section room-compose-section">
<RoomMessageForm
busy={busy}
onChange={onDraftChange}
onSubmit={onSendMessage}
t={t}
value={draft}
/>
</section>
</div>
) : null}
</article>
);
}
function UsageQuotaMeter({
row,
rowName,