diff --git a/apps/web/src/app/diagrams/[sessionId]/page.tsx b/apps/web/src/app/diagrams/[sessionId]/page.tsx index 6d765f1..5cd1424 100644 --- a/apps/web/src/app/diagrams/[sessionId]/page.tsx +++ b/apps/web/src/app/diagrams/[sessionId]/page.tsx @@ -1,6 +1,9 @@ "use client"; -import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; +import type { + BinaryFiles, + ExcalidrawImperativeAPI, +} from "@excalidraw/excalidraw/types"; import { api } from "@sketchi/backend/convex/_generated/api"; import { useAuth } from "@workos-inc/authkit-nextjs/components"; import { useMutation, useQuery } from "convex/react"; @@ -56,6 +59,62 @@ interface RunState { stopRequested: boolean; } +type StoredSceneFiles = Record; + +function normalizeSceneFiles( + files: BinaryFiles | StoredSceneFiles | null | undefined +): StoredSceneFiles | undefined { + if (!files) { + return undefined; + } + + if (Object.keys(files).length < 1) { + return undefined; + } + + return files as StoredSceneFiles; +} + +function createSceneFileFingerprint( + files: StoredSceneFiles | undefined +): string { + if (!files) { + return ""; + } + + return Object.entries(files) + .sort(([leftId], [rightId]) => leftId.localeCompare(rightId)) + .map(([fileId, file]) => { + const metadata = + file && typeof file === "object" + ? (file as { + created?: unknown; + mimeType?: unknown; + }) + : {}; + const created = + typeof metadata.created === "number" ? metadata.created : "na"; + const mimeType = + typeof metadata.mimeType === "string" ? metadata.mimeType : "unknown"; + return `${fileId}:${mimeType}:${created}`; + }) + .join("|"); +} + +function createSceneFingerprint(input: { + elements: readonly Record[]; + files?: BinaryFiles | StoredSceneFiles | null; +}): string { + const elementFingerprint = input.elements + .map( + (element) => + `${element.id}:${element.version}:${element.versionNonce}:${element.isDeleted ?? false}` + ) + .join("|"); + const files = normalizeSceneFiles(input.files); + return `${elementFingerprint}::${createSceneFileFingerprint(files)}`; +} + function createOptimisticUserMessage(input: { content: string; promptMessageId: string; @@ -130,10 +189,11 @@ export default function DiagramStudioPage() { const knownVersionRef = useRef(0); const appliedVersionRef = useRef(null); const isLocallyDirtyRef = useRef(false); - const lastElementsHashRef = useRef(""); + const lastSceneFingerprintRef = useRef(""); const pendingSceneRef = useRef<{ elements: readonly Record[]; appState: Record; + files?: StoredSceneFiles; } | null>(null); const previousRunStatusRef = useRef(null); @@ -218,6 +278,7 @@ export default function DiagramStudioPage() { async ( elements: readonly Record[], appState: Record, + files?: StoredSceneFiles, overrideVersion?: number ) => { if (!sessionId) { @@ -232,6 +293,7 @@ export default function DiagramStudioPage() { expectedVersion: overrideVersion ?? knownVersionRef.current, elements: elements as Record[], appState: sanitizeAppState(appState), + files, }); if (result.status === "success") { @@ -243,7 +305,7 @@ export default function DiagramStudioPage() { pendingSceneRef.current = null; } else if (result.status === "conflict") { isLocallyDirtyRef.current = true; - pendingSceneRef.current = { elements, appState }; + pendingSceneRef.current = { elements, appState, files }; setSaveState({ status: "conflict", serverVersion: result.latestSceneVersion, @@ -278,7 +340,8 @@ export default function DiagramStudioPage() { const handleChange = useCallback( ( elements: readonly Record[], - appState: Record + appState: Record, + files: BinaryFiles ) => { const nonDeleted = elements.filter( (element) => element.isDeleted !== true @@ -289,16 +352,15 @@ export default function DiagramStudioPage() { return; } - const hash = elements - .map( - (element) => - `${element.id}:${element.version}:${element.versionNonce}:${element.isDeleted ?? false}` - ) - .join("|"); - if (hash === lastElementsHashRef.current) { + const normalizedFiles = normalizeSceneFiles(files); + const fingerprint = createSceneFingerprint({ + elements, + files: normalizedFiles, + }); + if (fingerprint === lastSceneFingerprintRef.current) { return; } - lastElementsHashRef.current = hash; + lastSceneFingerprintRef.current = fingerprint; isLocallyDirtyRef.current = true; if (autosaveTimeoutRef.current) { @@ -306,7 +368,7 @@ export default function DiagramStudioPage() { } autosaveTimeoutRef.current = setTimeout(() => { - saveScene(elements, appState).catch(() => undefined); + saveScene(elements, appState, normalizedFiles).catch(() => undefined); }, AUTOSAVE_DELAY_MS); }, [autosaveDisabled, isProcessing, saveScene] @@ -316,6 +378,7 @@ export default function DiagramStudioPage() { (input: { elements: readonly Record[]; appState: Record; + files?: StoredSceneFiles; version: number; }) => { if (!excalidrawApi) { @@ -323,6 +386,13 @@ export default function DiagramStudioPage() { } suppressOnChangeRef.current = true; + excalidrawApi.resetScene({ resetLoadingState: false }); + const files = Object.values(input.files ?? {}); + if (files.length > 0) { + excalidrawApi.addFiles( + files as Parameters[0] + ); + } excalidrawApi.updateScene({ elements: input.elements as unknown as Parameters< typeof excalidrawApi.updateScene @@ -332,13 +402,10 @@ export default function DiagramStudioPage() { >[0]["appState"], }); - const hash = input.elements - .map( - (element) => - `${element.id}:${element.version}:${element.versionNonce}:${element.isDeleted ?? false}` - ) - .join("|"); - lastElementsHashRef.current = hash; + lastSceneFingerprintRef.current = createSceneFingerprint({ + elements: input.elements, + files: input.files, + }); const nonDeleted = input.elements.filter( (element) => element.isDeleted !== true ).length; @@ -366,6 +433,7 @@ export default function DiagramStudioPage() { unknown >[], appState: session.latestScene.appState as Record, + files: session.latestScene.files as StoredSceneFiles | undefined, version: session.latestSceneVersion, }); } else { @@ -407,6 +475,7 @@ export default function DiagramStudioPage() { appState: sanitizeAppState( excalidrawApi.getAppState() as Record ), + files: normalizeSceneFiles(excalidrawApi.getFiles()), }; knownVersionRef.current = version; setSaveState({ @@ -422,6 +491,7 @@ export default function DiagramStudioPage() { unknown >[], appState: session.latestScene.appState as Record, + files: session.latestScene.files as StoredSceneFiles | undefined, version, }); }, [ @@ -442,6 +512,7 @@ export default function DiagramStudioPage() { unknown >[], appState: session.latestScene.appState as Record, + files: session.latestScene.files as StoredSceneFiles | undefined, version: session.latestSceneVersion, }); @@ -458,6 +529,7 @@ export default function DiagramStudioPage() { await saveScene( pendingSceneRef.current.elements, pendingSceneRef.current.appState, + pendingSceneRef.current.files, saveState.serverVersion ); }, [saveScene, saveState]); @@ -701,9 +773,9 @@ export default function DiagramStudioPage() {
+ saveScene(elements, appState, files) + } suppressOnChangeRef={suppressOnChangeRef} />
@@ -758,6 +830,9 @@ export default function DiagramStudioPage() { string, unknown >, + files: session.latestScene.files as + | StoredSceneFiles + | undefined, } : null } diff --git a/apps/web/src/app/diagrams/page.tsx b/apps/web/src/app/diagrams/page.tsx index 51657d5..fa106cb 100644 --- a/apps/web/src/app/diagrams/page.tsx +++ b/apps/web/src/app/diagrams/page.tsx @@ -28,6 +28,7 @@ type SessionSource = "opencode" | "sketchi"; interface SessionPreview { appState: Record; elements: Record[]; + files?: Record; } interface CloudDiagram { diff --git a/apps/web/src/components/diagram-studio/chat-sidebar.tsx b/apps/web/src/components/diagram-studio/chat-sidebar.tsx index 6e4a7f7..4cab1ef 100644 --- a/apps/web/src/components/diagram-studio/chat-sidebar.tsx +++ b/apps/web/src/components/diagram-studio/chat-sidebar.tsx @@ -4,6 +4,8 @@ import { AlertTriangle, ArrowDown, Loader2, + PanelRightClose, + PanelRightOpen, Send, Square, Wrench, @@ -21,6 +23,7 @@ import { import { Button } from "@/components/ui/button"; const DRAFT_KEY_PREFIX = "sketchi.diagramDraft.v1"; +const SIDEBAR_COLLAPSED_KEY = "sketchi.diagramChatSidebarCollapsed.v1"; const AUTO_SCROLL_BOTTOM_OFFSET_PX = 32; type RunStatus = @@ -153,6 +156,7 @@ export function ChatSidebar({ }: ChatSidebarProps) { const [input, setInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + const [isCollapsed, setIsCollapsed] = useState(false); const [showScrollToLatest, setShowScrollToLatest] = useState(false); const isCanvasEmpty = nonDeletedElementCount === 0; @@ -172,6 +176,26 @@ export function ChatSidebar({ return `${messages.length}:${last.messageId}:${last.updatedAt}:${last.content.length}:${last.status ?? ""}`; }, [messages]); + useEffect(() => { + try { + setIsCollapsed(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1"); + } catch { + // ignore localStorage failures + } + }, []); + + useEffect(() => { + try { + if (isCollapsed) { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, "1"); + } else { + localStorage.removeItem(SIDEBAR_COLLAPSED_KEY); + } + } catch { + // ignore localStorage failures + } + }, [isCollapsed]); + useEffect(() => { const key = draftStorageKey(sessionId); try { @@ -300,13 +324,58 @@ export function ChatSidebar({ }); }, []); + if (isCollapsed) { + return ( + + ); + } + return (