diff --git a/apps/web/public/icons/opencode-logo-dark-svg.svg b/apps/web/public/icons/opencode-logo-dark-svg.svg new file mode 100644 index 0000000..1c26467 --- /dev/null +++ b/apps/web/public/icons/opencode-logo-dark-svg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/icons/opencode-logo-light-svg.svg b/apps/web/public/icons/opencode-logo-light-svg.svg new file mode 100644 index 0000000..126ca10 --- /dev/null +++ b/apps/web/public/icons/opencode-logo-light-svg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/app/diagrams/[sessionId]/page.tsx b/apps/web/src/app/diagrams/[sessionId]/page.tsx index b37bd50..6d765f1 100644 --- a/apps/web/src/app/diagrams/[sessionId]/page.tsx +++ b/apps/web/src/app/diagrams/[sessionId]/page.tsx @@ -24,10 +24,9 @@ import { import { ImportExportToolbar } from "@/components/diagram-studio/import-export-toolbar"; import { sanitizeAppState } from "@/components/diagram-studio/sanitize-app-state"; import { Button } from "@/components/ui/button"; +import { addDiagramRecent } from "@/lib/diagram-recents"; const AUTOSAVE_DELAY_MS = 2000; -const RECENTS_KEY = "sketchi.diagramRecents.v1"; -const RECENTS_MAX = 10; const COMPLETION_PULSE_MS = 1500; const STOP_RETRY_DELAYS_MS = [220, 380, 620]; @@ -57,44 +56,6 @@ interface RunState { stopRequested: boolean; } -function writeDiagramRecent(sessionId: string) { - if (typeof window === "undefined") { - return; - } - try { - let recents: Array<{ sessionId: string; visitedAt: number }> = []; - try { - const raw = localStorage.getItem(RECENTS_KEY); - if (raw) { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - recents = parsed.filter( - (item): item is { sessionId: string; visitedAt: number } => - typeof item === "object" && - item !== null && - typeof item.sessionId === "string" && - typeof item.visitedAt === "number" - ); - } - } - } catch { - recents = []; - } - - const filtered = recents.filter((recent) => recent.sessionId !== sessionId); - filtered.unshift({ sessionId, visitedAt: Date.now() }); - const capped = filtered.slice(0, RECENTS_MAX); - - try { - localStorage.setItem(RECENTS_KEY, JSON.stringify(capped)); - } catch { - // quota exceeded - } - } catch { - // localStorage unavailable - } -} - function createOptimisticUserMessage(input: { content: string; promptMessageId: string; @@ -178,7 +139,7 @@ export default function DiagramStudioPage() { useEffect(() => { if (sessionId) { - writeDiagramRecent(sessionId); + addDiagramRecent(sessionId); } }, [sessionId]); @@ -623,7 +584,7 @@ export default function DiagramStudioPage() { } setIsCreating(true); try { - const { sessionId: newId } = await createSession(); + const { sessionId: newId } = await createSession({ source: "sketchi" }); router.push(`/diagrams/${newId}` as never); } catch { // user can retry diff --git a/apps/web/src/app/diagrams/page.tsx b/apps/web/src/app/diagrams/page.tsx index d05f1cf..51657d5 100644 --- a/apps/web/src/app/diagrams/page.tsx +++ b/apps/web/src/app/diagrams/page.tsx @@ -1,128 +1,194 @@ "use client"; import { api } from "@sketchi/backend/convex/_generated/api"; -import { useMutation } from "convex/react"; -import { - ArrowRight, - Clock, - ExternalLink, - PenTool, - Save, - Share2, - ShieldAlert, - Sparkles, - Trash2, - Wand2, - X, -} from "lucide-react"; -import Link from "next/link"; +import { useConvexAuth, useMutation, useQuery } from "convex/react"; +import { ArrowRight, Clock, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { + type DiagramListCard, + DiagramListItem, + type DiagramListSource, +} from "@/components/diagram-studio/diagram-list-item"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { + addDiagramRecent, + clearDiagramRecents, + type DiagramRecent, + readDiagramRecents, + removeDiagramRecent, +} from "@/lib/diagram-recents"; + +type SessionSource = "opencode" | "sketchi"; -const RECENTS_KEY = "sketchi.diagramRecents.v1"; -const MAX_RECENTS = 10; +interface SessionPreview { + appState: Record; + elements: Record[]; +} -interface RecentDiagram { +interface CloudDiagram { + createdAt: number; + diagramType: string | null; + firstPrompt: string | null; + hasRenderableContent: boolean; + hasScene: boolean; + lastPrompt: string | null; + latestSceneVersion: number; + previewScene: SessionPreview | null; sessionId: string; - visitedAt: number; + source: SessionSource; + title: string; + updatedAt: number; } -function readRecents(): RecentDiagram[] { - if (typeof window === "undefined") { +function asCloudDiagramArray(value: unknown): CloudDiagram[] { + if (!Array.isArray(value)) { return []; } - try { - const raw = localStorage.getItem(RECENTS_KEY); - if (!raw) { - return []; - } - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return []; + + return value.filter((item): item is CloudDiagram => { + if (!(item && typeof item === "object")) { + return false; } - return parsed - .filter( - (item): item is RecentDiagram => - typeof item === "object" && - item !== null && - typeof (item as RecentDiagram).sessionId === "string" && - typeof (item as RecentDiagram).visitedAt === "number" - ) - .slice(0, MAX_RECENTS); - } catch { - return []; - } -} -function writeRecents(recents: RecentDiagram[]): void { - try { - localStorage.setItem(RECENTS_KEY, JSON.stringify(recents)); - } catch { - // Quota error — ignore - } + const candidate = item as CloudDiagram; + return ( + typeof candidate.sessionId === "string" && + typeof candidate.title === "string" && + (candidate.source === "sketchi" || candidate.source === "opencode") && + typeof candidate.createdAt === "number" && + typeof candidate.updatedAt === "number" && + typeof candidate.latestSceneVersion === "number" && + typeof candidate.hasScene === "boolean" && + typeof candidate.hasRenderableContent === "boolean" + ); + }); } -function relativeTime(timestamp: number): string { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - if (seconds < 60) { - return "just now"; +function isEmptyCard(item: DiagramListCard): boolean { + if (item.localOnly) { + return false; } - const minutes = Math.floor(seconds / 60); - if (minutes < 60) { - return `${minutes}m ago`; + if (!item.hasScene) { + return true; } - const hours = Math.floor(minutes / 60); - if (hours < 24) { - return `${hours}h ago`; + return item.hasRenderableContent === false; +} + +function mergeCards(input: { + cloudDiagrams: CloudDiagram[]; + localRecents: DiagramRecent[]; +}): DiagramListCard[] { + const localBySession = new Map( + input.localRecents.map((recent) => [recent.sessionId, recent.visitedAt]) + ); + + const merged = new Map(); + + for (const session of input.cloudDiagrams) { + merged.set(session.sessionId, { + context: session.lastPrompt ?? session.firstPrompt, + createdAt: session.createdAt, + diagramType: session.diagramType, + hasRenderableContent: session.hasRenderableContent, + hasScene: session.hasScene, + latestSceneVersion: session.latestSceneVersion, + localOnly: false, + previewScene: session.previewScene, + sessionId: session.sessionId, + source: session.source as DiagramListSource, + title: session.title, + updatedAt: session.updatedAt, + visitedAt: localBySession.get(session.sessionId) ?? null, + }); } - const days = Math.floor(hours / 24); - if (days < 30) { - return `${days}d ago`; + + for (const local of input.localRecents) { + if (merged.has(local.sessionId)) { + continue; + } + + merged.set(local.sessionId, { + context: null, + createdAt: null, + diagramType: null, + hasRenderableContent: null, + hasScene: false, + latestSceneVersion: null, + localOnly: true, + previewScene: null, + sessionId: local.sessionId, + source: "local", + title: "Local recent", + updatedAt: null, + visitedAt: local.visitedAt, + }); } - const months = Math.floor(days / 30); - return `${months}mo ago`; -} -const FEATURES = [ - { - icon: , - label: "AI-powered restructuring via chat sidebar", - }, - { - icon: , - label: "Auto-save with version history and conflict resolution", - }, - { - icon: , - label: "Signed-in sessions with sharable URLs", - }, - { - icon: , - label: "Import/export Excalidraw, PNG, and JSON", - }, -]; + return Array.from(merged.values()).sort((left, right) => { + const leftSort = Math.max(left.updatedAt ?? 0, left.visitedAt ?? 0); + const rightSort = Math.max(right.updatedAt ?? 0, right.visitedAt ?? 0); + return rightSort - leftSort; + }); +} export default function DiagramsPage() { const router = useRouter(); + const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth(); const createSession = useMutation(api.diagramSessions.create); + const renameSession = useMutation(api.diagramSessions.rename); + + const sessionsRaw = useQuery( + api.diagramSessions.listMine, + isAuthLoading || !isAuthenticated + ? "skip" + : { + limit: 80, + previewCount: 3, + } + ); + + const [editingSessionId, setEditingSessionId] = useState(null); + const [editingTitle, setEditingTitle] = useState(""); const [isCreating, setIsCreating] = useState(false); - const [recents, setRecents] = useState([]); + const [hideEmpty, setHideEmpty] = useState(true); + const [isSavingTitle, setIsSavingTitle] = useState(false); + const [localRecents, setLocalRecents] = useState([]); 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 ( +
+
+
+

+ Diagrams +

+

+ Jump back into any diagram. +

+ -
+ -
-
-

Recent diagrams

- {hasRecents && ( - - )} -
+ { + 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 ? ( + + ) : 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 ? ( + + ) : 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} - - - -
  • - ); -} 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 + OpenCode + OpenCode + + ); + } + + if (source === "local") { + return ( + + + Local + + ); + } + + return ( + + Sketchi + 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} + /> + + +
    + ); +} + +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 ? ( + + ) : 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 ? ( + + ) : 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(),