diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index ba961af..3a71d89 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -34,7 +34,6 @@ import { fetchAccounts, fetchDashboardData, fetchModelConfig, - fetchRoomsTimelineBatch, runInboxAction, runServiceAction, runScheduledTaskAction, @@ -52,6 +51,10 @@ import { type Locale, type Messages, } from './i18n'; +import { + useSelectedRoomActivity, + type RoomActivityMap, +} from './useRoomActivity'; import './styles.css'; interface DashboardState { @@ -60,8 +63,6 @@ interface DashboardState { tasks: DashboardTask[]; } -type RoomActivityMap = Record; - type UsageRow = DashboardOverview['usage']['rows'][number]; type InboxItem = DashboardOverview['inbox'][number]; type RiskLevel = 'ok' | 'warn' | 'critical'; @@ -2568,7 +2569,9 @@ function RoomBoardV2({ roomActivity, roomActivityLoading, roomMessageKey, + selectedJid, locale, + onSelectedJidChange, snapshots, t, }: { @@ -2585,13 +2588,14 @@ function RoomBoardV2({ 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('all'); const [sort, setSort] = useState('recent'); - const [selectedJid, setSelectedJid] = useState(null); const [drafts, setDrafts] = useState>({}); const allEntries: RoomEntryWithService[] = snapshots.flatMap((snapshot) => @@ -2601,10 +2605,6 @@ function RoomBoardV2({ })), ); - if (allEntries.length === 0) { - return {t.rooms.empty}; - } - const counts = { all: allEntries.length, processing: allEntries.filter((e) => e.status === 'processing').length, @@ -2632,6 +2632,15 @@ function RoomBoardV2({ const selectedEntry = sorted.find((e) => e.jid === selectedJid) ?? sorted[0] ?? null; + useEffect(() => { + const nextJid = selectedEntry?.jid ?? null; + if (nextJid !== selectedJid) onSelectedJidChange(nextJid); + }, [onSelectedJidChange, selectedEntry?.jid, selectedJid]); + + if (allEntries.length === 0) { + return {t.rooms.empty}; + } + function setDraft(jid: string, value: string) { setDrafts((previous) => ({ ...previous, [jid]: value })); } @@ -2705,7 +2714,7 @@ function RoomBoardV2({ aria-current={active ? 'page' : undefined} className={`rooms-list-item status-${entry.status}${active ? ' is-active' : ''}`} key={`${entry.serviceId}:${entry.jid}`} - onClick={() => setSelectedJid(entry.jid)} + onClick={() => onSelectedJidChange(entry.jid)} type="button" > @@ -3788,8 +3797,15 @@ function App() { const [serviceActionKey, setServiceActionKey] = useState(null); const [roomMessageKey, setRoomMessageKey] = useState(null); - const [roomActivity, setRoomActivity] = useState({}); - const [roomActivityLoading, setRoomActivityLoading] = useState(false); + const [selectedRoomJid, setSelectedRoomJid] = useState(null); + const { + refreshRoom: refreshRoomActivity, + roomActivity, + roomActivityLoading, + } = useSelectedRoomActivity({ + active: activeView === 'rooms', + selectedRoomJid: selectedRoomJid, + }); const [pendingMessages, setPendingMessages] = useState< Record> >({}); @@ -3956,8 +3972,7 @@ function App() { try { await sendRoomMessage(roomJid, text, requestId, nickname || null); try { - const fresh = await fetchRoomsTimelineBatch(); - setRoomActivity(fresh); + await refreshRoomActivity(roomJid); } catch { /* refresh will retry on next poll */ } @@ -4125,102 +4140,6 @@ function App() { return () => window.clearInterval(id); }, []); - const roomsJidsKey = useMemo(() => { - if (!data) return ''; - return [ - ...new Set( - data.snapshots.flatMap((snapshot) => - snapshot.entries.map((entry) => entry.jid), - ), - ), - ] - .sort() - .join('|'); - }, [data]); - - const roomsLastUpdatedKey = useMemo(() => { - if (!data) return ''; - return data.snapshots - .map((s) => s.updatedAt) - .sort() - .join('|'); - }, [data]); - - useEffect(() => { - if (activeView !== 'rooms' || !data) return; - if (!roomsJidsKey) { - setRoomActivity({}); - return; - } - - let cancelled = false; - let inFlight = false; - let pollIntervalId: number | null = null; - let es: EventSource | null = null; - - const applyMap = (map: RoomActivityMap) => { - if (!cancelled) setRoomActivity(map); - }; - - const fetchOnce = async (initial: boolean) => { - if (cancelled || inFlight) return; - inFlight = true; - if (initial) setRoomActivityLoading(true); - try { - applyMap(await fetchRoomsTimelineBatch()); - } catch { - /* keep last good state */ - } finally { - inFlight = false; - if (!cancelled && initial) setRoomActivityLoading(false); - } - }; - - const startPollingFallback = () => { - if (pollIntervalId !== null) return; - pollIntervalId = window.setInterval(() => void fetchOnce(false), 2000); - }; - - const stopPollingFallback = () => { - if (pollIntervalId === null) return; - window.clearInterval(pollIntervalId); - pollIntervalId = null; - }; - - if (typeof window !== 'undefined' && 'EventSource' in window) { - void fetchOnce(true); - try { - es = new EventSource('/api/stream'); - es.addEventListener('rooms-timeline', (event) => { - try { - const parsed = JSON.parse( - (event as MessageEvent).data, - ) as RoomActivityMap; - applyMap(parsed); - stopPollingFallback(); - } catch { - /* malformed event, ignore */ - } - }); - es.onerror = () => { - // Browser auto-reconnects; meanwhile keep data fresh via polling. - startPollingFallback(); - }; - } catch { - startPollingFallback(); - } - } else { - void fetchOnce(true); - startPollingFallback(); - } - - return () => { - cancelled = true; - stopPollingFallback(); - es?.close(); - }; - }, [activeView, roomsJidsKey]); - useEffect(() => { setPendingMessages((prev) => { const next: typeof prev = {}; @@ -4385,6 +4304,8 @@ function App() { roomActivityLoading={roomActivityLoading} roomMessageKey={roomMessageKey} locale={locale} + onSelectedJidChange={setSelectedRoomJid} + selectedJid={selectedRoomJid} snapshots={data.snapshots} t={t} /> diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts index 97fe4de..5279c90 100644 --- a/apps/dashboard/src/i18n.ts +++ b/apps/dashboard/src/i18n.ts @@ -1,3 +1,5 @@ +import { roomMessages, type RoomMessages } from './i18n/rooms'; + export const LOCALES = ['ko', 'en', 'zh', 'ja'] as const; export type Locale = (typeof LOCALES)[number]; @@ -152,46 +154,7 @@ export interface Messages { confirmDecline: string; }; }; - rooms: { - empty: string; - cardsAria: string; - room: string; - service: string; - agent: string; - status: string; - queue: string; - queueWaitingMessages: string; - tasks: 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; - latestOutput: string; - outputHistory: string; - noOutput: string; - recentMessages: string; - messageHistory: string; - noMessages: string; - details: string; - message: string; - messagePlaceholder: string; - send: string; - sending: string; - error: string; - filterAll: string; - sortRecent: string; - sortName: string; - sortQueue: string; - sortLabel: string; - }; + rooms: RoomMessages; usage: { empty: string; current: string; @@ -465,46 +428,7 @@ export const messages = { confirmDecline: 'Decline this item?', }, }, - rooms: { - empty: '룸 없음.', - cardsAria: '룸 상태 카드', - room: '룸', - service: '서비스', - agent: '에이전트', - status: '상태', - queue: '큐', - queueWaitingMessages: '대기 메시지', - tasks: '태스크', - elapsed: '경과', - activity: '진행', - loadingActivity: '진행 로딩 중', - noActivity: '진행 없음', - task: '태스크', - noTask: '태스크 없음', - currentTurn: '현재 턴', - noTurn: '턴 없음', - round: '라운드', - attempt: '시도', - updated: '갱신', - output: '출력', - latestOutput: '마지막 출력', - outputHistory: '출력 기록', - noOutput: '출력 없음', - recentMessages: '메시지 기록', - messageHistory: '메시지 기록', - noMessages: '메시지 없음', - details: '세부', - message: '메시지', - messagePlaceholder: '요청 입력...', - send: '전송', - sending: '전송 중...', - error: '오류', - filterAll: '전체', - sortRecent: '최근 활동', - sortName: '이름순', - sortQueue: '큐 많은 순', - sortLabel: '정렬', - }, + rooms: roomMessages.ko, usage: { empty: '사용량 스냅샷 없음. 수집기 확인.', current: '사용중', @@ -762,46 +686,7 @@ export const messages = { confirmDecline: 'Decline this item?', }, }, - rooms: { - empty: 'No rooms yet.', - cardsAria: 'Room status cards', - room: 'room', - service: 'service', - agent: 'agent', - status: 'status', - queue: 'queue', - queueWaitingMessages: 'pending messages', - tasks: 'tasks', - 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', - latestOutput: 'latest output', - outputHistory: 'output log', - noOutput: 'No output', - recentMessages: 'Message log', - messageHistory: 'Message log', - noMessages: 'No messages', - details: 'details', - message: 'message', - messagePlaceholder: 'Type request...', - send: 'Send', - sending: 'Sending', - error: 'Error', - filterAll: 'All', - sortRecent: 'Recent activity', - sortName: 'Name', - sortQueue: 'Queue size', - sortLabel: 'Sort', - }, + rooms: roomMessages.en, usage: { empty: 'No usage snapshot. Check collector.', current: 'Active', @@ -1059,46 +944,7 @@ export const messages = { confirmDecline: '确认拒绝此项?', }, }, - rooms: { - empty: '暂无房间。', - cardsAria: '房间状态卡片', - room: '房间', - service: '服务', - agent: '代理', - status: '状态', - queue: '队列', - queueWaitingMessages: '待处理消息', - tasks: '任务', - elapsed: '耗时', - activity: '进展', - loadingActivity: '正在加载进展', - noActivity: '暂无进展', - task: '任务', - noTask: '无任务', - currentTurn: '当前回合', - noTurn: '无回合', - round: '轮次', - attempt: '尝试', - updated: '更新', - output: '输出', - latestOutput: '最新输出', - outputHistory: '输出记录', - noOutput: '暂无输出', - recentMessages: '消息记录', - messageHistory: '消息记录', - noMessages: '暂无消息', - details: '详情', - message: 'Message', - messagePlaceholder: '输入请求...', - send: '发送', - sending: '发送中...', - error: '错误', - filterAll: '全部', - sortRecent: '最近活动', - sortName: '名称', - sortQueue: '队列大小', - sortLabel: '排序', - }, + rooms: roomMessages.zh, usage: { empty: '暂无用量快照。检查采集器。', current: '使用中', @@ -1358,46 +1204,7 @@ export const messages = { confirmDecline: 'この項目を拒否しますか?', }, }, - rooms: { - empty: 'ルームなし。', - cardsAria: 'ルーム状態カード', - room: 'ルーム', - service: 'サービス', - agent: 'エージェント', - status: '状態', - queue: 'キュー', - queueWaitingMessages: '待機メッセージ', - tasks: 'タスク', - elapsed: '経過', - activity: '進行', - loadingActivity: '進行を読み込み中', - noActivity: '進行なし', - task: 'タスク', - noTask: 'タスクなし', - currentTurn: '現在ターン', - noTurn: 'ターンなし', - round: 'ラウンド', - attempt: '試行', - updated: '更新', - output: '出力', - latestOutput: '最新出力', - outputHistory: '出力履歴', - noOutput: '出力なし', - recentMessages: 'メッセージ履歴', - messageHistory: 'メッセージ履歴', - noMessages: 'メッセージなし', - details: '詳細', - message: 'メッセージ', - messagePlaceholder: '依頼を入力...', - send: '送信', - sending: '送信中...', - error: 'エラー', - filterAll: '全て', - sortRecent: '最近の活動', - sortName: '名前順', - sortQueue: 'キュー順', - sortLabel: '並び替え', - }, + rooms: roomMessages.ja, usage: { empty: '使用量スナップショットなし。収集器を確認。', current: '使用中', diff --git a/apps/dashboard/src/i18n/rooms.ts b/apps/dashboard/src/i18n/rooms.ts new file mode 100644 index 0000000..2ff27ed --- /dev/null +++ b/apps/dashboard/src/i18n/rooms.ts @@ -0,0 +1,205 @@ +import type { Locale } from '../i18n'; + +export interface RoomMessages { + empty: string; + cardsAria: string; + room: string; + service: string; + agent: string; + status: string; + queue: string; + queueWaitingMessages: string; + tasks: 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; + latestOutput: string; + outputHistory: string; + noOutput: string; + recentMessages: string; + messageHistory: string; + noMessages: string; + details: string; + message: string; + messagePlaceholder: string; + send: string; + sending: string; + error: string; + filterAll: string; + sortRecent: string; + sortName: string; + sortQueue: string; + sortLabel: string; +} + +export const roomMessages = { + ko: { + empty: '룸 없음.', + cardsAria: '룸 상태 카드', + room: '룸', + service: '서비스', + agent: '에이전트', + status: '상태', + queue: '큐', + queueWaitingMessages: '대기 메시지', + tasks: '태스크', + elapsed: '경과', + activity: '진행', + loadingActivity: '진행 로딩 중', + noActivity: '진행 없음', + task: '태스크', + noTask: '태스크 없음', + currentTurn: '현재 턴', + noTurn: '턴 없음', + round: '라운드', + attempt: '시도', + updated: '갱신', + output: '출력', + latestOutput: '마지막 출력', + outputHistory: '출력 기록', + noOutput: '출력 없음', + recentMessages: '메시지 기록', + messageHistory: '메시지 기록', + noMessages: '메시지 없음', + details: '세부', + message: '메시지', + messagePlaceholder: '요청 입력...', + send: '전송', + sending: '전송 중...', + error: '오류', + filterAll: '전체', + sortRecent: '최근 활동', + sortName: '이름순', + sortQueue: '큐 많은 순', + sortLabel: '정렬', + }, + en: { + empty: 'No rooms yet.', + cardsAria: 'Room status cards', + room: 'room', + service: 'service', + agent: 'agent', + status: 'status', + queue: 'queue', + queueWaitingMessages: 'pending messages', + tasks: 'tasks', + 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', + latestOutput: 'latest output', + outputHistory: 'output log', + noOutput: 'No output', + recentMessages: 'Message log', + messageHistory: 'Message log', + noMessages: 'No messages', + details: 'details', + message: 'message', + messagePlaceholder: 'Type request...', + send: 'Send', + sending: 'Sending', + error: 'Error', + filterAll: 'All', + sortRecent: 'Recent activity', + sortName: 'Name', + sortQueue: 'Queue size', + sortLabel: 'Sort', + }, + zh: { + empty: '暂无房间。', + cardsAria: '房间状态卡片', + room: '房间', + service: '服务', + agent: '代理', + status: '状态', + queue: '队列', + queueWaitingMessages: '待处理消息', + tasks: '任务', + elapsed: '耗时', + activity: '进展', + loadingActivity: '正在加载进展', + noActivity: '暂无进展', + task: '任务', + noTask: '无任务', + currentTurn: '当前回合', + noTurn: '无回合', + round: '轮次', + attempt: '尝试', + updated: '更新', + output: '输出', + latestOutput: '最新输出', + outputHistory: '输出记录', + noOutput: '暂无输出', + recentMessages: '消息记录', + messageHistory: '消息记录', + noMessages: '暂无消息', + details: '详情', + message: 'Message', + messagePlaceholder: '输入请求...', + send: '发送', + sending: '发送中...', + error: '错误', + filterAll: '全部', + sortRecent: '最近活动', + sortName: '名称', + sortQueue: '队列大小', + sortLabel: '排序', + }, + ja: { + empty: 'ルームなし。', + cardsAria: 'ルーム状態カード', + room: 'ルーム', + service: 'サービス', + agent: 'エージェント', + status: '状態', + queue: 'キュー', + queueWaitingMessages: '待機メッセージ', + tasks: 'タスク', + elapsed: '経過', + activity: '進行', + loadingActivity: '進行を読み込み中', + noActivity: '進行なし', + task: 'タスク', + noTask: 'タスクなし', + currentTurn: '現在ターン', + noTurn: 'ターンなし', + round: 'ラウンド', + attempt: '試行', + updated: '更新', + output: '出力', + latestOutput: '最新出力', + outputHistory: '出力履歴', + noOutput: '出力なし', + recentMessages: 'メッセージ履歴', + messageHistory: 'メッセージ履歴', + noMessages: 'メッセージなし', + details: '詳細', + message: 'メッセージ', + messagePlaceholder: '依頼を入力...', + send: '送信', + sending: '送信中...', + error: 'エラー', + filterAll: '全て', + sortRecent: '最近の活動', + sortName: '名前順', + sortQueue: 'キュー順', + sortLabel: '並び替え', + }, +} satisfies Record; diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 0c41bc2..9f726b3 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -3004,35 +3004,66 @@ progress::-moz-progress-bar { .usage-row { grid-template-columns: - minmax(86px, 0.86fr) minmax(62px, 0.54fr) minmax(62px, 0.54fr) - minmax(58px, 0.4fr); - gap: 5px; - padding: 9px; + minmax(72px, 0.82fr) minmax(52px, 0.48fr) minmax(52px, 0.48fr) + minmax(48px, 0.36fr); + gap: 4px; + padding: 7px 6px; } .usage-account { display: grid; - gap: 5px; + gap: 3px; + } + + .usage-account div { + gap: 3px; + } + + .usage-account strong { + font-size: 13px; + } + + .usage-account .pill, + .usage-account .mono-chip { + padding: 3px 6px; + font-size: 10px; } .usage-quota, .usage-speed { - gap: 4px; + gap: 3px; } .usage-quota { - padding: 5px; + padding: 4px; } .usage-quota div { - gap: 6px; + gap: 4px; + } + + .usage-quota span, + .usage-speed span { + font-size: 9px; + letter-spacing: 0.04em; + } + + .usage-quota strong, + .usage-speed strong { + font-size: 14px; + } + + .usage-speed { + min-height: 0; + padding: 4px; } .usage-quota small { - overflow: hidden; - font-size: 10px; - text-overflow: ellipsis; - white-space: nowrap; + display: none; + } + + .usage-speed small { + display: none; } .record-card-grid { @@ -3168,7 +3199,7 @@ progress::-moz-progress-bar { } .usage-summary { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr) auto; } .usage-summary div:last-child { @@ -3176,19 +3207,19 @@ progress::-moz-progress-bar { } .usage-row { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: + minmax(68px, 0.8fr) minmax(48px, 0.48fr) minmax(48px, 0.48fr) + minmax(44px, 0.34fr); } .usage-account { - grid-column: 1 / -1; + grid-column: auto; } .usage-speed { - grid-column: 1 / -1; - display: flex; - min-height: auto; - align-items: center; - justify-content: space-between; + grid-column: auto; + display: grid; + min-height: 0; } .skeleton-grid, @@ -4582,6 +4613,35 @@ progress::-moz-progress-bar { color: var(--muted); } +@media (max-width: 640px) { + .usage-matrix { + overflow-x: hidden; + } + + .usage-row { + grid-template-columns: minmax(0, 1fr) 62px 62px !important; + align-items: center; + } + + .usage-speed { + display: none; + } + + .usage-account strong { + font-size: 13px; + } + + .usage-account .pill, + .usage-account .mono-chip { + padding: 3px 6px; + font-size: 10px; + } + + .usage-quota { + min-width: 0; + } +} + @media (max-width: 760px) { .rooms-grid { grid-template-columns: 1fr; diff --git a/apps/dashboard/src/useRoomActivity.ts b/apps/dashboard/src/useRoomActivity.ts new file mode 100644 index 0000000..800df46 --- /dev/null +++ b/apps/dashboard/src/useRoomActivity.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { fetchRoomTimeline, type DashboardRoomActivity } from './api'; + +export type RoomActivityMap = Record; + +interface UseSelectedRoomActivityOptions { + active: boolean; + pollMs?: number; + selectedRoomJid: string | null; +} + +export function useSelectedRoomActivity({ + active, + pollMs = 2000, + selectedRoomJid, +}: UseSelectedRoomActivityOptions): { + refreshRoom: (roomJid: string) => Promise; + roomActivity: RoomActivityMap; + roomActivityLoading: boolean; +} { + const [roomActivity, setRoomActivity] = useState({}); + const [roomActivityLoading, setRoomActivityLoading] = useState(false); + + const refreshRoom = useCallback(async (roomJid: string) => { + const activity = await fetchRoomTimeline(roomJid); + setRoomActivity((previous) => ({ + ...previous, + [roomJid]: activity, + })); + return activity; + }, []); + + useEffect(() => { + if (!active || !selectedRoomJid) { + setRoomActivityLoading(false); + return undefined; + } + + let cancelled = false; + let inFlight = false; + let pollIntervalId: number | null = null; + + const fetchOnce = async (initial: boolean) => { + if (cancelled || inFlight) return; + inFlight = true; + if (initial) setRoomActivityLoading(true); + try { + await refreshRoom(selectedRoomJid); + } catch { + /* Keep the last good timeline snapshot. */ + } finally { + inFlight = false; + if (!cancelled && initial) setRoomActivityLoading(false); + } + }; + + void fetchOnce(true); + pollIntervalId = window.setInterval(() => void fetchOnce(false), pollMs); + + return () => { + cancelled = true; + if (pollIntervalId !== null) window.clearInterval(pollIntervalId); + }; + }, [active, pollMs, refreshRoom, selectedRoomJid]); + + return { refreshRoom, roomActivity, roomActivityLoading }; +}