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