Compare commits

...

3 Commits

Author SHA1 Message Date
56fa8126f3 feat: sidebar 2026-03-29 09:13:18 +02:00
7ced2172af feat: multiple scenes 2026-03-29 01:53:15 +01:00
9607ff3478 feat: runnersok 2026-03-29 01:22:33 +01:00
35 changed files with 2823 additions and 788 deletions

6
.gitignore vendored
View File

@@ -40,6 +40,12 @@ Thumbs.db
.cache .cache
.parcel-cache .parcel-cache
# SQLite data
data/
*.db
*.db-wal
*.db-shm
# Docker (optional local overrides) # Docker (optional local overrides)
docker-compose.override.yml docker-compose.override.yml
docker-compose.override.yaml docker-compose.override.yaml

View File

@@ -1,108 +0,0 @@
# Performance Improvements Plan
## 1. Current State
The core rendering and state management architecture is already well-structured for performance:
- **Command/reducer store** — all mutations go through a pure reducer; individual selectors can prevent unnecessary re-renders.
- **Delta-based undo/redo** — only diffs are stored, not full graph snapshots (max 100 entries).
- **Plugin node registry** — node types are loaded once at app init, not dynamically on each render.
- **Streaming AI responses** — `POST /api/agent/stream` uses SSE so the UI updates incrementally.
The sections below identify remaining bottlenecks and concrete next steps.
---
## 2. Known Bottlenecks
| # | Issue | Location | Impact | Root Cause |
| --- | --- | --- | --- | --- |
| 1 | Unguarded re-renders on canvas | `CanvasPage.tsx` | Frame drops on large graphs | Components not subscribed to granular store slices |
| 2 | Nunjucks template resolution on every render | `rendering.ts` resolve step | Slow Config node updates | No memoization of template output keyed to input hash |
| 3 | Kroki SVG requests not deduplicated | `rendering.ts` render step | Redundant network calls | No in-flight request deduplication or client-side cache |
| 4 | Sidebar tree renders all items | `KosmosPage` recollection tree | Scrolling lag with many workspaces | No list virtualization |
| 5 | Backend cache not wired to agent routes | `agentRoutes.ts` | Repeated identical LLM calls | `InMemoryCache` and `rateLimiter` exist but are unused |
| 6 | Full graph serialized to localStorage on every change | `useGraphStateWithHistory` | Storage I/O on every keypress | No debounce on the persistence write |
| 7 | Initial bundle size | Vite build | Slow first load | Heavy deps (BlockNote, React Flow, Nunjucks) loaded eagerly |
---
## 3. Recommended Improvements
### 3.1 Granular Store Subscriptions
Zustand supports slice-level subscriptions. Node components should select only their own data slice:
```ts
// Instead of subscribing to the entire graph:
const node = useCanvasStore(s => s.graph.nodes.find(n => n.id === id))
```
This prevents all nodes from re-rendering when a single node changes.
### 3.2 Memoize Template Resolution
Cache the Nunjucks resolution output keyed to a hash of the template source plus variable inputs. Invalidate only when those inputs change:
```ts
const resolved = useMemo(
() => resolveTemplate(template, variables),
[templateHash, variableHash]
)
```
### 3.3 Deduplicate Kroki Requests
Add a simple in-flight map in the render step: if a request for the same PlantUML source is already pending, reuse its promise. Cache successful responses keyed to the source string with a short TTL (e.g. 5 minutes).
### 3.4 Wire Backend Cache and Rate Limiter
`InMemoryCache` and `rateLimiter` middleware are implemented in `backend/src/`. Connect them to `agentRoutes.ts`:
1. Add cache lookup before calling the AI service.
2. Store the response on cache miss.
3. Apply rate limiting per IP to prevent abuse.
### 3.5 Debounce localStorage Writes
Wrap the graph persistence call in a debounce (e.g., 300 ms) to avoid a write on every keystroke or node drag. The delta-based history already computes minimal diffs; the bottleneck is the frequency of writes.
### 3.6 Virtualize the Sidebar Tree
Integrate `react-arborist` (already installed) with virtualization enabled for the recollection sidebar when item count exceeds a threshold (~50).
### 3.7 Code Split Heavy Routes
Add lazy imports for the three heavy route components so the initial bundle only loads what the user navigates to:
```ts
const FluxRoute = lazy(() => import('./app/recollections/flux/FluxRoute'))
const LogosPage = lazy(() => import('./app/recollections/logos/LogosPage'))
const KatalogosPage = lazy(() => import('./app/recollections/katalogos/KatalogosPage'))
```
### 3.8 Enable Brotli Compression in Nginx
Add brotli/gzip compression to `frontend/nginx.conf` for JS, CSS, and SVG assets. This can cut transfer size by 6070% for the JS bundle.
---
## 4. Success Metrics
| Metric | Current (estimated) | Target |
| --- | --- | --- |
| Frame time on 50-node canvas | ~16 ms | < 10 ms |
| Initial JS bundle (gzipped) | ~800 KB | < 600 KB |
| Repeated identical LLM calls | uncached | 0 network round-trips |
| localStorage write frequency | every change | debounced 300 ms |
---
## 5. Contribution Path
1. Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the module you're optimizing.
2. Pick one item from section 2.
3. Add a Vitest benchmark (`performance.now()` before/after) alongside your change.
4. Submit a PR with the benchmark results in the description and update this file's "Current" column.
*This plan is a living document; update the metrics table when improvements land.*

View File

@@ -10,10 +10,12 @@
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^1.0.0", "@ai-sdk/openai": "^1.0.0",
"ai": "^4.0.0", "ai": "^4.0.0",
"better-sqlite3": "^12.8.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.0" "express": "^4.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
@@ -561,6 +563,16 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -720,6 +732,60 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.8.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -744,6 +810,30 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -794,6 +884,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -856,6 +952,30 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -884,6 +1004,15 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/diff-match-patch": { "node_modules/diff-match-patch": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
@@ -919,6 +1048,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1006,6 +1144,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -1052,6 +1199,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -1088,6 +1241,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1162,6 +1321,12 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
} }
}, },
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1230,12 +1395,38 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1337,6 +1528,33 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -1361,6 +1579,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1370,6 +1594,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-abi": {
"version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1403,6 +1639,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1418,6 +1663,33 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1431,6 +1703,16 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.2", "version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -1470,6 +1752,21 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -1480,6 +1777,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/resolve-pkg-maps": { "node_modules/resolve-pkg-maps": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -1522,6 +1833,18 @@
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": { "node_modules/send": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -1645,6 +1968,51 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1654,6 +2022,24 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/swr": { "node_modules/swr": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz",
@@ -1667,6 +2053,34 @@
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/throttleit": { "node_modules/throttleit": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
@@ -1708,6 +2122,18 @@
"fsevents": "~2.3.3" "fsevents": "~2.3.3"
} }
}, },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1760,6 +2186,12 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -1778,6 +2210,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View File

@@ -14,12 +14,14 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"ai": "^4.0.0",
"@ai-sdk/openai": "^1.0.0", "@ai-sdk/openai": "^1.0.0",
"ai": "^4.0.0",
"better-sqlite3": "^12.8.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.0" "express": "^4.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",

View File

@@ -0,0 +1,79 @@
/**
* SQLite database connection singleton.
* Uses better-sqlite3 for synchronous, fast access.
* DB_PATH env var controls location; defaults to ./data/zui.db
*/
import Database from 'better-sqlite3'
import path from 'node:path'
import fs from 'node:fs'
const DB_PATH = process.env.DB_PATH || path.resolve('data', 'zui.db')
let db: Database.Database | null = null
export function getDb(): Database.Database {
if (db) return db
// Ensure parent directory exists
const dir = path.dirname(DB_PATH)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
initSchema(db)
migrateSchema(db)
return db
}
function initSchema(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY,
recollectionId TEXT NOT NULL,
sceneId TEXT,
status TEXT NOT NULL DEFAULT 'pending',
graphSnapshot TEXT NOT NULL,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_runs_recollection
ON runs(recollectionId, createdAt DESC);
CREATE TABLE IF NOT EXISTS run_steps (
id TEXT PRIMARY KEY,
runId TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
nodeId TEXT NOT NULL,
nodeType TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
input TEXT,
output TEXT,
error TEXT,
startedAt TEXT,
endedAt TEXT,
sortOrder INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_run_steps_run
ON run_steps(runId, sortOrder);
`)
}
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 {
if (db) {
db.close()
db = null
}
}

View File

@@ -61,6 +61,8 @@ app.use(express.json())
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import { handleAgentRequest, handleAgentStreamRequest } from './routes/agentRoutes.js' import { handleAgentRequest, handleAgentStreamRequest } from './routes/agentRoutes.js'
import { handleCreateRun, handleRunStream, handleGetRun, handleListRuns } from './routes/runRoutes.js'
import { getDb } from './db/connection.js'
/** POST /api/agent - Run AI agent */ /** POST /api/agent - Run AI agent */
app.post('/api/agent', handleAgentRequest) app.post('/api/agent', handleAgentRequest)
@@ -68,6 +70,22 @@ app.post('/api/agent', handleAgentRequest)
/** POST /api/agent/stream - Stream AI agent response */ /** POST /api/agent/stream - Stream AI agent response */
app.post('/api/agent/stream', handleAgentStreamRequest) app.post('/api/agent/stream', handleAgentStreamRequest)
// ---------------------------------------------------------------------------
// Run Routes
// ---------------------------------------------------------------------------
/** POST /api/runs - Create and queue a graph execution run */
app.post('/api/runs', handleCreateRun)
/** GET /api/runs/:id/stream - SSE stream of run execution events */
app.get('/api/runs/:id/stream', handleRunStream)
/** GET /api/runs/:id - Get run details with steps */
app.get('/api/runs/:id', handleGetRun)
/** GET /api/recollections/:recollectionId/runs - List runs for a recollection */
app.get('/api/recollections/:recollectionId/runs', handleListRuns)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Health Check Endpoint // Health Check Endpoint
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -91,6 +109,9 @@ app.use((err: Error, req: express.Request, res: express.Response, next: express.
// Start Server // Start Server
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Initialize database on startup
getDb()
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`Backend listening on port ${PORT} (CORS: ${CORS_ORIGIN})`) console.log(`Backend listening on port ${PORT} (CORS: ${CORS_ORIGIN})`)
}) })

68
backend/src/models/run.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* Run and RunStep domain types for graph execution.
*/
export type RunStatus = 'pending' | 'running' | 'completed' | 'failed'
export type Run = {
id: string
recollectionId: string
sceneId?: string | null
status: RunStatus
graphSnapshot: string
createdAt: string
updatedAt: string
error?: string | null
}
export type RunStep = {
id: string
runId: string
nodeId: string
nodeType: string
status: RunStatus
input?: string | null
output?: string | null
error?: string | null
startedAt?: string | null
endedAt?: string | null
sortOrder: number
}
/** SSE event types for run streaming */
export type RunEventType =
| 'connected'
| 'run/started'
| 'run/completed'
| 'run/failed'
| 'step/started'
| 'step/completed'
| 'step/failed'
| 'step/chunk'
| 'ping'
export type RunEvent = {
type: RunEventType
data: Record<string, unknown>
}
/** Graph snapshot types (subset of frontend StoredGraphState) */
export type GraphNode = {
id: string
type?: string
data?: Record<string, unknown>
position?: { x: number; y: number }
[key: string]: unknown
}
export type GraphEdge = {
id: string
source: string
target: string
[key: string]: unknown
}
export type GraphSnapshot = {
nodes: GraphNode[]
edges: GraphEdge[]
}

View File

@@ -0,0 +1,62 @@
/**
* Data access layer for runs and run_steps.
*/
import { getDb } from '../db/connection.js'
import type { Run, RunStep, RunStatus } from '../models/run.js'
export function createRun(run: Run): void {
const db = getDb()
db.prepare(`
INSERT INTO runs (id, recollectionId, sceneId, status, graphSnapshot, createdAt, updatedAt, error)
VALUES (@id, @recollectionId, @sceneId, @status, @graphSnapshot, @createdAt, @updatedAt, @error)
`).run(run)
}
export function getRun(id: string): Run | undefined {
const db = getDb()
return db.prepare('SELECT * FROM runs WHERE id = ?').get(id) as Run | undefined
}
export function updateRunStatus(id: string, status: RunStatus, error?: string): void {
const db = getDb()
db.prepare(`
UPDATE runs SET status = ?, error = ?, updatedAt = datetime('now') WHERE id = ?
`).run(status, error ?? null, id)
}
export function getRunsByRecollection(recollectionId: string, limit = 50, offset = 0): Run[] {
const db = getDb()
return db.prepare(`
SELECT * FROM runs WHERE recollectionId = ? ORDER BY createdAt DESC LIMIT ? OFFSET ?
`).all(recollectionId, limit, offset) as Run[]
}
export function createRunStep(step: RunStep): void {
const db = getDb()
db.prepare(`
INSERT INTO run_steps (id, runId, nodeId, nodeType, status, input, output, error, startedAt, endedAt, sortOrder)
VALUES (@id, @runId, @nodeId, @nodeType, @status, @input, @output, @error, @startedAt, @endedAt, @sortOrder)
`).run(step)
}
export function updateRunStep(id: string, updates: Partial<Pick<RunStep, 'status' | 'output' | 'error' | 'startedAt' | 'endedAt'>>): void {
const db = getDb()
const fields: string[] = []
const values: unknown[] = []
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status) }
if (updates.output !== undefined) { fields.push('output = ?'); values.push(updates.output) }
if (updates.error !== undefined) { fields.push('error = ?'); values.push(updates.error) }
if (updates.startedAt !== undefined) { fields.push('startedAt = ?'); values.push(updates.startedAt) }
if (updates.endedAt !== undefined) { fields.push('endedAt = ?'); values.push(updates.endedAt) }
if (fields.length === 0) return
values.push(id)
db.prepare(`UPDATE run_steps SET ${fields.join(', ')} WHERE id = ?`).run(...values)
}
export function getRunSteps(runId: string): RunStep[] {
const db = getDb()
return db.prepare('SELECT * FROM run_steps WHERE runId = ? ORDER BY sortOrder').all(runId) as RunStep[]
}

View File

@@ -0,0 +1,128 @@
/**
* Run routes: create runs, stream execution, list history.
*/
import crypto from 'node:crypto'
import type { Request, Response } from 'express'
import type { Run, RunEvent } from '../models/run.js'
import * as runRepo from '../repositories/runRepository.js'
import { executeRun } from '../services/graphRunnerService.js'
/**
* POST /api/runs
* Body: { recollectionId, graph: { nodes, edges } }
* Creates a new run and starts execution, returning the run ID immediately.
* Client should connect to GET /api/runs/:id/stream for live updates.
*/
export async function handleCreateRun(req: Request, res: Response): Promise<void> {
try {
const { recollectionId, sceneId, graph } = req.body
if (!recollectionId || typeof recollectionId !== 'string') {
res.status(400).json({ error: 'recollectionId is required' })
return
}
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
res.status(400).json({ error: 'graph with nodes and edges is required' })
return
}
const run: Run = {
id: crypto.randomUUID(),
recollectionId,
sceneId: typeof sceneId === 'string' ? sceneId : null,
status: 'pending',
graphSnapshot: JSON.stringify(graph),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
error: null,
}
runRepo.createRun(run)
res.status(201).json({ id: run.id, status: run.status })
} catch (err) {
console.error('Create run error:', err)
res.status(500).json({ error: (err as Error).message ?? 'Failed to create run' })
}
}
/**
* GET /api/runs/:id/stream
* SSE endpoint that executes the run and streams events in real time.
*/
export async function handleRunStream(req: Request, res: Response): Promise<void> {
const { id } = req.params
const run = runRepo.getRun(id)
if (!run) {
res.status(404).json({ error: 'Run not found' })
return
}
if (run.status !== 'pending') {
res.status(409).json({ error: `Run already ${run.status}` })
return
}
// Set up SSE headers
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
res.flushHeaders()
const emit = (event: RunEvent) => {
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`)
}
emit({ type: 'connected', data: { runId: id } })
// Keepalive ping
const pingInterval = setInterval(() => {
emit({ type: 'ping', data: { timestamp: Date.now() } })
}, 15_000)
req.on('close', () => {
clearInterval(pingInterval)
})
try {
await executeRun(id, emit)
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err)
emit({ type: 'run/failed', data: { runId: id, error: errorMsg } })
runRepo.updateRunStatus(id, 'failed', errorMsg)
} finally {
clearInterval(pingInterval)
res.end()
}
}
/**
* GET /api/runs/:id
* Returns run details with steps.
*/
export async function handleGetRun(req: Request, res: Response): Promise<void> {
const { id } = req.params
const run = runRepo.getRun(id)
if (!run) {
res.status(404).json({ error: 'Run not found' })
return
}
const steps = runRepo.getRunSteps(id)
res.json({ ...run, steps })
}
/**
* GET /api/recollections/:recollectionId/runs
* Returns run history for a recollection.
*/
export async function handleListRuns(req: Request, res: Response): Promise<void> {
const { recollectionId } = req.params
const limit = Math.min(Number(req.query.limit) || 50, 200)
const offset = Number(req.query.offset) || 0
const runs = runRepo.getRunsByRecollection(recollectionId, limit, offset)
res.json({ runs })
}

