feat(mcp-server): version cards in the shell from the server's History

The shell's VersionsSource (shell/node-versions-source.ts) reads GET
/api/history through the sync and shows one version per distinct content
(a restore's copy folds into the entry it copies; the blank page is none),
numbered as they first appeared, with what changed since the one before.
The entry the server says is on the canvas marks the version; undo and
redo of the newest version restore the one before it, or it again, through
POST /api/restore, so hand edits stay as versions of their own. The cards
sit in a panel beside the canvas (toggle in the header); the strip and
Compare come from the shared canvas components. The sync tells listeners
once per server version, and when History changed without one, so the
list is read again only then.
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent 66c9daf5ca
commit 0de1cdfe7c
12 changed files with 885 additions and 46 deletions
+58 -29
View File
@@ -1,42 +1,35 @@
import { Moon, Sun } from "lucide-react"
import { useMemo } from "react"
import { History, Moon, Sun } from "lucide-react"
import { useMemo, useState } from "react"
import { CanvasStage } from "@/components/canvas/canvas-stage"
import { CompareDialog } from "@/components/canvas/compare-dialog"
import { VersionStrip } from "@/components/canvas/version-strip"
import {
VersionsProvider,
type VersionsSource,
} from "@/components/canvas/versions-context"
import { VersionsProvider } from "@/components/canvas/versions-context"
import { AppToaster } from "@/components/workspace/app-toaster"
import { useDictionary } from "@/hooks/use-dictionary"
import type { DrawioSource } from "@/lib/drawio/drawio-config"
import { cn } from "@/lib/utils"
import { useSettingsStore } from "@/stores/settings-store"
import { useNodeVersions } from "./node-versions-source"
import type { ShellConfig } from "./runtime-config"
import { setDark } from "./theme"
import { useMcpSync } from "./use-mcp-sync"
import { VersionsPanel } from "./versions-panel"
// No versions until the shell reads the server's History (a later step)
const NO_VERSIONS: VersionsSource = {
versions: [],
onCanvasId: null,
undoneId: null,
isBusy: false,
canUndo: false,
canRedo: false,
restore: () => {},
undo: () => {},
redo: () => {},
}
const iconButton =
"inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
/**
* The MCP preview: the web app's canvas without the chat. A slim bar names
* the session and the sync state; the versions strip and Compare come from
* the shared canvas components.
* the session and the sync state; the versions strip, the version cards
* (from the server's History) and Compare come from the shared canvas
* components.
*/
export function CanvasShell({ config }: { config: ShellConfig }) {
const dict = useDictionary()
const isDark = useSettingsStore((s) => s.isDark)
const status = useMcpSync(config)
const { status, sync } = useMcpSync(config)
const versions = useNodeVersions(sync)
const [panelOpen, setPanelOpen] = useState(true)
const drawioSource = useMemo<DrawioSource>(
() => ({
editorUrl: config.drawioBaseUrl,
@@ -45,9 +38,11 @@ export function CanvasShell({ config }: { config: ShellConfig }) {
}),
[config],
)
const hasVersions = versions.versions.length > 0
const showPanel = panelOpen && hasVersions
return (
<VersionsProvider value={NO_VERSIONS}>
<VersionsProvider value={versions}>
<div className="flex h-dvh flex-col bg-background text-foreground">
<header className="flex h-11 shrink-0 items-center gap-3 border-b border-border bg-surface-0 px-3.5">
<span className="text-[13px] font-semibold tracking-[-0.01em]">
@@ -69,23 +64,57 @@ export function CanvasShell({ config }: { config: ShellConfig }) {
>
{dict.shell[status]}
</span>
{hasVersions && (
<button
type="button"
onClick={() => setPanelOpen((open) => !open)}
aria-label={
panelOpen
? dict.shell.hideVersions
: dict.shell.showVersions
}
title={
panelOpen
? dict.shell.hideVersions
: dict.shell.showVersions
}
aria-pressed={panelOpen}
className={cn(
iconButton,
"ml-auto",
panelOpen && "bg-accent text-foreground",
)}
data-testid="versions-toggle"
>
<History />
</button>
)}
<button
type="button"
onClick={() => setDark(!isDark)}
aria-label={dict.shell.toggleTheme}
title={dict.shell.toggleTheme}
className="ml-auto inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
className={cn(iconButton, !hasVersions && "ml-auto")}
data-testid="theme-toggle"
>
{isDark ? <Sun /> : <Moon />}
</button>
</header>
<VersionStrip minVersions={1} />
<div className="relative flex-1">
<CanvasStage
className="inset-0"
drawioSource={drawioSource}
/>
<div className="flex min-h-0 flex-1">
<div className="flex min-w-0 flex-1 flex-col">
{/* With the cards open, one version alone adds
nothing to its card */}
<VersionStrip minVersions={showPanel ? 2 : 1} />
<div className="relative flex-1">
<CanvasStage
className="inset-0"
drawioSource={drawioSource}
/>
</div>
</div>
{showPanel && (
<VersionsPanel versions={versions.versions} />
)}
</div>
<CompareDialog />
<AppToaster />
+59 -9
View File
@@ -42,6 +42,21 @@ export interface HistoryEntryInfo {
id: number
index: number
svg: string
xml: string
/** Who made it: "user", "recover", "restore"; null for the AI's writes */
source: string | null
time: number
pages: number
/** The oldest entry with the same content (itself when it is the first) */
firstId: number
}
/** GET /api/history as the server answers it, for the state the list is of */
export interface HistoryList {
entries: HistoryEntryInfo[]
stateId: string | null
/** The entry whose content the canvas shows (the server's rule), or null */
currentId: number | null
}
/**
@@ -121,18 +136,18 @@ export interface McpSync {
/** draw.io reported a load done */
onDrawioLoad(): void
/** The server's History, with the state its ids belong to; null when
* it could not be read, or the state changed meanwhile. For the versions
* strip (plan step 5), with restoreEntry below; until then only the
* unit tests call them */
fetchHistory(): Promise<{
entries: HistoryEntryInfo[]
stateId: string | null
} | null>
* it could not be read, or the state changed meanwhile (the version
* cards, shell/node-versions-source.ts) */
fetchHistory(): Promise<HistoryList | null>
/** Put a History entry back, naming the state its list belongs to */
restoreEntry(
id: number,
listStateId: string | null,
): Promise<"ok" | "stale" | "failed">
/** Called when the server's diagram or History changed (a write
* loaded, an edit saved, a thumbnail sent, the state recreated): the
* version cards read History again. Returns the unsubscribe */
onServerChange(listener: () => void): () => void
/** For tests and the status bar */
read(): {
stateId: string | null
@@ -299,6 +314,22 @@ export function createMcpSync(options: SyncOptions): McpSync {
onStatus?.(next)
}
// Listeners of onServerChange, told once per server version seen (any
// source: a write, the tab's own push) and whenever History changed
// without one (a rejected edit kept, a thumbnail stored)
const changeListeners = new Set<() => void>()
let notedVersion = 0
let notedStateId: string | null = null
const emitChange = () => {
for (const listener of changeListeners) listener()
}
const noteServerVersion = (version: number, sid: string | null) => {
if (version === notedVersion && sid === notedStateId) return
notedVersion = version
notedStateId = sid
emitChange()
}
// A 403 means another MCP process (with its own token) now answers on
// this port: read its token and retry once, so the next poll can recover
// the session instead of being refused forever
@@ -379,7 +410,10 @@ export function createMcpSync(options: SyncOptions): McpSync {
svg: svgDataUrl(result.data),
stateId: t.stateId,
version: t.version,
}).catch(() => {})
})
// The write's History entry has its picture now
.then(emitChange)
.catch(() => {})
})
}
@@ -434,6 +468,7 @@ export function createMcpSync(options: SyncOptions): McpSync {
if (sid !== stateId || d.version < currentVersion) return
currentVersion = d.version
lastXml = xml
noteServerVersion(currentVersion, stateId)
// The canvas changed while this edit was on its way, to
// something no pending autosave will send (an undo back to
// the previous version): send it now. A sync reply is
@@ -460,6 +495,8 @@ export function createMcpSync(options: SyncOptions): McpSync {
onNotice(
source === "recover" ? "restoredFromFile" : "aiChanged",
)
// History grew without a new version
emitChange()
}
poll()
}
@@ -662,6 +699,8 @@ export function createMcpSync(options: SyncOptions): McpSync {
}
justLoaded = true
}
// Also the tab's own push, and a version a projection holds back
noteServerVersion(s.version, stateId)
// A sync request (get_diagram): after the load above, so draw.io
// exports what it just loaded; never while a one-page projection
// is on screen, which would be sent as the whole document
@@ -770,7 +809,12 @@ export function createMcpSync(options: SyncOptions): McpSync {
if (!r.ok) return null
const d = await r.json()
if (sid !== stateId) return null
return { entries: d.entries ?? [], stateId: sid }
return {
entries: d.entries ?? [],
stateId: sid,
currentId:
typeof d.currentId === "number" ? d.currentId : null,
}
} catch {
return null
}
@@ -796,6 +840,12 @@ export function createMcpSync(options: SyncOptions): McpSync {
onNotice("restoreFailed")
return "failed"
},
onServerChange(listener) {
changeListeners.add(listener)
return () => {
changeListeners.delete(listener)
}
},
read() {
return {
stateId,
@@ -0,0 +1,221 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import type {
VersionItem,
VersionsSource,
} from "@/components/canvas/versions-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { type ChangeSummary, diffDiagrams } from "@/lib/diagram-diff"
import { formatMessage } from "@/lib/i18n/utils"
import { contentFingerprint } from "@/packages/mcp-server/src/edit-gate.ts"
import { BLANK_MXFILE, hasCells } from "@/packages/mcp-server/src/pages.ts"
import { useCanvasStore } from "@/stores/canvas-store"
import type { HistoryEntryInfo, HistoryList, McpSync } from "./mcp-sync-core"
/**
* A version card of the shell: one per distinct content of the server's
* History, which keeps every state the diagram went through (a restore
* adds a copy of an older entry; copies fold into their first entry).
*/
export interface NodeVersion extends VersionItem {
/** The History entry to restore (the first copy's) */
entryId: number
/** Who made it: "user", "recover", "restore"; null for the AI */
source: string | null
}
// The blank page a tab shows before any drawing: the server keeps it in
// History when the first write replaces it, but it is no version to show
// or go back to (the server's restore leaves it out by the same rule)
const BLANK_FINGERPRINT = contentFingerprint(BLANK_MXFILE)
const isBlankPage = (xml: string) =>
!hasCells(xml) && contentFingerprint(xml) === BLANK_FINGERPRINT
/** The ids of a document's pages; a bare model has one, without an id */
function pageIds(xml: string): (string | undefined)[] {
const doc = new DOMParser().parseFromString(xml, "text/xml")
const ids = Array.from(doc.querySelectorAll("diagram")).map(
(page) => page.getAttribute("id") ?? undefined,
)
return ids.length > 0 ? ids : [undefined]
}
/** The page a change was made on: the first page with a change, else none */
function changedPage(beforeXml: string, afterXml: string): string | undefined {
for (const id of pageIds(afterXml)) {
const { summary } = diffDiagrams(beforeXml, afterXml, id)
if (Object.values(summary).some((n) => n > 0)) return id
}
return undefined
}
interface Change {
pageId?: string
summary: ChangeSummary
fromScratch: boolean
}
/**
* The version cards for a History list: entries folded by content, in the
* order of their first copy, numbered as they first appeared in this tab
* (numbers is kept between calls), each with what changed since the one
* before. The thumbnail is the first copy's that has one; the newest
* version on the canvas waits for its picture (the sync sends it), older
* ones without any show none. The blank page is no version.
*/
export function buildVersions(
list: HistoryList,
numbers: Map<number, number>,
changes: Map<number, Change>,
): { versions: NodeVersion[]; onCanvasId: string | null } {
const groups = new Map<number, HistoryEntryInfo[]>()
for (const entry of list.entries) {
const copies = groups.get(entry.firstId)
if (copies) copies.push(entry)
else groups.set(entry.firstId, [entry])
}
const current = list.entries.find((e) => e.id === list.currentId)
const onCanvasId = current ? String(current.firstId) : null
const versions: NodeVersion[] = []
let previous: HistoryEntryInfo | null = null
for (const [firstId, copies] of groups) {
const first = copies[0]
if (isBlankPage(first.xml)) {
previous = first
continue
}
let number = numbers.get(firstId)
if (number === undefined) {
number = Math.max(0, ...numbers.values()) + 1
numbers.set(firstId, number)
}
let change = changes.get(firstId)
if (!change) {
const beforeXml = previous?.xml ?? ""
const pageId = changedPage(beforeXml, first.xml)
const { summary, fromScratch } = diffDiagrams(
beforeXml,
first.xml,
pageId,
)
change = { pageId, summary, fromScratch }
changes.set(firstId, change)
}
const svg = copies.find((c) => c.svg)?.svg
versions.push({
id: String(firstId),
entryId: firstId,
number,
xml: first.xml,
beforeXml: previous?.xml ?? "",
...(change.pageId && { pageId: change.pageId }),
summary: change.summary,
fromScratch: change.fromScratch,
svg: svg || "",
source: first.source,
})
previous = first
}
// The newest version on the canvas waits for its picture
const latest = versions.at(-1)
if (latest && !latest.svg && latest.id === onCanvasId) {
latest.svg = undefined
}
return { versions, onCanvasId }
}
/** The versions source with the shell's own version type */
export interface NodeVersionsSource extends Omit<VersionsSource, "versions"> {
versions: NodeVersion[]
}
/**
* The canvas components' versions, from the MCP server's History: read
* again whenever the sync reports a change on the server, restored through
* it. Undo and redo of the newest version restore the one before it, or it
* again; the server keeps the state they leave as an entry of its own, so
* hand edits are never lost.
*/
export function useNodeVersions(sync: McpSync | null): NodeVersionsSource {
const dict = useDictionary()
const [list, setList] = useState<HistoryList | null>(null)
const [busy, setBusy] = useState(false)
// Stable across refreshes: an entry's number and change never move
const numbersRef = useRef(new Map<number, number>())
const changesRef = useRef(new Map<number, Change>())
// Reads overlap when changes come fast; only the latest one counts
const readSeqRef = useRef(0)
const refresh = useCallback(async () => {
if (!sync) return
const seq = ++readSeqRef.current
const next = await sync.fetchHistory()
if (next && seq === readSeqRef.current) setList(next)
}, [sync])
useEffect(() => {
if (!sync) return
refresh()
return sync.onServerChange(refresh)
}, [sync, refresh])
const { versions, onCanvasId } = useMemo(
() =>
list
? buildVersions(list, numbersRef.current, changesRef.current)
: { versions: [], onCanvasId: null },
[list],
)
const restore = async (id: string) => {
if (!sync || !list || busy) return
const version = versions.find((v) => v.id === id)
if (!version) return
setBusy(true)
// The canvas components wait (SelectionAsk, the cards' buttons)
useCanvasStore.getState().set({ isBusy: true })
try {
const result = await sync.restoreEntry(
version.entryId,
list.stateId,
)
if (result === "ok") {
toast.success(
formatMessage(dict.versions.restoredVersion, {
n: version.number,
}),
{ duration: 4000 },
)
}
// The sync's notices tell about a stale list or a failure; the
// list on screen is read again either way
await refresh()
} finally {
setBusy(false)
useCanvasStore.getState().set({ isBusy: false })
}
}
const latest = versions.at(-1)
const previous = versions.at(-2)
const canUndo = !!latest && !!previous && onCanvasId === latest.id
// The newest change is undone when the canvas is at the version before
const undoneId =
latest && previous && onCanvasId === previous.id ? latest.id : null
return {
versions,
onCanvasId,
undoneId,
isBusy: busy,
canUndo,
canRedo: undoneId !== null,
restore,
undo: () => {
if (canUndo && previous) restore(previous.id)
},
redo: () => {
if (undoneId && latest) restore(latest.id)
},
}
}
+10 -3
View File
@@ -13,12 +13,17 @@ import type { ShellConfig } from "./runtime-config"
* protocol): mounted once inside DiagramProvider. Server writes go on the
* canvas through loadDiagram, as one undo step when they can; the user's
* edits are pushed; the server's export and sync requests are answered.
* Returns the connection state for the status bar.
* Returns the connection state for the status bar, and the sync itself for
* the version cards (History and restores go through it).
*/
export function useMcpSync(config: ShellConfig): SyncStatus {
export function useMcpSync(config: ShellConfig): {
status: SyncStatus
sync: McpSync | null
} {
const dict = useDictionary()
const diagram = useDiagram()
const [status, setStatus] = useState<SyncStatus>("waiting")
const [sync, setSync] = useState<McpSync | null>(null)
const syncRef = useRef<McpSync | null>(null)
// The provider's functions are new on every render; the sync (created
// once) reads the latest through refs
@@ -54,6 +59,7 @@ export function useMcpSync(config: ShellConfig): SyncStatus {
onStatus: setStatus,
})
syncRef.current = sync
setSync(sync)
sync.setReady(diagramRef.current.isDrawioReady)
sync.start()
@@ -86,6 +92,7 @@ export function useMcpSync(config: ShellConfig): SyncStatus {
window.removeEventListener("message", onMessage)
sync.stop()
syncRef.current = null
setSync(null)
}
}, [config])
@@ -107,5 +114,5 @@ export function useMcpSync(config: ShellConfig): SyncStatus {
return () => diagram.setCommitHandler(null)
}, [])
return status
return { status, sync }
}
@@ -0,0 +1,50 @@
import { useEffect, useRef } from "react"
import { VersionCard } from "@/components/canvas/version-card"
import { useDictionary } from "@/hooks/use-dictionary"
import type { Dictionary } from "@/lib/i18n/dictionaries"
import type { NodeVersion } from "./node-versions-source"
/** What a card says happened: the web app's chat knows the tool that ran,
* the shell tells from who made the entry and what changed */
export function versionLabel(version: NodeVersion, dict: Dictionary): string {
if (version.source === "user" || version.source === "recover") {
return dict.shell.userEdit
}
return version.fromScratch ? dict.versions.created : dict.versions.edited
}
/** The version cards, oldest first, as the web app's chat shows them */
export function VersionsPanel({ versions }: { versions: NodeVersion[] }) {
const dict = useDictionary()
const listRef = useRef<HTMLDivElement>(null)
// Keep the newest version in view
useEffect(() => {
const el = listRef.current
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" })
}, [versions.length])
return (
<aside
className="flex w-[340px] shrink-0 flex-col border-l border-border bg-surface-0"
data-testid="versions-panel"
>
<div className="flex h-9 shrink-0 items-center px-3.5 text-xs font-medium text-muted-foreground">
{dict.versions.strip}
</div>
<div
ref={listRef}
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-3 pb-3 scrollbar-thin"
>
{versions.map((version, i) => (
<VersionCard
key={version.id}
version={version}
isLatest={i === versions.length - 1}
label={versionLabel(version, dict)}
/>
))}
</div>
</aside>
)
}