From 4aca45ca23095c9c1922d8f825b09264775a53ea Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 29 Apr 2026 19:49:33 +0900 Subject: [PATCH 1/2] fix dashboard collapsed output previews --- apps/dashboard/src/RoomCardV2.test.ts | 47 +++++++++++++++++++++++++++ apps/dashboard/src/RoomCardV2.tsx | 18 ++++++---- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/RoomCardV2.test.ts b/apps/dashboard/src/RoomCardV2.test.ts index a665d89..effb41d 100644 --- a/apps/dashboard/src/RoomCardV2.test.ts +++ b/apps/dashboard/src/RoomCardV2.test.ts @@ -115,6 +115,53 @@ describe('RoomCardV2', () => { expect(html).toContain('prod 배포 완료'); }); + it('uses canonical room messages for collapsed previews when outputs are empty', () => { + const html = renderToStaticMarkup( + createElement(RoomCardV2, { + activity: activity({ + messages: [ + { + id: 'bot-1', + sender: 'bot-1', + senderName: 'owner', + content: 'TASK_DONE\n\nprod 배포 완료', + timestamp: '2026-04-28T02:00:00.000Z', + isFromMe: false, + isBotMessage: true, + sourceKind: 'bot', + }, + ], + pairedTask: { + id: 'task-1', + title: 'Deploy production', + status: 'completed', + roundTripCount: 1, + updatedAt: '2026-04-28T02:01:00.000Z', + currentTurn: null, + outputs: [], + }, + }), + activityLoading: false, + busy: false, + draft: '', + entry, + expanded: false, + inboxItems: [], + locale: 'ko', + onDraftChange: () => {}, + onSendMessage: () => {}, + onToggle: () => {}, + pinned: false, + t, + ...formatters, + }), + ); + + expect(html).toContain('TASK_DONE'); + expect(html).toContain('prod 배포 완료'); + expect(html).not.toContain(t.rooms.noActivity); + }); + it('localizes protocol role and verdict labels in Korean rooms', () => { const html = renderToStaticMarkup( createElement(RoomCardV2, { diff --git a/apps/dashboard/src/RoomCardV2.tsx b/apps/dashboard/src/RoomCardV2.tsx index f7ce7fb..ab61e03 100644 --- a/apps/dashboard/src/RoomCardV2.tsx +++ b/apps/dashboard/src/RoomCardV2.tsx @@ -234,6 +234,7 @@ export function RoomCardV2({ turn={turn} /> {expanded ? ( - + ); } @@ -592,18 +598,18 @@ function CollapsedEmptyState({ activityLoading, expanded, isProcessing, - latestOutput, t, + visibleActivityCount, }: { activity: DashboardRoomActivity | undefined; activityLoading: boolean; expanded: boolean; isProcessing: boolean; - latestOutput: RoomOutput | null; t: Messages; + visibleActivityCount: number; }) { if (expanded) return null; - if (!latestOutput && !isProcessing && !activityLoading) { + if (visibleActivityCount === 0 && !isProcessing && !activityLoading) { return

{t.rooms.noActivity}

; } if (activityLoading && !activity) { From b653fe5375e11e0c1e564ca0b5038cd37b108995 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 29 Apr 2026 20:04:47 +0900 Subject: [PATCH 2/2] fix dashboard progress turn activity --- src/db-paired-turn-progress.test.ts | 62 ++++++++++++++++++++++++++++ src/db/paired-turns.ts | 17 ++++++-- src/web-dashboard-data.test.ts | 63 +++++++++++++++++++++++++++++ src/web-dashboard-data.ts | 15 ++++++- 4 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 src/db-paired-turn-progress.test.ts diff --git a/src/db-paired-turn-progress.test.ts b/src/db-paired-turn-progress.test.ts new file mode 100644 index 0000000..d59c7a6 --- /dev/null +++ b/src/db-paired-turn-progress.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + _initTestDatabase, + getLatestPairedTurnForTask, + markPairedTurnRunning, + reservePairedTurnReservation, + updatePairedTurnProgressText, +} from './db.js'; +import { CODEX_MAIN_SERVICE_ID } from './config.js'; +import { buildPairedTurnIdentity } from './paired-turn-identity.js'; + +beforeEach(() => { + _initTestDatabase(); +}); + +describe('paired turn progress activity', () => { + it('selects the latest paired turn by progress activity timestamp', async () => { + const taskId = 'progress-latest-task'; + const activeTurn = buildPairedTurnIdentity({ + taskId, + taskUpdatedAt: '2026-04-10T00:00:00.000Z', + intentKind: 'owner-follow-up', + role: 'owner', + }); + const queuedTurn = buildPairedTurnIdentity({ + taskId, + taskUpdatedAt: '2026-04-10T00:01:00.000Z', + intentKind: 'owner-follow-up', + role: 'owner', + }); + + markPairedTurnRunning({ + turnIdentity: activeTurn, + executorServiceId: CODEX_MAIN_SERVICE_ID, + executorAgentType: 'codex', + runId: 'run-progress-active', + }); + expect( + reservePairedTurnReservation({ + chatJid: 'dc:ops', + taskId, + taskStatus: 'active', + roundTripCount: 1, + taskUpdatedAt: queuedTurn.taskUpdatedAt, + intentKind: queuedTurn.intentKind, + runId: 'run-queued-empty', + }), + ).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 5)); + updatePairedTurnProgressText( + activeTurn.turnId, + 'checking dashboard parity', + ); + + expect(getLatestPairedTurnForTask(taskId)).toMatchObject({ + turn_id: activeTurn.turnId, + progress_text: 'checking dashboard parity', + }); + }); +}); diff --git a/src/db/paired-turns.ts b/src/db/paired-turns.ts index b8d9390..8b7f127 100644 --- a/src/db/paired-turns.ts +++ b/src/db/paired-turns.ts @@ -507,16 +507,19 @@ export function updatePairedTurnProgressTextFromDatabase( turnId: string, progressText: string | null, ): void { + const now = new Date().toISOString(); let stmt = updateProgressTextStmtCache.get(database); if (!stmt) { stmt = database.prepare(` UPDATE paired_turns - SET progress_text = ?, progress_updated_at = ? + SET progress_text = ?, + progress_updated_at = ?, + updated_at = ? WHERE turn_id = ? `); updateProgressTextStmtCache.set(database, stmt); } - stmt.run(progressText, new Date().toISOString(), turnId); + stmt.run(progressText, now, now, turnId); } const latestPairedTurnStmtCache = new WeakMap< @@ -534,7 +537,15 @@ export function getLatestPairedTurnForTaskFromDatabase( SELECT * FROM paired_turns WHERE task_id = ? - ORDER BY updated_at DESC, turn_id DESC + ORDER BY CASE + WHEN progress_text IS NOT NULL + AND trim(progress_text) <> '' + AND progress_updated_at IS NOT NULL + AND progress_updated_at > updated_at + THEN progress_updated_at + ELSE updated_at + END DESC, + turn_id DESC LIMIT 1 `); latestPairedTurnStmtCache.set(database, stmt); diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts index 2dd008b..1641eb4 100644 --- a/src/web-dashboard-data.test.ts +++ b/src/web-dashboard-data.test.ts @@ -646,6 +646,69 @@ describe('web dashboard room activity data', () => { ]); }); + it('keeps a progress-updated turn current when a newer empty reservation exists', () => { + const task = makePairedTask({ + id: 'paired-room-progress-race', + chat_jid: 'dc:ops', + status: 'active', + updated_at: '2026-04-26T06:10:00.000Z', + }); + const activeProgressTurn: PairedTurnRecord = { + turn_id: 'turn-progress-active', + task_id: task.id, + task_updated_at: task.updated_at, + role: 'owner', + intent_kind: 'owner-follow-up', + state: 'running', + executor_service_id: 'codex-main', + executor_agent_type: 'codex', + attempt_no: 1, + created_at: '2026-04-26T06:00:00.000Z', + updated_at: '2026-04-26T06:10:00.000Z', + completed_at: null, + last_error: null, + progress_text: 'checking dashboard parity', + progress_updated_at: '2026-04-26T06:12:00.000Z', + }; + const queuedEmptyTurn: PairedTurnRecord = { + ...activeProgressTurn, + turn_id: 'turn-queued-empty', + state: 'queued', + executor_service_id: null, + executor_agent_type: null, + attempt_no: 0, + created_at: '2026-04-26T06:11:00.000Z', + updated_at: '2026-04-26T06:11:00.000Z', + progress_text: null, + progress_updated_at: null, + }; + + const activity = buildWebDashboardRoomActivity({ + serviceId: 'codex-main', + entry: { + jid: 'dc:ops', + name: '#ops', + folder: 'ops', + agentType: 'codex', + status: 'processing', + elapsedMs: 20_000, + pendingMessages: false, + pendingTasks: 0, + }, + pairedTask: task, + turns: [activeProgressTurn, queuedEmptyTurn], + attempts: [], + outputs: [], + messages: [], + }); + + expect(activity.pairedTask?.currentTurn).toMatchObject({ + turnId: 'turn-progress-active', + progressText: 'checking dashboard parity', + progressUpdatedAt: '2026-04-26T06:12:00.000Z', + }); + }); + it('does not show active turn placeholders when canonical progress is absent', () => { const task = makePairedTask({ id: 'paired-progress-internal', diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts index aaf1b44..a8d7e5a 100644 --- a/src/web-dashboard-data.ts +++ b/src/web-dashboard-data.ts @@ -594,6 +594,17 @@ function compareRoomMessagesByTimestamp( return aTime - bTime; } +function getRoomTurnActivityTimestamp(turn: PairedTurnRecord): string { + const progressUpdatedAt = + turn.completed_at === null && turn.progress_text?.trim() + ? turn.progress_updated_at + : null; + if (progressUpdatedAt && progressUpdatedAt > turn.updated_at) { + return progressUpdatedAt; + } + return turn.updated_at; +} + const TASK_STATUS_PREFIX = '⁣⁣⁣'; function isTaskStatusMessage(message: NewMessage): boolean { @@ -673,7 +684,9 @@ export function buildWebDashboardRoomActivity(args: { } const currentTurn = [...args.turns].sort((a, b) => - b.updated_at.localeCompare(a.updated_at), + getRoomTurnActivityTimestamp(b).localeCompare( + getRoomTurnActivityTimestamp(a), + ), )[0] ?? null; const currentAttempt = currentTurn ? (latestAttemptByTurnId.get(currentTurn.turn_id) ?? null)