([]);
useEffect(() => {
- setRecents(readRecents());
+ setLocalRecents(readDiagramRecents());
}, []);
- const handleCreate = async () => {
+ const cloudDiagrams = useMemo(
+ () => asCloudDiagramArray(sessionsRaw ?? []),
+ [sessionsRaw]
+ );
+
+ const cards = useMemo(
+ () => mergeCards({ cloudDiagrams, localRecents }),
+ [cloudDiagrams, localRecents]
+ );
+ const visibleCards = useMemo(
+ () => (hideEmpty ? cards.filter((card) => !isEmptyCard(card)) : cards),
+ [cards, hideEmpty]
+ );
+ const hiddenEmptyCount = cards.length - visibleCards.length;
+ const onlyEmptyHidden =
+ hideEmpty && cards.length > 0 && visibleCards.length < 1;
+
+ const handleCreate = useCallback(async () => {
if (isCreating) {
return;
}
- setIsCreating(true);
+ setIsCreating(true);
try {
- const { sessionId } = await createSession();
+ const { sessionId } = await createSession({ source: "sketchi" });
+ addDiagramRecent(sessionId);
router.push(`/diagrams/${sessionId}` as never);
} catch (error) {
const message =
@@ -133,171 +199,163 @@ export default function DiagramsPage() {
} finally {
setIsCreating(false);
}
- };
+ }, [createSession, isCreating, router]);
+
+ const handleSaveRename = useCallback(
+ async (sessionId: string) => {
+ const nextTitle = editingTitle.trim();
+ if (!nextTitle) {
+ toast.error("Title cannot be empty.");
+ return;
+ }
- const handleRemoveRecent = useCallback(
- (sessionId: string) => {
- const next = recents.filter((r) => r.sessionId !== sessionId);
- setRecents(next);
- writeRecents(next);
+ setIsSavingTitle(true);
+ try {
+ await renameSession({ sessionId, title: nextTitle });
+ setEditingSessionId(null);
+ setEditingTitle("");
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Failed to rename diagram.";
+ toast.error(message);
+ } finally {
+ setIsSavingTitle(false);
+ }
},
- [recents]
+ [editingTitle, renameSession]
);
- const handleClearAll = useCallback(() => {
- setRecents([]);
- writeRecents([]);
+ const handleStartRename = useCallback((item: DiagramListCard) => {
+ if (item.localOnly) {
+ return;
+ }
+
+ setEditingSessionId(item.sessionId);
+ setEditingTitle(item.title);
}, []);
- const hasRecents = recents.length > 0;
+ const handleCancelRename = useCallback(() => {
+ setEditingSessionId(null);
+ setEditingTitle("");
+ }, []);
- return (
-
-
-
- AI Diagram Studio
-
-
- Create, restructure, and share diagrams on a collaborative Excalidraw
- canvas. Each session auto-saves and produces a unique URL you can
- share with anyone.
-
-
+ const handleOpen = useCallback((sessionId: string) => {
+ addDiagramRecent(sessionId);
+ }, []);
-
- {FEATURES.map((f) => (
-
-
- {f.icon}
-
- {f.label}
-
- ))}
-
-
-
-
-
-
-
Start a new diagram
-
- Opens an Excalidraw canvas with AI restructure, autosave, and
- import/export.
-
-
+ const handleClearLocalRecents = useCallback(() => {
+ clearDiagramRecents();
+ setLocalRecents([]);
+ }, []);
+
+ const handleRemoveLocalRecent = useCallback((sessionId: string) => {
+ const next = removeDiagramRecent(sessionId);
+ setLocalRecents(next);
+ }, []);
+
+ return (
+
+
-
-
-
Recent diagrams
- {hasRecents && (
-
+
+
All diagrams
+
+
-
- Clear all
-
- )}
-
+
{
+ setHideEmpty(checked === true);
+ }}
+ />
+ Hide empty
+
-
-
-
- Session URLs are capability tokens and grant edit access to anyone
- who has them. Share links carefully.
-
+ {localRecents.length > 0 ? (
+
+
+ Clear local recents
+
+ ) : null}
+
- {hasRecents ? (
-
- {recents.map((r) => (
- 0 ? (
+
+ {visibleCards.map((item) => (
+
))}
) : (
- No recent diagrams. Create one to get started.
+ {onlyEmptyHidden ? "No non-empty diagrams." : "No diagrams yet."}
+ {onlyEmptyHidden ? (
+
setHideEmpty(false)}
+ size="xs"
+ type="button"
+ variant="outline"
+ >
+ Show {hiddenEmptyCount} empty diagram
+ {hiddenEmptyCount === 1 ? "" : "s"}
+
+ ) : null}
)}
);
}
-
-function RecentItem({
- recent,
- onRemove,
-}: {
- recent: RecentDiagram;
- onRemove: (sessionId: string) => void;
-}) {
- const { sessionId, visitedAt } = recent;
- const truncatedId = useMemo(() => {
- return sessionId.length > 8 ? `${sessionId.slice(0, 8)}...` : sessionId;
- }, [sessionId]);
-
- const timeAgo = useMemo(() => relativeTime(visitedAt), [visitedAt]);
-
- return (
-
-
-
-
- {truncatedId}
- {timeAgo}
-
-
- {
- e.preventDefault();
- onRemove(sessionId);
- }}
- size="icon-xs"
- type="button"
- variant="ghost"
- >
-
-
-
- );
-}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index e87fa92..199e909 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -17,6 +17,7 @@ interface FeatureCardProps {
dark?: string;
alt: string;
};
+ showStatus?: boolean;
status: FeatureStatus;
statusLabel?: string;
title: string;
@@ -81,6 +82,7 @@ function FeatureCardContent({
screenshot,
isClickable,
isExternal,
+ showStatus = true,
}: FeatureCardProps & { isClickable: boolean; isExternal: boolean }) {
return (
<>
@@ -114,7 +116,9 @@ function FeatureCardContent({
{title}
-
+ {showStatus ? (
+
+ ) : null}
@@ -190,6 +194,7 @@ export default function Home() {
description:
"Transform SVG icons into hand-drawn Excalidraw assets. Upload, customize styles, and export production-ready .excalidrawlib files.",
status: "available",
+ showStatus: false,
href: "/library-generator",
icon: ,
screenshot: {
@@ -202,6 +207,7 @@ export default function Home() {
description:
"Convert natural language into flowcharts, architecture diagrams, and more. Powered by AI with automatic layout and hand-drawn aesthetics.",
status: "alpha",
+ showStatus: false,
href: "/diagrams",
icon: ,
screenshot: {
diff --git a/apps/web/src/components/diagram-studio/diagram-list-item.tsx b/apps/web/src/components/diagram-studio/diagram-list-item.tsx
new file mode 100644
index 0000000..b0d4cff
--- /dev/null
+++ b/apps/web/src/components/diagram-studio/diagram-list-item.tsx
@@ -0,0 +1,349 @@
+"use client";
+
+import { CalendarClock, Check, Clock, Pencil, X } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+
+import { DiagramScenePreview } from "@/components/diagram-studio/diagram-scene-preview";
+import { Button } from "@/components/ui/button";
+
+export type DiagramListSource = "local" | "opencode" | "sketchi";
+
+export interface DiagramListCard {
+ context: string | null;
+ createdAt: number | null;
+ diagramType: string | null;
+ hasRenderableContent: boolean | null;
+ hasScene: boolean;
+ latestSceneVersion: number | null;
+ localOnly: boolean;
+ previewScene: {
+ appState: Record;
+ elements: Record[];
+ } | null;
+ sessionId: string;
+ source: DiagramListSource;
+ title: string;
+ updatedAt: number | null;
+ visitedAt: number | null;
+}
+
+interface DiagramListItemProps {
+ editingTitle: string;
+ isEditing: boolean;
+ isSavingTitle: boolean;
+ item: DiagramListCard;
+ onCancelRename: () => void;
+ onEditingTitleChange: (value: string) => void;
+ onOpen: (sessionId: string) => void;
+ onRemoveLocalRecent: (sessionId: string) => void;
+ onSaveRename: (sessionId: string) => Promise;
+ onStartRename: (item: DiagramListCard) => void;
+}
+
+const DIAGRAM_TYPE_SPLIT_PATTERN = /[-_\s]+/;
+
+function relativeTime(timestamp: number): string {
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
+ if (seconds < 60) {
+ return "just now";
+ }
+
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) {
+ return `${minutes}m ago`;
+ }
+
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) {
+ return `${hours}h ago`;
+ }
+
+ const days = Math.floor(hours / 24);
+ if (days < 30) {
+ return `${days}d ago`;
+ }
+
+ return `${Math.floor(days / 30)}mo ago`;
+}
+
+function formatTimestamp(timestamp: number | null): string | null {
+ if (!timestamp) {
+ return null;
+ }
+
+ return new Intl.DateTimeFormat(undefined, {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ }).format(new Date(timestamp));
+}
+
+function formatDiagramType(value: string | null): string | null {
+ if (!value) {
+ return null;
+ }
+
+ return value
+ .split(DIAGRAM_TYPE_SPLIT_PATTERN)
+ .filter(Boolean)
+ .map((part) => part[0]?.toUpperCase() + part.slice(1).toLowerCase())
+ .join(" ");
+}
+
+function getSourceLabel(source: DiagramListSource): string {
+ if (source === "opencode") {
+ return "OpenCode";
+ }
+ if (source === "local") {
+ return "Local";
+ }
+ return "Sketchi";
+}
+
+function getUpdatedLabel(item: DiagramListCard): string | null {
+ if (item.updatedAt) {
+ return relativeTime(item.updatedAt);
+ }
+ if (item.visitedAt) {
+ return relativeTime(item.visitedAt);
+ }
+ return null;
+}
+
+function getContextLabel(item: DiagramListCard): string {
+ if (item.context) {
+ return item.context;
+ }
+ if (item.localOnly) {
+ return "Local recent from this browser";
+ }
+ return "No prompt context yet";
+}
+
+function SourceBadge({ source }: { source: DiagramListSource }) {
+ if (source === "opencode") {
+ return (
+
+
+
+ OpenCode
+
+ );
+ }
+
+ if (source === "local") {
+ return (
+
+
+ Local
+
+ );
+ }
+
+ return (
+
+
+ Sketchi
+
+ );
+}
+
+function DiagramTitleEditor({
+ isSavingTitle,
+ onCancel,
+ onChange,
+ onSave,
+ value,
+}: {
+ isSavingTitle: boolean;
+ onCancel: () => void;
+ onChange: (value: string) => void;
+ onSave: () => Promise;
+ value: string;
+}) {
+ return (
+
+ onChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ onSave().catch(() => undefined);
+ }
+ if (event.key === "Escape") {
+ event.preventDefault();
+ onCancel();
+ }
+ }}
+ value={value}
+ />
+ {
+ event.preventDefault();
+ onSave().catch(() => undefined);
+ }}
+ size="icon-xs"
+ type="button"
+ variant="ghost"
+ >
+
+
+ {
+ event.preventDefault();
+ onCancel();
+ }}
+ size="icon-xs"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+ );
+}
+
+export function DiagramListItem({
+ editingTitle,
+ isEditing,
+ isSavingTitle,
+ item,
+ onCancelRename,
+ onEditingTitleChange,
+ onOpen,
+ onRemoveLocalRecent,
+ onSaveRename,
+ onStartRename,
+}: DiagramListItemProps) {
+ const canRename = !(item.localOnly || isEditing);
+ const contextLabel = getContextLabel(item);
+ const createdLabel = formatTimestamp(item.createdAt);
+ const originLabel = getSourceLabel(item.source);
+ const typeLabel = formatDiagramType(item.diagramType);
+ const updatedLabel = getUpdatedLabel(item);
+
+ return (
+
+
+
+
+
+ {isEditing ? (
+
onSaveRename(item.sessionId)}
+ value={editingTitle}
+ />
+ ) : (
+
+ {item.title}
+
+ )}
+
+
+
+ {typeLabel ? (
+
+ {typeLabel}
+
+ ) : null}
+
+
+
+ {canRename ? (
+
{
+ event.preventDefault();
+ onStartRename(item);
+ }}
+ size="icon-xs"
+ type="button"
+ variant="ghost"
+ >
+
+
+ ) : null}
+
+
+
+ {contextLabel}
+
+
+
+ {updatedLabel ? (
+
+
+ Updated {updatedLabel}
+
+ ) : null}
+ {createdLabel ? (
+
+
+ Created {createdLabel}
+
+ ) : null}
+ {item.latestSceneVersion !== null ? (
+ v{item.latestSceneVersion}
+ ) : null}
+ {originLabel}
+
+
+
+ onOpen(item.sessionId)}
+ >
+ Open diagram
+
+
+ {item.localOnly ? (
+ {
+ event.preventDefault();
+ onRemoveLocalRecent(item.sessionId);
+ }}
+ size="icon-xs"
+ type="button"
+ variant="ghost"
+ >
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/diagram-studio/diagram-scene-preview.tsx b/apps/web/src/components/diagram-studio/diagram-scene-preview.tsx
new file mode 100644
index 0000000..8794905
--- /dev/null
+++ b/apps/web/src/components/diagram-studio/diagram-scene-preview.tsx
@@ -0,0 +1,303 @@
+"use client";
+
+import { Image as ImageIcon } from "lucide-react";
+import { useMemo } from "react";
+
+interface ScenePreview {
+ appState: Record;
+ elements: Record[];
+}
+
+interface DiagramScenePreviewProps {
+ scene: ScenePreview | null;
+}
+
+interface Transform {
+ offsetX: number;
+ offsetY: number;
+ scale: number;
+}
+
+const CANVAS_HEIGHT = 180;
+const CANVAS_WIDTH = 320;
+const MAX_RENDERED_ELEMENTS = 120;
+const PADDING = 12;
+
+function asNumber(value: unknown): number | null {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+ return value;
+}
+
+function asString(value: unknown): string | null {
+ if (typeof value !== "string") {
+ return null;
+ }
+ const normalized = value.trim();
+ return normalized.length > 0 ? normalized : null;
+}
+
+function getElementBounds(element: Record) {
+ const x = asNumber(element.x);
+ const y = asNumber(element.y);
+ if (x === null || y === null) {
+ return null;
+ }
+
+ const points = Array.isArray(element.points)
+ ? (element.points as unknown[])
+ : null;
+ if (points && points.length > 1) {
+ let minX = x;
+ let minY = y;
+ let maxX = x;
+ let maxY = y;
+
+ for (const point of points) {
+ if (!Array.isArray(point) || point.length < 2) {
+ continue;
+ }
+
+ const pointX = asNumber(point[0]);
+ const pointY = asNumber(point[1]);
+ if (pointX === null || pointY === null) {
+ continue;
+ }
+
+ minX = Math.min(minX, x + pointX);
+ minY = Math.min(minY, y + pointY);
+ maxX = Math.max(maxX, x + pointX);
+ maxY = Math.max(maxY, y + pointY);
+ }
+
+ return { maxX, maxY, minX, minY };
+ }
+
+ const width = asNumber(element.width) ?? 0;
+ const height = asNumber(element.height) ?? 0;
+ return {
+ maxX: Math.max(x, x + width),
+ maxY: Math.max(y, y + height),
+ minX: Math.min(x, x + width),
+ minY: Math.min(y, y + height),
+ };
+}
+
+function createTransform(
+ elements: Record[]
+): Transform | null {
+ const bounds = elements
+ .map((element) => getElementBounds(element))
+ .filter((bound): bound is NonNullable => bound !== null);
+
+ if (bounds.length === 0) {
+ return null;
+ }
+
+ const minX = Math.min(...bounds.map((bound) => bound.minX));
+ const minY = Math.min(...bounds.map((bound) => bound.minY));
+ const maxX = Math.max(...bounds.map((bound) => bound.maxX));
+ const maxY = Math.max(...bounds.map((bound) => bound.maxY));
+
+ const worldWidth = Math.max(1, maxX - minX);
+ const worldHeight = Math.max(1, maxY - minY);
+ const scale = Math.min(
+ (CANVAS_WIDTH - PADDING * 2) / worldWidth,
+ (CANVAS_HEIGHT - PADDING * 2) / worldHeight
+ );
+
+ const contentWidth = worldWidth * scale;
+ const contentHeight = worldHeight * scale;
+ return {
+ offsetX: (CANVAS_WIDTH - contentWidth) / 2 - minX * scale,
+ offsetY: (CANVAS_HEIGHT - contentHeight) / 2 - minY * scale,
+ scale,
+ };
+}
+
+function getElementKey(
+ element: Record,
+ index: number
+): string {
+ const elementId = asString(element.id);
+ if (elementId) {
+ return elementId;
+ }
+ return `element-${index}-${asString(element.type) ?? "shape"}`;
+}
+
+function renderPolyline(input: {
+ element: Record;
+ key: string;
+ opacity: number;
+ strokeColor: string;
+ strokeWidth: number;
+ toX: (value: number) => number;
+ toY: (value: number) => number;
+}) {
+ const x = asNumber(input.element.x);
+ const y = asNumber(input.element.y);
+ if (x === null || y === null) {
+ return null;
+ }
+
+ const points = Array.isArray(input.element.points)
+ ? (input.element.points as unknown[])
+ : [];
+ const mappedPoints = points
+ .map((point) => {
+ if (!Array.isArray(point) || point.length < 2) {
+ return null;
+ }
+
+ const pointX = asNumber(point[0]);
+ const pointY = asNumber(point[1]);
+ if (pointX === null || pointY === null) {
+ return null;
+ }
+
+ return `${input.toX(x + pointX)},${input.toY(y + pointY)}`;
+ })
+ .filter((point): point is string => point !== null);
+
+ if (mappedPoints.length < 2) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+function renderRectangle(input: {
+ element: Record;
+ fill: string;
+ key: string;
+ opacity: number;
+ strokeColor: string;
+ strokeWidth: number;
+ transform: Transform;
+}) {
+ const x = asNumber(input.element.x);
+ const y = asNumber(input.element.y);
+ if (x === null || y === null) {
+ return null;
+ }
+
+ const width = Math.max(1, asNumber(input.element.width) ?? 1);
+ const height = Math.max(1, asNumber(input.element.height) ?? 1);
+
+ const scaledX = x * input.transform.scale + input.transform.offsetX;
+ const scaledY = y * input.transform.scale + input.transform.offsetY;
+ const scaledWidth = width * input.transform.scale;
+ const scaledHeight = height * input.transform.scale;
+
+ return (
+
+ );
+}
+
+export function DiagramScenePreview({ scene }: DiagramScenePreviewProps) {
+ const previewElements = useMemo(
+ () =>
+ scene?.elements
+ ?.filter((element) => element?.isDeleted !== true)
+ .slice(0, MAX_RENDERED_ELEMENTS) ?? [],
+ [scene]
+ );
+
+ const transform = useMemo(
+ () => createTransform(previewElements),
+ [previewElements]
+ );
+
+ const backgroundColor =
+ asString(scene?.appState?.viewBackgroundColor) ?? "transparent";
+
+ if (!(scene && previewElements.length > 0 && transform)) {
+ return (
+
+
+
+ );
+ }
+
+ const toX = (value: number) => value * transform.scale + transform.offsetX;
+ const toY = (value: number) => value * transform.scale + transform.offsetY;
+
+ return (
+
+
+
+ {previewElements.map((element, index) => {
+ const type = asString(element.type) ?? "rectangle";
+ const strokeColor = asString(element.strokeColor) ?? "#757575";
+ const fillColor = asString(element.backgroundColor) ?? "transparent";
+ const opacity = Math.max(
+ 0.1,
+ Math.min(1, (asNumber(element.opacity) ?? 100) / 100)
+ );
+ const strokeWidth = Math.max(
+ 0.5,
+ (asNumber(element.strokeWidth) ?? 1) * Math.max(0.8, transform.scale)
+ );
+ const key = getElementKey(element, index);
+
+ if (type === "line" || type === "arrow" || type === "draw") {
+ return renderPolyline({
+ element,
+ key,
+ opacity,
+ strokeColor,
+ strokeWidth,
+ toX,
+ toY,
+ });
+ }
+
+ return renderRectangle({
+ element,
+ fill: fillColor,
+ key,
+ opacity,
+ strokeColor,
+ strokeWidth,
+ transform,
+ });
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index 68bd5da..4726296 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -141,16 +141,8 @@ export default function Header() {
- {authControls}
-
- API Docs
-
+ {authControls}
diff --git a/apps/web/src/lib/diagram-recents.ts b/apps/web/src/lib/diagram-recents.ts
new file mode 100644
index 0000000..fcfd41e
--- /dev/null
+++ b/apps/web/src/lib/diagram-recents.ts
@@ -0,0 +1,80 @@
+const RECENTS_KEY = "sketchi.diagramRecents.v1";
+const MAX_RECENTS = 30;
+
+export interface DiagramRecent {
+ sessionId: string;
+ visitedAt: number;
+}
+
+function isDiagramRecent(value: unknown): value is DiagramRecent {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ typeof (value as DiagramRecent).sessionId === "string" &&
+ typeof (value as DiagramRecent).visitedAt === "number"
+ );
+}
+
+export function readDiagramRecents(limit = MAX_RECENTS): DiagramRecent[] {
+ if (typeof window === "undefined") {
+ return [];
+ }
+
+ try {
+ const raw = localStorage.getItem(RECENTS_KEY);
+ if (!raw) {
+ return [];
+ }
+
+ const parsed: unknown = JSON.parse(raw);
+ if (!Array.isArray(parsed)) {
+ return [];
+ }
+
+ return parsed
+ .filter(isDiagramRecent)
+ .sort((left, right) => right.visitedAt - left.visitedAt)
+ .slice(0, Math.max(1, limit));
+ } catch {
+ return [];
+ }
+}
+
+export function writeDiagramRecents(recents: DiagramRecent[]): void {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ localStorage.setItem(
+ RECENTS_KEY,
+ JSON.stringify(recents.slice(0, MAX_RECENTS))
+ );
+ } catch {
+ // ignore localStorage failures
+ }
+}
+
+export function addDiagramRecent(
+ sessionId: string,
+ visitedAt = Date.now()
+): void {
+ const next = [
+ { sessionId, visitedAt },
+ ...readDiagramRecents().filter((recent) => recent.sessionId !== sessionId),
+ ].slice(0, MAX_RECENTS);
+
+ writeDiagramRecents(next);
+}
+
+export function removeDiagramRecent(sessionId: string): DiagramRecent[] {
+ const next = readDiagramRecents().filter(
+ (recent) => recent.sessionId !== sessionId
+ );
+ writeDiagramRecents(next);
+ return next;
+}
+
+export function clearDiagramRecents(): void {
+ writeDiagramRecents([]);
+}
diff --git a/apps/web/src/lib/orpc/router.ts b/apps/web/src/lib/orpc/router.ts
index 07208d4..1ea4b5a 100644
--- a/apps/web/src/lib/orpc/router.ts
+++ b/apps/web/src/lib/orpc/router.ts
@@ -246,7 +246,7 @@ async function ensureSessionForThreadRun(input: {
const created = await input.context.convex.mutation(
api.diagramSessions.create,
- {}
+ { source: "opencode" }
);
return {
sessionId: created.sessionId,
@@ -855,7 +855,7 @@ export const appRouter = {
} else {
const created = await context.convex.mutation(
api.diagramSessions.create,
- {}
+ { source: "opencode" }
);
sessionId = created.sessionId;
threadId = created.threadId;
diff --git a/packages/backend/convex/diagramSessions.test.ts b/packages/backend/convex/diagramSessions.test.ts
index 877a5ed..101161f 100644
--- a/packages/backend/convex/diagramSessions.test.ts
+++ b/packages/backend/convex/diagramSessions.test.ts
@@ -22,6 +22,15 @@ const HEX_32_PATTERN = /^[0-9a-f]{32}$/;
const THREAD_32_PATTERN = /^thread_[0-9a-f]{32}$/;
describe("diagramSessions", () => {
+ test("listMine returns empty array for authed user before first session", async () => {
+ const listed = await authed.query(api.diagramSessions.listMine, {
+ limit: 10,
+ previewCount: 3,
+ });
+
+ expect(listed).toEqual([]);
+ });
+
test("create -> get returns empty scene with version 0", async () => {
const { sessionId, threadId } = await authed.mutation(
api.diagramSessions.create,
@@ -210,4 +219,96 @@ describe("diagramSessions", () => {
})
).rejects.toThrow("Session not found");
});
+
+ test("create stores source + title and listMine returns summaries", async () => {
+ const first = await authed.mutation(api.diagramSessions.create, {
+ source: "sketchi",
+ });
+ await authed.mutation(api.diagramSessions.setLatestScene, {
+ sessionId: first.sessionId,
+ expectedVersion: 0,
+ elements: [{ id: "first-1", type: "rectangle", x: 0, y: 0 }],
+ appState: {},
+ });
+
+ const second = await authed.mutation(api.diagramSessions.create, {
+ source: "opencode",
+ title: "Backend flow",
+ });
+ await authed.mutation(api.diagramSessions.setLatestScene, {
+ sessionId: second.sessionId,
+ expectedVersion: 0,
+ elements: [{ id: "second-1", type: "ellipse", x: 10, y: 20 }],
+ appState: {},
+ });
+
+ const listed = await authed.query(api.diagramSessions.listMine, {
+ limit: 10,
+ previewCount: 1,
+ });
+
+ expect(listed.length).toBeGreaterThanOrEqual(2);
+ const byId = new Map(listed.map((item) => [item.sessionId, item]));
+
+ expect(byId.get(second.sessionId)?.source).toBe("opencode");
+ expect(byId.get(second.sessionId)?.title).toBe("Backend flow");
+ expect(byId.get(second.sessionId)?.hasRenderableContent).toBe(true);
+ expect(byId.get(first.sessionId)?.source).toBe("sketchi");
+ expect(byId.get(first.sessionId)?.title).toBe("Untitled diagram");
+ expect(byId.get(first.sessionId)?.hasRenderableContent).toBe(true);
+
+ const previewedCount = listed.filter((item) => item.previewScene).length;
+ expect(previewedCount).toBe(1);
+ });
+
+ test("listMine marks sessions with no visible elements as empty", async () => {
+ const noScene = await authed.mutation(api.diagramSessions.create, {
+ title: "No scene yet",
+ });
+ const deletedOnly = await authed.mutation(api.diagramSessions.create, {
+ title: "Deleted only",
+ });
+
+ await authed.mutation(api.diagramSessions.setLatestScene, {
+ sessionId: deletedOnly.sessionId,
+ expectedVersion: 0,
+ elements: [
+ {
+ id: "deleted-1",
+ type: "rectangle",
+ isDeleted: true,
+ x: 0,
+ y: 0,
+ },
+ ],
+ appState: {},
+ });
+
+ const listed = await authed.query(api.diagramSessions.listMine, {
+ limit: 10,
+ previewCount: 0,
+ });
+ const byId = new Map(listed.map((item) => [item.sessionId, item]));
+
+ expect(byId.get(noScene.sessionId)?.hasScene).toBe(false);
+ expect(byId.get(noScene.sessionId)?.hasRenderableContent).toBe(false);
+
+ expect(byId.get(deletedOnly.sessionId)?.hasScene).toBe(true);
+ expect(byId.get(deletedOnly.sessionId)?.hasRenderableContent).toBe(false);
+ });
+
+ test("rename updates session title", async () => {
+ const { sessionId } = await authed.mutation(api.diagramSessions.create, {});
+
+ const renamed = await authed.mutation(api.diagramSessions.rename, {
+ sessionId,
+ title: "Quarterly architecture map",
+ });
+
+ expect(renamed.title).toBe("Quarterly architecture map");
+ expect(renamed.updatedAt).toBeGreaterThan(0);
+
+ const session = await authed.query(api.diagramSessions.get, { sessionId });
+ expect(session?.title).toBe("Quarterly architecture map");
+ });
});
diff --git a/packages/backend/convex/diagramSessions.ts b/packages/backend/convex/diagramSessions.ts
index ce64702..d7338d5 100644
--- a/packages/backend/convex/diagramSessions.ts
+++ b/packages/backend/convex/diagramSessions.ts
@@ -5,6 +5,15 @@ import { internalMutation, mutation, query } from "./_generated/server";
import { ensureViewerUser, getViewerWithUser } from "./lib/users";
const MAX_SCENE_BYTES = 900_000;
+const MAX_LIST_LIMIT = 100;
+const MAX_PREVIEW_COUNT = 3;
+const MAX_PREVIEW_ELEMENTS = 180;
+const MAX_TITLE_LENGTH = 80;
+const DEFAULT_DIAGRAM_TITLE = "Untitled diagram";
+const diagramSessionSource = v.union(
+ v.literal("sketchi"),
+ v.literal("opencode")
+);
const STRIPPED_APP_STATE_KEYS = [
"selectedElementIds",
@@ -50,6 +59,15 @@ function filterAppState(
return filtered;
}
+function hasRenderableElements(elements: unknown[]): boolean {
+ return elements.some((element) => {
+ if (!(element && typeof element === "object")) {
+ return false;
+ }
+ return (element as { isDeleted?: unknown }).isDeleted !== true;
+ });
+}
+
function measureSceneBytes(scene: {
elements: unknown[];
appState: unknown;
@@ -95,17 +113,47 @@ function validateSceneSize(scene: { elements: unknown[]; appState: unknown }):
};
}
+function truncate(value: string, maxLength: number): string {
+ if (value.length <= maxLength) {
+ return value;
+ }
+ return value.slice(0, maxLength);
+}
+
+function normalizeWhitespace(value: string): string {
+ return value.replace(/\s+/g, " ").trim();
+}
+
+function normalizeSessionTitle(input: string | null | undefined): string {
+ const normalized = normalizeWhitespace(input ?? "");
+ if (!normalized) {
+ return DEFAULT_DIAGRAM_TITLE;
+ }
+ return truncate(normalized, MAX_TITLE_LENGTH);
+}
+
export const create = mutation({
- args: {},
- handler: async (ctx) => {
+ args: {
+ source: v.optional(diagramSessionSource),
+ title: v.optional(v.string()),
+ },
+ handler: async (ctx, args) => {
const { user } = await ensureViewerUser(ctx);
const sessionId = await getUniqueSessionId(ctx);
const threadId = generateThreadId();
const now = Date.now();
+ const source = args.source ?? "sketchi";
+ const title = normalizeSessionTitle(args.title);
await ctx.db.insert("diagramSessions", {
sessionId,
ownerUserId: user._id,
+ source,
+ title,
+ titleEditedAt: args.title ? now : undefined,
+ firstPrompt: undefined,
+ lastPrompt: undefined,
+ diagramType: undefined,
latestScene: undefined,
latestSceneVersion: 0,
createdAt: now,
@@ -172,6 +220,11 @@ export const get = query({
return {
sessionId: session.sessionId,
ownerUserId: session.ownerUserId ?? null,
+ title: normalizeSessionTitle(session.title),
+ source: session.source ?? "sketchi",
+ firstPrompt: session.firstPrompt ?? null,
+ lastPrompt: session.lastPrompt ?? null,
+ diagramType: session.diagramType ?? null,
latestScene: session.latestScene ?? null,
latestSceneVersion: session.latestSceneVersion,
threadId: session.threadId ?? null,
@@ -181,6 +234,98 @@ export const get = query({
},
});
+export const listMine = query({
+ args: {
+ limit: v.optional(v.number()),
+ previewCount: v.optional(v.number()),
+ },
+ handler: async (ctx, args) => {
+ const { user } = await getViewerWithUser(ctx);
+ if (!user) {
+ return [];
+ }
+
+ const limit = Math.max(1, Math.min(args.limit ?? 50, MAX_LIST_LIMIT));
+ const previewCount = Math.max(
+ 0,
+ Math.min(args.previewCount ?? 0, MAX_PREVIEW_COUNT)
+ );
+
+ const sessions = await ctx.db
+ .query("diagramSessions")
+ .withIndex("by_owner_updatedAt", (q) => q.eq("ownerUserId", user._id))
+ .order("desc")
+ .take(limit);
+
+ return sessions.map((session, index) => {
+ const includePreview = index < previewCount;
+ const previewScene =
+ includePreview && session.latestScene
+ ? {
+ elements: session.latestScene.elements.slice(
+ 0,
+ MAX_PREVIEW_ELEMENTS
+ ),
+ appState: session.latestScene.appState,
+ }
+ : null;
+
+ return {
+ sessionId: session.sessionId,
+ title: normalizeSessionTitle(session.title),
+ source: session.source ?? "sketchi",
+ firstPrompt: session.firstPrompt ?? null,
+ lastPrompt: session.lastPrompt ?? null,
+ diagramType: session.diagramType ?? null,
+ createdAt: session.createdAt,
+ updatedAt: session.updatedAt,
+ latestSceneVersion: session.latestSceneVersion,
+ hasScene: Boolean(session.latestScene),
+ hasRenderableContent: session.latestScene
+ ? hasRenderableElements(session.latestScene.elements)
+ : false,
+ previewScene,
+ };
+ });
+ },
+});
+
+export const rename = mutation({
+ args: {
+ sessionId: v.string(),
+ title: v.string(),
+ },
+ handler: async (ctx, { sessionId, title }) => {
+ const { user, isAdmin } = await ensureViewerUser(ctx);
+
+ const session = await ctx.db
+ .query("diagramSessions")
+ .withIndex("by_sessionId", (q) => q.eq("sessionId", sessionId))
+ .unique();
+
+ if (!session) {
+ throw new Error("Session not found");
+ }
+
+ if (session.ownerUserId && session.ownerUserId !== user._id && !isAdmin) {
+ throw new Error("Forbidden");
+ }
+
+ const now = Date.now();
+ const normalizedTitle = normalizeSessionTitle(title);
+ await ctx.db.patch(session._id, {
+ title: normalizedTitle,
+ titleEditedAt: now,
+ updatedAt: now,
+ });
+
+ return {
+ title: normalizedTitle,
+ updatedAt: now,
+ };
+ },
+});
+
export const setLatestScene = mutation({
args: {
sessionId: v.string(),
@@ -249,10 +394,11 @@ export const internalSetLatestSceneFromThreadRun = internalMutation({
expectedVersion: v.number(),
elements: v.array(v.any()),
appState: v.record(v.string(), v.any()),
+ diagramType: v.optional(v.string()),
},
handler: async (
ctx,
- { sessionId, ownerUserId, expectedVersion, elements, appState }
+ { sessionId, ownerUserId, expectedVersion, elements, appState, diagramType }
) => {
const session = await ctx.db
.query("diagramSessions")
@@ -299,6 +445,7 @@ export const internalSetLatestSceneFromThreadRun = internalMutation({
ownerUserId: session.ownerUserId ?? ownerUserId,
latestScene: sizeChecked.scene,
latestSceneVersion: newVersion,
+ ...(diagramType ? { diagramType: truncate(diagramType, 64) } : {}),
updatedAt: now,
});
diff --git a/packages/backend/convex/diagramThreads.test.ts b/packages/backend/convex/diagramThreads.test.ts
index 6322b93..2db2641 100644
--- a/packages/backend/convex/diagramThreads.test.ts
+++ b/packages/backend/convex/diagramThreads.test.ts
@@ -171,4 +171,22 @@ describe("diagramThreads", () => {
expect(run?.status).toBe("stopped");
expect(run?.stopRequested).toBe(true);
});
+
+ test("enqueuePrompt stamps session prompt metadata + default title", async () => {
+ const { sessionId } = await authed.mutation(api.diagramSessions.create, {});
+
+ await authed.mutation(api.diagramThreads.enqueuePrompt, {
+ sessionId,
+ prompt:
+ "Map the payment retry flow between client, api gateway, and ledger service.",
+ promptMessageId: "prompt-msg-meta",
+ traceId: "trace-thread-meta",
+ });
+
+ const session = await authed.query(api.diagramSessions.get, { sessionId });
+ expect(session).not.toBeNull();
+ expect(session?.firstPrompt).toContain("payment retry flow");
+ expect(session?.lastPrompt).toContain("payment retry flow");
+ expect(session?.title).toContain("Map the payment retry flow");
+ });
});
diff --git a/packages/backend/convex/diagramThreads.ts b/packages/backend/convex/diagramThreads.ts
index aadf028..a1ecf83 100644
--- a/packages/backend/convex/diagramThreads.ts
+++ b/packages/backend/convex/diagramThreads.ts
@@ -14,11 +14,40 @@ import { createTraceId } from "./lib/trace";
import { ensureViewerUser, getViewerWithUser } from "./lib/users";
type SessionLike = Doc<"diagramSessions">;
+const DEFAULT_DIAGRAM_TITLE = "Untitled diagram";
+const MAX_PROMPT_SNIPPET_LENGTH = 300;
+const MAX_TITLE_LENGTH = 80;
+const PROMPT_SENTENCE_SPLIT_PATTERN = /(?<=[.!?])\s+/;
function normalizePrompt(prompt: string): string {
return prompt.trim();
}
+function normalizeWhitespace(value: string): string {
+ return value.replace(/\s+/g, " ").trim();
+}
+
+function truncate(value: string, maxLength: number): string {
+ if (value.length <= maxLength) {
+ return value;
+ }
+ return value.slice(0, maxLength);
+}
+
+function normalizePromptSnippet(prompt: string): string {
+ return truncate(normalizeWhitespace(prompt), MAX_PROMPT_SNIPPET_LENGTH);
+}
+
+function deriveTitleFromPrompt(prompt: string): string {
+ const normalized = normalizeWhitespace(prompt);
+ if (!normalized) {
+ return DEFAULT_DIAGRAM_TITLE;
+ }
+
+ const [firstSentence] = normalized.split(PROMPT_SENTENCE_SPLIT_PATTERN);
+ return truncate(firstSentence || normalized, MAX_TITLE_LENGTH);
+}
+
function createMessageId(): string {
return `msg_${crypto.randomUUID().replace(/-/g, "")}`;
}
@@ -221,6 +250,20 @@ export const enqueuePrompt = mutation({
ctx,
args.sessionId
);
+ const now = Date.now();
+ const promptSnippet = normalizePromptSnippet(prompt);
+ const shouldAutoTitle =
+ !session.title ||
+ (session.title === DEFAULT_DIAGRAM_TITLE && !session.titleEditedAt);
+
+ await ctx.db.patch(session._id, {
+ ...(session.firstPrompt === undefined
+ ? { firstPrompt: promptSnippet }
+ : {}),
+ lastPrompt: promptSnippet,
+ ...(shouldAutoTitle ? { title: deriveTitleFromPrompt(prompt) } : {}),
+ updatedAt: now,
+ });
const existingRun = await ctx.db
.query("diagramThreadRuns")
@@ -256,7 +299,6 @@ export const enqueuePrompt = mutation({
throw new Error("Failed to initialize session thread");
}
- const now = Date.now();
const traceId = args.traceId ?? createTraceId();
const userMessageId = createMessageId();
const assistantMessageId = createMessageId();
diff --git a/packages/backend/convex/diagramThreadsNode.ts b/packages/backend/convex/diagramThreadsNode.ts
index 62a868f..1ae3a4b 100644
--- a/packages/backend/convex/diagramThreadsNode.ts
+++ b/packages/backend/convex/diagramThreadsNode.ts
@@ -70,6 +70,7 @@ interface RuntimeState {
toolCallId: string;
toolName: ToolName;
} | null;
+ proposedDiagramType: string | null;
proposedScene: SceneCandidate | null;
reasoningBuffer: string;
toolUsed: ToolUsed;
@@ -127,6 +128,14 @@ function coerceAppState(value: unknown): Record {
return {};
}
+function coerceDiagramType(value: unknown): string | null {
+ if (typeof value !== "string") {
+ return null;
+ }
+ const normalized = value.trim();
+ return normalized.length > 0 ? normalized : null;
+}
+
function isBlankScene(elements: Record[]): boolean {
return elements.filter((element) => element.isDeleted !== true).length === 0;
}
@@ -366,6 +375,9 @@ async function executeGenerateTool(input: {
elements,
appState: {},
};
+ input.state.proposedDiagramType = coerceDiagramType(
+ generated.intermediate?.graphOptions?.diagramType
+ );
input.state.toolUsed = "generate";
await upsertToolMessage(input.ctx, {
@@ -527,6 +539,9 @@ async function executeRestructureTool(input: {
elements,
appState: sourceScene.appState,
};
+ input.state.proposedDiagramType = coerceDiagramType(
+ parsed.data.graphOptions?.diagramType
+ );
input.state.toolUsed = "restructure";
await upsertToolMessage(input.ctx, {
@@ -648,6 +663,7 @@ async function executeTweakTool(input: {
elements: result.elements as Record[],
appState: coerceAppState(result.appState),
};
+ input.state.proposedDiagramType = null;
input.state.toolUsed = "tweak";
await upsertToolMessage(input.ctx, {
@@ -1001,6 +1017,7 @@ async function loadRunContext(
async function applySceneFromRun(input: {
ctx: ActionCtx;
+ diagramType: string | null;
run: RunDoc;
scene: SceneCandidate;
session: Doc<"diagramSessions">;
@@ -1013,6 +1030,7 @@ async function applySceneFromRun(input: {
expectedVersion: input.session.latestSceneVersion,
elements: input.scene.elements,
appState: input.scene.appState,
+ diagramType: input.diagramType ?? undefined,
}
)) as ApplySceneResult;
}
@@ -1046,6 +1064,7 @@ export const processRun = internalAction({
assistantContent: "",
reasoningBuffer: "",
lastFlushAt: 0,
+ proposedDiagramType: null,
proposedScene: null,
toolUsed: null,
aborted: false,
@@ -1150,6 +1169,7 @@ export const processRun = internalAction({
const applyResult = await applySceneFromRun({
ctx,
+ diagramType: state.proposedDiagramType,
run,
session,
scene: state.proposedScene,
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index dd6e466..860dd09 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -22,6 +22,10 @@ const styleSettings = v.object({
const userRole = v.union(v.literal("user"), v.literal("admin"));
const libraryVisibility = v.union(v.literal("public"), v.literal("private"));
+const diagramSessionSource = v.union(
+ v.literal("sketchi"),
+ v.literal("opencode")
+);
const threadRunStatus = v.union(
v.literal("sending"),
v.literal("running"),
@@ -84,6 +88,12 @@ export default defineSchema({
diagramSessions: defineTable({
sessionId: v.string(),
ownerUserId: v.optional(v.id("users")),
+ title: v.optional(v.string()),
+ titleEditedAt: v.optional(v.number()),
+ source: v.optional(diagramSessionSource),
+ firstPrompt: v.optional(v.string()),
+ lastPrompt: v.optional(v.string()),
+ diagramType: v.optional(v.string()),
latestScene: v.optional(
v.object({
elements: v.array(v.any()),
@@ -96,7 +106,8 @@ export default defineSchema({
threadId: v.optional(v.string()),
})
.index("by_sessionId", ["sessionId"])
- .index("by_ownerUserId", ["ownerUserId"]),
+ .index("by_ownerUserId", ["ownerUserId"])
+ .index("by_owner_updatedAt", ["ownerUserId", "updatedAt"]),
diagramThreadRuns: defineTable({
sessionId: v.string(),
threadId: v.string(),