Extract dashboard RoomBoardV2 component (#60)
* 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 * Extract dashboard RoomBoardV2 component
This commit is contained in:
@@ -44,10 +44,7 @@ import {
|
||||
type Locale,
|
||||
type Messages,
|
||||
} from './i18n';
|
||||
import {
|
||||
useSelectedRoomActivity,
|
||||
type RoomActivityMap,
|
||||
} from './useRoomActivity';
|
||||
import { useSelectedRoomActivity } from './useRoomActivity';
|
||||
import {
|
||||
SectionNav,
|
||||
SideRail,
|
||||
@@ -55,7 +52,7 @@ import {
|
||||
type DashboardView,
|
||||
} from './DashboardNav';
|
||||
import { isInternalProtocolPayload } from './roomThread';
|
||||
import { RoomCardV2, type RoomEntryWithService } from './RoomCardV2';
|
||||
import { RoomBoardV2 } from './RoomBoardV2';
|
||||
import { ParsedBody } from './ParsedBody';
|
||||
import { redactSecretsForPreview } from './redaction';
|
||||
import './styles.css';
|
||||
@@ -426,6 +423,14 @@ function formatLiveElapsed(value: number, t: Messages): string {
|
||||
return `${hours}${t.units.hour} ${remMin}${t.units.minute} ${remSec}${t.units.second}`;
|
||||
}
|
||||
|
||||
const ROOM_BOARD_FORMATTERS = {
|
||||
formatDate,
|
||||
formatDuration,
|
||||
formatLiveElapsed,
|
||||
senderRoleClass,
|
||||
statusLabel,
|
||||
};
|
||||
|
||||
function queueLabel(
|
||||
pendingTasks: number,
|
||||
pendingMessages: boolean,
|
||||
@@ -1738,258 +1743,6 @@ function HealthPanel({
|
||||
);
|
||||
}
|
||||
|
||||
type RoomFilter = 'all' | 'processing' | 'waiting' | 'inactive';
|
||||
type RoomSort = 'recent' | 'name' | 'queue';
|
||||
|
||||
const ROOM_FILTER_ORDER: RoomFilter[] = [
|
||||
'all',
|
||||
'processing',
|
||||
'waiting',
|
||||
'inactive',
|
||||
];
|
||||
|
||||
function roomFilterLabel(f: RoomFilter, t: Messages): string {
|
||||
if (f === 'all') return t.rooms.filterAll;
|
||||
return statusLabel(f, t);
|
||||
}
|
||||
|
||||
function roomSortLabel(s: RoomSort, t: Messages): string {
|
||||
if (s === 'recent') return t.rooms.sortRecent;
|
||||
if (s === 'queue') return t.rooms.sortQueue;
|
||||
return t.rooms.sortName;
|
||||
}
|
||||
|
||||
function RoomBoardV2({
|
||||
inbox,
|
||||
onSendRoomMessage,
|
||||
pendingMessages,
|
||||
roomActivity,
|
||||
roomActivityLoading,
|
||||
roomMessageKey,
|
||||
selectedJid,
|
||||
locale,
|
||||
onSelectedJidChange,
|
||||
snapshots,
|
||||
t,
|
||||
}: {
|
||||
inbox: InboxItem[];
|
||||
onSendRoomMessage: (
|
||||
roomJid: string,
|
||||
text: string,
|
||||
requestId: string,
|
||||
) => Promise<boolean>;
|
||||
pendingMessages: Record<
|
||||
string,
|
||||
Array<DashboardRoomActivity['messages'][number]>
|
||||
>;
|
||||
roomActivity: RoomActivityMap;
|
||||
roomActivityLoading: boolean;
|
||||
roomMessageKey: string | null;
|
||||
selectedJid: string | null;
|
||||
locale: Locale;
|
||||
onSelectedJidChange: (jid: string | null) => void;
|
||||
snapshots: StatusSnapshot[];
|
||||
t: Messages;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<RoomFilter>('all');
|
||||
const [sort, setSort] = useState<RoomSort>('recent');
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
|
||||
|
||||
const allEntries: RoomEntryWithService[] = snapshots.flatMap((snapshot) =>
|
||||
snapshot.entries.map((entry) => ({
|
||||
...entry,
|
||||
serviceId: snapshot.serviceId,
|
||||
})),
|
||||
);
|
||||
|
||||
const counts = {
|
||||
all: allEntries.length,
|
||||
processing: allEntries.filter((e) => e.status === 'processing').length,
|
||||
waiting: allEntries.filter((e) => e.status === 'waiting').length,
|
||||
inactive: allEntries.filter((e) => e.status === 'inactive').length,
|
||||
};
|
||||
|
||||
const filtered = allEntries.filter(
|
||||
(e) => filter === 'all' || e.status === filter,
|
||||
);
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
if (sort === 'name') return a.name.localeCompare(b.name);
|
||||
if (sort === 'queue') {
|
||||
const aQ = a.pendingTasks + (a.pendingMessages ? 1 : 0);
|
||||
const bQ = b.pendingTasks + (b.pendingMessages ? 1 : 0);
|
||||
return bQ - aQ;
|
||||
}
|
||||
const aA = roomActivity[a.jid]?.pairedTask?.updatedAt;
|
||||
const bA = roomActivity[b.jid]?.pairedTask?.updatedAt;
|
||||
const aT = aA ? new Date(aA).getTime() : (a.elapsedMs ?? 0);
|
||||
const bT = bA ? new Date(bA).getTime() : (b.elapsedMs ?? 0);
|
||||
return bT - aT;
|
||||
});
|
||||
|
||||
const selectedEntry =
|
||||
sorted.find((e) => e.jid === selectedJid) ?? sorted[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const nextJid = selectedEntry?.jid ?? null;
|
||||
if (nextJid !== selectedJid) {
|
||||
onSelectedJidChange(nextJid);
|
||||
setMobileDetailOpen(false);
|
||||
}
|
||||
}, [onSelectedJidChange, selectedEntry?.jid, selectedJid]);
|
||||
|
||||
if (allEntries.length === 0) {
|
||||
return <EmptyState>{t.rooms.empty}</EmptyState>;
|
||||
}
|
||||
|
||||
function setDraft(jid: string, value: string) {
|
||||
setDrafts((previous) => ({ ...previous, [jid]: value }));
|
||||
}
|
||||
|
||||
function scrollDetailToBottom() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const detail = document.querySelector(
|
||||
'.rooms-detail',
|
||||
) as HTMLElement | null;
|
||||
if (!detail) return;
|
||||
const tick = (n: number) => {
|
||||
detail.scrollTop = detail.scrollHeight;
|
||||
if (n > 0) requestAnimationFrame(() => tick(n - 1));
|
||||
};
|
||||
requestAnimationFrame(() => tick(3));
|
||||
}
|
||||
|
||||
async function submitRoomMessage(jid: string) {
|
||||
const text = drafts[jid]?.trim();
|
||||
if (!text) return;
|
||||
scrollDetailToBottom();
|
||||
const success = await onSendRoomMessage(jid, text, makeClientRequestId());
|
||||
if (success) {
|
||||
setDraft(jid, '');
|
||||
scrollDetailToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rooms-v2">
|
||||
<div className="rooms-toolbar" role="toolbar" aria-label="Rooms filters">
|
||||
<div className="rooms-filters">
|
||||
{ROOM_FILTER_ORDER.map((f) => (
|
||||
<button
|
||||
aria-pressed={filter === f}
|
||||
className={filter === f ? 'is-active' : undefined}
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
type="button"
|
||||
>
|
||||
{roomFilterLabel(f, t)}
|
||||
<span>{counts[f]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="rooms-sort">
|
||||
<span>{t.rooms.sortLabel}</span>
|
||||
<select
|
||||
onChange={(e) => setSort(e.target.value as RoomSort)}
|
||||
value={sort}
|
||||
>
|
||||
<option value="recent">{roomSortLabel('recent', t)}</option>
|
||||
<option value="queue">{roomSortLabel('queue', t)}</option>
|
||||
<option value="name">{roomSortLabel('name', t)}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyState>{t.rooms.empty}</EmptyState>
|
||||
) : (
|
||||
<div
|
||||
className={`rooms-twopane${mobileDetailOpen ? ' is-detail-open' : ''}`}
|
||||
>
|
||||
<aside className="rooms-list" aria-label={t.rooms.cardsAria}>
|
||||
{sorted.map((entry) => {
|
||||
const items = inbox.filter((it) => it.roomJid === entry.jid);
|
||||
const queue =
|
||||
entry.pendingTasks + (entry.pendingMessages ? 1 : 0);
|
||||
const active = (selectedEntry?.jid ?? null) === entry.jid;
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`rooms-list-item status-${entry.status}${active ? ' is-active' : ''}`}
|
||||
key={`${entry.serviceId}:${entry.jid}`}
|
||||
onClick={() => {
|
||||
onSelectedJidChange(entry.jid);
|
||||
setMobileDetailOpen(true);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className={`room-pulse pulse-${entry.status}`}>
|
||||
{entry.status === 'processing' ? (
|
||||
<span className="pulse-dot" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="rooms-list-text">
|
||||
<strong>{entry.name}</strong>
|
||||
<small>{entry.agentType}</small>
|
||||
</span>
|
||||
{items.length > 0 ? (
|
||||
<span
|
||||
className={`rooms-list-bell sev-${items[0].severity}`}
|
||||
title={items.map((i) => t.inbox.kinds[i.kind]).join(', ')}
|
||||
>
|
||||
{items.length}
|
||||
</span>
|
||||
) : null}
|
||||
{queue > 0 ? (
|
||||
<span className="rooms-list-queue">{queue}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
<main className="rooms-detail">
|
||||
<button
|
||||
className="rooms-mobile-back"
|
||||
onClick={() => setMobileDetailOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
← {t.panels.rooms}
|
||||
</button>
|
||||
{selectedEntry ? (
|
||||
<RoomCardV2
|
||||
activity={roomActivity[selectedEntry.jid]}
|
||||
activityLoading={roomActivityLoading}
|
||||
busy={roomMessageKey === selectedEntry.jid}
|
||||
draft={drafts[selectedEntry.jid] ?? ''}
|
||||
entry={selectedEntry}
|
||||
expanded={true}
|
||||
formatDate={formatDate}
|
||||
formatDuration={formatDuration}
|
||||
formatLiveElapsed={formatLiveElapsed}
|
||||
inboxItems={inbox.filter(
|
||||
(item) => item.roomJid === selectedEntry.jid,
|
||||
)}
|
||||
key={`${selectedEntry.serviceId}:${selectedEntry.jid}`}
|
||||
locale={locale}
|
||||
onDraftChange={(v) => setDraft(selectedEntry.jid, v)}
|
||||
onSendMessage={() => void submitRoomMessage(selectedEntry.jid)}
|
||||
onToggle={() => {}}
|
||||
pendingMessages={pendingMessages[selectedEntry.jid] ?? []}
|
||||
pinned={true}
|
||||
senderRoleClass={senderRoleClass}
|
||||
statusLabel={statusLabel}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState>{t.rooms.empty}</EmptyState>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageQuotaMeter({
|
||||
row,
|
||||
rowName,
|
||||
@@ -3036,14 +2789,16 @@ function App() {
|
||||
<span>{t.panels.queue}</span>
|
||||
</div>
|
||||
<RoomBoardV2
|
||||
{...ROOM_BOARD_FORMATTERS}
|
||||
createRequestId={makeClientRequestId}
|
||||
inbox={data.overview.inbox}
|
||||
locale={locale}
|
||||
onSelectedJidChange={setSelectedRoomJid}
|
||||
onSendRoomMessage={handleRoomMessage}
|
||||
pendingMessages={pendingMessages}
|
||||
roomActivity={roomActivity}
|
||||
roomActivityLoading={roomActivityLoading}
|
||||
roomMessageKey={roomMessageKey}
|
||||
locale={locale}
|
||||
onSelectedJidChange={setSelectedRoomJid}
|
||||
selectedJid={selectedRoomJid}
|
||||
snapshots={data.snapshots}
|
||||
t={t}
|
||||
|
||||
92
apps/dashboard/src/RoomBoardV2.test.ts
Normal file
92
apps/dashboard/src/RoomBoardV2.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { DashboardRoomActivity, StatusSnapshot } from './api';
|
||||
import { messages } from './i18n';
|
||||
import { RoomBoardV2, type RoomBoardV2Props } from './RoomBoardV2';
|
||||
|
||||
const t = messages.ko;
|
||||
|
||||
const snapshot: StatusSnapshot = {
|
||||
serviceId: 'svc-1',
|
||||
assistantName: 'codex',
|
||||
agentType: 'codex',
|
||||
updatedAt: '2026-04-28T04:00:00.000Z',
|
||||
entries: [
|
||||
{
|
||||
jid: 'room-1',
|
||||
name: 'eyejokerdb-main',
|
||||
folder: 'eyejokerdb',
|
||||
agentType: 'codex',
|
||||
status: 'processing',
|
||||
elapsedMs: 180_000,
|
||||
pendingMessages: false,
|
||||
pendingTasks: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const activity: DashboardRoomActivity = {
|
||||
serviceId: 'svc-1',
|
||||
jid: 'room-1',
|
||||
name: 'eyejokerdb-main',
|
||||
folder: 'eyejokerdb',
|
||||
agentType: 'codex',
|
||||
status: 'processing',
|
||||
elapsedMs: 180_000,
|
||||
pendingMessages: false,
|
||||
pendingTasks: 3,
|
||||
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',
|
||||
},
|
||||
],
|
||||
pairedTask: null,
|
||||
};
|
||||
|
||||
const baseProps: RoomBoardV2Props = {
|
||||
createRequestId: () => 'request-1',
|
||||
formatDate: (value) => value ?? '-',
|
||||
formatDuration: (value) => (value === null ? '-' : `${value}`),
|
||||
formatLiveElapsed: (value) => `${value}`,
|
||||
inbox: [],
|
||||
locale: 'ko',
|
||||
onSelectedJidChange: () => {},
|
||||
onSendRoomMessage: async () => true,
|
||||
pendingMessages: {},
|
||||
roomActivity: { 'room-1': activity },
|
||||
roomActivityLoading: false,
|
||||
roomMessageKey: null,
|
||||
selectedJid: 'room-1',
|
||||
senderRoleClass: (value) =>
|
||||
(value ?? '').toLowerCase().includes('owner') ? 'role-owner' : 'role-human',
|
||||
snapshots: [snapshot],
|
||||
statusLabel: (status) => status,
|
||||
t,
|
||||
};
|
||||
|
||||
describe('RoomBoardV2', () => {
|
||||
it('renders room list and selected detail thread', () => {
|
||||
const html = renderToStaticMarkup(createElement(RoomBoardV2, baseProps));
|
||||
|
||||
expect(html).toContain('eyejokerdb-main');
|
||||
expect(html).toContain('TASK_DONE');
|
||||
expect(html).toContain('prod 배포 완료');
|
||||
});
|
||||
|
||||
it('renders an empty state without snapshots', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(RoomBoardV2, { ...baseProps, snapshots: [] }),
|
||||
);
|
||||
|
||||
expect(html).toContain(t.rooms.empty);
|
||||
});
|
||||
});
|
||||
401
apps/dashboard/src/RoomBoardV2.tsx
Normal file
401
apps/dashboard/src/RoomBoardV2.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
import type {
|
||||
DashboardOverview,
|
||||
DashboardRoomActivity,
|
||||
StatusSnapshot,
|
||||
} from './api';
|
||||
import type { Locale, Messages } from './i18n';
|
||||
import { RoomCardV2, type RoomEntryWithService } from './RoomCardV2';
|
||||
import type { RoomActivityMap } from './useRoomActivity';
|
||||
|
||||
type InboxItem = DashboardOverview['inbox'][number];
|
||||
type RoomFilter = 'all' | 'processing' | 'waiting' | 'inactive';
|
||||
type RoomSort = 'recent' | 'name' | 'queue';
|
||||
|
||||
const ROOM_FILTER_ORDER: RoomFilter[] = [
|
||||
'all',
|
||||
'processing',
|
||||
'waiting',
|
||||
'inactive',
|
||||
];
|
||||
|
||||
export interface RoomBoardV2Props {
|
||||
createRequestId: () => string;
|
||||
formatDate: (value: string | null | undefined, locale: Locale) => string;
|
||||
formatDuration: (value: number | null, t: Messages) => string;
|
||||
formatLiveElapsed: (value: number, t: Messages) => string;
|
||||
inbox: InboxItem[];
|
||||
locale: Locale;
|
||||
onSelectedJidChange: (jid: string | null) => void;
|
||||
onSendRoomMessage: (
|
||||
roomJid: string,
|
||||
text: string,
|
||||
requestId: string,
|
||||
) => Promise<boolean>;
|
||||
pendingMessages: Record<
|
||||
string,
|
||||
Array<DashboardRoomActivity['messages'][number]>
|
||||
>;
|
||||
roomActivity: RoomActivityMap;
|
||||
roomActivityLoading: boolean;
|
||||
roomMessageKey: string | null;
|
||||
selectedJid: string | null;
|
||||
senderRoleClass: (value: string | null | undefined) => string;
|
||||
snapshots: StatusSnapshot[];
|
||||
statusLabel: (status: string, t: Messages) => string;
|
||||
t: Messages;
|
||||
}
|
||||
|
||||
function EmptyState({ children }: { children: ReactNode }) {
|
||||
return <div className="empty-state">{children}</div>;
|
||||
}
|
||||
|
||||
function roomFilterLabel(
|
||||
filter: RoomFilter,
|
||||
statusLabel: (status: string, t: Messages) => string,
|
||||
t: Messages,
|
||||
): string {
|
||||
if (filter === 'all') return t.rooms.filterAll;
|
||||
return statusLabel(filter, t);
|
||||
}
|
||||
|
||||
function roomSortLabel(sort: RoomSort, t: Messages): string {
|
||||
if (sort === 'recent') return t.rooms.sortRecent;
|
||||
if (sort === 'queue') return t.rooms.sortQueue;
|
||||
return t.rooms.sortName;
|
||||
}
|
||||
|
||||
function scrollDetailToBottom() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const detail = document.querySelector('.rooms-detail') as HTMLElement | null;
|
||||
if (!detail) return;
|
||||
const tick = (n: number) => {
|
||||
detail.scrollTop = detail.scrollHeight;
|
||||
if (n > 0) requestAnimationFrame(() => tick(n - 1));
|
||||
};
|
||||
requestAnimationFrame(() => tick(3));
|
||||
}
|
||||
|
||||
interface RoomsToolbarProps {
|
||||
counts: Record<RoomFilter, number>;
|
||||
filter: RoomFilter;
|
||||
onFilterChange: (filter: RoomFilter) => void;
|
||||
onSortChange: (sort: RoomSort) => void;
|
||||
sort: RoomSort;
|
||||
statusLabel: (status: string, t: Messages) => string;
|
||||
t: Messages;
|
||||
}
|
||||
|
||||
function RoomsToolbar({
|
||||
counts,
|
||||
filter,
|
||||
onFilterChange,
|
||||
onSortChange,
|
||||
sort,
|
||||
statusLabel,
|
||||
t,
|
||||
}: RoomsToolbarProps) {
|
||||
return (
|
||||
<div className="rooms-toolbar" role="toolbar" aria-label="Rooms filters">
|
||||
<div className="rooms-filters">
|
||||
{ROOM_FILTER_ORDER.map((filterKey) => (
|
||||
<button
|
||||
aria-pressed={filter === filterKey}
|
||||
className={filter === filterKey ? 'is-active' : undefined}
|
||||
key={filterKey}
|
||||
onClick={() => onFilterChange(filterKey)}
|
||||
type="button"
|
||||
>
|
||||
{roomFilterLabel(filterKey, statusLabel, t)}
|
||||
<span>{counts[filterKey]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="rooms-sort">
|
||||
<span>{t.rooms.sortLabel}</span>
|
||||
<select
|
||||
onChange={(event) => onSortChange(event.target.value as RoomSort)}
|
||||
value={sort}
|
||||
>
|
||||
<option value="recent">{roomSortLabel('recent', t)}</option>
|
||||
<option value="queue">{roomSortLabel('queue', t)}</option>
|
||||
<option value="name">{roomSortLabel('name', t)}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RoomsListProps {
|
||||
entries: RoomEntryWithService[];
|
||||
inbox: InboxItem[];
|
||||
onSelect: (jid: string) => void;
|
||||
selectedJid: string | null;
|
||||
t: Messages;
|
||||
}
|
||||
|
||||
function RoomsList({
|
||||
entries,
|
||||
inbox,
|
||||
onSelect,
|
||||
selectedJid,
|
||||
t,
|
||||
}: RoomsListProps) {
|
||||
return (
|
||||
<aside className="rooms-list" aria-label={t.rooms.cardsAria}>
|
||||
{entries.map((entry) => {
|
||||
const items = inbox.filter((item) => item.roomJid === entry.jid);
|
||||
const queue = entry.pendingTasks + (entry.pendingMessages ? 1 : 0);
|
||||
const active = selectedJid === entry.jid;
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`rooms-list-item status-${entry.status}${active ? ' is-active' : ''}`}
|
||||
key={`${entry.serviceId}:${entry.jid}`}
|
||||
onClick={() => onSelect(entry.jid)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`room-pulse pulse-${entry.status}`}>
|
||||
{entry.status === 'processing' ? (
|
||||
<span className="pulse-dot" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="rooms-list-text">
|
||||
<strong>{entry.name}</strong>
|
||||
<small>{entry.agentType}</small>
|
||||
</span>
|
||||
{items.length > 0 ? (
|
||||
<span
|
||||
className={`rooms-list-bell sev-${items[0].severity}`}
|
||||
title={items.map((item) => t.inbox.kinds[item.kind]).join(', ')}
|
||||
>
|
||||
{items.length}
|
||||
</span>
|
||||
) : null}
|
||||
{queue > 0 ? (
|
||||
<span className="rooms-list-queue">{queue}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
interface RoomsDetailProps extends Pick<
|
||||
RoomBoardV2Props,
|
||||
| 'formatDate'
|
||||
| 'formatDuration'
|
||||
| 'formatLiveElapsed'
|
||||
| 'locale'
|
||||
| 'pendingMessages'
|
||||
| 'roomActivity'
|
||||
| 'roomActivityLoading'
|
||||
| 'roomMessageKey'
|
||||
| 'senderRoleClass'
|
||||
| 'statusLabel'
|
||||
| 't'
|
||||
> {
|
||||
drafts: Record<string, string>;
|
||||
inbox: InboxItem[];
|
||||
onBack: () => void;
|
||||
onDraftChange: (jid: string, value: string) => void;
|
||||
onSubmit: (jid: string) => void;
|
||||
selectedEntry: RoomEntryWithService | null;
|
||||
}
|
||||
|
||||
function RoomsDetail({
|
||||
drafts,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
formatLiveElapsed,
|
||||
inbox,
|
||||
locale,
|
||||
onBack,
|
||||
onDraftChange,
|
||||
onSubmit,
|
||||
pendingMessages,
|
||||
roomActivity,
|
||||
roomActivityLoading,
|
||||
roomMessageKey,
|
||||
selectedEntry,
|
||||
senderRoleClass,
|
||||
statusLabel,
|
||||
t,
|
||||
}: RoomsDetailProps) {
|
||||
return (
|
||||
<main className="rooms-detail">
|
||||
<button className="rooms-mobile-back" onClick={onBack} type="button">
|
||||
← {t.panels.rooms}
|
||||
</button>
|
||||
{selectedEntry ? (
|
||||
<RoomCardV2
|
||||
activity={roomActivity[selectedEntry.jid]}
|
||||
activityLoading={roomActivityLoading}
|
||||
busy={roomMessageKey === selectedEntry.jid}
|
||||
draft={drafts[selectedEntry.jid] ?? ''}
|
||||
entry={selectedEntry}
|
||||
expanded={true}
|
||||
formatDate={formatDate}
|
||||
formatDuration={formatDuration}
|
||||
formatLiveElapsed={formatLiveElapsed}
|
||||
inboxItems={inbox.filter(
|
||||
(item) => item.roomJid === selectedEntry.jid,
|
||||
)}
|
||||
key={`${selectedEntry.serviceId}:${selectedEntry.jid}`}
|
||||
locale={locale}
|
||||
onDraftChange={(value) => onDraftChange(selectedEntry.jid, value)}
|
||||
onSendMessage={() => onSubmit(selectedEntry.jid)}
|
||||
onToggle={() => {}}
|
||||
pendingMessages={pendingMessages[selectedEntry.jid] ?? []}
|
||||
pinned={true}
|
||||
senderRoleClass={senderRoleClass}
|
||||
statusLabel={statusLabel}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState>{t.rooms.empty}</EmptyState>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoomBoardV2({
|
||||
createRequestId,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
formatLiveElapsed,
|
||||
inbox,
|
||||
onSendRoomMessage,
|
||||
pendingMessages,
|
||||
roomActivity,
|
||||
roomActivityLoading,
|
||||
roomMessageKey,
|
||||
selectedJid,
|
||||
locale,
|
||||
onSelectedJidChange,
|
||||
senderRoleClass,
|
||||
snapshots,
|
||||
statusLabel,
|
||||
t,
|
||||
}: RoomBoardV2Props) {
|
||||
const [filter, setFilter] = useState<RoomFilter>('all');
|
||||
const [sort, setSort] = useState<RoomSort>('recent');
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
|
||||
|
||||
const allEntries: RoomEntryWithService[] = snapshots.flatMap((snapshot) =>
|
||||
snapshot.entries.map((entry) => ({
|
||||
...entry,
|
||||
serviceId: snapshot.serviceId,
|
||||
})),
|
||||
);
|
||||
|
||||
const counts = {
|
||||
all: allEntries.length,
|
||||
processing: allEntries.filter((entry) => entry.status === 'processing')
|
||||
.length,
|
||||
waiting: allEntries.filter((entry) => entry.status === 'waiting').length,
|
||||
inactive: allEntries.filter((entry) => entry.status === 'inactive').length,
|
||||
};
|
||||
|
||||
const filtered = allEntries.filter(
|
||||
(entry) => filter === 'all' || entry.status === filter,
|
||||
);
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
if (sort === 'name') return a.name.localeCompare(b.name);
|
||||
if (sort === 'queue') {
|
||||
const aQ = a.pendingTasks + (a.pendingMessages ? 1 : 0);
|
||||
const bQ = b.pendingTasks + (b.pendingMessages ? 1 : 0);
|
||||
return bQ - aQ;
|
||||
}
|
||||
const aA = roomActivity[a.jid]?.pairedTask?.updatedAt;
|
||||
const bA = roomActivity[b.jid]?.pairedTask?.updatedAt;
|
||||
const aT = aA ? new Date(aA).getTime() : (a.elapsedMs ?? 0);
|
||||
const bT = bA ? new Date(bA).getTime() : (b.elapsedMs ?? 0);
|
||||
return bT - aT;
|
||||
});
|
||||
|
||||
const selectedEntry =
|
||||
sorted.find((entry) => entry.jid === selectedJid) ?? sorted[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const nextJid = selectedEntry?.jid ?? null;
|
||||
if (nextJid !== selectedJid) {
|
||||
onSelectedJidChange(nextJid);
|
||||
setMobileDetailOpen(false);
|
||||
}
|
||||
}, [onSelectedJidChange, selectedEntry?.jid, selectedJid]);
|
||||
|
||||
if (allEntries.length === 0) {
|
||||
return <EmptyState>{t.rooms.empty}</EmptyState>;
|
||||
}
|
||||
|
||||
function setDraft(jid: string, value: string) {
|
||||
setDrafts((previous) => ({ ...previous, [jid]: value }));
|
||||
}
|
||||
|
||||
async function submitRoomMessage(jid: string) {
|
||||
const text = drafts[jid]?.trim();
|
||||
if (!text) return;
|
||||
scrollDetailToBottom();
|
||||
const success = await onSendRoomMessage(jid, text, createRequestId());
|
||||
if (success) {
|
||||
setDraft(jid, '');
|
||||
scrollDetailToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rooms-v2">
|
||||
<RoomsToolbar
|
||||
counts={counts}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
onSortChange={setSort}
|
||||
sort={sort}
|
||||
statusLabel={statusLabel}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyState>{t.rooms.empty}</EmptyState>
|
||||
) : (
|
||||
<div
|
||||
className={`rooms-twopane${mobileDetailOpen ? ' is-detail-open' : ''}`}
|
||||
>
|
||||
<RoomsList
|
||||
entries={sorted}
|
||||
inbox={inbox}
|
||||
onSelect={(jid) => {
|
||||
onSelectedJidChange(jid);
|
||||
setMobileDetailOpen(true);
|
||||
}}
|
||||
selectedJid={selectedEntry?.jid ?? null}
|
||||
t={t}
|
||||
/>
|
||||
<RoomsDetail
|
||||
drafts={drafts}
|
||||
formatDate={formatDate}
|
||||
formatDuration={formatDuration}
|
||||
formatLiveElapsed={formatLiveElapsed}
|
||||
inbox={inbox}
|
||||
locale={locale}
|
||||
onBack={() => setMobileDetailOpen(false)}
|
||||
onDraftChange={setDraft}
|
||||
onSubmit={(jid) => void submitRoomMessage(jid)}
|
||||
pendingMessages={pendingMessages}
|
||||
roomActivity={roomActivity}
|
||||
roomActivityLoading={roomActivityLoading}
|
||||
roomMessageKey={roomMessageKey}
|
||||
selectedEntry={selectedEntry}
|
||||
senderRoleClass={senderRoleClass}
|
||||
statusLabel={statusLabel}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user