feat: add Ray-Ban display dashboard PoC (#226)
This commit is contained in:
207
apps/dashboard/src/GlassesApp.tsx
Normal file
207
apps/dashboard/src/GlassesApp.tsx
Normal file
@@ -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<GlassesState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [online, setOnline] = useState(() =>
|
||||
typeof navigator === 'undefined' ? true : navigator.onLine,
|
||||
);
|
||||
const [locale] = useState<Locale>(readInitialLocale);
|
||||
const [inboxActionKey, setInboxActionKey] = useState<InboxActionKey | null>(
|
||||
null,
|
||||
);
|
||||
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(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 (
|
||||
<main className="glasses-shell" aria-busy="true">
|
||||
<div className="glasses-empty">
|
||||
<strong>EJClaw</strong>
|
||||
<span>Loading</span>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<main className="glasses-shell">
|
||||
<p className="glasses-error">{error ?? 'Dashboard unavailable'}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassesPanel
|
||||
createRequestId={makeClientRequestId}
|
||||
error={error}
|
||||
freshnessText={freshnessLabel(online, data.overview.generatedAt)}
|
||||
inboxActionKey={inboxActionKey}
|
||||
locale={locale}
|
||||
onInboxAction={handleInboxAction}
|
||||
onRefresh={() => void refresh(true)}
|
||||
onSendRoomMessage={handleRoomMessage}
|
||||
overview={data.overview}
|
||||
refreshing={refreshing}
|
||||
roomMessageKey={roomMessageKey}
|
||||
snapshots={data.snapshots}
|
||||
/>
|
||||
);
|
||||
}
|
||||
120
apps/dashboard/src/GlassesPanel.test.ts
Normal file
120
apps/dashboard/src/GlassesPanel.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
607
apps/dashboard/src/GlassesPanel.tsx
Normal file
607
apps/dashboard/src/GlassesPanel.tsx
Normal file
@@ -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<SpeechRecognitionResultLike>;
|
||||
}
|
||||
|
||||
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<boolean>;
|
||||
onRefresh: () => void;
|
||||
onSendRoomMessage: (
|
||||
roomJid: string,
|
||||
text: string,
|
||||
requestId: string,
|
||||
) => Promise<boolean>;
|
||||
overview: DashboardOverview;
|
||||
refreshing: boolean;
|
||||
roomMessageKey: string | null;
|
||||
snapshots: StatusSnapshot[];
|
||||
}
|
||||
|
||||
const QUEUE_KIND_ORDER: Record<InboxItem['kind'], number> = {
|
||||
approval: 0,
|
||||
'reviewer-request': 1,
|
||||
'arbiter-request': 2,
|
||||
'ci-failure': 3,
|
||||
mention: 4,
|
||||
'pending-room': 5,
|
||||
};
|
||||
|
||||
const SEVERITY_ORDER: Record<InboxItem['severity'], number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
};
|
||||
|
||||
const SPEECH_LOCALES: Record<Locale, string> = {
|
||||
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 <X size={16} aria-hidden />;
|
||||
if (action === 'dismiss') return <Check size={16} aria-hidden />;
|
||||
return <Send size={16} aria-hidden />;
|
||||
}
|
||||
|
||||
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<string, RoomChoice>();
|
||||
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<boolean>;
|
||||
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 (
|
||||
<section className="glasses-card glasses-queue-card" aria-live="polite">
|
||||
<div className="glasses-empty">
|
||||
<strong>Queue clear</strong>
|
||||
<span>새 요청 없음</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="glasses-card glasses-queue-card" aria-live="polite">
|
||||
<div className="glasses-card-head">
|
||||
<span className={`glasses-pill sev-${selectedInbox.severity}`}>
|
||||
{selectedInbox.kind}
|
||||
</span>
|
||||
<small>
|
||||
{selectedInboxIndex + 1}/{inboxItems.length}
|
||||
</small>
|
||||
</div>
|
||||
<h2>{selectedInbox.title}</h2>
|
||||
<p>{selectedInbox.summary}</p>
|
||||
<dl className="glasses-meta">
|
||||
<div>
|
||||
<dt>Target</dt>
|
||||
<dd>
|
||||
{selectedInbox.roomName ??
|
||||
selectedInbox.groupFolder ??
|
||||
selectedInbox.roomJid ??
|
||||
selectedInbox.taskId ??
|
||||
'-'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Time</dt>
|
||||
<dd>{formatDate(selectedInbox.lastOccurredAt, locale)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="glasses-actions">
|
||||
{inboxActions.map((action, index) => {
|
||||
const actionKey = `${selectedInbox.id}:${action}`;
|
||||
const busy = inboxActionKey === actionKey;
|
||||
return (
|
||||
<button
|
||||
aria-busy={busy || undefined}
|
||||
aria-pressed={index === selectedActionIndex}
|
||||
className={
|
||||
index === selectedActionIndex ? 'is-active' : undefined
|
||||
}
|
||||
disabled={busy || Boolean(inboxActionKey)}
|
||||
key={action}
|
||||
onClick={() => {
|
||||
setSelectedActionIndex(index);
|
||||
void onInboxAction(selectedInbox, action);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{actionIcon(action)}
|
||||
{busy ? '처리 중' : actionLabel(selectedInbox, action)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="glasses-card glasses-voice-card">
|
||||
<div className="glasses-card-head">
|
||||
<span className="glasses-pill">voice</span>
|
||||
<small>{selectedRoom?.label ?? 'No room'}</small>
|
||||
</div>
|
||||
<textarea
|
||||
aria-label="음성 또는 키보드 입력"
|
||||
onChange={(event) => setVoiceText(event.target.value)}
|
||||
placeholder="말하거나 입력..."
|
||||
value={voiceText}
|
||||
/>
|
||||
<div className="glasses-room-strip" aria-label="room target">
|
||||
{rooms.slice(0, 4).map((room, index) => (
|
||||
<button
|
||||
aria-pressed={room.jid === selectedRoom?.jid}
|
||||
className={room.jid === selectedRoom?.jid ? 'is-active' : undefined}
|
||||
key={room.jid}
|
||||
onClick={() => setSelectedRoomIndex(index)}
|
||||
type="button"
|
||||
>
|
||||
{room.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="glasses-actions">
|
||||
<button
|
||||
disabled={!canListen || listening}
|
||||
onClick={onStartListening}
|
||||
type="button"
|
||||
>
|
||||
<Mic size={16} aria-hidden />
|
||||
{listening ? '듣는 중' : '말하기'}
|
||||
</button>
|
||||
<button
|
||||
className="is-active"
|
||||
disabled={!selectedRoom || !voiceText.trim() || sendingVoice}
|
||||
onClick={onSendVoiceText}
|
||||
type="button"
|
||||
>
|
||||
<Send size={16} aria-hidden />
|
||||
{sendingVoice ? '전송 중' : '전송'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface GlassesHeaderProps {
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
}
|
||||
|
||||
function GlassesHeader({ onRefresh, refreshing }: GlassesHeaderProps) {
|
||||
return (
|
||||
<header className="glasses-header">
|
||||
<div>
|
||||
<span className="glasses-kicker">EJClaw</span>
|
||||
<h1>Display</h1>
|
||||
</div>
|
||||
<button
|
||||
aria-busy={refreshing || undefined}
|
||||
aria-label="새로고침"
|
||||
className="glasses-icon-button"
|
||||
disabled={refreshing}
|
||||
onClick={onRefresh}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModeTabsProps {
|
||||
mode: GlassesMode;
|
||||
setMode: (mode: GlassesMode) => void;
|
||||
}
|
||||
|
||||
function ModeTabs({ mode, setMode }: ModeTabsProps) {
|
||||
return (
|
||||
<nav className="glasses-tabs" aria-label="display modes">
|
||||
<button
|
||||
aria-pressed={mode === 'queue'}
|
||||
className={mode === 'queue' ? 'is-active' : undefined}
|
||||
onClick={() => setMode('queue')}
|
||||
type="button"
|
||||
>
|
||||
<Inbox size={16} aria-hidden />
|
||||
Queue
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={mode === 'voice'}
|
||||
className={mode === 'voice' ? 'is-active' : undefined}
|
||||
onClick={() => setMode('voice')}
|
||||
type="button"
|
||||
>
|
||||
<Mic size={16} aria-hidden />
|
||||
Voice
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function footerStatus(
|
||||
mode: GlassesMode,
|
||||
busyActionKey: string | null,
|
||||
selectedRoom: RoomChoice | undefined,
|
||||
): string {
|
||||
if (mode === 'queue') return busyActionKey ?? 'ready';
|
||||
return selectedRoom?.status ?? 'ready';
|
||||
}
|
||||
|
||||
export function GlassesPanel({
|
||||
createRequestId,
|
||||
error,
|
||||
freshnessText,
|
||||
inboxActionKey,
|
||||
locale,
|
||||
onInboxAction,
|
||||
onRefresh,
|
||||
onSendRoomMessage,
|
||||
overview,
|
||||
refreshing,
|
||||
roomMessageKey,
|
||||
snapshots,
|
||||
}: GlassesPanelProps) {
|
||||
const shellRef = useRef<HTMLElement | null>(null);
|
||||
const [mode, setMode] = useState<GlassesMode>('queue');
|
||||
const [selectedInboxIndex, setSelectedInboxIndex] = useState(0);
|
||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||
const [selectedRoomIndex, setSelectedRoomIndex] = useState(0);
|
||||
const [voiceText, setVoiceText] = useState('');
|
||||
const [listening, setListening] = useState(false);
|
||||
const inboxItems = useMemo(
|
||||
() => sortInboxItems(Array.isArray(overview.inbox) ? overview.inbox : []),
|
||||
[overview.inbox],
|
||||
);
|
||||
const rooms = useMemo(
|
||||
() => buildRoomChoices(Array.isArray(snapshots) ? snapshots : []),
|
||||
[snapshots],
|
||||
);
|
||||
const selectedInbox =
|
||||
inboxItems[clampIndex(selectedInboxIndex, inboxItems.length)];
|
||||
const inboxActions = selectedInbox ? inboxActionsFor(selectedInbox) : [];
|
||||
const selectedAction =
|
||||
inboxActions[clampIndex(selectedActionIndex, inboxActions.length)];
|
||||
const selectedRoom = rooms[clampIndex(selectedRoomIndex, rooms.length)];
|
||||
const canListen = getSpeechRecognition() !== null;
|
||||
const busyActionKey =
|
||||
selectedInbox && selectedAction
|
||||
? `${selectedInbox.id}:${selectedAction}`
|
||||
: null;
|
||||
const sendingVoice = selectedRoom
|
||||
? roomMessageKey === selectedRoom.jid
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
shellRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedInboxIndex((value) => clampIndex(value, inboxItems.length));
|
||||
}, [inboxItems.length]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedActionIndex((value) => clampIndex(value, inboxActions.length));
|
||||
}, [inboxActions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRoomIndex((value) => clampIndex(value, rooms.length));
|
||||
}, [rooms.length]);
|
||||
|
||||
function startListening() {
|
||||
const SpeechRecognition = getSpeechRecognition();
|
||||
if (!SpeechRecognition || listening) return;
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = SPEECH_LOCALES[locale];
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = false;
|
||||
recognition.maxAlternatives = 1;
|
||||
recognition.onresult = (event) => {
|
||||
const nextText = readTranscript(event);
|
||||
if (nextText) setVoiceText(nextText);
|
||||
};
|
||||
recognition.onerror = () => setListening(false);
|
||||
recognition.onend = () => setListening(false);
|
||||
setListening(true);
|
||||
recognition.start();
|
||||
}
|
||||
|
||||
async function runSelectedAction() {
|
||||
if (!selectedInbox || !selectedAction || inboxActionKey) return;
|
||||
await onInboxAction(selectedInbox, selectedAction);
|
||||
}
|
||||
|
||||
async function sendVoiceText() {
|
||||
const text = voiceText.trim();
|
||||
if (!selectedRoom || !text || sendingVoice) return;
|
||||
const ok = await onSendRoomMessage(
|
||||
selectedRoom.jid,
|
||||
text,
|
||||
createRequestId(),
|
||||
);
|
||||
if (ok) setVoiceText('');
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
|
||||
if (
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
setMode((value) => (value === 'queue' ? 'voice' : 'queue'));
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1;
|
||||
if (mode === 'queue') {
|
||||
setSelectedInboxIndex((value) =>
|
||||
nextIndex(value, inboxItems.length, delta),
|
||||
);
|
||||
} else {
|
||||
setSelectedRoomIndex((value) => nextIndex(value, rooms.length, delta));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (mode === 'queue') void runSelectedAction();
|
||||
else void sendVoiceText();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
aria-label="EJClaw Ray-Ban Display"
|
||||
className="glasses-shell"
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={shellRef}
|
||||
tabIndex={0}
|
||||
>
|
||||
<GlassesHeader onRefresh={onRefresh} refreshing={refreshing} />
|
||||
|
||||
<section className="glasses-status-row" aria-label="상태">
|
||||
<span>{freshnessText}</span>
|
||||
<strong>{inboxItems.length} items</strong>
|
||||
</section>
|
||||
|
||||
{error ? <p className="glasses-error">{error}</p> : null}
|
||||
|
||||
<ModeTabs mode={mode} setMode={setMode} />
|
||||
|
||||
{mode === 'queue' ? (
|
||||
<QueueCard
|
||||
inboxActionKey={inboxActionKey}
|
||||
inboxActions={inboxActions}
|
||||
inboxItems={inboxItems}
|
||||
locale={locale}
|
||||
onInboxAction={onInboxAction}
|
||||
selectedActionIndex={selectedActionIndex}
|
||||
selectedInbox={selectedInbox}
|
||||
selectedInboxIndex={selectedInboxIndex}
|
||||
setSelectedActionIndex={setSelectedActionIndex}
|
||||
/>
|
||||
) : (
|
||||
<VoiceCard
|
||||
canListen={canListen}
|
||||
listening={listening}
|
||||
onSendVoiceText={() => void sendVoiceText()}
|
||||
onStartListening={startListening}
|
||||
rooms={rooms}
|
||||
selectedRoom={selectedRoom}
|
||||
sendingVoice={sendingVoice}
|
||||
setSelectedRoomIndex={setSelectedRoomIndex}
|
||||
setVoiceText={setVoiceText}
|
||||
voiceText={voiceText}
|
||||
/>
|
||||
)}
|
||||
|
||||
<footer className="glasses-footer">
|
||||
<span>{footerStatus(mode, busyActionKey, selectedRoom)}</span>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
293
apps/dashboard/src/glasses.css
Normal file
293
apps/dashboard/src/glasses.css
Normal file
@@ -0,0 +1,293 @@
|
||||
body:has(.glasses-shell) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.glasses-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
width: min(100vw, 600px);
|
||||
height: min(100vh, 600px);
|
||||
min-height: 0;
|
||||
margin: 0 auto;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
background: #050706;
|
||||
color: #f4f1e9;
|
||||
}
|
||||
|
||||
.glasses-header,
|
||||
.glasses-status-row,
|
||||
.glasses-card-head,
|
||||
.glasses-actions,
|
||||
.glasses-footer,
|
||||
.glasses-tabs,
|
||||
.glasses-room-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.glasses-header {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.glasses-kicker,
|
||||
.glasses-status-row,
|
||||
.glasses-card-head small,
|
||||
.glasses-meta dt,
|
||||
.glasses-footer {
|
||||
color: #a8afa6;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.glasses-header h1 {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.glasses-icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.glasses-status-row {
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.glasses-status-row strong {
|
||||
color: #6cb087;
|
||||
}
|
||||
|
||||
.glasses-error {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(224, 100, 75, 0.45);
|
||||
border-radius: 8px;
|
||||
background: rgba(224, 100, 75, 0.12);
|
||||
color: #ffb4a4;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.glasses-tabs {
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.glasses-tabs button,
|
||||
.glasses-actions button,
|
||||
.glasses-room-strip button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 42px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #f4f1e9;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.glasses-tabs button:hover:not(:disabled),
|
||||
.glasses-actions button:hover:not(:disabled),
|
||||
.glasses-room-strip button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glasses-tabs button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.glasses-tabs button.is-active,
|
||||
.glasses-tabs button[aria-pressed='true'],
|
||||
.glasses-actions button.is-active,
|
||||
.glasses-actions button[aria-pressed='true'],
|
||||
.glasses-room-strip button.is-active,
|
||||
.glasses-room-strip button[aria-pressed='true'] {
|
||||
border-color: rgba(232, 152, 112, 0.72);
|
||||
background: rgba(214, 130, 88, 0.2);
|
||||
color: #fff4ec;
|
||||
}
|
||||
|
||||
.glasses-tabs button.is-active:hover:not(:disabled),
|
||||
.glasses-tabs button[aria-pressed='true']:hover:not(:disabled),
|
||||
.glasses-actions button.is-active:hover:not(:disabled),
|
||||
.glasses-actions button[aria-pressed='true']:hover:not(:disabled),
|
||||
.glasses-room-strip button.is-active:hover:not(:disabled),
|
||||
.glasses-room-strip button[aria-pressed='true']:hover:not(:disabled) {
|
||||
background: rgba(214, 130, 88, 0.26);
|
||||
}
|
||||
|
||||
.glasses-card {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: #111512;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.glasses-card-head {
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.glasses-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #d7ded2;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.glasses-pill.sev-error {
|
||||
border-color: rgba(224, 100, 75, 0.5);
|
||||
color: #ffb4a4;
|
||||
}
|
||||
|
||||
.glasses-pill.sev-warn {
|
||||
border-color: rgba(212, 160, 74, 0.5);
|
||||
color: #ffd38b;
|
||||
}
|
||||
|
||||
.glasses-queue-card h2 {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: 22px;
|
||||
line-height: 1.08;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.glasses-queue-card p {
|
||||
display: -webkit-box;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #d2d5ce;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
|
||||
.glasses-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.glasses-meta div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.glasses-meta dt,
|
||||
.glasses-meta dd {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.glasses-meta dd {
|
||||
margin: 2px 0 0;
|
||||
color: #f4f1e9;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.glasses-actions {
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.glasses-actions button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.glasses-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 6px;
|
||||
min-height: 220px;
|
||||
color: #a8afa6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.glasses-empty strong {
|
||||
color: #f4f1e9;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.glasses-voice-card textarea {
|
||||
width: 100%;
|
||||
min-height: 126px;
|
||||
resize: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #f4f1e9;
|
||||
font: inherit;
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.glasses-voice-card textarea:focus-visible {
|
||||
border-color: #d68258;
|
||||
outline: 2px solid rgba(214, 130, 88, 0.2);
|
||||
}
|
||||
|
||||
.glasses-room-strip {
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.glasses-room-strip button {
|
||||
min-width: 0;
|
||||
max-width: 50%;
|
||||
overflow: hidden;
|
||||
padding: 0 10px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.glasses-footer {
|
||||
min-height: 18px;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.glasses-shell {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,13 @@ import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import App from './App';
|
||||
import { GlassesApp } from './GlassesApp';
|
||||
|
||||
function isGlassesRoute(): boolean {
|
||||
const pathname = window.location.pathname.replace(/\/+$/, '') || '/';
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
return pathname === '/glasses' || search.get('display') === 'rayban';
|
||||
}
|
||||
|
||||
const root = document.getElementById('root');
|
||||
|
||||
@@ -10,7 +17,5 @@ if (!root) {
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
<StrictMode>{isGlassesRoute() ? <GlassesApp /> : <App />}</StrictMode>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user