Compare commits

..
Author SHA1 Message Date
dayuan.jiang 97a1c0b1d7 feat(ui): tidy the start screen and the chat panel details
Start screen
- The five examples are one row of picture tiles (title under the picture); the old cards cut their descriptions off with an ellipsis
- "Open .drawio file" leaves the example grid and sits with "Start with a blank canvas" below it, both as outlined buttons
- The "Paper to diagram" example gets a thumbnail of its result (public/paper_demo.svg) like the other four
- The caption says what happens: examples use saved answers, so they appear at once and do not call the AI
- Shorter lede

Chat panel
- The version card's "</>" icon button becomes a "Show XML" text link in the summary line; the summary runs under the title row, so Undo is the card's only button
- Settings uses the gear icon; new diagram uses a file-plus icon instead of the pen that reads as "edit"
- The message box has the same side padding as the message column

Copy
- The model picker says "Server model" instead of "Default" when the server's model is in use
- The theme description no longer claims the diagram always stays on a white sheet (the canvas follows the theme)
- en, zh, ja and zh-Hant dictionaries updated together
2026-10-10 07:46:56 +09:00
Dayuan Jiang 2418c47764 fix(edit): refuse cut-off cell XML in edit_diagram operations (#960)
While an edit streams, the preview applies the operations received so
far. Chrome's DOMParser keeps a cell whose XML is cut off, so a vertex
without its geometry reached the canvas. draw.io placed it at the origin,
shifted the page layout and compensated the scroll position, and the
browser's clamping of that compensation left the diagram out of view
once the edit finished.

Check each new_xml with getXmlSyntaxError, as the whole document
already is, so an incomplete operation is reported instead of applied.
2026-10-09 21:06:49 +09:00
Dayuan Jiang 04d34534fd ci: run the e2e tests in six shards (#959)
* ci: run the e2e tests in four shards

One job ran all 300+ tests with one worker, about 20 minutes. Four
jobs at once take a quarter each, still with one worker per job.

* ci: six e2e shards

With four, the shard of the long workspace tests took 8 minutes while
the others took 4 to 5.
2026-10-08 18:44:14 +09:00
12 changed files with 161 additions and 93 deletions
+5 -5
View File
@@ -1,10 +1,10 @@
import {
ChevronDown,
Download,
FilePlus,
MessageSquareDashed,
PanelRightClose,
Settings2,
SquarePen,
Settings,
} from "lucide-react"
import { useRef } from "react"
import { DevXmlSimulator } from "@/components/dev-xml-simulator"
@@ -139,14 +139,14 @@ export function ChatPanel({
disabled={engine.isBusy}
data-testid="new-chat-button"
>
<SquarePen />
<FilePlus />
</IconButton>
<IconButton
label={dict.nav.settings}
onClick={() => openSettings()}
data-testid="settings-button"
>
<Settings2 />
<Settings />
</IconButton>
{!mobile && (
<IconButton
@@ -173,7 +173,7 @@ export function ChatPanel({
/>
)}
<div className="shrink-0 p-2.5 pt-1">
<div className="shrink-0 px-4 pt-1 pb-3">
<Composer
placeholder={
engine.messages.length > 0 &&
+32 -42
View File
@@ -18,14 +18,18 @@ interface Example {
file?: { path: string; name: string; type: string }
}
/** Secondary start buttons: outlined, so they read as clickable */
const startButtonClass =
"inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-card px-3 text-[13px] text-foreground transition-colors hover:bg-accent [&_svg]:text-muted-foreground"
async function loadExampleFile(file: NonNullable<Example["file"]>) {
const response = await fetch(getAssetUrl(file.path))
const blob = await response.blob()
return new File([blob], file.name, { type: file.type })
}
/** One start-screen choice: a small picture, a title and one line */
function ExampleCard({
/** One start-screen example: a small picture over its title */
function ExampleTile({
onClick,
title,
description,
@@ -35,6 +39,7 @@ function ExampleCard({
}: {
onClick: () => void
title: string
/** Shown as the tooltip: the title alone names the example */
description: string
picture: React.ReactNode
/** An icon on a tinted tile instead of a diagram on paper */
@@ -45,24 +50,20 @@ function ExampleCard({
<button
type="button"
onClick={onClick}
className="group/example flex min-w-0 items-center gap-2.5 rounded-xl border border-border bg-card p-1.5 pr-2.5 text-left transition-colors hover:border-foreground/25"
title={description}
className="group/example flex min-w-0 flex-col gap-2 rounded-xl border border-border bg-card p-1.5 pb-2.5 text-left transition-colors hover:border-foreground/25"
data-testid={testId}
>
<span
className={cn(
"flex h-12 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg",
"flex h-16 w-full items-center justify-center overflow-hidden rounded-lg",
plain ? "bg-muted" : "sheet-light bg-white",
)}
>
{picture}
</span>
<span className="min-w-0 flex-1">
<span className="line-clamp-2 text-[13px] leading-snug font-medium text-foreground">
{title}
</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{description}
</span>
<span className="px-1 text-[13px] leading-snug font-medium text-foreground">
{title}
</span>
</button>
)
@@ -92,6 +93,7 @@ export function LobbyHero({
key: "paper",
title: dict.examples.paperToDiagram,
description: dict.examples.paperDescription,
image: "/paper_demo.svg",
prompt: "Summarize this paper as a diagram",
file: {
path: "/chain-of-thought.txt",
@@ -185,10 +187,12 @@ export function LobbyHero({
placeholder={t.placeholder}
/>
{/* Examples: one row of tiles; the ways to bring your own
diagram follow as a separate group */}
<div
className={cn(
"mt-5 grid gap-2",
compact ? "grid-cols-1" : "grid-cols-3",
compact ? "grid-cols-1" : "grid-cols-5",
)}
>
{examples.map((example) =>
@@ -209,7 +213,7 @@ export function LobbyHero({
</span>
</button>
) : (
<ExampleCard
<ExampleTile
key={example.key}
onClick={() => pickExample(example)}
title={example.title}
@@ -221,7 +225,7 @@ export function LobbyHero({
<img
src={getAssetUrl(example.image)}
alt=""
className="max-h-10 max-w-[56px] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
/>
) : (
<FileText className="size-5 text-muted-foreground" />
@@ -231,48 +235,34 @@ export function LobbyHero({
/>
),
)}
{!compact && (
<ExampleCard
onClick={onOpenFile}
title={dict.workspace.openFile}
description={t.openFileHint}
picture={
<FileUp className="size-5 text-muted-foreground" />
}
plain
/>
)}
</div>
<p className="mt-3 text-center text-xs text-faint">
<p className="mt-2.5 text-center text-xs text-faint">
{dict.examples.cachedNote}
</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-[13px] text-muted-foreground">
{/* Ways to start without an example, as plain buttons */}
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
{engine.sessions.length > 0 && (
<SessionMenu align="center" onOpenFile={onOpenFile}>
<button
type="button"
className="inline-flex items-center gap-1.5 hover:text-foreground"
>
<button type="button" className={startButtonClass}>
<History className="size-4" />
{t.recent}
</button>
</SessionMenu>
)}
{compact && (
<button
type="button"
onClick={onOpenFile}
className="inline-flex items-center gap-1.5 hover:text-foreground"
>
<FileUp className="size-4" />
{dict.workspace.openFile}
</button>
)}
<button
type="button"
onClick={onOpenFile}
className={startButtonClass}
data-testid="open-file"
>
<FileUp className="size-4" />
{dict.workspace.openFile}
</button>
<button
type="button"
onClick={onDrawYourself}
className="inline-flex items-center gap-1.5 hover:text-foreground"
className={startButtonClass}
data-testid="draw-yourself"
>
<Pencil className="size-4" />
+26 -26
View File
@@ -2,7 +2,6 @@ import {
AlertCircle,
Check,
ChevronDown,
Code2,
Copy,
Library,
PenTool,
@@ -180,17 +179,23 @@ function VersionCard({
</button>
)
// What the AI wrote, on every card (main showed it on every tool call)
const codeButton = (
<button
type="button"
onClick={() => setShowCode((v) => !v)}
aria-label={showCode ? t.hideCode : t.showCode}
title={showCode ? t.hideCode : t.showCode}
className="inline-flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Code2 className="size-4" />
</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">
@@ -231,15 +236,12 @@ function VersionCard({
/>
</button>
{badge}
<div className="min-w-0 flex-1" title={summary}>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-foreground">
{title}
</div>
<div className="truncate text-xs text-muted-foreground">
{summary}
</div>
{summaryLine}
</div>
{codeButton}
{restoreButton}
</div>
{codePanel}
@@ -271,17 +273,12 @@ function VersionCard({
{t.compare}
</span>
</button>
<div className="flex items-center gap-2.5 px-3 py-2.5">
{/* 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="min-w-0 flex-1" title={summary}>
<div className="truncate text-[13px] font-medium text-foreground">
{title}
</div>
<div className="line-clamp-2 text-xs text-muted-foreground">
{summary}
</div>
<div className="truncate text-[13px] font-medium text-foreground">
{title}
</div>
{codeButton}
{canUndo ? (
<button
type="button"
@@ -300,6 +297,9 @@ function VersionCard({
) : (
restoreButton
)}
<div className="col-span-2 col-start-2 mt-0.5">
{summaryLine}
</div>
</div>
{codePanel}
</div>
+2 -2
View File
@@ -220,14 +220,14 @@ export function ModelSelector({
<span className="min-w-0 truncate text-xs">
{selectedModel
? shortModelName(selectedModel.modelId)
: dict.modelConfig.default}
: dict.modelConfig.serverDefault}
</span>
) : (
// Keep an sr-only label for screen readers when hidden
<span className="sr-only">
{selectedModel
? selectedModel.modelId
: dict.modelConfig.default}
: dict.modelConfig.serverDefault}
</span>
)}
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
+2 -2
View File
@@ -1,6 +1,6 @@
"use client"
import { MessageSquare, PanelRightOpen, Settings2, Shapes } from "lucide-react"
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"
@@ -100,7 +100,7 @@ function StartBar({
onClick={() => openSettings()}
data-testid="settings-button"
>
<Settings2 />
<Settings />
</IconButton>
</div>
)
+4 -4
View File
@@ -72,7 +72,7 @@
"replicateDescription": "Upload and replicate an existing flowchart",
"creativeDrawing": "Creative drawing",
"creativeDescription": "Draw something fun and creative",
"cachedNote": "Examples open instantly and don't use your model.",
"cachedNote": "Examples use saved answers: they appear instantly and don't call the AI.",
"mcpServer": "MCP Server",
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor"
},
@@ -96,7 +96,7 @@
"language": "Language",
"languageDescription": "Choose your interface language.",
"theme": "Theme",
"themeDescription": "Light or dark interface. The diagram always stays on a white sheet, the way it exports.",
"themeDescription": "Light or dark interface. The canvas follows the same theme.",
"diagramStyle": "Diagram style",
"diagramStyleDescription": "How the AI draws new diagrams: Styled uses colors and icons, Minimal keeps shapes plain.",
"sendShortcut": "Send shortcut",
@@ -400,7 +400,7 @@
"searchModels": "Search models...",
"noModelsFound": "No models found.",
"default": "Default",
"serverDefault": "Server Default",
"serverDefault": "Server model",
"serverModels": "Server Models",
"userModels": "User Models",
"configureModels": "Configure Models...",
@@ -695,7 +695,7 @@
},
"lobby": {
"title": "What would you like to draw?",
"lede": "Describe a diagram, or upload a screenshot or PDF. You get a regular draw.io diagram that you can keep editing and export.",
"lede": "Describe a diagram or upload a screenshot or PDF. You get a draw.io diagram you can keep editing and export.",
"placeholder": "For example: a login sequence diagram with SMS codes and retries",
"recent": "Recent diagrams",
"drawYourself": "Start with a blank canvas",
+4 -4
View File
@@ -72,7 +72,7 @@
"replicateDescription": "既存のフローチャートをアップロードして複製",
"creativeDrawing": "自由なイラスト",
"creativeDescription": "楽しくてクリエイティブなものを描く",
"cachedNote": "例はすぐに開き、モデルは使いません。",
"cachedNote": "例は保存済みの回答を使うので、すぐに表示され、AI を呼び出しません。",
"mcpServer": "MCP サーバー",
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用"
},
@@ -96,7 +96,7 @@
"language": "言語",
"languageDescription": "インターフェース言語を選択します。",
"theme": "テーマ",
"themeDescription": "画面をライトにするかダークにするか。図はエクスポートしたときと同じく、常に白い用紙の上に表示されます。",
"themeDescription": "画面をライトにするかダークにするか。キャンバスも同じテーマになります。",
"diagramStyle": "図のスタイル",
"diagramStyleDescription": "AI が新しい図を描くときのスタイル。スタイル付きは色やアイコンを使い、ミニマルは素朴な図形だけにします。",
"sendShortcut": "送信のショートカット",
@@ -355,7 +355,7 @@
"searchModels": "モデルを検索...",
"noModelsFound": "モデルが見つかりません。",
"default": "デフォルト",
"serverDefault": "サーバーデフォルト",
"serverDefault": "サーバーのモデル",
"serverModels": "サーバーモデル",
"userModels": "ユーザーモデル",
"configureModels": "モデルを設定...",
@@ -695,7 +695,7 @@
},
"lobby": {
"title": "どんな図を描きますか?",
"lede": "図の内容を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは通常の draw.io の図なので、続けて編集もエクスポートもできます。",
"lede": "図を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは編集もエクスポートもできる draw.io の図です。",
"placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図",
"recent": "最近の図",
"drawYourself": "白紙のキャンバスから始める",
+4 -4
View File
@@ -72,7 +72,7 @@
"replicateDescription": "上傳並複製現有流程圖",
"creativeDrawing": "創意繪畫",
"creativeDescription": "繪製有趣且富有創意的內容",
"cachedNote": "範例會立即開啟,不呼叫模型。",
"cachedNote": "範例使用保存好的答案,立即顯示,不呼叫 AI。",
"mcpServer": "MCP 伺服器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
},
@@ -96,7 +96,7 @@
"language": "語言",
"languageDescription": "選擇介面語言。",
"theme": "主題",
"themeDescription": "介面用淺色還是深色。圖紙始終是白底,和匯出後的樣子一致。",
"themeDescription": "介面用淺色還是深色,畫布跟隨同一主題。",
"diagramStyle": "圖形風格",
"diagramStyleDescription": "AI 畫新圖時的風格:精緻會用顏色和圖示,簡約只用樸素的圖形。",
"sendShortcut": "傳送快速鍵",
@@ -400,7 +400,7 @@
"searchModels": "搜尋模型...",
"noModelsFound": "未找到模型。",
"default": "預設",
"serverDefault": "伺服器預設",
"serverDefault": "伺服器模型",
"serverModels": "伺服器模型",
"userModels": "使用者模型",
"configureModels": "配置模型...",
@@ -695,7 +695,7 @@
},
"lobby": {
"title": "說說你要畫什麼",
"lede": "描述一張圖,或上傳截圖、PDF。畫出來的是標準的 draw.io 圖表,可以接著改,也可以匯出。",
"lede": "描述一張圖,或上傳截圖、PDF。得到的是可以繼續編輯和匯出的 draw.io 圖。",
"placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試",
"recent": "最近的圖紙",
"drawYourself": "從空白畫布開始",
+4 -4
View File
@@ -72,7 +72,7 @@
"replicateDescription": "上传并复制现有流程图",
"creativeDrawing": "创意绘画",
"creativeDescription": "绘制有趣且富有创意的内容",
"cachedNote": "示例会立即打开,不调用模型。",
"cachedNote": "示例使用保存好的答案,立即显示,不调用 AI。",
"mcpServer": "MCP 服务器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
},
@@ -96,7 +96,7 @@
"language": "语言",
"languageDescription": "选择界面语言。",
"theme": "主题",
"themeDescription": "界面用浅色还是深色。图纸始终是白底,和导出后的样子一致。",
"themeDescription": "界面用浅色还是深色,画布跟随同一主题。",
"diagramStyle": "图形风格",
"diagramStyleDescription": "AI 画新图时的风格:精致会用颜色和图标,简约只用朴素的图形。",
"sendShortcut": "发送快捷键",
@@ -400,7 +400,7 @@
"searchModels": "搜索模型...",
"noModelsFound": "未找到模型。",
"default": "默认",
"serverDefault": "服务器默认",
"serverDefault": "服务器模型",
"serverModels": "服务器模型",
"userModels": "用户模型",
"configureModels": "配置模型...",
@@ -695,7 +695,7 @@
},
"lobby": {
"title": "说说你要画什么",
"lede": "描述一张图,或者上传截图、PDF。画出来的是标准的 draw.io 图表,可以接着改,也可以导出。",
"lede": "描述一张图,或上传截图、PDF。得到的是可以继续编辑和导出的 draw.io 图。",
"placeholder": "比如:用户登录的时序图,包含短信验证码和失败重试",
"recent": "最近的图纸",
"drawYourself": "从空白画布开始",
@@ -216,6 +216,20 @@ export function applyDiagramOperations(
continue
}
// A cut-off cell (its XML still streaming in a preview) would
// parse in Chrome to a cell without its geometry
const syntaxError = getXmlSyntaxError(
`<wrapper>${op.new_xml}</wrapper>`,
)
if (syntaxError) {
errors.push({
type: "update",
cellId: op.cell_id,
message: `new_xml is not well-formed XML: ${syntaxError}`,
})
continue
}
// Parse the new XML
const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`,
@@ -268,6 +282,20 @@ export function applyDiagramOperations(
continue
}
// A cut-off cell (its XML still streaming in a preview) would
// parse in Chrome to a cell without its geometry
const syntaxError = getXmlSyntaxError(
`<wrapper>${op.new_xml}</wrapper>`,
)
if (syntaxError) {
errors.push({
type: "add",
cellId: op.cell_id,
message: `new_xml is not well-formed XML: ${syntaxError}`,
})
continue
}
// Parse the new XML
const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`,
@@ -149,3 +149,52 @@ describe("a wrapped mxCell with its wrapper's id", () => {
expect(result).toContain('label="B"')
})
})
describe("cut-off new_xml", () => {
// While edit_diagram streams, the last operation's XML is often
// incomplete. Chrome's DOMParser keeps the partial cell, so it must be
// refused before it reaches the diagram.
const CUT = `<mxCell id="b" value="B2" style="rounded=1;" vertex="1" parent="1">`
it("refuses an update whose XML is cut off", () => {
const { result, errors } = applyDiagramOperations(DOC, [
{ operation: "update", cell_id: "b", new_xml: CUT },
])
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({ type: "update", cellId: "b" })
expect(errors[0].message).toContain("not well-formed")
expect(result).toContain('value="B"')
expect(result).not.toContain("B2")
})
it("refuses an add whose XML is cut off", () => {
const { result, errors } = applyDiagramOperations(DOC, [
{
operation: "add",
cell_id: "n",
new_xml: `<mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="1" y="2" width="3"`,
},
])
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({ type: "add", cellId: "n" })
expect(result).not.toContain('id="n"')
})
it("applies the complete operations before a cut-off one", () => {
const { result, errors } = applyDiagramOperations(DOC, [
{
operation: "update",
cell_id: "b",
new_xml: `<mxCell id="b" value="B3" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>`,
},
{
operation: "add",
cell_id: "n",
new_xml: `<mxCell id="n" value="N" vertex="1" parent="1">`,
},
])
expect(errors).toHaveLength(1)
expect(result).toContain('value="B3"')
expect(result).not.toContain('id="n"')
})
})
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 67 KiB