Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 98 additions & 23 deletions apps/web/src/app/diagrams/[sessionId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -56,6 +59,62 @@ interface RunState {
stopRequested: boolean;
}

type StoredSceneFiles = Record<string, unknown>;

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<string, unknown>[];
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)}`;
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

function createOptimisticUserMessage(input: {
content: string;
promptMessageId: string;
Expand Down Expand Up @@ -130,10 +189,11 @@ export default function DiagramStudioPage() {
const knownVersionRef = useRef(0);
const appliedVersionRef = useRef<number | null>(null);
const isLocallyDirtyRef = useRef(false);
const lastElementsHashRef = useRef("");
const lastSceneFingerprintRef = useRef("");
const pendingSceneRef = useRef<{
elements: readonly Record<string, unknown>[];
appState: Record<string, unknown>;
files?: StoredSceneFiles;
} | null>(null);
const previousRunStatusRef = useRef<RunStatus | null>(null);

Expand Down Expand Up @@ -218,6 +278,7 @@ export default function DiagramStudioPage() {
async (
elements: readonly Record<string, unknown>[],
appState: Record<string, unknown>,
files?: StoredSceneFiles,
overrideVersion?: number
) => {
if (!sessionId) {
Expand All @@ -232,6 +293,7 @@ export default function DiagramStudioPage() {
expectedVersion: overrideVersion ?? knownVersionRef.current,
elements: elements as Record<string, unknown>[],
appState: sanitizeAppState(appState),
files,
});

if (result.status === "success") {
Expand All @@ -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,
Expand Down Expand Up @@ -278,7 +340,8 @@ export default function DiagramStudioPage() {
const handleChange = useCallback(
(
elements: readonly Record<string, unknown>[],
appState: Record<string, unknown>
appState: Record<string, unknown>,
files: BinaryFiles
) => {
const nonDeleted = elements.filter(
(element) => element.isDeleted !== true
Expand All @@ -289,24 +352,23 @@ 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) {
clearTimeout(autosaveTimeoutRef.current);
}

autosaveTimeoutRef.current = setTimeout(() => {
saveScene(elements, appState).catch(() => undefined);
saveScene(elements, appState, normalizedFiles).catch(() => undefined);
}, AUTOSAVE_DELAY_MS);
},
[autosaveDisabled, isProcessing, saveScene]
Expand All @@ -316,13 +378,21 @@ export default function DiagramStudioPage() {
(input: {
elements: readonly Record<string, unknown>[];
appState: Record<string, unknown>;
files?: StoredSceneFiles;
version: number;
}) => {
if (!excalidrawApi) {
return;
}

suppressOnChangeRef.current = true;
excalidrawApi.resetScene({ resetLoadingState: false });
const files = Object.values(input.files ?? {});
if (files.length > 0) {
excalidrawApi.addFiles(
files as Parameters<typeof excalidrawApi.addFiles>[0]
);
}
excalidrawApi.updateScene({
elements: input.elements as unknown as Parameters<
typeof excalidrawApi.updateScene
Expand All @@ -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;
Expand Down Expand Up @@ -366,6 +433,7 @@ export default function DiagramStudioPage() {
unknown
>[],
appState: session.latestScene.appState as Record<string, unknown>,
files: session.latestScene.files as StoredSceneFiles | undefined,
version: session.latestSceneVersion,
});
} else {
Expand Down Expand Up @@ -407,6 +475,7 @@ export default function DiagramStudioPage() {
appState: sanitizeAppState(
excalidrawApi.getAppState() as Record<string, unknown>
),
files: normalizeSceneFiles(excalidrawApi.getFiles()),
};
knownVersionRef.current = version;
setSaveState({
Expand All @@ -422,6 +491,7 @@ export default function DiagramStudioPage() {
unknown
>[],
appState: session.latestScene.appState as Record<string, unknown>,
files: session.latestScene.files as StoredSceneFiles | undefined,
version,
});
}, [
Expand All @@ -442,6 +512,7 @@ export default function DiagramStudioPage() {
unknown
>[],
appState: session.latestScene.appState as Record<string, unknown>,
files: session.latestScene.files as StoredSceneFiles | undefined,
version: session.latestSceneVersion,
});

Expand All @@ -458,6 +529,7 @@ export default function DiagramStudioPage() {
await saveScene(
pendingSceneRef.current.elements,
pendingSceneRef.current.appState,
pendingSceneRef.current.files,
saveState.serverVersion
);
}, [saveScene, saveState]);
Expand Down Expand Up @@ -701,9 +773,9 @@ export default function DiagramStudioPage() {
<div className="ml-auto">
<ImportExportToolbar
excalidrawApi={excalidrawApi}
knownVersionRef={knownVersionRef}
saveScene={saveScene}
sessionId={sessionId}
saveScene={(elements, appState, files) =>
saveScene(elements, appState, files)
}
suppressOnChangeRef={suppressOnChangeRef}
/>
</div>
Expand Down Expand Up @@ -758,6 +830,9 @@ export default function DiagramStudioPage() {
string,
unknown
>,
files: session.latestScene.files as
| StoredSceneFiles
| undefined,
}
: null
}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/diagrams/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type SessionSource = "opencode" | "sketchi";
interface SessionPreview {
appState: Record<string, unknown>;
elements: Record<string, unknown>[];
files?: Record<string, unknown>;
}

interface CloudDiagram {
Expand Down
71 changes: 70 additions & 1 deletion apps/web/src/components/diagram-studio/chat-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
AlertTriangle,
ArrowDown,
Loader2,
PanelRightClose,
PanelRightOpen,
Send,
Square,
Wrench,
Expand All @@ -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 =
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -300,13 +324,58 @@ export function ChatSidebar({
});
}, []);

if (isCollapsed) {
return (
<aside
className="flex h-full w-14 min-w-14 flex-col items-center border-l bg-background"
data-testid="diagram-chat-sidebar"
>
<div className="flex w-full justify-center border-b px-2 py-3">
<Button
aria-label="Expand AI sidebar"
onClick={() => setIsCollapsed(false)}
size="icon-sm"
title="Expand AI sidebar"
type="button"
variant="ghost"
>
<PanelRightOpen className="size-4" />
</Button>
</div>

<div className="flex flex-1 flex-col items-center justify-center gap-3 text-muted-foreground">
{getStatusIcon(activeStatus, runActive)}
<span className="-rotate-90 select-none font-medium text-[10px] uppercase tracking-[0.24em]">
AI
</span>
{showCompletionPulse ? (
<span className="size-2 rounded-full bg-emerald-500" />
) : null}
</div>
</aside>
);
}

return (
<aside
className="flex h-full w-96 min-w-80 max-w-[32rem] flex-col border-l bg-background"
data-testid="diagram-chat-sidebar"
>
<header className="border-b px-4 py-3" data-testid="diagram-chat-header">
<header
className="flex items-center justify-between gap-2 border-b px-4 py-3"
data-testid="diagram-chat-header"
>
<h2 className="font-semibold text-sm">AI Assistant</h2>
<Button
aria-label="Collapse AI sidebar"
onClick={() => setIsCollapsed(true)}
size="icon-sm"
title="Collapse AI sidebar"
type="button"
variant="ghost"
>
<PanelRightClose className="size-4" />
</Button>
</header>

{!isCanvasEmpty && (
Expand Down
Loading
Loading