diff --git a/apps/dashboard/src/RoomCardV2.test.ts b/apps/dashboard/src/RoomCardV2.test.ts
index 6da276c..16cbb4c 100644
--- a/apps/dashboard/src/RoomCardV2.test.ts
+++ b/apps/dashboard/src/RoomCardV2.test.ts
@@ -208,4 +208,42 @@ describe('RoomCardV2', () => {
);
expect(html).toContain('bar-chart-label-fit-playwright.png');
});
+
+ it('renders expanded watcher messages without truncating content', () => {
+ const watcherTail = 'WATCHER_TAIL_VISIBLE';
+ const html = renderToStaticMarkup(
+ createElement(RoomCardV2, {
+ activity: activity({
+ messages: [
+ {
+ id: 'watcher-1',
+ sender: 'bot-1',
+ senderName: 'reviewer',
+ content: `[Watcher] ${'검증 로그 '.repeat(40)} ${watcherTail}`,
+ timestamp: '2026-04-28T02:00:00.000Z',
+ isFromMe: false,
+ isBotMessage: true,
+ sourceKind: 'bot',
+ },
+ ],
+ }),
+ activityLoading: false,
+ busy: false,
+ draft: '',
+ entry,
+ expanded: true,
+ inboxItems: [],
+ locale: 'ko',
+ onDraftChange: () => {},
+ onSendMessage: () => {},
+ onToggle: () => {},
+ pinned: true,
+ t,
+ ...formatters,
+ }),
+ );
+
+ expect(html).toContain('class="room-watcher-fold"');
+ expect(html).toContain(watcherTail);
+ });
});
diff --git a/apps/dashboard/src/RoomCardV2.tsx b/apps/dashboard/src/RoomCardV2.tsx
index c3edd5a..8caad08 100644
--- a/apps/dashboard/src/RoomCardV2.tsx
+++ b/apps/dashboard/src/RoomCardV2.tsx
@@ -870,7 +870,7 @@ function RoomWatcherFold({
-
+
))}
diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css
index bf286dc..6d2075b 100644
--- a/apps/dashboard/src/styles.css
+++ b/apps/dashboard/src/styles.css
@@ -249,6 +249,7 @@ body:has(.view-stack.view-rooms) .error-card {
background: var(--panel);
overflow-y: auto;
overflow-x: hidden;
+ scroll-padding-bottom: 96px;
}
.view-stack.view-rooms .rooms-detail .room-card-v2.is-pinned {
@@ -290,7 +291,7 @@ body:has(.view-stack.view-rooms) .error-card {
}
.view-stack.view-rooms .rooms-detail .room-thread-section {
- padding: 4px 12px 4px 0;
+ padding: 4px 12px 96px 0;
}
.view-stack.view-rooms .rooms-detail .room-card-head {
diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts
index 7960cb4..35e1cd2 100644
--- a/src/web-dashboard-data.test.ts
+++ b/src/web-dashboard-data.test.ts
@@ -238,6 +238,8 @@ describe('web dashboard data', () => {
});
it('builds redacted room activity from messages and paired turn output', () => {
+ const longMessageTail = 'TAIL_MESSAGE_VISIBLE';
+ const longOutputTail = 'TAIL_OUTPUT_VISIBLE';
const task = makePairedTask({
id: 'paired-room-1',
chat_jid: 'dc:ops',
@@ -281,21 +283,12 @@ describe('web dashboard data', () => {
last_error: 'OPENAI_API_KEY=plain-secret-value',
};
const outputs: PairedTurnOutput[] = [
- {
- id: 1,
- task_id: task.id,
- turn_number: 1,
- role: 'owner',
- output_text: 'owner output',
- verdict: 'step_done',
- created_at: '2026-04-26T05:25:00.000Z',
- },
{
id: 2,
task_id: task.id,
turn_number: 2,
role: 'reviewer',
- output_text: 'reviewer output with BOT_TOKEN=plain-secret-value',
+ output_text: `reviewer output with BOT_TOKEN=plain-secret-value ${'x'.repeat(1900)} ${longOutputTail}`,
verdict: null,
created_at: '2026-04-26T05:30:00.000Z',
},
@@ -306,7 +299,7 @@ describe('web dashboard data', () => {
chat_jid: 'dc:ops',
sender: 'user-1',
sender_name: '눈쟁이',
- content: 'latest message',
+ content: `latest message ${'y'.repeat(950)} ${longMessageTail}`,
timestamp: '2026-04-26T05:29:00.000Z',
is_from_me: false,
is_bot_message: false,
@@ -347,12 +340,17 @@ describe('web dashboard data', () => {
{
turnNumber: 2,
role: 'reviewer',
- outputText: 'reviewer output with BOT_TOKEN=',
+ outputText: expect.stringMatching(
+ new RegExp(`BOT_TOKEN=[\\s\\S]*${longOutputTail}`),
+ ),
},
],
});
expect(activity.messages).toMatchObject([
- { senderName: '눈쟁이', content: 'latest message' },
+ {
+ senderName: '눈쟁이',
+ content: expect.stringContaining(longMessageTail),
+ },
]);
});
diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts
index ab0ae00..71f5e45 100644
--- a/src/web-dashboard-data.ts
+++ b/src/web-dashboard-data.ts
@@ -163,8 +163,6 @@ const SECRET_ASSIGNMENT_RE =
/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH|PRIVATE_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi;
const SECRET_VALUE_RE =
/\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,})\b/g;
-const ROOM_MESSAGE_PREVIEW_MAX_LENGTH = 900;
-const ROOM_OUTPUT_PREVIEW_MAX_LENGTH = 1800;
function redactSensitiveText(value: string): string {
return value
@@ -185,8 +183,8 @@ function buildInboxPreview(value: string): string {
return truncateText(redactSensitiveText(value).replace(/\s+/g, ' ').trim());
}
-function buildRoomPreview(value: string, maxLength: number): string {
- return truncateText(redactSensitiveText(value).trim(), maxLength);
+function buildRoomBody(value: string): string {
+ return redactSensitiveText(value).trim();
}
function decodeCommonHtmlEntities(value: string): string {
@@ -485,7 +483,7 @@ function sanitizeRoomMessage(
id: message.id,
sender: message.sender,
senderName: message.sender_name || message.sender,
- content: buildRoomPreview(normalized.text, ROOM_MESSAGE_PREVIEW_MAX_LENGTH),
+ content: buildRoomBody(normalized.text),
attachments: normalized.attachments,
timestamp: message.timestamp,
isFromMe: !!message.is_from_me,
@@ -576,10 +574,7 @@ function sanitizeRoomTurn(
completedAt,
lastError:
(attempt?.last_error ?? turn.last_error)
- ? buildRoomPreview(
- attempt?.last_error ?? turn.last_error ?? '',
- ROOM_MESSAGE_PREVIEW_MAX_LENGTH,
- )
+ ? buildRoomBody(attempt?.last_error ?? turn.last_error ?? '')
: null,
progressText,
progressUpdatedAt,
@@ -598,10 +593,7 @@ function sanitizeRoomTurnOutput(
role: output.role,
verdict: output.verdict ?? null,
createdAt: output.created_at,
- outputText: buildRoomPreview(
- normalized.text,
- ROOM_OUTPUT_PREVIEW_MAX_LENGTH,
- ),
+ outputText: buildRoomBody(normalized.text),
attachments: normalized.attachments,
};
}