View File

@@ -0,0 +1,237 @@
/**
* Graph execution engine.
* Topologically sorts graph nodes (Kahn's algorithm) and executes them in order.
* Emits SSE events via a callback for real-time streaming to clients.
*/
import crypto from 'node:crypto'
import type { GraphSnapshot, GraphNode, GraphEdge, RunEvent, RunStep, RunStatus } from '../models/run.js'
import * as runRepo from '../repositories/runRepository.js'
import { buildAgentRequest } from './agentService.js'
type EmitFn = (event: RunEvent) => void
/**
* Topological sort using Kahn's algorithm.
* Returns ordered node IDs or throws on cycle detection.
*/
function topologicalSort(nodes: GraphNode[], edges: GraphEdge[]): string[] {
const nodeIds = new Set(nodes.map((n) => n.id))
const inDegree = new Map<string, number>()
const adjacency = new Map<string, string[]>()
for (const id of nodeIds) {
inDegree.set(id, 0)
adjacency.set(id, [])
}
for (const edge of edges) {
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) continue
adjacency.get(edge.source)!.push(edge.target)
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1)
}
const queue: string[] = []
for (const [id, deg] of inDegree) {
if (deg === 0) queue.push(id)
}
const sorted: string[] = []
while (queue.length > 0) {
const current = queue.shift()!
sorted.push(current)
for (const neighbor of adjacency.get(current) ?? []) {
const newDeg = (inDegree.get(neighbor) ?? 1) - 1
inDegree.set(neighbor, newDeg)
if (newDeg === 0) queue.push(neighbor)
}
}
if (sorted.length !== nodeIds.size) {
throw new Error('Cycle detected in graph')
}
return sorted
}
/** Node types that produce output when executed */
const EXECUTABLE_TYPES = new Set(['agent', 'config', 'render'])
/** Node types that are pure inputs (no execution needed) */
const INPUT_TYPES = new Set(['variable', 'data', 'function'])
/**
* Execute a single node based on its type.
* Returns the output string or null for input-only nodes.
*/
async function executeNode(
node: GraphNode,
_edges: GraphEdge[],
_nodes: GraphNode[],
_outputs: Map<string, string>,
emit: EmitFn
): Promise<string | null> {
const nodeType = node.type ?? 'unknown'
if (INPUT_TYPES.has(nodeType)) {
// Input nodes: extract their value as output
const data = node.data ?? {}
if (nodeType === 'variable') {
const v = data.value
return v === undefined || v === null ? '' : String(v)
}
if (nodeType === 'data') {
const rows = (data.rows as Record<string, string>[]) ?? []
return JSON.stringify(rows)
}
if (nodeType === 'function') {
return (data.body as string) ?? ''
}
return null
}
if (nodeType === 'agent') {
// Agent nodes: call the AI service
const configContents: string[] = []
for (const edge of _edges) {
if (edge.target !== node.id) continue
const output = _outputs.get(edge.source)
if (output) configContents.push(output)
}
const prompt = configContents.join('\n\n---\n\n') || 'No prompt provided.'
const connection = (node.data?.connection as Record<string, unknown>) ?? undefined
const built = buildAgentRequest({
prompt,
connection: connection as any,
})
if ('error' in built) {
throw new Error(built.error)
}
// Use streaming for agent nodes
const { streamText } = await import('ai')
const result = streamText({
model: built.openai as any,
prompt: built.fullPrompt,
})
let fullText = ''
for await (const chunk of (await result).textStream) {
fullText += chunk
emit({
type: 'step/chunk',
data: { nodeId: node.id, chunk },
})
}
return fullText
}
if (nodeType === 'config') {
// Config nodes: return their template content as-is during execution
// (Nunjucks resolution happens on the frontend; backend treats as passthrough)
const content = (node.data?.content as string) ?? ''
return content
}
if (nodeType === 'render') {
// Render nodes: collect input from connected source nodes
const inputs: string[] = []
for (const edge of _edges) {
if (edge.target !== node.id) continue
const output = _outputs.get(edge.source)
if (output) inputs.push(output)
}
return inputs.join('\n\n')
}
return null
}
/**
* Run the full graph execution for a given run ID.
* Emits SSE events for each step.
*/
export async function executeRun(runId: string, emit: EmitFn): Promise<void> {
const run = runRepo.getRun(runId)
if (!run) throw new Error(`Run not found: ${runId}`)
let graph: GraphSnapshot
try {
graph = JSON.parse(run.graphSnapshot) as GraphSnapshot
} catch {
throw new Error('Invalid graph snapshot')
}
const { nodes, edges } = graph
// Sort nodes topologically
const sortedIds = topologicalSort(nodes, edges)
const nodeMap = new Map(nodes.map((n) => [n.id, n]))
const outputs = new Map<string, string>()
// Create run_steps records
const steps: RunStep[] = sortedIds.map((nodeId, i) => {
const node = nodeMap.get(nodeId)!
return {
id: crypto.randomUUID(),
runId,
nodeId,
nodeType: node.type ?? 'unknown',
status: 'pending' as RunStatus,
input: null,
output: null,
error: null,
startedAt: null,
endedAt: null,
sortOrder: i,
}
})
for (const step of steps) {
runRepo.createRunStep(step)
}
// Mark run as running
runRepo.updateRunStatus(runId, 'running')
const stepNodeIds = steps.map((s) => s.nodeId)
emit({ type: 'run/started', data: { runId, totalSteps: steps.length, nodeIds: stepNodeIds } })
// Execute each node in order
for (const step of steps) {
const node = nodeMap.get(step.nodeId)
if (!node) continue
const now = new Date().toISOString()
runRepo.updateRunStep(step.id, { status: 'running', startedAt: now })
emit({ type: 'step/started', data: { stepId: step.id, nodeId: step.nodeId, nodeType: step.nodeType } })
try {
const output = await executeNode(node, edges, nodes, outputs, emit)
if (output !== null) {
outputs.set(node.id, output)
}
const endedAt = new Date().toISOString()
runRepo.updateRunStep(step.id, { status: 'completed', output: output ?? '', endedAt })
emit({ type: 'step/completed', data: { stepId: step.id, nodeId: step.nodeId, output: output?.slice(0, 500) ?? '' } })
} catch (err) {
const endedAt = new Date().toISOString()
const errorMsg = err instanceof Error ? err.message : String(err)
runRepo.updateRunStep(step.id, { status: 'failed', error: errorMsg, endedAt })
emit({ type: 'step/failed', data: { stepId: step.id, nodeId: step.nodeId, error: errorMsg } })
// Fail the entire run
runRepo.updateRunStatus(runId, 'failed', errorMsg)
emit({ type: 'run/failed', data: { runId, error: errorMsg } })
return
}
}
// Mark run as completed
runRepo.updateRunStatus(runId, 'completed')
emit({ type: 'run/completed', data: { runId } })
}

View File

