Merge origin/main (#984, the canvas shell) into the cleanup branch

Conflicts resolved in 16 files. Where main's new shell, classic page and
tests depend on a wire format or a signature the cleanup had changed, main
wins; where main only extended code the cleanup had restructured, main's
change is carried over into the new structure:

- MCP HTTP server: the poll response keeps main's separate export fields
  (exportFormat, exportXml, exportOptions, exportId with a random id)
  instead of the cleanup's pendingExport object; the classic preview page
  is main's version
- MCP index: start_session keeps the cleanup's structure and gains main's
  BROWSER=none, previewUrl and same-origin note; get_selection is written
  in the registerTool style; create_new_diagram keeps the cleanup's
  helpers with main's comment
- history: addHistory takes main's source parameter and returns the index
  again (main's signature)
- new-diagram: the cleanup's prepareNewModel split plus main's page ids
  for diagrams without one
- diagram context: one ExportResult type (main's, with optional fields)
  for both the tagged exports and main's requestExport; the cleanup's
  single export resolver stays
- versions store and version-text test: main's VersionItem base type and
  describeChangeSummary rename, without the legacy history conversion the
  cleanup removed
- dictionaries: main's shell section added; openFileHint stays removed
  (unused on main too)
- tests: the server wiring test keeps the SDK client and gains main's
  session cases; the HTTP server test uses main's export fields; the DOM
  polyfill comes from tests/setup.ts
- package.json: main's postcss kept, the cleanup's removals kept; lockfile
  regenerated
This commit is contained in:
dayuan.jiang
2026-10-12 11:59:51 +09:00
88 changed files with 9744 additions and 605 deletions
+44 -31
View File
@@ -1,11 +1,14 @@
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"
import {
DRAWIO_CSS,
type DrawioSource,
getDrawioSrc,
webDrawioSource,
} from "@/lib/drawio/drawio-config"
import { keepCenter, refitIfRecent } from "@/lib/drawio/editor-bridge"
import { i18n, type Locale } from "@/lib/i18n/config"
import { STORAGE_KEYS } from "@/lib/storage"
@@ -13,28 +16,42 @@ 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,
drawioSource,
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
/** Where the editor comes from; none: the web app's own copy (the MCP
* shell passes what its server configured) */
drawioSource?: DrawioSource
/** 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 +60,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,20 +69,24 @@ 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
// may not have read it yet
const dark = document.documentElement.classList.contains("dark")
setSrc(getDrawioSrc({ lang, isElectron, dark }))
setSrc(getDrawioSrc(drawioSource ?? webDrawioSource(lang), dark))
}, [lang])
// A new iframe starts over: draw.io reports load and access again
@@ -82,8 +102,7 @@ export function CanvasStage({
if (editorAccess !== false || !src) return
const startedDark = new URL(src).searchParams.get("dark") === "1"
if (startedDark === isDark) return
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
setSrc(getDrawioSrc({ lang, isElectron, dark: isDark }))
setSrc(getDrawioSrc(drawioSource ?? webDrawioSource(lang), isDark))
}, [isDark, editorAccess, lang, src])
useEffect(() => {
@@ -158,13 +177,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
@@ -663,6 +663,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().set({ isBusy })
}, [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>
)
}
+5 -9
View File
@@ -4,7 +4,10 @@ import { toast } from "sonner"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
import { clearHighlights, highlightCells } from "@/lib/drawio/editor-bridge"
import {
clearHighlights,
highlightChangedCells,
} from "@/lib/drawio/editor-bridge"
import { formatMessage } from "@/lib/i18n/utils"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
import { useVersionsStore } from "@/stores/versions-store"
@@ -63,14 +66,7 @@ export function useVersions({
fromScratch,
})
// Mark what changed, unless the whole diagram is new
if (!fromScratch) {
setTimeout(() => {
const marker = getComputedStyle(document.documentElement)
.getPropertyValue("--marker")
.trim()
highlightCells(touchedIds, marker || "#ffd84d")
}, 60)
}
if (!fromScratch) highlightChangedCells(touchedIds)
// The picture is of the canvas: take it right away, before a
// newer version can be on it; once one is, this one gets none
// rather than the newer one's
+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" &&