diff --git a/CLAUDE.md b/CLAUDE.md index 3068dd3fc..b72d99731 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/docs/development.md b/docs/development.md index ad584aa61..f8834a341 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 diff --git a/docs/glossary.md b/docs/glossary.md index e19005307..d83ca9980 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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`. diff --git a/docs/side-panel.md b/docs/side-panel.md new file mode 100644 index 000000000..e19d3fffe --- /dev/null +++ b/docs/side-panel.md @@ -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. diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 2c08323aa..c77ab6b52 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -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 { + 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 { await page.addInitScript(() => { const fontSet = document.fonts; diff --git a/packages/app/e2e/browser/commit-diff-panel.spec.ts b/packages/app/e2e/browser/commit-diff-panel.spec.ts index 0eac05516..f044cc90d 100644 --- a/packages/app/e2e/browser/commit-diff-panel.spec.ts +++ b/packages/app/e2e/browser/commit-diff-panel.spec.ts @@ -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); diff --git a/packages/app/e2e/browser/diff-performance.spec.ts b/packages/app/e2e/browser/diff-performance.spec.ts index 183abbab4..7d7c65ab9 100644 --- a/packages/app/e2e/browser/diff-performance.spec.ts +++ b/packages/app/e2e/browser/diff-performance.spec.ts @@ -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); diff --git a/packages/app/e2e/browser/explorer-pane-placement.spec.ts b/packages/app/e2e/browser/explorer-pane-placement.spec.ts index 35a820daa..cdca2cf91 100644 --- a/packages/app/e2e/browser/explorer-pane-placement.spec.ts +++ b/packages/app/e2e/browser/explorer-pane-placement.spec.ts @@ -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 { - 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 { 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 } }); diff --git a/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts b/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts index ddadeb452..29b6b5256 100644 --- a/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts +++ b/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts @@ -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(); diff --git a/packages/app/e2e/browser/file-transition.spec.ts b/packages/app/e2e/browser/file-transition.spec.ts new file mode 100644 index 000000000..8235ce057 --- /dev/null +++ b/packages/app/e2e/browser/file-transition.spec.ts @@ -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>, +) { + 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); + }); +}); diff --git a/packages/app/e2e/browser/file-tree-rail-toggle.spec.ts b/packages/app/e2e/browser/file-tree-rail-toggle.spec.ts new file mode 100644 index 000000000..d023d2219 --- /dev/null +++ b/packages/app/e2e/browser/file-tree-rail-toggle.spec.ts @@ -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"); + }); +}); diff --git a/packages/app/e2e/browser/side-panel.spec.ts b/packages/app/e2e/browser/side-panel.spec.ts new file mode 100644 index 000000000..64fbd7f7d --- /dev/null +++ b/packages/app/e2e/browser/side-panel.spec.ts @@ -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 { + 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 { + 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(); + } + }); +}); diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 0568a3abd..e7c375f4d 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -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(); diff --git a/packages/app/e2e/browser/workspace-pane-remount.spec.ts b/packages/app/e2e/browser/workspace-pane-remount.spec.ts index ee403cfce..1f0efec74 100644 --- a/packages/app/e2e/browser/workspace-pane-remount.spec.ts +++ b/packages/app/e2e/browser/workspace-pane-remount.spec.ts @@ -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); diff --git a/packages/app/e2e/support/helpers/file-read-gate.ts b/packages/app/e2e/support/helpers/file-read-gate.ts new file mode 100644 index 000000000..f6624ef7d --- /dev/null +++ b/packages/app/e2e/support/helpers/file-read-gate.ts @@ -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((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?.(), + }; +} diff --git a/packages/app/e2e/support/helpers/split-pane.ts b/packages/app/e2e/support/helpers/split-pane.ts index ba95ec295..1e5cb17ec 100644 --- a/packages/app/e2e/support/helpers/split-pane.ts +++ b/packages/app/e2e/support/helpers/split-pane.ts @@ -13,12 +13,12 @@ function splitSeparator(page: Page): Locator { } export async function openExplorerSplit(page: Page): Promise { - 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 { - 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 }); } diff --git a/packages/app/e2e/support/helpers/workspace-tabs.ts b/packages/app/e2e/support/helpers/workspace-tabs.ts index c0d2082a5..e0ef99db8 100644 --- a/packages/app/e2e/support/helpers/workspace-tabs.ts +++ b/packages/app/e2e/support/helpers/workspace-tabs.ts @@ -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 { @@ -32,35 +30,51 @@ async function selectWorkspaceTab(tab: Locator): Promise { 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 { 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 { + 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 { - 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 { - 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 { 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 { diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index c003366f0..5a8d5bd58 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -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 + useWorkspaceLayoutStore.getState().openTab({ + workspaceKey, + target, + intent: "reveal", + placement, + }), focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab, }); }, diff --git a/packages/app/src/components/compact-explorer-sidebar-host.tsx b/packages/app/src/components/compact-explorer-sidebar-host.tsx index c22a4b3a2..d001eba69 100644 --- a/packages/app/src/components/compact-explorer-sidebar-host.tsx +++ b/packages/app/src/components/compact-explorer-sidebar-host.tsx @@ -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); diff --git a/packages/app/src/components/compact-explorer-sidebar.tsx b/packages/app/src/components/compact-explorer-sidebar.tsx index 68b5d99c8..23af1f161 100644 --- a/packages/app/src/components/compact-explorer-sidebar.tsx +++ b/packages/app/src/components/compact-explorer-sidebar.tsx @@ -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({ @@ -237,7 +238,7 @@ function ExplorerSidebarContent({ @@ -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(() => + changesStateSchema.parse(defaultChangesState), + ); return ( ); } diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 7b165e0b1..84a215f9c 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -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 ( @@ -1058,6 +1071,7 @@ export function FileExplorerPane({ > ); @@ -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 ( - + - + - + ) : null} @@ -1275,9 +1297,15 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { testID="files-hidden-toggle" > {showHiddenFiles ? ( - + ) : ( - + )} {isRefreshFetching ? ( - + ) : ( - + )} - + {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, }, diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index a9fa4a164..995002f9c 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -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 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 ( - + ); }) - : (renderPaneEmptyState?.() ?? null)} + : (renderPaneEmptyState?.({ + paneId, + paneIsolated, + onClosePane: handleClosePane, + }) ?? null)} diff --git a/packages/app/src/components/tree-rail-layout.ts b/packages/app/src/components/tree-rail-layout.ts index 1c08649c2..b9535b2c8 100644 --- a/packages/app/src/components/tree-rail-layout.ts +++ b/packages/app/src/components/tree-rail-layout.ts @@ -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( diff --git a/packages/app/src/components/tree-rail-toggle.tsx b/packages/app/src/components/tree-rail-toggle.tsx new file mode 100644 index 000000000..b88713d74 --- /dev/null +++ b/packages/app/src/components/tree-rail-toggle.tsx @@ -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 ( + + + + + + + + {label} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + tooltipText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/tree-rail.tsx b/packages/app/src/components/tree-rail.tsx index b3c4eacf5..e0a431966 100644 --- a/packages/app/src/components/tree-rail.tsx +++ b/packages/app/src/components/tree-rail.tsx @@ -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( diff --git a/packages/app/src/components/ui/pane-content-toolbar.tsx b/packages/app/src/components/ui/pane-content-toolbar.tsx new file mode 100644 index 000000000..8ddfad0c9 --- /dev/null +++ b/packages/app/src/components/ui/pane-content-toolbar.tsx @@ -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; + testID?: string; +}) { + return ( + + {children} + + ); +} + +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, + }, +})); diff --git a/packages/app/src/components/ui/shortcut.tsx b/packages/app/src/components/ui/shortcut.tsx index dc654cddd..6b9e00600 100644 --- a/packages/app/src/components/ui/shortcut.tsx +++ b/packages/app/src/components/ui/shortcut.tsx @@ -39,6 +39,7 @@ export function Shortcut({ if (displayChord.length === 1) { return ( + {formatShortcut(singleCombo, shortcutOs)} ); @@ -49,6 +50,7 @@ export function Shortcut({ {displayChord.map(function (combo) { return ( + {formatShortcut(combo, shortcutOs)} ); @@ -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", diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index ce980f14a..a8cf37813 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -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 }, diff --git a/packages/app/src/composer/focused-chat-target.test.ts b/packages/app/src/composer/focused-chat-target.test.ts index d1e621b80..925dbb89d 100644 --- a/packages/app/src/composer/focused-chat-target.test.ts +++ b/packages/app/src/composer/focused-chat-target.test.ts @@ -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", diff --git a/packages/app/src/composer/focused-chat-target.ts b/packages/app/src/composer/focused-chat-target.ts index c410d0188..7ee0167d8 100644 --- a/packages/app/src/composer/focused-chat-target.ts +++ b/packages/app/src/composer/focused-chat-target.ts @@ -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; } diff --git a/packages/app/src/desktop/browser/automation/handler.test.ts b/packages/app/src/desktop/browser/automation/handler.test.ts index 739584e0e..2f6d0ec68 100644 --- a/packages/app/src/desktop/browser/automation/handler.test.ts +++ b/packages/app/src/desktop/browser/automation/handler.test.ts @@ -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()); diff --git a/packages/app/src/desktop/browser/automation/handler.ts b/packages/app/src/desktop/browser/automation/handler.ts index b646ee9ed..bc30fdadb 100644 --- a/packages/app/src/desktop/browser/automation/handler.ts +++ b/packages/app/src/desktop/browser/automation/handler.ts @@ -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) { diff --git a/packages/app/src/desktop/browser/new-tab-requests/index.test.ts b/packages/app/src/desktop/browser/new-tab-requests/index.test.ts index d8ae58717..236e290bf 100644 --- a/packages/app/src/desktop/browser/new-tab-requests/index.test.ts +++ b/packages/app/src/desktop/browser/new-tab-requests/index.test.ts @@ -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; } diff --git a/packages/app/src/desktop/browser/panel.tsx b/packages/app/src/desktop/browser/panel.tsx index 16539a3fd..f4c89ee21 100644 --- a/packages/app/src/desktop/browser/panel.tsx +++ b/packages/app/src/desktop/browser/panel.tsx @@ -73,6 +73,7 @@ function BrowserPanel() { export const browserPanelRegistration: PanelRegistration<"browser"> = { kind: "browser", + resourceKey: (target) => target.browserId, component: BrowserPanel, useDescriptor: useBrowserPanelDescriptor, }; diff --git a/packages/app/src/file-pane/bar.tsx b/packages/app/src/file-pane/bar.tsx index a380cbc5c..51569785e 100644 --- a/packages/app/src/file-pane/bar.tsx +++ b/packages/app/src/file-pane/bar.tsx @@ -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 ( - - - - - {formatFileSize(size)} - - {lineCount !== undefined ? ( + + + + - {t("panels.file.editor.lines", { count: lineCount })} + {formatFileSize(size)} - ) : null} - - - {editorStatus === "dirty" ? ( - + {t("panels.file.editor.lines", { count: lineCount })} + + ) : null} + + + {editorStatus === "dirty" ? ( + + ) : null} + {editorStatus === "saving" ? ( + <> + + {t("panels.file.editor.saving")} + + ) : null} + {editorStatus === "error" ? ( + {t("panels.file.editor.saveFailed")} + ) : null} + {vimMode ? ( + + {vimMode} + + ) : null} + {cursor ? ( + + Ln {cursor.line}, Col {cursor.column} + + ) : null} + + {mode && onModeChange ? ( + ) : null} - {editorStatus === "saving" ? ( - <> - - {t("panels.file.editor.saving")} - - ) : null} - {editorStatus === "error" ? ( - {t("panels.file.editor.saveFailed")} - ) : null} - {vimMode ? ( - - {vimMode} - - ) : null} - {cursor ? ( - - Ln {cursor.line}, Col {cursor.column} - - ) : null} + - {mode && onModeChange ? ( - - ) : null} - + {conflict ? : null} ); @@ -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, diff --git a/packages/app/src/file-pane/live-file/hook.ts b/packages/app/src/file-pane/live-file/hook.ts index e318f7c0c..932301055 100644 --- a/packages/app/src/file-pane/live-file/hook.ts +++ b/packages/app/src/file-pane/live-file/hook.ts @@ -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, }; } diff --git a/packages/app/src/file-pane/live-file/model.test.ts b/packages/app/src/file-pane/live-file/model.test.ts index c8b450ed1..c84f73e8e 100644 --- a/packages/app/src/file-pane/live-file/model.test.ts +++ b/packages/app/src/file-pane/live-file/model.test.ts @@ -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(); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 9c1c7fb49..2d49eecac 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -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 ( - + {t("panels.file.loading")} @@ -283,7 +250,7 @@ function FilePreviewBody({ if (!preview) { return ( - + {t("panels.file.noPreview")} ); @@ -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 ( 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; +} diff --git a/packages/app/src/file-pane/preview-lifecycle/model.test.ts b/packages/app/src/file-pane/preview-lifecycle/model.test.ts new file mode 100644 index 000000000..a934e69b5 --- /dev/null +++ b/packages/app/src/file-pane/preview-lifecycle/model.test.ts @@ -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 { + promise: Promise; + resolve(value: T): void; + reject(error: Error): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((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(); + const second = deferred(); + const third = deferred(); + 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(); + const second = deferred(); + 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 { + await new Promise((resolve) => queueMicrotask(resolve)); + expect(model.getSnapshot()).toEqual(expected); +} diff --git a/packages/app/src/file-pane/preview-lifecycle/model.ts b/packages/app/src/file-pane/preview-lifecycle/model.ts new file mode 100644 index 000000000..fe69f2227 --- /dev/null +++ b/packages/app/src/file-pane/preview-lifecycle/model.ts @@ -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 { + 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, + ) {} + + 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 { + 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"; +} diff --git a/packages/app/src/file-pane/tree-toggle.tsx b/packages/app/src/file-pane/tree-toggle.tsx new file mode 100644 index 000000000..641ceaf8f --- /dev/null +++ b/packages/app/src/file-pane/tree-toggle.tsx @@ -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 ; +} diff --git a/packages/app/src/git/commits-section/commits-section.tsx b/packages/app/src/git/commits-section/commits-section.tsx index eaf579b17..29b215410 100644 --- a/packages/app/src/git/commits-section/commits-section.tsx +++ b/packages/app/src/git/commits-section/commits-section.tsx @@ -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) { diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index b27f268c4..55f024972 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -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 ( - - - - - - - - {label} - - - ); -} - 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 ( - + {!isMobile && hasFiles ? ( - ) : null} {isMobile ? : null} - + ); } @@ -531,7 +504,7 @@ function ChangesOptionsMenu({ style={overflowToggleStyle} > @@ -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 | StyleProp[], + baseStyles?: StyleProp | StyleProp[], + 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(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 ( - + {children} ); @@ -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 = ( 0} - workspaceId={workspaceId} - cwd={cwd} files={files} mode={workingMode} onSelectFile={handleSelectTreeFile} + treeWidth={instanceState.treeWidth} + onTreeWidthChange={handleChangesTreeWidth} + collapsedFolderPaths={instanceState.collapsedFolderPaths} + onCollapsedFolderPathsChange={updateCollapsedFolderPaths} > {diffContent} @@ -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({ {bodyContent} - + ); } @@ -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], diff --git a/packages/app/src/git/pull-request-panel/check-group-state.ts b/packages/app/src/git/pull-request-panel/check-group-state.ts new file mode 100644 index 000000000..53c7e9a69 --- /dev/null +++ b/packages/app/src/git/pull-request-panel/check-group-state.ts @@ -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>(() => 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 }; +} diff --git a/packages/app/src/git/pull-request-panel/checks-section.tsx b/packages/app/src/git/pull-request-panel/checks-section.tsx index 80521602a..fddb0e292 100644 --- a/packages/app/src/git/pull-request-panel/checks-section.tsx +++ b/packages/app/src/git/pull-request-panel/checks-section.tsx @@ -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>(() => 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 ( diff --git a/packages/app/src/git/pull-request-panel/pane-skeleton.tsx b/packages/app/src/git/pull-request-panel/pane-skeleton.tsx index f2716dd1a..9ddeeadf2 100644 --- a/packages/app/src/git/pull-request-panel/pane-skeleton.tsx +++ b/packages/app/src/git/pull-request-panel/pane-skeleton.tsx @@ -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() { - + - + {t("workspace.git.pr.sections.checks")} @@ -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, diff --git a/packages/app/src/git/pull-request-panel/pane.tsx b/packages/app/src/git/pull-request-panel/pane.tsx index a093cacef..e4faa833f 100644 --- a/packages/app/src/git/pull-request-panel/pane.tsx +++ b/packages/app/src/git/pull-request-panel/pane.tsx @@ -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 ( - + + ) : null} + + + ); +}); + +// 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", + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-pane-state.ts b/packages/app/src/screens/workspace/workspace-pane-state.ts index 016e2f0c8..b188865ee 100644 --- a/packages/app/src/screens/workspace/workspace-pane-state.ts +++ b/packages/app/src/screens/workspace/workspace-pane-state.ts @@ -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, }, }); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index c7c1cb268..1b71032fa 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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(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(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 => { 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 ( - - {t("workspace.tabs.emptyPane")} - + ); }, - [t], + [ + handleCreateDraftTab, + handleCreateTerminal, + handleOpenPaneView, + hasPullRequest, + isGitCheckout, + workspaceLayout, + ], ); const containerStyle = [styles.container, styles.containerWorkspaceBackground]; @@ -3259,15 +3376,15 @@ function WorkspaceScreenContent({ {({ hovered }) => { const colorMapping = hovered ? foregroundColorMapping : extraMutedColorMapping; @@ -3279,15 +3396,15 @@ function WorkspaceScreenContent({ {isMobile ? ( {({ 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, diff --git a/packages/app/src/screens/workspace/workspace-tabs-types.ts b/packages/app/src/screens/workspace/workspace-tabs-types.ts index 4a41512fa..ca216e492 100644 --- a/packages/app/src/screens/workspace/workspace-tabs-types.ts +++ b/packages/app/src/screens/workspace/workspace-tabs-types.ts @@ -5,4 +5,5 @@ export interface WorkspaceTabDescriptor { tabId: string; kind: WorkspaceTabTarget["kind"]; target: WorkspaceTabTarget; + state?: import("@getpaseo/protocol/agent-types").JsonValue; } diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index 9adcd6c6b..7c28c5b74 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -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), diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts index ce05714f1..8db0e5224 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -23,7 +23,7 @@ function createFakeDeps(overrides: Partial = {}) { 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; }, diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index 393653c62..d4100d3ad 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -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", }); } } diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index 2570b54a7..0fa86399d 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -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()( explorerSortOption: "name", explorerShowHiddenFiles: true, treeRailWidth: DEFAULT_TREE_RAIL_WIDTH, + fileTreeVisible: true, toggleFocusMode: () => set((state) => ({ @@ -278,10 +283,11 @@ export const usePanelStore = create()( 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()( explorerSortOption: state.explorerSortOption, explorerShowHiddenFiles: state.explorerShowHiddenFiles, treeRailWidth: state.treeRailWidth, + fileTreeVisible: state.fileTreeVisible, }), }, ), diff --git a/packages/app/src/stores/panel-store/state.test.ts b/packages/app/src/stores/panel-store/state.test.ts index 929190270..19c8d7d17 100644 --- a/packages/app/src/stores/panel-store/state.test.ts +++ b/packages/app/src/stores/panel-store/state.test.ts @@ -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 } }, diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts index 3310a646c..c9b68d258 100644 --- a/packages/app/src/stores/panel-store/state.ts +++ b/packages/app/src/stores/panel-store/state.ts @@ -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; @@ -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. diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 6e7a32fc1..f9cc759c8 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -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 | null; pendingAgentIds?: ReadonlySet | null; hiddenAgentIds?: ReadonlySet | 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 = 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 = new Set([ "agent", "provider_subagent", "terminal", @@ -1139,29 +1176,36 @@ const EXPLORER_EXCLUDED_TAB_KINDS: ReadonlySet = 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; + replacedTabId: string; + replacementTabId: string; +}): Record | undefined { + if (!input.parentTabIdByTabId) { + return undefined; + } + const parentTabId = input.parentTabIdByTabId[input.replacedTabId]; + const renamed: Record = {}; + 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; 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) { diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 34c30e25b..0fe7069e1 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -17,14 +17,18 @@ vi.mock("@react-native-async-storage/async-storage", () => { import AsyncStorage from "@react-native-async-storage/async-storage"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model"; +import { defaultChangesState, type ChangesState } from "@/panels/changes/state"; +import { defaultFileState, type FileState } from "@/panels/file/state"; import { + canDismissPaneInLayout, collectAllPanes, collectAllTabs, createWorkspaceLayoutStore, createDefaultLayout, - createWorkspaceLayoutWithExplorer, + createWorkspaceLayoutWithSidePanel, findPaneById, findPaneContainingTab, + FOCUSED_PANE_PLACEMENT, getFocusedBrowserId, getTreeDepth, insertSplit, @@ -110,6 +114,10 @@ function createWorkspaceKey(): string { return key as string; } +function collectTabIds(root: SplitNode): string[] { + return collectAllTabs(root).map((tab) => tab.tabId); +} + function expectGroup(node: SplitNode): Extract { expect(node.kind).toBe("group"); return node as Extract; @@ -300,9 +308,9 @@ describe("workspace-layout-store tree transforms", () => { for (const position of ["left", "right"] as const) { it(`keeps every pane in place when a tab is dragged ${position} from the default layout`, () => { workspaceLayoutIds.reset(); - const mainPaneId = collectAllPanes(createWorkspaceLayoutWithExplorer().root)[0]?.id; + const mainPaneId = collectAllPanes(createWorkspaceLayoutWithSidePanel().root)[0]?.id; expect(mainPaneId).toBeTruthy(); - const root = withPaneTabs(createWorkspaceLayoutWithExplorer().root, mainPaneId as string, [ + const root = withPaneTabs(createWorkspaceLayoutWithSidePanel().root, mainPaneId as string, [ "tab-a", "tab-b", ]); @@ -323,7 +331,7 @@ describe("workspace-layout-store tree transforms", () => { workspaceLayoutIds.reset(); const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const before = createWorkspaceLayoutWithExplorer(); + const before = createWorkspaceLayoutWithSidePanel(); const mainPaneId = collectAllPanes(before.root)[0]?.id; expect(mainPaneId).toBeTruthy(); @@ -397,6 +405,293 @@ describe("workspace-layout-store tree transforms", () => { }); describe("workspace-layout-store actions", () => { + it("creates duplicate Changes instances while reveal keeps the first instance", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const first = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "working_diff" }, + intent: "new", + }); + const second = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "working_diff" }, + intent: "new", + }); + + expect(first).not.toBe(second); + expect( + collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).filter( + (tab) => tab.target.kind === "working_diff", + ), + ).toHaveLength(2); + + expect( + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "working_diff" }, + intent: "reveal", + }), + ).toBe(first); + }); + + it("creates duplicate Pull request instances explicitly", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const first = store.openTab({ workspaceKey, target: { kind: "pull_request" }, intent: "new" }); + const second = store.openTab({ workspaceKey, target: { kind: "pull_request" }, intent: "new" }); + expect(first).not.toBe(second); + expect( + collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).filter( + (tab) => tab.target.kind === "pull_request", + ), + ).toHaveLength(2); + }); + + it("preserves same-kind state, clears cross-kind state, and accepts an explicit override", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const file = store.openTab({ + workspaceKey, + target: { kind: "file", path: "/repo/a.ts" }, + intent: "new", + })!; + const fileState: FileState = { treeVisible: true, treeWidth: 240 }; + store.setTabState(workspaceKey, file, fileState); + const sameKind = store.replaceTab(workspaceKey, file, { kind: "file", path: "/repo/b.ts" }); + expect( + collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find( + (tab) => tab.tabId === sameKind, + )?.state, + ).toEqual(fileState); + const crossKind = store.replaceTab(workspaceKey, sameKind!, { kind: "working_diff" }); + expect( + collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find( + (tab) => tab.tabId === crossKind, + )?.state, + ).toBeUndefined(); + const override: ChangesState = { ...defaultChangesState, wrapLines: true }; + const overridden = store.replaceTab( + workspaceKey, + crossKind!, + { kind: "working_diff" }, + override, + ); + expect( + collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find( + (tab) => tab.tabId === overridden, + )?.state, + ).toEqual(override); + }); + + it("keeps every Changes state field independent across explicit instances", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const first = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "working_diff" }, + intent: "new", + })!; + const second = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "working_diff" }, + intent: "new", + })!; + const changed: ChangesState = { + mode: "base", + baseRef: "origin/main", + layout: "split" as const, + wrapLines: true, + hideWhitespace: true, + treeVisible: true, + treeWidth: 320, + collapsedFilePaths: ["a.ts"], + collapsedFolderPaths: ["src"], + commitsCollapsed: false, + }; + store.setTabState(workspaceKey, first, changed); + const tabs = collectAllTabs( + workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, + ); + expect(tabs.find((tab) => tab.tabId === first)?.state).toEqual(changed); + expect(tabs.find((tab) => tab.tabId === second)?.state).toEqual(undefined); + }); + + it("restores a revealed file's parent after local replacement", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const parent = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "parent" }, + intent: "new", + })!; + const file = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/a.ts", lineStart: 1 }, + intent: "new", + })!; + store.setTabState(workspaceKey, file, { + treeVisible: true, + treeWidth: 300, + }); + expect( + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/a.ts", lineStart: 9 }, + intent: "reveal", + placement: undefined, + parentTabId: parent, + }), + ).toBe(file); + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(collectAllTabs(layout.root).find((tab) => tab.tabId === file)).toMatchObject({ + target: { path: "/repo/a.ts", lineStart: 9 }, + state: { ...defaultFileState, treeVisible: true, treeWidth: 300 }, + }); + expect(layout.parentTabIdByTabId?.[file]).toBe(parent); + + const replacement = store.replaceTab( + workspaceKey, + file, + { kind: "file", path: "/repo/b.ts" }, + { ...defaultFileState, treeVisible: true, treeWidth: 300 }, + ); + expect(replacement).not.toBe(file); + const replacedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(replacedLayout.parentTabIdByTabId?.[file]).toBeUndefined(); + expect(replacedLayout.parentTabIdByTabId?.[replacement as string]).toBe(parent); + expect(Object.entries(replacedLayout.parentTabIdByTabId ?? {}).flat()).not.toContain(file); + + store.closeTab(workspaceKey, replacement as string); + expect( + findPaneById(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, "main") + ?.focusedTabId, + ).toBe(parent); + }); + + it("keeps existing child edges attached to a replacement tab", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const replaced = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "files" }, + intent: "new", + })!; + const child = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/a.ts" }, + intent: "reveal", + parentTabId: replaced, + })!; + const unrelatedParent = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "draft", draftId: "unrelated" }, + intent: "new", + })!; + const unrelatedChild = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/unrelated.ts" }, + intent: "reveal", + parentTabId: unrelatedParent, + })!; + + const replacement = store.replaceTab( + workspaceKey, + replaced, + { kind: "file", path: "/repo/b.ts" }, + { ...defaultFileState, treeVisible: true }, + )!; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + + expect(layout.parentTabIdByTabId?.[child]).toBe(replacement); + expect(layout.parentTabIdByTabId?.[unrelatedChild]).toBe(unrelatedParent); + expect(Object.entries(layout.parentTabIdByTabId ?? {}).flat()).not.toContain(replaced); + + store.focusTab(workspaceKey, child); + store.closeTab(workspaceKey, child); + expect( + findPaneById(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, "main") + ?.focusedTabId, + ).toBe(replacement); + }); + + it("reveals provider children through the child capability in a compact pane", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const parent = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "parent" }, + intent: "new", + })!; + const target = { + kind: "provider_subagent" as const, + parentAgentId: "parent", + subagentId: "provider-child", + }; + const compactPanePlacement = { mode: "pane" as const, paneId: "main" }; + + const child = store.openTab({ + workspaceKey: workspaceKey, + target: target, + intent: "reveal", + parentTabId: parent, + placement: compactPanePlacement, + })!; + expect( + store.openTab({ + workspaceKey: workspaceKey, + target: target, + intent: "reveal", + parentTabId: parent, + placement: compactPanePlacement, + }), + ).toBe(child); + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneContainingTab(layout.root, child)?.id).toBe("main"); + expect(layout.parentTabIdByTabId?.[child]).toBe(parent); + + store.closeTab(workspaceKey, child); + expect( + findPaneById(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, "main") + ?.focusedTabId, + ).toBe(parent); + }); + + it("replaces only the supplied file slot and carries its state", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const preserved = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/a.ts" }, + intent: "new", + }); + const local = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/b.ts" }, + intent: "new", + }); + + const replacement = store.replaceTab( + workspaceKey, + local as string, + { kind: "file", path: "/repo/a.ts" }, + { ...defaultFileState, treeVisible: true, treeWidth: 280 }, + ); + + const tabs = collectAllTabs( + workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, + ); + expect(replacement).not.toBe(preserved); + expect(tabs.find((tab) => tab.tabId === preserved)?.target).toEqual({ + kind: "file", + path: "/repo/a.ts", + }); + expect(tabs.find((tab) => tab.tabId === replacement)?.state).toEqual({ + ...defaultFileState, + treeVisible: true, + treeWidth: 280, + }); + }); beforeEach(() => { workspaceLayoutIds.reset(); workspaceLayoutStore.setState({ @@ -405,8 +700,7 @@ describe("workspace-layout-store actions", () => { pinnedAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {}, focusRestorationByWorkspace: {}, - explorerPaneIdByWorkspace: {}, - acknowledgedPullRequestByWorkspace: {}, + sidePanelPaneIdByWorkspace: {}, }); }); @@ -425,29 +719,64 @@ describe("workspace-layout-store actions", () => { const state = restored.getState(); const layout = state.layoutByWorkspace.legacy; - const explorerPaneId = state.explorerPaneIdByWorkspace.legacy; + const sidePanelPaneId = state.sidePanelPaneIdByWorkspace.legacy; expect(findPaneById(layout.root, "main")).toBeTruthy(); - expect(explorerPaneId).toBeTruthy(); - expect(findPaneById(layout.root, explorerPaneId)?.hidden).toBe(true); + expect(sidePanelPaneId).toBeTruthy(); + expect(findPaneById(layout.root, sidePanelPaneId)?.hidden).toBe(true); expect(restored.getState().splitSizesByWorkspace).toEqual({}); await expect(AsyncStorage.getItem("workspace-layout-state")).resolves.not.toBeNull(); }); + it("restores a layout saved before the Side panel rename, PR bookkeeping and all", async () => { + const savedLayout = createWorkspaceLayoutWithSidePanel(); + await AsyncStorage.setItem( + "workspace-layout-state", + JSON.stringify({ + state: { + layoutByWorkspace: { renamed: savedLayout }, + explorerPaneIdByWorkspace: { renamed: "explorer" }, + acknowledgedPullRequestByWorkspace: { renamed: "url:https://example.test/pulls/1" }, + }, + version: 1, + }), + ); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + + await restored.persist.rehydrate(); + + const state = restored.getState(); + expect(state.layoutByWorkspace.renamed).toBeTruthy(); + expect(state.sidePanelPaneIdByWorkspace.renamed).toBe("explorer"); + }); + it("persists first-class pane targets, visibility, and focus", async () => { await AsyncStorage.removeItem("workspace-layout-state"); const workspaceKey = createWorkspaceKey(); const source = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); await source.persist.rehydrate(); - source.getState().openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); - const focusedAgentTabId = source + source.getState().openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + const focusedAgentTabId = source.getState().openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-2" }, + intent: "reveal", + }); + const sidePanelPaneId = source.getState().showSidePanel(workspaceKey); + expect(sidePanelPaneId).toBeTruthy(); + source .getState() - .openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-2" }); - const explorer = source.getState().ensureExplorerPane(workspaceKey); - expect(explorer).toBeTruthy(); - source.getState().openTabFocused(workspaceKey, { kind: "files" }); - source.getState().observePullRequest(workspaceKey, "pr:1"); - source.getState().hidePane(workspaceKey, explorer!.paneId); + .openTab({ workspaceKey: workspaceKey, target: { kind: "files" }, intent: "reveal" }); + source.getState().openTab({ + workspaceKey: workspaceKey, + target: { kind: "pull_request" }, + intent: "background", + placement: { mode: "prefer", paneId: sidePanelPaneId as string }, + }); + source.getState().hideSidePanel(workspaceKey); await vi.waitFor(async () => { expect(await AsyncStorage.getItem("workspace-layout-state")).not.toBeNull(); @@ -460,92 +789,82 @@ describe("workspace-layout-store actions", () => { expect(layout.focusedPaneId).toBe("main"); expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(focusedAgentTabId); - expect(findPaneById(layout.root, explorer!.paneId)?.hidden).toBe(true); + expect(findPaneById(layout.root, sidePanelPaneId)?.hidden).toBe(true); expect(collectAllTabs(layout.root).map((tab) => tab.target.kind)).toEqual([ "agent", "agent", "files", "pull_request", ]); - expect(state.explorerPaneIdByWorkspace[workspaceKey]).toBe(explorer!.paneId); - expect(state.acknowledgedPullRequestByWorkspace[workspaceKey]).toBe("pr:1"); + expect(state.sidePanelPaneIdByWorkspace[workspaceKey]).toBe(sidePanelPaneId); }); - it("auto-adds each detected pull request once and respects a deliberate close", () => { + it("persists and rehydrates independent Changes state through validated storage", async () => { + await AsyncStorage.removeItem("workspace-layout-state"); const workspaceKey = createWorkspaceKey(); - const store = workspaceLayoutStore.getState(); + const source = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + await source.persist.rehydrate(); + const first = source + .getState() + .openTab({ workspaceKey: workspaceKey, target: { kind: "working_diff" }, intent: "new" })!; + const second = source + .getState() + .openTab({ workspaceKey: workspaceKey, target: { kind: "working_diff" }, intent: "new" })!; + const firstState: ChangesState = { + mode: "base" as const, + baseRef: "origin/main", + layout: "split" as const, + wrapLines: true, + hideWhitespace: true, + treeVisible: true, + treeWidth: 320, + collapsedFilePaths: ["src/a.ts"], + collapsedFolderPaths: ["src"], + commitsCollapsed: false, + }; + const secondState: ChangesState = { + mode: "base" as const, + baseRef: "release", + layout: "unified" as const, + wrapLines: false, + hideWhitespace: false, + treeVisible: false, + collapsedFilePaths: ["README.md"], + collapsedFolderPaths: ["docs"], + commitsCollapsed: true, + }; + source.getState().setTabState(workspaceKey, first, firstState); + source.getState().setTabState(workspaceKey, second, secondState); - store.observePullRequest(workspaceKey, null); - expect(store.getWorkspaceTabs(workspaceKey)).toEqual([]); + await vi.waitFor(async () => { + const persisted = await AsyncStorage.getItem("workspace-layout-state"); + expect(persisted).not.toBeNull(); + const root = JSON.parse(persisted ?? "{}").state.layoutByWorkspace[workspaceKey].root; + expect(collectTabIds(root)).toEqual([first, second]); + }); - store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1"); - expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.target.kind)).toEqual([ - "pull_request", - ]); - - store.closeTab(workspaceKey, "pull_request"); - store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1"); - expect(store.getWorkspaceTabs(workspaceKey)).toEqual([]); - - store.observePullRequest(workspaceKey, "url:https://example.test/pulls/2"); - expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.target.kind)).toEqual([ - "pull_request", - ]); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + await restored.persist.rehydrate(); + const tabs = collectAllTabs(restored.getState().layoutByWorkspace[workspaceKey].root); + expect(tabs.find((tab) => tab.tabId === first)?.state).toEqual(firstState); + expect(tabs.find((tab) => tab.tabId === second)?.state).toEqual(secondState); }); - it("adds a first detected pull request to the explorer pane without revealing or focusing it", () => { + it("leaves a side-panel background tab in the only other pane instead of emptying it", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); + const setupTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "setup", workspaceId: WORKSPACE_ID }, + intent: "reveal", + placement: { mode: "pane", paneId: "main" }, + }); - store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1"); - - const state = workspaceLayoutStore.getState(); - const layout = state.layoutByWorkspace[workspaceKey]; - const explorerPaneId = state.explorerPaneIdByWorkspace[workspaceKey]; - expect(explorerPaneId).toBe("explorer"); - expect(findPaneContainingTab(layout.root, "pull_request")?.id).toBe(explorerPaneId); - expect(findPaneById(layout.root, explorerPaneId)?.hidden).toBe(true); - expect(layout.focusedPaneId).toBe("main"); - expect(state.acknowledgedPullRequestByWorkspace[workspaceKey]).toBe( - "url:https://example.test/pulls/1", - ); - }); - - it("leaves a pull request tab the user opened elsewhere where it lives", () => { - const workspaceKey = createWorkspaceKey(); - const store = workspaceLayoutStore.getState(); - const pullRequestTabId = store.openTabFocused( - workspaceKey, - { kind: "pull_request" }, - { paneId: "main" }, - ); - const before = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - - store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1"); - - const state = workspaceLayoutStore.getState(); - const layout = state.layoutByWorkspace[workspaceKey]; - expect(findPaneContainingTab(layout.root, pullRequestTabId as string)?.id).toBe("main"); - expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([pullRequestTabId]); - expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true); - expect(layout.focusedPaneId).toBe(before.focusedPaneId); - expect(state.acknowledgedPullRequestByWorkspace[workspaceKey]).toBe( - "url:https://example.test/pulls/1", - ); - }); - - it("leaves an explorer-pane background tab in the only other pane instead of emptying it", () => { - const workspaceKey = createWorkspaceKey(); - const store = workspaceLayoutStore.getState(); - const setupTabId = store.openTabFocused( - workspaceKey, - { kind: "setup", workspaceId: WORKSPACE_ID }, - { paneId: "main" }, - ); - - store.openTabInExplorerPaneBackground(workspaceKey, { - kind: "setup", - workspaceId: WORKSPACE_ID, + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "setup", workspaceId: WORKSPACE_ID }, + intent: "background", + placement: { mode: "prefer", paneId: "explorer" }, }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; @@ -555,13 +874,22 @@ describe("workspace-layout-store actions", () => { expect(layout.focusedPaneId).toBe("main"); }); - it("keeps ambient entity tabs out of the explorer pane after a pull request is detected", () => { + it("keeps ambient entity tabs out of the side panel after a background tab lands there", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1"); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "pull_request" }, + intent: "background", + placement: { mode: "prefer", paneId: "explorer" }, + }); store.focusPane(workspaceKey, "explorer"); - const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + const agentTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(agentTabId).toBeTruthy(); @@ -572,12 +900,20 @@ describe("workspace-layout-store actions", () => { it("keeps a background entity tab out of the focused explorer pane without moving focus", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); store.focusPane(workspaceKey, "explorer"); - const terminalTabId = store.openTabInBackground(workspaceKey, { - kind: "terminal", - terminalId: "terminal-1", + const terminalTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "terminal", + terminalId: "terminal-1", + }, + intent: "background", }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; @@ -588,12 +924,20 @@ describe("workspace-layout-store actions", () => { it("keeps ambient browser opens out of the focused explorer pane", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "browser", browserId: "browser-1" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "browser", browserId: "browser-1" }, + intent: "reveal", + }); store.focusPane(workspaceKey, "explorer"); - const browserTabId = store.openTabFocused(workspaceKey, { - kind: "browser", - browserId: "browser-2", + const browserTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "browser", + browserId: "browser-2", + }, + intent: "reveal", }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; @@ -604,10 +948,18 @@ describe("workspace-layout-store actions", () => { it("keeps ambient draft opens out of the focused explorer pane", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); store.focusPane(workspaceKey, "explorer"); - const draftTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" }); + const draftTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "draft", draftId: "draft-1" }, + intent: "reveal", + }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(findPaneContainingTab(layout.root, draftTabId as string)?.id).toBe("main"); @@ -617,28 +969,39 @@ describe("workspace-layout-store actions", () => { it("honours an explicit pane id over the explorer reroute", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); - const draftTabId = store.openTabFocused( - workspaceKey, - { kind: "draft", draftId: "draft-1" }, - { paneId: "explorer" }, - ); + const draftTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "draft", draftId: "draft-1" }, + intent: "reveal", + placement: { mode: "pane", paneId: "explorer" }, + }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(findPaneContainingTab(layout.root, draftTabId as string)?.id).toBe("explorer"); expect(layout.focusedPaneId).toBe("explorer"); }); - it("opens user-created entity tabs in the focused explorer pane", () => { + it("opens user-created entity tabs in the focused side panel", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); store.focusPane(workspaceKey, "explorer"); - const terminalTabId = store.openTabInFocusedPane(workspaceKey, { - kind: "terminal", - terminalId: "terminal-1", + const terminalTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "terminal", terminalId: "terminal-1" }, + intent: "reveal", + placement: FOCUSED_PANE_PLACEMENT, }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; @@ -649,16 +1012,21 @@ describe("workspace-layout-store actions", () => { it("keeps an existing user-created entity tab in its original pane", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const terminalTabId = store.openTabInBackground(workspaceKey, { - kind: "terminal", - terminalId: "terminal-1", + const terminalTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "terminal", + terminalId: "terminal-1", + }, + intent: "background", }); - store.showPane(workspaceKey, "explorer"); - store.focusPane(workspaceKey, "explorer"); + store.showSidePanel(workspaceKey); - store.openTabInFocusedPane(workspaceKey, { - kind: "terminal", - terminalId: "terminal-1", + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "terminal", terminalId: "terminal-1" }, + intent: "reveal", + placement: FOCUSED_PANE_PLACEMENT, }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; @@ -703,10 +1071,18 @@ describe("workspace-layout-store actions", () => { it("keeps non-entity tabs in the focused explorer pane", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); store.focusPane(workspaceKey, "explorer"); - const filesTabId = store.openTabFocused(workspaceKey, { kind: "files" }); + const filesTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "files" }, + intent: "reveal", + }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(findPaneContainingTab(layout.root, filesTabId as string)?.id).toBe("explorer"); @@ -715,12 +1091,20 @@ describe("workspace-layout-store actions", () => { it("focuses an entity tab the user dragged into the explorer pane where it lives", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); - store.showPane(workspaceKey, "explorer"); + const agentTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + store.showSidePanel(workspaceKey); store.moveTabToPane(workspaceKey, agentTabId as string, "explorer"); store.focusPane(workspaceKey, "main"); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(findPaneContainingTab(layout.root, agentTabId as string)?.id).toBe("explorer"); @@ -730,7 +1114,11 @@ describe("workspace-layout-store actions", () => { it("keeps reconcile auto-opened agents out of the focused explorer pane", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); store.focusPane(workspaceKey, "explorer"); store.reconcileTabs(workspaceKey, { @@ -758,129 +1146,116 @@ describe("workspace-layout-store actions", () => { focusedPaneId: "explorer", }), }, - explorerPaneIdByWorkspace: { - ...state.explorerPaneIdByWorkspace, + sidePanelPaneIdByWorkspace: { + ...state.sidePanelPaneIdByWorkspace, [workspaceKey]: "explorer", }, })); - const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + const agentTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(findPaneContainingTab(layout.root, agentTabId as string)?.id).toBe("explorer"); }); - it("creates setup in the hidden explorer pane without changing focus", () => { + it("creates setup in the hidden side panel without changing focus", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + const agentTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); - const tabId = store.openTabInExplorerPaneBackground(workspaceKey, { - kind: "setup", - workspaceId: WORKSPACE_ID, + const tabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "setup", workspaceId: WORKSPACE_ID }, + intent: "background", + placement: { mode: "prefer", paneId: "explorer" }, }); const state = workspaceLayoutStore.getState(); const layout = state.layoutByWorkspace[workspaceKey]; - const explorerPaneId = state.explorerPaneIdByWorkspace[workspaceKey]; + const sidePanelPaneId = state.sidePanelPaneIdByWorkspace[workspaceKey] ?? "explorer"; expect(tabId).toBe("setup_ws-main"); expect(layout.focusedPaneId).toBe("main"); - expect(findPaneById(layout.root, explorerPaneId)?.hidden).toBe(true); + expect(findPaneById(layout.root, sidePanelPaneId)?.hidden).toBe(true); expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(agentTabId); - expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(explorerPaneId); + expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(sidePanelPaneId); }); - it("places an auto-added pull request in the registered explorer pane", () => { + it("opens assistant files in the revealed side panel and reveals it again on the next open", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - useWorkspaceLayoutIds("explorer"); - const explorerPaneId = store.splitPaneEmpty(workspaceKey, { - targetPaneId: "main", - position: "right", - }); - store.setExplorerPaneId(workspaceKey, explorerPaneId); - - store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1"); - - const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - expect(findPaneContainingTab(layout.root, "pull_request")?.id).toBe(explorerPaneId); - }); - - it("keeps an auto-added pull request in the explorer pane when Changes is elsewhere", () => { - const workspaceKey = createWorkspaceKey(); - const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "working_diff" }); - - useWorkspaceLayoutIds("explorer"); - const explorerPaneId = store.splitPaneEmpty(workspaceKey, { - targetPaneId: "main", - position: "right", - }); - store.setExplorerPaneId(workspaceKey, explorerPaneId); - - store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1"); - - const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - expect(findPaneContainingTab(layout.root, "working_diff")?.id).toBe("main"); - expect(findPaneContainingTab(layout.root, "pull_request")?.id).toBe(explorerPaneId); - }); - - it("opens assistant files in an ensured explorer pane and reveals it on the next open", () => { - const workspaceKey = createWorkspaceKey(); - const store = workspaceLayoutStore.getState(); - const tabId = store.openTabInExplorerPaneFocused(workspaceKey, { + const paneId = store.showSidePanel(workspaceKey) as string; + const tabId = store.openTab({ + workspaceKey: workspaceKey, target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", parentTabId: "agent_agent-a", + placement: { mode: "prefer", paneId }, }); const createdState = workspaceLayoutStore.getState(); - const explorerPaneId = createdState.explorerPaneIdByWorkspace[workspaceKey]; + const sidePanelPaneId = createdState.sidePanelPaneIdByWorkspace[workspaceKey]; - expect(explorerPaneId).toBe("explorer"); + expect(sidePanelPaneId).toBe("explorer"); expect( findPaneContainingTab(createdState.layoutByWorkspace[workspaceKey].root, tabId!)?.id, - ).toBe(explorerPaneId); + ).toBe(sidePanelPaneId); expect(collectAllTabs(createdState.layoutByWorkspace[workspaceKey].root)).toContainEqual({ tabId, target: { kind: "file", path: "/repo/worktree/a.ts" }, createdAt: expect.any(Number), }); - store.hidePane(workspaceKey, explorerPaneId!); - store.openTabInExplorerPaneFocused(workspaceKey, { + store.hideSidePanel(workspaceKey); + store.openTab({ + workspaceKey: workspaceKey, target: { kind: "file", path: "/repo/worktree/b.ts" }, + intent: "reveal", + placement: { mode: "prefer", paneId: store.showSidePanel(workspaceKey) as string }, }); const revealedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - expect(findPaneById(revealedLayout.root, explorerPaneId)?.hidden).toBeUndefined(); + expect(findPaneById(revealedLayout.root, sidePanelPaneId)?.hidden).toBeUndefined(); expect(findPaneContainingTab(revealedLayout.root, "file_/repo/worktree/b.ts")?.id).toBe( - explorerPaneId, + sidePanelPaneId, ); }); - it("moves a canonical duplicate file tab into the revealed explorer pane", () => { + it("leaves a canonical duplicate file tab where the user has it, in the revealed side panel", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); useWorkspaceLayoutIds("explorer"); - const tabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/a.ts", + const paneId = store.showSidePanel(workspaceKey) as string; + const tabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", + placement: { mode: "pane", paneId }, }); - const explorerPane = store.ensureExplorerPane(workspaceKey); - store.hidePane(workspaceKey, explorerPane!.paneId); + store.hideSidePanel(workspaceKey); - const duplicateTabId = store.openTabInExplorerPaneFocused(workspaceKey, { + store.showSidePanel(workspaceKey); + const duplicateTabId = store.openTab({ + workspaceKey: workspaceKey, target: { kind: "file", path: "/repo/worktree/a.ts", lineStart: 12 }, + intent: "reveal", parentTabId: "agent_agent-a", + placement: { mode: "prefer", paneId }, }); - const state = workspaceLayoutStore.getState(); - const layout = state.layoutByWorkspace[workspaceKey]; + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(duplicateTabId).toBe(tabId); expect(collectAllTabs(layout.root).filter((tab) => tab.tabId === tabId)).toHaveLength(1); - expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(explorerPane?.paneId); - expect(findPaneById(layout.root, explorerPane?.paneId)?.hidden).toBeUndefined(); - expect(layout.focusedPaneId).toBe(explorerPane?.paneId); - expect(findPaneById(layout.root, explorerPane?.paneId)?.focusedTabId).toBe(tabId); + expect(findPaneContainingTab(layout.root, tabId as string)?.id).toBe(paneId); + expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined(); + expect(layout.focusedPaneId).toBe(paneId); + expect(findPaneById(layout.root, paneId)?.focusedTabId).toBe(tabId); }); it("restores workspace and agent plugin panel targets", async () => { @@ -939,13 +1314,21 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const firstTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/a.ts", + const firstTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/a.ts", + }, + intent: "reveal", }); - const secondTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/b.ts", + const secondTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/b.ts", + }, + intent: "reveal", }); const splitPaneId = store.splitPane(workspaceKey, { tabId: secondTabId!, @@ -956,9 +1339,13 @@ describe("workspace-layout-store actions", () => { expect(splitPaneId).toBe("pane_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); store.focusPane(workspaceKey, "main"); - const duplicateTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/b.ts", + const duplicateTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/b.ts", + }, + intent: "reveal", }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; @@ -976,16 +1363,24 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const firstTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/a.ts", - lineStart: 5, + const firstTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/a.ts", + lineStart: 5, + }, + intent: "reveal", }); - const secondTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/a.ts", - lineStart: 10, - lineEnd: 12, + const secondTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/a.ts", + lineStart: 10, + lineEnd: 12, + }, + intent: "reveal", }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; @@ -1005,14 +1400,22 @@ describe("workspace-layout-store actions", () => { ]); }); - it("openTabInBackground inserts a tab without stealing focus", () => { + it("openTab background intent inserts a tab without stealing focus", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); - const setupTabId = store.openTabInBackground(workspaceKey, { - kind: "setup", - workspaceId: "ws-main", + const agentTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + const setupTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "setup", + workspaceId: "ws-main", + }, + intent: "background", }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const pane = findPaneById(layout.root, "main")!; @@ -1024,21 +1427,33 @@ describe("workspace-layout-store actions", () => { expect(layout.focusedPaneId).toBe("main"); }); - it("openTabInBackground on an existing target is a no-op", () => { + it("openTab background intent on an existing target is a no-op", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const firstTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/a.ts", + const firstTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/a.ts", + }, + intent: "reveal", }); - const secondTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/b.ts", + const secondTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/b.ts", + }, + intent: "reveal", }); - const duplicateTabId = store.openTabInBackground(workspaceKey, { - kind: "file", - path: "/repo/worktree/a.ts", + const duplicateTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/a.ts", + }, + intent: "background", }); const layoutAfter = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const pane = findPaneById(layoutAfter.root, "main")!; @@ -1081,18 +1496,27 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const parentTabId = store.openTabFocused(workspaceKey, { - kind: "draft", - draftId: "draft-parent", + const parentTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "draft", + draftId: "draft-parent", + }, + intent: "reveal", }); - const childTabId = store.openChildTabFocused( - workspaceKey, - { kind: "draft", draftId: "draft-child" }, - parentTabId!, - ); - const rightTabId = store.openTabFocused(workspaceKey, { - kind: "draft", - draftId: "draft-right", + const childTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "draft", draftId: "draft-child" }, + intent: "reveal", + parentTabId: parentTabId!, + }); + const rightTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "draft", + draftId: "draft-right", + }, + intent: "reveal", }); store.focusTab(workspaceKey, childTabId!); @@ -1136,7 +1560,11 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); const token = store.unfocusPane(workspaceKey); expect(token).toBeTruthy(); expect( @@ -1154,7 +1582,11 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" }); + const firstTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "draft", draftId: "draft-1" }, + intent: "reveal", + }); store.splitPane(workspaceKey, { tabId: firstTabId!, targetPaneId: "main", @@ -1175,7 +1607,11 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); const outerToken = store.unfocusPane(workspaceKey); const innerToken = store.unfocusPane(workspaceKey); @@ -1194,8 +1630,16 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" }); - const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" }); + const firstTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "draft", draftId: "draft-1" }, + intent: "reveal", + }); + const secondTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "draft", draftId: "draft-2" }, + intent: "reveal", + }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(firstTabId).toBe("draft-1"); @@ -1221,14 +1665,22 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", + }); const newPaneId = store.splitPaneEmpty(workspaceKey, { targetPaneId: "main", position: "right", }); - const draftTabId = store.openTabFocused(workspaceKey, { - kind: "draft", - draftId: "draft-split", + const draftTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "draft", + draftId: "draft-split", + }, + intent: "reveal", }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; @@ -1240,21 +1692,26 @@ describe("workspace-layout-store actions", () => { expect(findPaneById(layout.root, newPaneId)?.focusedTabId).toBe(draftTabId); }); - it("hides and shows a pane without changing its tabs or split sizes", () => { + it("hides and shows the side panel without changing its tabs or split sizes", () => { useWorkspaceLayoutIds("88888888-8888-8888-8888-888888888888"); const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "draft", draftId: "main-tab" }); - const paneId = store.splitPaneEmpty(workspaceKey, { - targetPaneId: "main", - position: "right", - })!; - store.openTabFocused(workspaceKey, { kind: "working_diff" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "draft", draftId: "main-tab" }, + intent: "reveal", + }); + const paneId = store.showSidePanel(workspaceKey)!; + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "working_diff" }, + intent: "reveal", + }); const sizes = expectGroup(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root) .group.sizes; - store.hidePane(workspaceKey, paneId); + store.hideSidePanel(workspaceKey); let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(findPaneById(layout.root, paneId)).toMatchObject({ hidden: true, @@ -1263,7 +1720,7 @@ describe("workspace-layout-store actions", () => { expect(expectGroup(layout.root).group.sizes).toEqual(sizes); expect(layout.focusedPaneId).toBe("main"); - store.showPane(workspaceKey, paneId); + store.showSidePanel(workspaceKey); layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: ["working_diff"] }); expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined(); @@ -1275,13 +1732,21 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const fileTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/a.ts", + const fileTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/a.ts", + }, + intent: "reveal", }); - const terminalTabId = store.openTabFocused(workspaceKey, { - kind: "terminal", - terminalId: "term-1", + const terminalTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "terminal", + terminalId: "term-1", + }, + intent: "reveal", }); const splitPaneId = store.splitPane(workspaceKey, { tabId: terminalTabId!, @@ -1305,23 +1770,32 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); - const targetTabId = store.openTabFocused(workspaceKey, { - kind: "terminal", - terminalId: "term-hidden", + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", + }); + const targetTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "terminal", + terminalId: "term-hidden", + }, + intent: "reveal", })!; - const paneId = store.splitPane(workspaceKey, { - tabId: targetTabId, - targetPaneId: "main", - position: "right", - })!; - store.openTabFocused(workspaceKey, { kind: "working_diff" }); + const paneId = store.showSidePanel(workspaceKey)!; + store.moveTabToPane(workspaceKey, targetTabId, paneId); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "working_diff" }, + intent: "reveal", + }); store.focusPane(workspaceKey, "main"); const group = expectGroup( workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, ).group; store.resizeSplit(workspaceKey, group.id, [0.7, 0.3]); - store.hidePane(workspaceKey, paneId); + store.hideSidePanel(workspaceKey); const splitSizes = workspaceLayoutStore.getState().splitSizesByWorkspace[workspaceKey]; store.focusTab(workspaceKey, targetTabId); @@ -1343,8 +1817,16 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); - const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", + }); + const secondTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "draft", draftId: "draft-2" }, + intent: "reveal", + }); const splitPaneId = store.splitPane(workspaceKey, { tabId: secondTabId!, targetPaneId: "main", @@ -1367,15 +1849,19 @@ describe("workspace-layout-store actions", () => { }); }); - it("retargetTab keeps a draft tab in place while updating its target", () => { + it("replaceTab keeps a draft tab in place while updating its target", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const draftTabId = store.openTabFocused(workspaceKey, { - kind: "draft", - draftId: "draft-retarget", + const draftTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "draft", + draftId: "draft-retarget", + }, + intent: "reveal", }); - const nextTabId = store.retargetTab(workspaceKey, draftTabId!, { + const nextTabId = store.replaceTab(workspaceKey, draftTabId!, { kind: "file", path: "/repo/worktree/retargeted.ts", }); @@ -1393,15 +1879,19 @@ describe("workspace-layout-store actions", () => { ]); }); - it("retargetTab gives a non-draft tab the new target identity", () => { + it("replaceTab gives a non-draft tab the new target identity", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const agentTabId = store.openTabFocused(workspaceKey, { - kind: "agent", - agentId: "agent-retarget", + const agentTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "agent", + agentId: "agent-retarget", + }, + intent: "reveal", }); - const nextTabId = store.retargetTab(workspaceKey, agentTabId!, { + const nextTabId = store.replaceTab(workspaceKey, agentTabId!, { kind: "draft", draftId: "draft-from-agent", }); @@ -1419,27 +1909,39 @@ describe("workspace-layout-store actions", () => { ]); }); - it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => { + it("replaceTab keeps a pane-local replacement beside an existing target", () => { useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555"); const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const existingFileTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/existing.ts", + const existingFileTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/existing.ts", + }, + intent: "reveal", + }); + const draftTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "draft", draftId: "draft-dup" }, + intent: "reveal", }); - const draftTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-dup" }); const splitPaneId = store.splitPane(workspaceKey, { tabId: draftTabId!, targetPaneId: "main", position: "right", }); - const secondDraftTabId = store.openTabFocused(workspaceKey, { - kind: "draft", - draftId: "draft-dup-2", + const secondDraftTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "draft", + draftId: "draft-dup-2", + }, + intent: "reveal", }); - const nextTabId = store.retargetTab(workspaceKey, secondDraftTabId!, { + const nextTabId = store.replaceTab(workspaceKey, secondDraftTabId!, { kind: "file", path: "/repo/worktree/existing.ts", }); @@ -1448,65 +1950,77 @@ describe("workspace-layout-store actions", () => { expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts"); expect(draftTabId).toBe("draft-dup"); expect(splitPaneId).toBe("pane_55555555-5555-5555-5555-555555555555"); - expect(nextTabId).toBe(existingFileTabId); - expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([ - existingFileTabId!, - draftTabId!, - ]); - expect(layout.focusedPaneId).toBe("main"); - expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(existingFileTabId); + expect(nextTabId).not.toBe(existingFileTabId); + expect(collectAllTabs(layout.root)).toHaveLength(3); + expect(collectAllTabs(layout.root).filter((tab) => tab.target.kind === "file")).toHaveLength(2); }); - it("retargetTab closes a draft tab and focuses an existing matching target tab", () => { + it("replaceTab keeps a pane-local replacement beside an existing matching target", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const firstDraftTabId = store.openTabFocused(workspaceKey, { - kind: "draft", - draftId: "draft-agent-1", + const firstDraftTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "draft", + draftId: "draft-agent-1", + }, + intent: "reveal", }); - const firstAgentTabId = store.retargetTab(workspaceKey, firstDraftTabId!, { + const firstAgentTabId = store.replaceTab(workspaceKey, firstDraftTabId!, { kind: "agent", agentId: "agent-1", }); - const secondDraftTabId = store.openTabFocused(workspaceKey, { - kind: "draft", - draftId: "draft-agent-2", + const secondDraftTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "draft", + draftId: "draft-agent-2", + }, + intent: "reveal", }); - const nextTabId = store.retargetTab(workspaceKey, secondDraftTabId!, { + const nextTabId = store.replaceTab(workspaceKey, secondDraftTabId!, { kind: "agent", agentId: "agent-1", }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(firstAgentTabId).toBe(firstDraftTabId); - expect(nextTabId).toBe(firstDraftTabId); - expect(collectAllTabs(layout.root)).toEqual([ - { - tabId: firstDraftTabId!, - target: { kind: "agent", agentId: "agent-1" }, - createdAt: expect.any(Number), - }, - ]); - expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(firstDraftTabId); + expect(nextTabId).not.toBe(firstDraftTabId); + expect(collectAllTabs(layout.root)).toHaveLength(2); + expect(collectAllTabs(layout.root).filter((tab) => tab.target.kind === "agent")).toHaveLength( + 2, + ); }); it("reorderTabs reorders tabs within the focused pane", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const firstTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/a.ts", + const firstTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/a.ts", + }, + intent: "reveal", }); - const secondTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/b.ts", + const secondTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/b.ts", + }, + intent: "reveal", }); - const thirdTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/c.ts", + const thirdTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/c.ts", + }, + intent: "reveal", }); store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]); @@ -1541,18 +2055,34 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); - store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/b.ts", + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", }); - const thirdTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/c.ts", + store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/b.ts", + }, + intent: "reveal", }); - const fourthTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/d.ts", + const thirdTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/c.ts", + }, + intent: "reveal", + }); + const fourthTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/d.ts", + }, + intent: "reveal", }); const splitPaneId = store.splitPane(workspaceKey, { tabId: thirdTabId!, @@ -1591,10 +2121,18 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); - const secondTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/b.ts", + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", + }); + const secondTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/b.ts", + }, + intent: "reveal", }); const splitPaneId = store.splitPane(workspaceKey, { tabId: secondTabId!, @@ -1618,13 +2156,14 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); - const paneId = store.splitPaneEmpty(workspaceKey, { - targetPaneId: "main", - position: "right", - })!; + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", + }); + const paneId = store.showSidePanel(workspaceKey)!; store.focusPane(workspaceKey, "main"); - store.hidePane(workspaceKey, paneId); + store.hideSidePanel(workspaceKey); store.focusPane(workspaceKey, paneId); @@ -1638,10 +2177,18 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); - const secondTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/b.ts", + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", + }); + const secondTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/b.ts", + }, + intent: "reveal", }); const splitPaneId = store.splitPane(workspaceKey, { tabId: secondTabId!, @@ -1671,11 +2218,31 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); - const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); - const c = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" }); - const d = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/d.ts" }); - const e = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/e.ts" }); + const a = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", + }); + const b = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/b.ts" }, + intent: "reveal", + }); + const c = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/c.ts" }, + intent: "reveal", + }); + const d = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/d.ts" }, + intent: "reveal", + }); + const e = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/e.ts" }, + intent: "reveal", + }); expect(a).toBeTruthy(); const pane1 = store.splitPane(workspaceKey, { @@ -1714,13 +2281,21 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const leftTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/a.ts", + const leftTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/a.ts", + }, + intent: "reveal", }); - const rightTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/b.ts", + const rightTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/b.ts", + }, + intent: "reveal", }); const splitPaneId = store.splitPane(workspaceKey, { tabId: rightTabId!, @@ -1749,14 +2324,26 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); - const secondTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/b.ts", + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", }); - const thirdTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/c.ts", + const secondTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/b.ts", + }, + intent: "reveal", + }); + const thirdTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/c.ts", + }, + intent: "reveal", }); const paneBId = store.splitPane(workspaceKey, { tabId: secondTabId!, @@ -1785,10 +2372,18 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); - const secondTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/b.ts", + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", + }); + const secondTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/b.ts", + }, + intent: "reveal", }); const splitPaneId = store.splitPane(workspaceKey, { tabId: secondTabId!, @@ -1797,9 +2392,13 @@ describe("workspace-layout-store actions", () => { }); store.focusPane(workspaceKey, "main"); - const duplicateTabId = store.openTabFocused(workspaceKey, { - kind: "file", - path: "/repo/worktree/b.ts", + const duplicateTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "file", + path: "/repo/worktree/b.ts", + }, + intent: "reveal", }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; @@ -1816,11 +2415,19 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { - kind: "working_diff", - focusPath: "src/a.ts", + store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "working_diff", + focusPath: "src/a.ts", + }, + intent: "reveal", + }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "commit_diff", sha: "abc123" }, + intent: "reveal", }); - store.openTabFocused(workspaceKey, { kind: "commit_diff", sha: "abc123" }); const partialize = workspaceLayoutStore.persist.getOptions().partialize; expect(partialize).toBeTypeOf("function"); @@ -1834,8 +2441,8 @@ describe("workspace-layout-store actions", () => { expect(persisted).toEqual({ layoutByWorkspace: { [workspaceKey]: layout }, splitSizesByWorkspace: currentState.splitSizesByWorkspace, + // The persisted key keeps its pre-rename spelling for older clients. explorerPaneIdByWorkspace: {}, - acknowledgedPullRequestByWorkspace: {}, }); expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([ { @@ -1845,40 +2452,59 @@ describe("workspace-layout-store actions", () => { ]); }); - it("canonicalizes comparison-specific working diff tab ids from persisted layouts", () => { - const legacyTabId = "working_diff_uncommitted_0_n"; + it("hydrates duplicate Changes and Pull request instances without collapsing their ids or state", () => { + const tabs = [ + { + tabId: "changes-a", + createdAt: 1, + target: { kind: "working_diff" }, + state: { + mode: "base", + baseRef: "main", + layout: "split", + wrapLines: true, + hideWhitespace: true, + treeVisible: true, + treeWidth: 300, + collapsedFilePaths: ["a"], + collapsedFolderPaths: ["src"], + commitsCollapsed: false, + }, + }, + { + tabId: "changes-b", + createdAt: 2, + target: { kind: "working_diff" }, + state: { + mode: "uncommitted", + layout: "unified", + wrapLines: false, + hideWhitespace: false, + treeVisible: false, + collapsedFilePaths: [], + collapsedFolderPaths: [], + commitsCollapsed: true, + }, + }, + { tabId: "pr-a", createdAt: 3, target: { kind: "pull_request" } }, + { tabId: "pr-b", createdAt: 4, target: { kind: "pull_request" } }, + ]; const layout = normalizeLayout({ root: { kind: "pane", - pane: { - id: "main", - tabIds: [legacyTabId], - focusedTabId: legacyTabId, - tabs: [ - { - tabId: legacyTabId, - createdAt: 1, - target: { - kind: "working_diff", - focusPath: "src/a.ts", - mode: "uncommitted", - baseRef: null, - ignoreWhitespace: false, - }, - }, - ], - }, + pane: { id: "main", tabIds: tabs.map((tab) => tab.tabId), focusedTabId: "changes-a", tabs }, }, focusedPaneId: "main", }); - - expect(collectAllTabs(layout.root)).toEqual([ - { - tabId: "working_diff", - target: { kind: "working_diff", focusPath: "src/a.ts" }, - createdAt: 1, - }, + expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([ + "changes-a", + "changes-b", + "pr-a", + "pr-b", ]); + expect( + collectAllTabs(layout.root).find((tab) => tab.tabId === "changes-a")?.state, + ).toMatchObject({ baseRef: "main", treeWidth: 300, collapsedFolderPaths: ["src"] }); }); it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => { @@ -1891,9 +2517,21 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); - const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); - const c = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" }); + const a = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", + }); + const b = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/b.ts" }, + intent: "reveal", + }); + const c = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/c.ts" }, + intent: "reveal", + }); expect(a).toBeTruthy(); const rightPaneId = store.splitPane(workspaceKey, { @@ -1932,11 +2570,15 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const tabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" }); + const tabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "draft", draftId: "draft-1" }, + intent: "reveal", + }); store.closeTab(workspaceKey, tabId!); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - expect(layout).toEqual(createWorkspaceLayoutWithExplorer()); + expect(layout).toEqual(createWorkspaceLayoutWithSidePanel()); }); it("keeps pinned archived agents in memory per workspace without persisting them", () => { @@ -1973,7 +2615,6 @@ describe("workspace-layout-store actions", () => { layoutByWorkspace: {}, splitSizesByWorkspace: {}, explorerPaneIdByWorkspace: {}, - acknowledgedPullRequestByWorkspace: {}, }); }); @@ -2011,7 +2652,6 @@ describe("workspace-layout-store actions", () => { layoutByWorkspace: {}, splitSizesByWorkspace: {}, explorerPaneIdByWorkspace: {}, - acknowledgedPullRequestByWorkspace: {}, }); }); @@ -2020,11 +2660,19 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const draftTabId = store.openTabFocused(workspaceKey, { - kind: "draft", - draftId: "draft-existing", + const draftTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "draft", + draftId: "draft-existing", + }, + intent: "reveal", + }); + const agentTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", }); - const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); const splitPaneId = store.splitPane(workspaceKey, { tabId: agentTabId!, targetPaneId: "main", @@ -2213,7 +2861,11 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "child-agent" }, + intent: "reveal", + }); store.reconcileTabs(workspaceKey, { agentsHydrated: true, @@ -2237,7 +2889,11 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "child-agent" }, + intent: "reveal", + }); store.reconcileTabs(workspaceKey, { agentsHydrated: true, @@ -2257,7 +2913,7 @@ describe("workspace-layout-store actions", () => { ).toEqual(["agent_parent-agent"]); }); - it("openTabFocused reopens hidden subagent tabs and clears hidden intent", () => { + it("openTab reveal intent reopens hidden subagent tabs and clears hidden intent", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); @@ -2274,7 +2930,11 @@ describe("workspace-layout-store actions", () => { expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "child-agent" }, + intent: "reveal", + }); const state = workspaceLayoutStore.getState(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); @@ -2287,9 +2947,13 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - const scriptTabId = store.openTabFocused(workspaceKey, { - kind: "terminal", - terminalId: "term-script", + const scriptTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "terminal", + terminalId: "term-script", + }, + intent: "reveal", }); store.reconcileTabs(workspaceKey, { @@ -2331,7 +2995,11 @@ describe("workspace-layout-store actions", () => { const store = workspaceLayoutStore.getState(); store.hideAgent(workspaceKey, "agent-1"); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); const state = workspaceLayoutStore.getState(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); @@ -2356,7 +3024,11 @@ describe("workspace-layout-store actions", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "archived-agent" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "archived-agent" }, + intent: "reveal", + }); store.pinAgent(workspaceKey, "archived-agent"); store.reconcileTabs(workspaceKey, { agentsHydrated: true, @@ -2396,7 +3068,11 @@ describe("workspace-layout-store actions", () => { expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([]); - store.openTabFocused(workspaceKey, { kind: "agent", agentId: "archived-agent" }); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "archived-agent" }, + intent: "reveal", + }); store.pinAgent(workspaceKey, "archived-agent"); store.reconcileTabs(workspaceKey, { agentsHydrated: true, @@ -2417,10 +3093,399 @@ describe("workspace-layout-store actions", () => { const store = workspaceLayoutStore.getState(); store.hideAgent(workspaceKey, "agent-1"); - const tabId = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); - store.retargetTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" }); + const tabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", + }); + store.replaceTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" }); const state = workspaceLayoutStore.getState(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); }); + + it("closePane removes an emptied main pane and moves focus to the surviving pane", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const keptTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "kept" }, + intent: "reveal", + }); + useWorkspaceLayoutIds("split", "group-1"); + const splitPaneId = store.splitPane(workspaceKey, { + tabId: keptTabId as string, + targetPaneId: "main", + position: "right", + }); + const strandedTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { + kind: "agent", + agentId: "stranded", + }, + intent: "reveal", + }); + store.moveTabToPane(workspaceKey, strandedTabId as string, "main"); + store.closeTab(workspaceKey, strandedTabId as string); + expect( + findPaneById(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, "main"), + ).toBeTruthy(); + + store.closePane(workspaceKey, "main"); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId]); + expect(layout.focusedPaneId).toBe(splitPaneId); + }); + + it("closePane drops the tabs the pane was still holding", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const keptTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "kept" }, + intent: "reveal", + }); + useWorkspaceLayoutIds("split", "group-1"); + const splitPaneId = store.splitPane(workspaceKey, { + tabId: keptTabId as string, + targetPaneId: "main", + position: "right", + }); + const doomedTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "doomed" }, + intent: "reveal", + }); + store.moveTabToPane(workspaceKey, doomedTabId as string, "main"); + + store.closePane(workspaceKey, "main"); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([keptTabId]); + expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId]); + }); + + it("closePane refuses to close the last visible pane", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "only" }, + intent: "reveal", + }); + const before = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + + store.closePane(workspaceKey, "main"); + + expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]).toBe(before); + }); + + it("closePane hides the side panel instead of removing it, keeping its tabs", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "kept" }, + intent: "reveal", + }); + const paneId = store.showSidePanel(workspaceKey) as string; + const filesTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "files" }, + intent: "reveal", + placement: { mode: "pane", paneId }, + }); + + store.closePane(workspaceKey, paneId); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, paneId)?.hidden).toBe(true); + expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toContain(filesTabId); + expect(store.showSidePanel(workspaceKey)).toBe(paneId); + expect( + findPaneById(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, paneId) + ?.hidden, + ).toBeUndefined(); + }); + + it("closing the final Side panel tab hides its empty pane until it is revealed again", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "kept" }, + intent: "reveal", + }); + const paneId = store.showSidePanel(workspaceKey) as string; + const filesTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "files" }, + intent: "reveal", + placement: { mode: "pane", paneId }, + }) as string; + + store.closeTab(workspaceKey, filesTabId); + + let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: [], hidden: true }); + expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]); + + expect(store.showSidePanel(workspaceKey)).toBe(paneId); + layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: [] }); + expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined(); + }); + + it("closing one of several Side panel tabs leaves the panel visible", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "kept" }, + intent: "reveal", + }); + const paneId = store.showSidePanel(workspaceKey) as string; + const filesTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "files" }, + intent: "reveal", + placement: { mode: "pane", paneId }, + }) as string; + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "working_diff" }, + intent: "reveal", + placement: { mode: "pane", paneId }, + }); + + store.closeTab(workspaceKey, filesTabId); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined(); + expect(findPaneById(layout.root, paneId)?.tabIds).toEqual(["working_diff"]); + }); + + it("closing the final Side panel tab keeps the final visible pane on screen", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "only" }, + intent: "reveal", + }); + const paneId = store.showSidePanel(workspaceKey) as string; + const filesTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "files" }, + intent: "reveal", + placement: { mode: "pane", paneId }, + }) as string; + store.closePane(workspaceKey, "main"); + + store.closeTab(workspaceKey, filesTabId); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([paneId]); + expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: [] }); + expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined(); + }); + + it("an explicit pane-local open moves an existing tab into that pane", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + const sidePanelPaneId = store.showSidePanel(workspaceKey) as string; + const changesTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "working_diff" }, + intent: "reveal", + placement: { mode: "pane", paneId: sidePanelPaneId }, + }); + + const reopened = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "working_diff" }, + intent: "reveal", + placement: { mode: "pane", paneId: "main" }, + }); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(reopened).toBe(changesTabId); + expect(collectAllTabs(layout.root).filter((tab) => tab.tabId === changesTabId)).toHaveLength(1); + expect(findPaneContainingTab(layout.root, changesTabId as string)?.id).toBe("main"); + expect(layout.focusedPaneId).toBe("main"); + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(changesTabId); + }); + + it("a preferred open focuses an existing tab where the user left it", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + const sidePanelPaneId = store.showSidePanel(workspaceKey) as string; + const fileTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/a.ts" }, + intent: "reveal", + placement: { mode: "prefer", paneId: sidePanelPaneId }, + }) as string; + store.moveTabToPane(workspaceKey, fileTabId, "main"); + + const reopened = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "file", path: "/repo/a.ts" }, + intent: "reveal", + placement: { mode: "prefer", paneId: sidePanelPaneId }, + }); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(reopened).toBe(fileTabId); + expect(findPaneContainingTab(layout.root, fileTabId)?.id).toBe("main"); + expect(layout.focusedPaneId).toBe("main"); + }); + it("leaves the side panel behind, empty, when its last tab is claimed by another pane", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + const paneId = store.showSidePanel(workspaceKey) as string; + const changesTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "working_diff" }, + intent: "reveal", + placement: { mode: "pane", paneId }, + }) as string; + + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "working_diff" }, + intent: "reveal", + placement: { mode: "pane", paneId: "main" }, + }); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneContainingTab(layout.root, changesTabId)?.id).toBe("main"); + expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: [] }); + expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined(); + }); + it("closePane refuses to hide the side panel once it is the only visible pane", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "only" }, + intent: "reveal", + }); + const paneId = store.showSidePanel(workspaceKey) as string; + store.closePane(workspaceKey, "main"); + const afterMainClosed = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(afterMainClosed.root, "main")).toBeNull(); + expect(collectAllPanes(afterMainClosed.root).map((pane) => pane.id)).toEqual([paneId]); + const before = afterMainClosed; + + store.closePane(workspaceKey, paneId); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(layout).toBe(before); + expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined(); + }); + it("closePane refuses to hide the side panel once it is the only visible pane", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "only" }, + intent: "reveal", + }); + const paneId = store.showSidePanel(workspaceKey) as string; + store.closePane(workspaceKey, "main"); + const afterMainClosed = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(afterMainClosed.root, "main")).toBeNull(); + expect(collectAllPanes(afterMainClosed.root).map((pane) => pane.id)).toEqual([paneId]); + const before = afterMainClosed; + + store.closePane(workspaceKey, paneId); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(layout).toBe(before); + expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined(); + }); + it("hideSidePanel refuses to blank the workspace when the side panel is all that is left", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "only" }, + intent: "reveal", + }); + const paneId = store.showSidePanel(workspaceKey) as string; + store.closePane(workspaceKey, "main"); + const before = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(collectAllPanes(before.root).map((pane) => pane.id)).toEqual([paneId]); + + store.hideSidePanel(workspaceKey); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(layout).toBe(before); + expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([paneId]); + }); + it("canDismissPaneInLayout refuses the final visible pane, loaded or empty", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "only" }, + intent: "reveal", + }); + const loadedFinal = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + + // Only `main` is on screen; the side panel is present but hidden. + expect(collectAllPanes(loadedFinal.root).map((pane) => pane.id)).toEqual(["main"]); + expect(canDismissPaneInLayout(loadedFinal, "main")).toBe(false); + expect(canDismissPaneInLayout(loadedFinal, "explorer")).toBe(false); + + const paneId = store.showSidePanel(workspaceKey) as string; + const bothVisible = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(canDismissPaneInLayout(bothVisible, "main")).toBe(true); + expect(canDismissPaneInLayout(bothVisible, paneId)).toBe(true); + + store.closePane(workspaceKey, "main"); + const sidePanelAlone = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(canDismissPaneInLayout(sidePanelAlone, paneId)).toBe(false); + }); + + it("closePane on a loaded final pane keeps the pane and every tab in it", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const agentTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "agent", agentId: "only" }, + intent: "reveal", + }); + const filesTabId = store.openTab({ + workspaceKey: workspaceKey, + target: { kind: "files" }, + intent: "reveal", + }); + const before = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + + store.closePane(workspaceKey, "main"); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(layout).toBe(before); + expect(findPaneById(layout.root, "main")?.tabIds).toEqual([agentTabId, filesTabId]); + expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]); + }); }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 24d68c589..f8a79be7f 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -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; splitSizesByWorkspace: Record>; @@ -79,43 +101,20 @@ interface WorkspaceLayoutStore { pendingAgentIdsByWorkspace: Record>; hiddenAgentIdsByWorkspace: Record>; focusRestorationByWorkspace: Record; - explorerPaneIdByWorkspace: Record; - acknowledgedPullRequestByWorkspace: Record; - 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; + 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 = z.lazy(() => z.discriminatedUnion("kind", [ @@ -264,7 +257,11 @@ const WorkspaceLayoutStorageSchema: z.ZodType = 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>, workspaceKey: string, @@ -322,10 +327,33 @@ function getWorkspaceLayout( state: Record, 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 = {}; - const explorerPaneIdByWorkspace: Record = { + const sidePanelPaneIdByWorkspace: Record = { ...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, }; }, }, diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 46130ae36..f56bac931 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -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); }); }); diff --git a/packages/app/src/utils/prepare-workspace-tab.ts b/packages/app/src/utils/prepare-workspace-tab.ts index 7c8d7a43a..5fb2cfda9 100644 --- a/packages/app/src/utils/prepare-workspace-tab.ts +++ b/packages/app/src/utils/prepare-workspace-tab.ts @@ -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); diff --git a/packages/app/src/utils/workspace-navigation.test.ts b/packages/app/src/utils/workspace-navigation.test.ts index f4a7b7a9a..cbad3e56f 100644 --- a/packages/app/src/utils/workspace-navigation.test.ts +++ b/packages/app/src/utils/workspace-navigation.test.ts @@ -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; }, diff --git a/packages/app/src/utils/workspace-navigation.ts b/packages/app/src/utils/workspace-navigation.ts index 57c0ff6ab..60afce6cc 100644 --- a/packages/app/src/utils/workspace-navigation.ts +++ b/packages/app/src/utils/workspace-navigation.ts @@ -3,13 +3,15 @@ import { prepareWorkspaceTab as prepareWorkspaceTabPure, type PrepareWorkspaceTabInput, } from "./prepare-workspace-tab"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; export type { PrepareWorkspaceTabInput } from "./prepare-workspace-tab"; function layoutStoreDeps() { const store = useWorkspaceLayoutStore.getState(); return { - openTabFocused: store.openTabFocused, + openTab: (input: { workspaceKey: string; target: WorkspaceTabTarget; intent: "reveal" }) => + store.openTab(input), pinAgent: store.pinAgent, }; } diff --git a/packages/app/src/workspace-tabs/explorer-surface.test.ts b/packages/app/src/workspace-tabs/explorer-surface.test.ts deleted file mode 100644 index 8a2ba97db..000000000 --- a/packages/app/src/workspace-tabs/explorer-surface.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@react-native-async-storage/async-storage", () => { - const storage = new Map(); - return { - default: { - getItem: vi.fn(async (key: string) => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value); - }), - removeItem: vi.fn(async (key: string) => { - storage.delete(key); - }), - }, - }; -}); - -const paneSplits = vi.hoisted(() => ({ supported: true })); -vi.mock("@/constants/layout", async (importOriginal) => ({ - ...(await importOriginal()), - supportsDesktopPaneSplits: () => paneSplits.supported, -})); - -import { usePanelStore } from "@/stores/panel-store"; -import { - collectAllPanes, - collectAllTabs, - findPaneById, - findPaneContainingTab, - useWorkspaceLayoutStore, -} from "@/stores/workspace-layout-store"; -import { - openExplorerSurface, - openWorkspaceTabBeside, - isExplorerSurfaceOpen, - toggleExplorerSurface, -} from "@/workspace-tabs/explorer-surface"; - -const WORKSPACE_KEY = "server-1:ws-main"; -const CHECKOUT = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; - -function layout() { - const value = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]; - if (!value) throw new Error("workspace layout missing"); - return value; -} - -function tabKinds(): string[] { - return collectAllTabs(layout().root).map((tab) => tab.target.kind); -} - -function focusedPaneTabKinds(): string[] { - const current = layout(); - const pane = findPaneById(current.root, current.focusedPaneId); - const tabs = collectAllTabs(current.root); - return (pane?.tabIds ?? []).map( - (tabId) => tabs.find((tab) => tab.tabId === tabId)?.target.kind ?? "?", - ); -} - -beforeEach(() => { - paneSplits.supported = true; - useWorkspaceLayoutStore.setState({ - layoutByWorkspace: {}, - explorerPaneIdByWorkspace: {}, - splitSizesByWorkspace: {}, - }); - usePanelStore.setState({ mobilePanel: { target: "agent", revision: 0 } }); -}); - -describe("compact surface", () => { - const compact = { isCompact: true, workspaceKey: WORKSPACE_KEY, checkout: CHECKOUT }; - - it("opens and closes the overlay without touching the layout", () => { - openExplorerSurface({ ...compact, view: "files" }); - - expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); - expect(usePanelStore.getState().explorerTab).toBe("files"); - expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toBeUndefined(); - - toggleExplorerSurface(compact); - expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); - }); - - it("reports the overlay state, not the layout state", () => { - expect(isExplorerSurfaceOpen({ isCompact: true, workspaceKey: WORKSPACE_KEY })).toBe(false); - toggleExplorerSurface(compact); - expect(isExplorerSurfaceOpen({ isCompact: true, workspaceKey: WORKSPACE_KEY })).toBe(true); - }); -}); - -describe("non-compact with splits", () => { - const wide = { isCompact: false, workspaceKey: WORKSPACE_KEY, checkout: CHECKOUT }; - - it("seeds a Changes tab in a dedicated explorer pane", () => { - toggleExplorerSurface(wide); - - expect(tabKinds()).toContain("working_diff"); - expect(collectAllPanes(layout().root).length).toBeGreaterThan(1); - expect(isExplorerSurfaceOpen(wide)).toBe(true); - }); - - it("hides the pane on the second toggle and restores it on the third", () => { - toggleExplorerSurface(wide); - toggleExplorerSurface(wide); - expect(isExplorerSurfaceOpen(wide)).toBe(false); - - toggleExplorerSurface(wide); - expect(isExplorerSurfaceOpen(wide)).toBe(true); - // Restoring must not stack a second Changes tab. - expect(tabKinds().filter((kind) => kind === "working_diff")).toHaveLength(1); - }); - - it("leaves the compact overlay alone", () => { - toggleExplorerSurface(wide); - expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); - }); - - it("opens files beside the current tab, in the explorer pane", () => { - const tabId = openWorkspaceTabBeside({ - isCompact: false, - workspaceKey: WORKSPACE_KEY, - target: { kind: "files" }, - }); - - expect(tabId).not.toBeNull(); - const explorerPaneId = - useWorkspaceLayoutStore.getState().explorerPaneIdByWorkspace[WORKSPACE_KEY]; - expect(findPaneContainingTab(layout().root, tabId!)?.id).toBe(explorerPaneId); - }); -}); - -describe("non-compact without splits", () => { - const tablet = { isCompact: false, workspaceKey: WORKSPACE_KEY, checkout: CHECKOUT }; - - beforeEach(() => { - paneSplits.supported = false; - }); - - it("opens the explorer as a tab in the focused pane, never a second pane", () => { - toggleExplorerSurface(tablet); - - expect(focusedPaneTabKinds()).toContain("working_diff"); - expect(collectAllPanes(layout().root)).toHaveLength(1); - expect(isExplorerSurfaceOpen(tablet)).toBe(true); - }); - - it("closes the explorer tab on the second toggle", () => { - toggleExplorerSurface(tablet); - toggleExplorerSurface(tablet); - - expect(tabKinds()).not.toContain("working_diff"); - expect(isExplorerSurfaceOpen(tablet)).toBe(false); - }); - - it("keeps side-opened files in the focused pane so the tab row still lists them", () => { - const tabId = openWorkspaceTabBeside({ - isCompact: false, - workspaceKey: WORKSPACE_KEY, - target: { kind: "files" }, - }); - - expect(tabId).not.toBeNull(); - expect(findPaneContainingTab(layout().root, tabId!)?.id).toBe(layout().focusedPaneId); - expect(collectAllPanes(layout().root)).toHaveLength(1); - }); -}); diff --git a/packages/app/src/workspace-tabs/explorer-surface.ts b/packages/app/src/workspace-tabs/explorer-surface.ts deleted file mode 100644 index b6a5c1208..000000000 --- a/packages/app/src/workspace-tabs/explorer-surface.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { supportsDesktopPaneSplits } from "@/constants/layout"; -import { - usePanelStore, - selectIsCompactFileExplorerOpen, - type ExplorerTab, -} from "@/stores/panel-store"; -import type { ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; -import { - collectAllTabs, - findPaneById, - resolveExplorerPaneId, - useWorkspaceLayoutStore, -} from "@/stores/workspace-layout-store"; -import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; - -/** - * The explorer has three surfaces, picked from two facts — is the layout compact, - * and can this platform render split panes: - * - * 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 (native tablets) - * - * Every entry point goes through this module. Nothing else branches on the surface — - * the third case is why: 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 explorer - * pane there would silently disappear. - */ - -/** Tab kinds that are explorer views rather than user content. */ -const EXPLORER_TAB_KINDS = ["working_diff", "files", "pull_request"] as const; -type ExplorerTabKind = (typeof EXPLORER_TAB_KINDS)[number]; - -const EXPLORER_VIEW_TARGETS: Record = { - changes: { kind: "working_diff" }, - files: { kind: "files" }, - pr: { kind: "pull_request" }, -}; - -export interface ExplorerSurfaceQuery { - isCompact: boolean; - workspaceKey: string | null; -} - -export interface ExplorerSurfaceInput extends ExplorerSurfaceQuery { - checkout: ExplorerCheckoutContext | null; -} - -type LayoutState = ReturnType; - -function isExplorerTabKind(kind: string): kind is ExplorerTabKind { - return (EXPLORER_TAB_KINDS as readonly string[]).includes(kind); -} - -function canUseExplorerPane(isCompact: boolean): boolean { - return !isCompact && supportsDesktopPaneSplits(); -} - -// ─── Explorer pane (non-compact + splits) ────────────────────────────────── - -function findExplorerPane(state: LayoutState, workspaceKey: string) { - const layout = state.layoutByWorkspace[workspaceKey]; - if (!layout) { - return null; - } - const paneId = resolveExplorerPaneId(layout, state.explorerPaneIdByWorkspace[workspaceKey]); - return paneId ? findPaneById(layout.root, paneId) : null; -} - -function selectIsExplorerPaneOpen(state: LayoutState, workspaceKey: string | null): boolean { - if (!workspaceKey) { - return false; - } - const pane = findExplorerPane(state, workspaceKey); - return Boolean(pane && pane.hidden !== true); -} - -// ─── Explorer tabs (non-compact, no splits) ──────────────────────────────── - -/** - * The explorer tab the focused pane is currently showing, if any. On a tablet - * "the explorer is open" means exactly that — there is no pane to hide. - */ -function findActiveExplorerTabId(state: LayoutState, workspaceKey: string): string | null { - const layout = state.layoutByWorkspace[workspaceKey]; - if (!layout) { - return null; - } - const focusedTabId = findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null; - if (!focusedTabId) { - return null; - } - const tab = collectAllTabs(layout.root).find((candidate) => candidate.tabId === focusedTabId); - return tab && isExplorerTabKind(tab.target.kind) ? tab.tabId : null; -} - -// ─── Public API ──────────────────────────────────────────────────────────── - -/** - * Open a tab alongside the current one. With splits it lands in the explorer - * pane; without them it opens in the focused pane, because that is the only - * pane the user can see. - */ -export function openWorkspaceTabBeside(input: { - isCompact: boolean; - workspaceKey: string | null; - target: WorkspaceTabTarget; - parentTabId?: string | null; -}): string | null { - if (!input.workspaceKey) { - return null; - } - const store = useWorkspaceLayoutStore.getState(); - const parentTabId = input.parentTabId?.trim() || null; - - if (canUseExplorerPane(input.isCompact)) { - return store.openTabInExplorerPaneFocused(input.workspaceKey, { - target: input.target, - parentTabId, - }); - } - - return parentTabId - ? store.openChildTabInFocusedPane(input.workspaceKey, input.target, parentTabId) - : store.openTabInFocusedPane(input.workspaceKey, input.target); -} - -/** {@link openWorkspaceTabBeside} without moving focus. */ -export function openWorkspaceTabBesideInBackground(input: { - isCompact: boolean; - workspaceKey: string | null; - target: WorkspaceTabTarget; -}): string | null { - if (!input.workspaceKey) { - return null; - } - const store = useWorkspaceLayoutStore.getState(); - return canUseExplorerPane(input.isCompact) - ? store.openTabInExplorerPaneBackground(input.workspaceKey, input.target) - : store.openTabInBackground(input.workspaceKey, input.target); -} - -/** Reveal the explorer showing `view`. */ -export function openExplorerSurface(input: ExplorerSurfaceInput & { view: ExplorerTab }): void { - if (input.isCompact) { - if (!input.checkout) { - return; - } - const panel = usePanelStore.getState(); - panel.setExplorerTabForCheckout({ ...input.checkout, tab: input.view }); - panel.openCompactFileExplorer(input.checkout); - return; - } - - openWorkspaceTabBeside({ - isCompact: input.isCompact, - workspaceKey: input.workspaceKey, - target: EXPLORER_VIEW_TARGETS[input.view], - }); -} - -/** Show the explorer if it is hidden, hide it if it is showing. */ -export function toggleExplorerSurface(input: ExplorerSurfaceInput): void { - if (input.isCompact) { - if (input.checkout) { - usePanelStore.getState().toggleCompactFileExplorer(input.checkout); - } - return; - } - if (!input.workspaceKey) { - return; - } - - const workspaceKey = input.workspaceKey; - const store = useWorkspaceLayoutStore.getState(); - - if (!canUseExplorerPane(input.isCompact)) { - const activeExplorerTabId = findActiveExplorerTabId(store, workspaceKey); - if (activeExplorerTabId) { - store.closeTab(workspaceKey, activeExplorerTabId); - return; - } - store.openTabInFocusedPane(workspaceKey, EXPLORER_VIEW_TARGETS.changes); - return; - } - - const layout = store.layoutByWorkspace[workspaceKey]; - const parentTabId = layout - ? (findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null) - : null; - const explorerPane = findExplorerPane(store, workspaceKey); - - if (explorerPane) { - if (explorerPane.hidden !== true) { - store.hidePane(workspaceKey, explorerPane.id); - return; - } - store.showPane(workspaceKey, explorerPane.id); - // The companion pane can already hold an unrelated tab (the setup progress - // tab auto-opens here) before the user ever toggles it, so "empty" is the - // wrong signal for "needs a Changes tab seeded". Check for it specifically. - const layoutTabs = layout ? collectAllTabs(layout.root) : []; - const hasChangesTab = explorerPane.tabIds.some( - (tabId) => layoutTabs.find((tab) => tab.tabId === tabId)?.target.kind === "working_diff", - ); - if (hasChangesTab) { - return; - } - } - - store.openTabInExplorerPaneFocused(workspaceKey, { - target: EXPLORER_VIEW_TARGETS.changes, - parentTabId, - }); -} - -/** Reactive "is the explorer showing" for the surface this layout uses. */ -export function useIsExplorerSurfaceOpen(input: ExplorerSurfaceQuery): boolean { - const compactOpen = usePanelStore(selectIsCompactFileExplorerOpen); - const paneOpen = useWorkspaceLayoutStore((state) => - input.isCompact ? false : isExplorerOpenForLayout(state, input.workspaceKey), - ); - return input.isCompact ? compactOpen : paneOpen; -} - -/** Imperative counterpart of {@link useIsExplorerSurfaceOpen}. */ -export function isExplorerSurfaceOpen(input: ExplorerSurfaceQuery): boolean { - if (input.isCompact) { - return selectIsCompactFileExplorerOpen(usePanelStore.getState()); - } - return isExplorerOpenForLayout(useWorkspaceLayoutStore.getState(), input.workspaceKey); -} - -function isExplorerOpenForLayout(state: LayoutState, workspaceKey: string | null): boolean { - if (!workspaceKey) { - return false; - } - return supportsDesktopPaneSplits() - ? selectIsExplorerPaneOpen(state, workspaceKey) - : findActiveExplorerTabId(state, workspaceKey) !== null; -} diff --git a/packages/app/src/workspace-tabs/model.ts b/packages/app/src/workspace-tabs/model.ts index 5cee9d821..b059b3b2a 100644 --- a/packages/app/src/workspace-tabs/model.ts +++ b/packages/app/src/workspace-tabs/model.ts @@ -1,4 +1,5 @@ import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { WorkspaceFileTabTarget } from "@/workspace/file-open"; export interface WorkspaceDraftTabSetup { @@ -49,6 +50,7 @@ export interface WorkspaceTab { tabId: string; target: WorkspaceTabTarget; createdAt: number; + state?: JsonValue; } export function buildWorkspaceTabPersistenceKey(input: { diff --git a/packages/app/src/workspace-tabs/side-panel.test.ts b/packages/app/src/workspace-tabs/side-panel.test.ts new file mode 100644 index 000000000..a874e1b8a --- /dev/null +++ b/packages/app/src/workspace-tabs/side-panel.test.ts @@ -0,0 +1,247 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@react-native-async-storage/async-storage", () => { + const storage = new Map(); + return { + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key); + }), + }, + }; +}); + +import { usePanelStore } from "@/stores/panel-store"; +import { + collectAllPanes, + collectAllTabs, + findPaneById, + findPaneContainingTab, + selectSidePanelPaneId, + useWorkspaceLayoutStore, +} from "@/stores/workspace-layout-store"; +import { + hideSidePanel, + isSidePanelOpen, + openSidePanelView, + openSupportingTab, + showSidePanel, + toggleSidePanel, +} from "@/workspace-tabs/side-panel"; + +const WORKSPACE_KEY = "server-1:ws-main"; +const CHECKOUT = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; + +function layout() { + const value = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]; + if (!value) throw new Error("workspace layout missing"); + return value; +} + +function tabKinds(): string[] { + return collectAllTabs(layout().root).map((tab) => tab.target.kind); +} + +function focusedPaneTabKinds(): string[] { + const current = layout(); + const pane = findPaneById(current.root, current.focusedPaneId); + const tabs = collectAllTabs(current.root); + return (pane?.tabIds ?? []).map( + (tabId) => tabs.find((tab) => tab.tabId === tabId)?.target.kind ?? "?", + ); +} + +function sidePanelPaneId(): string | null { + return selectSidePanelPaneId(useWorkspaceLayoutStore.getState(), WORKSPACE_KEY); +} + +function paneIdHolding(tabId: string): string | undefined { + return findPaneContainingTab(layout().root, tabId)?.id; +} + +beforeEach(() => { + useWorkspaceLayoutStore.setState({ + layoutByWorkspace: {}, + sidePanelPaneIdByWorkspace: {}, + splitSizesByWorkspace: {}, + }); + usePanelStore.setState({ mobilePanel: { target: "agent", revision: 0 } }); +}); + +describe("compact surface", () => { + const compact = { isCompact: true, workspaceKey: WORKSPACE_KEY, checkout: CHECKOUT }; + + it("opens and closes the overlay without touching the layout", () => { + openSidePanelView({ ...compact, view: "files" }); + + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + expect(usePanelStore.getState().explorerTab).toBe("files"); + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toBeUndefined(); + + toggleSidePanel(compact); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + }); + + it("reports the overlay state, not the layout state", () => { + expect(isSidePanelOpen({ isCompact: true, workspaceKey: WORKSPACE_KEY })).toBe(false); + toggleSidePanel(compact); + expect(isSidePanelOpen({ isCompact: true, workspaceKey: WORKSPACE_KEY })).toBe(true); + }); +}); + +describe("non-compact with splits", () => { + const wide = { + isCompact: false, + supportsPaneSplits: true, + workspaceKey: WORKSPACE_KEY, + checkout: CHECKOUT, + }; + + it("reveals an empty pane rather than seeding a view into it", () => { + toggleSidePanel(wide); + + expect(isSidePanelOpen(wide)).toBe(true); + expect(tabKinds()).toEqual([]); + expect(collectAllPanes(layout().root).length).toBeGreaterThan(1); + }); + + it("opens the view the user picked from the revealed panel", () => { + showSidePanel(wide); + openSidePanelView({ ...wide, view: "changes" }); + + const changesTabId = collectAllTabs(layout().root).find( + (tab) => tab.target.kind === "working_diff", + )?.tabId; + expect(changesTabId).toBeTruthy(); + expect(paneIdHolding(changesTabId as string)).toBe(sidePanelPaneId()); + }); + + it("hides the pane on the second toggle and restores its tabs on the third", () => { + openSidePanelView({ ...wide, view: "changes" }); + toggleSidePanel(wide); + expect(isSidePanelOpen(wide)).toBe(false); + + toggleSidePanel(wide); + expect(isSidePanelOpen(wide)).toBe(true); + expect(tabKinds().filter((kind) => kind === "working_diff")).toHaveLength(1); + }); + + it("leaves the compact overlay alone", () => { + toggleSidePanel(wide); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + }); + + it("routes a new supporting tab into the side panel", () => { + const tabId = openSupportingTab({ + isCompact: false, + supportsPaneSplits: true, + workspaceKey: WORKSPACE_KEY, + target: { kind: "files" }, + openInSidePanelByDefault: true, + }); + + expect(tabId).not.toBeNull(); + expect(paneIdHolding(tabId as string)).toBe(sidePanelPaneId()); + expect(isSidePanelOpen(wide)).toBe(true); + }); + + it("finds a supporting tab the user moved instead of dragging it back", () => { + const tabId = openSupportingTab({ + isCompact: false, + supportsPaneSplits: true, + workspaceKey: WORKSPACE_KEY, + target: { kind: "files" }, + openInSidePanelByDefault: true, + }) as string; + useWorkspaceLayoutStore.getState().moveTabToPane(WORKSPACE_KEY, tabId, "main"); + + const reopened = openSupportingTab({ + isCompact: false, + supportsPaneSplits: true, + workspaceKey: WORKSPACE_KEY, + target: { kind: "files" }, + openInSidePanelByDefault: true, + }); + + expect(reopened).toBe(tabId); + expect(paneIdHolding(tabId)).toBe("main"); + }); + + it("opens a new supporting tab in the focused pane when routing is off", () => { + const tabId = openSupportingTab({ + isCompact: false, + supportsPaneSplits: true, + workspaceKey: WORKSPACE_KEY, + target: { kind: "files" }, + openInSidePanelByDefault: false, + }); + + expect(paneIdHolding(tabId as string)).toBe(layout().focusedPaneId); + expect(isSidePanelOpen(wide)).toBe(false); + }); + + it("adds a background supporting tab without revealing the panel", () => { + const tabId = openSupportingTab({ + isCompact: false, + supportsPaneSplits: true, + workspaceKey: WORKSPACE_KEY, + target: { kind: "setup", workspaceId: "ws-main" }, + openInSidePanelByDefault: true, + background: true, + }); + + expect(paneIdHolding(tabId as string)).toBe(sidePanelPaneId()); + expect(isSidePanelOpen(wide)).toBe(false); + }); + + it("keeps the panel's tabs when it is hidden", () => { + openSidePanelView({ ...wide, view: "files" }); + hideSidePanel(wide); + + expect(isSidePanelOpen(wide)).toBe(false); + expect(tabKinds()).toEqual(["files"]); + }); +}); + +describe("non-compact without splits", () => { + const tablet = { + isCompact: false, + supportsPaneSplits: false, + workspaceKey: WORKSPACE_KEY, + checkout: CHECKOUT, + }; + + it("opens the side panel as a tab in the focused pane, never a second pane", () => { + toggleSidePanel(tablet); + + expect(focusedPaneTabKinds()).toContain("working_diff"); + expect(collectAllPanes(layout().root)).toHaveLength(1); + expect(isSidePanelOpen(tablet)).toBe(true); + }); + + it("closes the side panel tab on the second toggle", () => { + toggleSidePanel(tablet); + toggleSidePanel(tablet); + + expect(tabKinds()).not.toContain("working_diff"); + expect(isSidePanelOpen(tablet)).toBe(false); + }); + + it("keeps supporting tabs in the focused pane so the tab row still lists them", () => { + const tabId = openSupportingTab({ + isCompact: false, + supportsPaneSplits: false, + workspaceKey: WORKSPACE_KEY, + target: { kind: "files" }, + openInSidePanelByDefault: true, + }); + + expect(tabId).not.toBeNull(); + expect(paneIdHolding(tabId as string)).toBe(layout().focusedPaneId); + expect(collectAllPanes(layout().root)).toHaveLength(1); + }); +}); diff --git a/packages/app/src/workspace-tabs/side-panel.ts b/packages/app/src/workspace-tabs/side-panel.ts new file mode 100644 index 000000000..e77fd5ee7 --- /dev/null +++ b/packages/app/src/workspace-tabs/side-panel.ts @@ -0,0 +1,254 @@ +import { supportsDesktopPaneSplits } from "@/constants/layout"; +import { + usePanelStore, + selectIsCompactFileExplorerOpen, + type ExplorerTab, +} from "@/stores/panel-store"; +import type { ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; +import { + collectAllTabs, + findPaneById, + selectIsSidePanelVisible, + selectSidePanelPaneId, + useWorkspaceLayoutStore, + type WorkspaceTabPlacement, +} from "@/stores/workspace-layout-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; + +/** + * The side panel is the companion surface beside the user's work — Changes, Files, + * the pull request, and anything an agent asks to show alongside its output. It has + * three renderings, picked from two facts — is the layout compact, and can this + * platform render split panes: + * + * 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 (native tablets) + * + * Every entry point goes through this module. Nothing else branches on the surface — + * the third case is why: 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 would silently disappear. + */ + +/** Tab kinds that are side panel views rather than user content. */ +const SIDE_PANEL_TAB_KINDS = ["working_diff", "files", "pull_request"] as const; +type SidePanelTabKind = (typeof SIDE_PANEL_TAB_KINDS)[number]; + +const SIDE_PANEL_VIEW_TARGETS: Record = { + changes: { kind: "working_diff" }, + files: { kind: "files" }, + pr: { kind: "pull_request" }, +}; + +export interface SidePanelQuery { + isCompact: boolean; + workspaceKey: string | null; + /** Allows callers with known layout capabilities to avoid platform inference. */ + supportsPaneSplits?: boolean; +} + +export interface SidePanelInput extends SidePanelQuery { + checkout: ExplorerCheckoutContext | null; +} + +type LayoutState = ReturnType; + +function isSidePanelTabKind(kind: string): kind is SidePanelTabKind { + return (SIDE_PANEL_TAB_KINDS as readonly string[]).includes(kind); +} + +function canUseSidePanelPane(input: SidePanelQuery): boolean { + return !input.isCompact && (input.supportsPaneSplits ?? supportsDesktopPaneSplits()); +} + +// ─── Side panel tabs (non-compact, no splits) ────────────────────────────── + +/** + * The side panel tab the focused pane is currently showing, if any. On a tablet + * "the side panel is open" means exactly that — there is no pane to hide. + */ +function findActiveSidePanelTabId(state: LayoutState, workspaceKey: string): string | null { + const layout = state.layoutByWorkspace[workspaceKey]; + if (!layout) { + return null; + } + const focusedTabId = findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null; + if (!focusedTabId) { + return null; + } + const tab = collectAllTabs(layout.root).find((candidate) => candidate.tabId === focusedTabId); + return tab && isSidePanelTabKind(tab.target.kind) ? tab.tabId : null; +} + +// ─── Public API ──────────────────────────────────────────────────────────── + +export interface OpenSupportingTabInput extends SidePanelQuery { + target: WorkspaceTabTarget; + /** The user's preference for where new supporting tabs land. Ignored on compact. */ + openInSidePanelByDefault: boolean; + parentTabId?: string | null; + /** Add the tab without revealing the side panel or moving focus. */ + background?: boolean; +} + +/** + * Opens a tab the user did not place themselves — an agent's file link, a detected + * pull request, workspace setup progress. A supporting tab that is already open is + * left where the user put it; only a brand new one follows the preference. + */ +export function openSupportingTab(input: OpenSupportingTabInput): string | null { + if (!input.workspaceKey) { + return null; + } + const workspaceKey = input.workspaceKey; + const store = useWorkspaceLayoutStore.getState(); + const background = input.background === true; + const placement = resolveSidePanelPlacement({ + store, + workspaceKey, + routeToSidePanel: input.openInSidePanelByDefault && canUseSidePanelPane(input), + background, + }); + + if (background) { + return store.openTab({ workspaceKey, target: input.target, intent: "background", placement }); + } + return store.openTab({ + workspaceKey, + target: input.target, + intent: "reveal", + placement, + parentTabId: input.parentTabId ?? undefined, + }); +} + +/** + * Undefined means "no opinion, use the focused pane". Revealing is part of resolving + * the side panel: a focused tab in a hidden pane is nothing the user can see, so a + * visible open brings the panel on screen. A background open never does. + */ +function resolveSidePanelPlacement(input: { + store: LayoutState; + workspaceKey: string; + routeToSidePanel: boolean; + background: boolean; +}): WorkspaceTabPlacement | undefined { + if (!input.routeToSidePanel) { + return undefined; + } + const paneId = input.background + ? selectSidePanelPaneId(input.store, input.workspaceKey) + : input.store.showSidePanel(input.workspaceKey); + return paneId ? { mode: "prefer", paneId } : undefined; +} + +/** Reveal the side panel showing `view`. */ +export function openSidePanelView(input: SidePanelInput & { view: ExplorerTab }): void { + if (input.isCompact) { + if (!input.checkout) { + return; + } + const panel = usePanelStore.getState(); + panel.setExplorerTabForCheckout({ ...input.checkout, tab: input.view }); + panel.openCompactFileExplorer(input.checkout); + return; + } + + openSupportingTab({ + isCompact: input.isCompact, + workspaceKey: input.workspaceKey, + target: SIDE_PANEL_VIEW_TARGETS[input.view], + openInSidePanelByDefault: true, + }); +} + +/** Reveal the side panel without putting anything in it. */ +export function showSidePanel(input: SidePanelInput): void { + if (input.isCompact) { + if (input.checkout) { + usePanelStore.getState().openCompactFileExplorer(input.checkout); + } + return; + } + if (!input.workspaceKey || !canUseSidePanelPane(input)) { + return; + } + useWorkspaceLayoutStore.getState().showSidePanel(input.workspaceKey); +} + +/** Put the side panel away. Its tabs stay open, waiting for the next reveal. */ +export function hideSidePanel(input: SidePanelInput): void { + if (input.isCompact) { + usePanelStore.getState().showMobileAgent(); + return; + } + if (!input.workspaceKey) { + return; + } + + const store = useWorkspaceLayoutStore.getState(); + if (!canUseSidePanelPane(input)) { + const activeTabId = findActiveSidePanelTabId(store, input.workspaceKey); + if (activeTabId) { + store.closeTab(input.workspaceKey, activeTabId); + } + return; + } + store.hideSidePanel(input.workspaceKey); +} + +/** Show the side panel if it is hidden, hide it if it is showing. */ +export function toggleSidePanel(input: SidePanelInput): void { + if (input.isCompact) { + if (input.checkout) { + usePanelStore.getState().toggleCompactFileExplorer(input.checkout); + } + return; + } + if (!input.workspaceKey) { + return; + } + + if (isSidePanelOpen(input)) { + hideSidePanel(input); + return; + } + + // Without splits there is no pane to reveal, so the toggle has to produce a tab. + if (!canUseSidePanelPane(input)) { + useWorkspaceLayoutStore.getState().openTab({ + workspaceKey: input.workspaceKey, + target: SIDE_PANEL_VIEW_TARGETS.changes, + intent: "reveal", + }); + return; + } + showSidePanel(input); +} + +/** Reactive "is the side panel showing" for the surface this layout uses. */ +export function useIsSidePanelOpen(input: SidePanelQuery): boolean { + const compactOpen = usePanelStore(selectIsCompactFileExplorerOpen); + const paneOpen = useWorkspaceLayoutStore((state) => + input.isCompact ? false : isSidePanelOpenForLayout(state, input), + ); + return input.isCompact ? compactOpen : paneOpen; +} + +/** Imperative counterpart of {@link useIsSidePanelOpen}. */ +export function isSidePanelOpen(input: SidePanelQuery): boolean { + if (input.isCompact) { + return selectIsCompactFileExplorerOpen(usePanelStore.getState()); + } + return isSidePanelOpenForLayout(useWorkspaceLayoutStore.getState(), input); +} + +function isSidePanelOpenForLayout(state: LayoutState, input: SidePanelQuery): boolean { + if (!input.workspaceKey) { + return false; + } + return canUseSidePanelPane(input) + ? selectIsSidePanelVisible(state, input.workspaceKey) + : findActiveSidePanelTabId(state, input.workspaceKey) !== null; +} diff --git a/packages/app/test-stubs/lucide-react-native.ts b/packages/app/test-stubs/lucide-react-native.ts index 716a0e2d6..200048ad5 100644 --- a/packages/app/test-stubs/lucide-react-native.ts +++ b/packages/app/test-stubs/lucide-react-native.ts @@ -43,6 +43,7 @@ export const File = StubIcon; export const FileText = StubIcon; export const Folder = StubIcon; export const FolderPlus = StubIcon; +export const FolderTree = StubIcon; export const Gift = StubIcon; export const GitBranch = StubIcon; export const GitCommitHorizontal = StubIcon;