feat: multiple scenes

This commit is contained in:
2026-03-29 01:53:15 +01:00
parent 9607ff3478
commit 7ced2172af
15 changed files with 503 additions and 48 deletions

View File

@@ -24,6 +24,7 @@ export function getDb(): Database.Database {
db.pragma('foreign_keys = ON') db.pragma('foreign_keys = ON')
initSchema(db) initSchema(db)
migrateSchema(db)
return db return db
} }
@@ -32,6 +33,7 @@ function initSchema(db: Database.Database): void {
CREATE TABLE IF NOT EXISTS runs ( CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
recollectionId TEXT NOT NULL, recollectionId TEXT NOT NULL,
sceneId TEXT,
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
graphSnapshot TEXT NOT NULL, graphSnapshot TEXT NOT NULL,
createdAt TEXT NOT NULL DEFAULT (datetime('now')), createdAt TEXT NOT NULL DEFAULT (datetime('now')),
@@ -61,6 +63,14 @@ function initSchema(db: Database.Database): void {
`) `)
} }
function migrateSchema(db: Database.Database): void {
// Add sceneId column if it doesn't exist (added in Phase 2)
const cols = db.prepare("PRAGMA table_info(runs)").all() as Array<{ name: string }>
if (!cols.some((c) => c.name === 'sceneId')) {
db.exec('ALTER TABLE runs ADD COLUMN sceneId TEXT')
}
}
export function closeDb(): void { export function closeDb(): void {
if (db) { if (db) {
db.close() db.close()

View File

@@ -7,6 +7,7 @@ export type RunStatus = 'pending' | 'running' | 'completed' | 'failed'
export type Run = { export type Run = {
id: string id: string
recollectionId: string recollectionId: string
sceneId?: string | null
status: RunStatus status: RunStatus
graphSnapshot: string graphSnapshot: string
createdAt: string createdAt: string

View File

@@ -8,8 +8,8 @@ import type { Run, RunStep, RunStatus } from '../models/run.js'
export function createRun(run: Run): void { export function createRun(run: Run): void {
const db = getDb() const db = getDb()
db.prepare(` db.prepare(`
INSERT INTO runs (id, recollectionId, status, graphSnapshot, createdAt, updatedAt, error) INSERT INTO runs (id, recollectionId, sceneId, status, graphSnapshot, createdAt, updatedAt, error)
VALUES (@id, @recollectionId, @status, @graphSnapshot, @createdAt, @updatedAt, @error) VALUES (@id, @recollectionId, @sceneId, @status, @graphSnapshot, @createdAt, @updatedAt, @error)
`).run(run) `).run(run)
} }

View File

@@ -16,7 +16,7 @@ import { executeRun } from '../services/graphRunnerService.js'
*/ */
export async function handleCreateRun(req: Request, res: Response): Promise<void> { export async function handleCreateRun(req: Request, res: Response): Promise<void> {
try { try {
const { recollectionId, graph } = req.body const { recollectionId, sceneId, graph } = req.body
if (!recollectionId || typeof recollectionId !== 'string') { if (!recollectionId || typeof recollectionId !== 'string') {
res.status(400).json({ error: 'recollectionId is required' }) res.status(400).json({ error: 'recollectionId is required' })
@@ -30,6 +30,7 @@ export async function handleCreateRun(req: Request, res: Response): Promise<void
const run: Run = { const run: Run = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
recollectionId, recollectionId,
sceneId: typeof sceneId === 'string' ? sceneId : null,
status: 'pending', status: 'pending',
graphSnapshot: JSON.stringify(graph), graphSnapshot: JSON.stringify(graph),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),

View File

@@ -202,11 +202,13 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
export type CanvasPageProps = { export type CanvasPageProps = {
/** Optional recollection id for per-recollection graph loading */ /** Optional recollection id for per-recollection graph loading */
recollectionId?: string recollectionId?: string
/** Optional scene id for per-scene graph loading */
sceneId?: string
/** Optional node id to focus when the canvas loads (e.g. from Katalogos artifacts). */ /** Optional node id to focus when the canvas loads (e.g. from Katalogos artifacts). */
focusNodeId?: string focusNodeId?: string
} }
export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) { export function CanvasPage({ recollectionId, sceneId, focusNodeId }: CanvasPageProps) {
const { theme } = useTheme() const { theme } = useTheme()
const { showMinimap } = usePlatform() const { showMinimap } = usePlatform()
const { const {
@@ -225,7 +227,7 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
setStateImmediate, setStateImmediate,
save, save,
saveStatus, saveStatus,
} = useCanvasGraph(recollectionId) } = useCanvasGraph(recollectionId, sceneId)
const [rfInstance, setRfInstance] = React.useState<unknown>(null) const [rfInstance, setRfInstance] = React.useState<unknown>(null)
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null) const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
@@ -281,10 +283,10 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
// Reset previous run state, save, then run // Reset previous run state, save, then run
resetRun() resetRun()
save() save()
createAndStreamRun(recollectionId, { nodes, edges }, connectToRun).catch((err) => { createAndStreamRun(recollectionId, { nodes, edges }, connectToRun, sceneId).catch((err) => {
toast.error(`Run failed: ${err.message}`) toast.error(`Run failed: ${err.message}`)
}) })
}, [recollectionId, nodes, edges, connectToRun, save, runStatus, resetRun]) }, [recollectionId, sceneId, nodes, edges, connectToRun, save, runStatus, resetRun])
const nodesRef = useRef(nodes) const nodesRef = useRef(nodes)
nodesRef.current = nodes nodesRef.current = nodes

View File

@@ -6,6 +6,7 @@
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils' import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
import { loadGraphFromStorage } from '@/app/recollections/state/recollectionGraphStorage' import { loadGraphFromStorage } from '@/app/recollections/state/recollectionGraphStorage'
import { getSceneGraph } from '@/app/recollections/state/recollectionStore'
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */ /** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
export function backfillEdgeTargetTypes( export function backfillEdgeTargetTypes(
@@ -70,9 +71,12 @@ export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
return { nodes, edges } return { nodes, edges }
} }
export function getInitialGraph(recollectionId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } { export function getInitialGraph(recollectionId: string | undefined, sceneId?: string): { nodes: AppNode[]; edges: AppEdge[] } {
if (recollectionId) { if (recollectionId) {
const stored = loadGraphFromStorage(recollectionId) // Scene-aware: load from scene key; only fall back to legacy when no sceneId
const stored = sceneId
? getSceneGraph(recollectionId, sceneId)
: loadGraphFromStorage(recollectionId)
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) { if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
const nodes = stored.nodes as AppNode[] const nodes = stored.nodes as AppNode[]
const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[]) const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[])

View File

@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory' import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils' import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage' import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
import { setSceneGraph } from '@/app/recollections/state/recollectionStore'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
export type SaveStatus = 'saved' | 'unsaved' | 'saving' export type SaveStatus = 'saved' | 'unsaved' | 'saving'
@@ -18,8 +19,8 @@ export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> &
saveStatus: SaveStatus saveStatus: SaveStatus
} }
export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGraphResult { export function useCanvasGraph(recollectionId: string | undefined, sceneId?: string): UseCanvasGraphResult {
const initialGraph = useMemo(() => getInitialGraph(recollectionId), [recollectionId]) const initialGraph = useMemo(() => getInitialGraph(recollectionId, sceneId), [recollectionId, sceneId])
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges) const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
const { nodes, edges } = result const { nodes, edges } = result
@@ -47,17 +48,23 @@ export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGra
edges: edgesRef.current, edges: edgesRef.current,
}) })
setIsSaving(true) setIsSaving(true)
saveGraphToStorage(recollectionId, { const graphState = {
version: RECOLLECTION_VERSION, version: RECOLLECTION_VERSION,
nodes: nodesRef.current, nodes: nodesRef.current,
edges: edgesRef.current, edges: edgesRef.current,
}) }
// Save to scene-specific key if sceneId is provided, otherwise legacy key
if (sceneId) {
setSceneGraph(recollectionId, sceneId, graphState)
} else {
saveGraphToStorage(recollectionId, graphState)
}
const SAVING_DISPLAY_MS = 360 const SAVING_DISPLAY_MS = 360
setTimeout(() => { setTimeout(() => {
setLastSavedSerialized(snapshot) setLastSavedSerialized(snapshot)
setIsSaving(false) setIsSaving(false)
}, SAVING_DISPLAY_MS) }, SAVING_DISPLAY_MS)
}, [recollectionId]) }, [recollectionId, sceneId])
// Auto-save after 5 seconds of inactivity when there are unsaved changes. // Auto-save after 5 seconds of inactivity when there are unsaved changes.
// Prevents data loss if the user closes the tab without pressing Ctrl+S. // Prevents data loss if the user closes the tab without pressing Ctrl+S.

View File

@@ -8,6 +8,7 @@ import { toast } from 'sonner'
import { usePlatform } from '@/app/kosmos/KosmosContext' import { usePlatform } from '@/app/kosmos/KosmosContext'
import { RecollectionActionsProvider } from './layout/RecollectionActionsContext' import { RecollectionActionsProvider } from './layout/RecollectionActionsContext'
import { RecollectionSidebarProvider } from './layout/RecollectionSidebarContext' import { RecollectionSidebarProvider } from './layout/RecollectionSidebarContext'
import { FluxSceneProvider } from './flux/FluxSceneContext'
import { RecollectionMenubar } from './layout/RecollectionMenubar' import { RecollectionMenubar } from './layout/RecollectionMenubar'
import { RecollectionSidebar } from './layout/RecollectionSidebar' import { RecollectionSidebar } from './layout/RecollectionSidebar'
@@ -50,6 +51,7 @@ export function RecollectionLayout() {
return ( return (
<RecollectionActionsProvider> <RecollectionActionsProvider>
<RecollectionSidebarProvider> <RecollectionSidebarProvider>
<FluxSceneProvider>
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<RecollectionMenubar /> <RecollectionMenubar />
<div className="flex min-h-0 flex-1 flex-row overflow-hidden"> <div className="flex min-h-0 flex-1 flex-row overflow-hidden">
@@ -59,6 +61,7 @@ export function RecollectionLayout() {
</div> </div>
</div> </div>
</div> </div>
</FluxSceneProvider>
</RecollectionSidebarProvider> </RecollectionSidebarProvider>
</RecollectionActionsProvider> </RecollectionActionsProvider>
) )

View File

@@ -1,5 +1,5 @@
/** /**
* Flux route: renders the graph canvas (CanvasPage) for the current recollection. * Flux route: renders the graph canvas (CanvasPage) for the current recollection + scene.
* Supports optional focusNode query param to center on a specific node. * Supports optional focusNode query param to center on a specific node.
*/ */
@@ -7,6 +7,7 @@ import React, { useEffect, useRef } from 'react'
import { useParams, useSearchParams } from 'react-router-dom' import { useParams, useSearchParams } from 'react-router-dom'
import { CanvasPage } from '@/app/canvas/CanvasPage' import { CanvasPage } from '@/app/canvas/CanvasPage'
import { usePlatform } from '@/app/kosmos/KosmosContext' import { usePlatform } from '@/app/kosmos/KosmosContext'
import { useFluxScenes } from './FluxSceneContext'
export function FluxRoute() { export function FluxRoute() {
const { recollectionId } = useParams<{ recollectionId: string }>() const { recollectionId } = useParams<{ recollectionId: string }>()
@@ -14,6 +15,7 @@ export function FluxRoute() {
const { updateLastEdited } = usePlatform() const { updateLastEdited } = usePlatform()
const updateLastEditedRef = useRef(updateLastEdited) const updateLastEditedRef = useRef(updateLastEdited)
updateLastEditedRef.current = updateLastEdited updateLastEditedRef.current = updateLastEdited
const { activeSceneId } = useFluxScenes()
useEffect(() => { useEffect(() => {
if (recollectionId) updateLastEditedRef.current(recollectionId) if (recollectionId) updateLastEditedRef.current(recollectionId)
@@ -25,7 +27,12 @@ export function FluxRoute() {
return ( return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden"> <div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<CanvasPage key={recollectionId} recollectionId={recollectionId} focusNodeId={focusNodeId} /> <CanvasPage
key={`${recollectionId}_${activeSceneId}`}
recollectionId={recollectionId}
sceneId={activeSceneId}
focusNodeId={focusNodeId}
/>
</div> </div>
) )
} }

View File

@@ -0,0 +1,146 @@
/**
* Context for Flux scenes: scene tree, active scene, URL sync.
* Mirrors RecollectionSidebarContext for Logos pages.
*/
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import {
ensureFluxSceneTree,
getFluxSceneTree,
setFluxSceneTree,
removeSceneGraph,
DEFAULT_SCENE_ID,
type FluxSceneMeta,
type FluxSceneId,
} from '../state/recollectionStore'
export type FluxSceneContextValue = {
scenes: FluxSceneMeta[]
activeSceneId: FluxSceneId
setScenes: React.Dispatch<React.SetStateAction<FluxSceneMeta[]>>
handleSelectScene: (id: FluxSceneId) => void
handleAddScene: (title?: string) => FluxSceneId
handleRenameScene: (id: FluxSceneId, title: string) => void
handleDeleteScene: (id: FluxSceneId) => void
handleReorderScenes: (scenes: FluxSceneMeta[]) => void
}
const FluxSceneContext = createContext<FluxSceneContextValue | null>(null)
export function useFluxScenes(): FluxSceneContextValue {
const ctx = useContext(FluxSceneContext)
if (!ctx) throw new Error('useFluxScenes must be used within FluxSceneProvider')
return ctx
}
export function useOptionalFluxScenes(): FluxSceneContextValue | null {
return useContext(FluxSceneContext)
}
let sceneCounter = 0
export function FluxSceneProvider({ children }: { children: React.ReactNode }) {
const { recollectionId } = useParams<{ recollectionId: string }>()
const [searchParams, setSearchParams] = useSearchParams()
const [scenes, setScenes] = useState<FluxSceneMeta[]>([])
const [activeSceneId, setActiveSceneId] = useState<FluxSceneId>(DEFAULT_SCENE_ID)
// Load scene tree when recollection changes
useEffect(() => {
if (!recollectionId) return
const tree = ensureFluxSceneTree(recollectionId)
setScenes(tree)
}, [recollectionId])
// Sync active scene from URL
useEffect(() => {
if (!recollectionId || scenes.length === 0) return
const sceneFromUrl = searchParams.get('scene')
setActiveSceneId((prev) => {
if (sceneFromUrl && scenes.some((s) => s.id === sceneFromUrl)) return sceneFromUrl
if (scenes.some((s) => s.id === prev)) return prev
return scenes[0].id
})
}, [recollectionId, searchParams, scenes])
// Persist scene tree on changes
useEffect(() => {
if (!recollectionId || scenes.length === 0) return
setFluxSceneTree(recollectionId, scenes)
}, [recollectionId, scenes])
const handleSelectScene = useCallback(
(id: FluxSceneId) => {
setActiveSceneId(id)
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
next.set('scene', id)
return next
}, { replace: true })
},
[setSearchParams]
)
const handleAddScene = useCallback(
(title?: string) => {
sceneCounter += 1
const id = `scene_${Date.now()}_${sceneCounter}`
const maxPos = scenes.reduce((max, s) => Math.max(max, s.position), -1)
const newScene: FluxSceneMeta = {
id,
title: title ?? `Scene ${scenes.length + 1}`,
position: maxPos + 1,
}
setScenes((prev) => [...prev, newScene])
handleSelectScene(id)
return id
},
[scenes, handleSelectScene]
)
const handleRenameScene = useCallback(
(id: FluxSceneId, title: string) => {
setScenes((prev) => prev.map((s) => (s.id === id ? { ...s, title } : s)))
},
[]
)
const handleDeleteScene = useCallback(
(id: FluxSceneId) => {
if (!recollectionId) return
// Prevent deleting the last scene
if (scenes.length <= 1) return
removeSceneGraph(recollectionId, id)
setScenes((prev) => {
const next = prev.filter((s) => s.id !== id)
// If we deleted the active scene, switch to the first remaining
if (activeSceneId === id && next.length > 0) {
handleSelectScene(next[0].id)
}
return next
})
},
[recollectionId, scenes.length, activeSceneId, handleSelectScene]
)
const handleReorderScenes = useCallback(
(newScenes: FluxSceneMeta[]) => {
setScenes(newScenes)
},
[]
)
const value: FluxSceneContextValue = {
scenes,
activeSceneId,
setScenes,
handleSelectScene,
handleAddScene,
handleRenameScene,
handleDeleteScene,
handleReorderScenes,
}
return <FluxSceneContext.Provider value={value}>{children}</FluxSceneContext.Provider>
}

View File

@@ -8,6 +8,7 @@ import { CheckCircle2, XCircle, Clock, Loader2 } from 'lucide-react'
type RunSummary = { type RunSummary = {
id: string id: string
sceneId?: string | null
status: string status: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
@@ -105,6 +106,9 @@ export function RunsTab() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{statusIcon[run.status] ?? statusIcon.pending} {statusIcon[run.status] ?? statusIcon.pending}
<span className="font-medium capitalize">{run.status}</span> <span className="font-medium capitalize">{run.status}</span>
{run.sceneId && (
<span className="text-xs text-muted-foreground">({run.sceneId})</span>
)}
</div> </div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{new Date(run.createdAt).toLocaleString()} {new Date(run.createdAt).toLocaleString()}

View File

@@ -12,11 +12,16 @@ import {
getLogosContent, getLogosContent,
setLogosContent, setLogosContent,
upsertRenderOutputEntry, upsertRenderOutputEntry,
getFluxSceneTree,
setFluxSceneTree,
getSceneGraph,
setSceneGraph,
RECOLLECTION_FILE_EXT, RECOLLECTION_FILE_EXT,
RECOLLECTION_VERSION, RECOLLECTION_VERSION,
type StoredGraphState, type StoredGraphState,
type StoredLogosContent, type StoredLogosContent,
type RenderOutputCacheEntry, type RenderOutputCacheEntry,
type FluxSceneMeta,
} from '../state/recollectionStore' } from '../state/recollectionStore'
export type { RenderOutputCacheEntry } export type { RenderOutputCacheEntry }
@@ -31,6 +36,8 @@ export type RecollectionFilePayload = {
version?: number version?: number
graph?: { nodes: unknown[]; edges: unknown[] } graph?: { nodes: unknown[]; edges: unknown[] }
logos?: StoredLogosContent logos?: StoredLogosContent
/** Flux scenes (Phase 2+). Each entry has scene metadata + graph. */
scenes?: Array<{ id: string; title: string; position: number; graph: { nodes: unknown[]; edges: unknown[] } }>
} }
export type FluxSlot = { export type FluxSlot = {
@@ -104,6 +111,26 @@ function validateAndWritePayload(
result.logos = payload.logos as StoredLogosContent result.logos = payload.logos as StoredLogosContent
} }
} }
// Import scenes
if (payload.scenes && Array.isArray(payload.scenes)) {
const sceneMetas: FluxSceneMeta[] = []
for (const s of payload.scenes) {
if (!s || typeof s.id !== 'string' || typeof s.title !== 'string') continue
sceneMetas.push({ id: s.id, title: s.title, position: s.position ?? 0 })
if (s.graph && Array.isArray(s.graph.nodes) && Array.isArray(s.graph.edges)) {
const nodes = s.graph.nodes as AppNode[]
const edges = backfillEdges(nodes, s.graph.edges as AppEdge[])
setSceneGraph(recollectionId, s.id, {
version: payload.version ?? RECOLLECTION_VERSION,
nodes,
edges,
})
}
}
if (sceneMetas.length > 0) {
setFluxSceneTree(recollectionId, sceneMetas)
}
}
return result return result
} }
@@ -151,10 +178,17 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
if (!recollectionId) return if (!recollectionId) return
const graph = getGraph(recollectionId) const graph = getGraph(recollectionId)
const logosContent = getLogosContent(recollectionId) const logosContent = getLogosContent(recollectionId)
// Export scenes
const sceneTree = getFluxSceneTree(recollectionId)
const scenes = sceneTree.map((s) => {
const sg = getSceneGraph(recollectionId, s.id)
return { ...s, graph: sg ? { nodes: sg.nodes, edges: sg.edges } : { nodes: [], edges: [] } }
})
const payload: RecollectionFilePayload = { const payload: RecollectionFilePayload = {
version: RECOLLECTION_VERSION, version: RECOLLECTION_VERSION,
...(graph && { graph: { nodes: graph.nodes, edges: graph.edges } }), ...(graph && { graph: { nodes: graph.nodes, edges: graph.edges } }),
...(logosContent && { logos: logosContent }), ...(logosContent && { logos: logosContent }),
...(scenes.length > 0 && { scenes }),
} }
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }) const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)

View File

@@ -2,13 +2,14 @@
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes. * Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes.
*/ */
import React, { useCallback } from 'react' import React, { useCallback, useState } from 'react'
import { useParams, useLocation, useNavigate } from 'react-router-dom' import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Plus, FileText } from 'lucide-react' import { Plus, FileText, Pencil, Trash2, Check, X } from 'lucide-react'
import { FluxIcon } from '@/lib/icons' import { FluxIcon } from '@/lib/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useRecollectionSidebar } from './RecollectionSidebarContext' import { useRecollectionSidebar } from './RecollectionSidebarContext'
import { useOptionalFluxScenes } from '../flux/FluxSceneContext'
import { TreeBrowser } from './TreeBrowser' import { TreeBrowser } from './TreeBrowser'
import { import {
SidebarContent, SidebarContent,
@@ -87,6 +88,25 @@ export function RecollectionSidebar() {
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<FluxScenesSection base={base} isFluxView={isFluxView} />
</SidebarContent>
</div>
)
}
// ---------------------------------------------------------------------------
// Flux Scenes Section
// ---------------------------------------------------------------------------
function FluxScenesSection({ base, isFluxView }: { base: string; isFluxView: boolean }) {
const navigate = useNavigate()
const fluxScenes = useOptionalFluxScenes()
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
// Fallback: single "Canvas" link when provider isn't mounted
if (!fluxScenes) {
return (
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Flux</SidebarGroupLabel> <SidebarGroupLabel>Flux</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
@@ -105,7 +125,113 @@ export function RecollectionSidebar() {
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
</SidebarContent> )
</div> }
const { scenes, activeSceneId, handleSelectScene, handleAddScene, handleRenameScene, handleDeleteScene } = fluxScenes
const startRename = (id: string, currentTitle: string) => {
setRenamingId(id)
setRenameValue(currentTitle)
}
const commitRename = () => {
if (renamingId && renameValue.trim()) {
handleRenameScene(renamingId, renameValue.trim())
}
setRenamingId(null)
}
const cancelRename = () => {
setRenamingId(null)
}
return (
<SidebarGroup>
<div className="flex items-center justify-between gap-2 py-1.5">
<SidebarGroupLabel className="py-0">Flux</SidebarGroupLabel>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
onClick={() => {
const id = handleAddScene()
navigate(`${base}/flux?scene=${encodeURIComponent(id)}`)
}}
>
<Plus className="size-3.5" />
New scene
</Button>
</div>
<SidebarGroupContent>
<SidebarMenu>
{scenes.map((scene) => {
const isActive = isFluxView && scene.id === activeSceneId
return (
<SidebarMenuItem key={scene.id}>
{renamingId === scene.id ? (
<div className="flex w-full items-center gap-1 px-2 py-1">
<input
autoFocus
className="h-6 flex-1 min-w-0 rounded border border-sidebar-border bg-sidebar px-1.5 text-xs outline-none focus:ring-1 focus:ring-sidebar-ring"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commitRename()
if (e.key === 'Escape') cancelRename()
}}
onBlur={commitRename}
/>
<button type="button" onClick={commitRename} className="shrink-0 text-sidebar-foreground/70 hover:text-sidebar-foreground">
<Check className="size-3.5" />
</button>
<button type="button" onClick={cancelRename} className="shrink-0 text-sidebar-foreground/70 hover:text-sidebar-foreground">
<X className="size-3.5" />
</button>
</div>
) : (
<div className="group flex w-full items-center">
<button
type="button"
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'flex-1 min-w-0')}
onClick={() => {
handleSelectScene(scene.id)
navigate(`${base}/flux?scene=${encodeURIComponent(scene.id)}`)
}}
>
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
<span className="truncate">{scene.title}</span>
</button>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pr-1">
<button
type="button"
onClick={(e) => { e.stopPropagation(); startRename(scene.id, scene.title) }}
className="rounded p-0.5 text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent"
title="Rename scene"
>
<Pencil className="size-3" />
</button>
{scenes.length > 1 && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); handleDeleteScene(scene.id) }}
className="rounded p-0.5 text-sidebar-foreground/50 hover:text-destructive hover:bg-sidebar-accent"
title="Delete scene"
>
<Trash2 className="size-3" />
</button>
)}
</div>
</div>
)}
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
) )
} }

