Repository navigation
Conversation
The right panel showed one surface at a time and the terminal lived in a bottom drawer, so seeing a file, a diff and a terminal together was not possible. Panels now live in a tree of splits and tab groups around the chat. Drag a tab onto a strip to tab it, onto an edge to split, onto the outer side of a stack to sit beside the whole stack, or onto the window edge to run its full length. - The layout is global and tabs stay per thread. A layout per thread would move panels on every thread switch, so a surface only records the group it was left in, and an empty group drops out of the render. - Surfaces render into a host element that moves between group slots. Keying React on the slot would remount terminals and unsaved editors on every drag. - The terminal is an ordinary surface and the drawer is gone. terminal.toggle hides its group without ending the session. - Files shows only the tree. A file opens as its own tab, split beside the tree the first time so the tree stays in view. - A View menu replaces the terminal and right panel toggles, because a control drawn as a fixed position is wrong once panels can be anywhere. Each panel has its own maximize. - The threads sidebar stays pinned, with a Left/Right setting, and the chat cannot be tabbed. Moving them cost a handle and a row of chrome for little gain. - Below 980px panels stay sheets, because splits do not fit.
A maximized panel covers the dock, but the title bar corners were still worked out from the layout underneath, so its Restore button slid under View. The view model now gives a maximized group those corners, which fixes the right inset, the left inset and the drag region together.
The chat's layers were not contained, so the composer tied a maximized panel's z-index and won whenever the panel came first in the page, and higher layers won everywhere. Covered groups now get their own stacking context, only while maximized: the floating mini-player needs to rise above panels the rest of the time. Covered surfaces are also told they are hidden, so a browser view cannot draw over the panel.
With Threads on the right, the collapse toggle stayed in the left corner and kept pointing left. It now sits at the right window edge in the Threads header and points right, and the View menu's Threads icon matches. The top-right controls stay in the dock instead of floating over the sidebar, where they were unreadable on the stage banner. That is why the dockView tests now expect the dock to keep its right corner with Threads on the right. The controls only move in to clear the toggle while Threads is collapsed. On the right the toggle drops below the narrow-layout panel sheet, which would otherwise leave it on top of the sheet's View button.
…s schema The desktop app decodes client settings with its own bundled contracts and drops fields it doesn't know. A web client patched onto a nightly would lose threadSidebarSide on every restart. The side now lives in the dock layout store, in localStorage, beside the layout it belongs to. That also leaves the feature with no contract changes, so the whole patch stays inside apps/web. Reset panel layout keeps the side on purpose: it is a preference, not an arrangement.
install_on_nightly_arch.sh builds the web client from the source of the installed nightly with only the feature's diff on top, and swaps it into app.asar. It applies a diff rather than merging, because a merge would also pull in any upstream commits newer than the nightly and skew the client from the server. Only the client is replaced: rebuilding the desktop side would lose configuration the release bakes in. The client's public Connect settings are read back out of the installed build for the same reason. The README now only covers this feature.
- Delete docs/user/panels.md and its link in docs/README.md - Drop the Dock section from docs/internals/web-ui.md - Update the Motion section in appearance.md to name the main sidebar, right panel, and terminal drawer
|
not ready yet. |
| const toRect = ({ left, top, width, height }: DOMRect): DockRect => ({ left, top, width, height }); | ||
|
|
||
| function readDockGeometry(root: Element): readonly DockGroupGeometry[] { | ||
| return Array.from(root.querySelectorAll<HTMLElement>("[data-dock-group]")).map((group) => { |
There was a problem hiding this comment.
🟡 Medium dock/dockDrag.ts:18
Dragging over a maximized panel can resolve the drop against an invisible underlying group, moving or splitting tabs into the wrong group. readDockGeometry includes groups marked inert, so resolveDockDrop can select their earlier-in-tree rectangles instead of the visible maximized panel; exclude covered groups (and their stacks) from the collected geometry.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/dock/dockDrag.ts around line 18:
Dragging over a maximized panel can resolve the drop against an invisible underlying group, moving or splitting tabs into the wrong group. `readDockGeometry` includes groups marked `inert`, so `resolveDockDrop` can select their earlier-in-tree rectangles instead of the visible maximized panel; exclude covered groups (and their stacks) from the collected geometry.
| useRightPanelStore | ||
| .getState() | ||
| .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); | ||
| const surface = readTerminalSurface(); |
There was a problem hiding this comment.
🟠 High chat/useChatTerminalActions.ts:295
Keyboard-activating a terminal in a non-active dock group leaves the previous group active, so subsequent split and close shortcuts target the wrong terminal. readTerminalSurface() selects a global surface rather than the one containing terminalId, and activateTerminal therefore does not switch groups. Resolve the owning surface from terminalId and bind split actions to their originating surface.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/useChatTerminalActions.ts around line 295:
Keyboard-activating a terminal in a non-active dock group leaves the previous group active, so subsequent split and close shortcuts target the wrong terminal. `readTerminalSurface()` selects a global surface rather than the one containing `terminalId`, and `activateTerminal` therefore does not switch groups. Resolve the owning surface from `terminalId` and bind split actions to their originating surface.
| "--dock-titlebar-end-inset": edges.right === id ? TITLEBAR_CONTROLS_INSET : "0.75rem", | ||
| } as CSSProperties | ||
| } | ||
| onPointerDownCapture={() => { |
There was a problem hiding this comment.
🟡 Medium dock/DockWorkspace.tsx:82
Clicking a portal-rendered surface body does not activate its dock group, so activeSurfaceId and focusedGroupId remain on the previous group and the new-surface shortcut targets the wrong group. Because portal events propagate through the React tree, this section's onPointerDownCapture does not receive clicks from content rendered under ChatView; handle activation in the portal's React ancestry or add a native capture listener to the frame.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/dock/DockWorkspace.tsx around line 82:
Clicking a portal-rendered surface body does not activate its dock group, so `activeSurfaceId` and `focusedGroupId` remain on the previous group and the new-surface shortcut targets the wrong group. Because portal events propagate through the React tree, this section's `onPointerDownCapture` does not receive clicks from content rendered under `ChatView`; handle activation in the portal's React ancestry or add a native capture listener to the frame.
| kind: "split", | ||
| id: `split-${newGroupId}`, | ||
| direction, | ||
| children: placed(target), |
There was a problem hiding this comment.
🟡 Medium dock/dockLayout.ts:80
A bottom drop onto a non-chat group requests 400px for the new pane, but this gives 400px to the last child, whose size dockElasticChild ignores; the existing pane falls back to its 520px default, leaving only 280px for the new pane in an 800px split. Put the requested size on target for trailing edges so the elastic new child gets the remaining space.
| children: placed(target), | |
| children: placed(isLeadingEdge(edge) ? target : { ...target, size }), |
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/dock/dockLayout.ts around line 80:
A bottom drop onto a non-chat group requests 400px for the new pane, but this gives 400px to the last child, whose size `dockElasticChild` ignores; the existing pane falls back to its 520px default, leaving only 280px for the new pane in an 800px split. Put the requested size on `target` for trailing edges so the elastic new child gets the remaining space.
| action: (...args: Args) => void, | ||
| ): (...args: Args) => void { | ||
| return (...args) => { | ||
| useDockLayoutStore.getState().setPendingSurfaceGroup(groupId); |
There was a problem hiding this comment.
🟡 Medium chat/ChatDockSurfaces.tsx:66
Selecting a surface that already exists leaves pendingSurfaceGroupId set, so the next unrelated new surface can be routed into this group—even from another thread. inGroup sets the global destination, but the existing-surface path in open(..., "files") does not consume it; clear or consume the pending destination when that open reuses an existing surface.
Also found in 1 other location(s)
apps/web/src/rightPanelStore.ts:338
The pending add-menu group is consumed only when a surface is newly inserted. Selecting Files from a group's add menu when Files already exists calls
open(..., "files"), takes the existing-surface branch here, and leavespendingSurfaceGroupIdset. The next unrelated new surface (including anopenFilein another thread) consumes that stale group and opens in the wrong pane. Consume/clear the pending request for existing-surface opens too, so one menu action cannot redirect a later action.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ChatDockSurfaces.tsx around line 66:
Selecting a surface that already exists leaves `pendingSurfaceGroupId` set, so the next unrelated new surface can be routed into this group—even from another thread. `inGroup` sets the global destination, but the existing-surface path in `open(..., "files")` does not consume it; clear or consume the pending destination when that open reuses an existing surface.
Also found in 1 other location(s):
- apps/web/src/rightPanelStore.ts:338 -- The pending add-menu group is consumed only when a surface is newly inserted. Selecting Files from a group's add menu when Files already exists calls `open(..., "files")`, takes the existing-surface branch here, and leaves `pendingSurfaceGroupId` set. The next unrelated new surface (including an `openFile` in another thread) consumes that stale group and opens in the wrong pane. Consume/clear the pending request for existing-surface opens too, so one menu action cannot redirect a later action.
| ); | ||
| if (terminalOpen && activeTerminalId) focusTerminal(threadRef, activeTerminalId); | ||
| const { byThreadKey } = useRightPanelStore.getState(); | ||
| const surface = selectTerminalSurface(selectThreadRightPanelState(byThreadKey, threadRef)); |
There was a problem hiding this comment.
🟡 Medium lib/terminalFocus.ts:118
On thread entry, this can request focus for hidden terminal B while terminal A is visible, so B's viewport rejects the request and A never receives focus. selectTerminalSurface chooses the globally active or last terminal, while selectRightPanelKindShown only confirms that some terminal is shown; select a terminal surface that is itself shown and focus that same surface.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/lib/terminalFocus.ts around line 118:
On thread entry, this can request focus for hidden terminal B while terminal A is visible, so B's viewport rejects the request and A never receives focus. `selectTerminalSurface` chooses the globally active or last terminal, while `selectRightPanelKindShown` only confirms that some terminal is shown; select a terminal surface that is itself shown and focus that same surface.
| const peers = pinned.filter( | ||
| (surface) => resolveSurfaceGroupId(surface, dock) === groupId, | ||
| ); | ||
| const before = index === null ? undefined : peers[index]; |
There was a problem hiding this comment.
🟡 Medium src/rightPanelStore.ts:1132
Rightward drops within the same group land one tab too far right: dropping A between B and C in [A, B, C] produces [B, C, A]. The supplied index includes the dragged tab, but pinned excludes it before peers[index] is selected; adjust the index when the source precedes the requested gap.
Also found in 1 other location(s)
apps/web/src/dock/dockDrop.ts:123
Same-group tab drops calculate the insertion index including the dragged tab, but
applyDockDroppasses that index unchanged torightPanelStore.placeSurface, which removes the dragged tab before indexing its peers. For tabs[A,B,C], dragging A into the gap between B and C resolves to index 2 and produces[B,C,A]instead of[B,A,C]. Exclude the source tab when calculating the index (or adjust forward moves) so drops land in the indicated gap.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/rightPanelStore.ts around line 1132:
Rightward drops within the same group land one tab too far right: dropping `A` between `B` and `C` in `[A, B, C]` produces `[B, C, A]`. The supplied index includes the dragged tab, but `pinned` excludes it before `peers[index]` is selected; adjust the index when the source precedes the requested gap.
Also found in 1 other location(s):
- apps/web/src/dock/dockDrop.ts:123 -- Same-group tab drops calculate the insertion index including the dragged tab, but `applyDockDrop` passes that index unchanged to `rightPanelStore.placeSurface`, which removes the dragged tab before indexing its peers. For tabs `[A,B,C]`, dragging A into the gap between B and C resolves to index 2 and produces `[B,C,A]` instead of `[B,A,C]`. Exclude the source tab when calculating the index (or adjust forward moves) so drops land in the indicated gap.
| const current = | ||
| activeArea && areas.includes(activeArea) | ||
| ? areas.indexOf(activeArea) | ||
| : areas.findIndex((area) => area.dataset.workspaceArea === currentId); |
There was a problem hiding this comment.
🟡 Medium lib/workspaceFocus.ts:233
Desktop browser forwarded next/previous commands pass fromArea="panel" because renderer focus is stale. If focus remains in the composer, this branch chooses the composer as current, so move() advances from the wrong area and selects the wrong destination. Honor fromArea before activeArea, and keep the identity-based lookup for calls without an override.
| const current = | |
| activeArea && areas.includes(activeArea) | |
| ? areas.indexOf(activeArea) | |
| : areas.findIndex((area) => area.dataset.workspaceArea === currentId); | |
| const current = fromArea | |
| ? areas.findIndex((area) => area.dataset.workspaceArea === fromArea) | |
| : activeArea && areas.includes(activeArea) | |
| ? areas.indexOf(activeArea) | |
| : areas.findIndex((area) => area.dataset.workspaceArea === currentId); |
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/lib/workspaceFocus.ts around lines 233-236:
Desktop browser forwarded next/previous commands pass `fromArea="panel"` because renderer focus is stale. If focus remains in the composer, this branch chooses the composer as `current`, so `move()` advances from the wrong area and selects the wrong destination. Honor `fromArea` before `activeArea`, and keep the identity-based lookup for calls without an override.
| const active = state.surfaces.find((surface) => surface.id === state.activeSurfaceId); | ||
| if (!active) return state; | ||
| const groupId = resolveSurfaceGroupId(active, useDockLayoutStore.getState()); | ||
| const shown = state.activeGroupTabs?.[groupId] === active.id; |
There was a problem hiding this comment.
🟡 Medium src/rightPanelStore.ts:347
Closing the last visible surface can make a previously hidden group visible again. When closeSurface selects a hidden surface as activeSurfaceId, settleActiveSurface removes that group from hiddenGroupIds and activates its tab. Keep hidden groups hidden and select a visible fallback—or close the panel—instead of un-hiding the group.
- if (shown && !hidden) return state;
+ if (shown) return state;
+ if (hidden) {
+ const visible = state.surfaces.filter((surface) => {
+ const candidateGroupId = resolveSurfaceGroupId(surface, layout);
+ return (
+ !state.hiddenGroupIds?.includes(candidateGroupId) &&
+ resolveGroupActiveSurface(state, candidateGroupId, layout)?.id === surface.id
+ );
+ });
+ return {
+ ...state,
+ activeSurfaceId: visible.at(-1)?.id ?? null,
+ isOpen: state.isOpen && visible.length > 0,
+ };
+ }🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/rightPanelStore.ts around line 347:
Closing the last visible surface can make a previously hidden group visible again. When `closeSurface` selects a hidden surface as `activeSurfaceId`, `settleActiveSurface` removes that group from `hiddenGroupIds` and activates its tab. Keep hidden groups hidden and select a visible fallback—or close the panel—instead of un-hiding the group.
| kind: RightPanelKind, | ||
| ): boolean { | ||
| const state = selectThreadRightPanelState(byThreadKey, ref); | ||
| return state.surfaces.some((surface) => surface.kind === kind && isSurfaceShown(state, surface)); |
There was a problem hiding this comment.
🟡 Medium src/rightPanelStore.ts:1390
In the narrow-window sheet presentation, selectRightPanelKindShown returns true for a terminal in an unselected dock group even though only renderedRightPanelSurface is rendered. As a result, the first terminal-toggle invocation calls hideKind instead of opening the invisible terminal; make this selector account for the single-surface presentation.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/rightPanelStore.ts around line 1390:
In the narrow-window sheet presentation, `selectRightPanelKindShown` returns `true` for a terminal in an unselected dock group even though only `renderedRightPanelSurface` is rendered. As a result, the first terminal-toggle invocation calls `hideKind` instead of opening the invisible terminal; make this selector account for the single-surface presentation.
ApprovabilityVerdict: Needs Human Approval Macroscope's review found this PR requires human approval — This changes the default workspace to a draggable dock and adds static-analysis suppressions, both requiring human review. The replacement installation instructions also direct users to a mutable fork-based patcher that replaces the installed application archive. Needs human approval because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
Problem
The panel layout is fixed. The threads sidebar is always on the left, and the terminal is a bottom drawer separate from the right panel. Users can't move a panel, split the window, or put the sidebar on the other side. The Files surface also mixes the project tree with file viewing, so there is no separate place to read a file.
Change
Adds a dock workspace in
apps/web/src/dock/. Panels (terminal, files, file, diff, browser, pull requests, device, side chat) are tabs that can be dragged to another edge or group, split, and maximized. The threads sidebar is a dock panel too, so it can sit on the left or right.terminalUiStateStoreare removed, and terminal visibility is tracked inrightPanelStore.These pieces depend on each other: the terminal drawer removal forces the terminal into the dock, and the sidebar move depends on the same dock layout store.
Scope and approval
No triaged issue or maintainer-approved discussion covers this yet. This is a broad change to the panel and terminal UI, which goes beyond a small focused fix, so it needs maintainer approval of direction and scope before review. Link the approval comment here once it exists.
Verification
Focused unit tests added or updated (not yet run for this PR text):
apps/web/src/dock/dockLayout.test.ts,dockDrop.test.ts,dockActions.test.ts,dockView.test.tsapps/web/src/dock/dockDrag.browser.test.ts(drag behavior in a real browser)apps/web/src/rightPanelStore.test.tsapps/web/src/terminalUiStateStore.test.tsremoved along with its storeResults: to be filled in after running
vp test runon the files above.Not covered by tests yet:
install_on_nightly_arch.share fork-only and should not ship in an upstream PR.