diff --git a/apps/dashboard/src/GlassesApp.tsx b/apps/dashboard/src/GlassesApp.tsx new file mode 100644 index 0000000..f6b83f6 --- /dev/null +++ b/apps/dashboard/src/GlassesApp.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState } from 'react'; + +import { + type DashboardInboxAction, + type DashboardOverview, + type StatusSnapshot, + fetchDashboardData, + runInboxAction, + sendRoomMessage, +} from './api'; +import { isLocale, matchLocale, type Locale } from './i18n'; +import { GlassesPanel } from './GlassesPanel'; +import './styles.css'; +import './glasses.css'; + +interface GlassesState { + overview: DashboardOverview; + snapshots: StatusSnapshot[]; +} + +type InboxItem = DashboardOverview['inbox'][number]; +type InboxActionKey = `${string}:${DashboardInboxAction}`; + +const REFRESH_INTERVAL_MS = 15_000; +const DASHBOARD_STALE_MS = 75_000; +const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2'; + +function makeClientRequestId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function readInitialLocale(): Locale { + const stored = + typeof window === 'undefined' + ? null + : window.localStorage.getItem(LOCALE_STORAGE_KEY); + if (isLocale(stored)) return stored; + + const languages = + typeof navigator === 'undefined' + ? [] + : [...(navigator.languages || []), navigator.language]; + for (const language of languages) { + const matched = matchLocale(language); + if (matched) return matched; + } + + return 'en'; +} + +function dashboardAgeMs(value: string | null | undefined): number | null { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return Math.max(0, Date.now() - date.getTime()); +} + +function freshnessLabel( + online: boolean, + generatedAt: string | null | undefined, +): string { + if (!online) return 'offline'; + const age = dashboardAgeMs(generatedAt); + if (age !== null && age > DASHBOARD_STALE_MS) return 'stale'; + return 'fresh'; +} + +function readNickname(): string { + if (typeof window === 'undefined') return ''; + return window.localStorage.getItem('ejclaw-nickname') ?? ''; +} + +export function GlassesApp() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [online, setOnline] = useState(() => + typeof navigator === 'undefined' ? true : navigator.onLine, + ); + const [locale] = useState(readInitialLocale); + const [inboxActionKey, setInboxActionKey] = useState( + null, + ); + const [roomMessageKey, setRoomMessageKey] = useState(null); + + async function refresh(showSpinner = false) { + if (showSpinner) setRefreshing(true); + try { + const nextData = await fetchDashboardData(); + setData({ + overview: nextData.overview, + snapshots: nextData.snapshots, + }); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + setRefreshing(false); + } + } + + async function handleInboxAction( + item: InboxItem, + action: DashboardInboxAction, + ) { + const actionKey: InboxActionKey = `${item.id}:${action}`; + setInboxActionKey(actionKey); + try { + await runInboxAction(item.id, action, { + lastOccurredAt: item.lastOccurredAt, + requestId: makeClientRequestId(), + }); + await refresh(false); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return false; + } finally { + setInboxActionKey(null); + } + } + + async function handleRoomMessage( + roomJid: string, + text: string, + requestId: string, + ) { + setRoomMessageKey(roomJid); + try { + await sendRoomMessage(roomJid, text, requestId, readNickname() || null); + void refresh(false); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return false; + } finally { + setRoomMessageKey(null); + } + } + + useEffect(() => { + document.documentElement.lang = locale; + }, [locale]); + + useEffect(() => { + function handleOnline() { + setOnline(true); + } + + function handleOffline() { + setOnline(false); + } + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + useEffect(() => { + void refresh(); + const id = window.setInterval(() => { + void refresh(); + }, REFRESH_INTERVAL_MS); + return () => window.clearInterval(id); + }, []); + + if (loading && !data) { + return ( +
+
+ EJClaw + Loading +
+
+ ); + } + + if (!data) { + return ( +
+

{error ?? 'Dashboard unavailable'}

+
+ ); + } + + return ( + void refresh(true)} + onSendRoomMessage={handleRoomMessage} + overview={data.overview} + refreshing={refreshing} + roomMessageKey={roomMessageKey} + snapshots={data.snapshots} + /> + ); +} diff --git a/apps/dashboard/src/GlassesPanel.test.ts b/apps/dashboard/src/GlassesPanel.test.ts new file mode 100644 index 0000000..9ebf0cd --- /dev/null +++ b/apps/dashboard/src/GlassesPanel.test.ts @@ -0,0 +1,120 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { GlassesPanel } from './GlassesPanel'; +import type { DashboardOverview, StatusSnapshot } from './api'; + +const overview: DashboardOverview = { + generatedAt: '2026-06-07T13:00:00.000Z', + services: [], + rooms: { + total: 1, + active: 1, + waiting: 0, + inactive: 0, + }, + tasks: { + total: 1, + active: 1, + paused: 0, + completed: 0, + watchers: { + active: 0, + paused: 0, + completed: 0, + }, + }, + usage: { + rows: [], + fetchedAt: null, + }, + inbox: [ + { + createdAt: '2026-06-07T12:59:30.000Z', + groupKey: 'task:review', + id: 'inbox-1', + kind: 'reviewer-request', + lastOccurredAt: '2026-06-07T12:59:30.000Z', + occurrences: 1, + occurredAt: '2026-06-07T12:59:30.000Z', + roomJid: 'dc:ops', + roomName: 'Ops', + severity: 'warn', + source: 'paired-task', + summary: 'Reviewer requested owner changes', + taskId: 'task-1', + taskStatus: 'active', + title: 'Review follow-up', + }, + ], +}; + +const snapshots: StatusSnapshot[] = [ + { + agentType: 'codex', + assistantName: 'Codex', + serviceId: 'codex-main', + updatedAt: '2026-06-07T13:00:00.000Z', + entries: [ + { + agentType: 'codex', + elapsedMs: 12000, + folder: 'ejclaw', + jid: 'dc:ops', + name: 'Ops', + pendingMessages: false, + pendingTasks: 1, + status: 'processing', + }, + ], + }, +]; + +describe('GlassesPanel', () => { + it('renders a compact display queue and voice input surface', () => { + const html = renderToStaticMarkup( + createElement(GlassesPanel, { + createRequestId: () => 'req-1', + error: null, + freshnessText: 'fresh', + inboxActionKey: null, + locale: 'ko', + onInboxAction: async () => true, + onRefresh: () => {}, + onSendRoomMessage: async () => true, + overview, + refreshing: false, + roomMessageKey: null, + snapshots, + }), + ); + + expect(html).toContain('glasses-shell'); + expect(html).toContain('Review follow-up'); + expect(html).toContain('리뷰'); + expect(html).toContain('Voice'); + }); + + it('tolerates partial status snapshot payloads while data is refreshing', () => { + const html = renderToStaticMarkup( + createElement(GlassesPanel, { + createRequestId: () => 'req-1', + error: null, + freshnessText: 'fresh', + inboxActionKey: null, + locale: 'ko', + onInboxAction: async () => true, + onRefresh: () => {}, + onSendRoomMessage: async () => true, + overview, + refreshing: false, + roomMessageKey: null, + snapshots: [{} as StatusSnapshot], + }), + ); + + expect(html).toContain('glasses-shell'); + expect(html).toContain('Review follow-up'); + }); +}); diff --git a/apps/dashboard/src/GlassesPanel.tsx b/apps/dashboard/src/GlassesPanel.tsx new file mode 100644 index 0000000..817a98a --- /dev/null +++ b/apps/dashboard/src/GlassesPanel.tsx @@ -0,0 +1,607 @@ +import { + type KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Check, Inbox, Mic, RefreshCw, Send, X } from 'lucide-react'; + +import { + type DashboardInboxAction, + type DashboardOverview, + type StatusSnapshot, +} from './api'; +import { formatDate } from './dashboardHelpers'; +import type { Locale } from './i18n'; + +type InboxItem = DashboardOverview['inbox'][number]; +type GlassesMode = 'queue' | 'voice'; + +interface RoomChoice { + jid: string; + label: string; + status: StatusSnapshot['entries'][number]['status']; + pendingTasks: number; +} + +interface SpeechRecognitionResultLike { + readonly 0?: { transcript?: string }; +} + +interface SpeechRecognitionEventLike { + readonly results: ArrayLike; +} + +interface SpeechRecognitionLike { + continuous: boolean; + interimResults: boolean; + lang: string; + maxAlternatives: number; + onend: (() => void) | null; + onerror: (() => void) | null; + onresult: ((event: SpeechRecognitionEventLike) => void) | null; + start: () => void; +} + +type SpeechRecognitionConstructor = new () => SpeechRecognitionLike; + +interface SpeechWindow extends Window { + SpeechRecognition?: SpeechRecognitionConstructor; + webkitSpeechRecognition?: SpeechRecognitionConstructor; +} + +export interface GlassesPanelProps { + createRequestId: () => string; + error: string | null; + freshnessText: string; + inboxActionKey: `${string}:${DashboardInboxAction}` | null; + locale: Locale; + onInboxAction: ( + item: InboxItem, + action: DashboardInboxAction, + ) => Promise; + onRefresh: () => void; + onSendRoomMessage: ( + roomJid: string, + text: string, + requestId: string, + ) => Promise; + overview: DashboardOverview; + refreshing: boolean; + roomMessageKey: string | null; + snapshots: StatusSnapshot[]; +} + +const QUEUE_KIND_ORDER: Record = { + approval: 0, + 'reviewer-request': 1, + 'arbiter-request': 2, + 'ci-failure': 3, + mention: 4, + 'pending-room': 5, +}; + +const SEVERITY_ORDER: Record = { + error: 0, + warn: 1, + info: 2, +}; + +const SPEECH_LOCALES: Record = { + en: 'en-US', + ja: 'ja-JP', + ko: 'ko-KR', + zh: 'zh-CN', +}; + +function getSpeechRecognition(): SpeechRecognitionConstructor | null { + if (typeof window === 'undefined') return null; + const speechWindow = window as SpeechWindow; + return ( + speechWindow.SpeechRecognition ?? + speechWindow.webkitSpeechRecognition ?? + null + ); +} + +function inboxActionsFor(item: InboxItem): DashboardInboxAction[] { + if ( + item.source === 'paired-task' && + (item.kind === 'reviewer-request' || + item.kind === 'approval' || + item.kind === 'arbiter-request') + ) { + return ['run', 'decline', 'dismiss']; + } + return ['dismiss']; +} + +function actionLabel(item: InboxItem, action: DashboardInboxAction): string { + if (action === 'dismiss') return '닫기'; + if (action === 'decline') return '거절'; + if (item.kind === 'approval') return '최종화'; + if (item.kind === 'reviewer-request') return '리뷰'; + if (item.kind === 'arbiter-request') return '중재'; + return '실행'; +} + +function actionIcon(action: DashboardInboxAction) { + if (action === 'decline') return ; + if (action === 'dismiss') return ; + return ; +} + +function sortInboxItems(items: InboxItem[]): InboxItem[] { + return [...items].sort((a, b) => { + const severity = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]; + if (severity !== 0) return severity; + const kind = QUEUE_KIND_ORDER[a.kind] - QUEUE_KIND_ORDER[b.kind]; + if (kind !== 0) return kind; + return b.lastOccurredAt.localeCompare(a.lastOccurredAt); + }); +} + +function buildRoomChoices(snapshots: StatusSnapshot[]): RoomChoice[] { + const rooms = new Map(); + for (const snapshot of snapshots) { + const entries = Array.isArray(snapshot.entries) ? snapshot.entries : []; + for (const entry of entries) { + const existing = rooms.get(entry.jid); + const label = entry.name || entry.folder || entry.jid; + if (!existing) { + rooms.set(entry.jid, { + jid: entry.jid, + label, + pendingTasks: entry.pendingTasks, + status: entry.status, + }); + continue; + } + if ( + entry.status === 'processing' || + entry.pendingTasks > existing.pendingTasks + ) { + rooms.set(entry.jid, { + jid: entry.jid, + label, + pendingTasks: entry.pendingTasks, + status: entry.status, + }); + } + } + } + return [...rooms.values()].sort((a, b) => { + if (a.status === 'processing' && b.status !== 'processing') return -1; + if (b.status === 'processing' && a.status !== 'processing') return 1; + return b.pendingTasks - a.pendingTasks || a.label.localeCompare(b.label); + }); +} + +function clampIndex(index: number, length: number): number { + if (length <= 0) return 0; + return Math.max(0, Math.min(index, length - 1)); +} + +function nextIndex(index: number, length: number, delta: number): number { + if (length <= 0) return 0; + return (index + delta + length) % length; +} + +function readTranscript(event: SpeechRecognitionEventLike): string { + return Array.from(event.results) + .map((result) => result[0]?.transcript?.trim() ?? '') + .filter(Boolean) + .join(' ') + .trim(); +} + +interface QueueCardProps { + inboxActionKey: `${string}:${DashboardInboxAction}` | null; + inboxActions: DashboardInboxAction[]; + inboxItems: InboxItem[]; + locale: Locale; + onInboxAction: ( + item: InboxItem, + action: DashboardInboxAction, + ) => Promise; + selectedActionIndex: number; + selectedInbox: InboxItem | undefined; + selectedInboxIndex: number; + setSelectedActionIndex: (index: number) => void; +} + +function QueueCard({ + inboxActionKey, + inboxActions, + inboxItems, + locale, + onInboxAction, + selectedActionIndex, + selectedInbox, + selectedInboxIndex, + setSelectedActionIndex, +}: QueueCardProps) { + if (!selectedInbox) { + return ( +
+
+ Queue clear + 새 요청 없음 +
+
+ ); + } + + return ( +
+
+ + {selectedInbox.kind} + + + {selectedInboxIndex + 1}/{inboxItems.length} + +
+

{selectedInbox.title}

+

{selectedInbox.summary}

+
+
+
Target
+
+ {selectedInbox.roomName ?? + selectedInbox.groupFolder ?? + selectedInbox.roomJid ?? + selectedInbox.taskId ?? + '-'} +
+
+
+
Time
+
{formatDate(selectedInbox.lastOccurredAt, locale)}
+
+
+
+ {inboxActions.map((action, index) => { + const actionKey = `${selectedInbox.id}:${action}`; + const busy = inboxActionKey === actionKey; + return ( + + ); + })} +
+
+ ); +} + +interface VoiceCardProps { + canListen: boolean; + listening: boolean; + onSendVoiceText: () => void; + onStartListening: () => void; + selectedRoom: RoomChoice | undefined; + sendingVoice: boolean; + setSelectedRoomIndex: (index: number) => void; + setVoiceText: (value: string) => void; + rooms: RoomChoice[]; + voiceText: string; +} + +function VoiceCard({ + canListen, + listening, + onSendVoiceText, + onStartListening, + selectedRoom, + sendingVoice, + setSelectedRoomIndex, + setVoiceText, + rooms, + voiceText, +}: VoiceCardProps) { + return ( +
+
+ voice + {selectedRoom?.label ?? 'No room'} +
+