diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx
index 0af0d83..74e7bb3 100644
--- a/apps/dashboard/src/App.tsx
+++ b/apps/dashboard/src/App.tsx
@@ -1,6 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
-import ReactMarkdown from 'react-markdown';
-import remarkGfm from 'remark-gfm';
import { Send } from 'lucide-react';
import {
@@ -62,6 +60,7 @@ import {
isInternalProtocolPayload,
isWatcherRoomMessage,
} from './roomThread';
+import { ParsedBody } from './ParsedBody';
import './styles.css';
interface DashboardState {
@@ -163,360 +162,6 @@ function humanizeError(raw: string, t: Messages): string {
return raw || t.error.unknown;
}
-type ParsedSegment =
- | { kind: 'text'; value: string }
- | { kind: 'code'; lang: string; value: string }
- | { kind: 'inline-code'; value: string }
- | { kind: 'bold'; value: string }
- | { kind: 'italic'; value: string }
- | { kind: 'strike'; value: string }
- | { kind: 'url'; href: string; label: string }
- | { kind: 'mention'; value: string }
- | { kind: 'marker'; tone: 'done' | 'fail' | 'info'; value: string }
- | { kind: 'path'; value: string };
-
-const URL_RE = /\bhttps?:\/\/[^\s)<>]+/g;
-const MENTION_RE = /(^|\s)(@[A-Za-z0-9_-]+)/g;
-const MARKER_RE =
- /\b(TASK_DONE|TASK_FAILED|TASK_FAIL|TASK_OK|DONE|FAILED|FAIL|OK|ERROR|WARNING|RETRY|STATUS|PASS|PASSED)\b/g;
-const PATH_RE = /(?:^|\s)((?:\/|\.\/|\.\.\/)[^\s)<>]+)/g;
-
-function tokenizeInline(input: string): ParsedSegment[] {
- if (!input) return [];
- const segments: ParsedSegment[] = [];
- const matches: Array<{
- start: number;
- end: number;
- seg: ParsedSegment;
- priority: number;
- }> = [];
- for (const m of input.matchAll(/`([^`\n]+)`/g)) {
- matches.push({
- start: m.index ?? 0,
- end: (m.index ?? 0) + m[0].length,
- seg: { kind: 'inline-code', value: m[1] },
- priority: 0,
- });
- }
- for (const m of input.matchAll(/\*\*([^*\n]+)\*\*/g)) {
- matches.push({
- start: m.index ?? 0,
- end: (m.index ?? 0) + m[0].length,
- seg: { kind: 'bold', value: m[1] },
- priority: 0,
- });
- }
- for (const m of input.matchAll(/(?
- a.start === b.start ? a.priority - b.priority : a.start - b.start,
- );
- let cursor = 0;
- const used: Array<[number, number]> = [];
- for (const match of matches) {
- if (match.start < cursor) continue;
- const overlaps = used.some(
- ([s, e]) => !(match.end <= s || match.start >= e),
- );
- if (overlaps) continue;
- if (match.start > cursor) {
- segments.push({ kind: 'text', value: input.slice(cursor, match.start) });
- }
- segments.push(match.seg);
- used.push([match.start, match.end]);
- cursor = match.end;
- }
- if (cursor < input.length) {
- segments.push({ kind: 'text', value: input.slice(cursor) });
- }
- return segments;
-}
-
-type ParsedBlock =
- | { kind: 'paragraph'; segments: ParsedSegment[] }
- | { kind: 'code'; lang: string; value: string }
- | { kind: 'heading'; level: 1 | 2 | 3 | 4; segments: ParsedSegment[] }
- | { kind: 'list'; items: ParsedSegment[][] }
- | { kind: 'olist'; items: ParsedSegment[][]; start: number }
- | { kind: 'quote'; segments: ParsedSegment[] };
-
-function parseTextBlocks(raw: string): ParsedBlock[] {
- const blocks: ParsedBlock[] = [];
- const lines = raw.split(/\n/);
- let i = 0;
- while (i < lines.length) {
- const line = lines[i];
- const trimmed = line.trim();
- if (!trimmed) {
- i++;
- continue;
- }
- const heading = trimmed.match(/^(#{1,4})\s+(.*)$/);
- if (heading) {
- const level = Math.min(heading[1].length, 4) as 1 | 2 | 3 | 4;
- blocks.push({
- kind: 'heading',
- level,
- segments: tokenizeInline(heading[2]),
- });
- i++;
- continue;
- }
- if (/^>\s+/.test(trimmed)) {
- const quoteLines: string[] = [];
- while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
- quoteLines.push(lines[i].replace(/^\s*>\s?/, ''));
- i++;
- }
- blocks.push({
- kind: 'quote',
- segments: tokenizeInline(quoteLines.join('\n')),
- });
- continue;
- }
- if (/^[-*]\s+/.test(trimmed)) {
- const items: ParsedSegment[][] = [];
- while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
- const itemText = lines[i].replace(/^\s*[-*]\s+/, '');
- items.push(tokenizeInline(itemText));
- i++;
- }
- blocks.push({ kind: 'list', items });
- continue;
- }
- const olistMatch = trimmed.match(/^(\d+)\.\s+/);
- if (olistMatch) {
- const items: ParsedSegment[][] = [];
- const start = Number(olistMatch[1]);
- while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
- const itemText = lines[i].replace(/^\s*\d+\.\s+/, '');
- items.push(tokenizeInline(itemText));
- i++;
- }
- blocks.push({ kind: 'olist', items, start });
- continue;
- }
- // Plain paragraph: gather consecutive non-empty lines
- const paraLines: string[] = [];
- while (
- i < lines.length &&
- lines[i].trim() &&
- !/^(#{1,4})\s+/.test(lines[i].trim()) &&
- !/^[-*]\s+/.test(lines[i].trim()) &&
- !/^\d+\.\s+/.test(lines[i].trim()) &&
- !/^>\s+/.test(lines[i].trim())
- ) {
- paraLines.push(lines[i]);
- i++;
- }
- if (paraLines.length > 0) {
- blocks.push({
- kind: 'paragraph',
- segments: tokenizeInline(paraLines.join('\n')),
- });
- }
- }
- return blocks;
-}
-
-function parseMessageBody(raw: string | null | undefined): ParsedBlock[] {
- if (!raw) return [];
- const cleaned = raw
- .replace(/<\/?internal[^>]*>/gi, '')
- .replace(/<\/?intern\.{3}/gi, '')
- .trim();
- if (!cleaned) return [];
- const blocks: ParsedBlock[] = [];
- const fenceRe = /```([A-Za-z0-9_+-]*)\n?([\s\S]*?)```/g;
- let cursor = 0;
- let m: RegExpExecArray | null;
- while ((m = fenceRe.exec(cleaned)) !== null) {
- if (m.index > cursor) {
- const pre = cleaned.slice(cursor, m.index).trim();
- if (pre) blocks.push(...parseTextBlocks(pre));
- }
- blocks.push({ kind: 'code', lang: m[1] || '', value: m[2] });
- cursor = m.index + m[0].length;
- }
- if (cursor < cleaned.length) {
- const tail = cleaned.slice(cursor).trim();
- if (tail) blocks.push(...parseTextBlocks(tail));
- }
- return blocks;
-}
-
-function renderInlineSegments(
- segments: ParsedSegment[],
- truncate: number | undefined,
- usedRef: { used: number },
-): ReactNode[] {
- const out: ReactNode[] = [];
- segments.forEach((s, j) => {
- if (truncate && usedRef.used >= truncate) return;
- const remaining = truncate ? truncate - usedRef.used : Infinity;
- if (s.kind === 'text') {
- const v =
- s.value.length > remaining
- ? `${s.value.slice(0, remaining)}…`
- : s.value;
- usedRef.used += v.length;
- out.push({v});
- return;
- }
- if (s.kind === 'inline-code') {
- usedRef.used += s.value.length;
- out.push(
-
- {s.value}
- ,
- );
- return;
- }
- if (s.kind === 'bold') {
- usedRef.used += s.value.length;
- out.push({s.value});
- return;
- }
- if (s.kind === 'italic') {
- usedRef.used += s.value.length;
- out.push({s.value});
- return;
- }
- if (s.kind === 'strike') {
- usedRef.used += s.value.length;
- out.push({s.value});
- return;
- }
- if (s.kind === 'url') {
- usedRef.used += s.label.length;
- out.push(
-
- {s.label}
- ,
- );
- return;
- }
- if (s.kind === 'mention') {
- usedRef.used += s.value.length;
- out.push(
-
- {s.value}
- ,
- );
- return;
- }
- if (s.kind === 'path') {
- usedRef.used += s.value.length;
- out.push(
-
- {s.value}
- ,
- );
- return;
- }
- if (s.kind === 'marker') {
- usedRef.used += s.value.length;
- out.push(
-
- {s.value}
- ,
- );
- return;
- }
- });
- return out;
-}
-
-function ParsedBody({
- text,
- truncate,
-}: {
- text: string | null | undefined;
- truncate?: number;
-}): ReactNode {
- const cleaned = useMemo(() => {
- if (!text) return '';
- let out = text
- .replace(SECRET_ASSIGNMENT_RE, (_m, key) => `${key}=***`)
- .replace(SECRET_VALUE_RE, '***');
- if (truncate && out.length > truncate) {
- out = out.slice(0, truncate) + '…';
- }
- return out;
- }, [text, truncate]);
-
- if (!cleaned.trim()) {
- return —;
- }
-
- return (
-