Files
EJClaw/apps/dashboard/src/RoomCardV2.tsx
Eyejoker 05865197c7 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
2026-04-28 14:04:34 +09:00

870 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import { Send } from 'lucide-react';
import type { DashboardOverview, DashboardRoomActivity } from './api';
import type { Locale, Messages } from './i18n';
import { ParsedBody } from './ParsedBody';
import {
buildRoomThreadEntries,
isWatcherRoomMessage,
type RoomThreadEntry,
} from './roomThread';
type InboxItem = DashboardOverview['inbox'][number];
type RoomMessage = DashboardRoomActivity['messages'][number];
type RoomOutput = NonNullable<
DashboardRoomActivity['pairedTask']
>['outputs'][number];
type RoomTask = NonNullable<DashboardRoomActivity['pairedTask']>;
type RoomTurn = NonNullable<RoomTask['currentTurn']>;
export interface RoomEntryWithService {
jid: string;
name: string;
folder: string;
agentType: string;
status: 'processing' | 'waiting' | 'inactive';
elapsedMs: number | null;
pendingMessages: boolean;
pendingTasks: number;
serviceId: string;
}
interface RoomCardFormatters {
formatDate: (value: string | null | undefined, locale: Locale) => string;
formatDuration: (value: number | null, t: Messages) => string;
formatLiveElapsed: (value: number, t: Messages) => string;
senderRoleClass: (value: string | null | undefined) => string;
statusLabel: (status: string, t: Messages) => string;
}
interface RoomMessageFormProps {
busy: boolean;
onChange: (value: string) => void;
onSubmit: () => void;
t: Messages;
value: string;
}
function RoomMessageForm({
busy,
onChange,
onSubmit,
t,
value,
}: RoomMessageFormProps) {
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>
);
}
export interface RoomCardV2Props extends RoomCardFormatters {
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?: RoomMessage[];
pinned?: boolean;
t: Messages;
}
export function RoomCardV2({
activity,
activityLoading,
busy,
draft,
entry,
expanded,
formatDate,
formatDuration,
formatLiveElapsed,
inboxItems,
locale,
onDraftChange,
onSendMessage,
onToggle,
pendingMessages = [],
pinned = false,
senderRoleClass,
statusLabel,
t,
}: RoomCardV2Props) {
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);
const liveProgressDisplay =
turn?.progressText && turn.progressText.trim()
? turn.progressText
: getLiveStatusFallback({
isProcessing,
messages,
senderRoleClass,
turn,
});
const lastUpdated = turn?.updatedAt ?? task?.updatedAt ?? null;
const watcherMessages = messages.filter(isWatcherRoomMessage);
const lastWatcher = watcherMessages.at(-1) ?? null;
const agentMessages = buildRoomThreadEntries({
messages,
outputs,
pendingMessages,
});
useRoomDetailAutoscroll({
entryJid: entry.jid,
messagesLen: messages.length,
outputsLen: outputs.length,
pendingLen: pendingMessages.length,
});
const formatters = {
formatDate,
formatDuration,
formatLiveElapsed,
senderRoleClass,
statusLabel,
};
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}
>
<RoomCardHead
entry={entry}
expanded={expanded}
inboxItems={inboxItems}
isProcessing={isProcessing}
pinned={pinned}
statusLabel={statusLabel}
t={t}
/>
<RoomCurrentTurnSummary
formatDate={formatDate}
lastUpdated={lastUpdated}
locale={locale}
senderRoleClass={senderRoleClass}
task={task}
turn={turn}
t={t}
/>
<RoomCardMeta
entry={entry}
formatDuration={formatDuration}
isProcessing={isProcessing}
t={t}
/>
<CollapsedLiveTurn
expanded={expanded}
formatDate={formatDate}
isProcessing={isProcessing}
locale={locale}
senderRoleClass={senderRoleClass}
t={t}
turn={turn}
/>
<CollapsedOutputPreview
expanded={expanded}
isProcessing={isProcessing}
latestOutput={latestOutput}
/>
<CollapsedWatcherStrip
expanded={expanded}
formatDate={formatDate}
lastWatcher={lastWatcher}
locale={locale}
senderRoleClass={senderRoleClass}
watcherCount={watcherMessages.length}
/>
<CollapsedEmptyState
activity={activity}
activityLoading={activityLoading}
expanded={expanded}
isProcessing={isProcessing}
latestOutput={latestOutput}
t={t}
/>
{expanded ? (
<RoomExpandedContent
agentMessages={agentMessages}
busy={busy}
draft={draft}
formatters={formatters}
isProcessing={isProcessing}
liveElapsedMs={liveElapsedMs}
liveProgressDisplay={liveProgressDisplay}
locale={locale}
onDraftChange={onDraftChange}
onSendMessage={onSendMessage}
t={t}
turn={turn}
watcherMessages={watcherMessages}
/>
) : null}
</article>
);
}
function useRoomDetailAutoscroll({
entryJid,
messagesLen,
outputsLen,
pendingLen,
}: {
entryJid: string;
messagesLen: number;
outputsLen: number;
pendingLen: number;
}) {
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));
}, [entryJid, 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);
}, [entryJid]);
}
const TASK_STATUS_PREFIX = '';
function getLiveStatusFallback({
isProcessing,
messages,
senderRoleClass,
turn,
}: {
isProcessing: boolean;
messages: RoomMessage[];
senderRoleClass: RoomCardFormatters['senderRoleClass'];
turn: RoomTurn | null;
}): string | null {
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);
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (!message.content.startsWith(TASK_STATUS_PREFIX)) continue;
const ts = message.timestamp ? new Date(message.timestamp).getTime() : 0;
if (ts < turnStartMs) continue;
const senderRole = senderRoleClass(message.senderName);
if (senderRole !== turnRole) continue;
const body = message.content.slice(TASK_STATUS_PREFIX.length);
const stripped = body.replace(/\n\n\d+[초smhMSH]?$/, '').trim();
return stripped || null;
}
return null;
}
function RoomCardHead({
entry,
expanded,
inboxItems,
isProcessing,
pinned,
statusLabel,
t,
}: {
entry: RoomEntryWithService;
expanded: boolean;
inboxItems: InboxItem[];
isProcessing: boolean;
pinned: boolean;
statusLabel: RoomCardFormatters['statusLabel'];
t: Messages;
}) {
return (
<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>
);
}
function RoomCurrentTurnSummary({
formatDate,
lastUpdated,
locale,
senderRoleClass,
task,
turn,
t,
}: {
formatDate: RoomCardFormatters['formatDate'];
lastUpdated: string | null;
locale: Locale;
senderRoleClass: RoomCardFormatters['senderRoleClass'];
task: RoomTask | null;
turn: RoomTurn | null;
t: Messages;
}) {
if (!turn || turn.completedAt === null) return null;
return (
<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>
);
}
function RoomCardMeta({
entry,
formatDuration,
isProcessing,
t,
}: {
entry: RoomEntryWithService;
formatDuration: RoomCardFormatters['formatDuration'];
isProcessing: boolean;
t: Messages;
}) {
const queueChips: string[] = [];
if (entry.pendingTasks > 0)
queueChips.push(`${entry.pendingTasks} ${t.rooms.tasks}`);
if (entry.pendingMessages) queueChips.push(t.rooms.queueWaitingMessages);
if (
queueChips.length === 0 &&
!(entry.elapsedMs && entry.elapsedMs > 0 && isProcessing)
) {
return null;
}
return (
<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>
);
}
function CollapsedLiveTurn({
expanded,
formatDate,
isProcessing,
locale,
senderRoleClass,
t,
turn,
}: {
expanded: boolean;
formatDate: RoomCardFormatters['formatDate'];
isProcessing: boolean;
locale: Locale;
senderRoleClass: RoomCardFormatters['senderRoleClass'];
t: Messages;
turn: RoomTurn | null;
}) {
if (expanded || !isProcessing || !turn) return null;
return (
<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>
);
}
function CollapsedOutputPreview({
expanded,
isProcessing,
latestOutput,
}: {
expanded: boolean;
isProcessing: boolean;
latestOutput: RoomOutput | null;
}) {
if (expanded || !latestOutput || isProcessing) return null;
return (
<div className="room-preview">
<ParsedBody text={latestOutput.outputText} truncate={140} />
</div>
);
}
function CollapsedWatcherStrip({
expanded,
formatDate,
lastWatcher,
locale,
senderRoleClass,
watcherCount,
}: {
expanded: boolean;
formatDate: RoomCardFormatters['formatDate'];
lastWatcher: RoomMessage | null;
locale: Locale;
senderRoleClass: RoomCardFormatters['senderRoleClass'];
watcherCount: number;
}) {
if (expanded || watcherCount === 0 || !lastWatcher) return null;
return (
<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>
);
}
function CollapsedEmptyState({
activity,
activityLoading,
expanded,
isProcessing,
latestOutput,
t,
}: {
activity: DashboardRoomActivity | undefined;
activityLoading: boolean;
expanded: boolean;
isProcessing: boolean;
latestOutput: RoomOutput | null;
t: Messages;
}) {
if (expanded) return null;
if (!latestOutput && !isProcessing && !activityLoading) {
return <p className="room-empty">{t.rooms.noActivity}</p>;
}
if (activityLoading && !activity) {
return <p className="room-empty">{t.rooms.loadingActivity}</p>;
}
return null;
}
function RoomExpandedContent({
agentMessages,
busy,
draft,
formatters,
isProcessing,
liveElapsedMs,
liveProgressDisplay,
locale,
onDraftChange,
onSendMessage,
t,
turn,
watcherMessages,
}: {
agentMessages: RoomThreadEntry[];
busy: boolean;
draft: string;
formatters: RoomCardFormatters;
isProcessing: boolean;
liveElapsedMs: number | null;
liveProgressDisplay: string | null;
locale: Locale;
onDraftChange: (value: string) => void;
onSendMessage: () => void;
t: Messages;
turn: RoomTurn | null;
watcherMessages: RoomMessage[];
}) {
return (
<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}
<RoomThreadSection
agentMessages={agentMessages}
formatters={formatters}
isProcessing={isProcessing}
liveElapsedMs={liveElapsedMs}
liveProgressDisplay={liveProgressDisplay}
locale={locale}
t={t}
turn={turn}
/>
<RoomWatcherFold
formatDate={formatters.formatDate}
locale={locale}
senderRoleClass={formatters.senderRoleClass}
watcherMessages={watcherMessages}
/>
<section className="room-section room-compose-section">
<RoomMessageForm
busy={busy}
onChange={onDraftChange}
onSubmit={onSendMessage}
t={t}
value={draft}
/>
</section>
</div>
);
}
function RoomThreadSection({
agentMessages,
formatters,
isProcessing,
liveElapsedMs,
liveProgressDisplay,
locale,
t,
turn,
}: {
agentMessages: RoomThreadEntry[];
formatters: RoomCardFormatters;
isProcessing: boolean;
liveElapsedMs: number | null;
liveProgressDisplay: string | null;
locale: Locale;
t: Messages;
turn: RoomTurn | null;
}) {
const showLiveItem =
turn && turn.completedAt === null && (isProcessing || liveProgressDisplay);
return (
<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) => (
<RoomTimelineEntry
entry={entry}
formatDate={formatters.formatDate}
key={entry.id}
locale={locale}
senderRoleClass={formatters.senderRoleClass}
/>
))}
{showLiveItem ? (
<RoomLiveTimelineEntry
formatLiveElapsed={formatters.formatLiveElapsed}
formatDate={formatters.formatDate}
isProcessing={isProcessing}
liveElapsedMs={liveElapsedMs}
liveProgressDisplay={liveProgressDisplay}
locale={locale}
senderRoleClass={formatters.senderRoleClass}
t={t}
turn={turn}
/>
) : null}
</ol>
)}
</section>
);
}
function RoomTimelineEntry({
entry,
formatDate,
locale,
senderRoleClass,
}: {
entry: RoomThreadEntry;
formatDate: RoomCardFormatters['formatDate'];
locale: Locale;
senderRoleClass: RoomCardFormatters['senderRoleClass'];
}) {
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}`}>
<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>
);
}
function RoomLiveTimelineEntry({
formatDate,
formatLiveElapsed,
isProcessing,
liveElapsedMs,
liveProgressDisplay,
locale,
senderRoleClass,
t,
turn,
}: {
formatDate: RoomCardFormatters['formatDate'];
formatLiveElapsed: RoomCardFormatters['formatLiveElapsed'];
isProcessing: boolean;
liveElapsedMs: number | null;
liveProgressDisplay: string | null;
locale: Locale;
senderRoleClass: RoomCardFormatters['senderRoleClass'];
t: Messages;
turn: RoomTurn;
}) {
return (
<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>
);
}
function RoomWatcherFold({
formatDate,
locale,
senderRoleClass,
watcherMessages,
}: {
formatDate: RoomCardFormatters['formatDate'];
locale: Locale;
senderRoleClass: RoomCardFormatters['senderRoleClass'];
watcherMessages: RoomMessage[];
}) {
if (watcherMessages.length === 0) return null;
return (
<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>
);
}