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
+176
View File
@@ -0,0 +1,176 @@
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 { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { DRAWIO_CSS, getDrawioSrc } 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"
import { cn } from "@/lib/utils"
import { useCanvasStore } from "@/stores/canvas-store"
import { useSettingsStore } from "@/stores/settings-store"
/**
* 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).
*/
export function CanvasStage({
className,
style,
hideOverlays = false,
}: {
className?: string
style?: React.CSSProperties
/** Hide the "ask AI" button (e.g. while the start screen covers the canvas) */
hideOverlays?: 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 {
drawioRef,
handleDiagramAutoSave,
handleDiagramExport,
onDrawioLoad,
resetDrawioReady,
isDrawioReady,
} = useDiagram()
const engine = useChatEngine()
const isDark = useSettingsStore((s) => s.isDark)
const hasEditor = useCanvasStore((s) => s.hasEditor)
// The model sees the first page only, so asking about shapes elsewhere
// would change the wrong page
const onFirstPage = useCanvasStore(
(s) => s.pages.length === 0 || s.pages[0]?.id === s.currentPageId,
)
// Canvas width when the chat panel starts sliding in or out
const slideStartWidthRef = useRef(0)
const [src, setSrc] = useState<string | null>(null)
// 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 })
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
}
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 }))
}, [lang])
// A new iframe starts over: draw.io reports load and access again
useEffect(() => {
resetDrawioReady()
setEditorAccess(null)
}, [src])
// Cross-origin draw.io only takes its dark mode when it starts: reload
// it when that is not the theme now (also one switched while it
// loaded). Same-origin switches it in place.
useEffect(() => {
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 }))
}, [isDark, editorAccess, lang, src])
useEffect(() => {
const stage = stageRef.current
if (!stage) return
// React passes no transitionstart
const onSlideStart = (event: TransitionEvent) => {
if (event.target !== stage || event.propertyName !== "right") return
slideStartWidthRef.current = stage.clientWidth
}
stage.addEventListener("transitionstart", onSlideStart)
const observer = new ResizeObserver(([entry]) => {
setSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
})
refitIfRecent()
})
observer.observe(stage)
return () => {
observer.disconnect()
stage.removeEventListener("transitionstart", onSlideStart)
}
}, [])
const showOverlays = hasEditor && isDrawioReady && !hideOverlays
return (
<div
ref={stageRef}
className={cn("absolute overflow-hidden bg-canvas", className)}
style={style}
data-testid="canvas-stage"
// The chat panel slides in or out (the canvas's right edge moves):
// once the canvas has its final size, fit a diagram just drawn,
// or keep the view's middle
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget) return
if (event.propertyName !== "right") return
const stage = event.currentTarget
// Let draw.io take the new size first
setTimeout(() => {
if (refitIfRecent(4000)) return
keepCenter(slideStartWidthRef.current, stage.clientWidth)
}, 60)
}}
>
{src && (
<DrawioFrame
key={src}
ref={drawioRef}
src={src}
configCss={DRAWIO_CSS}
editorCss={DRAWIO_CSS}
dark={isDark}
onLoad={onDrawioLoad}
onAutoSave={handleDiagramAutoSave}
onExport={handleDiagramExport}
onEditorAccess={setEditorAccess}
/>
)}
{/* Covers the iframe until draw.io has loaded and been styled */}
{(!isDrawioReady || editorAccess === null) && (
<div
className="absolute inset-0 flex items-center justify-center bg-canvas"
data-testid="canvas-loading"
>
<div className="flex items-center gap-2.5 text-[13px] text-muted-foreground">
<span className="size-3.5 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
{dict.workspace.canvasLoading}
</div>
</div>
)}
{showOverlays && (
<SelectionAsk
width={size.width}
height={size.height}
hidden={engine.isBusy || !onFirstPage}
/>
)}
</div>
)
}
+265
View File
@@ -0,0 +1,265 @@
"use client"
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"
import {
DRAWIO_CONFIG,
EMPTY_DRAWIO_DOCUMENT,
} from "@/lib/drawio/drawio-config"
import {
attachEditor,
detachEditor,
watchForEditorUi,
} from "@/lib/drawio/editor-bridge"
export interface DrawioExportEvent {
event: "export"
format: string
data: string
xml: string
message: { message?: string; [key: string]: unknown }
}
export interface DrawioFrameHandle {
load: (data: { xml: string }) => void
exportDiagram: (data: {
format: string
message?: string
[key: string]: unknown
}) => void
}
interface DrawioFrameProps {
src: string
/** CSS sent with the configure message (works cross-origin too) */
configCss: string
/** CSS injected once the editor is reachable directly (same origin) */
editorCss: string
/** draw.io's dark mode, switched in place when the editor is reachable */
dark: boolean
onLoad?: () => void
onAutoSave?: (data: { xml?: string }) => void
onExport?: (data: DrawioExportEvent) => void
/** Called once we know whether the editor can be driven directly */
onEditorAccess?: (hasEditor: boolean) => void
}
const STYLE_ID = "next-ai-drawio-theme"
/**
* The draw.io editor in an iframe, speaking draw.io's postMessage embed
* protocol (same messages react-drawio used). When the iframe is same-origin
* it also hands the editor object to editor-bridge for direct control.
*/
export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
function DrawioFrame(
{
src,
configCss,
editorCss,
dark,
onLoad,
onAutoSave,
onExport,
onEditorAccess,
},
ref,
) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const editorUiRef = useRef<any>(null)
const attachedRef = useRef(false)
const configCssRef = useRef(configCss)
configCssRef.current = configCss
const editorCssRef = useRef(editorCss)
editorCssRef.current = editorCss
const darkRef = useRef(dark)
darkRef.current = dark
// Handlers change on every render; the message listener reads refs
const handlersRef = useRef({
onLoad,
onAutoSave,
onExport,
onEditorAccess,
})
handlersRef.current = { onLoad, onAutoSave, onExport, onEditorAccess }
// draw.io's origin: messages go only there, and only its messages
// count (the frame could be navigated to another site)
// (read on use: there is no window while the page renders on the server)
const drawioOrigin = () => new URL(src, window.location.href).origin
const send = (action: string, data: Record<string, unknown> = {}) => {
iframeRef.current?.contentWindow?.postMessage(
JSON.stringify({ action, ...data }),
drawioOrigin(),
)
}
useImperativeHandle(ref, () => ({
load: ({ xml }) => send("load", { xml, autosave: 1 }),
exportDiagram: (data) => send("export", data),
}))
// Same-origin only: keep the theme CSS current without a reload
const applyThemeCss = () => {
if (!attachedRef.current) return
try {
const doc = iframeRef.current?.contentDocument
if (!doc?.head) return
let style = doc.getElementById(STYLE_ID)
if (!style) {
style = doc.createElement("style")
style.id = STYLE_ID
doc.head.appendChild(style)
}
style.textContent = editorCssRef.current
} catch {
// Cross-origin: the configure message already applied it
}
}
useEffect(() => {
applyThemeCss()
}, [editorCss])
// Same-origin only: draw.io's own dark mode, without a reload
const applyDarkMode = () => {
if (!attachedRef.current) return
try {
// Runs on every load: switch only when it differs
const frameWindow = iframeRef.current?.contentWindow as any
if (frameWindow?.Editor?.isDarkMode?.() === darkRef.current) {
return
}
editorUiRef.current?.setDarkMode?.(darkRef.current)
} catch (error) {
console.warn("Failed to set draw.io's dark mode:", error)
}
}
useEffect(() => {
applyDarkMode()
}, [dark])
useEffect(() => {
const iframe = iframeRef.current
if (!iframe) return
editorUiRef.current = null
attachedRef.current = false
let reported: boolean | null = null
const report = (value: boolean) => {
if (reported === value) return
reported = value
handlersRef.current.onEditorAccess?.(value)
}
// Watch for draw.io's scripts so the editor object can be caught
// as it starts. Stops once hooked, or when the frame turns out to
// be cross-origin.
const startedAt = Date.now()
const poll = setInterval(() => {
const frameWindow = iframe.contentWindow as any
if (!frameWindow) return
let ready = false
try {
ready =
frameWindow.location.href !== "about:blank" &&
!!frameWindow.EditorUi
} catch {
clearInterval(poll)
report(false)
return
}
if (ready) {
clearInterval(poll)
// Called on every action state update: once per editor
const ok = watchForEditorUi(frameWindow, (ui) => {
if (editorUiRef.current === ui) return
editorUiRef.current = ui
// draw.io follows the system's theme on its own:
// keep the app's
ui.addListener?.("darkModeChanged", () =>
applyDarkMode(),
)
})
if (!ok) report(false)
} else if (Date.now() - startedAt > 60_000) {
clearInterval(poll)
}
}, 5)
const handleMessage = (event: MessageEvent) => {
if (event.source !== iframe.contentWindow) return
if (event.origin !== drawioOrigin()) return
if (typeof event.data !== "string") return
let data: any
try {
data = JSON.parse(event.data)
} catch {
return
}
const handlers = handlersRef.current
switch (data.event) {
case "init":
send("load", {
xml: EMPTY_DRAWIO_DOCUMENT,
autosave: 1,
})
break
case "configure":
send("configure", {
config: {
...DRAWIO_CONFIG,
css: configCssRef.current,
},
})
break
case "load": {
const tryAttach = () => {
const ui = editorUiRef.current
if (!ui || !iframe.contentWindow) return false
attachEditor(ui, iframe.contentWindow)
attachedRef.current = true
applyThemeCss()
applyDarkMode()
report(true)
return true
}
// The editor object is usually caught before the
// first load; give it a moment, then fall back to
// draw.io's own toolbar
if (!tryAttach()) {
setTimeout(() => {
if (!tryAttach()) report(false)
}, 800)
}
handlers.onLoad?.()
break
}
case "autosave":
handlers.onAutoSave?.(data)
break
case "export":
handlers.onExport?.(data)
break
}
}
window.addEventListener("message", handleMessage)
return () => {
clearInterval(poll)
window.removeEventListener("message", handleMessage)
detachEditor()
}
}, [src])
return (
<iframe
ref={iframeRef}
src={src}
title="draw.io"
allow="clipboard-read; clipboard-write"
className="block h-full w-full border-0"
/>
)
},
)
+71
View File
@@ -0,0 +1,71 @@
import { Sparkles } from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary"
import { modKey } from "@/lib/platform"
import { useCanvasStore } from "@/stores/canvas-store"
import { useUiStore } from "@/stores/ui-store"
const BUTTON_HEIGHT = 32
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.
*/
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 focusComposer = useUiStore((s) => s.focusComposer)
if (hidden || popupOpen || !rect || selection.length === 0 || isFreehand) {
return null
}
// Selection scrolled out of view
if (
rect.x + rect.width < 0 ||
rect.y + rect.height < 0 ||
rect.x > width ||
rect.y > height
) {
return null
}
// Above the selection's top-right corner, kept inside the canvas
const approxWidth = 168
const left = Math.min(
Math.max(8, rect.x + rect.width - approxWidth / 2),
width - approxWidth - 8,
)
const above = rect.y - BUTTON_HEIGHT - GAP
const top =
above > 64
? above
: Math.min(rect.y + rect.height + GAP, height - BUTTON_HEIGHT - 72)
return (
<button
type="button"
onClick={focusComposer}
className="pointer-events-auto absolute z-10 flex h-8 items-center gap-1.5 rounded-xl bg-card pr-2.5 pl-2 text-[13px] font-medium text-foreground shadow-pop transition-transform animate-fade-in hover:-translate-y-px"
style={{ left, top }}
data-testid="selection-ask"
>
<Sparkles className="size-4 text-marker-ink" />
{dict.workspace.askAi}
<kbd className="ml-1 font-sans text-[11px] text-muted-foreground">
{modKey}/
</kbd>
</button>
)
}
-227
View File
@@ -1,227 +0,0 @@
"use client"
import {
Cloud,
FileText,
GitBranch,
Palette,
Terminal,
Zap,
} from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
interface ExampleCardProps {
icon: React.ReactNode
title: string
description: string
onClick: () => void
isNew?: boolean
}
function ExampleCard({
icon,
title,
description,
onClick,
isNew,
}: ExampleCardProps) {
const dict = useDictionary()
return (
<button
onClick={onClick}
className={`group w-full text-left p-4 rounded-xl border bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 hover:shadow-sm ${
isNew
? "border-primary/40 ring-1 ring-primary/20"
: "border-border/60"
}`}
>
<div className="flex items-start gap-3">
<div
className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors ${
isNew
? "bg-primary/20 group-hover:bg-primary/25"
: "bg-primary/10 group-hover:bg-primary/15"
}`}
>
{icon}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
{title}
</h3>
{isNew && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-primary text-primary-foreground rounded">
{dict.common.new}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{description}
</p>
</div>
</div>
</button>
)
}
export default function ExamplePanel({
setInput,
setFiles,
minimal = false,
}: {
setInput: (input: string) => void
setFiles: (files: File[]) => void
minimal?: boolean
}) {
const dict = useDictionary()
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart.")
try {
const response = await fetch(getAssetUrl("/example.png"))
const blob = await response.blob()
const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
const handleReplicateArchitecture = async () => {
setInput("Replicate this in aws style")
try {
const response = await fetch(getAssetUrl("/architecture.png"))
const blob = await response.blob()
const file = new File([blob], "architecture.png", {
type: "image/png",
})
setFiles([file])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
const handlePdfExample = async () => {
setInput("Summarize this paper as a diagram")
try {
const response = await fetch(getAssetUrl("/chain-of-thought.txt"))
const blob = await response.blob()
const file = new File([blob], "chain-of-thought.txt", {
type: "text/plain",
})
setFiles([file])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
return (
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
{!minimal && (
<>
{/* MCP Server Notice, hidden on self-hosted deployments */}
{!isSelfHosted && (
<a
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
target="_blank"
rel="noopener noreferrer"
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Terminal className="w-4 h-4 text-purple-500" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
{dict.examples.mcpServer}
</span>
</div>
<p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
</p>
</div>
</div>
</a>
)}
{/* Welcome section */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.examples.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.examples.subtitle}
</p>
</div>
</>
)}
{/* Examples grid */}
<div className="space-y-3">
{!minimal && (
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
{dict.examples.quickExamples}
</p>
)}
<div className="grid gap-2">
<ExampleCard
icon={<FileText className="w-4 h-4 text-primary" />}
title={dict.examples.paperToDiagram}
description={dict.examples.paperDescription}
onClick={handlePdfExample}
isNew
/>
<ExampleCard
icon={<Zap className="w-4 h-4 text-primary" />}
title={dict.examples.animatedDiagram}
description={dict.examples.animatedDescription}
onClick={() => {
setInput(
"Give me a **animated connector** diagram of transformer's architecture",
)
setFiles([])
}}
/>
<ExampleCard
icon={<Cloud className="w-4 h-4 text-primary" />}
title={dict.examples.awsArchitecture}
description={dict.examples.awsDescription}
onClick={handleReplicateArchitecture}
/>
<ExampleCard
icon={<GitBranch className="w-4 h-4 text-primary" />}
title={dict.examples.replicateFlowchart}
description={dict.examples.replicateDescription}
onClick={handleReplicateFlowchart}
/>
<ExampleCard
icon={<Palette className="w-4 h-4 text-primary" />}
title={dict.examples.creativeDrawing}
description={dict.examples.creativeDescription}
onClick={() => {
setInput("Draw a cat for me")
setFiles([])
}}
/>
</div>
<p className="text-[11px] text-muted-foreground/60 text-center mt-4">
{dict.examples.cachedNote}
</p>
</div>
</div>
)
}
-669
View File
@@ -1,669 +0,0 @@
"use client"
import {
BookmarkPlus,
Download,
History,
Image as ImageIcon,
Link,
Send,
Square,
} from "lucide-react"
import type React from "react"
import {
type Dispatch,
forwardRef,
type SetStateAction,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector"
import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { UrlInputDialog } from "@/components/url-input-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { STORAGE_KEYS } from "@/lib/storage"
import type { FlattenedModel } from "@/lib/types/model-config"
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
import { isRealDiagram } from "@/lib/utils"
import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5
// Image formats every supported model provider accepts (SVG is read as text)
const SUPPORTED_IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]
function isValidFileType(file: File): boolean {
return (
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
isPdfFile(file) ||
isTextFile(file)
)
}
function formatFileSize(bytes: number): string {
const mb = bytes / 1024 / 1024
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
return `${mb.toFixed(2)}MB`
}
function showErrorToast(message: React.ReactNode) {
toast.custom(
(t) => (
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
),
{ duration: 5000 },
)
}
interface ValidationResult {
validFiles: File[]
errors: string[]
}
function validateFiles(
newFiles: File[],
existingCount: number,
dict: any,
): ValidationResult {
const errors: string[] = []
const validFiles: File[] = []
const availableSlots = MAX_FILES - existingCount
if (availableSlots <= 0) {
errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))
return { validFiles, errors }
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(
formatMessage(dict.errors.onlyMoreAllowed, {
slots: availableSlots,
}),
)
break
}
if (!isValidFileType(file)) {
errors.push(
formatMessage(dict.errors.unsupportedType, { name: file.name }),
)
continue
}
// Only check size for images (PDFs/text files are extracted client-side, so file size doesn't matter)
const isExtractedFile = isPdfFile(file) || isTextFile(file)
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
const maxSizeMB = MAX_IMAGE_SIZE / 1024 / 1024
errors.push(
formatMessage(dict.errors.fileExceeds, {
name: file.name,
size: formatFileSize(file.size),
max: maxSizeMB,
}),
)
} else {
validFiles.push(file)
}
}
return { validFiles, errors }
}
function showValidationErrors(errors: string[], dict: any) {
if (errors.length === 0) return
if (errors.length === 1) {
showErrorToast(
<span className="text-muted-foreground">{errors[0]}</span>,
)
} else {
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">
{formatMessage(dict.errors.filesRejected, {
count: errors.length,
})}
</span>
<ul className="text-muted-foreground text-xs list-disc list-inside">
{errors.slice(0, 3).map((err) => (
<li key={err}>{err}</li>
))}
{errors.length > 3 && (
<li>
{formatMessage(dict.errors.andMore, {
count: errors.length - 3,
})}
</li>
)}
</ul>
</div>,
)
}
}
export interface ChatInputRef {
focus: () => void
}
interface ChatInputProps {
input: string
status: "submitted" | "streaming" | "ready" | "error"
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onStop?: () => void
files?: File[]
onFileChange?: (files: File[]) => void
pdfData?: Map<
File,
{ text: string; charCount: number; isExtracting: boolean }
>
urlData?: Map<string, UrlData>
onUrlChange?: Dispatch<SetStateAction<Map<string, UrlData>>>
sessionId?: string
error?: Error | null
// Model selector props
models?: FlattenedModel[]
selectedModelId?: string
onModelSelect?: (modelId: string | undefined) => void
onConfigureModels?: () => void
showUnvalidatedModels?: boolean
// Focus control props
shouldFocus?: boolean
onFocused?: () => void
}
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
function ChatInput(
{
input,
status,
onSubmit,
onChange,
onStop,
files = [],
onFileChange = () => {},
pdfData = new Map(),
urlData,
onUrlChange,
sessionId,
error = null,
models = [],
selectedModelId,
onModelSelect = () => {},
onConfigureModels,
showUnvalidatedModels = false,
shouldFocus = false,
onFocused,
},
ref,
) {
const dict = useDictionary()
const {
chartXML,
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
// Expose focus method via ref
useImperativeHandle(ref, () => ({
focus: () => {
textareaRef.current?.focus()
},
}))
// Focus the textarea when shouldFocus becomes true
// Use setTimeout to ensure focus happens after drawio iframe settles
useEffect(() => {
if (shouldFocus) {
const timer = setTimeout(() => {
textareaRef.current?.focus()
onFocused?.()
}, 150)
return () => clearTimeout(timer)
}
}, [shouldFocus, onFocused])
const [showHistory, setShowHistory] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false)
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
// Block sending until attached files and URLs have their text, otherwise
// their content would be silently dropped
const isExtractingAttachments =
files.some((file) => pdfData.get(file)?.isExtracting) ||
Array.from(urlData?.values() ?? []).some((d) => d.isExtracting)
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
// Load send shortcut preference from localStorage and listen for changes
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
if (stored) setSendShortcut(stored)
const handleChange = (e: CustomEvent<string>) =>
setSendShortcut(e.detail)
window.addEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
return () =>
window.removeEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
}, [])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
adjustTextareaHeight()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
// Enter that confirms an IME candidate must not send the message
if (e.nativeEvent.isComposing || e.keyCode === 229) return
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" &&
!e.shiftKey &&
!e.ctrlKey &&
!e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) {
e.preventDefault()
const form = e.currentTarget.closest("form")
if (
form &&
input.trim() &&
!isDisabled &&
!isExtractingAttachments
) {
form.requestSubmit()
}
}
}
const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return
const items = e.clipboardData.items
const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/"),
)
if (imageItems.length > 0) {
const imageFiles = (
await Promise.all(
imageItems.map(async (item, index) => {
const file = item.getAsFile()
if (!file) return null
return new File(
[file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type },
)
}),
)
).filter((f): f is File => f !== null)
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove))
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const triggerFileInput = () => {
fileInputRef.current?.click()
}
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (isDisabled) return
// Let validateFiles show a toast for unsupported types
const { validFiles, errors } = validateFiles(
Array.from(e.dataTransfer.files),
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
const handleUrlExtract = async (url: string) => {
if (!onUrlChange) return
setIsExtractingUrl(true)
// Use functional updates so a removal or send made while extracting
// is not overwritten when the request finishes
try {
onUrlChange((prev) =>
new Map(prev).set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
}),
)
const data = await extractUrlContent(url)
// Skip if the URL was removed while extracting
onUrlChange((prev) =>
prev.has(url) ? new Map(prev).set(url, data) : prev,
)
setShowUrlDialog(false)
} catch (error) {
// Remove the URL from the data map on error
onUrlChange((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
return (
<form
id="chat-form"
onSubmit={onSubmit}
className={`w-full transition-all duration-200 ${
isDragging
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
: ""
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* File & URL previews */}
{(files.length > 0 || (urlData && urlData.size > 0)) && (
<div className="mb-3">
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) =>
onUrlChange((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
: undefined
}
/>
</div>
)}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={dict.chat.placeholder}
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
/>
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
disabled={
isDisabled || diagramHistory.length === 0
}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={
isDisabled || !isRealDiagram(chartXML)
}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
{onUrlChange && (
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUrlDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.ExtractURL}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Link className="h-4 w-4" />
</ButtonWithTooltip>
)}
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveAsTemplate(true)}
disabled={isDisabled || !input.trim()}
tooltipContent={dict.templates.saveAsTemplate}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<BookmarkPlus className="h-4 w-4" />
</ButtonWithTooltip>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
</div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>
<div className="w-px h-5 bg-border mx-1" />
{(status === "streaming" || status === "submitted") &&
onStop ? (
<Button
type="button"
onClick={onStop}
size="sm"
variant="destructive"
className="h-8 w-8 p-0 rounded-xl shadow-sm"
aria-label={dict.chat.stopGeneration}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="submit"
disabled={
isDisabled ||
isExtractingAttachments ||
!input.trim()
}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={dict.chat.send}
>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</Button>
)}
</div>
</div>
<HistoryDialog
showHistory={showHistory}
onToggleHistory={setShowHistory}
/>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
sessionId,
dict.save.savedSuccessfully,
)
}
// Local date as YYYY-MM-DD (toISOString would give UTC)
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
)}
<TemplateCreateDialog
open={showSaveAsTemplate}
onOpenChange={setShowSaveAsTemplate}
onSuccess={() => {
setShowSaveAsTemplate(false)
// Let the template list in the lobby reload
window.dispatchEvent(new Event("templatesChanged"))
}}
initialPrompt={input.trim()}
/>
</form>
)
},
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-360
View File
@@ -1,360 +0,0 @@
"use client"
import {
ChevronDown,
ChevronUp,
MessageSquare,
Search,
Trash2,
X,
} from "lucide-react"
import { useEffect, useState } from "react"
import { TemplatePanel } from "@/components/chat/TemplatePanel"
import ExamplePanel from "@/components/chat-example-panel"
import Image from "@/components/image-with-basepath"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { STORAGE_KEYS } from "@/lib/storage"
import type { Template } from "@/lib/template-storage"
interface SessionMetadata {
id: string
title: string
updatedAt: number
thumbnailDataUrl?: string
}
interface ChatLobbyProps {
sessions: SessionMetadata[]
onSelectSession: (id: string) => void
onDeleteSession?: (id: string) => void
setInput: (input: string) => void
setFiles: (files: File[]) => void
onSendTemplate?: (template: Template) => void
currentInput?: string
dict: {
sessionHistory?: {
recentChats?: string
searchPlaceholder?: string
noResults?: string
justNow?: string
deleteTitle?: string
deleteDescription?: string
}
templates?: {
title?: string
myTemplates?: string
}
examples?: {
quickExamples?: string
}
common: {
delete: string
cancel: string
}
}
}
// Helper to format session date
function formatSessionDate(
timestamp: number,
dict?: { justNow?: string },
): string {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
if (diffMins < 1) return dict?.justNow || "Just now"
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
}
function getPanelVisibility() {
if (typeof window === "undefined")
return { recentChats: true, myTemplates: true, quickExamples: true }
return {
recentChats:
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
myTemplates:
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
quickExamples:
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !== "false",
}
}
export function ChatLobby({
sessions,
onSelectSession,
onDeleteSession,
setInput,
setFiles,
onSendTemplate,
currentInput = "",
dict,
}: ChatLobbyProps) {
const [templatesExpanded, setTemplatesExpanded] = useState(true)
const [examplesExpanded, setExamplesExpanded] = useState(true)
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState("")
// Listen for panel visibility changes from settings
useEffect(() => {
const handler = () => setPanelVisibility(getPanelVisibility())
window.addEventListener("panelVisibilityChange", handler)
return () =>
window.removeEventListener("panelVisibilityChange", handler)
}, [])
const hasHistory = sessions.length > 0
if (!hasHistory) {
if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
return null
}
return (
<div className="animate-fade-in">
{panelVisibility.myTemplates && (
<TemplatePanel
setInput={setInput}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
/>
)}
{panelVisibility.quickExamples && (
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
<ExamplePanel setInput={setInput} setFiles={setFiles} />
</div>
)}
</div>
)
}
// Show history + collapsible examples when there are sessions
return (
<div className="py-6 px-2 animate-fade-in">
{/* Recent Chats Section */}
{panelVisibility.recentChats && (
<div className="mb-6">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
{dict.sessionHistory?.recentChats || "Recent Chats"}
</p>
{/* Search Bar */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder={
dict.sessionHistory?.searchPlaceholder ||
"Search chats..."
}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
<div className="space-y-2">
{sessions
.filter((session) =>
session.title
.toLowerCase()
.includes(searchQuery.toLowerCase()),
)
.map((session) => (
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
<div
key={session.id}
role="button"
tabIndex={0}
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
onClick={() => onSelectSession(session.id)}
onKeyDown={(e) => {
// Ignore keys bubbling up from the delete button
if (e.target !== e.currentTarget) return
if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault()
onSelectSession(session.id)
}
}}
>
{session.thumbnailDataUrl ? (
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
<Image
src={session.thumbnailDataUrl}
alt=""
width={48}
height={48}
className="object-contain w-full h-full"
/>
</div>
) : (
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-primary" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{session.title}
</div>
<div className="text-xs text-muted-foreground">
{formatSessionDate(
session.updatedAt,
dict.sessionHistory,
)}
</div>
</div>
{onDeleteSession && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setSessionToDelete(session.id)
setDeleteDialogOpen(true)
}}
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
title={dict.common.delete}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
{sessions.filter((s) =>
s.title
.toLowerCase()
.includes(searchQuery.toLowerCase()),
).length === 0 &&
searchQuery && (
<p className="text-sm text-muted-foreground text-center py-4">
{dict.sessionHistory?.noResults ||
"No chats found"}
</p>
)}
</div>
</div>
)}
{/* Collapsible My Templates Section */}
{panelVisibility.myTemplates && (
<div className="border-t border-border/50 pt-4">
<button
type="button"
onClick={() => setTemplatesExpanded(!templatesExpanded)}
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
>
<span>
{dict.templates?.myTemplates || "My Templates"}
</span>
{templatesExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{templatesExpanded && (
<div className="mt-2">
<TemplatePanel
setInput={setInput}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
/>
</div>
)}
</div>
)}
{/* Collapsible Quick Examples Section */}
{panelVisibility.quickExamples && (
<div className="border-t border-border/50 pt-4">
<button
type="button"
onClick={() => setExamplesExpanded(!examplesExpanded)}
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
>
<span>
{dict.examples?.quickExamples || "Quick Examples"}
</span>
{examplesExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{examplesExpanded && (
<div className="mt-2">
<ExamplePanel
setInput={setInput}
setFiles={setFiles}
minimal
/>
</div>
)}
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.sessionHistory?.deleteTitle ||
"Delete this chat?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.sessionHistory?.deleteDescription ||
"This will permanently delete this chat session and its diagram. This action cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (sessionToDelete && onDeleteSession) {
onDeleteSession(sessionToDelete)
}
setDeleteDialogOpen(false)
setSessionToDelete(null)
}}
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
>
{dict.common.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
+15 -17
View File
@@ -41,7 +41,8 @@ import { TemplateEditDialog } from "./TemplateEditDialog"
interface TemplatePanelProps {
setInput: (input: string) => void
onSendTemplate?: (template: Template) => void
/** Whether it was sent */
onSendTemplate?: (template: Template) => Promise<boolean>
currentInput?: string
}
@@ -178,16 +179,21 @@ export function TemplatePanel({
// Actually send the template
const sendTemplate = async (template: Template) => {
// Increment click count only when actually sending
await incrementClickCount(template.id)
if (onSendTemplate) {
// Increment run count and update lastUsedAt
await incrementRunCount(template.id)
// Reload to show updated stats
loadTemplates()
// Call the send callback
onSendTemplate(template)
// Sent first, while the chat is the one it was clicked in (the
// counts wait for storage). A run only when it was sent (not
// while a message is still being sent, or attachments read).
const sending = onSendTemplate(template)
// Increment click count only when actually sending
await incrementClickCount(template.id)
if (await sending) {
// Increment run count and update lastUsedAt
await incrementRunCount(template.id)
// Reload to show updated stats
loadTemplates()
}
} else {
await incrementClickCount(template.id)
// Fallback: just fill the input if no send callback provided
setInput(template.prompt)
}
@@ -333,14 +339,6 @@ export function TemplatePanel({
if (!loading && templates.length === 0) {
return (
<div className="py-6 px-2 animate-fade-in">
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.templates.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.templates.subtitle}
</p>
</div>
<div className="flex flex-col items-center justify-center py-8 px-4">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<FileText className="w-8 h-8 text-primary/60" />
-267
View File
@@ -1,267 +0,0 @@
"use client"
import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
import type { Dispatch, SetStateAction } from "react"
import { CodeBlock } from "@/components/code-block"
import { isMxCellXmlComplete } from "@/lib/utils"
import type { DiagramOperation, ToolPartLike } from "./types"
interface ToolCallCardProps {
part: ToolPartLike
expandedTools: Record<string, boolean>
setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>
onCopy: (callId: string, text: string, isToolCall: boolean) => void
copiedToolCallId: string | null
copyFailedToolCallId: string | null
dict: {
tools: { complete: string }
chat: { copied: string; failedToCopy: string; copyResponse: string }
}
}
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
// Streamed or invalid input can hold anything: show only what React can
// render (an object in place of a string would crash the whole chat)
const shown = operations.filter(
(op) => typeof (op as { operation?: unknown })?.operation === "string",
)
const text = (value: unknown) => (typeof value === "string" ? value : "")
return (
<div className="space-y-3">
{shown.map((op, index) => (
<div
key={`${op.operation}-${text(op.cell_id)}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span
className={`text-[10px] font-medium uppercase tracking-wide ${
op.operation === "delete"
? "text-red-600"
: op.operation === "add"
? "text-green-600"
: "text-blue-600"
}`}
>
{op.operation}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {text(op.cell_id)}
</span>
</div>
{text(op.new_xml) && (
<div className="px-3 py-2">
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{text(op.new_xml)}
</pre>
</div>
)}
</div>
))}
</div>
)
}
export function ToolCallCard({
part,
expandedTools,
setExpandedTools,
onCopy,
copiedToolCallId,
copyFailedToolCallId,
dict,
}: ToolCallCardProps) {
const callId = part.toolCallId
const { state, input, output, errorText } = part
// Default to expanded for all states (user can manually collapse if needed)
const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "")
const isCopied = copiedToolCallId === callId
const toggleExpanded = () => {
setExpandedTools((prev) => ({
...prev,
[callId]: !isExpanded,
}))
}
const getToolDisplayName = (name: string) => {
switch (name) {
case "display_diagram":
return "Generate Diagram"
case "edit_diagram":
return "Edit Diagram"
case "get_shape_library":
return "Get Shape Library"
default:
return name
}
}
// Incomplete XML means the output hit the length limit, unless the user
// stopped the generation themselves. Without an input the JSON was
// broken (the server repairs JSON cut short by the limit).
const isTruncated =
state === "output-error" &&
errorText !== "Stopped by user" &&
(toolName === "display_diagram" || toolName === "append_diagram") &&
typeof input?.xml === "string" &&
!isMxCellXmlComplete(input.xml)
const handleCopy = () => {
let textToCopy = ""
if (input && typeof input === "object") {
if (input.xml) {
textToCopy = input.xml
} else if (input.operations && Array.isArray(input.operations)) {
textToCopy = JSON.stringify(input.operations, null, 2)
} else if (Object.keys(input).length > 0) {
textToCopy = JSON.stringify(input, null, 2)
}
}
if (
output &&
toolName === "get_shape_library" &&
typeof output === "string"
) {
textToCopy = output
}
if (textToCopy) {
onCopy(callId, textToCopy, true)
}
}
return (
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
<Cpu className="w-3.5 h-3.5 text-primary" />
</div>
<span className="text-sm font-medium text-foreground/80">
{getToolDisplayName(toolName)}
</span>
</div>
<div className="flex items-center gap-2">
{state === "input-streaming" && (
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
)}
{state === "output-available" && (
<>
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{dict.tools.complete}
</span>
{isExpanded && (
<button
type="button"
onClick={handleCopy}
className="p-1 rounded hover:bg-muted transition-colors"
title={
copiedToolCallId === callId
? dict.chat.copied
: copyFailedToolCallId === callId
? dict.chat.failedToCopy
: dict.chat.copyResponse
}
>
{isCopied ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-muted-foreground" />
)}
</button>
)}
</>
)}
{state === "output-error" &&
(isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated
</span>
) : (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
))}
{input && Object.keys(input).length > 0 && (
<button
type="button"
onClick={toggleExpanded}
className="p-1 rounded hover:bg-muted transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button>
)}
</div>
</div>
{input && isExpanded && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? (
state === "input-streaming" ||
state === "input-available" ? (
<pre
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
style={{
fontFamily:
"var(--font-mono), ui-monospace, monospace",
margin: 0,
padding: 0,
}}
>
{input.xml}
</pre>
) : (
<CodeBlock code={input.xml} language="xml" />
)
) : typeof input === "object" &&
input.operations &&
Array.isArray(input.operations) ? (
<OperationsDisplay operations={input.operations} />
) : typeof input === "object" &&
Object.keys(input).length > 0 ? (
<CodeBlock
code={JSON.stringify(input, null, 2)}
language="json"
/>
) : null}
</div>
)}
{/* AI SDK stores tool errors in errorText */}
{state === "output-error" && (errorText || output) && (
<div
className={`px-4 py-3 border-t border-border/40 text-sm whitespace-pre-wrap break-words ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase Max Output Tokens in Settings."
: (errorText ?? output)}
</div>
)}
{/* Show get_shape_library output on success */}
{output &&
toolName === "get_shape_library" &&
state === "output-available" &&
isExpanded && (
<div className="px-4 py-3 border-t border-border/40">
<div className="text-xs text-muted-foreground mb-2">
Library loaded (
{typeof output === "string" ? output.length : 0}{" "}
chars)
</div>
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
{typeof output === "string"
? output.substring(0, 800) +
(output.length > 800 ? "\n..." : "")
: String(output)}
</pre>
</div>
)}
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { MAX_FILES, MAX_FILE_SIZE as MAX_IMAGE_SIZE } from "@/lib/chat-helpers"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
// Image formats every supported model provider accepts (SVG is read as text)
const SUPPORTED_IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]
/** Accept list for the attachment picker */
export const ATTACHMENT_ACCEPT =
"image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
function isValidFileType(file: File): boolean {
return (
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
isPdfFile(file) ||
isTextFile(file)
)
}
function formatFileSize(bytes: number): string {
const mb = bytes / 1024 / 1024
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
return `${mb.toFixed(2)}MB`
}
/** Keep the files that can be attached; describe the rest as errors */
export function validateFiles(
newFiles: File[],
existingCount: number,
dict: any,
): { validFiles: File[]; errors: string[] } {
const errors: string[] = []
const validFiles: File[] = []
const availableSlots = MAX_FILES - existingCount
if (availableSlots <= 0) {
errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))
return { validFiles, errors }
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(
formatMessage(dict.errors.onlyMoreAllowed, {
slots: availableSlots,
}),
)
break
}
if (!isValidFileType(file)) {
errors.push(
formatMessage(dict.errors.unsupportedType, { name: file.name }),
)
continue
}
// PDFs and text files are extracted in the browser, so only images
// have a size limit
const isExtractedFile = isPdfFile(file) || isTextFile(file)
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
errors.push(
formatMessage(dict.errors.fileExceeds, {
name: file.name,
size: formatFileSize(file.size),
max: MAX_IMAGE_SIZE / 1024 / 1024,
}),
)
} else {
validFiles.push(file)
}
}
return { validFiles, errors }
}
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
import {
ChevronDown,
Download,
MessageSquareDashed,
PanelRightClose,
Settings2,
SquarePen,
} from "lucide-react"
import { useRef } from "react"
import { DevXmlSimulator } from "@/components/dev-xml-simulator"
import { BrandMark } from "@/components/workspace/brand-mark"
import { IconButton } from "@/components/workspace/icon-button"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { usePanelWidth } from "@/hooks/use-panel-width"
import { modKey } from "@/lib/platform"
import { cn } from "@/lib/utils"
import {
PANEL_MAX_WIDTH,
PANEL_MIN_WIDTH,
useSettingsStore,
} from "@/stores/settings-store"
import { useUiStore } from "@/stores/ui-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"
/** Drag handle on the panel's left edge (the panel sits on the right) */
function ResizeHandle() {
const panelWidth = usePanelWidth()
const setPanelWidth = useSettingsStore((s) => s.setPanelWidth)
const startRef = useRef<{ x: number; width: number } | null>(null)
return (
// biome-ignore lint/a11y/useSemanticElements: a draggable handle, not a divider line
<div
role="separator"
aria-orientation="vertical"
aria-valuenow={panelWidth}
aria-valuemin={PANEL_MIN_WIDTH}
aria-valuemax={PANEL_MAX_WIDTH}
tabIndex={-1}
className="absolute top-4 -left-1.5 bottom-4 z-10 w-3 cursor-col-resize after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 after:-translate-x-1/2 after:rounded-full after:bg-foreground/0 after:transition-colors hover:after:bg-foreground/15"
onPointerDown={(e) => {
e.preventDefault()
const panel = e.currentTarget.parentElement
startRef.current = {
x: e.clientX,
width: panel?.getBoundingClientRect().width ?? 384,
}
e.currentTarget.setPointerCapture(e.pointerId)
}}
onPointerMove={(e) => {
const start = startRef.current
if (!start) return
// Dragging left widens the panel
setPanelWidth(start.width - (e.clientX - start.x))
}}
onPointerUp={(e) => {
startRef.current = null
e.currentTarget.releasePointerCapture(e.pointerId)
}}
/>
)
}
function EmptyThread() {
const dict = useDictionary()
return (
<div className="flex h-full flex-col items-center justify-center gap-2 px-8 text-center">
<MessageSquareDashed className="size-5 text-faint" />
<p className="max-w-60 text-[13px] text-muted-foreground">
{dict.chat.emptyThread}
</p>
</div>
)
}
/** The floating chat panel: header, versions, conversation, message box */
export function ChatPanel({
mobile = false,
onOpenFile,
}: {
mobile?: boolean
onOpenFile: () => void
}) {
const dict = useDictionary()
const engine = useChatEngine()
const { loadDiagram, isDrawioReady } = useDiagram()
const setSaveDialogOpen = useUiStore((s) => s.setSaveDialogOpen)
const title = useSessionTitle()
const panelWidth = usePanelWidth()
const openSettings = useUiStore((s) => s.openSettings)
const setPanelOpen = useUiStore((s) => s.setPanelOpen)
return (
<aside
className={cn(
"flex flex-col bg-card",
mobile
? "absolute inset-0"
: "absolute top-3 right-3 bottom-3 z-20 rounded-2xl shadow-float animate-panel-in",
)}
style={mobile ? undefined : { width: panelWidth }}
aria-label={dict.nav.aiChat}
data-testid="chat-panel"
>
<header className="flex h-[52px] shrink-0 items-center gap-1 pr-2 pl-3">
<BrandMark />
<SessionMenu onOpenFile={onOpenFile}>
<button
type="button"
className="ml-1 flex min-w-0 items-center gap-1 rounded-lg px-1.5 py-1 hover:bg-accent"
data-testid="session-title"
>
<span className="truncate text-[15px] font-semibold tracking-[-0.01em]">
{title}
</span>
<ChevronDown className="size-4 shrink-0 text-faint" />
</button>
</SessionMenu>
<div className="ml-auto flex items-center">
{isDrawioReady && (
<IconButton
label={dict.workspace.export}
onClick={() => setSaveDialogOpen(true)}
data-testid="export-button"
>
<Download />
</IconButton>
)}
<IconButton
label={dict.nav.newChat}
onClick={engine.newChat}
disabled={engine.isBusy}
data-testid="new-chat-button"
>
<SquarePen />
</IconButton>
<IconButton
label={dict.nav.settings}
onClick={() => openSettings()}
data-testid="settings-button"
>
<Settings2 />
</IconButton>
{!mobile && (
<IconButton
label={dict.nav.hidePanel}
shortcut={`${modKey}B`}
onClick={() => setPanelOpen(false)}
>
<PanelRightClose />
</IconButton>
)}
</div>
</header>
<VersionStrip />
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
{engine.messages.length > 0 ? <MessageList /> : <EmptyThread />}
</div>
{DEBUG && (
<DevXmlSimulator
setMessages={engine.setMessages as any}
onDisplayChart={(xml) => loadDiagram(xml, true)}
/>
)}
<div className="shrink-0 p-2.5 pt-1">
<Composer
placeholder={
engine.messages.length > 0 &&
engine.chatSelection.length === 0
? dict.chat.placeholderFollowUp
: undefined
}
/>
</div>
{!mobile && <ResizeHandle />}
</aside>
)
}
+160
View File
@@ -0,0 +1,160 @@
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
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 { useUiStore } from "@/stores/ui-store"
import { useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
import { VersionThumb } from "./tool-activity"
function Sheet({
label,
svg,
}: {
label: string
svg: string | null | undefined
}) {
return (
<figure className="flex min-w-0 flex-1 flex-col gap-2">
<figcaption className="text-xs font-medium text-muted-foreground">
{label}
</figcaption>
<div className="sheet-light flex aspect-[16/10] items-center justify-center overflow-hidden rounded-xl border border-border bg-white p-2">
<VersionThumb svg={svg} className="max-h-full max-w-full" />
</div>
</figure>
)
}
/** A saved version next to the current canvas, with restore */
export function CompareDialog() {
const dict = useDictionary()
const t = dict.versions
const engine = useChatEngine()
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)
const version = index >= 0 ? versions[index] : null
// Whether the canvas is this version, on every page
const same = useMemo(
() => !version || isSameDocument(version.xml, chartXML),
[version, chartXML],
)
// What the canvas has changed since this version on the first page
// (null: nothing there)
const changes = useMemo(() => {
if (!version || same) return null
const { summary } = diffDiagrams(version.xml, chartXML)
return Object.values(summary).some((n) => n > 0) ? summary : null
}, [version, chartXML, same])
// The version went away (dropped as the oldest, another chat): the
// dialog closes, and nothing waits for its Escape
useEffect(() => {
if (versionId && !version) closeCompare()
}, [versionId, version, closeCompare])
// Picture of the canvas as it is now, taken again once an answer that
// was running is done ("" when none could be made)
useEffect(() => {
if (!versionId) return
let cancelled = false
setCurrentSvg(null)
getVersionSvg().then((svg) => {
if (!cancelled) setCurrentSvg(svg ?? "")
})
return () => {
cancelled = true
}
}, [versionId, engine.isBusy])
return (
<Dialog
open={!!version}
onOpenChange={(open) => !open && closeCompare()}
>
<DialogContent className="gap-5 sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{t.compareTitle}</DialogTitle>
<DialogDescription>
{!version
? ""
: same
? t.isOnCanvas
: changes
? `${formatMessage(t.compareSince, { n: version.number })} ${describeChanges(changes, t)}`
: formatMessage(t.differsElsewhere, {
n: version.number,
})}
</DialogDescription>
</DialogHeader>
{version && (
<div className="flex flex-col gap-4 sm:flex-row">
<Sheet
label={formatMessage(t.versionLabel, {
n: version.number,
})}
svg={version.svg}
/>
<Sheet label={t.currentCanvas} svg={currentSvg} />
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
disabled={index <= 0}
onClick={() => openCompare(versions[index - 1].id)}
className="inline-flex size-8 items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-foreground disabled:opacity-30"
aria-label={t.previous}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
disabled={index < 0 || index >= versions.length - 1}
onClick={() => openCompare(versions[index + 1].id)}
className="inline-flex size-8 items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-foreground disabled:opacity-30"
aria-label={t.next}
>
<ChevronRight className="size-4" />
</button>
<span className="text-xs text-muted-foreground tabular-nums">
{index + 1} / {versions.length}
</span>
<button
type="button"
disabled={!version || same || engine.isBusy}
onClick={() => {
if (!version) return
engine.restoreVersion(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"
data-testid="compare-restore"
>
<RotateCcw className="size-4" />
{formatMessage(t.restoreVersion, {
n: version?.number ?? 0,
})}
</button>
</div>
</DialogContent>
</Dialog>
)
}
+552
View File
@@ -0,0 +1,552 @@
import {
ArrowUp,
BookMarked,
BookmarkPlus,
Globe,
Paperclip,
Plus,
Square,
SquareDashedMousePointer,
X,
} from "lucide-react"
import type React from "react"
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react"
import { toast } from "sonner"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { TemplatePanel } from "@/components/chat/TemplatePanel"
import { ErrorToast } from "@/components/error-toast"
import { FilePreviewList } from "@/components/file-preview-list"
import { ModelSelector } from "@/components/model-selector"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { UrlInputDialog } from "@/components/url-input-dialog"
import { IconButton } from "@/components/workspace/icon-button"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { modKey } from "@/lib/platform"
import { extractUrlContent } from "@/lib/url-utils"
import { cn } from "@/lib/utils"
import { useSettingsStore } from "@/stores/settings-store"
import { useUiStore } from "@/stores/ui-store"
import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments"
import { useChatEngine } from "./chat-engine"
function showErrorToast(message: React.ReactNode) {
toast.custom(
(t) => (
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
),
{ duration: 5000 },
)
}
function showValidationErrors(errors: string[], dict: any) {
if (errors.length === 0) return
if (errors.length === 1) {
showErrorToast(
<span className="text-muted-foreground">{errors[0]}</span>,
)
return
}
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">
{formatMessage(dict.errors.filesRejected, {
count: errors.length,
})}
</span>
<ul className="list-inside list-disc text-xs text-muted-foreground">
{errors.slice(0, 3).map((err) => (
<li key={err}>{err}</li>
))}
{errors.length > 3 && (
<li>
{formatMessage(dict.errors.andMore, {
count: errors.length - 3,
})}
</li>
)}
</ul>
</div>,
)
}
function MenuItem({
icon,
children,
onClick,
disabled,
disabledHint,
}: {
icon: React.ReactNode
children: React.ReactNode
onClick: () => void
disabled?: boolean
/** Why the item can't be used now, shown under it while disabled */
disabledHint?: string
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="group/item flex w-full items-start gap-2.5 rounded-md px-2.5 py-2 text-left text-[13px] hover:bg-accent disabled:pointer-events-none [&_svg]:mt-px [&_svg]:size-4 [&_svg]:text-muted-foreground"
>
<span className="contents group-disabled/item:[&>svg]:opacity-40">
{icon}
</span>
<span className="min-w-0">
<span className="block group-disabled/item:opacity-40">
{children}
</span>
{disabled && disabledHint && (
<span className="block text-xs text-muted-foreground">
{disabledHint}
</span>
)}
</span>
</button>
)
}
/**
* The message box: text, attachments, the selected shapes, the model picker
* and send/stop. `variant="hero"` is the large box on the start screen.
*/
export function Composer({
variant = "panel",
placeholder,
autoFocus = false,
}: {
variant?: "panel" | "hero"
placeholder?: string
autoFocus?: boolean
}) {
const dict = useDictionary()
const engine = useChatEngine()
const {
input,
setInput,
files,
setFiles,
pdfData,
urlData,
setUrlData,
isBusy,
error,
chatSelection,
dismissSelection,
modelConfig,
} = engine
const sendShortcut = useSettingsStore((s) => s.sendShortcut)
const openSettings = useUiStore((s) => s.openSettings)
const focusToken = useUiStore((s) => s.focusComposerToken)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [templatesOpen, setTemplatesOpen] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false)
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [saveTemplateOpen, setSaveTemplateOpen] = useState(false)
const isHero = variant === "hero"
// Allow a retry when the last request failed, even mid-stream. While
// the chat is being left, what is typed could not be sent.
const isDisabled = (isBusy && !error) || engine.isLeaving
const canSend =
!isDisabled && !engine.isExtractingAttachments && !!input.trim()
const adjustHeight = useCallback(() => {
const textarea = textareaRef.current
if (!textarea) return
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 220)}px`
}, [])
useEffect(() => {
adjustHeight()
}, [input, adjustHeight])
// Focus requests from elsewhere (new chat, "ask AI" button, ⌘/)
useEffect(() => {
if (focusToken === 0 && !autoFocus) return
const timer = setTimeout(() => textareaRef.current?.focus(), 60)
return () => clearTimeout(timer)
}, [focusToken, autoFocus])
// The chat is left while the user types (browser back): the browser
// takes the focus from the input it disables, after this runs. It gets
// the focus back once enabled, unless something else took it meanwhile.
const refocusRef = useRef(false)
const isLeaving = engine.isLeaving
useLayoutEffect(() => {
if (isDisabled) {
if (isLeaving && document.activeElement === textareaRef.current) {
refocusRef.current = true
}
return
}
if (!refocusRef.current) return
refocusRef.current = false
if (document.activeElement === document.body) {
textareaRef.current?.focus()
}
}, [isDisabled, isLeaving])
const addFiles = (newFiles: File[]) => {
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) setFiles([...files, ...validFiles])
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Enter that confirms an IME candidate must not send the message
if (e.nativeEvent.isComposing || e.keyCode === 229) return
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) {
e.preventDefault()
if (canSend) engine.submit()
}
}
const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return
const imageItems = Array.from(e.clipboardData.items).filter((item) =>
item.type.startsWith("image/"),
)
if (imageItems.length === 0) return
const imageFiles = imageItems
.map((item, index) => {
const file = item.getAsFile()
if (!file) return null
return new File(
[file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type },
)
})
.filter((f): f is File => f !== null)
addFiles(imageFiles)
}
const handleUrlExtract = async (url: string) => {
setIsExtractingUrl(true)
// Functional updates, so a removal or send made while extracting is
// not overwritten when the request finishes
try {
setUrlData((prev) =>
new Map(prev).set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
}),
)
const data = await extractUrlContent(url)
setUrlData((prev) =>
prev.has(url) ? new Map(prev).set(url, data) : prev,
)
setShowUrlDialog(false)
} catch (err) {
setUrlData((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
showErrorToast(
<span className="text-muted-foreground">
{err instanceof Error ? err.message : dict.url.failed}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
const selectionLabel =
chatSelection.length === 1
? formatMessage(dict.selection.one, {
label:
chatSelection[0].label ||
(chatSelection[0].isEdge
? dict.selection.connector
: dict.selection.shape),
})
: formatMessage(dict.selection.many, {
count: chatSelection.length,
})
const hasChips =
chatSelection.length > 0 || files.length > 0 || urlData.size > 0
return (
<form
id={isHero ? "hero-chat-form" : "chat-form"}
onSubmit={(e) => {
e.preventDefault()
if (canSend) engine.submit()
}}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={(e) => {
e.preventDefault()
setIsDragging(false)
}}
onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
if (!isDisabled) addFiles(Array.from(e.dataTransfer.files))
}}
className={cn(
"group/composer relative w-full rounded-[14px] border border-border bg-card transition-[border-color,box-shadow] duration-150",
"focus-within:border-foreground/20 focus-within:shadow-[0_0_0_4px_var(--accent)]",
isHero && "rounded-[18px] border-transparent shadow-pop",
isDragging &&
"border-foreground/30 shadow-[0_0_0_4px_var(--marker-soft)]",
)}
>
{hasChips && (
<div className="flex flex-wrap items-center gap-1.5 px-2.5 pt-2.5">
{chatSelection.length > 0 && (
<span
className="inline-flex max-w-full items-center gap-1.5 rounded-lg bg-marker-soft py-1 pr-1 pl-2 text-xs font-medium text-marker-ink"
data-testid="selection-chip"
>
<SquareDashedMousePointer className="size-3.5 shrink-0" />
<span className="truncate">{selectionLabel}</span>
<button
type="button"
onClick={dismissSelection}
aria-label={dict.selection.remove}
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<X className="size-3" />
</button>
</span>
)}
{(files.length > 0 || urlData.size > 0) && (
// A short window keeps room for the buttons below
<div className="max-h-[min(176px,22vh)] w-full overflow-y-auto scrollbar-thin">
<FilePreviewList
files={files}
onRemoveFile={(file) =>
setFiles(files.filter((f) => f !== file))
}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={(url) =>
setUrlData((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
}
/>
</div>
)}
</div>
)}
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={
placeholder ??
(chatSelection.length > 0
? dict.selection.placeholder
: dict.chat.placeholder)
}
disabled={isDisabled}
aria-label={dict.chat.inputLabel}
data-testid="chat-input"
rows={isHero ? 3 : 2}
className={cn(
// A short window keeps room for the buttons below
"block max-h-[min(220px,30vh)] w-full resize-none bg-transparent text-foreground outline-none placeholder:text-faint scrollbar-thin disabled:opacity-60",
isHero
? "min-h-[84px] px-[18px] pt-4 pb-1 text-[15px] leading-relaxed"
: "min-h-[52px] px-3.5 pt-3 pb-1 text-sm",
)}
/>
<div
className={cn(
"flex items-center gap-0.5",
isHero ? "px-2.5 pt-1 pb-2.5" : "px-1.5 pt-0.5 pb-1.5",
)}
>
<Popover open={addOpen} onOpenChange={setAddOpen}>
<PopoverTrigger asChild>
<IconButton
label={dict.chat.attach}
tooltipSide="top"
disabled={isDisabled}
data-testid="composer-add"
>
<Plus />
</IconButton>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-64 p-1"
>
<MenuItem
icon={<Paperclip />}
onClick={() => {
setAddOpen(false)
fileInputRef.current?.click()
}}
>
{dict.chat.uploadFile}
</MenuItem>
<MenuItem
icon={<Globe />}
onClick={() => {
setAddOpen(false)
setShowUrlDialog(true)
}}
>
{dict.chat.ExtractURL}
</MenuItem>
<MenuItem
icon={<BookmarkPlus />}
disabled={!input.trim()}
disabledHint={dict.templates.saveDisabledHint}
onClick={() => {
setAddOpen(false)
setSaveTemplateOpen(true)
}}
>
{dict.templates.saveAsTemplate}
</MenuItem>
</PopoverContent>
</Popover>
<IconButton
label={dict.templates.myTemplates}
tooltipSide="top"
disabled={isDisabled}
onClick={() => setTemplatesOpen(true)}
>
<BookMarked />
</IconButton>
<div className="ml-auto flex min-w-0 flex-1 items-center justify-end gap-1">
<ModelSelector
models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId}
onSelect={modelConfig.setSelectedModelId}
onConfigure={() => openSettings("models")}
disabled={isDisabled}
showUnvalidatedModels={
modelConfig.showUnvalidatedModels
}
/>
{!isBusy && input.trim() && (
<kbd
className="hidden shrink-0 font-sans text-[11px] text-faint sm:inline"
aria-hidden
>
{sendShortcut === "enter" ? "↵" : `${modKey}↵`}
</kbd>
)}
{isBusy ? (
<button
type="button"
onClick={engine.stop}
aria-label={dict.chat.stopGeneration}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-foreground text-background transition-opacity hover:opacity-85"
>
<Square className="size-3 fill-current" />
</button>
) : (
<button
type="submit"
disabled={!canSend}
aria-label={dict.chat.send}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-primary text-primary-foreground transition-[opacity,background-color] hover:opacity-90 disabled:bg-muted disabled:text-faint"
>
<ArrowUp className="size-4 stroke-[2.25]" />
</button>
)}
</div>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={ATTACHMENT_ACCEPT}
data-testid="attachment-input"
multiple
disabled={isDisabled}
onChange={(e) => {
addFiles(Array.from(e.target.files || []))
e.target.value = ""
}}
/>
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
<TemplateCreateDialog
open={saveTemplateOpen}
onOpenChange={setSaveTemplateOpen}
onSuccess={() => setSaveTemplateOpen(false)}
initialPrompt={input.trim()}
/>
<Dialog open={templatesOpen} onOpenChange={setTemplatesOpen}>
<DialogContent className="flex max-h-[80vh] flex-col gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-5 pt-5 pb-2">
<DialogTitle>{dict.templates.myTemplates}</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin">
<TemplatePanel
setInput={(value) => {
setInput(value)
setTemplatesOpen(false)
}}
currentInput={input}
onSendTemplate={(template) => {
setTemplatesOpen(false)
return engine.sendTemplate(template.prompt)
}}
/>
</div>
</DialogContent>
</Dialog>
</form>
)
}
+284
View File
@@ -0,0 +1,284 @@
import { FileText, FileUp, History, Pencil } from "lucide-react"
import type React from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
import { cn } from "@/lib/utils"
import { useChatEngine } from "./chat-engine"
import { Composer } from "./composer"
import { SessionMenu } from "./session-menu"
interface Example {
key: string
title: string
description: string
/** Picture of the result (or of the input, for "replicate" examples) */
image?: string
prompt: string
/** Bundled file sent with the prompt; matches the cached example */
file?: { path: string; name: string; type: string }
}
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({
onClick,
title,
description,
picture,
plain,
testId,
}: {
onClick: () => void
title: string
description: string
picture: React.ReactNode
/** An icon on a tinted tile instead of a diagram on paper */
plain?: boolean
testId?: string
}) {
return (
<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"
data-testid={testId}
>
<span
className={cn(
"flex h-12 w-16 shrink-0 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>
</button>
)
}
/**
* Start screen over an empty canvas: one large message box and the cached
* examples (they answer instantly without calling a model).
*/
export function LobbyHero({
onDrawYourself,
onOpenFile,
compact = false,
}: {
onDrawYourself: () => void
onOpenFile: () => void
/** Phone layout: no example pictures, tighter spacing */
compact?: boolean
}) {
const dict = useDictionary()
const t = dict.lobby
const engine = useChatEngine()
// Prompts and file names must stay as they are: cached responses match them
const examples: Example[] = [
{
key: "paper",
title: dict.examples.paperToDiagram,
description: dict.examples.paperDescription,
prompt: "Summarize this paper as a diagram",
file: {
path: "/chain-of-thought.txt",
name: "chain-of-thought.txt",
type: "text/plain",
},
},
{
key: "animated",
title: dict.examples.animatedDiagram,
description: dict.examples.animatedDescription,
image: "/animated_connectors.svg",
prompt: "Give me a **animated connector** diagram of transformer's architecture",
},
{
key: "aws",
title: dict.examples.awsArchitecture,
description: dict.examples.awsDescription,
image: "/aws_demo.svg",
prompt: "Replicate this in aws style",
file: {
path: "/architecture.png",
name: "architecture.png",
type: "image/png",
},
},
{
key: "flowchart",
title: dict.examples.replicateFlowchart,
description: dict.examples.replicateDescription,
image: "/example.png",
prompt: "Replicate this flowchart.",
file: {
path: "/example.png",
name: "example.png",
type: "image/png",
},
},
{
key: "cat",
title: dict.examples.creativeDrawing,
description: dict.examples.creativeDescription,
image: "/cat_demo.svg",
prompt: "Draw a cat for me",
},
]
const pickExample = async (example: Example) => {
engine.setInput(example.prompt)
if (!example.file) {
engine.setFiles([])
return
}
try {
engine.setFiles([await loadExampleFile(example.file)])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
return (
<div
className={cn(
"mx-auto w-full",
compact ? "max-w-xl px-4 py-8" : "max-w-[720px] px-6 py-10",
)}
data-testid="lobby-hero"
>
<h1
className={cn(
"text-center font-semibold tracking-[-0.025em] text-foreground",
compact
? "text-[26px] leading-tight"
: "text-[34px] leading-[1.15]",
)}
>
{t.title}
</h1>
<p
className={cn(
"mx-auto mt-2.5 max-w-[34em] text-center text-muted-foreground",
compact ? "mb-5 text-[13px]" : "mb-6 text-[14.5px]",
)}
>
{t.lede}
</p>
<Composer
variant="hero"
autoFocus={!compact}
placeholder={t.placeholder}
/>
<div
className={cn(
"mt-5 grid gap-2",
compact ? "grid-cols-1" : "grid-cols-3",
)}
>
{examples.map((example) =>
compact ? (
<button
key={example.key}
type="button"
onClick={() => pickExample(example)}
className="flex items-center gap-3 rounded-xl border border-border bg-card px-3 py-2.5 text-left hover:border-foreground/25"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">
{example.title}
</span>
<span className="block truncate text-xs text-muted-foreground">
{example.description}
</span>
</span>
</button>
) : (
<ExampleCard
key={example.key}
onClick={() => pickExample(example)}
title={example.title}
description={example.description}
testId={`example-${example.key}`}
picture={
example.image ? (
// biome-ignore lint/performance/noImgElement: static preview
<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]"
/>
) : (
<FileText className="size-5 text-muted-foreground" />
)
}
plain={!example.image}
/>
),
)}
{!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">
{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">
{engine.sessions.length > 0 && (
<SessionMenu align="center" onOpenFile={onOpenFile}>
<button
type="button"
className="inline-flex items-center gap-1.5 hover:text-foreground"
>
<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={onDrawYourself}
className="inline-flex items-center gap-1.5 hover:text-foreground"
data-testid="draw-yourself"
>
<Pencil className="size-4" />
{t.drawYourself}
</button>
</div>
</div>
)
}
+640
View File
@@ -0,0 +1,640 @@
import type { UIMessage } from "ai"
import {
AlertCircle,
BookmarkPlus,
Check,
ChevronDown,
Copy,
FileCode,
FileText,
Link,
Pencil,
RotateCcw,
ThumbsDown,
ThumbsUp,
} from "lucide-react"
import { useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown"
import { toast } from "sonner"
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { Shimmer } from "@/components/ai-elements/shimmer"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import type { ToolPartLike } from "@/components/chat/types"
import { ValidationCard } from "@/components/chat/ValidationCard"
import Image from "@/components/image-with-basepath"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import { useCopy } from "@/lib/clipboard"
import { cn } from "@/lib/utils"
import { useUiStore } from "@/stores/ui-store"
import { useChatEngine } from "./chat-engine"
import {
getMessageTextContent,
getUserOriginalText,
splitTextIntoFileSections,
type TextSection,
} from "./message-text"
import { ToolActivity } from "./tool-activity"
function ActionButton({
label,
onClick,
active,
children,
}: {
label: string
onClick: () => void
active?: boolean
children: React.ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-label={label}
className={cn(
"inline-flex size-7 items-center justify-center rounded-lg text-faint transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-3.5",
active && "text-foreground",
)}
>
{children}
</button>
)
}
/** Copies a message's text, with a short "copied" state */
function CopyButton({ text }: { text: string }) {
const dict = useDictionary()
const { copied, copy } = useCopy(() =>
toast.error(dict.chat.failedToCopyDetail),
)
return (
<ActionButton
label={copied ? dict.chat.copied : dict.chat.copyResponse}
onClick={() => copy(text)}
>
{copied ? <Check /> : <Copy />}
</ActionButton>
)
}
function AttachedSection({ section }: { section: TextSection }) {
const [open, setOpen] = useState(false)
const dict = useDictionary()
const Icon =
section.fileType === "pdf"
? FileText
: section.fileType === "url"
? Link
: FileCode
const chars =
section.charCount && section.charCount >= 1000
? `${(section.charCount / 1000).toFixed(1)}k`
: section.charCount
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setOpen((v) => !v)
}}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left hover:bg-accent"
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{section.filename}
</span>
<span className="shrink-0 text-[11px] text-faint">
{chars} {dict.file.chars}
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
</button>
{open && (
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap border-t border-border px-2.5 py-2 text-xs text-muted-foreground scrollbar-thin">
{section.content}
</pre>
)}
</div>
)
}
function MessageText({ text }: { text: string }) {
const sections = splitTextIntoFileSections(text)
return (
<div className="space-y-2">
{sections.map((section, index) =>
section.type === "text" ? (
<div
key={index}
className={cn(
"prose prose-sm max-w-none break-words text-[14px] leading-relaxed text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
"prose-p:my-1.5 prose-pre:bg-surface-2 prose-pre:text-foreground prose-code:before:content-none prose-code:after:content-none dark:prose-invert",
)}
>
<ReactMarkdown>{section.content}</ReactMarkdown>
</div>
) : (
<AttachedSection key={index} section={section} />
),
)}
</div>
)
}
function UserMessage({
message,
index,
isLast,
}: {
message: UIMessage
index: number
isLast: boolean
}) {
const dict = useDictionary()
const engine = useChatEngine()
const [editing, setEditing] = useState(false)
const [editText, setEditText] = useState("")
const [saveTemplate, setSaveTemplate] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const text = getMessageTextContent(message)
const canEdit = isLast && !engine.isBusy
useEffect(() => {
if (editing) textareaRef.current?.focus()
}, [editing])
const startEdit = () => {
if (!canEdit) return
setEditText(getUserOriginalText(message))
setEditing(true)
}
const submitEdit = async () => {
if (!editText.trim()) return
// Kept open, with its text, when it could not go now
if (await engine.editMessage(index, editText.trim())) setEditing(false)
}
if (editing) {
return (
<div className="ml-6 flex flex-col gap-2">
<textarea
ref={textareaRef}
value={editText}
onChange={(e) => setEditText(e.target.value)}
rows={Math.min(editText.split("\n").length + 1, 6)}
onKeyDown={(e) => {
if (e.key === "Escape") setEditing(false)
else if (
e.key === "Enter" &&
(e.metaKey || e.ctrlKey)
) {
e.preventDefault()
submitEdit()
}
}}
className="w-full resize-none rounded-xl border border-foreground/20 bg-card px-3 py-2.5 text-sm outline-none focus:shadow-[0_0_0_4px_var(--accent)]"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setEditing(false)}
className="h-7 rounded-lg px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{dict.common.cancel}
</button>
<button
type="button"
onClick={submitEdit}
disabled={!editText.trim()}
className="h-7 rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground disabled:opacity-40"
>
{dict.chat.saveAndSubmit}
</button>
</div>
</div>
)
}
const images = (message.parts ?? []).filter((p) => p.type === "file") as {
url: string
}[]
return (
<div className="group/message flex items-start justify-end gap-1">
<div className="mt-1 flex shrink-0 items-center transition-opacity [@media(hover:hover)]:opacity-0 group-hover/message:opacity-100 focus-within:opacity-100">
{canEdit && (
<ActionButton
label={dict.chat.editMessage}
onClick={startEdit}
>
<Pencil />
</ActionButton>
)}
<CopyButton text={text} />
<ActionButton
label={dict.templates.saveAsTemplate}
onClick={() => setSaveTemplate(true)}
>
<BookmarkPlus />
</ActionButton>
</div>
<div
className={cn(
"min-w-0 max-w-[85%] rounded-2xl rounded-br-md bg-secondary px-3.5 py-2.5",
canEdit &&
"cursor-pointer transition-colors hover:bg-interactive-active",
)}
role={canEdit ? "button" : undefined}
tabIndex={canEdit ? 0 : undefined}
onClick={(e) => {
// Not for a link or an attachment's toggle inside, nor
// when text was selected to copy it
const target = e.target as Element
if (target.closest("a, button")) return
if (window.getSelection()?.toString()) return
startEdit()
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return
if (canEdit && (e.key === "Enter" || e.key === " ")) {
e.preventDefault()
startEdit()
}
}}
title={canEdit ? dict.chat.clickToEdit : undefined}
>
{images.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{images.map((image, i) => (
<Image
key={i}
src={image.url}
width={96}
height={72}
alt={dict.chat.attachedImage}
className="h-[72px] w-24 rounded-lg border border-border bg-white object-cover"
/>
))}
</div>
)}
{text && <MessageText text={text} />}
</div>
{saveTemplate && (
<TemplateCreateDialog
open
onOpenChange={(open) => !open && setSaveTemplate(false)}
onSuccess={() => setSaveTemplate(false)}
initialPrompt={getUserOriginalText(message)}
/>
)}
</div>
)
}
function AssistantMessage({
message,
index,
isLastMessage,
isLastAssistant,
}: {
message: UIMessage
index: number
isLastMessage: boolean
isLastAssistant: boolean
}) {
const dict = useDictionary()
// The thinking header in the page language
const thinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>{dict.reasoning.thinking}</Shimmer>
}
if (duration === undefined) return <p>{dict.reasoning.thoughtBrief}</p>
return (
<p>
{duration === 1
? dict.reasoning.thoughtForOne
: dict.reasoning.thoughtFor.replace(
"{duration}",
String(duration),
)}
</p>
)
}
const engine = useChatEngine()
const [feedback, setFeedback] = useState<"good" | "bad" | null>(null)
const isRestored = engine.loadedMessageIdsRef.current.has(message.id)
const parts = message.parts ?? []
const isCachedExample = parts.some((p: any) =>
p.toolCallId?.startsWith("cached-"),
)
const text = getMessageTextContent(message)
const submitFeedback = async (value: "good" | "bad") => {
if (feedback === value) {
setFeedback(null)
return
}
setFeedback(value)
try {
await fetch(getApiEndpoint("/api/log-feedback"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messageId: message.id,
feedback: value,
sessionId: engine.langfuseSessionId,
}),
})
} catch (error) {
console.error("Failed to log feedback:", error)
toast.error(dict.errors.failedToRecordFeedback)
setFeedback(null)
}
}
// Text parts with nothing shown between them render together (a step
// boundary of an automatic retry shows nothing); tools render in place
const blocks: {
kind: "text" | "tool" | "reasoning"
index: number
texts: string[]
}[] = []
parts.forEach((part, partIndex) => {
if (part.type === "reasoning")
blocks.push({ kind: "reasoning", index: partIndex, texts: [] })
else if (part.type?.startsWith("tool-"))
blocks.push({ kind: "tool", index: partIndex, texts: [] })
else if (part.type === "text") {
const text = (part as { text: string }).text
const last = blocks[blocks.length - 1]
if (last?.kind === "text") last.texts.push(text)
else blocks.push({ kind: "text", index: partIndex, texts: [text] })
}
})
return (
<div className="group/message space-y-2.5">
{blocks.map((block) => {
const part = parts[block.index]
if (block.kind === "reasoning") {
const isStreamingReasoning =
engine.status === "streaming" &&
isLastMessage &&
block.index === parts.length - 1
return (
<Reasoning
key={`r-${block.index}`}
className="w-full"
isStreaming={isStreamingReasoning}
defaultOpen={!isRestored}
>
<ReasoningTrigger
getThinkingMessage={thinkingMessage}
/>
<ReasoningContent>
{(part as { text: string }).text}
</ReasoningContent>
</Reasoning>
)
}
if (block.kind === "tool") {
const toolPart = part as unknown as ToolPartLike
const validationState =
engine.validationStates[toolPart.toolCallId]
return (
<div key={`t-${block.index}`} className="space-y-2">
<ToolActivity
part={toolPart}
isLastMessage={isLastMessage}
/>
{toolPart.type === "tool-display_diagram" &&
validationState && (
<ValidationCard
state={validationState}
onImproveWithSuggestions={
engine.improveWithSuggestions
}
/>
)}
</div>
)
}
const run = block.texts.join("\n")
if (!run.trim()) return null
return <MessageText key={`x-${block.index}`} text={run} />
})}
{!(engine.isBusy && isLastMessage) && (
<div className="-ml-1.5 flex items-center transition-opacity [@media(hover:hover)]:opacity-0 group-hover/message:opacity-100 focus-within:opacity-100">
{text && <CopyButton text={text} />}
{isLastAssistant && !isCachedExample && (
<ActionButton
label={dict.chat.regenerate}
onClick={() => engine.regenerate(index)}
>
<RotateCcw />
</ActionButton>
)}
<ActionButton
label={dict.chat.goodResponse}
active={feedback === "good"}
onClick={() => submitFeedback("good")}
>
<ThumbsUp
className={cn(
feedback === "good" && "fill-current",
)}
/>
</ActionButton>
<ActionButton
label={dict.chat.badResponse}
active={feedback === "bad"}
onClick={() => submitFeedback("bad")}
>
<ThumbsDown
className={cn(feedback === "bad" && "fill-current")}
/>
</ActionButton>
</div>
)}
</div>
)
}
/**
* A failed request: what to do in the text color, the provider's own words
* below it, and the action that can fix it first
*/
function ErrorNotice({
text,
openModelConfig,
onOpenModelConfig,
onRetry,
}: {
text: string
openModelConfig: boolean
onOpenModelConfig: () => void
onRetry?: () => void
}) {
const dict = useDictionary()
// Provider errors are "hint\n\nprovider message"
const split = text.indexOf("\n\n")
const headline = split >= 0 ? text.slice(0, split) : text
const detail = split >= 0 ? text.slice(split + 2).trim() : ""
const button =
"inline-flex h-7 items-center gap-1 rounded-lg px-2.5 text-xs font-medium"
return (
<div
role="alert"
className="rounded-xl border border-destructive/25 bg-destructive/5 px-3.5 py-3 text-[13px]"
>
<div className="flex items-start gap-2.5">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="whitespace-pre-line break-words font-medium text-foreground">
{headline}
</p>
{detail && (
<p className="mt-1 whitespace-pre-line break-words text-xs text-muted-foreground">
{detail}
</p>
)}
{(openModelConfig || onRetry) && (
<div className="mt-2.5 flex flex-wrap gap-2">
{openModelConfig && (
<button
type="button"
onClick={onOpenModelConfig}
className={cn(
button,
"bg-primary text-primary-foreground hover:opacity-90",
)}
>
{dict.errors.llm.openModelSettings}
</button>
)}
{onRetry && (
<button
type="button"
onClick={onRetry}
className={cn(
button,
"border border-border bg-card text-foreground hover:bg-accent",
)}
data-testid="retry-button"
>
<RotateCcw className="size-3.5" />
{dict.chat.retry}
</button>
)}
</div>
)}
</div>
</div>
</div>
)
}
export function MessageList() {
const dict = useDictionary()
const openSettings = useUiStore((s) => s.openSettings)
const engine = useChatEngine()
const { messages } = engine
const endRef = useRef<HTMLDivElement>(null)
const prevCountRef = useRef(0)
const throttleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Keep the newest message in view; jump instantly on bulk loads
useEffect(() => {
if (!endRef.current || messages.length === 0) return
const prev = prevCountRef.current
prevCountRef.current = messages.length
if (prev === 0 || messages.length - prev > 1) {
endRef.current.scrollIntoView({ behavior: "instant", block: "end" })
return
}
if (!throttleRef.current) {
endRef.current.scrollIntoView({ behavior: "smooth", block: "end" })
throttleRef.current = setTimeout(() => {
throttleRef.current = null
endRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
})
}, 150)
}
}, [messages])
const lastAssistantIndex = messages
.map((m) => m.role)
.lastIndexOf("assistant")
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
return (
<div className="space-y-4 px-4 pt-4 pb-2">
{messages.map((message, index) => {
const isRestored = engine.loadedMessageIdsRef.current.has(
message.id,
)
return (
<div
key={message.id}
className={cn(!isRestored && "animate-message-in")}
>
{message.role === "user" ? (
<UserMessage
message={message}
index={index}
isLast={index === lastUserIndex}
/>
) : message.role === "assistant" ? (
<AssistantMessage
message={message}
index={index}
isLastMessage={index === messages.length - 1}
isLastAssistant={index === lastAssistantIndex}
/>
) : (
<ErrorNotice
text={getMessageTextContent(message)}
openModelConfig={
!!(
message.metadata as
| { openModelConfig?: boolean }
| undefined
)?.openModelConfig
}
onOpenModelConfig={() => openSettings("models")}
onRetry={
index === messages.length - 1 &&
!engine.isBusy &&
lastUserIndex >= 0
? engine.retryLastMessage
: undefined
}
/>
)}
</div>
)
})}
{engine.status === "submitted" &&
messages[messages.length - 1]?.role === "user" && (
<div className="px-1 text-[13px]" aria-live="polite">
<Shimmer as="span">{dict.reasoning.thinking}</Shimmer>
</div>
)}
<div ref={endRef} />
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
import type { UIMessage } from "ai"
/** A run of plain text, or an attached file / URL whose text was inlined */
export interface TextSection {
type: "text" | "file" | "url"
content: string
filename?: string
charCount?: number
fileType?: "pdf" | "text" | "url"
}
// Matches the [PDF: ...], [File: ...] and [URL: ...] sections appended to the user's text
export const APPENDED_FILE_SECTIONS_PATTERN =
/\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
export function splitTextIntoFileSections(text: string): TextSection[] {
const sections: TextSection[] = []
const filePattern =
/\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = filePattern.exec(text)) !== null) {
const beforeText = text.slice(lastIndex, match.index).trim()
if (beforeText) sections.push({ type: "text", content: beforeText })
const sectionType = match[1].toLowerCase()
const fileType =
sectionType === "pdf"
? "pdf"
: sectionType === "url"
? "url"
: "text"
const content = match[3].trim()
sections.push({
type: sectionType === "url" ? "url" : "file",
content,
filename: match[2].trim(),
charCount: content.length,
fileType,
})
lastIndex = match.index + match[0].length
}
const remainingText = text.slice(lastIndex).trim()
if (remainingText) sections.push({ type: "text", content: remainingText })
if (sections.length === 0) sections.push({ type: "text", content: text })
return sections
}
export function getMessageTextContent(message: UIMessage): string {
if (!message.parts) return ""
return message.parts
.filter((part) => part.type === "text")
.map((part) => (part as { text: string }).text)
.join("\n")
}
/** Only the user's typed text, without appended file content */
export function getUserOriginalText(message: UIMessage): string {
return getMessageTextContent(message)
.replace(APPENDED_FILE_SECTIONS_PATTERN, "")
.trim()
}
+295
View File
@@ -0,0 +1,295 @@
import { FileUp, Pencil, Plus, Search, Trash2 } from "lucide-react"
import { useParams } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatRelativeTime } from "@/lib/relative-time"
import { cn } from "@/lib/utils"
import { useChatEngine } from "./chat-engine"
/** Title shown for a session, with the default "New Chat" localized */
export function useSessionTitle(): string {
const dict = useDictionary()
const { currentTitle } = useChatEngine()
return !currentTitle || currentTitle === "New Chat"
? dict.workspace.untitled
: currentTitle
}
/**
* Popover listing saved diagrams: rename the current one, switch, delete,
* start a new one or open a file.
*/
export function SessionMenu({
children,
align = "start",
onOpenFile,
}: {
children: React.ReactNode
align?: "start" | "center" | "end"
onOpenFile: () => void
}) {
const dict = useDictionary()
const t = dict.sessionHistory
const params = useParams<{ lang: string }>()
const engine = useChatEngine()
const [open, setOpen] = useState(false)
const title = useSessionTitle()
const [query, setQuery] = useState("")
const [renaming, setRenaming] = useState(false)
const [draftTitle, setDraftTitle] = useState("")
const [toDelete, setToDelete] = useState<string | null>(null)
const renameRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!open) {
setRenaming(false)
setQuery("")
}
}, [open])
useEffect(() => {
if (renaming) renameRef.current?.select()
}, [renaming])
// The title as the list shows it
const shownTitle = (title: string) =>
title === "New Chat" ? dict.workspace.untitled : title
const sessions = engine.sessions.filter((s) =>
shownTitle(s.title).toLowerCase().includes(query.trim().toLowerCase()),
)
const commitRename = async () => {
const next = draftTitle.trim()
setRenaming(false)
if (next && next !== title) await engine.renameSession(next)
}
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
align={align}
className="flex max-h-[min(560px,80vh)] w-[340px] flex-col overflow-hidden rounded-xl p-0 shadow-pop"
>
<div className="border-b border-border p-3">
{renaming ? (
<input
ref={renameRef}
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
// Enter that picks an IME candidate
// (Safari: keyCode 229)
if (
e.nativeEvent.isComposing ||
e.keyCode === 229
) {
return
}
if (e.key === "Enter") commitRename()
if (e.key === "Escape") setRenaming(false)
}}
maxLength={100}
aria-label={t.rename}
className="h-8 w-full rounded-lg border border-foreground/20 bg-card px-2.5 text-[13px] font-medium outline-none"
/>
) : (
<button
type="button"
onClick={() => {
setDraftTitle(title)
setRenaming(true)
}}
className="group/rename flex h-8 w-full items-center gap-2 rounded-lg px-2.5 text-left hover:bg-accent"
data-testid="rename-session"
>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">
{title}
</span>
<Pencil className="size-3.5 shrink-0 text-muted-foreground" />
</button>
)}
<div className="mt-2 flex gap-1.5">
<button
type="button"
disabled={engine.isBusy}
onClick={() => {
setOpen(false)
engine.newChat()
}}
className="flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
>
<Plus className="size-3.5" />
{dict.nav.newChat}
</button>
<button
type="button"
disabled={engine.isBusy}
onClick={() => {
setOpen(false)
onOpenFile()
}}
className="flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<FileUp className="size-3.5" />
{dict.workspace.openFile}
</button>
</div>
</div>
{engine.sessions.length > 0 && (
<div className="px-3 pt-3">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-faint" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t.searchPlaceholder}
className="h-8 w-full rounded-lg bg-muted pr-2.5 pl-8 text-[13px] outline-none placeholder:text-faint"
/>
</div>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-1.5 scrollbar-thin">
{engine.sessions.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
{t.empty}
</p>
) : sessions.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
{t.noResults}
</p>
) : (
sessions.map((session) => {
const isCurrent =
session.id === engine.currentSessionId
// The answer being written must stay in its
// own chat
const locked = engine.isBusy && !isCurrent
const choose = () => {
if (locked) return
setOpen(false)
if (!isCurrent) {
engine.selectSession(session.id)
}
}
return (
// biome-ignore lint/a11y/useSemanticElements: contains the delete button
<div
key={session.id}
role="button"
tabIndex={locked ? -1 : 0}
aria-disabled={locked || undefined}
title={locked ? t.busyHint : undefined}
onClick={choose}
onKeyDown={(e) => {
if (e.target !== e.currentTarget)
return
if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault()
choose()
}
}}
className={cn(
"group/session flex cursor-pointer items-center gap-2.5 rounded-lg p-1.5 hover:bg-accent",
isCurrent && "bg-accent",
locked &&
"cursor-not-allowed opacity-50 hover:bg-transparent",
)}
>
<span className="sheet-light flex h-9 w-12 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white">
{session.thumbnailDataUrl && (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={
session.thumbnailDataUrl
}
alt=""
className="max-h-8 max-w-11 object-contain"
/>
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] text-foreground">
{shownTitle(session.title)}
</span>
<span className="block text-[11px] text-faint">
{formatRelativeTime(
session.updatedAt,
params.lang,
t.justNow,
)}
</span>
</span>
<button
type="button"
disabled={
isCurrent && engine.isBusy
}
onClick={(e) => {
e.stopPropagation()
setToDelete(session.id)
}}
aria-label={t.deleteTitle}
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-faint [@media(hover:hover)]:opacity-0 hover:bg-destructive/10 hover:text-destructive group-hover/session:opacity-100 focus-visible:opacity-100"
>
<Trash2 className="size-3.5" />
</button>
</div>
)
})
)}
</div>
</PopoverContent>
</Popover>
<AlertDialog
open={!!toDelete}
onOpenChange={(o) => !o && setToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
<AlertDialogDescription>
{t.deleteDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
onClick={() => {
if (toDelete) engine.deleteSession(toDelete)
setToDelete(null)
}}
>
{dict.common.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
+531
View File
@@ -0,0 +1,531 @@
import {
AlertCircle,
Check,
ChevronDown,
Code2,
Copy,
Library,
PenTool,
Redo2,
RotateCcw,
Undo2,
} from "lucide-react"
import { useState } from "react"
import { Shimmer } from "@/components/ai-elements/shimmer"
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"
export function ToolInputDetails({ part }: { part: ToolPartLike }) {
const { input, state } = part
if (!input || typeof input !== "object") return null
if (typeof input.xml === "string" && input.xml) {
// Plain text while streaming; highlighting re-runs on every chunk
return state === "input-streaming" || state === "input-available" ? (
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed text-muted-foreground scrollbar-thin">
{input.xml}
</pre>
) : (
<CodeBlock code={input.xml} language="xml" />
)
}
if (Array.isArray(input.operations)) {
// Streamed or invalid input can hold anything: show only what React
// can render (an object in place of a string would crash the chat)
const shown = (input.operations as DiagramOperation[]).filter(
(op) =>
typeof (op as { operation?: unknown })?.operation === "string",
)
const text = (value: unknown) =>
typeof value === "string" ? value : ""
return (
<div className="space-y-2">
{shown.map((op, index) => (
<div
key={`${op.operation}-${text(op.cell_id)}-${index}`}
className="overflow-hidden rounded-lg border border-border bg-card"
>
<div className="flex items-center gap-2 border-b border-border px-2.5 py-1 text-[11px]">
<span className="font-medium text-foreground">
{op.operation}
</span>
<span className="text-muted-foreground">
{text(op.cell_id)}
</span>
</div>
{text(op.new_xml) && (
<pre className="overflow-x-auto whitespace-pre-wrap break-all px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground">
{text(op.new_xml)}
</pre>
)}
</div>
))}
</div>
)
}
if (Object.keys(input).length === 0) return null
return <CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
}
function toolText(part: ToolPartLike): string {
const { input } = part
if (!input || typeof input !== "object") return ""
if (typeof input.xml === "string" && input.xml) return input.xml
if (Array.isArray(input.operations)) {
return JSON.stringify(input.operations, null, 2)
}
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).
*/
function VersionCard({
part,
version,
isLatest,
}: {
part: ToolPartLike
version: DiagramVersion
isLatest: boolean
}) {
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, 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>
)
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" title={summary}>
<div className="truncate text-[13px] text-foreground">
{title}
</div>
<div className="truncate text-xs text-muted-foreground">
{summary}
</div>
</div>
{codeButton}
{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"
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>
<div className="flex items-center gap-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>
{codeButton}
{canUndo ? (
<button
type="button"
disabled={engine.isBusy}
onClick={() => engine.undoVersion(version.id)}
className={actionClass}
data-testid="version-undo"
>
{isUndone ? (
<Redo2 className="size-3.5" />
) : (
<Undo2 className="size-3.5" />
)}
{isUndone ? t.redo : t.undo}
</button>
) : (
restoreButton
)}
</div>
{codePanel}
</div>
)
}
/** Compact row for tool calls that are running, failed or produced no version */
function ToolRow({
part,
icon,
label,
tone = "default",
detail,
}: {
part: ToolPartLike
icon: React.ReactNode
label: React.ReactNode
tone?: "default" | "error" | "warning"
detail?: string
}) {
const dict = useDictionary()
const [open, setOpen] = useState(false)
const { copied, copy } = useCopy()
const hasInput = !!part.input && Object.keys(part.input).length > 0
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card",
tone === "error" && "border-destructive/25",
)}
data-testid="tool-row"
data-tool-name={part.type?.replace("tool-", "")}
data-tool-state={part.state}
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={!hasInput && !detail}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left"
>
<span
className={cn(
"inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground [&_svg]:size-4",
tone === "error" &&
"bg-destructive/10 text-destructive",
tone === "warning" && "bg-marker-soft text-marker-ink",
)}
>
{icon}
</span>
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
{label}
</span>
{(hasInput || detail) && (
<ChevronDown
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
)}
</button>
{open && (
<div
className={cn(
"relative space-y-2 border-t border-border bg-surface-1 px-3 py-2.5",
hasInput && "pr-11",
)}
>
{hasInput && (
<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>
)}
{detail && (
<p
className={cn(
"whitespace-pre-wrap break-words text-xs",
tone === "error"
? "text-destructive"
: "text-muted-foreground",
)}
>
{detail}
</p>
)}
{hasInput && <ToolInputDetails part={part} />}
</div>
)}
</div>
)
}
export function ToolActivity({
part,
isLastMessage,
}: {
part: ToolPartLike
isLastMessage: boolean
}) {
const dict = useDictionary()
const t = dict.versions
const engine = useChatEngine()
const versions = useVersionsStore((s) => s.versions)
const toolName = part.type?.replace("tool-", "")
const { state, errorText, output } = part
const versionIndex = versions.findIndex(
(v) => v.toolCallId === part.toolCallId,
)
if (state === "output-available" && versionIndex >= 0) {
return (
<VersionCard
part={part}
version={versions[versionIndex]}
isLatest={versionIndex === versions.length - 1}
/>
)
}
// The answer ended before the call did (stopped, network error)
const running = isLastMessage && engine.isBusy
if (toolName === "get_shape_library" && state !== "output-error") {
const isDone = state === "output-available"
return (
<ToolRow
part={part}
icon={<Library />}
label={
isDone ? (
t.libraryLoaded
) : running ? (
<Shimmer as="span">{t.loadingLibrary}</Shimmer>
) : (
t.stopped
)
}
detail={
isDone && typeof output === "string"
? `${output.slice(0, 600)}${output.length > 600 ? "…" : ""}`
: undefined
}
/>
)
}
// Stop turns a call still streaming into "Stopped by user" (right
// away, or when the answer has ended for one not on screen yet): one
// left like this was cut off (a closed connection, an error, a closed
// tab)
if (state === "input-streaming" || state === "input-available") {
const failed = !running && !(isLastMessage && engine.stoppedRef.current)
return (
<ToolRow
part={part}
icon={running ? <PenTool /> : <AlertCircle />}
tone={failed ? "error" : "default"}
label={
running ? (
<Shimmer as="span">
{toolName === "edit_diagram"
? t.editing
: t.drawing}
</Shimmer>
) : failed ? (
t.failed
) : (
t.stopped
)
}
/>
)
}
if (state === "output-error") {
const stopped = errorText === "Stopped by user"
// Incomplete XML means the output hit the length limit. Without an
// input the JSON was broken (the server repairs JSON cut short by
// the limit).
const truncated =
!stopped &&
(toolName === "display_diagram" || toolName === "append_diagram") &&
typeof part.input?.xml === "string" &&
!isMxCellXmlComplete(part.input.xml)
return (
<ToolRow
part={part}
icon={<AlertCircle />}
tone={stopped ? "default" : truncated ? "warning" : "error"}
label={
stopped
? t.stopped
: truncated
? dict.tools.truncated
: isLastMessage && engine.isBusy
? t.failedRetrying
: t.failed
}
detail={
stopped
? undefined
: truncated
? t.truncatedHint
: (errorText ??
(typeof output === "string" ? output : ""))
}
/>
)
}
// Finished without a version (sessions saved before versions existed)
return (
<ToolRow
part={part}
icon={<PenTool />}
label={toolName === "edit_diagram" ? t.edited : t.created}
/>
)
}
+231
View File
@@ -0,0 +1,231 @@
"use client"
import type { UIMessage } from "ai"
import type { RefObject } from "react"
import { useCallback, useEffect, useRef } from "react"
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
import { useDiagram } from "@/contexts/diagram-context"
import {
convertToLegalXml,
extractCompleteMxCells,
replaceNodes,
} from "@/lib/utils"
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
// Helper to extract complete operations from streaming input
function getCompleteOperations(
operations: DiagramOperation[] | undefined,
): DiagramOperation[] {
if (!operations || !Array.isArray(operations)) return []
return operations.filter(
(op) =>
op &&
typeof op.operation === "string" &&
["update", "add", "delete"].includes(op.operation) &&
typeof op.cell_id === "string" &&
op.cell_id.length > 0 &&
(op.operation === "delete" || typeof op.new_xml === "string"),
)
}
interface UseDiagramStreamingParams {
messages: UIMessage[]
processedToolCallsRef: RefObject<Set<string>>
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
loadedMessageIdsRef: RefObject<Set<string>>
}
/**
* Draws AI tool output onto the canvas while it streams, as a preview that
* is not recorded in undo history.
*
* display_diagram: the complete mxCells written so far. edit_diagram: the
* complete operations applied to the diagram from before the call. The tool
* handler validates and commits the final result. Runs in the chat engine
* so streaming continues while the chat panel is collapsed.
*/
export function useDiagramStreaming({
messages,
processedToolCallsRef,
editDiagramOriginalXmlRef,
loadedMessageIdsRef,
}: UseDiagramStreamingParams) {
const { chartXML, chartXMLRef, loadDiagram } = useDiagram()
const previousXML = useRef<string>("")
// Last processed XML per toolCallId, to skip redundant work while streaming
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
// Reset when messages become empty (new chat or session switch), so
// cached examples work again after starting a new session
useEffect(() => {
if (messages.length === 0) {
previousXML.current = ""
lastProcessedXmlRef.current.clear()
processedToolCallsRef.current.clear()
editDiagramOriginalXmlRef.current.clear()
}
}, [messages.length, processedToolCallsRef, editDiagramOriginalXmlRef])
// Streaming preview of display_diagram: draw the complete cells written
// so far. The tool handler validates and loads the final diagram.
const handleDisplayChart = useCallback(
(xml: string) => {
const completeCells = extractCompleteMxCells(xml || "")
if (!completeCells) return
const convertedXml = convertToLegalXml(completeCells)
if (convertedXml === previousXML.current) return
// Skip this update while the cells written so far don't parse
const testDoc = new DOMParser().parseFromString(
`<root>${convertedXml}</root>`,
"text/xml",
)
if (testDoc.querySelector("parsererror")) return
try {
// Replace the first page's cells so other pages stay intact.
// An empty canvas gets a default mxfile to put the cells in.
const baseXML = chartXML || BLANK_MXFILE
const replacedXML = replaceNodes(baseXML, convertedXml)
previousXML.current = convertedXml
loadDiagram(replacedXML, true, "preview")
} catch (error) {
console.error("Error processing XML:", error)
}
},
[chartXML, loadDiagram],
)
useEffect(() => {
// Only the last message can still be streaming
const message = messages[messages.length - 1]
if (!message?.parts) return
// Messages restored from a saved session were applied before it was
// saved; the saved diagram is authoritative, so don't replay them
if (loadedMessageIdsRef.current.has(message.id)) return
// The diagram without streamed previews, as loaded last: the tool
// handler's result of an earlier edit is there before the chartXML
// state catches up. Undoing a failed edit's preview below changes it
// too, and an edit streaming right after must start from the undone
// diagram.
let baseXml = chartXMLRef.current
for (const part of message.parts) {
if (
part.type !== "tool-display_diagram" &&
part.type !== "tool-edit_diagram"
) {
continue
}
const { toolCallId, state, input } = part as ToolPartLike
// Failed or stopped: if the original XML is still stored, the
// tool handler never ran (invalid JSON, or the user pressed
// stop), so undo the streamed preview here. Invalid JSON leaves
// no input, so check this first.
if (state === "output-error") {
const originalXml =
editDiagramOriginalXmlRef.current.get(toolCallId)
if (originalXml) {
editDiagramOriginalXmlRef.current.delete(toolCallId)
loadDiagram(originalXml, true, "revert")
baseXml = originalXml
}
continue
}
// Input complete, or the tool handler, a stop or an error took
// the call already: the tool handler loads the checked diagram.
// The messages update at most every 150 ms (useChat throttle in
// the chat engine), so they can still show the call streaming
// after that.
if (
state !== "input-streaming" ||
processedToolCallsRef.current.has(toolCallId)
) {
processedToolCallsRef.current.add(toolCallId)
lastProcessedXmlRef.current.delete(toolCallId)
lastProcessedXmlRef.current.delete(`${toolCallId}-result`)
lastProcessedXmlRef.current.delete(`${toolCallId}-ops`)
continue
}
if (part.type === "tool-display_diagram") {
const xml = input?.xml as string | undefined
// Skip if XML hasn't changed since last processing
if (!xml || lastProcessedXmlRef.current.get(toolCallId) === xml)
continue
// Keep the diagram from before the preview, to undo it on a
// stop or an error
if (!editDiagramOriginalXmlRef.current.has(toolCallId)) {
editDiagramOriginalXmlRef.current.set(
toolCallId,
baseXml || BLANK_MXFILE,
)
}
handleDisplayChart(xml)
lastProcessedXmlRef.current.set(toolCallId, xml)
continue
}
// edit_diagram: apply operations incrementally for a preview.
// editDiagramOriginalXmlRef is shared with the tool handler.
if (!input?.operations) continue
const completeOps = getCompleteOperations(
input.operations as DiagramOperation[],
)
if (completeOps.length === 0) continue
// Capture the original XML when streaming starts
if (!editDiagramOriginalXmlRef.current.has(toolCallId)) {
if (!baseXml) {
console.warn(
"[edit_diagram streaming] No chart XML available",
)
continue
}
editDiagramOriginalXmlRef.current.set(toolCallId, baseXml)
}
const originalXml =
editDiagramOriginalXmlRef.current.get(toolCallId)
if (!originalXml) continue
// The last operation's XML may still be streaming: it applies
// once it parses, so compare results, not the number of
// operations. Skip if nothing changed since the last preview.
const resultKey = `${toolCallId}-result`
const opsKey = `${toolCallId}-ops`
const ops = JSON.stringify(completeOps)
if (lastProcessedXmlRef.current.get(opsKey) === ops) continue
lastProcessedXmlRef.current.set(opsKey, ops)
try {
const { result } = applyDiagramOperations(
originalXml,
completeOps,
)
if (lastProcessedXmlRef.current.get(resultKey) === result) {
continue
}
// Load the full document so other pages stay intact
loadDiagram(result, true, "preview")
lastProcessedXmlRef.current.set(resultKey, result)
} catch (e) {
console.warn(
"[edit_diagram streaming] Operation failed:",
e instanceof Error ? e.message : e,
)
}
}
}, [
messages,
handleDisplayChart,
chartXMLRef,
loadDiagram,
processedToolCallsRef,
editDiagramOriginalXmlRef,
loadedMessageIdsRef,
])
}
+183
View File
@@ -0,0 +1,183 @@
import type { RefObject } from "react"
import { useEffect, useRef } from "react"
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 { formatMessage } from "@/lib/i18n/utils"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
import { useVersionsStore } from "@/stores/versions-store"
interface UseVersionsParams {
/** Index of the user message whose turn is running */
currentTurnRef: RefObject<number>
isBusy: boolean
/** Live busy state, for the restore toast's Undo that runs later */
isTurnActive: () => boolean
/** Changes when another chat comes on screen */
getChatGeneration: () => number
}
/**
* Turns every committed AI change into a version (card in the chat), marks
* the changed shapes on the canvas, and restores or undoes versions.
*/
export function useVersions({
currentTurnRef,
isBusy,
isTurnActive,
getChatGeneration,
}: UseVersionsParams) {
const dict = useDictionary()
// chartXML changes with hand edits, undo and loads; the ref is the
// document on the canvas now
const {
loadDiagram,
getVersionSvg,
setCommitHandler,
chartXML,
chartXMLRef,
} = useDiagram()
// A restore commits too; that path must not add a version
const restoringRef = useRef(false)
// Registered once: re-registering on every render would leave a gap
// (effect cleanups run before the streaming hook commits). The handler
// only reads refs and stable functions.
useEffect(() => {
setCommitHandler(({ beforeXml, afterXml, toolCallId }) => {
if (restoringRef.current) return
const { summary, touchedIds, fromScratch } = diffDiagrams(
beforeXml,
afterXml,
)
const id = useVersionsStore.getState().addVersion({
xml: afterXml,
beforeXml,
turnIndex: currentTurnRef.current,
toolCallId,
summary,
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)
}
// 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
const isNewest = () =>
useVersionsStore.getState().versions.at(-1)?.id === id
setTimeout(async () => {
const svg = isNewest() ? await getVersionSvg() : null
useVersionsStore
.getState()
.updateVersion(id, { svg: svg && isNewest() ? svg : "" })
}, 0)
})
return () => setCommitHandler(null)
}, [])
// A version is on the canvas (or its change undone) while the canvas
// holds that XML. Hand edits, Ctrl+Z or a new page end it, with an
// external draw.io too; Ctrl+Z or redo can also bring the latest version
// (or the diagram before it) back. Checked once an answer is done: its
// previews change the canvas on the way.
useEffect(() => {
if (isBusy || isTurnActive() || !chartXML) return
const store = useVersionsStore.getState()
const id = store.onCanvasVersionId ?? store.undoneVersionId
const version = store.versions.find((v) => v.id === id)
if (version) {
const shown =
id === store.onCanvasVersionId
? version.xml
: version.beforeXml || BLANK_MXFILE
if (isSameDocument(shown, chartXML)) return
// Its Undo would also take the changes made since away
toast.dismiss("version-restored")
}
const latest = store.versions.at(-1)
if (latest && isSameDocument(latest.xml, chartXML)) {
store.setOnCanvas(latest.id)
} else if (
latest?.beforeXml !== undefined &&
isSameDocument(latest.beforeXml || BLANK_MXFILE, chartXML)
) {
store.setUndone(latest.id)
} else {
store.clearCanvasFlags()
}
}, [chartXML, isBusy])
const apply = (xml: string) => {
restoringRef.current = true
try {
loadDiagram(xml, true, "commit")
} finally {
restoringRef.current = false
}
clearHighlights()
}
const restoreVersion = (versionId: string) => {
if (isTurnActive()) return
const store = useVersionsStore.getState()
const index = store.versions.findIndex((v) => v.id === versionId)
if (index < 0) return
// The canvas before the restore, to go back to it: a multi-page
// document is loaded in full, which Ctrl+Z cannot undo
const before = chartXMLRef.current
const wasOnCanvas = store.onCanvasVersionId
const wasUndone = store.undoneVersionId
const generation = getChatGeneration()
apply(store.versions[index].xml)
store.setOnCanvas(versionId)
toast.success(
formatMessage(dict.versions.restoredVersion, {
n: store.versions[index].number,
}),
{
id: "version-restored",
duration: 8000,
action: {
label: dict.versions.undoRestore,
onClick: () => {
// Another chat is on screen, or an answer is
// changing the canvas: the old canvas is not theirs
if (!before || isTurnActive()) return
if (getChatGeneration() !== generation) return
apply(before)
const now = useVersionsStore.getState()
if (wasUndone) now.setUndone(wasUndone)
else if (wasOnCanvas) now.setOnCanvas(wasOnCanvas)
},
},
},
)
}
/** Undo the change of the latest version, or redo it when undone */
const undoVersion = (versionId: string) => {
if (isTurnActive()) return
const store = useVersionsStore.getState()
const version = store.versions.find((v) => v.id === versionId)
if (!version) return
if (store.undoneVersionId === versionId) {
apply(version.xml)
store.setOnCanvas(versionId)
return
}
if (version.beforeXml === undefined) return
apply(version.beforeXml || BLANK_MXFILE)
store.setUndone(versionId)
}
return { restoreVersion, undoVersion }
}
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useRef } from "react"
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() {
const dict = useDictionary()
const versions = useVersionsStore((s) => s.versions)
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
const openCompare = useUiStore((s) => s.openCompare)
const engine = useChatEngine()
const scrollRef = useRef<HTMLDivElement>(null)
// Keep the newest version in view
useEffect(() => {
const el = scrollRef.current
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
return (
<div className="flex items-center gap-2 border-b border-border px-3.5 pt-1 pb-3">
<span className="shrink-0 text-xs text-muted-foreground">
{dict.versions.strip}
</span>
<div
ref={scrollRef}
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
return (
<button
key={version.id}
type="button"
onClick={() => openCompare(version.id)}
title={`v${version.number}${version.id === onCanvasVersionId ? ` · ${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
// picture in both themes
isCurrent &&
"outline-2 outline-offset-2 outline-foreground",
)}
data-testid="version-thumb"
>
{version.svg && (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={version.svg}
alt=""
className="max-h-9 max-w-[62px] object-contain"
/>
)}
<span
className={cn(
"absolute bottom-0.5 left-1 text-[10px] font-medium tabular-nums text-neutral-500",
isCurrent &&
"font-semibold text-neutral-900",
)}
>
v{version.number}
</span>
</button>
)
})}
</div>
</div>
)
}
-114
View File
@@ -1,114 +0,0 @@
"use client"
import { useState } from "react"
import Image from "@/components/image-with-basepath"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
interface HistoryDialogProps {
showHistory: boolean
onToggleHistory: (show: boolean) => void
}
export function HistoryDialog({
showHistory,
onToggleHistory,
}: HistoryDialogProps) {
const dict = useDictionary()
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const handleClose = () => {
setSelectedIndex(null)
onToggleHistory(false)
}
const handleConfirmRestore = () => {
if (selectedIndex !== null) {
// Skip validation for trusted history snapshots
onDisplayChart(diagramHistory[selectedIndex].xml, true)
handleClose()
}
}
return (
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin">
<DialogHeader>
<DialogTitle>{dict.history.title}</DialogTitle>
<DialogDescription>
{dict.history.description}
</DialogDescription>
</DialogHeader>
{diagramHistory.length === 0 ? (
<div className="text-center p-4 text-gray-500">
{dict.history.noHistory}
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 py-4">
{diagramHistory.map((item, index) => (
<div
key={index}
className={`border rounded-md p-2 cursor-pointer hover:border-primary transition-colors ${
selectedIndex === index
? "border-primary ring-2 ring-primary"
: ""
}`}
onClick={() => setSelectedIndex(index)}
>
<div className="aspect-video bg-white rounded overflow-hidden flex items-center justify-center">
<Image
src={item.svg}
alt={`${dict.history.version} ${index + 1}`}
width={200}
height={100}
className="object-contain w-full h-full p-1"
/>
</div>
<div className="text-xs text-center mt-1 text-gray-500">
{dict.history.version} {index + 1}
</div>
</div>
))}
</div>
)}
<DialogFooter>
{selectedIndex !== null ? (
<>
<div className="flex-1 text-sm text-muted-foreground">
{formatMessage(dict.history.restoreTo, {
version: selectedIndex + 1,
})}
</div>
<Button
variant="outline"
onClick={() => setSelectedIndex(null)}
>
{dict.common.cancel}
</Button>
<Button onClick={handleConfirmRestore}>
{dict.common.confirm}
</Button>
</>
) : (
<Button variant="outline" onClick={handleClose}>
{dict.common.close}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+69 -52
View File
@@ -41,13 +41,6 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
@@ -80,7 +73,6 @@ import { cn } from "@/lib/utils"
interface ModelConfigDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
modelConfig: UseModelConfigReturn
}
@@ -103,7 +95,7 @@ function ConfigSection({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
<span className="text-xs font-medium text-muted-foreground">
{title}
</span>
</div>
@@ -123,9 +115,28 @@ function ConfigCard({ children }: { children: React.ReactNode }) {
)
}
/** A Bedrock API key or an access key pair, and a region */
function hasBedrockCredentials(p: ProviderConfig): boolean {
return (
(!!p.apiKey || (!!p.awsAccessKeyId && !!p.awsSecretAccessKey)) &&
!!p.awsRegion
)
}
/** Providers as last saved (the model config is saved on every change) */
function savedProviders(): ProviderConfig[] {
try {
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
return stored
? (JSON.parse(stored) as { providers: ProviderConfig[] }).providers
: []
} catch {
return []
}
}
export function ModelConfigDialog({
open,
onOpenChange,
modelConfig,
}: ModelConfigDialogProps) {
const dict = useDictionary()
@@ -192,10 +203,22 @@ export function ModelConfigDialog({
// Number of the latest Test click: only that test may reset the busy
// state when its credentials changed meanwhile
const validationRunRef = useRef(0)
// Set when this view goes away (settings closed or on another tab): its
// config no longer follows edits, the saved one does
const closedRef = useRef(false)
useEffect(() => {
closedRef.current = false
return () => {
closedRef.current = true
}
}, [])
// The providers as they are now: once this view is gone, as saved
const providersNow = () =>
closedRef.current ? savedProviders() : configRef.current.providers
// A model list or test result belongs to the credentials it was asked
// with; they can change meanwhile, here or in another tab
const credentialsOf = (providerId: string) => {
const p = configRef.current.providers.find((x) => x.id === providerId)
const p = providersNow().find((x) => x.id === providerId)
return JSON.stringify([
p?.provider,
p?.apiKey,
@@ -266,7 +289,9 @@ export function ModelConfigDialog({
const hints = dict.errors.llm as Record<string, string>
setFetchModelsError(
[hints[data.code], data.error].filter(Boolean).join(" ") ||
`Request failed (${response.status})`,
formatMessage(dict.modelConfig.requestFailed, {
status: response.status,
}),
)
}
} catch {
@@ -355,7 +380,7 @@ export function ModelConfigDialog({
if (!selectedProviderId || !selectedProvider) return false
// Prevent duplicate model IDs
if (existingModelIds.includes(modelId)) {
setDuplicateError(`Model "${modelId}" already exists`)
setDuplicateError(dict.modelConfig.modelIdExists)
return false
}
setDuplicateError("")
@@ -388,13 +413,7 @@ export function ModelConfigDialog({
const isOllama = selectedProvider.provider === "ollama"
const isVertexAI = selectedProvider.provider === "vertexai"
if (isBedrock) {
if (
!selectedProvider.awsAccessKeyId ||
!selectedProvider.awsSecretAccessKey ||
!selectedProvider.awsRegion
) {
return
}
if (!hasBedrockCredentials(selectedProvider)) return
} else if (isVertexAI) {
// Vertex AI requires vertexApiKey for Express Mode
if (!selectedProvider.vertexApiKey) {
@@ -406,7 +425,7 @@ export function ModelConfigDialog({
// Need at least one model to validate
if (selectedProvider.models.length === 0) {
setValidationError("Add at least one model to validate")
setValidationError(dict.modelConfig.addModelFirst)
setValidationStatus("error")
return
}
@@ -485,14 +504,17 @@ export function ModelConfigDialog({
.filter(Boolean)
.join(" ") ||
(response.ok
? "Validation failed"
: `Request failed (${response.status})`),
? dict.modelConfig.validationError
: formatMessage(
dict.modelConfig.requestFailed,
{ status: response.status },
)),
validationWarning: undefined,
}
} catch {
update = {
validated: false,
validationError: "Network error",
validationError: dict.errors.networkError,
validationWarning: undefined,
}
}
@@ -511,7 +533,7 @@ export function ModelConfigDialog({
return
}
// So did this model's id: the result is for the old one
const current = configRef.current.providers
const current = providersNow()
.find((p) => p.id === selectedProviderId)
?.models.find((m) => m.id === model.id)
if (current?.modelId !== model.modelId) {
@@ -565,7 +587,11 @@ export function ModelConfigDialog({
}, 1500)
} else {
setValidationStatus("error")
setValidationError(`${errorCount} model(s) failed validation`)
setValidationError(
formatMessage(dict.modelConfig.validationFailedCount, {
count: errorCount,
}),
)
}
}, [
selectedProvider,
@@ -682,27 +708,13 @@ export function ModelConfigDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl h-[80vh] max-h-[800px] overflow-hidden flex flex-col gap-0 p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-surface-2">
<Server className="h-5 w-5 text-primary" />
</div>
{dict.modelConfig?.title || "AI Model Configuration"}
</DialogTitle>
<DialogDescription className="mt-1">
{dict.modelConfig?.description ||
"Configure multiple AI providers and models for your workspace"}
</DialogDescription>
</DialogHeader>
<div className="flex flex-1 min-h-0 overflow-hidden border-t border-border-subtle">
<>
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-1 min-h-0 overflow-hidden">
{/* Provider List (Left Sidebar) */}
<div className="w-60 shrink-0 flex flex-col bg-surface-1/50 border-r border-border-subtle">
<div className="px-4 py-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
<span className="text-xs font-medium text-muted-foreground">
{dict.modelConfig.providers}
</span>
</div>
@@ -898,6 +910,7 @@ export function ModelConfigDialog({
awsRegion={
selectedProvider.awsRegion
}
bedrockApiKey
onChange={(field, value) =>
handleProviderUpdate(
field,
@@ -914,9 +927,9 @@ export function ModelConfigDialog({
selectedProvider.provider ===
"bedrock"
? renderTestButton(
!!selectedProvider.awsAccessKeyId &&
!!selectedProvider.awsSecretAccessKey &&
!!selectedProvider.awsRegion,
hasBedrockCredentials(
selectedProvider,
),
)
: selectedProvider.provider ===
"edgeone"
@@ -1413,7 +1426,7 @@ export function ModelConfigDialog({
model.id,
)
}
aria-label={`Delete ${model.modelId}`}
aria-label={`${dict.common.delete} ${model.modelId}`}
>
<X className="h-4 w-4" />
</Button>
@@ -1474,10 +1487,14 @@ export function ModelConfigDialog({
<Server className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="font-semibold text-lg tracking-tight mb-1">
{dict.modelConfig.configureProviders}
{config.providers.length === 0
? dict.modelConfig.noProvidersTitle
: dict.modelConfig.configureProviders}
</h3>
<p className="text-sm text-muted-foreground max-w-xs">
{dict.modelConfig.selectProviderHint}
{config.providers.length === 0
? dict.modelConfig.noProvidersHint
: dict.modelConfig.selectProviderHint}
</p>
</div>
)}
@@ -1508,7 +1525,7 @@ export function ModelConfigDialog({
</p>
</div>
</div>
</DialogContent>
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog
@@ -1588,6 +1605,6 @@ export function ModelConfigDialog({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
</>
)
}
+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>
) : (
@@ -53,6 +53,9 @@ interface ProviderCredentialsFieldsProps {
name?: string
baseUrl?: string
awsRegion?: string
// Bedrock: also offer an API key (the user dialog; the admin panel
// stores access keys only)
bedrockApiKey?: boolean
disabled?: boolean
// Update a plain text field
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
@@ -72,6 +75,7 @@ export function ProviderCredentialsFields({
name,
baseUrl,
awsRegion,
bedrockApiKey,
disabled,
onChange,
renderSecret,
@@ -112,6 +116,26 @@ export function ProviderCredentialsFields({
{provider === "bedrock" ? (
<>
{/* Bedrock API key: used instead of the access keys */}
{bedrockApiKey && (
<div className="space-y-2">
<Label
htmlFor="bedrock-api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.bedrockApiKey}
</Label>
{renderSecret({
field: "apiKey",
id: "bedrock-api-key",
})}
<p className="text-xs text-muted-foreground">
{dict.modelConfig.bedrockApiKeyHint}
</p>
</div>
)}
{/* AWS Access Key ID */}
<div className="space-y-2">
<Label
-50
View File
@@ -1,50 +0,0 @@
"use client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDictionary } from "@/hooks/use-dictionary"
interface ResetWarningModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onClear: () => void
}
export function ResetWarningModal({
open,
onOpenChange,
onClear,
}: ResetWarningModalProps) {
const dict = useDictionary()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{dict.dialogs.clearTitle}</DialogTitle>
<DialogDescription>
{dict.dialogs.clearDescription}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
{dict.common.cancel}
</Button>
<Button variant="destructive" onClick={onClear}>
{dict.dialogs.clearEverything}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+54 -41
View File
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -10,15 +10,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
import { cn } from "@/lib/utils"
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
@@ -39,10 +32,12 @@ export function SaveDialog({
const [filename, setFilename] = useState(defaultFilename)
const [format, setFormat] = useState<ExportFormat>("drawio")
// The default name each time the dialog opens; a chat title that comes
// while it is open (the chat is saved meanwhile) keeps what was typed
const wasOpenRef = useRef(false)
useEffect(() => {
if (open) {
setFilename(defaultFilename)
}
if (open && !wasOpenRef.current) setFilename(defaultFilename)
wasOpenRef.current = open
}, [open, defaultFilename])
const handleSave = () => {
@@ -52,6 +47,8 @@ export function SaveDialog({
}
const handleKeyDown = (e: React.KeyboardEvent) => {
// Enter that picks an IME candidate (Safari: keyCode 229)
if (e.nativeEvent.isComposing || e.keyCode === 229) return
if (e.key === "Enter") {
e.preventDefault()
handleSave()
@@ -62,21 +59,25 @@ export function SaveDialog({
{
value: "drawio" as const,
label: dict.save.formats.drawio,
hint: dict.save.hints.drawio,
extension: ".drawio",
},
{
value: "png" as const,
label: dict.save.formats.png,
hint: dict.save.hints.png,
extension: ".png",
},
{
value: "svg" as const,
label: dict.save.formats.svg,
hint: dict.save.hints.svg,
extension: ".svg",
},
{
value: "xmlsvg" as const,
label: dict.save.formats.xmlsvg,
hint: dict.save.hints.xmlsvg,
extension: ".drawio.svg",
},
]
@@ -85,7 +86,7 @@ export function SaveDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{dict.save.title}</DialogTitle>
<DialogDescription>
@@ -93,44 +94,54 @@ export function SaveDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{dict.save.format}
</label>
<Select
value={format}
onValueChange={(v) => setFormat(v as ExportFormat)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FORMAT_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div
role="radiogroup"
aria-label={dict.save.format}
className="grid grid-cols-2 gap-2"
>
{FORMAT_OPTIONS.map((opt) => (
// biome-ignore lint/a11y/useSemanticElements: styled radio tile
<button
key={opt.value}
type="button"
role="radio"
aria-checked={format === opt.value}
onClick={() => setFormat(opt.value)}
className={cn(
"rounded-lg border border-border p-3 text-left transition-colors hover:border-foreground/30",
format === opt.value &&
"border-foreground bg-accent",
)}
>
<span className="block text-[13px] font-medium text-foreground">
{opt.label}
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{opt.hint}
</span>
</button>
))}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
<label
htmlFor="export-filename"
className="text-sm font-medium"
>
{dict.save.filename}
</label>
<div className="flex items-stretch">
<Input
{/* One field: the name, then its extension */}
<div className="flex h-9 items-center overflow-hidden rounded-md border border-input focus-within:ring-2 focus-within:ring-ring/40">
<input
id="export-filename"
value={filename}
onChange={(e) => setFilename(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={dict.save.filenamePlaceholder}
autoFocus
onFocus={(e) => e.target.select()}
className="rounded-r-none border-r-0 focus-visible:z-10"
className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
/>
<span className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-sm text-muted-foreground font-mono">
<span className="shrink-0 pr-3 text-sm text-muted-foreground">
{currentFormat?.extension || ".drawio"}
</span>
</div>
@@ -143,7 +154,9 @@ export function SaveDialog({
>
{dict.common.cancel}
</Button>
<Button onClick={handleSave}>{dict.common.save}</Button>
<Button onClick={handleSave}>
{dict.workspace.export}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
File diff suppressed because it is too large Load Diff
-56
View File
@@ -1,56 +0,0 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+28
View File
@@ -0,0 +1,28 @@
"use client"
import { Toaster } from "sonner"
import { useSettingsStore } from "@/stores/settings-store"
/** The single toast container, mounted once at the root */
export function AppToaster() {
const isDark = useSettingsStore((s) => s.isDark)
return (
<Toaster
position="bottom-center"
// Show stacked toasts in full, so a later one never covers the
// button of an earlier one (e.g. "Continue without saving")
expand
theme={isDark ? "dark" : "light"}
offset={{ bottom: 20 }}
mobileOffset={{ bottom: 72 }}
toastOptions={{
duration: 2500,
style: { maxWidth: "480px" },
classNames: {
toast: "!rounded-xl !border-border !bg-card !text-foreground !shadow-pop",
description: "!text-muted-foreground",
},
}}
/>
)
}
+29
View File
@@ -0,0 +1,29 @@
"use client"
import { getAssetUrl } from "@/lib/base-path"
import { cn } from "@/lib/utils"
/** The app icon in a rounded tile; the white variant is used in dark mode */
export function BrandMark({ className }: { className?: string }) {
return (
<span
className={cn(
"relative inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-card",
className,
)}
>
{/* biome-ignore lint/performance/noImgElement: tiny static icon */}
<img
src={getAssetUrl("/favicon-192x192.png")}
alt=""
className="size-full object-contain dark:hidden"
/>
{/* biome-ignore lint/performance/noImgElement: tiny static icon */}
<img
src={getAssetUrl("/favicon-white.svg")}
alt=""
className="hidden size-5 object-contain dark:block"
/>
</span>
)
}
+80
View File
@@ -0,0 +1,80 @@
"use client"
import type React from "react"
import { forwardRef } from "react"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
interface IconButtonProps extends React.ComponentProps<"button"> {
label: string
/** Shown next to the label in the tooltip, e.g. "⌘B" */
shortcut?: string
active?: boolean
tooltipSide?: "top" | "bottom" | "left" | "right"
size?: "sm" | "md"
}
/** Square icon button with a tooltip; the tooltip text is also its label */
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
function IconButton(
{
label,
shortcut,
active = false,
tooltipSide = "bottom",
size = "md",
className,
children,
...props
},
ref,
) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
ref={ref}
type="button"
aria-label={label}
aria-pressed={active || undefined}
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors",
"hover:bg-accent hover:text-foreground",
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring",
"disabled:pointer-events-none disabled:opacity-35",
"[&_svg]:size-4 [&_svg]:stroke-[1.75]",
size === "md" ? "size-8" : "size-7",
// A tinted tile: solid ink is for the main action
active &&
"bg-interactive-active text-foreground hover:bg-interactive-active [&_svg]:stroke-2",
className,
)}
{...props}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent
side={tooltipSide}
className="flex items-center gap-2"
>
{label}
{shortcut && (
<kbd className="font-sans text-[11px] opacity-60">
{shortcut}
</kbd>
)}
</TooltipContent>
</Tooltip>
)
},
)
/** Thin vertical divider between toolbar groups */
export function ToolbarDivider() {
return <span className="mx-1 h-5 w-px shrink-0 bg-border" aria-hidden />
}
+403
View File
@@ -0,0 +1,403 @@
"use client"
import { MessageSquare, PanelRightOpen, Settings2, Shapes } from "lucide-react"
import { usePathname, useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import { CanvasStage } from "@/components/canvas/canvas-stage"
import {
ChatEngineProvider,
useChatEngine,
} from "@/components/chat/chat-engine"
import { ChatPanel } from "@/components/chat/chat-panel"
import { CompareDialog } from "@/components/chat/compare-dialog"
import { LobbyHero } from "@/components/chat/lobby-hero"
import { SaveDialog } from "@/components/save-dialog"
import { SettingsDialog } from "@/components/settings-dialog"
import { BrandMark } from "@/components/workspace/brand-mark"
import { IconButton } from "@/components/workspace/icon-button"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { usePanelWidth } from "@/hooks/use-panel-width"
import { setAppShortcutHandler } from "@/lib/drawio/editor-bridge"
import { i18n, type Locale } from "@/lib/i18n/config"
import { modKey } from "@/lib/platform"
import { STORAGE_KEYS } from "@/lib/storage"
import { cn, isRealDiagram } from "@/lib/utils"
import { useSettingsStore } from "@/stores/settings-store"
import { useUiStore } from "@/stores/ui-store"
import { useVersionsStore } from "@/stores/versions-store"
/** File types the "Open .drawio file" picker accepts */
const DIAGRAM_FILE_ACCEPT = ".drawio,.xml,.svg,application/xml,image/svg+xml"
const MOBILE_BREAKPOINT = 768
// Gap between the floating panel and the window edge
const PANEL_GAP = 12
function useIsMobile() {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const query = window.matchMedia(
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
)
const update = () => setIsMobile(query.matches)
update()
query.addEventListener("change", update)
return () => query.removeEventListener("change", update)
}, [])
return isMobile
}
/** Restore the language picked last time (the URL may say otherwise) */
function useSavedLocale() {
const router = useRouter()
const pathname = usePathname()
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEYS.locale)
if (!saved || !i18n.locales.includes(saved as Locale)) return
const parts = pathname.split("/").filter(Boolean)
if (parts[0] === saved) return
parts[0] = saved
const { search, hash } = window.location
router.replace(`/${parts.join("/")}${search}${hash}`)
}, [])
}
/** The diagram's title as a file name, or diagram-YYYY-MM-DD without one */
export function exportFilename(title: string | null): string {
const name = (title && title !== "New Chat" ? title : "")
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80)
// Local date (toISOString would give UTC)
return name || `diagram-${new Date().toLocaleDateString("sv-SE")}`
}
/** App name and settings button above the start screen */
function StartBar({
className,
nameClassName,
}: {
className: string
nameClassName: string
}) {
const dict = useDictionary()
const openSettings = useUiStore((s) => s.openSettings)
return (
<div className={className}>
<BrandMark className="size-6" />
<span
className={cn(
"text-[13px] font-semibold tracking-[-0.01em]",
nameClassName,
)}
>
Next AI Draw.io
</span>
<IconButton
label={dict.nav.settings}
onClick={() => openSettings()}
data-testid="settings-button"
>
<Settings2 />
</IconButton>
</div>
)
}
/** The whole editor: canvas, chat panel, dialogs */
export function Workspace() {
const hydrate = useSettingsStore((s) => s.hydrate)
useEffect(() => {
hydrate()
}, [hydrate])
useSavedLocale()
return (
<ChatEngineProvider>
<WorkspaceLayout />
</ChatEngineProvider>
)
}
function WorkspaceLayout() {
const dict = useDictionary()
const engine = useChatEngine()
const { chartXML, saveDiagramToFile } = useDiagram()
const isMobile = useIsMobile()
const panelWidth = usePanelWidth()
const {
panelOpen,
togglePanel,
setPanelOpen,
mobileView,
setMobileView,
focusComposer,
saveDialogOpen,
setSaveDialogOpen,
} = useUiStore()
const heroDismissed = useUiStore((s) => s.heroDismissed)
const setHeroDismissed = useUiStore((s) => s.setHeroDismissed)
const fileInputRef = useRef<HTMLInputElement>(null)
// Start screen: nothing said and nothing drawn yet. A new chat brings
// it back, an opened file or saved chat puts it away (the chat engine).
const showHero =
engine.isRestored &&
engine.messages.length === 0 &&
!engine.isResending &&
!isRealDiagram(chartXML) &&
!heroDismissed
// Phones: show the canvas while the AI draws and when a new version
// lands, so the result is visible; the chat tab gets a dot meanwhile
const isBusy = engine.isBusy
const versionCount = useVersionsStore((s) => s.versions.length)
const [chatUnread, setChatUnread] = useState(false)
const lastVersionCountRef = useRef(versionCount)
useEffect(() => {
if (isMobile && isBusy) setMobileView("canvas")
}, [isMobile, isBusy, setMobileView])
useEffect(() => {
const grew = versionCount > lastVersionCountRef.current
lastVersionCountRef.current = versionCount
if (isMobile && grew && engine.isRestored) {
setMobileView("canvas")
setChatUnread(true)
}
}, [versionCount, isMobile, engine.isRestored, setMobileView])
useEffect(() => {
if (mobileView === "chat") setChatUnread(false)
}, [mobileView])
// ⌘B toggles the panel; ⌘/ asks the AI about the selection
useEffect(() => {
const handle = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey)) return false
// ⌘⇧B stays with the browser (Chrome's bookmarks bar)
if (!event.shiftKey && (event.key === "b" || event.key === "B")) {
if (isMobile) return false
togglePanel()
return true
}
if (event.key === "/") {
setHeroDismissed(true)
focusComposer()
return true
}
return false
}
const onKeyDown = (event: KeyboardEvent) => {
if (handle(event)) event.preventDefault()
}
// draw.io takes focus when it (re)loads, e.g. after a language
// switch; Escape typed there must still close our dialogs.
// ⌘B stays with draw.io in the canvas (bold, and ⇧ for to back).
const handleInFrame = (event: KeyboardEvent) => {
if (event.key === "b" || event.key === "B") return false
if (event.key === "Escape") {
const ui = useUiStore.getState()
if (
ui.settingsOpen ||
ui.compareVersionId ||
ui.saveDialogOpen
) {
ui.setSettingsOpen(false)
ui.closeCompare()
ui.setSaveDialogOpen(false)
return true
}
}
return handle(event)
}
window.addEventListener("keydown", onKeyDown)
setAppShortcutHandler(handleInFrame)
return () => {
window.removeEventListener("keydown", onKeyDown)
setAppShortcutHandler(null)
}
}, [isMobile, togglePanel, focusComposer])
const openFile = () => fileInputRef.current?.click()
const dismissHero = () => {
setHeroDismissed(true)
setPanelOpen(true)
}
const panelVisible = !isMobile && !showHero && panelOpen
// The canvas edge slides with the panel as it opens or closes; a window
// resize or a dragged panel edge moves it at once. Set in the render
// that changes the edge, so the transition applies to that change.
const [sliding, setSliding] = useState(false)
const [lastPanelVisible, setLastPanelVisible] = useState(panelVisible)
if (lastPanelVisible !== panelVisible) {
setLastPanelVisible(panelVisible)
setSliding(true)
}
useEffect(() => {
if (!sliding) return
const timer = setTimeout(() => setSliding(false), 400)
return () => clearTimeout(timer)
}, [sliding])
// The panel floats on the right; the canvas ends where it begins
const canvasRight = panelVisible ? panelWidth + PANEL_GAP * 2 : 0
return (
<div
className="fixed inset-0 overflow-hidden bg-canvas"
data-testid="workspace"
>
{/* One canvas for both layouts: a new one would reload draw.io
and lose its undo history */}
<div className="absolute inset-0 flex flex-col">
<div className="relative min-h-0 flex-1">
<CanvasStage
className={
isMobile
? cn(
"inset-0",
mobileView !== "canvas" && "invisible",
)
: cn(
"inset-y-0 left-0",
sliding &&
"transition-[right] duration-300 ease-[var(--ease-out)]",
)
}
style={isMobile ? undefined : { right: canvasRight }}
hideOverlays={!isMobile && showHero}
/>
{isMobile ? (
mobileView === "chat" &&
(showHero ? (
<div className="absolute inset-0 overflow-y-auto bg-canvas">
<StartBar
className="flex items-center gap-2 px-4 pt-3"
nameClassName="mr-auto"
/>
<LobbyHero
compact
onOpenFile={openFile}
onDrawYourself={() => {
setHeroDismissed(true)
setMobileView("canvas")
}}
/>
</div>
) : (
<ChatPanel mobile onOpenFile={openFile} />
))
) : (
<>
{panelVisible && (
<ChatPanel onOpenFile={openFile} />
)}
{/* Below draw.io's toolbar (50 px), whose
right-hand buttons it would cover */}
{!showHero && !panelOpen && (
<div className="absolute top-[60px] right-3 z-20 flex items-center gap-1 rounded-xl bg-card p-1 shadow-float animate-fade-in">
<BrandMark className="mx-0.5 size-6" />
<IconButton
label={dict.nav.showPanel}
shortcut={`${modKey}B`}
onClick={() => setPanelOpen(true)}
data-testid="show-panel"
>
<PanelRightOpen />
</IconButton>
</div>
)}
{showHero && (
<>
<div className="absolute inset-0 z-10 overflow-y-auto bg-canvas">
<div className="flex min-h-full items-center">
<LobbyHero
onOpenFile={openFile}
onDrawYourself={dismissHero}
/>
</div>
</div>
<StartBar
className="absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-xl bg-card py-1 pr-1 pl-1.5 shadow-float"
nameClassName="pr-1"
/>
</>
)}
</>
)}
</div>
{isMobile && (
<nav className="flex shrink-0 items-center justify-center gap-1 border-t border-border bg-card px-3 pt-1.5 pb-[max(6px,env(safe-area-inset-bottom))]">
{(
[
{
view: "canvas",
label: dict.workspace.mobileCanvas,
icon: <Shapes />,
},
{
view: "chat",
label: dict.workspace.mobileChat,
icon: <MessageSquare />,
},
] as const
).map((item) => (
<button
key={item.view}
type="button"
onClick={() => setMobileView(item.view)}
aria-pressed={mobileView === item.view}
className={cn(
"flex h-10 flex-1 items-center justify-center gap-2 rounded-xl text-[13px] text-muted-foreground [&_svg]:size-4",
mobileView === item.view &&
"bg-accent font-medium text-foreground",
)}
>
{item.icon}
{item.label}
{item.view === "chat" &&
(isBusy || chatUnread) &&
mobileView !== "chat" && (
<span className="size-1.5 rounded-full bg-marker" />
)}
</button>
))}
</nav>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept={DIAGRAM_FILE_ACCEPT}
className="hidden"
onChange={(event) => {
// The opened diagram hides the start screen; a file
// that is not one leaves it
const file = event.target.files?.[0]
if (file) engine.openDiagramFile(file)
event.target.value = ""
}}
/>
<SettingsDialog />
<CompareDialog />
<SaveDialog
open={saveDialogOpen}
onOpenChange={setSaveDialogOpen}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
engine.langfuseSessionId,
dict.save.savedSuccessfully,
)
}
defaultFilename={exportFilename(engine.currentTitle)}
/>
</div>
)
}