Files
waggle-os/docs/ux-refactor/appshell-conversion-plan.md
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

291 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AppShell Conversion Plan — Phase 0 Spine Freeze (D1-b)
**Status:** CANONICAL CONTRACT — Phases 1+ implement this document.
**Authority:** Ratification register D1D15 (2026-06-10), `docs/ux-refactor/deltas/open-questions.md` §"UX Refactor v2.1 — Ratification" > Brief v2.1 > `docs/UX_REFACTOR_STATE_AUDIT.md`.
**Decision implemented:** D1 option (b) — convert the windowed dock shell to **AppShell + left nav + single canvas + URL routes**, reusing every shipped screen component unchanged. B1 (2026-06-09, "keep windowed AppId nav, NO react-router") is superseded.
**Code baseline:** `main @ 9dfcc75`. File:line citations are against that commit; an in-flight D8/D9 naming sweep on this branch shifts a few cited lines by ±1 (comment/label-only diff: `Desktop.tsx`, `HomeCockpit.tsx`, `CommandCenter.tsx`, `adapter.ts`, `dock-tiers.ts` — e.g. dock `cockpit` label is already "Mission Control" at `dock-tiers.ts:97`).
D1 conditions this plan operationalizes:
1. Zero screen-component rewrites during conversion (one **derived** exception — §5.2, flagged §9.8: required by condition 4, not separately ratified; goes to the founder for explicit sign-off with this plan, before P1a).
2. URLs are the navigation contract; `waggle:open-app` becomes a shim (§2).
3. No window z-order code ships; `waggle-window-state-v1` migrates then clears (§3).
4. Chat = one widget inside Workspace Desktop, one per workspace (§4).
5. B4 ("alias, don't rename" `/api/*`) survives — this conversion touches **zero** server files (§8).
---
## 1. Canonical route map
Routing stack: `react-router-dom@^6.30.1`, already installed (`apps/web/src/App.tsx:2,20-28` — today exactly 2 routes: `/` and `*`). The conversion replaces the `/``Index``Desktop` chain with a layout route: `/``AppShell` (BootScreen gate + nav + StatusBar + overlays + `<Outlet/>`), index redirect → `/home`.
### 1.1 Frozen route groups (brief) → all 28 AppIds + dispositions
The full `AppId` union (28 ids) is `apps/web/src/lib/dock-tiers.ts:7-18`; the window registry `appConfig` and `renderAppContent` switch are `Desktop.tsx:82-115` and `:339-469`. Every id is dispositioned below — nothing is left implicit.
| Route | AppId(s) | Component (unchanged) | Nav zone | Notes |
|---|---|---|---|---|
| `/home` | `home` | `HomeCockpit` | Work | Launch surface. Replaces the launch-flip (`Desktop.tsx:192-206`): index route redirects `/``/home` unconditionally. `dashboard` (legacy `DashboardApp`, kept only "for back-compat with persisted window state", `Desktop.tsx:360-362`) is **KILLED** — the back-compat reason dies with `waggle-window-state-v1` (§3.3). |
| `/workspaces/:workspaceId/:tab?` | `workspace-desktop`, `chat` | `WorkspaceDesktopApp` (+ embedded `ChatWindowInstance`) | Work ("Chat" entry) | `:tab?` ∈ the 8 PRD §12.2 tabs already defined in `WorkspaceDesktopApp.tsx:53-72` (`overview` default, `chat`, `research`, `artifacts`, `memory`, `tasks`, `timeline`, `settings`). Chat widget = `/workspaces/:id/chat` (§4). `?session=<id>` reserved for session deep links (server already emits `action.route: /workspace/:id/session/:sid` at `routes/command.ts:152` — advisory only, FE parses result ids; B4 untouched). Bare `/workspaces` → redirect `/home` (Home IS the workspace selector per A2/Journey 1 — flagged §9). |
| `/memory/:mindScope?` | `memory` | `MemoryApp` (P1) → standalone `MemoryCenterApp` (P3, D2) | Work | P1 mounts the existing 7-tab `MemoryApp` at `/memory` (props re-hosted, §5.1). `:mindScope?``personal\|workspace` is **reserved now, implemented in P3** with the D2 two-mind split. `?tab=graph\|timeline\|harvest\|weaver\|wiki\|evolution` addresses the legacy tabs (D2: capability stays reachable); `MemoryApp` has no `initialTab` prop today, so `?tab=` wiring is **P3 phase work, not conversion work** (exclusion §5.3). |
| `/artifacts` | `artifacts` | `ArtifactCenterApp` | Work | `?workspace=<id>` optional; wrapper passes `?workspace ?? activeWorkspaceId` (prop today: `Desktop.tsx:435`). |
| `/files` | `files` | `FilesAppTabs` | Work | **Flagged addition** (§9) — Work-zone primary-spine entry (`dock-tiers.ts:55`) the brief's group list omits. `?workspace=<id>` replaces the `filesViewWorkspaceId` local state plumbing (`Desktop.tsx:211,436-451`). |
| `/agents` | `agents` | `AgentsApp` | Intelligence | Internal detail/builder (AgentCenterDetail, AgentBuilder S18) stay component-internal; no `/agents/new` at conversion. |
| `/automations` | `scheduled-jobs` | `AutomationCenterApp` | Intelligence | `?tab=logs&automationId=<id>` replaces the Journey-16 CustomEvent payload (`Desktop.tsx:172-190`, consumer `AutomationCenterApp.tsx:104-114`). |
| `/skills` | `capabilities` | `CapabilitiesApp` | Intelligence | SkillBuilder (S19) stays internal (`CapabilitiesApp.tsx:393`). |
| `/connectors` | `connectors` | `ConnectorsApp` | Extend | Wrapper passes `personaId={activeWorkspace?.persona}` (today `Desktop.tsx:410`). |
| `/mcps` | `mcp-hub` | `MCPHubApp` | Extend | Same `personaId` prop (`Desktop.tsx:411`). |
| `/marketplace` | `marketplace` | `MarketplaceApp` | Extend | **Flagged addition** (§9) — the brief omits an Extend-zone group for it; it is a shipped S21 surface with a dock entry (`dock-tiers.ts:78`). |
| `/launcher` | `launcher` | `LauncherApp` | Extend (label "AI Tools") | **Flagged addition** (§9) — dock parity (`dock-tiers.ts:79`); also hosts S14 Tool Discovery per D6. Wrapper passes `activeWorkspaceId`. |
| `/room` | `room` | `RoomApp` | Intelligence | **Flagged addition** (§9) — dock entry `dock-tiers.ts:65` + Ctrl+Shift+R (`useKeyboardShortcuts.ts:70-74`). Wrapper builds `workspaceNames` map (today `Desktop.tsx:456-461`). |
| `/waggle-dance` | `waggle-dance` | `WaggleDanceApp` | Intelligence | **Flagged addition** (§9) — dock entry `dock-tiers.ts:66`. |
| `/approvals` | `approvals` | `ApprovalsApp` | Intelligence (nav hidden < TEAMS) | D5: tier-hidden, not stripped. Route registered; nav entry carries `minBillingTier:'TEAMS'` exactly as `dock-tiers.ts:68`. PRO visibility = post-launch consideration (D5), untouched here. |
| `/team` | `governance` | `TeamGovernanceApp` | Team (zone hidden < TEAMS) | **Reserved — does not ship in nav below TEAMS.** Identical semantics to today's `filterByBillingTier` (`dock-tiers.ts:84-88,135-149`): route renders the legacy app for TEAMS+; FREE/PRO never see the zone. No new Team work (D5). |
| `/settings` | `settings` | `SettingsApp` | System | `?tab=general\|models\|billing\|permissions\|team\|backup\|enterprise\|advanced` (internal tabs, `SettingsApp.tsx:34-42`) — URL→tab wiring is best-effort at conversion, same exclusion class as `/memory?tab=` (§5.3). |
| `/settings/vault` | `vault` | `VaultApp` | System | System-zone surfaces nest under `/settings/*` — flagged call §9. Renders full-canvas with System nav section active; NOT embedded inside SettingsApp's UI (zero-rewrite). |
| `/settings/profile` | `profile` | `UserProfileApp` | System (no nav entry — parity with today) | `?tab=identity` replaces the HarvestTab deep link (`memory/HarvestTab.tsx:298-299` dispatches `{appId:'profile', tab:'identity'}`); consumer keeps its `waggle:open-app` listener untouched via the shim's post-navigation re-dispatch (§2.3 — the listener is live-only; `UserProfileApp` never calls `consumeDeepLink`). |
| `/settings/mission-control` | `cockpit` | `CockpitApp` | System | D8: label "Mission Control" (already applied, `dock-tiers.ts:97`). "Command Center" is reserved for the Ctrl+K palette. |
| `/settings/timeline` | `timeline` | `TimelineApp` | System | Wrapper passes `workspaceId={activeWorkspaceId}` (today `Desktop.tsx:463`). |
| `/settings/events` | `events` | `EventsApp` | System | Props re-hosted from `useEvents` (§5.1). |
| `/settings/usage` | `telemetry` | `TelemetryApp` | System | Dock label "Usage & Cost" (`dock-tiers.ts:99`). |
| — KILLED | `dashboard` | `DashboardApp` | — | See `/home` row. No route. File removal deferred to dead-code phase work (CLAUDE.md §3.3 — not conversion work). |
| — KILLED | `voice` | `VoiceApp` | — | No dock entry, no live opener anywhere in `apps/web/src` (grep: zero `openApp('voice')` / `appId:'voice'` dispatch hits). No route. |
| — KILLED | `mission-control` | `MissionControlApp` | — | Not in any dock config; only reachable via dead `GlobalSearch.tsx:47` (retained-for-rollback, no live imports per audit §2d). D8 moves the "Mission Control" name to `CockpitApp`. No route. Its spawn affordance survives via the global `SpawnAgentDialog` overlay. |
| — KILLED | `backup` | `BackupApp` | — | Duplicated by SettingsApp's internal `backup` tab (`SettingsApp.tsx:34,42,733-766`; dock comment `dock-tiers.ts:100` "Backup stays in Settings"). Only opener was dead GlobalSearch. `/settings?tab=backup` is the address. |
Count check: 22 routed ids + `chat` (widget, §4) + `workspace-desktop` (= `/workspaces/:id`) + 4 killed = 28. ✓
### 1.2 Overlays — disposition: overlay-stays-overlay, mounted ONCE in AppShell
All overlays currently mounted in `Desktop.tsx:569-663` move verbatim into AppShell (global across every route). None becomes a route.
| Overlay | Trigger | Conversion delta |
|---|---|---|
| `CommandCenter` (Ctrl+K palette) | `useKeyboardShortcuts.ts:92-97` + StatusBar | None to component; `onNavigate` handler retargets to `navigate()` (§2.2). |
| `PersonaSwitcher` | Ctrl+Shift+P | Retargets the **active workspace chat widget** persona instead of "focused chat window" (`Desktop.tsx:583-604` focused-window resolution dies with focus tracking — §4.3). |
| `WorkspaceSwitcher` | Ctrl+Tab | `onSelect``navigate('/workspaces/'+id)` instead of `openChatForWorkspace`. |
| `CreateWorkspaceDialog`, `SpawnAgentDialog`, `NotificationInbox`, `KeyboardShortcutsHelp`, `OnboardingTooltips`, `LoginBriefing`, `UpgradeModal` (D7), `TrialExpiredModal` | unchanged | Mount relocation only. `LoginBriefing.onOpenWorkspace``navigate()`. UpgradeModal/TrialExpiredModal checkout-failure fallbacks (`wm.openApp('settings')`, `Desktop.tsx:651,661`) → `navigate('/settings')` — instance of §2.1 rule 3, noted so the conversion diff is fully accounted. |
| `OnboardingWizard` | `!onboardingState.completed` early-return (`Desktop.tsx:325-336`) | Stays a full-screen takeover at the AppShell layout level (renders INSTEAD of nav+canvas, any URL). Not a route at conversion. `onFinish` retargets per §2.2: seed chat widget (`{personaId, initialMessage}`, §4.2) + `navigate()` to the new workspace's chat tab. |
| `ContextRail` | per-surface `onContextRail` callbacks | This is the brief's **optional right rail** — stays an overlay panel owned by AppShell; surfaces keep their existing `onContextRail` props. |
| `GlobalSearch.tsx` | none (dead, "retained for rollback") | Deleted with the window manager (§3.2) — rollback is git, not dead files. |
### 1.3 Left-nav zone → route entry points
The nav consumes the **same data** the dock consumes today: `POWER_CONFIG` 5 IA zones + `filterByBillingTier` + `getDockForTier` (`dock-tiers.ts:50-153` — survives unchanged as the nav model; `Dock.tsx`/`DockTray.tsx` renderers die). Each entry gains a `route` field next to its `appId` (additive change to `dock-tiers.ts`, the transition's appId→route table — §2.3).
- **Work:** Home→`/home` · Chat→`/workspaces/<active>/chat` (no active workspace → `/home`) · Memory→`/memory` · Files→`/files` · Artifacts→`/artifacts`
- **Intelligence:** Agent Center→`/agents` · Skills Hub→`/skills` · Automation Center→`/automations` · Room→`/room` · Waggle Dance→`/waggle-dance` · Approvals→`/approvals` (TEAMS+)
- **Extend:** Connector Hub→`/connectors` · MCP Hub→`/mcps` · Marketplace→`/marketplace` · AI Tools→`/launcher`
- **Team (hidden < TEAMS):** Team Governance→`/team`
- **System:** Settings→`/settings` · Vault→`/settings/vault` · Mission Control→`/settings/mission-control` · Timeline→`/settings/timeline` · Events & Logs→`/settings/events` · Usage & Cost→`/settings/usage`
The `simple`/`professional` UI-complexity tiers (`TIER_DOCK_CONFIG`, `dock-tiers.ts:105-128`) keep filtering the nav exactly as they filter the dock today.
---
## 2. Navigation contract — URL is the single source of truth
### 2.1 The contract
1. **AppShell layout route** owns: BootScreen gate (FR #23 sequencing moves from `pages/Index.tsx:14-36` into the layout), onboarding takeover, StatusBar, left nav, global overlays, the persistent ChatHost (§4.2), and `<Outlet/>` as the single canvas.
2. **Active nav state derives from the URL** (`useLocation` prefix match against the entry's `route`). No `openAppIds`/`minimizedAppIds` indicator state (`useWindowManager.ts:438-451` dies).
3. **Every navigation is `navigate()`.** No surface raises a window. Browser/Tauri back-forward works by construction.
### 2.2 Existing navigation surfaces — retarget table
| Surface today | Evidence | Becomes |
|---|---|---|
| `handleSearchNavigate` (palette result clicks) | `Desktop.tsx:258-288` — type-prefixed ids: `workspace:``openWorkspaceDesktop`, `session:<ws>:<sid>``openChatForWorkspace`, `memory:``openApp('memory')`, `skill:`/`connector:`/`mcp:`→hub apps, `command:``openApp(bareId)` | Pure function `routeForSearchResult(type,id)``navigate()`: `workspace:``/workspaces/:id` · `session:``/workspaces/:ws/chat?session=:sid` · `memory:``/memory` · `skill:``/skills` · `connector:``/connectors` · `mcp:``/mcps` · `command:``routeFor(appId)` fallback · `person:`→no-op (parity: unhandled today too — the server intentionally doesn't federate `person` yet, `routes/command.ts:22-24`; retarget to `/team` when it federates). This makes `routeForSearchResult` total over `NAVIGABLE_TYPES` (`CommandCenter.tsx:66-68`). `CommandCenter.tsx` itself is untouched (its `onNavigate(type,id)` prop contract holds, `CommandCenter.tsx:170,351-360`). |
| Keyboard Ctrl+Shift+0-9 | `useKeyboardShortcuts.ts:16-27,86-90` | `onOpenApp(id)` handler → `navigate(routeFor(id))`. Map entries pointing at killed ids retarget: `0:dashboard``/home`, `4:cockpit``/settings/mission-control`. Hook unchanged; only the Desktop-supplied callback changes. |
| Dock clicks | `Desktop.tsx:564-567` | Nav entry click → `navigate(entry.route)`; Chat special-case resolves active workspace (§1.3). |
| Launch-flip | `Desktop.tsx:192-206` | Deleted — index redirect `/``/home` subsumes it. |
| `waggle:open-app` CustomEvent | dispatchers: `HomeCockpit.tsx:255`, `MarketplaceApp.tsx:265`, `CapabilitiesApp.tsx:509`, `MissionControlApp.tsx:57`, `memory/HarvestTab.tsx:298`; Desktop listener `Desktop.tsx:178-190` | **Internal shim during transition** (§2.3). |
| `handleOnboardingFinish` (wizard `onFinish`) | `Desktop.tsx:309-313``selectWorkspace` + `wm.openChatForWorkspace(workspaceId, workspaceName, personaId, firstMessage)` + `refreshWorkspaces`. First-run critical path: lands the user in chat with the wizard-chosen persona and the QW-1 starter prompt prefilled (bug #4/#8/P1 fixes, comment block `Desktop.tsx:297-308`) | `selectWorkspace` + seed the workspace's chat widget with `{personaId, initialMessage: firstMessage}` via the ChatHost seed API (§4.2) + `navigate('/workspaces/'+workspaceId+'/chat')` + `refreshWorkspaces`. Verified by acceptance check 8. |
### 2.3 The `waggle:open-app` shim (D1 condition 2)
Dispatching components are screen components — rewriting their dispatch sites would violate D1 condition 1. So:
- AppShell installs ONE listener (replacing `Desktop.tsx:178-190`): `{appId, tab?, automationId?}``navigate(routeFor(appId) + queryString({tab, automationId}))` **and** keeps calling `stashDeepLink` (`lib/app-deeplink.ts:21-24`) so the mount-time consumer (`AutomationCenterApp.tsx:104-114` — the ONLY `consumeDeepLink` caller in the tree) works unchanged. The other consumer, `UserProfileApp.tsx:105-118`, **never reads the stash** — it has only a *live* `waggle:open-app` listener, which today works because the dispatcher (HarvestTab) and an already-mounted profile window can coexist. Under the single canvas they cannot, so after `navigate()` the shim **re-dispatches the same event once, on the tick after the target route has rendered** (detail marked `redispatch: true`; the shim ignores its own re-dispatches). The freshly mounted live listener catches the re-dispatch — zero screen-component edits, D1 condition 1 intact, and acceptance check 3's tab-preselect holds for both consumer styles (stash-on-mount and live-listener).
- `routeFor(appId, ctx?: { activeWorkspaceId?: string })` is the §1.1 table as code, co-located with `dock-tiers.ts`. A **function, not a `Record<AppId, string>`**: `chat` and `workspace-desktop` resolve to parameterized routes (`/workspaces/:id(/chat)`; no active workspace → `/home`, §1.3) that a static record cannot express. All three chat-capable consumers — the nav Chat entry (§1.3), Ctrl+Shift+1 (§2.2), and this shim (theoretical arm: no dispatcher emits `appId:'chat'` today) — resolve through it, so the chat special-case is implemented exactly once.
- **Slated for removal** (tracked exclusion, post-conversion phase work): dispatch sites migrate to `useNavigate`/`Link`, consumers migrate to `useSearchParams`, then the event listener + `app-deeplink.ts` are deleted. URLs are the contract from day one; the event is plumbing.
---
## 3. Window-manager retirement inventory (D1 condition 3)
### 3.1 Code that DIES (no z-order code ships — brief §6.5)
| Unit | Evidence | Verdict |
|---|---|---|
| z-order: `topZRef`, `nextZ()`, `zIndex` on `WindowState` | `useWindowManager.ts:122,190,44` | DELETE |
| Focus: `focusedInstanceId`, `focusWindow`, `cycleWindowFocus` + Ctrl+` listener, dangling-focus reassignment | `useWindowManager.ts:124,390-426,373-384` | DELETE (Ctrl+` shortcut retires) |
| Minimize: `minimizeApp`, `minimizeTopWindow`, `minimized`, `minimizedAppIds`; Ctrl+Shift+M / Ctrl+W handlers | `useWindowManager.ts:386-388,412-414,439-451`; `useKeyboardShortcuts.ts:56-67` | DELETE |
| Cascade: `cascadeCounter`, `cascadeOffset`, `computeCascadePosition` | `useWindowManager.ts:123`; `lib/window-cascade.ts` | DELETE |
| Open/close: `openApp`, `openChatForWorkspace`, `openWorkspaceDesktop`, `closeApp`, `closeTopWindow` | `useWindowManager.ts:192-309,363-365,408-410` | DELETE — replaced by `navigate()` (chat seeding logic relocates, §4) |
| Persistence: `loadPersistedWindows`/`savePersistedWindows` on `waggle-window-state-v1` | `useWindowManager.ts:7,54-84,186-188` | DELETE after one-shot migration (§3.3) |
| `AppWindow.tsx` (drag via framer-motion `useDragControls`, 8-direction resize) | 321 LOC; drag `:70,:232-236`, resize `:18-21,:73-176` | DELETE |
| `Dock.tsx` + `DockTray.tsx` renderers | audit §2a | DELETE — replaced by AppShell nav rendering the same `dock-tiers.ts` data |
| `lib/window-positions.ts` (`getSavedPosition`), `lib/status-bar-focus.ts` (`buildStatusBarFocus`) | `Desktop.tsx:67,536,515-525` | DELETE — StatusBar breadcrumb derives from the matched route's title instead |
| `overlays/GlobalSearch.tsx` (dead) | audit §2d | DELETE |
| Workspace-reconciliation sweep for restored chat windows | `useWindowManager.ts:155-183` | DELETE — chat instances are route/widget-scoped, no restored-window orphans exist (its one legacy duty, re-homing `'local-default'`-stamped chat state, is performed once by §3.3 steps 23) |
Deletion, not retention: D1-3 says no z-order code *ships*. Rollback is the git tag (§6), not dead files on disk.
### 3.2 What SURVIVES (transitional shim)
- **Per-chat persona/autonomy state**: `WindowState.personaId/autonomyLevel/autonomyExpiresAt` + `setWindowPersona`/`setWindowAutonomy` + the 10s autonomy auto-revert interval (`useWindowManager.ts:316-361`) relocate into a new `useChatWidgetState(workspaceId)` hook owned by the ChatHost (§4.2). Logic moves verbatim; the keying changes from `instanceId` to `workspaceId`. `WindowState.initialMessage` (`useWindowManager.ts:30`) relocates as the one-shot payload of the §4.2 chat seed API (it is transient intent, not persisted state — not part of the `waggle-chat-state-v1` key).
- `getWindowTitle`'s chat-title composition (`useWindowManager.ts:428-436`) → chat widget header label.
- **`useDockNudge`** (M-24/ENG-3 session-10/50 zone nudges — `Desktop.tsx:218-223`; `lib/dock-nudge.ts` + tests): relocates verbatim into AppShell. The IA zones it points at survive as nav zones (§1.3), so the behavior stays meaningful; the copy strings that say "dock" (`dock-nudge.ts:25,60-61`) retarget to the left nav — a lib copy edit, not a screen-component edit (D1-1 intact).
- `useWindowManager.ts` itself is deleted once both relocations land; it does NOT survive as a runtime shim.
### 3.3 `waggle-window-state-v1` migration → clear
One-shot, on first AppShell boot, before first render of the canvas:
1. Read + parse the key (same validation as `loadPersistedWindows`, `useWindowManager.ts:54-72`).
2. **Initial-route salvage:** highest-`zIndex` non-minimized window → `routeFor(appId)` (chat → `/workspaces/:workspaceId/chat`; a chat stamped with the `'local-default'` pre-fetch placeholder → `/home`) → seed the initial navigation. No windows / parse failure → `/home`.
3. **Chat-state salvage:** for each `appId==='chat'` entry, write `{personaId, autonomyLevel, autonomyExpiresAt}` keyed by `workspaceId` into the new chat-widget state store (last-write-wins when multiple windows share a workspace — the multi-instance case collapses per §4). Entries keyed `'local-default'` (the placeholder today's reconciliation sweep re-homes onto the first real workspace, `useWindowManager.ts:167-172` — sweep deleted in §3.1) re-key to the first real workspace when the chat-state store first sees the real workspace list (the migration itself runs pre-fetch); one-shot legacy re-key mirroring the deleted sweep — new widget state is never placeholder-stamped, so the re-key dies with the §2.3 shim cleanup.
4. `localStorage.removeItem('waggle-window-state-v1')` — unconditionally, including on parse failure. No dual-format support, ever.
---
## 4. Chat-as-widget design (D1 condition 4)
### 4.1 Current semantics being mapped
- Chat is the ONLY multi-instance app: singleton-per-workspace unless an explicit `personaOverride` spawns a second window on the same workspace (`useWindowManager.ts:223-268`; Ctrl+Shift+N via `handleNewChatWindow`, `Desktop.tsx:233-239`; PersonaSwitcher acting on the focused chat window, `Desktop.tsx:583-604`).
- `ChatWindowInstance` (per-window chat runtime: own `useChat`/`useSessions`, persona + autonomy header controls) receives everything via props (`ChatWindowInstance.tsx:50+`; render site `Desktop.tsx:341-358`).
- `WorkspaceDesktopApp` already has a `chat` tab whose body is a **placeholder that deep-links out** via `onOpenChat` (`WorkspaceDesktopApp.tsx:65,86-90,851`; "the shell never embeds a live composer itself", `:88`).
### 4.2 Target
- **One chat widget per workspace**, living at `/workspaces/:id/chat`: the `chat` tab body renders `ChatWindowInstance` (unchanged component) instead of the placeholder; `onOpenChat` callers inside the component (Overview preview widget `:161-199`, header button `:469`) retarget to the chat tab — which they already do semantically ("deep-links to the Chat tab", `:29`).
- **ChatHost keep-alive:** AppShell hosts a `ChatHost` that mounts one `ChatWindowInstance` per *visited* workspace this session, keyed by `workspaceId`, hidden (not unmounted) when the route is elsewhere. This preserves today's behavior where an open chat window keeps streaming while the user works in other windows — route unmounting would kill in-flight `useChat` SSE streams, which IS a hard requirement (an agent run must survive navigation). This is shell plumbing, not a screen rewrite.
- Per-widget persona/autonomy state: `useChatWidgetState(workspaceId)` (§3.2), persisted under a new versioned key (`waggle-chat-state-v1`), seeded by the §3.3 migration.
- **Chat seed API (imperative, one-shot):** `useChatWidgetState`/ChatHost expose `seedChat(workspaceId, {personaId?, initialMessage?})`; the widget consumes the seed on its first mount for that workspace. This is the post-conversion carrier for what `WindowState.initialMessage`/`personaId` carry today into the `ChatWindowInstance` `initialPersona`/`initialMessage` props (`useWindowManager.ts:30`, `Desktop.tsx:350-351`) — without it, wizard chat seeding dies with `openChatForWorkspace` (§3.1). Sole conversion-time caller: the wizard `onFinish` retarget (§2.2). Reusable by HomeCockpit `onContinue` when `?session=` seeded restore lands (§5.3 #5).
- PersonaSwitcher (Ctrl+Shift+P) targets the active workspace's widget; fallback to patching the workspace record stays (`Desktop.tsx:595-601` logic, re-hosted).
- Ctrl+Shift+N ("new chat window") retires as a window spawner; it navigates to the active workspace's chat tab. New-session affordance already exists inside `ChatWindowInstance` via `useSessions`.
### 4.3 D1-c lite (detached chat) — **NOT NEEDED**
Searched for a hard requirement; none exists in code. The only capability lost is *N concurrent same-workspace chat windows with different personas* (Phase A.2 convenience, `useWindowManager.ts:219-221`). Nothing depends on it: each window runs an independent `useChat` session; no cross-instance coordination, no feature consumes the multiplicity (PersonaSwitcher/Ctrl+Shift+N merely create it). Per-workspace persona switching survives in the widget header (`onPersonaChange` prop, `Desktop.tsx:352`). Cross-WORKSPACE parallelism survives fully: ChatHost keeps every visited workspace's widget alive (§4.2). Verdict: ship without the exception; if dogfood surfaces real demand for side-by-side personas, propose D1-c lite then — do not pre-build it.
---
## 5. Zero-rewrite mounting plan (D1 condition 1)
### 5.1 Mechanism: route wrappers re-host Desktop's prop plumbing
`Desktop.tsx` is today's single integrator: it owns the domain hooks (`useWorkspaces`, `useMemory`, `useEvents`, `useKnowledgeGraph`, `useAgentStatus`, `useNotifications`, `useWaggleDance`, `useOnboarding`, `useOfflineStatus``Desktop.tsx:119-128`) and feeds props in `renderAppContent`. The conversion relocates, never rewrites:
- **AppShell context** hosts the cross-cutting hooks once (workspaces/tier/notifications/onboarding/offline/agent-status — everything StatusBar + nav + overlays need).
- **One thin wrapper per route** (`apps/web/src/routes/*.tsx`, new files) reads route params + shell context, hosts any surface-local hooks, and renders the existing component **with byte-identical props**. `AppErrorBoundary` wraps each route surface exactly as it wraps window content today (`Desktop.tsx:556-558`).
| Screen | Props needed (source today) | Wrapper supplies |
|---|---|---|
| `HomeCockpit` | `onContinue`/`onOpenWorkspaceDesktop`/`onCreateWorkspace` (`Desktop.tsx:363-382`) | `navigate('/workspaces/:id/chat')` / `navigate('/workspaces/:id')` / open CreateWorkspaceDialog overlay |
| `WorkspaceDesktopApp` | `workspaceId`, `workspaceName`, `onOpenChat` (`Desktop.tsx:392-406`) | `useParams().workspaceId`; `onOpenChat`→chat tab |
| `MemoryApp` | 12+ props from `useMemory` + `useKnowledgeGraph` + `onContextRail` (`Desktop.tsx:412-423`) | wrapper hosts both hooks (heaviest re-host; verbatim move) |
| `ArtifactCenterApp` | `activeWorkspaceId`, `workspaceName` (`Desktop.tsx:435`) | `?workspace` ?? shell context |
| `FilesAppTabs` | `workspaceId`, `workspaces`, `onSelectWorkspace`, `onContextRail` (`Desktop.tsx:436-451`) | `?workspace` param replaces `filesViewWorkspaceId` state |
| `AgentsApp` | `workspaces` (`Desktop.tsx:434`) | shell context |
| `AutomationCenterApp`, `CapabilitiesApp`, `MarketplaceApp`, `SettingsApp`, `VaultApp`, `UserProfileApp`, `BackupApp`*, `TelemetryApp`, `CockpitApp`, `WaggleDanceApp`, `ApprovalsApp`, `TeamGovernanceApp` | zero props (`Desktop.tsx:407-409,430-433,452-453,462,464-466`) | mount bare (*BackupApp killed) |
| `ConnectorsApp` / `MCPHubApp` | `personaId` (`Desktop.tsx:410-411`) | shell context |
| `EventsApp` | `useEvents` bundle + `onAbort` (`Desktop.tsx:424-429`) | wrapper hosts `useEvents` |
| `TimelineApp` / `RoomApp` / `LauncherApp` | workspaceId / names map / activeWorkspaceId (`Desktop.tsx:454-463`) | shell context |
| `ChatWindowInstance` | full prop set (`Desktop.tsx:341-358`) | ChatHost + `useChatWidgetState` (§4.2) |
### 5.2 The ONE sanctioned component edit
`WorkspaceDesktopApp` must (a) accept a controlled `activeTab`/`onTabChange` pair (today internal state, `:615`) so `:tab?` is URL-driven, and (b) render the chat widget in the `chat` tab body (today a placeholder, `:851`). This is **derived from D1 condition 4**, not a discretionary rewrite — condition 4's chat-widget-inside-Workspace-Desktop cannot ship without both seams. The register itself never ratified a screen edit (condition 1 reads "screens mount under routes as-is"), so these two seams require **explicit founder sign-off as part of the Phase 0 ratification of this plan, before P1a starts** — scoped to exactly those two seams, test-pinned, everything else in the file untouched.
### 5.3 Exclusions — divergences that are PHASE work, not conversion work
1. `/memory?tab=` + `/settings?tab=` URL→internal-tab wiring (components lack tab props) → P3 (D2 rework) / P7.
2. Memory two-mind `/:mindScope` implementation → P3 (D2).
3. `waggle:open-app` dispatch-site + consumer migration off the shim → post-conversion cleanup (§2.3).
4. Dead-file removal beyond the §3.1 inventory (`DashboardApp`, `VoiceApp`, `MissionControlApp`, `BackupApp` files) → dead-code commit per CLAUDE.md §4.
5. `?session=` chat session-targeted restore (chat runtime doesn't accept a sessionId seed yet — `Desktop.tsx:366-373` comment) → carries as the existing known gap, URL shape reserved now.
6. D3 auth-gate, D2, D4, D11/D12 — own phases per the ratified sequence.
---
## 6. Transition sequencing
| Step | Content | Class |
|---|---|---|
| **P0 (this doc)** | Route map + contract frozen. No code. | — |
| **P1a — conversion** | Tag `checkpoint/pre-appshell-2026-06`. Then one PR: AppShell layout + nav (from `dock-tiers.ts` data) + route wrappers + ChatHost + §3.3 migration + retarget table (§2.2) + §3.1 deletions + §5.2 edit. FE-only; `packages/server` diff = **empty** (B4). | Mechanical: routes/wrappers/nav/retargets/deletions. **Risky:** ChatHost keep-alive + chat-state migration; `WorkspaceDesktopApp` tab control; MemoryApp hook re-host. Risk is isolated in NEW files + the one sanctioned edit. |
| **P1b — D3** | Adapter-level auth gate lands ON the converted shell (gate once, on one spine). | per ratification |
| **P2+** | Verify+J08, D2 (P3), D11/D12 (P4), D4 (P5), D15 (P7) — per the ratified phase sequence. | — |
| **Rollback** | Conversion is one revertable PR: screen components untouched (±§5.2) ⇒ `git revert` of the conversion merge restores the windowed shell byte-for-byte. The §3.3 migration deletes `waggle-window-state-v1`, so a rollback boots to the empty-desktop launch-flip (`home` opens) — accepted, not data loss. | — |
Note (flagged §9): the ratification's phase list reads "P1=D3" and left the conversion *build* implicit ("Phase 0 = D1 conversion plan"). This plan assigns the build to P1a, ahead of D3, so the structural auth gate is built once against the final spine instead of being re-plumbed after.
---
## 7. Launch-cut screen inventory lock (audit §3, frozen)
20 in-scope blueprint screens — verdicts restated from `docs/UX_REFACTOR_STATE_AUDIT.md` §3; this table is the Phase 0 freeze. No screen is rebuilt by the conversion.
| S# | Screen | Verdict | Component | Route (per §1) |
|---|---|---|---|---|
| 1 | Home Cockpit | exists | `HomeCockpit.tsx` | `/home` |
| 2 | Workspace Desktop | exists | `WorkspaceDesktopApp.tsx` | `/workspaces/:id` |
| 3 | Command Center (Ctrl+K) | exists | `overlays/CommandCenter.tsx` | overlay (global) |
| 4 | Memory Center | exists (tab; D2 promotes P3) | `MemoryApp.tsx` + `memory/MemoryCenterTab.tsx` | `/memory` |
| 5 | Artifact Center | exists | `ArtifactCenterApp.tsx` | `/artifacts` |
| 6 | Skills Hub | exists | `CapabilitiesApp.tsx` | `/skills` |
| 7 | Connector Hub | exists | `ConnectorsApp.tsx` | `/connectors` |
| 8 | MCP Hub | exists | `MCPHubApp.tsx` | `/mcps` |
| 9 | Agent Center | exists | `AgentsApp.tsx` | `/agents` |
| 10 | Team Workspace | DEFERRED (legacy app tier-hidden, D5) | `TeamGovernanceApp.tsx` | `/team` (reserved) |
| 11 | Automation Center | exists | `AutomationCenterApp.tsx` | `/automations` |
| 12 | First Launch | exists | `onboarding/WelcomeStep.tsx` | OnboardingWizard takeover |
| 13 | Who Are You | exists | `onboarding/WhoAreYouStep.tsx` | (wizard) |
| 14 | Tool Discovery | partial — relocated to Launcher (D6 ratified) | `LauncherApp.tsx` | `/launcher` |
| 15 | Memory Import | exists | `onboarding/ImportStep.tsx` | (wizard) |
| 16 | Memory Review | partial — relocated to Memory Center "Needs review" (D6) | `memory/MemoryCenterTab.tsx` | `/memory` |
| 17 | Create Workspace | exists | `onboarding/WorkspaceCreateStep.tsx` + `CreateWorkspaceDialog.tsx` | (wizard) + overlay |
| 18 | Agent Builder | exists | `agents/AgentBuilder.tsx` | inside `/agents` |
| 19 | Skill Builder | exists | `skills/SkillBuilder.tsx` | inside `/skills` |
| 20 | Automation Builder | exists | `automations/AutomationBuilder.tsx` | inside `/automations` |
| 21 | Marketplace / Extend | exists | `MarketplaceApp.tsx` | `/marketplace` (flagged) |
---
## 8. Acceptance checks (conversion done = ALL pass)
1. **No z-order code ships (brief §6.5):** `grep -rn "zIndex\|nextZ\|cascadeOffset\|minimizeApp\|focusWindow\|buildStatusBarFocus" apps/web/src` → zero hits outside CSS/z-index utility classes; `useWindowManager.ts`, `AppWindow.tsx`, `window-cascade.ts`, `window-positions.ts`, `status-bar-focus.ts` (+ its `.test.ts`), `Dock.tsx`, `DockTray.tsx`, `GlobalSearch.tsx` do not exist.
2. **Every §1.1 routed screen reachable by typing its URL** into a fresh tab (post-boot, post-onboarding) — including param forms `/workspaces/:id/chat` and `/automations?tab=logs&automationId=x`. Unknown URLs → existing `NotFound`.
3. **Deep links are URLs:** dispatching `waggle:open-app {appId:'scheduled-jobs', tab:'logs', automationId}` changes `location` to `/automations?tab=logs&automationId=…` AND the Logs tab preselects (stash consumed on mount, §2.3); HarvestTab's "Open Profile" lands on `/settings/profile?tab=identity` **with the Identity tab preselected** (the shim's post-render re-dispatch reaches `UserProfileApp`'s live listener, §2.3).
4. **Ctrl+K works on every route** (palette mounts in AppShell); a `workspace:` result click changes the URL to `/workspaces/:id`; browser Back returns to the prior route.
5. **B4 untouched:** `git diff main --stat -- packages/` for the conversion PR is **empty**. All `/api/*` aliases (`/api/automations`, `/api/command/*`, …) keep answering — FE adapter calls unchanged.
6. **Migration clean (D1-3):** boot with a populated `waggle-window-state-v1` → lands on the salvaged route, chat persona/autonomy preserved per workspace, key **absent** from localStorage afterward; boot with a corrupt value → `/home`, key absent, no crash.
7. **Chat-as-widget (D1-4):** one chat per workspace at `/workspaces/:id/chat`; an in-flight agent stream survives navigating to `/memory` and back (ChatHost keep-alive); persona change in the widget header does not mutate the workspace record.
8. **First-run chat seeding (wizard onFinish):** completing the onboarding wizard lands on `/workspaces/<newId>/chat` with the wizard-chosen persona active in the widget header and the QW-1 starter prompt prefilled in the composer — behavioral parity with `handleOnboardingFinish` (`Desktop.tsx:309-313`) via the §4.2 seed API.
9. **Zero-rewrite (D1-1):** `git diff main --stat -- apps/web/src/components/os/apps apps/web/src/components/os/overlays` shows changes ONLY in `WorkspaceDesktopApp.tsx` (§5.2); all other screen/overlay files byte-identical.
10. **Gates:** FE vitest suite green (`--root apps/web`); `tsc -p apps/web/tsconfig.app.json` 0 errors; lint 0.
---
## 9. Deviations & flagged calls
Calls made here (not deferred). Each is reversible at route level without touching screens.
1. **`/marketplace` added** — the brief's 11 route groups omit the shipped S21 Extend surface (`dock-tiers.ts:78`). A dock-reachable screen with no URL would fail acceptance check 2. Top-level route, Extend zone.
2. **`/files` added** — Work-zone primary-spine entry (`dock-tiers.ts:55`) omitted by the brief. Same reasoning.
3. **`/launcher` (AI Tools) and `/room`, `/waggle-dance` added** — Extend/Intelligence dock entries omitted by the brief; `/launcher` additionally hosts the D6-ratified S14 relocation, and Room has a shipped keyboard shortcut. Killing them would strip ratified/shipped capability; Phase 0 freezes them as routes.
4. **System-zone surfaces nest under `/settings/*`** (vault, profile, mission-control, timeline, events, usage) instead of new top-level groups — keeps the brief's 11-group spine intact while giving every System surface a URL. They render full-canvas; SettingsApp is not modified.
5. **Four AppIds killed** (no route): `dashboard` (back-compat shim obsoleted by §3.3), `voice` + `mission-control`/`MissionControlApp` (no live opener anywhere — dead surfaces; D8 reassigns the "Mission Control" name to CockpitApp), `backup`/`BackupApp` (duplicated by `SettingsApp` `?tab=backup`). Files deleted in a later dead-code commit, not the conversion PR.
6. **`/approvals` ships tier-hidden (TEAMS), `/team` reserved + tier-hidden** — exact D5 parity with today's `filterByBillingTier`; "reserve /team, do NOT ship" is implemented as no-nav-below-TEAMS + no new Team code.
7. **Bare `/workspaces` redirects to `/home`** — no workspace-list screen exists; Home is the selector (A2). Building a list screen would be net-new scope.
8. **`WorkspaceDesktopApp` is the single sanctioned component edit** (controlled tab + chat-tab embed) — derived from D1 condition 4 itself, NOT separately ratified (the register's condition 1 says "as-is"); the two seams go to the founder for explicit sign-off with this plan, before P1a. Held to two seams and pinned by tests (§5.2).
9. **Conversion build assigned to P1a, before D3** — the ratification's phase list says "P1=D3" but leaves the D1 build unassigned; building the shell first means the structural auth gate is wired once, on the final spine.
10. **D1-c lite NOT invoked** — no hard detached-chat requirement found in code (§4.3); concurrent same-workspace multi-persona windows are consciously dropped. Re-proposable post-dogfood with evidence.
11. **Window-manager files deleted, not retained-for-rollback** — D1-3's "no z-order code ships" is read literally; rollback = `checkpoint/pre-appshell-2026-06` tag + single PR revert. The dead `GlobalSearch.tsx` goes with them.
12. **ChatHost keep-alive mounting** (one live `ChatWindowInstance` per visited workspace, hidden off-route) — not in the brief, but required so route navigation cannot kill in-flight agent SSE streams; this is the conversion's only behavioral guarantee carried over from windowing. Non-chat surfaces accept standard route remount semantics (server-side state, refetch on entry).
---
*Authored 2026-06-10 · Phase 0, UX Refactor v2.1 Launch Cut · implements ratified D1(b)*