refactor(canvas): decouple the canvas from the chat engine

The canvas components will be reused by the MCP server's browser shell,
which has no chat and no Next.js. They now read everything they need
from small contexts and the canvas store instead of the chat engine:

- components/canvas/versions-context.tsx: VersionsProvider and
  useVersionsContext give the version cards, the strip and the compare
  dialog a VersionsSource (versions, onCanvasId, undoneId, isBusy,
  canUndo, canRedo, restore, undo, redo). The chat fills it from the
  versions store and the engine in components/chat/chat-versions.tsx.
- stores/canvas-store.ts: isBusy and busyReason; the chat engine sets
  them while a turn runs, SelectionAsk and the version UI read them.
- components/canvas/version-card.tsx: the visual version card and the
  thumbnail, out of tool-activity.tsx; the chat's card composes it and
  adds its "Show XML" link and code panel.
- compare-dialog.tsx and version-strip.tsx move to components/canvas;
  the strip takes the minimum number of versions to show as a prop, the
  chat panel computes it from the cards it has.
- components/canvas/locale-context.tsx: LocaleProvider and useLocale,
  fed by the [lang] layout; CanvasStage no longer uses next/navigation.
  The overlay (SelectionAsk) is a slot and the wait for the saved
  language is a prop, so the shell can leave both out.
