feat(ui): canvas-first redesign with a self-hosted draw.io

The canvas now fills the window and the chat floats on its right. draw.io is
served from our own origin (public/drawio, downloaded at build time and
checked against the release's SHA-256), so the app can drive the editor
directly:

- every AI change becomes a version card with a thumbnail; compare, restore
  or undo it, and Ctrl+Z on the canvas takes an AI change back in one step
- shapes the AI just changed are highlighted on the canvas
- selected shapes appear as a chip and are sent to the model
- the app's own canvas toolbar, shape library and page tabs replace
  draw.io's chrome
- new start screen, session rename and switcher, open .drawio files
- settings split into Models & keys, Appearance, Advanced and About
- phones switch between canvas and chat
- dark mode keeps the diagram on a white sheet and no longer flashes

With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) the app falls back to
draw.io's own toolbar and the features above that need same origin are off.

The chat logic moved from the 1500-line chat panel into a chat engine plus
small UI components; zustand holds canvas, versions, settings and UI state.
This commit is contained in:
dayuan.jiang
2026-10-06 22:01:09 +09:00
parent 18a20aa1ca
commit 366d9ee4ff
106 changed files with 12453 additions and 6879 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ describe("exports in flight at the same time", () => {
let tag = ""
const got: string[] = []
act(() => {
tag = result.current.handleExportWithoutHistory()
tag = result.current.handleExport()
result.current.exportResolversRef.current[tag] = (xml) =>
got.push(xml)
})
+229
View File
@@ -0,0 +1,229 @@
import pako from "pako"
import { describe, expect, it } from "vitest"
import { diffDiagrams, EMPTY_SUMMARY, isSameDocument } from "@/lib/diagram-diff"
const doc = (cells: string) =>
`<mxfile><diagram name="Page-1" id="p1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
const box = (id: string, value = id) =>
`<mxCell id="${id}" value="${value}" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
const edge = (id: string, source: string, target: string) =>
`<mxCell id="${id}" edge="1" parent="1" source="${source}" target="${target}"><mxGeometry relative="1" as="geometry"/></mxCell>`
const pageOf = (name: string, cells: string, attrs = "") =>
`<diagram name="${name}" id="${name}"><mxGraphModel${attrs}><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
const file = (...pages: string[]) => `<mxfile>${pages.join("")}</mxfile>`
const packedPage = (name: string, cells: string) => {
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel>`
const packed = Buffer.from(
pako.deflateRaw(encodeURIComponent(model)),
).toString("base64")
return `<diagram name="${name}" id="${name}">${packed}</diagram>`
}
describe("diffDiagrams", () => {
it("counts added shapes and connectors on an empty canvas", () => {
const { summary, touchedIds } = diffDiagrams(
"",
doc(box("a") + box("b") + edge("e", "a", "b")),
)
expect(summary.shapesAdded).toBe(2)
expect(summary.edgesAdded).toBe(1)
expect(touchedIds.sort()).toEqual(["a", "b", "e"])
})
it("separates added, changed and removed cells", () => {
const before = doc(box("a") + box("b") + edge("e", "a", "b"))
const after = doc(box("a", "renamed") + box("c") + edge("e", "a", "c"))
const { summary, touchedIds } = diffDiagrams(before, after)
expect(summary).toEqual({
shapesAdded: 1,
shapesRemoved: 1,
shapesChanged: 1,
edgesAdded: 0,
edgesRemoved: 0,
edgesChanged: 1,
})
expect(touchedIds.sort()).toEqual(["a", "c", "e"])
})
it("ignores how the same cell is written", () => {
// As the model writes it, and as draw.io saves it
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="A" style="rounded=1;html=1;" vertex="1" parent="1"><mxGeometry x="10" y="20" width="80" height="40" as="geometry"/></mxCell></root></mxGraphModel>`
const saved = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" parent="1" style="html=1;rounded=1" value="A" vertex="1"><mxGeometry as="geometry" height="40" width="80" x="10" y="20"/></mxCell></root></mxGraphModel>`
expect(diffDiagrams(model, saved).summary).toEqual(EMPTY_SUMMARY)
})
it("reports nothing for identical diagrams", () => {
const xml = doc(box("a"))
const { summary, touchedIds } = diffDiagrams(xml, xml)
expect(summary).toEqual(EMPTY_SUMMARY)
expect(touchedIds).toEqual([])
})
it("ignores values draw.io drops when it saves", () => {
// The model writes defaults and long numbers; draw.io leaves them out
const model = doc(
`<mxCell id="a" value="A" style="" vertex="1" connectable="1" parent="1"><mxGeometry x="0" y="0.0" width="80.0" height="40" relative="0" as="geometry"/></mxCell><mxCell id="e" edge="1" parent="1" source="a" target="a"><mxGeometry relative="1" as="geometry"><mxPoint x="0" y="0" as="offset"/></mxGeometry></mxCell>`,
)
const saved = doc(
`<mxCell id="a" value="A" vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell><mxCell id="e" edge="1" parent="1" source="a" target="a"><mxGeometry relative="1" as="geometry"><mxPoint as="offset"/></mxGeometry></mxCell>`,
)
expect(diffDiagrams(model, saved).summary).toEqual(EMPTY_SUMMARY)
expect(isSameDocument(model, saved)).toBe(true)
})
it("reads styles as draw.io does: a later key wins", () => {
const styled = (style: string) =>
doc(
`<mxCell id="a" style="${style}" vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell>`,
)
const blueThenRed = styled("fillColor=#00f;fillColor=#f00")
expect(
isSameDocument(
styled("fillColor=#f00;fillColor=#00f"),
blueThenRed,
),
).toBe(false)
expect(
isSameDocument(
styled("fillColor=#f00;rounded=1"),
blueThenRed.replace(
"fillColor=#00f;fillColor=#f00",
"rounded=1;fillColor=#f00",
),
),
).toBe(true)
})
it("reads the id from <UserObject> wrappers", () => {
const wrapped = `<UserObject label="Linked" link="https://example.com" id="u1"><mxCell vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell></UserObject>`
const { summary, touchedIds } = diffDiagrams(doc(""), doc(wrapped))
expect(summary.shapesAdded).toBe(1)
expect(touchedIds).toEqual(["u1"])
})
it("only looks at the first page", () => {
const twoPages = file(pageOf("p1", box("a")), pageOf("p2", box("z")))
expect(diffDiagrams("", twoPages).summary.shapesAdded).toBe(1)
})
it("reads compressed pages", () => {
const xml = file(packedPage("p1", box("a") + box("b")))
expect(diffDiagrams("", xml).summary.shapesAdded).toBe(2)
expect(diffDiagrams(xml, xml).fromScratch).toBe(false)
})
it("treats unparsable XML as empty", () => {
expect(
diffDiagrams("<mxfile><diagram>", doc(box("a"))).fromScratch,
).toBe(true)
})
})
describe("isSameDocument", () => {
const a = file(pageOf("One", box("a")), pageOf("Two", box("b")))
it("is true for the same pages written differently", () => {
const b = file(
pageOf("One", box("a"), ' dx="300" dy="120" grid="1"'),
packedPage("Two", box("b")),
)
expect(isSameDocument(a, b)).toBe(true)
})
it("sees a change on the second page", () => {
const b = file(pageOf("One", box("a")), pageOf("Two", box("b", "x")))
expect(diffDiagrams(a, b).summary).toEqual(diffDiagrams(a, a).summary)
expect(isSameDocument(a, b)).toBe(false)
})
it("sees added pages, renamed pages and changed page settings", () => {
expect(isSameDocument(a, file(pageOf("One", box("a"))))).toBe(false)
expect(
isSameDocument(
a,
file(pageOf("One", box("a")), pageOf("Renamed", box("b"))),
),
).toBe(false)
expect(
isSameDocument(
file(pageOf("One", box("a"), ' background="#ffffff"')),
file(pageOf("One", box("a"), ' background="#000000"')),
),
).toBe(false)
})
it("compares a setting written on one side with draw.io's default", () => {
const withAttrs = (attrs: string) =>
file(pageOf("One", box("a"), attrs))
const plain = withAttrs("")
// Defaults, as draw.io fills them in (paper: Letter or A4)
expect(
isSameDocument(
plain,
withAttrs(' math="0" pageWidth="827" pageHeight="1169"'),
),
).toBe(true)
expect(
isSameDocument(
plain,
withAttrs(' pageWidth="850" pageHeight="1100"'),
),
).toBe(true)
// Real changes
expect(isSameDocument(plain, withAttrs(' math="1"'))).toBe(false)
expect(isSameDocument(plain, withAttrs(' shadow="1"'))).toBe(false)
expect(isSameDocument(plain, withAttrs(' pageWidth="1654"'))).toBe(
false,
)
expect(
isSameDocument(
plain,
withAttrs(
' backgroundImage="{&quot;src&quot;:&quot;x.png&quot;}"',
),
),
).toBe(false)
})
it("sees a background set on one side", () => {
expect(
isSameDocument(
file(pageOf("One", box("a"))),
file(pageOf("One", box("a"), ' background="#000000"')),
),
).toBe(false)
})
it("sees shapes brought to front or sent back", () => {
expect(
isSameDocument(
file(pageOf("One", box("a") + box("b"))),
file(pageOf("One", box("b") + box("a"))),
),
).toBe(false)
})
it("sees a hidden layer", () => {
const hidden = file(pageOf("One", box("a"))).replace(
'<mxCell id="1" parent="0"/>',
'<mxCell id="1" parent="0" visible="0"/>',
)
expect(isSameDocument(file(pageOf("One", box("a"))), hidden)).toBe(
false,
)
})
it("does not care where children of other parents are written", () => {
const group = (inner: string) =>
`<mxCell id="g" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>${inner}`
const child = `<mxCell id="c" vertex="1" parent="g"><mxGeometry as="geometry"/></mxCell>`
expect(
isSameDocument(
file(pageOf("One", group(child) + box("a"))),
file(pageOf("One", group("") + box("a") + child)),
),
).toBe(true)
})
})
+168
View File
@@ -0,0 +1,168 @@
import { beforeEach, describe, expect, it } from "vitest"
import { formatSelectionContext } from "@/lib/chat-helpers"
import { EMPTY_SUMMARY } from "@/lib/diagram-diff"
import { withPageDefaults } from "@/lib/drawio/drawio-config"
import { describeChanges } from "@/lib/version-text"
import {
MAX_VERSIONS,
useVersionsStore,
versionsFromLegacyHistory,
} from "@/stores/versions-store"
describe("withPageDefaults", () => {
it("adds page and grid when the model has none", () => {
expect(withPageDefaults("<mxGraphModel><root/></mxGraphModel>")).toBe(
'<mxGraphModel page="1" grid="1" gridSize="10"><root/></mxGraphModel>',
)
})
it("keeps values the diagram already sets", () => {
const xml = '<mxGraphModel dx="10" page="0" grid="0" gridSize="5">'
expect(withPageDefaults(xml)).toBe(xml)
})
it("reads attributes written with spaces around =", () => {
const xml = '<mxGraphModel page = "0" grid ="0" gridSize= "5">'
expect(withPageDefaults(xml)).toBe(xml)
})
it("handles self-closing models and several pages", () => {
const xml =
'<mxfile><diagram><mxGraphModel/></diagram><diagram><mxGraphModel page="0"></mxGraphModel></diagram></mxfile>'
expect(withPageDefaults(xml)).toBe(
'<mxfile><diagram><mxGraphModel page="1" grid="1" gridSize="10"/></diagram><diagram><mxGraphModel page="0" grid="1" gridSize="10"></mxGraphModel></diagram></mxfile>',
)
})
})
describe("describeChanges", () => {
const en = {
shapesAddedOne: "added 1 shape",
shapesAddedOther: "added {count} shapes",
edgesChangedOne: "changed 1 connector",
edgesChangedOther: "changed {count} connectors",
noChanges: "No visible changes",
listSeparator: ", ",
}
it("uses singular and plural forms and sentence case", () => {
expect(
describeChanges(
{ ...EMPTY_SUMMARY, shapesAdded: 2, edgesChanged: 1 },
en,
),
).toBe("Added 2 shapes, changed 1 connector")
})
it("says so when nothing changed", () => {
expect(describeChanges({ ...EMPTY_SUMMARY }, en)).toBe(
"No visible changes",
)
})
it("uses the language's list separator", () => {
const zh = {
shapesAddedOther: "新增 {count} 个图形",
edgesChangedOne: "改了 1 条连线",
listSeparator: ",",
}
expect(
describeChanges(
{ ...EMPTY_SUMMARY, shapesAdded: 3, edgesChanged: 1 },
zh,
),
).toBe("新增 3 个图形,改了 1 条连线")
})
})
describe("formatSelectionContext", () => {
it("lists selected ids with their labels", () => {
const text = formatSelectionContext([
{ id: "4", label: "EC2" },
{ id: "cf", label: "" },
])
expect(text).toContain('- id="4" (EC2)')
expect(text).toContain('- id="cf"')
expect(text).not.toContain('id="cf" (')
})
it("returns nothing for missing or malformed input", () => {
expect(formatSelectionContext(undefined)).toBe("")
expect(formatSelectionContext("4")).toBe("")
expect(formatSelectionContext([{ label: "no id" }])).toBe("")
})
it("strips quotes and newlines and limits length and count", () => {
const many = Array.from({ length: 80 }, (_, i) => ({
id: `c${i}`,
label: `line\n"quoted"${"x".repeat(200)}`,
}))
const text = formatSelectionContext(many)
const lines = text.split("\n").filter((l) => l.startsWith("- id="))
expect(lines).toHaveLength(50)
// The model is told the list is cut short
expect(text).toContain("- and 30 more selected shapes not listed")
expect(text).not.toContain('"quoted"')
expect(lines[0].length).toBeLessThan(130)
})
})
describe("versions store", () => {
beforeEach(() => useVersionsStore.getState().clear())
const add = (n: number) =>
useVersionsStore.getState().addVersion({
xml: `<x n="${n}"/>`,
beforeXml: `<before n="${n}"/>`,
turnIndex: n,
summary: { ...EMPTY_SUMMARY },
})
it("keeps the before-XML only on the newest version", () => {
add(0)
add(2)
const [first, second] = useVersionsStore.getState().versions
expect(first.beforeXml).toBeUndefined()
expect(second.beforeXml).toBe('<before n="2"/>')
})
it("keeps at most MAX_VERSIONS versions", () => {
for (let i = 0; i < MAX_VERSIONS + 5; i++) add(i)
const { versions } = useVersionsStore.getState()
expect(versions).toHaveLength(MAX_VERSIONS)
expect(versions[0].turnIndex).toBe(5)
})
it("drops versions from a turn that is re-run, none left on the canvas", () => {
add(0)
add(2)
add(4)
useVersionsStore.getState().removeFromTurn(2)
const state = useVersionsStore.getState()
expect(state.versions.map((v) => v.turnIndex)).toEqual([0])
expect(state.onCanvasVersionId).toBeNull()
})
it("hand edits clear both version flags", () => {
const id = add(0)
expect(useVersionsStore.getState().onCanvasVersionId).toBe(id)
useVersionsStore.getState().clearCanvasFlags()
let state = useVersionsStore.getState()
expect(state.onCanvasVersionId).toBeNull()
expect(state.versions[0].onCanvas).toBeUndefined()
useVersionsStore.getState().setUndone(id)
useVersionsStore.getState().clearCanvasFlags()
state = useVersionsStore.getState()
expect(state.undoneVersionId).toBeNull()
expect(state.versions[0].undone).toBeUndefined()
})
it("converts the old history list", () => {
const versions = versionsFromLegacyHistory([
{ svg: "data:image/svg+xml;base64,AA", xml: "<a/>" },
])
expect(versions).toHaveLength(1)
expect(versions[0]).toMatchObject({ xml: "<a/>", turnIndex: -1 })
expect(versionsFromLegacyHistory(undefined)).toEqual([])
})
})
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest"
import { shortModelName } from "@/components/model-selector"
describe("shortModelName", () => {
it("drops the provider or region prefix the logo already shows", () => {
expect(shortModelName("nvidia/nemotron-3-ultra-550b-a55b:free")).toBe(
"nemotron-3-ultra-550b-a55b:free",
)
expect(shortModelName("global.anthropic.claude-opus-5-5")).toBe(
"claude-opus-5-5",
)
expect(shortModelName("us.amazon.nova-2-lite-v1:0")).toBe(
"nova-2-lite-v1:0",
)
expect(shortModelName("openai.gpt-6-luna")).toBe("gpt-6-luna")
expect(shortModelName("@tx/deepseek-ai/deepseek-v32")).toBe(
"deepseek-v32",
)
})
it("keeps the vendor when the rest does not name it", () => {
expect(shortModelName("deepseek.r1-v1:0")).toBe("deepseek.r1-v1:0")
expect(shortModelName("us.deepseek.r1-v1:0")).toBe("deepseek.r1-v1:0")
expect(shortModelName("deepseek.v3-v1:0")).toBe("deepseek.v3-v1:0")
})
it("keeps version numbers with dots", () => {
for (const id of [
"gpt-5.5",
"glm-4.7",
"gemini-2.5-flash",
"kimi-k2.6",
]) {
expect(shortModelName(id)).toBe(id)
}
})
})
@@ -1,15 +1,10 @@
import { cleanup, render } from "@testing-library/react"
import { afterEach, describe, expect, it } from "vitest"
import { ToolCallCard } from "@/components/chat/ToolCallCard"
import { ToolInputDetails } from "@/components/chat/tool-activity"
afterEach(cleanup)
const dict = {
tools: { complete: "Complete" },
chat: { copied: "Copied", failedToCopy: "Failed", copyResponse: "Copy" },
}
describe("ToolCallCard", () => {
describe("ToolInputDetails", () => {
it("shows streamed operations without crashing on broken entries", () => {
// A partly streamed or invalid edit_diagram input
const operations = [
@@ -22,7 +17,7 @@ describe("ToolCallCard", () => {
{ operation: "update", cell_id: "3", new_xml: '<mxCell id="3"/>' },
]
const { container } = render(
<ToolCallCard
<ToolInputDetails
part={
{
type: "tool-edit_diagram",
@@ -31,15 +26,25 @@ describe("ToolCallCard", () => {
input: { operations },
} as any
}
expandedTools={{ t1: true }}
setExpandedTools={() => {}}
onCopy={() => {}}
copiedToolCallId={null}
copyFailedToolCallId={null}
dict={dict}
/>,
)
expect(container.textContent).toContain("cell_id: 3")
expect(container.textContent).toContain("update3")
expect(container.textContent).toContain('<mxCell id="3"/>')
})
it("ignores an xml input that is not text", () => {
const { container } = render(
<ToolInputDetails
part={
{
type: "tool-display_diagram",
toolCallId: "t2",
state: "input-streaming",
input: { xml: { broken: true } },
} as any
}
/>,
)
expect(container.textContent).toContain("broken")
})
})
@@ -1,6 +1,5 @@
import { render } from "@testing-library/react"
import { renderHook } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import en from "@/lib/i18n/dictionaries/en.json"
const page = (cells: string) =>
`<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
@@ -19,12 +18,8 @@ vi.mock("@/contexts/diagram-context", () => ({
loadDiagram: vi.fn(() => null),
}),
}))
vi.mock("@/hooks/use-dictionary", () => ({ useDictionary: () => en }))
import { ChatMessageDisplay } from "@/components/chat-message-display"
// jsdom has no layout
Element.prototype.scrollIntoView = () => {}
import { useDiagramStreaming } from "@/components/chat/use-diagram-streaming"
describe("the streaming preview of a second edit", () => {
it("starts from the first edit's result", () => {
@@ -51,15 +46,13 @@ describe("the streaming preview of a second edit", () => {
],
},
] as any
render(
<ChatMessageDisplay
messages={messages}
setInput={() => {}}
setFiles={() => {}}
processedToolCallsRef={{ current: new Set() }}
editDiagramOriginalXmlRef={editDiagramOriginalXmlRef}
status="streaming"
/>,
renderHook(() =>
useDiagramStreaming({
messages,
processedToolCallsRef: { current: new Set() },
editDiagramOriginalXmlRef,
loadedMessageIdsRef: { current: new Set() },
}),
)
expect(editDiagramOriginalXmlRef.current.get("edit-2")).toBe(
AFTER_FIRST_EDIT,
+47 -12
View File
@@ -10,6 +10,7 @@ const box = (id: string) =>
function setup(partialXml: string) {
const refs = {
partialXmlRef: { current: partialXml },
continuationOriginalRef: { current: null },
// A failed edit's preview is still on the canvas, its original kept
editDiagramOriginalXmlRef: {
current: new Map([["edit-1", "<mxfile>original</mxfile>"]]),
@@ -19,30 +20,28 @@ function setup(partialXml: string) {
chartXMLRef: { current: "" },
}
const onDisplayChart = vi.fn(
(_xml: string, _skipValidation?: boolean): string | null => null,
(
_xml: string,
_skipValidation?: boolean,
_mode?: string,
): string | null => null,
)
const { result } = renderHook(() =>
useDiagramToolHandlers({
...refs,
onDisplayChart,
onFetchChart: async () => "",
onExport: () => {},
enableVlmValidation: false,
}),
)
const addToolOutput = vi.fn()
const append = (xml: string) =>
const call = (toolName: string, input: object, toolCallId = "call-1") =>
result.current.handleToolCall(
{
toolCall: {
toolCallId: "append-1",
toolName: "append_diagram",
input: { xml },
},
},
{ toolCall: { toolCallId, toolName, input } },
addToolOutput,
)
return { refs, onDisplayChart, addToolOutput, append }
const append = (xml: string) => call("append_diagram", { xml }, "append-1")
return { refs, onDisplayChart, addToolOutput, append, call }
}
describe("the screenshot check and Stop", () => {
@@ -57,13 +56,13 @@ describe("the screenshot check and Stop", () => {
const { result } = renderHook(() =>
useDiagramToolHandlers({
partialXmlRef: { current: "" },
continuationOriginalRef: { current: null },
editDiagramOriginalXmlRef: { current: new Map() },
processedToolCallsRef: { current: new Set() },
validationRetryCountRef: opts.retryCount ?? { current: 0 },
chartXMLRef: { current: "" },
onDisplayChart: () => null,
onFetchChart: async () => "",
onExport: () => {},
enableVlmValidation: true,
captureValidationPng:
opts.captureValidationPng ??
@@ -202,3 +201,39 @@ describe("append_diagram and the stored previews", () => {
expect(refs.editDiagramOriginalXmlRef.current.size).toBe(1)
})
})
describe("display_diagram and undo", () => {
it("commits the diagram as one undo step before any check", async () => {
const { onDisplayChart, call } = setup("")
await call("display_diagram", { xml: box("2") })
expect(onDisplayChart.mock.calls.map((c) => c[2])).toEqual(["commit"])
})
})
describe("a cut off drawing", () => {
it("is replaced starting from the diagram before it", async () => {
const { refs, onDisplayChart, call } = setup("")
// Cut off: its preview stays, the diagram before it is kept
await call(
"display_diagram",
{ xml: `${box("2")}<mxCell id="3"` },
"d1",
)
expect(refs.continuationOriginalRef.current).toBe(
"<mxfile>original</mxfile>",
)
// A later drawing (after failed calls undone to the cut off one)
// first goes back to it, so its undo step starts there
await call("display_diagram", { xml: box("4") }, "d2")
expect(
onDisplayChart.mock.calls.map(([xml, , mode]) => [
xml === "<mxfile>original</mxfile>" ? "original" : "new",
mode,
]),
).toEqual([
["original", "revert"],
["new", "commit"],
])
expect(refs.continuationOriginalRef.current).toBeNull()
})
})