Merge pull request #107 from phj1081/codex/owner/ejclaw
Fix dashboard collapsed output previews
This commit is contained in:
@@ -115,6 +115,53 @@ describe('RoomCardV2', () => {
|
|||||||
expect(html).toContain('prod 배포 완료');
|
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', () => {
|
it('localizes protocol role and verdict labels in Korean rooms', () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
createElement(RoomCardV2, {
|
createElement(RoomCardV2, {
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export function RoomCardV2({
|
|||||||
turn={turn}
|
turn={turn}
|
||||||
/>
|
/>
|
||||||
<CollapsedOutputPreview
|
<CollapsedOutputPreview
|
||||||
|
agentMessages={agentMessages}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
latestOutput={latestOutput}
|
latestOutput={latestOutput}
|
||||||
@@ -251,8 +252,8 @@ export function RoomCardV2({
|
|||||||
activityLoading={activityLoading}
|
activityLoading={activityLoading}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
latestOutput={latestOutput}
|
|
||||||
t={t}
|
t={t}
|
||||||
|
visibleActivityCount={agentMessages.length + watcherMessages.length}
|
||||||
/>
|
/>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<RoomExpandedContent
|
<RoomExpandedContent
|
||||||
@@ -541,18 +542,23 @@ function LiveProgressBody({ text }: { text: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CollapsedOutputPreview({
|
function CollapsedOutputPreview({
|
||||||
|
agentMessages,
|
||||||
expanded,
|
expanded,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
latestOutput,
|
latestOutput,
|
||||||
}: {
|
}: {
|
||||||
|
agentMessages: RoomThreadEntry[];
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
latestOutput: RoomOutput | null;
|
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 (
|
return (
|
||||||
<div className="room-preview">
|
<div className="room-preview">
|
||||||
<ParsedBody text={latestOutput.outputText} truncate={140} />
|
<ParsedBody text={previewText} truncate={140} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -592,18 +598,18 @@ function CollapsedEmptyState({
|
|||||||
activityLoading,
|
activityLoading,
|
||||||
expanded,
|
expanded,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
latestOutput,
|
|
||||||
t,
|
t,
|
||||||
|
visibleActivityCount,
|
||||||
}: {
|
}: {
|
||||||
activity: DashboardRoomActivity | undefined;
|
activity: DashboardRoomActivity | undefined;
|
||||||
activityLoading: boolean;
|
activityLoading: boolean;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
latestOutput: RoomOutput | null;
|
|
||||||
t: Messages;
|
t: Messages;
|
||||||
|
visibleActivityCount: number;
|
||||||
}) {
|
}) {
|
||||||
if (expanded) return null;
|
if (expanded) return null;
|
||||||
if (!latestOutput && !isProcessing && !activityLoading) {
|
if (visibleActivityCount === 0 && !isProcessing && !activityLoading) {
|
||||||
return <p className="room-empty">{t.rooms.noActivity}</p>;
|
return <p className="room-empty">{t.rooms.noActivity}</p>;
|
||||||
}
|
}
|
||||||
if (activityLoading && !activity) {
|
if (activityLoading && !activity) {
|
||||||
|
|||||||
62
src/db-paired-turn-progress.test.ts
Normal file
62
src/db-paired-turn-progress.test.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -507,16 +507,19 @@ export function updatePairedTurnProgressTextFromDatabase(
|
|||||||
turnId: string,
|
turnId: string,
|
||||||
progressText: string | null,
|
progressText: string | null,
|
||||||
): void {
|
): void {
|
||||||
|
const now = new Date().toISOString();
|
||||||
let stmt = updateProgressTextStmtCache.get(database);
|
let stmt = updateProgressTextStmtCache.get(database);
|
||||||
if (!stmt) {
|
if (!stmt) {
|
||||||
stmt = database.prepare(`
|
stmt = database.prepare(`
|
||||||
UPDATE paired_turns
|
UPDATE paired_turns
|
||||||
SET progress_text = ?, progress_updated_at = ?
|
SET progress_text = ?,
|
||||||
|
progress_updated_at = ?,
|
||||||
|
updated_at = ?
|
||||||
WHERE turn_id = ?
|
WHERE turn_id = ?
|
||||||
`);
|
`);
|
||||||
updateProgressTextStmtCache.set(database, stmt);
|
updateProgressTextStmtCache.set(database, stmt);
|
||||||
}
|
}
|
||||||
stmt.run(progressText, new Date().toISOString(), turnId);
|
stmt.run(progressText, now, now, turnId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestPairedTurnStmtCache = new WeakMap<
|
const latestPairedTurnStmtCache = new WeakMap<
|
||||||
@@ -534,7 +537,15 @@ export function getLatestPairedTurnForTaskFromDatabase(
|
|||||||
SELECT *
|
SELECT *
|
||||||
FROM paired_turns
|
FROM paired_turns
|
||||||
WHERE task_id = ?
|
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
|
LIMIT 1
|
||||||
`);
|
`);
|
||||||
latestPairedTurnStmtCache.set(database, stmt);
|
latestPairedTurnStmtCache.set(database, stmt);
|
||||||
|
|||||||
@@ -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', () => {
|
it('does not show active turn placeholders when canonical progress is absent', () => {
|
||||||
const task = makePairedTask({
|
const task = makePairedTask({
|
||||||
id: 'paired-progress-internal',
|
id: 'paired-progress-internal',
|
||||||
|
|||||||
@@ -594,6 +594,17 @@ function compareRoomMessagesByTimestamp(
|
|||||||
return aTime - bTime;
|
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 = '';
|
const TASK_STATUS_PREFIX = '';
|
||||||
|
|
||||||
function isTaskStatusMessage(message: NewMessage): boolean {
|
function isTaskStatusMessage(message: NewMessage): boolean {
|
||||||
@@ -673,7 +684,9 @@ export function buildWebDashboardRoomActivity(args: {
|
|||||||
}
|
}
|
||||||
const currentTurn =
|
const currentTurn =
|
||||||
[...args.turns].sort((a, b) =>
|
[...args.turns].sort((a, b) =>
|
||||||
b.updated_at.localeCompare(a.updated_at),
|
getRoomTurnActivityTimestamp(b).localeCompare(
|
||||||
|
getRoomTurnActivityTimestamp(a),
|
||||||
|
),
|
||||||
)[0] ?? null;
|
)[0] ?? null;
|
||||||
const currentAttempt = currentTurn
|
const currentAttempt = currentTurn
|
||||||
? (latestAttemptByTurnId.get(currentTurn.turn_id) ?? null)
|
? (latestAttemptByTurnId.get(currentTurn.turn_id) ?? null)
|
||||||
|
|||||||
Reference in New Issue
Block a user