diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 59e33d2..b956148 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import { type CreateScheduledTaskInput, - type DashboardInboxAction, type DashboardRoomActivity, type DashboardTaskAction, type DashboardOverview, @@ -11,7 +10,6 @@ import { type StatusSnapshot, createScheduledTask, fetchDashboardData, - runInboxAction, runServiceAction, runScheduledTaskAction, sendRoomMessage, @@ -33,7 +31,6 @@ import { type DashboardView, } from './DashboardNav'; import { formatDate, statusLabel } from './dashboardHelpers'; -import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel'; import { RoomBoardV2 } from './RoomBoardV2'; import { ServicePanel, type ServiceActionKey } from './ServicePanel'; import { SettingsPanel } from './SettingsPanel'; @@ -68,7 +65,6 @@ function isDashboardView( ): value is DashboardView { return ( value === 'usage' || - value === 'inbox' || value === 'health' || value === 'rooms' || value === 'scheduled' || @@ -82,6 +78,13 @@ function readViewFromHash(): DashboardView { return isDashboardView(raw) ? raw : DEFAULT_VIEW; } +function normalizeDashboardHash(view: DashboardView): void { + if (typeof window === 'undefined') return; + const raw = window.location.hash.replace(/^#\/?/, ''); + if (!raw || isDashboardView(raw)) return; + window.history.replaceState(null, '', `#/${view}`); +} + function readInitialLocale(): Locale { const stored = typeof window === 'undefined' @@ -385,9 +388,6 @@ function App() { const [taskActionKey, setTaskActionKey] = useState( null, ); - const [inboxActionKey, setInboxActionKey] = useState( - null, - ); const [serviceActionKey, setServiceActionKey] = useState(null); const [roomMessageKey, setRoomMessageKey] = useState(null); @@ -490,33 +490,6 @@ function App() { } } - async function handleInboxAction( - item: InboxItem, - action: DashboardInboxAction, - ) { - if ( - action === 'decline' && - typeof window !== 'undefined' && - !window.confirm(t.inbox.actions.confirmDecline) - ) { - return; - } - - const actionKey: InboxActionKey = `${item.id}:${action}`; - setInboxActionKey(actionKey); - try { - await runInboxAction(item.id, action, { - lastOccurredAt: item.lastOccurredAt, - requestId: makeClientRequestId(), - }); - await refresh(false); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setInboxActionKey(null); - } - } - async function handleServiceRestart() { if ( typeof window !== 'undefined' && @@ -715,7 +688,9 @@ function App() { useEffect(() => { function handleHashChange() { - setActiveView(readViewFromHash()); + const nextView = readViewFromHash(); + setActiveView(nextView); + normalizeDashboardHash(nextView); } handleHashChange(); @@ -840,29 +815,6 @@ function App() { ) : null} - {activeView === 'inbox' ? ( -
-
-

{t.panels.inbox}

- {t.panels.inboxQueue} -
- - void handleInboxAction(item, action) - } - onTaskAction={(task, action) => - void handleTaskAction(task, action) - } - overview={data.overview} - taskActionKey={taskActionKey} - tasks={data.tasks} - t={t} - /> -
- ) : null} - {activeView === 'health' ? (
diff --git a/apps/dashboard/src/DashboardNav.tsx b/apps/dashboard/src/DashboardNav.tsx index 217587a..2ab40b6 100644 --- a/apps/dashboard/src/DashboardNav.tsx +++ b/apps/dashboard/src/DashboardNav.tsx @@ -4,7 +4,6 @@ import { Clock, Download, Gauge, - Inbox as InboxIcon, MessageSquare, RefreshCw, Settings, @@ -15,7 +14,6 @@ import { type Messages } from './i18n'; export type DashboardView = | 'usage' - | 'inbox' | 'health' | 'rooms' | 'scheduled' @@ -29,7 +27,6 @@ interface DashboardNavData { const NAV_ICONS: Record = { usage: , - inbox: , health: , rooms: , scheduled: , @@ -39,7 +36,6 @@ const NAV_ICONS: Record = { function navItems(t: Messages) { return [ { href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const }, - { href: '#/inbox', label: t.nav.inbox, view: 'inbox' as const }, { href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const }, { href: '#/health', label: t.nav.health, view: 'health' as const }, { href: '#/usage', label: t.nav.usage, view: 'usage' as const }, @@ -59,7 +55,6 @@ function navStats(data: DashboardNavData | null) { { processing: 0 }, ); return { - inbox: data.overview.inbox.length, processing: queue.processing, }; } @@ -68,7 +63,6 @@ function navBadge( view: DashboardView, stats: ReturnType, ): number | null { - if (view === 'inbox' && stats && stats.inbox > 0) return stats.inbox; if (view === 'rooms' && stats && stats.processing > 0) { return stats.processing; } @@ -100,8 +94,8 @@ function DashboardBrandLink({ return ( onNavigate('usage')} + href="#/rooms" + onClick={() => onNavigate('rooms')} title="EJClaw" > EJ diff --git a/apps/dashboard/src/RoomBoardV2.test.ts b/apps/dashboard/src/RoomBoardV2.test.ts index c6d189b..bece090 100644 --- a/apps/dashboard/src/RoomBoardV2.test.ts +++ b/apps/dashboard/src/RoomBoardV2.test.ts @@ -82,6 +82,36 @@ describe('RoomBoardV2', () => { expect(html).toContain('prod 배포 완료'); }); + it('surfaces merge approval items as room action badges', () => { + const html = renderToStaticMarkup( + createElement(RoomBoardV2, { + ...baseProps, + inbox: [ + { + id: 'paired:merge-1:merge_ready', + groupKey: 'paired:merge-1:merge_ready', + kind: 'approval', + severity: 'warn', + title: 'Ready to merge', + summary: 'merge_ready', + occurredAt: '2026-04-28T04:00:00.000Z', + lastOccurredAt: '2026-04-28T04:00:00.000Z', + createdAt: '2026-04-28T04:00:00.000Z', + occurrences: 1, + source: 'paired-task', + roomJid: 'room-1', + taskId: 'merge-1', + taskStatus: 'merge_ready', + }, + ], + }), + ); + + expect(html).toContain(t.inbox.kinds.approval); + expect(html).toContain('room-inbox-pip sev-warn'); + expect(html).toContain('rooms-list-bell sev-warn'); + }); + it('renders an empty state without snapshots', () => { const html = renderToStaticMarkup( createElement(RoomBoardV2, { ...baseProps, snapshots: [] }), diff --git a/scripts/dashboard-ux.ts b/scripts/dashboard-ux.ts index 2ffd692..ef8ba40 100644 --- a/scripts/dashboard-ux.ts +++ b/scripts/dashboard-ux.ts @@ -6,6 +6,7 @@ import process from 'node:process'; import { createServer, type ViteDevServer } from 'vite'; interface MockApiState { + approvalAction: boolean; codexFeatures: { goals: boolean }; codexFeatureUpdates: number; ciWatcherFailures: number; @@ -13,6 +14,7 @@ interface MockApiState { } const seriousImpacts = new Set(['serious', 'critical']); +const MOCK_TIME = new Date(0).toISOString(); async function main() { const server = await startDashboardServer(); @@ -112,17 +114,21 @@ async function main() { ); await runScenario( - 'inbox stays focused on actionable work', + 'rooms surface user action badges without inbox navigation', browser, baseUrl, async (page, state) => { + state.approvalAction = true; state.ciWatcherFailures = 2; await page.goto(new URL('/#/inbox', baseUrl).toString(), { waitUntil: 'networkidle', }); - await assertVisible(page.locator('#inbox .empty-state')); + await page.waitForURL(/#\/rooms$/); + await assertVisible(page.locator('#rooms .rooms-v2')); + assert.equal(await page.locator('a[href="#/inbox"]').count(), 0); + await assertVisible(page.getByText(/승인|Approval/).first()); assert.equal(await page.getByText(/CI 실패|CI failure/).count(), 0); assert.equal( await page.getByRole('button', { name: 'Dismiss' }).count(), @@ -179,6 +185,7 @@ async function runScenario( function createMockApiState(): MockApiState { return { + approvalAction: false, codexFeatures: { goals: false }, codexFeatureUpdates: 0, ciWatcherFailures: 0, @@ -229,26 +236,15 @@ async function handleMockApi(route: Route, state: MockApiState) { const method = request.method(); if (method === 'GET' && url.pathname === '/api/overview') { - await fulfillJson(route, { - generatedAt: new Date(0).toISOString(), - services: [], - rooms: { total: 0, active: 0, waiting: 0, inactive: 0 }, - tasks: { - total: 0, - active: 0, - paused: state.ciWatcherFailures, - completed: 0, - watchers: { active: 0, paused: state.ciWatcherFailures, completed: 0 }, - }, - usage: { rows: [], fetchedAt: null }, - operations: { serviceRestarts: [] }, - inbox: [], - }); + await fulfillJson(route, mockOverview(state)); return; } if (method === 'GET' && url.pathname === '/api/status-snapshots') { - await fulfillJson(route, []); + await fulfillJson( + route, + state.approvalAction ? [mockStatusSnapshot()] : [], + ); return; } @@ -371,6 +367,78 @@ async function handleMockApi(route: Route, state: MockApiState) { ); } +function mockOverview(state: MockApiState) { + return { + generatedAt: MOCK_TIME, + services: [ + { + serviceId: 'codex-main', + assistantName: 'Codex', + agentType: 'codex', + updatedAt: MOCK_TIME, + totalRooms: state.approvalAction ? 1 : 0, + activeRooms: 0, + }, + ], + rooms: state.approvalAction + ? { total: 1, active: 0, waiting: 0, inactive: 1 } + : { total: 0, active: 0, waiting: 0, inactive: 0 }, + tasks: { + total: 0, + active: 0, + paused: state.ciWatcherFailures, + completed: 0, + watchers: { active: 0, paused: state.ciWatcherFailures, completed: 0 }, + }, + usage: { rows: [], fetchedAt: null }, + operations: { serviceRestarts: [] }, + inbox: state.approvalAction ? [mockApprovalInboxItem()] : [], + }; +} + +function mockApprovalInboxItem() { + return { + id: 'paired:merge-1:merge_ready', + groupKey: 'paired:merge-1:merge_ready', + kind: 'approval', + severity: 'warn', + title: 'Ready to merge', + summary: 'merge_ready', + occurredAt: MOCK_TIME, + lastOccurredAt: MOCK_TIME, + createdAt: MOCK_TIME, + occurrences: 1, + source: 'paired-task', + roomJid: 'dc:ops', + roomName: '#ops', + groupFolder: 'ops', + serviceId: 'codex-main', + taskId: 'merge-1', + taskStatus: 'merge_ready', + }; +} + +function mockStatusSnapshot() { + return { + serviceId: 'codex-main', + assistantName: 'Codex', + agentType: 'codex', + updatedAt: MOCK_TIME, + entries: [ + { + jid: 'dc:ops', + name: '#ops', + folder: 'ops', + agentType: 'codex', + status: 'inactive', + elapsedMs: null, + pendingMessages: false, + pendingTasks: 0, + }, + ], + }; +} + function parseJsonBody(body: string | null): Record { if (!body) return {}; const parsed = JSON.parse(body) as unknown; diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts index 51840f9..fcf4e04 100644 --- a/src/web-dashboard-data.test.ts +++ b/src/web-dashboard-data.test.ts @@ -761,7 +761,7 @@ describe('web dashboard room activity data', () => { }); describe('web dashboard inbox data', () => { - it('builds typed inbox items from pending rooms and paired tasks only', () => { + it('builds user-action inbox items from merge-ready paired tasks only', () => { const snapshots: StatusSnapshot[] = [ { serviceId: 'codex-main', @@ -803,6 +803,8 @@ describe('web dashboard inbox data', () => { review_requested_at: '2026-04-26T05:03:00.000Z', }), makePairedTask({ + chat_jid: 'dc:ops', + group_folder: 'ops', id: 'merge-1', status: 'merge_ready', title: 'Ready to merge', @@ -839,31 +841,27 @@ describe('web dashboard inbox data', () => { ], }); - expect(overview.inbox.map((item) => item.kind)).toEqual([ - 'approval', - 'reviewer-request', - 'pending-room', - ]); + expect(overview.inbox.map((item) => item.kind)).toEqual(['approval']); expect(overview.inbox).toContainEqual( expect.objectContaining({ - id: 'room:codex-main:dc:ops', - kind: 'pending-room', - severity: 'info', + id: 'paired:merge-1:merge_ready', + kind: 'approval', + severity: 'warn', roomJid: 'dc:ops', + taskId: 'merge-1', serviceId: 'codex-main', - occurredAt: '2026-04-26T05:00:00.000Z', + occurredAt: '2026-04-26T05:04:00.000Z', createdAt: '2026-04-26T05:10:00.000Z', }), ); - expect(overview.inbox).toContainEqual( - expect.objectContaining({ - id: 'paired:review-1:review_ready', - kind: 'reviewer-request', - severity: 'warn', - serviceId: 'claude-reviewer', - taskStatus: 'review_ready', - occurredAt: '2026-04-26T05:03:00.000Z', - }), + expect(overview.inbox.some((item) => item.kind === 'pending-room')).toBe( + false, + ); + expect( + overview.inbox.some((item) => item.kind === 'reviewer-request'), + ).toBe(false); + expect(overview.inbox.some((item) => item.kind === 'arbiter-request')).toBe( + false, ); expect(overview.inbox.some((item) => item.kind === 'ci-failure')).toBe( false, diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts index abf4274..11d76e7 100644 --- a/src/web-dashboard-data.ts +++ b/src/web-dashboard-data.ts @@ -274,51 +274,15 @@ function pairedTaskInboxKind( status: PairedTask['status'], ): InboxItemKind | null { if (status === 'merge_ready') return 'approval'; - if (status === 'review_ready' || status === 'in_review') { - return 'reviewer-request'; - } - if (status === 'arbiter_requested' || status === 'in_arbitration') { - return 'arbiter-request'; - } return null; } function collectInboxItems(args: { - snapshots: StatusSnapshot[]; - tasks: ScheduledTask[]; pairedTasks: PairedTask[]; createdAt: string; }): InboxItem[] { const items: InboxItem[] = []; - for (const snapshot of args.snapshots) { - for (const entry of snapshot.entries) { - if (!entry.pendingMessages && entry.pendingTasks === 0) continue; - - const parts: string[] = []; - if (entry.pendingMessages) parts.push('pending messages'); - if (entry.pendingTasks > 0) parts.push(`${entry.pendingTasks} tasks`); - - items.push({ - id: `room:${snapshot.serviceId}:${entry.jid}`, - groupKey: `room:${snapshot.serviceId}:${entry.jid}`, - kind: 'pending-room', - severity: entry.pendingTasks > 0 ? 'warn' : 'info', - title: entry.name || entry.folder || entry.jid, - summary: parts.join(' · '), - occurredAt: snapshot.updatedAt, - lastOccurredAt: snapshot.updatedAt, - createdAt: args.createdAt, - occurrences: 1, - source: 'status-snapshot', - roomJid: entry.jid, - roomName: entry.name, - groupFolder: entry.folder, - serviceId: snapshot.serviceId, - }); - } - } - for (const task of args.pairedTasks) { const kind = pairedTaskInboxKind(task.status); if (!kind) continue; @@ -327,30 +291,17 @@ function collectInboxItems(args: { id: `paired:${task.id}:${task.status}`, groupKey: `paired:${task.id}:${task.status}`, kind, - severity: kind === 'arbiter-request' ? 'error' : 'warn', + severity: 'warn', title: task.title || task.group_folder, summary: task.status, - occurredAt: - kind === 'arbiter-request' - ? (task.arbiter_requested_at ?? task.updated_at) - : kind === 'reviewer-request' - ? (task.review_requested_at ?? task.updated_at) - : task.updated_at, - lastOccurredAt: - kind === 'arbiter-request' - ? (task.arbiter_requested_at ?? task.updated_at) - : kind === 'reviewer-request' - ? (task.review_requested_at ?? task.updated_at) - : task.updated_at, + occurredAt: task.updated_at, + lastOccurredAt: task.updated_at, createdAt: args.createdAt, occurrences: 1, source: 'paired-task', roomJid: task.chat_jid, groupFolder: task.group_folder, - serviceId: - kind === 'reviewer-request' - ? task.reviewer_service_id - : task.owner_service_id, + serviceId: task.owner_service_id, taskId: task.id, taskStatus: task.status, }); @@ -793,8 +744,6 @@ export function buildWebDashboardOverview(args: { fetchedAt: usageFetchedAt, }, inbox: collectInboxItems({ - snapshots: args.snapshots, - tasks: args.tasks, pairedTasks: args.pairedTasks ?? [], createdAt: generatedAt, }), diff --git a/src/web-dashboard-overview-routes.test.ts b/src/web-dashboard-overview-routes.test.ts index f516fdb..b6c13de 100644 --- a/src/web-dashboard-overview-routes.test.ts +++ b/src/web-dashboard-overview-routes.test.ts @@ -142,7 +142,9 @@ describe('web dashboard overview route', () => { }, ]; const overview = route('/api/overview', { - loadPairedTasks: () => [makePairedTask({ id: 'review-1' })], + loadPairedTasks: () => [ + makePairedTask({ id: 'merge-1', status: 'merge_ready' }), + ], readSnapshots: () => snapshots, isInboxItemDismissed: () => true, }); diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts index 87902db..d14a511 100644 --- a/src/web-dashboard-server.test.ts +++ b/src/web-dashboard-server.test.ts @@ -4,7 +4,6 @@ import path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; -import type { StatusSnapshot } from './status-dashboard.js'; import type { NewMessage, PairedTask, RegisteredGroup } from './types.js'; import { createWebDashboardHandler } from './web-dashboard-server.js'; @@ -74,30 +73,17 @@ describe('web dashboard server handler', () => { }); it('shares inbox dismiss state with overview JSON', async () => { - const snapshots: StatusSnapshot[] = [ - { - serviceId: 'codex-main', - agentType: 'codex', - assistantName: 'Codex', - updatedAt: '2026-04-26T05:00:00.000Z', - entries: [ - { - jid: 'dc:ops', - name: '#ops', - folder: 'ops', - agentType: 'codex', - status: 'waiting', - elapsedMs: 2500, - pendingMessages: true, - pendingTasks: 1, - }, - ], - }, - ]; + const pairedTask = makePairedTask({ + chat_jid: 'dc:ops', + group_folder: 'ops', + id: 'merge-1', + status: 'merge_ready', + updated_at: '2026-04-26T05:00:00.000Z', + }); const handler = createWebDashboardHandler({ - readStatusSnapshots: () => snapshots, + readStatusSnapshots: () => [], getTasks: () => [], - getPairedTasks: () => [], + getPairedTasks: () => [pairedTask], now: () => '2026-04-26T05:10:00.000Z', startBackgroundCacheRefresh: false, }); @@ -133,10 +119,7 @@ describe('web dashboard server handler', () => { inbox: [], }); - snapshots[0] = { - ...snapshots[0]!, - updatedAt: '2026-04-26T05:01:00.000Z', - }; + pairedTask.updated_at = '2026-04-26T05:01:00.000Z'; const changedOverview = await handler( new Request('http://localhost/api/overview'), );