feat(app): make the side panel user-directed (#3605)

* feat(app): let the file panel close its tree rail

The file panel's tree rail was permanent on desktop — the changes panel
had a folder-tree toggle in its toolbar, the file toolbar had nothing, so
the rail was there whether you wanted it or not.

The changes panel's private DesktopTreeToggle becomes a shared
TreeRailToggle so both toolbars render the same affordance; that also
drops the treeToggleStyle prop threaded through ChangesToolbarProps,
which recomputed exactly what the component already built internally.
The i18n keys move out of workspace.git.diff into workspace.tree, since a
shared component reaching into the git namespace would rot.

The two rails keep independent flags — the file rail in panel-store, the
changes rail in useChangesPreferences — so closing one leaves the other
alone. FileTreeToggle wires itself to the store rather than taking a
callback: the bar renders several components below FilePanel, where the
rail actually lives.

Both rails also get narrower (320 -> 260 default, 200 -> 180 minimum).
The persist version bump resets treeRailWidth on purpose: nearly every
existing install carries the old 320 default, so without the reset the
narrower rail would only ever reach new users. Anyone who dragged their
rail to a deliberate custom width loses it once.

Note that MIN_CONTENT_WIDTH still clamps the rail to
`containerWidth - 240`, so in a narrow pane the new default never
applies — that floor, not the default, is what sets the width there.

* feat(app): make the side panel an empty workspace surface

The fixed, hideable side panel keeps supporting context beside an agent without seeding content. Pane-local choices stay local, while implicit supporting opens follow a persisted routing preference.

* test(app): exercise side panel policy through its capability boundary

The browser rail journey already covers the real tree-toggle behavior. Side-panel policy tests now pass their platform capability through the public boundary instead of replacing a production module.

* test(app): expect an empty side panel after upgrade

* test(app): expect an empty first split pane

* test(app): account for the empty side panel in split retention

* feat(app): make workspace panel tabs independent

Give explicit New actions fresh tab instances, keep panel state opaque to tab management, preserve File navigation context, and stabilize uncached File transitions.

* fix(app): preserve file conflicts during preview refresh

Keep the current preview mounted for same-target reads and conversions so the editor retains its conflict observation state. Clear retained previews only when the file target changes.
This commit is contained in:
Mohamed Boudra
2026-08-20 21:49:23 +02:00
committed by GitHub
parent 2a0a0ff656
commit 19a7d26349
107 changed files with 5386 additions and 2178 deletions
+94
View File
@@ -0,0 +1,94 @@
# Side panel
The companion surface beside the user's work: Changes, Files, the pull request, and
anything an agent asks to show alongside its output.
Everything that opens, reveals, hides, or asks about it goes through
`packages/app/src/workspace-tabs/side-panel.ts`. Nothing else branches on which
surface the current layout uses.
## Three renderings, two facts
| Layout | Rendering |
| ---------------------- | ----------------------------------------------- |
| compact | slide-over overlay, owned by the panel store |
| non-compact + splits | dedicated right pane, owned by the layout store |
| non-compact, no splits | ordinary tabs in the focused pane |
The third case is the one that bites. A native tablet renders one pane at a time and
the tab row only lists the focused pane's tabs, so anything routed into a separate
side panel pane there disappears with no way back.
## Placement
Where a tab lands is an intent the caller declares, not something the store infers
from a `paneId` that happens to be in scope. `WorkspaceTabPlacement`
(`packages/app/src/stores/workspace-layout-actions.ts`) has four modes, and the
difference between them only shows up when the tab is already open somewhere:
| Mode | New tab | Tab already open |
| --------- | ----------------- | ---------------------------- |
| `pane` | opens in the pane | **moves** to the pane |
| `prefer` | opens in the pane | stays where the user left it |
| `focused` | focused pane | focuses it in place |
| `ambient` | focused pane | focuses it in place |
`pane` is a pane-local affordance — that pane's `+` menu, its empty-state launcher.
The user is placing the tab and expects it to arrive.
`prefer` is an implicit supporting open — an agent's file link, workspace setup
progress. It has an opinion about new tabs only. Yanking a tab out of the pane the
user deliberately moved it to is the failure this mode exists to prevent.
`ambient` differs from `focused` in one rule: reconciliation opens tabs with nobody
behind the click, and the side panel can hold focus from an earlier reveal, so an
ambient open of something the user works _in_ (an agent, a terminal, a browser) is
pushed out of the side panel. A `focused` open honours the focused pane whatever it
is, because the user is standing in it.
Deduplication lives in `openTabInLayoutFocused`, so it obeys the placement intent for
every caller. Do not re-place a tab after opening it.
## Lifecycle
The side panel lives in the ordinary split tree, so it is a pane like any other until
it empties or the user dismisses it:
- **Close pane** on the side panel **hides** it; its tabs are waiting on the next reveal.
- **Close pane** on any other pane **removes** it.
- Closing the Side panel's final tab hides the pane. Revealing it returns the empty launcher.
This is close-only: claiming or dragging that tab to another pane leaves the empty Side panel
visible.
- Nothing takes the last visible pane away. `closePane` will not remove it, and
neither `closePane` nor `hideSidePanel` will hide it — including the header toggle
reaching `hideSidePanel` after every other pane is gone. The gate is one check in
`setPaneHiddenInLayout`; do not add a second one upstream.
`closePane` in the layout store owns that distinction. React renders the button and
does not decide what it means.
Dragging a pane's last tab into another pane collapses the source pane — except the
side panel, which the user summons and expects to find again, split size and all.
## What the side panel never does
It does not seed itself. Revealing it opens an empty pane with the launcher; the user
picks what goes in. Detecting a pull request makes the launcher's card available and
opens nothing. Event-driven surfaces such as workspace setup progress still add a
background tab, which never reveals the panel or moves focus.
## Routing preference
**Settings → General → Open supporting tabs in the Side panel**, on by default,
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
to it.
## The name
The product surface is the **Side panel**. **File Explorer** is still the name of the
Files view inside it. Persisted identifiers keep their pre-rename spelling: the pane
id is the literal string `"explorer"` (`SIDE_PANEL_PANE_ID`) and the persisted layout
key is `explorerPaneIdByWorkspace`. The persisted state schema is strict, so renaming
either would fail every saved blob and wipe the layout it describes.