diff --git a/.gitignore b/.gitignore index 6b6797b57..369cd8e00 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ agent-orchestrator.yaml # OS-specific files .DS_Store Thumbs.db +.gstack/ diff --git a/packages/core/src/__tests__/canvas-log.test.ts b/packages/core/src/__tests__/canvas-log.test.ts index 8e6048575..96797de58 100644 --- a/packages/core/src/__tests__/canvas-log.test.ts +++ b/packages/core/src/__tests__/canvas-log.test.ts @@ -163,6 +163,7 @@ describe("parseUnifiedDiff", () => { { kind: "context", text: "context" }, { kind: "del", text: "old" }, { kind: "add", text: "new" }, + { kind: "context", text: "" }, ]); }); @@ -184,6 +185,7 @@ describe("parseUnifiedDiff", () => { { kind: "context", text: "heading" }, { kind: "del", text: "--" }, { kind: "add", text: "++added text" }, + { kind: "context", text: "" }, ]); }); diff --git a/packages/core/src/canvas-log.ts b/packages/core/src/canvas-log.ts index df1db000c..d671552b0 100644 --- a/packages/core/src/canvas-log.ts +++ b/packages/core/src/canvas-log.ts @@ -238,9 +238,22 @@ export function parseUnifiedDiff(diff: string): { files: CanvasDiffFile[]; trunc truncated = true; break; } - const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line); - const path = match?.[2] ?? ""; - const oldPath = match?.[1]; + // Parse "diff --git a/ b/" without a regex. The naive + // regex `/^diff --git a\/(.+) b\/(.+)$/` is polynomial-backtracking on + // pathological inputs like "a/a b/a b/a b/a..." (CodeQL js/redos). + // lastIndexOf is O(n) and works for the common case where paths don't + // contain " b/"; for paths that do, git quotes them anyway. + const prefix = "diff --git a/"; + let path = ""; + let oldPath: string | undefined; + if (line.startsWith(prefix)) { + const rest = line.slice(prefix.length); + const sep = rest.lastIndexOf(" b/"); + if (sep >= 0) { + oldPath = rest.slice(0, sep); + path = rest.slice(sep + 3); + } + } current = { path, oldPath: oldPath !== path ? oldPath : undefined, @@ -259,7 +272,19 @@ export function parseUnifiedDiff(diff: string): { files: CanvasDiffFile[]; trunc if (currentHunk) current.hunks.push(currentHunk); currentHunk = { header: line, lines: [] }; } else if (currentHunk) { - if (line === "") continue; + // Bare-empty lines inside a hunk represent a blank context line that some + // diff generators emit without the leading space prefix. Treat them as + // empty context rather than dropping (which would misalign surrounding + // lines in the rendered hunk). + if (line === "") { + currentHunk.lines.push({ kind: "context", text: "" }); + totalLines++; + if (totalLines >= DIFF_MAX_LINES) { + truncated = true; + break; + } + continue; + } const kind: "add" | "del" | "context" = line.startsWith("+") ? "add" : line.startsWith("-") diff --git a/packages/web/src/components/canvas/renderers/DiffCanvas.tsx b/packages/web/src/components/CanvasDiff.tsx similarity index 98% rename from packages/web/src/components/canvas/renderers/DiffCanvas.tsx rename to packages/web/src/components/CanvasDiff.tsx index 2efe39ead..2083b34ca 100644 --- a/packages/web/src/components/canvas/renderers/DiffCanvas.tsx +++ b/packages/web/src/components/CanvasDiff.tsx @@ -5,7 +5,7 @@ import type { CanvasArtifact, CanvasDiffFile } from "@aoagents/ao-core"; type Props = { canvas: Extract }; -export function DiffCanvas({ canvas }: Props) { +export function CanvasDiff({ canvas }: Props) { return (
{canvas.payload.files.map((file) => ( diff --git a/packages/web/src/components/CanvasMarkdown.tsx b/packages/web/src/components/CanvasMarkdown.tsx new file mode 100644 index 000000000..73a461762 --- /dev/null +++ b/packages/web/src/components/CanvasMarkdown.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { Fragment, type ReactNode } from "react"; +import type { CanvasArtifact } from "@aoagents/ao-core"; + +type Props = { canvas: Extract }; + +export function CanvasMarkdown({ canvas }: Props) { + return ( +
+ {renderMarkdown(canvas.payload.markdown)} +
+ ); +} + +// Minimal markdown subset: ATX headings (# .. ######), fenced code blocks +// (``` … ```), unordered list groups (- / *), paragraphs, and inline formatting +// (**bold**, *italic*, `code`). Plain-text only — no HTML pass-through, so user +// content can't inject markup. Heavier markdown features (tables, links, images, +// nested lists) are out of scope; agents that need them can ship a `table` or +// `diff` canvas type instead. +function renderMarkdown(input: string): ReactNode[] { + const lines = input.split("\n"); + const blocks: ReactNode[] = []; + let i = 0; + let key = 0; + + while (i < lines.length) { + const line = lines[i] ?? ""; + + if (line.startsWith("```")) { + const buf: string[] = []; + i++; + while (i < lines.length && !(lines[i] ?? "").startsWith("```")) { + buf.push(lines[i] ?? ""); + i++; + } + if (i < lines.length) i++; + blocks.push( +
+          {buf.join("\n")}
+        
, + ); + continue; + } + + const heading = /^(#{1,6})\s+(.*)$/.exec(line); + if (heading) { + const level = (heading[1] ?? "#").length; + const text = heading[2] ?? ""; + const sizes = ["text-lg", "text-base", "text-sm", "text-sm", "text-sm", "text-sm"]; + blocks.push( +

+ {renderInline(text)} +

, + ); + i++; + continue; + } + + if (/^[-*]\s+/.test(line)) { + const items: string[] = []; + while (i < lines.length && /^[-*]\s+/.test(lines[i] ?? "")) { + items.push((lines[i] ?? "").replace(/^[-*]\s+/, "")); + i++; + } + blocks.push( +
    + {items.map((it, idx) => ( +
  • {renderInline(it)}
  • + ))} +
, + ); + continue; + } + + if (line.trim() === "") { + i++; + continue; + } + + const buf: string[] = []; + while ( + i < lines.length && + (lines[i] ?? "").trim() !== "" && + !(lines[i] ?? "").startsWith("```") && + !/^(#{1,6})\s+/.test(lines[i] ?? "") && + !/^[-*]\s+/.test(lines[i] ?? "") + ) { + buf.push(lines[i] ?? ""); + i++; + } + blocks.push( +

+ {renderInline(buf.join(" "))} +

, + ); + } + + return blocks; +} + +function renderInline(text: string): ReactNode[] { + // Tokens: **bold**, *italic*, `code`. Plain text fills the gaps. No HTML + // escaping needed — React renders strings as text nodes. + const out: ReactNode[] = []; + const re = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g; + let last = 0; + let key = 0; + for (const match of text.matchAll(re)) { + const idx = match.index ?? 0; + if (idx > last) out.push({text.slice(last, idx)}); + const tok = match[0]; + if (tok.startsWith("**")) { + out.push({tok.slice(2, -2)}); + } else if (tok.startsWith("`")) { + out.push( + + {tok.slice(1, -1)} + , + ); + } else { + out.push({tok.slice(1, -1)}); + } + last = idx + tok.length; + } + if (last < text.length) out.push({text.slice(last)}); + return out; +} diff --git a/packages/web/src/components/canvas/CanvasRail.tsx b/packages/web/src/components/CanvasRail.tsx similarity index 87% rename from packages/web/src/components/canvas/CanvasRail.tsx rename to packages/web/src/components/CanvasRail.tsx index dda197129..0c21a8b94 100644 --- a/packages/web/src/components/canvas/CanvasRail.tsx +++ b/packages/web/src/components/CanvasRail.tsx @@ -3,10 +3,10 @@ import { useEffect, useState } from "react"; import type { CanvasArtifact } from "@aoagents/ao-core"; import { useSessionCanvases } from "@/hooks/useSessionCanvases"; -import { MarkdownCanvas } from "./renderers/MarkdownCanvas"; -import { DiffCanvas } from "./renderers/DiffCanvas"; -import { TableCanvas } from "./renderers/TableCanvas"; -import { StatsCanvas } from "./renderers/StatsCanvas"; +import { CanvasMarkdown } from "./CanvasMarkdown"; +import { CanvasDiff } from "./CanvasDiff"; +import { CanvasTable } from "./CanvasTable"; +import { CanvasStats } from "./CanvasStats"; type Props = { sessionId: string }; @@ -30,7 +30,7 @@ export function CanvasRail({ sessionId }: Props) { if (!isOpen) { return ( -
+