diff --git a/apps/dashboard/src/RoomCardV2.test.ts b/apps/dashboard/src/RoomCardV2.test.ts index 89643ec..a665d89 100644 --- a/apps/dashboard/src/RoomCardV2.test.ts +++ b/apps/dashboard/src/RoomCardV2.test.ts @@ -211,6 +211,77 @@ describe('RoomCardV2', () => { expect(html).toContain('pnpm openapi:sync'); expect(html).not.toContain('`origin/dev`'); }); +}); + +describe('RoomCardV2 room thread details', () => { + it('does not render site-only active turn placeholders without visible progress', () => { + const pairedTask: NonNullable = { + 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, }; }