feat(ui): canvas-first redesign with a self-hosted draw.io (#957)

The canvas now fills the window and the chat floats on its right. draw.io is
served from our own origin (public/drawio, downloaded at build time and
checked against the release's SHA-256), so the app can drive the editor
directly:

- every AI change becomes a version card with a thumbnail; compare, restore
  or undo it, and Ctrl+Z on the canvas takes an AI change back in one step
- shapes the AI just changed are highlighted on the canvas
- selected shapes appear as a chip and are sent to the model
- draw.io's own simple UI: its toolbar, menus (auto layout, Mermaid, CSV
  import) and page tabs; the shape library starts closed
- new start screen, session rename and switcher, open .drawio files
- settings split into Models & keys, Appearance, Advanced and About
- Bedrock in the model settings also takes an API key instead of access keys
- phones switch between canvas and chat
- dark mode uses draw.io's own dark mode, with neutral greys around it

With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) the features above
that need same origin are off.

The chat logic moved from the 1500-line chat panel into a chat engine plus
small UI components; zustand holds canvas, versions, settings and UI state.
This commit is contained in:
Dayuan Jiang
2026-10-08 16:20:15 +09:00
committed by GitHub
parent 18a20aa1ca
commit c513d1a0c2
110 changed files with 17891 additions and 6944 deletions
+33 -4
View File
@@ -26,6 +26,7 @@ import {
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ProviderLogo } from "@/components/provider-logo"
import { useDictionary } from "@/hooks/use-dictionary"
import {
type FlattenedModel,
@@ -66,6 +67,26 @@ function groupModelsByProvider(
return groups
}
/**
* The model id without the provider or region prefix that the logo already
* shows: "nvidia/nemotron-3-ultra" and "global.anthropic.claude-opus-5-5"
* become "nemotron-3-ultra" and "claude-opus-5-5". The full id is in the
* tooltip and the list.
*/
export function shortModelName(id: string): string {
const name = id.slice(id.lastIndexOf("/") + 1)
const short = name.replace(
/^(?:(?:global|us|eu|apac|jp|au|ca|us-gov)\.)?[a-z][a-z0-9-]*\.(?=[a-z])/i,
"",
)
// Ids like "deepseek.r1-v1:0" name the vendor only in the prefix: drop
// just the region
if (!short || /^[a-z]\d/i.test(short)) {
return name.replace(/^(?:global|us|eu|apac|jp|au|ca|us-gov)\./i, "")
}
return short
}
export function ModelSelector({
models,
selectedModelId,
@@ -158,7 +179,7 @@ export function ModelSelector({
}, [])
return (
<div ref={wrapperRef} className="min-w-0 max-w-48">
<div ref={wrapperRef} className="min-w-0 max-w-44">
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
<ModelSelectorTrigger asChild>
<ButtonWithTooltip
@@ -167,18 +188,26 @@ export function ModelSelector({
size="sm"
disabled={disabled}
className={cn(
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 font-normal text-muted-foreground transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent hover:text-foreground",
!showLabel && "px-1.5 justify-center",
)}
// accessibility: expose label to screen readers
aria-label={tooltipContent}
data-testid="model-selector"
>
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
{selectedModel ? (
<ProviderLogo
provider={selectedModel.provider}
className="size-3.5 flex-shrink-0 opacity-80"
/>
) : (
<Bot className="h-4 w-4 flex-shrink-0" />
)}
{/* show/hide visible label based on measured width */}
{showLabel ? (
<span className="min-w-0 truncate text-xs">
{selectedModel
? selectedModel.modelId
? shortModelName(selectedModel.modelId)
: dict.modelConfig.default}
</span>
) : (