Files
EJClaw/apps/dashboard/src/RoomBoardV2.tsx
2026-04-28 14:28:57 +09:00

399 lines
11 KiB
TypeScript

import { useEffect, useState } from 'react';
import type {
DashboardOverview,
DashboardRoomActivity,
StatusSnapshot,
} from './api';
import type { Locale, Messages } from './i18n';
import { RoomCardV2, type RoomEntryWithService } from './RoomCardV2';
import { EmptyState } from './EmptyState';
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 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>
);
}