- lib/version-text.ts: describeChanges is now describeChangeSummary, so
  it can be imported next to the MCP core's describeChanges.
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent 07f3e3c2a3
commit 899b6a3b5b
18 changed files with 527 additions and 297 deletions
+4 -1
View File
@@ -3,6 +3,7 @@ import type { Metadata, Viewport } from "next"
import { Instrument_Sans, JetBrains_Mono } from "next/font/google"
import { notFound } from "next/navigation"
import Script from "next/script"
import { LocaleProvider } from "@/components/canvas/locale-context"
import { AppToaster } from "@/components/workspace/app-toaster"
import { DiagramProvider } from "@/contexts/diagram-context"
import { DictionaryProvider } from "@/hooks/use-dictionary"
@@ -184,7 +185,9 @@ export default async function RootLayout({
className={`${instrumentSans.variable} ${jetbrainsMono.variable} antialiased`}
>
<DictionaryProvider dictionary={dictionary}>
<DiagramProvider>{children}</DiagramProvider>
<LocaleProvider locale={validLang}>
<DiagramProvider>{children}</DiagramProvider>
</LocaleProvider>
</DictionaryProvider>
<AppToaster />
</body>
+32 -26
View File
@@ -1,8 +1,6 @@
import { useParams } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import { DrawioFrame } from "@/components/canvas/drawio-frame"
import { SelectionAsk } from "@/components/canvas/selection-ask"
import { useChatEngine } from "@/components/chat/chat-engine"
import { useLocale } from "@/components/canvas/locale-context"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { DRAWIO_CSS, getDrawioSrc } from "@/lib/drawio/drawio-config"
@@ -13,28 +11,38 @@ import { cn } from "@/lib/utils"
import { useCanvasStore } from "@/stores/canvas-store"
import { useSettingsStore } from "@/stores/settings-store"
/** Size of the canvas area, for overlays that stay inside it */
export interface CanvasSize {
width: number
height: number
}
/**
* The draw.io canvas, with draw.io's own toolbar, menus and page tabs, and
* the "ask AI" button laid over it (only when the page can drive draw.io
* directly, not with an external cross-origin draw.io).
* an overlay laid over it (the web app's "ask AI" button; only when the
* page can drive draw.io directly, not with an external cross-origin
* draw.io).
*/
export function CanvasStage({
className,
style,
hideOverlays = false,
overlay,
waitForSavedLocale = false,
}: {
className?: string
style?: React.CSSProperties
/** Hide the "ask AI" button (e.g. while the start screen covers the canvas) */
/** Hide the overlay (e.g. while the start screen covers the canvas) */
hideOverlays?: boolean
/** Rendered over the canvas once draw.io is ready */
overlay?: (size: CanvasSize) => React.ReactNode
/** Web app: the page is about to switch to the language picked last
* time (useSavedLocale in the workspace); do not load draw.io in the
* URL's language meanwhile */
waitForSavedLocale?: boolean
}) {
const dict = useDictionary()
const params = useParams<{ lang: string }>()
const lang = (
i18n.locales.includes(params.lang as Locale)
? params.lang
: i18n.defaultLocale
) as Locale
const lang = useLocale()
const {
drawioRef,
handleDiagramAutoSave,
@@ -43,7 +51,6 @@ export function CanvasStage({
resetDrawioReady,
isDrawioReady,
} = useDiagram()
const engine = useChatEngine()
const isDark = useSettingsStore((s) => s.isDark)
const hasEditor = useCanvasStore((s) => s.hasEditor)
// Canvas width when the chat panel starts sliding in or out
@@ -53,14 +60,19 @@ export function CanvasStage({
// null until draw.io tells us whether we can drive it directly
const [editorAccess, setEditorAccess] = useState<boolean | null>(null)
const stageRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState({ width: 0, height: 0 })
const [size, setSize] = useState<CanvasSize>({ width: 0, height: 0 })
useEffect(() => {
// The page is about to switch to the language picked last time
// (useSavedLocale): load draw.io once, in that language
const saved = localStorage.getItem(STORAGE_KEYS.locale)
if (saved && saved !== lang && i18n.locales.includes(saved as Locale)) {
return
// Load draw.io once, in the language the page will have
if (waitForSavedLocale) {
const saved = localStorage.getItem(STORAGE_KEYS.locale)
if (
saved &&
saved !== lang &&
i18n.locales.includes(saved as Locale)
) {
return
}
}
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
// The theme class is set before the page renders; the settings store
@@ -159,13 +171,7 @@ export function CanvasStage({
</div>
)}
{showOverlays && (
<SelectionAsk
width={size.width}
height={size.height}
hidden={engine.isBusy}
/>
)}
{showOverlays && overlay?.(size)}
</div>
)
}
@@ -1,5 +1,7 @@
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { VersionThumb } from "@/components/canvas/version-card"
import { useVersionsContext } from "@/components/canvas/versions-context"
import {
Dialog,
DialogContent,
@@ -11,11 +13,8 @@ import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
import { formatMessage } from "@/lib/i18n/utils"
import { describeChanges } from "@/lib/version-text"
import { describeChangeSummary } from "@/lib/version-text"
import { useUiStore } from "@/stores/ui-store"
import { useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
import { VersionThumb } from "./tool-activity"
function Sheet({
label,
@@ -40,12 +39,11 @@ function Sheet({
export function CompareDialog() {
const dict = useDictionary()
const t = dict.versions
const engine = useChatEngine()
const { versions, isBusy, restore } = useVersionsContext()
const { getVersionSvg, chartXML } = useDiagram()
const versionId = useUiStore((s) => s.compareVersionId)
const openCompare = useUiStore((s) => s.openCompare)
const closeCompare = useUiStore((s) => s.closeCompare)
const versions = useVersionsStore((s) => s.versions)
const [currentSvg, setCurrentSvg] = useState<string | null>(null)
const index = versions.findIndex((v) => v.id === versionId)
@@ -82,7 +80,7 @@ export function CompareDialog() {
return () => {
cancelled = true
}
}, [versionId, engine.isBusy])
}, [versionId, isBusy])
return (
<Dialog
@@ -98,7 +96,7 @@ export function CompareDialog() {
: same
? t.isOnCanvas
: changes
? `${formatMessage(t.compareSince, { n: version.number })} ${describeChanges(changes, t)}`
? `${formatMessage(t.compareSince, { n: version.number })} ${describeChangeSummary(changes, t)}`
: formatMessage(t.differsElsewhere, {
n: version.number,
})}
@@ -139,10 +137,10 @@ export function CompareDialog() {
</span>
<button
type="button"
disabled={!version || same || engine.isBusy}
disabled={!version || same || isBusy}
onClick={() => {
if (!version) return
engine.restoreVersion(version.id)
restore(version.id)
closeCompare()
}}
className="ml-auto inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
+29
View File
@@ -0,0 +1,29 @@
"use client"
import { createContext, useContext } from "react"
import type { Locale } from "@/lib/i18n/config"
const LocaleContext = createContext<Locale | null>(null)
/** The page's language; the web layout feeds it from its route param */
export function LocaleProvider({
locale,
children,
}: {
locale: Locale
children: React.ReactNode
}) {
return (
<LocaleContext.Provider value={locale}>
{children}
</LocaleContext.Provider>
)
}
export function useLocale(): Locale {
const locale = useContext(LocaleContext)
if (!locale) {
throw new Error("useLocale must be used within a LocaleProvider")
}
return locale
}
+3 -3
View File
@@ -10,25 +10,25 @@ const GAP = 10
/**
* "Ask AI to change this" next to the selected shapes. Clicking it moves
* focus to the chat input, where the selection already shows as a chip.
* Hidden while an answer is changing the canvas.
*/
export function SelectionAsk({
width,
height,
hidden,
}: {
/** Size of the canvas area, for keeping the button inside it */
width: number
height: number
hidden: boolean
}) {
const dict = useDictionary()
const rect = useCanvasStore((s) => s.selectionRect)
const selection = useCanvasStore((s) => s.selection)
const isFreehand = useCanvasStore((s) => s.isFreehand)
const popupOpen = useCanvasStore((s) => s.isDrawioPopupOpen)
const isBusy = useCanvasStore((s) => s.isBusy)
const focusComposer = useUiStore((s) => s.focusComposer)
if (hidden || popupOpen || !rect || selection.length === 0 || isFreehand) {
if (isBusy || popupOpen || !rect || selection.length === 0 || isFreehand) {
return null
}
// Selection scrolled out of view
+217
View File
@@ -0,0 +1,217 @@
import { Redo2, RotateCcw, Undo2 } from "lucide-react"
import { Shimmer } from "@/components/ai-elements/shimmer"
import {
useVersionsContext,
type VersionItem,
} from "@/components/canvas/versions-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { cn } from "@/lib/utils"
import { describeChangeSummary, describeTotals } from "@/lib/version-text"
import { useUiStore } from "@/stores/ui-store"
/** Picture of a version (SVG data URL), or what stands in for it */
export function VersionThumb({
svg,
dimmed,
className,
}: {
/** undefined or null: still being made; "": none could be made */
svg: string | null | undefined
dimmed?: boolean
className?: string
}) {
const t = useDictionary().versions
if (svg) {
return (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={svg}
alt=""
className={cn(
"object-contain transition-opacity",
dimmed && "opacity-40",
className,
)}
/>
)
}
if (svg === "") {
return <span className="text-xs text-faint">{t.noPreview}</span>
}
return (
<Shimmer as="span" className="text-xs">
{t.rendering}
</Shimmer>
)
}
interface VersionCardProps extends React.ComponentProps<"div"> {
version: VersionItem
/** The newest version shows its picture; older ones fold into one row */
isLatest: boolean
/** What happened ("Drew the diagram"); an undone change says so instead */
label: string
/** Goes after the summary text (the chat adds its "Show XML" link) */
afterSummary?: React.ReactNode
/** Panel below the card (the chat shows the AI's XML there) */
children?: React.ReactNode
}
/**
* Card for one diagram version: picture, number, label, what changed, and
* the action (undo or redo on the latest, restore on older ones). The
* picture opens Compare. Other attributes go on the card's element.
*/
export function VersionCard({
version,
isLatest,
label,
afterSummary,
children,
className,
...rest
}: VersionCardProps) {
const t = useDictionary().versions
const source = useVersionsContext()
const openCompare = useUiStore((s) => s.openCompare)
const isUndone = source.undoneId === version.id
const isOnCanvas = source.onCanvasId === version.id
const title = isUndone ? t.undone : label
const summary = version.fromScratch
? describeTotals(version.summary, t)
: describeChangeSummary(version.summary, t)
const badge = (
<span
className={cn(
"shrink-0 rounded-md px-1.5 py-0.5 text-[11px] font-semibold tabular-nums",
isOnCanvas
? "bg-marker-soft text-marker-ink"
: "bg-muted text-muted-foreground",
)}
>
v{version.number}
</span>
)
const actionClass =
"inline-flex h-7 shrink-0 items-center gap-1 rounded-lg px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
const restoreButton = (
<button
type="button"
disabled={source.isBusy || isOnCanvas}
onClick={() => source.restore(version.id)}
className={actionClass}
data-testid="version-restore"
>
<RotateCcw className="size-3.5" />
{formatMessage(t.restoreVersion, { n: version.number })}
</button>
)
const summaryLine = (
<div className="text-xs text-muted-foreground">
{summary}
{afterSummary}
</div>
)
if (!isLatest) {
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card",
className,
)}
data-testid="version-card"
{...rest}
>
<div className="flex items-center gap-2.5 p-1.5 pr-2">
<button
type="button"
onClick={() => openCompare(version.id)}
className="sheet-light flex h-9 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white hover:border-foreground/30"
aria-label={t.compare}
title={t.compare}
>
<VersionThumb
svg={version.svg}
className="max-h-8 max-w-[52px]"
/>
</button>
{badge}
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-foreground">
{title}
</div>
{summaryLine}
</div>
{restoreButton}
</div>
{children}
</div>
)
}
// Undo and redo belong to the newest change while it is (or was) on
// the canvas; after an older version was restored, this one is
// restored too
const canUndo = source.canUndo || source.canRedo
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card",
isOnCanvas && "border-foreground/15",
className,
)}
data-testid="version-card"
{...rest}
>
<button
type="button"
onClick={() => openCompare(version.id)}
className="group/thumb sheet-light relative flex h-24 w-full items-center justify-center border-b border-border bg-white"
aria-label={t.compare}
>
<VersionThumb
svg={version.svg}
dimmed={isUndone}
className="max-h-20 max-w-[88%]"
/>
<span className="absolute right-2 bottom-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] text-white opacity-0 transition-opacity group-hover/thumb:opacity-100">
{t.compare}
</span>
</button>
{/* Title row with the action; the summary runs under both */}
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3 py-2.5">
{badge}
<div className="truncate text-[13px] font-medium text-foreground">
{title}
</div>
{canUndo ? (
<button
type="button"
disabled={source.isBusy}
onClick={() =>
source.canRedo ? source.redo() : source.undo()
}
className={actionClass}
data-testid="version-undo"
>
{source.canRedo ? (
<Redo2 className="size-3.5" />
) : (
<Undo2 className="size-3.5" />
)}
{source.canRedo ? t.redo : t.undo}
</button>
) : (
restoreButton
)}
<div className="col-span-2 col-start-2 mt-0.5">
{summaryLine}
</div>
</div>
{children}
</div>
)
}
@@ -1,17 +1,20 @@
import { useEffect, useRef } from "react"
import { useVersionsContext } from "@/components/canvas/versions-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { cn } from "@/lib/utils"
import { useUiStore } from "@/stores/ui-store"
import { useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
/** Row of version thumbnails under the panel header; click to compare */
export function VersionStrip() {
export function VersionStrip({
minVersions = 2,
}: {
/** Hidden below this many versions: one version alone adds nothing to
* its card, unless the caller knows there is no card for it */
minVersions?: number
}) {
const dict = useDictionary()
const versions = useVersionsStore((s) => s.versions)
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
const { versions, onCanvasId } = useVersionsContext()
const openCompare = useUiStore((s) => s.openCompare)
const engine = useChatEngine()
const scrollRef = useRef<HTMLDivElement>(null)
// Keep the newest version in view
@@ -20,24 +23,7 @@ export function VersionStrip() {
if (el) el.scrollTo({ left: el.scrollWidth, behavior: "smooth" })
}, [versions.length])
// Versions with no card in the chat: saved by older app versions, from
// a turn that was retried, or drawn by a call that then failed its
// screenshot check (a call still running gets its card when done)
const hasCardless = versions.some(
(v) =>
!v.toolCallId ||
!engine.messages.some((m) =>
m.parts?.some((p) => {
const part = p as { toolCallId?: string; state?: string }
return (
part.toolCallId === v.toolCallId &&
part.state !== "output-error"
)
}),
),
)
// One version with a card: the strip adds nothing to it
if (versions.length < 2 && !hasCardless) return null
if (versions.length < minVersions) return null
return (
<div className="flex items-center gap-2 border-b border-border px-3.5 pt-1 pb-3">
@@ -49,13 +35,13 @@ export function VersionStrip() {
className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto p-1 scrollbar-thin"
>
{versions.map((version) => {
const isCurrent = version.id === onCanvasVersionId
const isCurrent = version.id === onCanvasId
return (
<button
key={version.id}
type="button"
onClick={() => openCompare(version.id)}
title={`v${version.number}${version.id === onCanvasVersionId ? ` · ${dict.versions.currentCanvas}` : ""}`}
title={`v${version.number}${isCurrent ? ` · ${dict.versions.currentCanvas}` : ""}`}
className={cn(
"sheet-light relative flex h-12 w-[72px] shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-white transition-colors hover:border-foreground/30",
// An outline with a gap reads on the white
+72
View File
@@ -0,0 +1,72 @@
"use client"
import { createContext, useContext } from "react"
import type { ChangeSummary } from "@/lib/diagram-diff"
/** One saved state of the diagram, as the version card and the compare
* dialog show it */
export interface VersionItem {
id: string
/** Shown as v{number} */
number: number
/** Full multi-page document */
xml: string
/** The page the change was made on; none: the first page */
pageId?: string
/** Document before the change, when it is known */
beforeXml?: string
/** SVG data URL thumbnail; undefined: still being made; "": none could
* be made */
svg?: string
summary: ChangeSummary
/** Drawn on an empty canvas (the card shows totals, not changes) */
fromScratch?: boolean
}
/**
* Where the versions come from and what can be done with them. The web app
* fills it from the chat's version store; the MCP shell from the server's
* history.
*/
export interface VersionsSource {
versions: VersionItem[]
/** The version the canvas shows now, or null (hand edits, undone) */
onCanvasId: string | null
/** The latest version, when its change was undone */
undoneId: string | null
/** Something is changing the canvas: version actions wait */
isBusy: boolean
/** The latest version's change can be undone */
canUndo: boolean
/** The latest version's change was undone and can be put back */
canRedo: boolean
restore: (id: string) => void
undo: () => void
redo: () => void
}
const VersionsContext = createContext<VersionsSource | null>(null)
export function VersionsProvider({
value,
children,
}: {
value: VersionsSource
children: React.ReactNode
}) {
return (
<VersionsContext.Provider value={value}>
{children}
</VersionsContext.Provider>
)
}
export function useVersionsContext(): VersionsSource {
const source = useContext(VersionsContext)
if (!source) {
throw new Error(
"useVersionsContext must be used within a VersionsProvider",
)
}
return source
}
+5
View File
@@ -726,6 +726,11 @@ export function ChatEngineProvider({
// isBusy of the latest render, for code that runs after an await
const busyRef = useRef(isBusy)
busyRef.current = isBusy
// Canvas components (version cards, the compare dialog, "ask AI") read
// the busy flag from the canvas store
useEffect(() => {
useCanvasStore.getState().setBusy(isBusy ? "chat" : null)
}, [isBusy])
// The page goes (another language mounts a new one): the answer stops,
// so it cannot reach the next page's canvas
const stopRef = useRef(stop)
+22 -2
View File
@@ -7,6 +7,7 @@ import {
Settings,
} from "lucide-react"
import { useRef } from "react"
import { VersionStrip } from "@/components/canvas/version-strip"
import { DevXmlSimulator } from "@/components/dev-xml-simulator"
import { BrandMark } from "@/components/workspace/brand-mark"
import { IconButton } from "@/components/workspace/icon-button"
@@ -21,11 +22,11 @@ import {
useSettingsStore,
} from "@/stores/settings-store"
import { useUiStore } from "@/stores/ui-store"
import { useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
import { Composer } from "./composer"
import { MessageList } from "./message-list"
import { SessionMenu, useSessionTitle } from "./session-menu"
import { VersionStrip } from "./version-strip"
const DEBUG = process.env.NODE_ENV === "development"
@@ -97,6 +98,25 @@ export function ChatPanel({
const openSettings = useUiStore((s) => s.openSettings)
const setPanelOpen = useUiStore((s) => s.setPanelOpen)
// Versions with no card in the chat: saved by older app versions, from
// a turn that was retried, or drawn by a call that then failed its
// screenshot check (a call still running gets its card when done). The
// strip shows them even when there is only one.
const versions = useVersionsStore((s) => s.versions)
const hasCardless = versions.some(
(v) =>
!v.toolCallId ||
!engine.messages.some((m) =>
m.parts?.some((p) => {
const part = p as { toolCallId?: string; state?: string }
return (
part.toolCallId === v.toolCallId &&
part.state !== "output-error"
)
}),
),
)
return (
<aside
className={cn(
@@ -160,7 +180,7 @@ export function ChatPanel({
</div>
</header>
<VersionStrip />
<VersionStrip minVersions={hasCardless ? 1 : 2} />
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
{engine.messages.length > 0 ? <MessageList /> : <EmptyThread />}
+46
View File
@@ -0,0 +1,46 @@
"use client"
import {
VersionsProvider,
type VersionsSource,
} from "@/components/canvas/versions-context"
import { useCanvasStore } from "@/stores/canvas-store"
import { useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
/**
* The chat's versions for the canvas components (version cards, the strip,
* the compare dialog): the version store's list and flags, and the chat
* engine's restore and undo
*/
export function ChatVersionsProvider({
children,
}: {
children: React.ReactNode
}) {
const { restoreVersion, undoVersion } = useChatEngine()
const versions = useVersionsStore((s) => s.versions)
const onCanvasId = useVersionsStore((s) => s.onCanvasVersionId)
const undoneId = useVersionsStore((s) => s.undoneVersionId)
const isBusy = useCanvasStore((s) => s.isBusy)
// Only the latest version can be undone, and only while it is (or was)
// on the canvas: after an older version was restored, the latest one
// is restored too
const latest = versions.at(-1)
const undoable = !!latest && latest.beforeXml !== undefined
const value: VersionsSource = {
versions,
onCanvasId,
undoneId,
isBusy,
canUndo: undoable && latest.id === onCanvasId,
canRedo: undoable && latest.id === undoneId,
restore: restoreVersion,
// The engine's undo toggles: it redoes a change that is undone
undo: () => latest && undoVersion(latest.id),
redo: () => latest && undoVersion(latest.id),
}
return <VersionsProvider value={value}>{children}</VersionsProvider>
}
+35 -202
View File
@@ -5,19 +5,14 @@ import {
Copy,
Library,
PenTool,
Redo2,
RotateCcw,
Undo2,
} from "lucide-react"
import { useState } from "react"
import { Shimmer } from "@/components/ai-elements/shimmer"
import { VersionCard as VersionCardView } from "@/components/canvas/version-card"
import { CodeBlock } from "@/components/code-block"
import { useDictionary } from "@/hooks/use-dictionary"
import { useCopy } from "@/lib/clipboard"
import { formatMessage } from "@/lib/i18n/utils"
import { cn, isMxCellXmlComplete } from "@/lib/utils"
import { describeChanges, describeTotals } from "@/lib/version-text"
import { useUiStore } from "@/stores/ui-store"
import { type DiagramVersion, useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
import type { DiagramOperation, ToolPartLike } from "./types"
@@ -83,45 +78,9 @@ function toolText(part: ToolPartLike): string {
return JSON.stringify(input, null, 2)
}
/** Picture of a version (SVG data URL), or what stands in for it */
export function VersionThumb({
svg,
dimmed,
className,
}: {
/** undefined or null: still being made; "": none could be made */
svg: string | null | undefined
dimmed?: boolean
className?: string
}) {
const t = useDictionary().versions
if (svg) {
return (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={svg}
alt=""
className={cn(
"object-contain transition-opacity",
dimmed && "opacity-40",
className,
)}
/>
)
}
if (svg === "") {
return <span className="text-xs text-faint">{t.noPreview}</span>
}
return (
<Shimmer as="span" className="text-xs">
{t.rendering}
</Shimmer>
)
}
/**
* Card for one diagram version produced by a tool call. The latest shows
* its picture; older ones fold into one row (the picture opens Compare).
* Card for one diagram version produced by a tool call: the shared card
* with a link to what the AI wrote (shown in a panel under the card)
*/
function VersionCard({
part,
@@ -134,175 +93,49 @@ function VersionCard({
}) {
const dict = useDictionary()
const t = dict.versions
const engine = useChatEngine()
const undoneVersionId = useVersionsStore((s) => s.undoneVersionId)
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
const openCompare = useUiStore((s) => s.openCompare)
const [showCode, setShowCode] = useState(false)
const { copied, copy } = useCopy()
const isUndone = undoneVersionId === version.id
const isOnCanvas = onCanvasVersionId === version.id
const isEdit = part.type === "tool-edit_diagram"
// Undo/redo belongs to the newest change while it is (or was) on the
// canvas; after an older version was restored, this one is restored too
const canUndo =
isLatest && version.beforeXml !== undefined && (isOnCanvas || isUndone)
const title = isUndone ? t.undone : isEdit ? t.edited : t.created
const summary = version.fromScratch
? describeTotals(version.summary, t)
: describeChanges(version.summary, t)
const badge = (
<span
className={cn(
"shrink-0 rounded-md px-1.5 py-0.5 text-[11px] font-semibold tabular-nums",
isOnCanvas
? "bg-marker-soft text-marker-ink"
: "bg-muted text-muted-foreground",
)}
>
v{version.number}
</span>
)
const actionClass =
"inline-flex h-7 shrink-0 items-center gap-1 rounded-lg px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
const restoreButton = (
<button
type="button"
disabled={engine.isBusy || isOnCanvas}
onClick={() => engine.restoreVersion(version.id)}
className={actionClass}
data-testid="version-restore"
>
<RotateCcw className="size-3.5" />
{formatMessage(t.restoreVersion, { n: version.number })}
</button>
)
// What the AI wrote: a text link in the summary line, so the card's
// only button is the action (undo or restore)
const summaryLine = (
<div className="text-xs text-muted-foreground">
{summary}
{/* The link wraps as one unit with its separator */}
<span className="whitespace-nowrap">
<span aria-hidden> · </span>
<button
type="button"
onClick={() => setShowCode((v) => !v)}
className="underline decoration-foreground/30 underline-offset-2 hover:text-foreground hover:decoration-foreground"
>
{showCode ? t.hideCode : t.showCode}
</button>
</span>
</div>
)
const codePanel = showCode && (
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
<button
type="button"
onClick={() => copy(toolText(part))}
className="absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-float hover:text-foreground"
aria-label={dict.chat.copyResponse}
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</button>
<ToolInputDetails part={part} />
</div>
)
if (!isLatest) {
return (
<div
className="overflow-hidden rounded-xl border border-border bg-card"
data-testid="version-card"
data-tool-state={part.state}
>
<div className="flex items-center gap-2.5 p-1.5 pr-2">
<button
type="button"
onClick={() => openCompare(version.id)}
className="sheet-light flex h-9 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white hover:border-foreground/30"
aria-label={t.compare}
title={t.compare}
>
<VersionThumb
svg={version.svg}
className="max-h-8 max-w-[52px]"
/>
</button>
{badge}
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-foreground">
{title}
</div>
{summaryLine}
</div>
{restoreButton}
</div>
{codePanel}
</div>
)
}
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card",
isOnCanvas && "border-foreground/15",
)}
data-testid="version-card"
<VersionCardView
version={version}
isLatest={isLatest}
label={isEdit ? t.edited : t.created}
data-tool-state={part.state}
>
<button
type="button"
onClick={() => openCompare(version.id)}
className="group/thumb sheet-light relative flex h-24 w-full items-center justify-center border-b border-border bg-white"
aria-label={t.compare}
>
<VersionThumb
svg={version.svg}
dimmed={isUndone}
className="max-h-20 max-w-[88%]"
/>
<span className="absolute right-2 bottom-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] text-white opacity-0 transition-opacity group-hover/thumb:opacity-100">
{t.compare}
</span>
</button>
{/* Title row with the action; the summary runs under both */}
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3 py-2.5">
{badge}
<div className="truncate text-[13px] font-medium text-foreground">
{title}
</div>
{canUndo ? (
// A text link, so the card's only button is the action (undo
// or restore); it wraps as one unit with its separator
afterSummary={
<span className="whitespace-nowrap">
<span aria-hidden> · </span>
<button
type="button"
disabled={engine.isBusy}
onClick={() => engine.undoVersion(version.id)}
className={actionClass}
data-testid="version-undo"
onClick={() => setShowCode((v) => !v)}
className="underline decoration-foreground/30 underline-offset-2 hover:text-foreground hover:decoration-foreground"
>
{isUndone ? (
<Redo2 className="size-3.5" />
) : (
<Undo2 className="size-3.5" />
)}
{isUndone ? t.redo : t.undo}
{showCode ? t.hideCode : t.showCode}
</button>
) : (
restoreButton
)}
<div className="col-span-2 col-start-2 mt-0.5">
{summaryLine}
</span>
}
>
{showCode && (
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
<button
type="button"
onClick={() => copy(toolText(part))}
className="absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-float hover:text-foreground"
aria-label={dict.chat.copyResponse}
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</button>
<ToolInputDetails part={part} />
</div>
</div>
{codePanel}
</div>
)}
</VersionCardView>
)
}
+13 -2
View File
@@ -4,12 +4,14 @@ import { MessageSquare, PanelRightOpen, Settings, Shapes } from "lucide-react"
import { usePathname, useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import { CanvasStage } from "@/components/canvas/canvas-stage"
import { CompareDialog } from "@/components/canvas/compare-dialog"
import { SelectionAsk } from "@/components/canvas/selection-ask"
import {
ChatEngineProvider,
useChatEngine,
} from "@/components/chat/chat-engine"
import { ChatPanel } from "@/components/chat/chat-panel"
import { CompareDialog } from "@/components/chat/compare-dialog"
import { ChatVersionsProvider } from "@/components/chat/chat-versions"
import { LobbyHero } from "@/components/chat/lobby-hero"
import { SaveDialog } from "@/components/save-dialog"
import { SettingsDialog } from "@/components/settings-dialog"
@@ -116,7 +118,9 @@ export function Workspace() {
return (
<ChatEngineProvider>
<WorkspaceLayout />
<ChatVersionsProvider>
<WorkspaceLayout />
</ChatVersionsProvider>
</ChatEngineProvider>
)
}
@@ -267,6 +271,13 @@ function WorkspaceLayout() {
}
style={isMobile ? undefined : { right: canvasRight }}
hideOverlays={!isMobile && showHero}
overlay={(size) => (
<SelectionAsk
width={size.width}
height={size.height}
/>
)}
waitForSavedLocale
/>
{isMobile ? (
mobileView === "chat" &&
+4 -1
View File
@@ -285,7 +285,10 @@ export function sameFileVars(a: string | null, b: string | null): boolean {
/**
* Whether two documents hold the same pages (names, cells, their order),
* page settings and file variables
* page settings and file variables. This is the web app's one rule for "is
* this version on the canvas" (versions, compare, one-step commits). The
* MCP server's edit gate has its own (contentFingerprint): it compares the
* cells as written, and leaves page settings and file variables out.
*/
export function isSameDocument(a: string, b: string): boolean {
const pagesOf = (doc: Document) => {
+1 -1
View File
@@ -17,7 +17,7 @@ const PARTS: [keyof ChangeSummary, string][] = [
* singular (...One) and plural (...Other) form; languages without plurals
* use the same text for both.
*/
export function describeChanges(
export function describeChangeSummary(
summary: ChangeSummary,
t: VersionDict,
): string {
+10 -1
View File
@@ -30,7 +30,13 @@ interface CanvasState {
isFreehand: boolean
/** One of draw.io's menus or dialogs is open over the canvas */
isDrawioPopupOpen: boolean
set: (partial: Partial<Omit<CanvasState, "set">>) => void
/** Something is changing the canvas (an answer streams in, a sync
* runs): version actions and "ask AI" wait */
isBusy: boolean
/** Who set the busy flag ("chat", "sync"); null when not busy */
busyReason: string | null
set: (partial: Partial<Omit<CanvasState, "set" | "setBusy">>) => void
setBusy: (reason: string | null) => void
}
export const useCanvasStore = create<CanvasState>((set) => ({
@@ -41,5 +47,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
selectionRect: null,
isFreehand: false,
isDrawioPopupOpen: false,
isBusy: false,
busyReason: null,
set: (partial) => set(partial),
setBusy: (reason) => set({ isBusy: reason !== null, busyReason: reason }),
}))
+9 -17
View File
@@ -1,27 +1,19 @@
import { nanoid } from "nanoid"
import { create } from "zustand"
import type { ChangeSummary } from "@/lib/diagram-diff"
import type { VersionItem } from "@/components/canvas/versions-context"
import { EMPTY_SUMMARY } from "@/lib/diagram-diff"
/** One diagram state produced by an AI change, shown as a card in the chat */
export interface DiagramVersion {
id: string
/** Shown as v{number}; it stays when older versions are dropped */
number: number
/** Full multi-page document after the change */
xml: string
/** The page the change was made on; older sessions have none (the first page) */
pageId?: string
/** Diagram before the change; kept only on the latest version */
beforeXml?: string
/** SVG data URL thumbnail; "" when none could be made */
svg?: string
/**
* One diagram state produced by an AI change, shown as a card in the chat.
* What the card displays is the VersionItem part; the rest ties the version
* to its chat turn. The number stays when older versions are dropped; the
* "before" document is kept only on the latest version; older sessions
* have no pageId (the first page).
*/
export interface DiagramVersion extends VersionItem {
/** Index of the user message that started this turn */
turnIndex: number
toolCallId?: string
summary: ChangeSummary
/** Drawn on an empty canvas (the card shows totals, not changes) */
fromScratch?: boolean
/** Saved with the session: this version is what the canvas shows */
onCanvas?: boolean
/** Saved with the session: this version's change was undone */
+5 -5
View File
@@ -6,7 +6,7 @@ import enDict from "@/lib/i18n/dictionaries/en.json"
import jaDict from "@/lib/i18n/dictionaries/ja.json"
import zhDict from "@/lib/i18n/dictionaries/zh.json"
import zhHantDict from "@/lib/i18n/dictionaries/zh-Hant.json"
import { describeChanges, describeTotals } from "@/lib/version-text"
import { describeChangeSummary, describeTotals } from "@/lib/version-text"
import {
MAX_VERSIONS,
useVersionsStore,
@@ -39,7 +39,7 @@ describe("withPageDefaults", () => {
})
})
describe("describeChanges", () => {
describe("describeChangeSummary", () => {
const en = {
shapesAddedOne: "added 1 shape",
shapesAddedOther: "added {count} shapes",
@@ -51,7 +51,7 @@ describe("describeChanges", () => {
it("uses singular and plural forms and sentence case", () => {
expect(
describeChanges(
describeChangeSummary(
{ ...EMPTY_SUMMARY, shapesAdded: 2, edgesChanged: 1 },
en,
),
@@ -59,7 +59,7 @@ describe("describeChanges", () => {
})
it("says so when nothing changed", () => {
expect(describeChanges({ ...EMPTY_SUMMARY }, en)).toBe(
expect(describeChangeSummary({ ...EMPTY_SUMMARY }, en)).toBe(
"No visible changes",
)
})
@@ -71,7 +71,7 @@ describe("describeChanges", () => {
listSeparator: ",",
}
expect(
describeChanges(
describeChangeSummary(
{ ...EMPTY_SUMMARY, shapesAdded: 3, edgesChanged: 1 },
zh,
),