"use client" import { MessageSquare, PanelRightOpen, Settings2, Shapes } from "lucide-react" import { usePathname, useRouter } from "next/navigation" import { useEffect, useRef, useState } from "react" import { CanvasStage, DIAGRAM_FILE_ACCEPT, } 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/settings-dialog" import { TooltipProvider } from "@/components/ui/tooltip" 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 { debugCellIds, debugView, setAppShortcutHandler, toggleShapesPanel, } 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 { useCanvasStore } from "@/stores/canvas-store" import { useSettingsStore } from "@/stores/settings-store" import { useUiStore } from "@/stores/ui-store" import { useVersionsStore } from "@/stores/versions-store" 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}`) }, []) } // Stores on window in development, for debugging and end-to-end tests if (typeof window !== "undefined" && process.env.NODE_ENV === "development") { ;(window as any).__naiStores = { canvas: useCanvasStore, versions: useVersionsStore, ui: useUiStore, settings: useSettingsStore, cellIds: debugCellIds, view: debugView, } } /** 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")}` } /** The whole editor: canvas, chat panel, dialogs */ export function Workspace() { const hydrate = useSettingsStore((s) => s.hydrate) useEffect(() => { hydrate() }, [hydrate]) useSavedLocale() return ( ) } function WorkspaceLayout() { const dict = useDictionary() const engine = useChatEngine() const { chartXML, saveDiagramToFile } = useDiagram() const isMobile = useIsMobile() const panelWidth = useSettingsStore((s) => s.panelWidth) const { panelOpen, togglePanel, setPanelOpen, mobileView, setMobileView, openSettings, focusComposer, saveDialogOpen, setSaveDialogOpen, } = useUiStore() const [heroDismissed, setHeroDismissed] = useState(false) const fileInputRef = useRef(null) // Start screen: nothing said and nothing drawn yet const showHero = engine.isRestored && engine.messages.length === 0 && !isRealDiagram(chartXML) && !heroDismissed // A new conversation brings the start screen back (a new chat gets a // new tracing id, also when it was empty before) const chatId = engine.langfuseSessionId useEffect(() => { setHeroDismissed(false) }, [chatId]) const messageCount = engine.messages.length const wasEmptyRef = useRef(true) useEffect(() => { if (messageCount > 0) wasEmptyRef.current = false else if (!wasEmptyRef.current) { wasEmptyRef.current = true setHeroDismissed(false) } }, [messageCount]) // 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; ⌘⇧K // shows the shape library useEffect(() => { const handle = (event: KeyboardEvent) => { if (!(event.metaKey || event.ctrlKey)) return false if (event.shiftKey && (event.key === "k" || event.key === "K")) { toggleShapesPanel() return true } if (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 panel floats on the right; the canvas ends where it begins const canvasRight = panelVisible ? panelWidth + PANEL_GAP * 2 : 0 return (
{/* One canvas for both layouts: a new one would reload draw.io and lose its undo history */}
{isMobile ? ( mobileView === "chat" && (showHero ? (
Next AI Draw.io openSettings()} data-testid="settings-button" >
{ setHeroDismissed(true) setMobileView("canvas") }} />
) : ( )) ) : ( <> {panelVisible && ( )} {!showHero && !panelOpen && (
setPanelOpen(true)} data-testid="show-panel" >
)} {showHero && ( <>
Next AI Draw.io openSettings()} data-testid="settings-button" >
)} )}
{isMobile && ( )}
{ const file = event.target.files?.[0] if (file) { setHeroDismissed(true) engine.openDiagramFile(file) } event.target.value = "" }} /> saveDiagramToFile( filename, format, engine.langfuseSessionId, dict.save.savedSuccessfully, ) } defaultFilename={exportFilename(engine.currentTitle)} />
) }