* 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 * Extract dashboard ServicePanel component * Render dashboard live progress markdown * Extract dashboard SettingsPanel component * Fix structured attachment rendering
199 lines
6.3 KiB
TypeScript
199 lines
6.3 KiB
TypeScript
import type { DashboardRoomActivity } from './api';
|
|
|
|
type RoomMessage = DashboardRoomActivity['messages'][number];
|
|
type RoomOutput = NonNullable<
|
|
DashboardRoomActivity['pairedTask']
|
|
>['outputs'][number];
|
|
|
|
export type RoomThreadEntry = {
|
|
id: string;
|
|
senderName: string;
|
|
content: string;
|
|
attachments?: RoomMessage['attachments'];
|
|
timestamp: string;
|
|
isFromMe: boolean;
|
|
isBotMessage: boolean;
|
|
sourceKind: string;
|
|
verdict?: string | null;
|
|
turnNumber?: number;
|
|
};
|
|
|
|
const WATCHER_RE =
|
|
/^\s*(?:CI 완료|Build |Deploy |Lint |Release |\[CI\]|\[Watcher\]|GitHub Actions)/i;
|
|
|
|
export function isInternalProtocolPayload(
|
|
content: string | null | undefined,
|
|
): boolean {
|
|
if (!content) return false;
|
|
if (/<\/?(sub-agent[-\w]*|tool-call|internal)\b/i.test(content)) return true;
|
|
if (/"author"\s*:\s*"[^"]+"\s*,\s*"recipient"\s*:\s*"[^"]+"/.test(content)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function isWatcherRoomMessage(message: RoomMessage): boolean {
|
|
return message.sourceKind === 'bot' && WATCHER_RE.test(message.content);
|
|
}
|
|
|
|
function isThreadChatMessage(message: RoomMessage): boolean {
|
|
if (isInternalProtocolPayload(message.content)) return false;
|
|
if (isWatcherRoomMessage(message)) return false;
|
|
return (
|
|
message.sourceKind === 'human' ||
|
|
message.sourceKind === 'ipc_injected_human' ||
|
|
message.sourceKind === 'trusted_external_bot' ||
|
|
message.sourceKind === 'bot'
|
|
);
|
|
}
|
|
|
|
function toMessageEntry(message: RoomMessage): RoomThreadEntry {
|
|
return {
|
|
id: message.id,
|
|
senderName: message.senderName,
|
|
content: message.content,
|
|
attachments: message.attachments,
|
|
timestamp: message.timestamp,
|
|
isFromMe: message.isFromMe,
|
|
isBotMessage: message.isBotMessage,
|
|
sourceKind: message.sourceKind,
|
|
};
|
|
}
|
|
|
|
function toOutputEntry(output: RoomOutput): RoomThreadEntry | null {
|
|
if (isInternalProtocolPayload(output.outputText)) return null;
|
|
return {
|
|
id: `out:${output.id}`,
|
|
senderName: output.role,
|
|
content: output.outputText,
|
|
attachments: output.attachments,
|
|
timestamp: output.createdAt,
|
|
isFromMe: false,
|
|
isBotMessage: true,
|
|
sourceKind: 'agent_output',
|
|
verdict: output.verdict,
|
|
turnNumber: output.turnNumber,
|
|
};
|
|
}
|
|
|
|
function messageKey(message: { content: string; senderName: string }): string {
|
|
return `${message.senderName}\u0001${message.content}`;
|
|
}
|
|
|
|
function normalizeForDedupe(value: string): string {
|
|
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(
|
|
message: RoomThreadEntry,
|
|
outputEntries: RoomThreadEntry[],
|
|
): boolean {
|
|
if (message.sourceKind !== 'bot') return false;
|
|
const messageText = normalizeForDedupe(message.content);
|
|
if (!messageText) return false;
|
|
const messageTime = new Date(message.timestamp).getTime();
|
|
|
|
return outputEntries.some((output) => {
|
|
const outputText = normalizeForDedupe(output.content);
|
|
if (!outputText) return false;
|
|
const contentMatches =
|
|
outputText === messageText ||
|
|
outputText.startsWith(messageText) ||
|
|
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;
|
|
const outputTime = new Date(output.timestamp).getTime();
|
|
if (!Number.isFinite(messageTime) || !Number.isFinite(outputTime)) {
|
|
return true;
|
|
}
|
|
return Math.abs(outputTime - messageTime) <= 120_000;
|
|
});
|
|
}
|
|
|
|
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({
|
|
messages,
|
|
outputs,
|
|
pendingMessages = [],
|
|
}: {
|
|
messages: RoomMessage[];
|
|
outputs: RoomOutput[];
|
|
pendingMessages?: RoomMessage[];
|
|
}): RoomThreadEntry[] {
|
|
const confirmedSet = new Set(messages.map(messageKey));
|
|
const outputEntries = outputs
|
|
.map(toOutputEntry)
|
|
.filter((entry): entry is RoomThreadEntry => Boolean(entry));
|
|
const chatEntries = messages
|
|
.filter(isThreadChatMessage)
|
|
.map(toMessageEntry)
|
|
.filter((entry) => !isDuplicateOutputMessage(entry, outputEntries));
|
|
const optimisticPending = pendingMessages
|
|
.filter((message) => !confirmedSet.has(messageKey(message)))
|
|
.filter((message) => !isInternalProtocolPayload(message.content))
|
|
.map(toMessageEntry);
|
|
|
|
const entries = [...chatEntries, ...optimisticPending, ...outputEntries].sort(
|
|
(a, b) => a.timestamp.localeCompare(b.timestamp),
|
|
);
|
|
return mergeAdjacentBotChunks(entries);
|
|
}
|