diff --git a/packages/web/src/components/issues-board/issue-activity-copy-utils.ts b/packages/web/src/components/issues-board/issue-activity-copy-utils.ts index cb62a62f..a58b43bf 100644 --- a/packages/web/src/components/issues-board/issue-activity-copy-utils.ts +++ b/packages/web/src/components/issues-board/issue-activity-copy-utils.ts @@ -1,17 +1,19 @@ import type { TaskActivityRecord } from "@/lib/api"; +import { formatOperatorActivityText } from "./issue-activity-display-utils"; + export function createActivityCopyText(activity: TaskActivityRecord): string { const lines = [`${activity.actorId} ${activity.title}`]; if (activity.status?.trim()) { lines.push(`Status: ${activity.status}`); } - const body = activity.body.trim(); + const body = formatOperatorActivityText(activity.body); if (body) { lines.push("", body); } const stepLines = activity.steps?.flatMap((step) => { const summary = `${step.stepNumber}. ${step.action} [${step.status}]`; - const detail = step.detail?.trim(); + const detail = step.detail ? formatOperatorActivityText(step.detail) : ""; return detail ? [summary, ` ${detail}`] : [summary]; }); if (stepLines?.length) { diff --git a/packages/web/src/components/issues-board/issue-activity-display-utils.ts b/packages/web/src/components/issues-board/issue-activity-display-utils.ts new file mode 100644 index 00000000..fc00c8d4 --- /dev/null +++ b/packages/web/src/components/issues-board/issue-activity-display-utils.ts @@ -0,0 +1,154 @@ +const LABELED_SAFE_FIELDS = ["result", "thinking", "planning"] as const; +const UNLABELED_SAFE_FIELDS = ["text", "message", "detail", "summary"] as const; +const MAX_FORMAT_DEPTH = 2; +const RAW_JSON_FIELD_LINE = + /^"?(?:command|payload|detail|arguments|args|input|parameters|recipient_name|tool_name|toolName|schema)"?\s*:/; + +export function formatOperatorActivityText(rawText: string): string { + return formatText(rawText, 0); +} + +function formatText(rawText: string, depth: number): string { + const trimmed = rawText.trim(); + if (!trimmed) return ""; + const structuredText = formatStructuredValue(parseJsonValue(trimmed), depth); + if (structuredText !== null) return structuredText; + return formatMixedLines(trimmed.split(/\r?\n/), depth); +} + +function formatMixedLines(lines: string[], depth: number): string { + const output: string[] = []; + for (let index = 0; index < lines.length; index += 1) { + const block = readJsonBlock(lines, index); + if (block) { + const text = formatJsonBlock(block.text, depth); + if (text) output.push(text); + index = block.endIndex; + continue; + } + const text = formatLine(lines[index] ?? "", depth); + if (text) output.push(text); + } + return output.join("\n"); +} + +function readJsonBlock( + lines: string[], + startIndex: number, +): { text: string; endIndex: number } | null { + const firstLine = lines[startIndex]?.trim() ?? ""; + if (!firstLine.startsWith("{") && !firstLine.startsWith("[")) return null; + const blockLines: string[] = []; + for (let index = startIndex; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + blockLines.push(line); + if (parseJsonValue(blockLines.join("\n").trim()) !== null) { + return { text: blockLines.join("\n"), endIndex: index }; + } + } + return null; +} + +function formatJsonBlock(text: string, depth: number): string { + const value = parseJsonValue(text.trim()); + const structuredText = formatStructuredValue(value, depth); + if (structuredText !== null) return structuredText; + return hasRawJsonDumpField(text) ? "" : text.trim(); +} + +function formatLine(line: string, depth: number): string { + const trimmed = line.trim(); + if (!trimmed || isRawJsonDumpLine(trimmed)) return ""; + const structuredText = formatStructuredValue(parseJsonValue(trimmed), depth); + return structuredText ?? trimmed; +} + +function formatStructuredValue(value: unknown, depth: number): string | null { + if (value === null) return null; + if (Array.isArray(value)) { + const lines = value + .map((item) => formatStructuredValue(item, depth)) + .filter((line): line is string => Boolean(line)); + return lines.length > 0 ? lines.join("\n") : ""; + } + if (!isRecord(value)) return null; + const lines = [ + ...LABELED_SAFE_FIELDS.flatMap((field) => + formatSafeField(value, field, depth, labelForField(field)), + ), + ...UNLABELED_SAFE_FIELDS.flatMap((field) => + formatSafeField(value, field, depth), + ), + ]; + const item = value.item; + if (isRecord(item)) { + const itemText = formatStructuredValue(item, depth); + if (itemText) lines.push(itemText); + } + return uniqueLines(lines).join("\n"); +} + +function formatSafeField( + record: Record, + field: string, + depth: number, + label?: string, +): string[] { + const value = record[field]; + if (typeof value !== "string") return []; + const text = + depth >= MAX_FORMAT_DEPTH ? value.trim() : formatText(value, depth + 1); + if (!text) return []; + return [label ? `${label}: ${text}` : text]; +} + +function parseJsonValue(text: string): unknown { + if (!looksLikeJson(text)) return null; + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } +} + +function looksLikeJson(text: string): boolean { + return ( + (text.startsWith("{") && text.endsWith("}")) || + (text.startsWith("[") && text.endsWith("]")) + ); +} + +function isRawJsonDumpLine(text: string): boolean { + return ( + text === "{" || + text === "}" || + text === "[" || + text === "]" || + text === "}," || + text === "]," || + RAW_JSON_FIELD_LINE.test(text) + ); +} + +function hasRawJsonDumpField(text: string): boolean { + return text + .split(/\r?\n/) + .some((line) => RAW_JSON_FIELD_LINE.test(line.trim())); +} + +function labelForField(field: string): string { + return field.charAt(0).toUpperCase() + field.slice(1); +} + +function uniqueLines(lines: string[]): string[] { + const seen = new Set(); + return lines.filter((line) => { + if (seen.has(line)) return false; + seen.add(line); + return true; + }); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/web/src/components/issues-board/issue-activity.tsx b/packages/web/src/components/issues-board/issue-activity.tsx index fe300f3f..213fd6c2 100644 --- a/packages/web/src/components/issues-board/issue-activity.tsx +++ b/packages/web/src/components/issues-board/issue-activity.tsx @@ -9,6 +9,7 @@ import { isApiRequestError } from "@/lib/api"; import { useTaskActivityQuery } from "@/lib/api/task-activity-query"; import { IssueActivityCardMenu } from "./issue-activity-card-menu"; +import { formatOperatorActivityText } from "./issue-activity-display-utils"; import { ActivityRichText } from "./issue-activity-rich-text"; import { createActivityDisclosureState, @@ -96,7 +97,8 @@ function ActivityItem({ }: { activity: TaskActivityRecord; }): ReactElement { - const hasCard = Boolean(activity.body.trim()) || activity.steps?.length; + const body = formatOperatorActivityText(activity.body); + const hasCard = Boolean(body) || activity.steps?.length; if (hasCard) { return ; } @@ -121,6 +123,7 @@ function ActivityCard({ }: { activity: TaskActivityRecord; }): ReactElement { + const body = formatOperatorActivityText(activity.body); return (
@@ -140,7 +143,7 @@ function ActivityCard({
- {activity.body.trim() ? : null} + {body ? : null} {activity.steps?.length ? : null}
); @@ -164,15 +167,22 @@ function ActivitySteps({ {step.status} - {step.detail ? ( - {step.detail} - ) : null} + {step.detail ? : null} ))} ); } +function ActivityStepDetail({ + detail, +}: { + detail: string; +}): ReactElement | null { + const text = formatOperatorActivityText(detail); + return text ? {text} : null; +} + function ActivityIcon({ activity, isLarge = false, diff --git a/packages/web/src/components/issues-board/issue-card.tsx b/packages/web/src/components/issues-board/issue-card.tsx index 59550984..182fb560 100644 --- a/packages/web/src/components/issues-board/issue-card.tsx +++ b/packages/web/src/components/issues-board/issue-card.tsx @@ -14,6 +14,7 @@ import { Typography } from "@/components/ui/typography"; import type { ProjectBoardTaskRecord } from "@/lib/api"; import { cn } from "@/lib/utils"; +import { formatOperatorActivityText } from "./issue-activity-display-utils"; import { getPriorityLabel, isAgentTask } from "./issues-board-utils"; interface IssueCardProps { @@ -98,6 +99,8 @@ export function IssueCard({ onOpenContextMenu(task, { x: event.clientX, y: event.clientY }); } + const content = formatOperatorActivityText(task.content); + return (