diff --git a/ui/goose2/docs/superpowers/plans/2026-04-29-interactive-home-canvas.md b/ui/goose2/docs/superpowers/plans/2026-04-29-interactive-home-canvas.md new file mode 100644 index 0000000000..3fdec25aad --- /dev/null +++ b/ui/goose2/docs/superpowers/plans/2026-04-29-interactive-home-canvas.md @@ -0,0 +1,273 @@ +# Interactive Home Canvas Implementation Plan + +> Source spec: `docs/superpowers/specs/2026-04-29-interactive-home-canvas-design.md` +> +> Status: review + implementation plan. This plan intentionally stops before code changes to the homepage. + +## Summary + +Replace `HomeView` with a persisted, draggable widget canvas while leaving `HomeScreen` unchanged. The core spec is strong: catalog, instance layout, picker, and persistence are cleanly separated, and the existing app already has most of the needed libraries (`motion`, Radix Popover, Radix ContextMenu, Zustand). + +The main things to settle before implementation are: + +- Cube source access: `https://github.com/anaghavi/cube-explo` was not discoverable publicly, and no exact `cube-explo` npm package showed up in search. +- Agent pin semantics: the spec alternates between "opens chat" and "opens agent"; the app currently has a direct `onOpenAgent` handler and a direct `onSelectSession` handler, but no dedicated "start chat with this persona" callback. +- Default layout seeding: `DEFAULT_INSTANCES` cannot know the canvas size or async-loaded persona list at module initialization time. +- Localization: the home namespace already exists, so new picker/menu/widget UI copy should use `react-i18next` instead of hardcoded English. + +## Review Feedback + +### 1. Use `motion/react`, not `framer-motion` + +The spec mentions framer-motion, but the codebase imports from `motion/react` in `ChatView`, `ChatContextPanel`, and `LoadingGoose`. Implement `WidgetFrame` and `AnimatePresence` with `motion/react` to match the existing dependency. + +### 2. Make default instances viewport-safe without making persistence complicated + +The spec models widget positions as pixels, but also asks for percentage-based first-load placement. Keep persisted positions as pixels. For first load, create defaults with calibrated pixel positions for the home content area, then clamp positions to the current canvas before render and after drag. This keeps storage simple and avoids schema churn. + +If we want smarter first-load placement, implement a `createDefaultInstances(canvasRect, defaultPersonaId)` helper and seed only when the persisted storage key is absent. Do not seed by checking `instances.length === 0`, because an intentionally empty persisted layout must stay empty. + +### 3. Keep agent pins resilient to async persona loading + +Do not require `DEFAULT_INSTANCES` to contain the default Goose persona id up front. The persona list loads asynchronously through `useAppStartup`. Let `AgentPinWidget` resolve its display target this way: + +1. Use `instance.state.agentId` if it matches a loaded persona. +2. Fall back to the first built-in persona. +3. Fall back to a generic "Goose" label while personas are still loading. + +Picker-created agent pins can pre-fill `state.agentId` when a persona is available. + +### 4. Decide what "Pin an agent" does + +Current `AppShellContent` renders `` without routing props. To make pins real, pass routing callbacks into `HomeView`. + +Recommended demo behavior: + +- `chatPin` calls existing `onSelectSession(sessionId)`. +- `agentPin` calls existing `onOpenAgent(agentId)`, opening the agent details surface. + +If the desired demo is "click agent pin to start a chat with that persona," add an explicit app-shell callback such as `onStartChatWithPersona(personaId)` rather than overloading `onOpenAgent`. That callback should create or reuse a draft session with `personaId` set. + +### 5. Use Radix ContextMenu in its natural shape + +The spec says `onContextMenu` opens a Radix context menu anchored at cursor. Radix already anchors context menus to the native context-menu event when using `ContextMenuTrigger asChild`. Prefer: + +```tsx + + + + + + ... + + +``` + +This avoids custom cursor anchoring state. + +### 6. Localize new visible UI strings + +Add stable keys under `src/shared/i18n/locales/en/home.json` and `src/shared/i18n/locales/es/home.json` for: + +- Picker section labels and item labels/descriptions +- Context menu "Remove" +- Widget mock labels/content that is rendered as app UI +- Empty/fallback labels such as "Recent chat" or "Goose" + +The mock content can still be static, but it should not be raw English in migrated home UI. + +### 7. Add a small demo recovery affordance only if wanted + +The spec says defaults return only after clearing localStorage. That is acceptable, but for demos it is easy to remove every widget and get stuck with a blank canvas. Optional follow-up: add an empty-canvas context menu item or small hidden developer action for "Reset layout." This is not required for the first implementation. + +## Implementation Plan + +## Phase 0 - Pre-flight + +- [ ] Confirm working tree and current branch. +- [ ] Re-read the source spec and this plan. +- [ ] Confirm `HomeView` is the only source importer of retired home assets, then delete those assets only during the implementation phase. +- [ ] Confirm cube path: + - [ ] If `cube-explo` source is provided or accessible, copy the relevant source into `src/features/home/widgets/cube/`. + - [ ] If it requires Three/R3F, evaluate dependency cost before adding packages. + - [ ] If source remains unavailable, implement a lightweight CSS/DOM animated cube fallback and document that the cube source remains blocked. +- [ ] Decide agent pin behavior: + - [ ] Recommended: `agentPin` opens agent details with `onOpenAgent`. + - [ ] Alternative: add `onStartChatWithPersona`. + +## Phase 1 - Types, Catalog, and Store + +Files: + +- Create: `src/features/home/widgets/types.ts` +- Create: `src/features/home/widgets/catalog.ts` +- Create: `src/features/home/stores/homeWidgetStore.ts` + +Tasks: + +- [ ] Define `WidgetCategory`, `WidgetCatalogEntry`, `WidgetInstance`, and `WidgetRenderProps`. +- [ ] Add an optional `defaultState?: () => Record | undefined` concept to catalog entries, or keep state resolution in the picker layer. Prefer picker-layer state for pins because it depends on current stores. +- [ ] Build the 8-entry catalog in the order expected by the picker: tiles, apps, pins. +- [ ] Implement store actions: + - [ ] `addWidget(type, x, y, state?)` + - [ ] `moveWidget(id, x, y)` + - [ ] `bumpZ(id)` + - [ ] `removeWidget(id)` + - [ ] `updateWidgetState(id, state)` +- [ ] Use `persist` middleware with `name: "goose2:home-widgets"` and `version: 1`. +- [ ] Add a shared clamp helper so add/move/render can keep widgets inside the canvas when dimensions are known. +- [ ] Preserve intentionally empty persisted layouts; do not auto-restore defaults just because the array is empty. + +Implementation notes: + +- Use `crypto.randomUUID()` for new instances, consistent with existing code. +- `updateWidgetState` should merge the existing `instance.state` with the patch, not replace it wholesale, so widgets can add future fields safely. +- Unknown catalog ids in persisted state should be filtered out or rendered as a small fallback frame. Prefer filtering during selector/render to avoid crashing the home route after catalog edits. + +## Phase 2 - Shell, Canvas, Frame, and Picker + +Files: + +- Modify: `src/app/ui/AppShellContent.tsx` +- Modify: `src/features/home/ui/HomeView.tsx` +- Create: `src/features/home/ui/WidgetCanvas.tsx` +- Create: `src/features/home/ui/WidgetFrame.tsx` +- Create: `src/features/home/ui/WidgetPicker.tsx` +- Modify: `src/shared/i18n/locales/en/home.json` +- Modify: `src/shared/i18n/locales/es/home.json` + +Tasks: + +- [ ] Update `AppShellContent` to pass `onOpenAgent` and `onSelectSession` into `HomeView`. +- [ ] Refactor `HomeView` into a thin shell that renders the canvas and no longer imports decorative home assets. +- [ ] Implement `WidgetCanvas`: + - [ ] Own a `ref` for drag constraints. + - [ ] Open picker on double-click only when `event.target === event.currentTarget`. + - [ ] Convert `clientX/clientY` to canvas-relative coordinates. + - [ ] Render the existing `bg-dot-grid` aesthetic through the home route container. +- [ ] Implement `WidgetFrame`: + - [ ] Use `motion.div` from `motion/react`. + - [ ] Use `drag`, `dragConstraints={canvasRef}`, and `dragMomentum={false}`. + - [ ] Persist final position from drag offsets. + - [ ] Bump z on pointer down. + - [ ] Wrap the frame in Radix `ContextMenu` with one remove item. + - [ ] Apply width/height from catalog default size. + - [ ] Use `AnimatePresence` around the rendered instance list. +- [ ] Implement `WidgetPicker`: + - [ ] Use `Popover`, `PopoverAnchor`, and `PopoverContent`. + - [ ] Position an invisible anchor at the captured canvas-relative coordinate. + - [ ] Render Tile, App, Pin sections. + - [ ] Use real `