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
+52 -21
View File
@@ -1,9 +1,10 @@
import type { MutableRefObject } from "react"
import type { RefObject } from "react"
import type { DiagramOperation } from "@/components/chat/types"
import type {
ValidationState,
ValidationStatus,
} from "@/components/chat/ValidationCard"
import type { LoadMode } from "@/contexts/diagram-context"
import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator"
import { isMxCellXmlComplete } from "@/lib/utils"
@@ -50,16 +51,23 @@ type ValidateDiagramFn = (
) => Promise<ValidationResult>
interface UseDiagramToolHandlersParams {
partialXmlRef: MutableRefObject<string>
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
partialXmlRef: RefObject<string>
// Diagram before a cut off display_diagram whose half drawn preview
// stays while append_diagram finishes it; null when none is pending
continuationOriginalRef: RefObject<string | null>
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
// Tool calls the streaming preview must leave alone (shared with it)
processedToolCallsRef: MutableRefObject<Set<string>>
processedToolCallsRef: RefObject<Set<string>>
// Failed VLM validations in the current user turn (reset on each user message)
validationRetryCountRef: MutableRefObject<number>
chartXMLRef: MutableRefObject<string>
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
onFetchChart: (saveToHistory?: boolean) => Promise<string>
onExport: () => void
validationRetryCountRef: RefObject<number>
chartXMLRef: RefObject<string>
onDisplayChart: (
xml: string,
skipValidation?: boolean,
mode?: LoadMode,
meta?: { toolCallId?: string },
) => string | null
onFetchChart: () => Promise<string>
captureValidationPng?: () => Promise<string | null>
validateDiagram?: ValidateDiagramFn
enableVlmValidation?: boolean
@@ -84,13 +92,13 @@ interface UseDiagramToolHandlersParams {
*/
export function useDiagramToolHandlers({
partialXmlRef,
continuationOriginalRef,
editDiagramOriginalXmlRef,
processedToolCallsRef,
validationRetryCountRef,
chartXMLRef,
onDisplayChart,
onFetchChart,
onExport,
captureValidationPng,
validateDiagram,
enableVlmValidation = true,
@@ -146,6 +154,17 @@ export function useDiagramToolHandlers({
// handler. The first is the diagram before all of them. A call that
// draws its result replaces those previews, so the preview code must
// neither draw them again nor undo them later. Returns that first one.
// The final result as one undo step. A cut off drawing it replaces goes
// first, so the step (and the version) starts from the diagram before
// it, also when a failed call in between was undone to the cut off one.
const commit = (xml: string, toolCallId: string) => {
const original = continuationOriginalRef.current
if (original !== null) onDisplayChart(original, true, "revert")
const error = onDisplayChart(xml, true, "commit", { toolCallId })
if (!error) continuationOriginalRef.current = null
return error
}
const takeOriginals = (): string | undefined => {
const [originalXml] = editDiagramOriginalXmlRef.current.values()
for (const id of editDiagramOriginalXmlRef.current.keys()) {
@@ -181,6 +200,15 @@ export function useDiagramToolHandlers({
if (isTruncated) {
// Store the partial XML for continuation via append_diagram
partialXmlRef.current = xml
// The half drawn preview stays while append_diagram finishes
// it; the chat engine brings this diagram back if that never
// happens
if (
originalXml !== undefined &&
continuationOriginalRef.current === null
) {
continuationOriginalRef.current = originalXml
}
// Tell LLM to use append_diagram to continue
const partialEnding = partialXmlRef.current.slice(-500)
@@ -210,16 +238,19 @@ NEXT STEP: Call append_diagram with the continuation XML.
// Wrap, validate and auto-fix the model's XML like the MCP server's
// create_new_diagram, then load it
// One undo step (and a version) right away, before the screenshot
// check: a stop, another chat or a hand edit during the check then
// finds the diagram already in place
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
const validationError = prepared.ok
? onDisplayChart(prepared.xml, true)
? commit(prepared.xml, toolCall.toolCallId)
: prepared.error
if (validationError) {
console.warn("[display_diagram] Validation error:", validationError)
// Undo the streamed preview, as a failed edit does: the canvas
// keeps the diagram from before this failed call
if (originalXml) onDisplayChart(originalXml, true)
if (originalXml) onDisplayChart(originalXml, true, "revert")
// Return error to model - sendAutomaticallyWhen will trigger retry
if (DEBUG) {
console.log(
@@ -430,7 +461,7 @@ ${finalXml}
// On failure, undo the streaming preview so the canvas matches the XML
// reported back to the model
const restoreOriginal = () => {
if (originalXml) onDisplayChart(originalXml, true)
if (originalXml) onDisplayChart(originalXml, true, "revert")
}
try {
if (originalXml) {
@@ -442,7 +473,7 @@ ${finalXml}
currentXml = cachedXML
} else {
// Last resort: export from iframe
currentXml = await onFetchChart(false)
currentXml = await onFetchChart()
}
}
@@ -470,8 +501,7 @@ Please check the cell IDs and retry.`,
return
}
onDisplayChart(outcome.xml, true)
onExport()
commit(outcome.xml, toolCall.toolCallId)
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
@@ -561,14 +591,15 @@ Start your continuation with the NEXT character after where it stopped.`,
// It draws now: it takes the stored originals, as display_diagram
const originalXml = prepared.ok ? takeOriginals() : undefined
const validationError = prepared.ok
? onDisplayChart(prepared.xml, true)
? commit(prepared.xml, toolCall.toolCallId)
: prepared.error
if (validationError) {
// Loading failed: back to the diagram before the previews
if (prepared.ok && originalXml) {
onDisplayChart(originalXml, true)
}
// Loading failed: back to the diagram before the previews,
// and before the cut off drawing
const before = originalXml ?? continuationOriginalRef.current
continuationOriginalRef.current = null
if (before) onDisplayChart(before, true, "revert")
addToolOutput({
tool: "append_diagram",
toolCallId: toolCall.toolCallId,
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useState } from "react"
import { PANEL_MIN_WIDTH, useSettingsStore } from "@/stores/settings-store"
// Room the canvas keeps next to the chat panel in a small window
const MIN_CANVAS_WIDTH = 400
/** The chat panel's width, narrower when the window has no room for it */
export function usePanelWidth(): number {
const width = useSettingsStore((s) => s.panelWidth)
// Set after mounting, so the server render and the first one agree
const [windowWidth, setWindowWidth] = useState(Number.POSITIVE_INFINITY)
useEffect(() => {
const update = () => setWindowWidth(window.innerWidth)
update()
window.addEventListener("resize", update)
return () => window.removeEventListener("resize", update)
}, [])
return Math.max(
PANEL_MIN_WIDTH,
Math.min(width, windowWidth - MIN_CANVAS_WIDTH),
)
}
+60 -6
View File
@@ -14,18 +14,32 @@ import {
isIndexedDBAvailable,
migrateFromLocalStorage,
readSessionCount,
renameSession as renameSessionInDB,
type SessionMetadata,
type StoredMessage,
saveSession,
} from "@/lib/session-storage"
import { STORAGE_KEYS } from "@/lib/storage"
import {
type DiagramVersion,
versionsFromLegacyHistory,
} from "@/stores/versions-store"
export interface SessionData {
messages: StoredMessage[]
xmlSnapshots: [number, string][]
diagramXml: string
thumbnailDataUrl?: string
diagramHistory?: { svg: string; xml: string }[]
versions?: DiagramVersion[]
/** Title for a session created by this save (default: first message) */
title?: string
}
/** Versions of a stored session, converting the old history format */
export function getSessionVersions(
session: Pick<ChatSession, "versions" | "diagramHistory">,
): DiagramVersion[] {
return session.versions ?? versionsFromLegacyHistory(session.diagramHistory)
}
// Taken right before a save's data is read: the chat on screen then, and
@@ -57,7 +71,10 @@ export interface UseSessionManagerReturn {
refreshSessions: () => Promise<void>
clearCurrentSession: () => void
getChatGeneration: () => number
/** The chat on screen now, also right after a save created it */
getCurrentSessionId: () => string | null
getSaveTicket: () => SaveTicket
renameSession: (id: string, title: string) => Promise<void>
}
// Reading the session list loads every stored session in full, and window
@@ -267,7 +284,7 @@ export function useSessionManager(
xmlSnapshots: session.xmlSnapshots,
diagramXml: session.diagramXml,
thumbnailDataUrl: session.thumbnailDataUrl,
diagramHistory: session.diagramHistory,
versions: getSessionVersions(session),
}
},
[currentSessionId, changeChat],
@@ -319,8 +336,8 @@ export function useSessionManager(
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl: data.thumbnailDataUrl,
diagramHistory: data.diagramHistory,
title: extractTitle(data.messages),
versions: data.versions,
title: data.title || extractTitle(data.messages),
}
// Without a stored session, keep no session id (it would end
// up in the URL and point to nothing after a reload)
@@ -348,8 +365,9 @@ export function useSessionManager(
thumbnailDataUrl:
data.thumbnailDataUrl ??
currentSession.thumbnailDataUrl,
diagramHistory:
data.diagramHistory ?? currentSession.diagramHistory,
versions:
data.versions ?? getSessionVersions(currentSession),
diagramHistory: undefined,
updatedAt: Date.now(),
// Update title if it's still default and we have messages
title:
@@ -403,6 +421,10 @@ export function useSessionManager(
}, [changeChat])
const getChatGeneration = useCallback(() => chatGenerationRef.current, [])
const getCurrentSessionId = useCallback(
() => currentSessionRef.current?.id ?? null,
[],
)
const getSaveTicket = useCallback(
(): SaveTicket => ({
@@ -412,6 +434,36 @@ export function useSessionManager(
[],
)
const renameSession = useCallback(
(id: string, title: string): Promise<void> => {
const trimmed = title.trim().slice(0, 100)
if (!trimmed) return Promise.resolve()
// In the save queue, so a save running now cannot put the old
// title back
const run = async () => {
const updated = await renameSessionInDB(id, trimmed)
if (!updated) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return
}
const current = currentSessionRef.current
if (current?.id === id) {
currentSessionRef.current = { ...current, title: trimmed }
setCurrentSession(currentSessionRef.current)
}
setSessions((prev) =>
prev.map((s) =>
s.id === id ? { ...s, title: trimmed } : s,
),
)
}
const result = saveQueueRef.current.then(run)
saveQueueRef.current = result.catch(() => {})
return result
},
[dict],
)
return {
sessions,
currentSessionId,
@@ -424,6 +476,8 @@ export function useSessionManager(
refreshSessions,
clearCurrentSession,
getChatGeneration,
getCurrentSessionId,
getSaveTicket,
renameSession,
}
}