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 { 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,
|
||||||
|
|||||||
112
apps/dashboard/src/RoomCardV2.test.ts
Normal file
112
apps/dashboard/src/RoomCardV2.test.ts
Normal 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 배포 완료');
|
||||||
|
});
|
||||||
|
});
|
||||||
869
apps/dashboard/src/RoomCardV2.tsx
Normal file
869
apps/dashboard/src/RoomCardV2.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user