Fix dashboard room thread chunk rendering (#65)
* review: harden readonly git checks and lint verification * test: satisfy readonly reviewer sandbox typing * test: fix readonly reviewer sandbox assertions * test: remove impossible readonly sandbox guards * test: relax readonly sandbox assertions * test: cast readonly sandbox expectation shape * test: cast readonly sandbox expectation through unknown * Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke * Add STEP_DONE guards, verdict storage, and stale delivery suppression * style: format paired stepdone telemetry files * Route STEP_DONE through reviewer * Add structured Discord attachments * style: format structured attachment test * Fix room thread bot output parity * Remove duplicate work item attachment migration from owner branch * Remove legacy dashboard rooms renderer * Extract dashboard parsed body renderer * Extract dashboard redaction helpers * Extract dashboard RoomCardV2 component * Include RoomCardV2 test in vitest suite * Extract dashboard RoomBoardV2 component * Extract dashboard EmptyState component * Extract dashboard InboxPanel component * Split dashboard InboxPanel card renderer * Extract dashboard TaskPanel component * Extract dashboard UsagePanel component * Fix dashboard room thread chunk rendering
This commit is contained in:
@@ -97,4 +97,117 @@ describe('room thread entries', () => {
|
|||||||
turnNumber: 1,
|
turnNumber: 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deduplicates continuation chunks that are contained in a paired output', () => {
|
||||||
|
const continuation =
|
||||||
|
'PR 설명에 TaskPanel.tsx + statusLabel 이동+테스트 2개 모두 명시되어 있고 검증도 완료됐습니다.';
|
||||||
|
const entries = buildRoomThreadEntries({
|
||||||
|
messages: [
|
||||||
|
message({
|
||||||
|
id: 'bot-continuation',
|
||||||
|
senderName: '리뷰어',
|
||||||
|
content: continuation,
|
||||||
|
timestamp: '2026-04-28T01:02:30.000Z',
|
||||||
|
isBotMessage: true,
|
||||||
|
sourceKind: 'bot',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
output({
|
||||||
|
id: 8,
|
||||||
|
role: '리뷰어',
|
||||||
|
outputText: `STEP_DONE\n\n파일 검증 완료\n\n${continuation}\n\n다음 슬라이스 권고`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0]).toMatchObject({
|
||||||
|
id: 'out:8',
|
||||||
|
senderName: '리뷰어',
|
||||||
|
sourceKind: 'agent_output',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges adjacent bot chunks from the same sender', () => {
|
||||||
|
const entries = buildRoomThreadEntries({
|
||||||
|
messages: [
|
||||||
|
message({
|
||||||
|
id: 'reviewer-1',
|
||||||
|
senderName: '리뷰어',
|
||||||
|
content: 'STEP_DONE — PR #63 검증 완료\n\n파일 검증',
|
||||||
|
timestamp: '2026-04-28T01:02:00.000Z',
|
||||||
|
isBotMessage: true,
|
||||||
|
sourceKind: 'bot',
|
||||||
|
}),
|
||||||
|
message({
|
||||||
|
id: 'reviewer-2',
|
||||||
|
senderName: '리뷰어',
|
||||||
|
content: 'PR 설명에 추출 모듈 전부 명시\n\n다음 슬라이스 권고',
|
||||||
|
timestamp: '2026-04-28T01:02:03.000Z',
|
||||||
|
isBotMessage: true,
|
||||||
|
sourceKind: 'bot',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
outputs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0]).toMatchObject({
|
||||||
|
id: 'reviewer-1+reviewer-2',
|
||||||
|
senderName: '리뷰어',
|
||||||
|
sourceKind: 'bot',
|
||||||
|
});
|
||||||
|
expect(entries[0].content).toContain('파일 검증');
|
||||||
|
expect(entries[0].content).toContain('다음 슬라이스 권고');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps adjacent human messages separate', () => {
|
||||||
|
const entries = buildRoomThreadEntries({
|
||||||
|
messages: [
|
||||||
|
message({
|
||||||
|
id: 'human-1',
|
||||||
|
content: '첫 번째 요청',
|
||||||
|
timestamp: '2026-04-28T01:02:00.000Z',
|
||||||
|
}),
|
||||||
|
message({
|
||||||
|
id: 'human-2',
|
||||||
|
content: '두 번째 요청',
|
||||||
|
timestamp: '2026-04-28T01:02:03.000Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
outputs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entries.map((entry) => entry.id)).toEqual(['human-1', 'human-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps adjacent bot status outputs separate', () => {
|
||||||
|
const entries = buildRoomThreadEntries({
|
||||||
|
messages: [
|
||||||
|
message({
|
||||||
|
id: 'reviewer-status-1',
|
||||||
|
senderName: '리뷰어',
|
||||||
|
content: 'STEP_DONE — PR #63 검증 완료',
|
||||||
|
timestamp: '2026-04-28T01:02:00.000Z',
|
||||||
|
isBotMessage: true,
|
||||||
|
sourceKind: 'bot',
|
||||||
|
}),
|
||||||
|
message({
|
||||||
|
id: 'reviewer-status-2',
|
||||||
|
senderName: '리뷰어',
|
||||||
|
content: 'STEP_DONE — PR #64 검증 완료',
|
||||||
|
timestamp: '2026-04-28T01:02:03.000Z',
|
||||||
|
isBotMessage: true,
|
||||||
|
sourceKind: 'bot',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
outputs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entries.map((entry) => entry.id)).toEqual([
|
||||||
|
'reviewer-status-1',
|
||||||
|
'reviewer-status-2',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ function normalizeForDedupe(value: string): string {
|
|||||||
return value.replace(/\s+/g, ' ').trim();
|
return value.replace(/\s+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIN_SUBSTRING_DEDUPE_LENGTH = 40;
|
||||||
|
const BOT_CHUNK_MERGE_WINDOW_MS = 15_000;
|
||||||
|
const STATUS_OUTPUT_PREFIX_RE =
|
||||||
|
/^(?:STEP_DONE|TASK_DONE|DONE_WITH_CONCERNS|DONE|BLOCKED|NEEDS_CONTEXT)\b/;
|
||||||
|
|
||||||
function isDuplicateOutputMessage(
|
function isDuplicateOutputMessage(
|
||||||
message: RoomThreadEntry,
|
message: RoomThreadEntry,
|
||||||
outputEntries: RoomThreadEntry[],
|
outputEntries: RoomThreadEntry[],
|
||||||
@@ -96,7 +101,11 @@ function isDuplicateOutputMessage(
|
|||||||
const contentMatches =
|
const contentMatches =
|
||||||
outputText === messageText ||
|
outputText === messageText ||
|
||||||
outputText.startsWith(messageText) ||
|
outputText.startsWith(messageText) ||
|
||||||
messageText.startsWith(outputText);
|
messageText.startsWith(outputText) ||
|
||||||
|
(messageText.length >= MIN_SUBSTRING_DEDUPE_LENGTH &&
|
||||||
|
outputText.includes(messageText)) ||
|
||||||
|
(outputText.length >= MIN_SUBSTRING_DEDUPE_LENGTH &&
|
||||||
|
messageText.includes(outputText));
|
||||||
if (!contentMatches) return false;
|
if (!contentMatches) return false;
|
||||||
const outputTime = new Date(output.timestamp).getTime();
|
const outputTime = new Date(output.timestamp).getTime();
|
||||||
if (!Number.isFinite(messageTime) || !Number.isFinite(outputTime)) {
|
if (!Number.isFinite(messageTime) || !Number.isFinite(outputTime)) {
|
||||||
@@ -106,6 +115,57 @@ function isDuplicateOutputMessage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function entryTimeMs(entry: RoomThreadEntry): number | null {
|
||||||
|
const time = new Date(entry.timestamp).getTime();
|
||||||
|
return Number.isFinite(time) ? time : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldMergeAdjacentBotChunk(
|
||||||
|
previous: RoomThreadEntry,
|
||||||
|
next: RoomThreadEntry,
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
previous.sourceKind === 'agent_output' ||
|
||||||
|
next.sourceKind === 'agent_output'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (previous.senderName !== next.senderName) return false;
|
||||||
|
if (previous.sourceKind !== next.sourceKind) return false;
|
||||||
|
if (!previous.isBotMessage || !next.isBotMessage) return false;
|
||||||
|
if (STATUS_OUTPUT_PREFIX_RE.test(next.content.trimStart())) return false;
|
||||||
|
const previousTime = entryTimeMs(previous);
|
||||||
|
const nextTime = entryTimeMs(next);
|
||||||
|
if (previousTime === null || nextTime === null) {
|
||||||
|
return previous.timestamp === next.timestamp;
|
||||||
|
}
|
||||||
|
return Math.abs(nextTime - previousTime) <= BOT_CHUNK_MERGE_WINDOW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeEntryContent(left: string, right: string): string {
|
||||||
|
const trimmedLeft = left.trimEnd();
|
||||||
|
const trimmedRight = right.trimStart();
|
||||||
|
if (!trimmedLeft) return trimmedRight;
|
||||||
|
if (!trimmedRight) return trimmedLeft;
|
||||||
|
return `${trimmedLeft}\n\n${trimmedRight}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeAdjacentBotChunks(entries: RoomThreadEntry[]): RoomThreadEntry[] {
|
||||||
|
const merged: RoomThreadEntry[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const previous = merged.at(-1);
|
||||||
|
if (previous && shouldMergeAdjacentBotChunk(previous, entry)) {
|
||||||
|
previous.id = `${previous.id}+${entry.id}`;
|
||||||
|
previous.content = mergeEntryContent(previous.content, entry.content);
|
||||||
|
previous.verdict ??= entry.verdict;
|
||||||
|
previous.turnNumber ??= entry.turnNumber;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
merged.push({ ...entry });
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildRoomThreadEntries({
|
export function buildRoomThreadEntries({
|
||||||
messages,
|
messages,
|
||||||
outputs,
|
outputs,
|
||||||
@@ -128,7 +188,8 @@ export function buildRoomThreadEntries({
|
|||||||
.filter((message) => !isInternalProtocolPayload(message.content))
|
.filter((message) => !isInternalProtocolPayload(message.content))
|
||||||
.map(toMessageEntry);
|
.map(toMessageEntry);
|
||||||
|
|
||||||
return [...chatEntries, ...optimisticPending, ...outputEntries].sort((a, b) =>
|
const entries = [...chatEntries, ...optimisticPending, ...outputEntries].sort(
|
||||||
a.timestamp.localeCompare(b.timestamp),
|
(a, b) => a.timestamp.localeCompare(b.timestamp),
|
||||||
);
|
);
|
||||||
|
return mergeAdjacentBotChunks(entries);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4234,6 +4234,9 @@ progress::-moz-progress-bar {
|
|||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parsed-body pre {
|
.parsed-body pre {
|
||||||
|
|||||||
Reference in New Issue
Block a user