@@ -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 `<HomeView />` 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
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<motion.div ... />
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={...}>...</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
```
|
||||
|
||||
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<string, unknown> | 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 `<button type="button">` rows for picker options.
|
||||
- [ ] On select, call `addWidget` centered on the original cursor coordinate.
|
||||
- [ ] Add i18n keys for all visible picker/menu labels.
|
||||
|
||||
## Phase 3 - Widget Components
|
||||
|
||||
Files:
|
||||
|
||||
- Create: `src/features/home/widgets/ClockWidget.tsx`
|
||||
- Create: `src/features/home/widgets/WeatherWidget.tsx`
|
||||
- Create: `src/features/home/widgets/StickyNoteWidget.tsx`
|
||||
- Create: `src/features/home/widgets/MondayBriefTile.tsx`
|
||||
- Create: `src/features/home/widgets/WeeklyHighlightsTile.tsx`
|
||||
- Create: `src/features/home/widgets/AgentPinWidget.tsx`
|
||||
- Create: `src/features/home/widgets/ChatPinWidget.tsx`
|
||||
- Create/modify: `src/features/home/widgets/CubeWidget.tsx`
|
||||
|
||||
Tasks:
|
||||
|
||||
- [ ] Move the clock logic from current `HomeView` into `ClockWidget`.
|
||||
- [ ] Build weather and tile widgets as polished static mock cards.
|
||||
- [ ] Build `StickyNoteWidget` as a controlled textarea:
|
||||
- [ ] Value from `instance.state.text`.
|
||||
- [ ] `onChange` calls `onUpdateState({ text })`.
|
||||
- [ ] `onPointerDown` stops propagation so text selection works.
|
||||
- [ ] Build `AgentPinWidget`:
|
||||
- [ ] Read personas from `useAgentStore`.
|
||||
- [ ] Resolve selected persona with fallback behavior from Review Feedback.
|
||||
- [ ] On click, use the chosen agent behavior from Phase 0.
|
||||
- [ ] Build `ChatPinWidget`:
|
||||
- [ ] Read sessions from `useChatSessionStore`.
|
||||
- [ ] Filter to visible, unarchived sessions using `getVisibleSessions` and `useChatStore().messagesBySession`.
|
||||
- [ ] Resolve selected session from state or fall back to most recent visible chat.
|
||||
- [ ] On click, call `onSelectSession`.
|
||||
- [ ] Keep widget styling restrained and canvas-native: no page-section cards inside cards.
|
||||
|
||||
## Phase 4 - Cube
|
||||
|
||||
Preferred path:
|
||||
|
||||
- [ ] Copy accessible cube source into `src/features/home/widgets/cube/`.
|
||||
- [ ] Adapt imports and sizing so `CubeWidget` fills its catalog size.
|
||||
- [ ] Use `prefers-reduced-motion` or `useReducedMotion` if the source exposes a clean pause/reduce hook.
|
||||
- [ ] Avoid adding Three/R3F dependencies unless the source truly needs them and the visual payoff is worth the package cost.
|
||||
|
||||
Fallback path if source remains unavailable:
|
||||
|
||||
- [ ] Build a CSS/DOM animated cube in `CubeWidget`.
|
||||
- [ ] Keep the public widget contract identical so it can be replaced by the real cube later.
|
||||
- [ ] Note in the final handoff that the cube is a fallback, not the `cube-explo` integration.
|
||||
|
||||
## Phase 5 - Asset Cleanup
|
||||
|
||||
Files:
|
||||
|
||||
- Delete, if still unused:
|
||||
- `src/assets/home/world-cube.png`
|
||||
- `src/assets/home/clock.svg`
|
||||
- `src/assets/home/person-1.png`
|
||||
- `src/assets/home/person-2.png`
|
||||
- `src/assets/home/sticky-note.svg`
|
||||
|
||||
Tasks:
|
||||
|
||||
- [ ] Re-run `rg "assets/home|world-cube|clock.svg|person-2|sticky-note|person-1" src`.
|
||||
- [ ] Delete only assets no longer imported from `src`.
|
||||
- [ ] Leave historical docs references alone.
|
||||
|
||||
## Phase 6 - Tests and Verification
|
||||
|
||||
Focused tests:
|
||||
|
||||
- [ ] Add store tests for add, move, bump, remove, state update, and persisted empty layout behavior.
|
||||
- [ ] Add component tests for:
|
||||
- [ ] Picker opens on canvas double-click.
|
||||
- [ ] Picker does not open from widget double-click.
|
||||
- [ ] Selecting an item adds an instance.
|
||||
- [ ] Remove context menu removes an instance.
|
||||
- [ ] Sticky note updates persisted state.
|
||||
|
||||
Manual verification:
|
||||
|
||||
- [ ] First load shows cube, clock, and agent pin.
|
||||
- [ ] Dragging clamps to canvas bounds and persists after reload.
|
||||
- [ ] Right-click remove animates out and persists after reload.
|
||||
- [ ] Picker shows 2 Tile, 4 App, and 2 Pin entries.
|
||||
- [ ] Agent pin and chat pin route using the chosen callbacks.
|
||||
- [ ] Existing chat empty state still renders from `HomeScreen`.
|
||||
|
||||
Commands:
|
||||
|
||||
- [ ] `./bin/pnpm exec tsc --noEmit`
|
||||
- [ ] `./bin/pnpm exec biome check .`
|
||||
- [ ] `./bin/pnpm test -- src/features/home`
|
||||
- [ ] If asked for broader verification: `./bin/just check` and `./bin/just test`
|
||||
|
||||
## Suggested Implementation Order
|
||||
|
||||
1. Land types, catalog, and store first.
|
||||
2. Build canvas/frame/picker with placeholder widget bodies.
|
||||
3. Fill in the 8 widgets.
|
||||
4. Wire routing callbacks for pins.
|
||||
5. Integrate or fallback the cube.
|
||||
6. Delete retired assets.
|
||||
7. Add focused tests.
|
||||
8. Run typecheck, Biome, and home tests.
|
||||
|
||||
## Risks
|
||||
|
||||
- The cube source may be private or otherwise unavailable.
|
||||
- Agent/persona concepts are named inconsistently in the spec and code; settle click behavior before implementation.
|
||||
- Persisted layouts can survive catalog changes, so unknown widget types need graceful handling.
|
||||
- Widgets can be off-canvas after a large window resize; the spec accepts this, but clamping on next render/drag reduces demo awkwardness without adding full reflow.
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
# Interactive home canvas — design spec
|
||||
|
||||
**Date:** 2026-04-29
|
||||
**Branch:** `tulsi/visual-design`
|
||||
**Author:** Tulsi
|
||||
**Implementer:** Codex (via follow-up implementation plan)
|
||||
**Status:** ready for plan
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the static editorial home page with an **interactive widget canvas** that demos as a personal, customizable surface. Users see a small set of pre-installed widgets on first load and can:
|
||||
|
||||
- Drag any widget freely on the canvas
|
||||
- Right-click a widget to remove it
|
||||
- Double-click empty canvas space to open a widget picker
|
||||
- Click an example in the picker to spawn a new widget at the cursor
|
||||
|
||||
Layout persists across reloads. Demo-grade fidelity — most widget *content* is mock/static; the *system* (drag, picker, persistence, removal, click-to-front, animated cube) is real.
|
||||
|
||||
---
|
||||
|
||||
## Why this shape
|
||||
|
||||
The brand-driven editorial home has visual richness but no interactivity. For the upcoming demo we need home to feel like a personal surface the user owns, not a curated illustration. The demo arc is "home is lived-in on first load → user adds a few widgets → home is uniquely theirs."
|
||||
|
||||
The three-category taxonomy (tile / app / pin) maps the conceptual surface area Goose2 should expose:
|
||||
|
||||
- **Tiles** — outputs of agent runs / scheduled briefs (e.g. "Monday morning brief")
|
||||
- **Apps** — self-contained mini-tools (weather, sticky note, the animated cube)
|
||||
- **Pins** — references to existing surfaces (an agent to chat with, a chat to resume)
|
||||
|
||||
This taxonomy is also the spine for future home-extensibility: agent-generated tiles, third-party apps, user-pinned anything.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Three concerns kept clean:
|
||||
|
||||
1. **Catalog** — static registry of widget *types*. Each type knows its render component, default size, default content. Defined in code at `src/features/home/widgets/catalog.ts`.
|
||||
|
||||
2. **Layout state** — array of widget *instances*. Each instance has a unique id, references a catalog type, and holds `{x, y, z, state?}`. Persisted to `localStorage`. Single source of truth for "what's on the home page right now." Lives in a Zustand store at `src/features/home/stores/homeWidgetStore.ts`.
|
||||
|
||||
3. **Picker** — programmatically-opened Radix `<Popover>` anchored at the user's double-click coordinates. Three sections (Tile / App / Pin) showing catalog examples. Click an example → spawns instance into layout state.
|
||||
|
||||
Drag, persistence, right-click removal, and bring-to-front are all expressed as updates to layout state. The rendering layer is purely `layoutState + catalog → React tree`.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
```ts
|
||||
type WidgetCategory = "tile" | "app" | "pin";
|
||||
|
||||
interface WidgetCatalogEntry {
|
||||
id: string; // stable type id, e.g. "weather"
|
||||
category: WidgetCategory;
|
||||
label: string; // shown in picker
|
||||
description?: string; // optional secondary line in picker
|
||||
defaultSize: { width: number; height: number };
|
||||
Component: React.ComponentType<WidgetRenderProps>;
|
||||
}
|
||||
|
||||
interface WidgetInstance {
|
||||
id: string; // crypto.randomUUID()
|
||||
type: string; // catalog entry id
|
||||
x: number; // px from canvas top-left
|
||||
y: number;
|
||||
z: number; // stacking — bumped on click/drag
|
||||
state?: Record<string, unknown>; // per-instance state
|
||||
}
|
||||
|
||||
interface WidgetRenderProps {
|
||||
instance: WidgetInstance;
|
||||
onUpdateState: (next: Record<string, unknown>) => void;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Catalog content
|
||||
|
||||
Eight widget types:
|
||||
|
||||
| ID | Category | Label | Component | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `cube` | App | "Cube" | `CubeWidget.tsx` | Wraps cube-explo animated cube |
|
||||
| `clock` | App | "Clock" | `ClockWidget.tsx` | Real-time, repurposes existing `HomeClock` time-update logic |
|
||||
| `weather` | App | "Weather" | `WeatherWidget.tsx` | Static mock 3-day forecast |
|
||||
| `stickyNote` | App | "Sticky note" | `StickyNoteWidget.tsx` | Real interactive textarea, content in `instance.state.text` |
|
||||
| `mondayBrief` | Tile | "Monday morning brief" | `MondayBriefTile.tsx` | Static mock card |
|
||||
| `weeklyHighlights` | Tile | "Weekly highlights" | `WeeklyHighlightsTile.tsx` | Static mock card |
|
||||
| `agentPin` | Pin | "Pin an agent" | `AgentPinWidget.tsx` | `instance.state.agentId` → avatar+name; click opens chat |
|
||||
| `chatPin` | Pin | "Pin a chat" | `ChatPinWidget.tsx` | `instance.state.sessionId` → title preview; click jumps to chat |
|
||||
|
||||
Pin examples in the picker pre-fill `state` with sensible defaults — first available agent for `agentPin`, most recent chat for `chatPin`. No "pick which one" UI for the demo.
|
||||
|
||||
---
|
||||
|
||||
## Default layout
|
||||
|
||||
Used when `localStorage` is empty (first-load state):
|
||||
|
||||
```ts
|
||||
const DEFAULT_INSTANCES: WidgetInstance[] = [
|
||||
{ id: "default-cube", type: "cube", x: ~center, y: ~center, z: 1 },
|
||||
{ id: "default-clock", type: "clock", x: ~top-right, y: ~top, z: 1 },
|
||||
{ id: "default-agent-pin", type: "agentPin", x: ~bottom-left, y: ~bottom-left, z: 1,
|
||||
state: { agentId: <default Goose persona id> } },
|
||||
];
|
||||
```
|
||||
|
||||
Specific px coordinates are calibrated by Codex against a typical viewport (suggest 1440×900 as baseline). `<default Goose persona id>` is resolved at implementation time from the existing `useAgentStore` defaults — likely the built-in Goose persona's id.
|
||||
|
||||
Once persisted, user removals/edits override these defaults — defaults only show again if `localStorage` is cleared.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
```
|
||||
src/features/home/
|
||||
ui/
|
||||
HomeView.tsx ← refactored (no time/greeting, no static PNG decoration layer)
|
||||
WidgetCanvas.tsx ← new — double-click target + drag bounds reference
|
||||
WidgetFrame.tsx ← new — generic wrapper around every instance
|
||||
WidgetPicker.tsx ← new — programmatically-anchored picker
|
||||
widgets/
|
||||
types.ts ← types defined above
|
||||
catalog.ts ← 8-entry registry
|
||||
CubeWidget.tsx
|
||||
ClockWidget.tsx
|
||||
WeatherWidget.tsx
|
||||
StickyNoteWidget.tsx
|
||||
MondayBriefTile.tsx
|
||||
WeeklyHighlightsTile.tsx
|
||||
AgentPinWidget.tsx
|
||||
ChatPinWidget.tsx
|
||||
cube/ ← cube-explo source ported here (see Cube integration)
|
||||
stores/
|
||||
homeWidgetStore.ts ← Zustand + persist middleware
|
||||
```
|
||||
|
||||
**Existing files affected:**
|
||||
- `src/features/home/ui/HomeView.tsx` — heavily refactored: removes time/greeting, removes static PNG decorations, becomes a thin shell rendering `<WidgetCanvas>` and `<WidgetPicker>`.
|
||||
- `src/assets/home/world-cube.png`, `clock.svg`, `person-2.png`, `sticky-note.svg`, `person-1.png` — **delete after confirming no other component imports them.** Currently only `HomeView` imports these, but Codex should grep `src/` to be sure before deletion.
|
||||
- `src/features/home/ui/HomeScreen.tsx` — **untouched.** This is the chat-empty-state surface, distinct from `HomeView`. Don't change it.
|
||||
|
||||
---
|
||||
|
||||
## Component responsibilities
|
||||
|
||||
**`HomeView`** — pulls `instances` from `homeWidgetStore`, renders `<WidgetCanvas>` containing one `<WidgetFrame>` per instance, plus the `<WidgetPicker>` overlay.
|
||||
|
||||
**`WidgetCanvas`** — full-bleed `<div>` filling the home route. Owns:
|
||||
- `onDoubleClick` handler that fires only when `event.target === event.currentTarget` (the canvas itself, not a child widget) → opens picker at `{event.clientX, event.clientY}` mapped to canvas-relative coords (subtract canvas's `getBoundingClientRect()`)
|
||||
- `dragConstraints` ref passed down to children
|
||||
- The `bg-dot-grid` background (preserves the home aesthetic)
|
||||
|
||||
**`WidgetFrame`** — generic, renders one instance:
|
||||
- `<motion.div drag dragConstraints={canvasRef} dragMomentum={false} onDragEnd={...} />`
|
||||
- `onPointerDown` → calls `bumpZ(instance.id)` if `instance.z < currentMaxZ`
|
||||
- `onContextMenu` → opens Radix `<ContextMenu>` anchored at cursor with single "Remove" item
|
||||
- Looks up `catalog[instance.type].Component` and renders it with `{instance, onUpdateState}` props
|
||||
- Applies `position: absolute`, `transform: translate(x, y)`, `z-index: z`, width/height from `defaultSize`
|
||||
- Wrapped at parent level in `<AnimatePresence>` for spawn/exit animations
|
||||
|
||||
**`WidgetPicker`** — receives `{open, x, y, onSelect, onClose}` props. Internals:
|
||||
- Invisible `<PopoverAnchor>` positioned absolutely at `{x, y}` size `0×0`
|
||||
- Popover content has 3 sections (Tile / App / Pin); each section maps catalog entries to clickable rows with label + description
|
||||
- `onSelect(catalogId)` → store action `addWidget(catalogId, x, y)` → close picker
|
||||
|
||||
**Widget components (8)** — all receive `{instance, onUpdateState}`:
|
||||
- Most static; ignore `onUpdateState`
|
||||
- `StickyNoteWidget`: `<textarea>` whose value comes from `instance.state.text ?? ""`, calls `onUpdateState({ text: next })` on change. The textarea's `onPointerDown` calls `e.stopPropagation()` so framer-motion doesn't intercept text-selection presses.
|
||||
- `AgentPinWidget` / `ChatPinWidget`: read `instance.state.agentId` / `sessionId`, render avatar+label, click handler calls existing chat-routing functions (the same handlers the sidebar uses to open agents / jump to chats).
|
||||
|
||||
---
|
||||
|
||||
## Store API
|
||||
|
||||
```ts
|
||||
interface HomeWidgetStore {
|
||||
instances: WidgetInstance[];
|
||||
addWidget: (type: string, x: number, y: number, state?: Record<string, unknown>) => void;
|
||||
moveWidget: (id: string, x: number, y: number) => void;
|
||||
bumpZ: (id: string) => void; // sets z = max(currentZ) + 1
|
||||
removeWidget: (id: string) => void;
|
||||
updateWidgetState: (id: string, state: Record<string, unknown>) => void;
|
||||
}
|
||||
```
|
||||
|
||||
`addWidget` centers the widget on the click point: `{x: clickX - defaultSize.width / 2, y: clickY - defaultSize.height / 2}` so widgets materialize *around* the cursor.
|
||||
|
||||
---
|
||||
|
||||
## Interactions
|
||||
|
||||
### Double-click → picker
|
||||
- Listener on `WidgetCanvas`'s top-level `<div>`
|
||||
- Guard: only fires when `event.target === event.currentTarget`
|
||||
- Captures click coords relative to canvas, opens picker
|
||||
|
||||
### Picker → spawn
|
||||
- Click an example row → `addWidget(catalogId, x, y, defaultState)`
|
||||
- Picker closes simultaneously
|
||||
- New widget enters with `motion`'s `initial={{scale: 0.9, opacity: 0}}` → `animate={{scale: 1, opacity: 1}}` spring (~250ms)
|
||||
- Z-index implicit: new widgets get `z = currentMax + 1`
|
||||
|
||||
### Drag
|
||||
- `<motion.div drag dragMomentum={false} dragConstraints={canvasRef} onDragEnd={...} />`
|
||||
- `dragConstraints={ref}` clamps so widget edges stay within canvas (motion handles the size math automatically)
|
||||
- `dragMomentum={false}` prevents inertia overshoot
|
||||
- `onDragEnd={(_, info) => moveWidget(id, currentX + info.offset.x, currentY + info.offset.y)}`
|
||||
|
||||
### Click-vs-drag separation (load-bearing)
|
||||
|
||||
framer-motion's built-in distance threshold (~3px) decides:
|
||||
|
||||
- Below threshold → `onClick` fires (relevant for pins)
|
||||
- Above threshold → drag fires, click is suppressed
|
||||
|
||||
Result: tiny mouse jitter never opens a pinned chat by accident; intentional clicks always work. **No custom logic needed — this is "free" from the library.**
|
||||
|
||||
### Click to bring to front
|
||||
- `onPointerDown` on `WidgetFrame` → `if (instance.z < maxZ) bumpZ(instance.id)`
|
||||
- Pure side effect; doesn't `preventDefault`, doesn't `stopPropagation`
|
||||
- The same press still flows into either click or drag on the inner widget
|
||||
|
||||
### Inner interactive surfaces
|
||||
- `AgentPinWidget` / `ChatPinWidget`: avatar+name block has `onClick={openChat / jumpToChat}`. Click separation handled by motion's threshold.
|
||||
- `StickyNoteWidget`: textarea calls `e.stopPropagation()` on `onPointerDown` only, so framer-motion never sees the press. Text selection / caret placement work without triggering drag or z-bump.
|
||||
|
||||
### Right-click → remove
|
||||
- Use Radix `<ContextMenu>` (same package as existing dropdown/popover, no new dep)
|
||||
- Single menu item: "Remove"
|
||||
- On click → `removeWidget(instance.id)`
|
||||
- Widget exit animation: `<AnimatePresence>` wrapping the list of widgets; widget exits with `exit={{scale: 0.9, opacity: 0}}` for symmetry with spawn
|
||||
|
||||
---
|
||||
|
||||
## Persistence
|
||||
|
||||
**Storage key:** `goose2:home-widgets`
|
||||
|
||||
**Stored shape:** the `instances` array, JSON-serialized — same shape as in-memory state. No transform layer.
|
||||
|
||||
**Wiring** (Zustand `persist` middleware):
|
||||
|
||||
```ts
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
const DEFAULT_INSTANCES: WidgetInstance[] = [/* cube + clock + agentPin */];
|
||||
|
||||
export const useHomeWidgetStore = create<HomeWidgetStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
instances: DEFAULT_INSTANCES,
|
||||
// actions...
|
||||
}),
|
||||
{ name: "goose2:home-widgets", version: 1 },
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- First-ever load → no storage entry → `instances` = `DEFAULT_INSTANCES` → middleware writes them on first state change
|
||||
- Subsequent loads → storage value hydrates over defaults (stored state wins, including emptiness)
|
||||
- User removes a default widget then reloads → stays removed
|
||||
- `localStorage` unavailable (privacy mode) → middleware silently falls back to in-memory state; no persistence that session
|
||||
- Schema migration deferred — `version: 1` is a hatch for future format changes. If shape changes during demo iteration, blow away the storage key.
|
||||
|
||||
---
|
||||
|
||||
## Cube integration
|
||||
|
||||
The cube comes from `https://github.com/anaghavi/cube-explo`. Repo not yet inspected — this spec defines the *interface* the cube widget exposes; Codex decides how to port the source.
|
||||
|
||||
**Interface contract:**
|
||||
- `CubeWidget.tsx` exports a default React component accepting `WidgetRenderProps`
|
||||
- Renders inside a div matching `defaultSize` (`{width: 320, height: 320}` as starting target — Codex calibrates after seeing the source)
|
||||
- `instance.state` unused — cube has no per-instance configuration
|
||||
- Animation runs continuously while mounted; pauses naturally when unmounted
|
||||
|
||||
**Integration paths Codex should evaluate, in order of preference:**
|
||||
|
||||
1. **Copy cube source into `src/features/home/widgets/cube/`** as a self-contained subfolder. Lift the Three.js / React Three Fiber / shader code (whatever it is) into the goose2 tree. Adjust imports, add necessary deps.
|
||||
2. **Install cube-explo as an npm dep** if it's published. (Probably isn't — looks like a personal repo — but worth a 10-second check.)
|
||||
3. **Stub with a static cube image + CSS animation** if the source turns out to be heavy enough that integrating would balloon scope past demo-grade. **This fallback should be flagged in the implementation plan, not chosen silently.**
|
||||
|
||||
---
|
||||
|
||||
## Content scope
|
||||
|
||||
### Real
|
||||
- Drag/drop layout (motion + dragConstraints)
|
||||
- localStorage persistence
|
||||
- Picker open/close/select
|
||||
- Right-click context menu → remove
|
||||
- Sticky note text editing
|
||||
- Pin click → opens chat / opens agent (calls existing chat-routing handlers — same ones the sidebar uses)
|
||||
- Cube animation (per cube-explo source)
|
||||
- Clock real-time display (re-uses `HomeClock` time-update logic from current `HomeView`)
|
||||
|
||||
### Mock (static / hardcoded)
|
||||
- "Monday morning brief" tile content — hardcoded copy, e.g., "3 priorities · 2 meetings · ☕ at 10:30"
|
||||
- "Weekly highlights" tile content — hardcoded copy
|
||||
- Weather forecast — hardcoded current conditions + 3-day forecast
|
||||
- Picker pin examples auto-fill with first available agent / most recent chat — no "which one to pin?" UI
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
Explicitly *not* part of this work:
|
||||
|
||||
- Widget resizing
|
||||
- Widget collision detection / snapping
|
||||
- Multi-select drag
|
||||
- Undo/redo
|
||||
- Named layouts ("save as 'Focus mode'")
|
||||
- Cross-device sync
|
||||
- Touch/mobile gestures (Tauri desktop only)
|
||||
- Real APIs (no actual weather, no real morning brief generation)
|
||||
- User-extensible catalog
|
||||
- Resize-aware reflow when window shrinks (widgets may end up off-canvas until next drag — acceptable)
|
||||
- Accessibility deep dive — basic semantics free; full a11y audit deferred
|
||||
- Dragging the existing static decorations being retired (`sticky-note.svg`, `person-2.png`, current `clock.svg`, `world-cube.png`, `person-1.png`) — those are deleted, not made draggable. Their replacements in the catalog are reborn from scratch.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Home page loads with 3 default widgets visible (cube, clock, agentPin → Goose), positioned per `DEFAULT_INSTANCES`
|
||||
- Existing time/greeting and static decorative PNGs no longer render
|
||||
- Dragging any widget moves it; release commits position; bounds clamp to canvas
|
||||
- Double-click on empty canvas space opens picker at cursor; double-click on a widget does NOT open picker
|
||||
- Picker shows three sections — Tile (2), App (4), Pin (2) — 8 catalog examples total
|
||||
- Clicking an example spawns a new widget centered on the click point with a spring entry animation
|
||||
- Right-clicking any widget opens a context menu with "Remove"; clicking Remove removes widget with exit animation
|
||||
- Clicking a widget that is behind another brings it forward (z-bump)
|
||||
- Clicking a pin (no drag) opens the linked chat / agent via existing chat-routing handlers
|
||||
- Tiny mouse jitter on a pin (< ~3px movement) does NOT spuriously open the chat — motion's threshold handles this
|
||||
- Layout persists across reloads; user-removed widgets stay removed
|
||||
- `pnpm typecheck` clean; biome lint clean; i18n strings (if any new) checked
|
||||
- No regressions to the existing chat home-screen flow (`HomeScreen.tsx` is separate and unchanged)
|
||||
|
||||
---
|
||||
|
||||
## Open questions for Codex
|
||||
|
||||
- **Cube integration path.** Needs investigation of `https://github.com/anaghavi/cube-explo` before implementation strategy can be confirmed. Implementation plan should flag the chosen path explicitly (copy / npm dep / static fallback).
|
||||
- **Default position calibration.** `~center`, `~top-right`, `~bottom-left` are placeholders. Suggest measuring against a 1440×900 baseline (typical Mac dev resolution) and using percentage-based positioning so behavior at other sizes is graceful.
|
||||
- **Default Goose persona id.** Resolved at implementation time from `useAgentStore` — likely the built-in Goose persona's id.
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
- Codex handoff for the related global composer model picker bug: `docs/codex/2026-04-29-global-composer-model-picker-empty.md` (separate issue, but in the same broader "make the home + composer surfaces work for the demo" sweep).
|
||||
- Existing animation patterns: `src/features/chat/ui/ChatContextPanel.tsx`, `src/features/chat/ui/LoadingGoose.tsx` (uses `motion.div`, `AnimatePresence`, `useReducedMotion`).
|
||||
- Existing Radix Popover wrapper: `src/shared/ui/popover.tsx`.
|
||||
- Existing Zustand patterns: `src/features/agents/stores/agentStore.ts` (no persist middleware) and other feature stores.
|
||||
@@ -0,0 +1,150 @@
|
||||
import { BoxGeometry, Vector3 } from "./three.module.js";
|
||||
|
||||
const _tempNormal = new Vector3();
|
||||
|
||||
function getUv(
|
||||
faceDirVector,
|
||||
normal,
|
||||
uvAxis,
|
||||
projectionAxis,
|
||||
radius,
|
||||
sideLength,
|
||||
) {
|
||||
const totArcLength = (2 * Math.PI * radius) / 4;
|
||||
|
||||
// length of the planes between the arcs on each axis
|
||||
const centerLength = Math.max(sideLength - 2 * radius, 0);
|
||||
const halfArc = Math.PI / 4;
|
||||
|
||||
// Get the vector projected onto the Y plane
|
||||
_tempNormal.copy(normal);
|
||||
_tempNormal[projectionAxis] = 0;
|
||||
_tempNormal.normalize();
|
||||
|
||||
// total amount of UV space alloted to a single arc
|
||||
const arcUvRatio = (0.5 * totArcLength) / (totArcLength + centerLength);
|
||||
|
||||
// the distance along one arc the point is at
|
||||
const arcAngleRatio = 1.0 - _tempNormal.angleTo(faceDirVector) / halfArc;
|
||||
|
||||
if (Math.sign(_tempNormal[uvAxis]) === 1) {
|
||||
return arcAngleRatio * arcUvRatio;
|
||||
} else {
|
||||
// total amount of UV space alloted to the plane between the arcs
|
||||
const lenUv = centerLength / (totArcLength + centerLength);
|
||||
return lenUv + arcUvRatio + arcUvRatio * (1.0 - arcAngleRatio);
|
||||
}
|
||||
}
|
||||
|
||||
class RoundedBoxGeometry extends BoxGeometry {
|
||||
constructor(width = 1, height = 1, depth = 1, segments = 2, radius = 0.1) {
|
||||
// ensure segments is odd so we have a plane connecting the rounded corners
|
||||
segments = segments * 2 + 1;
|
||||
|
||||
// ensure radius isn't bigger than shortest side
|
||||
radius = Math.min(width / 2, height / 2, depth / 2, radius);
|
||||
|
||||
super(1, 1, 1, segments, segments, segments);
|
||||
|
||||
// if we just have one segment we're the same as a regular box
|
||||
if (segments === 1) return;
|
||||
|
||||
const geometry2 = this.toNonIndexed();
|
||||
|
||||
this.index = null;
|
||||
this.attributes.position = geometry2.attributes.position;
|
||||
this.attributes.normal = geometry2.attributes.normal;
|
||||
this.attributes.uv = geometry2.attributes.uv;
|
||||
|
||||
//
|
||||
|
||||
const position = new Vector3();
|
||||
const normal = new Vector3();
|
||||
|
||||
const box = new Vector3(width, height, depth)
|
||||
.divideScalar(2)
|
||||
.subScalar(radius);
|
||||
|
||||
const positions = this.attributes.position.array;
|
||||
const normals = this.attributes.normal.array;
|
||||
const uvs = this.attributes.uv.array;
|
||||
|
||||
const faceTris = positions.length / 6;
|
||||
const faceDirVector = new Vector3();
|
||||
const halfSegmentSize = 0.5 / segments;
|
||||
|
||||
for (let i = 0, j = 0; i < positions.length; i += 3, j += 2) {
|
||||
position.fromArray(positions, i);
|
||||
normal.copy(position);
|
||||
normal.x -= Math.sign(normal.x) * halfSegmentSize;
|
||||
normal.y -= Math.sign(normal.y) * halfSegmentSize;
|
||||
normal.z -= Math.sign(normal.z) * halfSegmentSize;
|
||||
normal.normalize();
|
||||
|
||||
positions[i + 0] = box.x * Math.sign(position.x) + normal.x * radius;
|
||||
positions[i + 1] = box.y * Math.sign(position.y) + normal.y * radius;
|
||||
positions[i + 2] = box.z * Math.sign(position.z) + normal.z * radius;
|
||||
|
||||
normals[i + 0] = normal.x;
|
||||
normals[i + 1] = normal.y;
|
||||
normals[i + 2] = normal.z;
|
||||
|
||||
const side = Math.floor(i / faceTris);
|
||||
|
||||
switch (side) {
|
||||
case 0: // right
|
||||
// generate UVs along Z then Y
|
||||
faceDirVector.set(1, 0, 0);
|
||||
uvs[j + 0] = getUv(faceDirVector, normal, "z", "y", radius, depth);
|
||||
uvs[j + 1] =
|
||||
1.0 - getUv(faceDirVector, normal, "y", "z", radius, height);
|
||||
break;
|
||||
|
||||
case 1: // left
|
||||
// generate UVs along Z then Y
|
||||
faceDirVector.set(-1, 0, 0);
|
||||
uvs[j + 0] =
|
||||
1.0 - getUv(faceDirVector, normal, "z", "y", radius, depth);
|
||||
uvs[j + 1] =
|
||||
1.0 - getUv(faceDirVector, normal, "y", "z", radius, height);
|
||||
break;
|
||||
|
||||
case 2: // top
|
||||
// generate UVs along X then Z
|
||||
faceDirVector.set(0, 1, 0);
|
||||
uvs[j + 0] =
|
||||
1.0 - getUv(faceDirVector, normal, "x", "z", radius, width);
|
||||
uvs[j + 1] = getUv(faceDirVector, normal, "z", "x", radius, depth);
|
||||
break;
|
||||
|
||||
case 3: // bottom
|
||||
// generate UVs along X then Z
|
||||
faceDirVector.set(0, -1, 0);
|
||||
uvs[j + 0] =
|
||||
1.0 - getUv(faceDirVector, normal, "x", "z", radius, width);
|
||||
uvs[j + 1] =
|
||||
1.0 - getUv(faceDirVector, normal, "z", "x", radius, depth);
|
||||
break;
|
||||
|
||||
case 4: // front
|
||||
// generate UVs along X then Y
|
||||
faceDirVector.set(0, 0, 1);
|
||||
uvs[j + 0] =
|
||||
1.0 - getUv(faceDirVector, normal, "x", "y", radius, width);
|
||||
uvs[j + 1] =
|
||||
1.0 - getUv(faceDirVector, normal, "y", "x", radius, height);
|
||||
break;
|
||||
|
||||
case 5: // back
|
||||
// generate UVs along X then Y
|
||||
faceDirVector.set(0, 0, -1);
|
||||
uvs[j + 0] = getUv(faceDirVector, normal, "x", "y", radius, width);
|
||||
uvs[j + 1] =
|
||||
1.0 - getUv(faceDirVector, normal, "y", "x", radius, height);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { RoundedBoxGeometry };
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
} from "@/features/chat/stores/chatSessionStore";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import { useProjectStore } from "@/features/projects/stores/projectStore";
|
||||
import { findExistingDraft } from "@/features/chat/lib/newChat";
|
||||
import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle";
|
||||
import { useAppStartup } from "./hooks/useAppStartup";
|
||||
import { useCreateChatTab } from "./hooks/useCreateChatTab";
|
||||
import { useGlobalAppShortcuts } from "./hooks/useGlobalAppShortcuts";
|
||||
import { useHomeSessionStateSync } from "./hooks/useHomeSessionStateSync";
|
||||
import { useSettingsModal } from "./hooks/useSettingsModal";
|
||||
@@ -277,93 +277,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
});
|
||||
}, [activeView, ensureHomeSession]);
|
||||
|
||||
const createNewTab = useCallback(
|
||||
async (
|
||||
title = DEFAULT_CHAT_TITLE,
|
||||
project?: ProjectInfo | null,
|
||||
composeOptions?: GlobalComposeOptions,
|
||||
) => {
|
||||
const tStart = performance.now();
|
||||
perfLog(
|
||||
`[perf:newtab] createNewTab start (project=${project?.id ?? "none"})`,
|
||||
);
|
||||
const effectiveProject = project ?? null;
|
||||
const agentId = agentStore.activeAgentId ?? undefined;
|
||||
const providerId =
|
||||
composeOptions?.providerId ??
|
||||
effectiveProject?.preferredProvider ??
|
||||
agentStore.selectedProvider ??
|
||||
"goose";
|
||||
const preferredModel =
|
||||
composeOptions?.modelId ??
|
||||
(composeOptions?.providerId &&
|
||||
effectiveProject?.preferredProvider &&
|
||||
composeOptions.providerId !== effectiveProject.preferredProvider
|
||||
? undefined
|
||||
: (effectiveProject?.preferredModel ?? undefined));
|
||||
const sessionModelPreference =
|
||||
await resolveSupportedSessionModelPreference(
|
||||
providerId,
|
||||
providerInventoryEntries,
|
||||
preferredModel,
|
||||
);
|
||||
const sessionState = useChatSessionStore.getState();
|
||||
const chatState = useChatStore.getState();
|
||||
const existingDraft = findExistingDraft({
|
||||
sessions: sessionState.sessions,
|
||||
activeSessionId: sessionState.activeSessionId,
|
||||
draftsBySession: chatState.draftsBySession,
|
||||
messagesBySession: chatState.messagesBySession,
|
||||
request: {
|
||||
title,
|
||||
projectId: effectiveProject?.id,
|
||||
},
|
||||
});
|
||||
|
||||
const draftMatchesSelection =
|
||||
existingDraft &&
|
||||
existingDraft.providerId === sessionModelPreference.providerId &&
|
||||
(existingDraft.modelId ?? null) ===
|
||||
(sessionModelPreference.modelId ?? null);
|
||||
|
||||
if (draftMatchesSelection) {
|
||||
sessionStore.setActiveSession(existingDraft.id);
|
||||
setActiveView("chat");
|
||||
chatStore.setActiveSession(existingDraft.id);
|
||||
perfLog(
|
||||
`[perf:newtab] ${existingDraft.id.slice(0, 8)} reused draft in ${(performance.now() - tStart).toFixed(1)}ms`,
|
||||
);
|
||||
return existingDraft;
|
||||
}
|
||||
|
||||
const workingDir = await resolveSessionCwd(effectiveProject);
|
||||
const session = await sessionStore.createSession({
|
||||
title,
|
||||
projectId: effectiveProject?.id,
|
||||
agentId,
|
||||
providerId: sessionModelPreference.providerId,
|
||||
workingDir,
|
||||
modelId: sessionModelPreference.modelId,
|
||||
modelName: sessionModelPreference.modelId
|
||||
? (composeOptions?.modelName ?? sessionModelPreference.modelName)
|
||||
: undefined,
|
||||
});
|
||||
sessionStore.setActiveSession(session.id);
|
||||
setActiveView("chat");
|
||||
chatStore.setActiveSession(session.id);
|
||||
perfLog(
|
||||
`[perf:newtab] ${session.id.slice(0, 8)} created session in ${(performance.now() - tStart).toFixed(1)}ms`,
|
||||
);
|
||||
return session;
|
||||
},
|
||||
[
|
||||
agentStore.activeAgentId,
|
||||
agentStore.selectedProvider,
|
||||
chatStore,
|
||||
providerInventoryEntries,
|
||||
sessionStore,
|
||||
],
|
||||
);
|
||||
const createNewTab = useCreateChatTab(setActiveView);
|
||||
|
||||
const handleStartChatFromProject = useCallback(
|
||||
(project: ProjectInfo) => {
|
||||
@@ -372,6 +286,21 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
[createNewTab],
|
||||
);
|
||||
|
||||
const handleStartChatWithPersona = useCallback(
|
||||
(personaId: string) => {
|
||||
const personas = useAgentStore.getState().personas;
|
||||
const persona =
|
||||
personas.find((candidate) => candidate.id === personaId) ??
|
||||
personas.find((candidate) => candidate.isBuiltin);
|
||||
void createNewTab(DEFAULT_CHAT_TITLE, null, {
|
||||
personaId: persona?.id,
|
||||
providerId: persona?.provider,
|
||||
modelId: persona?.model,
|
||||
});
|
||||
},
|
||||
[createNewTab],
|
||||
);
|
||||
|
||||
const handleGlobalCompose = useCallback(
|
||||
async (text: string, options?: GlobalComposeOptions) => {
|
||||
const project =
|
||||
@@ -765,6 +694,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
onExitSearch={handleExitSearch}
|
||||
onOpenExtension={handleOpenExtensionFromSearch}
|
||||
onOpenAgent={handleOpenAgentFromSearch}
|
||||
onStartChatWithPersona={handleStartChatWithPersona}
|
||||
onOpenSkill={handleOpenSkillFromSearch}
|
||||
onStartChatFromProject={handleStartChatFromProject}
|
||||
openAgentId={pendingAgentId}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useCallback } from "react";
|
||||
import type { ProjectInfo } from "@/features/projects/api/projects";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import { useChatStore } from "@/features/chat/stores/chatStore";
|
||||
import { findExistingDraft } from "@/features/chat/lib/newChat";
|
||||
import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle";
|
||||
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
|
||||
import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection";
|
||||
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
|
||||
import { perfLog } from "@/shared/lib/perfLog";
|
||||
import type { GlobalComposeOptions } from "@/shared/ui/GlobalComposerPill";
|
||||
import type { AppView } from "../types";
|
||||
import { resolveSupportedSessionModelPreference } from "../lib/resolveSupportedSessionModelPreference";
|
||||
|
||||
interface CreateTabOptions extends GlobalComposeOptions {
|
||||
personaId?: string;
|
||||
}
|
||||
|
||||
export function useCreateChatTab(setActiveView: (view: AppView) => void) {
|
||||
const agentStore = useAgentStore();
|
||||
const chatStore = useChatStore();
|
||||
const sessionStore = useChatSessionStore();
|
||||
const providerInventoryEntries = useProviderInventoryStore(
|
||||
(state) => state.entries,
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
async (
|
||||
title = DEFAULT_CHAT_TITLE,
|
||||
project?: ProjectInfo | null,
|
||||
composeOptions?: CreateTabOptions,
|
||||
) => {
|
||||
const tStart = performance.now();
|
||||
perfLog(
|
||||
`[perf:newtab] createNewTab start (project=${project?.id ?? "none"})`,
|
||||
);
|
||||
const effectiveProject = project ?? null;
|
||||
const agentId = agentStore.activeAgentId ?? undefined;
|
||||
const personaId = composeOptions?.personaId;
|
||||
const providerId =
|
||||
composeOptions?.providerId ??
|
||||
effectiveProject?.preferredProvider ??
|
||||
agentStore.selectedProvider ??
|
||||
"goose";
|
||||
const preferredModel =
|
||||
composeOptions?.modelId ??
|
||||
(composeOptions?.providerId &&
|
||||
effectiveProject?.preferredProvider &&
|
||||
composeOptions.providerId !== effectiveProject.preferredProvider
|
||||
? undefined
|
||||
: (effectiveProject?.preferredModel ?? undefined));
|
||||
const sessionModelPreference =
|
||||
await resolveSupportedSessionModelPreference(
|
||||
providerId,
|
||||
providerInventoryEntries,
|
||||
preferredModel,
|
||||
);
|
||||
const sessionState = useChatSessionStore.getState();
|
||||
const chatState = useChatStore.getState();
|
||||
const existingDraft = findExistingDraft({
|
||||
sessions: sessionState.sessions,
|
||||
activeSessionId: sessionState.activeSessionId,
|
||||
draftsBySession: chatState.draftsBySession,
|
||||
messagesBySession: chatState.messagesBySession,
|
||||
request: {
|
||||
title,
|
||||
projectId: effectiveProject?.id,
|
||||
personaId,
|
||||
},
|
||||
});
|
||||
|
||||
const draftMatchesSelection =
|
||||
existingDraft &&
|
||||
existingDraft.providerId === sessionModelPreference.providerId &&
|
||||
(existingDraft.modelId ?? null) ===
|
||||
(sessionModelPreference.modelId ?? null);
|
||||
|
||||
if (draftMatchesSelection) {
|
||||
sessionStore.setActiveSession(existingDraft.id);
|
||||
setActiveView("chat");
|
||||
chatStore.setActiveSession(existingDraft.id);
|
||||
perfLog(
|
||||
`[perf:newtab] ${existingDraft.id.slice(0, 8)} reused draft in ${(performance.now() - tStart).toFixed(1)}ms`,
|
||||
);
|
||||
return existingDraft;
|
||||
}
|
||||
|
||||
const workingDir = await resolveSessionCwd(effectiveProject);
|
||||
const session = await sessionStore.createSession({
|
||||
title,
|
||||
projectId: effectiveProject?.id,
|
||||
agentId,
|
||||
personaId,
|
||||
providerId: sessionModelPreference.providerId,
|
||||
workingDir,
|
||||
modelId: sessionModelPreference.modelId,
|
||||
modelName: sessionModelPreference.modelId
|
||||
? (composeOptions?.modelName ?? sessionModelPreference.modelName)
|
||||
: undefined,
|
||||
});
|
||||
sessionStore.setActiveSession(session.id);
|
||||
setActiveView("chat");
|
||||
chatStore.setActiveSession(session.id);
|
||||
perfLog(
|
||||
`[perf:newtab] ${session.id.slice(0, 8)} created session in ${(performance.now() - tStart).toFixed(1)}ms`,
|
||||
);
|
||||
return session;
|
||||
},
|
||||
[
|
||||
agentStore.activeAgentId,
|
||||
agentStore.selectedProvider,
|
||||
chatStore,
|
||||
providerInventoryEntries,
|
||||
sessionStore,
|
||||
setActiveView,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ interface AppShellContentProps {
|
||||
onExitSearch: () => void;
|
||||
onOpenExtension: (entry: ExtensionEntry) => void;
|
||||
onOpenAgent: (agentId: string) => void;
|
||||
onStartChatWithPersona: (personaId: string) => void;
|
||||
onOpenSkill: (skill: SkillInfo) => void;
|
||||
onStartChatFromProject: (project: ProjectInfo) => void;
|
||||
openAgentId?: string | null;
|
||||
@@ -55,6 +56,7 @@ export function AppShellContent({
|
||||
onExitSearch,
|
||||
onOpenExtension,
|
||||
onOpenAgent,
|
||||
onStartChatWithPersona,
|
||||
onOpenSkill,
|
||||
onStartChatFromProject,
|
||||
openAgentId,
|
||||
@@ -105,7 +107,13 @@ export function AppShellContent({
|
||||
/>
|
||||
);
|
||||
case "home":
|
||||
return <HomeView />;
|
||||
return (
|
||||
<HomeView
|
||||
onOpenAgent={onOpenAgent}
|
||||
onStartChatWithPersona={onStartChatWithPersona}
|
||||
onSelectSession={onSelectSession}
|
||||
/>
|
||||
);
|
||||
case "search":
|
||||
return (
|
||||
<SearchView
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 186.867 186.867" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="Group 2147229963">
|
||||
<rect id="Rectangle 34652896" x="9.73799" y="9.74477" width="166.51" height="166.51" rx="83.2549" fill="var(--fill-0, #19191A)"/>
|
||||
<path id="Line 2439" d="M93.4546 105.697L93.4546 32.6969" stroke="var(--stroke-0, white)" stroke-width="1.50406"/>
|
||||
<path id="Line 2441" d="M80.4545 92.6969L153.455 92.6969" stroke="var(--stroke-0, #818181)" stroke-width="1.50406"/>
|
||||
<g id="Repeat group 1">
|
||||
<g id="Repeat group 1_inner" data-figma-trr="r12u30.5-0f">
|
||||
<line id="Line 2441_2" x1="111.59" y1="24.3338" x2="112.844" y2="19.6552" stroke="var(--stroke-0, white)" stroke-width="0.692816"/>
|
||||
</g>
|
||||
<use xlink:href="#Repeat%20group%201_inner" transform="translate(59.2345 -34.1991) rotate(30)"/>
|
||||
<use xlink:href="#Repeat%20group%201_inner" transform="translate(127.633 -34.199) rotate(60)"/>
|
||||
<use xlink:href="#Repeat%20group%201_inner" transform="translate(186.867 1.17729e-05) rotate(90)"/>
|
||||
<use xlink:href="#Repeat%20group%201_inner" transform="translate(221.066 59.2345) rotate(120)"/>
|
||||
<use xlink:href="#Repeat%20group%201_inner" transform="translate(221.066 127.633) rotate(150)"/>
|
||||
<use xlink:href="#Repeat%20group%201_inner" transform="translate(186.867 186.867) rotate(-180)"/>
|
||||
<use xlink:href="#Repeat%20group%201_inner" transform="translate(127.633 221.066) rotate(-150)"/>
|
||||
<use xlink:href="#Repeat%20group%201_inner" transform="translate(59.2345 221.066) rotate(-120)"/>
|
||||
<use xlink:href="#Repeat%20group%201_inner" transform="translate(-8.80294e-06 186.867) rotate(-90)"/>
|
||||
<use xlink:href="#Repeat%20group%201_inner" transform="translate(-34.1991 127.633) rotate(-60)"/>
|
||||
<use xlink:href="#Repeat%20group%201_inner" transform="translate(-34.199 59.2345) rotate(-30)"/>
|
||||
</g>
|
||||
<g id="Repeat group 2">
|
||||
<g id="Repeat group 2_inner" data-figma-trr="r12u5.8-0f">
|
||||
<line id="Line 2441_3" x1="92.7021" y1="34.4203" x2="92.7021" y2="9.9595" stroke="var(--stroke-0, white)" stroke-width="0.73"/>
|
||||
</g>
|
||||
<use xlink:href="#Repeat%20group%202_inner" transform="translate(59.0317 -34.057) rotate(30)"/>
|
||||
<use xlink:href="#Repeat%20group%202_inner" transform="translate(127.183 -34.0354) rotate(60)"/>
|
||||
<use xlink:href="#Repeat%20group%202_inner" transform="translate(186.193 0.0590983) rotate(90)"/>
|
||||
<use xlink:href="#Repeat%20group%202_inner" transform="translate(220.25 59.0908) rotate(120)"/>
|
||||
<use xlink:href="#Repeat%20group%202_inner" transform="translate(220.229 127.242) rotate(150)"/>
|
||||
<use xlink:href="#Repeat%20group%202_inner" transform="translate(186.134 186.252) rotate(-180)"/>
|
||||
<use xlink:href="#Repeat%20group%202_inner" transform="translate(127.102 220.309) rotate(-150)"/>
|
||||
<use xlink:href="#Repeat%20group%202_inner" transform="translate(58.951 220.288) rotate(-120)"/>
|
||||
<use xlink:href="#Repeat%20group%202_inner" transform="translate(-0.0590953 186.193) rotate(-90)"/>
|
||||
<use xlink:href="#Repeat%20group%202_inner" transform="translate(-34.1161 127.162) rotate(-60)"/>
|
||||
<use xlink:href="#Repeat%20group%202_inner" transform="translate(-34.0945 59.0101) rotate(-30)"/>
|
||||
</g>
|
||||
<path id="Line 2440" d="M144.944 121.757C143.873 123.696 144.576 126.136 146.515 127.207C148.453 128.279 150.894 127.576 151.965 125.637C153.036 123.698 152.333 121.258 150.395 120.186C148.456 119.115 146.015 119.818 144.944 121.757ZM93.0949 93.1041L92.7312 93.7624L148.091 124.355L148.455 123.697L148.818 123.039L93.4587 92.4459L93.0949 93.1041Z" fill="var(--stroke-0, #BC0003)"/>
|
||||
<rect id="Rectangle 34652951" x="89.4545" y="88.6969" width="8" height="8" rx="4" fill="var(--fill-0, white)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 762 KiB |
|
After Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 122 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 279 309" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group 2147229965">
|
||||
<rect id="Rectangle 34653164" width="279" height="309" rx="37" fill="var(--fill-0, white)"/>
|
||||
<circle id="Ellipse 12744" cx="249.5" cy="26.5" r="7.5" fill="var(--fill-0, #BC0003)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 354 KiB |
@@ -0,0 +1,18 @@
|
||||
import figureUrl from "@/assets/agents/figure.png";
|
||||
import ralphUrl from "@/assets/agents/ralph.png";
|
||||
import scoutUrl from "@/assets/agents/scout.png";
|
||||
import soloUrl from "@/assets/agents/solo.png";
|
||||
import tulsiUrl from "@/assets/agents/tulsi.png";
|
||||
|
||||
const PERSONA_FIGURES: Record<string, string> = {
|
||||
ralph: ralphUrl,
|
||||
scout: scoutUrl,
|
||||
solo: soloUrl,
|
||||
tulsi: tulsiUrl,
|
||||
};
|
||||
|
||||
export function resolvePersonaFigure(displayName: string | null | undefined) {
|
||||
return displayName
|
||||
? (PERSONA_FIGURES[displayName.toLowerCase()] ?? figureUrl)
|
||||
: figureUrl;
|
||||
}
|
||||
@@ -1,25 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MoreVertical, Copy, Pencil, Trash2, Download } from "lucide-react";
|
||||
import figureUrl from "@/assets/agents/figure.png";
|
||||
import ralphUrl from "@/assets/agents/ralph.png";
|
||||
import scoutUrl from "@/assets/agents/scout.png";
|
||||
import soloUrl from "@/assets/agents/solo.png";
|
||||
import tulsiUrl from "@/assets/agents/tulsi.png";
|
||||
|
||||
// Name-based persona avatars — Tulsi-personal placeholder.
|
||||
// Real per-persona avatar storage on the data model is a separate spec;
|
||||
// unknown displayNames fall back to the shared cutout figure.
|
||||
const PERSONA_FIGURES: Record<string, string> = {
|
||||
ralph: ralphUrl,
|
||||
scout: scoutUrl,
|
||||
solo: soloUrl,
|
||||
tulsi: tulsiUrl,
|
||||
};
|
||||
|
||||
function resolvePersonaFigure(displayName: string): string {
|
||||
return PERSONA_FIGURES[displayName.toLowerCase()] ?? figureUrl;
|
||||
}
|
||||
import { resolvePersonaFigure } from "@/features/agents/lib/personaFigure";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
|
||||
@@ -58,6 +58,26 @@ describe("findExistingDraft", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not reuse a draft from a different persona", () => {
|
||||
const draft = makeSession("goose-draft", {
|
||||
personaId: "goose",
|
||||
providerId: "goose",
|
||||
});
|
||||
|
||||
expect(
|
||||
findExistingDraft({
|
||||
sessions: [draft],
|
||||
activeSessionId: null,
|
||||
draftsBySession: { "goose-draft": "goose draft" },
|
||||
messagesBySession: {},
|
||||
request: {
|
||||
title: "New Chat",
|
||||
personaId: "scout",
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not reuse an abandoned empty draft", () => {
|
||||
const draft = makeSession("alpha-draft", {
|
||||
projectId: "alpha",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DEFAULT_CHAT_TITLE } from "./sessionTitle";
|
||||
interface NewChatRequest {
|
||||
title: string;
|
||||
projectId?: string;
|
||||
personaId?: string;
|
||||
}
|
||||
|
||||
interface FindExistingDraftArgs {
|
||||
@@ -19,7 +20,10 @@ function isMatchingContext(
|
||||
session: ChatSession,
|
||||
request: Omit<NewChatRequest, "title">,
|
||||
): boolean {
|
||||
return session.projectId === request.projectId;
|
||||
return (
|
||||
session.projectId === request.projectId &&
|
||||
session.personaId === request.personaId
|
||||
);
|
||||
}
|
||||
|
||||
function isReusableDraft(
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDefaultHomeWidgets,
|
||||
useHomeWidgetStore,
|
||||
} from "./homeWidgetStore";
|
||||
|
||||
function resetStore() {
|
||||
useHomeWidgetStore.setState({ instances: [] });
|
||||
}
|
||||
|
||||
describe("homeWidgetStore", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetStore();
|
||||
});
|
||||
|
||||
it("creates the expected first-load widgets", () => {
|
||||
expect(createDefaultHomeWidgets().map((widget) => widget.type)).toEqual([
|
||||
"cube",
|
||||
"clock",
|
||||
"agentPin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds widgets centered on the click point", () => {
|
||||
useHomeWidgetStore
|
||||
.getState()
|
||||
.addWidget("clock", 130, 66, undefined, { width: 400, height: 300 });
|
||||
|
||||
expect(useHomeWidgetStore.getState().instances).toMatchObject([
|
||||
{ type: "clock", x: 0, y: 0, z: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves widgets within canvas bounds", () => {
|
||||
useHomeWidgetStore
|
||||
.getState()
|
||||
.addWidget("clock", 130, 66, undefined, { width: 400, height: 300 });
|
||||
|
||||
const id = useHomeWidgetStore.getState().instances[0].id;
|
||||
useHomeWidgetStore
|
||||
.getState()
|
||||
.moveWidget(id, 500, 500, { width: 400, height: 300 });
|
||||
|
||||
expect(useHomeWidgetStore.getState().instances[0]).toMatchObject({
|
||||
x: 140,
|
||||
y: 168,
|
||||
});
|
||||
});
|
||||
|
||||
it("bumps stacking order, updates state, and removes widgets", () => {
|
||||
useHomeWidgetStore.getState().addWidget("stickyNote", 200, 200);
|
||||
useHomeWidgetStore.getState().addWidget("weather", 300, 300);
|
||||
const [note, weather] = useHomeWidgetStore.getState().instances;
|
||||
|
||||
useHomeWidgetStore.getState().bumpZ(note.id);
|
||||
useHomeWidgetStore.getState().updateWidgetState(note.id, { text: "hello" });
|
||||
useHomeWidgetStore.getState().removeWidget(weather.id);
|
||||
|
||||
expect(useHomeWidgetStore.getState().instances).toMatchObject([
|
||||
{ id: note.id, z: 3, state: { text: "hello" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores unknown catalog types", () => {
|
||||
useHomeWidgetStore.getState().addWidget("missing", 10, 10);
|
||||
|
||||
expect(useHomeWidgetStore.getState().instances).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { HOME_WIDGET_CATALOG_BY_ID } from "../widgets/catalog";
|
||||
import type { CanvasBounds, WidgetInstance } from "../widgets/types";
|
||||
|
||||
export const HOME_WIDGET_STORAGE_KEY = "goose2:home-widgets";
|
||||
|
||||
const BASELINE_CANVAS: CanvasBounds = { width: 1080, height: 760 };
|
||||
|
||||
function maxZ(instances: WidgetInstance[]): number {
|
||||
return instances.reduce((max, instance) => Math.max(max, instance.z), 0);
|
||||
}
|
||||
|
||||
export function clampWidgetPosition(
|
||||
type: string,
|
||||
x: number,
|
||||
y: number,
|
||||
bounds?: CanvasBounds,
|
||||
): { x: number; y: number } {
|
||||
const catalogEntry = HOME_WIDGET_CATALOG_BY_ID[type];
|
||||
if (!catalogEntry || !bounds) {
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.min(
|
||||
Math.max(0, x),
|
||||
Math.max(0, bounds.width - catalogEntry.defaultSize.width),
|
||||
),
|
||||
y: Math.min(
|
||||
Math.max(0, y),
|
||||
Math.max(0, bounds.height - catalogEntry.defaultSize.height),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function positionFromAnchor(
|
||||
type: string,
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
bounds = BASELINE_CANVAS,
|
||||
): { x: number; y: number } {
|
||||
const size = HOME_WIDGET_CATALOG_BY_ID[type]?.defaultSize ?? {
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
return clampWidgetPosition(
|
||||
type,
|
||||
bounds.width * anchorX - size.width / 2,
|
||||
bounds.height * anchorY - size.height / 2,
|
||||
bounds,
|
||||
);
|
||||
}
|
||||
|
||||
export function createDefaultHomeWidgets(
|
||||
bounds = BASELINE_CANVAS,
|
||||
): WidgetInstance[] {
|
||||
const cube = positionFromAnchor("cube", 0.5, 0.48, bounds);
|
||||
const clock = positionFromAnchor("clock", 0.83, 0.18, bounds);
|
||||
const agentPin = positionFromAnchor("agentPin", 0.2, 0.78, bounds);
|
||||
|
||||
return [
|
||||
{ id: "default-cube", type: "cube", x: cube.x, y: cube.y, z: 1 },
|
||||
{ id: "default-clock", type: "clock", x: clock.x, y: clock.y, z: 2 },
|
||||
{
|
||||
id: "default-agent-pin",
|
||||
type: "agentPin",
|
||||
x: agentPin.x,
|
||||
y: agentPin.y,
|
||||
z: 3,
|
||||
state: { agentId: "scout" },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
interface HomeWidgetStore {
|
||||
instances: WidgetInstance[];
|
||||
addWidget: (
|
||||
type: string,
|
||||
x: number,
|
||||
y: number,
|
||||
state?: Record<string, unknown>,
|
||||
bounds?: CanvasBounds,
|
||||
) => void;
|
||||
moveWidget: (id: string, x: number, y: number, bounds?: CanvasBounds) => void;
|
||||
bumpZ: (id: string) => void;
|
||||
removeWidget: (id: string) => void;
|
||||
updateWidgetState: (id: string, state: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export const useHomeWidgetStore = create<HomeWidgetStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
instances: createDefaultHomeWidgets(),
|
||||
addWidget: (type, x, y, state, bounds) =>
|
||||
set((current) => {
|
||||
const catalogEntry = HOME_WIDGET_CATALOG_BY_ID[type];
|
||||
if (!catalogEntry) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const centered = clampWidgetPosition(
|
||||
type,
|
||||
x - catalogEntry.defaultSize.width / 2,
|
||||
y - catalogEntry.defaultSize.height / 2,
|
||||
bounds,
|
||||
);
|
||||
|
||||
return {
|
||||
instances: [
|
||||
...current.instances,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
x: centered.x,
|
||||
y: centered.y,
|
||||
z: maxZ(current.instances) + 1,
|
||||
state,
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
moveWidget: (id, x, y, bounds) =>
|
||||
set((current) => ({
|
||||
instances: current.instances.map((instance) =>
|
||||
instance.id === id
|
||||
? {
|
||||
...instance,
|
||||
...clampWidgetPosition(instance.type, x, y, bounds),
|
||||
}
|
||||
: instance,
|
||||
),
|
||||
})),
|
||||
bumpZ: (id) =>
|
||||
set((current) => {
|
||||
const nextZ = maxZ(current.instances) + 1;
|
||||
return {
|
||||
instances: current.instances.map((instance) =>
|
||||
instance.id === id ? { ...instance, z: nextZ } : instance,
|
||||
),
|
||||
};
|
||||
}),
|
||||
removeWidget: (id) =>
|
||||
set((current) => ({
|
||||
instances: current.instances.filter((instance) => instance.id !== id),
|
||||
})),
|
||||
updateWidgetState: (id, state) =>
|
||||
set((current) => ({
|
||||
instances: current.instances.map((instance) =>
|
||||
instance.id === id
|
||||
? {
|
||||
...instance,
|
||||
state: { ...(instance.state ?? {}), ...state },
|
||||
}
|
||||
: instance,
|
||||
),
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: HOME_WIDGET_STORAGE_KEY,
|
||||
version: 1,
|
||||
partialize: (state) => ({ instances: state.instances }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,111 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import { useHomeWidgetStore } from "../stores/homeWidgetStore";
|
||||
import { HomeView } from "./HomeView";
|
||||
|
||||
describe("HomeView", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useHomeWidgetStore.setState({ instances: [] });
|
||||
useAgentStore.setState({ personas: [] });
|
||||
});
|
||||
|
||||
it("opens the picker from empty canvas and adds a widget", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<HomeView />);
|
||||
const canvas = container.querySelector(".bg-dot-grid");
|
||||
|
||||
expect(canvas).not.toBeNull();
|
||||
fireEvent.doubleClick(canvas as Element, { clientX: 100, clientY: 100 });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /clock/i }));
|
||||
|
||||
expect(useHomeWidgetStore.getState().instances).toMatchObject([
|
||||
{ type: "clock" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not open the picker when double-clicking a widget", () => {
|
||||
useHomeWidgetStore.getState().addWidget("clock", 130, 66);
|
||||
|
||||
render(<HomeView onOpenAgent={vi.fn()} onSelectSession={vi.fn()} />);
|
||||
fireEvent.doubleClick(screen.getByText(/local time/i));
|
||||
|
||||
expect(screen.queryByText("Tile")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("starts a chat from an intentional agent pin click", async () => {
|
||||
const onStartChatWithPersona = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
useAgentStore.setState({
|
||||
personas: [
|
||||
{
|
||||
id: "scout",
|
||||
displayName: "Scout",
|
||||
systemPrompt: "",
|
||||
isBuiltin: true,
|
||||
createdAt: "2026-04-29T00:00:00.000Z",
|
||||
updatedAt: "2026-04-29T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
useHomeWidgetStore.setState({
|
||||
instances: [
|
||||
{
|
||||
id: "agent-pin",
|
||||
type: "agentPin",
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
state: { agentId: "scout" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<HomeView onStartChatWithPersona={onStartChatWithPersona} />);
|
||||
expect(screen.queryByText("Start chat")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /scout/i }));
|
||||
|
||||
expect(onStartChatWithPersona).toHaveBeenCalledWith("scout");
|
||||
});
|
||||
|
||||
it("does not start a chat when releasing a dragged agent pin", () => {
|
||||
const onStartChatWithPersona = vi.fn();
|
||||
useAgentStore.setState({
|
||||
personas: [
|
||||
{
|
||||
id: "scout",
|
||||
displayName: "Scout",
|
||||
systemPrompt: "",
|
||||
isBuiltin: true,
|
||||
createdAt: "2026-04-29T00:00:00.000Z",
|
||||
updatedAt: "2026-04-29T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
useHomeWidgetStore.setState({
|
||||
instances: [
|
||||
{
|
||||
id: "agent-pin",
|
||||
type: "agentPin",
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
state: { agentId: "scout" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<HomeView onStartChatWithPersona={onStartChatWithPersona} />);
|
||||
const scoutPin = screen.getByRole("button", { name: /scout/i });
|
||||
|
||||
fireEvent.mouseDown(scoutPin, { clientX: 20, clientY: 20 });
|
||||
fireEvent.mouseMove(scoutPin, { clientX: 72, clientY: 28 });
|
||||
fireEvent.mouseUp(scoutPin, { clientX: 72, clientY: 28 });
|
||||
fireEvent.click(scoutPin);
|
||||
|
||||
expect(onStartChatWithPersona).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,102 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocaleFormatting } from "@/shared/i18n";
|
||||
import worldCubeUrl from "@/assets/home/world-cube.png";
|
||||
import clockUrl from "@/assets/home/clock.svg";
|
||||
import person2Url from "@/assets/home/person-2.png";
|
||||
import stickyNoteUrl from "@/assets/home/sticky-note.svg";
|
||||
import { useHomeWidgetStore } from "../stores/homeWidgetStore";
|
||||
import type { WidgetNavigationHandlers } from "../widgets/types";
|
||||
import { WidgetCanvas } from "./WidgetCanvas";
|
||||
|
||||
function getGreetingKey(hour: number): "morning" | "afternoon" | "evening" {
|
||||
if (hour < 12) return "morning";
|
||||
if (hour < 17) return "afternoon";
|
||||
return "evening";
|
||||
}
|
||||
|
||||
// HomeClock — editorial-display typography. fontFamily is set inline so we
|
||||
// don't depend on inheritance from <body>; font-light (weight 300) targets
|
||||
// the Cash Sans Light @font-face declared in globals.css.
|
||||
function HomeClock() {
|
||||
const [time, setTime] = useState(new Date());
|
||||
const { getTimeParts } = useLocaleFormatting();
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const { hour, minute, dayPeriod } = getTimeParts(time, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-baseline gap-3"
|
||||
style={{ fontFamily: "var(--font-sans-alex)" }}
|
||||
>
|
||||
<span className="text-[120px] font-light leading-none tracking-[-0.04em] text-foreground">
|
||||
{hour}:{minute}
|
||||
</span>
|
||||
{dayPeriod ? (
|
||||
<span className="text-[40px] font-light leading-none tracking-[-0.02em] text-foreground">
|
||||
{dayPeriod}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeView() {
|
||||
const { t } = useTranslation("home");
|
||||
const [hour] = useState(() => new Date().getHours());
|
||||
const greeting = t(`greeting.${getGreetingKey(hour)}`);
|
||||
export function HomeView({
|
||||
onOpenAgent,
|
||||
onStartChatWithPersona,
|
||||
onSelectSession,
|
||||
}: WidgetNavigationHandlers = {}) {
|
||||
const instances = useHomeWidgetStore((state) => state.instances);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
{/* Time + greeting — primary focal element, vertically centered */}
|
||||
<div className="pointer-events-none absolute left-[8%] top-[42%] flex -translate-y-1/2 flex-col gap-3">
|
||||
<HomeClock />
|
||||
<p
|
||||
className="text-[28px] font-light leading-tight tracking-[-0.02em] text-foreground/70"
|
||||
style={{ fontFamily: "var(--font-sans-alex)" }}
|
||||
>
|
||||
{greeting},{" "}
|
||||
{/* i18n-check-ignore: placeholder for dynamic user name — will be replaced when user profile lookup ships */}
|
||||
<span>Tulsi</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cube — right-center, gives the right side its visual weight */}
|
||||
<img
|
||||
src={worldCubeUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-[14%] top-1/2 w-[30%] max-w-[560px] -translate-y-1/2 select-none"
|
||||
/>
|
||||
|
||||
{/* Clock — top right corner */}
|
||||
<img
|
||||
src={clockUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-[6%] top-[6%] w-[11%] max-w-[180px] select-none"
|
||||
/>
|
||||
|
||||
{/* Person2 — pushed to the bottom-right corner so cube has breathing room */}
|
||||
<img
|
||||
src={person2Url}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-[5%] bottom-[8%] w-[7%] max-w-[120px] select-none"
|
||||
/>
|
||||
|
||||
{/* Sticky note — pulled rightward to ~center-bottom so it anchors the
|
||||
lower band rather than crowding the bottom-left corner alone */}
|
||||
<img
|
||||
src={stickyNoteUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute bottom-[10%] left-[28%] w-[15%] max-w-[260px] select-none"
|
||||
<WidgetCanvas
|
||||
instances={instances}
|
||||
onOpenAgent={onOpenAgent}
|
||||
onStartChatWithPersona={onStartChatWithPersona}
|
||||
onSelectSession={onSelectSession}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import { HOME_WIDGET_CATALOG_BY_ID } from "../widgets/catalog";
|
||||
import type {
|
||||
CanvasBounds,
|
||||
WidgetInstance,
|
||||
WidgetNavigationHandlers,
|
||||
} from "../widgets/types";
|
||||
import { useHomeWidgetStore } from "../stores/homeWidgetStore";
|
||||
import { WidgetFrame } from "./WidgetFrame";
|
||||
import { WidgetPicker } from "./WidgetPicker";
|
||||
|
||||
interface WidgetCanvasProps extends WidgetNavigationHandlers {
|
||||
instances: WidgetInstance[];
|
||||
}
|
||||
|
||||
interface PickerState {
|
||||
open: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function WidgetCanvas({
|
||||
instances,
|
||||
onOpenAgent,
|
||||
onStartChatWithPersona,
|
||||
onSelectSession,
|
||||
}: WidgetCanvasProps) {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
const addWidget = useHomeWidgetStore((state) => state.addWidget);
|
||||
const [picker, setPicker] = useState<PickerState>({
|
||||
open: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const getCanvasBounds = useCallback((): CanvasBounds | undefined => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
return rect ? { width: rect.width, height: rect.height } : undefined;
|
||||
}, []);
|
||||
|
||||
const currentMaxZ = useMemo(
|
||||
() => instances.reduce((max, instance) => Math.max(max, instance.z), 0),
|
||||
[instances],
|
||||
);
|
||||
|
||||
const handleDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setPicker({
|
||||
open: true,
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: freeform canvas opens the widget picker on empty-space double-click
|
||||
<div
|
||||
ref={canvasRef}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className="relative h-full w-full overflow-hidden bg-dot-grid"
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{instances
|
||||
.filter((instance) => HOME_WIDGET_CATALOG_BY_ID[instance.type])
|
||||
.map((instance) => (
|
||||
<WidgetFrame
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
canvasRef={canvasRef}
|
||||
currentMaxZ={currentMaxZ}
|
||||
getCanvasBounds={getCanvasBounds}
|
||||
onOpenAgent={onOpenAgent}
|
||||
onStartChatWithPersona={onStartChatWithPersona}
|
||||
onSelectSession={onSelectSession}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
<WidgetPicker
|
||||
open={picker.open}
|
||||
x={picker.x}
|
||||
y={picker.y}
|
||||
onClose={() => setPicker((current) => ({ ...current, open: false }))}
|
||||
onSelect={(type, state) => {
|
||||
addWidget(type, picker.x, picker.y, state, getCanvasBounds());
|
||||
setPicker((current) => ({ ...current, open: false }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/shared/ui/context-menu";
|
||||
import { HOME_WIDGET_CATALOG_BY_ID } from "../widgets/catalog";
|
||||
import type {
|
||||
CanvasBounds,
|
||||
WidgetInstance,
|
||||
WidgetNavigationHandlers,
|
||||
} from "../widgets/types";
|
||||
import { useHomeWidgetStore } from "../stores/homeWidgetStore";
|
||||
|
||||
interface WidgetFrameProps extends WidgetNavigationHandlers {
|
||||
instance: WidgetInstance;
|
||||
canvasRef: RefObject<HTMLDivElement | null>;
|
||||
currentMaxZ: number;
|
||||
getCanvasBounds: () => CanvasBounds | undefined;
|
||||
}
|
||||
|
||||
export function WidgetFrame({
|
||||
instance,
|
||||
canvasRef,
|
||||
currentMaxZ,
|
||||
getCanvasBounds,
|
||||
onOpenAgent,
|
||||
onStartChatWithPersona,
|
||||
onSelectSession,
|
||||
}: WidgetFrameProps) {
|
||||
const { t } = useTranslation("home");
|
||||
const moveWidget = useHomeWidgetStore((state) => state.moveWidget);
|
||||
const bumpZ = useHomeWidgetStore((state) => state.bumpZ);
|
||||
const removeWidget = useHomeWidgetStore((state) => state.removeWidget);
|
||||
const updateWidgetState = useHomeWidgetStore(
|
||||
(state) => state.updateWidgetState,
|
||||
);
|
||||
const suppressClickRef = useRef(false);
|
||||
const clickSuppressionTimerRef = useRef<number | null>(null);
|
||||
const removeClickBlockerRef = useRef<(() => void) | null>(null);
|
||||
const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const didDragRef = useRef(false);
|
||||
const catalogEntry = HOME_WIDGET_CATALOG_BY_ID[instance.type];
|
||||
|
||||
const handleUpdateState = useCallback(
|
||||
(next: Record<string, unknown>) => updateWidgetState(instance.id, next),
|
||||
[instance.id, updateWidgetState],
|
||||
);
|
||||
|
||||
const blockNextClick = useCallback(() => {
|
||||
removeClickBlockerRef.current?.();
|
||||
|
||||
const preventNextClick = (event: MouseEvent) => {
|
||||
if (!suppressClickRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
removeClickBlockerRef.current = null;
|
||||
};
|
||||
|
||||
window.addEventListener("click", preventNextClick, {
|
||||
capture: true,
|
||||
once: true,
|
||||
});
|
||||
removeClickBlockerRef.current = () => {
|
||||
window.removeEventListener("click", preventNextClick, {
|
||||
capture: true,
|
||||
});
|
||||
removeClickBlockerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const suppressClickBriefly = useCallback(() => {
|
||||
suppressClickRef.current = true;
|
||||
blockNextClick();
|
||||
if (clickSuppressionTimerRef.current) {
|
||||
window.clearTimeout(clickSuppressionTimerRef.current);
|
||||
}
|
||||
clickSuppressionTimerRef.current = window.setTimeout(() => {
|
||||
suppressClickRef.current = false;
|
||||
didDragRef.current = false;
|
||||
clickSuppressionTimerRef.current = null;
|
||||
removeClickBlockerRef.current?.();
|
||||
}, 600);
|
||||
}, [blockNextClick]);
|
||||
|
||||
const shouldIgnoreActivation = useCallback(
|
||||
() => suppressClickRef.current || didDragRef.current,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (clickSuppressionTimerRef.current) {
|
||||
window.clearTimeout(clickSuppressionTimerRef.current);
|
||||
}
|
||||
removeClickBlockerRef.current?.();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (!catalogEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Component = catalogEntry.Component;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<motion.div
|
||||
drag
|
||||
dragConstraints={canvasRef}
|
||||
dragElastic={0}
|
||||
dragMomentum={false}
|
||||
initial={false}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 430, damping: 32 }}
|
||||
onDragStart={suppressClickBriefly}
|
||||
onPointerDown={() => {
|
||||
if (instance.z < currentMaxZ) {
|
||||
bumpZ(instance.id);
|
||||
}
|
||||
}}
|
||||
onPointerDownCapture={(event) => {
|
||||
didDragRef.current = false;
|
||||
pointerStartRef.current = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
}}
|
||||
onPointerMoveCapture={(event) => {
|
||||
const start = pointerStartRef.current;
|
||||
if (!start || didDragRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(event.clientX - start.x) > 3 ||
|
||||
Math.abs(event.clientY - start.y) > 3
|
||||
) {
|
||||
didDragRef.current = true;
|
||||
suppressClickBriefly();
|
||||
}
|
||||
}}
|
||||
onPointerUpCapture={(event) => {
|
||||
const start = pointerStartRef.current;
|
||||
pointerStartRef.current = null;
|
||||
if (!start) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
didDragRef.current ||
|
||||
Math.abs(event.clientX - start.x) > 3 ||
|
||||
Math.abs(event.clientY - start.y) > 3
|
||||
) {
|
||||
didDragRef.current = true;
|
||||
suppressClickBriefly();
|
||||
}
|
||||
}}
|
||||
onDragEnd={(_, info) => {
|
||||
if (Math.abs(info.offset.x) > 3 || Math.abs(info.offset.y) > 3) {
|
||||
didDragRef.current = true;
|
||||
suppressClickBriefly();
|
||||
}
|
||||
moveWidget(
|
||||
instance.id,
|
||||
instance.x + info.offset.x,
|
||||
instance.y + info.offset.y,
|
||||
getCanvasBounds(),
|
||||
);
|
||||
}}
|
||||
onClickCapture={(event) => {
|
||||
if (!suppressClickRef.current) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
x: instance.x,
|
||||
y: instance.y,
|
||||
zIndex: instance.z,
|
||||
width: catalogEntry.defaultSize.width,
|
||||
height: catalogEntry.defaultSize.height,
|
||||
}}
|
||||
className="absolute left-0 top-0 cursor-grab select-none touch-none active:cursor-grabbing"
|
||||
>
|
||||
<Component
|
||||
instance={instance}
|
||||
onUpdateState={handleUpdateState}
|
||||
shouldIgnoreActivation={shouldIgnoreActivation}
|
||||
onOpenAgent={onOpenAgent}
|
||||
onStartChatWithPersona={onStartChatWithPersona}
|
||||
onSelectSession={onSelectSession}
|
||||
/>
|
||||
</motion.div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => removeWidget(instance.id)}
|
||||
>
|
||||
{t("widgets.actions.remove")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import { useChatStore } from "@/features/chat/stores/chatStore";
|
||||
import {
|
||||
getVisibleSessions,
|
||||
useChatSessionStore,
|
||||
} from "@/features/chat/stores/chatSessionStore";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
|
||||
import {
|
||||
HOME_WIDGET_CATALOG,
|
||||
HOME_WIDGET_CATEGORIES,
|
||||
} from "../widgets/catalog";
|
||||
import type { WidgetCategory } from "../widgets/types";
|
||||
|
||||
interface WidgetPickerProps {
|
||||
open: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
onSelect: (type: string, state?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const SECTION_CLASS_BY_CATEGORY: Record<WidgetCategory, string> = {
|
||||
tile: "bg-[#F4F0FF]",
|
||||
app: "bg-[#EEF4F8]",
|
||||
pin: "bg-[#E7F4EA]",
|
||||
};
|
||||
|
||||
export function WidgetPicker({
|
||||
open,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: WidgetPickerProps) {
|
||||
const { t } = useTranslation("home");
|
||||
const personas = useAgentStore((state) => state.personas);
|
||||
const sessions = useChatSessionStore((state) => state.sessions);
|
||||
const messagesBySession = useChatStore((state) => state.messagesBySession);
|
||||
const visibleSessions = useMemo(
|
||||
() =>
|
||||
getVisibleSessions(sessions, messagesBySession).filter(
|
||||
(session) => !session.archivedAt,
|
||||
),
|
||||
[messagesBySession, sessions],
|
||||
);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getDefaultState = (type: string) => {
|
||||
if (type === "agentPin") {
|
||||
const persona =
|
||||
personas.find((candidate) => candidate.isBuiltin) ?? personas[0];
|
||||
return persona ? { agentId: persona.id } : undefined;
|
||||
}
|
||||
|
||||
if (type === "chatPin") {
|
||||
const session = visibleSessions[0];
|
||||
return session ? { sessionId: session.id } : undefined;
|
||||
}
|
||||
|
||||
if (type === "stickyNote") {
|
||||
return { text: t("widgets.stickyNote.defaultText") };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||
<PopoverAnchor asChild>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute size-0"
|
||||
style={{ left: x, top: y }}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
className="w-80 rounded-lg border-black/10 bg-white/95 p-3 text-[var(--text-default-alex)] backdrop-blur"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{HOME_WIDGET_CATEGORIES.map((category) => {
|
||||
const entries = HOME_WIDGET_CATALOG.filter(
|
||||
(entry) => entry.category === category,
|
||||
);
|
||||
|
||||
return (
|
||||
<section key={category}>
|
||||
<h2 className="px-1 text-[11px] font-medium uppercase tracking-normal text-[var(--text-muted-alex)]">
|
||||
{t(`widgets.picker.sections.${category}`)}
|
||||
</h2>
|
||||
<div className="mt-2 space-y-1">
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSelect(entry.id, getDefaultState(entry.id))
|
||||
}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-black/[0.04]",
|
||||
SECTION_CLASS_BY_CATEGORY[category],
|
||||
)}
|
||||
>
|
||||
<span className="mt-1 size-2 shrink-0 rounded-full bg-black/25" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm">
|
||||
{t(entry.labelKey)}
|
||||
</span>
|
||||
{entry.descriptionKey ? (
|
||||
<span className="mt-0.5 block text-xs leading-4 text-[var(--text-muted-alex)]">
|
||||
{t(entry.descriptionKey)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { resolvePersonaFigure } from "@/features/agents/lib/personaFigure";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import type { Persona } from "@/shared/types/agents";
|
||||
import { useWidgetActivationGuard } from "./useWidgetActivationGuard";
|
||||
import type { WidgetRenderProps } from "./types";
|
||||
|
||||
function getAgentId(state: Record<string, unknown> | undefined): string | null {
|
||||
return typeof state?.agentId === "string" ? state.agentId : null;
|
||||
}
|
||||
|
||||
function resolvePersona(personas: Persona[], id: string | null) {
|
||||
const normalizedId = id?.toLowerCase();
|
||||
return (
|
||||
personas.find(
|
||||
(persona) =>
|
||||
persona.id === id ||
|
||||
(normalizedId && persona.displayName.toLowerCase() === normalizedId),
|
||||
) ??
|
||||
personas.find((persona) => persona.isBuiltin) ??
|
||||
personas[0]
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPinWidget({
|
||||
instance,
|
||||
shouldIgnoreActivation,
|
||||
onStartChatWithPersona,
|
||||
}: WidgetRenderProps) {
|
||||
const { t } = useTranslation("home");
|
||||
const personas = useAgentStore((state) => state.personas);
|
||||
const persona = useMemo(
|
||||
() => resolvePersona(personas, getAgentId(instance.state)),
|
||||
[instance.state, personas],
|
||||
);
|
||||
const label = persona?.displayName ?? t("widgets.agentPin.fallbackName");
|
||||
const personaId = persona?.id ?? getAgentId(instance.state) ?? "goose";
|
||||
const figureSrc = resolvePersonaFigure(persona?.displayName ?? "Scout");
|
||||
const activationGuard = useWidgetActivationGuard(shouldIgnoreActivation);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...activationGuard.pointerHandlers}
|
||||
onClick={(event) => {
|
||||
if (activationGuard.shouldIgnoreActivation()) {
|
||||
event.preventDefault();
|
||||
activationGuard.clearIgnoredActivation();
|
||||
return;
|
||||
}
|
||||
onStartChatWithPersona?.(personaId);
|
||||
}}
|
||||
aria-label={t("widgets.agentPin.openAria", { name: label })}
|
||||
className="group flex h-full w-full appearance-none items-center justify-center border-0 bg-transparent p-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--color-accent)]"
|
||||
>
|
||||
<img
|
||||
src={figureSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none h-full w-full select-none object-contain transition-transform group-hover:scale-[1.03]"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconMessageCircle } from "@tabler/icons-react";
|
||||
import { useChatStore } from "@/features/chat/stores/chatStore";
|
||||
import {
|
||||
getVisibleSessions,
|
||||
useChatSessionStore,
|
||||
} from "@/features/chat/stores/chatSessionStore";
|
||||
import { useLocaleFormatting } from "@/shared/i18n";
|
||||
import type { ChatSession } from "@/features/chat/stores/chatSessionStore";
|
||||
import { useWidgetActivationGuard } from "./useWidgetActivationGuard";
|
||||
import type { WidgetRenderProps } from "./types";
|
||||
|
||||
function getSessionId(
|
||||
state: Record<string, unknown> | undefined,
|
||||
): string | null {
|
||||
return typeof state?.sessionId === "string" ? state.sessionId : null;
|
||||
}
|
||||
|
||||
function resolveSession(
|
||||
sessions: ChatSession[],
|
||||
id: string | null,
|
||||
): ChatSession | undefined {
|
||||
return sessions.find((session) => session.id === id) ?? sessions[0];
|
||||
}
|
||||
|
||||
export function ChatPinWidget({
|
||||
instance,
|
||||
shouldIgnoreActivation,
|
||||
onSelectSession,
|
||||
}: WidgetRenderProps) {
|
||||
const { t } = useTranslation("home");
|
||||
const { formatRelativeTimeToNow } = useLocaleFormatting();
|
||||
const sessions = useChatSessionStore((state) => state.sessions);
|
||||
const messagesBySession = useChatStore((state) => state.messagesBySession);
|
||||
const visibleSessions = useMemo(
|
||||
() =>
|
||||
getVisibleSessions(sessions, messagesBySession).filter(
|
||||
(session) => !session.archivedAt,
|
||||
),
|
||||
[messagesBySession, sessions],
|
||||
);
|
||||
const session = resolveSession(visibleSessions, getSessionId(instance.state));
|
||||
const title = session?.title ?? t("widgets.chatPin.emptyTitle");
|
||||
const activationGuard = useWidgetActivationGuard(shouldIgnoreActivation);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...activationGuard.pointerHandlers}
|
||||
onClick={(event) => {
|
||||
if (activationGuard.shouldIgnoreActivation()) {
|
||||
event.preventDefault();
|
||||
activationGuard.clearIgnoredActivation();
|
||||
return;
|
||||
}
|
||||
if (session) {
|
||||
onSelectSession?.(session.id);
|
||||
}
|
||||
}}
|
||||
aria-label={t("widgets.chatPin.openAria", { title })}
|
||||
className="flex h-full w-full flex-col rounded-lg border border-black/10 bg-white/80 p-4 text-left text-[var(--text-default-alex)] backdrop-blur transition-colors hover:bg-white"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-[13px] text-[var(--text-muted-alex)]">
|
||||
<IconMessageCircle className="size-4" />
|
||||
{t("widgets.chatPin.kicker")}
|
||||
</span>
|
||||
<span className="mt-3 line-clamp-2 text-base leading-5">{title}</span>
|
||||
<span className="mt-auto text-sm text-[var(--text-muted-alex)]">
|
||||
{session
|
||||
? formatRelativeTimeToNow(session.updatedAt)
|
||||
: t("widgets.chatPin.emptyDescription")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocaleFormatting } from "@/shared/i18n";
|
||||
|
||||
export function ClockWidget() {
|
||||
const { t } = useTranslation("home");
|
||||
const [time, setTime] = useState(new Date());
|
||||
const { formatDate, getTimeParts } = useLocaleFormatting();
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const { hour, minute, dayPeriod } = getTimeParts(time, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="flex h-full w-full flex-col justify-between rounded-lg border border-black/10 bg-white/75 p-5 text-[var(--text-default-alex)] backdrop-blur">
|
||||
<p className="text-[13px] text-[var(--text-muted-alex)]">
|
||||
{t("widgets.clock.current")}
|
||||
</p>
|
||||
<div>
|
||||
<div
|
||||
className="flex items-baseline gap-2"
|
||||
style={{ fontFamily: "var(--font-sans-alex)" }}
|
||||
>
|
||||
<span className="text-[52px] font-light leading-none tracking-normal">
|
||||
{hour}:{minute}
|
||||
</span>
|
||||
{dayPeriod ? (
|
||||
<span className="text-[20px] font-light leading-none text-[var(--text-muted-alex)]">
|
||||
{dayPeriod}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-[var(--text-muted-alex)]">
|
||||
{formatDate(time, {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import cubeImage1 from "@/assets/home/cube/1.png";
|
||||
import cubeImage2 from "@/assets/home/cube/2.png";
|
||||
import cubeImage3 from "@/assets/home/cube/3.png";
|
||||
import cubeImage4 from "@/assets/home/cube/4.png";
|
||||
import cubeImage5 from "@/assets/home/cube/5.png";
|
||||
import type { WidgetRenderProps } from "./types";
|
||||
|
||||
const THREE_MODULE_URL = "/vendor/three/three.module.js";
|
||||
const ROUNDED_BOX_MODULE_URL = "/vendor/three/RoundedBoxGeometry.js";
|
||||
const ORTHO_SIZE = 1.4;
|
||||
|
||||
type ThreeVector3 = {
|
||||
set: (x: number, y: number, z: number) => ThreeVector3;
|
||||
copy: (value: ThreeVector3) => ThreeVector3;
|
||||
applyMatrix4: (matrix: ThreeMatrix4) => ThreeVector3;
|
||||
setFromMatrixPosition: (matrix: unknown) => ThreeVector3;
|
||||
};
|
||||
|
||||
type ThreeMatrix4 = {
|
||||
copy: (matrix: unknown) => ThreeMatrix4;
|
||||
invert: () => ThreeMatrix4;
|
||||
};
|
||||
|
||||
type ThreeTexture = {
|
||||
wrapS: unknown;
|
||||
wrapT: unknown;
|
||||
colorSpace: unknown;
|
||||
minFilter: unknown;
|
||||
magFilter: unknown;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
type CubeUniforms = {
|
||||
uTime: { value: number };
|
||||
uCamObj: { value: ThreeVector3 };
|
||||
uTexA: { value: ThreeTexture };
|
||||
uTexB: { value: ThreeTexture };
|
||||
uTexC: { value: ThreeTexture };
|
||||
uTexD: { value: ThreeTexture };
|
||||
uTexE: { value: ThreeTexture };
|
||||
};
|
||||
|
||||
type ThreeShaderMaterial = {
|
||||
uniforms: CubeUniforms;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
type ThreeGeometry = {
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
type ThreeMesh = {
|
||||
matrixWorld: unknown;
|
||||
rotation: { x: number; y: number };
|
||||
updateMatrixWorld: () => void;
|
||||
};
|
||||
|
||||
type ThreeScene = {
|
||||
add: (object: ThreeMesh) => void;
|
||||
};
|
||||
|
||||
type ThreeCamera = {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
matrixWorld: unknown;
|
||||
position: ThreeVector3;
|
||||
lookAt: (x: number, y: number, z: number) => void;
|
||||
updateProjectionMatrix: () => void;
|
||||
};
|
||||
|
||||
type ThreeRenderer = {
|
||||
domElement: HTMLCanvasElement;
|
||||
outputColorSpace: unknown;
|
||||
toneMapping: unknown;
|
||||
setPixelRatio: (value: number) => void;
|
||||
setSize: (width: number, height: number, updateStyle?: boolean) => void;
|
||||
setClearColor: (color: number, alpha?: number) => void;
|
||||
render: (scene: ThreeScene, camera: ThreeCamera) => void;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
type ThreeTextureLoader = {
|
||||
setCrossOrigin: (value: string) => void;
|
||||
load: (url: string) => ThreeTexture;
|
||||
};
|
||||
|
||||
type ThreeClock = {
|
||||
getElapsedTime: () => number;
|
||||
};
|
||||
|
||||
type ThreeRuntime = {
|
||||
WebGLRenderer: new (options: {
|
||||
antialias: boolean;
|
||||
alpha: boolean;
|
||||
}) => ThreeRenderer;
|
||||
Scene: new () => ThreeScene;
|
||||
OrthographicCamera: new (
|
||||
left: number,
|
||||
right: number,
|
||||
top: number,
|
||||
bottom: number,
|
||||
near: number,
|
||||
far: number,
|
||||
) => ThreeCamera;
|
||||
TextureLoader: new () => ThreeTextureLoader;
|
||||
ShaderMaterial: new (options: {
|
||||
transparent: boolean;
|
||||
depthWrite: boolean;
|
||||
uniforms: CubeUniforms;
|
||||
vertexShader: string;
|
||||
fragmentShader: string;
|
||||
}) => ThreeShaderMaterial;
|
||||
Mesh: new (
|
||||
geometry: ThreeGeometry,
|
||||
material: ThreeShaderMaterial,
|
||||
) => ThreeMesh;
|
||||
Clock: new () => ThreeClock;
|
||||
Matrix4: new () => ThreeMatrix4;
|
||||
Vector3: new () => ThreeVector3;
|
||||
ClampToEdgeWrapping: unknown;
|
||||
LinearFilter: unknown;
|
||||
LinearMipmapLinearFilter: unknown;
|
||||
NoToneMapping: unknown;
|
||||
SRGBColorSpace: unknown;
|
||||
};
|
||||
|
||||
type RoundedBoxModule = {
|
||||
RoundedBoxGeometry: new (
|
||||
width: number,
|
||||
height: number,
|
||||
depth: number,
|
||||
segments: number,
|
||||
radius: number,
|
||||
) => ThreeGeometry;
|
||||
};
|
||||
|
||||
const VERTEX_SHADER = `
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewDir;
|
||||
varying vec3 vPos;
|
||||
varying vec2 vScreenXY;
|
||||
uniform float uTime;
|
||||
|
||||
void main() {
|
||||
vec3 p = position;
|
||||
float breathe = sin(uTime * 0.5 + p.x * 1.8 + p.y * 1.3) * 0.012;
|
||||
p += normal * breathe;
|
||||
|
||||
vec4 mv = modelViewMatrix * vec4(p, 1.0);
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
vViewDir = normalize(-mv.xyz);
|
||||
vPos = p;
|
||||
vScreenXY = mv.xy;
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAGMENT_SHADER = `
|
||||
precision highp float;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewDir;
|
||||
varying vec3 vPos;
|
||||
varying vec2 vScreenXY;
|
||||
uniform float uTime;
|
||||
uniform vec3 uCamObj;
|
||||
uniform sampler2D uTexA;
|
||||
uniform sampler2D uTexB;
|
||||
uniform sampler2D uTexC;
|
||||
uniform sampler2D uTexD;
|
||||
uniform sampler2D uTexE;
|
||||
|
||||
vec3 mod289(vec3 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
|
||||
vec4 mod289(vec4 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
|
||||
vec4 permute(vec4 x){ return mod289(((x*34.0)+1.0)*x); }
|
||||
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }
|
||||
float snoise(vec3 v){
|
||||
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
|
||||
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
|
||||
vec3 i = floor(v + dot(v, C.yyy));
|
||||
vec3 x0 = v - i + dot(i, C.xxx);
|
||||
vec3 g = step(x0.yzx, x0.xyz);
|
||||
vec3 l = 1.0 - g;
|
||||
vec3 i1 = min(g.xyz, l.zxy);
|
||||
vec3 i2 = max(g.xyz, l.zxy);
|
||||
vec3 x1 = x0 - i1 + C.xxx;
|
||||
vec3 x2 = x0 - i2 + C.yyy;
|
||||
vec3 x3 = x0 - D.yyy;
|
||||
i = mod289(i);
|
||||
vec4 p = permute(permute(permute(
|
||||
i.z + vec4(0.0, i1.z, i2.z, 1.0))
|
||||
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
|
||||
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
|
||||
float n_ = 0.142857142857;
|
||||
vec3 ns = n_ * D.wyz - D.xzx;
|
||||
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
|
||||
vec4 x_ = floor(j * ns.z);
|
||||
vec4 y_ = floor(j - 7.0 * x_);
|
||||
vec4 x = x_ * ns.x + ns.yyyy;
|
||||
vec4 y = y_ * ns.x + ns.yyyy;
|
||||
vec4 h = 1.0 - abs(x) - abs(y);
|
||||
vec4 b0 = vec4(x.xy, y.xy);
|
||||
vec4 b1 = vec4(x.zw, y.zw);
|
||||
vec4 s0 = floor(b0) * 2.0 + 1.0;
|
||||
vec4 s1 = floor(b1) * 2.0 + 1.0;
|
||||
vec4 sh = -step(h, vec4(0.0));
|
||||
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||||
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||||
vec3 p0 = vec3(a0.xy, h.x);
|
||||
vec3 p1 = vec3(a0.zw, h.y);
|
||||
vec3 p2 = vec3(a1.xy, h.z);
|
||||
vec3 p3 = vec3(a1.zw, h.w);
|
||||
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
|
||||
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
|
||||
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
|
||||
m = m * m;
|
||||
return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
|
||||
}
|
||||
|
||||
void imgWeights(out float wA, out float wB, out float wC, out float wD, out float wE) {
|
||||
float cycle = uTime * 0.314;
|
||||
float step = 6.2831853 / 5.0;
|
||||
wA = pow(max(cos(cycle), 0.0), 3.0);
|
||||
wB = pow(max(cos(cycle - step), 0.0), 3.0);
|
||||
wC = pow(max(cos(cycle - step * 2.0), 0.0), 3.0);
|
||||
wD = pow(max(cos(cycle - step * 3.0), 0.0), 3.0);
|
||||
wE = pow(max(cos(cycle - step * 4.0), 0.0), 3.0);
|
||||
float s = wA + wB + wC + wD + wE + 1e-5;
|
||||
wA /= s; wB /= s; wC /= s; wD /= s; wE /= s;
|
||||
}
|
||||
|
||||
vec3 sampleBlend(vec2 uv, float wA, float wB, float wC, float wD, float wE) {
|
||||
vec3 a = texture2D(uTexA, uv).rgb;
|
||||
vec3 b = texture2D(uTexB, uv).rgb;
|
||||
vec3 c = texture2D(uTexC, uv).rgb;
|
||||
vec3 d = texture2D(uTexD, uv).rgb;
|
||||
vec3 e = texture2D(uTexE, uv).rgb;
|
||||
return a * wA + b * wB + c * wC + d * wD + e * wE;
|
||||
}
|
||||
|
||||
vec3 blurSample(vec2 uv, float r, float wA, float wB, float wC, float wD, float wE) {
|
||||
vec3 c = sampleBlend(uv, wA,wB,wC,wD,wE) * 0.36;
|
||||
c += sampleBlend(uv + vec2(r, 0.0), wA,wB,wC,wD,wE) * 0.16;
|
||||
c += sampleBlend(uv + vec2(-r, 0.0), wA,wB,wC,wD,wE) * 0.16;
|
||||
c += sampleBlend(uv + vec2(0.0, r), wA,wB,wC,wD,wE) * 0.16;
|
||||
c += sampleBlend(uv + vec2(0.0, -r), wA,wB,wC,wD,wE) * 0.16;
|
||||
return c;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 n = normalize(vNormal);
|
||||
float t = uTime * 0.08;
|
||||
|
||||
float ay = uTime * 0.22;
|
||||
float ax = sin(uTime * 0.18) * 0.35;
|
||||
mat3 rotY = mat3(
|
||||
cos(ay), 0.0, -sin(ay),
|
||||
0.0, 1.0, 0.0,
|
||||
sin(ay), 0.0, cos(ay)
|
||||
);
|
||||
mat3 rotX = mat3(
|
||||
1.0, 0.0, 0.0,
|
||||
0.0, cos(ax), -sin(ax),
|
||||
0.0, sin(ax), cos(ax)
|
||||
);
|
||||
mat3 innerRot = rotY * rotX;
|
||||
|
||||
float w1 = snoise(vPos * 1.1 + vec3(t, -t*0.6, t*0.4));
|
||||
vec2 warp = vec2(w1, snoise(vPos * 1.5 + vec3(-t, t*0.7, t*0.3))) * 0.012;
|
||||
|
||||
float wA, wB, wC, wD, wE;
|
||||
imgWeights(wA, wB, wC, wD, wE);
|
||||
float wMax = max(max(max(wA, wB), max(wC, wD)), wE);
|
||||
float morph = 1.0 - smoothstep(0.5, 0.95, wMax);
|
||||
|
||||
vec2 base = vScreenXY * 0.55;
|
||||
vec2 parallax = (innerRot * n).xy * 0.08;
|
||||
vec2 drift = vec2(sin(uTime * 0.08), cos(uTime * 0.10)) * 0.03;
|
||||
vec2 uv = base + parallax + drift + 0.5 + warp;
|
||||
|
||||
float r = 0.014 + morph * 0.040;
|
||||
vec3 col = blurSample(uv, r, wA,wB,wC,wD,wE);
|
||||
col = mix(col, col * vec3(1.03, 1.0, 0.96), 0.5);
|
||||
float l = dot(col, vec3(0.299, 0.587, 0.114));
|
||||
col = mix(col, vec3(l), 0.08);
|
||||
col = col * 0.95 + 0.03;
|
||||
|
||||
vec3 lightDir = normalize(vec3(0.6, 0.9, 0.5));
|
||||
float nl = max(dot(n, lightDir), 0.0);
|
||||
col *= 0.62 + 0.38 * nl;
|
||||
|
||||
float edgeBand = length(fwidth(n));
|
||||
float edgeDark = smoothstep(0.05, 0.25, edgeBand);
|
||||
col *= 1.0 - edgeDark * 0.18;
|
||||
|
||||
float grain = snoise(vec3(gl_FragCoord.xy * 0.7, uTime * 4.0)) * 0.05;
|
||||
col += grain;
|
||||
|
||||
float ndv = max(dot(n, vViewDir), 0.0);
|
||||
float alpha = 0.85 + 0.15 * smoothstep(0.0, 0.15, ndv);
|
||||
|
||||
gl_FragColor = vec4(col, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
const CUBE_TEXTURES = [
|
||||
cubeImage1,
|
||||
cubeImage2,
|
||||
cubeImage3,
|
||||
cubeImage4,
|
||||
cubeImage5,
|
||||
];
|
||||
|
||||
function importRuntimeModule<T>(path: string): Promise<T> {
|
||||
const nativeImport = new Function("path", "return import(path)") as (
|
||||
modulePath: string,
|
||||
) => Promise<T>;
|
||||
return nativeImport(path);
|
||||
}
|
||||
|
||||
export function CubeWidget(_props: WidgetRenderProps) {
|
||||
const { t } = useTranslation("home");
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const host = container;
|
||||
|
||||
let disposed = false;
|
||||
let animationFrame = 0;
|
||||
let cleanupScene: (() => void) | undefined;
|
||||
|
||||
async function mountCube() {
|
||||
const [THREE, { RoundedBoxGeometry }] = await Promise.all([
|
||||
importRuntimeModule<ThreeRuntime>(THREE_MODULE_URL),
|
||||
importRuntimeModule<RoundedBoxModule>(ROUNDED_BOX_MODULE_URL),
|
||||
]);
|
||||
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setClearColor(0xffffff, 0);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.NoToneMapping;
|
||||
renderer.domElement.ariaHidden = "true";
|
||||
renderer.domElement.className = "block h-full w-full";
|
||||
host.appendChild(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(
|
||||
-ORTHO_SIZE,
|
||||
ORTHO_SIZE,
|
||||
ORTHO_SIZE,
|
||||
-ORTHO_SIZE,
|
||||
0.1,
|
||||
100,
|
||||
);
|
||||
camera.position.set(2.2, -1.4, 2.2);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
const loader = new THREE.TextureLoader();
|
||||
loader.setCrossOrigin("anonymous");
|
||||
const textures = CUBE_TEXTURES.map((url) => {
|
||||
const texture = loader.load(url);
|
||||
texture.wrapS = THREE.ClampToEdgeWrapping;
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
return texture;
|
||||
});
|
||||
|
||||
const geometry = new RoundedBoxGeometry(1.6, 1.6, 1.6, 10, 0.22);
|
||||
const material = new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uCamObj: { value: new THREE.Vector3() },
|
||||
uTexA: { value: textures[0] },
|
||||
uTexB: { value: textures[1] },
|
||||
uTexC: { value: textures[2] },
|
||||
uTexD: { value: textures[3] },
|
||||
uTexE: { value: textures[4] },
|
||||
},
|
||||
vertexShader: VERTEX_SHADER,
|
||||
fragmentShader: FRAGMENT_SHADER,
|
||||
});
|
||||
const cube = new THREE.Mesh(geometry, material);
|
||||
scene.add(cube);
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
const invModel = new THREE.Matrix4();
|
||||
const camObj = new THREE.Vector3();
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
|
||||
const resize = () => {
|
||||
const { width, height } = host.getBoundingClientRect();
|
||||
const safeWidth = Math.max(1, Math.round(width));
|
||||
const safeHeight = Math.max(1, Math.round(height));
|
||||
const aspect = safeWidth / safeHeight;
|
||||
camera.left = -ORTHO_SIZE * aspect;
|
||||
camera.right = ORTHO_SIZE * aspect;
|
||||
camera.top = ORTHO_SIZE;
|
||||
camera.bottom = -ORTHO_SIZE;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(safeWidth, safeHeight, false);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(host);
|
||||
resize();
|
||||
|
||||
let velX = 0;
|
||||
let velY = 0;
|
||||
|
||||
const tick = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = clock.getElapsedTime();
|
||||
material.uniforms.uTime.value = prefersReducedMotion ? 0 : elapsed;
|
||||
|
||||
if (!prefersReducedMotion) {
|
||||
cube.rotation.y += velX;
|
||||
cube.rotation.x += velY;
|
||||
velX = velX * 0.975 + 0.002 * 0.025;
|
||||
velY = velY * 0.975 + 0.0006 * 0.025;
|
||||
}
|
||||
|
||||
cube.updateMatrixWorld();
|
||||
invModel.copy(cube.matrixWorld).invert();
|
||||
camObj.setFromMatrixPosition(camera.matrixWorld).applyMatrix4(invModel);
|
||||
material.uniforms.uCamObj.value.copy(camObj);
|
||||
|
||||
renderer.render(scene, camera);
|
||||
animationFrame = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
tick();
|
||||
|
||||
cleanupScene = () => {
|
||||
resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
renderer.domElement.remove();
|
||||
for (const texture of textures) {
|
||||
texture.dispose();
|
||||
}
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
renderer.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
void mountCube().catch((error) => {
|
||||
console.error("Failed to mount home cube:", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanupScene?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section aria-label={t("widgets.cube.ariaLabel")} className="h-full w-full">
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconCalendarEvent, IconChecklist } from "@tabler/icons-react";
|
||||
|
||||
export function MondayBriefTile() {
|
||||
const { t } = useTranslation("home");
|
||||
|
||||
return (
|
||||
<section className="flex h-full w-full flex-col rounded-lg border border-black/10 bg-[#F4F0FF] p-5 text-[var(--text-default-alex)]">
|
||||
<div className="flex items-center gap-2 text-[13px] text-[var(--text-muted-alex)]">
|
||||
<IconCalendarEvent className="size-4" />
|
||||
<span>{t("widgets.mondayBrief.kicker")}</span>
|
||||
</div>
|
||||
<h2 className="mt-4 text-xl font-normal leading-tight">
|
||||
{t("widgets.mondayBrief.title")}
|
||||
</h2>
|
||||
<div className="mt-auto grid grid-cols-3 gap-2 pt-4">
|
||||
<div className="rounded-md bg-white/60 p-2">
|
||||
<p className="text-lg leading-none">3</p>
|
||||
<p className="mt-1 text-[11px] text-[var(--text-muted-alex)]">
|
||||
{t("widgets.mondayBrief.priorities")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/60 p-2">
|
||||
<p className="text-lg leading-none">2</p>
|
||||
<p className="mt-1 text-[11px] text-[var(--text-muted-alex)]">
|
||||
{t("widgets.mondayBrief.meetings")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/60 p-2">
|
||||
<IconChecklist className="size-5" />
|
||||
<p className="mt-1 text-[11px] text-[var(--text-muted-alex)]">
|
||||
{t("widgets.mondayBrief.focus")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { WidgetRenderProps } from "./types";
|
||||
|
||||
function getNoteText(state: Record<string, unknown> | undefined): string {
|
||||
return typeof state?.text === "string" ? state.text : "";
|
||||
}
|
||||
|
||||
export function StickyNoteWidget({
|
||||
instance,
|
||||
onUpdateState,
|
||||
}: WidgetRenderProps) {
|
||||
const { t } = useTranslation("home");
|
||||
const text = getNoteText(instance.state);
|
||||
|
||||
return (
|
||||
<section className="h-full w-full rounded-lg border border-amber-900/10 bg-[#F7E7A6] p-4 text-amber-950 shadow-[0_8px_24px_rgba(0,0,0,0.08)]">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(event) => onUpdateState({ text: event.target.value })}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
placeholder={t("widgets.stickyNote.placeholder")}
|
||||
aria-label={t("widgets.stickyNote.ariaLabel")}
|
||||
className="h-full w-full resize-none bg-transparent text-[15px] leading-6 placeholder:text-amber-950/45 focus:outline-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconCloud, IconCloudRain, IconSun } from "@tabler/icons-react";
|
||||
|
||||
const FORECAST = [
|
||||
{ key: "today", temp: "72", icon: IconSun },
|
||||
{ key: "tomorrow", temp: "68", icon: IconCloud },
|
||||
{ key: "friday", temp: "63", icon: IconCloudRain },
|
||||
] as const;
|
||||
|
||||
export function WeatherWidget() {
|
||||
const { t } = useTranslation("home");
|
||||
|
||||
return (
|
||||
<section className="flex h-full w-full flex-col rounded-lg border border-black/10 bg-white/75 p-5 text-[var(--text-default-alex)] backdrop-blur">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-[13px] text-[var(--text-muted-alex)]">
|
||||
{t("widgets.weather.location")}
|
||||
</p>
|
||||
<p className="mt-1 text-4xl font-light leading-none">72°</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-yellow-200/70 p-3 text-yellow-700">
|
||||
<IconSun className="size-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm leading-5 text-[var(--text-muted-alex)]">
|
||||
{t("widgets.weather.summary")}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto grid grid-cols-3 gap-2 pt-4">
|
||||
{FORECAST.map(({ key, temp, icon: Icon }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col items-center gap-2 rounded-md bg-black/[0.04] px-2 py-3 text-center"
|
||||
>
|
||||
<Icon className="size-4 text-[var(--text-muted-alex)]" />
|
||||
<span className="text-[11px] text-[var(--text-muted-alex)]">
|
||||
{t(`widgets.weather.forecast.${key}`)}
|
||||
</span>
|
||||
<span className="text-sm">{temp}°</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconArrowUpRight, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
export function WeeklyHighlightsTile() {
|
||||
const { t } = useTranslation("home");
|
||||
|
||||
return (
|
||||
<section className="flex h-full w-full flex-col rounded-lg border border-black/10 bg-[#E7F4EA] p-5 text-[var(--text-default-alex)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-[13px] text-[var(--text-muted-alex)]">
|
||||
<IconSparkles className="size-4" />
|
||||
<span>{t("widgets.weeklyHighlights.kicker")}</span>
|
||||
</div>
|
||||
<IconArrowUpRight className="size-4 text-[var(--text-muted-alex)]" />
|
||||
</div>
|
||||
|
||||
<h2 className="mt-4 text-xl font-normal leading-tight">
|
||||
{t("widgets.weeklyHighlights.title")}
|
||||
</h2>
|
||||
|
||||
<div className="mt-auto space-y-2 pt-4">
|
||||
{["briefs", "prs", "docs"].map((key) => (
|
||||
<div key={key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-[var(--text-muted-alex)]">
|
||||
{t(`widgets.weeklyHighlights.items.${key}.label`)}
|
||||
</span>
|
||||
<span>{t(`widgets.weeklyHighlights.items.${key}.value`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { AgentPinWidget } from "./AgentPinWidget";
|
||||
import { ChatPinWidget } from "./ChatPinWidget";
|
||||
import { ClockWidget } from "./ClockWidget";
|
||||
import { CubeWidget } from "./CubeWidget";
|
||||
import { MondayBriefTile } from "./MondayBriefTile";
|
||||
import { StickyNoteWidget } from "./StickyNoteWidget";
|
||||
import { WeatherWidget } from "./WeatherWidget";
|
||||
import { WeeklyHighlightsTile } from "./WeeklyHighlightsTile";
|
||||
import type { WidgetCatalogEntry } from "./types";
|
||||
|
||||
export const HOME_WIDGET_CATALOG = [
|
||||
{
|
||||
id: "mondayBrief",
|
||||
category: "tile",
|
||||
labelKey: "widgets.mondayBrief.label",
|
||||
descriptionKey: "widgets.mondayBrief.description",
|
||||
defaultSize: { width: 300, height: 180 },
|
||||
Component: MondayBriefTile,
|
||||
},
|
||||
{
|
||||
id: "weeklyHighlights",
|
||||
category: "tile",
|
||||
labelKey: "widgets.weeklyHighlights.label",
|
||||
descriptionKey: "widgets.weeklyHighlights.description",
|
||||
defaultSize: { width: 300, height: 200 },
|
||||
Component: WeeklyHighlightsTile,
|
||||
},
|
||||
{
|
||||
id: "cube",
|
||||
category: "app",
|
||||
labelKey: "widgets.cube.label",
|
||||
descriptionKey: "widgets.cube.description",
|
||||
defaultSize: { width: 360, height: 360 },
|
||||
Component: CubeWidget,
|
||||
},
|
||||
{
|
||||
id: "clock",
|
||||
category: "app",
|
||||
labelKey: "widgets.clock.label",
|
||||
descriptionKey: "widgets.clock.description",
|
||||
defaultSize: { width: 260, height: 132 },
|
||||
Component: ClockWidget,
|
||||
},
|
||||
{
|
||||
id: "weather",
|
||||
category: "app",
|
||||
labelKey: "widgets.weather.label",
|
||||
descriptionKey: "widgets.weather.description",
|
||||
defaultSize: { width: 280, height: 220 },
|
||||
Component: WeatherWidget,
|
||||
},
|
||||
{
|
||||
id: "stickyNote",
|
||||
category: "app",
|
||||
labelKey: "widgets.stickyNote.label",
|
||||
descriptionKey: "widgets.stickyNote.description",
|
||||
defaultSize: { width: 260, height: 220 },
|
||||
Component: StickyNoteWidget,
|
||||
},
|
||||
{
|
||||
id: "agentPin",
|
||||
category: "pin",
|
||||
labelKey: "widgets.agentPin.label",
|
||||
descriptionKey: "widgets.agentPin.description",
|
||||
defaultSize: { width: 136, height: 270 },
|
||||
Component: AgentPinWidget,
|
||||
},
|
||||
{
|
||||
id: "chatPin",
|
||||
category: "pin",
|
||||
labelKey: "widgets.chatPin.label",
|
||||
descriptionKey: "widgets.chatPin.description",
|
||||
defaultSize: { width: 260, height: 128 },
|
||||
Component: ChatPinWidget,
|
||||
},
|
||||
] satisfies WidgetCatalogEntry[];
|
||||
|
||||
export const HOME_WIDGET_CATALOG_BY_ID = Object.fromEntries(
|
||||
HOME_WIDGET_CATALOG.map((entry) => [entry.id, entry]),
|
||||
) as Record<string, WidgetCatalogEntry>;
|
||||
|
||||
export const HOME_WIDGET_CATEGORIES = ["tile", "app", "pin"] as const;
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
export type WidgetCategory = "tile" | "app" | "pin";
|
||||
|
||||
export interface WidgetSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface WidgetInstance {
|
||||
id: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
state?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WidgetNavigationHandlers {
|
||||
onOpenAgent?: (agentId: string) => void;
|
||||
onStartChatWithPersona?: (personaId: string) => void;
|
||||
onSelectSession?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export interface WidgetRenderProps extends WidgetNavigationHandlers {
|
||||
instance: WidgetInstance;
|
||||
onUpdateState: (next: Record<string, unknown>) => void;
|
||||
shouldIgnoreActivation: () => boolean;
|
||||
}
|
||||
|
||||
export interface WidgetCatalogEntry {
|
||||
id: string;
|
||||
category: WidgetCategory;
|
||||
labelKey: string;
|
||||
descriptionKey?: string;
|
||||
defaultSize: WidgetSize;
|
||||
Component: ComponentType<WidgetRenderProps>;
|
||||
}
|
||||
|
||||
export interface CanvasBounds {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { MouseEventHandler, PointerEventHandler } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
|
||||
const DRAG_ACTIVATION_THRESHOLD = 3;
|
||||
|
||||
type Point = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type ActivationMovementEvent = {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
};
|
||||
|
||||
type ActivationPointerHandlers = {
|
||||
onPointerDown: PointerEventHandler<HTMLButtonElement>;
|
||||
onPointerMove: PointerEventHandler<HTMLButtonElement>;
|
||||
onPointerUp: PointerEventHandler<HTMLButtonElement>;
|
||||
onPointerCancel: PointerEventHandler<HTMLButtonElement>;
|
||||
onMouseDown: MouseEventHandler<HTMLButtonElement>;
|
||||
onMouseMove: MouseEventHandler<HTMLButtonElement>;
|
||||
onMouseUp: MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
export function useWidgetActivationGuard(
|
||||
shouldIgnoreParentActivation: () => boolean,
|
||||
) {
|
||||
const pointerStartRef = useRef<Point | null>(null);
|
||||
const movedRef = useRef(false);
|
||||
|
||||
const markMovedIfNeeded = useCallback((event: ActivationMovementEvent) => {
|
||||
const start = pointerStartRef.current;
|
||||
if (!start || movedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(event.clientX - start.x) > DRAG_ACTIVATION_THRESHOLD ||
|
||||
Math.abs(event.clientY - start.y) > DRAG_ACTIVATION_THRESHOLD
|
||||
) {
|
||||
movedRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pointerHandlers = useMemo<ActivationPointerHandlers>(
|
||||
() => ({
|
||||
onPointerDown: (event) => {
|
||||
movedRef.current = false;
|
||||
pointerStartRef.current = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
},
|
||||
onPointerMove: markMovedIfNeeded,
|
||||
onPointerUp: (event) => {
|
||||
markMovedIfNeeded(event);
|
||||
pointerStartRef.current = null;
|
||||
},
|
||||
onPointerCancel: () => {
|
||||
movedRef.current = false;
|
||||
pointerStartRef.current = null;
|
||||
},
|
||||
onMouseDown: (event) => {
|
||||
movedRef.current = false;
|
||||
pointerStartRef.current = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
},
|
||||
onMouseMove: markMovedIfNeeded,
|
||||
onMouseUp: (event) => {
|
||||
markMovedIfNeeded(event);
|
||||
pointerStartRef.current = null;
|
||||
},
|
||||
}),
|
||||
[markMovedIfNeeded],
|
||||
);
|
||||
|
||||
const shouldIgnoreActivation = useCallback(
|
||||
() => shouldIgnoreParentActivation() || movedRef.current,
|
||||
[shouldIgnoreParentActivation],
|
||||
);
|
||||
|
||||
const clearIgnoredActivation = useCallback(() => {
|
||||
window.setTimeout(() => {
|
||||
movedRef.current = false;
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
clearIgnoredActivation,
|
||||
pointerHandlers,
|
||||
shouldIgnoreActivation,
|
||||
};
|
||||
}
|
||||
@@ -3,5 +3,89 @@
|
||||
"afternoon": "Good afternoon",
|
||||
"evening": "Good evening",
|
||||
"morning": "Good morning"
|
||||
},
|
||||
"widgets": {
|
||||
"actions": {
|
||||
"remove": "Remove"
|
||||
},
|
||||
"picker": {
|
||||
"sections": {
|
||||
"app": "App",
|
||||
"pin": "Pin",
|
||||
"tile": "Tile"
|
||||
}
|
||||
},
|
||||
"agentPin": {
|
||||
"action": "Start chat",
|
||||
"description": "Start a new chat with a saved agent.",
|
||||
"fallbackName": "Scout",
|
||||
"label": "Pin an agent",
|
||||
"openAria": "Start a chat with {{name}}"
|
||||
},
|
||||
"chatPin": {
|
||||
"description": "Jump back into a recent chat.",
|
||||
"emptyDescription": "No recent chat yet",
|
||||
"emptyTitle": "Recent chat",
|
||||
"kicker": "Pinned chat",
|
||||
"label": "Pin a chat",
|
||||
"openAria": "Open {{title}}"
|
||||
},
|
||||
"clock": {
|
||||
"current": "Local time",
|
||||
"description": "A live clock for your canvas.",
|
||||
"label": "Clock"
|
||||
},
|
||||
"cube": {
|
||||
"ariaLabel": "Animated cube",
|
||||
"description": "A continuously animated canvas object.",
|
||||
"label": "Cube"
|
||||
},
|
||||
"mondayBrief": {
|
||||
"description": "A static preview of an agent-generated morning brief.",
|
||||
"focus": "Focus",
|
||||
"kicker": "Monday brief",
|
||||
"label": "Monday morning brief",
|
||||
"meetings": "Meetings",
|
||||
"priorities": "Priorities",
|
||||
"title": "Plan the week before the week plans you."
|
||||
},
|
||||
"stickyNote": {
|
||||
"ariaLabel": "Sticky note",
|
||||
"defaultText": "Follow up on the demo flow.",
|
||||
"description": "A simple editable note.",
|
||||
"label": "Sticky note",
|
||||
"placeholder": "Write a note..."
|
||||
},
|
||||
"weather": {
|
||||
"description": "A mock three-day forecast.",
|
||||
"forecast": {
|
||||
"friday": "Fri",
|
||||
"today": "Today",
|
||||
"tomorrow": "Thu"
|
||||
},
|
||||
"label": "Weather",
|
||||
"location": "San Francisco",
|
||||
"summary": "Clear afternoon, light breeze, good window for a walk."
|
||||
},
|
||||
"weeklyHighlights": {
|
||||
"description": "A static summary tile for weekly progress.",
|
||||
"items": {
|
||||
"briefs": {
|
||||
"label": "Briefs sent",
|
||||
"value": "4"
|
||||
},
|
||||
"docs": {
|
||||
"label": "Docs updated",
|
||||
"value": "7"
|
||||
},
|
||||
"prs": {
|
||||
"label": "PRs reviewed",
|
||||
"value": "11"
|
||||
}
|
||||
},
|
||||
"kicker": "Highlights",
|
||||
"label": "Weekly highlights",
|
||||
"title": "Small wins are stacking up."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,89 @@
|
||||
"afternoon": "Buenas tardes",
|
||||
"evening": "Buenas noches",
|
||||
"morning": "Buenos días"
|
||||
},
|
||||
"widgets": {
|
||||
"actions": {
|
||||
"remove": "Quitar"
|
||||
},
|
||||
"picker": {
|
||||
"sections": {
|
||||
"app": "App",
|
||||
"pin": "Pin",
|
||||
"tile": "Mosaico"
|
||||
}
|
||||
},
|
||||
"agentPin": {
|
||||
"action": "Iniciar chat",
|
||||
"description": "Inicia un chat nuevo con un agente guardado.",
|
||||
"fallbackName": "Scout",
|
||||
"label": "Fijar un agente",
|
||||
"openAria": "Iniciar un chat con {{name}}"
|
||||
},
|
||||
"chatPin": {
|
||||
"description": "Vuelve a un chat reciente.",
|
||||
"emptyDescription": "Aún no hay chat reciente",
|
||||
"emptyTitle": "Chat reciente",
|
||||
"kicker": "Chat fijado",
|
||||
"label": "Fijar un chat",
|
||||
"openAria": "Abrir {{title}}"
|
||||
},
|
||||
"clock": {
|
||||
"current": "Hora local",
|
||||
"description": "Un reloj en vivo para tu lienzo.",
|
||||
"label": "Reloj"
|
||||
},
|
||||
"cube": {
|
||||
"ariaLabel": "Cubo animado",
|
||||
"description": "Un objeto animado para el lienzo.",
|
||||
"label": "Cubo"
|
||||
},
|
||||
"mondayBrief": {
|
||||
"description": "Vista previa estática de un resumen matutino generado por un agente.",
|
||||
"focus": "Enfoque",
|
||||
"kicker": "Resumen del lunes",
|
||||
"label": "Resumen del lunes",
|
||||
"meetings": "Reuniones",
|
||||
"priorities": "Prioridades",
|
||||
"title": "Planifica la semana antes de que la semana te planifique."
|
||||
},
|
||||
"stickyNote": {
|
||||
"ariaLabel": "Nota adhesiva",
|
||||
"defaultText": "Dar seguimiento al flujo de demo.",
|
||||
"description": "Una nota editable sencilla.",
|
||||
"label": "Nota adhesiva",
|
||||
"placeholder": "Escribe una nota..."
|
||||
},
|
||||
"weather": {
|
||||
"description": "Un pronóstico simulado de tres días.",
|
||||
"forecast": {
|
||||
"friday": "Vie",
|
||||
"today": "Hoy",
|
||||
"tomorrow": "Jue"
|
||||
},
|
||||
"label": "Clima",
|
||||
"location": "San Francisco",
|
||||
"summary": "Tarde despejada, brisa ligera y buen momento para caminar."
|
||||
},
|
||||
"weeklyHighlights": {
|
||||
"description": "Un mosaico estático con el progreso semanal.",
|
||||
"items": {
|
||||
"briefs": {
|
||||
"label": "Resúmenes enviados",
|
||||
"value": "4"
|
||||
},
|
||||
"docs": {
|
||||
"label": "Docs actualizados",
|
||||
"value": "7"
|
||||
},
|
||||
"prs": {
|
||||
"label": "PRs revisados",
|
||||
"value": "11"
|
||||
}
|
||||
},
|
||||
"kicker": "Destacados",
|
||||
"label": "Destacados semanales",
|
||||
"title": "Las pequeñas victorias se van acumulando."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||