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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user