View File

@@ -47,16 +47,40 @@ export function formatTimeSinceLastUpdate(updatedAt: number | undefined): string
export const RECOLLECTION_FILE_EXT = '.zui.json' export const RECOLLECTION_FILE_EXT = '.zui.json'
export const RECOLLECTION_VERSION = 1 export const RECOLLECTION_VERSION = 1
/** Id for a Flux scene. */
export type FluxSceneId = string
/** Metadata for one scene in the Flux scene tree. */
export type FluxSceneMeta = {
id: FluxSceneId
title: string
/** Order among scenes. */
position: number
}
export const DEFAULT_SCENE_ID: FluxSceneId = '_default'
const GRAPH_KEY_PREFIX = 'zui_graph_' const GRAPH_KEY_PREFIX = 'zui_graph_'
const FLUX_SCENE_TREE_PREFIX = 'zui_flux_scenes_'
const FLUX_SCENE_GRAPH_PREFIX = 'zui_flux_scene_'
const LOGOS_KEY_PREFIX = 'zui_logos_' const LOGOS_KEY_PREFIX = 'zui_logos_'
const LOGOS_PAGE_TREE_PREFIX = 'zui_logos_pagetree_' const LOGOS_PAGE_TREE_PREFIX = 'zui_logos_pagetree_'
const LOGOS_PAGE_CONTENT_PREFIX = 'zui_logos_page_' const LOGOS_PAGE_CONTENT_PREFIX = 'zui_logos_page_'
const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_' const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_'
/** Legacy single-graph key (pre-scenes). */
function getGraphKey(recollectionId: string): string { function getGraphKey(recollectionId: string): string {
return `${GRAPH_KEY_PREFIX}${recollectionId}` return `${GRAPH_KEY_PREFIX}${recollectionId}`
} }
function getFluxSceneTreeKey(recollectionId: string): string {
return `${FLUX_SCENE_TREE_PREFIX}${recollectionId}`
}
function getFluxSceneGraphKey(recollectionId: string, sceneId: FluxSceneId): string {
return `${FLUX_SCENE_GRAPH_PREFIX}${recollectionId}_${sceneId}`
}
function getLogosKey(recollectionId: string): string { function getLogosKey(recollectionId: string): string {
return `${LOGOS_KEY_PREFIX}${recollectionId}` return `${LOGOS_KEY_PREFIX}${recollectionId}`
} }
@@ -155,6 +179,82 @@ export function setGraph(recollectionId: string, state: StoredGraphState): void
localStorage.setItem(getGraphKey(recollectionId), JSON.stringify(state)) localStorage.setItem(getGraphKey(recollectionId), JSON.stringify(state))
} }
// ---------------------------------------------------------------------------
// Flux Scene Storage
// ---------------------------------------------------------------------------
/** Scene tree: ordered list of scene metas. */
export function getFluxSceneTree(recollectionId: string): FluxSceneMeta[] {
try {
const raw = localStorage.getItem(getFluxSceneTreeKey(recollectionId))
if (!raw) return []
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return []
return data.filter(
(item): item is FluxSceneMeta =>
item != null &&
typeof item === 'object' &&
typeof (item as FluxSceneMeta).id === 'string' &&
typeof (item as FluxSceneMeta).title === 'string' &&
typeof (item as FluxSceneMeta).position === 'number'
)
} catch {
return []
}
}
export function setFluxSceneTree(recollectionId: string, tree: FluxSceneMeta[]): void {
localStorage.setItem(getFluxSceneTreeKey(recollectionId), JSON.stringify(tree))
}
/** Per-scene graph data. */
export function getSceneGraph(recollectionId: string, sceneId: FluxSceneId): StoredGraphState | null {
try {
const raw = localStorage.getItem(getFluxSceneGraphKey(recollectionId, sceneId))
if (!raw) return null
const data = JSON.parse(raw) as unknown
if (
!data ||
typeof data !== 'object' ||
!Array.isArray((data as StoredGraphState).nodes) ||
!Array.isArray((data as StoredGraphState).edges)
)
return null
return data as StoredGraphState
} catch {
return null
}
}
export function setSceneGraph(recollectionId: string, sceneId: FluxSceneId, state: StoredGraphState): void {
localStorage.setItem(getFluxSceneGraphKey(recollectionId, sceneId), JSON.stringify(state))
}
export function removeSceneGraph(recollectionId: string, sceneId: FluxSceneId): void {
localStorage.removeItem(getFluxSceneGraphKey(recollectionId, sceneId))
}
/**
* Initialize scene tree for a recollection. If no scenes exist, migrate
* the legacy single graph into a '_default' scene.
*/
export function ensureFluxSceneTree(recollectionId: string): FluxSceneMeta[] {
let tree = getFluxSceneTree(recollectionId)
if (tree.length > 0) return tree
// Migrate legacy single graph to default scene
const legacyGraph = getGraph(recollectionId)
const defaultScene: FluxSceneMeta = { id: DEFAULT_SCENE_ID, title: 'Main', position: 0 }
tree = [defaultScene]
setFluxSceneTree(recollectionId, tree)
if (legacyGraph) {
setSceneGraph(recollectionId, DEFAULT_SCENE_ID, legacyGraph)
}
return tree
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Logos Storage // Logos Storage
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -270,14 +370,23 @@ export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOut
// Remove Recollection Data // Remove Recollection Data
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Removes both graph, logos (legacy + page tree + all per-page content), and render cache for the recollection. */ /** Removes all data for the recollection: graph, scenes, logos, and render cache. */
export function removeRecollectionData(recollectionId: string): void { export function removeRecollectionData(recollectionId: string): void {
// Legacy graph
localStorage.removeItem(getGraphKey(recollectionId)) localStorage.removeItem(getGraphKey(recollectionId))
// Flux scenes
const scenes = getFluxSceneTree(recollectionId)
for (const scene of scenes) {
removeSceneGraph(recollectionId, scene.id)
}
localStorage.removeItem(getFluxSceneTreeKey(recollectionId))
// Logos
localStorage.removeItem(getLogosKey(recollectionId)) localStorage.removeItem(getLogosKey(recollectionId))
const tree = getLogosPageTree(recollectionId) const logosTree = getLogosPageTree(recollectionId)
for (const page of tree) { for (const page of logosTree) {
removeLogosPageContent(recollectionId, page.id) removeLogosPageContent(recollectionId, page.id)
} }
localStorage.removeItem(getLogosPageTreeKey(recollectionId)) localStorage.removeItem(getLogosPageTreeKey(recollectionId))
// Render cache
localStorage.removeItem(getRenderCacheKey(recollectionId)) localStorage.removeItem(getRenderCacheKey(recollectionId))
} }

View File

@@ -91,12 +91,13 @@ export function useRunStream() {
export async function createAndStreamRun( export async function createAndStreamRun(
recollectionId: string, recollectionId: string,
graph: { nodes: unknown[]; edges: unknown[] }, graph: { nodes: unknown[]; edges: unknown[] },
connectToRun: (runId: string) => void connectToRun: (runId: string) => void,
sceneId?: string
): Promise<void> { ): Promise<void> {
const res = await fetch('/api/runs', { const res = await fetch('/api/runs', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recollectionId, graph }), body: JSON.stringify({ recollectionId, sceneId, graph }),
}) })
if (!res.ok) { if (!res.ok) {