Add dashboard room activity timeline

This commit is contained in:
Eyejoker
2026-04-27 11:46:35 +09:00
committed by GitHub
parent d7fe6fa13a
commit d28068f646
8 changed files with 1031 additions and 2 deletions

View File

@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react';
import {
type CreateScheduledTaskInput,
type DashboardInboxAction,
type DashboardRoomActivity,
type DashboardTaskContextMode,
type DashboardTaskScheduleType,
type DashboardTaskAction,
@@ -12,6 +13,7 @@ import {
type StatusSnapshot,
createScheduledTask,
fetchDashboardData,
fetchRoomTimeline,
runInboxAction,
runServiceAction,
runScheduledTaskAction,
@@ -36,6 +38,8 @@ interface DashboardState {
tasks: DashboardTask[];
}
type RoomActivityMap = Record<string, DashboardRoomActivity>;
type UsageRow = DashboardOverview['usage']['rows'][number];
type InboxItem = DashboardOverview['inbox'][number];
type RiskLevel = 'ok' | 'warn' | 'critical';
@@ -1289,9 +1293,131 @@ function RoomMessageForm({
);
}
function RoomActivityPanel({
activity,
loading,
locale,
t,
}: {
activity: DashboardRoomActivity | undefined;
loading: boolean;
locale: Locale;
t: Messages;
}) {
if (!activity) {
return (
<div className="room-activity room-activity-empty">
{loading ? t.rooms.loadingActivity : t.rooms.noActivity}
</div>
);
}
const task = activity.pairedTask;
const turn = task?.currentTurn ?? null;
const outputs = task?.outputs ?? [];
return (
<div className="room-activity">
<div className="room-activity-grid">
<span>
<small>{t.rooms.task}</small>
<strong>{task?.title || task?.id || t.rooms.noTask}</strong>
</span>
<span>
<small>{t.rooms.currentTurn}</small>
<strong>
{turn
? `${turn.role} · ${t.rooms.attempt} ${turn.attemptNo}`
: t.rooms.noTurn}
</strong>
</span>
<span>
<small>{t.rooms.round}</small>
<strong>{task ? task.roundTripCount : '-'}</strong>
</span>
<span>
<small>{t.rooms.updated}</small>
<strong>
{formatDate(turn?.updatedAt ?? task?.updatedAt, locale)}
</strong>
</span>
</div>
{turn ? (
<div className="room-turn-line">
<span className={`pill pill-${turn.state}`}>
{statusLabel(turn.state, t)}
</span>
<span>{turn.intentKind}</span>
{turn.executorServiceId ? (
<span>{turn.executorServiceId}</span>
) : null}
{turn.activeRunId ? (
<span className="mono-chip">{turn.activeRunId}</span>
) : null}
</div>
) : null}
{turn?.lastError ? (
<p className="room-activity-error">{turn.lastError}</p>
) : null}
<div className="room-activity-columns">
<section>
<strong>{t.rooms.output}</strong>
{outputs.length === 0 ? (
<p className="room-muted">{t.rooms.noOutput}</p>
) : (
<div className="room-output-list">
{[...outputs].reverse().map((output) => (
<article key={output.id} className="room-output-item">
<header>
<span>
#{output.turnNumber} · {output.role}
{output.verdict ? ` · ${output.verdict}` : ''}
</span>
<time>{formatDate(output.createdAt, locale)}</time>
</header>
<p>{output.outputText}</p>
</article>
))}
</div>
)}
</section>
<section>
<strong>{t.rooms.recentMessages}</strong>
{activity.messages.length === 0 ? (
<p className="room-muted">{t.rooms.noMessages}</p>
) : (
<div className="room-message-list">
{activity.messages.map((message) => (
<article
className={`room-message-item ${
message.isBotMessage ? 'room-message-bot' : ''
}`}
key={message.id}
>
<header>
<span>{message.senderName}</span>
<time>{formatDate(message.timestamp, locale)}</time>
</header>
<p>{message.content}</p>
</article>
))}
</div>
)}
</section>
</div>
</div>
);
}
function RoomPanel({
onSendRoomMessage,
roomActivity,
roomActivityLoading,
roomMessageKey,
locale,
snapshots,
t,
}: {
@@ -1300,7 +1426,10 @@ function RoomPanel({
text: string,
requestId: string,
) => Promise<boolean>;
roomActivity: RoomActivityMap;
roomActivityLoading: boolean;
roomMessageKey: string | null;
locale: Locale;
snapshots: StatusSnapshot[];
t: Messages;
}) {
@@ -1339,6 +1468,7 @@ function RoomPanel({
<th>{t.rooms.status}</th>
<th>{t.rooms.queue}</th>
<th>{t.rooms.elapsed}</th>
<th>{t.rooms.activity}</th>
<th>{t.rooms.message}</th>
</tr>
</thead>
@@ -1364,6 +1494,14 @@ function RoomPanel({
{queueLabel(entry.pendingTasks, entry.pendingMessages, t)}
</td>
<td>{formatDuration(entry.elapsedMs, t)}</td>
<td className="room-activity-cell">
<RoomActivityPanel
activity={roomActivity[entry.jid]}
loading={roomActivityLoading}
locale={locale}
t={t}
/>
</td>
<td>
<RoomMessageForm
busy={roomMessageKey === entry.jid}
@@ -1406,6 +1544,12 @@ function RoomPanel({
<strong>{formatDuration(entry.elapsedMs, t)}</strong>
</span>
</div>
<RoomActivityPanel
activity={roomActivity[entry.jid]}
loading={roomActivityLoading}
locale={locale}
t={t}
/>
<RoomMessageForm
busy={roomMessageKey === entry.jid}
onChange={(value) => setDraft(entry.jid, value)}
@@ -1950,6 +2094,8 @@ function App() {
const [serviceActionKey, setServiceActionKey] =
useState<ServiceActionKey | null>(null);
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
const [roomActivity, setRoomActivity] = useState<RoomActivityMap>({});
const [roomActivityLoading, setRoomActivityLoading] = useState(false);
const t = messages[locale];
const secureContext =
typeof window === 'undefined' ? true : window.isSecureContext;
@@ -2196,6 +2342,49 @@ function App() {
return () => window.clearInterval(id);
}, []);
useEffect(() => {
if (activeView !== 'rooms' || !data) return;
const jids = [
...new Set(
data.snapshots.flatMap((snapshot) =>
snapshot.entries.map((entry) => entry.jid),
),
),
];
if (jids.length === 0) {
setRoomActivity({});
return;
}
let cancelled = false;
setRoomActivityLoading(true);
void Promise.all(
jids.map(async (jid) => {
try {
return [jid, await fetchRoomTimeline(jid)] as const;
} catch {
return [jid, null] as const;
}
}),
)
.then((entries) => {
if (cancelled) return;
const next: RoomActivityMap = {};
for (const [jid, activity] of entries) {
if (activity) next[jid] = activity;
}
setRoomActivity(next);
})
.finally(() => {
if (!cancelled) setRoomActivityLoading(false);
});
return () => {
cancelled = true;
};
}, [activeView, data]);
useEffect(() => {
if (!drawerOpen) return;
@@ -2335,7 +2524,10 @@ function App() {
</div>
<RoomPanel
onSendRoomMessage={handleRoomMessage}
roomActivity={roomActivity}
roomActivityLoading={roomActivityLoading}
roomMessageKey={roomMessageKey}
locale={locale}
snapshots={data.snapshots}
t={t}
/>

View File

@@ -92,6 +92,57 @@ export interface StatusSnapshot {
}>;
}
export interface DashboardRoomActivity {
serviceId: string;
jid: string;
name: string;
folder: string;
agentType: string;
status: 'processing' | 'waiting' | 'inactive';
elapsedMs: number | null;
pendingMessages: boolean;
pendingTasks: number;
messages: Array<{
id: string;
sender: string;
senderName: string;
content: string;
timestamp: string;
isFromMe: boolean;
isBotMessage: boolean;
sourceKind: string;
}>;
pairedTask: {
id: string;
title: string | null;
status: string;
roundTripCount: number;
updatedAt: string;
currentTurn: {
turnId: string;
role: string;
intentKind: string;
state: string;
attemptNo: number;
executorServiceId: string | null;
executorAgentType: string | null;
activeRunId: string | null;
createdAt: string;
updatedAt: string;
completedAt: string | null;
lastError: string | null;
} | null;
outputs: Array<{
id: number;
turnNumber: number;
role: string;
verdict: string | null;
createdAt: string;
outputText: string;
}>;
} | null;
}
export interface DashboardTask {
id: string;
groupFolder: string;
@@ -180,6 +231,14 @@ export async function fetchDashboardData(): Promise<{
return { overview, snapshots, tasks };
}
export async function fetchRoomTimeline(
roomJid: string,
): Promise<DashboardRoomActivity> {
return fetchJson<DashboardRoomActivity>(
`/api/rooms/${encodeURIComponent(roomJid)}/timeline`,
);
}
export async function runScheduledTaskAction(
taskId: string,
action: DashboardTaskAction,

View File

@@ -147,6 +147,20 @@ export interface Messages {
status: string;
queue: string;
elapsed: string;
activity: string;
loadingActivity: string;
noActivity: string;
task: string;
noTask: string;
currentTurn: string;
noTurn: string;
round: string;
attempt: string;
updated: string;
output: string;
noOutput: string;
recentMessages: string;
noMessages: string;
details: string;
message: string;
messagePlaceholder: string;
@@ -421,6 +435,20 @@ export const messages = {
status: '상태',
queue: '큐',
elapsed: '경과',
activity: '진행',
loadingActivity: '진행 로딩 중',
noActivity: '진행 없음',
task: '태스크',
noTask: '태스크 없음',
currentTurn: '현재 턴',
noTurn: '턴 없음',
round: '라운드',
attempt: '시도',
updated: '갱신',
output: '출력',
noOutput: '출력 없음',
recentMessages: '최근 메시지',
noMessages: '메시지 없음',
details: '세부',
message: 'Message',
messagePlaceholder: 'Type request...',
@@ -679,6 +707,20 @@ export const messages = {
status: 'status',
queue: 'queue',
elapsed: 'elapsed',
activity: 'activity',
loadingActivity: 'Loading activity',
noActivity: 'No activity',
task: 'task',
noTask: 'No task',
currentTurn: 'Current turn',
noTurn: 'No turn',
round: 'round',
attempt: 'attempt',
updated: 'updated',
output: 'output',
noOutput: 'No output',
recentMessages: 'Recent messages',
noMessages: 'No messages',
details: 'details',
message: 'message',
messagePlaceholder: 'Type request...',
@@ -937,6 +979,20 @@ export const messages = {
status: '状态',
queue: '队列',
elapsed: '耗时',
activity: '进展',
loadingActivity: '正在加载进展',
noActivity: '暂无进展',
task: '任务',
noTask: '无任务',
currentTurn: '当前回合',
noTurn: '无回合',
round: '轮次',
attempt: '尝试',
updated: '更新',
output: '输出',
noOutput: '暂无输出',
recentMessages: '最近消息',
noMessages: '暂无消息',
details: '详情',
message: 'Message',
messagePlaceholder: 'Type request...',
@@ -1195,6 +1251,20 @@ export const messages = {
status: '状態',
queue: 'キュー',
elapsed: '経過',
activity: '進行',
loadingActivity: '進行を読み込み中',
noActivity: '進行なし',
task: 'タスク',
noTask: 'タスクなし',
currentTurn: '現在ターン',
noTurn: 'ターンなし',
round: 'ラウンド',
attempt: '試行',
updated: '更新',
output: '出力',
noOutput: '出力なし',
recentMessages: '最近のメッセージ',
noMessages: 'メッセージなし',
details: '詳細',
message: 'Message',
messagePlaceholder: 'Type request...',

View File

@@ -1327,6 +1327,165 @@ progress::-moz-progress-bar {
opacity: 0.44;
}
.room-activity-cell {
min-width: 360px;
}
.room-activity {
display: grid;
min-width: min(360px, 100%);
gap: 9px;
padding: 10px;
border: 1px solid rgba(43, 55, 38, 0.1);
border-radius: 18px;
background: rgba(255, 250, 240, 0.58);
}
.room-activity-empty,
.room-muted {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.room-activity-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
}
.room-activity-grid > span {
min-width: 0;
padding: 8px;
border-radius: 13px;
background: rgba(43, 55, 38, 0.045);
}
.room-activity-grid small,
.room-activity-grid strong {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.room-activity-grid small {
color: var(--muted);
font-size: 10px;
font-weight: 850;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.room-activity-grid strong {
margin-top: 3px;
font-size: 12px;
}
.room-turn-line {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.room-turn-line .mono-chip {
margin-top: 0;
}
.room-activity-error {
margin: 0;
color: #7c2518;
font-size: 12px;
font-weight: 850;
overflow-wrap: anywhere;
}
.room-activity-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 9px;
}
.room-activity-columns > section {
display: grid;
min-width: 0;
align-content: start;
gap: 7px;
}
.room-activity-columns > section > strong {
color: var(--bg-ink);
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.room-output-list,
.room-message-list {
display: grid;
gap: 7px;
}
.room-output-item,
.room-message-item {
min-width: 0;
padding: 8px;
border-radius: 13px;
background: rgba(43, 55, 38, 0.045);
}
.room-message-bot {
background: rgba(191, 95, 44, 0.08);
}
.room-output-item header,
.room-message-item header {
display: flex;
gap: 8px;
align-items: baseline;
justify-content: space-between;
color: var(--muted);
font-size: 11px;
font-weight: 850;
}
.room-output-item header span,
.room-message-item header span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.room-output-item time,
.room-message-item time {
flex: none;
}
.room-output-item p,
.room-message-item p,
.room-muted {
margin: 0;
}
.room-output-item p,
.room-message-item p {
display: -webkit-box;
overflow: hidden;
margin-top: 5px;
color: var(--ink);
font-size: 12px;
line-height: 1.35;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.task-board {
display: grid;
gap: 12px;
@@ -2001,6 +2160,16 @@ progress::-moz-progress-bar {
grid-template-columns: 1fr 1fr;
}
.room-activity,
.room-activity-cell {
min-width: 0;
}
.room-activity-grid,
.room-activity-columns {
grid-template-columns: 1fr;
}
.inbox-summary,
.health-signals {
grid-template-columns: repeat(2, minmax(0, 1fr));