feat(diagram-engine): corners, borderless fills, shadows and strikethrough

Four more Tailwind classes, all four verified against draw.io's own source in
public/drawio rather than against a prose reference — which is how three earlier
exclusions turned out to be wrong:

  rounded-*      mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to
                 absolute pixels and halves it, so the same class is the same
                 corner on every box. Previously excluded as 'percentage only'
  shadow-sm..xl  mxShape.js:505-535 — getShadowStyle reads five independent
                 params, not one flag, so Tailwind's offset+blur rungs map one
                 to one. Previously excluded as 'six sizes collapse to one'
  line-through   mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both
                 mxText.js:723 and :1040. The bitmask has four bits, not three
  border-none    the only one of the four that adds something previously
                 inexpressible: a fill with no outline

Also fixes an edge style growing 76 characters per re-layout, without bound. The
router recomputes ports on every pass, and appending them to a style recovered
from the canvas — which already carried the previous pass's eight port keys —
grew the string forever. draw.io resolves duplicates last-wins so the arrow
always looked right; a byte-identity check is what caught it.

Two traps found while wiring the readback, both the same shape: a value the
THEME emits being recorded as one the model asked for. strokeColor=none from a
filled or ghost role, and rounded=0 from the fallback style. Either one would
outlive a set_role, since that clears style but keeps text.

Deliberately not included, with reasons in tw.ts: per-side borders and per-corner
radius (both would take the shape slot, and what a node IS matters more than
which of its edges show), per-side padding (draw.io's keys pad the label, not the
room left for children), text-shadow (a bare flag with no offset or blur),
opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading
(absent from draw.io — zero grep hits, not merely coarse).

615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is
real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while
an untouched terminator stays a stadium.
This commit is contained in:
dayuan.jiang
2026-08-11 09:09:45 +09:00
parent 8687e8f04b
commit 0b03e15336
25 changed files with 3669 additions and 1234 deletions

View File

