* 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.
95 lines
4.7 KiB
Markdown
95 lines
4.7 KiB
Markdown
# 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.
|