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 { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Send } from 'lucide-react';
import { import {
type ClaudeAccountSummary, type ClaudeAccountSummary,
@@ -55,11 +54,8 @@ import {
type DashboardFreshness, type DashboardFreshness,
type DashboardView, type DashboardView,
} from './DashboardNav'; } from './DashboardNav';
import { import { isInternalProtocolPayload } from './roomThread';
buildRoomThreadEntries, import { RoomCardV2, type RoomEntryWithService } from './RoomCardV2';
isInternalProtocolPayload,
isWatcherRoomMessage,
} from './roomThread';
import { ParsedBody } from './ParsedBody'; import { ParsedBody } from './ParsedBody';
import { redactSecretsForPreview } from './redaction'; import { redactSecretsForPreview } from './redaction';
import './styles.css'; 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 RoomFilter = 'all' | 'processing' | 'waiting' | 'inactive';
type RoomSort = 'recent' | 'name' | 'queue'; type RoomSort = 'recent' | 'name' | 'queue';
@@ -1811,18 +1759,6 @@ function roomSortLabel(s: RoomSort, t: Messages): string {
return t.rooms.sortName; 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({ function RoomBoardV2({
inbox, inbox,
onSendRoomMessage, onSendRoomMessage,
@@ -2027,6 +1963,9 @@ function RoomBoardV2({
draft={drafts[selectedEntry.jid] ?? ''} draft={drafts[selectedEntry.jid] ?? ''}
entry={selectedEntry} entry={selectedEntry}
expanded={true} expanded={true}
formatDate={formatDate}
formatDuration={formatDuration}
formatLiveElapsed={formatLiveElapsed}
inboxItems={inbox.filter( inboxItems={inbox.filter(
(item) => item.roomJid === selectedEntry.jid, (item) => item.roomJid === selectedEntry.jid,
)} )}
@@ -2037,6 +1976,8 @@ function RoomBoardV2({
onToggle={() => {}} onToggle={() => {}}
pendingMessages={pendingMessages[selectedEntry.jid] ?? []} pendingMessages={pendingMessages[selectedEntry.jid] ?? []}
pinned={true} pinned={true}
senderRoleClass={senderRoleClass}
statusLabel={statusLabel}
t={t} 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({ function UsageQuotaMeter({
row, row,
rowName, rowName,

View File

@@ -0,0 +1,112 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { DashboardRoomActivity } from './api';
import { messages } from './i18n';
import { RoomCardV2, type RoomEntryWithService } from './RoomCardV2';
const t = messages.ko;
const entry: RoomEntryWithService = {
jid: 'room-1',
name: 'eyejokerdb-main',
folder: 'eyejokerdb',
agentType: 'codex',
status: 'inactive',
elapsedMs: null,
pendingMessages: false,
pendingTasks: 0,
serviceId: 'svc-1',
};
const formatters = {
formatDate: (value: string | null | undefined) => value ?? '-',
formatDuration: (value: number | null) => (value === null ? '-' : `${value}`),
formatLiveElapsed: (value: number) => `${value}`,
senderRoleClass: (value: string | null | undefined) =>
(value ?? '').toLowerCase().includes('owner') ? 'role-owner' : 'role-human',
statusLabel: (status: string) => status,
};
function activity(
overrides: Partial<DashboardRoomActivity> = {},
): DashboardRoomActivity {
return {
serviceId: 'svc-1',
jid: 'room-1',
name: 'eyejokerdb-main',
folder: 'eyejokerdb',
agentType: 'codex',
status: 'inactive',
elapsedMs: null,
pendingMessages: false,
pendingTasks: 0,
messages: [],
pairedTask: null,
...overrides,
};
}
describe('RoomCardV2', () => {
it('renders an inactive room without activity', () => {
const html = renderToStaticMarkup(
createElement(RoomCardV2, {
activity: undefined,
activityLoading: false,
busy: false,
draft: '',
entry,
expanded: false,
inboxItems: [],
locale: 'ko',
onDraftChange: () => {},
onSendMessage: () => {},
onToggle: () => {},
pinned: false,
t,
...formatters,
}),
);
expect(html).toContain('eyejokerdb-main');
expect(html).toContain(t.rooms.noActivity);
});
it('renders regular bot messages in the expanded thread', () => {
const html = renderToStaticMarkup(
createElement(RoomCardV2, {
activity: activity({
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',
},
],
}),
activityLoading: false,
busy: false,
draft: '',
entry,
expanded: true,
inboxItems: [],
locale: 'ko',
onDraftChange: () => {},
onSendMessage: () => {},
onToggle: () => {},
pinned: true,
t,
...formatters,
}),
);
expect(html).toContain('TASK_DONE');
expect(html).toContain('prod 배포 완료');
});
});

View File

@@ -0,0 +1,869 @@
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>
);
}