From ab274d635e17c82efa3f74e842d59f211374b8c0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 16 Aug 2026 18:18:32 +0200 Subject: [PATCH] Make the workspace right pane a first-class tab host (#3287) * feat(app): add persistent workspace side panes Keep the common review and file-browsing workflow beside the active agent without tearing down pane contents. Workspace tabs now share one compact chip language, assistant file links route into the retained right pane, and diff/file trees share stable geometry. * fix(app): localize pull request empty state * test(app): update explorer pane coverage * fix(app): preserve explorer pane workflows * test(app): make explorer tab selection idempotent * fix(app): persist workspace review pins * fix(app): preserve sidebar scrim drag hook * fix(app): restore companion pane interactions * test(app): follow selected sidebar surface --- .../browser/add-changed-file-to-chat.spec.ts | 4 +- .../browser/agent-message-submission.spec.ts | 7 +- .../browser/appearance-theme-picker.spec.ts | 2 +- packages/app/e2e/browser/changes-pane.spec.ts | 168 +++--- .../app/e2e/browser/commit-diff-panel.spec.ts | 6 +- packages/app/e2e/browser/file-editing.spec.ts | 24 +- .../file-explorer-context-actions.spec.ts | 3 +- .../app/e2e/browser/hidden-split-pane.spec.ts | 60 ++ .../browser/same-directory-workspaces.spec.ts | 38 +- .../e2e/browser/sidebar-resize-handle.spec.ts | 25 +- .../app/e2e/browser/sidebar-workspace.spec.ts | 48 +- .../workspace-agent-title-handoff.spec.ts | 9 +- .../browser/workspace-multiplicity.spec.ts | 9 +- .../workspace-navigation-regression.spec.ts | 6 + .../e2e/support/helpers/branch-switcher.ts | 16 +- .../app/e2e/support/helpers/file-explorer.ts | 4 +- packages/app/e2e/support/helpers/launcher.ts | 18 +- packages/app/e2e/support/helpers/pr-pane.ts | 5 +- .../app/e2e/support/helpers/split-pane.ts | 84 +++ .../app/e2e/support/helpers/workspace-tabs.ts | 78 ++- packages/app/src/agent-stream/view.tsx | 2 +- .../app/src/assistant-file-links/link.tsx | 47 +- .../use-file-link.test.tsx | 3 +- .../src/assistant-file-links/use-file-link.ts | 9 +- .../app/src/components/explorer-sidebar.tsx | 115 +--- packages/app/src/components/message.tsx | 2 +- .../src/components/resize-handle-drag.test.ts | 31 + .../app/src/components/resize-handle-drag.ts | 35 ++ packages/app/src/components/resize-handle.tsx | 35 +- .../sidebar/sidebar-workspace-row-content.tsx | 80 +-- .../app/src/components/split-container.tsx | 182 ++++-- .../src/components/tree-rail-layout.test.ts | 21 + .../app/src/components/tree-rail-layout.ts | 18 + packages/app/src/components/tree-rail.tsx | 117 ++++ .../components/ui/trailing-action-scrim.tsx | 67 +++ .../src/composer/focused-chat-target.test.ts | 28 + .../app/src/composer/focused-chat-target.ts | 55 +- packages/app/src/file-pane/conflict-alert.tsx | 3 + packages/app/src/git/diff-pane.tsx | 147 ++++- packages/app/src/i18n/resources.test.ts | 9 + packages/app/src/i18n/resources/ar.ts | 15 + packages/app/src/i18n/resources/en.ts | 15 + packages/app/src/i18n/resources/es.ts | 15 + packages/app/src/i18n/resources/fr.ts | 16 + packages/app/src/i18n/resources/ja.ts | 16 + packages/app/src/i18n/resources/ko.ts | 15 + packages/app/src/i18n/resources/pt-BR.ts | 15 + packages/app/src/i18n/resources/ru.ts | 16 + packages/app/src/i18n/resources/zh-CN.ts | 15 + packages/app/src/panels/diff-panel.tsx | 216 +------ packages/app/src/panels/file-panel.tsx | 43 +- packages/app/src/panels/files-panel.tsx | 65 ++ .../src/panels/pull-request-availability.ts | 22 + .../app/src/panels/pull-request-panel.tsx | 61 ++ .../app/src/panels/pull-request/index.test.ts | 32 + .../app/src/panels/pull-request/index.tsx | 102 ++++ packages/app/src/panels/pull-request/state.ts | 28 + packages/app/src/panels/register-panels.ts | 4 + .../app/src/panels/use-add-file-to-chat.ts | 34 ++ .../workspace/workspace-desktop-tabs-row.tsx | 559 ++++++++++++------ .../screens/workspace/workspace-screen.tsx | 179 ++++-- .../workspace/workspace-tab-layout.test.ts | 20 +- .../screens/workspace/workspace-tab-menu.ts | 3 + packages/app/src/stores/panel-store/index.ts | 40 +- .../app/src/stores/panel-store/state.test.ts | 8 + packages/app/src/stores/panel-store/state.ts | 26 +- .../src/stores/workspace-layout-actions.ts | 68 ++- .../src/stores/workspace-layout-store.test.ts | 242 ++++++++ .../app/src/stores/workspace-layout-store.ts | 203 ++++++- .../app/src/utils/split-navigation.test.ts | 14 +- packages/app/src/utils/split-navigation.ts | 3 + packages/app/src/workspace-pins/launch.tsx | 54 +- .../src/workspace-pins/pinnable-menu-item.tsx | 55 +- packages/app/src/workspace-pins/run.test.ts | 5 +- packages/app/src/workspace-pins/run.ts | 15 + packages/app/src/workspace-pins/store.test.ts | 46 ++ packages/app/src/workspace-pins/store.ts | 3 + packages/app/src/workspace-pins/target.ts | 16 +- .../app/src/workspace-tabs/identity.test.ts | 13 + packages/app/src/workspace-tabs/identity.ts | 12 + packages/app/src/workspace-tabs/model.ts | 2 + 81 files changed, 2900 insertions(+), 1051 deletions(-) create mode 100644 packages/app/e2e/browser/hidden-split-pane.spec.ts create mode 100644 packages/app/e2e/support/helpers/split-pane.ts create mode 100644 packages/app/src/components/resize-handle-drag.test.ts create mode 100644 packages/app/src/components/resize-handle-drag.ts create mode 100644 packages/app/src/components/tree-rail-layout.test.ts create mode 100644 packages/app/src/components/tree-rail-layout.ts create mode 100644 packages/app/src/components/tree-rail.tsx create mode 100644 packages/app/src/components/ui/trailing-action-scrim.tsx create mode 100644 packages/app/src/panels/files-panel.tsx create mode 100644 packages/app/src/panels/pull-request-availability.ts create mode 100644 packages/app/src/panels/pull-request-panel.tsx create mode 100644 packages/app/src/panels/pull-request/index.test.ts create mode 100644 packages/app/src/panels/pull-request/index.tsx create mode 100644 packages/app/src/panels/pull-request/state.ts create mode 100644 packages/app/src/panels/use-add-file-to-chat.ts create mode 100644 packages/app/src/workspace-pins/store.test.ts diff --git a/packages/app/e2e/browser/add-changed-file-to-chat.spec.ts b/packages/app/e2e/browser/add-changed-file-to-chat.spec.ts index 9307aef33..c5d4d6cb1 100644 --- a/packages/app/e2e/browser/add-changed-file-to-chat.spec.ts +++ b/packages/app/e2e/browser/add-changed-file-to-chat.spec.ts @@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { test, expect, type Page } from "../support/fixtures"; import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent"; +import { openChangesPanel } from "../support/helpers/workspace-tabs"; function visibleComposer(page: Page) { return page.locator("textarea[data-composer-input]").filter({ visible: true }).first(); @@ -31,8 +32,7 @@ test("adds a changed file to the focused chat without replacing its composer dra await expect(agentComposer).toBeEditable({ timeout: 30_000 }); await agentComposer.fill("Preserve this thought"); - await page.getByRole("button", { name: "Open explorer" }).click(); - await page.getByTestId("explorer-tab-changes").click(); + await openChangesPanel(page); const changedFile = page.getByText("changed file.ts", { exact: true }).first(); await expect(changedFile).toBeVisible({ timeout: 30_000 }); await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index ebd33be83..16a112dc4 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -24,7 +24,10 @@ import { } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; +import { + createAgentTabFromMenu, + waitForWorkspaceTabsVisible, +} from "../support/helpers/workspace-tabs"; import { getServerId } from "../support/helpers/server-id"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention"; @@ -755,7 +758,7 @@ async function expectRenderedBefore(first: Locator, second: Locator): Promise { await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId)); await waitForWorkspaceTabsVisible(page); - await page.getByTestId("workspace-new-agent-tab-inline").click(); + await createAgentTabFromMenu(page); await expectComposerVisible(page); } diff --git a/packages/app/e2e/browser/appearance-theme-picker.spec.ts b/packages/app/e2e/browser/appearance-theme-picker.spec.ts index 0d654f319..b23dd2af5 100644 --- a/packages/app/e2e/browser/appearance-theme-picker.spec.ts +++ b/packages/app/e2e/browser/appearance-theme-picker.spec.ts @@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test await row.click(); await expect(row).toHaveAttribute("aria-selected", "true"); - await expect(row).toHaveCSS("background-color", "rgb(22, 22, 22)"); + await expect(row).toHaveCSS("background-color", "rgb(17, 17, 17)"); await page.screenshot({ path: testInfo.outputPath("pure-black-selected-workspace.png"), fullPage: true, diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 03ad5e802..3043437c7 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -8,7 +8,7 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { getServerId } from "../support/helpers/server-id"; import { connectSeedClient } from "../support/helpers/seed-client"; import { createTempGitRepo } from "../support/helpers/workspace"; -import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; +import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; interface DirtyWorkspace { id: string; @@ -29,6 +29,25 @@ interface CleanupTask { const cleanupTasks: CleanupTask[] = []; const APP_SETTINGS_KEY = "@paseo:app-settings"; +function changesTree(page: Page) { + return page.getByTestId("changes-tree-rail-tree"); +} + +function changesContent(page: Page) { + return page.getByTestId("changes-tree-rail-content"); +} + +async function readFileIfPresent(filePath: string): Promise { + try { + return await readFile(filePath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } +} + async function failNextDiscardRequest(page: Page): Promise { await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { const serverSocket = browserSocket.connectToServer(); @@ -266,7 +285,7 @@ test("changes file actions open below the right-click without a reserved kebab", await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible(); const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox(); expect(menuBounds).not.toBeNull(); - expect(menuBounds!.x).toBeCloseTo(fileRowBounds!.x + 80, 0); + expect(Math.abs(menuBounds!.x - (fileRowBounds!.x + 80))).toBeLessThanOrEqual(1); expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10); await page.getByTestId("diff-file-0-open-file").click(); @@ -282,14 +301,14 @@ test("changes context menus duplicate files and folders", async ({ page }) => { await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); await page.getByTestId("diff-file-0-duplicate").click(); await expect - .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"), "utf8")) + .poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"))) .toBe(AFTER); await page.getByTestId("changes-toggle-view-mode").click(); - await page.getByTestId("diff-folder-src-toggle").click({ button: "right" }); + await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" }); await page.getByTestId("diff-folder-src-duplicate").click(); await expect - .poll(() => readFile(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"), "utf8")) + .poll(() => readFileIfPresent(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"))) .toBe(AFTER); }); @@ -299,24 +318,25 @@ test("changes context menu recursively collapses descendant folders", async ({ p await openWorkspaceChanges(page, workspace); await page.getByTestId("changes-toggle-view-mode").click(); - await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); - await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); + const tree = changesTree(page); + await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); + await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); - await page.getByTestId("diff-folder-src-toggle").click({ button: "right" }); + await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" }); await page.getByTestId("diff-folder-src-collapse-folder").click(); - await expect(page.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0); + await expect(tree.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0); - await page.getByTestId("diff-folder-src-toggle").click(); - await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); - await expect(page.getByText("root.ts", { exact: true })).toHaveCount(0); + await tree.getByTestId("diff-folder-src-toggle").click(); + await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); + await expect(tree.getByText("root.ts", { exact: true })).toHaveCount(0); - await page.getByTestId("diff-folder-src/zz-folder-toggle").click(); - await expect(page.getByText("root.ts", { exact: true })).toBeVisible(); - await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); - await expect(page.getByText("changed.ts", { exact: true })).toHaveCount(0); + await tree.getByTestId("diff-folder-src/zz-folder-toggle").click(); + await expect(tree.getByText("root.ts", { exact: true })).toBeVisible(); + await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); + await expect(tree.getByText("changed.ts", { exact: true })).toHaveCount(0); - await page.getByTestId("diff-folder-src/zz-folder/nested-toggle").click(); - await expect(page.getByText("changed.ts", { exact: true })).toBeVisible(); + await tree.getByTestId("diff-folder-src/zz-folder/nested-toggle").click(); + await expect(tree.getByText("changed.ts", { exact: true })).toBeVisible(); }); test("changes context menus expose folder revert and restore a file after confirmation", async ({ @@ -327,7 +347,8 @@ test("changes context menus expose folder revert and restore a file after confir await openWorkspaceChanges(page, workspace); await page.getByTestId("changes-toggle-view-mode").click(); - await page.getByTestId("diff-folder-src-toggle").click({ button: "right" }); + const tree = changesTree(page); + await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" }); const folderRevert = page.getByTestId("diff-folder-src-revert"); await expect(folderRevert).toBeVisible(); const revertLabelColor = await folderRevert @@ -336,7 +357,7 @@ test("changes context menus expose folder revert and restore a file after confir await expect(folderRevert.locator("svg")).toHaveCSS("stroke", revertLabelColor); await page.keyboard.press("Escape"); - await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + await tree.getByTestId("diff-file-0-toggle").click({ button: "right" }); const cancelledConfirmation = new Promise((resolve) => { page.once("dialog", async (dialog) => { const message = dialog.message(); @@ -346,12 +367,12 @@ test("changes context menus expose folder revert and restore a file after confir }); await page.getByTestId("diff-file-0-revert").click(); expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts"); - await expect(page.getByTestId("diff-file-0")).toBeVisible(); + await expect(tree.getByTestId("diff-file-0")).toBeVisible(); await expect .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) .toBe(AFTER); - await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + await tree.getByTestId("diff-file-0-toggle").click({ button: "right" }); const confirmation = new Promise((resolve) => { page.once("dialog", async (dialog) => { const message = dialog.message(); @@ -362,7 +383,7 @@ test("changes context menus expose folder revert and restore a file after confir await page.getByTestId("diff-file-0-revert").click(); expect(await confirmation).toContain("src/use-mounted-tab-set.ts"); - await expect(page.getByTestId("diff-file-0")).toHaveCount(0, { timeout: 30_000 }); + await expect(tree.getByTestId("diff-file-0")).toHaveCount(0, { timeout: 30_000 }); await expect .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) .toBe(BEFORE); @@ -451,49 +472,39 @@ test("shows a revert error returned by the daemon", async ({ page }) => { .toBe(AFTER); }); -test("Changes switches between inline and full-tab navigation", async ({ page }) => { +test("Changes keeps review navigation and controls inside its workspace tab", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); - const changesTabToggle = page.getByTestId("changes-open-tab"); - await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab"); - await changesTabToggle.click(); - await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab"); - const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true }); await expect(visiblePanel).toBeVisible(); await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible(); await expect(visiblePanel).toContainText("zz-deleted.ts"); + await expect(visiblePanel.getByTestId("changes-open-tab")).toHaveCount(0); await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); - await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0); await visiblePanel.getByTestId("diff-file-0-toggle").click(); await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0); await visiblePanel.getByTestId("diff-file-0-toggle").click(); await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); - const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout"); + const workingDiffLayoutToggle = visiblePanel.getByRole("button", { + name: "Switch to side-by-side diff", + }); await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff"); await workingDiffLayoutToggle.click(); - await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff"); - await visiblePanel.getByTestId("working-diff-options-menu").click(); - await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace"); - await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines"); - await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh"); - await page.getByTestId("working-diff-toggle-wrap-lines").click(); - await visiblePanel.getByTestId("working-diff-options-menu").click(); - await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText( - "Scroll long lines", - ); + await expect(visiblePanel.getByRole("button", { name: "Switch to unified diff" })).toBeVisible(); + await visiblePanel.getByRole("button", { name: "Diff options" }).click(); + await expect(page.getByText("Hide whitespace", { exact: true })).toBeVisible(); + await expect(page.getByText("Wrap long lines", { exact: true })).toBeVisible(); + await expect(page.getByText(/^Refresh/)).toBeVisible(); + await page.getByText("Wrap long lines", { exact: true }).click(); + await visiblePanel.getByRole("button", { name: "Diff options" }).click(); + await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible(); await page.keyboard.press("Escape"); - await visiblePanel.getByTestId("working-diff-toggle-expand-all").click(); - await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0); - await visiblePanel.getByTestId("working-diff-toggle-expand-all").click(); + await page.getByRole("button", { name: "Expand all files" }).click(); await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); - - await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click(); - await expect( - page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), - ).toHaveCount(0); + await page.getByRole("button", { name: "Collapse all files" }).click(); + await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0); await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1); await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE); @@ -505,24 +516,8 @@ test("Changes switches between inline and full-tab navigation", async ({ page }) await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({ timeout: 30_000, }); - - await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText( - "zz-deleted.ts", - ); - await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click(); - await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1); await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible(); await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible(); - - await changesTabToggle.click(); - await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0); - await expect( - page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), - ).toBeVisible(); - await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click(); - await expect( - page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), - ).toHaveCount(0); }); test("changes diff switches between flat and tree file lists", async ({ page }) => { @@ -547,16 +542,18 @@ test("changes diff switches between flat and tree file lists", async ({ page }) await scrollToLowerUnwrappedDiffRows(page); await page.getByTestId("changes-toggle-view-mode").click(); - await expect(page.getByTestId("diff-folder-src")).toBeVisible(); - await expect(page.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS( + const tree = changesTree(page); + const content = changesContent(page); + await expect(tree.getByTestId("diff-folder-src")).toBeVisible(); + await expect(tree.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS( "user-select", "none", ); - await expect(page.getByTestId("diff-file-0")).toBeVisible(); - await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1); - await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1); - const folderToggleBounds = await page.getByTestId("diff-folder-src-toggle").boundingBox(); - const folderChevronBounds = await page + await expect(tree.getByTestId("diff-file-0")).toBeVisible(); + await expect(tree.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1); + await expect(tree.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1); + const folderToggleBounds = await tree.getByTestId("diff-folder-src-toggle").boundingBox(); + const folderChevronBounds = await tree .getByTestId("diff-folder-src-toggle") .locator("svg") .boundingBox(); @@ -566,11 +563,11 @@ test("changes diff switches between flat and tree file lists", async ({ page }) folderToggleBounds!.y + folderToggleBounds!.height / 2, 0, ); - const folderLabelBounds = await page + const folderLabelBounds = await tree .getByTestId("diff-folder-src") .getByText("src", { exact: true }) .boundingBox(); - const fileLabelBounds = await page + const fileLabelBounds = await tree .getByTestId("diff-file-0") .getByText("use-mounted-tab-set.ts", { exact: true }) .boundingBox(); @@ -578,28 +575,28 @@ test("changes diff switches between flat and tree file lists", async ({ page }) expect(fileLabelBounds).not.toBeNull(); expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(16, 0); - const folderToggle = page.getByTestId("diff-folder-src-toggle"); + const folderToggle = tree.getByTestId("diff-folder-src-toggle"); await folderToggle.click(); await expect(folderToggle).toHaveAttribute("aria-selected", "true"); - await expect(page.getByTestId("diff-file-0")).toHaveCount(0); + await expect(tree.getByTestId("diff-file-0")).toHaveCount(0); await folderToggle.click(); await expect(folderToggle).toHaveAttribute("aria-selected", "true"); - await expect(page.getByTestId("diff-file-0")).toBeVisible(); + await expect(tree.getByTestId("diff-file-0")).toBeVisible(); - const fileToggle = page.getByTestId("diff-file-0-toggle"); + const fileToggle = tree.getByTestId("diff-file-0-toggle"); await fileToggle.click({ button: "right" }); await expect(fileToggle).toHaveAttribute("aria-selected", "true"); await expect(folderToggle).toHaveAttribute("aria-selected", "false"); await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible(); await page.keyboard.press("Escape"); - await page.getByRole("button", { name: "Collapse all" }).click(); - await expect(page.getByTestId("diff-file-0")).toHaveCount(0); - await page.getByRole("button", { name: "Expand all" }).click(); - await expect(page.getByTestId("diff-file-0-body")).toBeVisible(); + await page.getByRole("button", { name: "Collapse all files" }).click(); + await expect(content.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0); + await page.getByRole("button", { name: "Expand all files" }).click(); + await expect(content.getByTestId("diff-file-0-body")).toBeVisible(); - await page.getByTestId("diff-folder-src-toggle").click(); - await expect(page.getByTestId("diff-file-0")).toHaveCount(0); + await tree.getByTestId("diff-folder-src-toggle").click(); + await expect(tree.getByTestId("diff-file-0")).toHaveCount(0); await page.getByTestId("changes-toggle-view-mode").click(); await expectFlatFileList(page); @@ -801,14 +798,13 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom await page.setViewportSize({ width: 1400, height: 900 }); await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); await waitForWorkspaceTabsVisible(page); - await page.getByRole("button", { name: "Open explorer" }).click(); await openChangesInVisibleExplorer(page); await page.getByTestId("diff-file-0").click(); await expectExpandedMountedTabDiff(page); } async function openChangesInVisibleExplorer(page: Page): Promise { - await expect(page.getByTestId("explorer-tab-changes")).toBeVisible({ timeout: 30_000 }); + await openChangesPanel(page); await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 }); } diff --git a/packages/app/e2e/browser/commit-diff-panel.spec.ts b/packages/app/e2e/browser/commit-diff-panel.spec.ts index 1f658fac0..fae202d36 100644 --- a/packages/app/e2e/browser/commit-diff-panel.spec.ts +++ b/packages/app/e2e/browser/commit-diff-panel.spec.ts @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import { test, expect } from "../support/fixtures"; +import { openChangesPanel } from "../support/helpers/workspace-tabs"; const COMMIT_SUBJECT = "Show commit timestamps"; @@ -13,7 +14,7 @@ test("commit history explains when the workspace has no commits ahead of its bas execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" }); await workspace.navigateTo(); - await page.getByRole("button", { name: "Open explorer" }).click(); + await openChangesPanel(page); const commitsSection = page.getByRole("button", { name: /Commits/i }); await expect(commitsSection).toBeVisible({ timeout: 30_000 }); await commitsSection.click(); @@ -33,7 +34,7 @@ test("commit history shows dates and shares diff layout preferences", async ({ await page.setViewportSize({ width: 1400, height: 900 }); await workspace.navigateTo(); - await page.getByRole("button", { name: "Open explorer" }).click(); + await openChangesPanel(page); const commitsSection = page.getByRole("button", { name: /Commits/i }); await expect(commitsSection).toBeVisible({ timeout: 30_000 }); await commitsSection.click(); @@ -57,6 +58,7 @@ test("commit history shows dates and shares diff layout preferences", async ({ await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0); await expect(panel.getByTestId("diff-file-0-body")).toBeVisible(); + await page.getByTestId(/^workspace-tab-commit_diff_/).hover(); await page.getByTestId(/^workspace-commit-diff-close-/).click(); await expect(panel).toHaveCount(0); await commitRow.click(); diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index 6e46b4da6..20026ceaa 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -38,10 +38,7 @@ function fitsViewportWidth(element: HTMLElement): boolean { } async function replaceEditorText(page: Page, content: string): Promise { - const contentElement = editor(page); - await contentElement.click(); - await contentElement.press("Control+A"); - await contentElement.type(content); + await editor(page).fill(content); } async function openWorkspaceFile(page: Page, filename: string): Promise { @@ -154,9 +151,8 @@ test.describe("CodeMirror workspace file editing", () => { await page.setViewportSize({ width: 1280, height: 900 }); await openAgentRoute(page, session); - await page.getByRole("button", { name: "Split pane right" }).first().click(); - await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2); await openWorkspaceFile(page, "target.ts"); + await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2); await page .getByTestId(`workspace-tab-agent_${session.agentId}`) @@ -347,14 +343,13 @@ test.describe("CodeMirror workspace file editing", () => { await replaceEditorText(page, "const localWins = 5;\n"); await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8"); await expect(page.getByTestId("file-conflict-alert")).toBeVisible(); + await page.getByRole("button", { name: "Overwrite", exact: true }).click(); + await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n"); for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) { await openWorkspaceFile(page, fileName); } - await page.getByTestId("workspace-tab-file_source.ts").filter({ visible: true }).click(); + await openWorkspaceFile(page, "source.ts"); await expect(editor(page)).toContainText("const localWins = 5;"); - await expect(page.getByTestId("file-conflict-alert")).toBeVisible(); - await page.getByRole("button", { name: "Overwrite", exact: true }).click(); - await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n"); await replaceEditorText(page, "const discarded = 7;\n"); await writeFile(sourcePath, "const diskWins = 8;\n", "utf8"); @@ -441,20 +436,21 @@ test.describe("CodeMirror workspace file editing", () => { await workspace.navigateTo(); await openWorkspaceFile(page, "notes.md"); - await expect(page.getByText("First heading", { exact: true })).toBeVisible(); + const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true }); + await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible(); await writeFile(markdownPath, "# Updated heading\n", "utf8"); - await expect(page.getByText("Updated heading", { exact: true })).toBeVisible(); + await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible(); await selectFileView(page, "Source"); await expect(page.getByTestId("file-source-editor")).toBeVisible(); await replaceEditorText(page, "# Saved from source\n"); await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n"); await selectFileView(page, "Preview"); - await expect(page.getByText("Saved from source", { exact: true })).toBeVisible(); + await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible(); await openWorkspaceFile(page, "pixel.png"); - const image = page.getByTestId("workspace-file-pane").locator("img"); + const image = visibleFilePane.locator("img"); await expect(image).toBeVisible(); const initialSource = await image.getAttribute("src"); await writeFile(imagePath, BLUE_PIXEL); diff --git a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts index d8622a616..bbc73b797 100644 --- a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts +++ b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { expect, type Page } from "@playwright/test"; import { test } from "../support/fixtures"; import { openFileExplorer } from "../support/helpers/file-explorer"; +import { openChangesPanel } from "../support/helpers/workspace-tabs"; import { gotoWorkspace } from "../support/helpers/launcher"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; @@ -437,7 +438,7 @@ test("hides unsupported file operations and revert actions", async ({ page }) => await expect(filesMenu.getByText("Delete", { exact: true })).toHaveCount(0); await page.keyboard.press("Escape"); - await page.getByTestId("explorer-tab-changes").click(); + await openChangesPanel(page); await expect(page.getByTestId("diff-file-0")).toBeVisible({ timeout: 30_000 }); await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0); diff --git a/packages/app/e2e/browser/hidden-split-pane.spec.ts b/packages/app/e2e/browser/hidden-split-pane.spec.ts new file mode 100644 index 000000000..50eb2c2fa --- /dev/null +++ b/packages/app/e2e/browser/hidden-split-pane.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from "../support/fixtures"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { + expectExplorerNodeRetained, + hideExplorerSplit, + measureExplorerSplit, + openExplorerSplit, + resizeExplorerSplit, + stampExplorerNode, +} from "../support/helpers/split-pane"; +import { + createAgentTabFromMenu, + waitForWorkspaceTabsVisible, +} from "../support/helpers/workspace-tabs"; + +test.describe("Hidden split pane retention", () => { + test("releases all layout space without unmounting and restores its dragged width", async ({ + page, + }, testInfo) => { + const workspace = await seedWorkspace({ repoPrefix: "hidden-split-pane-" }); + + try { + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + await createAgentTabFromMenu(page); + await openExplorerSplit(page); + + // Drag to a non-default width first: restoring the default would prove nothing. + await resizeExplorerSplit(page, 137); + await stampExplorerNode(page); + const open = await measureExplorerSplit(page); + expect(open.explorerWidth).toBeGreaterThan(250); + expect(open.separatorCount).toBe(1); + + await hideExplorerSplit(page); + const hidden = await measureExplorerSplit(page); + expect(hidden.explorerWidth).toBe(0); + // The sibling must absorb the whole row, not just collapse the hidden pane. + expect(hidden.visibleWidth).toBeCloseTo(hidden.containerWidth, 5); + expect(hidden.occupiedWidth).toBeCloseTo(hidden.containerWidth, 5); + expect(hidden.separatorCount).toBe(0); + // Same DOM node, so a teardown-and-rebuild cannot pass as retention. + await expectExplorerNodeRetained(page); + + await openExplorerSplit(page); + await expectExplorerNodeRetained(page); + const reopened = await measureExplorerSplit(page); + expect(reopened.explorerWidth).toBeCloseTo(open.explorerWidth, 5); + expect(reopened.separatorCount).toBe(1); + + await testInfo.attach("split-pane-geometry", { + body: JSON.stringify({ open, hidden, reopened }, null, 2), + contentType: "application/json", + }); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/same-directory-workspaces.spec.ts b/packages/app/e2e/browser/same-directory-workspaces.spec.ts index 8546ffb4e..f50b113bb 100644 --- a/packages/app/e2e/browser/same-directory-workspaces.spec.ts +++ b/packages/app/e2e/browser/same-directory-workspaces.spec.ts @@ -1,28 +1,22 @@ import { writeFile } from "node:fs/promises"; import path from "node:path"; -import { test, expect, type Page } from "../support/fixtures"; +import { test, expect } from "../support/fixtures"; import { gotoWorkspace, clickNewTerminal } from "../support/helpers/launcher"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; -import { expectExplorerEntryVisible } from "../support/helpers/file-explorer"; -import { expectNoTerminalTabs, clickFirstTerminalTab } from "../support/helpers/workspace-tabs"; +import { expectExplorerEntryVisible, openFileExplorer } from "../support/helpers/file-explorer"; +import { + expectNoTerminalTabs, + clickFirstTerminalTab, + openChangesPanel, +} from "../support/helpers/workspace-tabs"; // Model B: two workspaces can back the SAME directory. What follows from that // split is the contract these specs pin: -// - The right sidebar (file browser / git changes) reads the directory, so it -// is IDENTICAL across same-directory workspaces. +// - Files and Changes read the directory, so their content is IDENTICAL +// across same-directory workspaces. // - Tabs (agents, terminals) are owned by the workspace, so they are // INDEPENDENT across same-directory workspaces. -// On desktop the explorer is pinned open; on narrow layouts it must be toggled. -// Open it either way, then select the requested tab. -async function openExplorerTab(page: Page, tab: "files" | "changes"): Promise { - const openButton = page.getByRole("button", { name: "Open explorer" }).first(); - if (await openButton.isVisible().catch(() => false)) { - await openButton.click(); - } - await page.getByTestId(`explorer-tab-${tab}`).click(); -} - async function createSecondWorkspaceOnSameDir( seeded: SeededWorkspace, title: string, @@ -41,7 +35,7 @@ async function createSecondWorkspaceOnSameDir( test.describe("Same-directory workspaces", () => { test.describe.configure({ timeout: 180_000 }); - test("the right sidebar is shared: a directory change shows in both same-dir workspaces", async ({ + test("directory-backed Files and Changes content is shared across same-dir workspaces", async ({ page, }) => { const seeded = await seedWorkspace({ repoPrefix: "same-dir-shared-" }); @@ -71,19 +65,19 @@ test.describe("Same-directory workspaces", () => { // Workspace A: the new file shows in both the file browser and the git // changes view. await gotoWorkspace(page, seeded.workspaceId); - await openExplorerTab(page, "files"); + await openFileExplorer(page); await expectExplorerEntryVisible(page, "SHARED_CHANGE.md"); - await openExplorerTab(page, "changes"); + await openChangesPanel(page); await expect( page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(), ).toBeVisible({ timeout: 30_000 }); - // Workspace B (same directory): the SAME change is visible. The right - // sidebar content does not differ between the two views. + // Workspace B (same directory): the SAME directory-backed content is + // visible even though the panel tabs belong to a different workspace. await gotoWorkspace(page, secondWorkspaceId); - await openExplorerTab(page, "files"); + await openFileExplorer(page); await expectExplorerEntryVisible(page, "SHARED_CHANGE.md"); - await openExplorerTab(page, "changes"); + await openChangesPanel(page); await expect( page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(), ).toBeVisible({ timeout: 30_000 }); diff --git a/packages/app/e2e/browser/sidebar-resize-handle.spec.ts b/packages/app/e2e/browser/sidebar-resize-handle.spec.ts index 5859ca0e1..dd59ab02b 100644 --- a/packages/app/e2e/browser/sidebar-resize-handle.spec.ts +++ b/packages/app/e2e/browser/sidebar-resize-handle.spec.ts @@ -1,26 +1,39 @@ +import type { Locator } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; test.use({ viewport: { width: 1600, height: 900 } }); -async function expectBorderHighlight(page: Page, testID: string) { +async function expectBorderHighlight( + page: Page, + testID: string, + hoverTarget: Locator, + expectedWidth: string, +) { const handle = page.getByTestId(testID); await expect(handle).toBeVisible(); await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0); - await handle.hover(); + await hoverTarget.hover(); const highlight = page.getByTestId(`${testID}-highlight`); await expect(highlight).toBeVisible(); - await expect(highlight).toHaveCSS("width", "1px"); + await expect(highlight).toHaveCSS("width", expectedWidth); await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); } -test("both sidebar borders highlight on hover", async ({ page, withWorkspace }) => { +test("sidebar and workspace pane borders highlight on hover", async ({ page, withWorkspace }) => { const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" }); await workspace.navigateTo(); - await expectBorderHighlight(page, "left-sidebar-resize-handle"); + const sidebarHandle = page.getByTestId("left-sidebar-resize-handle"); + await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px"); await page.getByTestId("workspace-explorer-toggle").first().click(); - await expectBorderHighlight(page, "explorer-sidebar-resize-handle"); + const splitHandle = page.getByTestId("workspace-split-resize-handle"); + await expectBorderHighlight( + page, + "workspace-split-resize-handle", + splitHandle.getByRole("separator"), + "3px", + ); }); diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 28d8236ad..0568a3abd 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -13,6 +13,7 @@ import { expectWorkspaceHeader } from "../support/helpers/workspace-ui"; import { getServerId } from "../support/helpers/server-id"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { escapeRegex } from "../support/helpers/regex"; +import { openFilesPanel } from "../support/helpers/workspace-tabs"; const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git"; @@ -331,7 +332,7 @@ test.describe("Half-screen desktop layout", () => { await expect(page.getByTestId("sidebar-settings")).not.toBeVisible(); }); - test("yields app navigation to the Explorer", async ({ page }) => { + test("keeps app navigation beside the Explorer pane", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" }); try { @@ -339,44 +340,19 @@ test.describe("Half-screen desktop layout", () => { await waitForSidebarProject(page, path.basename(workspace.repoPath)); await openWorkspaceFromSidebar(page, workspace.workspaceId); - await page.getByTestId("workspace-explorer-toggle").first().click(); - await expect( - page.getByTestId("explorer-tab-files").filter({ visible: true }).first(), - ).toBeVisible(); - await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible(); - await expect(page.getByTestId("explorer-close")).toBeVisible(); - await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); + 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(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); + await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2); - const centerBounds = await page.getByTestId("workspace-tabs-row").first().boundingBox(); - const headerGlyphBounds = await page - .getByTestId("menu-button") - .locator("svg") - .first() - .boundingBox(); - const tabGlyphBounds = await page - .locator('[data-testid^="workspace-tab-"]') - .first() - .locator("svg") - .first() - .boundingBox(); - expect(centerBounds).not.toBeNull(); - expect(headerGlyphBounds).not.toBeNull(); - expect(tabGlyphBounds).not.toBeNull(); - expect((headerGlyphBounds?.x ?? 0) - (centerBounds?.x ?? 0)).toBeCloseTo( - (tabGlyphBounds?.x ?? 0) - (centerBounds?.x ?? 0), + await explorerToggle.click(); + await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount( 0, ); - - await expect - .poll( - async () => - (await page.getByTestId("workspace-tabs-row").first().boundingBox())?.width ?? 0, - ) - .toBeGreaterThanOrEqual(400); - - await page.getByTestId("explorer-close").click(); - await expect(page.getByTestId("explorer-tab-files")).not.toBeVisible(); - await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible(); + await expect(explorerToggle).toHaveAccessibleName("Open explorer"); + await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); } finally { await workspace.cleanup(); } diff --git a/packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts b/packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts index b7375104c..5984b2be3 100644 --- a/packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts +++ b/packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts @@ -3,7 +3,10 @@ import { expectComposerVisible, submitMessage } from "../support/helpers/compose import { delayCreatedAgentInitialTailResponse } from "../support/helpers/agent-timeline-gate"; import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace"; import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-client"; -import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; +import { + createAgentTabFromMenu, + waitForWorkspaceTabsVisible, +} from "../support/helpers/workspace-tabs"; import { getServerId } from "../support/helpers/server-id"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; @@ -56,7 +59,7 @@ test.describe("Workspace agent title handoff", () => { try { await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId)); await waitForWorkspaceTabsVisible(page); - await page.getByTestId("workspace-new-agent-tab-inline").click(); + await createAgentTabFromMenu(page); await expectComposerVisible(page); const prompt = "Keep the optimistic agent pane visible during handoff"; @@ -99,7 +102,7 @@ test.describe("Workspace agent title handoff", () => { try { await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId)); await waitForWorkspaceTabsVisible(page); - await page.getByTestId("workspace-new-agent-tab-inline").click(); + await createAgentTabFromMenu(page); await expectComposerVisible(page); const promptTitle = "Investigate optimistic tab title handoff"; diff --git a/packages/app/e2e/browser/workspace-multiplicity.spec.ts b/packages/app/e2e/browser/workspace-multiplicity.spec.ts index 74d974318..ff13e2217 100644 --- a/packages/app/e2e/browser/workspace-multiplicity.spec.ts +++ b/packages/app/e2e/browser/workspace-multiplicity.spec.ts @@ -12,6 +12,7 @@ import { import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { expectExplorerEntryVisible } from "../support/helpers/file-explorer"; import { getServerId } from "../support/helpers/server-id"; +import { openFilesPanel } from "../support/helpers/workspace-tabs"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; @@ -24,14 +25,8 @@ function workspaceRowTestId(workspaceId: string): string { return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; } -// On desktop the file explorer is pinned open; on narrower layouts it must be -// toggled first. Open it either way, then select the Files tab. async function openFilesTab(page: Page): Promise { - const openButton = page.getByRole("button", { name: "Open explorer" }).first(); - if (await openButton.isVisible().catch(() => false)) { - await openButton.click(); - } - await page.getByTestId("explorer-tab-files").click(); + await openFilesPanel(page); await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 }); } diff --git a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts index 09c81ef3a..d60d2081a 100644 --- a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts @@ -79,6 +79,12 @@ async function getVisibleDraftTabCount(page: Page): Promise { } async function closeFirstVisibleDraftTab(page: Page): Promise { + const tab = page + .locator('[data-testid^="workspace-tab-draft"]') + .filter({ visible: true }) + .first(); + await expect(tab).toBeVisible({ timeout: 30_000 }); + await tab.hover(); const closeButton = page.locator('[data-testid^="workspace-draft-close-"]').filter({ visible: true, }); diff --git a/packages/app/e2e/support/helpers/branch-switcher.ts b/packages/app/e2e/support/helpers/branch-switcher.ts index 25588280c..c9fbe3530 100644 --- a/packages/app/e2e/support/helpers/branch-switcher.ts +++ b/packages/app/e2e/support/helpers/branch-switcher.ts @@ -1,8 +1,8 @@ import { expect, type Page } from "@playwright/test"; import { escapeRegex } from "./regex"; +import { openChangesPanel as openWorkspaceChangesPanel } from "./workspace-tabs"; -// The branch switcher lives in the git diff panel's Changes header (right-side -// ExplorerSidebar), not in the workspace header. It renders as a button whose +// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose // accessible name carries the current branch ("Current branch: . Press to // switch branch."). Matching on the accessible name keeps these helpers tied to // what a screen reader user hears, and it proves the panel resolved a real @@ -16,17 +16,9 @@ function branchSwitcherTrigger(page: Page, branchName: string) { .first(); } -// Opens the right-side explorer and lands on the Changes tab, where the branch -// switcher and diff live. Git checkouts default to the Changes tab when the -// explorer opens, so this is enough to reveal the switcher on desktop and mobile. +// Opens the fixed pane and lands on the Changes tab, where the switcher and diff live. export async function openChangesPanel(page: Page): Promise { - await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible({ - timeout: 30_000, - }); - await page.getByTestId("workspace-explorer-toggle").first().click(); - const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true }).first(); - await expect(changesTab).toBeVisible({ timeout: 30_000 }); - await changesTab.click(); + await openWorkspaceChangesPanel(page); await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({ timeout: 30_000, }); diff --git a/packages/app/e2e/support/helpers/file-explorer.ts b/packages/app/e2e/support/helpers/file-explorer.ts index 0d63f34ba..519f95553 100644 --- a/packages/app/e2e/support/helpers/file-explorer.ts +++ b/packages/app/e2e/support/helpers/file-explorer.ts @@ -1,4 +1,5 @@ import { expect, type Page } from "@playwright/test"; +import { openFilesPanel } from "./workspace-tabs"; function fileExplorerTree(page: Page) { return page.getByTestId("file-explorer-tree-scroll"); @@ -9,8 +10,7 @@ function fileExplorerEntry(page: Page, name: string) { } export async function openFileExplorer(page: Page): Promise { - await page.getByRole("button", { name: "Open explorer" }).first().click(); - await page.getByTestId("explorer-tab-files").click(); + await openFilesPanel(page); await expect(fileExplorerTree(page)).toBeVisible({ timeout: 30_000 }); } diff --git a/packages/app/e2e/support/helpers/launcher.ts b/packages/app/e2e/support/helpers/launcher.ts index 0c7debd19..2bcc1336d 100644 --- a/packages/app/e2e/support/helpers/launcher.ts +++ b/packages/app/e2e/support/helpers/launcher.ts @@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes"; import { createTempGitRepo } from "./workspace"; import { getServerId } from "./server-id"; +import { createAgentTabFromMenu } from "./workspace-tabs"; // ─── Navigation ──────────────────────────────────────────────────────────── @@ -69,10 +70,10 @@ export async function pressNewTabShortcut(page: Page): Promise { // ─── Tab bar assertions ─────────────────────────────────────────────────── -/** Assert the inline new-agent plus button is visible in the tab bar. */ +/** Assert the inline plus button is visible in the tab bar. */ export async function assertNewChatTileVisible(page: Page): Promise { await expect( - page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true }).first(), + page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(), ).toBeVisible(); } @@ -85,14 +86,9 @@ export async function assertNewTabMenuTriggerVisible(page: Page): Promise // ─── Tab creation actions ───────────────────────────────────────────────── -/** Click the inline plus button to create a draft/chat tab. */ +/** Open the new-tab menu and click "New agent" to create a draft/chat tab. */ export async function clickNewChat(page: Page): Promise { - const button = page - .getByTestId("workspace-new-agent-tab-inline") - .filter({ visible: true }) - .first(); - await expect(button).toBeVisible({ timeout: 10_000 }); - await button.click(); + await createAgentTabFromMenu(page); } /** Open the new-tab menu and click "New terminal". */ @@ -129,9 +125,9 @@ export async function waitForTabWithTitle( ).toBeVisible({ timeout }); } -/** Assert the inline new-agent plus button is visible in the tab bar. */ +/** Assert the inline plus button is visible in the tab bar. */ export async function assertSingleNewTabButton(page: Page): Promise { - const buttons = page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true }); + const buttons = page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }); const count = await buttons.count(); expect(count).toBeGreaterThanOrEqual(1); } diff --git a/packages/app/e2e/support/helpers/pr-pane.ts b/packages/app/e2e/support/helpers/pr-pane.ts index 570480165..bf13a7702 100644 --- a/packages/app/e2e/support/helpers/pr-pane.ts +++ b/packages/app/e2e/support/helpers/pr-pane.ts @@ -1,10 +1,9 @@ import { expect, type Page } from "@playwright/test"; import { getStateLabel } from "@/git/pull-request-panel/data"; +import { openPullRequestPanel } from "./workspace-tabs"; export async function openPrPane(page: Page): Promise { - await page.getByRole("button", { name: "Open explorer" }).click(); - await page.getByTestId("explorer-tab-pr").click(); - await expect(page.getByTestId("pr-pane")).toBeVisible({ timeout: 15_000 }); + await openPullRequestPanel(page); } export async function expectPrPaneTitle(page: Page, title: string): Promise { diff --git a/packages/app/e2e/support/helpers/split-pane.ts b/packages/app/e2e/support/helpers/split-pane.ts new file mode 100644 index 000000000..ba95ec295 --- /dev/null +++ b/packages/app/e2e/support/helpers/split-pane.ts @@ -0,0 +1,84 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +export interface SplitPaneGeometry { + containerWidth: number; + explorerWidth: number; + visibleWidth: number; + separatorCount: number; + occupiedWidth: number; +} + +function splitSeparator(page: Page): Locator { + return page.getByRole("separator", { name: "" }); +} + +export async function openExplorerSplit(page: Page): Promise { + await page.getByRole("button", { name: "Open explorer" }).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 expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 }); +} + +export async function resizeExplorerSplit(page: Page, deltaX: number): Promise { + const separator = splitSeparator(page); + await expect(separator).toHaveCount(1); + const box = await separator.boundingBox(); + expect(box).not.toBeNull(); + if (!box) throw new Error("Split separator has no bounding box"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + deltaX, box.y + box.height / 2, { steps: 10 }); + await page.mouse.up(); +} + +export async function stampExplorerNode(page: Page): Promise { + await page + .getByTestId("split-group-child") + .last() + .evaluate((node) => { + Reflect.set(window, "__splitPaneExplorerNode", node); + node.setAttribute("data-split-pane-identity", "retained"); + }); +} + +export async function expectExplorerNodeRetained(page: Page): Promise { + const stampedNode = page.locator('[data-split-pane-identity="retained"]'); + await expect(stampedNode).toHaveCount(1); + expect( + await stampedNode.evaluate((node) => Reflect.get(window, "__splitPaneExplorerNode") === node), + ).toBe(true); +} + +export async function measureExplorerSplit(page: Page): Promise { + const explorerChild = page.locator('[data-split-pane-identity="retained"]'); + await expect(explorerChild).toHaveCount(1); + + return explorerChild.evaluate((explorerNode) => { + const visibleNode = Array.from(explorerNode.parentElement?.children ?? []) + .filter( + (candidate) => + candidate !== explorerNode && + (candidate as HTMLElement).dataset.testid === "split-group-child", + ) + .sort( + (left, right) => right.getBoundingClientRect().width - left.getBoundingClientRect().width, + )[0]; + if (!(visibleNode instanceof HTMLElement)) throw new Error("Visible split child not found"); + const visible = visibleNode.getBoundingClientRect(); + const explorer = explorerNode.getBoundingClientRect(); + const container = explorerNode.parentElement?.getBoundingClientRect(); + if (!container) throw new Error("Split child has no container"); + const separatorCount = + explorerNode.parentElement?.querySelectorAll('[role="separator"]').length ?? 0; + return { + containerWidth: container.width, + explorerWidth: explorer.width, + visibleWidth: visible.width, + separatorCount, + occupiedWidth: explorer.width + visible.width, + }; + }); +} diff --git a/packages/app/e2e/support/helpers/workspace-tabs.ts b/packages/app/e2e/support/helpers/workspace-tabs.ts index 3f0a853f9..c0d2082a5 100644 --- a/packages/app/e2e/support/helpers/workspace-tabs.ts +++ b/packages/app/e2e/support/helpers/workspace-tabs.ts @@ -1,4 +1,4 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; export async function getWorkspaceTabTestIds(page: Page): Promise { const tabs = page.locator('[data-testid^="workspace-tab-"]'); @@ -17,15 +17,89 @@ 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(); +} + +async function selectWorkspaceTab(tab: Locator): Promise { + if ((await tab.getAttribute("aria-selected")) !== "true") { + // The close action overlays the chip's trailing edge on hover. Click the + // leading icon area so Playwright does not target that separate control. + await tab.click({ position: { x: 12, y: 13 } }); + } + await expect(tab).toHaveAttribute("aria-selected", "true"); +} + +export async function ensureExplorerPane(page: Page) { + const toggle = page.getByTestId("workspace-explorer-toggle").first(); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + const tabRow = explorerPaneTabRow(page); + if ((await tabRow.count()) === 0) { + await toggle.click(); + } + await expect(tabRow).toBeVisible({ timeout: 30_000 }); + return tabRow; +} + +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, + }); +} + +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, + }); +} + +export async function openPullRequestPanel(page: Page): Promise { + const existingTab = visibleTestId(page, "workspace-tab-pull_request").first(); + if ((await existingTab.count()) > 0) { + await selectWorkspaceTab(existingTab); + 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 }); +} + export async function waitForWorkspaceTabsVisible(page: Page): Promise { await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({ timeout: 30_000, }); - await expect(visibleTestId(page, "workspace-new-agent-tab-inline").first()).toBeVisible({ + await expect(visibleTestId(page, "workspace-new-tab-menu-trigger").first()).toBeVisible({ timeout: 30_000, }); } +/** Open the `+` menu in the tab row and pick "New agent". */ +export async function createAgentTabFromMenu(page: Page): Promise { + const trigger = visibleTestId(page, "workspace-new-tab-menu-trigger").first(); + await expect(trigger).toBeVisible({ timeout: 10_000 }); + await trigger.click(); + const item = visibleTestId(page, "workspace-new-tab-menu-agent").first(); + await expect(item).toBeVisible({ timeout: 10_000 }); + await item.click(); +} + export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise { const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true }); const count = await tabs.count(); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 58174eae1..8bc999cb0 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -445,7 +445,7 @@ const AgentStreamViewComponent = forwardRef { - handleInlinePathPress({ raw: filePath, path: filePath }, "main"); + handleInlinePathPress({ raw: filePath, path: filePath }, "side"); }); const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent( diff --git a/packages/app/src/assistant-file-links/link.tsx b/packages/app/src/assistant-file-links/link.tsx index 9b2838c3d..4ea6bcc61 100644 --- a/packages/app/src/assistant-file-links/link.tsx +++ b/packages/app/src/assistant-file-links/link.tsx @@ -5,9 +5,7 @@ import { isNative, isWeb } from "@/constants/platform"; import { MarkdownTextSpan } from "@/components/markdown-text"; import { MarkdownLinkText } from "@/components/markdown/link-text"; import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context"; -import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useStableEvent } from "@/hooks/use-stable-event"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { markdownCopyDataSet } from "@/assistant-selection-copy/markup"; import { useAssistantFileLinkResolverContext } from "./provider"; @@ -32,21 +30,13 @@ export function AssistantMarkdownLink({ monoSurface, children, }: AssistantMarkdownLinkProps) { - const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source); + const { target, onHoverIn, onPress } = useFileLink(source); const { configRef } = useAssistantFileLinkResolverContext(); const workspaceRoot = configRef.current.workspaceRoot; const tooltipPath = useMemo( () => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null), [target, workspaceRoot], ); - const handleAnchorClickCapture = useStableEvent((event: MouseEvent) => { - event.preventDefault(); - if (!isModifiedOpenEvent(event)) { - return; - } - event.stopPropagation(); - onAuxPress(); - }); const linkPress = useMemo( () => ({ onPress, accessibilityRole: "link" }), [onPress], @@ -93,7 +83,7 @@ export function AssistantMarkdownLink({ {...(unwrapForMarkdownCopy ? { "data-paseo-markdown-unwrap": "true" } : {})} href={source.href} title={source.title} - onClickCapture={handleAnchorClickCapture} + onClickCapture={preventAnchorNavigation} onAuxClickCapture={preventAnchorNavigation} style={LINK_ANCHOR_STYLE} > @@ -209,8 +199,6 @@ const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = { display: "inline-flex" as ViewStyle["display"], }; -const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"]; - function FileLinkHoverTooltip({ filePath, children, @@ -228,17 +216,9 @@ function FileLinkHoverTooltip({ {filePath ? ( - - - {filePath} - - - - - click for side pane - - - + + {filePath} + ) : null} @@ -255,27 +235,10 @@ function preventAnchorNavigation(event: MouseEvent): void { event.preventDefault(); } -function isModifiedOpenEvent(event: MouseEvent): boolean { - return event.metaKey || event.ctrlKey; -} - const styles = StyleSheet.create((theme) => ({ - tooltipBody: { - gap: theme.spacing[1], - }, tooltipPath: { color: theme.colors.foreground, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, }, - tooltipHintRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - }, - tooltipHintText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - }, })); diff --git a/packages/app/src/assistant-file-links/use-file-link.test.tsx b/packages/app/src/assistant-file-links/use-file-link.test.tsx index d81d0aa3f..ca934ef6f 100644 --- a/packages/app/src/assistant-file-links/use-file-link.test.tsx +++ b/packages/app/src/assistant-file-links/use-file-link.test.tsx @@ -136,7 +136,6 @@ describe("useFileLink", () => { expect(result.current).toBe(first); expect(result.current.onHoverIn).toBe(first.onHoverIn); expect(result.current.onPress).toBe(first.onPress); - expect(result.current.onAuxPress).toBe(first.onAuxPress); expect(result.current.open).toBe(first.open); }); @@ -177,7 +176,7 @@ describe("useFileLink", () => { lineStart: undefined, lineEnd: undefined, }, - disposition: "main", + disposition: "side", }, ]); }); diff --git a/packages/app/src/assistant-file-links/use-file-link.ts b/packages/app/src/assistant-file-links/use-file-link.ts index 0ce4515b0..7bb4843ba 100644 --- a/packages/app/src/assistant-file-links/use-file-link.ts +++ b/packages/app/src/assistant-file-links/use-file-link.ts @@ -20,7 +20,6 @@ export interface UseFileLinkResult { target: InlinePathTarget | null; onHoverIn: () => void; onPress: () => void; - onAuxPress: () => void; open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; } @@ -118,9 +117,6 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult }); const onPress = useStableEvent(() => { - open(stableSource, "main"); - }); - const onAuxPress = useStableEvent(() => { open(stableSource, "side"); }); @@ -131,10 +127,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult return query.data ?? null; }, [query.data, resolution]); - return useMemo( - () => ({ target, onHoverIn, onPress, onAuxPress, open }), - [target, onHoverIn, onPress, onAuxPress, open], - ); + return useMemo(() => ({ target, onHoverIn, onPress, open }), [target, onHoverIn, onPress, open]); } export function useAssistantFileLinkActions(): AssistantFileLinkActions { diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 9788776a2..a30c9aa87 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -13,18 +13,8 @@ import { Gesture } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; -import { - formatPrTabLabel, - PullRequestPane, - PullRequestPaneError, - PullRequestPaneSkeleton, - PullRequestTabIcon, - usePrPaneData, -} from "@/git/pull-request-panel"; -import { useCheckoutGitActionsStore } from "@/git/actions-store"; -import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data"; +import { formatPrTabLabel, PullRequestTabIcon } from "@/git/pull-request-panel"; import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store"; -import { useToast } from "@/contexts/toast-context"; import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { HEADER_INNER_HEIGHT } from "@/constants/layout"; @@ -35,17 +25,14 @@ import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/util import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { RetainedPanelActivity } from "@/components/retained-panel"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; -import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout"; import { SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET, } from "@/components/sidebar-resize-handle-layout"; -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; -import { resolveFocusedChatTarget } from "@/composer/focused-chat-target"; -import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; -import { useDraftStore } from "@/stores/draft-store"; +import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability"; +import { PullRequestContent } from "@/panels/pull-request"; +import { useAddFileToChat } from "@/panels/use-add-file-to-chat"; function logExplorerSidebar(_event: string, _details: Record): void {} @@ -318,31 +305,19 @@ function ExplorerSidebarContent({ }: SidebarContentProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); - const toast = useToast(); const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right"); - const canQueryPullRequest = isGit && Boolean(workspaceRoot); - const prPane = usePrPaneData({ + const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({ serverId, cwd: workspaceRoot, - enabled: canQueryPullRequest && isOpen, - timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen, + isGit, + requested: activeTab === "pr", + enabled: isOpen, + timelineEnabled: activeTab === "pr", }); - const hasPullRequest = prPane.prNumber !== null; - const showPrTab = hasPullRequest || (activeTab === "pr" && prPane.isLoading); const requestedTab: ExplorerTab = !isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab; const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab; const prTabLabel = formatPrTabLabel(prPane.prNumber); - const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh); - const handlePrRetry = useCallback(() => { - refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => { - toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); - }); - }, [refreshGitActions, serverId, t, toast, workspaceRoot]); - const workspaceAttachmentScopeKey = useMemo( - () => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }), - [serverId, workspaceId, workspaceRoot], - ); return ( @@ -436,10 +411,9 @@ function ExplorerSidebarContent({ {resolvedTab === "pr" && ( )} @@ -447,42 +421,6 @@ function ExplorerSidebarContent({ ); } -/** - * Shared add-to-chat state for the changes/files panes: both expose an "add file - * to chat" action that attaches the file to the focused chat's composer. - * Available only when a workspace with a focused chat is available. - */ -function useAddFileToChat({ - serverId, - workspaceId, -}: Pick) { - const workspaceKey = workspaceId - ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) - : null; - const layout = useWorkspaceLayoutStore((state) => - workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined, - ); - const focusTab = useWorkspaceLayoutStore((state) => state.focusTab); - const focusedChat = useMemo( - () => resolveFocusedChatTarget({ serverId, layout }), - [serverId, layout], - ); - const addFile = useCallback( - (filePath: string) => { - if (!focusedChat || !workspaceKey) { - return; - } - void useDraftStore.getState().attachWorkspaceFile({ - draftKey: focusedChat.draftKey, - attachment: createWorkspaceFileAttachment({ path: filePath }), - }); - focusTab(workspaceKey, focusedChat.tabId); - }, - [focusTab, focusedChat, workspaceKey], - ); - return { addFile, canAddToChat: focusedChat !== null }; -} - function ChangedFilesPane({ serverId, workspaceId, @@ -496,6 +434,7 @@ function ChangedFilesPane({ const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); return ( void; -} - -function PrTabContent({ - serverId, - cwd, - prPane, - workspaceAttachmentScopeKey, - onRetry, -}: PrTabContentProps) { - if (prPane.data) { - return ( - - ); - } - if (prPane.error) { - return ; - } - return ; -} +const PrTabContent = PullRequestContent; // Static styles for Animated.Views — must NOT use Unistyles dynamic theme to // avoid the "Unable to find node on an unmounted component" crash when Unistyles diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index eac8a68d3..ab76854ef 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1459,7 +1459,7 @@ export const AssistantMessage = memo(function AssistantMessage({ const fileLinkActions = useAssistantFileLinkActions(); const handleMarkdownLinkPress = useStableEvent((url: string) => { - fileLinkActions.open({ href: url }, "main"); + fileLinkActions.open({ href: url }, "side"); // react-native-markdown-display opens the link itself when this returns true. // We already handled it above, so return false to avoid duplicate opens. return false; diff --git a/packages/app/src/components/resize-handle-drag.test.ts b/packages/app/src/components/resize-handle-drag.test.ts new file mode 100644 index 000000000..40b23c85d --- /dev/null +++ b/packages/app/src/components/resize-handle-drag.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import { startResizeHandleDrag } from "@/components/resize-handle-drag"; + +describe("startResizeHandleDrag", () => { + it("previews pointer frames and commits only the final size", () => { + const preview = vi.fn(); + const commit = vi.fn(); + const drag = startResizeHandleDrag({ + sizes: [0.5, 0.5], + index: 0, + preview, + commit, + }); + + drag.move(0.05); + drag.move(0.1); + + expect(preview).toHaveBeenCalledTimes(2); + expect(preview.mock.calls[0]?.[0][0]).toBeCloseTo(0.55, 10); + expect(preview.mock.calls[0]?.[0][1]).toBeCloseTo(0.45, 10); + expect(preview.mock.calls[1]?.[0][0]).toBeCloseTo(0.6, 10); + expect(preview.mock.calls[1]?.[0][1]).toBeCloseTo(0.4, 10); + expect(commit).not.toHaveBeenCalled(); + + drag.finish(); + + expect(commit).toHaveBeenCalledOnce(); + expect(commit.mock.calls[0]?.[0][0]).toBeCloseTo(0.6, 10); + expect(commit.mock.calls[0]?.[0][1]).toBeCloseTo(0.4, 10); + }); +}); diff --git a/packages/app/src/components/resize-handle-drag.ts b/packages/app/src/components/resize-handle-drag.ts new file mode 100644 index 000000000..9a25a0f75 --- /dev/null +++ b/packages/app/src/components/resize-handle-drag.ts @@ -0,0 +1,35 @@ +import { computeResizeHandleSizes } from "@/components/resize-handle-sizes"; + +interface StartResizeHandleDragInput { + sizes: number[]; + index: number; + preview: (sizes: number[]) => void; + commit: (sizes: number[]) => void; +} + +export interface ResizeHandleDrag { + move: (deltaRatio: number) => void; + finish: () => void; +} + +export function startResizeHandleDrag({ + sizes, + index, + preview, + commit, +}: StartResizeHandleDragInput): ResizeHandleDrag { + let pendingSizes: number[] | null = null; + + return { + move(deltaRatio) { + pendingSizes = computeResizeHandleSizes({ sizes, index, deltaRatio }); + preview(pendingSizes); + }, + finish() { + if (pendingSizes) { + commit(pendingSizes); + pendingSizes = null; + } + }, + }; +} diff --git a/packages/app/src/components/resize-handle.tsx b/packages/app/src/components/resize-handle.tsx index 06b020cd5..43688578e 100644 --- a/packages/app/src/components/resize-handle.tsx +++ b/packages/app/src/components/resize-handle.tsx @@ -1,19 +1,22 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { View, type PointerEvent as RNPointerEvent } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { computeResizeHandleSizes } from "@/components/resize-handle-sizes"; +import { startResizeHandleDrag, type ResizeHandleDrag } from "@/components/resize-handle-drag"; export interface ResizeHandleProps { + testID?: string; direction: "horizontal" | "vertical"; groupId: string; index: number; sizes: number[]; + onPreviewResizeSplit: (groupId: string, sizes: number[]) => void; onResizeSplit: (groupId: string, sizes: number[]) => void; } interface PointerState { containerSize: number; pointerStart: number; + drag: ResizeHandleDrag; } function resetWindowHorizontalScroll() { @@ -25,10 +28,12 @@ function resetWindowHorizontalScroll() { } export function ResizeHandle({ + testID, direction, groupId, index, sizes, + onPreviewResizeSplit, onResizeSplit, }: ResizeHandleProps) { const { theme } = useUnistyles(); @@ -68,6 +73,12 @@ export function ResizeHandle({ containerSize, pointerStart: direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY, + drag: startResizeHandleDrag({ + sizes, + index, + preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes), + commit: (nextSizes) => onResizeSplit(groupId, nextSizes), + }), }); if (pointerStatesRef.current.size === 1) { @@ -113,14 +124,7 @@ export function ResizeHandle({ const deltaRatio = (pointerCurrent - pointerState.pointerStart) / pointerState.containerSize; - onResizeSplit( - groupId, - computeResizeHandleSizes({ - sizes, - index, - deltaRatio, - }), - ); + pointerState.drag.move(deltaRatio); } function handlePointerUp(upEvent: PointerEvent) { @@ -128,6 +132,7 @@ export function ResizeHandle({ return; } + pointerStatesRef.current.get(pointerId)?.drag.finish(); cleanup(); } @@ -135,7 +140,7 @@ export function ResizeHandle({ window.addEventListener("pointerup", handlePointerUp); window.addEventListener("pointercancel", handlePointerUp); }, - [direction, groupId, index, onResizeSplit, sizes], + [direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes], ); const handlePointerEnter = useCallback(() => { @@ -181,8 +186,14 @@ export function ResizeHandle({ ); return ( - - {highlighted && } + + {highlighted && ( + + )} ({ color: theme.colors.foregroundMuted }); const needsInputColorMapping = (theme: Theme) => ({ @@ -45,38 +40,6 @@ const ThemedMonitor = withUnistyles(Monitor); const ThemedFolder = withUnistyles(Folder); const ThemedFolderGit2 = withUnistyles(FolderGit2); -/** - * react-native-svg's extractGradient reads stopColor off the child elements structurally, - * without rendering them, so wrapping Stop itself in withUnistyles hides the color from it and - * the native gradient silently falls back to black. Theme the whole SVG instead and keep real - * Stop elements as direct children of the gradient. - */ -function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) { - return ( - - - - {/* Same color at both ends, varying only stopOpacity. Interpolating a hex toward - `transparent` goes through black in some engines and leaves a grey fringe. */} - - - - - - - - ); -} - -const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg); - -const scrimColorMappings: Record { color: string }> = { - surfaceSidebar: (theme) => ({ color: theme.colors.surfaceSidebar }), - surfaceSidebarHover: (theme) => ({ color: theme.colors.surfaceSidebarHover }), - surfaceSidebarSelected: (theme) => ({ color: theme.colors.surfaceSidebarSelected }), - surface2: (theme) => ({ color: theme.colors.surface2 }), -}; - export function SidebarWorkspaceRowFrame({ workspace, isDragging = false, @@ -365,13 +328,6 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({ top: 0, right: 0, }, - trailingActionScrim: { - position: "absolute", - top: 0, - bottom: 0, - right: 0, - width: SCRIM_WIDTH, - }, })); export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) { @@ -476,40 +432,14 @@ export function SidebarWorkspaceTrailingActionOverlay({ if (!visible || !children) return null; return ( <> - {scrimBackdrop ? : null} + {scrimBackdrop ? ( + + ) : null} {children} ); } -/** - * The row's own background, faded in from the right, sitting between the diff stat and the - * kebab. The kebab lands on fully opaque background while the diff dissolves underneath it - * rather than blinking out — hiding the diff outright was the old behavior and it cost a - * visible flicker on every hover. - * - * Anchored to the trailing slot, which is position:relative. Wider than the slot on purpose: - * the fade has to start before the diff stat does or the diff's left edge cuts off hard. - */ -function TrailingActionScrim({ backdrop }: { backdrop: SidebarSurfaceBackdrop }) { - // useId's output contains characters that are not legal inside url(#...) — React 19 wraps - // ids in guillemets, React 18 in colons — and an unresolvable fill paints nothing at all. - // Keep the per-instance uniqueness, drop everything a fragment reference can't carry. - const gradientId = `sidebar-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; - return ( - - - - ); -} - const styles = StyleSheet.create((theme) => ({ workspaceRowContent: { position: "relative", diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index d31822a48..cc7f5c506 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -28,6 +28,11 @@ import { } from "@dnd-kit/core"; import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import { View, Text } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + type SharedValue, +} from "react-native-reanimated"; import { useTranslation } from "react-i18next"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ResizeHandle } from "@/components/resize-handle"; @@ -139,6 +144,9 @@ interface SplitPaneDropData { paneId: string; } +const EMPTY_SPLIT_NODES: SplitNode[] = []; +const EMPTY_SPLIT_SIZES: number[] = []; + function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData { return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab"; } @@ -721,9 +729,68 @@ function DragOverlayTabChipInner({ ); } -function SplitGroupChild({ flex, children }: { flex: number; children: ReactNode }) { - const childStyle = useMemo(() => [styles.groupChild, { flex }], [flex]); - return {children}; +function SplitGroupChild({ + resizeFlex, + index, + hidden, + children, +}: { + resizeFlex: SharedValue; + index: number; + hidden: boolean; + children: ReactNode; +}) { + const resizeStyle = useAnimatedStyle(() => ({ + flexGrow: resizeFlex.value[index] ?? 0, + })); + const childStyle = useMemo( + () => [ + styles.groupChild, + { + flexShrink: hidden ? 0 : 1, + flexBasis: 0, + ...(hidden ? { width: 0, height: 0 } : {}), + }, + ], + [hidden], + ); + return ( + + {children} + + ); +} + +/** + * Flex grow per child, renormalized so the visible ones always sum to 1. + * + * `sizes` are fractions, so a two-pane group is `[0.5, 0.5]`. Hiding one child drops the group's + * total grow factor to 0.5, and CSS hands children that sum to less than 1 only that fraction of + * the free space — the other half of the row is simply left empty. Renormalizing is what makes a + * hidden pane give its space back instead of just going invisible. The stored `sizes` are never + * touched, so unhiding restores the width the user dragged to. + */ +function resolveVisibleGroupFlex(children: SplitNode[], sizes: number[]): number[] { + const visibleTotal = children.reduce( + (total, child, index) => (isSplitNodeHidden(child) ? total : total + (sizes[index] ?? 1)), + 0, + ); + if (visibleTotal <= 0) { + return children.map(() => 0); + } + return children.map((child, index) => + isSplitNodeHidden(child) ? 0 : (sizes[index] ?? 1) / visibleTotal, + ); +} + +function isSplitNodeHidden(node: SplitNode): boolean { + if (node.kind === "pane") { + return node.pane.hidden === true; + } + return node.group.children.every(isSplitNodeHidden); } function SplitNodeView({ @@ -773,6 +840,23 @@ function SplitNodeView({ const storedGroupSizes = useWorkspaceLayoutStore((state) => groupId ? state.splitSizesByWorkspace[workspaceKey]?.[groupId] : undefined, ); + const groupChildren = node.kind === "group" ? node.group.children : EMPTY_SPLIT_NODES; + const groupSizes = + storedGroupSizes ?? (node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES); + const visibleFlex = useMemo( + () => resolveVisibleGroupFlex(groupChildren, groupSizes), + [groupChildren, groupSizes], + ); + const resizeFlex = useSharedValue(visibleFlex); + useEffect(() => { + resizeFlex.value = visibleFlex; + }, [resizeFlex, visibleFlex]); + const previewResizeSplit = useCallback( + (_groupId: string, sizes: number[]) => { + resizeFlex.value = resolveVisibleGroupFlex(groupChildren, sizes); + }, + [groupChildren, resizeFlex], + ); const groupStyle = useMemo( () => [ @@ -784,56 +868,56 @@ function SplitNodeView({ if (node.kind === "pane") { return ( - - - + + + + + ); } - const groupSizes = storedGroupSizes ?? node.group.sizes; - return ( {node.group.children.map((child, index) => ( - + - {index < node.group.children.length - 1 ? ( + {index < node.group.children.length - 1 && + !isSplitNodeHidden(child) && + !isSplitNodeHidden(node.group.children[index + 1]) ? ( ) : null} diff --git a/packages/app/src/components/tree-rail-layout.test.ts b/packages/app/src/components/tree-rail-layout.test.ts new file mode 100644 index 000000000..3dcead4f4 --- /dev/null +++ b/packages/app/src/components/tree-rail-layout.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { + acceptTreeRailContainerMeasurement, + resolveVisibleTreeRailWidth, +} from "@/components/tree-rail-layout"; + +describe("tree rail layout", () => { + it("keeps the last usable geometry while a retained panel is hidden", () => { + const visibleContainerWidth = 900; + const hiddenContainerWidth = acceptTreeRailContainerMeasurement(visibleContainerWidth, 0); + + expect(hiddenContainerWidth).toBe(visibleContainerWidth); + expect(resolveVisibleTreeRailWidth(420, hiddenContainerWidth)).toBe(420); + }); + + it("clamps the rail after a usable narrow-container measurement", () => { + const containerWidth = acceptTreeRailContainerMeasurement(900, 500); + + expect(resolveVisibleTreeRailWidth(420, containerWidth)).toBe(260); + }); +}); diff --git a/packages/app/src/components/tree-rail-layout.ts b/packages/app/src/components/tree-rail-layout.ts new file mode 100644 index 000000000..1c08649c2 --- /dev/null +++ b/packages/app/src/components/tree-rail-layout.ts @@ -0,0 +1,18 @@ +import { MIN_TREE_RAIL_WIDTH, clampTreeRailWidth } from "@/stores/panel-store"; + +const MIN_CONTENT_WIDTH = 240; + +export function resolveVisibleTreeRailWidth( + requestedWidth: number, + containerWidth: number, +): number { + const maximumVisibleWidth = Math.max(MIN_TREE_RAIL_WIDTH, containerWidth - MIN_CONTENT_WIDTH); + return Math.min(clampTreeRailWidth(requestedWidth), maximumVisibleWidth); +} + +export function acceptTreeRailContainerMeasurement( + currentWidth: number, + measuredWidth: number, +): number { + return measuredWidth > 0 ? measuredWidth : currentWidth; +} diff --git a/packages/app/src/components/tree-rail.tsx b/packages/app/src/components/tree-rail.tsx new file mode 100644 index 000000000..b3c4eacf5 --- /dev/null +++ b/packages/app/src/components/tree-rail.tsx @@ -0,0 +1,117 @@ +import type { ReactNode } from "react"; +import { Children, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { View, useWindowDimensions } from "react-native"; +import { Gesture } from "react-native-gesture-handler"; +import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; +import { StyleSheet } from "react-native-unistyles"; +import { + SIDEBAR_RESIZE_ACTIVATION_OFFSET, + SIDEBAR_RESIZE_FAIL_OFFSET, +} from "@/components/sidebar-resize-handle-layout"; +import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; +import { + acceptTreeRailContainerMeasurement, + resolveVisibleTreeRailWidth, +} from "@/components/tree-rail-layout"; +import { usePanelStore } from "@/stores/panel-store"; + +export interface TreeRailProps { + /** + * Exactly two children, in order: content, then tree. + * + * Slots are children rather than `content` / `tree` props because `react-perf/jsx-no-jsx-as-prop` + * rejects JSX in a prop, including via a local variable. Pass both unconditionally — a `{cond && + * …}` slot collapses out of the array and silently promotes the tree into the content position. + * Branch around the whole rail instead, the way `diff-panel.tsx` does. + */ + children: ReactNode; + testID?: string; +} + +/** Content-left/tree-right master-detail geometry with one app-wide tree width. */ +export function TreeRail({ children, testID }: TreeRailProps) { + const [content, tree] = Children.toArray(children); + const requestedWidth = usePanelStore((state) => state.treeRailWidth); + const setTreeRailWidth = usePanelStore((state) => state.setTreeRailWidth); + const { width: viewportWidth } = useWindowDimensions(); + const [containerWidth, setContainerWidth] = useState(viewportWidth); + const visibleWidth = resolveVisibleTreeRailWidth(requestedWidth, containerWidth); + const startWidthRef = useRef(visibleWidth); + const resizeWidth = useSharedValue(visibleWidth); + const [resizePressed, setResizePressed] = useState(false); + const showResizeGrip = useCallback(() => setResizePressed(true), []); + const hideResizeGrip = useCallback(() => setResizePressed(false), []); + + useEffect(() => { + resizeWidth.value = visibleWidth; + }, [resizeWidth, visibleWidth]); + + const resizeGesture = useMemo( + () => + Gesture.Pan() + .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) + .onBegin(() => scheduleOnRN(showResizeGrip)) + .activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET]) + .failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]) + .onStart((event) => { + startWidthRef.current = visibleWidth + event.translationX; + resizeWidth.value = visibleWidth; + }) + .onUpdate((event) => { + resizeWidth.value = resolveVisibleTreeRailWidth( + startWidthRef.current - event.translationX, + containerWidth, + ); + }) + .onEnd(() => runOnJS(setTreeRailWidth)(resizeWidth.value)) + .onFinalize(() => scheduleOnRN(hideResizeGrip)), + [containerWidth, hideResizeGrip, resizeWidth, setTreeRailWidth, showResizeGrip, visibleWidth], + ); + const railWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value })); + const handleLayout = useCallback( + (event: { nativeEvent: { layout: { width: number } } }) => + setContainerWidth((currentWidth) => + acceptTreeRailContainerMeasurement(currentWidth, event.nativeEvent.layout.width), + ), + [], + ); + + return ( + + + {content} + + + + {tree} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + minHeight: 0, + flexDirection: "row", + }, + content: { + flex: 1, + minWidth: 0, + }, + rail: { + position: "relative", + flexShrink: 0, + borderLeftWidth: 1, + borderLeftColor: theme.colors.border, + }, +})); diff --git a/packages/app/src/components/ui/trailing-action-scrim.tsx b/packages/app/src/components/ui/trailing-action-scrim.tsx new file mode 100644 index 000000000..e106bf3ec --- /dev/null +++ b/packages/app/src/components/ui/trailing-action-scrim.tsx @@ -0,0 +1,67 @@ +import { useId } from "react"; +import { View } from "react-native"; +import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; +import type { Theme } from "@/styles/theme"; + +export const SCRIM_WIDTH = 48; +const SCRIM_SOLID_OFFSET = "55%"; + +function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) { + return ( + + + + {/* Vary opacity rather than interpolating toward `transparent`, which crosses black in + some engines and leaves a grey fringe. */} + + + + + + + + ); +} + +const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg); + +const backdropColorMappings: Record { color: string }> = { + surface0: (theme) => ({ color: theme.colors.surface0 }), + surface1: (theme) => ({ color: theme.colors.surface1 }), + surface2: (theme) => ({ color: theme.colors.surface2 }), + surfaceSidebar: (theme) => ({ color: theme.colors.surfaceSidebar }), + surfaceSidebarHover: (theme) => ({ color: theme.colors.surfaceSidebarHover }), + surfaceSidebarSelected: (theme) => ({ color: theme.colors.surfaceSidebarSelected }), +}; + +/** Fades trailing content into the surface beneath an absolutely overlaid action. */ +export function TrailingActionScrim({ + backdrop, + testID, +}: { + backdrop: SurfaceBackdrop; + testID?: string; +}) { + // React-generated ids contain characters that are invalid inside SVG fragment references. + const gradientId = `trailing-action-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; + return ( + + + + ); +} + +const styles = StyleSheet.create({ + scrim: { + position: "absolute", + top: 0, + bottom: 0, + right: 0, + width: SCRIM_WIDTH, + }, +}); diff --git a/packages/app/src/composer/focused-chat-target.test.ts b/packages/app/src/composer/focused-chat-target.test.ts index a76ae6f64..d1e621b80 100644 --- a/packages/app/src/composer/focused-chat-target.test.ts +++ b/packages/app/src/composer/focused-chat-target.test.ts @@ -46,4 +46,32 @@ describe("focused chat target", () => { }), ).toBeNull(); }); + + it("targets a utility tab's parent chat", () => { + const layout = { + root: { + kind: "pane", + pane: { + id: "pane-1", + tabIds: ["agent-tab", "changes-tab"], + focusedTabId: "changes-tab", + tabs: [ + { + tabId: "agent-tab", + target: { kind: "agent", agentId: "agent-1" }, + createdAt: 1, + }, + { tabId: "changes-tab", target: { kind: "working_diff" }, createdAt: 2 }, + ], + }, + }, + focusedPaneId: "pane-1", + parentTabIdByTabId: { "changes-tab": "agent-tab" }, + } 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 479fcf088..c410d0188 100644 --- a/packages/app/src/composer/focused-chat-target.ts +++ b/packages/app/src/composer/focused-chat-target.ts @@ -10,6 +10,29 @@ export interface FocusedChatTarget { draftKey: string; } +function resolveChatTab( + serverId: string, + tab: ReturnType[number] | undefined, +): FocusedChatTarget | null { + if (tab?.target.kind === "agent") { + return { + tabId: tab.tabId, + draftKey: buildDraftStoreKey({ serverId, agentId: tab.target.agentId }), + }; + } + if (tab?.target.kind === "draft") { + return { + tabId: tab.tabId, + draftKey: buildDraftStoreKey({ + serverId, + agentId: tab.tabId, + draftId: tab.target.draftId, + }), + }; + } + return null; +} + export function resolveFocusedChatTarget(input: { serverId: string; layout: WorkspaceLayout | undefined; @@ -22,27 +45,15 @@ export function resolveFocusedChatTarget(input: { if (!focusedTabId) { return null; } - const tab = collectAllTabs(input.layout.root).find( - (candidate) => candidate.tabId === focusedTabId, + 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 parentTabId = input.layout.parentTabIdByTabId?.[focusedTabId]; + return resolveChatTab( + input.serverId, + tabs.find((candidate) => candidate.tabId === parentTabId), ); - if (!tab) { - return null; - } - if (tab.target.kind === "agent") { - return { - tabId: tab.tabId, - draftKey: buildDraftStoreKey({ serverId: input.serverId, agentId: tab.target.agentId }), - }; - } - if (tab.target.kind === "draft") { - return { - tabId: tab.tabId, - draftKey: buildDraftStoreKey({ - serverId: input.serverId, - agentId: tab.tabId, - draftId: tab.target.draftId, - }), - }; - } - return null; } diff --git a/packages/app/src/file-pane/conflict-alert.tsx b/packages/app/src/file-pane/conflict-alert.tsx index 106e16a8f..6cc26ec01 100644 --- a/packages/app/src/file-pane/conflict-alert.tsx +++ b/packages/app/src/file-pane/conflict-alert.tsx @@ -57,6 +57,7 @@ const styles = StyleSheet.create((theme) => ({ minHeight: 48, flexDirection: "row", alignItems: "center", + flexWrap: "wrap", gap: theme.spacing[3], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], @@ -66,6 +67,7 @@ const styles = StyleSheet.create((theme) => ({ }, message: { flex: 1, + flexBasis: 180, minWidth: 0, gap: theme.spacing[1], }, @@ -80,6 +82,7 @@ const styles = StyleSheet.create((theme) => ({ }, actions: { flexShrink: 0, + marginLeft: "auto", flexDirection: "row", gap: theme.spacing[2], }, diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 02c07a5f6..9fa315595 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -12,6 +12,7 @@ import { import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { DiffStat } from "@/components/diff-stat"; +import { TreeRail } from "@/components/tree-rail"; import { View, Text, @@ -1575,6 +1576,7 @@ interface GitDiffPaneProps { workspaceId?: string | null; cwd: string; enabled?: boolean; + host: "explorer" | "panel"; onOpenFile?: (path: string) => void; onAddToChat?: (path: string) => void; } @@ -1660,6 +1662,29 @@ interface ChangesTabToggleProps { onPress: () => void; } +function resolveChangesTabOpen(host: "explorer" | "panel", changesTabOpen: boolean): boolean { + return host === "explorer" ? changesTabOpen : false; +} + +function resolveChangesFilePress( + host: "explorer" | "panel", + onChangesFilePress: ((path?: string) => void) | undefined, +): ((path?: string) => void) | undefined { + return host === "explorer" ? onChangesFilePress : undefined; +} + +function ChangesTabToggleForHost({ + host, + isMobile, + selected, + onPress, +}: ChangesTabToggleProps & { host: "explorer" | "panel" }) { + if (host === "panel") { + return null; + } + return ; +} + interface DiffModeMenuProps { diffMode: "uncommitted" | "base"; committedDescription?: string; @@ -1749,17 +1774,21 @@ function ChangesTabToggle({ isMobile, selected, onPress }: ChangesTabToggleProps interface DiffViewModeToggleProps { viewMode: "flat" | "tree"; isMobile: boolean; - toggleStyle: PressableStyleFn; + toggleStyle?: PressableStyleFn; onToggle: () => void; } -function DiffViewModeToggle({ +export function DiffViewModeToggle({ viewMode, isMobile, toggleStyle, onToggle, }: DiffViewModeToggleProps) { const { t } = useTranslation(); + const defaultToggleStyle = useMemo( + () => buildToggleButtonStyle(viewMode === "tree", styles.expandAllButton), + [viewMode], + ); const label = viewMode === "flat" ? t("workspace.git.diff.showTreeView") @@ -1771,7 +1800,7 @@ function DiffViewModeToggle({ accessibilityRole="button" accessibilityLabel={label} testID="changes-toggle-view-mode" - style={toggleStyle} + style={toggleStyle ?? defaultToggleStyle} onPress={onToggle} > {viewMode === "flat" ? ( @@ -2140,6 +2169,8 @@ interface SharedDiffViewProps { }; } +type WorkingTreeDiffMode = Extract; + export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffViewProps) { const isCompact = useIsCompactFormFactor(); const { layout, wrapLines, codeFontSize, monoFontFamily } = displayPreferences; @@ -2929,6 +2960,81 @@ function useChangesTreeState({ }; } +export function ChangedFilesTree({ + workspaceId, + cwd, + files, + displayPreferences, + workingTreeMode, +}: { + workspaceId?: string | null; + cwd: string; + files: ParsedDiffFile[]; + displayPreferences: SharedDiffViewProps["displayPreferences"]; + workingTreeMode: WorkingTreeDiffMode; +}) { + const treeState = useChangesTreeState({ + workspaceId, + cwd, + files, + viewMode: "tree", + changesTabOpen: false, + onViewModeChange: () => {}, + }); + const mode = useMemo( + () => ({ + ...workingTreeMode, + viewMode: "tree" as const, + expandedPaths: EMPTY_PATH_LIST, + collapsedFolders: treeState.collapsedFolders, + onExpandedPathsChange: () => {}, + onCollapsedFoldersChange: treeState.updateCollapsedFolders, + }), + [treeState.collapsedFolders, treeState.updateCollapsedFolders, workingTreeMode], + ); + return ; +} + +function GitDiffTreeRail({ + shown, + children, + workspaceId, + cwd, + files, + displayPreferences, + workingTreeMode, +}: { + shown: boolean; + children: ReactElement; + workspaceId?: string | null; + cwd: string; + files: ParsedDiffFile[]; + displayPreferences: SharedDiffViewProps["displayPreferences"]; + workingTreeMode: WorkingTreeDiffMode; +}) { + if (!shown) return children; + return ( + + {children} + + + ); +} + +function resolveWorkingTreeMode(isMobile: boolean, mobileMode: T, desktopMode: T): T { + return isMobile ? mobileMode : desktopMode; +} + +function shouldShowTreeRail(viewMode: "flat" | "tree", isMobile: boolean): boolean { + return viewMode === "tree" && !isMobile; +} + function useDiffTabNavigation({ serverId, workspaceId, @@ -3000,6 +3106,7 @@ export function GitDiffPane({ workspaceId, cwd, enabled, + host, onOpenFile, onAddToChat, }: GitDiffPaneProps) { @@ -3049,11 +3156,13 @@ export function GitDiffPane({ }); const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager"); const { - changesTabOpen, + changesTabOpen: workspaceChangesTabOpen, toggleChanges: handleToggleChangesTab, openCommit: handleCommitPress, - onChangesFilePress, + onChangesFilePress: workspaceOnChangesFilePress, } = useDiffTabNavigation({ serverId, workspaceId, cwd, isMobile }); + const changesTabOpen = resolveChangesTabOpen(host, workspaceChangesTabOpen); + const onChangesFilePress = resolveChangesFilePress(host, workspaceOnChangesFilePress); const refreshSupported = useSessionStore( (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, ); @@ -3283,7 +3392,16 @@ export function GitDiffPane({ }, ); - const bodyContent: ReactElement = ( + const flatWorkingTreeMode = useMemo( + () => ({ ...workingTreeMode, viewMode: "flat" as const }), + [workingTreeMode], + ); + const primaryWorkingTreeMode = resolveWorkingTreeMode( + isMobile, + workingTreeMode, + flatWorkingTreeMode, + ); + const diffContent: ReactElement = ( ); + const bodyContent = ( + + {diffContent} + + ); return ( - { expect(countMatchingEnglishStrings(zhCN)).toBeLessThan(maxFallbackStrings); }); + it("localizes the pull request empty state in every supported language", () => { + for (const resource of [ar, es, fr, ja, ko, ptBR, ru, zhCN]) { + expect(resource.panels.pullRequest.emptyTitle).not.toBe(en.panels.pullRequest.emptyTitle); + expect(resource.panels.pullRequest.emptyDescription).not.toBe( + en.panels.pullRequest.emptyDescription, + ); + } + }); + it("preserves interpolation placeholders in every language", () => { expect(findInterpolationMismatches(ar)).toEqual([]); expect(findInterpolationMismatches(es)).toEqual([]); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 5533ed0fc..db2891516 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -587,6 +587,7 @@ export const ar: TranslationResources = { renameAgent: "إعادة تسمية الوكيل", }, actions: { + newTab: "علامة تبويب جديدة", newAgent: "وكيل جديد", newTerminal: "محطة جديدة", preparingTerminal: "إعداد علامة التبويب المحطة الطرفية", @@ -595,6 +596,9 @@ export const ar: TranslationResources = { exitFocusMode: "إنهاء وضع التركيز", splitRight: "تقسيم الجزء الأيمن", splitDown: "تقسيم الجزء لأسفل", + changes: "التغييرات", + files: "الملفات", + pullRequest: "طلب السحب", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", pinTarget: "تثبيت", @@ -1658,6 +1662,17 @@ export const ar: TranslationResources = { reloadMessage: "ستفقد تغييراتك المحلية.", }, }, + files: { + label: "الملفات", + subtitle: "ملفات مساحة العمل", + tooltip: "تصفح ملفات مساحة العمل", + }, + pullRequest: { + label: "طلب السحب", + subtitle: "تفاصيل طلب السحب", + emptyTitle: "لا يوجد طلب سحب بعد", + emptyDescription: "أنشئ طلب سحب لنسخة العمل هذه لعرض تفاصيله هنا.", + }, diff: { changesLabel: "التغييرات", changesSubtitle: "فروقات شجرة العمل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 49feb3e83..6e74ca8d5 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -585,6 +585,7 @@ export const en = { renameAgent: "Rename agent", }, actions: { + newTab: "New tab", newAgent: "New agent", newTerminal: "New terminal", preparingTerminal: "Preparing terminal tab", @@ -593,6 +594,9 @@ export const en = { exitFocusMode: "Exit focus mode", splitRight: "Split pane right", splitDown: "Split pane down", + changes: "Changes", + files: "Files", + pullRequest: "Pull request", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", pinTarget: "Pin", @@ -1668,6 +1672,17 @@ export const en = { reloadMessage: "Your local changes will be lost.", }, }, + files: { + label: "Files", + subtitle: "Workspace files", + tooltip: "Browse workspace files", + }, + pullRequest: { + label: "Pull request", + subtitle: "Pull request details", + emptyTitle: "No pull request yet", + emptyDescription: "Create a pull request for this checkout to see its details here.", + }, diff: { changesLabel: "Changes", changesSubtitle: "Working tree diff", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index daa2f058d..1e1269adb 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -592,6 +592,7 @@ export const es: TranslationResources = { renameAgent: "Cambiar nombre del agente", }, actions: { + newTab: "Nueva pestaña", newAgent: "Nuevo agente", newTerminal: "Nueva terminal", preparingTerminal: "Preparando la pestaña del terminal", @@ -600,6 +601,9 @@ export const es: TranslationResources = { exitFocusMode: "Salir del modo de concentración", splitRight: "Panel dividido a la derecha", splitDown: "Dividir panel hacia abajo", + changes: "Cambios", + files: "Archivos", + pullRequest: "Solicitud de extracción", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", pinTarget: "Fijar", @@ -1701,6 +1705,17 @@ export const es: TranslationResources = { reloadMessage: "Se perderán tus cambios locales.", }, }, + files: { + label: "Archivos", + subtitle: "Archivos del espacio de trabajo", + tooltip: "Explorar archivos del espacio de trabajo", + }, + pullRequest: { + label: "Solicitud de extracción", + subtitle: "Detalles de la solicitud de extracción", + emptyTitle: "Aún no hay ninguna solicitud de extracción", + emptyDescription: "Crea una solicitud para este checkout y consulta aquí sus detalles.", + }, diff: { changesLabel: "Cambios", changesSubtitle: "Diferencias del árbol de trabajo", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 172d26358..a464c1a27 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -592,6 +592,7 @@ export const fr: TranslationResources = { renameAgent: "Renommer l'agent", }, actions: { + newTab: "Nouvel onglet", newAgent: "Nouvel agent", newTerminal: "Nouveau terminal", preparingTerminal: "Préparation de l'onglet du terminal", @@ -600,6 +601,9 @@ export const fr: TranslationResources = { exitFocusMode: "Quitter le mode concentration", splitRight: "Volet divisé à droite", splitDown: "Diviser le volet vers le bas", + changes: "Modifications", + files: "Fichiers", + pullRequest: "Demande de fusion", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", pinTarget: "Épingler", @@ -1705,6 +1709,18 @@ export const fr: TranslationResources = { reloadMessage: "Vos modifications locales seront perdues.", }, }, + files: { + label: "Fichiers", + subtitle: "Fichiers de l’espace de travail", + tooltip: "Parcourir les fichiers de l’espace de travail", + }, + pullRequest: { + label: "Demande de fusion", + subtitle: "Détails de la demande de fusion", + emptyTitle: "Aucune demande de fusion pour le moment", + emptyDescription: + "Créez une demande de fusion pour cette copie de travail afin d’afficher ses détails ici.", + }, diff: { changesLabel: "Modifications", changesSubtitle: "Différences de l'arbre de travail", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 15b74d7de..b4bde32fd 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -592,6 +592,7 @@ export const ja: TranslationResources = { renameAgent: "エージェントの名前を変更", }, actions: { + newTab: "新しいタブ", newAgent: "新しいエージェント", newTerminal: "新しいターミナル", preparingTerminal: "ターミナルタブを準備中", @@ -600,6 +601,9 @@ export const ja: TranslationResources = { exitFocusMode: "フォーカスモードを終了", splitRight: "右にペインを分割", splitDown: "下にペインを分割", + changes: "変更", + files: "ファイル", + pullRequest: "プルリクエスト", terminalProfilesMenu: "ターミナルプロファイル", editTerminalProfiles: "プロファイルを編集…", pinTarget: "ピン留め", @@ -1674,6 +1678,18 @@ export const ja: TranslationResources = { reloadMessage: "ローカルの変更は失われます。", }, }, + files: { + label: "ファイル", + subtitle: "ワークスペースのファイル", + tooltip: "ワークスペースのファイルを参照", + }, + pullRequest: { + label: "プルリクエスト", + subtitle: "プルリクエストの詳細", + emptyTitle: "プルリクエストはまだありません", + emptyDescription: + "このチェックアウトのプルリクエストを作成すると、ここに詳細が表示されます。", + }, diff: { changesLabel: "変更", changesSubtitle: "作業ツリーの差分", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 2876ad5ff..54c8c324f 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -589,6 +589,7 @@ export const ko: TranslationResources = { renameAgent: "에이전트 이름 변경", }, actions: { + newTab: "새 탭", newAgent: "새 에이전트", newTerminal: "새 터미널", preparingTerminal: "터미널 탭 준비 중", @@ -597,6 +598,9 @@ export const ko: TranslationResources = { exitFocusMode: "집중 모드 종료", splitRight: "창을 오른쪽으로 분할", splitDown: "창을 아래로 분할", + changes: "변경 사항", + files: "파일", + pullRequest: "풀 리퀘스트", terminalProfilesMenu: "터미널 프로필", editTerminalProfiles: "프로필 편집…", pinTarget: "고정", @@ -1668,6 +1672,17 @@ export const ko: TranslationResources = { reloadMessage: "로컬 변경사항이 손실됩니다.", }, }, + files: { + label: "파일", + subtitle: "워크스페이스 파일", + tooltip: "워크스페이스 파일 탐색", + }, + pullRequest: { + label: "풀 리퀘스트", + subtitle: "풀 리퀘스트 세부 정보", + emptyTitle: "아직 풀 리퀘스트가 없습니다", + emptyDescription: "이 체크아웃에 풀 리퀘스트를 만들면 세부 정보가 여기에 표시됩니다.", + }, diff: { changesLabel: "변경 사항", changesSubtitle: "작업 트리 diff", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index af0533349..b32d524b7 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -591,6 +591,7 @@ export const ptBR: TranslationResources = { renameAgent: "Renomear agente", }, actions: { + newTab: "Nova aba", newAgent: "Novo agente", newTerminal: "Novo terminal", preparingTerminal: "Preparando aba de terminal", @@ -599,6 +600,9 @@ export const ptBR: TranslationResources = { exitFocusMode: "Sair do modo de foco", splitRight: "Dividir painel à direita", splitDown: "Dividir painel abaixo", + changes: "Alterações", + files: "Arquivos", + pullRequest: "Pull request", terminalProfilesMenu: "Perfis de terminal", editTerminalProfiles: "Editar perfis…", pinTarget: "Fixar", @@ -1687,6 +1691,17 @@ export const ptBR: TranslationResources = { reloadMessage: "Suas alterações locais serão perdidas.", }, }, + files: { + label: "Arquivos", + subtitle: "Arquivos do espaço de trabalho", + tooltip: "Explorar arquivos do espaço de trabalho", + }, + pullRequest: { + label: "Pull request", + subtitle: "Detalhes do pull request", + emptyTitle: "Ainda não há pull request", + emptyDescription: "Crie um pull request para este checkout para ver os detalhes aqui.", + }, diff: { changesLabel: "Alterações", changesSubtitle: "Diff da árvore de trabalho", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index db0735c0f..9c1285d1b 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -591,6 +591,7 @@ export const ru: TranslationResources = { renameAgent: "Переименовать агента", }, actions: { + newTab: "Новая вкладка", newAgent: "Новый агент", newTerminal: "Новый терминал", preparingTerminal: "Подготовка вкладки терминала", @@ -599,6 +600,9 @@ export const ru: TranslationResources = { exitFocusMode: "Выйти из режима фокусировки", splitRight: "Разделить панель справа", splitDown: "Разделить панель вниз", + changes: "Изменения", + files: "Файлы", + pullRequest: "Запрос на слияние", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", pinTarget: "Закрепить", @@ -1692,6 +1696,18 @@ export const ru: TranslationResources = { reloadMessage: "Локальные изменения будут потеряны.", }, }, + files: { + label: "Файлы", + subtitle: "Файлы рабочего пространства", + tooltip: "Просмотр файлов рабочего пространства", + }, + pullRequest: { + label: "Запрос на слияние", + subtitle: "Сведения о запросе на слияние", + emptyTitle: "Запроса на слияние пока нет", + emptyDescription: + "Создайте запрос на слияние для этой рабочей копии, чтобы увидеть здесь его сведения.", + }, diff: { changesLabel: "Изменения", changesSubtitle: "Различия рабочего дерева", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index c9c8507ec..43ecef1c0 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -587,6 +587,7 @@ export const zhCN: TranslationResources = { renameAgent: "重命名 Agent", }, actions: { + newTab: "新建标签页", newAgent: "新建 Agent", newTerminal: "新建 Terminal", preparingTerminal: "正在准备 Terminal 标签", @@ -595,6 +596,9 @@ export const zhCN: TranslationResources = { exitFocusMode: "退出专注模式", splitRight: "向右拆分窗格", splitDown: "向下拆分窗格", + changes: "更改", + files: "文件", + pullRequest: "拉取请求", terminalProfilesMenu: "Terminal profiles", editTerminalProfiles: "Edit profiles…", pinTarget: "固定", @@ -1638,6 +1642,17 @@ export const zhCN: TranslationResources = { reloadMessage: "本地更改将丢失。", }, }, + files: { + label: "文件", + subtitle: "工作区文件", + tooltip: "浏览工作区文件", + }, + pullRequest: { + label: "拉取请求", + subtitle: "拉取请求详情", + emptyTitle: "尚无拉取请求", + emptyDescription: "为此检出创建拉取请求后,可在此处查看其详情。", + }, diff: { changesLabel: "更改", changesSubtitle: "工作区差异", diff --git a/packages/app/src/panels/diff-panel.tsx b/packages/app/src/panels/diff-panel.tsx index f315dd5bc..745d0843e 100644 --- a/packages/app/src/panels/diff-panel.tsx +++ b/packages/app/src/panels/diff-panel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, type ComponentProps, type ReactNode } from "react"; +import { useCallback, useMemo, type ReactNode } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { FileDiff, GitCommitHorizontal } from "lucide-react-native"; @@ -7,25 +7,13 @@ import invariant from "tiny-invariant"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; -import { useToast } from "@/contexts/toast-context"; -import { useCheckoutGitActionsStore } from "@/git/actions-store"; -import { - DiffFilesToolbar, - DiffLayoutToggle, - DiffModeMenu, - DiffOptionsMenu, - resolveDiffLayout, - SharedDiffView, -} from "@/git/diff-pane"; -import { DiffTooLargeState } from "@/git/diff-too-large-state"; +import { DiffLayoutToggle, GitDiffPane, resolveDiffLayout, SharedDiffView } from "@/git/diff-pane"; import { useCommitDiffFiles } from "@/git/use-diff-files"; -import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useAppSettings } from "@/hooks/use-settings"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; -import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; +import { useAddFileToChat } from "@/panels/use-add-file-to-chat"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; @@ -56,6 +44,9 @@ function useDiffPanelPreferences() { const toggleHideWhitespace = useCallback(() => { void updatePreferences({ hideWhitespace: !preferences.hideWhitespace }); }, [preferences.hideWhitespace, updatePreferences]); + const toggleViewMode = useCallback(() => { + void updatePreferences({ viewMode: preferences.viewMode === "flat" ? "tree" : "flat" }); + }, [preferences.viewMode, updatePreferences]); return { preferences, @@ -65,6 +56,7 @@ function useDiffPanelPreferences() { toggleLayout, toggleWrapLines, toggleHideWhitespace, + toggleViewMode, }; } @@ -84,186 +76,34 @@ function PanelState({ ); } -function WorkingDiffBody({ - cwd, - isConnected, - workingDiff, - hideWhitespace, - displayPreferences, - mode, -}: { - cwd: string | null | undefined; - isConnected: boolean; - workingDiff: ReturnType; - hideWhitespace: boolean; - displayPreferences: ReturnType["displayPreferences"]; - mode: Extract["mode"], { kind: "working_tab" }>; -}) { +function WorkingDiffPanel() { const { t } = useTranslation(); + const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext(); + const cwd = useWorkspaceDirectory(serverId, workspaceId); + const isActive = useRetainedPanelActive(); + const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); + invariant(target.kind === "working_diff", "WorkingDiffPanel requires working_diff target"); + + const handleOpenFile = useCallback( + (path: string) => openFileInWorkspace({ location: { path }, disposition: "side" }), + [openFileInWorkspace], + ); + if (!cwd) { return ; } - if (!isConnected) { - return ; - } - if (workingDiff.isStatusLoading) { - return ; - } - if (workingDiff.statusErrorMessage) { - return ( - - ); - } - if (workingDiff.notGit) { - return ; - } - if (workingDiff.diffTooLarge) { - return ; - } - if (workingDiff.diffPayloadError) { - return ( - - ); - } - if (workingDiff.isDiffLoading && workingDiff.files.length === 0) { - return ; - } - if (workingDiff.files.length === 0) { - return ( - - ); - } - return ( - - ); -} -function WorkingDiffPanel() { - const { t } = useTranslation(); - const toast = useToast(); - const { serverId, workspaceId, tabId, target } = usePaneContext(); - const cwd = useWorkspaceDirectory(serverId, workspaceId); - const isConnected = useHostRuntimeIsConnected(serverId); - const isActive = useRetainedPanelActive(); - const panelPreferences = useDiffPanelPreferences(); - const [expandedPaths, setExpandedPaths] = useState(null); - invariant(target.kind === "working_diff", "WorkingDiffPanel requires working_diff target"); - - const workingDiff = useWorkingDiff({ - serverId, - workspaceId, - cwd: cwd ?? "", - ignoreWhitespace: panelPreferences.preferences.hideWhitespace, - enabled: Boolean(cwd) && isActive, - queryScope: `working-diff-tab:${tabId}`, - }); - usePublishWorkingDiffAttachment({ - serverId, - workspaceId, - cwd: cwd ?? "", - attachment: workingDiff.reviewAttachment, - enabled: Boolean(cwd) && isActive, - }); - - const refreshSupported = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, - ); - const runRefresh = useCheckoutGitActionsStore((state) => state.refresh); - const isRefreshing = - useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd: cwd ?? "", actionId: "refresh" }), - ) === "pending"; - const refresh = useCallback(() => { - if (!cwd || isRefreshing) { - return; - } - void runRefresh({ serverId, cwd }).catch((error) => { - toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); - }); - }, [cwd, isRefreshing, runRefresh, serverId, t, toast]); - - const expandedPathSet = useMemo( - () => (expandedPaths === null ? null : new Set(expandedPaths)), - [expandedPaths], - ); - const allFilesExpanded = - workingDiff.files.length > 0 && - (expandedPathSet === null || workingDiff.files.every((file) => expandedPathSet.has(file.path))); - const toggleExpandAll = useCallback(() => { - setExpandedPaths(allFilesExpanded ? [] : null); - }, [allFilesExpanded]); - const mode = useMemo( - () => ({ - kind: "working_tab" as const, - expandedPaths, - reviewActions: workingDiff.reviewActions, - focusPath: target.focusPath, - focusRequestId: target.focusRequestId, - onExpandedPathsChange: setExpandedPaths, - }), - [expandedPaths, target.focusPath, target.focusRequestId, workingDiff.reviewActions], - ); - - const baseRefLabel = workingDiff.baseRef?.replace(/^refs\/(heads|remotes)\//, "") ?? ""; return ( - - - - {panelPreferences.canUseSplitLayout ? ( - - ) : null} - {workingDiff.files.length > 0 ? ( - - ) : null} - - - - - - + ); } diff --git a/packages/app/src/panels/file-panel.tsx b/packages/app/src/panels/file-panel.tsx index b4b6975e5..ded91f83a 100644 --- a/packages/app/src/panels/file-panel.tsx +++ b/packages/app/src/panels/file-panel.tsx @@ -1,5 +1,5 @@ import { Text, View } from "react-native"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import invariant from "tiny-invariant"; import { useTranslation } from "react-i18next"; import { FilePane } from "@/file-pane/pane"; @@ -7,6 +7,9 @@ import { usePaneContext } from "@/panels/pane-context"; import type { PanelRegistration } from "@/panels/panel-registry"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { createMaterialFileIcon } from "@/components/material-file-icon"; +import { FileExplorerPane } from "@/components/file-explorer-pane"; +import { TreeRail } from "@/components/tree-rail"; +import { useIsCompactFormFactor } from "@/constants/layout"; const CENTERED_PADDED_STYLE = { flex: 1, @@ -30,8 +33,14 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) { function FilePanel() { const { t } = useTranslation(); - const { serverId, workspaceId, target, fileNavigationRevision } = usePaneContext(); + const { serverId, workspaceId, target, fileNavigationRevision, retargetCurrentTab } = + usePaneContext(); + const isCompact = useIsCompactFormFactor(); const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId); + const handleOpenFile = useCallback( + (path: string) => retargetCurrentTab({ kind: "file", path }), + [retargetCurrentTab], + ); invariant(target.kind === "file", "FilePanel requires file target"); if (!workspaceDirectory) { return ( @@ -40,13 +49,31 @@ function FilePanel() { ); } + if (isCompact) { + return ( + + ); + } return ( - + + + + ); } diff --git a/packages/app/src/panels/files-panel.tsx b/packages/app/src/panels/files-panel.tsx new file mode 100644 index 000000000..dc9d0e829 --- /dev/null +++ b/packages/app/src/panels/files-panel.tsx @@ -0,0 +1,65 @@ +import { Text, View } from "react-native"; +import { FolderTree } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import invariant from "tiny-invariant"; +import { FileExplorerPane } from "@/components/file-explorer-pane"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelRegistration } from "@/panels/panel-registry"; +import { useAddFileToChat } from "@/panels/use-add-file-to-chat"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; + +const ThemedFolderTree = withUnistyles(FolderTree); +const CENTERED_PADDED_STYLE = { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: 16, +} as const; + +function useFilesPanelDescriptor() { + const { t } = useTranslation(); + return { + label: t("panels.files.label"), + subtitle: t("panels.files.subtitle"), + tooltip: t("panels.files.tooltip"), + titleState: "ready" as const, + icon: ThemedFolderTree, + statusBucket: null, + }; +} + +function FilesPanel() { + const { t } = useTranslation(); + const { serverId, workspaceId, target, retargetCurrentTab } = usePaneContext(); + const workspaceRoot = useWorkspaceDirectory(serverId, workspaceId); + const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); + invariant(target.kind === "files", "FilesPanel requires files target"); + const onOpenFile = useCallback( + (path: string) => retargetCurrentTab({ kind: "file", path }), + [retargetCurrentTab], + ); + if (!workspaceRoot) { + return ( + + {t("panels.file.directoryMissing")} + + ); + } + return ( + + ); +} + +export const filesPanelRegistration: PanelRegistration<"files"> = { + kind: "files", + component: FilesPanel, + useDescriptor: useFilesPanelDescriptor, +}; diff --git a/packages/app/src/panels/pull-request-availability.ts b/packages/app/src/panels/pull-request-availability.ts new file mode 100644 index 000000000..80dc948c6 --- /dev/null +++ b/packages/app/src/panels/pull-request-availability.ts @@ -0,0 +1,22 @@ +import { usePullRequestData } from "@/panels/pull-request"; + +export function usePullRequestPanelAvailability(input: { + serverId: string; + cwd: string; + isGit: boolean; + requested: boolean; + enabled: boolean; + timelineEnabled?: boolean; +}) { + const canQuery = input.isGit && Boolean(input.cwd); + const prPane = usePullRequestData({ + serverId: input.serverId, + cwd: input.cwd, + enabled: canQuery && input.enabled, + timelineEnabled: canQuery && input.enabled && Boolean(input.timelineEnabled), + }); + return { + prPane, + showPullRequest: prPane.prNumber !== null || (input.requested && prPane.isLoading), + }; +} diff --git a/packages/app/src/panels/pull-request-panel.tsx b/packages/app/src/panels/pull-request-panel.tsx new file mode 100644 index 000000000..5503b0ae3 --- /dev/null +++ b/packages/app/src/panels/pull-request-panel.tsx @@ -0,0 +1,61 @@ +import { Text, View } from "react-native"; +import { GitPullRequest } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import invariant from "tiny-invariant"; +import { formatPrTabLabel } from "@/git/pull-request-panel"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelRegistration } from "@/panels/panel-registry"; +import { PullRequestContent, usePullRequestData } from "@/panels/pull-request"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; + +const ThemedGitPullRequest = withUnistyles(GitPullRequest); +const CENTERED_PADDED_STYLE = { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: 16, +} as const; + +function usePullRequestPanelDescriptor( + _target: { kind: "pull_request" }, + context: { serverId: string; workspaceId: string }, +) { + const { t } = useTranslation(); + const cwd = useWorkspaceDirectory(context.serverId, context.workspaceId) ?? ""; + const prPane = usePullRequestData({ serverId: context.serverId, cwd, timelineEnabled: false }); + const label = + prPane.prNumber === null ? t("panels.pullRequest.label") : formatPrTabLabel(prPane.prNumber); + return { + label, + subtitle: t("panels.pullRequest.subtitle"), + tooltip: label, + titleState: prPane.isLoading ? ("loading" as const) : ("ready" as const), + icon: ThemedGitPullRequest, + statusBucket: null, + }; +} + +function PullRequestPanel() { + const { t } = useTranslation(); + const { serverId, workspaceId, target } = usePaneContext(); + const cwd = useWorkspaceDirectory(serverId, workspaceId) ?? ""; + invariant(target.kind === "pull_request", "PullRequestPanel requires pull_request target"); + const prPane = usePullRequestData({ serverId, cwd }); + if (!cwd) { + return ( + + {t("panels.file.directoryMissing")} + + ); + } + return ( + + ); +} + +export const pullRequestPanelRegistration: PanelRegistration<"pull_request"> = { + kind: "pull_request", + component: PullRequestPanel, + useDescriptor: usePullRequestPanelDescriptor, +}; diff --git a/packages/app/src/panels/pull-request/index.test.ts b/packages/app/src/panels/pull-request/index.test.ts new file mode 100644 index 000000000..78cd700a7 --- /dev/null +++ b/packages/app/src/panels/pull-request/index.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { getPullRequestIdentity, resolvePullRequestContentState } from "./state"; + +describe("pull request panel", () => { + it("resolves a manually opened panel without a pull request to the empty state", () => { + expect(resolvePullRequestContentState({ data: null, error: null, isLoading: false })).toBe( + "empty", + ); + }); + + it("uses the canonical URL as the stable detected pull request identity", () => { + expect( + getPullRequestIdentity({ + forge: "github", + number: 42, + url: "https://github.com/getpaseo/paseo/pull/42", + title: "PR panel", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + isDraft: false, + mergeable: "MERGEABLE", + checks: [], + }), + ).toBe("url:https://github.com/getpaseo/paseo/pull/42"); + }); + + it("does not manufacture an identity while detection has no result", () => { + expect(getPullRequestIdentity(null)).toBeNull(); + }); +}); diff --git a/packages/app/src/panels/pull-request/index.tsx b/packages/app/src/panels/pull-request/index.tsx new file mode 100644 index 000000000..8eb339846 --- /dev/null +++ b/packages/app/src/panels/pull-request/index.tsx @@ -0,0 +1,102 @@ +import { useCallback, useEffect } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; +import { useToast } from "@/contexts/toast-context"; +import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { + PullRequestPane, + PullRequestPaneError, + PullRequestPaneSkeleton, + usePrPaneData, +} from "@/git/pull-request-panel"; +import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data"; +import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; + +import { getPullRequestIdentity, resolvePullRequestContentState } from "./state"; + +export function usePullRequestAutoAdd(input: { + workspaceKey: string | null; + serverId: string; + cwd: string | null; + enabled: boolean; +}): void { + const status = useCheckoutPrStatusQuery({ + serverId: input.serverId, + cwd: input.cwd || "", + enabled: input.enabled, + }).status; + const observePullRequest = useWorkspaceLayoutStore((state) => state.observePullRequest); + const identity = getPullRequestIdentity(status); + + useEffect(() => { + if (input.workspaceKey) { + observePullRequest(input.workspaceKey, identity); + } + }, [identity, input.workspaceKey, observePullRequest]); +} + +export function PullRequestContent(input: { + serverId: string; + workspaceId?: string | null; + cwd: string; + prPane: UsePrPaneDataResult; +}) { + const { t } = useTranslation(); + const toast = useToast(); + const refresh = useCheckoutGitActionsStore((state) => state.refresh); + const onRetry = useCallback(() => { + void refresh({ serverId: input.serverId, cwd: input.cwd }).catch((error) => { + toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); + }); + }, [input.cwd, input.serverId, refresh, t, toast]); + + if (input.prPane.data) { + return ( + + ); + } + const contentState = resolvePullRequestContentState(input.prPane); + if (contentState === "error") { + return ; + } + if (contentState === "loading") { + return ; + } + return ( + + {t("panels.pullRequest.emptyTitle")} + {t("panels.pullRequest.emptyDescription")} + + ); +} + +export function usePullRequestData(input: { + serverId: string; + cwd: string; + enabled?: boolean; + timelineEnabled?: boolean; +}) { + return usePrPaneData(input); +} + +const EMPTY_STYLE = { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 8, + padding: 24, +} as const; + +const EMPTY_TITLE_STYLE = { fontWeight: "600" } as const; diff --git a/packages/app/src/panels/pull-request/state.ts b/packages/app/src/panels/pull-request/state.ts new file mode 100644 index 000000000..cf968bc05 --- /dev/null +++ b/packages/app/src/panels/pull-request/state.ts @@ -0,0 +1,28 @@ +import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages"; + +type PullRequestStatus = CheckoutPrStatusResponse["payload"]["status"]; + +export function getPullRequestIdentity(status: PullRequestStatus): string | null { + if (!status) { + return null; + } + const url = status.url.trim(); + if (url) { + return `url:${url}`; + } + if (status.number === undefined) { + return null; + } + return `repo:${status.forge}:${status.repoOwner ?? ""}/${status.repoName ?? ""}#${status.number}`; +} + +export function resolvePullRequestContentState(input: { + data: unknown; + error: Error | null; + isLoading: boolean; +}): "ready" | "error" | "loading" | "empty" { + if (input.data) return "ready"; + if (input.error) return "error"; + if (input.isLoading) return "loading"; + return "empty"; +} diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index 0e9ed7ad8..972210134 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -3,10 +3,12 @@ import { browserPanelRegistration } from "@/desktop/browser/panel"; import { commitDiffPanelRegistration, workingDiffPanelRegistration } from "@/panels/diff-panel"; import { draftPanelRegistration } from "@/panels/draft-panel"; import { filePanelRegistration } from "@/panels/file-panel"; +import { filesPanelRegistration } from "@/panels/files-panel"; import { registerPanel } from "@/panels/panel-registry"; import { setupPanelRegistration } from "@/panels/setup-panel"; import { terminalPanelRegistration } from "@/panels/terminal-panel"; import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel"; +import { pullRequestPanelRegistration } from "@/panels/pull-request-panel"; let panelsRegistered = false; @@ -21,6 +23,8 @@ export function ensurePanelsRegistered(): void { registerPanel(terminalPanelRegistration); registerPanel(browserPanelRegistration); registerPanel(filePanelRegistration); + registerPanel(filesPanelRegistration); + registerPanel(pullRequestPanelRegistration); registerPanel(commitDiffPanelRegistration); registerPanel(workingDiffPanelRegistration); panelsRegistered = true; diff --git a/packages/app/src/panels/use-add-file-to-chat.ts b/packages/app/src/panels/use-add-file-to-chat.ts new file mode 100644 index 000000000..8261a2b0a --- /dev/null +++ b/packages/app/src/panels/use-add-file-to-chat.ts @@ -0,0 +1,34 @@ +import { useCallback, useMemo } from "react"; +import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; +import { resolveFocusedChatTarget } from "@/composer/focused-chat-target"; +import { useDraftStore } from "@/stores/draft-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; + +export function useAddFileToChat(input: { serverId: string; workspaceId?: string | null }) { + const workspaceKey = input.workspaceId + ? buildWorkspaceTabPersistenceKey({ serverId: input.serverId, workspaceId: input.workspaceId }) + : null; + const layout = useWorkspaceLayoutStore((state) => + workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined, + ); + const focusTab = useWorkspaceLayoutStore((state) => state.focusTab); + const focusedChat = useMemo( + () => resolveFocusedChatTarget({ serverId: input.serverId, layout }), + [input.serverId, layout], + ); + const addFile = useCallback( + async (filePath: string) => { + if (!focusedChat || !workspaceKey) { + return; + } + await useDraftStore.getState().attachWorkspaceFile({ + draftKey: focusedChat.draftKey, + attachment: createWorkspaceFileAttachment({ path: filePath }), + }); + focusTab(workspaceKey, focusedChat.tabId); + }, + [focusTab, focusedChat, workspaceKey], + ); + return { addFile, canAddToChat: focusedChat !== null }; +} diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 82da244d9..23bd94123 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -20,13 +20,15 @@ import { CopyX, ArrowLeftToLine, ArrowRightToLine, - ChevronDown, Columns2, Copy, Pencil, RotateCw, Rows2, Globe, + FileDiff, + FolderTree, + GitPullRequest, Plus, SquarePen, SquareTerminal, @@ -59,7 +61,7 @@ import { import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; -import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; +import { WORKSPACE_SECONDARY_HEADER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; import { @@ -75,6 +77,7 @@ import { type WorkspaceTabMenuLabels, } from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import type { Theme } from "@/styles/theme"; import { RenderProfile } from "@/utils/render-profiler"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; @@ -89,10 +92,25 @@ import { runPinnedTabTarget, type TabTargetHandlers } from "@/workspace-pins/run import type { PinnedTabTarget } from "@/workspace-pins/target"; import { PinnedTargetsRow } from "@/workspace-pins/pinned-targets-row"; import { PinnableMenuItem } from "@/workspace-pins/pinnable-menu-item"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import { useCheckoutStatusQuery } from "@/git/use-status-query"; +import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; const DROPDOWN_WIDTH = 220; -const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36; +// Chip geometry. `layoutMetrics` measures tabs from these same numbers, so a chip that changes +// shape without changing them mis-measures and drops the row into the overflow-scroll fallback at +// the wrong width. Keep them together. +const TAB_CHIP_HEIGHT = 26; +const TAB_CHIP_HORIZONTAL_PADDING = 8; +const TAB_CHIP_GAP = 4; +const TAB_ROW_PADDING_HORIZONTAL = 4; +const TAB_ICON_WIDTH = 14; +const TAB_DROP_INDICATOR_WIDTH = 4; +const TAB_MAX_WIDTH = 160; +const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedX = withUnistyles(X); @@ -104,21 +122,29 @@ const ThemedCopyX = withUnistyles(CopyX); const ThemedPencil = withUnistyles(Pencil); const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquareTerminal = withUnistyles(SquareTerminal); -const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedGlobe = withUnistyles(Globe); const ThemedColumns2 = withUnistyles(Columns2); const ThemedRows2 = withUnistyles(Rows2); const ThemedPlus = withUnistyles(Plus); +const ThemedFileDiff = withUnistyles(FileDiff); +const ThemedFolderTree = withUnistyles(FolderTree); +const ThemedGitPullRequest = withUnistyles(GitPullRequest); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const AGENT_ICON = ; const TERMINAL_ICON = ; const BROWSER_ICON = ; +const CHANGES_ICON = ; +const FILES_ICON = ; +const PULL_REQUEST_ICON = ; const DRAFT_TARGET: PinnedTabTarget = { kind: "draft" }; const TERMINAL_TARGET: PinnedTabTarget = { kind: "terminal" }; const BROWSER_TARGET: PinnedTabTarget = { kind: "browser" }; +const CHANGES_TARGET = { kind: "working_diff" } as const; +const FILES_TARGET = { kind: "files" } as const; +const PULL_REQUEST_TARGET = { kind: "pull_request" } as const; function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) { return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; @@ -169,67 +195,32 @@ function PinnableProfileMenuItem({ profile, disabled, onLaunch }: PinnableProfil ); } -interface WorkspaceInlineAddTabButtonProps { - shortcutKeys: ShortcutKey[][] | null; - onCreateAgentTab: () => void; - onLayout: (event: LayoutChangeEvent) => void; -} - -function WorkspaceInlineAddTabButton({ - shortcutKeys, - onCreateAgentTab, - onLayout, -}: WorkspaceInlineAddTabButtonProps) { - const { t } = useTranslation(); - const tooltipText = t("workspace.tabs.actions.newAgent"); - - return ( - - - - - - - - {tooltipText} - {shortcutKeys ? ( - - ) : null} - - - - - ); -} - -interface WorkspaceTabRowExtrasProps { +interface TabTargetLauncherOptions { + normalizedServerId: string; onCreateAgentTab: () => void; onCreateTerminal: () => void; onCreateBrowser: () => void; + onOpenChanges: () => void; + onOpenFiles: () => void; + onOpenPullRequest: () => void; onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void; - onEditProfiles: () => void; - normalizedServerId: string; - showCreateBrowserTab: boolean; - terminalDisabled: boolean; } -function WorkspaceTabRowExtras({ +/** + * The `+` menu and the pinned-targets row launch the same targets but sit in different parts of + * the row, so each owns its own subscription rather than re-rendering the whole row from a shared + * one. `useDaemonConfig` is a cached query, so the second caller is free. + */ +function useTabTargetLauncher({ + normalizedServerId, onCreateAgentTab, onCreateTerminal, onCreateBrowser, + onOpenChanges, + onOpenFiles, + onOpenPullRequest, onCreateTerminalWithProfile, - onEditProfiles, - normalizedServerId, - showCreateBrowserTab, - terminalDisabled, -}: WorkspaceTabRowExtrasProps) { - const { t } = useTranslation(); +}: TabTargetLauncherOptions) { const { config } = useDaemonConfig(normalizedServerId); const profiles = useMemo( () => resolveTerminalProfiles(config?.terminalProfiles), @@ -241,9 +232,20 @@ function WorkspaceTabRowExtras({ createDraft: onCreateAgentTab, createTerminal: onCreateTerminal, createBrowser: onCreateBrowser, + openChanges: onOpenChanges, + openFiles: onOpenFiles, + openPullRequest: onOpenPullRequest, createTerminalWithProfile: onCreateTerminalWithProfile, }), - [onCreateAgentTab, onCreateBrowser, onCreateTerminal, onCreateTerminalWithProfile], + [ + onCreateAgentTab, + onCreateBrowser, + onCreateTerminal, + onCreateTerminalWithProfile, + onOpenChanges, + onOpenFiles, + onOpenPullRequest, + ], ); const onLaunch = useCallback( @@ -253,32 +255,74 @@ function WorkspaceTabRowExtras({ [handlers, profiles], ); - const launchers = usePinnedLaunchers({ serverId: normalizedServerId, onLaunch }); + return { profiles, onLaunch }; +} + +interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions { + shortcutKeys: ShortcutKey[][] | null; + onEditProfiles: () => void; + showCreateBrowserTab: boolean; + terminalDisabled: boolean; + isGit: boolean; + showPullRequest: boolean; + onLayout: (event: LayoutChangeEvent) => void; +} + +function WorkspaceNewTabButton({ + shortcutKeys, + onCreateAgentTab, + onCreateTerminal, + onCreateBrowser, + onOpenChanges, + onOpenFiles, + onOpenPullRequest, + onCreateTerminalWithProfile, + onEditProfiles, + normalizedServerId, + showCreateBrowserTab, + terminalDisabled, + isGit, + showPullRequest, + onLayout, +}: WorkspaceNewTabButtonProps) { + const { t } = useTranslation(); + const { profiles, onLaunch } = useTabTargetLauncher({ + normalizedServerId, + onCreateAgentTab, + onCreateTerminal, + onCreateBrowser, + onCreateTerminalWithProfile, + onOpenChanges, + onOpenFiles, + onOpenPullRequest, + }); + const tooltipText = t("workspace.tabs.actions.newTab"); return ( - <> + - + - {t("workspace.tabs.actions.moreActions")} + {tooltipText} - + ) : null} + {isGit ? ( + + ) : null} + + {showPullRequest ? ( + + ) : null} {t("workspace.tabs.actions.terminalProfilesMenu")} {profiles.map((profile) => ( @@ -314,11 +383,24 @@ function WorkspaceTabRowExtras({ - - + ); } +function WorkspacePinnedTargets( + options: TabTargetLauncherOptions & { isGit: boolean; showPullRequest: boolean }, +) { + const { onLaunch } = useTabTargetLauncher(options); + const launchers = usePinnedLaunchers({ + serverId: options.normalizedServerId, + onLaunch, + isGit: options.isGit, + hasPullRequest: options.showPullRequest, + }); + + return ; +} + function TabContextMenuItem({ entry, }: { @@ -445,7 +527,15 @@ interface WorkspaceDesktopTabsRowProps { function getFallbackTabLabel( tab: WorkspaceTabDescriptor, - labels: { newAgent: string; setup: string; terminal: string; agent: string; changes: string }, + labels: { + newAgent: string; + setup: string; + terminal: string; + agent: string; + changes: string; + files: string; + pullRequest: string; + }, ): string { if (tab.target.kind === "draft") { return labels.newAgent; @@ -462,6 +552,12 @@ function getFallbackTabLabel( if (tab.target.kind === "working_diff") { return labels.changes; } + if (tab.target.kind === "files") { + return labels.files; + } + if (tab.target.kind === "pull_request") { + return labels.pullRequest; + } return labels.agent; } @@ -487,16 +583,30 @@ function useMiddleClickClose(onClose: () => void) { return ref; } +/** The chip fill the running-status ring has to knock out of. Mirrors `styles.tab*` exactly. */ +function resolveChipBackdrop({ + isActiveFocused, + isFilled, +}: { + isActiveFocused: boolean; + isFilled: boolean; +}): SurfaceBackdrop { + if (isActiveFocused) return "surface2"; + return isFilled ? "surface1" : "surface0"; +} + function TabHandleContent({ presentation, isHighlighted, showLabel, + backdrop, tabLabelSkeletonStyle, tabLabelStyle, }: { presentation: WorkspaceTabPresentation; isHighlighted: boolean; showLabel: boolean; + backdrop: SurfaceBackdrop; tabLabelSkeletonStyle: React.ComponentProps["style"]; tabLabelStyle: React.ComponentProps["style"]; }) { @@ -508,7 +618,7 @@ function TabHandleContent({ return ( - + {showLabel && presentation.titleState === "loading" ? ( @@ -562,9 +672,18 @@ function TabChip({ const middleClickRef = useMiddleClickClose( useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]), ); + const isCompact = useIsCompactFormFactor(); const [hovered, setHovered] = useState(false); - const isHighlighted = isActive || hovered || isCloseHovered; - const showTrailingAffordance = showCloseButton || presentation.modified; + // An active tab in a pane that does not have focus stays legible but quiet: it keeps the fill of + // a hovered chip and the muted label, so only one chip in the window reads as the live one. + const isActiveFocused = isActive && isFocused; + const isHovered = hovered || isCloseHovered; + const isHighlighted = isActiveFocused || isHovered; + const chipBackdrop: SurfaceBackdrop = resolveChipBackdrop({ + isActiveFocused, + isFilled: isActive || isHovered, + }); + const showCloseControl = showCloseButton && (isHovered || isNative || isCompact || isClosingTab); const closeButtonDragBlockers = isWeb ? ({ onPointerDown: (event: { stopPropagation?: () => void }) => { @@ -579,6 +698,9 @@ function TabChip({ const tabChipStyle = useCallback( () => [ styles.tab, + isActiveFocused && styles.tabActive, + isActive && !isFocused && styles.tabActiveUnfocused, + !isActive && isHovered && styles.tabHovered, isWeb && isDragging && ({ cursor: "grabbing" } as object), { minWidth: resolvedTabWidth, @@ -586,14 +708,14 @@ function TabChip({ maxWidth: resolvedTabWidth, }, ], - [isDragging, resolvedTabWidth], + [isActive, isActiveFocused, isDragging, isFocused, isHovered, resolvedTabWidth], ); - const handleTabHoverIn = useCallback(() => { + const handleTabPointerEnter = useCallback(() => { setHovered(true); }, []); - const handleTabHoverOut = useCallback(() => { + const handleTabPointerLeave = useCallback(() => { setHovered(false); }, []); @@ -621,38 +743,20 @@ function TabChip({ [onCloseTab, tab.tabId], ); - const closeButtonStyle = useCallback( - ({ hovered: isButtonHovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.tabCloseButton, - styles.tabCloseButtonShown, - (Boolean(isButtonHovered) || pressed) && styles.tabCloseButtonActive, - ], - [], - ); - const tabAccessibilityState = useMemo(() => ({ selected: isActive }), [isActive]); - const tabFocusIndicatorStyle = useMemo( - () => [styles.tabFocusIndicator, !isFocused && styles.tabFocusIndicatorUnfocused], - [isFocused], - ); - const tabLabelSkeletonStyle = useMemo( - () => [ - styles.tabLabelSkeleton, - showTrailingAffordance && styles.tabLabelSkeletonWithCloseButton, - ], - [showTrailingAffordance], - ); + const tabLabelSkeletonStyle = styles.tabLabelSkeleton; const tabLabelStyle = useMemo( - () => [ - styles.tabLabel, - isHighlighted && styles.tabLabelActive, - showTrailingAffordance && styles.tabLabelWithCloseButton, - ], - [isHighlighted, showTrailingAffordance], + () => [styles.tabLabel, isHighlighted && styles.tabLabelActive], + [isHighlighted], ); return ( - + @@ -663,8 +767,6 @@ function TabChip({ triggerRef={dragHandleProps?.setActivatorNodeRef as unknown as undefined} enabledOnMobile={false} style={tabChipStyle} - onHoverIn={handleTabHoverIn} - onHoverOut={handleTabHoverOut} onPressIn={handleNavigateTab} onPress={handleNavigateTab} accessibilityRole="button" @@ -672,53 +774,22 @@ function TabChip({ accessibilityState={tabAccessibilityState} aria-selected={isActive} > - {isActive && } - - {showTrailingAffordance ? ( - - {({ hovered: closeHovered, pressed }) => { - const highlighted = closeHovered || pressed; - if (isClosingTab) { - return ( - - ); - } - if (highlighted || !presentation.modified) { - return ( - - ); - } - return ( - - ); - }} - + + ) : null} @@ -740,6 +811,46 @@ function TabChip({ + {showCloseButton ? ( + + + + {({ hovered: closeHovered, pressed }) => { + const highlighted = closeHovered || pressed; + if (isClosingTab) { + return ( + + ); + } + return ( + + ); + }} + + + ) : null} + {menuEntries.map((entry) => entry.kind === "separator" ? ( @@ -798,6 +909,19 @@ export function WorkspaceDesktopTabsRow({ const [tabsActionsWidth, setTabsActionsWidth] = useState(0); const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState(0); const [exitFocusModeWidth, setExitFocusModeWidth] = useState(0); + const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? ""; + const checkoutStatus = useCheckoutStatusQuery({ + serverId: normalizedServerId, + cwd: workspaceRoot, + }); + const isGit = checkoutStatus.status?.isGit === true; + const showPullRequest = isGit; + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }); + const focusPane = useWorkspaceLayoutStore((state) => state.focusPane); + const openTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => { updateMeasuredWidth(setTabsContainerWidth, event); @@ -824,13 +948,13 @@ export function WorkspaceDesktopTabsRow({ (inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) + (focusModeEnabled ? exitFocusModeWidth : 0), ), - rowPaddingHorizontal: 0, - tabGap: 0, - maxTabWidth: 200, - tabIconWidth: 14, - tabHorizontalPadding: 12, + rowPaddingHorizontal: TAB_ROW_PADDING_HORIZONTAL, + tabGap: TAB_CHIP_GAP, + maxTabWidth: TAB_MAX_WIDTH, + tabIconWidth: TAB_ICON_WIDTH, + tabHorizontalPadding: TAB_CHIP_HORIZONTAL_PADDING, estimatedCharWidth: 7, - closeButtonWidth: 22, + closeButtonWidth: TAB_CLOSE_BUTTON_RESERVED_WIDTH, }), [exitFocusModeWidth, focusModeEnabled, inlineAddButtonWidth, tabsActionsWidth], ); @@ -842,6 +966,8 @@ export function WorkspaceDesktopTabsRow({ terminal: t("workspace.tabs.fallback.terminal"), agent: t("workspace.tabs.fallback.agent"), changes: t("panels.diff.changesLabel"), + files: t("panels.files.label"), + pullRequest: t("panels.pullRequest.label"), }), [t], ); @@ -917,6 +1043,25 @@ export function WorkspaceDesktopTabsRow({ onCreateBrowserTab({ paneId }); }, [onCreateBrowserTab, paneId]); + const openPanelTarget = useCallback( + (target: { kind: "working_diff" } | { kind: "files" } | { kind: "pull_request" }) => { + if (!workspaceKey) { + return; + } + if (paneId) { + focusPane(workspaceKey, paneId); + } + openTabFocused(workspaceKey, target); + }, + [focusPane, openTabFocused, paneId, workspaceKey], + ); + const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]); + const handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]); + const handleOpenPullRequest = useCallback( + () => openPanelTarget(PULL_REQUEST_TARGET), + [openPanelTarget], + ); + const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness; const renderTab = useCallback( @@ -1053,22 +1198,36 @@ export function WorkspaceDesktopTabsRow({ getItemData={getTabDragData} renderItem={renderTab} /> - + - {showPaneSplitActions ? ( <> @@ -1251,7 +1410,8 @@ const styles = StyleSheet.create((theme) => ({ }, tabsContent: { flexDirection: "row", - alignItems: "stretch", + alignItems: "center", + paddingHorizontal: TAB_ROW_PADDING_HORIZONTAL, }, tabsActions: { flexDirection: "row", @@ -1272,18 +1432,30 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[1], }, tab: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - borderRightWidth: 1, - borderRightColor: theme.colors.border, + height: TAB_CHIP_HEIGHT, + paddingHorizontal: TAB_CHIP_HORIZONTAL_PADDING, + borderRadius: theme.borderRadius.md, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], userSelect: "none", }, + tabHovered: { + backgroundColor: theme.colors.surface1, + }, + tabActive: { + backgroundColor: theme.colors.surface2, + }, + tabActiveUnfocused: { + backgroundColor: theme.colors.surface1, + }, + tabHoverFrame: { + position: "relative", + }, tabSlot: { position: "relative", overflow: "visible", + marginHorizontal: TAB_CHIP_GAP / 2, }, tabHandle: { flexDirection: "row", @@ -1294,34 +1466,29 @@ const styles = StyleSheet.create((theme) => ({ userSelect: "none", }, tabIcon: { + width: TAB_ICON_WIDTH, + height: TAB_ICON_WIDTH, flexShrink: 0, + alignItems: "center", + justifyContent: "center", }, - tabFocusIndicator: { - position: "absolute", - top: 0, - left: 0, - right: 0, - height: 2, - backgroundColor: theme.colors.accent, - }, - tabFocusIndicatorUnfocused: { - backgroundColor: theme.colors.borderAccent, - }, + // The chip box stops at the slot's padding box, so the gap between two chips runs from + // -TAB_CHIP_GAP to 0. Centre a TAB_DROP_INDICATOR_WIDTH pill in it. tabDropIndicator: { position: "absolute", - top: theme.spacing[2], - bottom: theme.spacing[2], - width: 5, + top: theme.spacing[0.5], + bottom: theme.spacing[0.5], + width: TAB_DROP_INDICATOR_WIDTH, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.accent, zIndex: 10, pointerEvents: "none", }, tabDropIndicatorBefore: { - left: -3, + left: -TAB_CHIP_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2, }, tabDropIndicatorAfter: { - right: -3, + right: -TAB_CHIP_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2, }, tabLabel: { flexShrink: 1, @@ -1341,28 +1508,44 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface3, opacity: 0.9, }, - tabLabelSkeletonWithCloseButton: { - width: LOADING_TAB_LABEL_SKELETON_WIDTH, - }, - tabLabelWithCloseButton: { - paddingRight: 0, - }, tabLabelActive: { color: theme.colors.foreground, }, + tabTrailingOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + width: 48, + borderTopRightRadius: theme.borderRadius.md, + borderBottomRightRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + zIndex: 2, + }, + tabTrailingOverlayShown: { + opacity: 1, + }, + tabTrailingOverlayHidden: { + opacity: 0, + }, tabCloseButton: { + position: "absolute", + right: 4, width: 18, height: 18, - marginLeft: 0, borderRadius: theme.borderRadius.sm, alignItems: "center", justifyContent: "center", + zIndex: 1, }, - tabCloseButtonShown: { - opacity: 1, - }, - tabCloseButtonActive: { - backgroundColor: theme.colors.surface3, + tabModifiedIndicator: { + position: "absolute", + right: 8, + top: 0, + bottom: 0, + alignItems: "center", + justifyContent: "center", }, tabModifiedDot: { width: 8, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 413ef3bda..01519b9e5 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -74,6 +74,8 @@ import { traceInstant } from "@/performance/native-trace"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { collectAllTabs, + createDefaultLayout, + findPaneById, getFocusedBrowserId, type WorkspaceLayout, useWorkspaceLayoutStore, @@ -152,10 +154,7 @@ import { deriveWorkspaceAgentVisibility, workspaceAgentVisibilityEqual, } from "@/workspace-tabs/agent-visibility"; -import { - deriveWorkspacePaneState, - resolveSideFileOpenPlacement, -} from "@/screens/workspace/workspace-pane-state"; +import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; import { buildWorkspacePaneContentModel, WorkspacePaneContent, @@ -204,6 +203,7 @@ 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"; const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000; const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; @@ -344,6 +344,8 @@ function getFallbackTabOptionLabel( browser: string; agent: string; changes: string; + files: string; + pullRequest: string; }, ): string { if (tab.target.kind === "draft") { @@ -364,6 +366,12 @@ function getFallbackTabOptionLabel( if (tab.target.kind === "working_diff") { return labels.changes; } + if (tab.target.kind === "files") { + return labels.files; + } + if (tab.target.kind === "pull_request") { + return labels.pullRequest; + } if (tab.target.kind === "commit_diff") { return tab.target.sha.slice(0, 7); } @@ -379,6 +387,8 @@ function getFallbackTabOptionDescription( terminal: string; browser: string; changes: string; + files: string; + pullRequest: string; }, ): string { if (tab.target.kind === "draft") { @@ -405,6 +415,12 @@ function getFallbackTabOptionDescription( if (tab.target.kind === "working_diff") { return labels.changes; } + if (tab.target.kind === "files") { + return labels.files; + } + if (tab.target.kind === "pull_request") { + return labels.pullRequest; + } return tab.target.path; } @@ -603,6 +619,8 @@ function MobileWorkspaceTabOption({ browser: t("workspace.tabs.fallback.browser"), agent: t("workspace.tabs.fallback.agent"), changes: t("panels.diff.changesLabel"), + files: t("panels.files.label"), + pullRequest: t("panels.pullRequest.label"), }), [t], ); @@ -1627,6 +1645,75 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): return `${serverId}:${workspaceId}`; } +function canObservePullRequest(isRouteFocused: boolean, isGitCheckout: boolean): boolean { + return isRouteFocused && isGitCheckout; +} + +interface ToggleWorkspaceExplorerPaneInput { + isCompact: boolean; + persistenceKey: string | null; + checkout: ExplorerCheckoutContext | null; + toggleCompact: (input: { isCompact: boolean; checkout: ExplorerCheckoutContext }) => void; +} + +function toggleWorkspaceExplorerPane(input: ToggleWorkspaceExplorerPaneInput): void { + if (input.isCompact || !supportsDesktopPaneSplits()) { + if (input.checkout) { + input.toggleCompact({ isCompact: input.isCompact, checkout: input.checkout }); + } + return; + } + if (!input.persistenceKey) { + return; + } + + const store = useWorkspaceLayoutStore.getState(); + const layout = store.layoutByWorkspace[input.persistenceKey] ?? createDefaultLayout(); + const parentTabId = findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null; + const explorerPaneId = store.explorerPaneIdByWorkspace[input.persistenceKey] ?? null; + const explorerPane = findPaneById(layout.root, explorerPaneId); + if (explorerPane) { + if (explorerPane.hidden === true) { + store.showPane(input.persistenceKey, explorerPane.id); + } else { + store.hidePane(input.persistenceKey, explorerPane.id); + } + return; + } + + const ensuredPane = store.ensureExplorerPane(input.persistenceKey); + if (!ensuredPane) { + return; + } + const tabId = parentTabId + ? store.openChildTabFocused(input.persistenceKey, { kind: "working_diff" }, parentTabId) + : store.openTabFocused(input.persistenceKey, { kind: "working_diff" }); + if (tabId) { + store.moveTabToPane(input.persistenceKey, tabId, ensuredPane.paneId); + } +} + +function isWorkspaceExplorerPaneOpen( + state: ReturnType, + persistenceKey: string | null, +): boolean { + if (!persistenceKey) { + return false; + } + const paneId = state.explorerPaneIdByWorkspace[persistenceKey]; + const layout = state.layoutByWorkspace[persistenceKey]; + const pane = paneId && layout ? findPaneById(layout.root, paneId) : null; + return Boolean(pane && pane.hidden !== true); +} + +function resolveExplorerOpen( + isCompact: boolean, + compactExplorerOpen: boolean, + desktopExplorerOpen: boolean, +): boolean { + return isCompact ? compactExplorerOpen : desktopExplorerOpen; +} + interface WorkspaceTerminalTabActionsInput { persistenceKey: string | null; focusWorkspacePane: (workspaceKey: string, paneId: string) => void; @@ -1900,8 +1987,14 @@ function WorkspaceScreenContent({ workspace: workspaceDescriptor, checkoutState: workspaceHeaderCheckoutState, }); + usePullRequestAutoAdd({ + workspaceKey: persistenceKey, + serverId: normalizedServerId, + cwd: workspaceDirectory, + enabled: canObservePullRequest(isRouteFocused, isGitCheckout), + }); - const isExplorerOpen = usePanelStore((state) => + const isCompactExplorerOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }), ); const toggleFileExplorerForCheckout = usePanelStore( @@ -1920,15 +2013,14 @@ function WorkspaceScreenContent({ }; }, [isGitCheckout, normalizedServerId, workspaceDirectory]); - const handleToggleExplorer = useCallback(() => { - if (!activeExplorerCheckout) { - return; - } - toggleFileExplorerForCheckout({ - isCompact: isMobile, - checkout: activeExplorerCheckout, - }); - }, [activeExplorerCheckout, isMobile, toggleFileExplorerForCheckout]); + const isDesktopExplorerOpen = useWorkspaceLayoutStore((state) => + isWorkspaceExplorerPaneOpen(state, persistenceKey), + ); + const isExplorerOpen = resolveExplorerOpen( + isMobile, + isCompactExplorerOpen, + isDesktopExplorerOpen, + ); const hasDiffStat = useMemo(() => Boolean(workspaceDescriptor?.diffStat), [workspaceDescriptor]); const explorerToggleStyle = useCallback( @@ -1996,6 +2088,14 @@ function WorkspaceScreenContent({ const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane); const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty); const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane); + const handleToggleExplorer = useCallback(() => { + toggleWorkspaceExplorerPane({ + isCompact: isMobile, + persistenceKey, + checkout: activeExplorerCheckout, + toggleCompact: toggleFileExplorerForCheckout, + }); + }, [activeExplorerCheckout, isMobile, persistenceKey, toggleFileExplorerForCheckout]); const paneFocusSuppressedRef = useRef(false); const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit); const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); @@ -2373,58 +2473,30 @@ function WorkspaceScreenContent({ ], ); - const handleOpenFileFromChatInSidePane = useCallback( - (input: { - location: WorkspaceFileLocation; - sourcePaneId?: string; - parentTabId?: string | null; - }) => { + const handleOpenAssistantFileInExplorerPane = useCallback( + (input: { location: WorkspaceFileLocation; parentTabId?: string | null }) => { const location = normalizeWorkspaceFileLocation(input.location); if (!location) { return; } - if (!persistenceKey || isMobile || !input.sourcePaneId) { + if (!persistenceKey || isMobile || !supportsDesktopPaneSplits()) { handleOpenFileFromChat(location, { parentTabId: input.parentTabId }); return; } const target: WorkspaceTabTarget = createWorkspaceFileTabTarget(location); - const placement = resolveSideFileOpenPlacement({ - layout: workspaceLayout, - sourcePaneId: input.sourcePaneId, - tabs: uiTabs, - target, - }); - if (placement.kind === "focus-side-pane") { - focusWorkspacePane(persistenceKey, placement.paneId); - } else if (placement.kind === "split-side-pane") { - splitWorkspacePaneEmpty(persistenceKey, { - targetPaneId: placement.paneId, - position: "right", + const tabId = useWorkspaceLayoutStore + .getState() + .openTabInExplorerPaneFocused(persistenceKey, { + target, + parentTabId: input.parentTabId, }); - } - - const tabId = input.parentTabId - ? openWorkspaceChildTabFocused(persistenceKey, target, input.parentTabId) - : openWorkspaceTabFocused(persistenceKey, target); if (tabId) { requestFileNavigation(tabId); navigateToTabId(tabId); } }, - [ - handleOpenFileFromChat, - isMobile, - focusWorkspacePane, - navigateToTabId, - openWorkspaceChildTabFocused, - openWorkspaceTabFocused, - persistenceKey, - requestFileNavigation, - splitWorkspacePaneEmpty, - uiTabs, - workspaceLayout, - ], + [handleOpenFileFromChat, isMobile, navigateToTabId, persistenceKey, requestFileNavigation], ); const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({ @@ -2442,9 +2514,8 @@ function WorkspaceScreenContent({ focusWorkspacePane(persistenceKey, paneId); } if (request.disposition === "side") { - handleOpenFileFromChatInSidePane({ + handleOpenAssistantFileInExplorerPane({ location: request.location, - sourcePaneId: paneId ?? undefined, parentTabId, }); return; @@ -2523,6 +2594,8 @@ function WorkspaceScreenContent({ browser: t("workspace.tabs.fallback.browser"), agent: t("workspace.tabs.fallback.agent"), changes: t("panels.diff.changesLabel"), + files: t("panels.files.label"), + pullRequest: t("panels.pullRequest.label"), }), [t], ); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index 2b608fc7c..c31b4c705 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -6,11 +6,11 @@ const metrics = { actionsReservedWidth: 120, rowPaddingHorizontal: 8, tabGap: 4, - maxTabWidth: 200, + maxTabWidth: 160, tabIconWidth: 14, - tabHorizontalPadding: 12, + tabHorizontalPadding: 8, estimatedCharWidth: 7, - closeButtonWidth: 22, + closeButtonWidth: 0, }; describe("computeWorkspaceTabLayout", () => { @@ -25,7 +25,7 @@ describe("computeWorkspaceTabLayout", () => { expect(result.requiresHorizontalScrollFallback).toBe(false); expect(result.items).toHaveLength(3); expect(result.items.every((item) => item.showLabel)).toBe(true); - expect(result.items.map((item) => item.width)).toEqual([200, 200, 200]); + expect(result.items.map((item) => item.width)).toEqual([160, 160, 160]); }); it("shrinks equal-width tabs proportionally to fit the pane", () => { @@ -41,7 +41,7 @@ describe("computeWorkspaceTabLayout", () => { expect(result.items.every((item) => item.showLabel)).toBe(true); }); - it("uses the split width for evenly sized tabs when space is available", () => { + it("keeps evenly sized tabs at the cap when the split width exceeds it", () => { const result = computeWorkspaceTabLayout({ viewportWidth: 743, tabLabelLengths: [8, 8, 8, 8], @@ -55,32 +55,32 @@ describe("computeWorkspaceTabLayout", () => { expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(false); - expect(result.items.map((item) => item.width)).toEqual([175, 175, 175, 175]); + expect(result.items.map((item) => item.width)).toEqual([160, 160, 160, 160]); }); it("collapses to icon-only before allowing horizontal scroll fallback", () => { const result = computeWorkspaceTabLayout({ - viewportWidth: 388, + viewportWidth: 268, tabLabelLengths: [14, 14, 14, 14], metrics, }); expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(false); - expect(result.items.map((item) => item.width)).toEqual([60, 60, 60, 60]); + expect(result.items.map((item) => item.width)).toEqual([30, 30, 30, 30]); expect(result.items.every((item) => !item.showLabel)).toBe(true); }); it("allows horizontal scroll only when icon-only tabs still cannot fit", () => { const result = computeWorkspaceTabLayout({ - viewportWidth: 300, + viewportWidth: 267, tabLabelLengths: [14, 14, 14, 14], metrics, }); expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(true); - expect(result.items.map((item) => item.width)).toEqual([60, 60, 60, 60]); + expect(result.items.map((item) => item.width)).toEqual([30, 30, 30, 30]); expect(result.items.every((item) => !item.showLabel)).toBe(true); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index d6e41e3b3..90ff3dfc3 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -151,6 +151,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "working_diff") { return `workspace-working-diff-close-${encodeFilePathForPathSegment(buildDeterministicWorkspaceTabId(tab.target))}`; } + if (tab.target.kind === "files" || tab.target.kind === "pull_request") { + return `workspace-${tab.target.kind}-close`; + } return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; } diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index 6695ee5f4..bc05114c4 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -11,16 +11,16 @@ import { type ExplorerCheckoutContext } from "../explorer-checkout-context"; import { buildOpenFileExplorerPatch, buildToggleFileExplorerPatch, - clampExplorerFilesSplitRatio, + clampTreeRailWidth, clampExplorerWidth, clampSidebarWidth, - DEFAULT_EXPLORER_FILES_SPLIT_RATIO, + DEFAULT_TREE_RAIL_WIDTH, DEFAULT_EXPLORER_SIDEBAR_WIDTH, DEFAULT_SIDEBAR_WIDTH, - MAX_EXPLORER_FILES_SPLIT_RATIO, + MAX_TREE_RAIL_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH, - MIN_EXPLORER_FILES_SPLIT_RATIO, + MIN_TREE_RAIL_WIDTH, MIN_EXPLORER_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, migratePanelState, @@ -51,15 +51,16 @@ export type { SortOption, } from "./state"; export { - DEFAULT_EXPLORER_FILES_SPLIT_RATIO, + DEFAULT_TREE_RAIL_WIDTH, DEFAULT_EXPLORER_SIDEBAR_WIDTH, DEFAULT_SIDEBAR_WIDTH, - MAX_EXPLORER_FILES_SPLIT_RATIO, + MAX_TREE_RAIL_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH, - MIN_EXPLORER_FILES_SPLIT_RATIO, + MIN_TREE_RAIL_WIDTH, MIN_EXPLORER_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, + clampTreeRailWidth, selectIsAgentListOpen, selectIsFileExplorerOpen, selectPanelVisibility, @@ -88,7 +89,7 @@ export interface PanelState { explorerWidth: number; explorerSortOption: SortOption; explorerShowHiddenFiles: boolean; - explorerFilesSplitRatio: number; + treeRailWidth: number; // Actions toggleFocusMode: () => void; @@ -117,7 +118,7 @@ export interface PanelState { setExplorerWidth: (width: number) => void; setExplorerSortOption: (option: SortOption) => void; toggleExplorerShowHiddenFiles: () => void; - setExplorerFilesSplitRatio: (ratio: number) => void; + setTreeRailWidth: (width: number) => void; } const DEFAULT_DESKTOP_OPEN = isWeb; @@ -153,7 +154,7 @@ export const usePanelStore = create()( explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH, explorerSortOption: "name", explorerShowHiddenFiles: true, - explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO, + treeRailWidth: DEFAULT_TREE_RAIL_WIDTH, toggleFocusMode: () => set((state) => ({ @@ -303,16 +304,11 @@ export const usePanelStore = create()( setExplorerSortOption: (option) => set({ explorerSortOption: option }), toggleExplorerShowHiddenFiles: () => set((state) => ({ explorerShowHiddenFiles: !state.explorerShowHiddenFiles })), - setExplorerFilesSplitRatio: (ratio) => - set({ - explorerFilesSplitRatio: Number.isFinite(ratio) - ? clampExplorerFilesSplitRatio(ratio) - : DEFAULT_EXPLORER_FILES_SPLIT_RATIO, - }), + setTreeRailWidth: (width) => set({ treeRailWidth: clampTreeRailWidth(width) }), }), { name: "panel-state", - version: 12, + version: 13, storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema), migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }), partialize: (state) => ({ @@ -326,7 +322,7 @@ export const usePanelStore = create()( explorerWidth: state.explorerWidth, explorerSortOption: state.explorerSortOption, explorerShowHiddenFiles: state.explorerShowHiddenFiles, - explorerFilesSplitRatio: state.explorerFilesSplitRatio, + treeRailWidth: state.treeRailWidth, }), }, ), @@ -356,7 +352,7 @@ export function usePanelState(isMobile: boolean) { const explorerTabByCheckout = usePanelStore((state) => state.explorerTabByCheckout); const explorerWidth = usePanelStore((state) => state.explorerWidth); const explorerSortOption = usePanelStore((state) => state.explorerSortOption); - const explorerFilesSplitRatio = usePanelStore((state) => state.explorerFilesSplitRatio); + const treeRailWidth = usePanelStore((state) => state.treeRailWidth); const setExplorerTab = usePanelStore((state) => state.setExplorerTab); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const activateExplorerTabForCheckout = usePanelStore( @@ -364,7 +360,7 @@ export function usePanelState(isMobile: boolean) { ); const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth); const setExplorerSortOption = usePanelStore((state) => state.setExplorerSortOption); - const setExplorerFilesSplitRatio = usePanelStore((state) => state.setExplorerFilesSplitRatio); + const setTreeRailWidth = usePanelStore((state) => state.setTreeRailWidth); return { isAgentListOpen, @@ -377,12 +373,12 @@ export function usePanelState(isMobile: boolean) { explorerTabByCheckout, explorerWidth, explorerSortOption, - explorerFilesSplitRatio, + treeRailWidth, setExplorerTab, setExplorerTabForCheckout, activateExplorerTabForCheckout, setExplorerWidth, setExplorerSortOption, - setExplorerFilesSplitRatio, + setTreeRailWidth, }; } diff --git a/packages/app/src/stores/panel-store/state.test.ts b/packages/app/src/stores/panel-store/state.test.ts index 729b89252..adf53a97d 100644 --- a/packages/app/src/stores/panel-store/state.test.ts +++ b/packages/app/src/stores/panel-store/state.test.ts @@ -7,6 +7,7 @@ import { import { buildOpenFileExplorerPatch, buildToggleFileExplorerPatch, + DEFAULT_TREE_RAIL_WIDTH, migratePanelState, selectIsAgentListOpen, selectIsFileExplorerOpen, @@ -119,6 +120,13 @@ describe("panel-store migration", () => { expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] }); }); + it("replaces the dead explorer split ratio with the shared tree rail width", () => { + const state = migratePanelState({ explorerFilesSplitRatio: 0.5 }, 12, { isWeb: false }); + + expect(state.explorerFilesSplitRatio).toBeUndefined(); + expect(state.treeRailWidth).toBe(DEFAULT_TREE_RAIL_WIDTH); + }); + 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 611c133b5..bf55f1fd1 100644 --- a/packages/app/src/stores/panel-store/state.ts +++ b/packages/app/src/stores/panel-store/state.ts @@ -31,9 +31,9 @@ export const MIN_EXPLORER_SIDEBAR_WIDTH = 280; // Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint. export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000; -export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38; -export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2; -export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8; +export const DEFAULT_TREE_RAIL_WIDTH = 320; +export const MIN_TREE_RAIL_WIDTH = 200; +export const MAX_TREE_RAIL_WIDTH = 600; export interface PanelVisibilityState { isAgentListOpen: boolean; @@ -70,8 +70,8 @@ export function clampExplorerWidth(width: number): number { return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH); } -export function clampExplorerFilesSplitRatio(ratio: number): number { - return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO); +export function clampTreeRailWidth(width: number): number { + return clampNumber(width, MIN_TREE_RAIL_WIDTH, MAX_TREE_RAIL_WIDTH); } export function selectPanelVisibility( @@ -190,6 +190,7 @@ export const PanelPersistedStateSchema = z.strictObject({ explorerSortOption: z.enum(["name", "modified", "size"]).optional(), explorerShowHiddenFiles: z.boolean().optional(), explorerFilesSplitRatio: z.number().optional(), + treeRailWidth: z.number().optional(), }); type MigratablePanelState = z.infer; @@ -198,11 +199,6 @@ function migratePanelV2Explorer(state: MigratablePanelState, isWeb: boolean): vo if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) { state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH; } - if (typeof state.explorerFilesSplitRatio !== "number") { - state.explorerFilesSplitRatio = DEFAULT_EXPLORER_FILES_SPLIT_RATIO; - } else { - state.explorerFilesSplitRatio = clampExplorerFilesSplitRatio(state.explorerFilesSplitRatio); - } } function migratePanelV3Explorer(state: MigratablePanelState, isWeb: boolean): void { @@ -253,6 +249,15 @@ function migratePanelDesktopFocusMode(state: MigratablePanelState): void { } } +function migrateTreeRailWidth(state: MigratablePanelState, version: number): void { + if (version < 13 || typeof state.treeRailWidth !== "number") { + delete state.explorerFilesSplitRatio; + state.treeRailWidth = DEFAULT_TREE_RAIL_WIDTH; + return; + } + state.treeRailWidth = clampTreeRailWidth(state.treeRailWidth); +} + export function migratePanelState( persistedState: unknown, version: number, @@ -302,6 +307,7 @@ export function migratePanelState( if (typeof state.explorerShowHiddenFiles !== "boolean") { state.explorerShowHiddenFiles = true; } + migrateTreeRailWidth(state, version); 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 5ccf90adc..d68b2e8d9 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -13,6 +13,7 @@ export interface SplitPane { id: string; tabIds: string[]; focusedTabId: string | null; + hidden?: boolean; } export interface SplitGroup { @@ -240,6 +241,7 @@ function createPaneNode(input: { id: string; tabs?: WorkspaceTab[]; focusedTabId?: string | null; + hidden?: boolean; }): SplitNodeInternal { const normalizedTabs = normalizeWorkspaceTabs(input.tabs ?? []); const tabIds = normalizedTabs.map((tab) => tab.tabId); @@ -254,6 +256,7 @@ function createPaneNode(input: { tabs: normalizedTabs, tabIds, focusedTabId, + ...(input.hidden === true ? { hidden: true } : {}), }, }; } @@ -511,7 +514,7 @@ function insertChildIntoGroup( function listPaneIds(node: SplitNodeInternal): string[] { if (node.kind === "pane") { - return [node.pane.id]; + return node.pane.hidden === true ? [] : [node.pane.id]; } const next: string[] = []; for (const child of node.group.children) { @@ -562,6 +565,7 @@ function normalizePaneAfterTabChange(pane: SplitPaneInternal): SplitPaneInternal tabs, tabIds, focusedTabId, + ...(pane.hidden === true ? { hidden: true } : {}), }; } @@ -584,6 +588,7 @@ function normalizePaneNode(rawPane: SplitPaneInternal | undefined): SplitNodeInt id: paneId, tabs: mergedTabs, focusedTabId: trimNonEmpty(rawPane?.focusedTabId) ?? null, + hidden: rawPane?.hidden === true, }); } @@ -756,6 +761,7 @@ function focusTabInPane(root: SplitNodeInternal, paneId: string, tabId: string): pane: normalizePaneAfterTabChange({ ...node.pane, focusedTabId: tabId, + hidden: undefined, }), }; }); @@ -972,7 +978,7 @@ export function collectAllTabs(root: SplitNode): WorkspaceTab[] { export function collectAllPanes(root: SplitNode): SplitPane[] { const internalRoot = asInternalNode(root); if (internalRoot.kind === "pane") { - return [internalRoot.pane]; + return internalRoot.pane.hidden === true ? [] : [internalRoot.pane]; } return internalRoot.group.children.flatMap((child) => collectAllPanes(child)); } @@ -995,6 +1001,7 @@ function stripEphemeralTabsFromNode(node: SplitNodeInternal): SplitNodeInternal id: node.pane.id, tabs: nextTabs, focusedTabId: node.pane.focusedTabId, + hidden: node.pane.hidden, }); } return createGroupNode({ @@ -1028,7 +1035,7 @@ export function getFocusedBrowserId(layout: WorkspaceLayout | null | undefined): return null; } const focusedPane = findPaneById(layout.root, layout.focusedPaneId); - if (!focusedPane?.focusedTabId) { + if (!focusedPane?.focusedTabId || focusedPane.hidden === true) { return null; } const focusedTab = collectAllTabs(layout.root).find( @@ -1221,7 +1228,11 @@ export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout return null; } - if (pane.focusedTabId === input.tabId && layout.focusedPaneId === pane.id) { + if ( + pane.focusedTabId === input.tabId && + layout.focusedPaneId === pane.id && + pane.hidden !== true + ) { return null; } @@ -1448,7 +1459,8 @@ export function splitPaneEmptyInLayout( export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): WorkspaceLayout | null { const layout = asInternalLayout(input.layout); const sourcePane = findPaneContainingTab(layout.root, input.tabId); - if (!sourcePane || !findPaneById(layout.root, input.toPaneId)) { + const targetPane = findPaneById(layout.root, input.toPaneId); + if (!sourcePane || !targetPane || targetPane.hidden === true) { return null; } @@ -1472,19 +1484,59 @@ export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): Worksp } export function focusPaneInLayout(input: FocusPaneInLayoutInput): WorkspaceLayout | null { - if (!findPaneById(input.layout.root, input.paneId)) { + const pane = findPaneById(input.layout.root, input.paneId); + if (!pane) { return null; } - if (input.layout.focusedPaneId === input.paneId) { + if (input.layout.focusedPaneId === input.paneId && pane.hidden !== true) { return null; } return withNormalizedParentTabMap({ - root: input.layout.root, + root: + pane.hidden === true + ? updatePaneInTree(asInternalNode(input.layout.root), { + paneId: input.paneId, + updater: (currentPane) => ({ ...currentPane, hidden: undefined }), + }) + : input.layout.root, focusedPaneId: input.paneId, parentTabIdByTabId: input.layout.parentTabIdByTabId, }); } +export function setPaneHiddenInLayout(input: { + layout: WorkspaceLayout; + paneId: string; + hidden: boolean; +}): WorkspaceLayout | null { + const pane = findPaneById(input.layout.root, input.paneId); + const isHidden = pane?.hidden === true; + if (!pane || isHidden === input.hidden) { + return null; + } + if (input.hidden && collectAllPanes(input.layout.root).length === 1) { + return null; + } + + const root = updatePaneInTree(asInternalNode(input.layout.root), { + paneId: input.paneId, + updater: (currentPane) => ({ + ...currentPane, + hidden: input.hidden ? true : undefined, + }), + }); + const focusedPaneId = + input.hidden && input.layout.focusedPaneId === input.paneId + ? (collectAllPanes(root)[0]?.id ?? null) + : input.layout.focusedPaneId; + + return withNormalizedParentTabMap({ + root, + focusedPaneId, + parentTabIdByTabId: input.layout.parentTabIdByTabId, + }); +} + export function resizeSplitInLayout(input: ResizeSplitInLayoutInput): WorkspaceLayout { const layout = asInternalLayout(input.layout); return withNormalizedParentTabMap({ diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 4951d2868..f38ed31d4 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -84,6 +84,7 @@ function createPane(input: { id: string; tabIds: string[]; focusedTabId?: string | null; + hidden?: boolean; targetsByTabId?: Record; }): SplitNode { const tabs = input.tabIds.map((tabId) => createTab(tabId, input.targetsByTabId?.[tabId])); @@ -93,6 +94,7 @@ function createPane(input: { id: input.id, tabIds: input.tabIds, focusedTabId: input.focusedTabId ?? input.tabIds[input.tabIds.length - 1] ?? null, + ...(input.hidden === true ? { hidden: true } : {}), tabs, } as SplitPane, }; @@ -200,6 +202,14 @@ describe("workspace-layout-store helpers", () => { expect(getFocusedBrowserId({ root, focusedPaneId: "main" })).toBeNull(); }); + + it("keeps tabs in hidden panes while excluding those panes from focus helpers", () => { + const root = createPane({ id: "hidden", tabIds: ["tab-a"], hidden: true }); + + expect(collectAllTabs(root).map((tab) => tab.tabId)).toEqual(["tab-a"]); + expect(collectAllPanes(root)).toEqual([]); + expect(getFocusedBrowserId({ root, focusedPaneId: "hidden" })).toBeNull(); + }); }); describe("workspace-layout-store tree transforms", () => { @@ -314,6 +324,8 @@ describe("workspace-layout-store actions", () => { pinnedAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {}, focusRestorationByWorkspace: {}, + explorerPaneIdByWorkspace: {}, + acknowledgedPullRequestByWorkspace: {}, }); }); @@ -335,6 +347,142 @@ describe("workspace-layout-store actions", () => { await expect(AsyncStorage.getItem("workspace-layout-state")).resolves.not.toBeNull(); }); + 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 + .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); + + await vi.waitFor(async () => { + expect(await AsyncStorage.getItem("workspace-layout-state")).not.toBeNull(); + }); + + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + await restored.persist.rehydrate(); + const state = restored.getState(); + const layout = state.layoutByWorkspace[workspaceKey]; + + expect(layout.focusedPaneId).toBe("main"); + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(focusedAgentTabId); + expect(findPaneById(layout.root, explorer!.paneId)?.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"); + }); + + it("auto-adds each detected pull request once and respects a deliberate close", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + + store.observePullRequest(workspaceKey, null); + expect(store.getWorkspaceTabs(workspaceKey)).toEqual([]); + + 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", + ]); + }); + + it("places an auto-added pull request in the registered explorer pane", () => { + 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("opens assistant files in an ensured explorer pane and reveals it on the next open", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + useWorkspaceLayoutIds("explorer"); + + const tabId = store.openTabInExplorerPaneFocused(workspaceKey, { + target: { kind: "file", path: "/repo/worktree/a.ts" }, + parentTabId: "agent_agent-a", + }); + const createdState = workspaceLayoutStore.getState(); + const explorerPaneId = createdState.explorerPaneIdByWorkspace[workspaceKey]; + + expect(explorerPaneId).toBe("pane_explorer"); + expect( + findPaneContainingTab(createdState.layoutByWorkspace[workspaceKey].root, tabId!)?.id, + ).toBe(explorerPaneId); + 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, { + target: { kind: "file", path: "/repo/worktree/b.ts" }, + }); + const revealedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + + expect(findPaneById(revealedLayout.root, explorerPaneId)?.hidden).toBeUndefined(); + expect(findPaneContainingTab(revealedLayout.root, "file_/repo/worktree/b.ts")?.id).toBe( + explorerPaneId, + ); + }); + + it("moves a canonical duplicate file tab into the revealed explorer pane", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + useWorkspaceLayoutIds("explorer"); + const tabId = store.openTabFocused(workspaceKey, { + kind: "file", + path: "/repo/worktree/a.ts", + }); + const explorerPane = store.ensureExplorerPane(workspaceKey); + store.hidePane(workspaceKey, explorerPane!.paneId); + + const duplicateTabId = store.openTabInExplorerPaneFocused(workspaceKey, { + target: { kind: "file", path: "/repo/worktree/a.ts", lineStart: 12 }, + parentTabId: "agent_agent-a", + }); + const state = workspaceLayoutStore.getState(); + const layout = state.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); + }); + it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => { useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); const workspaceKey = createWorkspaceKey(); @@ -641,6 +789,36 @@ 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", () => { + 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" }); + const sizes = expectGroup(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root) + .group.sizes; + + store.hidePane(workspaceKey, paneId); + let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, paneId)).toMatchObject({ + hidden: true, + tabIds: ["working_diff"], + }); + expect(expectGroup(layout.root).group.sizes).toEqual(sizes); + expect(layout.focusedPaneId).toBe("main"); + + store.showPane(workspaceKey, paneId); + layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: ["working_diff"] }); + expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined(); + expect(expectGroup(layout.root).group.sizes).toEqual(sizes); + }); + it("focusTab moves workspace focus to the pane containing the tab", () => { useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); const workspaceKey = createWorkspaceKey(); @@ -671,6 +849,44 @@ describe("workspace-layout-store actions", () => { expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId); }); + it("focusTab reveals a hidden pane and focuses its requested tab without changing sizes", () => { + useWorkspaceLayoutIds("89898989-8989-8989-8989-898989898989"); + 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", + })!; + const paneId = store.splitPane(workspaceKey, { + tabId: targetTabId, + targetPaneId: "main", + position: "right", + })!; + store.openTabFocused(workspaceKey, { kind: "working_diff" }); + 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); + const splitSizes = workspaceLayoutStore.getState().splitSizesByWorkspace[workspaceKey]; + + store.focusTab(workspaceKey, targetTabId); + + const state = workspaceLayoutStore.getState(); + const layout = state.layoutByWorkspace[workspaceKey]; + expect(layout.focusedPaneId).toBe(paneId); + expect(findPaneById(layout.root, paneId)).toMatchObject({ + focusedTabId: targetTabId, + tabIds: [targetTabId, "working_diff"], + }); + expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined(); + expect(expectGroup(layout.root).group.sizes).toEqual(group.sizes); + expect(state.splitSizesByWorkspace[workspaceKey]).toBe(splitSizes); + }); + it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => { useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212"); const workspaceKey = createWorkspaceKey(); @@ -946,6 +1162,26 @@ describe("workspace-layout-store actions", () => { expect(layout.focusedPaneId).toBe(splitPaneId); }); + it("focusPane reveals an explicitly targeted hidden pane", () => { + useWorkspaceLayoutIds("57575757-5757-5757-5757-575757575757"); + 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.focusPane(workspaceKey, "main"); + store.hidePane(workspaceKey, paneId); + + store.focusPane(workspaceKey, paneId); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(layout.focusedPaneId).toBe(paneId); + expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined(); + }); + it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => { useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc"); const workspaceKey = createWorkspaceKey(); @@ -1170,6 +1406,8 @@ describe("workspace-layout-store actions", () => { expect(persisted).toEqual({ layoutByWorkspace: { [workspaceKey]: layout }, splitSizesByWorkspace: currentState.splitSizesByWorkspace, + explorerPaneIdByWorkspace: {}, + acknowledgedPullRequestByWorkspace: {}, }); expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([ { @@ -1302,6 +1540,8 @@ describe("workspace-layout-store actions", () => { expect(partialize?.(state)).toEqual({ layoutByWorkspace: {}, splitSizesByWorkspace: {}, + explorerPaneIdByWorkspace: {}, + acknowledgedPullRequestByWorkspace: {}, }); }); @@ -1338,6 +1578,8 @@ describe("workspace-layout-store actions", () => { expect(partialize?.(state)).toEqual({ layoutByWorkspace: {}, splitSizesByWorkspace: {}, + explorerPaneIdByWorkspace: {}, + acknowledgedPullRequestByWorkspace: {}, }); }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 8631756a6..2f443c811 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -32,6 +32,7 @@ import { reorderFocusedPaneTabsInLayout, reorderPaneTabsInLayout, retargetTabInLayout, + setPaneHiddenInLayout, splitPaneEmptyInLayout, splitPaneInLayout, stripEphemeralTabsFromLayout, @@ -75,6 +76,8 @@ interface WorkspaceLayoutStore { pendingAgentIdsByWorkspace: Record>; hiddenAgentIdsByWorkspace: Record>; focusRestorationByWorkspace: Record; + explorerPaneIdByWorkspace: Record; + acknowledgedPullRequestByWorkspace: Record; openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; openChildTabFocused: ( workspaceKey: string, @@ -82,6 +85,12 @@ interface WorkspaceLayoutStore { parentTabId: string, ) => string | null; openTabInBackground: (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; closeTab: (workspaceKey: string, tabId: string) => void; focusTab: (workspaceKey: string, tabId: string) => void; retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null; @@ -107,6 +116,9 @@ interface WorkspaceLayoutStore { ) => string | null; moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: 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; @@ -118,6 +130,11 @@ interface WorkspaceLayoutStore { purgeWorkspace: (workspaceKey: string) => void; } +interface EnsureExplorerPaneResult { + paneId: string; + created: boolean; +} + interface WorkspaceFocusRestorationState { restorePaneId: string | null; tokens: string[]; @@ -147,6 +164,8 @@ const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [ }), z.strictObject({ kind: z.literal("terminal"), terminalId: z.string() }), z.strictObject({ kind: z.literal("browser"), browserId: z.string() }), + z.strictObject({ kind: z.literal("files") }), + z.strictObject({ kind: z.literal("pull_request") }), z.strictObject({ kind: z.literal("file"), path: z.string(), @@ -179,6 +198,7 @@ const SplitNodeStorageSchema: z.ZodType = z.lazy(() => tabIds: z.array(z.string()), focusedTabId: z.string().nullable(), tabs: z.array(WorkspaceTabStorageSchema).optional(), + hidden: z.boolean().optional(), }), }), z.strictObject({ @@ -200,6 +220,8 @@ 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(), + explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(), + acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(), }); function trimNonEmpty(value: string | null | undefined): string | null { @@ -308,6 +330,8 @@ export function createWorkspaceLayoutStore( pendingAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {}, focusRestorationByWorkspace: {}, + explorerPaneIdByWorkspace: {}, + acknowledgedPullRequestByWorkspace: {}, openTabFocused: (workspaceKey, target) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedTarget = normalizeWorkspaceTabTarget(target); @@ -408,6 +432,104 @@ export function createWorkspaceLayoutStore( return result.tabId; }, + ensureExplorerPane: (workspaceKey) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + if (!normalizedWorkspaceKey) { + return null; + } + + const store = get(); + const layout = getWorkspaceLayout(store.layoutByWorkspace, normalizedWorkspaceKey); + const explorerPaneId = store.explorerPaneIdByWorkspace[normalizedWorkspaceKey] ?? null; + const explorerPane = findPaneById(layout.root, explorerPaneId); + if (explorerPane) { + 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", + }); + 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 tabId = parentTabId + ? get().openChildTabFocused(normalizedWorkspaceKey, normalizedTarget, parentTabId) + : get().openTabFocused(normalizedWorkspaceKey, normalizedTarget); + 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; + }, + observePullRequest: (workspaceKey, identity) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedIdentity = trimNonEmpty(identity); + if (!normalizedWorkspaceKey || !normalizedIdentity) { + return; + } + + set((state) => { + if ( + state.acknowledgedPullRequestByWorkspace[normalizedWorkspaceKey] === + normalizedIdentity + ) { + return state; + } + + const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); + const explorerPaneId = state.explorerPaneIdByWorkspace[normalizedWorkspaceKey] ?? null; + const explorerPane = findPaneById(layout.root, explorerPaneId); + const placementLayout = explorerPane + ? (focusPaneInLayout({ layout, paneId: explorerPane.id }) ?? layout) + : layout; + const result = openTabInLayoutFocused({ + layout: placementLayout, + target: { kind: "pull_request" }, + now: Date.now(), + }); + + return { + ...withoutFocusRestoration(state, normalizedWorkspaceKey), + acknowledgedPullRequestByWorkspace: { + ...state.acknowledgedPullRequestByWorkspace, + [normalizedWorkspaceKey]: normalizedIdentity, + }, + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: result.layout, + }, + }; + }); + }, closeTab: (workspaceKey, tabId) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedTabId = trimNonEmpty(tabId); @@ -722,6 +844,67 @@ 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) { @@ -787,7 +970,8 @@ export function createWorkspaceLayoutStore( }; } - const restorePaneId = findPaneById(layout.root, restoration.restorePaneId)?.id ?? null; + const restorePane = findPaneById(layout.root, restoration.restorePaneId); + const restorePaneId = restorePane?.hidden === true ? null : (restorePane?.id ?? null); if (!restorePaneId) { return { focusRestorationByWorkspace: remainingRestorations, @@ -991,7 +1175,9 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace || normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace || normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace || - normalizedWorkspaceKey in state.focusRestorationByWorkspace; + normalizedWorkspaceKey in state.focusRestorationByWorkspace || + normalizedWorkspaceKey in state.explorerPaneIdByWorkspace || + normalizedWorkspaceKey in state.acknowledgedPullRequestByWorkspace; if (!hasAny) { return state; } @@ -1007,6 +1193,12 @@ export function createWorkspaceLayoutStore( state.hiddenAgentIdsByWorkspace; const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } = state.focusRestorationByWorkspace; + const { [normalizedWorkspaceKey]: _explorerPane, ...explorerPaneIdByWorkspace } = + state.explorerPaneIdByWorkspace; + const { + [normalizedWorkspaceKey]: _acknowledgedPullRequest, + ...acknowledgedPullRequestByWorkspace + } = state.acknowledgedPullRequestByWorkspace; return { layoutByWorkspace, splitSizesByWorkspace, @@ -1014,6 +1206,8 @@ export function createWorkspaceLayoutStore( pendingAgentIdsByWorkspace, hiddenAgentIdsByWorkspace, focusRestorationByWorkspace, + explorerPaneIdByWorkspace, + acknowledgedPullRequestByWorkspace, }; }); }, @@ -1034,6 +1228,8 @@ export function createWorkspaceLayoutStore( return { layoutByWorkspace, splitSizesByWorkspace: state.splitSizesByWorkspace, + explorerPaneIdByWorkspace: state.explorerPaneIdByWorkspace, + acknowledgedPullRequestByWorkspace: state.acknowledgedPullRequestByWorkspace, }; }, merge: (persistedState, currentState) => { @@ -1045,6 +1241,9 @@ export function createWorkspaceLayoutStore( ...currentState, layoutByWorkspace: result.data.layoutByWorkspace, splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {}, + explorerPaneIdByWorkspace: result.data.explorerPaneIdByWorkspace ?? {}, + acknowledgedPullRequestByWorkspace: + result.data.acknowledgedPullRequestByWorkspace ?? {}, }; }, }, diff --git a/packages/app/src/utils/split-navigation.test.ts b/packages/app/src/utils/split-navigation.test.ts index f80b1ad6e..e4fa5a740 100644 --- a/packages/app/src/utils/split-navigation.test.ts +++ b/packages/app/src/utils/split-navigation.test.ts @@ -2,13 +2,14 @@ import { describe, expect, it } from "vitest"; import type { SplitNode } from "@/stores/workspace-layout-store"; import { findAdjacentPane } from "./split-navigation"; -function createPaneNode(id: string): SplitNode { +function createPaneNode(id: string, hidden = false): SplitNode { return { kind: "pane", pane: { id, tabIds: [], focusedTabId: null, + ...(hidden ? { hidden: true } : {}), }, }; } @@ -80,4 +81,15 @@ describe("findAdjacentPane", () => { expect(findAdjacentPane(root, "top", "down")).toBe("bottom-left"); expect(findAdjacentPane(root, "bottom-right", "up")).toBe("top"); }); + + it("skips hidden panes", () => { + const root = createGroupNode({ + direction: "horizontal", + sizes: [0.3, 0.3, 0.4], + children: [createPaneNode("left"), createPaneNode("hidden", true), createPaneNode("right")], + }); + + expect(findAdjacentPane(root, "left", "right")).toBe("right"); + expect(findAdjacentPane(root, "hidden", "right")).toBeNull(); + }); }); diff --git a/packages/app/src/utils/split-navigation.ts b/packages/app/src/utils/split-navigation.ts index a00192edc..222052e7e 100644 --- a/packages/app/src/utils/split-navigation.ts +++ b/packages/app/src/utils/split-navigation.ts @@ -171,6 +171,9 @@ function collectPaneBounds( bounds: { left: number; top: number; right: number; bottom: number }, ): PaneBounds[] { if (node.kind === "pane") { + if (node.pane.hidden === true) { + return []; + } return [ { paneId: node.pane.id, diff --git a/packages/app/src/workspace-pins/launch.tsx b/packages/app/src/workspace-pins/launch.tsx index 2bdd561ba..0c6be8258 100644 --- a/packages/app/src/workspace-pins/launch.tsx +++ b/packages/app/src/workspace-pins/launch.tsx @@ -1,6 +1,13 @@ import { useMemo, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; -import { Globe, SquarePen, SquareTerminal } from "lucide-react-native"; +import { + FileDiff, + FolderTree, + GitPullRequest, + Globe, + SquarePen, + SquareTerminal, +} from "lucide-react-native"; import { withUnistyles } from "react-native-unistyles"; import { getTerminalProfileIcon, @@ -27,6 +34,8 @@ export interface ResolvedPin { interface UsePinnedLaunchersInput { serverId: string; onLaunch: (target: PinnedTabTarget) => void; + isGit?: boolean; + hasPullRequest?: boolean; } const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -34,6 +43,9 @@ const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMut const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedGlobe = withUnistyles(Globe); +const ThemedFileDiff = withUnistyles(FileDiff); +const ThemedFolderTree = withUnistyles(FolderTree); +const ThemedGitPullRequest = withUnistyles(GitPullRequest); function ProviderPinIcon({ iconKey, @@ -57,7 +69,12 @@ export function ProfileIcon({ iconKey }: { iconKey: string | undefined }): React return ; } -export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInput): ResolvedPin[] { +export function usePinnedLaunchers({ + serverId, + onLaunch, + isGit, + hasPullRequest, +}: UsePinnedLaunchersInput): ResolvedPin[] { const { t } = useTranslation(); const pinned = usePinnedTargetsStore((state) => state.pinned); const { config } = useDaemonConfig(serverId); @@ -69,7 +86,9 @@ export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInp return useMemo(() => { const resolved: ResolvedPin[] = []; for (const target of pinned) { - if (!isPinnedTargetAvailable(target, { isElectron: getIsElectron() })) { + if ( + !isPinnedTargetAvailable(target, { isElectron: getIsElectron(), isGit, hasPullRequest }) + ) { continue; } if (target.kind === "draft") { @@ -99,6 +118,33 @@ export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInp }); continue; } + if (target.kind === "working_diff") { + resolved.push({ + key: pinnedTargetKey(target), + label: t("workspace.tabs.actions.changes"), + icon: , + onPress: () => onLaunch(target), + }); + continue; + } + if (target.kind === "files") { + resolved.push({ + key: pinnedTargetKey(target), + label: t("workspace.tabs.actions.files"), + icon: , + onPress: () => onLaunch(target), + }); + continue; + } + if (target.kind === "pull_request") { + resolved.push({ + key: pinnedTargetKey(target), + label: t("workspace.tabs.actions.pullRequest"), + icon: , + onPress: () => onLaunch(target), + }); + continue; + } const profile = profiles.find((entry) => entry.id === target.profileId); if (!profile) { continue; @@ -111,5 +157,5 @@ export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInp }); } return resolved; - }, [onLaunch, pinned, profiles, t]); + }, [hasPullRequest, isGit, onLaunch, pinned, profiles, t]); } diff --git a/packages/app/src/workspace-pins/pinnable-menu-item.tsx b/packages/app/src/workspace-pins/pinnable-menu-item.tsx index cefa57d49..a0caa2fb0 100644 --- a/packages/app/src/workspace-pins/pinnable-menu-item.tsx +++ b/packages/app/src/workspace-pins/pinnable-menu-item.tsx @@ -4,9 +4,11 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { Pin, PinOff } from "lucide-react-native"; import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { Shortcut } from "@/components/ui/shortcut"; import { isNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import type { Theme } from "@/styles/theme"; +import type { ShortcutKey } from "@/utils/format-shortcut"; import { pinnedTargetKey, type PinnedTabTarget } from "@/workspace-pins/target"; import { usePinnedTargetsStore } from "@/workspace-pins/store"; @@ -22,6 +24,8 @@ interface PinnableMenuItemProps { disabled?: boolean; onSelect?: () => void; testID?: string; + /** Chord for the keyboard action that runs this target directly, shown beside the pin toggle. */ + shortcut?: ShortcutKey[][] | null; } export function PinnableMenuItem({ @@ -31,6 +35,7 @@ export function PinnableMenuItem({ disabled, onSelect, testID, + shortcut, }: PinnableMenuItemProps): ReactElement { const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); @@ -58,28 +63,31 @@ export function PinnableMenuItem({ const trailing = useMemo( () => ( - - - {isPinned ? ( - - ) : ( - - )} - + + {shortcut ? : null} + + + {isPinned ? ( + + ) : ( + + )} + + ), - [handleTogglePin, isPinned, showToggle, slotStyle, t, target], + [handleTogglePin, isPinned, shortcut, showToggle, slotStyle, t, target], ); return ( @@ -97,7 +105,12 @@ export function PinnableMenuItem({ ); } -const styles = StyleSheet.create(() => ({ +const styles = StyleSheet.create((theme) => ({ + trailingRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, pinToggleSlot: { width: 22, height: 22, diff --git a/packages/app/src/workspace-pins/run.test.ts b/packages/app/src/workspace-pins/run.test.ts index 1c6f9730f..6432a4793 100644 --- a/packages/app/src/workspace-pins/run.test.ts +++ b/packages/app/src/workspace-pins/run.test.ts @@ -8,7 +8,7 @@ const PROFILES: readonly TerminalProfile[] = [ ]; interface RecordedLaunch { - action: "draft" | "terminal" | "browser" | "profile"; + action: "draft" | "terminal" | "browser" | "changes" | "files" | "pull_request" | "profile"; profile?: TerminalProfileInput; } @@ -18,6 +18,9 @@ function recordingHandlers() { createDraft: () => launches.push({ action: "draft" }), createTerminal: () => launches.push({ action: "terminal" }), createBrowser: () => launches.push({ action: "browser" }), + openChanges: () => launches.push({ action: "changes" }), + openFiles: () => launches.push({ action: "files" }), + openPullRequest: () => launches.push({ action: "pull_request" }), createTerminalWithProfile: (profile) => launches.push({ action: "profile", profile }), }; return { launches, handlers }; diff --git a/packages/app/src/workspace-pins/run.ts b/packages/app/src/workspace-pins/run.ts index 7d36a4913..cf755f1ca 100644 --- a/packages/app/src/workspace-pins/run.ts +++ b/packages/app/src/workspace-pins/run.ts @@ -7,6 +7,9 @@ export interface TabTargetHandlers { createDraft: () => void; createTerminal: () => void; createBrowser: () => void; + openChanges: () => void; + openFiles: () => void; + openPullRequest: () => void; createTerminalWithProfile: (profile: TerminalProfileInput) => void; } @@ -27,6 +30,18 @@ export function runPinnedTabTarget( handlers.createBrowser(); return; } + if (target.kind === "working_diff") { + handlers.openChanges(); + return; + } + if (target.kind === "files") { + handlers.openFiles(); + return; + } + if (target.kind === "pull_request") { + handlers.openPullRequest(); + return; + } const profile = profiles.find((entry) => entry.id === target.profileId); if (!profile) { return; diff --git a/packages/app/src/workspace-pins/store.test.ts b/packages/app/src/workspace-pins/store.test.ts new file mode 100644 index 000000000..4d21f180c --- /dev/null +++ b/packages/app/src/workspace-pins/store.test.ts @@ -0,0 +1,46 @@ +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 AsyncStorage from "@react-native-async-storage/async-storage"; +import { usePinnedTargetsStore } from "./store"; + +describe("pinned targets persistence", () => { + beforeEach(async () => { + await AsyncStorage.removeItem("pinned-tab-targets"); + usePinnedTargetsStore.setState({ pinned: [] }); + }); + + it("restores workspace review targets", async () => { + await AsyncStorage.setItem( + "pinned-tab-targets", + JSON.stringify({ + state: { + pinned: [{ kind: "working_diff" }, { kind: "files" }, { kind: "pull_request" }], + }, + version: 1, + }), + ); + + await usePinnedTargetsStore.persist.rehydrate(); + + expect(usePinnedTargetsStore.getState().pinned).toEqual([ + { kind: "working_diff" }, + { kind: "files" }, + { kind: "pull_request" }, + ]); + }); +}); diff --git a/packages/app/src/workspace-pins/store.ts b/packages/app/src/workspace-pins/store.ts index 45a49c471..51257ea02 100644 --- a/packages/app/src/workspace-pins/store.ts +++ b/packages/app/src/workspace-pins/store.ts @@ -16,6 +16,9 @@ const PinnedTabTargetSchema = z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("draft") }), z.strictObject({ kind: z.literal("terminal") }), z.strictObject({ kind: z.literal("browser") }), + z.strictObject({ kind: z.literal("working_diff") }), + z.strictObject({ kind: z.literal("files") }), + z.strictObject({ kind: z.literal("pull_request") }), z.strictObject({ kind: z.literal("profile"), profileId: z.string() }), ]); const PinnedTargetsPersistedStateSchema = z.strictObject({ diff --git a/packages/app/src/workspace-pins/target.ts b/packages/app/src/workspace-pins/target.ts index 6330f8302..3631c1aea 100644 --- a/packages/app/src/workspace-pins/target.ts +++ b/packages/app/src/workspace-pins/target.ts @@ -2,13 +2,25 @@ export type PinnedTabTarget = | { kind: "draft" } | { kind: "terminal" } | { kind: "browser" } + | { kind: "working_diff" } + | { kind: "files" } + | { kind: "pull_request" } | { kind: "profile"; profileId: string }; export function isPinnedTargetAvailable( target: PinnedTabTarget, - environment: { isElectron: boolean }, + environment: { isElectron: boolean; isGit?: boolean; hasPullRequest?: boolean }, ): boolean { - return target.kind !== "browser" || environment.isElectron; + if (target.kind === "browser") { + return environment.isElectron; + } + if (target.kind === "working_diff") { + return environment.isGit !== false; + } + if (target.kind === "pull_request") { + return environment.hasPullRequest !== false; + } + return true; } export function pinnedTargetKey(target: PinnedTabTarget): string { diff --git a/packages/app/src/workspace-tabs/identity.test.ts b/packages/app/src/workspace-tabs/identity.test.ts index 5c175f4f3..4eed50d56 100644 --- a/packages/app/src/workspace-tabs/identity.test.ts +++ b/packages/app/src/workspace-tabs/identity.test.ts @@ -80,6 +80,19 @@ describe("working diff tab identity", () => { }); }); +describe("workspace utility panel identity", () => { + it.each(["files", "pull_request"] as const)( + "normalizes and deterministically keys %s", + (kind) => { + const target = { kind }; + + expect(normalizeWorkspaceTabTarget(target)).toEqual(target); + expect(buildDeterministicWorkspaceTabId(target)).toBe(kind); + expect(workspaceTabTargetsEqual(target, target)).toBe(true); + }, + ); +}); + describe("commit diff tab identity", () => { it("keys a commit diff tab by its sha", () => { expect(buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" })).toBe( diff --git a/packages/app/src/workspace-tabs/identity.ts b/packages/app/src/workspace-tabs/identity.ts index 0741321c7..cf857343d 100644 --- a/packages/app/src/workspace-tabs/identity.ts +++ b/packages/app/src/workspace-tabs/identity.ts @@ -49,6 +49,9 @@ function normalizeSimpleWorkspaceTabTarget(value: WorkspaceTabTarget): Workspace const browserId = trimNonEmpty(value.browserId); return browserId ? { kind: "browser", browserId } : null; } + case "files": + case "pull_request": + return { kind: value.kind }; case "setup": { const workspaceId = trimNonEmpty(value.workspaceId); return workspaceId ? { kind: "setup", workspaceId } : null; @@ -121,6 +124,12 @@ function secondaryWorkspaceTabTargetsEqual( if (left.kind === "working_diff" && right.kind === "working_diff") { return left.focusPath === right.focusPath && left.focusRequestId === right.focusRequestId; } + if (left.kind === "files" && right.kind === "files") { + return true; + } + if (left.kind === "pull_request" && right.kind === "pull_request") { + return true; + } if (left.kind === "setup" && right.kind === "setup") { return left.workspaceId === right.workspaceId; } @@ -188,6 +197,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st if (target.kind === "working_diff") { return "working_diff"; } + if (target.kind === "files" || target.kind === "pull_request") { + return target.kind; + } return `file_${target.path}`; } diff --git a/packages/app/src/workspace-tabs/model.ts b/packages/app/src/workspace-tabs/model.ts index 885894475..56af53df9 100644 --- a/packages/app/src/workspace-tabs/model.ts +++ b/packages/app/src/workspace-tabs/model.ts @@ -22,6 +22,8 @@ export type WorkspaceTabTarget = | { kind: "provider_subagent"; parentAgentId: string; subagentId: string } | { kind: "terminal"; terminalId: string } | { kind: "browser"; browserId: string } + | { kind: "files" } + | { kind: "pull_request" } | WorkspaceFileTabTarget | WorkspaceWorkingDiffTabTarget | { kind: "setup"; workspaceId: string }