@@ -68,6 +68,8 @@ import {
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import { toast } from 'sonner' import { toast } from 'sonner'
import { RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage' import { RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
import { useRunStream, createAndStreamRun } from '@/hooks/useRunStream'
import { useRunStore } from '@/lib/graph/runStore'
const SNAP_GRID: [number, number] = [15, 15] const SNAP_GRID: [number, number] = [15, 15]
const DUPLICATE_OFFSET = { x: 30, y: 30 } const DUPLICATE_OFFSET = { x: 30, y: 30 }
@@ -200,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 {
@@ -223,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)
@@ -257,6 +261,33 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
const connectionPath = useCanvasConnectionPathFromStore() const connectionPath = useCanvasConnectionPathFromStore()
// --- Run execution ---
const { connectToRun } = useRunStream()
const runStatus = useRunStore((s) => s.status)
const resetRun = useRunStore((s) => s.reset)
const markDirty = useRunStore((s) => s.markDirty)
// Mark run state as dirty when graph structure or data changes
const prevNodesLenRef = useRef(nodes.length)
const prevEdgesLenRef = useRef(edges.length)
useEffect(() => {
// Skip the initial render
if (prevNodesLenRef.current === nodes.length && prevEdgesLenRef.current === edges.length) return
prevNodesLenRef.current = nodes.length
prevEdgesLenRef.current = edges.length
markDirty()
}, [nodes.length, edges.length, markDirty])
const handleRun = useCallback(() => {
if (!recollectionId) return
if (runStatus === 'running' || runStatus === 'pending') return
// Reset previous run state, save, then run
resetRun()
save()
createAndStreamRun(recollectionId, { nodes, edges }, connectToRun, sceneId).catch((err) => {
toast.error(`Run failed: ${err.message}`)
})
}, [recollectionId, sceneId, nodes, edges, connectToRun, save, runStatus, resetRun])
const nodesRef = useRef(nodes) const nodesRef = useRef(nodes)
nodesRef.current = nodes nodesRef.current = nodes
const graphRef = useRef<{ nodes: AppNode[]; edges: AppEdge[] }>({ nodes: [], edges: [] }) const graphRef = useRef<{ nodes: AppNode[]; edges: AppEdge[] }>({ nodes: [], edges: [] })
@@ -464,6 +495,7 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
canDuplicate: selectedNodes.length > 0, canDuplicate: selectedNodes.length > 0,
canCopy: selectedNodes.length === 1, canCopy: selectedNodes.length === 1,
onFitView: () => flowActionsRef.current?.fitView?.(), onFitView: () => flowActionsRef.current?.fitView?.(),
onRun: recollectionId ? handleRun : undefined,
} }
setFluxSlot(slot) setFluxSlot(slot)
return () => setFluxSlot(null) return () => setFluxSlot(null)
@@ -481,6 +513,7 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
handleCopy, handleCopy,
handlePaste, handlePaste,
selectedNodes.length, selectedNodes.length,
handleRun,
]) ])
const graphContextValue = useMemo( const graphContextValue = useMemo(

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

@@ -7,7 +7,7 @@ import { Outlet, useParams, useNavigate } from 'react-router-dom'
import { toast } from 'sonner' 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 { WorkspaceTreeProvider } from './layout/WorkspaceTreeContext'
import { RecollectionMenubar } from './layout/RecollectionMenubar' import { RecollectionMenubar } from './layout/RecollectionMenubar'
import { RecollectionSidebar } from './layout/RecollectionSidebar' import { RecollectionSidebar } from './layout/RecollectionSidebar'
@@ -49,7 +49,7 @@ export function RecollectionLayout() {
return ( return (
<RecollectionActionsProvider> <RecollectionActionsProvider>
<RecollectionSidebarProvider> <WorkspaceTreeProvider>
<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,7 +59,7 @@ export function RecollectionLayout() {
</div> </div>
</div> </div>
</div> </div>
</RecollectionSidebarProvider> </WorkspaceTreeProvider>
</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 '../layout/WorkspaceTreeContext'
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

@@ -3,17 +3,19 @@
* Shows live \"Artifacts\" from Flux rendering nodes in a card grid. * Shows live \"Artifacts\" from Flux rendering nodes in a card grid.
*/ */
import React, { useMemo } from 'react' import React, { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { usePlatform } from '@/app/kosmos/KosmosContext' import { usePlatform } from '@/app/kosmos/KosmosContext'
import { getRenderOutputCache, formatTimeSinceLastUpdate } from '../state/recollectionStore' import { getRenderOutputCache, formatTimeSinceLastUpdate } from '../state/recollectionStore'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { RunsTab } from './RunsTab'
export function KatalogosPage() { export function KatalogosPage() {
const { recollectionId } = useParams<{ recollectionId: string }>() const { recollectionId } = useParams<{ recollectionId: string }>()
const { recollections } = usePlatform() const { recollections } = usePlatform()
const navigate = useNavigate() const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<'artifacts' | 'runs'>('artifacts')
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
const title = recollection?.name ?? 'Untitled' const title = recollection?.name ?? 'Untitled'
@@ -33,10 +35,25 @@ export function KatalogosPage() {
<h1 className="mb-2 truncate font-serif text-2xl font-semibold tracking-tight text-foreground"> <h1 className="mb-2 truncate font-serif text-2xl font-semibold tracking-tight text-foreground">
{title} {title}
</h1> </h1>
<p className="mb-6 text-sm text-muted-foreground"> <div className="mb-4 flex gap-1 border-b border-border">
Katalogos · Live artifacts produced by Flux rendering nodes for this recollection. <button
</p> type="button"
{artifacts.length === 0 ? ( onClick={() => setActiveTab('artifacts')}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'artifacts' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Artifacts
</button>
<button
type="button"
onClick={() => setActiveTab('runs')}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'runs' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Runs
</button>
</div>
{activeTab === 'runs' ? (
<RunsTab />
) : artifacts.length === 0 ? (
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/10 p-4 text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/10 p-4 text-sm text-muted-foreground">
No artifacts yet. In Flux, run a graph with a rendering node; its output will be cached as an artifact and No artifacts yet. In Flux, run a graph with a rendering node; its output will be cached as an artifact and
appear here, as well as in Logos blocks that insert artifacts. appear here, as well as in Logos blocks that insert artifacts.

View File

@@ -0,0 +1,139 @@
/**
* Runs tab for Katalogos: shows execution history for the current recollection.
*/
import React, { useEffect, useState, useCallback } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle2, XCircle, Clock, Loader2 } from 'lucide-react'
type RunSummary = {
id: string
sceneId?: string | null
status: string
createdAt: string
updatedAt: string
error?: string | null
}
type RunDetail = RunSummary & {
steps: Array<{
id: string
nodeId: string
nodeType: string
status: string
error?: string | null
startedAt?: string | null
endedAt?: string | null
}>
}
const statusIcon: Record<string, React.ReactNode> = {
pending: <Clock className="size-3.5 text-muted-foreground" />,
running: <Loader2 className="size-3.5 animate-spin text-blue-500" />,
completed: <CheckCircle2 className="size-3.5 text-emerald-500" />,
failed: <XCircle className="size-3.5 text-destructive" />,
}
export function RunsTab() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const [runs, setRuns] = useState<RunSummary[]>([])
const [loading, setLoading] = useState(true)
const [expandedRunId, setExpandedRunId] = useState<string | null>(null)
const [runDetail, setRunDetail] = useState<RunDetail | null>(null)
const fetchRuns = useCallback(async () => {
if (!recollectionId) return
setLoading(true)
try {
const res = await fetch(`/api/recollections/${recollectionId}/runs`)
if (res.ok) {
const data = await res.json()
setRuns(data.runs)
}
} catch {
// silently fail
} finally {
setLoading(false)
}
}, [recollectionId])
useEffect(() => { fetchRuns() }, [fetchRuns])
const toggleExpand = async (runId: string) => {
if (expandedRunId === runId) {
setExpandedRunId(null)
setRunDetail(null)
return
}
setExpandedRunId(runId)
try {
const res = await fetch(`/api/runs/${runId}`)
if (res.ok) {
const data = await res.json()
setRunDetail(data)
}
} catch {
// silently fail
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
Loading runs
</div>
)
}
if (runs.length === 0) {
return (
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/10 p-4 text-sm text-muted-foreground">
No runs yet. Use the Run button in Flux to execute your graph.
</div>
)
}
return (
<div className="flex flex-col gap-2">
{runs.map((run) => (
<div key={run.id} className="rounded-lg border border-border bg-card text-card-foreground shadow-sm">
<button
type="button"
onClick={() => toggleExpand(run.id)}
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left text-sm hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-2">
{statusIcon[run.status] ?? statusIcon.pending}
<span className="font-medium capitalize">{run.status}</span>
{run.sceneId && (
<span className="text-xs text-muted-foreground">({run.sceneId})</span>
)}
</div>
<span className="text-xs text-muted-foreground">
{new Date(run.createdAt).toLocaleString()}
</span>
</button>
{expandedRunId === run.id && runDetail && (
<div className="border-t border-border/60 px-3 py-2">
{run.error && (
<p className="mb-2 text-xs text-destructive">{run.error}</p>
)}
<div className="flex flex-col gap-1">
{runDetail.steps.map((step) => (
<div key={step.id} className="flex items-center gap-2 rounded px-2 py-1 text-xs">
{statusIcon[step.status] ?? statusIcon.pending}
<span className="font-mono text-muted-foreground">{step.nodeId.slice(0, 8)}</span>
<span className="capitalize text-muted-foreground">{step.nodeType}</span>
<span className="capitalize">{step.status}</span>
{step.error && <span className="truncate text-destructive">{step.error}</span>}
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
)
}

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 = {
@@ -48,6 +55,7 @@ export type FluxSlot = {
canDuplicate?: boolean canDuplicate?: boolean
canCopy?: boolean canCopy?: boolean
onFitView?: () => void onFitView?: () => void
onRun?: () => void
} }
export type LogosSlot = { export type LogosSlot = {
@@ -103,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
} }
@@ -150,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

@@ -14,7 +14,8 @@ import {
} from '@/components/ui/menubar' } from '@/components/ui/menubar'
import { Kbd, KbdGroup } from '@/components/ui/kbd' import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { useRecollectionActions } from './RecollectionActionsContext' import { useRecollectionActions } from './RecollectionActionsContext'
import { ClipboardPaste, Copy, CopyPlus, Redo2, Undo2 } from 'lucide-react' import { ClipboardPaste, Copy, CopyPlus, Redo2, Undo2, Play, Square } from 'lucide-react'
import { useRunStore } from '@/lib/graph/runStore'
const UNDO_KEYS = { key: 'z', shiftKey: false } const UNDO_KEYS = { key: 'z', shiftKey: false }
const REDO_KEYS = { key: 'z', shiftKey: true } const REDO_KEYS = { key: 'z', shiftKey: true }
@@ -26,8 +27,28 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
export function RecollectionEditViewMenus() { export function RecollectionEditViewMenus() {
const { activeSlot, flux, isFluxActive } = useRecollectionActions() const { activeSlot, flux, isFluxActive } = useRecollectionActions()
const runStatus = useRunStore((s) => s.status)
const resetRun = useRunStore((s) => s.reset)
const isDirty = useRunStore((s) => s.dirty)
const fluxSlot = isFluxActive ? flux : null const fluxSlot = isFluxActive ? flux : null
const isRunning = runStatus === 'running' || runStatus === 'pending'
const hasFinished = runStatus === 'completed' || runStatus === 'failed'
// Keyboard shortcut: Cmd+Enter to run
useEffect(() => {
if (!fluxSlot?.onRun) return
const onKeyDown = (ev: KeyboardEvent) => {
const mod = ev.ctrlKey || ev.metaKey
if (mod && ev.key === 'Enter' && !isRunning) {
ev.preventDefault()
ev.stopPropagation()
fluxSlot.onRun?.()
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [fluxSlot?.onRun, isRunning])
useEffect(() => { useEffect(() => {
if (!activeSlot) return if (!activeSlot) return
@@ -150,5 +171,40 @@ export function RecollectionEditViewMenus() {
[activeSlot, fluxSlot, hasFluxOnly] [activeSlot, fluxSlot, hasFluxOnly]
) )
return menus return (
<div className="flex items-center gap-1">
{menus}
{fluxSlot?.onRun && (
<button
type="button"
onClick={fluxSlot.onRun}
disabled={isRunning}
className={`relative flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium shadow-sm transition-colors disabled:cursor-not-allowed ${
isRunning
? 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400'
: isDirty
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
: 'border-border/60 bg-card text-foreground hover:bg-accent'
}`}
aria-label={isRunning ? 'Run in progress' : isDirty ? 'Changes pending — run graph (⌘↵)' : 'Run graph (⌘↵)'}
title="⌘↵"
>
{isRunning ? (
<>
<Square className="size-3 fill-current" />
<span>Running</span>
</>
) : (
<>
<Play className="size-3 fill-current" />
<span>Run</span>
</>
)}
{isDirty && !isRunning && (
<span className="absolute -top-1 -right-1 size-2 rounded-full bg-primary" />
)}
</button>
)}
</div>
)
} }

View File

@@ -1,111 +1,275 @@
/** /**
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes. * Recollection sidebar: VS Code-style collapsible/resizable sections.
* - Workspace: unified tree (pages, scenes, folders)
* - Katalogos: artifacts & run history
* Horizontally resizable via right-edge drag. Sections vertically resizable via divider drag.
*/ */
import React, { useCallback } from 'react' import React, { useCallback, useRef, 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 { Plus, FileText, Folder, ChevronDown, ChevronRight, Search, X, ChevronsDownUp, ChevronsUpDown } from 'lucide-react'
import { Plus, FileText } 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 { useWorkspaceTree } from './WorkspaceTreeContext'
import { TreeBrowser } from './TreeBrowser' import { TreeBrowser, type TreeBrowserHandle } from './TreeBrowser'
import { Input } from '@/components/ui/input'
import { import {
SidebarContent, DropdownMenu,
SidebarGroup, DropdownMenuContent,
SidebarGroupContent, DropdownMenuItem,
SidebarGroupLabel, DropdownMenuTrigger,
SidebarMenu, } from '@/components/ui/dropdown-menu'
SidebarMenuItem,
sidebarMenuButtonVariants,
} from '@/components/ui/sidebar'
export function RecollectionSidebar() { // ---------------------------------------------------------------------------
const { recollectionId } = useParams<{ recollectionId: string }>() // Constants
const { pathname } = useLocation() // ---------------------------------------------------------------------------
const navigate = useNavigate()
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
const base = recollectionId ? `/recollections/${recollectionId}` : '' const MIN_WIDTH = 180
const baseLogos = `${base}/logos` const MAX_WIDTH = 480
const isKatalogosView = pathname.endsWith('/logos/katalogos') const DEFAULT_WIDTH = 240
const isFluxView = pathname.endsWith('/flux') const SECTION_HEADER_H = 28
const MIN_SECTION_H = SECTION_HEADER_H + 32 // header + one row
const handleAddPage = useCallback(() => { // ---------------------------------------------------------------------------
// Add a new top-level page // Section header (VS Code style: uppercase label, chevron, actions on hover)
const maxPos = Math.max(0, ...tree.filter((p) => p.parentId === null).map((p) => p.position), -1) // ---------------------------------------------------------------------------
const newPage = {
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
title: 'Untitled',
parentId: null,
position: maxPos + 1,
}
handleTreeChange([...tree, newPage])
handleSelectPage(newPage.id)
navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`)
}, [tree, handleTreeChange, handleSelectPage, baseLogos, navigate])
function SectionHeader({
title,
open,
onToggle,
actions,
}: {
title: string
open: boolean
onToggle: () => void
actions?: React.ReactNode
}) {
return ( return (
<div <div
className="flex h-full w-[var(--sidebar-width)] shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground" className="group/header flex h-7 shrink-0 items-center gap-1 px-2 select-none cursor-pointer border-b border-sidebar-border/50 bg-sidebar hover:bg-sidebar-accent/40 transition-colors"
style={{ '--sidebar-width': '16rem' } as React.CSSProperties} onClick={onToggle}
> >
<SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent"> {open
<SidebarGroup> ? <ChevronDown className="size-3 shrink-0 opacity-50" />
<div className="flex items-center justify-between gap-2 py-1.5"> : <ChevronRight className="size-3 shrink-0 opacity-50" />}
<SidebarGroupLabel className="py-0">Logos</SidebarGroupLabel> <span className="flex-1 min-w-0 truncate text-[11px] font-semibold uppercase tracking-wider opacity-70">
<Button {title}
type="button" </span>
variant="ghost" {actions && (
size="sm" <div
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" className="flex shrink-0 items-center opacity-0 group-hover/header:opacity-100 transition-opacity"
onClick={handleAddPage} onClick={(e) => e.stopPropagation()}
> >
<Plus className="size-3.5" /> {actions}
New page </div>
</Button> )}
</div> </div>
<SidebarGroupContent> )
<TreeBrowser /> }
</SidebarGroupContent>
</SidebarGroup> // ---------------------------------------------------------------------------
<SidebarGroup> // Katalogos section content
<SidebarGroupLabel>Katalogos</SidebarGroupLabel> // ---------------------------------------------------------------------------
<SidebarGroupContent>
<SidebarMenu> function KatalogosContent() {
<SidebarMenuItem> const { recollectionId } = useParams<{ recollectionId: string }>()
<button const { pathname } = useLocation()
type="button" const navigate = useNavigate()
data-active={isKatalogosView} const base = recollectionId ? `/recollections/${recollectionId}` : ''
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')} const isKatalogosView = pathname.endsWith('/logos/katalogos')
onClick={() => navigate(`${base}/logos/katalogos`)}
> return (
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" /> <div className="flex flex-col gap-0.5 p-1 overflow-y-auto">
<span className="truncate">Artifacts</span> <button
</button> type="button"
</SidebarMenuItem> data-active={isKatalogosView || undefined}
</SidebarMenu> className={cn(
</SidebarGroupContent> 'flex items-center gap-2 rounded-sm px-2 h-[30px] text-[13px] w-full text-left transition-colors',
</SidebarGroup> 'hover:bg-sidebar-accent/60',
<SidebarGroup> isKatalogosView && 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
<SidebarGroupLabel>Flux</SidebarGroupLabel> )}
<SidebarGroupContent> onClick={() => navigate(`${base}/logos/katalogos`)}
<SidebarMenu> >
<SidebarMenuItem> <FileText className="size-[15px] shrink-0 opacity-60" />
<button <span className="truncate">Artifacts</span>
type="button" </button>
data-active={isFluxView} </div>
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')} )
onClick={() => navigate(`${base}/flux`)} }
>
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" /> // ---------------------------------------------------------------------------
<span className="truncate">Canvas</span> // Sidebar
</button> // ---------------------------------------------------------------------------
</SidebarMenuItem>
</SidebarMenu> export function RecollectionSidebar() {
</SidebarGroupContent> const { handleAddNode } = useWorkspaceTree()
</SidebarGroup>
</SidebarContent> // Search & tree ref
const [searchQuery, setSearchQuery] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const treeBrowserRef = useRef<TreeBrowserHandle>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
// Horizontal resize
const [width, setWidth] = useState(DEFAULT_WIDTH)
const onHResizeStart = useCallback((e: React.PointerEvent) => {
e.preventDefault()
const startX = e.clientX
const startW = width
const onMove = (ev: PointerEvent) => setWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startW + (ev.clientX - startX))))
const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = '' }
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}, [width])
// Section collapse
const [workspaceOpen, setWorkspaceOpen] = useState(true)
const [katalogosOpen, setKatalogosOpen] = useState(true)
// Vertical section resize (drag the divider between sections)
// workspaceFlex: fraction of available space for workspace section (0.20.8)
const [workspaceFlex, setWorkspaceFlex] = useState(0.75)
const containerRef = useRef<HTMLDivElement>(null)
const onVResizeStart = useCallback((e: React.PointerEvent) => {
e.preventDefault()
const container = containerRef.current
if (!container) return
const startY = e.clientY
const startFlex = workspaceFlex
const containerH = container.clientHeight
const onMove = (ev: PointerEvent) => {
const delta = ev.clientY - startY
const newFlex = startFlex + delta / containerH
setWorkspaceFlex(Math.min(0.85, Math.max(0.15, newFlex)))
}
const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = '' }
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
document.body.style.cursor = 'row-resize'
document.body.style.userSelect = 'none'
}, [workspaceFlex])
const bothOpen = workspaceOpen && katalogosOpen
return (
<div
className="relative flex h-full shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground overflow-hidden"
style={{ width }}
>
<div ref={containerRef} className="flex min-h-0 flex-1 flex-col overflow-hidden">
{/* Workspace section */}
<div
className="flex flex-col overflow-hidden"
style={workspaceOpen && bothOpen ? { flex: `${workspaceFlex} 1 0%`, minHeight: MIN_SECTION_H } : workspaceOpen ? { flex: '1 1 0%', minHeight: MIN_SECTION_H } : undefined}
>
<SectionHeader
title="Workspace"
open={workspaceOpen}
onToggle={() => setWorkspaceOpen((v) => !v)}
actions={
<>
<button
type="button"
className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
title="Search"
onClick={() => { setSearchVisible((v) => { if (!v) setTimeout(() => searchInputRef.current?.focus(), 0); return !v }); if (searchVisible) setSearchQuery('') }}
>
<Search className="size-3 opacity-70" />
</button>
<button
type="button"
className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
title="Collapse all"
onClick={() => treeBrowserRef.current?.closeAll()}
>
<ChevronsDownUp className="size-3 opacity-70" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
<Plus className="size-3 opacity-70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={() => handleAddNode('page')}>
<FileText className="mr-2 size-3.5" /> Page
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAddNode('scene')}>
<FluxIcon className="mr-2 size-3.5" /> Scene
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAddNode('folder')}>
<Folder className="mr-2 size-3.5" /> Folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
{workspaceOpen && (
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
{searchVisible && (
<div className="flex items-center gap-1 px-1.5 py-1 shrink-0">
<div className="relative flex-1 min-w-0">
<Input
ref={searchInputRef}
type="text"
placeholder="Filter..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Escape') { setSearchQuery(''); setSearchVisible(false) } }}
className="h-6 text-xs pl-2 pr-6 bg-sidebar-accent/30 border-sidebar-border/50 focus:bg-sidebar-accent/50"
/>
{searchQuery && (
<button type="button" className="absolute right-1 top-1/2 -translate-y-1/2 flex size-4 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent" onClick={() => setSearchQuery('')}>
<X className="size-2.5 opacity-60" />
</button>
)}
</div>
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<TreeBrowser ref={treeBrowserRef} searchQuery={searchQuery} />
</div>
</div>
)}
</div>
{/* Vertical resize divider (only when both sections open) */}
{bothOpen && (
<div
className="h-px shrink-0 cursor-row-resize bg-sidebar-border/50 hover:bg-primary/30 active:bg-primary/40 transition-colors"
onPointerDown={onVResizeStart}
/>
)}
{/* Katalogos section */}
<div
className="flex flex-col overflow-hidden"
style={katalogosOpen && bothOpen ? { flex: `${1 - workspaceFlex} 1 0%`, minHeight: MIN_SECTION_H } : katalogosOpen ? { flex: '1 1 0%', minHeight: MIN_SECTION_H } : undefined}
>
<SectionHeader
title="Katalogos"
open={katalogosOpen}
onToggle={() => setKatalogosOpen((v) => !v)}
/>
{katalogosOpen && (
<div className="min-h-0 flex-1 overflow-y-auto">
<KatalogosContent />
</div>
)}
</div>
</div>
{/* Horizontal resize handle */}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/30 transition-colors z-10"
onPointerDown={onHResizeStart}
/>
</div> </div>
) )
} }

View File

@@ -1,23 +1,12 @@
/** /**
* TreeBrowser: A tree browser component using react-arborist for managing * TreeBrowser: Unified workspace tree (pages, scenes, folders).
* Logos pages in a hierarchical folder structure. * Notion/VS Code inspired design. Only folders accept children.
*
* Features:
* - Drag and drop reordering
* - Folder expansion/collapse
* - Context menu for actions
* - Shadcn theme integration
* - Inline editing for page titles
* - Smooth animations
* - Search functionality
* - Expand/collapse all
*/ */
import React, { useCallback, useMemo, useRef, useState } from 'react' import React, { useCallback, useMemo, useRef } from 'react'
import { useResizeHeight } from '@/hooks/useResizeHeight' import { useResizeHeight } from '@/hooks/useResizeHeight'
import { import {
Tree, Tree,
NodeApi,
NodeRendererProps, NodeRendererProps,
RowRendererProps, RowRendererProps,
TreeApi, TreeApi,
@@ -32,13 +21,10 @@ import {
MoreHorizontal, MoreHorizontal,
Trash2, Trash2,
Pencil, Pencil,
Search, Folder,
ChevronsDownUp, FolderOpen,
ChevronsUpDown,
X,
GripVertical,
} from 'lucide-react' } from 'lucide-react'
import { Button } from '@/components/ui/button' import { FluxIcon } from '@/lib/icons'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -46,489 +32,332 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input' import { useWorkspaceTree } from './WorkspaceTreeContext'
import { useRecollectionSidebar } from './RecollectionSidebarContext' import type { WorkspaceNodeMeta } from '../state/recollectionStore'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import type { LogosPageMeta } from '../state/recollectionStore'
import { removeLogosPageContent } from '../state/recollectionStore'
// Tree node: page metadata plus tree shape (react-arborist `node.data` is this whole object). // ---------------------------------------------------------------------------
type TreeNode = LogosPageMeta & { // Tree node type
// ---------------------------------------------------------------------------
type TreeNode = WorkspaceNodeMeta & {
children?: TreeNode[] children?: TreeNode[]
isFolder: boolean
} }
// Convert flat tree to hierarchical structure function buildTree(nodes: WorkspaceNodeMeta[]): TreeNode[] {
function buildTree(pages: LogosPageMeta[]): TreeNode[] { const nodeMap = new Map<string, TreeNode>()
const pageMap = new Map<string, TreeNode>()
const rootNodes: TreeNode[] = [] const rootNodes: TreeNode[] = []
// First pass: create all nodes for (const n of nodes) nodeMap.set(n.id, { ...n, children: n.kind === 'folder' ? [] : undefined })
pages.forEach((page) => {
pageMap.set(page.id, {
...page,
children: [],
isFolder: false,
})
})
// Second pass: build parent-child relationships for (const n of nodes) {
pages.forEach((page) => { const tn = nodeMap.get(n.id)!
const node = pageMap.get(page.id)! if (n.parentId === null) {
if (page.parentId === null) { rootNodes.push(tn)
rootNodes.push(node)
} else { } else {
const parent = pageMap.get(page.parentId) const parent = nodeMap.get(n.parentId)
if (parent) { if (parent?.children) parent.children.push(tn)
parent.children?.push(node) else rootNodes.push(tn) // orphan fallback
parent.isFolder = true
}
} }
})
// Sort children by position
function sortNodes(nodes: TreeNode[]) {
nodes.sort((a, b) => a.position - b.position)
nodes.forEach((node) => sortNodes(node.children || []))
} }
sortNodes(rootNodes)
const sort = (ns: TreeNode[]) => {
ns.sort((a, b) => {
// Folders first, then by position
const aIsFolder = a.kind === 'folder' ? 0 : 1
const bIsFolder = b.kind === 'folder' ? 0 : 1
if (aIsFolder !== bIsFolder) return aIsFolder - bIsFolder
return a.position - b.position
})
ns.forEach((n) => { if (n.children) sort(n.children) })
}
sort(rootNodes)
return rootNodes return rootNodes
} }
// Memoize the buildTree result to avoid unnecessary re-creation // ---------------------------------------------------------------------------
function useTreeNodes(tree: LogosPageMeta[]) { // Inline title editor
return useMemo(() => buildTree(tree), [tree]) // ---------------------------------------------------------------------------
}
// Inline editable title component
function EditableTitle({
title,
onSave,
onCancel,
}: {
title: string
onSave: (newTitle: string) => void
onCancel: () => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
React.useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
onSave(inputRef.current?.value || '')
} else if (e.key === 'Escape') {
onCancel()
}
}
function EditableTitle({ title, onSave, onCancel }: { title: string; onSave: (v: string) => void; onCancel: () => void }) {
const ref = useRef<HTMLInputElement>(null)
React.useEffect(() => { ref.current?.focus(); ref.current?.select() }, [])
return ( return (
<input <input
ref={inputRef} ref={ref}
type="text" type="text"
defaultValue={title} defaultValue={title}
onBlur={(e) => onSave(e.currentTarget.value)} onBlur={(e) => onSave(e.currentTarget.value)}
onKeyDown={handleKeyDown} onKeyDown={(e) => { if (e.key === 'Enter') onSave(ref.current?.value || ''); else if (e.key === 'Escape') onCancel() }}
className="w-full rounded px-1 py-0.5 text-sm outline-none ring-2 ring-ring focus:ring-2 transition-all duration-200 bg-background" className="w-full rounded-sm px-1 py-0.5 text-[13px] outline-none ring-1 ring-primary/50 bg-background"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
) )
} }
const ROW_INDENT_PX = 20 // ---------------------------------------------------------------------------
const ROW_GUTTER_PX = 8 // Constants
// ---------------------------------------------------------------------------
/** const INDENT = 10
* Line cursor = “insert as sibling at this indent” (not “drop into folder”). const GUTTER = 2
* Triangle + dashed rail read as a slot between rows; matches sidebar theme.
*/ // ---------------------------------------------------------------------------
const LogosDropCursor = React.memo(function LogosDropCursor({ top, left, indent }: CursorProps) { // Drop cursor (line between rows)
// ---------------------------------------------------------------------------
const DropCursor = React.memo(function DropCursor({ top, left, indent }: CursorProps) {
return ( return (
<div <div
role="presentation" role="presentation"
aria-hidden aria-hidden
className="pointer-events-none absolute z-20 flex items-center gap-1.5" className="pointer-events-none absolute z-20 flex items-center"
style={{ style={{ top: top - 1, left: left + GUTTER + 4, right: GUTTER + 4 }}
top: top - 5,
left: left + ROW_GUTTER_PX,
right: Math.max(indent, ROW_GUTTER_PX),
}}
> >
<span className="flex h-3 w-2.5 shrink-0 items-center justify-center text-primary drop-shadow-sm"> <span className="size-1.5 rounded-full bg-primary shrink-0" />
<svg width="9" height="10" viewBox="0 0 9 10" fill="currentColor" aria-hidden> <div className="h-0.5 flex-1 bg-primary rounded-full" />
<path d="M0 5 L9 1.5 L9 8.5 Z" />
</svg>
</span>
<div className="flex h-3 min-w-[2rem] flex-1 items-center">
<div
className={cn(
'h-0 w-full border-t-2 border-dashed border-primary',
'shadow-[0_1px_0_0_hsl(var(--primary)/0.35)]',
'motion-safe:animate-[tree-drop-line-pulse_1.8s_ease-in-out_infinite]'
)}
/>
</div>
</div> </div>
) )
}) })
/** // ---------------------------------------------------------------------------
* Drag ref on a compact handle at the start of the row; handle is a floating pill shown on row hover // Node icon
* (always visible on coarse pointers). Title/icon strip is click-to-navigate only. // ---------------------------------------------------------------------------
*/
function LogosTreeNode({ node, dragHandle, style }: NodeRendererProps<TreeNode>) {
const { recollectionId } = useParams<{ recollectionId: string }>()
const navigate = useNavigate()
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
const base = recollectionId ? `/recollections/${recollectionId}` : '' function NodeIcon({ kind, isOpen }: { kind: string; isOpen?: boolean }) {
const baseLogos = `${base}/logos` const cls = 'size-[15px] shrink-0 opacity-60'
if (kind === 'scene') return <FluxIcon className={cls} />
if (kind === 'folder') return isOpen ? <FolderOpen className={cls} /> : <Folder className={cls} />
return <FileText className={cls} />
}
const handleToggle = useCallback( // ---------------------------------------------------------------------------
(e: React.MouseEvent) => { // Tree node renderer (Notion/VS Code style)
e.stopPropagation() // ---------------------------------------------------------------------------
node.toggle()
},
[node]
)
const handleSelect = useCallback( function WorkspaceTreeNode({ node, dragHandle, style }: NodeRendererProps<TreeNode>) {
(e: React.MouseEvent) => { const { handleSelectItem, handleAddNode, handleRenameNode, handleDeleteNode } = useWorkspaceTree()
if (!node.state.isEditing && !node.state.isDragging) {
handleSelectPage(node.data.id)
navigate(`${baseLogos}?page=${encodeURIComponent(node.data.id)}`)
}
},
[handleSelectPage, node.data.id, baseLogos, navigate, node.state.isEditing, node.state.isDragging]
)
const handleRename = useCallback(() => { const isFolder = node.data.kind === 'folder'
node.edit() const hasChildren = isFolder && (node.children?.length || 0) > 0
}, [node])
const handleDelete = useCallback(() => { const handleClick = useCallback(() => {
const toDelete = new Set<string>() if (node.state.isEditing || node.state.isDragging) return
function collectDescendants(id: string) { if (isFolder) node.toggle()
toDelete.add(id) else handleSelectItem(node.data.id)
tree.forEach((p) => { }, [handleSelectItem, node, isFolder])
if (p.parentId === id) collectDescendants(p.id)
})
}
collectDescendants(node.data.id)
if (recollectionId) {
toDelete.forEach((pageId) => {
removeLogosPageContent(recollectionId, pageId)
})
}
handleTreeChange(tree.filter((p) => !toDelete.has(p.id)))
}, [tree, handleTreeChange, node.data.id, recollectionId])
const handleAddChild = useCallback(() => {
const maxPos = Math.max(
0,
...tree.filter((p) => p.parentId === node.data.id).map((p) => p.position),
-1
)
const newPage: LogosPageMeta = {
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
title: 'Untitled',
parentId: node.data.id,
position: maxPos + 1,
}
handleTreeChange([...tree, newPage])
if (!node.isOpen) node.open()
}, [tree, handleTreeChange, node])
const hasChildren = (node.children?.length || 0) > 0
const pageTitle = node.data.title || 'Untitled'
// The entire row is the drag handle — Notion style
return ( return (
<div className="flex min-w-0 flex-1 items-center gap-1"> <div
{/* Floating drag pill: expands on row hover; coarse pointers keep it tappable */} ref={dragHandle}
<div style={{ ...style, paddingLeft: 0 }}
className={cn( className={cn(
'flex shrink-0 items-center justify-center overflow-hidden', 'flex min-w-0 flex-1 items-center gap-0.5 select-none overflow-hidden',
'transition-[width,opacity] duration-200 ease-out', node.state.isEditing ? 'cursor-text' : node.state.isDragging ? 'cursor-grabbing' : 'cursor-default',
'w-0 opacity-0 group-hover:w-4 group-hover:opacity-100', node.state.isDragging && 'opacity-40'
'[@media(pointer:coarse)]:w-5 [@media(pointer:coarse)]:opacity-100', )}
node.state.isDragging && 'w-4 opacity-100 [@media(pointer:coarse)]:w-5', onClick={handleClick}
node.state.isEditing && 'pointer-events-none w-0 opacity-0' >
)} {/* Chevron (folders only) */}
> {isFolder ? (
<div
ref={dragHandle}
style={{ ...style, paddingLeft: 0 }}
title={node.state.isEditing ? undefined : 'Drag to reorder'}
className={cn(
'logos-tree-drag-handle group/drag',
'relative flex h-6 w-4 shrink-0 touch-none items-center justify-center rounded-md',
'[@media(pointer:coarse)]:h-7 [@media(pointer:coarse)]:w-5',
'border border-sidebar-border/50 bg-sidebar-accent/40',
'text-sidebar-foreground/70',
'transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out',
'hover:border-sidebar-border hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
'hover:shadow-sm',
'active:scale-[0.97] active:cursor-grabbing',
'cursor-grab outline-none',
'dark:border-sidebar-border/55 dark:bg-sidebar-accent/35 dark:text-sidebar-foreground/75',
'dark:hover:bg-sidebar-accent dark:hover:text-sidebar-accent-foreground',
node.state.isDragging &&
'cursor-grabbing border-primary/55 bg-primary/18 text-primary shadow-sm ring-1 ring-primary/30 dark:border-primary/50 dark:bg-primary/22 dark:text-primary'
)}
onClick={(e) => e.stopPropagation()}
>
<GripVertical
className={cn(
'size-3 shrink-0 text-current transition-opacity duration-150 opacity-85 group-hover/drag:opacity-100',
node.state.isDragging && 'opacity-100'
)}
strokeWidth={2}
/>
</div>
</div>
{hasChildren ? (
<button <button
type="button" type="button"
className="shrink-0 p-0.5 hover:bg-sidebar-accent rounded transition-colors" className="flex size-5 shrink-0 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
onClick={handleToggle} onClick={(e) => { e.stopPropagation(); node.toggle() }}
> >
{node.isOpen ? ( {node.isOpen
<ChevronDown className="size-3.5 text-sidebar-foreground/70" /> ? <ChevronDown className="size-3 opacity-50" />
) : ( : <ChevronRight className="size-3 opacity-50" />}
<ChevronRight className="size-3.5 text-sidebar-foreground/70" />
)}
</button> </button>
) : ( ) : (
<div className="w-4 shrink-0" /> <div className="w-5 shrink-0" />
)} )}
<div {/* Icon */}
className={cn( <NodeIcon kind={node.data.kind} isOpen={node.isOpen} />
'flex min-h-[28px] min-w-0 flex-1 items-center gap-1 rounded-md px-0.5 -mx-0.5',
node.state.isEditing ? 'cursor-text' : 'cursor-pointer hover:bg-sidebar-accent/40' {/* Title */}
<div className="min-w-0 flex-1 overflow-hidden pl-1.5">
{node.state.isEditing ? (
<EditableTitle
title={node.data.title}
onSave={(v) => { if (v.trim()) handleRenameNode(node.data.id, v.trim()); node.submit(v) }}
onCancel={() => node.reset()}
/>
) : (
<span className="block truncate text-[13px] leading-tight cursor-pointer" title={node.data.title}>{node.data.title || 'Untitled'}</span>
)} )}
onClick={handleSelect}
>
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
<div className="min-w-0 flex-1">
{node.state.isEditing ? (
<EditableTitle
title={node.data.title}
onSave={(newTitle) => {
if (newTitle.trim()) {
handleTreeChange(
tree.map((p) => (p.id === node.data.id ? { ...p, title: newTitle.trim() } : p))
)
}
node.submit(newTitle)
}}
onCancel={() => node.reset()}
/>
) : (
<span className="block truncate select-none">{pageTitle}</span>
)}
</div>
</div> </div>
<div {/* Hover actions */}
className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100" {!node.state.isEditing && (
onPointerDown={(e) => e.stopPropagation()} <div
> className="flex shrink-0 items-center cursor-default opacity-0 group-hover:opacity-100 transition-opacity"
<button onPointerDown={(e) => e.stopPropagation()}
type="button" onClick={(e) => e.stopPropagation()}
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
onClick={(e) => {
e.stopPropagation()
handleAddChild()
}}
title="Add child page"
> >
<Plus className="size-3.5 text-sidebar-foreground/70" /> {/* Plus — only on folders (add inside) or any node (add sibling) */}
</button> <DropdownMenu>
<DropdownMenu> <DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild> <button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
<button <Plus className="size-3 opacity-60" />
type="button" </button>
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors" </DropdownMenuTrigger>
onClick={(e) => e.stopPropagation()} <DropdownMenuContent align="end" className="w-36">
> <DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('page', pid); if (isFolder && !node.isOpen) node.open() }}>
<MoreHorizontal className="size-3.5 text-sidebar-foreground/70" /> <FileText className="mr-2 size-3.5" /> Page
</button> </DropdownMenuItem>
</DropdownMenuTrigger> <DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('scene', pid); if (isFolder && !node.isOpen) node.open() }}>
<DropdownMenuContent align="end" className="w-40" onClick={(e) => e.stopPropagation()}> <FluxIcon className="mr-2 size-3.5" /> Scene
<DropdownMenuItem onClick={handleRename}> </DropdownMenuItem>
<Pencil className="mr-2 size-3.5" /> <DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('folder', pid); if (isFolder && !node.isOpen) node.open() }}>
Rename <Folder className="mr-2 size-3.5" /> Folder
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> </DropdownMenuContent>
<DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive"> </DropdownMenu>
<Trash2 className="mr-2 size-3.5" />
Delete {/* More */}
</DropdownMenuItem> <DropdownMenu>
</DropdownMenuContent> <DropdownMenuTrigger asChild>
</DropdownMenu> <button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
</div> <MoreHorizontal className="size-3 opacity-60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={() => node.edit()}>
<Pencil className="mr-2 size-3.5" /> Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleDeleteNode(node.data.id)} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-3.5" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div> </div>
) )
} }
/** Drop target + layout shell; `children` must be the Node renderer (drag layer). */ // ---------------------------------------------------------------------------
// Row renderer
// ---------------------------------------------------------------------------
function TreeRow({ node, innerRef, attrs, children }: RowRendererProps<TreeNode>) { function TreeRow({ node, innerRef, attrs, children }: RowRendererProps<TreeNode>) {
const { pathname } = useLocation() const { activeItemId } = useWorkspaceTree()
const { activePageId } = useRecollectionSidebar() const isActive = activeItemId === node.data.id
const { recollectionId } = useParams<{ recollectionId: string }>() const indentPx = GUTTER + node.level * INDENT
const base = recollectionId ? `/recollections/${recollectionId}` : ''
const baseLogos = `${base}/logos`
const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id
const level = node.level
const indentPx = ROW_GUTTER_PX + level * ROW_INDENT_PX
return ( return (
<div <div
ref={innerRef} ref={innerRef}
{...attrs} {...attrs}
style={{ style={{ ...attrs.style, paddingLeft: `${indentPx}px` }}
...attrs.style,
paddingLeft: `${indentPx}px`,
}}
className={cn( className={cn(
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md transition-[box-shadow,background-color,ring-color] duration-150', 'group relative flex items-center pr-1 h-[30px] text-[13px] rounded-sm mx-1',
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', 'transition-colors duration-100',
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground', 'hover:bg-sidebar-accent/60',
node.state.isDragging && 'cursor-grabbing opacity-45', isActive && 'bg-sidebar-accent text-sidebar-accent-foreground font-medium',
// Folder drop target: index === null in library — reads as “open container”, not a line between rows node.state.isDragging && 'opacity-40',
node.state.willReceiveDrop && node.state.willReceiveDrop && node.data.kind === 'folder' &&
'cursor-copy bg-sidebar-accent/30 ring-2 ring-inset ring-dashed ring-sidebar-ring/80 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]' 'bg-sidebar-accent/40 ring-1 ring-primary/40 cursor-copy'
)} )}
title={node.state.willReceiveDrop ? 'Release to drop inside this folder' : undefined}
> >
{level > 0 && ( {/* VS Code-style indent guides */}
{Array.from({ length: node.level }, (_, i) => (
<div <div
className="pointer-events-none absolute bottom-0 top-0 border-l-2 border-sidebar-border/45" key={i}
style={{ left: `${ROW_GUTTER_PX + (level - 1) * ROW_INDENT_PX + ROW_INDENT_PX / 2}px` }} className="pointer-events-none absolute top-0 bottom-0 w-px bg-sidebar-border/40"
style={{ left: `${GUTTER + i * INDENT + INDENT / 2}px` }}
/> />
)} ))}
{children} {children}
</div> </div>
) )
} }
export function TreeBrowser() { // ---------------------------------------------------------------------------
const { tree, handleTreeChange } = useRecollectionSidebar() // TreeBrowser
const [searchQuery, setSearchQuery] = useState('') // ---------------------------------------------------------------------------
export type TreeBrowserHandle = {
openAll: () => void
closeAll: () => void
}
export type TreeBrowserProps = {
searchQuery?: string
}
export const TreeBrowser = React.forwardRef<TreeBrowserHandle, TreeBrowserProps>(
function TreeBrowser({ searchQuery = '' }, ref) {
const { tree, handleTreeChange } = useWorkspaceTree()
const treeRef = useRef<TreeApi<TreeNode> | null>(null) const treeRef = useRef<TreeApi<TreeNode> | null>(null)
const [treeContainerHeight, treeContainerRef] = useResizeHeight(600) const [treeContainerHeight, treeContainerRef] = useResizeHeight(200)
// Build tree from flat structure React.useImperativeHandle(ref, () => ({
const treeNodes = useTreeNodes(tree) openAll: () => treeRef.current?.openAll(),
closeAll: () => treeRef.current?.closeAll(),
}), [])
const treeNodes = useMemo(() => buildTree(tree), [tree])
// Filter tree based on search query
const filteredTree = useMemo(() => { const filteredTree = useMemo(() => {
if (!searchQuery.trim()) return treeNodes if (!searchQuery.trim()) return treeNodes
const term = searchQuery.toLowerCase()
const searchTerm = searchQuery.toLowerCase() const filter = (nodes: TreeNode[]): TreeNode[] => {
const out: TreeNode[] = []
function filterNodes(nodes: TreeNode[]): TreeNode[] { for (const n of nodes) {
const result: TreeNode[] = [] const matches = n.title.toLowerCase().includes(term)
for (const node of nodes) { const kids = n.children ? filter(n.children) : []
const matches = node.title.toLowerCase().includes(searchTerm) if (matches || kids.length > 0) out.push({ ...n, children: kids.length > 0 ? kids : n.children ? [] : undefined })
const children = filterNodes(node.children || [])
if (matches || children.length > 0) {
result.push({
...node,
children: children.length > 0 ? children : undefined,
})
}
} }
return result return out
} }
return filter(treeNodes)
return filterNodes(treeNodes)
}, [treeNodes, searchQuery]) }, [treeNodes, searchQuery])
// Get all folder IDs for initial open state
const initialOpenState = useMemo(() => { const initialOpenState = useMemo(() => {
const folderIds: Record<string, boolean> = {} const ids: Record<string, boolean> = {}
function collectIds(nodes: TreeNode[]) { const collect = (ns: TreeNode[]) => ns.forEach((n) => { if (n.kind === 'folder') { ids[n.id] = true; if (n.children) collect(n.children) } })
nodes.forEach((node) => { collect(treeNodes)
if (node.isFolder) { return ids
folderIds[node.id] = true
collectIds(node.children || [])
}
})
}
collectIds(treeNodes)
return folderIds
}, [treeNodes]) }, [treeNodes])
// Handle drag and drop reordering (react-arborist onMove)
const handleMove = useCallback( const handleMove = useCallback(
({ ({ dragIds, parentId: newParentId, index }: { dragIds: string[]; parentId: string | null; index: number }) => {
dragIds,
parentId: newParentId,
index,
}: {
dragIds: string[]
parentId: string | null
index: number
}) => {
const dragId = dragIds[0] const dragId = dragIds[0]
if (!dragId) return if (!dragId) return
if (newParentId !== null) {
const dragged = tree.find((p) => p.id === dragId) const target = tree.find((n) => n.id === newParentId)
if (target && target.kind !== 'folder') return
}
const dragged = tree.find((n) => n.id === dragId)
if (!dragged) return if (!dragged) return
const oldParentId = dragged.parentId const oldParentId = dragged.parentId
const moved: LogosPageMeta = { ...dragged, parentId: newParentId } const moved: WorkspaceNodeMeta = { ...dragged, parentId: newParentId }
const newSiblings = tree const newSiblings = tree
.filter((p) => p.parentId === newParentId && p.id !== dragId) .filter((n) => n.parentId === newParentId && n.id !== dragId)
.sort((a, b) => a.position - b.position) .sort((a, b) => a.position - b.position)
newSiblings.splice(index, 0, moved) newSiblings.splice(index, 0, moved)
const updates = new Map<string, LogosPageMeta>() const updates = new Map<string, WorkspaceNodeMeta>()
newSiblings.forEach((p, i) => { newSiblings.forEach((n, i) => updates.set(n.id, { ...n, parentId: newParentId, position: i }))
updates.set(p.id, { ...p, parentId: newParentId, position: i })
})
if (oldParentId !== newParentId) { if (oldParentId !== newParentId) {
tree tree
.filter((p) => p.parentId === oldParentId && p.id !== dragId) .filter((n) => n.parentId === oldParentId && n.id !== dragId)
.sort((a, b) => a.position - b.position) .sort((a, b) => a.position - b.position)
.forEach((p, i) => { .forEach((n, i) => updates.set(n.id, { ...n, position: i }))
updates.set(p.id, { ...p, position: i })
})
} }
handleTreeChange(tree.map((p) => updates.get(p.id) ?? p)) handleTreeChange(tree.map((n) => updates.get(n.id) ?? n))
}, },
[tree, handleTreeChange] [tree, handleTreeChange]
) )
// Expand all folders
const handleExpandAll = useCallback(() => {
treeRef.current?.openAll()
}, [])
// Collapse all folders
const handleCollapseAll = useCallback(() => {
treeRef.current?.closeAll()
}, [])
// Clear search
const handleClearSearch = useCallback(() => {
setSearchQuery('')
}, [])
// Fixed prop shape: react-arborist TreeProvider uses [...Object.values(treeProps), …] as useMemo deps;
// varying key counts on the props object trigger "dependency array changed size" warnings.
const arboristTreeProps = useMemo( const arboristTreeProps = useMemo(
() => ({ () => ({
data: filteredTree, data: filteredTree,
@@ -536,74 +365,23 @@ export function TreeBrowser() {
childrenAccessor: 'children' as const, childrenAccessor: 'children' as const,
width: '100%' as const, width: '100%' as const,
height: Math.max(treeContainerHeight, 100), height: Math.max(treeContainerHeight, 100),
rowHeight: 32, rowHeight: 30,
indent: ROW_INDENT_PX, indent: INDENT,
renderRow: TreeRow, renderRow: TreeRow,
initialOpenState, initialOpenState,
onMove: handleMove, onMove: handleMove,
disableDrag: Boolean(searchQuery), disableDrag: Boolean(searchQuery),
disableDrop: Boolean(searchQuery), disableDrop: Boolean(searchQuery),
className: 'react-arborist-tree', className: 'react-arborist-tree',
renderCursor: LogosDropCursor, renderCursor: DropCursor,
children: LogosTreeNode, children: WorkspaceTreeNode,
}), }),
[filteredTree, initialOpenState, handleMove, searchQuery, treeContainerHeight] [filteredTree, initialOpenState, handleMove, searchQuery, treeContainerHeight]
) )
return ( return (
<div className="flex flex-col gap-2 h-full"> <div ref={treeContainerRef} className="h-full min-h-0 overflow-hidden">
{/* Search Toolbar */} <Tree ref={treeRef} {...arboristTreeProps} />
<div className="flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-sidebar-foreground/50 pointer-events-none" />
<Input
type="text"
placeholder="Search pages..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-7 pr-7 h-7 text-xs bg-sidebar-accent/50 border-sidebar-border"
/>
{searchQuery && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-1/2 -translate-y-1/2 h-7 w-7"
onClick={handleClearSearch}
>
<X className="size-3" />
</Button>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
title="Expand/Collapse options"
>
<ChevronsUpDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleExpandAll}>
<ChevronsDownUp className="mr-2 size-3.5" />
Expand all
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCollapseAll}>
<ChevronsUpDown className="mr-2 size-3.5" />
Collapse all
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Tree */}
<div ref={treeContainerRef} className="flex-1 min-h-0 overflow-hidden">
<Tree ref={treeRef} {...arboristTreeProps} />
</div>
</div> </div>
) )
} })

View File

@@ -0,0 +1,265 @@
/**
* Unified workspace tree context: pages, scenes, and folders in a single tree.
* Replaces both RecollectionSidebarContext and FluxSceneContext.
* Exports backward-compat shim hooks for existing consumers.
*/
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom'
import {
ensureWorkspaceTree,
setWorkspaceTree,
removeLogosPageContent,
removeSceneGraph,
DEFAULT_SCENE_ID,
type WorkspaceNodeMeta,
type WorkspaceNodeId,
type WorkspaceNodeKind,
type LogosPageMeta,
type FluxSceneMeta,
type FluxSceneId,
} from '../state/recollectionStore'
// ---------------------------------------------------------------------------
// Context types
// ---------------------------------------------------------------------------
export type WorkspaceTreeContextValue = {
tree: WorkspaceNodeMeta[]
setTree: React.Dispatch<React.SetStateAction<WorkspaceNodeMeta[]>>
activeItemId: WorkspaceNodeId | null
activeItemKind: WorkspaceNodeKind | null
handleSelectItem: (id: WorkspaceNodeId) => void
handleTreeChange: (tree: WorkspaceNodeMeta[]) => void
handleAddNode: (kind: WorkspaceNodeKind, parentId?: WorkspaceNodeId | null, title?: string) => WorkspaceNodeId
handleRenameNode: (id: WorkspaceNodeId, title: string) => void
handleDeleteNode: (id: WorkspaceNodeId) => void
}
const WorkspaceTreeContext = createContext<WorkspaceTreeContextValue | null>(null)
export function useWorkspaceTree(): WorkspaceTreeContextValue {
const ctx = useContext(WorkspaceTreeContext)
if (!ctx) throw new Error('useWorkspaceTree must be used within WorkspaceTreeProvider')
return ctx
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
let nodeCounter = 0
export function WorkspaceTreeProvider({ children }: { children: React.ReactNode }) {
const { recollectionId } = useParams<{ recollectionId: string }>()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const { pathname } = useLocation()
const [tree, setTree] = useState<WorkspaceNodeMeta[]>([])
const [activeItemId, setActiveItemId] = useState<WorkspaceNodeId | null>(null)
// Load tree on recollection change
useEffect(() => {
if (!recollectionId) return
const t = ensureWorkspaceTree(recollectionId)
setTree(t)
}, [recollectionId])
// Sync activeItemId from URL
useEffect(() => {
if (!recollectionId || tree.length === 0) return
const pageFromUrl = searchParams.get('page')
const sceneFromUrl = searchParams.get('scene')
setActiveItemId((prev) => {
if (pageFromUrl && tree.some((n) => n.id === pageFromUrl && n.kind === 'page')) return pageFromUrl
if (sceneFromUrl && tree.some((n) => n.id === sceneFromUrl && n.kind === 'scene')) return sceneFromUrl
if (prev != null && tree.some((n) => n.id === prev)) return prev
// Default to first page or scene
const firstContent = tree.find((n) => n.kind === 'page' || n.kind === 'scene')
return firstContent?.id ?? null
})
}, [recollectionId, searchParams, tree])
// Persist tree on changes
useEffect(() => {
if (!recollectionId || tree.length === 0) return
setWorkspaceTree(recollectionId, tree)
}, [recollectionId, tree])
const activeItemKind = useMemo(() => {
if (!activeItemId) return null
return tree.find((n) => n.id === activeItemId)?.kind ?? null
}, [activeItemId, tree])
const handleSelectItem = useCallback(
(id: WorkspaceNodeId) => {
if (!recollectionId) return
const node = tree.find((n) => n.id === id)
if (!node) return
setActiveItemId(id)
const base = `/recollections/${recollectionId}`
if (node.kind === 'page') {
navigate(`${base}/logos?page=${encodeURIComponent(id)}`)
} else if (node.kind === 'scene') {
navigate(`${base}/flux?scene=${encodeURIComponent(id)}`)
}
// folder: no navigation, just select
},
[recollectionId, tree, navigate]
)
const handleTreeChange = useCallback((newTree: WorkspaceNodeMeta[]) => {
setTree(newTree)
}, [])
const handleAddNode = useCallback(
(kind: WorkspaceNodeKind, parentId?: WorkspaceNodeId | null, title?: string) => {
nodeCounter += 1
const prefix = kind === 'page' ? 'page' : kind === 'scene' ? 'scene' : 'folder'
const id = `${prefix}_${Date.now()}_${nodeCounter}`
const siblings = tree.filter((n) => n.parentId === (parentId ?? null))
const maxPos = siblings.reduce((max, n) => Math.max(max, n.position), -1)
const defaultTitle = kind === 'page' ? 'Untitled' : kind === 'scene' ? `Scene ${tree.filter((n) => n.kind === 'scene').length + 1}` : 'New Folder'
const node: WorkspaceNodeMeta = {
id,
title: title ?? defaultTitle,
kind,
parentId: parentId ?? null,
position: maxPos + 1,
}
setTree((prev) => [...prev, node])
if (kind !== 'folder') {
handleSelectItem(id)
}
return id
},
[tree, handleSelectItem]
)
const handleRenameNode = useCallback(
(id: WorkspaceNodeId, title: string) => {
setTree((prev) => prev.map((n) => (n.id === id ? { ...n, title } : n)))
},
[]
)
const handleDeleteNode = useCallback(
(id: WorkspaceNodeId) => {
if (!recollectionId) return
// Collect this node and all descendants
const toDelete = new Set<string>()
const collect = (nodeId: string) => {
toDelete.add(nodeId)
for (const child of tree.filter((n) => n.parentId === nodeId)) {
collect(child.id)
}
}
collect(id)
// Clean up storage for deleted nodes
for (const nodeId of toDelete) {
const node = tree.find((n) => n.id === nodeId)
if (!node) continue
if (node.kind === 'page') removeLogosPageContent(recollectionId, nodeId)
if (node.kind === 'scene') removeSceneGraph(recollectionId, nodeId)
}
setTree((prev) => {
const next = prev.filter((n) => !toDelete.has(n.id))
// If we deleted the active item, select the first remaining content node
if (activeItemId && toDelete.has(activeItemId) && next.length > 0) {
const firstContent = next.find((n) => n.kind === 'page' || n.kind === 'scene')
if (firstContent) handleSelectItem(firstContent.id)
}
return next
})
},
[recollectionId, tree, activeItemId, handleSelectItem]
)
const value: WorkspaceTreeContextValue = {
tree,
setTree,
activeItemId,
activeItemKind,
handleSelectItem,
handleTreeChange,
handleAddNode,
handleRenameNode,
handleDeleteNode,
}
return <WorkspaceTreeContext.Provider value={value}>{children}</WorkspaceTreeContext.Provider>
}
// ---------------------------------------------------------------------------
// Backward-compat shim: useRecollectionSidebar
// ---------------------------------------------------------------------------
export type RecollectionSidebarContextValue = {
tree: LogosPageMeta[]
activePageId: string | null
setTree: React.Dispatch<React.SetStateAction<WorkspaceNodeMeta[]>>
handleSelectPage: (id: string) => void
handleTreeChange: (tree: WorkspaceNodeMeta[]) => void
handleDeletePage: (pageId: string) => void
}
export function useRecollectionSidebar(): RecollectionSidebarContextValue {
const ctx = useWorkspaceTree()
return useMemo(() => ({
tree: ctx.tree.filter((n) => n.kind === 'page').map((n) => ({
id: n.id,
title: n.title,
parentId: n.parentId,
position: n.position,
})),
activePageId: ctx.activeItemKind === 'page' ? ctx.activeItemId : null,
setTree: ctx.setTree,
handleSelectPage: ctx.handleSelectItem,
handleTreeChange: ctx.handleTreeChange,
handleDeletePage: ctx.handleDeleteNode,
}), [ctx])
}
// ---------------------------------------------------------------------------
// Backward-compat shim: useFluxScenes
// ---------------------------------------------------------------------------
export type FluxSceneContextValue = {
scenes: FluxSceneMeta[]
activeSceneId: FluxSceneId
setScenes: React.Dispatch<React.SetStateAction<WorkspaceNodeMeta[]>>
handleSelectScene: (id: FluxSceneId) => void
handleAddScene: (title?: string) => FluxSceneId
handleRenameScene: (id: FluxSceneId, title: string) => void
handleDeleteScene: (id: FluxSceneId) => void
handleReorderScenes: (scenes: FluxSceneMeta[]) => void
}
export function useFluxScenes(): FluxSceneContextValue {
const ctx = useWorkspaceTree()
return useMemo(() => ({
scenes: ctx.tree.filter((n) => n.kind === 'scene').map((n) => ({
id: n.id,
title: n.title,
position: n.position,
})),
activeSceneId: ctx.activeItemKind === 'scene' && ctx.activeItemId ? ctx.activeItemId : DEFAULT_SCENE_ID,
setScenes: ctx.setTree,
handleSelectScene: ctx.handleSelectItem,
handleAddScene: (title?: string) => ctx.handleAddNode('scene', null, title),
handleRenameScene: ctx.handleRenameNode,
handleDeleteScene: ctx.handleDeleteNode,
handleReorderScenes: () => {},
}), [ctx])
}
export function useOptionalFluxScenes(): FluxSceneContextValue | null {
const ctx = useContext(WorkspaceTreeContext)
if (!ctx) return null
return useFluxScenes()
}

View File

@@ -11,7 +11,7 @@ import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuContro
import { BlockNoteView } from '@blocknote/shadcn' import { BlockNoteView } from '@blocknote/shadcn'
import { useTheme } from '@/lib/themeContext' import { useTheme } from '@/lib/themeContext'
import { useRecollectionActions } from '../layout/RecollectionActionsContext' import { useRecollectionActions } from '../layout/RecollectionActionsContext'
import { useRecollectionSidebar } from '../layout/RecollectionSidebarContext' import { useRecollectionSidebar } from '../layout/WorkspaceTreeContext'
import { import {
getLogosContentForPage, getLogosContentForPage,
setLogosContentForPage, setLogosContentForPage,

View File

@@ -47,16 +47,61 @@ 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'
// ---------------------------------------------------------------------------
// Unified Workspace Tree (pages + scenes + folders in one tree)
// ---------------------------------------------------------------------------
export type WorkspaceNodeKind = 'page' | 'scene' | 'folder'
export type WorkspaceNodeId = string
/** Unified node in the workspace tree. Replaces separate LogosPageMeta and FluxSceneMeta. */
export type WorkspaceNodeMeta = {
id: WorkspaceNodeId
title: string
kind: WorkspaceNodeKind
parentId: WorkspaceNodeId | null
position: number
}
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 WORKSPACE_TREE_PREFIX = 'zui_workspace_tree_'
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 getWorkspaceTreeKey(recollectionId: string): string {
return `${WORKSPACE_TREE_PREFIX}${recollectionId}`
}
function getLogosKey(recollectionId: string): string { function getLogosKey(recollectionId: string): string {
return `${LOGOS_KEY_PREFIX}${recollectionId}` return `${LOGOS_KEY_PREFIX}${recollectionId}`
} }
@@ -155,6 +200,168 @@ 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
}
// ---------------------------------------------------------------------------
// Unified Workspace Tree Storage
// ---------------------------------------------------------------------------
export function getWorkspaceTree(recollectionId: string): WorkspaceNodeMeta[] {
try {
const raw = localStorage.getItem(getWorkspaceTreeKey(recollectionId))
if (!raw) return []
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return []
return data.filter(
(item): item is WorkspaceNodeMeta =>
item != null &&
typeof item === 'object' &&
typeof (item as WorkspaceNodeMeta).id === 'string' &&
typeof (item as WorkspaceNodeMeta).title === 'string' &&
typeof (item as WorkspaceNodeMeta).kind === 'string' &&
((item as WorkspaceNodeMeta).parentId === null || typeof (item as WorkspaceNodeMeta).parentId === 'string') &&
typeof (item as WorkspaceNodeMeta).position === 'number'
)
} catch {
return []
}
}
export function setWorkspaceTree(recollectionId: string, tree: WorkspaceNodeMeta[]): void {
localStorage.setItem(getWorkspaceTreeKey(recollectionId), JSON.stringify(tree))
}
/**
* Initialize workspace tree for a recollection. Migrates existing separate
* page tree + scene tree into a unified workspace tree on first access.
*/
export function ensureWorkspaceTree(recollectionId: string): WorkspaceNodeMeta[] {
const existing = getWorkspaceTree(recollectionId)
if (existing.length > 0) return existing
const merged: WorkspaceNodeMeta[] = []
// Migrate Logos pages
let pages = getLogosPageTree(recollectionId)
if (pages.length === 0) {
// Check for legacy single-document Logos content
const legacy = getLogosContent(recollectionId)
const mainPage: LogosPageMeta = { id: 'main', title: 'Main', parentId: null, position: 0 }
setLogosContentForPage(recollectionId, 'main', legacy ?? [])
setLogosPageTree(recollectionId, [mainPage])
pages = [mainPage]
}
for (const page of pages) {
merged.push({
id: page.id,
title: page.title,
kind: 'page',
parentId: page.parentId,
position: page.position,
})
}
// Migrate Flux scenes
const scenes = getFluxSceneTree(recollectionId)
if (scenes.length === 0) {
// Migrate legacy single graph to default scene
const legacyGraph = getGraph(recollectionId)
const defaultScene: WorkspaceNodeMeta = { id: DEFAULT_SCENE_ID, title: 'Main', kind: 'scene', parentId: null, position: merged.length }
merged.push(defaultScene)
if (legacyGraph) {
setSceneGraph(recollectionId, DEFAULT_SCENE_ID, legacyGraph)
}
} else {
const maxRootPos = merged.filter((n) => n.parentId === null).reduce((max, n) => Math.max(max, n.position), -1)
for (const scene of scenes) {
merged.push({
id: scene.id,
title: scene.title,
kind: 'scene',
parentId: null,
position: maxRootPos + 1 + scene.position,
})
}
}
setWorkspaceTree(recollectionId, merged)
return merged
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Logos Storage // Logos Storage
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -270,14 +477,25 @@ 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))
// Workspace tree
localStorage.removeItem(getWorkspaceTreeKey(recollectionId))
// Render cache
localStorage.removeItem(getRenderCacheKey(recollectionId)) localStorage.removeItem(getRenderCacheKey(recollectionId))
} }

View File

@@ -5,6 +5,7 @@ import { useContext } from "react";
import { FlowUIContext } from "@/lib/graph/flowContext"; import { FlowUIContext } from "@/lib/graph/flowContext";
import { useConnectionPathRoleFromStore } from "@/app/canvas/useCanvasConnectionPathFromStore"; import { useConnectionPathRoleFromStore } from "@/app/canvas/useCanvasConnectionPathFromStore";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { NodeRunStatusOverlay } from "./NodeRunStatusOverlay";
/** Default min size for resizable nodes (used by NodeResizer). */ /** Default min size for resizable nodes (used by NodeResizer). */
export const RESIZE_MIN_WIDTH = 120; export const RESIZE_MIN_WIDTH = 120;
@@ -102,6 +103,7 @@ export function BaseNode({
{!isFullscreenInstance && ( {!isFullscreenInstance && (
<div className={cn(!selected && "pointer-events-none")}>{handles}</div> <div className={cn(!selected && "pointer-events-none")}>{handles}</div>
)} )}
{nodeId && <NodeRunStatusOverlay nodeId={nodeId} />}
</div> </div>
); );
} }

View File

@@ -3,6 +3,7 @@ import { GraphContext } from '@/lib/graph/flowContext'
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react' import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
import { NodeHelpPopover } from '@/components/graph/NodeHelpPopover' import { NodeHelpPopover } from '@/components/graph/NodeHelpPopover'
import { getNodeType, getNodeClassificationLabel } from '@/lib/graph/nodeRegistry' import { getNodeType, getNodeClassificationLabel } from '@/lib/graph/nodeRegistry'
import { NodeRunStatusBadge } from './NodeRunStatusOverlay'
type Props = { type Props = {
nodeId: string nodeId: string
@@ -55,6 +56,7 @@ export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props)
<span className="shrink-0" title="Node classification">{classificationLabel}</span> <span className="shrink-0" title="Node classification">{classificationLabel}</span>
</> </>
)} )}
<NodeRunStatusBadge nodeId={nodeId} />
<span className="shrink-0 ml-auto"> <span className="shrink-0 ml-auto">
<NodeHelpPopover nodeType={nodeType} /> <NodeHelpPopover nodeType={nodeType} />
</span> </span>

View File

@@ -0,0 +1,84 @@
/**
* Run status indicator for nodes. Two exports:
* - NodeRunStatusBadge: inline badge for node footers (primary integration point)
* - NodeRunStatusOverlay: subtle border overlay for running/pending/failed states
*/
import React from 'react'
import { useRunStore, type NodeRunStatus } from '@/lib/graph/runStore'
import { cn } from '@/lib/utils'
import { CheckCircle2, Loader2, XCircle, Clock } from 'lucide-react'
const badgeConfig: Record<NodeRunStatus, { icon: React.ReactNode; label: string; className: string }> = {
pending: {
icon: <Clock className="size-3" />,
label: 'Pending',
className: 'text-muted-foreground',
},
running: {
icon: <Loader2 className="size-3 animate-spin" />,
label: 'Running',
className: 'text-blue-500',
},
completed: {
icon: <CheckCircle2 className="size-3" />,
label: 'Done',
className: 'text-emerald-500',
},
failed: {
icon: <XCircle className="size-3" />,
label: 'Failed',
className: 'text-destructive',
},
}
/** Inline badge for node footers — shows run status next to edge indicators. */
export function NodeRunStatusBadge({ nodeId }: { nodeId: string }) {
const nodeState = useRunStore((s) => s.nodeStates[nodeId])
const runStatus = useRunStore((s) => s.status)
if (runStatus === 'idle') return null
if (!nodeState) return null
const { icon, label, className } = badgeConfig[nodeState.status]
return (
<span className={cn('flex items-center gap-1 shrink-0', className)} title={label}>
{icon}
<span className="text-[10px]">{label}</span>
</span>
)
}
const overlayBorder: Record<NodeRunStatus, string> = {
pending: 'border-muted-foreground/20',
running: 'border-blue-500/40',
completed: 'border-transparent',
failed: 'border-destructive/40',
}
/** Subtle border overlay — only visible for running/pending/failed. */
export function NodeRunStatusOverlay({ nodeId }: { nodeId: string }) {
const nodeState = useRunStore((s) => s.nodeStates[nodeId])
const runStatus = useRunStore((s) => s.status)
if (runStatus === 'idle') return null
if (!nodeState) return null
// No overlay needed for completed — the footer badge is enough
if (nodeState.status === 'completed') return null
return (
<div
className={cn(
'absolute inset-0 z-10 pointer-events-none rounded-md border-2 transition-colors duration-300',
overlayBorder[nodeState.status]
)}
>
{nodeState.error && (
<div className="absolute bottom-0 left-0 right-0 truncate rounded-b-md bg-destructive/90 px-2 py-0.5 text-[10px] text-destructive-foreground">
{nodeState.error}
</div>
)}
</div>
)
}

View File

@@ -22,17 +22,9 @@ import {
MenubarSubContent, MenubarSubContent,
MenubarSubTrigger, MenubarSubTrigger,
} from '@/components/ui/menubar' } from '@/components/ui/menubar'
import { Sparkles, Play, ChevronDown, Loader2, RotateCw } from 'lucide-react' import { Sparkles, Loader2, RotateCw } from 'lucide-react'
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles' import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTheme } from '@/lib/themeContext' import { useTheme } from '@/lib/themeContext'
import { import {
useRenderingNodeState, useRenderingNodeState,
@@ -165,95 +157,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
: undefined : undefined
} }
right={ right={
state.incomingIds.length > 0 ? ( state.incomingIds.length > 0 && state.loading ? (
<div className="flex items-center gap-2 shrink-0"> <Loader2 className="size-3.5 animate-spin shrink-0 text-muted-foreground" aria-label="Rendering" />
<ButtonGroup className="nodrag nopan">
{state.effectiveUpdateMode === 'manual' ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={state.loading || !state.hasPendingInputs}
className="h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs"
onClick={(e) => {
e.stopPropagation()
state.incrementRunTrigger()
}}
title={
state.hasPendingInputs
? 'Inputs changed — click to render'
: !state.hasPendingInputs
? 'No new data to render'
: undefined
}
>
{state.loading ? (
<Loader2 className="size-3.5 animate-spin shrink-0" aria-hidden />
) : (
<Play className="size-3.5 shrink-0" />
)}
Run
</Button>
) : state.loading ? (
<Button
type="button"
size="sm"
variant="outline"
disabled
className="h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs"
aria-label="Updating"
>
<Loader2 className="size-3.5 animate-spin shrink-0" aria-hidden />
</Button>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
className={`h-7 min-w-[4.5rem] gap-1 pl-2 pr-1.5 text-xs font-normal ${state.effectiveUpdateMode === 'manual' || state.loading ? 'rounded-l-none' : 'rounded-l-md'}`}
aria-label="Update mode"
onClick={(e) => e.stopPropagation()}
>
<span className="text-muted-foreground">
{state.effectiveUpdateMode === 'manual' ? 'Manual' : 'Auto'}
</span>
<ChevronDown className="size-3.5 shrink-0 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
When to re-render
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={state.effectiveUpdateMode === 'auto'}
onCheckedChange={(checked) =>
checked && state.setUpdateMode('auto')
}
className="flex flex-col items-start gap-0.5 py-2"
>
<span className="font-medium">Auto</span>
<span className="text-muted-foreground text-xs font-normal">
Re-renders when upstream content changes
</span>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={state.effectiveUpdateMode === 'manual'}
onCheckedChange={(checked) =>
checked && state.setUpdateMode('manual')
}
className="flex flex-col items-start gap-0.5 py-2"
>
<span className="font-medium">Manual</span>
<span className="text-muted-foreground text-xs font-normal">
Re-renders only when you click Run
</span>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</div>
) : undefined ) : undefined
} }
/> />

View File

@@ -76,6 +76,7 @@ import {
import { buildSourceSignatures, type NodeLike, type EdgeLike } from '@/lib/graph/renderingSignatures' import { buildSourceSignatures, type NodeLike, type EdgeLike } from '@/lib/graph/renderingSignatures'
import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state' import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state'
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering' import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
import { useRunStore } from '@/lib/graph/runStore'
export type OutputMode = 'image' | 'string' export type OutputMode = 'image' | 'string'
@@ -207,8 +208,11 @@ export function useRenderingNodeState(
() => (srcNode?.type ? getSourceRenderingLogic(srcNode.type as string) : null), () => (srcNode?.type ? getSourceRenderingLogic(srcNode.type as string) : null),
[srcNode?.type] [srcNode?.type]
) )
const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual' // All rendering is now triggered by the global Run button — no auto/manual distinction.
const runTrigger = data?.runTrigger ?? 0 const effectiveUpdateMode: 'auto' | 'manual' = 'manual'
// Use global run trigger from runStore instead of per-node trigger
const globalRunTrigger = useRunStore((s) => s.globalRunTrigger)
const runTrigger = globalRunTrigger
const outputMode: OutputMode = (data?.outputMode ?? 'image') as OutputMode const outputMode: OutputMode = (data?.outputMode ?? 'image') as OutputMode
const setOutputMode = useCallback( const setOutputMode = useCallback(
(mode: OutputMode) => updateData({ outputMode: mode }), (mode: OutputMode) => updateData({ outputMode: mode }),
@@ -330,7 +334,7 @@ export function useRenderingNodeState(
if (!hasCachedOutput) { if (!hasCachedOutput) {
setRenderedContent(null) setRenderedContent(null)
setResolvedContent(null) setResolvedContent(null)
setError({ kind: 'no-content', message: 'Click Run to render.' }) setError({ kind: 'no-content', message: 'Press Run (⌘↵) to render.' })
setLoading(false) setLoading(false)
} }
return return

View File

@@ -0,0 +1,110 @@
/**
* SSE subscriber hook for graph run execution.
* Connects to GET /api/runs/:id/stream and updates runStore.
*/
import { useEffect, useRef, useCallback } from 'react'
import { useRunStore } from '@/lib/graph/runStore'
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
export function useRunStream() {
const eventSourceRef = useRef<EventSource | null>(null)
const { startRun, setRunStatus, setNodeStatus, appendChunk } = useRunStore()
const disconnect = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
}, [])
const connectToRun = useCallback(
(runId: string) => {
disconnect()
startRun(runId)
const es = new EventSource(`/api/runs/${runId}/stream`)
eventSourceRef.current = es
es.addEventListener('run/started', (e) => {
const data = JSON.parse(e.data)
setRunStatus('running')
// Initialize all nodes as pending so overlays appear immediately
const nodeIds = data.nodeIds as string[] | undefined
if (nodeIds) {
for (const nodeId of nodeIds) {
setNodeStatus(nodeId, { status: 'pending' })
}
}
})
es.addEventListener('run/completed', () => {
setRunStatus('completed')
es.close()
})
es.addEventListener('run/failed', (e) => {
const data = JSON.parse(e.data)
setRunStatus('failed', data.error)
es.close()
})
es.addEventListener('step/started', (e) => {
const data = JSON.parse(e.data)
setNodeStatus(data.nodeId, { status: 'running' })
// Fire trail animation along edges leading to this node
dispatchCanvasCommand({ type: 'path/addTrigger', payload: data.nodeId })
})
es.addEventListener('step/completed', (e) => {
const data = JSON.parse(e.data)
setNodeStatus(data.nodeId, { status: 'completed', output: data.output })
})
es.addEventListener('step/failed', (e) => {
const data = JSON.parse(e.data)
setNodeStatus(data.nodeId, { status: 'failed', error: data.error })
})
es.addEventListener('step/chunk', (e) => {
const data = JSON.parse(e.data)
appendChunk(data.nodeId, data.chunk)
})
es.onerror = () => {
setRunStatus('failed', 'Connection lost')
es.close()
}
},
[disconnect, startRun, setRunStatus, setNodeStatus, appendChunk]
)
// Cleanup on unmount
useEffect(() => disconnect, [disconnect])
return { connectToRun, disconnect }
}
/**
* Trigger a new run: POST the graph, then connect SSE.
*/
export async function createAndStreamRun(
recollectionId: string,
graph: { nodes: unknown[]; edges: unknown[] },
connectToRun: (runId: string) => void,
sceneId?: string
): Promise<void> {
const res = await fetch('/api/runs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recollectionId, sceneId, graph }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Failed to create run' }))
throw new Error(err.error ?? 'Failed to create run')
}
const { id } = await res.json()
connectToRun(id)
}

View File

@@ -4,7 +4,7 @@
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node. * - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges, * - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. Calling updateData() * updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. Calling updateData()
* also marks this node as a connection-path trigger so edges update on data changes. * also marks the run state as dirty so the Run button shows pending changes.
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual. * - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
* *
* **Node lifecycle / connection status:** Nodes that can be updating, paused, or in error should * **Node lifecycle / connection status:** Nodes that can be updating, paused, or in error should
@@ -17,9 +17,23 @@
import React, { useCallback, useContext, useMemo } from 'react' import React, { useCallback, useContext, useMemo } from 'react'
import { GraphContext } from './flowContext' import { GraphContext } from './flowContext'
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { nodePropsAreEqual } from './flowUtils' import { nodePropsAreEqual } from './flowUtils'
import type { AppNode } from './nodeTypes' import type { AppNode } from './nodeTypes'
import { useRunStore } from './runStore'
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Data keys written by the render pipeline cache — these should NOT mark the run state as dirty. */
const CACHE_DATA_KEYS = new Set([
'cachedRenderedContent',
'cachedResolvedContent',
'cachedReasoningContent',
'cachedOutputValue',
'lastRunSourceSignature',
'outputMarkdown',
])
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -87,6 +101,7 @@ export function useAbstractNode<TData = Record<string, unknown>>(
const setNodes = graphCtx?.setNodes const setNodes = graphCtx?.setNodes
const setEdges = graphCtx?.setEdges const setEdges = graphCtx?.setEdges
const markDirty = useRunStore((s) => s.markDirty)
const updateData = useCallback( const updateData = useCallback(
(partial: Partial<TData>) => { (partial: Partial<TData>) => {
if (!setNodes) return if (!setNodes) return
@@ -95,9 +110,12 @@ export function useAbstractNode<TData = Record<string, unknown>>(
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
) as AppNode[] ) as AppNode[]
) )
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id }) // Only mark dirty for user-facing changes, not internal cache writes from the render pipeline
const keys = Object.keys(partial)
const isCacheOnly = keys.length > 0 && keys.every((k) => CACHE_DATA_KEYS.has(k))
if (!isCacheOnly) markDirty()
}, },
[id, setNodes] [id, setNodes, markDirty]
) )
const incomingEdges = useMemo( const incomingEdges = useMemo(

View File

@@ -0,0 +1,99 @@
/**
* Run execution state (Zustand). Tracks active run status and per-node execution state.
* Separate from canvasStore to avoid coupling graph editing with run state.
*/
import { create } from 'zustand'
export type NodeRunStatus = 'pending' | 'running' | 'completed' | 'failed'
export type RunStatus = 'idle' | 'pending' | 'running' | 'completed' | 'failed'
export type NodeStepState = {
status: NodeRunStatus
output?: string
error?: string
chunk?: string
}
export type RunState = {
/** Current run ID (null when no run is active) */
activeRunId: string | null
/** Overall run status */
status: RunStatus
/** Per-node execution state, keyed by node ID */
nodeStates: Record<string, NodeStepState>
/** Error message if the run failed */
error: string | null
/** Whether the graph has changed since the last run */
dirty: boolean
/** Global trigger counter — render nodes subscribe to this to trigger their pipeline */
globalRunTrigger: number
}
type RunActions = {
startRun: (runId: string) => void
setRunStatus: (status: RunStatus, error?: string) => void
setNodeStatus: (nodeId: string, state: Partial<NodeStepState>) => void
appendChunk: (nodeId: string, chunk: string) => void
markDirty: () => void
reset: () => void
}
const initialState: RunState = {
activeRunId: null,
status: 'idle',
nodeStates: {},
error: null,
dirty: true,
globalRunTrigger: 0,
}
export const useRunStore = create<RunState & RunActions>((set) => ({
...initialState,
startRun: (runId) =>
set((s) => ({
activeRunId: runId,
status: 'pending',
nodeStates: {},
error: null,
dirty: false,
globalRunTrigger: s.globalRunTrigger + 1,
})),
setRunStatus: (status, error) =>
set({ status, error: error ?? null }),
setNodeStatus: (nodeId, partial) =>
set((s) => ({
nodeStates: {
...s.nodeStates,
[nodeId]: { ...s.nodeStates[nodeId], ...partial } as NodeStepState,
},
})),
appendChunk: (nodeId, chunk) =>
set((s) => {
const prev = s.nodeStates[nodeId]
return {
nodeStates: {
...s.nodeStates,
[nodeId]: {
...prev,
chunk: (prev?.chunk ?? '') + chunk,
},
},
}
}),
markDirty: () => set((s) => {
// Clear completed/failed overlays when graph changes — stale results no longer meaningful
const isFinished = s.status === 'completed' || s.status === 'failed'
return {
dirty: true,
...(isFinished ? { nodeStates: {}, status: 'idle' as RunStatus } : {}),
}
}),
reset: () => set((s) => ({ ...initialState, globalRunTrigger: s.globalRunTrigger })),
}))

View File

@@ -418,6 +418,7 @@ pre {
.react-arborist-tree { .react-arborist-tree {
background: transparent !important; background: transparent !important;
font-size: 0.875rem; font-size: 0.875rem;
overflow: hidden !important;
} }
.react-arborist-tree * { .react-arborist-tree * {

View File

@@ -16,6 +16,14 @@ export default defineConfig({
target: 'http://localhost:8080', target: 'http://localhost:8080',
changeOrigin: true, changeOrigin: true,
}, },
'/api/runs': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/api/recollections': {
target: 'http://localhost:8080',
changeOrigin: true,
},
// Kroki diagram service // Kroki diagram service
'/api/kroki': { '/api/kroki': {
target: 'https://kroki.io', target: 'https://kroki.io',