diff --git a/apps/server/src/services/plugins/builtin-registry.ts b/apps/server/src/services/plugins/builtin-registry.ts index 1bb78d2e87..0abe2f90b2 100644 --- a/apps/server/src/services/plugins/builtin-registry.ts +++ b/apps/server/src/services/plugins/builtin-registry.ts @@ -192,6 +192,12 @@ export const OFFICIAL_PLUGINS = [ defaultEnabled: true, category: "Workflow management", }, + { + name: "sceneseed", + pluginId: "sceneseed", + defaultEnabled: true, + category: "Interface", + }, ].map( (plugin): BundledPluginDefinition => ({ ...plugin, diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index 484f0a7b80..5c53f30ca1 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -785,7 +785,7 @@ them by mixing ink into canvas), the `--primary` accent, the secondary text tier (except `side-chat`, which is gated by the **"Side chat plugin"** experiment); official plugins install from the bundled store on demand. - **BB Official plugins** (store under `/api/v1/plugin-catalog`): - - BB's official plugins (GitHub, Docs, Memory, and Tasks) ship + - BB's official plugins (GitHub, Docs, Memory, Tasks, and SceneSeed) ship bundled inside the app and install from the local copy — no network. Installed official plugins are pinned to the bundled copy and update with BB app releases. - The store also lists the **BB Community marketplace** catalog: a manifest diff --git a/apps/server/test/services/plugins/builtin-plugins.test.ts b/apps/server/test/services/plugins/builtin-plugins.test.ts index 93089d1f6a..d613aebfb5 100644 --- a/apps/server/test/services/plugins/builtin-plugins.test.ts +++ b/apps/server/test/services/plugins/builtin-plugins.test.ts @@ -213,7 +213,13 @@ describe("builtin plugin reconciliation", () => { it("keeps official plugins bundled but out of the auto-install builtins", () => { const optionalNames = OFFICIAL_PLUGINS.map((plugin) => plugin.name); - expect(optionalNames).toEqual(["github", "docs", "memory", "tasks"]); + expect(optionalNames).toEqual([ + "github", + "docs", + "memory", + "tasks", + "sceneseed", + ]); for (const name of optionalNames) { expect(BUILTIN_PLUGINS.map((plugin) => plugin.name)).not.toContain(name); } diff --git a/apps/server/test/services/plugins/official-plugins.test.ts b/apps/server/test/services/plugins/official-plugins.test.ts index 3d80bb37c2..beecd31cc1 100644 --- a/apps/server/test/services/plugins/official-plugins.test.ts +++ b/apps/server/test/services/plugins/official-plugins.test.ts @@ -108,6 +108,7 @@ describe("official plugin registry invariants", () => { "provider-codex": "Agent interaction", "provider-pi": "Agent interaction", "provider-retry": "Agent interaction", + sceneseed: "Interface", secrets: "Developer tools", "side-chat": "Agent interaction", tasks: "Workflow management", diff --git a/docs/configuration.md b/docs/configuration.md index cd5f4e96fb..cc5891e889 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -705,7 +705,7 @@ Plugin state lives under the data dir: commands, injected into agent threads) ``` -BB's official plugins (GitHub, Docs, Memory, and Tasks) ship bundled +BB's official plugins (GitHub, Docs, Memory, Tasks, and SceneSeed) ship bundled inside the app and install from the local bundled copy — no network, no remote catalog. Discover them with `bb plugin search` or Extensions → Plugins → Browse; users cannot add, remove, or configure the bundled official plugin set. Installed official diff --git a/docs/official-plugin-release-process.md b/docs/official-plugin-release-process.md index 1555a88404..32fb9e4644 100644 --- a/docs/official-plugin-release-process.md +++ b/docs/official-plugin-release-process.md @@ -15,12 +15,13 @@ until a user installs them. The official plugins are: -| Directory | Package name | Store entry | Plugin id | -| ---------------- | ------------------------ | ----------- | -------------- | -| `plugins/github` | `bb-plugin-github` | `github` | `github` | -| `plugins/docs` | `bb-plugin-simple-notes` | `docs` | `simple-notes` | -| `plugins/memory` | `bb-plugin-memory` | `memory` | `memory` | -| `plugins/tasks` | `bb-plugin-tasks` | `tasks` | `tasks` | +| Directory | Package name | Store entry | Plugin id | +| ------------------- | ------------------------ | ----------- | -------------- | +| `plugins/github` | `bb-plugin-github` | `github` | `github` | +| `plugins/docs` | `bb-plugin-simple-notes` | `docs` | `simple-notes` | +| `plugins/memory` | `bb-plugin-memory` | `memory` | `memory` | +| `plugins/tasks` | `bb-plugin-tasks` | `tasks` | `tasks` | +| `plugins/sceneseed` | `bb-plugin-sceneseed` | `sceneseed` | `sceneseed` | ## Releasing a change diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 9769c98cb0..9546172762 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -159,6 +159,22 @@ label-link/name change, active-thread change, or project-prefix change invalidat outstanding cursor; restart without `--cursor` instead of accepting a mixed snapshot. +The SceneSeed plugin is an opt-in official plugin bundled with the app: +`bb plugin install sceneseed`. It turns prompt cards into bounded, dimensional +scenes and exposes the same durable canvases to agents through its CLI: + + bb sceneseed list + bb sceneseed show --json + bb sceneseed add --prompt [--x ] [--y ] + bb sceneseed wait + bb sceneseed cancel + bb sceneseed remove-object + +SceneSeed stores prompts, scene graphs, transforms, and job state in its +private plugin database. Generated scenes cannot contain executable code, +URLs, files, remote assets, or shaders. Use **Delete all canvas data** in its +settings to archive its hidden canvas threads and erase the stored canvases. + The builtin Secrets plugin provides a secure credential form and guarded dotenv reconciliation: @@ -174,7 +190,7 @@ added/updated/unchanged counts. the app plus every registered marketplace catalog bb plugin install Install a bundled official plugin by name - (github, docs, memory, tasks), + (github, docs, memory, tasks, sceneseed), @, a Git repository URL, local path, builtin:, git:[@], or @@ -331,13 +347,14 @@ rollback, and remove keep working per plugin. BB Official plugins -BB's official plugins — GitHub, Docs, Memory, and Tasks — ship bundled inside -the app itself. They appear in Extensions → Plugins → Browse +BB's official plugins — GitHub, Docs, Memory, Tasks, and SceneSeed — ship +bundled inside the app itself. They appear in Extensions → Plugins → Browse and install with one click from the local bundled copy: no network, no download, no separate release. Install from the CLI by bare name (`bb plugin install github`, `bb plugin install docs`, `bb plugin install -memory`, or `bb plugin install tasks`). Installed official plugins are pinned -to the bundled copy and update automatically when the BB app updates. +memory`, `bb plugin install tasks`, or `bb plugin install sceneseed`). Installed +official plugins are pinned to the bundled copy and update automatically when +the BB app updates. The BB Community marketplace (reserved name `bb-community`) lists reviewed plugins that live outside the app bundle. bb reads its manifest from @@ -776,9 +793,9 @@ in a checkout). The builtin `inline-vis` plugin renders path-shaped, sandboxed worktree HTML iframe preview; `height` is optional. Its card header includes an open-in-sidebar action for the source HTML file. The `plugins/` directory contains every bundled plugin: the auto-installed -builtins and the store-only BB Official GitHub, Docs, Memory, and Tasks -plugins. The `examples/plugins/` reference plugins cover slack-bot (webhook -bot), agent-enrichment (agent surfaces), and composer-customization (all +builtins and the store-only BB Official GitHub, Docs, Memory, Tasks, and +SceneSeed plugins. The `examples/plugins/` reference plugins cover slack-bot +(webhook bot), agent-enrichment (agent surfaces), and composer-customization (all composer regions). Thread Hover Cards installs from the BB Community marketplace (source: the bb-plugins repo). diff --git a/plugins/sceneseed/README.md b/plugins/sceneseed/README.md new file mode 100644 index 0000000000..848d47be23 --- /dev/null +++ b/plugins/sceneseed/README.md @@ -0,0 +1,62 @@ +# SceneSeed + +SceneSeed is bb's playful prompt-to-object canvas. Write a short prompt on a +card, place it on the stage, and a hidden bb agent interprets it as a bounded, +dimensional Three.js scene. + +## Install + +Install SceneSeed from the BB Official catalog: + +```bash +bb plugin install sceneseed +``` + +Open **SceneSeed** in bb's navigation, acknowledge the first-run disclosure, +create a canvas, and place a prompt card. Dragging is optional: every ready card +also has a keyboard-accessible **Place on canvas** action. + +## Agent access + +The same saved records are available through the plugin CLI: + +```bash +bb sceneseed list +bb sceneseed show --json +bb sceneseed add --prompt "a storm in a teacup" --x 0 --y 0 +bb sceneseed wait +bb sceneseed cancel +bb sceneseed remove-object +``` + +## Safety and privacy + +- Generated output must conform to SceneSeed's strict, versioned scene contract. + It cannot contain executable code, URLs, files, remote assets, or shaders. +- Prompts, generated scene graphs, transforms, job state, and canvas metadata are + stored in the plugin's private SQLite database, not in a project workspace. +- Each canvas uses a hidden, persistent bb thread in the personal project. The + thread uses the normal provider and bb capability envelope; SceneSeed asks it + not to inspect unrelated context, but does not claim structural isolation. +- Disabling or uninstalling the plugin does not erase its database or spawned + threads. Use **Delete all canvas data** in SceneSeed settings to archive canvas + threads and clear the stored canvases. + +## MVP limits + +A canvas accepts at most 12 cards in flight, 25 active objects, and 100 active +scene-cost units. Jobs run serially per canvas. Sharing, export, collaboration, +raw mesh editing, remote model loading, and image generation are intentionally +out of scope. + +`fixtures/prompt-scenes.json` is the fixed 32-prompt evaluation set for future +agent-quality runs: eight literal, metaphorical, spatial, and abstract prompts, +each with nearby-scene context and observable interpretation cues. It is test +data, not bundled executable content. + +## Develop + +```bash +pnpm exec turbo run test typecheck build --filter=bb-plugin-sceneseed +bb plugin dev ./plugins/sceneseed +``` diff --git a/plugins/sceneseed/app.css b/plugins/sceneseed/app.css new file mode 100644 index 0000000000..52adb00551 --- /dev/null +++ b/plugins/sceneseed/app.css @@ -0,0 +1,1018 @@ +.sceneseed-library, +.sceneseed-editor, +.sceneseed-missing, +.sceneseed-settings, +.sceneseed-disclosure { + --sceneseed-line: color-mix(in oklch, var(--ink) 14%, var(--canvas)); + --sceneseed-line-strong: color-mix(in oklch, var(--ink) 24%, var(--canvas)); + --sceneseed-soft: color-mix(in oklch, var(--ink) 5%, var(--canvas)); + --sceneseed-raised: color-mix(in oklch, var(--ink) 2.5%, var(--canvas)); + --sceneseed-muted: color-mix(in oklch, var(--ink) 58%, var(--canvas)); + --sceneseed-accent: var( + --accent, + color-mix(in oklch, var(--ink) 76%, var(--canvas)) + ); + color: var(--ink); +} + +.sceneseed-library { + box-sizing: border-box; + min-height: 100%; + overflow: auto; + padding: 2rem clamp(1rem, 4vw, 3.5rem) 4rem; + background: var(--canvas); +} + +.sceneseed-library-intro { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 26rem); + gap: clamp(2rem, 6vw, 6rem); + align-items: end; + max-width: 74rem; + margin: 0 auto 2rem; + padding: clamp(1.5rem, 4vw, 3rem) 0 2.25rem; + border-bottom: 1px solid var(--sceneseed-line); +} + +.sceneseed-library-intro h1, +.sceneseed-missing h1 { + max-width: 13ch; + margin: 0.2rem 0 0.75rem; + font-size: clamp(2.25rem, 6vw, 4.5rem); + font-weight: 590; + line-height: 0.98; + letter-spacing: -0.055em; +} + +.sceneseed-library-intro p:not(.sceneseed-eyebrow) { + max-width: 42rem; + margin: 0; + color: var(--sceneseed-muted); + font-size: 1rem; + line-height: 1.65; +} + +.sceneseed-eyebrow { + margin: 0; + color: var(--sceneseed-muted); + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.12em; + line-height: 1.4; + text-transform: uppercase; +} + +.sceneseed-new-canvas { + padding: 1rem; + border: 1px solid var(--sceneseed-line); + border-radius: 1rem; + background: var(--sceneseed-raised); + box-shadow: 0 1rem 3rem color-mix(in oklab, var(--ink) 8%, transparent); +} + +.sceneseed-new-canvas label { + display: block; + margin-bottom: 0.55rem; + font-size: 0.78rem; + font-weight: 650; +} + +.sceneseed-new-canvas > div { + display: flex; + gap: 0.55rem; +} + +.sceneseed-disclosure { + display: grid; + grid-template-columns: minmax(12rem, 0.75fr) repeat(2, minmax(12rem, 1fr)); + gap: 1rem 1.5rem; + max-width: 74rem; + margin: 0 auto 2rem; + padding: 1.25rem; + border: 1px solid var(--sceneseed-line-strong); + border-radius: 1rem; + background: color-mix(in oklch, var(--sceneseed-accent) 7%, var(--canvas)); +} + +.sceneseed-disclosure h2 { + margin: 0.15rem 0 0; + font-size: 1.05rem; + line-height: 1.25; +} + +.sceneseed-disclosure > p { + margin: 0; + color: var(--sceneseed-muted); + font-size: 0.8rem; + line-height: 1.55; +} + +.sceneseed-disclosure-actions { + grid-column: 2 / -1; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 1rem; +} + +.sceneseed-disclosure-screen { + display: flex; + flex-direction: column; + justify-content: center; +} + +.sceneseed-disclosure-back { + max-width: 74rem; + width: 100%; + margin: 0 auto; +} + +.sceneseed-text-link { + color: var(--ink); + font-size: 0.8rem; + text-decoration: underline; + text-underline-offset: 0.2rem; +} + +.sceneseed-connection, +.sceneseed-error { + max-width: 74rem; + margin: 0 auto 1rem; + padding: 0.75rem 0.9rem; + border: 1px solid var(--sceneseed-line); + border-radius: 0.7rem; + color: var(--sceneseed-muted); + background: var(--sceneseed-soft); + font-size: 0.8rem; +} + +.sceneseed-error, +.sceneseed-editor-error { + color: var(--destructive, var(--ink)); + border-color: color-mix( + in oklch, + var(--destructive, var(--ink)) 30%, + var(--canvas) + ); +} + +.sceneseed-library > section { + max-width: 74rem; + margin: 0 auto; +} + +.sceneseed-section-heading, +.sceneseed-rail-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.9rem; +} + +.sceneseed-section-heading h2, +.sceneseed-rail-heading h2, +.sceneseed-object-list h2 { + margin: 0.15rem 0 0; + font-size: 1.05rem; + font-weight: 630; + line-height: 1.2; +} + +.sceneseed-section-heading > span, +.sceneseed-rail-heading > span { + color: var(--sceneseed-muted); + font-size: 0.75rem; +} + +.sceneseed-library-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 0.75rem; +} + +.sceneseed-canvas-card { + position: relative; + display: flex; + min-height: 9rem; + flex-direction: column; + align-items: flex-start; + padding: 1rem; + overflow: hidden; + border: 1px solid var(--sceneseed-line); + border-radius: 1rem; + color: var(--ink); + background: var(--sceneseed-raised); + text-align: left; + transition: + transform 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease; +} + +.sceneseed-canvas-card:hover, +.sceneseed-canvas-card:focus-visible { + border-color: var(--sceneseed-line-strong); + outline: none; + box-shadow: 0 0.8rem 2.2rem color-mix(in oklab, var(--ink) 7%, transparent); + transform: translateY(-2px); +} + +.sceneseed-canvas-card-mark { + width: 2.25rem; + height: 2.25rem; + margin-bottom: 1.5rem; + border: 1px solid var(--sceneseed-line-strong); + border-radius: 50% 50% 46% 54%; + background: color-mix(in oklch, var(--sceneseed-accent) 24%, var(--canvas)); + box-shadow: 0 0.35rem 0 color-mix(in oklab, var(--ink) 10%, transparent); + transform: rotate(-8deg); +} + +.sceneseed-canvas-card-name { + margin-bottom: 0.25rem; + font-size: 1rem; + font-weight: 630; +} + +.sceneseed-canvas-card-meta, +.sceneseed-canvas-card-time { + color: var(--sceneseed-muted); + font-size: 0.72rem; +} + +.sceneseed-canvas-card-time { + margin-top: auto; + padding-top: 0.8rem; +} + +.sceneseed-empty-library { + display: flex; + min-height: 16rem; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + border: 1px dashed var(--sceneseed-line-strong); + border-radius: 1rem; + text-align: center; +} + +.sceneseed-empty-library h3, +.sceneseed-renderer-fallback h3 { + margin: 0.8rem 0 0.25rem; + font-size: 1rem; +} + +.sceneseed-empty-library p, +.sceneseed-renderer-fallback p { + max-width: 28rem; + margin: 0; + color: var(--sceneseed-muted); + font-size: 0.8rem; + line-height: 1.55; +} + +.sceneseed-seed-mark { + width: 1.6rem; + height: 2rem; + border: 1px solid var(--sceneseed-line-strong); + border-radius: 60% 40% 54% 46%; + background: color-mix(in oklch, var(--sceneseed-accent) 30%, var(--canvas)); + box-shadow: 0 0.35rem 0 color-mix(in oklab, var(--ink) 12%, transparent); + transform: rotate(12deg); +} + +.sceneseed-editor { + display: flex; + width: 100%; + height: 100%; + min-height: 0; + flex-direction: column; + overflow: hidden; + background: var(--canvas); +} + +.sceneseed-editor-header { + position: relative; + z-index: 20; + display: grid; + min-height: 3.4rem; + grid-template-columns: 8rem minmax(0, 1fr) 8rem; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 0.75rem; + border-bottom: 1px solid var(--sceneseed-line); + background: var(--canvas); +} + +.sceneseed-editor-title { + display: flex; + min-width: 0; + align-items: center; + justify-content: center; + gap: 0.65rem; +} + +.sceneseed-editor-title h1 { + margin: 0; + overflow: hidden; + font-size: 0.9rem; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sceneseed-rename { + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; +} + +.sceneseed-rename input { + max-width: 20rem; +} + +.sceneseed-canvas-menu { + position: relative; + justify-self: end; +} + +.sceneseed-canvas-menu summary { + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + border-radius: 0.45rem; + color: var(--sceneseed-muted); + cursor: pointer; + font-weight: 650; + list-style: none; +} + +.sceneseed-canvas-menu summary::-webkit-details-marker { + display: none; +} + +.sceneseed-canvas-menu summary:hover, +.sceneseed-canvas-menu summary:focus-visible { + color: var(--ink); + background: var(--sceneseed-soft); + outline: none; +} + +.sceneseed-canvas-menu > div { + position: absolute; + top: calc(100% + 0.35rem); + right: 0; + display: flex; + width: 14rem; + flex-direction: column; + gap: 0.2rem; + padding: 0.35rem; + border: 1px solid var(--sceneseed-line); + border-radius: 0.65rem; + background: var(--canvas); + box-shadow: 0 1rem 2.5rem color-mix(in oklab, var(--ink) 12%, transparent); +} + +.sceneseed-canvas-menu button { + min-height: 2rem; + padding: 0.4rem 0.55rem; + border: 0; + border-radius: 0.4rem; + color: var(--ink); + background: transparent; + text-align: left; +} + +.sceneseed-canvas-menu button:hover, +.sceneseed-canvas-menu button:focus-visible { + background: var(--sceneseed-soft); + outline: none; +} + +.sceneseed-canvas-menu button:disabled { + opacity: 0.45; +} + +.sceneseed-canvas-menu p { + margin: 0; + padding: 0.35rem 0.55rem; + color: var(--sceneseed-muted); + font-size: 0.75rem; + line-height: 1.4; +} + +.sceneseed-canvas-menu .sceneseed-danger-action { + color: var(--destructive, var(--ink)); +} + +.sceneseed-offline-banner, +.sceneseed-editor-error { + position: relative; + z-index: 10; + padding: 0.55rem 1rem; + border-bottom: 1px solid var(--sceneseed-line); + color: var(--sceneseed-muted); + background: var(--sceneseed-soft); + font-size: 0.75rem; + text-align: center; +} + +.sceneseed-workspace { + display: grid; + min-height: 0; + flex: 1; + grid-template-columns: minmax(16rem, 19rem) minmax(0, 1fr); +} + +.sceneseed-card-rail { + min-height: 0; + padding: 1rem; + overflow: auto; + border-right: 1px solid var(--sceneseed-line); + background: var(--canvas); +} + +.sceneseed-rail-instruction { + margin: 0 0 1rem; + color: var(--sceneseed-muted); + font-size: 0.75rem; + line-height: 1.5; +} + +.sceneseed-card-stack { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.sceneseed-prompt-card { + position: relative; + padding: 0.75rem; + border: 1px solid var(--sceneseed-line); + border-radius: 0.8rem; + background: var(--sceneseed-raised); + box-shadow: 0 0.25rem 0 color-mix(in oklab, var(--ink) 8%, transparent); + transition: + border-color 120ms ease, + transform 120ms ease, + box-shadow 120ms ease; +} + +.sceneseed-prompt-card[draggable="true"] { + cursor: grab; +} + +.sceneseed-prompt-card[draggable="true"]:active { + cursor: grabbing; +} + +.sceneseed-prompt-card[data-selected="true"] { + border-color: color-mix(in oklch, var(--sceneseed-accent) 65%, var(--canvas)); + box-shadow: + 0 0 0 2px color-mix(in oklab, var(--sceneseed-accent) 18%, transparent), + 0 0.25rem 0 color-mix(in oklab, var(--ink) 8%, transparent); +} + +.sceneseed-prompt-card[data-state="queued"], +.sceneseed-prompt-card[data-state="interpreting"], +.sceneseed-prompt-card[data-state="realizing"] { + background: color-mix(in oklch, var(--sceneseed-accent) 5%, var(--canvas)); +} + +.sceneseed-card-prompt { + display: block; + width: 100%; + padding: 0; + border: 0; + color: var(--ink); + background: transparent; + font-size: 0.88rem; + font-weight: 560; + line-height: 1.35; + text-align: left; +} + +.sceneseed-card-prompt:disabled { + cursor: default; + opacity: 1; +} + +.sceneseed-card-prompt:focus-visible { + border-radius: 0.25rem; + outline: 2px solid var(--sceneseed-accent); + outline-offset: 0.25rem; +} + +.sceneseed-card-footer { + display: flex; + min-height: 2rem; + align-items: center; + gap: 0.45rem; + margin-top: 0.65rem; + color: var(--sceneseed-muted); + font-size: 0.68rem; +} + +.sceneseed-card-footer > button:first-of-type { + margin-left: auto; +} + +.sceneseed-status { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-weight: 620; +} + +.sceneseed-status > span { + width: 0.42rem; + height: 0.42rem; + border: 1px solid currentColor; + border-radius: 50%; + background: transparent; +} + +.sceneseed-status[data-state="complete"] > span { + background: var(--success, var(--ink)); +} + +.sceneseed-status[data-state="queued"] > span, +.sceneseed-status[data-state="interpreting"] > span, +.sceneseed-status[data-state="realizing"] > span { + background: var(--sceneseed-accent); +} + +.sceneseed-status[data-state="failed"] > span, +.sceneseed-status[data-state="cancelled"] > span { + background: var(--destructive, var(--ink)); +} + +.sceneseed-card-error { + margin: 0.45rem 0 0; + color: var(--destructive, var(--ink)); + font-size: 0.72rem; + line-height: 1.4; +} + +.sceneseed-draft-card textarea { + min-height: 4.75rem; + padding: 0; + resize: none; + border: 0; + background: transparent; + box-shadow: none; + font-size: 0.88rem; + line-height: 1.4; +} + +.sceneseed-draft-card textarea:focus-visible { + outline: none; + box-shadow: none; +} + +.sceneseed-unsaved { + color: var(--warning, var(--ink)); + font-weight: 620; +} + +.sceneseed-stage-column { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + overflow: auto; + background: var(--sceneseed-soft); +} + +.sceneseed-stage { + position: relative; + min-height: 28rem; + flex: 1 0 min(64vh, 52rem); + overflow: hidden; + background: color-mix(in oklch, var(--ink) 3%, var(--canvas)); +} + +.sceneseed-webgl { + position: absolute !important; + inset: 0; +} + +.sceneseed-stage-empty, +.sceneseed-renderer-fallback { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + color: var(--sceneseed-muted); + text-align: center; + pointer-events: none; +} + +.sceneseed-stage-empty strong { + margin-top: 0.9rem; + color: var(--ink); + font-size: 0.95rem; +} + +.sceneseed-stage-empty span { + margin-top: 0.25rem; + font-size: 0.75rem; +} + +.sceneseed-renderer-fallback { + z-index: 8; + background: var(--canvas); + pointer-events: auto; +} + +.sceneseed-renderer-fallback button { + margin-top: 1rem; +} + +.sceneseed-placement-layer { + position: absolute; + z-index: 9; + inset: 0; + cursor: crosshair; + background: color-mix(in oklab, var(--sceneseed-accent) 7%, transparent); + outline: none; +} + +.sceneseed-placement-layer:focus-visible { + box-shadow: inset 0 0 0 3px + color-mix(in oklab, var(--sceneseed-accent) 55%, transparent); +} + +.sceneseed-placement-cursor { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + color: var(--ink); + font-size: 0.68rem; + transform: translate(-50%, -50%); + pointer-events: none; +} + +.sceneseed-placement-cursor > span { + width: 1.45rem; + height: 1.8rem; + border: 2px solid var(--sceneseed-accent); + border-radius: 60% 40% 54% 46%; + background: color-mix(in oklab, var(--canvas) 90%, transparent); + box-shadow: 0 0.35rem 0 color-mix(in oklab, var(--ink) 15%, transparent); +} + +.sceneseed-placement-cancel { + position: absolute; + right: 1rem; + bottom: 1rem; + min-height: 2rem; + padding: 0.4rem 0.7rem; + border: 1px solid var(--sceneseed-line-strong); + border-radius: 0.45rem; + color: var(--ink); + background: var(--canvas); + font-size: 0.72rem; +} + +.sceneseed-stage-seed { + position: absolute; + z-index: 4; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + color: var(--sceneseed-muted); + font-size: 0.68rem; + transform: translate(-50%, -50%); + pointer-events: none; +} + +.sceneseed-stage-seed > span { + width: 1.15rem; + height: 1.45rem; + border: 1px solid var(--sceneseed-line-strong); + border-radius: 55% 45% 58% 42%; + background: color-mix(in oklch, var(--sceneseed-accent) 25%, var(--canvas)); + box-shadow: 0 0.3rem 0 color-mix(in oklab, var(--ink) 12%, transparent); + animation: sceneseed-seed-breathe 1.8s ease-in-out infinite; +} + +.sceneseed-object-controls { + display: grid; + grid-template-columns: minmax(11rem, 1fr) auto; + gap: 1rem; + align-items: center; + padding: 0.8rem 1rem; + border-top: 1px solid var(--sceneseed-line); + border-bottom: 1px solid var(--sceneseed-line); + background: var(--canvas); +} + +.sceneseed-selected-copy { + min-width: 0; +} + +.sceneseed-selected-copy h3 { + margin: 0.12rem 0 0; + overflow: hidden; + font-size: 0.86rem; + font-weight: 630; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sceneseed-transform-groups, +.sceneseed-object-actions, +.sceneseed-control-group { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.sceneseed-transform-groups { + grid-column: 1 / -1; + justify-content: flex-end; + gap: 0.65rem; +} + +.sceneseed-object-actions { + justify-content: flex-end; +} + +.sceneseed-control-group > span { + margin-right: 0.1rem; + color: var(--sceneseed-muted); + font-size: 0.68rem; + font-weight: 620; +} + +.sceneseed-control-group button { + min-width: 2rem; + padding-inline: 0.45rem; +} + +.sceneseed-no-selection { + padding: 0.75rem 1rem; + border-top: 1px solid var(--sceneseed-line); + border-bottom: 1px solid var(--sceneseed-line); + color: var(--sceneseed-muted); + background: var(--canvas); + font-size: 0.75rem; + text-align: center; +} + +.sceneseed-object-list { + padding: 1rem; + background: var(--canvas); +} + +.sceneseed-object-list > p { + color: var(--sceneseed-muted); + font-size: 0.75rem; +} + +.sceneseed-object-list ol { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: 0.5rem; + margin: 0.75rem 0 0; + padding: 0; + list-style: none; +} + +.sceneseed-object-list li { + border: 1px solid var(--sceneseed-line); + border-radius: 0.65rem; +} + +.sceneseed-object-list li[data-selected="true"] { + border-color: color-mix(in oklch, var(--sceneseed-accent) 65%, var(--canvas)); +} + +.sceneseed-object-list button { + display: flex; + width: 100%; + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + padding: 0.65rem; + border: 0; + border-radius: inherit; + color: var(--ink); + background: transparent; + text-align: left; +} + +.sceneseed-object-list button:hover, +.sceneseed-object-list button:focus-visible { + background: var(--sceneseed-soft); + outline: none; +} + +.sceneseed-object-list strong { + font-size: 0.78rem; +} + +.sceneseed-object-list span, +.sceneseed-object-list small { + color: var(--sceneseed-muted); + font-size: 0.68rem; +} + +.sceneseed-missing { + display: flex; + min-height: 100%; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + background: var(--canvas); + text-align: center; +} + +.sceneseed-missing h1 { + max-width: 18ch; + margin-top: 1.25rem; + font-size: clamp(2rem, 5vw, 3.5rem); +} + +.sceneseed-missing p { + max-width: 30rem; + margin: 0 0 1rem; + color: var(--sceneseed-muted); +} + +.sceneseed-settings { + display: flex; + max-width: 42rem; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; +} + +.sceneseed-settings h3, +.sceneseed-settings p { + margin: 0; +} + +.sceneseed-settings p { + color: var(--sceneseed-muted); + font-size: 0.8rem; + line-height: 1.55; +} + +.sceneseed-clear-confirmation { + width: 100%; + padding: 1rem; + border: 1px solid + color-mix(in oklch, var(--destructive, var(--ink)) 30%, var(--canvas)); + border-radius: 0.75rem; + background: color-mix( + in oklch, + var(--destructive, var(--ink)) 5%, + var(--canvas) + ); +} + +.sceneseed-clear-confirmation > div { + display: flex; + gap: 0.5rem; + margin-top: 0.85rem; +} + +.sceneseed-settings-result { + padding: 0.65rem 0.75rem; + border: 1px solid var(--sceneseed-line); + border-radius: 0.55rem; + background: var(--sceneseed-soft); +} + +.sceneseed-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@keyframes sceneseed-seed-breathe { + 0%, + 100% { + transform: translateY(0) rotate(8deg); + } + 50% { + transform: translateY(-0.2rem) rotate(4deg); + } +} + +@media (max-width: 900px) { + .sceneseed-library-intro, + .sceneseed-disclosure { + grid-template-columns: 1fr; + } + + .sceneseed-disclosure-actions { + grid-column: 1; + justify-content: flex-start; + } + + .sceneseed-object-controls { + grid-template-columns: 1fr; + } + + .sceneseed-transform-groups, + .sceneseed-object-actions { + grid-column: auto; + justify-content: flex-start; + flex-wrap: wrap; + } +} + +@media (max-width: 1100px) { + .sceneseed-library { + padding: 1rem 0.85rem 3rem; + } + + .sceneseed-library-intro { + padding-top: 1rem; + } + + .sceneseed-library-intro h1 { + max-width: 11ch; + } + + .sceneseed-new-canvas > div { + flex-direction: column; + } + + .sceneseed-editor-header { + grid-template-columns: auto minmax(0, 1fr) auto; + } + + .sceneseed-workspace { + display: flex; + flex-direction: column; + overflow: auto; + } + + .sceneseed-card-rail { + max-height: 42vh; + flex: 0 0 auto; + border-right: 0; + border-bottom: 1px solid var(--sceneseed-line); + } + + .sceneseed-card-stack { + display: grid; + grid-auto-columns: minmax(15rem, 78vw); + grid-auto-flow: column; + overflow-x: auto; + padding-bottom: 0.35rem; + scroll-snap-type: x proximity; + } + + .sceneseed-prompt-card { + scroll-snap-align: start; + } + + .sceneseed-stage-column { + min-height: 35rem; + overflow: visible; + } + + .sceneseed-stage { + min-height: 28rem; + flex-basis: 58vh; + } + + .sceneseed-rename { + position: absolute; + inset: 0.4rem 3.25rem; + z-index: 1; + background: var(--canvas); + } +} + +@media (prefers-reduced-motion: reduce) { + .sceneseed-canvas-card, + .sceneseed-prompt-card { + transition-duration: 0.01ms; + } + + .sceneseed-stage-seed > span { + animation: none; + } +} diff --git a/plugins/sceneseed/app.test.tsx b/plugins/sceneseed/app.test.tsx new file mode 100644 index 0000000000..ce29f4583c --- /dev/null +++ b/plugins/sceneseed/app.test.tsx @@ -0,0 +1,519 @@ +// @vitest-environment jsdom + +import { act, fireEvent, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; + +import { + createSceneSeedUiFixture, + SCENESEED_QA_SUBPATH, +} from "./sceneseed-ui-fixture.js"; +import type { CanvasSnapshotDto } from "./store.js"; + +interface MockRenderObject { + scene: { jobId: string; objectId: string }; + probeOnly?: boolean; + reveal?: boolean; + revisionKey?: string | number; +} + +vi.mock("./scene-renderer.js", () => ({ + SceneRenderer: ({ + objects, + onRenderProbe, + onSelectObject, + onContextLost, + }: { + objects: MockRenderObject[]; + onRenderProbe?: (event: { + status: "ready"; + jobId: string; + objectId: string; + nodeCount: number; + }) => void; + onSelectObject?: (objectId: string) => void; + onContextLost?: () => void; + }) => ( +
+ {objects.map((object) => + object.probeOnly ? ( + + ) : ( + + ), + )} + +
+ ), +})); + +const app = await loadPluginApp(() => import("./app")); +const mounted: Array<{ lifecycle: { unmount(): void } }> = []; + +function track(slot: T): T { + mounted.push(slot); + return slot; +} + +function disclosureState(acknowledged = true) { + return { + canvases: [], + disclosureAcknowledged: acknowledged, + }; +} + +function candidateReadySnapshot(): CanvasSnapshotDto { + const snapshot = createSceneSeedUiFixture(); + const objectId = "object_lighthouse"; + const jobId = "job_lighthouse"; + return { + ...snapshot, + canvas: { ...snapshot.canvas, revision: 20 }, + cards: snapshot.cards.map((card) => + card.id === "card_lighthouse" ? { ...card, state: "realizing" } : card, + ), + objects: snapshot.objects.map((object) => + object.id === objectId + ? { ...object, activeSceneId: null, activeJobId: jobId } + : object, + ), + jobs: snapshot.jobs.map((job) => + job.id === jobId + ? { ...job, state: "candidate_ready", finishedAt: null } + : job, + ), + candidates: snapshot.candidates.map((candidate) => + candidate.jobId === jobId + ? { + ...candidate, + state: "pending", + realizationAttempts: 0, + realizedAt: null, + } + : candidate, + ), + }; +} + +beforeEach(() => { + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", () => undefined); +}); + +afterEach(() => { + while (mounted.length > 0) mounted.pop()?.lifecycle.unmount(); + vi.unstubAllGlobals(); +}); + +describe("SceneSeed app", () => { + it("registers an independent canvas route and a real settings surface", () => { + expect(app.navPanels).toHaveLength(1); + expect(app.navPanels[0]?.path).toBe("sceneseed"); + expect(app.settingsSections).toHaveLength(1); + expect(SCENESEED_QA_SUBPATH).toBe("qa-fixture"); + }); + + it("creates a canvas from the library and navigates to its durable route", async () => { + const snapshot = createSceneSeedUiFixture(); + const slot = track( + renderSlot( + app.navPanels[0]!, + { subPath: "" }, + { + rpc: { + listCanvases: () => disclosureState(true), + createCanvas: (input: unknown) => { + if ( + typeof input !== "object" || + input === null || + !("name" in input) || + typeof input.name !== "string" + ) { + throw new Error("expected a canvas name"); + } + return { + snapshot: { + ...snapshot, + canvas: { + ...snapshot.canvas, + id: "canvas_new", + name: input.name, + }, + }, + }; + }, + }, + }, + ), + ); + + fireEvent.change(await slot.findByLabelText("New canvas"), { + target: { value: "Small wonders" }, + }); + fireEvent.click(slot.getByRole("button", { name: "Create" })); + + await waitFor(() => + expect(slot.navigateCalls).toContainEqual({ + method: "toPluginPanel", + path: "sceneseed", + options: { subPath: "canvas/canvas_new" }, + }), + ); + }); + + it("requires an explicit capability and retention acknowledgement", async () => { + const slot = track( + renderSlot( + app.navPanels[0]!, + { subPath: "" }, + { + rpc: { + listCanvases: () => disclosureState(false), + acknowledgeDisclosure: () => ({ acknowledgedAt: Date.now() }), + }, + }, + ), + ); + + expect( + await slot.findByText("Know what the interpreter can access"), + ).toBeDefined(); + fireEvent.click(slot.getByRole("button", { name: "I understand" })); + await waitFor(() => + expect(slot.rpcCalls).toContainEqual({ + method: "acknowledgeDisclosure", + input: null, + }), + ); + }); + + it("keeps an offline draft in memory and disables placement", () => { + const slot = track( + renderSlot( + app.navPanels[0]!, + { subPath: SCENESEED_QA_SUBPATH }, + { realtimeConnectionState: "reconnecting" }, + ), + ); + + const draft = slot.getByPlaceholderText("Write a prompt…"); + fireEvent.change(draft, { target: { value: "a warm clock under snow" } }); + expect(slot.getByText("Unsaved")).toBeDefined(); + const place = slot + .getAllByRole("button", { name: "Place on canvas" }) + .find((button) => !button.hasAttribute("disabled")); + expect(place).toBeUndefined(); + }); + + it("supports the keyboard placement path and persists before queuing", async () => { + let current = createSceneSeedUiFixture(); + const slot = track( + renderSlot( + app.navPanels[0]!, + { subPath: "canvas/canvas_fixture" }, + { + rpc: { + listCanvases: () => disclosureState(true), + getCanvas: () => ({ snapshot: current }), + createCard: (input: unknown) => { + if ( + typeof input !== "object" || + input === null || + !("prompt" in input) || + typeof input.prompt !== "string" + ) { + throw new Error("expected a card prompt"); + } + current = { + ...current, + canvas: { ...current.canvas, revision: 8 }, + cards: [ + ...current.cards, + { + id: "card_keyboard", + canvasId: current.canvas.id, + prompt: input.prompt, + state: "ready", + order: current.cards.length, + placement: null, + activeJobId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + ], + }; + return { snapshot: current, cardId: "card_keyboard" }; + }, + placeCard: () => ({ snapshot: current, jobId: "job_keyboard" }), + }, + }, + ), + ); + + const draft = await slot.findByPlaceholderText("Write a prompt…"); + fireEvent.change(draft, { + target: { value: "a silver memory with roots" }, + }); + fireEvent.keyDown(draft, { key: "Enter", metaKey: true }); + const target = await slot.findByRole("button", { + name: /Place “a silver memory with roots”/, + }); + fireEvent.keyDown(target, { key: "ArrowRight" }); + fireEvent.keyDown(target, { key: "Enter" }); + + await waitFor(() => + expect(slot.rpcCalls).toContainEqual({ + method: "placeCard", + input: { + canvasId: "canvas_fixture", + cardId: "card_keyboard", + placement: { x: 0.5, y: 0 }, + expectedRevision: 8, + }, + }), + ); + const createIndex = slot.rpcCalls.findIndex( + (call) => call.method === "createCard", + ); + const placeIndex = slot.rpcCalls.findIndex( + (call) => call.method === "placeCard", + ); + expect(createIndex).toBeGreaterThan(-1); + expect(placeIndex).toBeGreaterThan(createIndex); + }); + + it("probes an inactive candidate before acknowledging and revealing it", async () => { + let current = candidateReadySnapshot(); + let resolveBegin!: (result: { + snapshot: CanvasSnapshotDto; + alreadyProcessed: boolean; + }) => void; + const beginResult = new Promise<{ + snapshot: CanvasSnapshotDto; + alreadyProcessed: boolean; + }>((resolve) => { + resolveBegin = resolve; + }); + const slot = track( + renderSlot( + app.navPanels[0]!, + { subPath: "canvas/canvas_fixture" }, + { + rpc: { + listCanvases: () => disclosureState(true), + getCanvas: () => ({ snapshot: current }), + beginRealization: () => beginResult, + acknowledgeRealization: () => { + current = { + ...current, + canvas: { ...current.canvas, revision: 22 }, + cards: current.cards.map((card) => + card.id === "card_lighthouse" + ? { ...card, state: "complete" } + : card, + ), + objects: current.objects.map((object) => + object.id === "object_lighthouse" + ? { ...object, activeSceneId: "scene_lighthouse" } + : object, + ), + jobs: current.jobs.map((job) => + job.id === "job_lighthouse" + ? { ...job, state: "complete" } + : job, + ), + candidates: current.candidates.map((candidate) => + candidate.id === "scene_lighthouse" + ? { ...candidate, state: "active" } + : candidate, + ), + }; + return { snapshot: current, outcome: "complete" }; + }, + }, + }, + ), + ); + + await waitFor(() => + expect( + slot.rpcCalls.some((call) => call.method === "beginRealization"), + ).toBe(true), + ); + expect( + slot.queryByRole("button", { name: "Probe job_lighthouse" }), + ).toBeNull(); + current = { + ...current, + canvas: { ...current.canvas, revision: 21 }, + jobs: current.jobs.map((job) => + job.id === "job_lighthouse" ? { ...job, state: "realizing" } : job, + ), + }; + resolveBegin({ snapshot: current, alreadyProcessed: false }); + + const probe = await slot.findByRole("button", { + name: "Probe job_lighthouse", + }); + expect(probe.getAttribute("data-probe-only")).toBe("true"); + expect( + slot.rpcCalls.some((call) => call.method === "acknowledgeRealization"), + ).toBe(false); + fireEvent.click(probe); + + await waitFor(() => + expect( + slot.rpcCalls.some((call) => call.method === "acknowledgeRealization"), + ).toBe(true), + ); + await waitFor(() => { + const revealed = slot.getByRole("button", { + name: "Select object_lighthouse", + }); + expect(revealed.getAttribute("data-reveal")).toBe("true"); + }); + }); + + it("waits out a stale realization lease before exposing the accepted attempt", async () => { + vi.useFakeTimers(); + try { + const initial = candidateReadySnapshot(); + let current: CanvasSnapshotDto = { + ...initial, + jobs: initial.jobs.map((job) => + job.id === "job_lighthouse" ? { ...job, state: "realizing" } : job, + ), + }; + let beginCount = 0; + const slot = track( + renderSlot( + app.navPanels[0]!, + { subPath: "canvas/canvas_fixture" }, + { + rpc: { + listCanvases: () => disclosureState(true), + getCanvas: () => ({ snapshot: current }), + beginRealization: () => { + beginCount += 1; + if (beginCount === 1) { + throw new Error( + "another client is already realizing this candidate", + ); + } + current = { + ...current, + canvas: { + ...current.canvas, + revision: current.canvas.revision + 1, + }, + }; + return { snapshot: current, alreadyProcessed: false }; + }, + }, + }, + ), + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(beginCount).toBe(1); + expect( + slot.queryByRole("button", { name: "Probe job_lighthouse" }), + ).toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_500); + await Promise.resolve(); + }); + + expect(beginCount).toBe(2); + const probe = slot.getByRole("button", { name: "Probe job_lighthouse" }); + const beginCalls = slot.rpcCalls.filter( + (call) => call.method === "beginRealization", + ); + const acceptedInput = beginCalls[1]?.input; + if ( + typeof acceptedInput !== "object" || + acceptedInput === null || + !("attemptId" in acceptedInput) || + typeof acceptedInput.attemptId !== "string" + ) { + throw new Error("expected an accepted realization attempt id"); + } + expect(probe.getAttribute("data-revision-key")).toContain( + acceptedInput.attemptId, + ); + } finally { + vi.useRealTimers(); + } + }); + + it("makes clear-all an explicit, confirmed settings action", async () => { + const slot = track( + renderSlot( + app.settingsSections[0]!, + {}, + { + rpc: { + clearAllCanvasData: () => ({ + deletedCanvasCount: 3, + failedThreadIds: [], + }), + }, + }, + ), + ); + + fireEvent.click( + slot.getByRole("button", { name: "Delete all canvas data…" }), + ); + expect( + slot.getByText( + "This clears the plugin database and archives every canvas interpreter thread. This cannot be undone.", + ), + ).toBeDefined(); + fireEvent.click( + slot.getByRole("button", { name: "Delete all canvas data" }), + ); + await waitFor(() => + expect(slot.rpcCalls).toContainEqual({ + method: "clearAllCanvasData", + input: null, + }), + ); + expect( + await slot.findByText( + "Deleted 3 canvases and archived their hidden threads.", + ), + ).toBeDefined(); + }); +}); diff --git a/plugins/sceneseed/app.tsx b/plugins/sceneseed/app.tsx new file mode 100644 index 0000000000..adb947811f --- /dev/null +++ b/plugins/sceneseed/app.tsx @@ -0,0 +1,2212 @@ +import { + Component, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ErrorInfo, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from "react"; +import { + UrlLink, + definePluginApp, + useBbNavigate, + useRealtime, + useRealtimeConnectionState, + useRpc, + type PluginNavPanelProps, + type PluginSettingsSectionProps, +} from "@get-bb/plugin-sdk/app"; +import { Badge } from "@bb/shared-ui/badge"; +import { Button } from "@bb/shared-ui/button"; +import { Input } from "@bb/shared-ui/input"; +import { Skeleton } from "@bb/shared-ui/skeleton"; +import { Textarea } from "@bb/shared-ui/textarea"; + +import type { rpcContract } from "./server"; +import { + SceneRenderer, + type SceneRenderObject, + type SceneRenderProbeEvent, +} from "./scene-renderer.js"; +import { + createSceneSeedUiFixture, + SCENESEED_QA_SUBPATH, +} from "./sceneseed-ui-fixture.js"; +import type { + CanvasSnapshotDto, + CanvasSummaryDto, + CardDto, + JobDto, + ObjectDto, + Placement, + Transform3D, +} from "./store.js"; +import "./app.css"; + +const PANEL_PATH = "sceneseed"; +const ACTIVE_CARD_STATES = new Set([ + "ready", + "queued", + "interpreting", + "realizing", +]); + +type ConnectionState = ReturnType; + +interface DraftCard { + id: string; + prompt: string; +} + +type PlacementSource = + | { kind: "draft"; id: string; prompt: string } + | { kind: "card"; id: string; prompt: string }; + +interface WorkspaceActions { + rename(name: string): Promise; + deleteCanvas(): Promise; + place(source: PlacementSource, placement: Placement): Promise; + cancel(jobId: string): Promise; + transform(object: ObjectDto, transform: Transform3D): Promise; + remix(objectId: string): Promise; + duplicate(object: ObjectDto): Promise; + remove(object: ObjectDto): Promise; +} + +function nextClientId(prefix: string): string { + const id = globalThis.crypto?.randomUUID?.(); + if (id) return `${prefix}_${id.replaceAll("-", "")}`; + return `${prefix}_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isCanvasSignal( + payload: unknown, +): payload is { canvasId: string; revision: number } { + if (typeof payload !== "object" || payload === null) return false; + return ( + "canvasId" in payload && + typeof payload.canvasId === "string" && + "revision" in payload && + typeof payload.revision === "number" + ); +} + +function canvasSubPath(canvasId: string): string { + return `canvas/${encodeURIComponent(canvasId)}`; +} + +function parseCanvasId(subPath: string): string | null { + if (!subPath.startsWith("canvas/")) return null; + const encoded = subPath.slice("canvas/".length); + if (!encoded || encoded.includes("/")) return null; + try { + return decodeURIComponent(encoded); + } catch { + return null; + } +} + +function formatRelativeTime(timestamp: number, now: number): string { + const elapsed = Math.max(0, now - timestamp); + if (elapsed < 60_000) return "just now"; + if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m ago`; + if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)}h ago`; + return `${Math.floor(elapsed / 86_400_000)}d ago`; +} + +function cardStateLabel(card: CardDto): string { + switch (card.state) { + case "ready": + return "Ready"; + case "queued": + return "Queued"; + case "interpreting": + return "Interpreting"; + case "realizing": + return "Realizing"; + case "complete": + return "Complete"; + case "cancelled": + return "Cancelled"; + case "failed": + return "Failed"; + } +} + +function useClock(active: boolean): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!active) return; + const timer = window.setInterval(() => setNow(Date.now()), 1_000); + return () => window.clearInterval(timer); + }, [active]); + return now; +} + +function useDisclosure(): { + acknowledged: boolean | null; + acknowledge: () => Promise; + error: string | null; +} { + const rpc = useRpc(); + const [acknowledged, setAcknowledged] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { + let active = true; + void rpc.call("listCanvases").then( + (result) => { + if (active) setAcknowledged(result.disclosureAcknowledged); + }, + (reason: unknown) => { + if (active) setError(errorMessage(reason)); + }, + ); + return () => { + active = false; + }; + }, [rpc]); + const acknowledge = useCallback(async () => { + setError(null); + try { + await rpc.call("acknowledgeDisclosure"); + setAcknowledged(true); + } catch (reason) { + setError(errorMessage(reason)); + } + }, [rpc]); + return { acknowledged, acknowledge, error }; +} + +function Disclosure({ + acknowledged, + error, + onAcknowledge, +}: { + acknowledged: boolean | null; + error: string | null; + onAcknowledge: () => Promise; +}) { + const [saving, setSaving] = useState(false); + if (acknowledged !== false) return null; + return ( +
+
+

Before the first seed

+

+ Know what the interpreter can access +

+
+

+ SceneSeed sends the prompt, placement, and nearby scene summaries to a + hidden bb agent in the personal project. Hidden means absent from normal + navigation, not secret or ephemeral. +

+

+ It is still a normal bb agent session and may have core, provider, and + plugin tools, shared skills and instructions, filesystem access, and + network access. Prompts and transcripts follow your provider and bb + retention settings. +

+

+ Uninstalling SceneSeed does not delete its canvas database or hidden + threads. “Delete all canvas data” clears the plugin database and + archives those threads. +

+
+ + Review provider settings + + +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +function LibraryPanel() { + const rpc = useRpc(); + const navigate = useBbNavigate(); + const connection = useRealtimeConnectionState(); + const [canvases, setCanvases] = useState(null); + const [newName, setNewName] = useState("Untitled canvas"); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const now = useClock(false); + const disclosure = useDisclosure(); + + const refresh = useCallback(() => { + void rpc.call("listCanvases").then( + (result) => { + setCanvases(result.canvases); + setError(null); + }, + (reason: unknown) => setError(errorMessage(reason)), + ); + }, [rpc]); + + useEffect(refresh, [refresh]); + useRealtime("library-changed", refresh); + useEffect(() => { + if (connection === "connected") refresh(); + }, [connection, refresh]); + + const create = async () => { + const name = newName.trim(); + if (!name || creating || connection !== "connected") return; + setCreating(true); + setError(null); + try { + const result = await rpc.call("createCanvas", { name }); + navigate.toPluginPanel(PANEL_PATH, { + subPath: canvasSubPath(result.snapshot.canvas.id), + }); + } catch (reason) { + setError(errorMessage(reason)); + } finally { + setCreating(false); + } + }; + + return ( +
+
+
+

Prompt → seed → scene

+

Grow an idea into a tiny world.

+

+ Drop a short phrase onto the canvas and see how the SceneSeed + interpreter draws it in three dimensions. +

+
+
{ + event.preventDefault(); + void create(); + }} + > + +
+ setNewName(event.currentTarget.value)} + disabled={connection !== "connected"} + /> + +
+
+
+ + + + {connection !== "connected" ? ( +

+ Reconnecting — saved canvases remain available, but changes are + paused. +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + +
+
+
+

Your work

+

Canvases

+
+ {canvases?.length ?? 0} +
+ {canvases === null ? ( +
+ + +
+ ) : canvases.length === 0 ? ( +
+ + ) : ( +
+ {canvases.map((canvas) => ( + + ))} +
+ )} +
+
+ ); +} + +class RendererBoundary extends Component< + { resetKey: number; children: ReactNode; fallback: ReactNode }, + { failed: boolean } +> { + state = { failed: false }; + + static getDerivedStateFromError(): { failed: boolean } { + return { failed: true }; + } + + componentDidUpdate(previous: Readonly<{ resetKey: number }>) { + if (previous.resetKey !== this.props.resetKey && this.state.failed) { + this.setState({ failed: false }); + } + } + + componentDidCatch(_error: Error, _info: ErrorInfo): void {} + + render(): ReactNode { + return this.state.failed ? this.props.fallback : this.props.children; + } +} + +function RendererUnavailable({ onReload }: { onReload: () => void }) { + return ( +
+ + ); +} + +function activeJobForCard( + snapshot: CanvasSnapshotDto, + card: CardDto, +): JobDto | null { + if (!card.activeJobId) return null; + return snapshot.jobs.find((job) => job.id === card.activeJobId) ?? null; +} + +function objectForCard( + snapshot: CanvasSnapshotDto, + cardId: string, +): ObjectDto | null { + return ( + snapshot.objects.find( + (object) => object.sourceCardId === cardId && object.removedAt === null, + ) ?? null + ); +} + +function buildRenderObjects( + snapshot: CanvasSnapshotDto, + revealingObjectIds: ReadonlySet, + realizationAttemptIds: ReadonlyMap = new Map(), +): SceneRenderObject[] { + const objects: SceneRenderObject[] = []; + for (const object of snapshot.objects) { + if (object.removedAt !== null) continue; + const active = snapshot.candidates.find( + (candidate) => candidate.id === object.activeSceneId, + ); + if (active?.normalizedScene) { + objects.push({ + scene: active.normalizedScene, + position: object.transform.position, + rotation: object.transform.rotation, + scale: object.transform.scale, + revisionKey: active.id, + reveal: revealingObjectIds.has(object.id), + }); + } + const currentJob = object.activeJobId + ? snapshot.jobs.find((job) => job.id === object.activeJobId) + : null; + if (currentJob?.state !== "realizing") continue; + const pending = snapshot.candidates.find( + (candidate) => + candidate.jobId === currentJob.id && + candidate.state === "pending" && + candidate.normalizedScene !== null, + ); + if (!pending?.normalizedScene) continue; + const attemptId = realizationAttemptIds.get(pending.id); + if (!attemptId) continue; + objects.push({ + scene: pending.normalizedScene, + position: object.transform.position, + rotation: object.transform.rotation, + scale: object.transform.scale, + revisionKey: `${pending.id}:${attemptId}`, + reveal: true, + probeOnly: true, + }); + } + return objects; +} + +function PromptCard({ + card, + snapshot, + readOnly, + selected, + now, + setRef, + onSelect, + onPlace, + onCancel, + onDragStart, +}: { + card: CardDto; + snapshot: CanvasSnapshotDto; + readOnly: boolean; + selected: boolean; + now: number; + setRef: (element: HTMLButtonElement | null) => void; + onSelect: () => void; + onPlace: () => void; + onCancel: (jobId: string) => void; + onDragStart: (event: React.DragEvent) => void; +}) { + const job = activeJobForCard(snapshot, card); + const placeable = + card.state === "ready" || + card.state === "cancelled" || + card.state === "failed"; + const cancellable = card.state === "queued" || card.state === "interpreting"; + const object = objectForCard(snapshot, card.id); + const startedAt = job?.startedAt ?? job?.createdAt ?? card.updatedAt; + return ( +
+ +
+ + + {(card.state === "interpreting" || card.state === "realizing") && + job ? ( + {Math.max(1, Math.floor((now - startedAt) / 1_000))}s + ) : null} + {placeable ? ( + + ) : null} + {cancellable && job ? ( + + ) : null} +
+ {card.state === "failed" && job?.errorMessage ? ( +

{job.errorMessage}

+ ) : null} +
+ ); +} + +function DraftPromptCard({ + draft, + readOnly, + atCapacity, + setRef, + onChange, + onPlace, + onDragStart, +}: { + draft: DraftCard; + readOnly: boolean; + atCapacity: boolean; + setRef: (element: HTMLTextAreaElement | null) => void; + onChange: (value: string) => void; + onPlace: () => void; + onDragStart: (event: React.DragEvent) => void; +}) { + const valid = draft.prompt.trim().length > 0; + return ( +
+ +