= {
+ id: 'task-1',
+ title: 'OpenAPI sync',
+ status: 'running',
+ roundTripCount: 1,
+ updatedAt: '2026-04-28T02:01:00.000Z',
+ currentTurn: {
+ turnId: 'turn-1',
+ role: 'owner',
+ intentKind: 'implementation',
+ state: 'running',
+ attemptNo: 1,
+ executorServiceId: 'svc-1',
+ executorAgentType: 'codex',
+ activeRunId: 'run-site-only',
+ createdAt: '2026-04-28T02:00:00.000Z',
+ updatedAt: '2026-04-28T02:01:00.000Z',
+ completedAt: null,
+ lastError: null,
+ progressText: null,
+ progressUpdatedAt: null,
+ },
+ outputs: [],
+ };
+
+ const collapsed = renderToStaticMarkup(
+ createElement(RoomCardV2, {
+ activity: activity({ pairedTask }),
+ activityLoading: false,
+ busy: false,
+ draft: '',
+ entry: { ...entry, status: 'processing' },
+ expanded: false,
+ inboxItems: [],
+ locale: 'ko',
+ onDraftChange: () => {},
+ onSendMessage: () => {},
+ onToggle: () => {},
+ pinned: false,
+ t,
+ ...formatters,
+ }),
+ );
+ const expanded = renderToStaticMarkup(
+ createElement(RoomCardV2, {
+ activity: activity({ pairedTask }),
+ activityLoading: false,
+ busy: false,
+ draft: '',
+ entry: { ...entry, status: 'processing' },
+ expanded: true,
+ inboxItems: [],
+ locale: 'ko',
+ onDraftChange: () => {},
+ onSendMessage: () => {},
+ onToggle: () => {},
+ pinned: false,
+ t,
+ ...formatters,
+ }),
+ );
+
+ expect(collapsed).not.toContain('class="room-live"');
+ expect(expanded).not.toContain('room-timeline-live');
+ expect(expanded).not.toContain(t.rooms.loadingActivity);
+ expect(expanded).toContain(t.rooms.noActivity);
+ });
it('renders message attachments as dashboard images', () => {
const html = renderToStaticMarkup(
diff --git a/apps/dashboard/src/RoomCardV2.tsx b/apps/dashboard/src/RoomCardV2.tsx
index 6fe7da4..f7ce7fb 100644
--- a/apps/dashboard/src/RoomCardV2.tsx
+++ b/apps/dashboard/src/RoomCardV2.tsx
@@ -227,6 +227,7 @@ export function RoomCardV2({
expanded={expanded}
formatDate={formatDate}
isProcessing={isProcessing}
+ liveProgressDisplay={liveProgressDisplay}
locale={locale}
senderRoleClass={senderRoleClass}
t={t}
@@ -488,6 +489,7 @@ function CollapsedLiveTurn({
expanded,
formatDate,
isProcessing,
+ liveProgressDisplay,
locale,
senderRoleClass,
t,
@@ -496,12 +498,13 @@ function CollapsedLiveTurn({
expanded: boolean;
formatDate: RoomCardFormatters['formatDate'];
isProcessing: boolean;
+ liveProgressDisplay: string | null;
locale: Locale;
senderRoleClass: RoomCardFormatters['senderRoleClass'];
t: Messages;
turn: RoomTurn | null;
}) {
- if (expanded || !isProcessing || !turn) return null;
+ if (expanded || !isProcessing || !turn || !liveProgressDisplay) return null;
return (
@@ -524,11 +527,7 @@ function CollapsedLiveTurn({
{formatDate(turn.progressUpdatedAt ?? turn.updatedAt, locale)}
- {turn.progressText && turn.progressText.trim() ? (
-
- ) : turn.activeRunId ? (
- {turn.activeRunId}
- ) : null}
+
);
}
@@ -702,15 +701,18 @@ function RoomThreadSection({
t: Messages;
turn: RoomTurn | null;
}) {
- const showLiveItem =
- turn && turn.completedAt === null && (isProcessing || liveProgressDisplay);
+ const showLiveItem = !!(
+ turn &&
+ turn.completedAt === null &&
+ liveProgressDisplay
+ );
return (
{t.rooms.activity}
{agentMessages.length}
- {agentMessages.length === 0 && !(isProcessing && turn) ? (
+ {agentMessages.length === 0 && !showLiveItem ? (
{t.rooms.noActivity}
) : (
diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts
index 35e1cd2..b4b8143 100644
--- a/src/web-dashboard-data.test.ts
+++ b/src/web-dashboard-data.test.ts
@@ -236,7 +236,9 @@ describe('web dashboard data', () => {
);
expect(sanitized.promptPreview).not.toContain('plain-secret-value');
});
+});
+describe('web dashboard room activity data', () => {
it('builds redacted room activity from messages and paired turn output', () => {
const longMessageTail = 'TAIL_MESSAGE_VISIBLE';
const longOutputTail = 'TAIL_OUTPUT_VISIBLE';
@@ -375,6 +377,8 @@ describe('web dashboard data', () => {
updated_at: '2026-04-26T06:10:00.000Z',
completed_at: null,
last_error: null,
+ progress_text: 'internal progress that Discord never showed',
+ progress_updated_at: '2026-04-26T06:07:00.000Z',
};
const statusPrefix = '';
const messages: NewMessage[] = [
@@ -406,7 +410,7 @@ describe('web dashboard data', () => {
id: 'msg-live-progress',
chat_jid: 'dc:ops',
sender: 'bot-1',
- sender_name: '오너',
+ sender_name: 'clone-코덱스',
content: `${statusPrefix}building mobile parity\n\n12s`,
timestamp: '2026-04-26T06:08:00.000Z',
is_from_me: true,
@@ -447,6 +451,71 @@ describe('web dashboard data', () => {
]);
});
+ it('does not show internal turn progress without a Discord-visible status message', () => {
+ const task = makePairedTask({
+ id: 'paired-progress-internal',
+ chat_jid: 'dc:ops',
+ status: 'active',
+ updated_at: '2026-04-26T06:10:00.000Z',
+ });
+ const turn: PairedTurnRecord = {
+ turn_id: 'turn-progress-internal',
+ task_id: task.id,
+ task_updated_at: task.updated_at,
+ role: 'owner',
+ intent_kind: 'owner-turn',
+ 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: 'site-only internal progress',
+ progress_updated_at: '2026-04-26T06:09:00.000Z',
+ };
+
+ 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: [turn],
+ attempts: [],
+ outputs: [],
+ messages: [],
+ progressMessages: [
+ {
+ id: 'spoofed-progress',
+ chat_jid: 'dc:ops',
+ sender: 'user-1',
+ sender_name: '눈쟁이',
+ content: 'human spoofed progress',
+ timestamp: '2026-04-26T06:09:30.000Z',
+ is_from_me: false,
+ is_bot_message: false,
+ message_source_kind: 'human',
+ },
+ ],
+ });
+
+ expect(activity.pairedTask?.currentTurn).toMatchObject({
+ progressText: null,
+ progressUpdatedAt: null,
+ });
+ });
+});
+
+describe('web dashboard inbox data', () => {
it('builds typed inbox items from pending rooms, paired tasks, and CI failures', () => {
const snapshots: StatusSnapshot[] = [
{
diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts
index 71f5e45..e0b3c03 100644
--- a/src/web-dashboard-data.ts
+++ b/src/web-dashboard-data.ts
@@ -514,6 +514,16 @@ function turnRoleFromSenderName(name: string | null | undefined): string {
return 'human';
}
+function isBotProgressSource(message: NewMessage): boolean {
+ return (
+ !!message.is_bot_message ||
+ !!message.is_from_me ||
+ message.message_source_kind === 'bot' ||
+ message.message_source_kind === 'ipc_injected_bot' ||
+ message.message_source_kind === 'trusted_external_bot'
+ );
+}
+
function deriveLiveProgress(
turnRole: string,
turnCreatedAt: string,
@@ -528,7 +538,9 @@ function deriveLiveProgress(
const ts = m.timestamp ? new Date(m.timestamp).getTime() : 0;
if (Number.isFinite(startMs) && ts < startMs) continue;
const role = turnRoleFromSenderName(m.sender_name);
- if (role !== targetRole) continue;
+ if (role !== targetRole && (role !== 'human' || !isBotProgressSource(m))) {
+ continue;
+ }
const body = content.slice(TASK_STATUS_PREFIX.length);
const stripped = body.replace(/\n\n\d+[초smhMSH]?$/, '').trim();
if (!stripped) continue;
@@ -546,19 +558,13 @@ function sanitizeRoomTurn(
const createdAt = attempt?.created_at ?? turn.created_at;
const completedAt = attempt?.completed_at ?? turn.completed_at;
- // Read paired_turns.progress_text first (written by recordTurnProgress
- // when the controller is in the loop). If that's empty AND the turn is
- // active, fall back to scanning the messages table for the most recent
- // TASK_STATUS-prefixed message — same content the Discord bridge ingests.
- let progressText = turn.progress_text ?? null;
- let progressUpdatedAt = turn.progress_updated_at ?? null;
- if ((!progressText || !progressText.trim()) && completedAt === null) {
- const live = deriveLiveProgress(role, createdAt, messages);
- if (live.progressText) {
- progressText = live.progressText;
- progressUpdatedAt = live.progressUpdatedAt;
- }
- }
+ // Dashboard progress must match Discord-visible progress. The legacy
+ // paired_turns.progress_text column is written before Discord delivery, so
+ // using it here can create site-only progress that never appeared in Discord.
+ const live =
+ completedAt === null
+ ? deriveLiveProgress(role, createdAt, messages)
+ : { progressText: null, progressUpdatedAt: null };
return {
turnId: turn.turn_id,
@@ -576,8 +582,8 @@ function sanitizeRoomTurn(
(attempt?.last_error ?? turn.last_error)
? buildRoomBody(attempt?.last_error ?? turn.last_error ?? '')
: null,
- progressText,
- progressUpdatedAt,
+ progressText: live.progressText,
+ progressUpdatedAt: live.progressUpdatedAt,
};
}