* 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.
27 KiB
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 toSessionView. - Session view:
pages/session.tsx(~2,000 lines) — SolidJS, receives all state as props viaSessionViewProps. - State: SolidJS signals +
createStore(). No external state libs. - Router:
@solidjs/router, imperative navigation. - Prepared seam:
@openwork/uialready exports both React and Solid components.SessionViewPropsis 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 /sessionsorGET /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-themeattribute on<html>+ CSS variable swap. NOT Tailwinddark:prefix. - Component styling: Inline Tailwind
class=strings with template literal conditionals. Nocn(),clsx, ortailwind-merge. - Custom CSS classes:
ow-*prefixed classes in globalindex.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 chromesession— task/session experience, composer, messagesworkspace— workspace lifecycle, switching, connectconnections— providers, MCPautomations— scheduled jobscloud— hosted workers, denapp-settings— preferences, themeskernel— 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:
// 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/:
// 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
- Use the same Tailwind classes. Reference
DESIGN-LANGUAGE.mdfor visual decisions. - Use DLS tokens (
dls-surface,dls-border,dls-accent, etc.) via Tailwind config, not raw hex values. - Use Radix color scales (
bg-gray-3,text-blue-11) for non-semantic colors. - Use
ow-*classes where they exist (e.g.,ow-button-primary,ow-soft-card). - Use
cn()for conditional classes instead of template literals. - No CSS-in-JS. No styled-components, no emotion. Tailwind only.
- No
dark:prefix. Dark mode is handled by CSS variable swap on[data-theme="dark"]. - Animation is CSS-only. Use Tailwind
transition-*and the existing custom@keyframes. No framer-motion. - 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:
- Screenshot the Solid version (Chrome DevTools).
- Screenshot the React version (same viewport, same data).
- 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:
- Add
@vitejs/plugin-reactto Vite config (alongsidevite-plugin-solid). - File convention:
*.tsxinsrc/react/= React. Everything else = Solid. island.tsx— Solid component that mounts a React root into a DOM node.boot.tsx— React root withQueryClientProvider.- Add
react,react-dom,@tanstack/react-querytoapps/app/package.json. feature-flag.ts— reads localStorage / query param.- 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:
react/kernel/opencode-client.ts— plainfetch()client for OpenCode proxy.react/kernel/types.ts— Session, Message, Part shapes.react/session/session-store.ts—renderedSessionId,intendedSessionId,renderSource.react/session/sessions-query.ts— react-query: list sessions.react/session/session-snapshot-query.ts— react-query: session + messages.react/session/session-view.tsx— composition root.react/session/message-list/— virtualized message rendering.- Feature-flagged:
?react=1shows 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.
renderSourcevisible in dev panel.
Phase 2: React Composer (Send/Receive)
Goal: The React session view can send prompts and display streaming responses.
Deliverables:
react/session/composer/composer.tsx— prompt input, file attachment, run/abort.react/session/composer/send-prompt.ts— mutation: send, SSE stream, abort.react/session/composer/attachment-picker.tsx.- SSE subscription for streaming message parts.
streamdownfor 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:
react/session/transition-controller.ts— state machine:idle → switching → (cache-render) → (live-upgrade) → idle idle → switching → failed → recovering → idlereact/session/transition-overlay.tsx— skeleton/indicator during transitions.react/kernel/dev-panel.tsx— showsrouteState,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):
GET /workspace/:id/sessions— list sessions for a workspace.GET /workspace/:id/sessions/:sessionId— session detail with messages.GET /workspace/:id/sessions/:sessionId/snapshot— full session snapshot.- Typed response schemas (zod).
Test actions:
curl http://localhost:<PORT>/workspace/<id>/sessionsreturns session list.curl http://localhost:<PORT>/workspace/<id>/sessions/<sid>/snapshotreturns 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:
- Feature flag default flips to
true. ?solid=1to opt back into Solid session view.- 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:
react/workspace/workspace-list.tsx— workspace groups in sidebar.react/session/session-sidebar/— session list per workspace.react/workspace/workspace-switcher.tsx— switching logic.- 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:
- Fill
react/app-settings/— theme, preferences, config. - Fill
react/connections/— provider auth, MCP. - Fill
react/cloud/— hosted workers, den.
Phase 8: Island Inversion
Goal: React becomes the shell. Solid becomes the guest (if anything remains).
Deliverables:
react/shell/layout.tsxbecomes the top-level composition.react/shell/router.tsxowns all routing.- If any Solid surfaces remain, they render inside a
SolidIslandReact component. - Island props are now zero or near-zero.
Phase 9: Remove Solid
Goal: The app is pure React.
Deliverables:
- Remove
vite-plugin-solidfrom Vite config. - Remove
solid-js,@solidjs/router,solid-primitivesfrompackage.json. - Delete
apps/app/src/app/(the old Solid tree). apps/app/src/react/becomesapps/app/src/app/(or stays where it is).- 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:
- Boot two Docker dev stacks (
dev-up.shx2). - Connect Stack B as a workspace from Stack A's UI.
- Run the phase's test actions via Chrome DevTools (
functions.chrome-devtools_*). - Screenshot evidence saved to repo.
- Update
test-actions.mdwith the new test actions. - 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/orhelpers/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.