remove inbox top-level navigation
This commit is contained in:
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type CreateScheduledTaskInput,
|
type CreateScheduledTaskInput,
|
||||||
type DashboardInboxAction,
|
|
||||||
type DashboardRoomActivity,
|
type DashboardRoomActivity,
|
||||||
type DashboardTaskAction,
|
type DashboardTaskAction,
|
||||||
type DashboardOverview,
|
type DashboardOverview,
|
||||||
@@ -11,7 +10,6 @@ import {
|
|||||||
type StatusSnapshot,
|
type StatusSnapshot,
|
||||||
createScheduledTask,
|
createScheduledTask,
|
||||||
fetchDashboardData,
|
fetchDashboardData,
|
||||||
runInboxAction,
|
|
||||||
runServiceAction,
|
runServiceAction,
|
||||||
runScheduledTaskAction,
|
runScheduledTaskAction,
|
||||||
sendRoomMessage,
|
sendRoomMessage,
|
||||||
@@ -33,7 +31,6 @@ import {
|
|||||||
type DashboardView,
|
type DashboardView,
|
||||||
} from './DashboardNav';
|
} from './DashboardNav';
|
||||||
import { formatDate, statusLabel } from './dashboardHelpers';
|
import { formatDate, statusLabel } from './dashboardHelpers';
|
||||||
import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel';
|
|
||||||
import { RoomBoardV2 } from './RoomBoardV2';
|
import { RoomBoardV2 } from './RoomBoardV2';
|
||||||
import { ServicePanel, type ServiceActionKey } from './ServicePanel';
|
import { ServicePanel, type ServiceActionKey } from './ServicePanel';
|
||||||
import { SettingsPanel } from './SettingsPanel';
|
import { SettingsPanel } from './SettingsPanel';
|
||||||
@@ -68,7 +65,6 @@ function isDashboardView(
|
|||||||
): value is DashboardView {
|
): value is DashboardView {
|
||||||
return (
|
return (
|
||||||
value === 'usage' ||
|
value === 'usage' ||
|
||||||
value === 'inbox' ||
|
|
||||||
value === 'health' ||
|
value === 'health' ||
|
||||||
value === 'rooms' ||
|
value === 'rooms' ||
|
||||||
value === 'scheduled' ||
|
value === 'scheduled' ||
|
||||||
@@ -82,6 +78,13 @@ function readViewFromHash(): DashboardView {
|
|||||||
return isDashboardView(raw) ? raw : DEFAULT_VIEW;
|
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 {
|
function readInitialLocale(): Locale {
|
||||||
const stored =
|
const stored =
|
||||||
typeof window === 'undefined'
|
typeof window === 'undefined'
|
||||||
@@ -385,9 +388,6 @@ function App() {
|
|||||||
const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>(
|
const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [inboxActionKey, setInboxActionKey] = useState<InboxActionKey | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [serviceActionKey, setServiceActionKey] =
|
const [serviceActionKey, setServiceActionKey] =
|
||||||
useState<ServiceActionKey | null>(null);
|
useState<ServiceActionKey | null>(null);
|
||||||
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
|
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(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() {
|
async function handleServiceRestart() {
|
||||||
if (
|
if (
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
@@ -715,7 +688,9 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleHashChange() {
|
function handleHashChange() {
|
||||||
setActiveView(readViewFromHash());
|
const nextView = readViewFromHash();
|
||||||
|
setActiveView(nextView);
|
||||||
|
normalizeDashboardHash(nextView);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHashChange();
|
handleHashChange();
|
||||||
@@ -840,29 +815,6 @@ function App() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeView === 'inbox' ? (
|
|
||||||
<section className="panel view-panel" id="inbox">
|
|
||||||
<div className="panel-title">
|
|
||||||
<h2>{t.panels.inbox}</h2>
|
|
||||||
<span>{t.panels.inboxQueue}</span>
|
|
||||||
</div>
|
|
||||||
<InboxPanel
|
|
||||||
inboxActionKey={inboxActionKey}
|
|
||||||
locale={locale}
|
|
||||||
onInboxAction={(item, action) =>
|
|
||||||
void handleInboxAction(item, action)
|
|
||||||
}
|
|
||||||
onTaskAction={(task, action) =>
|
|
||||||
void handleTaskAction(task, action)
|
|
||||||
}
|
|
||||||
overview={data.overview}
|
|
||||||
taskActionKey={taskActionKey}
|
|
||||||
tasks={data.tasks}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeView === 'health' ? (
|
{activeView === 'health' ? (
|
||||||
<section className="panel view-panel" id="health">
|
<section className="panel view-panel" id="health">
|
||||||
<div className="panel-title">
|
<div className="panel-title">
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Download,
|
Download,
|
||||||
Gauge,
|
Gauge,
|
||||||
Inbox as InboxIcon,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -15,7 +14,6 @@ import { type Messages } from './i18n';
|
|||||||
|
|
||||||
export type DashboardView =
|
export type DashboardView =
|
||||||
| 'usage'
|
| 'usage'
|
||||||
| 'inbox'
|
|
||||||
| 'health'
|
| 'health'
|
||||||
| 'rooms'
|
| 'rooms'
|
||||||
| 'scheduled'
|
| 'scheduled'
|
||||||
@@ -29,7 +27,6 @@ interface DashboardNavData {
|
|||||||
|
|
||||||
const NAV_ICONS: Record<DashboardView, ReactNode> = {
|
const NAV_ICONS: Record<DashboardView, ReactNode> = {
|
||||||
usage: <Gauge size={20} strokeWidth={2} aria-hidden />,
|
usage: <Gauge size={20} strokeWidth={2} aria-hidden />,
|
||||||
inbox: <InboxIcon size={20} strokeWidth={2} aria-hidden />,
|
|
||||||
health: <Activity size={20} strokeWidth={2} aria-hidden />,
|
health: <Activity size={20} strokeWidth={2} aria-hidden />,
|
||||||
rooms: <MessageSquare size={20} strokeWidth={2} aria-hidden />,
|
rooms: <MessageSquare size={20} strokeWidth={2} aria-hidden />,
|
||||||
scheduled: <Clock size={20} strokeWidth={2} aria-hidden />,
|
scheduled: <Clock size={20} strokeWidth={2} aria-hidden />,
|
||||||
@@ -39,7 +36,6 @@ const NAV_ICONS: Record<DashboardView, ReactNode> = {
|
|||||||
function navItems(t: Messages) {
|
function navItems(t: Messages) {
|
||||||
return [
|
return [
|
||||||
{ href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const },
|
{ 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: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const },
|
||||||
{ href: '#/health', label: t.nav.health, view: 'health' as const },
|
{ href: '#/health', label: t.nav.health, view: 'health' as const },
|
||||||
{ href: '#/usage', label: t.nav.usage, view: 'usage' as const },
|
{ href: '#/usage', label: t.nav.usage, view: 'usage' as const },
|
||||||
@@ -59,7 +55,6 @@ function navStats(data: DashboardNavData | null) {
|
|||||||
{ processing: 0 },
|
{ processing: 0 },
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
inbox: data.overview.inbox.length,
|
|
||||||
processing: queue.processing,
|
processing: queue.processing,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -68,7 +63,6 @@ function navBadge(
|
|||||||
view: DashboardView,
|
view: DashboardView,
|
||||||
stats: ReturnType<typeof navStats>,
|
stats: ReturnType<typeof navStats>,
|
||||||
): number | null {
|
): number | null {
|
||||||
if (view === 'inbox' && stats && stats.inbox > 0) return stats.inbox;
|
|
||||||
if (view === 'rooms' && stats && stats.processing > 0) {
|
if (view === 'rooms' && stats && stats.processing > 0) {
|
||||||
return stats.processing;
|
return stats.processing;
|
||||||
}
|
}
|
||||||
@@ -100,8 +94,8 @@ function DashboardBrandLink({
|
|||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className="rail-brand"
|
className="rail-brand"
|
||||||
href="#/usage"
|
href="#/rooms"
|
||||||
onClick={() => onNavigate('usage')}
|
onClick={() => onNavigate('rooms')}
|
||||||
title="EJClaw"
|
title="EJClaw"
|
||||||
>
|
>
|
||||||
EJ
|
EJ
|
||||||
|
|||||||
@@ -82,6 +82,36 @@ describe('RoomBoardV2', () => {
|
|||||||
expect(html).toContain('prod 배포 완료');
|
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', () => {
|
it('renders an empty state without snapshots', () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
createElement(RoomBoardV2, { ...baseProps, snapshots: [] }),
|
createElement(RoomBoardV2, { ...baseProps, snapshots: [] }),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import process from 'node:process';
|
|||||||
import { createServer, type ViteDevServer } from 'vite';
|
import { createServer, type ViteDevServer } from 'vite';
|
||||||
|
|
||||||
interface MockApiState {
|
interface MockApiState {
|
||||||
|
approvalAction: boolean;
|
||||||
codexFeatures: { goals: boolean };
|
codexFeatures: { goals: boolean };
|
||||||
codexFeatureUpdates: number;
|
codexFeatureUpdates: number;
|
||||||
ciWatcherFailures: number;
|
ciWatcherFailures: number;
|
||||||
@@ -13,6 +14,7 @@ interface MockApiState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seriousImpacts = new Set(['serious', 'critical']);
|
const seriousImpacts = new Set(['serious', 'critical']);
|
||||||
|
const MOCK_TIME = new Date(0).toISOString();
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const server = await startDashboardServer();
|
const server = await startDashboardServer();
|
||||||
@@ -112,17 +114,21 @@ async function main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await runScenario(
|
await runScenario(
|
||||||
'inbox stays focused on actionable work',
|
'rooms surface user action badges without inbox navigation',
|
||||||
browser,
|
browser,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
async (page, state) => {
|
async (page, state) => {
|
||||||
|
state.approvalAction = true;
|
||||||
state.ciWatcherFailures = 2;
|
state.ciWatcherFailures = 2;
|
||||||
|
|
||||||
await page.goto(new URL('/#/inbox', baseUrl).toString(), {
|
await page.goto(new URL('/#/inbox', baseUrl).toString(), {
|
||||||
waitUntil: 'networkidle',
|
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.getByText(/CI 실패|CI failure/).count(), 0);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.getByRole('button', { name: 'Dismiss' }).count(),
|
await page.getByRole('button', { name: 'Dismiss' }).count(),
|
||||||
@@ -179,6 +185,7 @@ async function runScenario(
|
|||||||
|
|
||||||
function createMockApiState(): MockApiState {
|
function createMockApiState(): MockApiState {
|
||||||
return {
|
return {
|
||||||
|
approvalAction: false,
|
||||||
codexFeatures: { goals: false },
|
codexFeatures: { goals: false },
|
||||||
codexFeatureUpdates: 0,
|
codexFeatureUpdates: 0,
|
||||||
ciWatcherFailures: 0,
|
ciWatcherFailures: 0,
|
||||||
@@ -229,26 +236,15 @@ async function handleMockApi(route: Route, state: MockApiState) {
|
|||||||
const method = request.method();
|
const method = request.method();
|
||||||
|
|
||||||
if (method === 'GET' && url.pathname === '/api/overview') {
|
if (method === 'GET' && url.pathname === '/api/overview') {
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, mockOverview(state));
|
||||||
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: [],
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'GET' && url.pathname === '/api/status-snapshots') {
|
if (method === 'GET' && url.pathname === '/api/status-snapshots') {
|
||||||
await fulfillJson(route, []);
|
await fulfillJson(
|
||||||
|
route,
|
||||||
|
state.approvalAction ? [mockStatusSnapshot()] : [],
|
||||||
|
);
|
||||||
return;
|
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<string, unknown> {
|
function parseJsonBody(body: string | null): Record<string, unknown> {
|
||||||
if (!body) return {};
|
if (!body) return {};
|
||||||
const parsed = JSON.parse(body) as unknown;
|
const parsed = JSON.parse(body) as unknown;
|
||||||
|
|||||||
@@ -761,7 +761,7 @@ describe('web dashboard room activity data', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('web dashboard inbox 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[] = [
|
const snapshots: StatusSnapshot[] = [
|
||||||
{
|
{
|
||||||
serviceId: 'codex-main',
|
serviceId: 'codex-main',
|
||||||
@@ -803,6 +803,8 @@ describe('web dashboard inbox data', () => {
|
|||||||
review_requested_at: '2026-04-26T05:03:00.000Z',
|
review_requested_at: '2026-04-26T05:03:00.000Z',
|
||||||
}),
|
}),
|
||||||
makePairedTask({
|
makePairedTask({
|
||||||
|
chat_jid: 'dc:ops',
|
||||||
|
group_folder: 'ops',
|
||||||
id: 'merge-1',
|
id: 'merge-1',
|
||||||
status: 'merge_ready',
|
status: 'merge_ready',
|
||||||
title: 'Ready to merge',
|
title: 'Ready to merge',
|
||||||
@@ -839,31 +841,27 @@ describe('web dashboard inbox data', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(overview.inbox.map((item) => item.kind)).toEqual([
|
expect(overview.inbox.map((item) => item.kind)).toEqual(['approval']);
|
||||||
'approval',
|
|
||||||
'reviewer-request',
|
|
||||||
'pending-room',
|
|
||||||
]);
|
|
||||||
expect(overview.inbox).toContainEqual(
|
expect(overview.inbox).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'room:codex-main:dc:ops',
|
id: 'paired:merge-1:merge_ready',
|
||||||
kind: 'pending-room',
|
kind: 'approval',
|
||||||
severity: 'info',
|
severity: 'warn',
|
||||||
roomJid: 'dc:ops',
|
roomJid: 'dc:ops',
|
||||||
|
taskId: 'merge-1',
|
||||||
serviceId: 'codex-main',
|
serviceId: 'codex-main',
|
||||||
occurredAt: '2026-04-26T05:00:00.000Z',
|
occurredAt: '2026-04-26T05:04:00.000Z',
|
||||||
createdAt: '2026-04-26T05:10:00.000Z',
|
createdAt: '2026-04-26T05:10:00.000Z',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(overview.inbox).toContainEqual(
|
expect(overview.inbox.some((item) => item.kind === 'pending-room')).toBe(
|
||||||
expect.objectContaining({
|
false,
|
||||||
id: 'paired:review-1:review_ready',
|
);
|
||||||
kind: 'reviewer-request',
|
expect(
|
||||||
severity: 'warn',
|
overview.inbox.some((item) => item.kind === 'reviewer-request'),
|
||||||
serviceId: 'claude-reviewer',
|
).toBe(false);
|
||||||
taskStatus: 'review_ready',
|
expect(overview.inbox.some((item) => item.kind === 'arbiter-request')).toBe(
|
||||||
occurredAt: '2026-04-26T05:03:00.000Z',
|
false,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
expect(overview.inbox.some((item) => item.kind === 'ci-failure')).toBe(
|
expect(overview.inbox.some((item) => item.kind === 'ci-failure')).toBe(
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -274,51 +274,15 @@ function pairedTaskInboxKind(
|
|||||||
status: PairedTask['status'],
|
status: PairedTask['status'],
|
||||||
): InboxItemKind | null {
|
): InboxItemKind | null {
|
||||||
if (status === 'merge_ready') return 'approval';
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectInboxItems(args: {
|
function collectInboxItems(args: {
|
||||||
snapshots: StatusSnapshot[];
|
|
||||||
tasks: ScheduledTask[];
|
|
||||||
pairedTasks: PairedTask[];
|
pairedTasks: PairedTask[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}): InboxItem[] {
|
}): InboxItem[] {
|
||||||
const items: 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) {
|
for (const task of args.pairedTasks) {
|
||||||
const kind = pairedTaskInboxKind(task.status);
|
const kind = pairedTaskInboxKind(task.status);
|
||||||
if (!kind) continue;
|
if (!kind) continue;
|
||||||
@@ -327,30 +291,17 @@ function collectInboxItems(args: {
|
|||||||
id: `paired:${task.id}:${task.status}`,
|
id: `paired:${task.id}:${task.status}`,
|
||||||
groupKey: `paired:${task.id}:${task.status}`,
|
groupKey: `paired:${task.id}:${task.status}`,
|
||||||
kind,
|
kind,
|
||||||
severity: kind === 'arbiter-request' ? 'error' : 'warn',
|
severity: 'warn',
|
||||||
title: task.title || task.group_folder,
|
title: task.title || task.group_folder,
|
||||||
summary: task.status,
|
summary: task.status,
|
||||||
occurredAt:
|
occurredAt: task.updated_at,
|
||||||
kind === 'arbiter-request'
|
lastOccurredAt: task.updated_at,
|
||||||
? (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,
|
|
||||||
createdAt: args.createdAt,
|
createdAt: args.createdAt,
|
||||||
occurrences: 1,
|
occurrences: 1,
|
||||||
source: 'paired-task',
|
source: 'paired-task',
|
||||||
roomJid: task.chat_jid,
|
roomJid: task.chat_jid,
|
||||||
groupFolder: task.group_folder,
|
groupFolder: task.group_folder,
|
||||||
serviceId:
|
serviceId: task.owner_service_id,
|
||||||
kind === 'reviewer-request'
|
|
||||||
? task.reviewer_service_id
|
|
||||||
: task.owner_service_id,
|
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
taskStatus: task.status,
|
taskStatus: task.status,
|
||||||
});
|
});
|
||||||
@@ -793,8 +744,6 @@ export function buildWebDashboardOverview(args: {
|
|||||||
fetchedAt: usageFetchedAt,
|
fetchedAt: usageFetchedAt,
|
||||||
},
|
},
|
||||||
inbox: collectInboxItems({
|
inbox: collectInboxItems({
|
||||||
snapshots: args.snapshots,
|
|
||||||
tasks: args.tasks,
|
|
||||||
pairedTasks: args.pairedTasks ?? [],
|
pairedTasks: args.pairedTasks ?? [],
|
||||||
createdAt: generatedAt,
|
createdAt: generatedAt,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -142,7 +142,9 @@ describe('web dashboard overview route', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
const overview = route('/api/overview', {
|
const overview = route('/api/overview', {
|
||||||
loadPairedTasks: () => [makePairedTask({ id: 'review-1' })],
|
loadPairedTasks: () => [
|
||||||
|
makePairedTask({ id: 'merge-1', status: 'merge_ready' }),
|
||||||
|
],
|
||||||
readSnapshots: () => snapshots,
|
readSnapshots: () => snapshots,
|
||||||
isInboxItemDismissed: () => true,
|
isInboxItemDismissed: () => true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import path from 'path';
|
|||||||
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { StatusSnapshot } from './status-dashboard.js';
|
|
||||||
import type { NewMessage, PairedTask, RegisteredGroup } from './types.js';
|
import type { NewMessage, PairedTask, RegisteredGroup } from './types.js';
|
||||||
import { createWebDashboardHandler } from './web-dashboard-server.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 () => {
|
it('shares inbox dismiss state with overview JSON', async () => {
|
||||||
const snapshots: StatusSnapshot[] = [
|
const pairedTask = makePairedTask({
|
||||||
{
|
chat_jid: 'dc:ops',
|
||||||
serviceId: 'codex-main',
|
group_folder: 'ops',
|
||||||
agentType: 'codex',
|
id: 'merge-1',
|
||||||
assistantName: 'Codex',
|
status: 'merge_ready',
|
||||||
updatedAt: '2026-04-26T05:00:00.000Z',
|
updated_at: '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 handler = createWebDashboardHandler({
|
const handler = createWebDashboardHandler({
|
||||||
readStatusSnapshots: () => snapshots,
|
readStatusSnapshots: () => [],
|
||||||
getTasks: () => [],
|
getTasks: () => [],
|
||||||
getPairedTasks: () => [],
|
getPairedTasks: () => [pairedTask],
|
||||||
now: () => '2026-04-26T05:10:00.000Z',
|
now: () => '2026-04-26T05:10:00.000Z',
|
||||||
startBackgroundCacheRefresh: false,
|
startBackgroundCacheRefresh: false,
|
||||||
});
|
});
|
||||||
@@ -133,10 +119,7 @@ describe('web dashboard server handler', () => {
|
|||||||
inbox: [],
|
inbox: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
snapshots[0] = {
|
pairedTask.updated_at = '2026-04-26T05:01:00.000Z';
|
||||||
...snapshots[0]!,
|
|
||||||
updatedAt: '2026-04-26T05:01:00.000Z',
|
|
||||||
};
|
|
||||||
const changedOverview = await handler(
|
const changedOverview = await handler(
|
||||||
new Request('http://localhost/api/overview'),
|
new Request('http://localhost/api/overview'),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user