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
+1
View File
@@ -52,6 +52,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
+2 -2
View File
@@ -242,13 +242,13 @@ focused invalidation capture; React Native's generated stacks make that mode hig
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
For the desktop explorer toggle, run the app against the root checkout's daemon and use:
For the desktop Side panel toggle, run the app against the root checkout's daemon and use:
```bash
npm run profile:explorer-toggle --workspace=@getpaseo/app
```
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the explorer pane,
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Side panel pane,
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
+1
View File
@@ -19,6 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Side panel** — The companion surface beside the user's work, holding Changes, Files, the pull request, and tabs an agent opens alongside its output. UI: "Side panel". Code: `packages/app/src/workspace-tabs/side-panel.ts`; the persisted pane id and layout key keep the pre-rename `explorer` spelling. See [side-panel.md](side-panel.md). Forbidden: "Explorer" as the name of the surface — **File Explorer** is only the Files view inside it.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
+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.
+101 -1
View File
@@ -648,7 +648,7 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
page,
}) => {
}, testInfo) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
@@ -658,6 +658,41 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
// The tree toggle is the one icon action that stays outside the overflow menu.
await expect(page.getByTestId("changes-toggle-tree")).toBeVisible();
const [changesToolbarBox, treeToggleBox, optionsBox, treeGlyphBox, optionsGlyphBox] =
await Promise.all([
page.getByTestId("changes-header").boundingBox(),
page.getByTestId("changes-toggle-tree").boundingBox(),
page.getByRole("button", { name: "Diff options" }).boundingBox(),
page.getByTestId("changes-toggle-tree").locator("svg").boundingBox(),
page.getByRole("button", { name: "Diff options" }).locator("svg").boundingBox(),
]);
if (!changesToolbarBox || !treeToggleBox || !optionsBox || !treeGlyphBox || !optionsGlyphBox) {
throw new Error("Changes toolbar geometry could not be measured");
}
expect(changesToolbarBox.height).toBe(36);
expect(treeToggleBox.width).toBe(24);
expect(treeToggleBox.height).toBe(24);
expect(optionsBox.width).toBe(24);
expect(optionsBox.height).toBe(24);
expect(treeGlyphBox.width).toBe(14);
expect(treeGlyphBox.height).toBe(14);
expect(optionsGlyphBox.width).toBe(14);
expect(optionsGlyphBox.height).toBe(14);
const diffOptions = page.getByRole("button", { name: "Diff options" });
const diffOptionsRestBackground = await diffOptions.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await diffOptions.hover();
const diffOptionsHoverBackground = await diffOptions.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
expect(diffOptionsHoverBackground).not.toBe(diffOptionsRestBackground);
await page.getByTestId("changes-toggle-tree").hover();
expect(
await page
.getByTestId("changes-toggle-tree")
.evaluate((element) => getComputedStyle(element).backgroundColor),
).toBe(diffOptionsHoverBackground);
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
await expect(page.getByTestId("changes-toggle-layout")).toHaveCount(0);
@@ -675,6 +710,12 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
await scrollToLowerUnwrappedDiffRows(page);
await page.getByTestId("changes-toggle-tree").click();
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "true");
expect(
await page
.getByTestId("changes-toggle-tree")
.evaluate((element) => getComputedStyle(element).backgroundColor),
).toBe(diffOptionsHoverBackground);
const tree = changesTree(page);
const content = changesContent(page);
await expect(tree.getByTestId("diff-folder-src")).toBeVisible();
@@ -735,7 +776,38 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
await page.getByTestId("changes-toggle-tree").click();
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "false");
await expectFlatFileList(page);
const screenshot = testInfo.outputPath("changes-toolbar.png");
await page.screenshot({ path: screenshot });
await testInfo.attach("changes-toolbar", { path: screenshot, contentType: "image/png" });
});
test("compact Changes keeps its options control touch-sized", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.setViewportSize({ width: 480, height: 900 });
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
const [optionsBox, glyphBox] = await Promise.all([
options.boundingBox(),
options.locator("svg").boundingBox(),
]);
if (!optionsBox || !glyphBox) {
throw new Error("Compact Changes options geometry could not be measured");
}
expect(optionsBox.width).toBe(32);
expect(optionsBox.height).toBe(32);
expect(glyphBox.width).toBe(18);
expect(glyphBox.height).toBe(18);
await options.click();
const wrapLines = page.getByText("Wrap long lines", { exact: true });
await expect(wrapLines).toBeVisible();
await wrapLines.click();
await options.click();
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Scroll long lines");
});
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
@@ -875,6 +947,7 @@ test("split canvas creates a review on the changed side and keeps it in that col
const workspace = await createWorkspaceWithMountedTabDiff();
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
await openWorkspaceChanges(page, workspace);
await setOpenChangesPresentation(page, { layout: "split", wrapLines: false });
await startReviewOnFirstChangedLine(page, "right");
const [editor, body] = await Promise.all([
page.getByTestId("inline-review-editor").boundingBox(),
@@ -1004,6 +1077,7 @@ test("canvas diff copies only the selected split side", async ({ context, page }
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
await openSelectionWorkspaceChanges(page, workspace);
await setOpenChangesPresentation(page, { layout: "split", wrapLines: false });
const before = await readSelectionPaintSamples(page, "right");
await dragAddedTextRange(page, {
lines,
@@ -1027,6 +1101,7 @@ test("canvas diff copies exact wrapped fragments", async ({ context, page }) =>
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "unified", wrapLines: true });
await openSelectionWorkspaceChanges(page, workspace);
await setOpenChangesPresentation(page, { layout: "unified", wrapLines: true });
await dragAddedTextRange(page, {
lines: [content],
wrapped: true,
@@ -1151,6 +1226,31 @@ async function configureDiffPresentation(
);
}
async function setOpenChangesPresentation(
page: Page,
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
): Promise<void> {
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
await options.click();
const layoutItem = page.getByTestId("changes-toggle-layout");
const currentLayout = (await layoutItem.locator("svg").count()) === 2 ? "split" : "unified";
if (currentLayout !== requestedPresentation.layout) {
await layoutItem.click();
} else {
await page.keyboard.press("Escape");
}
await options.click();
const wrapItem = page.getByTestId("changes-toggle-wrap-lines");
const currentWrapLines = (await wrapItem.locator("svg").count()) === 2;
if (currentWrapLines !== requestedPresentation.wrapLines) {
await wrapItem.click();
} else {
await page.keyboard.press("Escape");
}
}
async function holdBrowserFontLoads(page: Page): Promise<void> {
await page.addInitScript(() => {
const fontSet = document.fonts;
@@ -51,6 +51,19 @@ test("commit history shows dates and shares diff layout preferences", async ({
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
const [commitToolbarBox, layoutToggleBox, layoutToggleGlyphBox] = await Promise.all([
panel.getByTestId("commit-diff-header").boundingBox(),
layoutToggle.boundingBox(),
layoutToggle.locator("svg").boundingBox(),
]);
if (!commitToolbarBox || !layoutToggleBox || !layoutToggleGlyphBox) {
throw new Error("Commit-diff toolbar geometry could not be measured");
}
expect(commitToolbarBox.height).toBe(36);
expect(layoutToggleBox.width).toBe(24);
expect(layoutToggleBox.height).toBe(24);
expect(layoutToggleGlyphBox.width).toBe(14);
expect(layoutToggleGlyphBox.height).toBe(14);
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
await expectCommitDiffHeaderGeometry(panel);
@@ -77,7 +77,7 @@ diffPerfDescribe("Diff canvas performance", () => {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByRole("button", { name: "Open explorer" }).click();
await openChangesPanel(page);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
await expectSingleCanvasSurface(page);
@@ -9,7 +9,7 @@ import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { ensureExplorerPane, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
function visible(page: Page, testId: string): Locator {
return page.getByTestId(testId).filter({ visible: true });
@@ -37,9 +37,8 @@ function mainTabRow(page: Page): Locator {
}
async function focusExplorerPane(page: Page): Promise<void> {
await ensureExplorerPane(page);
await openChangesPanel(page);
const changesTab = visible(page, "workspace-tab-working_diff").first();
await changesTab.click({ position: { x: 12, y: 13 } });
await expect(changesTab).toHaveAttribute("aria-selected", "true");
}
@@ -54,11 +53,15 @@ async function closeSeededDraftInMainPane(page: Page): Promise<void> {
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
}
async function emptyPaneBox(page: Page) {
const paneChild = page
function emptyPaneChild(page: Page): Locator {
return page
.getByTestId("split-group-child")
.filter({ hasText: "No tabs in this pane." })
.filter({ has: page.getByTestId("workspace-pane-empty-state") })
.first();
}
async function emptyPaneBox(page: Page) {
const paneChild = emptyPaneChild(page);
await expect(paneChild).toBeVisible({ timeout: 15_000 });
const box = await paneChild.boundingBox();
expect(box).not.toBeNull();
@@ -170,7 +173,7 @@ test.describe("explorer pane tab placement", () => {
});
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
await expect(page.getByText("No tabs in this pane.")).toBeVisible();
await expect(page.getByTestId("workspace-pane-empty-state")).toBeVisible();
});
await test.step("app must stay interactive after the split", async () => {
@@ -183,12 +186,10 @@ test.describe("explorer pane tab placement", () => {
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
// Clicking inside the empty pane (focuses it) must not lock anything.
const emptyPane = page
.getByTestId("split-group-child")
.filter({ hasText: "No tabs in this pane." })
.first();
await emptyPane.click({ timeout: 5_000 });
// Clicking inside the empty pane (focuses it) must not lock anything. Aim
// above the vertically centred launcher, which would open a tab instead.
const emptyPane = emptyPaneChild(page);
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
// The empty pane's + menu must open and dismiss cleanly.
const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first();
@@ -207,7 +208,9 @@ test.describe("explorer pane tab placement", () => {
});
// The agent chip must have moved into the previously empty pane and stay clickable.
await expect(page.getByText("No tabs in this pane.")).toHaveCount(0, { timeout: 10_000 });
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(0, {
timeout: 10_000,
});
await agentTabChip(page, agentId)
.first()
.click({ position: { x: 12, y: 13 } });
@@ -24,11 +24,9 @@ function dockedSidebar(page: Page): Locator {
return visible(page, "explorer-header");
}
/** The explorer pane is the tab row carrying the Changes tab. */
function explorerTabRow(page: Page): Locator {
return visible(page, "workspace-tabs-row").filter({
has: page.getByTestId("workspace-tab-working_diff"),
});
/** The desktop companion surface is a hideable side panel. */
function sidePanel(page: Page): Locator {
return visible(page, "workspace-side-panel");
}
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
@@ -72,11 +70,12 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
const toggle = visible(page, "workspace-explorer-toggle").first();
await toggle.click();
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(dockedSidebar(page)).toHaveCount(0);
await toggle.click();
await expect(explorerTabRow(page)).toHaveCount(0, { timeout: 15_000 });
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
await expect(dockedSidebar(page)).toHaveCount(0);
});
@@ -108,20 +107,22 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await test.step("open the explorer so both sides are showing", async () => {
await toggle.click();
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(agentList).toHaveCount(1);
});
await test.step("the shortcut collapses both", async () => {
await page.keyboard.press(`${modifier}+.`);
await expect(explorerTabRow(page)).toHaveCount(0, { timeout: 15_000 });
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
await expect(agentList).toHaveCount(0);
});
await test.step("the shortcut restores both", async () => {
await page.keyboard.press(`${modifier}+.`);
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
});
} finally {
await workspace.cleanup();
@@ -0,0 +1,128 @@
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { delayFileReadResponse } from "../support/helpers/file-read-gate";
import {
expandFolder,
openFileExplorer,
openFileFromExplorer,
} from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({
repoPrefix: "file-transition-",
repo: {
files: [
{ path: "src/a.ts", content: "export const sourceA = true;\n" },
{ path: "src/b.ts", content: "export const sourceB = true;\n" },
{ path: "docs/b.md", content: "# Markdown B\n" },
],
},
});
});
test.afterAll(async () => workspace?.cleanup());
async function openSourceA(page: import("@playwright/test").Page) {
await openFileExplorer(page);
await expandFolder(page, "src");
await openFileFromExplorer(page, "a.ts");
await expect(page.getByText("export const sourceA = true;")).toBeVisible({ timeout: 30_000 });
}
async function assertHeldFileTransition(
page: import("@playwright/test").Page,
targetPath: string,
finalText: string,
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
) {
await page.evaluate((tabTestId) => {
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
const sample = () => {
const next = {
chip: Boolean(document.querySelector(`[data-testid="${tabTestId}"]`)),
loading: Boolean(document.querySelector('[data-testid="file-preview-loading"]')),
unavailable: Boolean(document.querySelector('[data-testid="file-preview-unsupported"]')),
};
if (JSON.stringify(timeline.at(-1)) !== JSON.stringify(next)) timeline.push(next);
};
sample();
const observer = new MutationObserver(sample);
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
(
window as typeof window & { __fileTransitionTimeline?: typeof timeline }
).__fileTransitionTimeline = timeline;
(
window as typeof window & { __fileTransitionObserver?: MutationObserver }
).__fileTransitionObserver = observer;
}, `workspace-tab-file_${targetPath}`);
await openFileFromExplorer(page, targetPath.split("/").pop() as string);
await gate.waitUntilHeld();
const targetChip = page.getByTestId(`workspace-tab-file_${targetPath}`).first();
await expect(targetChip).toBeVisible();
await expect(targetChip).toContainText(targetPath.split("/").pop() as string);
await expect(page.getByTestId("file-preview-loading")).toBeVisible();
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
const heldTimeline = await page.evaluate(
() =>
(
window as typeof window & {
__fileTransitionTimeline?: Array<{
chip: boolean;
loading: boolean;
unavailable: boolean;
}>;
}
).__fileTransitionTimeline ?? [],
);
expect(heldTimeline).toContainEqual(expect.objectContaining({ chip: true, loading: true }));
expect(heldTimeline).not.toContainEqual(expect.objectContaining({ unavailable: true }));
const descriptorIndex = heldTimeline.findIndex((sample) => sample.chip);
expect(heldTimeline.slice(descriptorIndex)).not.toContainEqual(
expect.objectContaining({ chip: false }),
);
gate.release();
await expect(page.getByText(finalText)).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("file-preview-loading")).toHaveCount(0);
const timeline = await page.evaluate(() => {
const windowWithTimeline = window as typeof window & {
__fileTransitionTimeline?: Array<{ chip: boolean; loading: boolean; unavailable: boolean }>;
__fileTransitionObserver?: MutationObserver;
};
windowWithTimeline.__fileTransitionObserver?.disconnect();
return windowWithTimeline.__fileTransitionTimeline ?? [];
});
expect(
timeline.filter((sample, index) => sample.loading && !timeline[index - 1]?.loading),
).toHaveLength(1);
}
test.describe("File target transitions", () => {
test("keeps the Markdown descriptor and loading state stable through a real delayed read", async ({
page,
}) => {
const gate = await delayFileReadResponse(page, "docs/b.md");
await page.reload();
await gotoWorkspace(page, workspace.workspaceId);
await openSourceA(page);
await expandFolder(page, "docs");
await assertHeldFileTransition(page, "docs/b.md", "Markdown B", gate);
});
test("keeps the source descriptor and loading state stable through a real delayed read", async ({
page,
}) => {
const gate = await delayFileReadResponse(page, "src/b.ts");
await page.reload();
await gotoWorkspace(page, workspace.workspaceId);
await openSourceA(page);
await assertHeldFileTransition(page, "src/b.ts", "export const sourceB = true;", gate);
});
});
@@ -0,0 +1,115 @@
// The file panel's tree rail had no way to close: the changes panel's toolbar
// carries a folder-tree toggle, the file toolbar carried nothing, and the rail
// was permanent on desktop.
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({
repoPrefix: "file-tree-rail-toggle-",
repo: { files: [{ path: "docs/guide.md", content: "# Guide\n" }] },
});
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test.describe("File panel tree rail", () => {
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openFileFromExplorer(page, "docs");
await openFileFromExplorer(page, "guide.md");
const rail = page.getByTestId("file-tree-rail-tree").filter({ visible: true });
const toggle = page.getByTestId("file-toggle-tree").filter({ visible: true }).first();
const editorToolbar = page.getByTestId("file-panel-bar").filter({ visible: true });
const filesToolbar = page.getByTestId("files-pane-header").filter({ visible: true });
const refresh = page.getByRole("button", { name: "Refresh files" }).filter({ visible: true });
const hiddenFiles = page
.getByRole("button", { name: "Hide hidden files" })
.filter({ visible: true });
const shoot = async (name: string) => {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
await testInfo.attach(name, { path, contentType: "image/png" });
};
await expect(rail).toBeVisible({ timeout: 30_000 });
await expect(toggle).toHaveAttribute("aria-selected", "true");
const [
editorToolbarBox,
filesToolbarBox,
treeToggleBox,
refreshBox,
treeGlyphBox,
refreshGlyphBox,
] = await Promise.all([
editorToolbar.boundingBox(),
filesToolbar.boundingBox(),
toggle.boundingBox(),
refresh.boundingBox(),
toggle.locator("svg").boundingBox(),
refresh.locator("svg").boundingBox(),
]);
if (
!editorToolbarBox ||
!filesToolbarBox ||
!treeToggleBox ||
!refreshBox ||
!treeGlyphBox ||
!refreshGlyphBox
) {
throw new Error("Pane-content toolbar geometry could not be measured");
}
expect(editorToolbarBox.height).toBe(36);
expect(filesToolbarBox.height).toBe(36);
expect(editorToolbarBox.y + editorToolbarBox.height).toBe(
filesToolbarBox.y + filesToolbarBox.height,
);
expect(treeToggleBox.width).toBe(24);
expect(treeToggleBox.height).toBe(24);
expect(refreshBox.width).toBe(24);
expect(refreshBox.height).toBe(24);
expect(treeGlyphBox.width).toBe(14);
expect(treeGlyphBox.height).toBe(14);
expect(refreshGlyphBox.width).toBe(14);
expect(refreshGlyphBox.height).toBe(14);
const restBackground = await refresh.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
const treeToggleSelectedBackground = await toggle.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await refresh.hover();
const refreshHoverBackground = await refresh.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
expect(refreshHoverBackground).not.toBe(restBackground);
expect(refreshHoverBackground).toBe(treeToggleSelectedBackground);
await hiddenFiles.click();
const hiddenFilesSelected = page
.getByRole("button", { name: "Show hidden files" })
.filter({ visible: true });
expect(
await hiddenFilesSelected.evaluate((element) => getComputedStyle(element).backgroundColor),
).toBe(refreshHoverBackground);
await shoot("tree-open");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-selected", "false");
await expect(rail).toHaveCount(0);
await shoot("tree-closed");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-selected", "true");
await expect(rail).toBeVisible();
await shoot("tree-reopened");
});
});
+217
View File
@@ -0,0 +1,217 @@
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { ensureSidePanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
await testInfo.attach(name, { path, contentType: "image/png" });
}
function sidePanel(page: Page): Locator {
return page.getByTestId("workspace-side-panel").filter({ visible: true });
}
function mainPane(page: Page): Locator {
return page.getByTestId("workspace-pane-main").filter({ visible: true });
}
function emptyState(scope: Locator): Locator {
return scope.getByTestId("workspace-pane-empty-state");
}
function sidePanelToggle(page: Page): Locator {
return page.getByTestId("workspace-explorer-toggle").first();
}
function closePaneButton(page: Page): Locator {
return page.getByRole("button", { name: "Close pane" });
}
/** Drags the pane's only tab into the left edge of its own pane, splitting it out. */
async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise<void> {
const paneBox = await mainPane(page).boundingBox();
const chipBox = await chip.boundingBox();
expect(paneBox).not.toBeNull();
expect(chipBox).not.toBeNull();
if (!paneBox || !chipBox) throw new Error("Unable to measure the main pane");
await page.mouse.move(chipBox.x + chipBox.width / 2, chipBox.y + chipBox.height / 2);
await page.mouse.down();
await page.mouse.move(chipBox.x + chipBox.width / 2 + 12, chipBox.y + chipBox.height / 2 + 4);
await page.mouse.move(paneBox.x + paneBox.width * 0.06, paneBox.y + paneBox.height * 0.6, {
steps: 20,
});
await page.mouse.up();
}
test.describe("Side panel", () => {
test("reveals empty, launches a view, and hides back out of the way", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "side-panel-reveal-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await expect(sidePanel(page)).toHaveCount(0);
await test.step("revealing the Side panel seeds nothing", async () => {
await sidePanelToggle(page).click();
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
await expect(emptyState(sidePanel(page))).toBeVisible();
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
await capture(page, testInfo, "01-side-panel-reveals-empty");
});
await test.step("Changes opens in the Side panel that offered it", async () => {
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
await capture(page, testInfo, "02-changes-opens-in-side-panel");
});
await test.step("a loaded pane offers no one-tap close", async () => {
await expect(closePaneButton(page)).toHaveCount(0);
});
await test.step("closing its final tab hides the Side panel, and it reopens empty", async () => {
const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff");
await changesTab.hover();
await page
.locator('[data-testid^="workspace-working-diff-close-"]')
.filter({ visible: true })
.click();
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
await capture(page, testInfo, "03-side-panel-hidden-after-final-tab-close");
await sidePanelToggle(page).click();
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 });
await expect(emptyState(sidePanel(page))).toBeVisible();
await capture(page, testInfo, "04-side-panel-revealed-empty");
});
} finally {
await workspace.cleanup();
}
});
test("an emptied ordinary pane is removed rather than hidden", async ({ page }, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "side-panel-close-main-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await ensureSidePanel(page);
// Dragging the pane's only tab into its own left edge splits that tab into a
// new pane and leaves the one it came from empty.
const draftTab = page
.locator('[data-testid^="workspace-tab-draft_"]')
.filter({ visible: true })
.first();
await expect(draftTab).toBeVisible({ timeout: 30_000 });
await splitTabOutOfMainPane(page, draftTab);
const draftPane = page
.locator('[data-testid^="workspace-pane-pane_"]')
.filter({ visible: true })
.first();
await expect(draftPane).toBeVisible({ timeout: 15_000 });
const emptyMain = emptyState(mainPane(page));
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
await capture(page, testInfo, "06-empty-main-pane-launcher");
await emptyMain.getByRole("button", { name: "Close pane" }).click();
// Gone for good, unlike the Side panel beside it, which is still only hidden away.
await expect(mainPane(page)).toHaveCount(0, { timeout: 30_000 });
await expect(sidePanel(page)).toBeVisible();
await expect(draftPane.getByTestId("workspace-pane-empty-state")).toHaveCount(0);
await capture(page, testInfo, "07-empty-main-pane-removed");
} finally {
await workspace.cleanup();
}
});
test("the last pane on screen offers no Close pane", async ({ page }, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
// Park a tab in the Side panel, then put the panel away. The workspace still
// holds that tab, so emptying Main does not look like an empty workspace and
// nothing reseeds a draft into it.
await ensureSidePanel(page);
await emptyState(sidePanel(page)).getByRole("button", { name: "Files" }).click();
await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
timeout: 30_000,
});
await sidePanelToggle(page).click();
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
const draftTab = page
.locator('[data-testid^="workspace-tab-draft_"]')
.filter({ visible: true })
.first();
await draftTab.hover();
await page
.locator('[data-testid^="workspace-draft-close-"]')
.filter({ visible: true })
.first()
.click();
// Main is now empty and the only pane the user can see. Taking it away would
// leave nowhere to look, so the launcher appears without a Close pane.
const lastPane = emptyState(mainPane(page));
await expect(lastPane).toBeVisible({ timeout: 15_000 });
await expect(lastPane.getByRole("button", { name: "Files" })).toBeVisible();
await expect(lastPane.getByRole("button", { name: "Close pane" })).toHaveCount(0);
await expect(closePaneButton(page)).toHaveCount(0);
await capture(page, testInfo, "08-final-visible-pane-has-no-close");
} finally {
await workspace.cleanup();
}
});
test("a pane's own menu creates an independent view when one is open elsewhere", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "side-panel-claim-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await ensureSidePanel(page);
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await capture(page, testInfo, "08-changes-in-side-panel");
await mainPane(page).getByTestId("workspace-new-tab-menu-trigger").click();
await page
.getByTestId("workspace-new-tab-menu-changes")
.filter({ visible: true })
.first()
.click();
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 15_000,
});
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(2);
await capture(page, testInfo, "09-independent-changes-in-both-panes");
} finally {
await workspace.cleanup();
}
});
});
@@ -343,7 +343,7 @@ test.describe("Half-screen desktop layout", () => {
await openFilesPanel(page);
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
await expect(explorerToggle).toHaveAccessibleName("Close explorer");
await expect(explorerToggle).toHaveAccessibleName("Close side panel");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
@@ -351,7 +351,7 @@ test.describe("Half-screen desktop layout", () => {
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
0,
);
await expect(explorerToggle).toHaveAccessibleName("Open explorer");
await expect(explorerToggle).toHaveAccessibleName("Open side panel");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
} finally {
await workspace.cleanup();
@@ -65,12 +65,14 @@ test.describe("Workspace pane mounting", () => {
.first()
.elementHandle();
expect(originalComposer).not.toBeNull();
const emptyPanesBeforeSplit = await page.getByTestId("workspace-pane-empty-state").count();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
2,
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(
emptyPanesBeforeSplit + 1,
{ timeout: 30_000 },
);
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(1);
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
expect(originalStillConnected).toBe(true);
@@ -0,0 +1,48 @@
import type { Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
function fileReadRequest(message: WebSocketMessage, path: string): boolean {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
const session =
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as { type?: unknown; mode?: unknown; path?: unknown })
: (envelope as { type?: unknown; mode?: unknown; path?: unknown });
return (
session.type === "file_explorer_request" && session.mode === "file" && session.path === path
);
} catch {
return false;
}
}
export async function delayFileReadResponse(page: Page, path: string) {
let delayed: (() => void) | null = null;
let resolveDelayed: (() => void) | null = null;
const delayedResponse = new Promise<void>((resolve) => {
resolveDelayed = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
server.onMessage((message) => {
ws.send(message);
});
// Hold the outgoing JSON request. The corresponding body response travels as
// binary frames, so intercepting the response cannot identify this read.
ws.onMessage((message) => {
if (delayed || !fileReadRequest(message, path)) {
server.send(message);
return;
}
delayed = () => server.send(message);
resolveDelayed?.();
});
});
return {
waitUntilHeld: () => delayedResponse,
release: () => delayed?.(),
};
}
@@ -13,12 +13,12 @@ function splitSeparator(page: Page): Locator {
}
export async function openExplorerSplit(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open explorer" }).click();
await page.getByRole("button", { name: "Open side panel" }).click();
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
}
export async function hideExplorerSplit(page: Page): Promise<void> {
await page.getByRole("button", { name: "Close explorer" }).click();
await page.getByRole("button", { name: "Close side panel" }).click();
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
}
@@ -17,10 +17,8 @@ function visibleTestId(page: Page, testId: string) {
return page.getByTestId(testId).filter({ visible: true });
}
function explorerPaneTabRow(page: Page) {
return visibleTestId(page, "workspace-tabs-row")
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
.first();
function sidePanel(page: Page) {
return visibleTestId(page, "workspace-side-panel").first();
}
async function selectWorkspaceTab(tab: Locator): Promise<void> {
@@ -32,35 +30,51 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
await expect(tab).toHaveAttribute("aria-selected", "true");
}
export async function ensureExplorerPane(page: Page) {
/** Reveal the Side panel. It opens empty; the caller decides what goes in it. */
export async function ensureSidePanel(page: Page): Promise<Locator> {
const toggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(toggle).toBeVisible({ timeout: 30_000 });
const tabRow = explorerPaneTabRow(page);
if ((await tabRow.count()) === 0) {
const panel = sidePanel(page);
if ((await panel.count()) === 0) {
await toggle.click();
}
await expect(tabRow).toBeVisible({ timeout: 30_000 });
return tabRow;
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
}
/**
* Reveals the Side panel and brings one of its views up in it. Goes through the
* pane's own `+` menu, which is there whether the pane is empty or already loaded.
*/
async function openSidePanelView(
page: Page,
view: { tabTestId: string; menuTestId: string; contentTestId: string; timeout?: number },
): Promise<void> {
const panel = await ensureSidePanel(page);
const tab = panel.getByTestId(view.tabTestId);
if ((await tab.count()) === 0) {
await panel.getByTestId("workspace-new-tab-menu-trigger").click();
await visibleTestId(page, view.menuTestId).first().click();
}
await selectWorkspaceTab(tab);
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
timeout: view.timeout ?? 30_000,
});
}
export async function openChangesPanel(page: Page): Promise<void> {
const tabRow = await ensureExplorerPane(page);
await selectWorkspaceTab(tabRow.getByTestId("workspace-tab-working_diff"));
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
timeout: 30_000,
await openSidePanelView(page, {
tabTestId: "workspace-tab-working_diff",
menuTestId: "workspace-new-tab-menu-changes",
contentTestId: "working-diff-panel",
});
}
export async function openFilesPanel(page: Page): Promise<void> {
const tabRow = await ensureExplorerPane(page);
const filesTab = tabRow.getByTestId("workspace-tab-files");
if ((await filesTab.count()) === 0) {
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
await visibleTestId(page, "workspace-new-tab-menu-files").first().click();
}
await selectWorkspaceTab(filesTab);
await expect(visibleTestId(page, "file-explorer-tree-scroll").first()).toBeVisible({
timeout: 30_000,
await openSidePanelView(page, {
tabTestId: "workspace-tab-files",
menuTestId: "workspace-new-tab-menu-files",
contentTestId: "file-explorer-tree-scroll",
});
}
@@ -71,14 +85,12 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
return;
}
const tabRow = await ensureExplorerPane(page);
const pullRequestTab = tabRow.getByTestId("workspace-tab-pull_request");
if ((await pullRequestTab.count()) === 0) {
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
}
await selectWorkspaceTab(pullRequestTab);
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
await openSidePanelView(page, {
tabTestId: "workspace-tab-pull_request",
menuTestId: "workspace-new-tab-menu-pull-request",
contentTestId: "pr-pane",
timeout: 15_000,
});
}
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
+2 -2
View File
@@ -27,7 +27,7 @@ import { useMutation } from "@tanstack/react-query";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openExplorerSurface } from "@/workspace-tabs/explorer-surface";
import { openSidePanelView } from "@/workspace-tabs/side-panel";
import {
AssistantMessage,
SpeakMessage,
@@ -450,7 +450,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setCurrentPath: false,
});
openExplorerSurface({
openSidePanelView({
isCompact: isMobile,
workspaceKey: buildWorkspaceTabPersistenceKey({
serverId: resolvedServerId,
@@ -132,7 +132,13 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
filePath: path,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
showMobileAgent: usePanelStore.getState().showMobileAgent,
openWorkspaceTabInFocusedPane: useWorkspaceLayoutStore.getState().openTabInFocusedPane,
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
useWorkspaceLayoutStore.getState().openTab({
workspaceKey,
target,
intent: "reveal",
placement,
}),
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
});
},
@@ -8,6 +8,7 @@ import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { isWeb } from "@/constants/platform";
@@ -99,8 +100,11 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
const model = useActiveCompactExplorerSidebarModel(enabled);
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const openWorkspaceTabInFocusedPane = useWorkspaceLayoutStore(
(state) => state.openTabInFocusedPane,
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
const openWorkspaceTabInFocusedPane = useCallback(
(workspaceKey: string, target: WorkspaceTabTarget) =>
openTab({ workspaceKey, target, intent: "reveal" }),
[openTab],
);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { View, Text, Pressable } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -18,6 +18,7 @@ import {
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import { ChangesSurface } from "@/git/diff-pane";
import { changesStateSchema, defaultChangesState, type ChangesState } from "@/panels/changes/state";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { shouldUseCompactExplorerKeyboardPadding } from "@/hooks/keyboard-shift-policy";
@@ -229,7 +230,7 @@ function ExplorerSidebarContent({
<ExplorerTabButton
tab="changes"
active={resolvedTab === "changes"}
label={t("workspace.tabs.explorer.changes")}
label={t("workspace.tabs.sidePanel.changes")}
onTabPress={onTabPress}
testID="explorer-tab-changes"
/>
@@ -237,7 +238,7 @@ function ExplorerSidebarContent({
<ExplorerTabButton
tab="files"
active={resolvedTab === "files"}
label={t("workspace.tabs.explorer.files")}
label={t("workspace.tabs.sidePanel.files")}
onTabPress={onTabPress}
testID="explorer-tab-files"
/>
@@ -267,7 +268,7 @@ function ExplorerSidebarContent({
nativeID="explorer-close"
accessible
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.explorer.close")}
accessibilityLabel={t("workspace.tabs.sidePanel.close")}
hitSlop={8}
>
{({ hovered, pressed }) => (
@@ -329,6 +330,9 @@ function ChangedFilesPane({
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
>) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
const [changesState, setChangesState] = useState<ChangesState>(() =>
changesStateSchema.parse(defaultChangesState),
);
return (
<ChangesSurface
host="explorer"
@@ -338,6 +342,8 @@ function ChangedFilesPane({
enabled={isOpen}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
state={changesState}
onStateChange={setChangesState}
/>
);
}
@@ -24,7 +24,7 @@ import {
} from "react-native";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import * as Clipboard from "expo-clipboard";
import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide-react-native";
@@ -38,6 +38,11 @@ import {
WORKSPACE_TREE_LOADING_ICON_SIZE,
} from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
PaneContentToolbar,
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
} from "@/components/ui/pane-content-toolbar";
import {
useOverlayFlatListScrollbar,
type OverlayFlatListScrollbar,
@@ -136,8 +141,11 @@ function sortTriggerStyle({
return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered];
}
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered];
function iconButtonStyle(
isCompact: boolean,
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
) {
return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact);
}
type ExplorerListRow =
@@ -1041,6 +1049,11 @@ export function FileExplorerPane({
});
}, [requestDirectoryListing]);
const toolbarIconButtonStyle = useCallback(
(state: PressableStateCallbackType) => iconButtonStyle(isCompact, state),
[isCompact],
);
if (!hasWorkspaceScope) {
return (
<View style={styles.centerState}>
@@ -1058,6 +1071,7 @@ export function FileExplorerPane({
>
<FileExplorerPaneContent
error={error}
isCompact={isCompact}
showInitialLoading={showInitialLoading}
showBackFromError={showBackFromError}
listRows={listRows}
@@ -1073,7 +1087,7 @@ export function FileExplorerPane({
handleBackFromError={handleBackFromError}
handleRetry={handleRetry}
sortTriggerStyle={sortTriggerStyle}
iconButtonStyle={iconButtonStyle}
iconButtonStyle={toolbarIconButtonStyle}
/>
</View>
);
@@ -1132,6 +1146,7 @@ function RootCreationContextTarget({
interface FileExplorerPaneContentProps {
error: string | null;
isCompact: boolean;
showInitialLoading: boolean;
showBackFromError: boolean;
listRows: ExplorerListRow[];
@@ -1155,6 +1170,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
const { t } = useTranslation();
const {
error,
isCompact,
showInitialLoading,
showBackFromError,
listRows,
@@ -1229,7 +1245,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<PaneContentToolbar style={styles.paneHeader} testID="files-pane-header">
<Pressable
onPress={handleSortCycle}
style={sortTriggerStyleProp}
@@ -1251,7 +1267,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
accessibilityLabel={t("workspace.fileActions.newFile")}
testID="files-new-file"
>
<FilePlus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<FilePlus
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
</Pressable>
<Pressable
onPress={handleNewFolderAtRoot}
@@ -1261,7 +1280,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
accessibilityLabel={t("workspace.fileActions.newFolder")}
testID="files-new-folder"
>
<FolderPlus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<FolderPlus
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
</Pressable>
</>
) : null}
@@ -1275,9 +1297,15 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
testID="files-hidden-toggle"
>
{showHiddenFiles ? (
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Eye
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
) : (
<EyeOff size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<EyeOff
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
)}
</Pressable>
<Pressable
@@ -1295,14 +1323,20 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<LoadingSpinner
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<RotateCw
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
)}
</View>
</Pressable>
</View>
</View>
</PaneContentToolbar>
<ContextMenu>
<RootCreationContextTarget enabled={Boolean(onNewEntryAtRoot)}>
{listRows.length === 0 ? (
@@ -1662,13 +1696,10 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
paneHeader: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingRight: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
sortTrigger: {
flexDirection: "row",
@@ -1806,16 +1837,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
iconButton: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
iconButtonActive: {
backgroundColor: theme.colors.surface2,
},
+47 -14
View File
@@ -129,9 +129,14 @@ interface SplitContainerProps {
position: "left" | "right" | "top" | "bottom";
}) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onClosePane: (paneId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: () => ReactNode;
renderPaneEmptyState?: (input: {
paneId: string;
paneIsolated: boolean;
onClosePane: () => void;
}) => ReactNode;
focusModeEnabled?: boolean;
onExitFocusMode: () => void;
}
@@ -174,9 +179,10 @@ interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMov
node: SplitNode;
uiTabs: WorkspaceTab[];
focusedPaneId: string | null;
explorerPaneId: string | null;
sidePanelPaneId: string | null;
maximizedPaneId: string | null;
onTogglePaneMaximized: (paneId: string) => void;
paneIsolated: boolean;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
@@ -263,6 +269,7 @@ function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
cachedDescriptor &&
cachedDescriptor.key === tabDescriptor.key &&
cachedDescriptor.kind === tabDescriptor.kind &&
cachedDescriptor.state === tabDescriptor.state &&
workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
) {
next.set(tabDescriptor.tabId, cachedDescriptor);
@@ -404,6 +411,7 @@ export function SplitContainer({
onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane,
onClosePane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState = () => null,
@@ -421,8 +429,8 @@ export function SplitContainer({
} | null>(null);
const maximizedPaneId =
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
const explorerPaneId = useWorkspaceLayoutStore(
(state) => state.explorerPaneIdByWorkspace[workspaceKey] ?? null,
const sidePanelPaneId = useWorkspaceLayoutStore(
(state) => state.sidePanelPaneIdByWorkspace[workspaceKey] ?? null,
);
useEffect(() => {
@@ -451,7 +459,7 @@ export function SplitContainer({
if (action.id !== "workspace.explorer.maximize.toggle") {
return false;
}
const explorerPane = findPaneById(layout.root, explorerPaneId);
const explorerPane = findPaneById(layout.root, sidePanelPaneId);
if (focusModeEnabled || !explorerPane || explorerPane.hidden === true) {
return true;
}
@@ -459,7 +467,7 @@ export function SplitContainer({
handleTogglePaneMaximized(explorerPane.id);
return true;
},
[explorerPaneId, focusModeEnabled, handleTogglePaneMaximized, layout.root, onFocusPane],
[sidePanelPaneId, focusModeEnabled, handleTogglePaneMaximized, layout.root, onFocusPane],
);
useKeyboardActionHandler({
@@ -494,6 +502,11 @@ export function SplitContainer({
[focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId],
);
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
// Isolating a pane (focus mode, maximize) hides its siblings without removing
// them, so the one pane on screen is not really the last one and closing it is not
// what anyone means. That is all this container knows; whether the layout itself
// allows the dismissal is `canDismissPaneInLayout`, which the caller reads.
const paneIsolated = focusModeEnabled || Boolean(maximizedPaneId);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = asWorkspaceTabDragData(event.active.data.current);
@@ -654,7 +667,7 @@ export function SplitContainer({
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
explorerPaneId={explorerPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={handleTogglePaneMaximized}
normalizedServerId={normalizedServerId}
@@ -682,6 +695,8 @@ export function SplitContainer({
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
@@ -863,7 +878,7 @@ function SplitNodeView({
workspaceKey,
uiTabs,
focusedPaneId,
explorerPaneId,
sidePanelPaneId,
maximizedPaneId,
onTogglePaneMaximized,
normalizedServerId,
@@ -891,6 +906,8 @@ function SplitNodeView({
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
paneIsolated,
onClosePane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState,
@@ -942,7 +959,7 @@ function SplitNodeView({
pane={node.pane}
uiTabs={uiTabs}
isFocused={node.pane.id === focusedPaneId}
explorerPaneId={explorerPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={onTogglePaneMaximized}
normalizedServerId={normalizedServerId}
@@ -970,6 +987,8 @@ function SplitNodeView({
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
@@ -994,7 +1013,7 @@ function SplitNodeView({
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={focusedPaneId}
explorerPaneId={explorerPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={onTogglePaneMaximized}
normalizedServerId={normalizedServerId}
@@ -1022,6 +1041,8 @@ function SplitNodeView({
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
@@ -1082,13 +1103,15 @@ function SplitPaneView({
onFocusPane,
onSplitPane: _onSplitPane,
onSplitPaneEmpty: _onSplitPaneEmpty,
paneIsolated,
onClosePane,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
tabDropPreview,
explorerPaneId,
sidePanelPaneId,
maximizedPaneId,
onTogglePaneMaximized,
focusModeEnabled,
@@ -1195,9 +1218,15 @@ function SplitPaneView({
() => onTogglePaneMaximized(paneId),
[onTogglePaneMaximized, paneId],
);
const handleClosePane = useCallback(() => onClosePane(paneId), [onClosePane, paneId]);
return (
<RenderProfile id={`SplitPaneView:${pane.id}`}>
<View ref={paneRef} collapsable={false} style={styles.pane}>
<View
ref={paneRef}
collapsable={false}
style={styles.pane}
testID={pane.id === sidePanelPaneId ? "workspace-side-panel" : `workspace-pane-${pane.id}`}
>
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
@@ -1228,7 +1257,7 @@ function SplitPaneView({
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
showPaneMaximizeAction={pane.id === explorerPaneId && !focusModeEnabled}
showPaneMaximizeAction={pane.id === sidePanelPaneId && !focusModeEnabled}
paneMaximized={pane.id === maximizedPaneId}
onTogglePaneMaximized={handleToggleMaximized}
focusModeEnabled={Boolean(focusModeEnabled)}
@@ -1257,7 +1286,11 @@ function SplitPaneView({
/>
);
})
: (renderPaneEmptyState?.() ?? null)}
: (renderPaneEmptyState?.({
paneId,
paneIsolated,
onClosePane: handleClosePane,
}) ?? null)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
</View>
@@ -1,13 +1,14 @@
import { MIN_TREE_RAIL_WIDTH, clampTreeRailWidth } from "@/stores/panel-store";
import { MIN_TREE_RAIL_WIDTH } from "@/stores/panel-store";
const MIN_CONTENT_WIDTH = 240;
export function resolveVisibleTreeRailWidth(
requestedWidth: number,
containerWidth: number,
minimumWidth = MIN_TREE_RAIL_WIDTH,
): number {
const maximumVisibleWidth = Math.max(MIN_TREE_RAIL_WIDTH, containerWidth - MIN_CONTENT_WIDTH);
return Math.min(clampTreeRailWidth(requestedWidth), maximumVisibleWidth);
const maximumVisibleWidth = Math.max(minimumWidth, containerWidth - MIN_CONTENT_WIDTH);
return Math.min(Math.max(minimumWidth, Math.min(600, requestedWidth)), maximumVisibleWidth);
}
export function acceptTreeRailContainerMeasurement(
@@ -0,0 +1,65 @@
import { useMemo } from "react";
import { Pressable, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { FolderTree } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
} from "@/components/ui/pane-content-toolbar";
import type { Theme } from "@/styles/theme";
const ThemedFolderTree = withUnistyles(FolderTree);
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/**
* Opens and closes a `TreeRail`. Every panel that owns a tree rail uses this one
* button so the affordance reads the same wherever the rail appears; only the
* flag it drives is per-panel.
*/
export function TreeRailToggle({
visible,
testID,
onToggle,
}: {
visible: boolean;
testID: string;
onToggle: () => void;
}) {
const { t } = useTranslation();
const label = t(visible ? "workspace.tree.hideFolderTree" : "workspace.tree.showFolderTree");
const buttonStyle = useMemo(
() => (state: { hovered?: boolean; pressed: boolean }) =>
paneContentToolbarIconButtonStyle(state, visible),
[visible],
);
const accessibilityState = useMemo(() => ({ selected: visible }), [visible]);
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-selected={visible}
testID={testID}
style={buttonStyle}
onPress={onToggle}
>
<ThemedFolderTree size={paneContentToolbarIconSize(false)} uniProps={iconColorMapping} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
}));
+20 -6
View File
@@ -27,16 +27,20 @@ export interface TreeRailProps {
*/
children: ReactNode;
testID?: string;
width?: number;
onWidthChange?: (width: number) => void;
minimumWidth?: number;
}
/** Content-left/tree-right master-detail geometry with one app-wide tree width. */
export function TreeRail({ children, testID }: TreeRailProps) {
export function TreeRail({ children, testID, width, onWidthChange, minimumWidth }: TreeRailProps) {
const [content, tree] = Children.toArray(children);
const requestedWidth = usePanelStore((state) => state.treeRailWidth);
const setTreeRailWidth = usePanelStore((state) => state.setTreeRailWidth);
const sharedWidth = usePanelStore((state) => state.treeRailWidth);
const setSharedWidth = usePanelStore((state) => state.setTreeRailWidth);
const requestedWidth = width ?? sharedWidth;
const { width: viewportWidth } = useWindowDimensions();
const [containerWidth, setContainerWidth] = useState(viewportWidth);
const visibleWidth = resolveVisibleTreeRailWidth(requestedWidth, containerWidth);
const visibleWidth = resolveVisibleTreeRailWidth(requestedWidth, containerWidth, minimumWidth);
const startWidthRef = useRef(visibleWidth);
const resizeWidth = useSharedValue(visibleWidth);
const [resizePressed, setResizePressed] = useState(false);
@@ -62,11 +66,21 @@ export function TreeRail({ children, testID }: TreeRailProps) {
resizeWidth.value = resolveVisibleTreeRailWidth(
startWidthRef.current - event.translationX,
containerWidth,
minimumWidth,
);
})
.onEnd(() => runOnJS(setTreeRailWidth)(resizeWidth.value))
.onEnd(() => runOnJS(onWidthChange ?? setSharedWidth)(resizeWidth.value))
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
[containerWidth, hideResizeGrip, resizeWidth, setTreeRailWidth, showResizeGrip, visibleWidth],
[
containerWidth,
hideResizeGrip,
onWidthChange,
minimumWidth,
resizeWidth,
setSharedWidth,
showResizeGrip,
visibleWidth,
],
);
const railWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
const handleLayout = useCallback(
@@ -0,0 +1,65 @@
import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
/** Shared chrome at the boundary between a workspace pane's tabs and content. */
export function PaneContentToolbar({
children,
style,
testID,
}: {
children: ReactNode;
style?: StyleProp<ViewStyle>;
testID?: string;
}) {
return (
<View style={[styles.toolbar, style]} testID={testID}>
{children}
</View>
);
}
export function paneContentToolbarIconSize(isCompact: boolean): number {
return isCompact ? 18 : 14;
}
export function paneContentToolbarIconButtonStyle(
{ hovered, pressed }: { hovered?: boolean; pressed: boolean },
selected = false,
isCompact = false,
) {
return [
isCompact ? styles.iconButtonCompact : styles.iconButton,
(selected || Boolean(hovered) || pressed) && styles.iconButtonSelected,
];
}
const styles = StyleSheet.create((theme) => ({
toolbar: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
backgroundColor: theme.colors.surface0,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
flexShrink: 0,
},
iconButton: {
width: 24,
height: 24,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
iconButtonCompact: {
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
iconButtonSelected: {
backgroundColor: theme.colors.surface2,
},
}));
+9 -1
View File
@@ -39,6 +39,7 @@ export function Shortcut({
if (displayChord.length === 1) {
return (
<View style={badgeStyle}>
<View style={styles.badgeBackground} />
<Text style={textCombinedStyle}>{formatShortcut(singleCombo, shortcutOs)}</Text>
</View>
);
@@ -49,6 +50,7 @@ export function Shortcut({
{displayChord.map(function (combo) {
return (
<View key={combo.join("+")} style={styles.badge}>
<View style={styles.badgeBackground} />
<Text style={textCombinedStyle}>{formatShortcut(combo, shortcutOs)}</Text>
</View>
);
@@ -59,12 +61,18 @@ export function Shortcut({
const styles = StyleSheet.create((theme) => ({
badge: {
position: "relative",
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
borderWidth: 0,
},
badgeBackground: {
...StyleSheet.absoluteFillObject,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface4,
opacity: theme.opacity[50],
},
sequence: {
flexDirection: "row",
alignItems: "center",
@@ -52,7 +52,7 @@ import {
buildWorkspaceTabPersistenceKey,
type WorkspaceDraftTabSetup,
} from "@/workspace-tabs/model";
import { openExplorerSurface } from "@/workspace-tabs/explorer-surface";
import { openSidePanelView } from "@/workspace-tabs/side-panel";
const EMPTY_PENDING_PERMISSIONS = new Map();
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
@@ -451,7 +451,7 @@ export function WorkspaceDraftAgentTab({
if (attachment.kind !== "review") {
return;
}
openExplorerSurface({
openSidePanelView({
isCompact: isCompactFormFactor,
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? "" }),
checkout: { serverId, cwd: attachment.attachment.cwd, isGit: true },
@@ -69,6 +69,50 @@ describe("focused chat target", () => {
parentTabIdByTabId: { "changes-tab": "agent-tab" },
} as WorkspaceLayout;
expect(resolveFocusedChatTarget({ serverId: "server-1", layout })).toEqual({
tabId: "agent-tab",
draftKey: "agent:server-1:agent-1",
});
});
it("targets a chat in a neighbouring pane when the focused pane holds none", () => {
const layout = {
root: {
kind: "group",
group: {
id: "group-1",
direction: "horizontal",
sizes: [0.5, 0.5],
children: [
{
kind: "pane",
pane: {
id: "main",
tabIds: ["agent-tab"],
focusedTabId: "agent-tab",
tabs: [
{
tabId: "agent-tab",
target: { kind: "agent", agentId: "agent-1" },
createdAt: 1,
},
],
},
},
{
kind: "pane",
pane: {
id: "explorer",
tabIds: ["changes-tab"],
focusedTabId: "changes-tab",
tabs: [{ tabId: "changes-tab", target: { kind: "working_diff" }, createdAt: 2 }],
},
},
],
},
},
focusedPaneId: "explorer",
} as unknown as WorkspaceLayout;
expect(resolveFocusedChatTarget({ serverId: "server-1", layout })).toEqual({
tabId: "agent-tab",
draftKey: "agent:server-1:agent-1",
@@ -1,5 +1,6 @@
import { buildDraftStoreKey } from "@/stores/draft-keys";
import {
collectAllPanes,
collectAllTabs,
findPaneById,
type WorkspaceLayout,
@@ -33,6 +34,12 @@ function resolveChatTab(
return null;
}
/**
* The chat a file should attach to. The focused pane answers first, then the tab it
* came from — a file opened beside a chat belongs to that chat. Failing both, any
* chat the user can see: standing in the Side panel looking at Changes, "add to
* chat" plainly means the chat in the pane next to it.
*/
export function resolveFocusedChatTarget(input: {
serverId: string;
layout: WorkspaceLayout | undefined;
@@ -40,20 +47,34 @@ export function resolveFocusedChatTarget(input: {
if (!input.layout) {
return null;
}
const pane = findPaneById(input.layout.root, input.layout.focusedPaneId);
const focusedTabId = pane?.focusedTabId;
if (!focusedTabId) {
return null;
}
const tabs = collectAllTabs(input.layout.root);
const tab = tabs.find((candidate) => candidate.tabId === focusedTabId);
const focusedChat = resolveChatTab(input.serverId, tab);
if (focusedChat) {
return focusedChat;
const focusedTabId = findPaneById(input.layout.root, input.layout.focusedPaneId)?.focusedTabId;
if (focusedTabId) {
const focusedChat = resolveChatTab(
input.serverId,
tabs.find((candidate) => candidate.tabId === focusedTabId),
);
if (focusedChat) {
return focusedChat;
}
const parentTabId = input.layout.parentTabIdByTabId?.[focusedTabId];
const parentChat = resolveChatTab(
input.serverId,
tabs.find((candidate) => candidate.tabId === parentTabId),
);
if (parentChat) {
return parentChat;
}
}
const parentTabId = input.layout.parentTabIdByTabId?.[focusedTabId];
return resolveChatTab(
input.serverId,
tabs.find((candidate) => candidate.tabId === parentTabId),
);
for (const pane of collectAllPanes(input.layout.root)) {
const paneChat = resolveChatTab(
input.serverId,
tabs.find((candidate) => candidate.tabId === pane.focusedTabId),
);
if (paneChat) {
return paneChat;
}
}
return null;
}
@@ -291,9 +291,11 @@ describe("mountBrowserAutomationHandler", () => {
if (!workspaceKey) {
throw new Error("Expected workspace key");
}
const previousFocusedTabId = useWorkspaceLayoutStore
.getState()
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
const previousFocusedTabId = useWorkspaceLayoutStore.getState().openTab({
workspaceKey: workspaceKey,
target: { kind: "draft", draftId: "human-draft" },
intent: "reveal",
});
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
@@ -336,9 +336,10 @@ async function openBrowserTabForRequest(params: {
message: "Cannot create a browser tab without a workspace context.",
});
}
useWorkspaceLayoutStore.getState().openTabInBackground(workspaceKey, {
kind: "browser",
browserId,
useWorkspaceLayoutStore.getState().openTab({
workspaceKey,
target: { kind: "browser", browserId },
intent: "background",
});
if (browserHost?.executeAutomationCommand) {
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import { createDefaultLayout } from "@/stores/workspace-layout-store";
import { openTabInLayoutFocused } from "@/stores/workspace-layout-actions";
import { FOCUSED_PANE_PLACEMENT, openTabInLayoutFocused } from "@/stores/workspace-layout-actions";
import { resolveBrowserNewTabRequest, type BrowserNewTabRequest } from ".";
function createLayoutWithBrowser(browserId: string): WorkspaceLayout {
@@ -9,7 +9,8 @@ function createLayoutWithBrowser(browserId: string): WorkspaceLayout {
layout: createDefaultLayout(),
target: { kind: "browser", browserId },
now: 1,
explorerPaneId: null,
placement: FOCUSED_PANE_PLACEMENT,
sidePanelPaneId: null,
}).layout;
}
@@ -73,6 +73,7 @@ function BrowserPanel() {
export const browserPanelRegistration: PanelRegistration<"browser"> = {
kind: "browser",
resourceKey: (target) => target.browserId,
component: BrowserPanel,
useDescriptor: useBrowserPanelDescriptor,
};
+67 -65
View File
@@ -2,10 +2,12 @@ import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { Theme } from "@/styles/theme";
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
import type { FileEditorStatus } from "./editor/model";
import { FileTreeToggle } from "./tree-toggle";
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -39,74 +41,77 @@ export function FilePanelBar({
{ value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" },
];
return (
<View style={styles.chrome} testID="file-panel-bar">
<View style={styles.row}>
<View style={styles.metadata}>
<Text
style={styles.whisper}
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
>
{formatFileSize(size)}
</Text>
{lineCount !== undefined ? (
<View style={styles.chrome}>
<PaneContentToolbar testID="file-panel-bar">
<View style={styles.row}>
<View style={styles.metadata}>
<Text
style={styles.whisper}
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
>
{t("panels.file.editor.lines", { count: lineCount })}
{formatFileSize(size)}
</Text>
) : null}
</View>
<View
style={styles.status}
accessibilityLabel={
editorStatus
? t("panels.file.editor.editorStatus", { status: editorStatus })
: undefined
}
>
{editorStatus === "dirty" ? (
<View
style={styles.dirtyDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
{lineCount !== undefined ? (
<Text
style={styles.whisper}
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
>
{t("panels.file.editor.lines", { count: lineCount })}
</Text>
) : null}
</View>
<View
style={styles.status}
accessibilityLabel={
editorStatus
? t("panels.file.editor.editorStatus", { status: editorStatus })
: undefined
}
>
{editorStatus === "dirty" ? (
<View
style={styles.dirtyDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
/>
) : null}
{editorStatus === "saving" ? (
<>
<ThemedSpinner size={14} uniProps={spinnerMapping} />
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
</>
) : null}
{editorStatus === "error" ? (
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
) : null}
{vimMode ? (
<Text
style={styles.vim}
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
>
{vimMode}
</Text>
) : null}
{cursor ? (
<Text
style={styles.whisper}
accessibilityLabel={t("panels.file.editor.cursor", cursor)}
>
Ln {cursor.line}, Col {cursor.column}
</Text>
) : null}
</View>
{mode && onModeChange ? (
<SegmentedControl
size="xs"
value={mode}
onValueChange={onModeChange}
testID="file-preview-mode"
options={previewModes}
/>
) : null}
{editorStatus === "saving" ? (
<>
<ThemedSpinner size={14} uniProps={spinnerMapping} />
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
</>
) : null}
{editorStatus === "error" ? (
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
) : null}
{vimMode ? (
<Text
style={styles.vim}
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
>
{vimMode}
</Text>
) : null}
{cursor ? (
<Text
style={styles.whisper}
accessibilityLabel={t("panels.file.editor.cursor", cursor)}
>
Ln {cursor.line}, Col {cursor.column}
</Text>
) : null}
<FileTreeToggle />
</View>
{mode && onModeChange ? (
<SegmentedControl
size="xs"
value={mode}
onValueChange={onModeChange}
testID="file-preview-mode"
options={previewModes}
/>
) : null}
</View>
</PaneContentToolbar>
{conflict ? <FileConflictAlert state={conflict} /> : null}
</View>
);
@@ -121,16 +126,13 @@ function formatFileSize(size: number): string {
const styles = StyleSheet.create((theme) => ({
chrome: {
flexShrink: 0,
backgroundColor: theme.colors.surface1,
},
row: {
minHeight: 32,
height: "100%",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
metadata: {
flex: 1,
+7 -1
View File
@@ -40,9 +40,15 @@ export function useLiveFile(input: {
return {
file: snapshot.observation?.status === "ready" ? snapshot.observation.file : null,
error: snapshot.read.status === "error" ? snapshot.read.error : null,
isFetching: snapshot.read.status === "pending",
// Effects start reads after the first render. An enabled target is therefore
// already in the producer's initial/retargeting display state.
isFetching:
input.enabled &&
(snapshot.read.status === "pending" ||
(snapshot.read.status === "idle" && snapshot.observation === null)),
isRetrying: snapshot.read.status === "pending" && snapshot.read.requested,
refresh: model.refresh,
model,
snapshot,
};
}
@@ -117,6 +117,31 @@ async function waitForRead(
}
describe("LiveFileModel", () => {
it("reports initial and read-pending work before any terminal observation", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({ session, target: { cwd: "/workspace", path: "file.ts" }, liveUpdates: false });
await waitForRead(session, 1);
expect(model.getSnapshot()).toEqual({
observation: null,
read: { status: "pending", requested: false },
});
});
it("reports read error as terminal and recovers on refresh", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
model.open({ session, target: { cwd: "/workspace", path: "file.ts" }, liveUpdates: false });
await waitForRead(session, 1);
session.reads[0].reject(new Error("read failed"));
await vi.waitFor(() =>
expect(model.getSnapshot().read).toEqual({ status: "error", error: "read failed" }),
);
model.refresh();
await waitForRead(session, 2);
session.reads[1].resolve(file("revision-2"));
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready"));
});
it("does not publish a missing candidate when the following read succeeds", async () => {
const session = new TestLiveFileSession();
const model = new LiveFileModel();
+20 -70
View File
@@ -8,7 +8,7 @@ import React, {
useState,
useSyncExternalStore,
} from "react";
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { Image as RNImage, ScrollView as RNScrollView, Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
@@ -20,11 +20,8 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { getFileNameFromPath } from "@/attachments/utils";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { useRetainedPanelActive } from "@/components/retained-panel";
@@ -33,6 +30,8 @@ import { isFileQueryEnabled } from "@/components/file-pane-enabled";
import { isWeb } from "@/constants/platform";
import { useAppSettings } from "@/hooks/use-settings";
import { useLiveFile } from "./live-file/hook";
import { useFilePreview } from "./preview-lifecycle/hook";
import { resolveFilePreviewLifecycle } from "./preview-lifecycle/model";
import { FilePanelBar } from "./bar";
import { FileHtmlPreview } from "./html-preview";
import { FileMarkdownPreview } from "./markdown-preview";
@@ -92,38 +91,6 @@ function formatFileSize({ size }: { size: number }): string {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
async function createFilePanePreview(file: FileReadResult | null): Promise<{
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
}> {
if (!file) {
return { file: null, imageAttachment: null };
}
const explorerFile = explorerFileFromReadResult(file);
if (file.kind !== "image") {
return { file: explorerFile, imageAttachment: null };
}
const imageAttachment = await persistAttachmentFromBytes({
id: createPreviewAttachmentId({
mimeType: file.mime,
path: file.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.bytes.byteLength,
}),
bytes: file.bytes,
mimeType: file.mime,
fileName: getFileNameFromPath(file.path),
});
return {
file: explorerFile,
imageAttachment,
};
}
function clampLineSelection(input: {
lineStart?: number;
lineEnd?: number;
@@ -274,7 +241,7 @@ function FilePreviewBody({
if (isLoading && !preview) {
return (
<View style={styles.centerState}>
<View style={styles.centerState} testID="file-preview-loading">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
@@ -283,7 +250,7 @@ function FilePreviewBody({
if (!preview) {
return (
<View style={styles.centerState}>
<View style={styles.centerState} testID="file-preview-unsupported">
<Text style={styles.emptyText}>{t("panels.file.noPreview")}</Text>
</View>
);
@@ -411,11 +378,6 @@ export function FilePane({
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
const [resolvedPreview, setResolvedPreview] = useState<{
key: string | null;
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
}>({ key: null, file: null, imageAttachment: null });
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
@@ -453,26 +415,16 @@ export function FilePane({
liveUpdates: supportsEditing,
});
useEffect(() => {
if (!liveFile.file) return;
let active = true;
const key = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
void (async () => {
const nextPreview = await createFilePanePreview(liveFile.file);
if (active) setResolvedPreview({ key, ...nextPreview });
})();
return () => {
active = false;
};
}, [liveFile.file, readTarget]);
const targetKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
const previewLifecycle = useFilePreview({
targetKey,
liveFileSnapshot: liveFile.snapshot,
});
const previewKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
useEffect(() => setPreviewMode("preview"), [previewKey]);
useEffect(() => setPreviewMode("preview"), [targetKey]);
const preview = resolvedPreview.key === previewKey ? resolvedPreview.file : null;
const imagePreviewUri = useAttachmentPreviewUrl(
resolvedPreview.key === previewKey ? resolvedPreview.imageAttachment : null,
);
const { file: preview, imageAttachment } = resolveFilePreviewLifecycle(previewLifecycle);
const imagePreviewUri = useAttachmentPreviewUrl(imageAttachment);
const isRenderable = isRenderablePreview(preview, location.path);
const editable = isEditableTextFile({
preview,
@@ -481,7 +433,11 @@ export function FilePane({
const canTogglePreviewMode = isRenderable && !location.lineStart;
const lineCount =
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
const errorMessage = getFileErrorMessage(liveFile.error, t("panels.file.failedToLoad"));
const errorMessage = previewLifecycle.status === "error" ? previewLifecycle.message : null;
const isLoading =
previewLifecycle.status === "initial" ||
previewLifecycle.status === "read_pending" ||
previewLifecycle.status === "preparing";
return (
<FilePanePresentation
@@ -500,7 +456,7 @@ export function FilePane({
editable={editable}
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
errorMessage={errorMessage}
isLoading={liveFile.isFetching}
isLoading={isLoading}
isMobile={isMobile}
location={location}
navigationRevision={navigationRevision}
@@ -513,12 +469,6 @@ function isRenderablePreview(preview: ExplorerFile | null, path: string): boolea
return preview?.kind === "text" && filePreviewRenderKind(path) !== null;
}
function getFileErrorMessage(error: unknown, fallback: string): string | null {
if (!error) return null;
if (typeof error === "string") return error;
return error instanceof Error ? error.message : fallback;
}
function isEditableTextFile(input: {
preview: ExplorerFile | null;
supportsEditing: boolean;
@@ -0,0 +1,23 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import type { LiveFileSnapshot } from "../live-file/model";
import {
createFilePanePreview,
FilePreviewLifecycleModel,
type FilePreviewLifecycleSnapshot,
} from "./model";
/** Bridges the raw-file owner to the preview preparation owner. */
export function useFilePreview(input: {
targetKey: string | null;
liveFileSnapshot: LiveFileSnapshot;
}): FilePreviewLifecycleSnapshot {
const [model] = useState(() => new FilePreviewLifecycleModel(createFilePanePreview));
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const { targetKey, liveFileSnapshot } = input;
useEffect(() => {
model.setSource({ targetKey, liveFileSnapshot });
}, [liveFileSnapshot, model, targetKey]);
return snapshot;
}
@@ -0,0 +1,157 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { LiveFileSnapshot } from "../live-file/model";
import { describe, expect, it } from "vitest";
import {
FilePreviewLifecycleModel,
type FilePanePreview,
type FilePreviewLifecycleSnapshot,
} from "./model";
function previewFile(content: string) {
return {
kind: "text" as const,
path: "file.ts",
size: 7,
encoding: "utf-8" as const,
hasBom: false,
modifiedAt: "2026-08-20T00:00:00.000Z",
content,
};
}
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(error: Error): void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { promise, resolve, reject };
}
function file(revision = "revision-1", path = "file.ts"): FileReadResult {
return {
bytes: new TextEncoder().encode("preview"),
mime: "text/plain",
size: 7,
path,
kind: "text",
modifiedAt: "2026-08-20T00:00:00.000Z",
revision,
};
}
function pending(): LiveFileSnapshot {
return { observation: null, read: { status: "pending", requested: false } };
}
function completed(rawFile: FileReadResult): LiveFileSnapshot {
return {
observation: {
status: "ready",
file: rawFile,
version: {
status: "ready",
cwd: "/workspace",
path: rawFile.path,
size: rawFile.size,
modifiedAt: rawFile.modifiedAt,
revision: rawFile.revision,
},
},
read: { status: "idle" },
};
}
function source(
targetKey: string,
snapshot: LiveFileSnapshot,
): {
targetKey: string;
liveFileSnapshot: LiveFileSnapshot;
} {
return { targetKey, liveFileSnapshot: snapshot };
}
describe("FilePreviewLifecycleModel", () => {
it("represents pending raw reads, preparation, ready previews, unsupported files, and conversion failures", async () => {
const first = deferred<FilePanePreview | null>();
const second = deferred<FilePanePreview | null>();
const third = deferred<FilePanePreview | null>();
const preparations = [first, second, third];
const model = new FilePreviewLifecycleModel(() => preparations.shift()!.promise);
const preview: FilePanePreview = {
file: previewFile("preview"),
imageAttachment: null,
};
model.setSource(source("/workspace:file.ts", pending()));
expect(model.getSnapshot()).toEqual({ status: "read_pending" });
model.setSource(source("/workspace:file.ts", completed(file("one"))));
expect(model.getSnapshot()).toEqual({ status: "preparing" });
first.resolve(preview);
await expectSnapshot(model, { status: "ready", preview });
model.setSource(source("/workspace:file.ts", pending()));
expect(model.getSnapshot()).toEqual({ status: "read_pending", preview });
model.setSource(
source("/workspace:file.ts", {
observation: {
status: "error",
cwd: "/workspace",
path: "file.ts",
error: "Requested path is not a file",
},
read: { status: "pending", requested: true },
}),
);
expect(model.getSnapshot()).toEqual({ status: "read_pending", preview });
model.setSource(source("/workspace:file.ts", completed(file("two"))));
expect(model.getSnapshot()).toEqual({ status: "preparing", preview });
second.resolve(null);
await expectSnapshot(model, { status: "unsupported" });
model.setSource(source("/workspace:file.ts", completed(file("three"))));
expect(model.getSnapshot()).toEqual({ status: "preparing" });
third.reject(new Error("attachment failed"));
await expectSnapshot(model, { status: "error", message: "attachment failed" });
});
it("does not publish a stale conversion after retargeting", async () => {
const first = deferred<FilePanePreview | null>();
const second = deferred<FilePanePreview | null>();
const model = new FilePreviewLifecycleModel((rawFile) =>
rawFile.revision === "one" ? first.promise : second.promise,
);
const nextPreview: FilePanePreview = {
file: previewFile("two"),
imageAttachment: null,
};
model.setSource(source("/workspace:file.ts", completed(file("one"))));
model.setSource(source("/workspace:second.ts", completed(file("two", "second.ts"))));
first.resolve({ file: previewFile("one"), imageAttachment: null });
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "preparing" });
second.resolve(nextPreview);
await expectSnapshot(model, { status: "ready", preview: nextPreview });
});
});
async function expectSnapshot(
model: FilePreviewLifecycleModel,
expected: FilePreviewLifecycleSnapshot,
): Promise<void> {
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(model.getSnapshot()).toEqual(expected);
}
@@ -0,0 +1,190 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { ExplorerFile } from "@/stores/session-store";
import type { LiveFileSnapshot } from "../live-file/model";
export interface FilePanePreview {
file: ExplorerFile;
imageAttachment: AttachmentMetadata | null;
}
export type FilePreviewLifecycleSnapshot =
| { status: "initial" }
| { status: "read_pending"; preview?: FilePanePreview }
| { status: "preparing"; preview?: FilePanePreview }
| { status: "ready"; preview: FilePanePreview }
| { status: "error"; message: string; preview?: FilePanePreview }
| { status: "unsupported" };
const initialSnapshot: FilePreviewLifecycleSnapshot = { status: "initial" };
/** Converts a completed raw read into the preview resources consumed by FilePane. */
export async function createFilePanePreview(file: FileReadResult): Promise<FilePanePreview | null> {
const explorerFile = explorerFileFromReadResult(file);
if (file.kind !== "image") {
return { file: explorerFile, imageAttachment: null };
}
const imageAttachment = await persistAttachmentFromBytes({
id: createPreviewAttachmentId({
mimeType: file.mime,
path: file.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.bytes.byteLength,
}),
bytes: file.bytes,
mimeType: file.mime,
fileName: getFileNameFromPath(file.path),
});
return { file: explorerFile, imageAttachment };
}
/** Owns conversion after LiveFileModel has produced a raw file snapshot. */
export class FilePreviewLifecycleModel {
private readonly listeners = new Set<() => void>();
private snapshot: FilePreviewLifecycleSnapshot = initialSnapshot;
private generation = 0;
private preparedSourceKey: string | null = null;
private targetKey: string | null = null;
constructor(
private readonly prepare: (file: FileReadResult) => Promise<FilePanePreview | null>,
) {}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FilePreviewLifecycleSnapshot => this.snapshot;
setSource(input: { targetKey: string | null; liveFileSnapshot: LiveFileSnapshot }): void {
const targetChanged = input.targetKey !== this.targetKey;
const retainedPreview = targetChanged ? undefined : filePreviewFromLifecycle(this.snapshot);
if (targetChanged) {
this.targetKey = input.targetKey;
this.preparedSourceKey = null;
this.generation += 1;
}
const source = getReadySource(input);
if (source) {
if (source.key === this.preparedSourceKey) {
return;
}
this.preparedSourceKey = source.key;
const generation = ++this.generation;
this.setSnapshot({
status: "preparing",
...(retainedPreview ? { preview: retainedPreview } : {}),
});
void this.prepareSource({ file: source.file, generation, retainedPreview });
return;
}
const nextSnapshot = getNonReadySnapshot(input.liveFileSnapshot);
if (nextSnapshot.status === "initial" && retainedPreview) {
return;
}
const retainedSnapshot = retainedPreview
? { ...nextSnapshot, preview: retainedPreview }
: nextSnapshot;
if (sameNonReadyState(this.snapshot, retainedSnapshot)) {
return;
}
this.preparedSourceKey = null;
this.generation += 1;
this.setSnapshot(retainedSnapshot);
}
private setSnapshot(snapshot: FilePreviewLifecycleSnapshot): void {
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
private async prepareSource(input: {
file: FileReadResult;
generation: number;
retainedPreview?: FilePanePreview;
}): Promise<void> {
try {
const preview = await this.prepare(input.file);
if (input.generation !== this.generation) return;
this.setSnapshot(preview ? { status: "ready", preview } : { status: "unsupported" });
} catch (error) {
if (input.generation !== this.generation) return;
this.setSnapshot({
status: "error",
message: errorMessage(error),
...(input.retainedPreview ? { preview: input.retainedPreview } : {}),
});
}
}
}
export function filePreviewFromLifecycle(
snapshot: FilePreviewLifecycleSnapshot,
): FilePanePreview | undefined {
return "preview" in snapshot ? snapshot.preview : undefined;
}
export function resolveFilePreviewLifecycle(snapshot: FilePreviewLifecycleSnapshot): {
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
} {
const preview = filePreviewFromLifecycle(snapshot);
return preview ?? { file: null, imageAttachment: null };
}
function getReadySource(input: {
targetKey: string | null;
liveFileSnapshot: LiveFileSnapshot;
}): { key: string; file: FileReadResult } | null {
const observation = input.liveFileSnapshot.observation;
if (!input.targetKey || observation?.status !== "ready") {
return null;
}
const { version } = observation;
if (input.targetKey !== `${version.cwd}:${version.path}`) {
return null;
}
return {
key: `${input.targetKey}:${version.revision ?? version.modifiedAt}:${version.size}`,
file: observation.file,
};
}
function getNonReadySnapshot(snapshot: LiveFileSnapshot): FilePreviewLifecycleSnapshot {
if (snapshot.read.status === "error") {
return { status: "error", message: snapshot.read.error };
}
if (snapshot.read.status === "pending") {
return { status: "read_pending" };
}
if (snapshot.observation?.status === "error") {
return { status: "error", message: snapshot.observation.error };
}
if (snapshot.observation?.status === "missing") {
return { status: "error", message: "File not found" };
}
return initialSnapshot;
}
function sameNonReadyState(
current: FilePreviewLifecycleSnapshot,
next: FilePreviewLifecycleSnapshot,
): boolean {
if (current.status !== next.status) {
return false;
}
return current.status !== "error" || next.status !== "error" || current.message === next.message;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Failed to prepare preview";
}
@@ -0,0 +1,26 @@
import { TreeRailToggle } from "@/components/tree-rail-toggle";
import { useCallback } from "react";
import { useIsCompactFormFactor } from "@/constants/layout";
import { defaultFileState, fileStateSchema } from "@/panels/file/state";
import { usePanelState } from "@/panels/use-panel-state";
/**
* Self-wired so `FilePanelBar` stays a leaf of the file pane tree — the bar is
* rendered several components below `FilePanel`, which is where the rail lives,
* and threading a callback down that path buys nothing.
*/
export function FileTreeToggle() {
const isCompact = useIsCompactFormFactor();
const [fileState, setFileState] = usePanelState(fileStateSchema, defaultFileState);
const visible = fileState.treeVisible;
const toggle = useCallback(
() => setFileState({ ...fileState, treeVisible: !visible }),
[fileState, setFileState, visible],
);
// Compact layouts reach the tree through the explorer panel; there is no rail
// beside the file to open or close.
if (isCompact) {
return null;
}
return <TreeRailToggle visible={visible} testID="file-toggle-tree" onToggle={toggle} />;
}
@@ -3,7 +3,6 @@ import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
import { normalizeBranchOptionName } from "@/utils/branch-suggestions";
@@ -13,6 +12,8 @@ interface CommitsSectionProps {
serverId: string;
cwd: string;
onCommitPress: (sha: string) => void;
collapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
}
function CommitsSectionSkeleton() {
@@ -82,11 +83,15 @@ function CommitsSectionContent({
);
}
export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) {
export function CommitsSection({
serverId,
cwd,
onCommitPress,
collapsed = true,
onCollapsedChange,
}: CommitsSectionProps) {
const { t } = useTranslation();
const { preferences, updatePreferences } = useChangesPreferences();
const isPanelActive = useRetainedPanelActive();
const collapsed = preferences.commitsCollapsed;
const [now, setNow] = useState(() => new Date());
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
const query = useCheckoutCommitsQuery({
@@ -99,8 +104,8 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
if (collapsed) {
setNow(new Date());
}
void updatePreferences({ commitsCollapsed: !collapsed });
}, [collapsed, updatePreferences]);
onCollapsedChange?.(!collapsed);
}, [collapsed, onCollapsedChange]);
useEffect(() => {
if (collapsed || !isPanelActive) {
+130 -232
View File
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { TreeRail } from "@/components/tree-rail";
import { TreeRailToggle } from "@/components/tree-rail-toggle";
import { DiffStat } from "@/components/diff-stat";
import {
View,
@@ -13,13 +14,12 @@ import {
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
AlignJustify,
ChevronDown,
Columns2,
FolderTree,
ListChevronsDownUp,
ListChevronsUpDown,
Maximize2,
@@ -29,6 +29,8 @@ import {
WrapText,
} from "lucide-react-native";
import { type ParsedDiffFile } from "@/git/use-diff-query";
import type { ChangesState } from "@/panels/changes/state";
import { defaultChangesState } from "@/panels/changes/state";
import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document";
import { FileHeader } from "@/git/file-header";
import {
@@ -41,7 +43,6 @@ import {
import { DiffFolderRow } from "@/git/diff-folder-row";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { CommitsSection } from "@/git/commits-section/commits-section";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings";
import {
DropdownMenu,
@@ -68,10 +69,15 @@ import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { confirmDialog } from "@/utils/confirm-dialog";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { usePanelStore } from "@/stores/panel-store";
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
PaneContentToolbar,
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
} from "@/components/ui/pane-content-toolbar";
import { FOCUSED_PANE_PLACEMENT, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { WorkspaceTabPlacement } from "@/stores/workspace-layout-actions";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { isWeb } from "@/constants/platform";
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
import { DiffTooLargeState } from "@/git/diff-too-large-state";
@@ -163,6 +169,8 @@ interface ChangesSurfaceProps {
focusRequestId?: number;
onOpenFile?: (path: string) => void;
onAddToChat?: (path: string) => void;
state?: ChangesState;
onStateChange?: (state: ChangesState) => void;
}
type PressableStyleFn = (
@@ -176,10 +184,10 @@ const ThemedAlignJustify = withUnistyles(AlignJustify);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedPilcrow = withUnistyles(Pilcrow);
const ThemedWrapText = withUnistyles(WrapText);
const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
const ThemedMaximize2 = withUnistyles(Maximize2);
const noopStateChange = () => {};
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedMoreHorizontal = withUnistyles(MoreHorizontal);
const DIFF_OPTIONS_WHITESPACE_ICON = (
@@ -216,8 +224,8 @@ export function DiffLayoutToggle({
onToggle,
}: DiffLayoutToggleProps) {
const defaultToggleStyle = useMemo(
() => buildToggleButtonStyle(false, styles.toolbarIconButton),
[],
() => buildToggleButtonStyle(false, undefined, isMobile),
[isMobile],
);
const { t } = useTranslation();
const label =
@@ -316,39 +324,6 @@ export function DiffModeMenu({
);
}
interface DesktopTreeToggleProps {
visible: boolean;
toggleStyle?: PressableStyleFn;
onToggle: () => void;
}
function DesktopTreeToggle({ visible, toggleStyle, onToggle }: DesktopTreeToggleProps) {
const { t } = useTranslation();
const defaultToggleStyle = useMemo(
() => buildToggleButtonStyle(visible, styles.toolbarIconButton),
[visible],
);
const label = t(visible ? "workspace.git.diff.hideTreeView" : "workspace.git.diff.showTreeView");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
testID="changes-toggle-tree"
style={toggleStyle ?? defaultToggleStyle}
onPress={onToggle}
>
<ThemedFolderTree size={14} uniProps={foregroundMutedIconColorMapping} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
interface ChangesToolbarProps {
branchName: string | null;
allFilesCollapsed: boolean;
@@ -369,7 +344,6 @@ interface ChangesToolbarProps {
refreshSupported: boolean;
selectedDiffStat: { additions: number; deletions: number } | null;
serverId: string;
treeToggleStyle: PressableStyleFn;
workspaceId?: string | null;
wrapLines: boolean;
onRefresh: () => void;
@@ -400,14 +374,13 @@ function ChangesToolbar(props: ChangesToolbarProps) {
isMobile,
selectedDiffStat,
serverId,
treeToggleStyle,
workspaceId,
onSelectBase,
onSelectUncommitted,
onToggleDesktopTree,
} = props;
return (
<View style={styles.changesToolbar} testID="changes-header">
<PaneContentToolbar style={styles.changesToolbar} testID="changes-header">
<View style={styles.changesToolbarIdentity}>
<DiffModeMenu
diffMode={diffMode}
@@ -433,16 +406,16 @@ function ChangesToolbar(props: ChangesToolbarProps) {
</View>
<View style={styles.changesToolbarControls}>
{!isMobile && hasFiles ? (
<DesktopTreeToggle
<TreeRailToggle
visible={desktopTreeVisible}
toggleStyle={treeToggleStyle}
testID="changes-toggle-tree"
onToggle={onToggleDesktopTree}
/>
) : null}
{isMobile ? <GitActionsSplitButton gitActions={gitActions} /> : null}
<ChangesOptionsMenu {...props} />
</View>
</View>
</PaneContentToolbar>
);
}
@@ -531,7 +504,7 @@ function ChangesOptionsMenu({
style={overflowToggleStyle}
>
<ThemedMoreHorizontal
size={isMobile ? 18 : 14}
size={paneContentToolbarIconSize(isMobile)}
uniProps={foregroundMutedIconColorMapping}
/>
</DropdownMenuTrigger>
@@ -606,8 +579,6 @@ function ChangesOptionsMenu({
const ThemedRotateCw = withUnistyles(RotateCw);
const EMPTY_PATH_LIST: string[] = [];
function computeEmptyMessage(
hideWhitespace: boolean,
diffMode: "uncommitted" | "base",
@@ -793,74 +764,32 @@ function buildDiffModeTriggerStyle(): PressableStyleFn {
];
}
function buildOverflowButtonStyle(): PressableStyleFn {
return ({ hovered, pressed }) => [
styles.overflowButton,
(Boolean(hovered) || pressed) && styles.toggleButtonSelected,
];
function buildOverflowButtonStyle(isMobile: boolean): PressableStyleFn {
return (state) => paneContentToolbarIconButtonStyle(state, false, isMobile);
}
function buildToggleButtonStyle(
selected: boolean,
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
baseStyles?: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
isMobile = false,
): PressableStyleFn {
return ({ hovered, pressed }) => [
baseStyles,
(selected || Boolean(hovered) || pressed) && styles.toggleButtonSelected,
];
}
function useCollapsedDiffFolders({
workspaceId,
cwd,
}: {
workspaceId?: string | null;
cwd: string;
}) {
const workspaceStateKey = useMemo(
() =>
buildWorkspaceExplorerStateKey({
workspaceId,
workspaceRoot: cwd.trim(),
}),
[cwd, workspaceId],
);
const collapsedFolders = usePanelStore((state) =>
workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined,
);
const setCollapsedFolders = usePanelStore((state) => state.setDiffCollapsedFoldersForWorkspace);
const stableCollapsedFolders = collapsedFolders ?? EMPTY_PATH_LIST;
const updateCollapsedFolders = useCallback(
(paths: string[]) => {
if (workspaceStateKey) {
setCollapsedFolders(workspaceStateKey, paths);
}
},
[setCollapsedFolders, workspaceStateKey],
);
return {
collapsedFolders: stableCollapsedFolders,
updateCollapsedFolders,
};
return (state) => [baseStyles, paneContentToolbarIconButtonStyle(state, selected, isMobile)];
}
function ChangedFilesTree({
workspaceId,
cwd,
files,
mode,
onSelectFile,
collapsedFolderPaths,
onCollapsedFolderPathsChange,
}: {
workspaceId?: string | null;
cwd: string;
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
collapsedFolderPaths: string[];
onCollapsedFolderPathsChange: (paths: string[]) => void;
}) {
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const { collapsedFolders: collapsedFolderPaths, updateCollapsedFolders } =
useCollapsedDiffFolders({ workspaceId, cwd });
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
const collapsedFolders = useMemo(() => new Set(collapsedFolderPaths), [collapsedFolderPaths]);
@@ -881,14 +810,14 @@ function ChangedFilesTree({
const next = collapsedFolders.has(dirPath)
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
: [...collapsedFolders, dirPath];
updateCollapsedFolders(next);
onCollapsedFolderPathsChange(next);
},
[collapsedFolders, updateCollapsedFolders],
[collapsedFolders, onCollapsedFolderPathsChange],
);
const handleCollapseFolder = useCallback(
(dirPath: string) => {
const prefix = `${dirPath}/`;
updateCollapsedFolders([
onCollapsedFolderPathsChange([
...new Set([
...collapsedFolders,
...allFolderPaths.filter(
@@ -897,7 +826,7 @@ function ChangedFilesTree({
]),
]);
},
[allFolderPaths, collapsedFolders, updateCollapsedFolders],
[allFolderPaths, collapsedFolders, onCollapsedFolderPathsChange],
);
const renderItem = useCallback(
({ item }: { item: DiffTreeRow }) => {
@@ -979,30 +908,34 @@ function ChangedFilesTree({
function ChangesTreeRail({
shown,
children,
workspaceId,
cwd,
files,
mode,
onSelectFile,
treeWidth,
onTreeWidthChange,
collapsedFolderPaths,
onCollapsedFolderPathsChange,
}: {
shown: boolean;
children: ReactElement;
workspaceId?: string | null;
cwd: string;
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
treeWidth?: number;
onTreeWidthChange: (width: number) => void;
collapsedFolderPaths: string[];
onCollapsedFolderPathsChange: (paths: string[]) => void;
}) {
if (!shown) return children;
return (
<TreeRail testID="changes-tree-rail">
<TreeRail testID="changes-tree-rail" width={treeWidth ?? 220} onWidthChange={onTreeWidthChange}>
{children}
<ChangedFilesTree
workspaceId={workspaceId}
cwd={cwd}
files={files}
mode={mode}
onSelectFile={onSelectFile}
collapsedFolderPaths={collapsedFolderPaths}
onCollapsedFolderPathsChange={onCollapsedFolderPathsChange}
/>
</TreeRail>
);
@@ -1019,33 +952,30 @@ function useDiffTabNavigation({
cwd: string;
isMobile: boolean;
}) {
const openWorkspaceTabInFocusedPane = useWorkspaceLayoutStore(
(state) => state.openTabInFocusedPane,
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
const openWorkspaceTabInFocusedPane = useCallback(
(workspaceKey: string, target: WorkspaceTabTarget, placement?: WorkspaceTabPlacement) =>
openTab({ workspaceKey, target, intent: "reveal", placement }),
[openTab],
);
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const persistenceKey = useMemo(
() => buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? cwd }),
[cwd, serverId, workspaceId],
);
const changesTabId = useWorkspaceLayoutStore((state) => {
if (!persistenceKey) {
return null;
}
const layout = state.layoutByWorkspace[persistenceKey];
return (
layout && collectAllTabs(layout.root).find((tab) => tab.target.kind === "working_diff")?.tabId
);
});
const changesTabOpen = !isMobile && Boolean(changesTabId);
const changesTabOpen = false;
const openChanges = useCallback(
(path?: string) => {
if (!persistenceKey || isMobile) {
return;
}
openWorkspaceTabInFocusedPane(persistenceKey, {
kind: "working_diff",
...(path ? { focusPath: path, focusRequestId: Date.now() } : {}),
});
openWorkspaceTabInFocusedPane(
persistenceKey,
{
kind: "working_diff",
...(path ? { focusPath: path, focusRequestId: Date.now() } : {}),
},
FOCUSED_PANE_PLACEMENT,
);
},
[isMobile, openWorkspaceTabInFocusedPane, persistenceKey],
);
@@ -1053,16 +983,16 @@ function useDiffTabNavigation({
if (!persistenceKey || isMobile) {
return;
}
if (changesTabId) {
closeWorkspaceTab(persistenceKey, changesTabId);
return;
}
openChanges();
}, [changesTabId, closeWorkspaceTab, isMobile, openChanges, persistenceKey]);
}, [isMobile, openChanges, persistenceKey]);
const openCommit = useCallback(
(sha: string) => {
if (persistenceKey) {
openWorkspaceTabInFocusedPane(persistenceKey, { kind: "commit_diff", sha });
openWorkspaceTabInFocusedPane(
persistenceKey,
{ kind: "commit_diff", sha },
FOCUSED_PANE_PLACEMENT,
);
}
},
[openWorkspaceTabInFocusedPane, persistenceKey],
@@ -1086,27 +1016,26 @@ export function ChangesSurface({
focusRequestId,
onOpenFile,
onAddToChat,
state: changesState,
onStateChange,
}: ChangesSurfaceProps) {
const { settings: appSettings } = useAppSettings();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const canUseSplitLayout = isWeb && !isMobile;
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
useChangesPreferences();
const wrapLines = changesPreferences.wrapLines;
const desktopTreeVisible = changesPreferences.desktopTreeVisible;
const effectiveLayout = resolveDiffLayout(changesPreferences.layout, canUseSplitLayout);
const collapsedFilesWorkspaceKey = `${serverId}:${workspaceId ?? cwd}`;
const persistedCollapsedFilePaths = usePanelStore(
(state) => state.collapsedFilePathsByWorkspace[collapsedFilesWorkspaceKey],
);
const setCollapsedFilePaths = usePanelStore((state) => state.setCollapsedFilePathsForWorkspace);
const collapsedFilePaths = persistedCollapsedFilePaths ?? EMPTY_PATH_LIST;
const instanceState = changesState ?? defaultChangesState;
const updateState = onStateChange ?? noopStateChange;
const wrapLines = instanceState.wrapLines;
const desktopTreeVisible = instanceState.treeVisible;
const effectiveLayout = resolveDiffLayout(instanceState.layout, canUseSplitLayout);
const collapsedFilePaths = instanceState.collapsedFilePaths;
const updateCollapsedFilePaths = useCallback(
(paths: string[]) => {
setCollapsedFilePaths(collapsedFilesWorkspaceKey, paths);
},
[collapsedFilesWorkspaceKey, setCollapsedFilePaths],
(paths: string[]) => updateState({ ...instanceState, collapsedFilePaths: paths }),
[instanceState, updateState],
);
const updateCollapsedFolderPaths = useCallback(
(paths: string[]) => updateState({ ...instanceState, collapsedFolderPaths: paths }),
[instanceState, updateState],
);
const collapseState = useMemo(
() => ({ paths: collapsedFilePaths, onChange: updateCollapsedFilePaths }),
@@ -1114,26 +1043,30 @@ export function ChangesSurface({
);
const handleToggleWrapLines = useCallback(() => {
void updateChangesPreferences({ wrapLines: !wrapLines });
}, [updateChangesPreferences, wrapLines]);
updateState({ ...instanceState, wrapLines: !wrapLines });
}, [instanceState, updateState, wrapLines]);
const handleToggleHideWhitespace = useCallback(() => {
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
updateState({
...instanceState,
hideWhitespace: !instanceState.hideWhitespace,
});
}, [instanceState, updateState]);
const handleToggleLayout = useCallback(() => {
void updateChangesPreferences({
layout: changesPreferences.layout === "unified" ? "split" : "unified",
updateState({
...instanceState,
layout: instanceState.layout === "unified" ? "split" : "unified",
});
}, [changesPreferences.layout, updateChangesPreferences]);
const codeFontSize = appSettings.codeFontSize;
const treeToggleStyle = useMemo(
() => buildToggleButtonStyle(desktopTreeVisible, styles.toolbarIconButton),
[desktopTreeVisible],
}, [instanceState, updateState]);
const handleDiffModeChange = useCallback(
(mode: "uncommitted" | "base") => updateState({ ...instanceState, mode }),
[instanceState, updateState],
);
const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []);
const codeFontSize = appSettings.codeFontSize;
const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(isMobile), [isMobile]);
const toast = useToast();
const isLocalDaemon = useIsLocalDaemon(serverId);
@@ -1192,8 +1125,11 @@ export function ChangesSurface({
serverId,
workspaceId: workspaceId ?? undefined,
cwd,
ignoreWhitespace: changesPreferences.hideWhitespace,
ignoreWhitespace: instanceState.hideWhitespace,
enabled: enabled !== false,
mode: instanceState.mode,
baseRef: instanceState.baseRef,
onModeChange: handleDiffModeChange,
});
usePublishWorkingDiffAttachment({
serverId,
@@ -1231,8 +1167,16 @@ export function ChangesSurface({
[forgeSetupAction, forge, status?.remoteUrl, t],
);
const handleToggleDesktopTree = useCallback(() => {
void updateChangesPreferences({ desktopTreeVisible: !desktopTreeVisible });
}, [desktopTreeVisible, updateChangesPreferences]);
updateState({ ...instanceState, treeVisible: !desktopTreeVisible });
}, [desktopTreeVisible, instanceState, updateState]);
const handleCommitsCollapsedChange = useCallback(
(commitsCollapsed: boolean) => updateState({ ...instanceState, commitsCollapsed }),
[instanceState, updateState],
);
const handleChangesTreeWidth = useCallback(
(treeWidth: number) => updateState({ ...instanceState, treeWidth }),
[instanceState, updateState],
);
const sharedDisplayPreferences = useMemo(
() => ({
layout: effectiveLayout,
@@ -1383,16 +1327,11 @@ export function ChangesSurface({
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
[baseRefLabel, branchLabel],
);
const emptyMessage = computeEmptyMessage(
changesPreferences.hideWhitespace,
diffMode,
baseRefLabel,
{
hiddenWhitespace: t("workspace.git.diff.emptyHiddenWhitespace"),
uncommitted: t("workspace.git.diff.emptyUncommitted"),
againstBase: (label) => t("workspace.git.diff.emptyAgainstBase", { baseRef: label }),
},
);
const emptyMessage = computeEmptyMessage(instanceState.hideWhitespace, diffMode, baseRefLabel, {
hiddenWhitespace: t("workspace.git.diff.emptyHiddenWhitespace"),
uncommitted: t("workspace.git.diff.emptyUncommitted"),
againstBase: (label) => t("workspace.git.diff.emptyAgainstBase", { baseRef: label }),
});
const diffContent: ReactElement = (
<DiffBodyContent
@@ -1418,11 +1357,13 @@ export function ChangesSurface({
const bodyContent = (
<ChangesTreeRail
shown={desktopTreeVisible && !isMobile && files.length > 0}
workspaceId={workspaceId}
cwd={cwd}
files={files}
mode={workingMode}
onSelectFile={handleSelectTreeFile}
treeWidth={instanceState.treeWidth}
onTreeWidthChange={handleChangesTreeWidth}
collapsedFolderPaths={instanceState.collapsedFolderPaths}
onCollapsedFolderPathsChange={updateCollapsedFolderPaths}
>
{diffContent}
</ChangesTreeRail>
@@ -1447,16 +1388,15 @@ export function ChangesSurface({
diffMode={diffMode}
gitActions={gitActions}
hasFiles={hasChanges}
hideWhitespace={changesPreferences.hideWhitespace}
hideWhitespace={instanceState.hideWhitespace}
host={host}
isMobile={isMobile}
isRefreshing={isRefreshing}
layout={changesPreferences.layout}
layout={instanceState.layout}
overflowToggleStyle={overflowToggleStyle}
refreshSupported={refreshSupported}
selectedDiffStat={selectedDiffStat}
serverId={serverId}
treeToggleStyle={treeToggleStyle}
workspaceId={workspaceId}
wrapLines={wrapLines}
onCollapseAll={handleCollapseAllFiles}
@@ -1482,7 +1422,13 @@ export function ChangesSurface({
<View style={styles.diffContainer}>{bodyContent}</View>
<CommitsSection serverId={serverId} cwd={cwd} onCommitPress={handleCommitPress} />
<CommitsSection
serverId={serverId}
cwd={cwd}
onCommitPress={handleCommitPress}
collapsed={instanceState.commitsCollapsed}
onCollapsedChange={handleCommitsCollapsedChange}
/>
</View>
);
}
@@ -1493,16 +1439,12 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
changesToolbar: {
minHeight: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
flexShrink: 0,
},
changesToolbarIdentity: {
minWidth: 0,
@@ -1535,12 +1477,6 @@ const styles = StyleSheet.create((theme) => ({
diffModeTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
diffModeTriggerPressed: {
backgroundColor: theme.colors.surface2,
},
diffStatusRowHovered: {
backgroundColor: theme.colors.surface2,
},
diffStatusText: {
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.25,
@@ -1549,44 +1485,6 @@ const styles = StyleSheet.create((theme) => ({
diffStatusIconHidden: {
opacity: 0,
},
toggleButtonSelected: {
backgroundColor: theme.colors.surface2,
},
toolbarIconButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
minWidth: {
xs: 32,
sm: 32,
md: 24,
},
height: {
xs: 32,
sm: 32,
md: 24,
},
paddingHorizontal: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
overflowButton: {
width: ICON_SIZE.sm + 2 * SPACING[1],
height: {
xs: 32,
sm: 32,
md: 24,
},
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
actionErrorText: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[1],
@@ -0,0 +1,15 @@
import { useCallback, useState } from "react";
import type { CheckStatus } from "./check-status";
/** Per-mounted PR panel state; never shared with another tab instance. */
export function useCheckGroupState() {
const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<CheckStatus>>(() => new Set());
const toggle = useCallback((status: CheckStatus) => {
setCollapsedGroups((current) => {
const next = new Set(current);
if (!next.delete(status)) next.add(status);
return next;
});
}, []);
return { collapsedGroups, toggle };
}
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo } from "react";
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native";
@@ -11,6 +11,7 @@ import { summarizeChecks, type ChecksGroup } from "./checks-summary";
import { canAddPullRequestCheckLogsToChat } from "./context-attachment";
import type { PrPaneCheck } from "./data";
import { CheckStatusIcon, foregroundMutedColorMapping, sectionKitStyles } from "./section-kit";
import { useCheckGroupState } from "./check-group-state";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
@@ -67,17 +68,7 @@ export function ChecksSection({
onAddLogsToChat,
}: ChecksSectionProps) {
const summary = useMemo(() => summarizeChecks(checks), [checks]);
const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<CheckStatus>>(() => new Set());
const handleToggleGroup = useCallback((status: CheckStatus) => {
setCollapsedGroups((current) => {
const next = new Set(current);
if (!next.delete(status)) {
next.add(status);
}
return next;
});
}, []);
const { collapsedGroups, toggle: handleToggleGroup } = useCheckGroupState();
return (
<View testID="pr-pane-checks">
@@ -1,6 +1,7 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
import { PrActivitySkeleton, SkeletonPulse, useSkeletonPulse } from "./activity-skeleton";
const CHECK_ROW_KEYS = [0, 1, 2].map((i) => `pr-pane-skeleton-check-${i}`);
@@ -16,10 +17,10 @@ export function PullRequestPaneSkeleton() {
<SkeletonPulse pulse={pulse} style={styles.subtitle} />
</View>
<View style={styles.toolbar}>
<PaneContentToolbar style={styles.toolbar}>
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
</View>
</PaneContentToolbar>
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t("workspace.git.pr.sections.checks")}</Text>
@@ -68,9 +69,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
toolbarButton: {
width: 96,
@@ -36,7 +36,12 @@ import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachment
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
PaneContentToolbar,
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
} from "@/components/ui/pane-content-toolbar";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { getForgePresentation } from "@/git/forge";
import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view";
@@ -145,11 +150,11 @@ function kebabTriggerStyle({
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function refreshButtonStyle({
hovered = false,
pressed = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.refreshButton, (hovered || pressed) && styles.refreshButtonHovered];
function refreshButtonStyle(
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
isCompact: boolean,
) {
return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact);
}
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
@@ -193,6 +198,11 @@ export function PullRequestPane({
workspaceAttachmentScopeKey?: string;
}) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const toolbarRefreshButtonStyle = useCallback(
(state: PressableStateCallbackType) => refreshButtonStyle(state, isCompact),
[isCompact],
);
const toast = useToast();
const daemonClient = useHostRuntimeClient(serverId);
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
@@ -467,7 +477,7 @@ export function PullRequestPane({
return (
<View style={styles.root} testID="pr-pane">
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
<View style={styles.toolbar} testID="pr-pane-toolbar">
<PaneContentToolbar style={styles.toolbar} testID="pr-pane-toolbar">
<View style={styles.toolbarActions}>
<Button
variant="ghost"
@@ -489,7 +499,7 @@ export function PullRequestPane({
: t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel })
}
testID="pr-pane-refresh"
style={refreshButtonStyle}
style={toolbarRefreshButtonStyle}
hitSlop={8}
onPress={handleRefresh}
disabled={isRefreshing}
@@ -497,16 +507,19 @@ export function PullRequestPane({
<View style={styles.refreshIcon}>
{isRefreshing ? (
<ThemedLoadingSpinner
size={ICON_SIZE.sm}
size={paneContentToolbarIconSize(isCompact)}
uniProps={foregroundMutedColorMapping}
/>
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
<ThemedRotateCw
size={paneContentToolbarIconSize(isCompact)}
uniProps={foregroundMutedColorMapping}
/>
)}
</View>
</Pressable>
) : null}
</View>
</PaneContentToolbar>
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
{({ hovered }) => (
@@ -1239,15 +1252,10 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.border,
},
toolbar: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
paddingTop: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingBottom: theme.spacing[2],
paddingLeft: theme.spacing[3],
},
toolbarActions: {
@@ -1263,17 +1271,6 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[1],
borderRadius: theme.borderRadius.base,
},
refreshButton: {
marginLeft: "auto",
width: 22,
height: 22,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
refreshButtonHovered: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
+17 -26
View File
@@ -5,11 +5,8 @@ import {
} from "@/attachments/workspace-attachments-store";
import {
buildReviewDraftKey,
buildReviewDraftScopeKey,
useInlineReviewController,
useResolvedDiffMode,
useReviewAttachmentSnapshot,
useSetDiffModeOverride,
} from "@/review";
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
@@ -21,6 +18,9 @@ interface UseWorkingDiffOptions {
ignoreWhitespace: boolean;
enabled: boolean;
queryScope?: string;
mode?: "uncommitted" | "base";
baseRef?: string;
onModeChange?: (mode: "uncommitted" | "base") => void;
}
export function useWorkingDiff({
@@ -30,6 +30,9 @@ export function useWorkingDiff({
ignoreWhitespace,
enabled,
queryScope,
mode,
baseRef: selectedBaseRef,
onModeChange,
}: UseWorkingDiffOptions) {
const {
status,
@@ -43,38 +46,26 @@ export function useWorkingDiff({
const statusErrorMessage =
status?.error?.message ??
(isStatusError && statusError instanceof Error ? statusError.message : null);
const baseRef = gitStatus?.baseRef ?? undefined;
const baseRef = selectedBaseRef ?? gitStatus?.baseRef ?? undefined;
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const currentBranchName =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
const reviewDraftScopeKey = useMemo(
() =>
buildReviewDraftScopeKey({
serverId,
workspaceId,
cwd,
baseRef,
ignoreWhitespace,
}),
[baseRef, cwd, ignoreWhitespace, serverId, workspaceId],
);
const diffMode = useResolvedDiffMode({
scopeKey: reviewDraftScopeKey,
hasUncommittedChanges,
});
const setDiffModeOverride = useSetDiffModeOverride();
const selectDiffMode = useCallback(
(mode: "uncommitted" | "base") => {
setDiffModeOverride({
scopeKey: reviewDraftScopeKey,
override: { serverId, cwd, mode, isDirtyAtSelection: hasUncommittedChanges },
});
(nextMode: "uncommitted" | "base") => {
if (onModeChange) {
onModeChange(nextMode);
return;
}
// A Changes surface always supplies its tab-owned presentation. Keep this
// fallback deterministic for legacy non-tab consumers without mutating
// workspace-wide review preferences.
},
[cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride],
[onModeChange],
);
const selectUncommitted = useCallback(() => selectDiffMode("uncommitted"), [selectDiffMode]);
const selectBase = useCallback(() => selectDiffMode("base"), [selectDiffMode]);
const diffMode = mode ?? (hasUncommittedChanges ? "uncommitted" : "base");
const {
files,
@@ -77,6 +77,8 @@ export interface AppSettings {
toolCallDetailLevel: ToolCallDetailLevel;
chatOutlineEnabled: boolean;
vimKeybindings: boolean;
/** Route implicitly opened supporting tabs into the Side panel. Desktop only. */
openSupportingTabsInSidePanel: boolean;
}
export interface Settings extends AppSettings {
@@ -124,6 +126,7 @@ const StoredAppSettingsSchema = z.strictObject({
compactToolCalls: z.boolean().optional(),
chatOutlineEnabled: z.boolean().optional(),
vimKeybindings: z.boolean().optional(),
openSupportingTabsInSidePanel: z.boolean().optional(),
// COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key.
manageBuiltInDaemon: z.boolean().optional(),
releaseChannel: z.enum(["stable", "beta"]).optional(),
@@ -153,6 +156,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
toolCallDetailLevel: "detailed",
chatOutlineEnabled: true,
vimKeybindings: false,
openSupportingTabsInSidePanel: true,
};
export const DEFAULT_APP_SETTINGS: Settings = {
@@ -315,6 +319,9 @@ function pickBooleanAppSettings(stored: StoredAppSettings): Partial<AppSettings>
if (typeof stored.chatOutlineEnabled === "boolean") {
result.chatOutlineEnabled = stored.chatOutlineEnabled;
}
if (typeof stored.openSupportingTabsInSidePanel === "boolean") {
result.openSupportingTabsInSidePanel = stored.openSupportingTabsInSidePanel;
}
return result;
}
+3 -4
View File
@@ -412,7 +412,6 @@ describe("translation resources", () => {
});
it("includes workspace tab toast keys for the Batch 4J migration", () => {
expect(en.workspace.tabs.emptyPane).toBe("No tabs in this pane.");
expect(en.workspace.tabs.toasts.copyFailed).toBe("Copy failed");
expect(en.workspace.tabs.toasts.agentIdCopiedLabel).toBe("Agent ID");
expect(en.workspace.tabs.toasts.resumeCommandCopiedLabel).toBe("resume command");
@@ -494,12 +493,12 @@ describe("translation resources", () => {
expect(en.agentList.dateSections.recent).toBe("Recent");
expect(en.message.attachments.imagePreviewUnavailable).toBe("Image preview unavailable.");
expect(en.message.attachments.imagePreviewLoadFailed).toBe("Unable to load image preview.");
expect(en.workspace.tabs.explorer.changes).toBe("Changes");
expect(en.workspace.tabs.explorer.files).toBe("Files");
expect(en.workspace.tabs.sidePanel.changes).toBe("Changes");
expect(en.workspace.tabs.sidePanel.files).toBe("Files");
expect(en.workspace.tabs.actions.maximizePane).toBe("Maximize pane");
expect(en.workspace.tabs.actions.restorePane).toBe("Restore pane");
expect(en.settings.shortcuts.help.toggleExplorerPaneMaximization).toBe(
"Toggle Explorer pane maximization",
"Toggle Side panel maximization",
);
expect(en.branchSwitcher.triggerTooltip).toBe("Switch workspace branch");
expect(en.branchSwitcher.uncommittedTitle).toBe("Uncommitted changes");
+16 -9
View File
@@ -553,7 +553,6 @@ export const ar: TranslationResources = {
loading: "تحميل...",
modified: "تغييرات غير محفوظة",
loadingAgentTitle: "جارٍ تحميل عنوان الوكيل",
emptyPane: "لا توجد علامات تبويب في هذا الجزء.",
fallback: {
newAgent: "وكيل جديد",
setup: "يثبت",
@@ -595,6 +594,7 @@ export const ar: TranslationResources = {
newBrowser: "متصفح جديد",
maximizePane: "تكبير الجزء",
restorePane: "استعادة الجزء",
closePane: "إغلاق الجزء",
exitFocusMode: "إنهاء وضع التركيز",
splitRight: "تقسيم الجزء الأيمن",
splitDown: "تقسيم الجزء لأسفل",
@@ -604,10 +604,10 @@ export const ar: TranslationResources = {
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
},
explorer: {
open: "افتح المستكشف",
close: "إغلاق المستكشف",
toggle: "تبديل المستكشف",
sidePanel: {
open: "افتح اللوحة الجانبية",
close: "إغلاق اللوحة الجانبية",
toggle: "تبديل اللوحة الجانبية",
changes: "التغييرات",
files: "ملفات",
},
@@ -710,6 +710,10 @@ export const ar: TranslationResources = {
stopFailed: "فشل إيقاف{{scriptName}}",
},
},
tree: {
showFolderTree: "إظهار شجرة المجلدات",
hideFolderTree: "إخفاء شجرة المجلدات",
},
git: {
actions: {
moreOptions: "المزيد من الخيارات",
@@ -855,8 +859,6 @@ export const ar: TranslationResources = {
split: "فرق جنبًا إلى جنب",
switchToUnified: "التبديل إلى الفرق الموحد",
switchToSplit: "التبديل إلى الفرق جنبًا إلى جنب",
showTreeView: "إظهار شجرة المجلدات",
hideTreeView: "إخفاء شجرة المجلدات",
options: "خيارات الفرق",
hideWhitespace: "إخفاء المسافة البيضاء",
showWhitespace: "إظهار المسافة البيضاء",
@@ -1917,6 +1919,11 @@ export const ar: TranslationResources = {
description: "يتم الاحتفاظ بالخطوط في المخزن المؤقت الطرفي المدمج",
accessibilityLabel: "خطوط التمرير Terminal",
},
sidePanelRouting: {
label: "فتح علامات التبويب المساعدة في اللوحة الجانبية",
description:
"تفتح روابط الملفات وطلبات السحب وتقدم الإعداد بجوار عملك بدلاً من الجزء المركّز عليه",
},
autoExpandReasoning: {
label: "عرض التفكير دائماً",
description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي",
@@ -2100,11 +2107,11 @@ export const ar: TranslationResources = {
toggleCommandCenter: "تبديل مركز القيادة",
showKeyboardShortcuts: "إظهار اختصارات لوحة المفاتيح",
toggleLeftSidebar: "تبديل الشريط الجانبي الأيسر",
toggleRightSidebar: "تبديل الشريط الجانبي الأيمن",
toggleRightSidebar: "تبديل اللوحة الجانبية",
toggleBothSidebars: "تبديل كلا الشريطين الجانبيين",
toggleSettings: "تبديل الإعدادات",
toggleFocusMode: "تبديل وضع التركيز",
toggleExplorerPaneMaximization: "تبديل تكبير جزء المستكشف",
toggleExplorerPaneMaximization: "تبديل تكبير اللوحة الجانبية",
cycleTheme: "موضوع الدورة",
focusMessageInput: "التركيز على إدخال الرسالة",
cycleAgentMode: "تبديل وضع الوكيل",
+16 -9
View File
@@ -551,7 +551,6 @@ export const en = {
loading: "Loading...",
modified: "Unsaved changes",
loadingAgentTitle: "Loading agent title",
emptyPane: "No tabs in this pane.",
fallback: {
newAgent: "New agent",
setup: "Setup",
@@ -593,6 +592,7 @@ export const en = {
newBrowser: "New browser",
maximizePane: "Maximize pane",
restorePane: "Restore pane",
closePane: "Close pane",
exitFocusMode: "Exit focus mode",
splitRight: "Split pane right",
splitDown: "Split pane down",
@@ -602,10 +602,10 @@ export const en = {
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
},
explorer: {
open: "Open explorer",
close: "Close explorer",
toggle: "Toggle explorer",
sidePanel: {
open: "Open side panel",
close: "Close side panel",
toggle: "Toggle side panel",
changes: "Changes",
files: "Files",
},
@@ -706,6 +706,10 @@ export const en = {
stopFailed: "Failed to stop {{scriptName}}",
},
},
tree: {
showFolderTree: "Show folder tree",
hideFolderTree: "Hide folder tree",
},
git: {
actions: {
moreOptions: "More options",
@@ -864,8 +868,6 @@ export const en = {
split: "Side-by-side diff",
switchToUnified: "Switch to unified diff",
switchToSplit: "Switch to side-by-side diff",
showTreeView: "Show folder tree",
hideTreeView: "Hide folder tree",
options: "Diff options",
hideWhitespace: "Hide whitespace",
showWhitespace: "Show whitespace",
@@ -1981,6 +1983,11 @@ export const en = {
description: "Lines kept in the built-in terminal buffer",
accessibilityLabel: "Terminal scrollback lines",
},
sidePanelRouting: {
label: "Open supporting tabs in the Side panel",
description:
"File links, pull requests, and setup progress open beside your work instead of in the focused pane",
},
autoExpandReasoning: {
label: "Always expand reasoning",
description: "Show agent thinking and chain-of-thought blocks fully expanded by default",
@@ -2164,11 +2171,11 @@ export const en = {
toggleCommandCenter: "Toggle command center",
showKeyboardShortcuts: "Show keyboard shortcuts",
toggleLeftSidebar: "Toggle left sidebar",
toggleRightSidebar: "Toggle right sidebar",
toggleRightSidebar: "Toggle side panel",
toggleBothSidebars: "Toggle both sidebars",
toggleSettings: "Toggle settings",
toggleFocusMode: "Toggle focus mode",
toggleExplorerPaneMaximization: "Toggle Explorer pane maximization",
toggleExplorerPaneMaximization: "Toggle Side panel maximization",
cycleTheme: "Cycle theme",
focusMessageInput: "Focus message input",
cycleAgentMode: "Cycle agent mode",
+16 -9
View File
@@ -557,7 +557,6 @@ export const es: TranslationResources = {
loading: "Cargando...",
modified: "Cambios sin guardar",
loadingAgentTitle: "Título del agente de carga",
emptyPane: "No hay pestañas en este panel.",
fallback: {
newAgent: "Nuevo agente",
setup: "Configuración",
@@ -600,6 +599,7 @@ export const es: TranslationResources = {
newBrowser: "Nuevo navegador",
maximizePane: "Maximizar panel",
restorePane: "Restaurar panel",
closePane: "Cerrar panel",
exitFocusMode: "Salir del modo de concentración",
splitRight: "Panel dividido a la derecha",
splitDown: "Dividir panel hacia abajo",
@@ -609,10 +609,10 @@ export const es: TranslationResources = {
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
},
explorer: {
open: "Explorador abierto",
close: "Cerrar explorador",
toggle: "Alternar explorador",
sidePanel: {
open: "Abrir panel lateral",
close: "Cerrar panel lateral",
toggle: "Alternar panel lateral",
changes: "Cambios",
files: "Archivos",
},
@@ -716,6 +716,10 @@ export const es: TranslationResources = {
stopFailed: "No se pudo detener{{scriptName}}",
},
},
tree: {
showFolderTree: "Mostrar árbol de carpetas",
hideFolderTree: "Ocultar árbol de carpetas",
},
git: {
actions: {
moreOptions: "Más opciones",
@@ -886,8 +890,6 @@ export const es: TranslationResources = {
split: "Diferencia de lado a lado",
switchToUnified: "Cambiar a diferencia unificada",
switchToSplit: "Cambiar a diferencia lado a lado",
showTreeView: "Mostrar árbol de carpetas",
hideTreeView: "Ocultar árbol de carpetas",
options: "Opciones de diferencia",
hideWhitespace: "Ocultar espacios en blanco",
showWhitespace: "Mostrar espacios en blanco",
@@ -1966,6 +1968,11 @@ export const es: TranslationResources = {
description: "Líneas mantenidas en el búfer de terminal incorporado",
accessibilityLabel: "Líneas del historial de terminal",
},
sidePanelRouting: {
label: "Abrir pestañas de apoyo en el panel lateral",
description:
"Los enlaces a archivos, las solicitudes de incorporación y el progreso de configuración se abren junto a tu trabajo en lugar del panel enfocado",
},
autoExpandReasoning: {
label: "Siempre expandir razonamiento",
description:
@@ -2151,11 +2158,11 @@ export const es: TranslationResources = {
toggleCommandCenter: "Alternar centro de comando",
showKeyboardShortcuts: "Mostrar atajos de teclado",
toggleLeftSidebar: "Alternar barra lateral izquierda",
toggleRightSidebar: "Alternar barra lateral derecha",
toggleRightSidebar: "Alternar panel lateral",
toggleBothSidebars: "Alternar ambas barras laterales",
toggleSettings: "Alternar configuración",
toggleFocusMode: "Alternar modo de enfoque",
toggleExplorerPaneMaximization: "Alternar maximización del panel Explorador",
toggleExplorerPaneMaximization: "Alternar maximización del panel lateral",
cycleTheme: "Tema del ciclo",
focusMessageInput: "Entrada de mensaje de enfoque",
cycleAgentMode: "Alternar modo del agente",
+16 -9
View File
@@ -557,7 +557,6 @@ export const fr: TranslationResources = {
loading: "Chargement...",
modified: "Modifications non enregistrées",
loadingAgentTitle: "Titre d'agent de chargement",
emptyPane: "Aucun onglet dans ce volet.",
fallback: {
newAgent: "Nouvel agent",
setup: "Installation",
@@ -600,6 +599,7 @@ export const fr: TranslationResources = {
newBrowser: "Nouveau navigateur",
maximizePane: "Agrandir le volet",
restorePane: "Restaurer le volet",
closePane: "Fermer le volet",
exitFocusMode: "Quitter le mode concentration",
splitRight: "Volet divisé à droite",
splitDown: "Diviser le volet vers le bas",
@@ -609,10 +609,10 @@ export const fr: TranslationResources = {
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
},
explorer: {
open: "Ouvrir l'explorateur",
close: "Fermer l'explorateur",
toggle: "Basculer l'explorateur",
sidePanel: {
open: "Ouvrir le panneau latéral",
close: "Fermer le panneau latéral",
toggle: "Basculer le panneau latéral",
changes: "Changements",
files: "Fichiers",
},
@@ -716,6 +716,10 @@ export const fr: TranslationResources = {
stopFailed: "Échec de l'arrêt de{{scriptName}}",
},
},
tree: {
showFolderTree: "Afficher l'arborescence des dossiers",
hideFolderTree: "Masquer l'arborescence des dossiers",
},
git: {
actions: {
moreOptions: "Plus d'options",
@@ -885,8 +889,6 @@ export const fr: TranslationResources = {
split: "Différent côte à côte",
switchToUnified: "Passer au diff unifié",
switchToSplit: "Passer au diff côte à côte",
showTreeView: "Afficher l'arborescence des dossiers",
hideTreeView: "Masquer l'arborescence des dossiers",
options: "Options du diff",
hideWhitespace: "Masquer les espaces",
showWhitespace: "Afficher les espaces",
@@ -1970,6 +1972,11 @@ export const fr: TranslationResources = {
description: "Lignes conservées dans le tampon du terminal intégré",
accessibilityLabel: "Lignes de défilementTerminal",
},
sidePanelRouting: {
label: "Ouvrir les onglets d'appoint dans le panneau latéral",
description:
"Les liens de fichiers, les pull requests et la progression de la configuration s'ouvrent à côté de votre travail plutôt que dans le volet actif",
},
autoExpandReasoning: {
label: "Toujours afficher le raisonnement",
description: "Afficher le raisonnement de l'agent entièrement développé par défaut",
@@ -2155,11 +2162,11 @@ export const fr: TranslationResources = {
toggleCommandCenter: "Basculer le centre de commande",
showKeyboardShortcuts: "Afficher les raccourcis clavier",
toggleLeftSidebar: "Basculer la barre latérale gauche",
toggleRightSidebar: "Basculer la barre latérale droite",
toggleRightSidebar: "Basculer le panneau latéral",
toggleBothSidebars: "Basculer les deux barres latérales",
toggleSettings: "Basculer les paramètres",
toggleFocusMode: "Basculer le mode de mise au point",
toggleExplorerPaneMaximization: "Basculer l’agrandissement du volet Explorateur",
toggleExplorerPaneMaximization: "Basculer l’agrandissement du panneau latéral",
cycleTheme: "Thème du cycle",
focusMessageInput: "Saisie du message de focus",
cycleAgentMode: "Parcourir les modes de l'agent",
+16 -9
View File
@@ -557,7 +557,6 @@ export const ja: TranslationResources = {
loading: "読み込み中...",
modified: "未保存の変更",
loadingAgentTitle: "エージェントタイトルを読み込み中",
emptyPane: "このペインにタブがありません。",
fallback: {
newAgent: "新しいエージェント",
setup: "セットアップ",
@@ -600,6 +599,7 @@ export const ja: TranslationResources = {
newBrowser: "新しいブラウザ",
maximizePane: "ペインを最大化",
restorePane: "ペインを元に戻す",
closePane: "ペインを閉じる",
exitFocusMode: "フォーカスモードを終了",
splitRight: "右にペインを分割",
splitDown: "下にペインを分割",
@@ -609,10 +609,10 @@ export const ja: TranslationResources = {
terminalProfilesMenu: "ターミナルプロファイル",
editTerminalProfiles: "プロファイルを編集…",
},
explorer: {
open: "エクスプローラーを開く",
close: "エクスプローラーを閉じる",
toggle: "エクスプローラーを切り替え",
sidePanel: {
open: "サイドパネルを開く",
close: "サイドパネルを閉じる",
toggle: "サイドパネルを切り替え",
changes: "変更",
files: "ファイル",
},
@@ -712,6 +712,10 @@ export const ja: TranslationResources = {
stopFailed: "{{scriptName}}の停止に失敗しました",
},
},
tree: {
showFolderTree: "フォルダツリーを表示",
hideFolderTree: "フォルダツリーを非表示",
},
git: {
actions: {
moreOptions: "その他のオプション",
@@ -866,8 +870,6 @@ export const ja: TranslationResources = {
split: "左右比較",
switchToUnified: "ユニファイド差分に切り替え",
switchToSplit: "左右比較差分に切り替え",
showTreeView: "フォルダツリーを表示",
hideTreeView: "フォルダツリーを非表示",
options: "差分オプション",
hideWhitespace: "空白を非表示",
showWhitespace: "空白を表示",
@@ -1934,6 +1936,11 @@ export const ja: TranslationResources = {
description: "組み込みターミナルバッファに保持する行数",
accessibilityLabel: "ターミナルスクロールバック行数",
},
sidePanelRouting: {
label: "補助タブをサイドパネルで開く",
description:
"ファイルリンク、プルリクエスト、セットアップの進行状況が、フォーカス中のペインではなく作業の横に開きます",
},
autoExpandReasoning: {
label: "常に思考プロセスを展開",
description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します",
@@ -2118,11 +2125,11 @@ export const ja: TranslationResources = {
toggleCommandCenter: "コマンドセンターを切り替え",
showKeyboardShortcuts: "キーボードショートカットを表示",
toggleLeftSidebar: "左サイドバーを切り替え",
toggleRightSidebar: "右サイドバーを切り替え",
toggleRightSidebar: "サイドパネルを切り替え",
toggleBothSidebars: "両方のサイドバーを切り替え",
toggleSettings: "設定を切り替え",
toggleFocusMode: "フォーカスモードを切り替え",
toggleExplorerPaneMaximization: "エクスプローラーペインの最大化を切り替え",
toggleExplorerPaneMaximization: "サイドパネルの最大化を切り替え",
cycleTheme: "テーマを順に切り替え",
focusMessageInput: "メッセージ入力にフォーカス",
cycleAgentMode: "エージェントモードを順に切り替え",
+16 -9
View File
@@ -554,7 +554,6 @@ export const ko: TranslationResources = {
loading: "불러오는 중...",
modified: "저장되지 않은 변경사항",
loadingAgentTitle: "에이전트 제목 불러오는 중",
emptyPane: "이 창에 탭이 없습니다.",
fallback: {
newAgent: "새 에이전트",
setup: "설정",
@@ -597,6 +596,7 @@ export const ko: TranslationResources = {
newBrowser: "새 브라우저",
maximizePane: "창 최대화",
restorePane: "창 복원",
closePane: "창 닫기",
exitFocusMode: "집중 모드 종료",
splitRight: "창을 오른쪽으로 분할",
splitDown: "창을 아래로 분할",
@@ -606,10 +606,10 @@ export const ko: TranslationResources = {
terminalProfilesMenu: "터미널 프로필",
editTerminalProfiles: "프로필 편집…",
},
explorer: {
open: "탐색기 열기",
close: "탐색기 닫기",
toggle: "탐색기 토글",
sidePanel: {
open: "사이드 패널 열기",
close: "사이드 패널 닫기",
toggle: "사이드 패널 토글",
changes: "변경 사항",
files: "파일",
},
@@ -710,6 +710,10 @@ export const ko: TranslationResources = {
stopFailed: "{{scriptName}}를 중지하지 못했습니다.",
},
},
tree: {
showFolderTree: "폴더 트리 표시",
hideFolderTree: "폴더 트리 숨기기",
},
git: {
actions: {
moreOptions: "옵션 더 보기",
@@ -862,8 +866,6 @@ export const ko: TranslationResources = {
split: "나란히 보기 diff",
switchToUnified: "통합 diff로 전환",
switchToSplit: "나란히 보기 diff로 전환",
showTreeView: "폴더 트리 표시",
hideTreeView: "폴더 트리 숨기기",
options: "Diff 옵션",
hideWhitespace: "공백 숨기기",
showWhitespace: "공백 표시",
@@ -1928,6 +1930,11 @@ export const ko: TranslationResources = {
description: "내장 터미널 버퍼에 보관되는 줄 수",
accessibilityLabel: "터미널 스크롤백 줄 수",
},
sidePanelRouting: {
label: "보조 탭을 사이드 패널에서 열기",
description:
"파일 링크, 풀 리퀘스트, 설정 진행 상황이 포커스된 창이 아니라 작업 옆에서 열립니다",
},
autoExpandReasoning: {
label: "추론 항상 펼치기",
description: "에이전트의 사고 및 추론 블록을 기본적으로 모두 펼쳐 표시합니다.",
@@ -2111,11 +2118,11 @@ export const ko: TranslationResources = {
toggleCommandCenter: "명령 센터 토글",
showKeyboardShortcuts: "키보드 단축키 표시",
toggleLeftSidebar: "왼쪽 사이드바 토글",
toggleRightSidebar: "오른쪽 사이드바 토글",
toggleRightSidebar: "사이드 패널 토글",
toggleBothSidebars: "양쪽 사이드바 토글",
toggleSettings: "설정 토글",
toggleFocusMode: "집중 모드 토글",
toggleExplorerPaneMaximization: "탐색기 창 최대화 전환",
toggleExplorerPaneMaximization: "사이드 패널 최대화 전환",
cycleTheme: "테마 순환",
focusMessageInput: "메시지 입력란에 포커스",
cycleAgentMode: "에이전트 모드 전환",
+16 -9
View File
@@ -557,7 +557,6 @@ export const ptBR: TranslationResources = {
loading: "Carregando...",
modified: "Alterações não salvas",
loadingAgentTitle: "Carregando título do agente",
emptyPane: "Nenhuma aba neste painel.",
fallback: {
newAgent: "Novo agente",
setup: "Configuração",
@@ -599,6 +598,7 @@ export const ptBR: TranslationResources = {
newBrowser: "Novo navegador",
maximizePane: "Maximizar painel",
restorePane: "Restaurar painel",
closePane: "Fechar painel",
exitFocusMode: "Sair do modo de foco",
splitRight: "Dividir painel à direita",
splitDown: "Dividir painel abaixo",
@@ -608,10 +608,10 @@ export const ptBR: TranslationResources = {
terminalProfilesMenu: "Perfis de terminal",
editTerminalProfiles: "Editar perfis…",
},
explorer: {
open: "Abrir explorador",
close: "Fechar explorador",
toggle: "Alternar explorador",
sidePanel: {
open: "Abrir painel lateral",
close: "Fechar painel lateral",
toggle: "Alternar painel lateral",
changes: "Alterações",
files: "Arquivos",
},
@@ -713,6 +713,10 @@ export const ptBR: TranslationResources = {
stopFailed: "Falha ao parar {{scriptName}}",
},
},
tree: {
showFolderTree: "Mostrar árvore de pastas",
hideFolderTree: "Ocultar árvore de pastas",
},
git: {
actions: {
moreOptions: "Mais opções",
@@ -877,8 +881,6 @@ export const ptBR: TranslationResources = {
split: "Diff lado a lado",
switchToUnified: "Alternar para diff unificado",
switchToSplit: "Alternar para diff lado a lado",
showTreeView: "Mostrar árvore de pastas",
hideTreeView: "Ocultar árvore de pastas",
options: "Opções do diff",
hideWhitespace: "Ocultar espaços em branco",
showWhitespace: "Mostrar espaços em branco",
@@ -1950,6 +1952,11 @@ export const ptBR: TranslationResources = {
description: "Linhas mantidas no buffer do terminal integrado",
accessibilityLabel: "Linhas do scrollback do terminal",
},
sidePanelRouting: {
label: "Abrir abas de apoio no painel lateral",
description:
"Links de arquivo, pull requests e progresso da configuração abrem ao lado do seu trabalho em vez do painel em foco",
},
autoExpandReasoning: {
label: "Sempre expandir raciocínio",
description:
@@ -2134,11 +2141,11 @@ export const ptBR: TranslationResources = {
toggleCommandCenter: "Alternar central de comandos",
showKeyboardShortcuts: "Mostrar atalhos de teclado",
toggleLeftSidebar: "Alternar barra lateral esquerda",
toggleRightSidebar: "Alternar barra lateral direita",
toggleRightSidebar: "Alternar painel lateral",
toggleBothSidebars: "Alternar ambas as barras laterais",
toggleSettings: "Alternar Configurações",
toggleFocusMode: "Alternar modo de foco",
toggleExplorerPaneMaximization: "Alternar maximização do painel Explorador",
toggleExplorerPaneMaximization: "Alternar maximização do painel lateral",
cycleTheme: "Alternar tema",
focusMessageInput: "Focar entrada de mensagem",
cycleAgentMode: "Alternar modo do agente",
+16 -9
View File
@@ -557,7 +557,6 @@ export const ru: TranslationResources = {
loading: "Загрузка...",
modified: "Несохранённые изменения",
loadingAgentTitle: "Название агента загрузки",
emptyPane: "На этой панели нет вкладок.",
fallback: {
newAgent: "Новый агент",
setup: "Настраивать",
@@ -599,6 +598,7 @@ export const ru: TranslationResources = {
newBrowser: "Новый браузер",
maximizePane: "Развернуть панель",
restorePane: "Восстановить панель",
closePane: "Закрыть панель",
exitFocusMode: "Выйти из режима фокусировки",
splitRight: "Разделить панель справа",
splitDown: "Разделить панель вниз",
@@ -608,10 +608,10 @@ export const ru: TranslationResources = {
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
},
explorer: {
open: "Открыть проводник",
close: "Закрыть проводник",
toggle: "Переключить проводник",
sidePanel: {
open: "Открыть боковую панель",
close: "Закрыть боковую панель",
toggle: "Переключить боковую панель",
changes: "Изменения",
files: "Файлы",
},
@@ -715,6 +715,10 @@ export const ru: TranslationResources = {
stopFailed: "Не удалось остановить{{scriptName}}",
},
},
tree: {
showFolderTree: "Показать дерево папок",
hideFolderTree: "Скрыть дерево папок",
},
git: {
actions: {
moreOptions: "Больше возможностей",
@@ -877,8 +881,6 @@ export const ru: TranslationResources = {
split: "Параллельная разница",
switchToUnified: "Переключить на объединенный diff",
switchToSplit: "Переключить на diff рядом",
showTreeView: "Показать дерево папок",
hideTreeView: "Скрыть дерево папок",
options: "Параметры diff",
hideWhitespace: "Скрыть пробелы",
showWhitespace: "Показать пробелы",
@@ -1953,6 +1955,11 @@ export const ru: TranslationResources = {
description: "Строки, хранящиеся во встроенном буфере терминала.",
accessibilityLabel: "Линии прокрутки Terminal",
},
sidePanelRouting: {
label: "Открывать вспомогательные вкладки в боковой панели",
description:
"Ссылки на файлы, пул-реквесты и ход настройки открываются рядом с вашей работой, а не в активной панели",
},
autoExpandReasoning: {
label: "Всегда разворачивать размышления",
description:
@@ -2140,11 +2147,11 @@ export const ru: TranslationResources = {
toggleCommandCenter: "Переключить командный центр",
showKeyboardShortcuts: "Показать сочетания клавиш",
toggleLeftSidebar: "Переключить левую боковую панель",
toggleRightSidebar: "Переключить правую боковую панель",
toggleRightSidebar: "Переключить боковую панель",
toggleBothSidebars: "Переключить обе боковые панели",
toggleSettings: "Переключить настройки",
toggleFocusMode: "Переключить режим фокусировки",
toggleExplorerPaneMaximization: "Переключить разворачивание панели Проводника",
toggleExplorerPaneMaximization: "Переключить разворачивание боковой панели",
cycleTheme: "Циклическая тема",
focusMessageInput: "Фокус ввода сообщения",
cycleAgentMode: "Переключить режим агента",
+15 -9
View File
@@ -553,7 +553,6 @@ export const zhCN: TranslationResources = {
loading: "正在加载...",
modified: "未保存的更改",
loadingAgentTitle: "正在加载 Agent 标题",
emptyPane: "此窗格中没有标签。",
fallback: {
newAgent: "新建 Agent",
setup: "Setup",
@@ -595,6 +594,7 @@ export const zhCN: TranslationResources = {
newBrowser: "新建浏览器",
maximizePane: "最大化窗格",
restorePane: "还原窗格",
closePane: "关闭窗格",
exitFocusMode: "退出专注模式",
splitRight: "向右拆分窗格",
splitDown: "向下拆分窗格",
@@ -604,10 +604,10 @@ export const zhCN: TranslationResources = {
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
},
explorer: {
open: "打开 explorer",
close: "关闭 explorer",
toggle: "切换 explorer",
sidePanel: {
open: "打开侧边面板",
close: "关闭侧边面板",
toggle: "切换侧边面板",
changes: "变更",
files: "文件",
},
@@ -706,6 +706,10 @@ export const zhCN: TranslationResources = {
stopFailed: "停止 {{scriptName}} 失败",
},
},
tree: {
showFolderTree: "显示文件夹树",
hideFolderTree: "隐藏文件夹树",
},
git: {
actions: {
moreOptions: "更多选项",
@@ -847,8 +851,6 @@ export const zhCN: TranslationResources = {
split: "Side-by-side diff",
switchToUnified: "切换到统一差异视图",
switchToSplit: "切换到并排差异视图",
showTreeView: "显示文件夹树",
hideTreeView: "隐藏文件夹树",
options: "Diff 选项",
hideWhitespace: "隐藏空白差异",
showWhitespace: "显示空白差异",
@@ -1894,6 +1896,10 @@ export const zhCN: TranslationResources = {
description: "内置终端缓冲区保留的行数",
accessibilityLabel: "终端回滚行数",
},
sidePanelRouting: {
label: "在侧边面板中打开辅助标签页",
description: "文件链接、拉取请求和设置进度会在你的工作旁边打开,而不是在聚焦的窗格中",
},
autoExpandReasoning: {
label: "始终展开推理过程",
description: "默认情况下完全展开 AI 的思考和推理过程",
@@ -2076,11 +2082,11 @@ export const zhCN: TranslationResources = {
toggleCommandCenter: "切换命令中心",
showKeyboardShortcuts: "显示键盘快捷键",
toggleLeftSidebar: "切换左侧边栏",
toggleRightSidebar: "切换右侧边栏",
toggleRightSidebar: "切换侧边面板",
toggleBothSidebars: "切换两侧边栏",
toggleSettings: "切换设置",
toggleFocusMode: "切换专注模式",
toggleExplorerPaneMaximization: "切换资源管理器窗格最大化",
toggleExplorerPaneMaximization: "切换侧边面板最大化",
cycleTheme: "循环切换主题",
focusMessageInput: "聚焦消息输入框",
cycleAgentMode: "循环切换代理模式",
@@ -953,7 +953,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
help: {
id: "toggle-right-sidebar",
section: "layout",
label: "Toggle right sidebar",
label: "Toggle side panel",
},
},
{
@@ -964,7 +964,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
help: {
id: "toggle-right-sidebar",
section: "layout",
label: "Toggle right sidebar",
label: "Toggle side panel",
},
},
{
+3 -2
View File
@@ -90,7 +90,7 @@ import {
} from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openExplorerSurface } from "@/workspace-tabs/explorer-surface";
import { openSidePanelView } from "@/workspace-tabs/side-panel";
import type { Theme } from "@/styles/theme";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem, TodoEntry } from "@/types/stream";
@@ -437,6 +437,7 @@ export function AgentConversationPanel() {
export const agentPanelRegistration: PanelRegistration<"agent"> = {
kind: "agent",
resourceKey: (target) => target.agentId,
component: AgentConversationPanel,
useDescriptor: useAgentPanelDescriptor,
};
@@ -1592,7 +1593,7 @@ function ActiveAgentComposer({
if (attachment.kind !== "review") {
return;
}
openExplorerSurface({
openSidePanelView({
isCompact: isCompactFormFactor,
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
checkout: { serverId, cwd: attachment.attachment.cwd, isGit: true },
+15 -5
View File
@@ -3,8 +3,8 @@ import { AgentTaskList } from "@/composer/task-list";
import { ComposerTrackBar } from "@/composer/tracks";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
import { usePaneContext } from "@/panels/pane-context";
import { useSettings } from "@/hooks/use-settings";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
type ArchiveFinishedStatus,
useArchiveSubagent,
@@ -15,6 +15,7 @@ import { SubagentsTrack } from "@/subagents/track";
import type { TodoEntry } from "@/types/stream";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openSupportingTab } from "@/workspace-tabs/side-panel";
/**
* The pane's trackers — its subagents and its task list — as a row of pills above the composer.
@@ -39,6 +40,9 @@ export const AgentTracks = memo(function AgentTracks({
const { workspaceId, tabId, openTab } = usePaneContext();
const isCompact = useIsCompactFormFactor();
const canSplit = supportsDesktopPaneSplits() && !isCompact;
const openInSidePanelByDefault = useSettings(
(settings) => settings.openSupportingTabsInSidePanel,
);
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const canDetachSubagents = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
@@ -54,28 +58,34 @@ export const AgentTracks = memo(function AgentTracks({
return;
}
if (canSplit && workspaceKey) {
useWorkspaceLayoutStore.getState().openTabInExplorerPaneFocused(workspaceKey, {
openSupportingTab({
isCompact,
workspaceKey,
target: { kind: "agent", agentId: subagentId },
openInSidePanelByDefault,
parentTabId: tabId,
});
return;
}
navigateToAgent({ serverId, agentId: subagentId });
},
[canSplit, serverId, tabId, workspaceId, workspaceKey],
[canSplit, isCompact, openInSidePanelByDefault, serverId, tabId, workspaceId, workspaceKey],
);
const handleOpenProviderSubagent = useCallback(
(parentAgentId: string, subagentId: string) => {
if (canSplit && workspaceKey) {
useWorkspaceLayoutStore.getState().openTabInExplorerPaneFocused(workspaceKey, {
openSupportingTab({
isCompact,
workspaceKey,
target: { kind: "provider_subagent", parentAgentId, subagentId },
openInSidePanelByDefault,
parentTabId: tabId,
});
return;
}
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
},
[canSplit, openTab, tabId, workspaceKey],
[canSplit, isCompact, openInSidePanelByDefault, openTab, tabId, workspaceKey],
);
if (!hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
+27
View File
@@ -0,0 +1,27 @@
import { z } from "zod";
export const changesStateSchema = z.strictObject({
mode: z.enum(["uncommitted", "base"]),
baseRef: z.string().optional(),
layout: z.enum(["unified", "split"]),
wrapLines: z.boolean(),
hideWhitespace: z.boolean(),
treeVisible: z.boolean(),
treeWidth: z.number().optional(),
collapsedFilePaths: z.array(z.string()),
collapsedFolderPaths: z.array(z.string()),
commitsCollapsed: z.boolean(),
});
export type ChangesState = z.infer<typeof changesStateSchema>;
export const defaultChangesState: ChangesState = {
mode: "uncommitted",
layout: "unified",
wrapLines: false,
hideWhitespace: false,
treeVisible: false,
collapsedFilePaths: [],
collapsedFolderPaths: [],
commitsCollapsed: true,
};
+11 -7
View File
@@ -5,7 +5,8 @@ import { FileDiff, GitCommitHorizontal } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
import { isWeb } from "@/constants/platform";
import { DiffDocument } from "@/git/diff-document";
import { ChangesSurface, DiffLayoutToggle, resolveDiffLayout } from "@/git/diff-pane";
@@ -17,6 +18,8 @@ import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { defaultChangesState, changesStateSchema } from "@/panels/changes/state";
import { usePanelState } from "@/panels/use-panel-state";
const ThemedFileDiff = withUnistyles(FileDiff);
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
@@ -75,6 +78,7 @@ function PanelState({
function WorkingDiffPanel() {
const { t } = useTranslation();
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
const [changesState, setChangesState] = usePanelState(changesStateSchema, defaultChangesState);
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const isActive = useRetainedPanelActive();
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
@@ -101,6 +105,8 @@ function WorkingDiffPanel() {
focusRequestId={target.focusRequestId}
onOpenFile={handleOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
state={changesState}
onStateChange={setChangesState}
/>
</View>
);
@@ -151,7 +157,7 @@ function CommitDiffPanel() {
return (
<View style={styles.container} testID="commit-diff-panel">
{panelPreferences.canUseSplitLayout ? (
<View style={styles.toolbar}>
<PaneContentToolbar style={styles.toolbar} testID="commit-diff-header">
<View style={styles.toolbarActions} testID="commit-diff-toolbar">
<DiffLayoutToggle
layout={panelPreferences.preferences.layout}
@@ -160,7 +166,7 @@ function CommitDiffPanel() {
onToggle={panelPreferences.toggleLayout}
/>
</View>
</View>
</PaneContentToolbar>
) : null}
<View style={styles.body}>{body}</View>
</View>
@@ -197,12 +203,14 @@ export const workingDiffPanelRegistration: PanelRegistration<"working_diff"> = {
kind: "working_diff",
component: WorkingDiffPanel,
useDescriptor: useWorkingDiffPanelDescriptor,
resourceKey: () => "working_diff",
};
export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
kind: "commit_diff",
component: CommitDiffPanel,
useDescriptor: useCommitDiffPanelDescriptor,
resourceKey: (target) => target.sha,
};
const styles = StyleSheet.create((theme) => ({
@@ -211,15 +219,11 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
toolbar: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingRight: theme.spacing[2],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
flexShrink: 0,
},
toolbarActions: {
flexDirection: "row",
+1
View File
@@ -3,6 +3,7 @@ import type { PanelRegistration } from "@/panels/panel-registry";
export const draftPanelRegistration: PanelRegistration<"draft"> = {
kind: "draft",
resourceKey: (target) => target.draftId,
component: AgentConversationPanel,
useDescriptor: useDraftPanelDescriptor,
};
+18 -2
View File
@@ -10,6 +10,8 @@ import { createMaterialFileIcon } from "@/components/material-file-icon";
import { FileExplorerPane } from "@/components/file-explorer-pane";
import { TreeRail } from "@/components/tree-rail";
import { useIsCompactFormFactor } from "@/constants/layout";
import { defaultFileState, fileStateSchema } from "@/panels/file/state";
import { usePanelState } from "@/panels/use-panel-state";
const CENTERED_PADDED_STYLE = {
flex: 1,
@@ -35,12 +37,18 @@ function FilePanel() {
const { t } = useTranslation();
const { serverId, workspaceId, target, fileNavigationRevision, retargetCurrentTab } =
usePaneContext();
const [fileState, setFileState] = usePanelState(fileStateSchema, defaultFileState);
const isCompact = useIsCompactFormFactor();
const treeVisible = fileState.treeVisible;
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
const handleOpenFile = useCallback(
(path: string) => retargetCurrentTab({ kind: "file", path }),
[retargetCurrentTab],
);
const handleTreeWidthChange = useCallback(
(treeWidth: number) => setFileState({ ...fileState, treeWidth }),
[fileState, setFileState],
);
invariant(target.kind === "file", "FilePanel requires file target");
if (!workspaceDirectory) {
return (
@@ -49,7 +57,9 @@ function FilePanel() {
</View>
);
}
if (isCompact) {
// Branch around the whole rail rather than making the tree slot conditional —
// see the `TreeRail` children contract.
if (isCompact || !treeVisible) {
return (
<FilePane
serverId={serverId}
@@ -60,7 +70,12 @@ function FilePanel() {
);
}
return (
<TreeRail testID="file-tree-rail">
<TreeRail
testID="file-tree-rail"
width={fileState.treeWidth ?? 220}
onWidthChange={handleTreeWidthChange}
minimumWidth={160}
>
<FilePane
serverId={serverId}
workspaceRoot={workspaceDirectory}
@@ -81,4 +96,5 @@ export const filePanelRegistration: PanelRegistration<"file"> = {
kind: "file",
component: FilePanel,
useDescriptor: useFilePanelDescriptor,
resourceKey: (target) => target.path,
};
+12
View File
@@ -0,0 +1,12 @@
import { z } from "zod";
export const fileStateSchema = z.strictObject({
treeVisible: z.boolean(),
treeWidth: z.number().optional(),
});
export type FileState = z.infer<typeof fileStateSchema>;
export const defaultFileState: FileState = { treeVisible: false };
export const fileStateForFilesView: FileState = { treeVisible: true };
+1
View File
@@ -60,6 +60,7 @@ function FilesPanel() {
export const filesPanelRegistration: PanelRegistration<"files"> = {
kind: "files",
resourceKey: () => "files",
component: FilesPanel,
useDescriptor: useFilesPanelDescriptor,
};
+3
View File
@@ -1,5 +1,6 @@
import React, { createContext, useContext, type ReactNode } from "react";
import invariant from "tiny-invariant";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
@@ -8,10 +9,12 @@ export interface PaneContextValue {
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
state?: JsonValue;
fileNavigationRevision?: number;
openTab: (target: WorkspaceTabTarget) => void;
closeCurrentTab: () => void;
retargetCurrentTab: (target: WorkspaceTabTarget) => void;
setCurrentTabState: (state: JsonValue) => void;
openFileInWorkspace: (request: WorkspaceFileOpenRequest) => void;
openImportSheet: () => void;
}
@@ -31,6 +31,7 @@ export interface PanelRegistration<
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: PanelDescriptorContext,
): PanelDescriptor;
resourceKey(target: Extract<WorkspaceTabTarget, { kind: K }>): string;
}
const panelRegistry = new Map<WorkspaceTabTarget["kind"], PanelRegistration>();
@@ -230,6 +230,7 @@ const styles = StyleSheet.create((theme) => ({
export const providerSubagentPanelRegistration: PanelRegistration<"provider_subagent"> = {
kind: "provider_subagent",
resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`,
component: ProviderSubagentPanel,
useDescriptor: useProviderSubagentDescriptor,
};
@@ -56,6 +56,7 @@ function PullRequestPanel() {
export const pullRequestPanelRegistration: PanelRegistration<"pull_request"> = {
kind: "pull_request",
resourceKey: () => "pull_request",
component: PullRequestPanel,
useDescriptor: usePullRequestPanelDescriptor,
};
+8 -13
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react";
import { useCallback } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
@@ -13,29 +13,24 @@ import {
} from "@/git/pull-request-panel";
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { getPullRequestIdentity, resolvePullRequestContentState } from "./state";
export function usePullRequestAutoAdd(input: {
workspaceKey: string | null;
/**
* Whether this workspace's branch has a pull request. Detection only offers the
* pull request as something the user can open; it never opens a tab on its own.
*/
export function useHasPullRequest(input: {
serverId: string;
cwd: string | null;
enabled: boolean;
}): void {
}): boolean {
const status = useCheckoutPrStatusQuery({
serverId: input.serverId,
cwd: input.cwd || "",
enabled: input.enabled,
}).status;
const observePullRequest = useWorkspaceLayoutStore((state) => state.observePullRequest);
const identity = getPullRequestIdentity(status);
useEffect(() => {
if (input.workspaceKey) {
observePullRequest(input.workspaceKey, identity);
}
}, [identity, input.workspaceKey, observePullRequest]);
return getPullRequestIdentity(status) !== null;
}
export function PullRequestContent(input: {
+1
View File
@@ -392,6 +392,7 @@ function SetupCommandRow({
export const setupPanelRegistration: PanelRegistration<"setup"> = {
kind: "setup",
resourceKey: (target) => target.workspaceId,
component: SetupPanel,
useDescriptor: useSetupPanelDescriptor,
};
@@ -116,6 +116,7 @@ function TerminalPanel() {
export const terminalPanelRegistration: PanelRegistration<"terminal"> = {
kind: "terminal",
resourceKey: (target) => target.terminalId,
component: TerminalPanel,
useDescriptor: useTerminalPanelDescriptor,
};
@@ -0,0 +1,18 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { useCallback, useMemo } from "react";
import { z } from "zod";
import { usePaneContext } from "@/panels/pane-context";
export function usePanelState<T extends JsonValue>(
schema: z.ZodType<T>,
defaultState: T,
): [T, (state: T) => void] {
const { state, setCurrentTabState } = usePaneContext();
const parsed = useMemo(() => schema.safeParse(state), [schema, state]);
const currentState = parsed.success ? parsed.data : defaultState;
const setState = useCallback(
(nextState: T) => setCurrentTabState(nextState),
[setCurrentTabState],
);
return [currentState, setState];
}
@@ -161,6 +161,7 @@ function usePluginPanelDescriptor(
export const pluginPanelRegistration: PanelRegistration<"plugin"> = {
kind: "plugin",
resourceKey: (target) => `${target.pluginId}:${target.panelId}:${target.context}`,
component: PluginPanel,
useDescriptor: usePluginPanelDescriptor,
};
@@ -77,6 +77,7 @@ import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-
import { EditorSection } from "@/screens/settings/editor-section";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { supportsDesktopPaneSplits } from "@/constants/layout";
import { CommunityLinks } from "@/components/community-links";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
@@ -273,6 +274,7 @@ interface GeneralSectionProps {
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
handleLanguageChange: (language: AppLanguage) => void;
handleTerminalScrollbackLinesChange: (lines: number) => void;
handleSidePanelRoutingChange: (enabled: boolean) => void;
}
interface ServiceUrlBehaviorMenuItemProps {
@@ -329,6 +331,7 @@ function GeneralSection({
handleServiceUrlBehaviorChange,
handleLanguageChange,
handleTerminalScrollbackLinesChange,
handleSidePanelRoutingChange,
}: GeneralSectionProps) {
const { t, i18n } = useTranslation();
const activeLocale = getActiveLocale(i18n.language);
@@ -459,6 +462,23 @@ function GeneralSection({
accessibilityLabel={t("settings.general.terminalScrollback.accessibilityLabel")}
/>
</View>
{supportsDesktopPaneSplits() ? (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.general.sidePanelRouting.label")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.general.sidePanelRouting.description")}
</Text>
</View>
<Switch
value={settings.openSupportingTabsInSidePanel}
onValueChange={handleSidePanelRoutingChange}
accessibilityLabel={t("settings.general.sidePanelRouting.label")}
/>
</View>
) : null}
</View>
</SettingsSection>
);
@@ -1211,6 +1231,13 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
[updateSettings],
);
const handleSidePanelRoutingChange = useCallback(
(openSupportingTabsInSidePanel: boolean) => {
void updateSettings({ openSupportingTabsInSidePanel });
},
[updateSettings],
);
const handleUseLegacyTerminalRendererChange = useCallback(
(useLegacyTerminalRenderer: boolean) => {
void updateSettings({ useLegacyTerminalRenderer });
@@ -1420,6 +1447,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
handleLanguageChange={handleLanguageChange}
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
handleSidePanelRoutingChange={handleSidePanelRoutingChange}
/>
{isDesktopApp ? <BrowserDataSection /> : null}
</>
@@ -87,6 +87,8 @@ import {
type WorkspaceTabMenuLabels,
} from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabPlacement } from "@/stores/workspace-layout-actions";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import type { Theme } from "@/styles/theme";
import { RenderProfile } from "@/utils/render-profiler";
@@ -1183,7 +1185,15 @@ function ResolvedWorkspaceDesktopTabsRow({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
const openTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
const openNewTab = useCallback(
(
requestedWorkspaceKey: string,
target: WorkspaceTabTarget,
placement?: WorkspaceTabPlacement,
) => openTab({ workspaceKey: requestedWorkspaceKey, target, intent: "new", placement }),
[openTab],
);
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
updateMeasuredWidth(setTabsContainerWidth, event);
@@ -1425,9 +1435,11 @@ function ResolvedWorkspaceDesktopTabsRow({
if (!workspaceKey) {
return;
}
openTabFocused(workspaceKey, target, { paneId });
// Picked from this pane's own menu, so the tab belongs here even if it is
// currently open somewhere else.
openNewTab(workspaceKey, target, paneId ? { mode: "pane", paneId } : undefined);
},
[openTabFocused, paneId, workspaceKey],
[openNewTab, paneId, workspaceKey],
);
const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]);
const handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]);
@@ -2,6 +2,10 @@ import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
} from "@/workspace/file-open";
import {
FOCUSED_PANE_PLACEMENT,
type WorkspaceTabPlacement,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
interface OpenWorkspaceFileFromExplorerInput {
@@ -11,6 +15,7 @@ interface OpenWorkspaceFileFromExplorerInput {
openWorkspaceTabInFocusedPane: (
workspaceKey: string,
target: WorkspaceTabTarget,
placement?: WorkspaceTabPlacement,
) => string | null;
focusWorkspaceTab: (workspaceKey: string, tabId: string) => void;
}
@@ -27,6 +32,7 @@ export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplor
const tabId = input.openWorkspaceTabInFocusedPane(
input.persistenceKey,
createWorkspaceFileTabTarget(location),
FOCUSED_PANE_PLACEMENT,
);
if (tabId) {
input.focusWorkspaceTab(input.persistenceKey, tabId);
@@ -63,6 +63,7 @@ function buildContent(tab: WorkspaceTabDescriptor = agentTab) {
onOpenTab: vi.fn(),
onCloseCurrentTab: vi.fn(),
onRetargetCurrentTab: vi.fn(),
onSetCurrentTabState: vi.fn(),
onOpenWorkspaceFile: vi.fn(),
onOpenImportSheet: vi.fn(),
});
@@ -27,6 +27,7 @@ export interface BuildWorkspacePaneContentModelInput {
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
onCloseCurrentTab: () => void;
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
onSetCurrentTabState: (state: WorkspaceTabDescriptor["state"]) => void;
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
onOpenImportSheet: () => void;
}
@@ -39,6 +40,7 @@ export function buildWorkspacePaneContentModel({
onOpenTab,
onCloseCurrentTab,
onRetargetCurrentTab,
onSetCurrentTabState,
onOpenWorkspaceFile,
onOpenImportSheet,
}: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel {
@@ -53,10 +55,12 @@ export function buildWorkspacePaneContentModel({
workspaceId: normalizedWorkspaceId,
tabId: tab.tabId,
target: tab.target,
state: tab.state,
fileNavigationRevision,
openTab: onOpenTab,
closeCurrentTab: onCloseCurrentTab,
retargetCurrentTab: onRetargetCurrentTab,
setCurrentTabState: onSetCurrentTabState,
openFileInWorkspace: onOpenWorkspaceFile,
openImportSheet: onOpenImportSheet,
},
@@ -80,6 +84,7 @@ export function WorkspacePaneContent({
const openTab = useStableEvent(paneContextValue.openTab);
const closeCurrentTab = useStableEvent(paneContextValue.closeCurrentTab);
const retargetCurrentTab = useStableEvent(paneContextValue.retargetCurrentTab);
const setCurrentTabState = useStableEvent(paneContextValue.setCurrentTabState);
const openFileInWorkspace = useStableEvent(paneContextValue.openFileInWorkspace);
const openImportSheet = useStableEvent(paneContextValue.openImportSheet);
const stablePaneContextValue = useMemo(
@@ -88,10 +93,12 @@ export function WorkspacePaneContent({
workspaceId: paneContextValue.workspaceId,
tabId: paneContextValue.tabId,
target: paneContextValue.target,
state: paneContextValue.state,
fileNavigationRevision: paneContextValue.fileNavigationRevision,
openTab,
closeCurrentTab,
retargetCurrentTab,
setCurrentTabState,
openFileInWorkspace,
openImportSheet,
}),
@@ -104,8 +111,10 @@ export function WorkspacePaneContent({
paneContextValue.fileNavigationRevision,
paneContextValue.tabId,
paneContextValue.target,
paneContextValue.state,
paneContextValue.workspaceId,
retargetCurrentTab,
setCurrentTabState,
],
);
const paneFocusValue = useMemo(
@@ -0,0 +1,216 @@
import { memo, useCallback, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { useTranslation } from "react-i18next";
import {
FileDiff,
FolderTree,
GitPullRequest,
SquarePen,
SquareTerminal,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { Theme } from "@/styles/theme";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
const ThemedFileDiff = withUnistyles(FileDiff);
const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const FILES_ICON = <ThemedFolderTree size={16} uniProps={mutedColorMapping} />;
const CHANGES_ICON = <ThemedFileDiff size={16} uniProps={mutedColorMapping} />;
const PULL_REQUEST_ICON = <ThemedGitPullRequest size={16} uniProps={mutedColorMapping} />;
const AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
const TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMapping} />;
const FILES_TARGET: WorkspaceTabTarget = { kind: "files" };
const CHANGES_TARGET: WorkspaceTabTarget = { kind: "working_diff" };
const PULL_REQUEST_TARGET: WorkspaceTabTarget = { kind: "pull_request" };
/** The launcher's own binding, or nothing when the action has no shortcut. */
function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null {
const keys = useShortcutKeys(actionId);
return keys ? <Shortcut chord={keys} /> : null;
}
function getLauncherRowStyle({ pressed, hovered }: PressableStateCallbackType) {
return [styles.row, hovered && styles.rowHovered, pressed && styles.pressed];
}
/**
* One launcher. This is a row with a trailing slot, not a `<Button>` — the shortcut
* badge sits opposite the label on a shared rail, which is the list-row anatomy in
* `docs/design.md` §12 rather than anything the button primitive can express.
*/
function LauncherRow({
label,
icon,
onPress,
children,
}: {
label: string;
icon: ReactNode;
onPress: () => void;
children?: ReactNode;
}): ReactElement {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
style={getLauncherRowStyle}
>
{icon}
<Text style={styles.rowLabel}>{label}</Text>
<View style={styles.rowSpacer} />
{children}
</Pressable>
);
}
export interface WorkspacePaneEmptyStateProps {
paneId: string;
/** Changes needs a Git workspace; the pull request needs one that has been detected. */
showChanges: boolean;
showPullRequest: boolean;
onOpenView: (input: { paneId: string; target: WorkspaceTabTarget }) => void;
onCreateAgent: (input: { paneId: string }) => void;
onCreateTerminal: (input: { paneId: string }) => void;
/**
* Dismisses the pane. Absent on the last pane standing, where there is nothing
* for the layout to fall back to. What "close" means to this pane — hiding the
* Side panel, removing an ordinary split — belongs to the layout store.
*/
onClosePane: (() => void) | null;
}
export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
paneId,
showChanges,
showPullRequest,
onOpenView,
onCreateAgent,
onCreateTerminal,
onClosePane,
}: WorkspacePaneEmptyStateProps): ReactElement {
const { t } = useTranslation();
const openFiles = useCallback(
() => onOpenView({ paneId, target: FILES_TARGET }),
[onOpenView, paneId],
);
const openChanges = useCallback(
() => onOpenView({ paneId, target: CHANGES_TARGET }),
[onOpenView, paneId],
);
const openPullRequest = useCallback(
() => onOpenView({ paneId, target: PULL_REQUEST_TARGET }),
[onOpenView, paneId],
);
const createAgent = useCallback(() => onCreateAgent({ paneId }), [onCreateAgent, paneId]);
const createTerminal = useCallback(
() => onCreateTerminal({ paneId }),
[onCreateTerminal, paneId],
);
return (
<View style={styles.container} testID="workspace-pane-empty-state">
<View style={styles.rail}>
<View style={styles.stack}>
<LauncherRow
label={t("workspace.tabs.actions.files")}
icon={FILES_ICON}
onPress={openFiles}
>
<LauncherShortcut actionId="workspace-tab-target-files" />
</LauncherRow>
{showChanges ? (
<LauncherRow
label={t("workspace.tabs.actions.changes")}
icon={CHANGES_ICON}
onPress={openChanges}
>
<LauncherShortcut actionId="workspace-tab-target-changes" />
</LauncherRow>
) : null}
{showPullRequest ? (
<LauncherRow
label={t("workspace.tabs.actions.pullRequest")}
icon={PULL_REQUEST_ICON}
onPress={openPullRequest}
/>
) : null}
<LauncherRow
label={t("workspace.tabs.actions.newAgent")}
icon={AGENT_ICON}
onPress={createAgent}
>
<LauncherShortcut actionId="workspace-tab-target-agent" />
</LauncherRow>
<LauncherRow
label={t("workspace.tabs.actions.newTerminal")}
icon={TERMINAL_ICON}
onPress={createTerminal}
>
<LauncherShortcut actionId="workspace-terminal-new" />
</LauncherRow>
</View>
{onClosePane ? (
<Button variant="ghost" size="sm" style={styles.closePane} onPress={onClosePane}>
{t("workspace.tabs.actions.closePane")}
</Button>
) : null}
</View>
</View>
);
});
// Wide enough for a shortcut badge to sit clear of the longest label, narrow enough
// that the stack still reads as one column in a half-width pane.
const LAUNCHER_RAIL_WIDTH = 380;
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[6],
},
rail: {
width: "100%",
maxWidth: LAUNCHER_RAIL_WIDTH,
gap: theme.spacing[3],
},
stack: {
gap: theme.spacing[1],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 44,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
},
rowHovered: {
backgroundColor: theme.colors.surface3,
},
pressed: {
opacity: 0.85,
},
rowLabel: {
color: theme.colors.foreground,
},
rowSpacer: {
flex: 1,
},
closePane: {
alignSelf: "center",
},
}));
@@ -58,6 +58,7 @@ function normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null {
tabId,
target,
createdAt: tab.createdAt,
state: tab.state,
};
}
@@ -98,6 +99,7 @@ function normalizeWorkspacePaneTabs(tabs: WorkspaceTab[]): NormalizeWorkspacePan
tabId: normalizedTab.tabId,
kind: normalizedTab.target.kind,
target: normalizedTab.target,
state: normalizedTab.state,
},
});
}
@@ -1,4 +1,5 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
import {
memo,
@@ -47,20 +48,22 @@ import { getOrCreateClientId } from "@/utils/client-id";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import {
isExplorerSurfaceOpen,
openWorkspaceTabBeside,
openWorkspaceTabBesideInBackground,
toggleExplorerSurface,
useIsExplorerSurfaceOpen,
} from "@/workspace-tabs/explorer-surface";
isSidePanelOpen,
openSupportingTab,
toggleSidePanel,
useIsSidePanelOpen,
} from "@/workspace-tabs/side-panel";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { traceInstant } from "@/performance/native-trace";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
canDismissPaneInLayout,
collectAllTabs,
findPaneById,
getFocusedBrowserId,
FOCUSED_PANE_PLACEMENT,
type WorkspaceLayout,
type WorkspaceTabOpenOptions,
type WorkspaceTabPlacement,
useWorkspaceLayoutStore,
useWorkspaceLayoutStoreHydrated,
} from "@/stores/workspace-layout-store";
@@ -69,6 +72,7 @@ import {
type WorkspaceTab,
type WorkspaceTabTarget,
} from "@/workspace-tabs/model";
import { useSettings } from "@/hooks/use-settings";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
@@ -112,6 +116,7 @@ import {
WorkspaceTabRenameModal,
} from "@/screens/workspace/use-workspace-tab-rename";
import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory";
import { WorkspacePaneEmptyState } from "@/screens/workspace/workspace-pane-empty-state";
import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
@@ -177,7 +182,8 @@ import {
} from "@/workspace/file-open";
import { RenderProfile } from "@/utils/render-profiler";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { usePullRequestAutoAdd } from "@/panels/pull-request";
import { useHasPullRequest } from "@/panels/pull-request";
import { fileStateForFilesView } from "@/panels/file/state";
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
@@ -797,6 +803,7 @@ function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
cachedDescriptor &&
cachedDescriptor.key === tabDescriptor.key &&
cachedDescriptor.kind === tabDescriptor.kind &&
cachedDescriptor.state === tabDescriptor.state &&
workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
) {
next.set(tabDescriptor.tabId, cachedDescriptor);
@@ -1317,7 +1324,15 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
return `${serverId}:${workspaceId}`;
}
function canObservePullRequest(
/**
* A pane the user acted inside owns the tab: it opens there, and an existing tab
* moves there. No pane means the open has no opinion beyond the focused pane.
*/
function paneLocalPlacement(paneId: string | null | undefined): WorkspaceTabPlacement {
return paneId ? { mode: "pane", paneId } : FOCUSED_PANE_PLACEMENT;
}
function canDetectPullRequest(
isRouteFocused: boolean,
isGitCheckout: boolean,
isCompact: boolean,
@@ -1330,11 +1345,7 @@ interface WorkspaceTerminalTabActionsInput {
openWorkspaceTabFocused: (
workspaceKey: string,
target: WorkspaceTabTarget,
options?: WorkspaceTabOpenOptions,
) => string | null;
openWorkspaceTabInFocusedPane: (
workspaceKey: string,
target: WorkspaceTabTarget,
placement?: WorkspaceTabPlacement,
) => string | null;
labels: {
workspacePathUnavailable: string;
@@ -1357,7 +1368,6 @@ interface WorkspaceTerminalTabActions {
function useWorkspaceTerminalTabActions({
persistenceKey,
openWorkspaceTabFocused,
openWorkspaceTabInFocusedPane,
labels,
toast,
}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions {
@@ -1366,22 +1376,26 @@ function useWorkspaceTerminalTabActions({
if (!persistenceKey) {
return;
}
if (paneId) {
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId }, { paneId });
} else {
openWorkspaceTabInFocusedPane(persistenceKey, { kind: "terminal", terminalId });
}
openWorkspaceTabFocused(
persistenceKey,
{ kind: "terminal", terminalId },
paneLocalPlacement(paneId),
);
},
[openWorkspaceTabFocused, openWorkspaceTabInFocusedPane, persistenceKey],
[openWorkspaceTabFocused, persistenceKey],
);
const handleScriptTerminalSelected = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
openWorkspaceTabInFocusedPane(persistenceKey, { kind: "terminal", terminalId });
openWorkspaceTabFocused(
persistenceKey,
{ kind: "terminal", terminalId },
FOCUSED_PANE_PLACEMENT,
);
},
[openWorkspaceTabInFocusedPane, persistenceKey],
[openWorkspaceTabFocused, persistenceKey],
);
const handleWorkspacePathUnavailable = useCallback(() => {
toast.error(labels.workspacePathUnavailable);
@@ -1482,15 +1496,29 @@ function WorkspaceScreenContent({
}),
[normalizedServerId, normalizedWorkspaceId],
);
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const openWorkspaceTabInFocusedPane = useWorkspaceLayoutStore(
(state) => state.openTabInFocusedPane,
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
const openWorkspaceTabFocused = useCallback(
(workspaceKey: string, target: WorkspaceTabTarget, placement?: WorkspaceTabPlacement) =>
openTab({ workspaceKey, target, intent: "reveal", placement }),
[openTab],
);
const openWorkspaceChildTabFocused = useWorkspaceLayoutStore(
(state) => state.openChildTabFocused,
const createWorkspaceTab = useCallback(
(
workspaceKey: string,
target: WorkspaceTabTarget,
placement?: WorkspaceTabPlacement,
stateValue?: JsonValue,
) => openTab({ workspaceKey, target, intent: "new", placement, state: stateValue }),
[openTab],
);
const openWorkspaceChildTabInFocusedPane = useWorkspaceLayoutStore(
(state) => state.openChildTabInFocusedPane,
const revealWorkspaceChildTab = useCallback(
(
workspaceKey: string,
target: WorkspaceTabTarget,
parentTabId: string,
placement?: WorkspaceTabPlacement,
) => openTab({ workspaceKey, target, intent: "reveal", parentTabId, placement }),
[openTab],
);
// File targets stay identity-stable so the same path reuses its tab. Keep navigation
// requests separate so clicking an unchanged path:line can still recenter the pane.
@@ -1538,7 +1566,6 @@ function WorkspaceScreenContent({
} = useWorkspaceTerminalTabActions({
persistenceKey,
openWorkspaceTabFocused,
openWorkspaceTabInFocusedPane,
labels: {
workspacePathUnavailable: t("workspace.header.toasts.workspacePathUnavailable"),
terminalQueued: t("workspace.header.toasts.terminalQueued"),
@@ -1612,11 +1639,10 @@ function WorkspaceScreenContent({
workspace: workspaceDescriptor,
checkoutState: workspaceHeaderCheckoutState,
});
usePullRequestAutoAdd({
workspaceKey: persistenceKey,
const hasPullRequest = useHasPullRequest({
serverId: normalizedServerId,
cwd: workspaceDirectory,
enabled: canObservePullRequest(isRouteFocused, isGitCheckout, isMobile),
enabled: canDetectPullRequest(isRouteFocused, isGitCheckout, isMobile),
});
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
@@ -1632,21 +1658,21 @@ function WorkspaceScreenContent({
};
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
const isExplorerOpen = useIsExplorerSurfaceOpen({
const isSidePanelShowing = useIsSidePanelOpen({
isCompact: isMobile,
workspaceKey: persistenceKey,
});
const explorerToggleAccessibilityState = useMemo(
() => ({ expanded: isExplorerOpen }),
[isExplorerOpen],
const sidePanelToggleAccessibilityState = useMemo(
() => ({ expanded: isSidePanelShowing }),
[isSidePanelShowing],
);
useEffect(() => {
// Back dismisses the compact overlay only. On a wide native layout the
// explorer is a tab, `showMobileAgent` has no rendered consumer, and
// returning true would swallow Back with nothing to show for it.
if (!isRouteFocused || isWeb || !isMobile || !isExplorerOpen) {
if (!isRouteFocused || isWeb || !isMobile || !isSidePanelShowing) {
return;
}
@@ -1656,7 +1682,7 @@ function WorkspaceScreenContent({
});
return () => handler.remove();
}, [isExplorerOpen, isMobile, isRouteFocused, showMobileAgent]);
}, [isSidePanelShowing, isMobile, isRouteFocused, showMobileAgent]);
const workspaceLayout = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
@@ -1682,23 +1708,27 @@ function WorkspaceScreenContent({
isRouteFocused,
workspaceId: normalizedWorkspaceId,
});
const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
(state) => state.openTabInBackground,
const openWorkspaceTabInBackground = useCallback(
(workspaceKey: string, target: WorkspaceTabTarget, placement?: WorkspaceTabPlacement) =>
openTab({ workspaceKey, target, intent: "background", placement }),
[openTab],
);
const openWorkspaceTabInExplorerPaneBackground = useWorkspaceLayoutStore(
(state) => state.openTabInExplorerPaneBackground,
const openInSidePanelByDefault = useSettings(
(settings) => settings.openSupportingTabsInSidePanel,
);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
const replaceWorkspaceTabTarget = useWorkspaceLayoutStore((state) => state.replaceTab);
const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState);
const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs);
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
const handleToggleExplorer = useCallback(() => {
toggleExplorerSurface({
const closeWorkspacePane = useWorkspaceLayoutStore((state) => state.closePane);
const handleToggleSidePanel = useCallback(() => {
toggleSidePanel({
isCompact: isMobile,
workspaceKey: persistenceKey,
checkout: activeExplorerCheckout,
@@ -1836,22 +1866,13 @@ function WorkspaceScreenContent({
if (input?.ambient) {
return openWorkspaceTabFocused(persistenceKey, target);
}
const placement = paneLocalPlacement(input?.paneId);
if (input?.focus === false) {
return openWorkspaceTabInBackground(persistenceKey, target, {
paneId: input?.paneId,
});
return openWorkspaceTabInBackground(persistenceKey, target, placement);
}
if (input?.paneId) {
return openWorkspaceTabFocused(persistenceKey, target, { paneId: input.paneId });
}
return openWorkspaceTabInFocusedPane(persistenceKey, target);
return openWorkspaceTabFocused(persistenceKey, target, placement);
},
[
openWorkspaceTabFocused,
openWorkspaceTabInBackground,
openWorkspaceTabInFocusedPane,
persistenceKey,
],
[openWorkspaceTabFocused, openWorkspaceTabInBackground, persistenceKey],
);
useEffect(() => {
@@ -1932,12 +1953,16 @@ function WorkspaceScreenContent({
if (!persistenceKey) {
return;
}
const tabId = openWorkspaceTabInFocusedPane(persistenceKey, { kind: "agent", agentId });
const tabId = openWorkspaceTabFocused(
persistenceKey,
{ kind: "agent", agentId },
FOCUSED_PANE_PLACEMENT,
);
if (tabId) {
navigateToTabId(tabId);
}
},
[navigateToTabId, openWorkspaceTabInFocusedPane, persistenceKey],
[navigateToTabId, openWorkspaceTabFocused, persistenceKey],
);
const emptyWorkspaceSeedRef = useRef<string | null>(null);
@@ -1980,10 +2005,12 @@ function WorkspaceScreenContent({
return;
}
const tabId = openWorkspaceTabBesideInBackground({
const tabId = openSupportingTab({
isCompact: isMobile,
workspaceKey: persistenceKey,
target,
openInSidePanelByDefault,
background: true,
});
if (!tabId) {
return;
@@ -1995,8 +2022,7 @@ function WorkspaceScreenContent({
isMobile,
isRouteFocused,
normalizedWorkspaceId,
openWorkspaceTabInBackground,
openWorkspaceTabInExplorerPaneBackground,
openInSidePanelByDefault,
persistenceKey,
showWorkspaceSetup,
workspaceSetupSnapshot,
@@ -2030,7 +2056,9 @@ function WorkspaceScreenContent({
hasLoadedTerminals: terminalsQuery.isSuccess,
activeAgentCount: workspaceAgentVisibility.activeAgentIds.size,
terminalCount: terminals.length,
tabCount: tabs.length,
// Workspace-wide, like every other count here. A pane-scoped count made an
// empty pane look like an empty workspace and reseeded a draft on top of it.
tabCount: uiTabs.length,
})
) {
emptyWorkspaceSeedRef.current = null;
@@ -2052,13 +2080,13 @@ function WorkspaceScreenContent({
isRouteFocused,
terminals.length,
terminalsQuery.isSuccess,
tabs.length,
uiTabs.length,
workspaceDirectory,
workspaceAgentVisibility.activeAgentIds.size,
]);
const handleOpenFileFromChat = useCallback(
(location: WorkspaceFileLocation, options?: { parentTabId?: string | null }) => {
(location: WorkspaceFileLocation, parentTabId?: string | null) => {
const normalizedLocation = normalizeWorkspaceFileLocation(location);
if (!normalizedLocation) {
return;
@@ -2070,9 +2098,9 @@ function WorkspaceScreenContent({
return;
}
const target = createWorkspaceFileTabTarget(normalizedLocation);
const tabId = options?.parentTabId
? openWorkspaceChildTabInFocusedPane(persistenceKey, target, options.parentTabId)
: openWorkspaceTabInFocusedPane(persistenceKey, target);
const tabId = parentTabId
? revealWorkspaceChildTab(persistenceKey, target, parentTabId, FOCUSED_PANE_PLACEMENT)
: openWorkspaceTabFocused(persistenceKey, target, FOCUSED_PANE_PLACEMENT);
if (tabId) {
requestFileNavigation(tabId);
navigateToTabId(tabId);
@@ -2081,15 +2109,15 @@ function WorkspaceScreenContent({
[
isMobile,
navigateToTabId,
openWorkspaceChildTabInFocusedPane,
openWorkspaceTabInFocusedPane,
openWorkspaceTabFocused,
revealWorkspaceChildTab,
persistenceKey,
requestFileNavigation,
showMobileAgent,
],
);
const handleOpenAssistantFileInExplorerPane = useCallback(
const handleOpenAssistantFileInSidePanel = useCallback(
(input: { location: WorkspaceFileLocation; parentTabId?: string | null }) => {
const location = normalizeWorkspaceFileLocation(input.location);
if (!location) {
@@ -2102,10 +2130,11 @@ function WorkspaceScreenContent({
return;
}
const tabId = openWorkspaceTabBeside({
const tabId = openSupportingTab({
isCompact: isMobile,
workspaceKey: persistenceKey,
target: createWorkspaceFileTabTarget(location),
openInSidePanelByDefault,
parentTabId: input.parentTabId,
});
if (tabId) {
@@ -2113,7 +2142,14 @@ function WorkspaceScreenContent({
navigateToTabId(tabId);
}
},
[isMobile, navigateToTabId, persistenceKey, requestFileNavigation, showMobileAgent],
[
isMobile,
navigateToTabId,
openInSidePanelByDefault,
persistenceKey,
requestFileNavigation,
showMobileAgent,
],
);
const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({
@@ -2131,13 +2167,13 @@ function WorkspaceScreenContent({
focusWorkspacePane(persistenceKey, paneId);
}
if (request.disposition === "side") {
handleOpenAssistantFileInExplorerPane({
handleOpenAssistantFileInSidePanel({
location: request.location,
parentTabId,
});
return;
}
handleOpenFileFromChat(request.location, { parentTabId });
handleOpenFileFromChat(request.location, parentTabId);
});
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(null);
@@ -2197,9 +2233,9 @@ function WorkspaceScreenContent({
}),
[t],
);
const explorerToggleLabel = isExplorerOpen
? t("workspace.tabs.explorer.close")
: t("workspace.tabs.explorer.open");
const sidePanelToggleLabel = isSidePanelShowing
? t("workspace.tabs.sidePanel.close")
: t("workspace.tabs.sidePanel.open");
const activeTabKey = useMemo(() => activeTabId ?? "", [activeTabId]);
const tabFallbackLabels = useMemo(
@@ -2236,6 +2272,16 @@ function WorkspaceScreenContent({
const handleCreateTerminal = useStableEvent(createTerminal);
const handleOpenPaneView = useCallback(
(input: { paneId: string; target: WorkspaceTabTarget }) => {
if (!persistenceKey) {
return;
}
createWorkspaceTab(persistenceKey, input.target, paneLocalPlacement(input.paneId));
},
[createWorkspaceTab, persistenceKey],
);
const handleCreateTerminalWithProfile = useCallback(
(profile: TerminalProfile) => {
createTerminal({ profile });
@@ -2249,19 +2295,13 @@ function WorkspaceScreenContent({
return;
}
const { browserId } = createWorkspaceBrowser();
if (input?.paneId) {
openWorkspaceTabFocused(
persistenceKey,
{ kind: "browser", browserId },
{
paneId: input.paneId,
},
);
} else {
openWorkspaceTabInFocusedPane(persistenceKey, { kind: "browser", browserId });
}
openWorkspaceTabFocused(
persistenceKey,
{ kind: "browser", browserId },
paneLocalPlacement(input?.paneId),
);
},
[openWorkspaceTabFocused, openWorkspaceTabInFocusedPane, persistenceKey],
[openWorkspaceTabFocused, persistenceKey],
);
const handleOpenUrlInBrowserTab = useCallback(
@@ -2270,7 +2310,11 @@ function WorkspaceScreenContent({
return;
}
const { browserId } = createWorkspaceBrowser({ initialUrl: url });
openWorkspaceTabFocused(persistenceKey, { kind: "browser", browserId });
openWorkspaceTabFocused(
persistenceKey,
{ kind: "browser", browserId },
FOCUSED_PANE_PLACEMENT,
);
},
[openWorkspaceTabFocused, persistenceKey],
);
@@ -2288,20 +2332,16 @@ function WorkspaceScreenContent({
[navigateToTabId],
);
const handleCreateDraftSplit = useCallback(
// The new pane opens empty and the user picks what goes in it from the launcher.
// Seeding a draft here guessed for them, and guessed "new agent" every time.
const handleCreateEmptySplit = useCallback(
(input: { targetPaneId: string; position: "left" | "right" | "top" | "bottom" }) => {
if (!persistenceKey) {
return;
}
const paneId = splitWorkspacePaneEmpty(persistenceKey, input);
if (!paneId) {
return;
}
handleCreateDraftTab({ paneId });
splitWorkspacePaneEmpty(persistenceKey, input);
},
[handleCreateDraftTab, persistenceKey, splitWorkspacePaneEmpty],
[persistenceKey, splitWorkspacePaneEmpty],
);
const killTerminalAsync = killTerminalMutation.mutateAsync;
@@ -2621,14 +2661,23 @@ function WorkspaceScreenContent({
if (!target) {
return;
}
openWorkspaceTabBeside({ isCompact: isMobile, workspaceKey: persistenceKey, target });
}, [isMobile, normalizedWorkspaceId, persistenceKey]);
openSupportingTab({
isCompact: isMobile,
workspaceKey: persistenceKey,
target,
openInSidePanelByDefault,
});
}, [isMobile, normalizedWorkspaceId, openInSidePanelByDefault, persistenceKey]);
const handleBulkCloseTabs = useCallback(
async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => {
async (input: {
tabsToClose: WorkspaceTabDescriptor[];
title: string;
logLabel: string;
}): Promise<boolean> => {
const { tabsToClose, title, logLabel } = input;
if (tabsToClose.length === 0) {
return;
return true;
}
const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => {
@@ -2655,7 +2704,7 @@ function WorkspaceScreenContent({
destructive: true,
});
if (!confirmed) {
return;
return false;
}
await closeBulkWorkspaceTabs({
@@ -2690,6 +2739,7 @@ function WorkspaceScreenContent({
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
return true;
},
[
archiveAgent,
@@ -2767,6 +2817,42 @@ function WorkspaceScreenContent({
[handleCloseOtherTabsInPane, tabs],
);
const handleClosePane = useCallback(
async (paneId: string) => {
if (!persistenceKey || !workspaceLayout) {
return;
}
const pane = findPaneById(workspaceLayout.root, paneId);
// Ask before tearing anything down. The layout refuses to dismiss the final
// visible pane, and discovering that after closing its tabs would cost the
// user the tabs and leave the pane standing.
if (!pane || !canDismissPaneInLayout(workspaceLayout, paneId)) {
return;
}
const tabsToClose = pane.tabIds.flatMap((tabId) => {
const tab = allTabDescriptorsById.get(tabId);
return tab ? [tab] : [];
});
const closed = await handleBulkCloseTabs({
tabsToClose,
title: t("workspace.tabs.confirmations.closePaneTitle"),
logLabel: "from pane close",
});
if (!closed) {
return;
}
closeWorkspacePane(persistenceKey, paneId);
},
[
allTabDescriptorsById,
closeWorkspacePane,
handleBulkCloseTabs,
persistenceKey,
t,
workspaceLayout,
],
);
const handleWorkspaceTabAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
switch (action.id) {
@@ -2821,7 +2907,7 @@ function WorkspaceScreenContent({
const handleWorkspaceSidebarAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id === "sidebar.toggle.right") {
handleToggleExplorer();
handleToggleSidePanel();
return true;
}
if (action.id !== "sidebar.toggle.both") {
@@ -2832,17 +2918,17 @@ function WorkspaceScreenContent({
const panel = usePanelStore.getState();
toggleDesktopSidebarsWithCheckoutIntent({
isAgentListOpen: selectIsAgentListOpen(panel, { isCompact: isMobile }),
isExplorerOpen: isExplorerSurfaceOpen({
isExplorerOpen: isSidePanelOpen({
isCompact: isMobile,
workspaceKey: persistenceKey,
}),
openAgentList: () => panel.openAgentListForLayout({ isCompact: isMobile }),
closeAgentList: () => panel.closeAgentListForLayout({ isCompact: isMobile }),
toggleExplorer: handleToggleExplorer,
toggleExplorer: handleToggleSidePanel,
});
return true;
},
[handleToggleExplorer, isMobile, persistenceKey],
[handleToggleSidePanel, isMobile, persistenceKey],
);
const handleWorkspacePaneAction = useCallback(
@@ -2862,7 +2948,7 @@ function WorkspaceScreenContent({
}
if (action.id === "workspace.pane.split.right") {
handleCreateDraftSplit({
handleCreateEmptySplit({
targetPaneId: focusedPane.id,
position: "right",
});
@@ -2870,7 +2956,7 @@ function WorkspaceScreenContent({
}
if (action.id === "workspace.pane.split.down") {
handleCreateDraftSplit({
handleCreateEmptySplit({
targetPaneId: focusedPane.id,
position: "bottom",
});
@@ -2905,31 +2991,21 @@ function WorkspaceScreenContent({
}
if (action.id === "workspace.pane.close") {
const tabsToClose = focusedPane.tabIds.flatMap((tabId) => {
const tab = allTabDescriptorsById.get(tabId);
return tab ? [tab] : [];
});
void handleBulkCloseTabs({
tabsToClose,
title: t("workspace.tabs.confirmations.closePaneTitle"),
logLabel: "from pane close",
});
void handleClosePane(focusedPane.id);
return true;
}
return false;
},
[
allTabDescriptorsById,
focusWorkspacePane,
handleBulkCloseTabs,
handleCreateDraftSplit,
handleClosePane,
handleCreateEmptySplit,
moveWorkspaceTabToPane,
persistenceKey,
focusedPaneTabState.activeTabId,
focusedPaneTabState.pane,
toggleFocusMode,
t,
workspaceLayout,
],
);
@@ -3033,9 +3109,12 @@ function WorkspaceScreenContent({
if (!persistenceKey) {
return;
}
const tabId = openWorkspaceChildTabFocused(persistenceKey, target, input.tab.tabId, {
paneId: input.focusPaneBeforeOpen ? input.paneId : null,
});
const tabId = revealWorkspaceChildTab(
persistenceKey,
target,
input.tab.tabId,
paneLocalPlacement(input.focusPaneBeforeOpen ? input.paneId : null),
);
if (tabId) {
navigateToTabId(tabId);
}
@@ -3047,7 +3126,21 @@ function WorkspaceScreenContent({
if (!persistenceKey) {
return;
}
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
if (target.kind === "file" && input.tab.target.kind === "files") {
replaceWorkspaceTabTarget(
persistenceKey,
input.tab.tabId,
target,
fileStateForFilesView,
);
} else {
replaceWorkspaceTabTarget(persistenceKey, input.tab.tabId, target);
}
},
onSetCurrentTabState: (state) => {
if (persistenceKey) {
setWorkspaceTabState(persistenceKey, input.tab.tabId, state);
}
},
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => {
handleOpenWorkspaceFileFromPane({
@@ -3067,9 +3160,10 @@ function WorkspaceScreenContent({
normalizedServerId,
normalizedWorkspaceId,
openImportSheet,
openWorkspaceChildTabFocused,
revealWorkspaceChildTab,
persistenceKey,
retargetWorkspaceTab,
replaceWorkspaceTabTarget,
setWorkspaceTabState,
],
);
const focusedPaneId = useMemo(
@@ -3204,14 +3298,37 @@ function WorkspaceScreenContent({
);
const renderSplitPaneEmptyState = useCallback(
function renderSplitPaneEmptyState() {
function renderSplitPaneEmptyState(input: {
paneId: string;
paneIsolated: boolean;
onClosePane: () => void;
}) {
// Same predicate the close action reads, so the button is offered exactly when
// pressing it would do something.
const canClosePane =
!input.paneIsolated &&
Boolean(workspaceLayout) &&
canDismissPaneInLayout(workspaceLayout as WorkspaceLayout, input.paneId);
return (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>{t("workspace.tabs.emptyPane")}</Text>
</View>
<WorkspacePaneEmptyState
paneId={input.paneId}
showChanges={isGitCheckout}
showPullRequest={hasPullRequest}
onOpenView={handleOpenPaneView}
onCreateAgent={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onClosePane={canClosePane ? input.onClosePane : null}
/>
);
},
[t],
[
handleCreateDraftTab,
handleCreateTerminal,
handleOpenPaneView,
hasPullRequest,
isGitCheckout,
workspaceLayout,
],
);
const containerStyle = [styles.container, styles.containerWorkspaceBackground];
@@ -3259,15 +3376,15 @@ function WorkspaceScreenContent({
<WorkspaceActions serverId={normalizedServerId} cwd={workspaceDirectory} />
<HeaderToggleButton
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
tooltipLabel={t("workspace.tabs.explorer.toggle")}
onPress={handleToggleSidePanel}
tooltipLabel={t("workspace.tabs.sidePanel.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
tooltipSide="left"
style={styles.compactHeaderActionButton}
accessible
accessibilityRole="button"
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
accessibilityLabel={sidePanelToggleLabel}
accessibilityState={sidePanelToggleAccessibilityState}
>
{({ hovered }) => {
const colorMapping = hovered ? foregroundColorMapping : extraMutedColorMapping;
@@ -3279,15 +3396,15 @@ function WorkspaceScreenContent({
{isMobile ? (
<HeaderToggleButton
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
tooltipLabel={t("workspace.tabs.explorer.toggle")}
onPress={handleToggleSidePanel}
tooltipLabel={t("workspace.tabs.sidePanel.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
tooltipSide="left"
style={styles.headerActionButton}
accessible
accessibilityRole="button"
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
accessibilityLabel={sidePanelToggleLabel}
accessibilityState={sidePanelToggleAccessibilityState}
>
{({ hovered }) => {
const colorMapping = hovered ? foregroundColorMapping : mutedColorMapping;
@@ -3309,9 +3426,9 @@ function WorkspaceScreenContent({
handleScriptTerminalStarted,
handleViewScriptTerminal,
handleOpenUrlInBrowserTab,
handleToggleExplorer,
explorerToggleLabel,
explorerToggleAccessibilityState,
handleToggleSidePanel,
sidePanelToggleLabel,
sidePanelToggleAccessibilityState,
t,
],
);
@@ -3370,8 +3487,9 @@ function WorkspaceScreenContent({
buildPaneContentModel={buildDesktopPaneContentModel}
onFocusPane={handleFocusPane}
onSplitPane={handleSplitPane}
onSplitPaneEmpty={handleCreateDraftSplit}
onSplitPaneEmpty={handleCreateEmptySplit}
onMoveTabToPane={handleMoveTabToPane}
onClosePane={handleClosePane}
onResizeSplit={handleResizePaneSplit}
onReorderTabsInPane={handleReorderTabsInPane}
renderPaneEmptyState={renderSplitPaneEmptyState}
@@ -3407,8 +3525,9 @@ function WorkspaceScreenContent({
buildDesktopPaneContentModel,
handleFocusPane,
handleSplitPane,
handleCreateDraftSplit,
handleCreateEmptySplit,
handleMoveTabToPane,
handleClosePane,
handleResizePaneSplit,
handleReorderTabsInPane,
renderSplitPaneEmptyState,
@@ -5,4 +5,5 @@ export interface WorkspaceTabDescriptor {
tabId: string;
kind: WorkspaceTabTarget["kind"];
target: WorkspaceTabTarget;
state?: import("@getpaseo/protocol/agent-types").JsonValue;
}
@@ -37,8 +37,7 @@ function navigateDeps(): NavigateToWorkspaceDeps {
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
getSessionAgents: (serverId) =>
useSessionStore.getState().sessions[serverId]?.agents.values() ?? [],
openTabFocused: (workspaceKey, target) =>
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, target),
openTab: (input) => useWorkspaceLayoutStore.getState().openTab(input),
pinAgent: (workspaceKey, agentId) =>
useWorkspaceLayoutStore.getState().pinAgent(workspaceKey, agentId),
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
@@ -23,7 +23,7 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
const deps: NavigateToWorkspaceDeps = {
getSessionWorkspaces: () => null,
getSessionAgents: () => [] as Agent[],
openTabFocused: (workspaceKey, target) => {
openTab: ({ workspaceKey, target }) => {
openedTabs.push({ workspaceKey, target });
return target.kind === "agent" ? target.agentId : null;
},
@@ -100,9 +100,10 @@ export function navigateToWorkspace(
: [];
const attentionAgentId = pickAttentionAgent(workspaceAgents);
if (attentionAgentId && resolvedWorkspaceId) {
deps.openTabFocused(`${input.serverId}:${resolvedWorkspaceId}`, {
kind: "agent",
agentId: attentionAgentId,
deps.openTab({
workspaceKey: `${input.serverId}:${resolvedWorkspaceId}`,
target: { kind: "agent", agentId: attentionAgentId },
intent: "reveal",
});
}
}
+8 -1
View File
@@ -76,6 +76,9 @@ export interface PanelState {
explorerSortOption: SortOption;
explorerShowHiddenFiles: boolean;
treeRailWidth: number;
// File panel's tree rail. The changes panel keeps its own flag in
// `useChangesPreferences`; the two rails open and close independently.
fileTreeVisible: boolean;
// Actions
toggleFocusMode: () => void;
@@ -103,6 +106,7 @@ export interface PanelState {
setExplorerSortOption: (option: SortOption) => void;
toggleExplorerShowHiddenFiles: () => void;
setTreeRailWidth: (width: number) => void;
toggleFileTreeVisible: () => void;
}
const DEFAULT_DESKTOP_OPEN = isWeb;
@@ -137,6 +141,7 @@ export const usePanelStore = create<PanelState>()(
explorerSortOption: "name",
explorerShowHiddenFiles: true,
treeRailWidth: DEFAULT_TREE_RAIL_WIDTH,
fileTreeVisible: true,
toggleFocusMode: () =>
set((state) => ({
@@ -278,10 +283,11 @@ export const usePanelStore = create<PanelState>()(
toggleExplorerShowHiddenFiles: () =>
set((state) => ({ explorerShowHiddenFiles: !state.explorerShowHiddenFiles })),
setTreeRailWidth: (width) => set({ treeRailWidth: clampTreeRailWidth(width) }),
toggleFileTreeVisible: () => set((state) => ({ fileTreeVisible: !state.fileTreeVisible })),
}),
{
name: "panel-state",
version: 15,
version: 16,
storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema),
migrate: (persistedState, version) => migratePanelState(persistedState, version),
partialize: (state) => ({
@@ -295,6 +301,7 @@ export const usePanelStore = create<PanelState>()(
explorerSortOption: state.explorerSortOption,
explorerShowHiddenFiles: state.explorerShowHiddenFiles,
treeRailWidth: state.treeRailWidth,
fileTreeVisible: state.fileTreeVisible,
}),
},
),
@@ -137,6 +137,18 @@ describe("panel-store migration", () => {
expect(state.treeRailWidth).toBe(DEFAULT_TREE_RAIL_WIDTH);
});
it("narrows an existing install's tree rail to the new default width", () => {
expect(DEFAULT_TREE_RAIL_WIDTH).toBeLessThan(320);
expect(migratePanelState({ treeRailWidth: 320 }, 15).treeRailWidth).toBe(
DEFAULT_TREE_RAIL_WIDTH,
);
});
it("defaults the file panel tree to open and keeps it closed once closed", () => {
expect(migratePanelState({}, 15).fileTreeVisible).toBe(true);
expect(migratePanelState({ fileTreeVisible: false }, 16).fileTreeVisible).toBe(false);
});
it("drops persisted compact panel state so cold starts return to content", () => {
const state = migratePanelState(
{ mobileView: "agent-list", mobilePanel: { target: "file-explorer", revision: 42 } },
+9 -3
View File
@@ -25,8 +25,8 @@ export const DEFAULT_SIDEBAR_WIDTH = 320;
export const MIN_SIDEBAR_WIDTH = 200;
export const MAX_SIDEBAR_WIDTH = 600;
export const DEFAULT_TREE_RAIL_WIDTH = 320;
export const MIN_TREE_RAIL_WIDTH = 200;
export const DEFAULT_TREE_RAIL_WIDTH = 260;
export const MIN_TREE_RAIL_WIDTH = 180;
export const MAX_TREE_RAIL_WIDTH = 600;
export interface PanelLayoutInput {
@@ -151,6 +151,7 @@ export const PanelPersistedStateSchema = z.strictObject({
explorerShowHiddenFiles: z.boolean().optional(),
explorerFilesSplitRatio: z.number().optional(),
treeRailWidth: z.number().optional(),
fileTreeVisible: z.boolean().optional(),
});
type MigratablePanelState = z.infer<typeof PanelPersistedStateSchema>;
@@ -193,8 +194,10 @@ function migratePanelDesktopFocusMode(state: MigratablePanelState): void {
}
}
// v16 narrowed the rail. Existing installs almost all carry the old 320 default,
// so the reset is what makes the narrower rail visible to anyone but a new user.
function migrateTreeRailWidth(state: MigratablePanelState, version: number): void {
if (version < 13 || typeof state.treeRailWidth !== "number") {
if (version < 16 || typeof state.treeRailWidth !== "number") {
delete state.explorerFilesSplitRatio;
state.treeRailWidth = DEFAULT_TREE_RAIL_WIDTH;
return;
@@ -248,6 +251,9 @@ export function migratePanelState(persistedState: unknown, version: number): Mig
state.explorerShowHiddenFiles = true;
}
migrateTreeRailWidth(state, version);
if (typeof state.fileTreeVisible !== "boolean") {
state.fileTreeVisible = true;
}
if (version < 12) {
// Compact panel position is transient UI state. Cold starts always begin
// at content, regardless of what an older version persisted.
@@ -1,6 +1,8 @@
import invariant from "tiny-invariant";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
import { getPanelRegistration } from "@/panels/panel-registry";
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
import {
@@ -99,14 +101,40 @@ interface InsertSplitInternalResult {
newPaneId: string;
}
/**
* Where an open wants its tab, and how hard it wants it there.
*
* The distinction only shows up when the tab already exists somewhere. A user who
* picks Changes from a specific pane's `+` menu is placing it there and expects it
* to arrive. A file link in an agent's output is a supporting open with an opinion
* about new tabs only — it must never yank a tab out from under the pane the user
* deliberately moved it to.
*/
export type WorkspaceTabPlacement =
/** Pane-local affordance: new tabs open here, existing tabs move here. */
| { mode: "pane"; paneId: string }
/** Implicit open: new tabs open here, existing tabs stay where the user left them. */
| { mode: "prefer"; paneId: string }
/** The user is working in the focused pane, whichever one that is. */
| { mode: "focused" }
/** Nobody placed this tab — reconciliation and other opens with no user behind them. */
| { mode: "ambient" };
export const FOCUSED_PANE_PLACEMENT: WorkspaceTabPlacement = { mode: "focused" };
export const AMBIENT_PLACEMENT: WorkspaceTabPlacement = { mode: "ambient" };
interface OpenTabInLayoutInput {
layout: WorkspaceLayout;
target: WorkspaceTabTarget;
now: number;
/** Explicit placement from a pane-local affordance. Wins over every other rule. */
paneId?: string | null;
/** Required so a new caller cannot silently opt out of the explorer placement rule. */
explorerPaneId: string | null;
placement: WorkspaceTabPlacement;
/** Required so a new caller cannot silently opt out of the side-panel placement rule. */
sidePanelPaneId: string | null;
}
interface CreateTabInLayoutInput extends OpenTabInLayoutInput {
createTabId: () => string;
state?: JsonValue;
}
interface OpenTabInLayoutResult {
@@ -120,6 +148,11 @@ interface RetargetTabInLayoutInput {
target: WorkspaceTabTarget;
}
interface ReplaceTabTargetInLayoutInput extends RetargetTabInLayoutInput {
createTabId: () => string;
state?: JsonValue;
}
interface RetargetTabInLayoutResult {
layout: WorkspaceLayout;
tabId: string;
@@ -147,6 +180,11 @@ interface CloseTabInLayoutInput {
preserveEmptyPaneId?: string | null;
}
interface ClosePaneInLayoutInput {
layout: WorkspaceLayout;
paneId: string;
}
interface SplitPaneInLayoutInput {
layout: WorkspaceLayout;
tabId: string;
@@ -202,7 +240,7 @@ export interface WorkspaceTabReconcileState {
pinnedAgentIds?: ReadonlySet<string> | null;
pendingAgentIds?: ReadonlySet<string> | null;
hiddenAgentIds?: ReadonlySet<string> | null;
explorerPaneId: string | null;
sidePanelPaneId: string | null;
}
export interface WorkspaceTabSnapshot {
@@ -218,7 +256,8 @@ export interface WorkspaceTabSnapshot {
}
const DEFAULT_PANE_ID = "main";
export const DEFAULT_EXPLORER_PANE_ID = "explorer";
/** The pane id is persisted, so it keeps its pre-rename spelling. */
export const SIDE_PANEL_PANE_ID = "explorer";
const DEFAULT_LAYOUT_GROUP_ID = "workspace-root";
function trimNonEmpty(value: string | null | undefined): string | null {
@@ -300,18 +339,15 @@ function normalizeWorkspaceTab(value: unknown): WorkspaceTab | null {
if (!target) {
return null;
}
const tabId =
target.kind === "working_diff"
? buildDeterministicWorkspaceTabId(target)
: (trimNonEmpty(tab.tabId) ?? buildDeterministicWorkspaceTabId(target));
const tabId = trimNonEmpty(tab.tabId) ?? buildDeterministicWorkspaceTabId(target);
if (!tabId) {
return null;
}
return {
tabId,
target,
createdAt: typeof tab.createdAt === "number" ? tab.createdAt : Date.now(),
...(tab.state !== undefined ? { state: tab.state } : {}),
};
}
@@ -797,6 +833,7 @@ function replaceTabInTree(
tabId: string;
nextTabId: string;
target: WorkspaceTabTarget;
state?: JsonValue;
},
): SplitNodeInternal {
const panePath = findPanePathContainingTab(root, input.tabId);
@@ -807,15 +844,15 @@ function replaceTabInTree(
kind: "pane",
pane: normalizePaneAfterTabChange({
...node.pane,
tabs: node.pane.tabs.map((tab) =>
tab.tabId === input.tabId
? {
...tab,
tabId: input.nextTabId,
target: input.target,
}
: tab,
),
tabs: node.pane.tabs.map((tab) => {
if (tab.tabId !== input.tabId) return tab;
return {
tabId: input.nextTabId,
target: input.target,
createdAt: tab.createdAt,
...(input.state !== undefined ? { state: input.state } : {}),
};
}),
focusedTabId:
node.pane.focusedTabId === input.tabId ? input.nextTabId : node.pane.focusedTabId,
}),
@@ -1076,14 +1113,14 @@ export function createDefaultLayout(): WorkspaceLayout {
}
/** The desktop companion pane exists before it is first shown. */
export function createWorkspaceLayoutWithExplorer(): WorkspaceLayout {
export function createWorkspaceLayoutWithSidePanel(): WorkspaceLayout {
return {
root: createGroupNode({
id: DEFAULT_LAYOUT_GROUP_ID,
direction: "horizontal",
children: [
createPaneNode({ id: DEFAULT_PANE_ID }),
createPaneNode({ id: DEFAULT_EXPLORER_PANE_ID, hidden: true }),
createPaneNode({ id: SIDE_PANEL_PANE_ID, hidden: true }),
],
sizes: [0.5, 0.5],
}),
@@ -1125,10 +1162,10 @@ export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode {
}).root;
}
// The explorer pane is a background surface for the PR, files, and assistant output.
// It can hold focus without the user ever having looked at it, so an ambient open of
// something the user works *in* must not land there just because focus leaked.
const EXPLORER_EXCLUDED_TAB_KINDS: ReadonlySet<WorkspaceTabTarget["kind"]> = new Set([
// Tab kinds the user works *in* rather than consults. Reconciliation opens these
// with nobody behind the click, and the side panel can hold focus from an earlier
// reveal, so an ambient open must not drop an agent there just because focus stayed.
const SIDE_PANEL_EXCLUDED_TAB_KINDS: ReadonlySet<WorkspaceTabTarget["kind"]> = new Set([
"agent",
"provider_subagent",
"terminal",
@@ -1139,29 +1176,36 @@ const EXPLORER_EXCLUDED_TAB_KINDS: ReadonlySet<WorkspaceTabTarget["kind"]> = new
function resolvePlacementPane(input: {
layout: { root: SplitNode; focusedPaneId: string | null };
target: WorkspaceTabTarget;
paneId: string | null | undefined;
explorerPaneId: string | null;
placement: WorkspaceTabPlacement;
sidePanelPaneId: string | null;
}): SplitPane {
const requestedPane = findPaneById(input.layout.root, input.paneId);
const requestedPane =
input.placement.mode === "pane" || input.placement.mode === "prefer"
? findPaneById(input.layout.root, input.placement.paneId)
: null;
if (requestedPane) {
return requestedPane;
}
// `collectAllPanes` skips hidden panes, so a focused-but-hidden pane — the side
// panel between a reveal and a hide — falls through to a pane the user can see.
const focusedCandidate = findPaneById(input.layout.root, input.layout.focusedPaneId);
const focusedPane =
findPaneById(input.layout.root, input.layout.focusedPaneId) ??
(focusedCandidate?.hidden === true ? null : focusedCandidate) ??
collectAllPanes(input.layout.root)[0] ??
findPaneById(createDefaultLayout().root, DEFAULT_PANE_ID);
invariant(focusedPane, "Workspace layout must always have a pane");
if (
focusedPane.id !== input.explorerPaneId ||
!EXPLORER_EXCLUDED_TAB_KINDS.has(input.target.kind)
input.placement.mode !== "ambient" ||
focusedPane.id !== input.sidePanelPaneId ||
!SIDE_PANEL_EXCLUDED_TAB_KINDS.has(input.target.kind)
) {
return focusedPane;
}
// `collectAllPanes` skips hidden panes, so the chain falls back to the explorer
// pane itself only when it is the sole visible pane. The explorer is the only pane
// anything ever hides, so today that means "the explorer is the only pane".
// `collectAllPanes` skips hidden panes, so the chain falls back to the side panel
// pane itself only when it is the sole visible pane. The side panel is the only
// pane anything ever hides, so today that means "the side panel is the only pane".
const panes = collectAllPanes(input.layout.root);
return (
panes.find((pane) => pane.id === DEFAULT_PANE_ID && pane.id !== focusedPane.id) ??
@@ -1171,21 +1215,22 @@ function resolvePlacementPane(input: {
}
function insertNewTabIntoPane(
input: OpenTabInLayoutInput & { focus: boolean },
input: CreateTabInLayoutInput & { focus: boolean },
): OpenTabInLayoutResult {
const layout = asInternalLayout(input.layout);
const targetPane = resolvePlacementPane({
layout,
target: input.target,
paneId: input.paneId,
explorerPaneId: input.explorerPaneId,
placement: input.placement,
sidePanelPaneId: input.sidePanelPaneId,
});
const tabId = buildDeterministicWorkspaceTabId(input.target);
const tabId = input.createTabId();
const nextTab: WorkspaceTab = {
tabId,
target: input.target,
createdAt: input.now,
...(input.state !== undefined ? { state: input.state } : {}),
};
const preservedFocusTabId = targetPane.focusedTabId ?? tabId;
@@ -1205,10 +1250,15 @@ function insertNewTabIntoPane(
}
function findExistingTabForTarget(root: SplitNodeInternal, target: WorkspaceTabTarget) {
const targetTabId = buildDeterministicWorkspaceTabId(target);
const registration = getPanelRegistration(target.kind);
const targetResourceKey = registration?.resourceKey(target as never);
const targetIdentity = targetResourceKey ?? buildDeterministicWorkspaceTabId(target);
return (
collectAllTabs(root).find(
(tab) => tab.tabId === targetTabId || workspaceTabTargetsEqual(tab.target, target),
(tab) =>
tab.target.kind === target.kind &&
(registration?.resourceKey(tab.target as never) ??
buildDeterministicWorkspaceTabId(tab.target)) === targetIdentity,
) ?? null
);
}
@@ -1227,6 +1277,7 @@ function updateExistingTabTarget(
tabId: tab.tabId,
nextTabId: tab.tabId,
target,
state: tab.state,
}),
});
}
@@ -1238,17 +1289,70 @@ export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLa
const nextLayout = updateExistingTabTarget(input.layout, existingTab, input.target);
return {
tabId: existingTab.tabId,
layout:
focusTabInLayout({
layout: nextLayout,
tabId: existingTab.tabId,
}) ?? nextLayout,
layout: revealExistingTab({
layout: nextLayout,
tabId: existingTab.tabId,
placement: input.placement,
}),
};
}
return insertNewTabIntoPane({
...input,
createTabId: () => buildDeterministicWorkspaceTabId(input.target),
focus: true,
});
}
/** Always allocates an independent tab instance. */
export function createTabInLayout(input: CreateTabInLayoutInput): OpenTabInLayoutResult {
return insertNewTabIntoPane({ ...input, focus: true });
}
/** Reveals an equivalent target, or creates it when absent. */
export function revealTargetInLayout(input: CreateTabInLayoutInput): OpenTabInLayoutResult {
const existingTab = findExistingTabForTarget(asInternalLayout(input.layout).root, input.target);
if (existingTab) {
return {
tabId: existingTab.tabId,
layout: revealExistingTab({
layout: updateExistingTabTarget(input.layout, existingTab, input.target),
tabId: existingTab.tabId,
placement: input.placement,
}),
};
}
return createTabInLayout({
...input,
createTabId: () => buildDeterministicWorkspaceTabId(input.target),
});
}
/**
* Brings an already-open tab to the user. Only an explicit pane-local placement
* relocates it; every other open finds it where the user last put it.
*/
function revealExistingTab(input: {
layout: WorkspaceLayout;
tabId: string;
placement: WorkspaceTabPlacement;
}): WorkspaceLayout {
if (input.placement.mode === "pane") {
const currentPane = findPaneContainingTab(asInternalNode(input.layout.root), input.tabId);
if (currentPane?.id !== input.placement.paneId) {
const moved = moveTabToPaneInLayout({
layout: input.layout,
tabId: input.tabId,
toPaneId: input.placement.paneId,
});
if (moved) {
return moved;
}
}
}
return focusTabInLayout({ layout: input.layout, tabId: input.tabId }) ?? input.layout;
}
export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
const layout = asInternalLayout(input.layout);
const existingTab = findExistingTabForTarget(layout.root, input.target);
@@ -1259,7 +1363,11 @@ export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabI
};
}
return insertNewTabIntoPane({ ...input, focus: false });
return insertNewTabIntoPane({
...input,
createTabId: () => buildDeterministicWorkspaceTabId(input.target),
focus: false,
});
}
export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null {
@@ -1270,7 +1378,7 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
}
const preserveEmptyPaneId =
input.preserveEmptyPaneId ??
(pane.id === DEFAULT_PANE_ID || pane.id === DEFAULT_EXPLORER_PANE_ID ? pane.id : null);
(pane.id === DEFAULT_PANE_ID || pane.id === SIDE_PANEL_PANE_ID ? pane.id : null);
const closeSuccessorTabId = getCloseSuccessorTabId({
pane,
@@ -1313,6 +1421,57 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
return nextLayoutWithParentMap;
}
/**
* Whether dismissing this pane would do anything — removing it, or hiding it if it
* is the side panel. A workspace always has somewhere to look, so the last pane the
* user can see stays.
*
* Ask this *before* tearing down the pane's tabs. `closePaneInLayout` and
* `setPaneHiddenInLayout` both refuse the same case, but they refuse at the end,
* once the tabs are already gone. This is the same rule stated early enough to act
* on, and it is the one both the affordance and the action read so they cannot drift.
*/
export function canDismissPaneInLayout(layout: WorkspaceLayout, paneId: string): boolean {
const pane = findPaneById(layout.root, paneId);
if (!pane || pane.hidden === true) {
return false;
}
return collectAllPanes(layout.root).length > 1;
}
/**
* Removes a pane outright, tabs and all. Callers own tab teardown (archiving
* agents, killing terminals) before calling this; the layout only forgets them.
*
* The split tree cannot represent a workspace with nothing in it, so the last
* visible pane never goes. `main` and the explorer pane are ordinary panes here
* — `closeTabInLayout` keeps them alive when they empty, this is how they leave.
*/
export function closePaneInLayout(input: ClosePaneInLayoutInput): WorkspaceLayout | null {
const layout = asInternalLayout(input.layout);
const panePath = findPanePathById(layout.root, input.paneId);
if (!panePath) {
return null;
}
const visiblePaneIds = listPaneIds(layout.root);
if (visiblePaneIds.length <= 1 && visiblePaneIds.includes(input.paneId)) {
return null;
}
const fallbackPaneId = findNearestSiblingPaneId(layout.root, input.paneId);
const nextRoot = removePaneByPath(layout.root, panePath);
return withNormalizedParentTabMap({
root: nextRoot,
focusedPaneId: getFocusedPaneIdAfterTabClose({
root: nextRoot,
focusedPaneId: layout.focusedPaneId,
fallbackPaneId,
}),
parentTabIdByTabId: input.layout.parentTabIdByTabId,
});
}
export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout | null {
const layout = asInternalLayout(input.layout);
const pane = findPaneContainingTab(layout.root, input.tabId);
@@ -1395,6 +1554,107 @@ export function retargetTabInLayout(
};
}
/** Replaces one pane-local slot and never claims an equivalent tab elsewhere. */
export function replaceTabTargetInLayout(
input: ReplaceTabTargetInLayoutInput,
): RetargetTabInLayoutResult | null {
const layout = asInternalLayout(input.layout);
if (!findPaneContainingTab(layout.root, input.tabId)) return null;
const currentTab = collectAllTabs(layout.root).find((tab) => tab.tabId === input.tabId) ?? null;
if (currentTab && workspaceTabTargetsEqual(currentTab.target, input.target)) {
if (input.state === undefined) {
return { layout: input.layout, tabId: input.tabId };
}
return {
tabId: input.tabId,
layout: withNormalizedParentTabMap({
root: replaceTabInTree(layout.root, {
tabId: input.tabId,
nextTabId: input.tabId,
target: input.target,
state: input.state,
}),
focusedPaneId: layout.focusedPaneId,
parentTabIdByTabId: input.layout.parentTabIdByTabId,
}),
};
}
let tabId = input.createTabId();
if (currentTab?.target.kind === "draft") {
tabId = input.tabId;
} else if (input.target.kind === "draft") {
tabId = buildDeterministicWorkspaceTabId(input.target);
}
const parentTabIdByTabId = transferReplacedTabParent({
parentTabIdByTabId: input.layout.parentTabIdByTabId,
replacedTabId: input.tabId,
replacementTabId: tabId,
});
let state: JsonValue | undefined;
if (input.state !== undefined) {
state = input.state;
} else if (currentTab?.target.kind === input.target.kind) {
state = currentTab.state;
}
return {
tabId,
layout: withNormalizedParentTabMap({
root: replaceTabInTree(layout.root, {
tabId: input.tabId,
nextTabId: tabId,
target: input.target,
state,
}),
focusedPaneId: layout.focusedPaneId,
parentTabIdByTabId,
}),
};
}
function transferReplacedTabParent(input: {
parentTabIdByTabId?: Record<string, string>;
replacedTabId: string;
replacementTabId: string;
}): Record<string, string> | undefined {
if (!input.parentTabIdByTabId) {
return undefined;
}
const parentTabId = input.parentTabIdByTabId[input.replacedTabId];
const renamed: Record<string, string> = {};
for (const [childTabId, currentParentTabId] of Object.entries(input.parentTabIdByTabId)) {
if (childTabId === input.replacedTabId) {
continue;
}
renamed[childTabId] =
currentParentTabId === input.replacedTabId ? input.replacementTabId : currentParentTabId;
}
if (parentTabId) {
renamed[input.replacementTabId] =
parentTabId === input.replacedTabId ? input.replacementTabId : parentTabId;
}
return Object.keys(renamed).length > 0 ? renamed : undefined;
}
export function setTabStateInLayout(input: {
layout: WorkspaceLayout;
tabId: string;
state: JsonValue | undefined;
}): WorkspaceLayout | null {
const layout = asInternalLayout(input.layout);
const tab = collectAllTabs(layout.root).find((candidate) => candidate.tabId === input.tabId);
if (!tab) return null;
return withNormalizedParentTabMap({
root: replaceTabInTree(layout.root, {
tabId: tab.tabId,
nextTabId: tab.tabId,
target: tab.target,
state: input.state,
}),
focusedPaneId: layout.focusedPaneId,
parentTabIdByTabId: input.layout.parentTabIdByTabId,
});
}
export function convertDraftToAgentInLayout(
input: ConvertDraftToAgentInLayoutInput,
): ConvertDraftToAgentInLayoutResult | null {
@@ -1558,7 +1818,12 @@ export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): Worksp
const detached = detachTabFromTree(layout.root, {
tabId: input.tabId,
preserveEmptyPaneId: sourcePane.id === input.toPaneId ? input.toPaneId : null,
// Dragging a pane's last tab elsewhere collapses that pane — except the side
// panel, which the user summons and expects to find again, geometry and all.
preserveEmptyPaneId:
sourcePane.id === input.toPaneId || sourcePane.id === SIDE_PANEL_PANE_ID
? sourcePane.id
: null,
});
if (!detached.tab) {
return null;
@@ -1606,6 +1871,10 @@ export function setPaneHiddenInLayout(input: {
if (!pane || isHidden === input.hidden) {
return null;
}
// A workspace always has somewhere to look. This is the single gate on hiding the
// last visible pane — `hideSidePanel` and `closePane` both land here, so neither
// needs its own check, and `collectAllPanes` skipping hidden panes is what makes
// "the only one left" mean visibly left.
if (input.hidden && collectAllPanes(input.layout.root).length === 1) {
return null;
}
@@ -1771,13 +2040,15 @@ function isTerminalTab(
function openEntityTabWithoutFocusing(input: {
layout: WorkspaceLayout;
target: WorkspaceTabTarget;
explorerPaneId: string | null;
sidePanelPaneId: string | null;
}): WorkspaceLayout {
return insertNewTabIntoPane({
layout: input.layout,
target: input.target,
now: Date.now(),
explorerPaneId: input.explorerPaneId,
placement: AMBIENT_PLACEMENT,
sidePanelPaneId: input.sidePanelPaneId,
createTabId: () => buildDeterministicWorkspaceTabId(input.target),
focus: false,
}).layout;
}
@@ -1869,7 +2140,7 @@ function addMissingEntityTabs(input: {
standaloneTerminalIds: Set<string>;
hasActivePendingTerminalCreate: boolean;
hasActivePendingDraftCreate: boolean;
explorerPaneId: string | null;
sidePanelPaneId: string | null;
}): WorkspaceLayout {
const {
autoOpenAgentIds,
@@ -1877,7 +2148,7 @@ function addMissingEntityTabs(input: {
standaloneTerminalIds,
hasActivePendingTerminalCreate,
hasActivePendingDraftCreate,
explorerPaneId,
sidePanelPaneId,
} = input;
let nextLayout = input.layout;
const currentEntityTabs = collectAllTabs(nextLayout.root);
@@ -1899,7 +2170,7 @@ function addMissingEntityTabs(input: {
nextLayout = openEntityTabWithoutFocusing({
layout: nextLayout,
target: { kind: "agent", agentId },
explorerPaneId,
sidePanelPaneId,
});
currentAgentIds.add(agentId);
}
@@ -1913,7 +2184,7 @@ function addMissingEntityTabs(input: {
nextLayout = openEntityTabWithoutFocusing({
layout: nextLayout,
target: { kind: "terminal", terminalId },
explorerPaneId,
sidePanelPaneId,
});
currentTerminalIds.add(terminalId);
}
@@ -2006,7 +2277,7 @@ export function reconcileWorkspaceTabs(
standaloneTerminalIds,
hasActivePendingTerminalCreate: snapshot.hasActivePendingTerminalCreate ?? false,
hasActivePendingDraftCreate: snapshot.hasActivePendingDraftCreate ?? false,
explorerPaneId: state.explorerPaneId,
sidePanelPaneId: state.sidePanelPaneId,
});
if (reconciledFocusedTabId) {
File diff suppressed because it is too large Load Diff
+268 -395
View File
@@ -3,20 +3,26 @@ import { useEffect, useState } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { z } from "zod";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
import {
defaultWorkspaceLayoutIds,
type WorkspaceLayoutIdSource,
} from "@/stores/workspace-layout-ids";
import {
canDismissPaneInLayout,
clampNormalizedSizes,
closePaneInLayout,
closeTabInLayout,
collectAllPanes,
collectAllTabs,
convertDraftToAgentInLayout,
createTabInLayout,
createDefaultLayout,
createWorkspaceLayoutWithExplorer,
DEFAULT_EXPLORER_PANE_ID,
AMBIENT_PLACEMENT,
createWorkspaceLayoutWithSidePanel,
FOCUSED_PANE_PLACEMENT,
SIDE_PANEL_PANE_ID,
findPaneById,
findPaneContainingTab,
focusPaneInLayout,
@@ -27,20 +33,22 @@ import {
moveTabToPaneInLayout,
normalizeLayout,
openTabInLayoutBackground,
openTabInLayoutFocused,
replaceTabTargetInLayout,
revealTargetInLayout,
reconcileWorkspaceTabs,
removePaneFromTree,
removeTabFromTree,
reorderFocusedPaneTabsInLayout,
reorderPaneTabsInLayout,
retargetTabInLayout,
setPaneHiddenInLayout,
setTabStateInLayout,
splitPaneEmptyInLayout,
splitPaneInLayout,
stripEphemeralTabsFromLayout,
type SplitGroup,
type SplitNode,
type SplitPane,
type WorkspaceTabPlacement,
type WorkspaceTabReconcileState,
type WorkspaceTabSnapshot,
type WorkspaceLayout,
@@ -49,10 +57,13 @@ import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
export {
AMBIENT_PLACEMENT,
canDismissPaneInLayout,
collectAllPanes,
collectAllTabs,
createDefaultLayout,
createWorkspaceLayoutWithExplorer,
createWorkspaceLayoutWithSidePanel,
FOCUSED_PANE_PLACEMENT,
findPaneById,
findPaneContainingTab,
getFocusedBrowserId,
@@ -68,10 +79,21 @@ export type {
SplitNode,
SplitPane,
WorkspaceLayout,
WorkspaceTabPlacement,
WorkspaceTabReconcileState,
WorkspaceTabSnapshot,
};
export type WorkspaceTabOpenIntent = "new" | "reveal" | "background";
export interface OpenWorkspaceTabInput {
workspaceKey: string;
target: WorkspaceTabTarget;
intent: WorkspaceTabOpenIntent;
placement?: WorkspaceTabPlacement;
parentTabId?: string;
state?: JsonValue;
}
interface WorkspaceLayoutStore {
layoutByWorkspace: Record<string, WorkspaceLayout>;
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
@@ -79,43 +101,20 @@ interface WorkspaceLayoutStore {
pendingAgentIdsByWorkspace: Record<string, Set<string>>;
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
explorerPaneIdByWorkspace: Record<string, string | null>;
acknowledgedPullRequestByWorkspace: Record<string, string>;
openTabFocused: (
workspaceKey: string,
target: WorkspaceTabTarget,
options?: WorkspaceTabOpenOptions,
) => string | null;
openTabInFocusedPane: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
openChildTabFocused: (
workspaceKey: string,
target: WorkspaceTabTarget,
parentTabId: string,
options?: WorkspaceTabOpenOptions,
) => string | null;
openChildTabInFocusedPane: (
workspaceKey: string,
target: WorkspaceTabTarget,
parentTabId: string,
) => string | null;
openTabInBackground: (
workspaceKey: string,
target: WorkspaceTabTarget,
options?: WorkspaceTabOpenOptions,
) => string | null;
openTabInExplorerPaneBackground: (
workspaceKey: string,
target: WorkspaceTabTarget,
) => string | null;
ensureExplorerPane: (workspaceKey: string) => EnsureExplorerPaneResult | null;
openTabInExplorerPaneFocused: (
workspaceKey: string,
input: { target: WorkspaceTabTarget; parentTabId?: string | null },
) => string | null;
observePullRequest: (workspaceKey: string, identity: string | null) => void;
sidePanelPaneIdByWorkspace: Record<string, string | null>;
openTab: (input: OpenWorkspaceTabInput) => string | null;
/** Reveals the side panel without putting anything in it. Returns its pane id. */
showSidePanel: (workspaceKey: string) => string | null;
hideSidePanel: (workspaceKey: string) => void;
closeTab: (workspaceKey: string, tabId: string) => void;
focusTab: (workspaceKey: string, tabId: string) => void;
retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null;
replaceTab: (
workspaceKey: string,
tabId: string,
target: WorkspaceTabTarget,
state?: JsonValue,
) => string | null;
setTabState: (workspaceKey: string, tabId: string, state: JsonValue | undefined) => void;
convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null;
reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void;
resolvePendingAgent: (workspaceKey: string, agentId: string) => void;
@@ -137,10 +136,13 @@ interface WorkspaceLayoutStore {
},
) => string | null;
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
/**
* Dismisses the pane along with whatever it still holds. The side panel hides so
* the user can bring it back; every other pane is removed. Callers own tab teardown
* (archiving agents, killing terminals) first. No-ops on the last visible pane.
*/
closePane: (workspaceKey: string, paneId: string) => void;
focusPane: (workspaceKey: string, paneId: string) => void;
hidePane: (workspaceKey: string, paneId: string) => void;
showPane: (workspaceKey: string, paneId: string) => void;
setExplorerPaneId: (workspaceKey: string, paneId: string | null) => void;
unfocusPane: (workspaceKey: string) => string | null;
restorePaneFocus: (workspaceKey: string, token: string) => void;
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
@@ -152,22 +154,12 @@ interface WorkspaceLayoutStore {
purgeWorkspace: (workspaceKey: string) => void;
}
/** `paneId` is explicit placement for a pane-local affordance; omitted opens use policy. */
export interface WorkspaceTabOpenOptions {
paneId?: string | null;
}
interface EnsureExplorerPaneResult {
paneId: string;
created: boolean;
}
interface WorkspaceFocusRestorationState {
restorePaneId: string | null;
tokens: string[];
}
// The root companion split is always present for the hidden explorer pane.
// The root companion split is always present for the hidden side panel pane.
// Preserve four user-created split levels beneath it.
const MAX_TREE_DEPTH = 5;
@@ -232,6 +224,7 @@ const WorkspaceTabStorageSchema = z.strictObject({
tabId: z.string(),
target: WorkspaceTabTargetStorageSchema,
createdAt: z.number(),
state: z.json().optional(),
});
const SplitNodeStorageSchema: z.ZodType<SplitNode> = z.lazy(() =>
z.discriminatedUnion("kind", [
@@ -264,7 +257,11 @@ const WorkspaceLayoutStorageSchema: z.ZodType<WorkspaceLayout> = z.strictObject(
const WorkspaceLayoutPersistedStateSchema = z.strictObject({
layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema),
splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(),
// The persisted keys keep their pre-rename spelling: the schema is strict, so a
// rename here would fail every existing blob and wipe the layout it describes.
explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
// COMPAT(pullRequestAutoAdd): PR detection stopped opening a tab in v0.5; accepted
// and ignored so upgrading does not discard the layout. Remove after 2027-08-20.
acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(),
});
@@ -276,6 +273,14 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
function createWorkspaceTabInstanceId(): string {
const value =
typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `tab_${value}`;
}
function addAgentIdToWorkspaceSet(
state: Record<string, Set<string>>,
workspaceKey: string,
@@ -322,10 +327,33 @@ function getWorkspaceLayout(
state: Record<string, WorkspaceLayout>,
workspaceKey: string,
): WorkspaceLayout {
return normalizeLayout(state[workspaceKey] ?? createWorkspaceLayoutWithExplorer());
return normalizeLayout(state[workspaceKey] ?? createWorkspaceLayoutWithSidePanel());
}
export function resolveExplorerPaneId(
type SidePanelState = Pick<
WorkspaceLayoutStore,
"layoutByWorkspace" | "sidePanelPaneIdByWorkspace"
>;
/**
* The side panel's pane, on screen or not. A workspace the user has not laid out
* yet still has one — the default layout is born with it — so this answers before
* the first tab exists.
*/
export function selectSidePanelPaneId(state: SidePanelState, workspaceKey: string): string | null {
const layout = getWorkspaceLayout(state.layoutByWorkspace, workspaceKey);
return resolveSidePanelPaneId(layout, state.sidePanelPaneIdByWorkspace[workspaceKey]);
}
/** Whether the side panel pane is currently on screen. */
export function selectIsSidePanelVisible(state: SidePanelState, workspaceKey: string): boolean {
const layout = state.layoutByWorkspace[workspaceKey];
const paneId = layout ? selectSidePanelPaneId(state, workspaceKey) : null;
const pane = paneId && layout ? findPaneById(layout.root, paneId) : null;
return Boolean(pane && pane.hidden !== true);
}
export function resolveSidePanelPaneId(
layout: WorkspaceLayout,
registeredPaneId: string | null | undefined,
): string | null {
@@ -333,16 +361,16 @@ export function resolveExplorerPaneId(
if (registeredPane) {
return registeredPane.id;
}
const defaultPane = findPaneById(layout.root, DEFAULT_EXPLORER_PANE_ID);
const defaultPane = findPaneById(layout.root, SIDE_PANEL_PANE_ID);
return defaultPane?.id ?? null;
}
function ensurePersistedExplorerPane(input: {
function ensurePersistedSidePanelPane(input: {
layout: WorkspaceLayout;
registeredPaneId: string | null | undefined;
ids: WorkspaceLayoutIdSource;
}): { layout: WorkspaceLayout; paneId: string } | null {
const existingPaneId = resolveExplorerPaneId(input.layout, input.registeredPaneId);
const existingPaneId = resolveSidePanelPaneId(input.layout, input.registeredPaneId);
if (existingPaneId) {
return { layout: input.layout, paneId: existingPaneId };
}
@@ -373,13 +401,17 @@ function ensurePersistedExplorerPane(input: {
function getOpenTabPlacement(
state: WorkspaceLayoutStore,
workspaceKey: string,
options: WorkspaceTabOpenOptions | undefined,
): { layout: WorkspaceLayout; paneId: string | null; explorerPaneId: string | null } {
placement: WorkspaceTabPlacement | undefined,
): {
layout: WorkspaceLayout;
placement: WorkspaceTabPlacement;
sidePanelPaneId: string | null;
} {
const layout = getWorkspaceLayout(state.layoutByWorkspace, workspaceKey);
return {
layout,
paneId: trimNonEmpty(options?.paneId),
explorerPaneId: resolveExplorerPaneId(layout, state.explorerPaneIdByWorkspace[workspaceKey]),
placement: placement ?? AMBIENT_PLACEMENT,
sidePanelPaneId: resolveSidePanelPaneId(layout, state.sidePanelPaneIdByWorkspace[workspaceKey]),
};
}
@@ -420,6 +452,20 @@ function attachParentTab(input: {
});
}
/**
* Splits a side panel out of the pane the user is in. Only reached by layouts saved
* before the side panel became part of the default tree; new ones are born with it.
*/
function createSidePanelPane(
workspaceKey: string,
layout: WorkspaceLayout,
splitPaneEmpty: WorkspaceLayoutStore["splitPaneEmpty"],
): string | null {
const targetPaneId =
findPaneById(layout.root, layout.focusedPaneId)?.id ?? collectAllPanes(layout.root)[0]?.id;
return targetPaneId ? splitPaneEmpty(workspaceKey, { targetPaneId, position: "right" }) : null;
}
export function createWorkspaceLayoutStore(
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
) {
@@ -432,21 +478,37 @@ export function createWorkspaceLayoutStore(
pendingAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {},
explorerPaneIdByWorkspace: {},
acknowledgedPullRequestByWorkspace: {},
openTabFocused: (workspaceKey, target, options) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
sidePanelPaneIdByWorkspace: {},
openTab: (input) => {
const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey);
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
if (!normalizedWorkspaceKey || !normalizedTarget) {
return null;
}
const result = openTabInLayoutFocused({
...getOpenTabPlacement(get(), normalizedWorkspaceKey, options),
target: normalizedTarget,
now: Date.now(),
});
const placement = getOpenTabPlacement(get(), normalizedWorkspaceKey, input.placement);
let result;
if (input.intent === "new") {
result = createTabInLayout({
...placement,
target: normalizedTarget,
now: Date.now(),
createTabId: createWorkspaceTabInstanceId,
state: input.state,
});
} else if (input.intent === "background") {
result = openTabInLayoutBackground({
...placement,
target: normalizedTarget,
now: Date.now(),
});
} else {
result = revealTargetInLayout({
...placement,
target: normalizedTarget,
now: Date.now(),
createTabId: createWorkspaceTabInstanceId,
});
}
set((state) => ({
...withoutFocusRestoration(state, normalizedWorkspaceKey),
hiddenAgentIdsByWorkspace:
@@ -459,244 +521,73 @@ export function createWorkspaceLayoutStore(
),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: result.layout,
[normalizedWorkspaceKey]: input.parentTabId
? attachParentTab({
layout: result.layout,
childTabId: result.tabId,
parentTabId: input.parentTabId,
})
: result.layout,
},
}));
return result.tabId;
},
openTabInFocusedPane: (workspaceKey, target) => {
showSidePanel: (workspaceKey) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) {
return null;
}
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
const focusedPane = findPaneById(layout.root, layout.focusedPaneId);
return get().openTabFocused(normalizedWorkspaceKey, target, {
paneId: focusedPane?.hidden === true ? null : focusedPane?.id,
});
},
openChildTabFocused: (workspaceKey, target, parentTabId, options) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedParentTabId = trimNonEmpty(parentTabId);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!normalizedWorkspaceKey || !normalizedParentTabId || !normalizedTarget) {
return null;
}
const result = openTabInLayoutFocused({
...getOpenTabPlacement(get(), normalizedWorkspaceKey, options),
target: normalizedTarget,
now: Date.now(),
});
set((state) => {
const layout = attachParentTab({
layout: result.layout,
childTabId: result.tabId,
parentTabId: normalizedParentTabId,
});
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
hiddenAgentIdsByWorkspace:
normalizedTarget.kind !== "agent"
? state.hiddenAgentIdsByWorkspace
: removeAgentIdFromWorkspaceSet(
state.hiddenAgentIdsByWorkspace,
normalizedWorkspaceKey,
normalizedTarget.agentId,
),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: layout,
},
};
});
return result.tabId;
},
openChildTabInFocusedPane: (workspaceKey, target, parentTabId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) {
return null;
}
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
const focusedPane = findPaneById(layout.root, layout.focusedPaneId);
return get().openChildTabFocused(normalizedWorkspaceKey, target, parentTabId, {
paneId: focusedPane?.hidden === true ? null : focusedPane?.id,
});
},
openTabInBackground: (workspaceKey, target, options) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!normalizedWorkspaceKey || !normalizedTarget) {
return null;
}
const result = openTabInLayoutBackground({
...getOpenTabPlacement(get(), normalizedWorkspaceKey, options),
target: normalizedTarget,
now: Date.now(),
});
set((state) => ({
hiddenAgentIdsByWorkspace:
normalizedTarget.kind !== "agent"
? state.hiddenAgentIdsByWorkspace
: removeAgentIdFromWorkspaceSet(
state.hiddenAgentIdsByWorkspace,
normalizedWorkspaceKey,
normalizedTarget.agentId,
),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: result.layout,
},
}));
return result.tabId;
},
openTabInExplorerPaneBackground: (workspaceKey, target) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!normalizedWorkspaceKey || !normalizedTarget) {
return null;
}
let tabId: string | null = null;
set((state) => {
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const explorerPaneId = resolveExplorerPaneId(
const paneId =
resolveSidePanelPaneId(
layout,
state.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
);
const explorerPane = findPaneById(layout.root, explorerPaneId);
if (!explorerPane) {
return state;
}
// A new tab is placed straight into the explorer pane, hidden or not.
// A tab already open elsewhere stays where the user put it — this open
// must not move, reveal, or focus anything.
const result = openTabInLayoutBackground({
layout,
target: normalizedTarget,
now: Date.now(),
paneId: explorerPane.id,
explorerPaneId: explorerPane.id,
});
tabId = result.tabId;
return {
explorerPaneIdByWorkspace: {
...state.explorerPaneIdByWorkspace,
[normalizedWorkspaceKey]: explorerPane.id,
},
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: result.layout,
},
};
});
return tabId;
},
ensureExplorerPane: (workspaceKey) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) {
return null;
}
const store = get();
const layout = getWorkspaceLayout(store.layoutByWorkspace, normalizedWorkspaceKey);
const explorerPaneId = resolveExplorerPaneId(
layout,
store.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
);
const explorerPane = findPaneById(layout.root, explorerPaneId);
if (explorerPane) {
if (store.explorerPaneIdByWorkspace[normalizedWorkspaceKey] !== explorerPane.id) {
store.setExplorerPaneId(normalizedWorkspaceKey, explorerPane.id);
}
store.focusPane(normalizedWorkspaceKey, explorerPane.id);
return { paneId: explorerPane.id, created: false };
}
const targetPaneId =
findPaneById(layout.root, layout.focusedPaneId)?.id ??
collectAllPanes(layout.root)[0]?.id;
if (!targetPaneId) {
return null;
}
const paneId = store.splitPaneEmpty(normalizedWorkspaceKey, {
targetPaneId,
position: "right",
});
get().sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
) ?? createSidePanelPane(normalizedWorkspaceKey, layout, get().splitPaneEmpty);
if (!paneId) {
return null;
}
get().setExplorerPaneId(normalizedWorkspaceKey, paneId);
return { paneId, created: true };
},
openTabInExplorerPaneFocused: (workspaceKey, input) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
if (!normalizedWorkspaceKey || !normalizedTarget) {
return null;
}
const store = get();
const explorerPane = store.ensureExplorerPane(normalizedWorkspaceKey);
if (!explorerPane) {
return null;
}
const parentTabId = trimNonEmpty(input.parentTabId);
const placement = { paneId: explorerPane.paneId };
const tabId = parentTabId
? get().openChildTabFocused(
normalizedWorkspaceKey,
normalizedTarget,
parentTabId,
placement,
)
: get().openTabFocused(normalizedWorkspaceKey, normalizedTarget, placement);
if (!tabId) {
return null;
}
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
const currentPane = findPaneContainingTab(layout.root, tabId);
if (currentPane?.id !== explorerPane.paneId) {
get().moveTabToPane(normalizedWorkspaceKey, tabId, explorerPane.paneId);
}
return tabId;
set((state) =>
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey] === paneId
? state
: {
sidePanelPaneIdByWorkspace: {
...state.sidePanelPaneIdByWorkspace,
[normalizedWorkspaceKey]: paneId,
},
},
);
// Focusing a hidden pane reveals it, so this is both halves of "show".
get().focusPane(normalizedWorkspaceKey, paneId);
return paneId;
},
observePullRequest: (workspaceKey, identity) => {
hideSidePanel: (workspaceKey) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedIdentity = trimNonEmpty(identity);
if (!normalizedWorkspaceKey || !normalizedIdentity) {
if (!normalizedWorkspaceKey) {
return;
}
if (
get().acknowledgedPullRequestByWorkspace[normalizedWorkspaceKey] === normalizedIdentity
) {
return;
}
// A detected pull request is a background add: the tab appears in the
// explorer pane, but nothing is revealed and focus never moves.
const tabId = get().openTabInExplorerPaneBackground(normalizedWorkspaceKey, {
kind: "pull_request",
set((state) => {
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const paneId = resolveSidePanelPaneId(
layout,
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
);
const nextLayout = paneId
? setPaneHiddenInLayout({ layout, paneId, hidden: true })
: null;
if (!nextLayout) {
return state;
}
return {
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
},
};
});
if (!tabId) {
return;
}
set((state) => ({
acknowledgedPullRequestByWorkspace: {
...state.acknowledgedPullRequestByWorkspace,
[normalizedWorkspaceKey]: normalizedIdentity,
},
}));
},
closeTab: (workspaceKey, tabId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
@@ -707,20 +598,28 @@ export function createWorkspaceLayoutStore(
set((state) => {
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const explorerPaneId = resolveExplorerPaneId(
const sidePanelPaneId = resolveSidePanelPaneId(
layout,
state.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
);
const closingPane = findPaneContainingTab(layout.root, normalizedTabId);
const preserveEmptyPaneId =
closingPane?.id === "main" || closingPane?.id === explorerPaneId
closingPane?.id === "main" || closingPane?.id === sidePanelPaneId
? closingPane.id
: null;
const nextLayout = closeTabInLayout({
const closedLayout = closeTabInLayout({
layout,
tabId: normalizedTabId,
preserveEmptyPaneId,
});
const nextLayout =
closedLayout && closingPane?.id === sidePanelPaneId && closingPane.tabIds.length === 1
? (setPaneHiddenInLayout({
layout: closedLayout,
paneId: sidePanelPaneId,
hidden: true,
}) ?? closedLayout)
: closedLayout;
if (!nextLayout) {
return state;
}
@@ -759,27 +658,21 @@ export function createWorkspaceLayoutStore(
};
});
},
retargetTab: (workspaceKey, tabId, target) => {
replaceTab: (workspaceKey, tabId, target, tabState) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTabId = trimNonEmpty(tabId);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) {
return null;
}
const result = retargetTabInLayout({
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null;
const result = replaceTabTargetInLayout({
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
tabId: normalizedTabId,
target: normalizedTarget,
createTabId: createWorkspaceTabInstanceId,
state: tabState,
});
if (!result) {
return null;
}
if (!result) return null;
set((state) => ({
...(result.layout.focusedPaneId !== null
? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {})
: {}),
...withoutFocusRestoration(state, normalizedWorkspaceKey),
hiddenAgentIdsByWorkspace:
normalizedTarget.kind !== "agent"
? state.hiddenAgentIdsByWorkspace
@@ -793,9 +686,27 @@ export function createWorkspaceLayoutStore(
[normalizedWorkspaceKey]: result.layout,
},
}));
return result.tabId;
},
setTabState: (workspaceKey, tabId, tabState) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedWorkspaceKey || !normalizedTabId) return;
set((state) => {
const layout = setTabStateInLayout({
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
tabId: normalizedTabId,
state: tabState,
});
if (!layout) return state;
return {
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: layout,
},
};
});
},
convertDraftToAgent: (workspaceKey, tabId, agentId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTabId = trimNonEmpty(tabId);
@@ -847,9 +758,9 @@ export function createWorkspaceLayoutStore(
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
explorerPaneId: resolveExplorerPaneId(
sidePanelPaneId: resolveSidePanelPaneId(
currentLayout,
state.explorerPaneIdByWorkspace[normalizedWorkspaceKey],
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
),
},
snapshot,
@@ -1002,6 +913,38 @@ export function createWorkspaceLayoutStore(
};
});
},
closePane: (workspaceKey, paneId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedPaneId = trimNonEmpty(paneId);
if (!normalizedWorkspaceKey || !normalizedPaneId) {
return;
}
set((state) => {
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
// The side panel is a surface the user summons, so closing it puts it
// away rather than dismantling the split it lives in.
const isSidePanel =
resolveSidePanelPaneId(
layout,
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
) === normalizedPaneId;
const nextLayout = isSidePanel
? setPaneHiddenInLayout({ layout, paneId: normalizedPaneId, hidden: true })
: closePaneInLayout({ layout, paneId: normalizedPaneId });
if (!nextLayout) {
return state;
}
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
},
};
});
},
focusPane: (workspaceKey, paneId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedPaneId = trimNonEmpty(paneId);
@@ -1027,67 +970,6 @@ export function createWorkspaceLayoutStore(
};
});
},
hidePane: (workspaceKey, paneId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedPaneId = trimNonEmpty(paneId);
if (!normalizedWorkspaceKey || !normalizedPaneId) {
return;
}
set((state) => {
const nextLayout = setPaneHiddenInLayout({
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
paneId: normalizedPaneId,
hidden: true,
});
if (!nextLayout) {
return state;
}
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
},
};
});
},
showPane: (workspaceKey, paneId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedPaneId = trimNonEmpty(paneId);
if (!normalizedWorkspaceKey || !normalizedPaneId) {
return;
}
set((state) => {
const nextLayout = setPaneHiddenInLayout({
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
paneId: normalizedPaneId,
hidden: false,
});
if (!nextLayout) {
return state;
}
return {
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
},
};
});
},
setExplorerPaneId: (workspaceKey, paneId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) {
return;
}
set((state) => ({
explorerPaneIdByWorkspace: {
...state.explorerPaneIdByWorkspace,
[normalizedWorkspaceKey]: trimNonEmpty(paneId),
},
}));
},
unfocusPane: (workspaceKey) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) {
@@ -1359,8 +1241,7 @@ export function createWorkspaceLayoutStore(
normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
normalizedWorkspaceKey in state.explorerPaneIdByWorkspace ||
normalizedWorkspaceKey in state.acknowledgedPullRequestByWorkspace;
normalizedWorkspaceKey in state.sidePanelPaneIdByWorkspace;
if (!hasAny) {
return state;
}
@@ -1376,12 +1257,8 @@ export function createWorkspaceLayoutStore(
state.hiddenAgentIdsByWorkspace;
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
state.focusRestorationByWorkspace;
const { [normalizedWorkspaceKey]: _explorerPane, ...explorerPaneIdByWorkspace } =
state.explorerPaneIdByWorkspace;
const {
[normalizedWorkspaceKey]: _acknowledgedPullRequest,
...acknowledgedPullRequestByWorkspace
} = state.acknowledgedPullRequestByWorkspace;
const { [normalizedWorkspaceKey]: _sidePanelPane, ...sidePanelPaneIdByWorkspace } =
state.sidePanelPaneIdByWorkspace;
return {
layoutByWorkspace,
splitSizesByWorkspace,
@@ -1389,8 +1266,7 @@ export function createWorkspaceLayoutStore(
pendingAgentIdsByWorkspace,
hiddenAgentIdsByWorkspace,
focusRestorationByWorkspace,
explorerPaneIdByWorkspace,
acknowledgedPullRequestByWorkspace,
sidePanelPaneIdByWorkspace,
};
});
},
@@ -1411,8 +1287,7 @@ export function createWorkspaceLayoutStore(
return {
layoutByWorkspace,
splitSizesByWorkspace: state.splitSizesByWorkspace,
explorerPaneIdByWorkspace: state.explorerPaneIdByWorkspace,
acknowledgedPullRequestByWorkspace: state.acknowledgedPullRequestByWorkspace,
explorerPaneIdByWorkspace: state.sidePanelPaneIdByWorkspace,
};
},
merge: (persistedState, currentState) => {
@@ -1421,31 +1296,29 @@ export function createWorkspaceLayoutStore(
return currentState;
}
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
const explorerPaneIdByWorkspace: Record<string, string | null> = {
const sidePanelPaneIdByWorkspace: Record<string, string | null> = {
...result.data.explorerPaneIdByWorkspace,
};
for (const [workspaceKey, persistedLayout] of Object.entries(
result.data.layoutByWorkspace,
)) {
const explorer = ensurePersistedExplorerPane({
const sidePanel = ensurePersistedSidePanelPane({
layout: normalizeLayout(persistedLayout),
registeredPaneId: explorerPaneIdByWorkspace[workspaceKey],
registeredPaneId: sidePanelPaneIdByWorkspace[workspaceKey],
ids,
});
if (!explorer) {
if (!sidePanel) {
layoutByWorkspace[workspaceKey] = normalizeLayout(persistedLayout);
continue;
}
layoutByWorkspace[workspaceKey] = explorer.layout;
explorerPaneIdByWorkspace[workspaceKey] = explorer.paneId;
layoutByWorkspace[workspaceKey] = sidePanel.layout;
sidePanelPaneIdByWorkspace[workspaceKey] = sidePanel.paneId;
}
return {
...currentState,
layoutByWorkspace,
splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {},
explorerPaneIdByWorkspace,
acknowledgedPullRequestByWorkspace:
result.data.acknowledgedPullRequestByWorkspace ?? {},
sidePanelPaneIdByWorkspace,
};
},
},
@@ -254,7 +254,7 @@ describe("workspace subagents integration", () => {
).toEqual([child.id]);
});
it("opens a subagent in the explorer pane when focused", () => {
it("opens a subagent in the side panel when focused", () => {
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
@@ -275,22 +275,26 @@ describe("workspace subagents integration", () => {
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
const store = useWorkspaceLayoutStore.getState();
const tabId = store.openTabInExplorerPaneFocused(workspaceKey!, {
const paneId = store.showSidePanel(workspaceKey!) as string;
const tabId = store.openTab({
workspaceKey: workspaceKey!,
target: { kind: "agent", agentId: child.id },
intent: "reveal",
parentTabId: `agent_${parent.id}`,
placement: { mode: "prefer", paneId },
});
const state = useWorkspaceLayoutStore.getState();
const layout = state.layoutByWorkspace[workspaceKey!];
const explorerPaneId = state.explorerPaneIdByWorkspace[workspaceKey!];
const sidePanelPaneId = state.sidePanelPaneIdByWorkspace[workspaceKey!];
expect(explorerPaneId).toBeTruthy();
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(explorerPaneId);
expect(findPaneById(layout.root, explorerPaneId!)?.hidden).toBeUndefined();
expect(layout.focusedPaneId).toBe(explorerPaneId);
expect(sidePanelPaneId).toBeTruthy();
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(sidePanelPaneId);
expect(findPaneById(layout.root, sidePanelPaneId!)?.hidden).toBeUndefined();
expect(layout.focusedPaneId).toBe(sidePanelPaneId);
});
it("opens a provider subagent in the explorer pane when focused", () => {
it("opens a provider subagent in the side panel when focused", () => {
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
@@ -298,18 +302,22 @@ describe("workspace subagents integration", () => {
expect(workspaceKey).toBeTruthy();
const store = useWorkspaceLayoutStore.getState();
const tabId = store.openTabInExplorerPaneFocused(workspaceKey!, {
const paneId = store.showSidePanel(workspaceKey!) as string;
const tabId = store.openTab({
workspaceKey: workspaceKey!,
target: { kind: "provider_subagent", parentAgentId: "parent-agent", subagentId: "task-1" },
intent: "reveal",
parentTabId: "agent_parent-agent",
placement: { mode: "prefer", paneId },
});
const state = useWorkspaceLayoutStore.getState();
const layout = state.layoutByWorkspace[workspaceKey!];
const explorerPaneId = state.explorerPaneIdByWorkspace[workspaceKey!];
const sidePanelPaneId = state.sidePanelPaneIdByWorkspace[workspaceKey!];
expect(explorerPaneId).toBeTruthy();
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(explorerPaneId);
expect(findPaneById(layout.root, explorerPaneId!)?.hidden).toBeUndefined();
expect(layout.focusedPaneId).toBe(explorerPaneId);
expect(sidePanelPaneId).toBeTruthy();
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(sidePanelPaneId);
expect(findPaneById(layout.root, sidePanelPaneId!)?.hidden).toBeUndefined();
expect(layout.focusedPaneId).toBe(sidePanelPaneId);
});
});
@@ -9,7 +9,11 @@ export interface PrepareWorkspaceTabInput {
}
export interface PrepareWorkspaceTabDeps {
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
openTab: (input: {
workspaceKey: string;
target: WorkspaceTabTarget;
intent: "reveal";
}) => string | null;
pinAgent: (workspaceKey: string, agentId: string) => void;
}
@@ -31,7 +35,7 @@ export function prepareWorkspaceTab(
workspaceId: input.workspaceId,
}) ?? "";
deps.openTabFocused(key, target);
deps.openTab({ workspaceKey: key, target, intent: "reveal" });
if (input.pin && target.kind === "agent") {
deps.pinAgent(key, target.agentId);
@@ -22,7 +22,13 @@ function createFakeLayout() {
return {
openedTabs,
pinnedAgents,
openTabFocused: (key: string, target: WorkspaceTabTarget) => {
openTab: ({
workspaceKey: key,
target,
}: {
workspaceKey: string;
target: WorkspaceTabTarget;
}) => {
openedTabs.push({ key, target });
return target.kind === "agent" ? target.agentId : null;
},

Some files were not shown because too many files have changed in this diff Show More