@@ -4,7 +4,11 @@ import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
import type { Dispatch, SetStateAction } from "react"
import { CodeBlock } from "@/components/code-block"
import { isMxCellXmlComplete } from "@/lib/utils"
import type { DiagramOperation, ToolPartLike } from "./types"
import type {
DiagramOperation,
StructureOperation,
ToolPartLike,
} from "./types"
interface ToolCallCardProps {
part: ToolPartLike
@@ -19,31 +23,137 @@ interface ToolCallCardProps {
}
}
/**
* Colour an operation by what it does to the diagram: removes, adds, or changes.
*
* Takes an unknown rather than a string because this renders DURING streaming: the tool
* input arrives character by character, so an operation is briefly `{}` or `{"op": "add_c`
* before it is whole. A missing name is the normal mid-stream state, not an error.
*/
function opColour(op: unknown): string {
if (typeof op !== "string") return "text-muted-foreground"
if (op === "delete" || op === "remove" || op === "unlink" || op === "clear")
return "text-red-600"
if (op.startsWith("add") || op === "link") return "text-green-600"
return "text-blue-600"
}
/**
* The arguments worth showing beside an operation's name.
*
* A whitelist rather than "everything except op and id", because some operations carry a
* whole nested graph (add_graph's nodes and edges) and dumping that turns one line into a
* screenful. The excluded keys are summarised instead.
*/
const SHOWN_KEYS = [
"label",
"name",
"parent",
"dir",
"class",
"role",
"group",
"shape",
"cols",
"lanes",
"aspect",
"source",
"target",
"title",
] as const
function summarise(op: StructureOperation | undefined | null): string {
if (!op || typeof op !== "object") return ""
const parts: string[] = []
for (const key of SHOWN_KEYS) {
const v = op[key]
if (v === undefined || v === null || v === "") continue
parts.push(
`${key}=${Array.isArray(v) ? v.join("/") : String(v).slice(0, 60)}`,
)
}
// A graph carries its own nodes and edges; report the size, not the contents.
const nodes = op.nodes
const edges = op.edges
if (Array.isArray(nodes))
parts.push(
`${nodes.length} node${nodes.length === 1 ? "" : "s"}${
Array.isArray(edges)
? `, ${edges.length} edge${edges.length === 1 ? "" : "s"}`
: ""
}`,
)
return parts.join(" ")
}
/**
* `restructure_diagram`'s operations: structural steps, not XML patches.
*
* Written to survive PARTIAL data. This renders while the tool input is still streaming, so
* an entry may be `{}`, or `{op: "add_contai"}`, or — because a JSON array is repaired as it
* arrives — `undefined`. Every field is therefore treated as possibly absent rather than
* validated up front: dropping incomplete entries would make rows appear and disappear as
* the text arrives, and asserting on them crashes the whole message.
*/
function StructureOperationsDisplay({
operations,
}: {
operations: StructureOperation[]
}) {
return (
<div className="space-y-1">
{operations.map((op, index) => (
<div
key={`${op?.op ?? "pending"}-${op?.id ?? index}-${index}`}
className="flex items-baseline gap-2 px-2 py-1 rounded bg-background/50 border border-border/40"
>
<span
className={`text-[10px] font-medium uppercase tracking-wide shrink-0 ${opColour(op?.op)}`}
>
{op?.op ?? "…"}
</span>
{op?.id && (
<span className="text-xs font-mono text-foreground/80 shrink-0">
{op.id}
</span>
)}
<span className="text-[11px] text-muted-foreground font-mono break-all">
{summarise(op)}
</span>
</div>
))}
</div>
)
}
/** `edit_diagram`'s operations. Also streamed, so also written for partial entries. */
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
return (
<div className="space-y-3">
{operations.map((op, index) => (
<div
key={`${op.operation}-${op.cell_id}-${index}`}
key={`${op?.operation ?? "pending"}-${op?.cell_id ?? index}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span
className={`text-[10px] font-medium uppercase tracking-wide ${
op.operation === "delete"
op?.operation === "delete"
? "text-red-600"
: op.operation === "add"
: op?.operation === "add"
? "text-green-600"
: "text-blue-600"
}`}
>
{op.operation}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {op.cell_id}
{op?.operation ?? "…"}
</span>
{op?.cell_id && (
<span className="text-xs text-muted-foreground">
cell_id: {op.cell_id}
</span>
)}
</div>
{op.new_xml && (
{op?.new_xml && (
<div className="px-3 py-2">
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{op.new_xml}
@@ -81,12 +191,16 @@ export function ToolCallCard({
const getToolDisplayName = (name: string) => {
switch (name) {
case "display_diagram":
return "Generate Diagram"
case "restructure_diagram":
return "Build Diagram"
case "edit_diagram":
return "Edit Diagram"
case "get_shape_library":
return "Get Shape Library"
case "search_stencils":
return "Find Icons"
// Only ever arrives from the server's cache-hit path now; the model cannot
// call it. See createCachedStreamResponse in app/api/chat/route.ts.
case "display_diagram":
return "Generate Diagram"
default:
return name
}
@@ -105,14 +219,6 @@ export function ToolCallCard({
}
}
if (
output &&
toolName === "get_shape_library" &&
typeof output === "string"
) {
textToCopy = output
}
if (textToCopy) {
onCopy(callId, textToCopy, true)
}
@@ -162,10 +268,11 @@ export function ToolCallCard({
)}
{state === "output-error" &&
(() => {
// Check if this is a truncation (incomplete XML) vs real error
// Truncation only applies to a tool that streams raw XML, which
// is now just the cached-answer replay. The engine tools send
// structured operations, so a failure there is a real error.
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
toolName === "display_diagram" &&
!isMxCellXmlComplete(input?.xml)
return isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
@@ -214,7 +321,23 @@ export function ToolCallCard({
) : typeof input === "object" &&
input.operations &&
Array.isArray(input.operations) ? (
<OperationsDisplay operations={input.operations} />
// Dispatch by TOOL, not by whether an `operations` key exists: both
// tools call their argument that, but the items have different shapes
// (op/id versus operation/cell_id), and reading one as the other
// printed a row of blank `cell_id:` labels.
toolName === "restructure_diagram" ? (
<StructureOperationsDisplay
operations={
input.operations as StructureOperation[]
}
/>
) : (
<OperationsDisplay
operations={
input.operations as DiagramOperation[]
}
/>
)
) : typeof input === "object" &&
Object.keys(input).length > 0 ? (
<CodeBlock
@@ -228,8 +351,7 @@ export function ToolCallCard({
state === "output-error" &&
(() => {
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
toolName === "display_diagram" &&
!isMxCellXmlComplete(input?.xml)
return (
<div
@@ -241,25 +363,21 @@ export function ToolCallCard({
</div>
)
})()}
{/* Show get_shape_library output on success */}
{output &&
toolName === "get_shape_library" &&
state === "output-available" &&
isExpanded && (
<div className="px-4 py-3 border-t border-border/40">
<div className="text-xs text-muted-foreground mb-2">
Library loaded (
{typeof output === "string" ? output.length : 0}{" "}
chars)
</div>
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
{typeof output === "string"
? output.substring(0, 800) +
(output.length > 800 ? "\n..." : "")
: String(output)}
</pre>
</div>
)}
{/* What the tool actually returned. Worth showing on success, not only on
error: restructure_diagram answers with an outline of the structure it
built plus any notes about classes it could not honour, and that is the
same text the model reads to name ids in its next call. */}
{output && state === "output-available" && isExpanded && (
<div className="px-4 py-3 border-t border-border/40">
<pre className="text-[11px] font-mono text-muted-foreground bg-muted/40 rounded-md p-2 overflow-auto max-h-64 whitespace-pre-wrap break-all">
{typeof output === "string"
? output.length > 4000
? `${output.slice(0, 4000)}\n…`
: output
: String(output)}
</pre>
</div>
)}
</div>
)
}

View File

@@ -1,16 +1,38 @@
/** An `edit_diagram` operation: a patch against one cell, addressed by its id. */
export interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
/**
* A `restructure_diagram` operation.
*
* Deliberately loose. The engine owns the real schema (lib/diagram-engine/operations.ts)
* and it has two dozen variants; the card only needs to say WHAT each step did, so it
* reads the two fields every variant shares and picks a few recognisable extras out of
* the rest. Mirroring the full union here would mean editing this file every time the
* engine gains an operation.
*
* The field names matter: `op`/`id`, where edit_diagram has `operation`/`cell_id`. Both
* tools happen to call their argument `operations`, which is what let the card render one
* as the other and print six blank `cell_id:` lines.
*/
export interface StructureOperation {
op: string
id?: string
label?: string
parent?: string
[key: string]: unknown
}
export interface ToolPartLike {
type: string
toolCallId: string
state?: string
input?: {
xml?: string
operations?: DiagramOperation[]
operations?: DiagramOperation[] | StructureOperation[]
} & Record<string, unknown>
output?: string
}