Status: implementation-ready spec
Scope: apps/web (browser + Electron), including the responsive sidebar sheet
Baseline verified: d3154f092 on 2026-07-31
Ship as: docs/specs/unified-workspace-sidebar-v2-port.md (step 0 of execution)
Marcode's unified workspace tree is mounted only inside apps/web/src/components/Sidebar.tsx
(sidebar v1). Two facts make that a problem right now:
- Upstream is deleting v1.
upstream/agent/sidebar-v2-only(c5610286c) removesSidebar.tsxentirely — 3,642 lines — and makesSidebarV2.tsxthe only sidebar. Marcode has +690 / −124 lines of its own inside that file (~290 of them the seam component itself,SidebarProjectWorkspaceTree, Sidebar.tsx:1083–1374). When that lands, the sync produces a delete/modify conflict over 690 lines of our code. - The tree is already invisible in dev.
resolveSidebarV2Default(branding.logic.ts:22) returns true for stage"Dev"and"Nightly";APP_STAGE_LABELis"Dev"underimport.meta.env.DEV. SinceSidebarV2.tsxcontains zero unified-workspace references, any local dev run without an explicit Beta opt-out already shows v2 — with no tree.
Outcome: the tree becomes a first-class part of v2, ~90% of the feature stays in Marcode-owned paths, and the code left inside upstream's file shrinks from ~690 lines to ~10.
| # | Decision | Consequence |
|---|---|---|
| D1 | Project scope swaps the list. All projects keeps v2's flat inbox; selecting a project renders that project's tree instead. |
Reuses projectScopeKey/scopedProjectGroup (SidebarV2.tsx:1373–1396). No new nav concept. |
| D2 | Settled/snoozed fold into the tree. No shelves in tree mode. | Preserves "every unarchived thread renders exactly once." Requires settle/snooze parity in the row menu (§6.3) or scoping to a project silently removes a core v2 capability. |
| D3 | Flip Marcode's default to v2. | resolveSidebarV2Default returns true for every stage. Alpha/production builds get v2 + tree instead of v1. |
- Marcode-owned, zero conflict surface:
apps/web/src/unifiedWorkspace/**+apps/web/src/components/unified-workspace/**— 6,532 lines. Upstream never touches these paths. - Inside upstream files:
Sidebar.tsx+690/−124,Sidebar.logic.ts+44/−1,AppSidebarLayout.tsx+38/−68. SidebarV2.tsx— 0 diff. pristine upstream..github/upstream-sync.ymlhotspots coverSidebar.tsx,Sidebar.logic*,AppSidebarLayout.tsx. They do not coverSidebarV2.tsxorbranding.logic*— both are touched by this work.
Everything the seam needs already exists in SidebarV2(): useThreadActions (:1183),
confirmThreadDelete (:1179), copyPathToClipboard (:1194), markThreadUnread (:1246),
openPrLink (:451), useSidebar() → isMobile (:1176), routeThreadKey (:1258).
Verified: all twelve props v1 threads into the seam are reconstructible inside a Marcode-owned
component — five are plain hooks, attemptArchiveThread is a 15-line useCallback over
useThreadActions().archiveThread (Sidebar.tsx:2367), and the thread lookup derives from the
threads list. Thread creation with placement needs no new wiring: useUnifiedWorkspaceProject
already owns it via useHandleNewThread.
New Marcode-owned component owns everything: the tree, the grouped-member picker, the Add-item
menu, and its own hook calls. What lands in SidebarV2.tsx is one import plus one conditional:
{!isSearchingThreads && workspaceTreeProject ? (
<UnifiedWorkspaceSidebarSection
projectGroup={workspaceTreeProject}
activeRouteThreadKey={routeThreadKey}
/>
) : (
/* existing flat list, unchanged */
)}workspaceTreeProject comes from a pure helper in our own logic file, not from inline logic in
upstream's component.
Move SidebarProjectWorkspaceTree (Sidebar.tsx:1083–1374) out of the upstream file.
- NEW
apps/web/src/components/unified-workspace/UnifiedWorkspaceSidebarSection.tsx- the moved component, renamed, plus the grouped-member picker block currently at Sidebar.tsx:2749–2814.
- self-serves via hooks instead of props:
useThreadActions,useCopyToClipboard(×2),useUiStateStore,useOpenPrLink,useClientSettings,useSidebar. Keep theattemptArchiveThreadwrapper verbatim — do not write a second archive flow. - remaining props:
projectGroup: SidebarProjectSnapshot,activeRouteThreadKey: string | null, and optionaladdMenuSlotElement. When the slot is null the section renders its own inline header row with the Add-item button; when present it portals as today. One prop, both hosts, no duplicated menu. - keeps the headless
ProjectScriptsControlmount for "Add command" — do not re-implement it.
- NEW
apps/web/src/components/unified-workspace/UnifiedWorkspaceSidebarSection.logic.tsresolveWorkspaceTreeMember(...)— lifted from Sidebar.tsx:1558–1578 (auto-select the member owning the active thread, fall back to first). Both sidebars call it.resolveV2WorkspaceTreeProject({ featureEnabled, scopedProjectGroup, isSearching }).shouldRenderUnifiedWorkspaceTreestays inSidebar.logic.ts(v1's expansion/pinning gating has no v2 analogue); the v2 helper is separate, not an overload.
- EDIT
apps/web/src/components/Sidebar.tsx— delete the moved code, import from the new path, keep both existing mount points and the portal slot. Net ≈ −290 lines from the upstream file.
Gate: v1 behaves identically. Existing unified-workspace tests pass untouched.
- EDIT
apps/web/src/components/SidebarV2.tsx(~10 lines) — import + the conditional in §4, inside theSidebarGroupat :2763, replacing the!isSearchingThreadslist branch. - Suppress v2's scoped empty state (:3001–3022) in tree mode — the tree owns its own empty state; both rendering produces a doubled message.
- Search wins: while
isSearchingThreads, v2's flat search results render even under a project scope. The tree is the not-searching view. - Grouped projects: the section renders the member picker when
projectGroup.memberProjects.length > 1, exactly as v1 does.
Folding the shelves means a scoped project loses settle/snooze unless the row menu carries them.
- Extend
UnifiedWorkspaceTreeThreadActionwithsettle | unsettle | snooze | unsnooze. - Handle them in the section using v2's existing flows (
attemptSettle/attemptUnsettle/attemptSnooze/attemptUnsnooze, SidebarV2.tsx:2900–2903) — extract to a shared hook rather than copying the bodies. - Gate each item on the environment capability flags v2 already reads:
capabilities.threadSettlement/capabilities.threadSnooze. - Context-menu items only. No new row chrome, no settled variant styling.
- EDIT
apps/web/src/branding.logic.ts—resolveSidebarV2Defaultreturnstrueunconditionally (drop the stage check). Updateapps/web/src/branding.test.tsto assert the Marcode default and keep theconfiguredByUseropt-out path covered — an explicit v1 choice in Settings → Beta must still win. - EDIT
.github/upstream-sync.yml— add hotspots:apps/web/src/components/SidebarV2.tsx, ownerweb-navigation, reason "Marcode mounts the unified workspace tree in the scoped-project view"apps/web/src/branding.logic*, ownerweb-navigation, reason "Marcode defaults sidebar v2 on for every stage"
- EDIT
docs/user/unified-workspace-sidebar.md— the tree now appears by selecting a project in the sidebar header, not by expanding a project row. - EDIT
.claude/skills/unified-workspace-sidebar/SKILL.md— update the "Where it lives" integration-seam row and the enable-the-flag section (v2 is now the default host).
6.1 Entry/exit. Tree mode is on when the feature flag is on, a project scope is selected, and
search is empty. Changing scope back to All projects restores the flat inbox with its shelves.
Scope selection already persists in component state; this spec adds no new persistence.
6.2 Contents. Every unarchived, undeleted thread of the scoped project renders exactly once, at its layout placement, alongside attached files/folders, live terminals, live browser tabs, URL shortcuts, and commands. Settled and snoozed threads render as ordinary rows (D2). Archived and deleted threads do not render — unchanged from today.
6.3 Actions. Row context menu carries the five existing actions plus the four from Phase 3.
New child thread, drag-and-drop, Move to…, Remove from sidebar, and Pin shortcut behave
exactly as in v1 — same controller, same server commands.
6.4 Header. The project filter, search box, New thread, and New project buttons are unchanged.
The Add-item (+) button renders in the section's own header row inside the content area.
6.5 Not in scope. Thread-row extras missing in v1's tree (PR badge, worktree badge, jump hint, relative time) stay missing — porting is not the moment to close that gap. Named, not silently dropped.
From the spec's §16 and the skill's invariant list — a change that breaks any of these is wrong:
- every unarchived thread renders exactly once; an unplaced thread becomes a synthetic root entry;
- the layout stores placement, never runtime objects (terminals/browser tabs are synthesized);
- "Remove from sidebar" deletes nothing real;
- attaching a path never touches disk; a vanished path renders as a broken reference;
- no cycles, no cross-project moves — server is authoritative;
expectedVersionconflicts never silently overwrite;- old data decodes cleanly (
workspaceLayoutVersion/workspaceLayoutdefaults); - grouped projects retain one physical tree per member.
New/updated:
apps/web/src/components/unified-workspace/UnifiedWorkspaceSidebarSection.logic.test.ts— member resolution (active-thread member wins, fallback, member disappears), andresolveV2WorkspaceTreeProjectgating across flag off / no scope / searching.apps/web/src/branding.test.ts— default true for every stage; explicit user opt-out still wins.apps/web/src/components/Sidebar.logic.test.ts— unchanged expectations after the extraction (regression proof that Phase 1 changed nothing).
Run (per root AGENTS.md, focused only):
npx vp test run \
apps/web/src/components/unified-workspace/UnifiedWorkspaceSidebarSection.logic.test.ts \
apps/web/src/components/unified-workspace/UnifiedWorkspaceTree.logic.test.ts \
apps/web/src/components/Sidebar.logic.test.ts \
apps/web/src/branding.test.ts \
apps/web/src/unifiedWorkspace/buildTree.test.ts \
apps/web/src/unifiedWorkspace/treeOperations.test.ts \
apps/web/src/unifiedWorkspace/activateNode.test.ts
npx vp test run \
scripts/upstream-sync.test.ts \
scripts/lib/upstream-sync-config.test.tsTargeted typecheck for apps/web only — not repo-wide.
Use $test-t3-app (one isolated environment, pairing URL). Do not verify on a shared session.
- Fresh profile → v2 renders by default with no Beta toggle touched (proves Phase 4).
All projects→ flat inbox with active cards, Snoozed and Settled shelves intact.- Select a project → tree renders; verify a thread, an attached file, a live terminal, and a live browser tab all appear under their placements.
- Settle a thread from the tree row menu → it stays in place, does not vanish (proves D2 + Phase 3).
- Type in search while scoped → flat search results; clear it → tree returns.
- Group two checkouts → member picker appears, the member owning the active thread auto-selects, switching members swaps trees with no cross-project move offered.
- Drag a thread under a folder, then
Move to…→ both persist across reload. - Attempt a cycle → rejected with an explanation.
- Toggle v1 in Settings → Beta → tree still renders in v1 (proves Phase 1 preserved it).
- 390px, 820px, desktop; both themes. Watch for new overflow in the section header row.
- Phase 3 touches the thread-action union, consumed by
UnifiedWorkspaceRowand the tree's keyboard handling. Extend, never reshape — a rename ripples through the row and its tests. - The default flip is user-visible. Anyone on an Alpha build wakes up in a different sidebar.
The
configuredByUseropt-out must keep working, or a user who deliberately chose v1 gets overridden. branding.logic.tsis upstream-clean today. Touching it creates a new conflict boundary; the hotspot entry is what makes that intentional rather than accidental. It resolves to "take upstream's deletion" once the v2-only branch lands and the flag disappears.- Not verified in this pass: native mobile (
apps/mobile) decode path. Out of scope, unchanged by this work, but stated rather than assumed.
After this, when upstream lands sidebar-v2-only and deletes Sidebar.tsx, Marcode takes the
deletion clean — the feature is already out of the blast radius, and the remaining conflict is
~10 lines in SidebarV2.tsx plus a flag that upstream is removing anyway.