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
+117 -7
View File
@@ -14,18 +14,32 @@ import {
isIndexedDBAvailable,
migrateFromLocalStorage,
readSessionCount,
renameSession as renameSessionInDB,
type SessionMetadata,
type StoredMessage,
saveSession,
} from "@/lib/session-storage"
import { STORAGE_KEYS } from "@/lib/storage"
import {
type DiagramVersion,
versionsFromLegacyHistory,
} from "@/stores/versions-store"
export interface SessionData {
messages: StoredMessage[]
xmlSnapshots: [number, string][]
diagramXml: string
thumbnailDataUrl?: string
diagramHistory?: { svg: string; xml: string }[]
versions?: DiagramVersion[]
/** Title for a session created by this save (default: first message) */
title?: string
}
/** Versions of a stored session, converting the old history format */
export function getSessionVersions(
session: Pick<ChatSession, "versions" | "diagramHistory">,
): DiagramVersion[] {
return session.versions ?? versionsFromLegacyHistory(session.diagramHistory)
}
// Taken right before a save's data is read: the chat on screen then, and
@@ -57,7 +71,22 @@ export interface UseSessionManagerReturn {
refreshSessions: () => Promise<void>
clearCurrentSession: () => void
getChatGeneration: () => number
/** The chat on screen now, also right after a save created it */
getCurrentSessionId: () => string | null
getSaveTicket: () => SaveTicket
renameSession: (id: string, title: string) => Promise<void>
/**
* Run before the URL (browser back or forward) puts another chat on
* screen: the chat on screen is saved first, which the app's own ways
* of leaving it do themselves
*/
setBeforeUrlSwitch: (handler: (() => Promise<void>) | null) => void
/**
* Runs fn while the chat with this id is about to be put on screen: a
* save meanwhile that creates a session (over the chat limit) does not
* delete it as the oldest one
*/
whileOpening: <T>(id: string, fn: () => Promise<T>) => Promise<T>
}
// Reading the session list loads every stored session in full, and window
@@ -103,6 +132,31 @@ export function useSessionManager(
const saveSeqRef = useRef(0)
const savedSeqRef = useRef(0)
const beforeUrlSwitchRef = useRef<(() => Promise<void>) | null>(null)
// The chat about to be put on screen, while the one on screen is saved
const openingSessionRef = useRef<{ id: string } | null>(null)
const whileOpening = useCallback(
async <T>(id: string, fn: () => Promise<T>): Promise<T> => {
// Its own: another one opened meanwhile sets another
const opening = { id }
openingSessionRef.current = opening
try {
return await fn()
} finally {
if (openingSessionRef.current === opening) {
openingSessionRef.current = null
}
}
},
[],
)
const setBeforeUrlSwitch = useCallback(
(handler: (() => Promise<void>) | null) => {
beforeUrlSwitchRef.current = handler
},
[],
)
const changeChat = useCallback((session: ChatSession | null) => {
chatGenerationRef.current++
currentSessionRef.current = session
@@ -215,6 +269,19 @@ export function useSessionManager(
// Only update if the session is different from current
if (session && currentSessionRef.current?.id !== session.id) {
// The chat on screen is saved first. Meanwhile the URL
// may change again, or another chat come on screen.
await whileOpening(session.id, async () => {
try {
await beforeUrlSwitchRef.current?.()
} catch (error) {
console.warn("Failed to save the chat left:", error)
}
})
if (currentSequence !== urlChangeSequenceRef.current) {
return
}
if (generation !== chatGenerationRef.current) return
changeChat(session)
}
}
@@ -267,7 +334,7 @@ export function useSessionManager(
xmlSnapshots: session.xmlSnapshots,
diagramXml: session.diagramXml,
thumbnailDataUrl: session.thumbnailDataUrl,
diagramHistory: session.diagramHistory,
versions: getSessionVersions(session),
}
},
[currentSessionId, changeChat],
@@ -319,8 +386,8 @@ export function useSessionManager(
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl: data.thumbnailDataUrl,
diagramHistory: data.diagramHistory,
title: extractTitle(data.messages),
versions: data.versions,
title: data.title || extractTitle(data.messages),
}
// Without a stored session, keep no session id (it would end
// up in the URL and point to nothing after a reload)
@@ -329,7 +396,9 @@ export function useSessionManager(
return false
}
savedSeqRef.current = seq
await enforceSessionLimit()
await enforceSessionLimit(
() => openingSessionRef.current?.id,
)
if (stillOnScreen()) {
currentSessionRef.current = newSession
setCurrentSession(newSession)
@@ -348,8 +417,9 @@ export function useSessionManager(
thumbnailDataUrl:
data.thumbnailDataUrl ??
currentSession.thumbnailDataUrl,
diagramHistory:
data.diagramHistory ?? currentSession.diagramHistory,
versions:
data.versions ?? getSessionVersions(currentSession),
diagramHistory: undefined,
updatedAt: Date.now(),
// Update title if it's still default and we have messages
title:
@@ -403,6 +473,10 @@ export function useSessionManager(
}, [changeChat])
const getChatGeneration = useCallback(() => chatGenerationRef.current, [])
const getCurrentSessionId = useCallback(
() => currentSessionRef.current?.id ?? null,
[],
)
const getSaveTicket = useCallback(
(): SaveTicket => ({
@@ -412,6 +486,38 @@ export function useSessionManager(
[],
)
const renameSession = useCallback(
(id: string, title: string): Promise<void> => {
const trimmed = title.trim().slice(0, 100)
if (!trimmed) return Promise.resolve()
// In the save queue, so a save running now cannot put the old
// title back
const run = async () => {
const updated = await renameSessionInDB(id, trimmed)
// Also when it could not be stored (another tab deleted the
// chat): the next save of the chat on screen writes it
const current = currentSessionRef.current
if (current?.id === id) {
currentSessionRef.current = { ...current, title: trimmed }
setCurrentSession(currentSessionRef.current)
}
if (!updated) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return
}
setSessions((prev) =>
prev.map((s) =>
s.id === id ? { ...s, title: trimmed } : s,
),
)
}
const result = saveQueueRef.current.then(run)
saveQueueRef.current = result.catch(() => {})
return result
},
[dict],
)
return {
sessions,
currentSessionId,
@@ -424,6 +530,10 @@ export function useSessionManager(
refreshSessions,
clearCurrentSession,
getChatGeneration,
getCurrentSessionId,
getSaveTicket,
renameSession,
setBeforeUrlSwitch,
whileOpening,
}
}