Files
openwork/prds/react-incremental-adoption.md
ben 9365e7d397 feat(app): add incremental React session path (#1362)
* feat(server): add workspace session read APIs

Expose workspace-scoped session list, detail, message, and snapshot reads so the client can fetch session data without depending on activation choreography.

* feat(app): route mounted session reads through OpenWork APIs

Use the new workspace-scoped session read endpoints for mounted OpenWork clients so the current frontend stops depending on direct session proxy reads for list, detail, message, and todo loading.

* feat(app): add React read-only session transcript

Introduce a feature-gated React island for the session transcript so we can replace the session surface incrementally while keeping the Solid shell intact.

* feat(app): add React session composer surface

Extend the feature-gated React session island to own its draft, prompt send, stop flow, and snapshot polling so the session body can evolve independently from the Solid composer.

* feat(app): add React session transition model

Keep the React session surface stable during session switches by tracking rendered vs intended session state and exposing a developer debug panel for render-source and transition inspection.

* docs(prd): add React migration plan to repo

Copy the incremental React adoption PRD into the OpenWork repo so the migration plan lives next to the implementation and PR branch.

* docs(prd): sync full React migration plan

Replace the shortened repo copy with the full incremental React adoption PRD so the implementation branch and product plan stay in sync.

* feat(desktop): add React session launch modes

Add dedicated Tauri dev and debug-build entrypoints for the React session path and honor a build-time React session flag before local storage so the alternate shell is easy to launch and reproduce.

* fix(app): fall back to legacy mounted session reads

Keep the new app working against older OpenWork servers by falling back to the original mounted OpenCode session reads when the workspace-scoped session read APIs are unavailable.
2026-04-05 16:46:06 -07:00

621 lines
27 KiB
Markdown

# PRD: Incremental React Adoption with Isolated Testing
## Status: Draft
## Date: 2026-04-05
## Problem
The OpenWork app is 100% SolidJS. The session UI has resilience issues (white screens, flicker, route/runtime/selection mismatches) rooted in overlapping owners of truth. The plan is to incrementally adopt React for the session experience layer, then expand to replace the entire app — while keeping the existing app running at every step. Each phase must be testable in isolation against a real Docker dev stack before merging.
## Current Architecture (Ground Truth)
### Frontend
- **Framework**: SolidJS only. Zero React in `apps/app/`.
- **Monolith**: `app.tsx` (~2,500 lines) creates ~15 stores, threads ~90 props to `SessionView`.
- **Session view**: `pages/session.tsx` (~2,000 lines) — SolidJS, receives all state as props via `SessionViewProps`.
- **State**: SolidJS signals + `createStore()`. No external state libs.
- **Router**: `@solidjs/router`, imperative navigation.
- **Prepared seam**: `@openwork/ui` already exports both React and Solid components. `SessionViewProps` is a clean data-only interface.
- **Build**: Vite + `vite-plugin-solid`. No React plugin configured.
- **Platform**: Tauri 2.x for desktop/mobile. Web mode uses standard browser APIs. Platform abstraction lives in `context/platform.tsx`.
### Backend
- **Server**: `Bun.serve()`, hand-rolled router. No framework.
- **Session data**: Lives in OpenCode's SQLite DB. Client reads via OpenCode SDK or proxied through `/w/:id/opencode/*`.
- **No server-side session read endpoints**: The OpenWork server has no `GET /sessions` or `GET /session/:id`. It proxies to OpenCode.
- **Activation**: Nearly free (array reorder). The expensive part is client-side workspace bootstrapping.
- **Orchestrator**: Process supervisor that spawns server + OpenCode + router.
### Styling
- **CSS framework**: Tailwind CSS v4.1.18 via `@tailwindcss/vite`.
- **Color system**: Radix UI Colors (30+ scales, 12 steps each) + DLS semantic tokens — all CSS custom properties (~700+).
- **Dark mode**: `data-theme` attribute on `<html>` + CSS variable swap. NOT Tailwind `dark:` prefix.
- **Component styling**: Inline Tailwind `class=` strings with template literal conditionals. No `cn()`, `clsx`, or `tailwind-merge`.
- **Custom CSS classes**: `ow-*` prefixed classes in global `index.css` (buttons, cards, pills, inputs).
- **CSS-in-JS**: None.
- **Animation**: CSS-only (Tailwind transitions + custom `@keyframes`). No framer-motion or JS animation libs.
- **Fonts**: System font stack (IBM Plex Sans preferred, no bundled fonts).
- **Design language**: `DESIGN-LANGUAGE.md` (871 lines) — quiet, premium, flat-first. Shadow is last resort.
- **Key files**: `tailwind.config.ts`, `src/app/index.css`, `src/styles/colors.css`, `DESIGN-LANGUAGE.md`.
### Existing domain map (CUPID)
The app follows CUPID domain organization:
- `shell` — routing, layout, boot, global chrome
- `session` — task/session experience, composer, messages
- `workspace` — workspace lifecycle, switching, connect
- `connections` — providers, MCP
- `automations` — scheduled jobs
- `cloud` — hosted workers, den
- `app-settings` — preferences, themes
- `kernel` — tiny shared primitives
---
## Three-Stage Transition: Solid → Hybrid → React
### Stage 1: React Island (Phases 0-3)
React lives inside the Solid app as a guest. Solid owns the shell, routing, and platform layer. React renders into a div that Solid manages.
```
Tauri/Web shell
└── Solid app (owns everything)
├── Solid sidebar
├── Solid settings
└── ReactIsland (a div)
└── React session view (our new code)
```
State bridge: minimal. React gets workspace URL + token + session ID from Solid via island props. React fetches its own data. Two independent state worlds.
### Stage 2: React Expands, Island Inverts (Phases 5-8)
React takes over more surfaces. Each Solid surface migrates to its React counterpart, one domain at a time. At a tipping point (after workspace sidebar moves to React), the island inverts:
```
Tauri/Web shell
└── React app (owns the shell now)
├── React sidebar
├── React session view
├── React settings (partial)
└── SolidIsland (a div) ← for remaining Solid surfaces
└── remaining Solid components
```
### Stage 3: React Owns Everything (Phase 9+)
```
Tauri shell (just the native window + IPC)
└── React app
├── react/shell/
├── react/session/
├── react/workspace/
├── react/connections/
├── react/app-settings/
├── react/cloud/
└── react/kernel/
```
At this point `vite-plugin-solid` and `solid-js` are removed. The app is a standard React SPA that happens to run inside Tauri for desktop. The web build is the same React app without the Tauri wrapper.
---
## State Ownership Rule
**At any point in time, each piece of state has exactly one framework owning it.**
When you migrate a surface from Solid to React, you delete the Solid version of that state. You never have both frameworks managing the same concern.
| Concern | Stage 1 (React island) | Stage 2 (React expanding) | Stage 3 (React owns all) |
|---------|----------------------|--------------------------|-------------------------|
| Session messages | React (react-query) | React | React |
| Session transition | React (transition-controller) | React | React |
| Workspace list | Solid | React (after migration) | React |
| Workspace switching | Solid → passes result to React via island props | React | React |
| Routing | Solid router | Hybrid: Solid routes to React islands | React router |
| Platform (Tauri IPC) | Solid platform provider | Framework-agnostic adapter module | React calls adapter directly |
| Settings/config | Solid | Migrated domain by domain | React |
---
## Bridge Contract (Shrinks Over Time)
The island props are the formal contract between Solid and React. It starts small and shrinks to zero:
```ts
// Stage 1 — React island gets minimal props from Solid
interface IslandProps {
workspaceUrl: string
workspaceToken: string
workspaceId: string
sessionId: string | null
onNavigate: (path: string) => void // React tells Solid to route
}
// Stage 2 — React takes over sidebar, fewer props needed
interface IslandProps {
workspaces: WorkspaceConnection[] // React now owns selection
onNavigate: (path: string) => void
}
// Stage 3 — no island, no props. React owns everything.
// island.tsx deleted, solid-js removed.
```
Each time a surface migrates, the island props shrink. When they hit zero, the island is removed.
---
## File Structure (CUPID Domains, Component-Enclosed State)
Mirrors the existing CUPID domain map. Each domain colocates state, data, and UI. Components own the state they render — "general" session state sits at the session boundary, local UI state lives inside the component that needs it.
```
apps/app/src/react/
├── README.md # Why this exists, how to enable, migration status
├── island.tsx # Solid→React bridge (mounts boot.tsx into a DOM node)
├── boot.tsx # React root, providers, top-level wiring
├── feature-flag.ts # Read/write opt-in flag
├── kernel/ # Smallest shared layer (CUPID kernel rules apply)
│ ├── opencode-client.ts # Plain fetch() for OpenCode proxy — no Solid dependency
│ ├── types.ts # Session, Message, Workspace shapes
│ ├── query-provider.tsx # react-query provider + defaults
│ └── dev-panel.tsx # Dev-only: renderSource, transition, timings
├── shell/ # App-wide composition only (thin)
│ ├── layout.tsx # Sidebar + main area composition
│ ├── router.tsx # Route → domain view dispatch
│ └── index.ts
├── session/ # Domain: active task/session experience
│ │
│ │ -- General session state (shared by session components) --
│ ├── session-store.ts # renderedSessionId, intendedSessionId, renderSource
│ ├── transition-controller.ts # idle → switching → cache → live → idle
│ ├── sessions-query.ts # react-query: list sessions for a workspace
│ ├── session-snapshot-query.ts # react-query: full session + messages
│ │
│ │ -- Session view (composition root for the main area) --
│ ├── session-view.tsx # Composes message-list + composer + status
│ │ # owns: scroll position, view-level layout
│ │
│ │ -- Message list (owns its own scroll/virtualization) --
│ ├── message-list/
│ │ ├── message-list.tsx # Virtualized container
│ │ │ # owns: virtualization state, scroll anchor
│ │ ├── message-item.tsx # Single message bubble
│ │ │ # owns: collapsed/expanded, copy state
│ │ ├── part-view.tsx # Tool call, text, file, reasoning
│ │ │ # owns: expand/collapse per part
│ │ └── index.ts
│ │
│ │ -- Composer (owns its own input state) --
│ ├── composer/
│ │ ├── composer.tsx # Prompt textarea + attachments + run/abort
│ │ │ # owns: draft text, file list, submitting
│ │ ├── send-prompt.ts # Mutation: send, SSE subscribe, abort
│ │ ├── attachment-picker.tsx
│ │ │ # owns: file picker open/selected state
│ │ └── index.ts
│ │
│ │ -- Session sidebar (owns its own list state) --
│ ├── session-sidebar/
│ │ ├── session-sidebar.tsx # Session list for one workspace
│ │ │ # owns: search filter, rename-in-progress
│ │ ├── session-item.tsx # Single row
│ │ │ # owns: hover, context menu open
│ │ └── index.ts
│ │
│ │ -- Transition UX --
│ ├── transition-overlay.tsx # "Switching..." / skeleton during transitions
│ │ # owns: nothing — reads from transition-controller
│ │
│ └── index.ts # Public surface (only what shell needs)
├── workspace/ # Domain: workspace lifecycle
│ ├── workspace-store.ts # Which workspaces exist, connection info
│ ├── workspace-list.tsx # Sidebar workspace groups
│ │ # owns: collapsed state, selection highlight
│ ├── workspace-switcher.tsx # Switching logic + transition state
│ │ # owns: switching/idle/failed for workspace changes
│ ├── workspaces-query.ts # react-query: list + status
│ ├── create-workspace-modal.tsx # Add workspace flow
│ └── index.ts
├── connections/ # Domain: providers, MCP
│ └── index.ts # Placeholder — empty until needed
├── cloud/ # Domain: hosted workers, den
│ └── index.ts # Placeholder — empty until needed
├── app-settings/ # Domain: preferences, themes
│ └── index.ts # Placeholder — empty until needed
└── automations/ # Domain: scheduled jobs
└── index.ts # Placeholder — empty until needed
```
### Component-enclosed state hierarchy
Visual hierarchy = state hierarchy. A human reading the tree knows who owns what:
```
shell/layout.tsx
├── workspace/workspace-list.tsx → owns: selection, collapse
│ └── workspace-switcher.tsx → owns: workspace transition state
└── session/session-view.tsx → reads: session-store (general)
├── session/message-list/ → owns: scroll, virtualization
│ └── message-item.tsx → owns: expand/collapse per message
│ └── part-view.tsx → owns: expand/collapse per part
├── session/composer/ → owns: draft, files, submitting
├── session/session-sidebar/ → owns: search, rename-in-progress
└── session/transition-overlay.tsx → reads: transition-controller (no local state)
```
General session state (`session-store.ts`, `transition-controller.ts`, queries) lives at the `session/` root — shared by components below it. Component-local state (draft text, scroll position, expand/collapse) lives inside the component that renders it. No ambiguity.
---
## Styling Strategy
### What carries over for free
The entire styling foundation is framework-agnostic. React components inherit everything without configuration:
| Asset | Framework-dependent? | Notes |
|-------|---------------------|-------|
| Tailwind classes | No | Just CSS strings. Same classes, same output. |
| CSS custom properties (700+ Radix + DLS tokens) | No | Pure CSS, loaded in `index.css`. |
| Dark mode (`data-theme` + variable swap) | No | Works on any DOM element. |
| `ow-*` CSS classes (buttons, cards, pills) | No | Global CSS, available everywhere. |
| `@keyframes` animations | No | Pure CSS. |
| Font stack | No | System fonts, nothing to load. |
| `DESIGN-LANGUAGE.md` reference | No | Design rules are visual, not framework. |
**React components use `className=` instead of `class=`. That is the only syntax change.**
### What to add for React
One utility in `react/kernel/`:
```ts
// react/kernel/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
The Solid side manages without this (template literals). The React side benefits from `cn()` for conditional classes — it's the standard React/Tailwind convention and prevents class conflicts during composition.
**New dependencies (Phase 0):** `clsx`, `tailwind-merge`.
### Styling rules for React components
1. **Use the same Tailwind classes.** Reference `DESIGN-LANGUAGE.md` for visual decisions.
2. **Use DLS tokens** (`dls-surface`, `dls-border`, `dls-accent`, etc.) via Tailwind config, not raw hex values.
3. **Use Radix color scales** (`bg-gray-3`, `text-blue-11`) for non-semantic colors.
4. **Use `ow-*` classes** where they exist (e.g., `ow-button-primary`, `ow-soft-card`).
5. **Use `cn()`** for conditional classes instead of template literals.
6. **No CSS-in-JS.** No styled-components, no emotion. Tailwind only.
7. **No `dark:` prefix.** Dark mode is handled by CSS variable swap on `[data-theme="dark"]`.
8. **Animation is CSS-only.** Use Tailwind `transition-*` and the existing custom `@keyframes`. No framer-motion.
9. **Match the Solid component's visual output exactly.** When migrating a surface, screenshot both versions and diff. Same spacing, same colors, same radius, same shadows.
### Visual parity verification
Each migrated surface gets a visual comparison test:
1. Screenshot the Solid version (Chrome DevTools).
2. Screenshot the React version (same viewport, same data).
3. Overlay or side-by-side compare. No visible difference = pass.
This is added to the test actions for each phase.
---
## Isolation & Testing Strategy
### Per-phase Docker isolation
Each phase gets tested against two independent Docker dev stacks:
```
Stack A (control): runs the existing SolidJS app
→ packaging/docker/dev-up.sh → server :PORT_A, web :PORT_A_WEB
Stack B (experiment): independent server
→ packaging/docker/dev-up.sh → server :PORT_B, web :PORT_B_WEB
```
Both stacks share the same repo (bind-mounted), but run independent servers with independent tokens and hostnames (verified via `hostname` command through the UI).
### Test actions
Every phase adds entries to `test-actions.md` with:
- Steps to exercise the new React surface
- Expected results
- Comparison against the Solid version on the control stack
- Chrome DevTools verification (using `functions.chrome-devtools_*`)
### Feature flag gate
```
localStorage.setItem('openwork:react-session', 'true')
// or
http://localhost:<WEB_PORT>/session?react=1
```
The app shell checks this flag and renders either:
- `<SessionView />` (Solid, existing)
- `<ReactIsland />` → React session view (new)
---
## Phase Roadmap
### Phase 0: Build Infrastructure
**Goal**: React components can render inside the SolidJS app.
**Deliverables**:
1. Add `@vitejs/plugin-react` to Vite config (alongside `vite-plugin-solid`).
2. File convention: `*.tsx` in `src/react/` = React. Everything else = Solid.
3. `island.tsx` — Solid component that mounts a React root into a DOM node.
4. `boot.tsx` — React root with `QueryClientProvider`.
5. Add `react`, `react-dom`, `@tanstack/react-query` to `apps/app/package.json`.
6. `feature-flag.ts` — reads localStorage / query param.
7. Verify: a trivial React component renders inside the Solid shell.
**Test**:
- Boot Docker stack.
- Navigate to session view.
- Enable feature flag.
- Confirm React island mounts (check React DevTools or a visible test banner).
**Does NOT change any user-visible behavior.**
### Phase 1: React Session View (Read-Only)
**Goal**: A React component can display a session's messages (read-only, no composer).
**Deliverables**:
1. `react/kernel/opencode-client.ts` — plain `fetch()` client for OpenCode proxy.
2. `react/kernel/types.ts` — Session, Message, Part shapes.
3. `react/session/session-store.ts``renderedSessionId`, `intendedSessionId`, `renderSource`.
4. `react/session/sessions-query.ts` — react-query: list sessions.
5. `react/session/session-snapshot-query.ts` — react-query: session + messages.
6. `react/session/session-view.tsx` — composition root.
7. `react/session/message-list/` — virtualized message rendering.
8. Feature-flagged: `?react=1` shows React view, default shows Solid.
**State ownership**: React owns all session read state. It fetches directly from the OpenCode proxy. No Solid signal subscriptions. The island props provide only: `workspaceUrl`, `workspaceToken`, `workspaceId`, `sessionId`.
**Test actions**:
- Create session in Solid view, send a prompt, get a response.
- Switch to React view (`?react=1`) — same session's messages appear.
- Switch sessions — React view transitions without white screen.
- Compare: Solid view on Stack A, React view on Stack B, same prompt, same output.
**Success criteria**:
- No blank pane during session switch.
- Messages render from cache instantly, upgrade to live data.
- `renderSource` visible in dev panel.
### Phase 2: React Composer (Send/Receive)
**Goal**: The React session view can send prompts and display streaming responses.
**Deliverables**:
1. `react/session/composer/composer.tsx` — prompt input, file attachment, run/abort.
2. `react/session/composer/send-prompt.ts` — mutation: send, SSE stream, abort.
3. `react/session/composer/attachment-picker.tsx`.
4. SSE subscription for streaming message parts.
5. `streamdown` for markdown rendering of streaming text.
**State ownership**: Composer owns draft text, file list, submitting state. Send mutation is local to composer. Streaming messages flow into react-query cache via SSE → cache invalidation.
**Test actions**:
- Type a prompt in React composer, click Run.
- Response streams in real-time.
- Abort mid-stream — session stops cleanly.
- Switch workspace mid-stream — no crash.
**Success criteria**:
- Full send/receive/abort cycle works in React view.
- Streaming feels identical to Solid view.
### Phase 3: Transition Controller + Debug Panel
**Goal**: The React path handles workspace and session switching with explicit transition states.
**Deliverables**:
1. `react/session/transition-controller.ts` — state machine:
```
idle → switching → (cache-render) → (live-upgrade) → idle
idle → switching → failed → recovering → idle
```
2. `react/session/transition-overlay.tsx` — skeleton/indicator during transitions.
3. `react/kernel/dev-panel.tsx` — shows `routeState`, `transitionState`, `renderSource`, `runtimeState`.
**Test actions**:
- Connect two Docker dev stacks as workspaces.
- Switch between workspaces rapidly.
- React view never shows white screen.
- Debug panel visible and accurate.
**Success criteria**:
- Zero white screens during any switch sequence.
- Transition states are inspectable via Chrome DevTools.
### Phase 4: Backend Read APIs (parallel track)
**Goal**: Session reads don't require client-side OpenCode proxy orchestration.
**Deliverables** (in `apps/server/src/server.ts`):
1. `GET /workspace/:id/sessions` — list sessions for a workspace.
2. `GET /workspace/:id/sessions/:sessionId` — session detail with messages.
3. `GET /workspace/:id/sessions/:sessionId/snapshot` — full session snapshot.
4. Typed response schemas (zod).
**Test actions**:
- `curl http://localhost:<PORT>/workspace/<id>/sessions` returns session list.
- `curl http://localhost:<PORT>/workspace/<id>/sessions/<sid>/snapshot` returns full snapshot.
- Works for any workspace, not just the "active" one.
- React query layer switches to these endpoints.
**Success criteria**:
- Session reads work without activation.
- Response times < 100ms for cached reads.
### Phase 5: React Session as Default
**Goal**: Flip the feature flag. React session view is the default.
**Deliverables**:
1. Feature flag default flips to `true`.
2. `?solid=1` to opt back into Solid session view.
3. Remove any Solid↔React shims that are no longer needed for session.
**Success criteria**:
- All test actions pass with React as default.
- No regression in any existing flow.
### Phase 6: Migrate Workspace Sidebar
**Goal**: React owns the workspace list and session sidebar.
**Deliverables**:
1. `react/workspace/workspace-list.tsx` — workspace groups in sidebar.
2. `react/session/session-sidebar/` — session list per workspace.
3. `react/workspace/workspace-switcher.tsx` — switching logic.
4. Island props shrink: React now receives `workspaces[]` instead of single workspace info.
**State ownership**: React owns workspace selection, sidebar collapse, session list filtering. Solid still owns settings and platform.
### Phase 7: Migrate Settings & Connections
**Goal**: React owns settings pages and provider/MCP flows.
**Deliverables**:
1. Fill `react/app-settings/` — theme, preferences, config.
2. Fill `react/connections/` — provider auth, MCP.
3. Fill `react/cloud/` — hosted workers, den.
### Phase 8: Island Inversion
**Goal**: React becomes the shell. Solid becomes the guest (if anything remains).
**Deliverables**:
1. `react/shell/layout.tsx` becomes the top-level composition.
2. `react/shell/router.tsx` owns all routing.
3. If any Solid surfaces remain, they render inside a `SolidIsland` React component.
4. Island props are now zero or near-zero.
### Phase 9: Remove Solid
**Goal**: The app is pure React.
**Deliverables**:
1. Remove `vite-plugin-solid` from Vite config.
2. Remove `solid-js`, `@solidjs/router`, `solid-primitives` from `package.json`.
3. Delete `apps/app/src/app/` (the old Solid tree).
4. `apps/app/src/react/` becomes `apps/app/src/app/` (or stays where it is).
5. Remove `island.tsx`, `feature-flag.ts`.
---
## Migration Surface Order
```
Phase 0-3 → Session view (messages, composer, transitions)
Phase 5 → Flip session default to React
Phase 6 → Workspace sidebar + session sidebar
← tipping point: React owns enough to invert the island →
Phase 7 → Settings, connections, cloud
Phase 8 → Shell/layout/routing — island inversion
Phase 9 → Remove Solid entirely
```
## Timeline Guidance
| Phase | Scope | Estimated Effort |
|-------|-------|-----------------|
| 0 | Build infra | ~1 day |
| 1 | Read-only session view | ~1 week |
| 2 | Composer + streaming | ~1 week |
| 3 | Transition controller + debug | ~1 week |
| 4 | Backend read APIs (parallel) | ~1 week |
| 5 | Flip session default | ~1 day |
| 6 | Workspace sidebar | ~1 week |
| 7 | Settings, connections, cloud | ~2-3 weeks |
| 8 | Island inversion | ~1 week |
| 9 | Remove Solid | ~1 day |
Phases 0-3 are fast and highly visible. Phase 4 can run in parallel. Phases 6+ can be paced based on stability.
---
## Files Changed Per Phase
| Phase | Files |
|-------|-------|
| 0 | `apps/app/vite.config.ts`, `apps/app/package.json`, new `src/react/island.tsx`, `src/react/boot.tsx`, `src/react/feature-flag.ts` |
| 1 | New `src/react/kernel/` (3 files), new `src/react/session/` (6-8 files), feature flag check in `app.tsx` |
| 2 | New `src/react/session/composer/` (3 files) |
| 3 | New `src/react/session/transition-controller.ts`, `transition-overlay.tsx`, `src/react/kernel/dev-panel.tsx` |
| 4 | `apps/server/src/server.ts` (add 3-4 endpoints), new `apps/server/src/session-read-model.ts` |
| 5 | `app.tsx` flag flip, cleanup |
| 6 | New `src/react/workspace/` (4-5 files), `src/react/session/session-sidebar/` (2 files) |
| 7 | Fill `src/react/connections/`, `src/react/app-settings/`, `src/react/cloud/` |
| 8 | `src/react/shell/` becomes the root, island inversion |
| 9 | Delete `src/app/`, remove Solid deps |
---
## Verification Approach
Every phase:
1. Boot two Docker dev stacks (`dev-up.sh` x2).
2. Connect Stack B as a workspace from Stack A's UI.
3. Run the phase's test actions via Chrome DevTools (`functions.chrome-devtools_*`).
4. Screenshot evidence saved to repo.
5. Update `test-actions.md` with the new test actions.
6. PR includes screenshots and test action references.
---
## Dependency Direction
Same CUPID rules apply to the React tree:
```
shell → domain public API (index.ts) → domain internals
```
- Domains may depend on `kernel/` primitives.
- Domains never reach into another domain's internals.
- Cross-domain imports go through `index.ts`.
- No bidirectional imports.
- No "super util" files.
---
## Anti-Patterns
- Adding feature logic to `shell/layout.tsx` (shell orchestrates, doesn't absorb).
- Sharing state between Solid and React for the same concern (one owner always).
- Creating `utils/` or `helpers/` buckets instead of colocating with the owning domain.
- Migrating more than one domain per phase.
- Rewriting Solid component behavior during migration (preserve behavior, change placement).
---
## Decision Heuristic
- **Immediate product feel**: start with frontend Phase 0-1 (session view).
- **Highest compounding win**: invest in backend Phase 4 (read APIs) in parallel.
- **When to invert the island**: after workspace sidebar (Phase 6) moves to React — that's when React owns enough of the visual hierarchy to be the shell.
- **When to remove Solid**: only after all domains are migrated and stable. Not before.