feat(maestro): wire React app as sole web entry point
Deletes the vanilla shell and mounts App.tsx on #root. main.ts and the 23 views it drove are gone; App.tsx composes the ported components — sidebar, multi-view, command bar, spawn modal, help and restart overlays, daemon link, drawer — around the catalog and layout stores. The pure parts of four deleted modules moved next to the components that still use them: the spawn-form model and spawnSimilar into components/spawn, the transcript's markdown/format helpers and the diff into components/structured, the help overlay's grouping into components/dialogs. Document-level event names now live in uiEvents.ts. Deduplicated two pairs the migration left side by side: the sidebar uses dialogs/KillDialog and common/ContextMenu. It also listens for the command bar's kill/rename events, which nothing had picked up since catalog.ts went away. styles.css stays as the token and base layer, with `#app` renamed to `#root` and the pane-body rules re-keyed off the classes the React panes actually carry. Closes #253
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
// A `Diff` content block, rendered as coloured hunks. ACP hands over whole
|
||||
// `oldText`/`newText`, so the lines and their `+added −removed` counts are
|
||||
// computed here (structuredDiff.ts) rather than parsed out of a patch.
|
||||
// computed here (diff.ts) rather than parsed out of a patch.
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { computeDiffLines, diffStats, type DiffLine } from "../../structuredDiff.js";
|
||||
import { computeDiffLines, diffStats, type DiffLine } from "./diff.js";
|
||||
import type { AcpDiff } from "../../types.js";
|
||||
import { Copyable } from "./Copyable.js";
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
setStructuredConfig,
|
||||
uploadStructuredFile,
|
||||
} from "../../api.js";
|
||||
import { formatContextTokens, formatElapsed } from "../../structuredRender.js";
|
||||
import { formatContextTokens, formatElapsed } from "./render.js";
|
||||
import type {
|
||||
AcpSessionConfigOption,
|
||||
StructuredApprovalDecision,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "../../structuredRender.js";
|
||||
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "./render.js";
|
||||
import type {
|
||||
AcpContentBlock,
|
||||
AcpPermissionOption,
|
||||
@@ -35,7 +35,7 @@ import { ToolIcon } from "./ToolIcon.js";
|
||||
// content rows: user / agent / thought
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Model-authored markdown, sanitized by structuredRender.ts before it enters
|
||||
/** Model-authored markdown, sanitized by render.ts before it enters
|
||||
* the DOM. Tables get their own scroller and links open out of the app. */
|
||||
function Prose({ html }: { html: string }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeDiffLines, diffStats, type DiffLine } from "./diff.js";
|
||||
|
||||
function texts(lines: DiffLine[]): Array<[string, string]> {
|
||||
return lines.map((l) => [l.kind, l.text]);
|
||||
}
|
||||
|
||||
describe("computeDiffLines", () => {
|
||||
it("renders a brand new file (no oldText) as all additions", () => {
|
||||
const lines = computeDiffLines(null, "a\nb\n");
|
||||
expect(texts(lines)).toEqual([
|
||||
["add", "a"],
|
||||
["add", "b"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a full deletion as all removals", () => {
|
||||
const lines = computeDiffLines("a\nb\n", "");
|
||||
expect(texts(lines)).toEqual([
|
||||
["del", "a"],
|
||||
["del", "b"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows a small replace as context around del/add", () => {
|
||||
const lines = computeDiffLines("one\ntwo\nthree\n", "one\ntwx\nthree\n");
|
||||
expect(texts(lines)).toEqual([
|
||||
["ctx", "one"],
|
||||
["del", "two"],
|
||||
["add", "twx"],
|
||||
["ctx", "three"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves identical text as pure context, uncollapsed when short", () => {
|
||||
const lines = computeDiffLines("a\nb\nc\n", "a\nb\nc\n");
|
||||
expect(texts(lines)).toEqual([
|
||||
["ctx", "a"],
|
||||
["ctx", "b"],
|
||||
["ctx", "c"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles an insertion without changing surrounding lines", () => {
|
||||
expect(texts(computeDiffLines("a\nc\n", "a\nb\nc\n"))).toEqual([
|
||||
["ctx", "a"],
|
||||
["add", "b"],
|
||||
["ctx", "c"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles a deletion without changing surrounding lines", () => {
|
||||
expect(texts(computeDiffLines("a\nb\nc\n", "a\nc\n"))).toEqual([
|
||||
["ctx", "a"],
|
||||
["del", "b"],
|
||||
["ctx", "c"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders two empty files without invented lines", () => {
|
||||
expect(computeDiffLines("", "")).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows when a final newline is added", () => {
|
||||
expect(texts(computeDiffLines("a", "a\n"))).toEqual([
|
||||
["del", "a"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
["add", "a"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows when a final newline is removed", () => {
|
||||
expect(texts(computeDiffLines("a\n", "a"))).toEqual([
|
||||
["del", "a"],
|
||||
["add", "a"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks an unchanged unterminated final context line", () => {
|
||||
expect(texts(computeDiffLines("a\nb\nc", "a\nX\nc"))).toEqual([
|
||||
["ctx", "a"],
|
||||
["del", "b"],
|
||||
["add", "X"],
|
||||
["ctx", "c"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks an unterminated new file only once", () => {
|
||||
const lines = computeDiffLines(null, "a");
|
||||
expect(texts(lines)).toEqual([
|
||||
["add", "a"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
expect(lines.filter((line) => line.kind === "eof")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("collapses a long unchanged interior run, keeping 3 lines of context each side", () => {
|
||||
const common = Array.from({ length: 20 }, (_, i) => `line${i}`);
|
||||
const oldText = [...common.slice(0, 10), "OLD", ...common.slice(10)].join("\n");
|
||||
const newText = [...common.slice(0, 10), "NEW", ...common.slice(10)].join("\n");
|
||||
const lines = computeDiffLines(oldText, newText);
|
||||
// The leading run keeps only its last 3 lines (adjacent to the change),
|
||||
// behind a marker for the rest; the trailing run mirrors that.
|
||||
expect(lines[0].kind).toBe("hunk");
|
||||
expect(lines[1]).toEqual({ kind: "ctx", text: "line7" });
|
||||
expect(lines[2]).toEqual({ kind: "ctx", text: "line8" });
|
||||
expect(lines[3]).toEqual({ kind: "ctx", text: "line9" });
|
||||
expect(lines[4].kind).toBe("del");
|
||||
expect(lines[5].kind).toBe("add");
|
||||
expect(lines[6]).toEqual({ kind: "ctx", text: "line10" });
|
||||
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("preserves the EOF marker when a long unterminated trailing run collapses", () => {
|
||||
const trailing = Array.from({ length: 10 }, (_, i) => `t${i}`);
|
||||
const oldText = ["OLD", ...trailing].join("\n");
|
||||
const newText = ["NEW", ...trailing].join("\n");
|
||||
|
||||
expect(texts(computeDiffLines(oldText, newText))).toEqual([
|
||||
["del", "OLD"],
|
||||
["add", "NEW"],
|
||||
["ctx", "t0"],
|
||||
["ctx", "t1"],
|
||||
["ctx", "t2"],
|
||||
["hunk", "⋯ 7 unchanged lines ⋯"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("produces one hunk for the long middle run, none for the short edges", () => {
|
||||
// Edge runs (2 lines) fit within their one-sided 3-line context budget;
|
||||
// only the interior run (10 lines, budgeted 3+3=6) needs to collapse.
|
||||
const middle = Array.from({ length: 10 }, (_, i) => `c${i}`);
|
||||
const oldText = ["lead0", "lead1", "OLD1", ...middle, "OLD2", "trail0", "trail1"].join("\n");
|
||||
const newText = ["lead0", "lead1", "NEW1", ...middle, "NEW2", "trail0", "trail1"].join("\n");
|
||||
const lines = computeDiffLines(oldText, newText);
|
||||
const dels = lines.filter((l) => l.kind === "del").map((l) => l.text);
|
||||
const adds = lines.filter((l) => l.kind === "add").map((l) => l.text);
|
||||
expect(dels).toEqual(["OLD1", "OLD2"]);
|
||||
expect(adds).toEqual(["NEW1", "NEW2"]);
|
||||
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(1);
|
||||
expect(texts(lines).slice(0, 2)).toEqual([
|
||||
["ctx", "lead0"],
|
||||
["ctx", "lead1"],
|
||||
]);
|
||||
expect(texts(lines).slice(-3)).toEqual([
|
||||
["ctx", "trail0"],
|
||||
["ctx", "trail1"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles an old file with no trailing newline", () => {
|
||||
const lines = computeDiffLines("a\nb", "a\nb\nc");
|
||||
expect(texts(lines)).toEqual([
|
||||
["ctx", "a"],
|
||||
["del", "b"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
["add", "b"],
|
||||
["add", "c"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to a bounded coarse diff for a pathological changed middle", () => {
|
||||
const oldLines = Array.from({ length: 501 }, (_, index) => `line${index}`);
|
||||
const newLines = [...oldLines.slice(1), oldLines[0]];
|
||||
const lines = computeDiffLines(`${oldLines.join("\n")}\n`, `${newLines.join("\n")}\n`);
|
||||
expect(lines).toHaveLength(1_002);
|
||||
expect(lines.slice(0, 501).every((line) => line.kind === "del")).toBe(true);
|
||||
expect(lines.slice(501).every((line) => line.kind === "add")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffStats", () => {
|
||||
it("counts add/del lines, ignoring context and hunk markers", () => {
|
||||
const lines = computeDiffLines("a\nb\nc\n", "a\nx\ny\nc\n");
|
||||
expect(diffStats(lines)).toEqual({ added: 2, removed: 1 });
|
||||
});
|
||||
|
||||
it("is zero for identical text", () => {
|
||||
expect(diffStats(computeDiffLines("same\n", "same\n"))).toEqual({ added: 0, removed: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// Line-level diff for a `ToolDiff` row's `Diff` content block (#140). ACP
|
||||
// hands over full `oldText`/`newText`, not a unified-diff patch, so the
|
||||
// coloured hunks the design calls for have to be computed here.
|
||||
|
||||
export type DiffLineKind = "add" | "del" | "ctx" | "hunk" | "eof";
|
||||
|
||||
export interface DiffLine {
|
||||
kind: DiffLineKind;
|
||||
/** The line's text (a `hunk` line is a human summary, not source text). */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Interior unchanged runs longer than this collapse to a summary line
|
||||
* flanked by this many lines of real context on each side. */
|
||||
const CONTEXT = 3;
|
||||
|
||||
/** Bounds the O(n·m) LCS table: above this cell count, the changed middle is
|
||||
* shown as one coarse del-then-add block instead of a fine-grained diff —
|
||||
* still correct, just not minimal. */
|
||||
const LCS_CELL_LIMIT = 250_000;
|
||||
|
||||
interface SourceLine {
|
||||
text: string;
|
||||
terminated: boolean;
|
||||
}
|
||||
|
||||
function splitLines(text: string): SourceLine[] {
|
||||
if (text === "") return [];
|
||||
const parts = text.split("\n");
|
||||
const hasFinalNewline = text.endsWith("\n");
|
||||
if (hasFinalNewline) parts.pop();
|
||||
return parts.map((line, index) => ({
|
||||
text: line,
|
||||
terminated: index < parts.length - 1 || hasFinalNewline,
|
||||
}));
|
||||
}
|
||||
|
||||
type Op = { kind: "add" | "del" | "ctx"; line: SourceLine };
|
||||
|
||||
function sameLine(a: SourceLine, b: SourceLine): boolean {
|
||||
return a.text === b.text && a.terminated === b.terminated;
|
||||
}
|
||||
|
||||
/** Longest-common-subsequence diff of two line arrays, already trimmed of any
|
||||
* shared prefix/suffix by the caller. */
|
||||
function lcsDiff(a: SourceLine[], b: SourceLine[]): Op[] {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
if (n === 0) return b.map((line) => ({ kind: "add", line }));
|
||||
if (m === 0) return a.map((line) => ({ kind: "del", line }));
|
||||
if (n * m > LCS_CELL_LIMIT) {
|
||||
return [...a.map((line) => ({ kind: "del" as const, line })), ...b.map((line) => ({ kind: "add" as const, line }))];
|
||||
}
|
||||
// dp[i][j] = length of the LCS of a[i:] and b[j:].
|
||||
const columns = m + 1;
|
||||
const dp = new Uint32Array((n + 1) * columns);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
dp[i * columns + j] = sameLine(a[i], b[j])
|
||||
? dp[(i + 1) * columns + j + 1] + 1
|
||||
: Math.max(dp[(i + 1) * columns + j], dp[i * columns + j + 1]);
|
||||
}
|
||||
}
|
||||
const ops: Op[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (sameLine(a[i], b[j])) {
|
||||
ops.push({ kind: "ctx", line: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[(i + 1) * columns + j] >= dp[i * columns + j + 1]) {
|
||||
ops.push({ kind: "del", line: a[i] });
|
||||
i++;
|
||||
} else {
|
||||
ops.push({ kind: "add", line: b[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) ops.push({ kind: "del", line: a[i++] });
|
||||
while (j < m) ops.push({ kind: "add", line: b[j++] });
|
||||
return ops;
|
||||
}
|
||||
|
||||
function diffLines(a: SourceLine[], b: SourceLine[]): Op[] {
|
||||
let pre = 0;
|
||||
while (pre < a.length && pre < b.length && sameLine(a[pre], b[pre])) pre++;
|
||||
let suf = 0;
|
||||
while (
|
||||
suf < a.length - pre &&
|
||||
suf < b.length - pre &&
|
||||
sameLine(a[a.length - 1 - suf], b[b.length - 1 - suf])
|
||||
) {
|
||||
suf++;
|
||||
}
|
||||
const prefixCtx: Op[] = a.slice(0, pre).map((line) => ({ kind: "ctx", line }));
|
||||
const suffixCtx: Op[] = a.slice(a.length - suf).map((line) => ({ kind: "ctx", line }));
|
||||
const middle = lcsDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf));
|
||||
return [...prefixCtx, ...middle, ...suffixCtx];
|
||||
}
|
||||
|
||||
/** Collapse a long run of unchanged lines to a summary, keeping up to
|
||||
* {@link CONTEXT} real lines adjacent to each neighbouring change — same
|
||||
* shape a unified diff gives a reader, without claiming line numbers this
|
||||
* module never tracked. A run touching the start/end of the whole diff has
|
||||
* no change to be adjacent to on that side, so it keeps no context there
|
||||
* (unless it is the *only* run, i.e. nothing changed at all). */
|
||||
function hunk(ops: Op[]): DiffLine[] {
|
||||
const out: DiffLine[] = [];
|
||||
const push = (op: Op) => {
|
||||
out.push({ kind: op.kind, text: op.line.text });
|
||||
if (!op.line.terminated) {
|
||||
out.push({ kind: "eof", text: "\\ No newline at end of file" });
|
||||
}
|
||||
};
|
||||
let i = 0;
|
||||
while (i < ops.length) {
|
||||
if (ops[i].kind !== "ctx") {
|
||||
push(ops[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (j < ops.length && ops[j].kind === "ctx") j++;
|
||||
const run = ops.slice(i, j);
|
||||
const wholeDiff = i === 0 && j === ops.length;
|
||||
const keepHead = wholeDiff || i > 0 ? CONTEXT : 0;
|
||||
const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0;
|
||||
if (run.length <= keepHead + keepTail) {
|
||||
run.forEach(push);
|
||||
} else {
|
||||
if (keepHead) run.slice(0, keepHead).forEach(push);
|
||||
const collapsed = run.slice(keepHead, run.length - keepTail);
|
||||
out.push({
|
||||
kind: "hunk",
|
||||
text: `⋯ ${collapsed.length} unchanged lines ⋯`,
|
||||
});
|
||||
if (collapsed.some((op) => !op.line.terminated)) {
|
||||
out.push({ kind: "eof", text: "\\ No newline at end of file" });
|
||||
}
|
||||
if (keepTail) run.slice(run.length - keepTail).forEach(push);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Coloured, hunked diff lines for a `Diff` content block. `oldText` absent
|
||||
* (a new file) renders every line of `newText` as added. */
|
||||
export function computeDiffLines(oldText: string | null | undefined, newText: string): DiffLine[] {
|
||||
const newLines = splitLines(newText);
|
||||
if (oldText == null) return hunk(newLines.map((line) => ({ kind: "add", line })));
|
||||
const ops = diffLines(splitLines(oldText), newLines);
|
||||
return hunk(ops);
|
||||
}
|
||||
|
||||
/** `+added -removed` counts straight off the diff — real derived data, not a
|
||||
* fabricated duration or line count. */
|
||||
export function diffStats(lines: DiffLine[]): { added: number; removed: number } {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const line of lines) {
|
||||
if (line.kind === "add") added++;
|
||||
else if (line.kind === "del") removed++;
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Pure transcript logic for the structured pane (#246) — the decisions
|
||||
// docs/STRUCTURED-VIEW.md leaves to a client, kept out of the components so
|
||||
// they can be tested without a DOM or a socket. Ported from structured.ts's
|
||||
// private helpers and the parts of structuredRender.ts that were data, not
|
||||
// private helpers and the parts of the old structuredRender.ts that were data, not
|
||||
// markup.
|
||||
|
||||
import type {
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
TranscriptRowKind,
|
||||
TurnMeta,
|
||||
} from "../../types.js";
|
||||
import { computeDiffLines, diffStats } from "../../structuredDiff.js";
|
||||
import { computeDiffLines, diffStats } from "./diff.js";
|
||||
|
||||
/** Human label behind a `ToolKind`. An unrecognised/absent kind is "Tool" —
|
||||
* never a blank label. */
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Pure text helpers for the structured transcript, kept when
|
||||
// structuredRender.ts's imperative DOM builders were deleted (#253):
|
||||
// markdown rendering (sanitized) and the elapsed/context formatters.
|
||||
|
||||
import { marked, Renderer } from "marked";
|
||||
|
||||
import { escapeHtml } from "../../html.js";
|
||||
|
||||
/** Small inline renderer retained for compact labels and focused tests. */
|
||||
export function renderInlineMarkdown(text: string): string {
|
||||
const escaped = escapeHtml(text);
|
||||
return escaped
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
|
||||
}
|
||||
|
||||
/** Render assistant output as GFM, then sanitize the complete result before it
|
||||
* enters the DOM. This gives transcripts proper tables, lists, code blocks and
|
||||
* links without trusting model-authored HTML or URL attributes. */
|
||||
export function renderMarkdown(text: string): string {
|
||||
const renderer = new Renderer();
|
||||
renderer.html = ({ text: raw }) => escapeHtml(raw);
|
||||
renderer.image = ({ text: alt }) => escapeHtml(alt ? `[image: ${alt}]` : "[image]");
|
||||
renderer.link = ({ href, title, tokens }) => {
|
||||
const label = renderer.parser.parseInline(tokens);
|
||||
const scheme = /^([a-z][a-z\d+.-]*):/i.exec(href.trim())?.[1]?.toLowerCase();
|
||||
if (scheme && !["http", "https", "mailto"].includes(scheme)) return label;
|
||||
const safeTitle = title ? ` title="${escapeHtml(title)}"` : "";
|
||||
return `<a href="${escapeHtml(href)}"${safeTitle}>${label}</a>`;
|
||||
};
|
||||
|
||||
return marked.parse(text, { async: false, gfm: true, renderer });
|
||||
}
|
||||
|
||||
function compactNumber(value: number): string {
|
||||
if (Math.abs(value) < 1_000) return String(value);
|
||||
if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}k`;
|
||||
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}m`;
|
||||
}
|
||||
|
||||
export function formatElapsed(milliseconds: number): string {
|
||||
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
|
||||
}
|
||||
|
||||
export function formatContextTokens(value: number): string {
|
||||
return `${compactNumber(value)} context`;
|
||||
}
|
||||
Reference in New Issue
Block a user