Merge pull request #107 from phj1081/codex/owner/ejclaw

Fix dashboard collapsed output previews
This commit is contained in:
Eyejoker
2026-04-29 22:03:54 +09:00
committed by GitHub
6 changed files with 212 additions and 10 deletions

View File

@@ -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, {

View File

@@ -234,6 +234,7 @@ export function RoomCardV2({
turn={turn}
/>
<CollapsedOutputPreview
agentMessages={agentMessages}
expanded={expanded}
isProcessing={isProcessing}
latestOutput={latestOutput}
@@ -251,8 +252,8 @@ export function RoomCardV2({
activityLoading={activityLoading}
expanded={expanded}
isProcessing={isProcessing}
latestOutput={latestOutput}
t={t}
visibleActivityCount={agentMessages.length + watcherMessages.length}
/>
{expanded ? (
<RoomExpandedContent
@@ -541,18 +542,23 @@ function LiveProgressBody({ text }: { text: string }) {
}
function CollapsedOutputPreview({
agentMessages,
expanded,
isProcessing,
latestOutput,
}: {
agentMessages: RoomThreadEntry[];
expanded: boolean;
isProcessing: boolean;
latestOutput: RoomOutput | null;
}) {
if (expanded || !latestOutput || isProcessing) return null;
const latestThreadEntry = agentMessages.at(-1) ?? null;
const previewText =
latestOutput?.outputText ?? latestThreadEntry?.content ?? null;
if (expanded || !previewText || isProcessing) return null;
return (
<div className="room-preview">
<ParsedBody text={latestOutput.outputText} truncate={140} />
<ParsedBody text={previewText} truncate={140} />
</div>
);
}
@@ -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 <p className="room-empty">{t.rooms.noActivity}</p>;
}
if (activityLoading && !activity) {

View File

@@ -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',
});
});
});

View File

@@ -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);

View File

@@ -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',

View File

@@ -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)