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
This commit is contained in:
Mohamed Boudra
2026-08-16 18:18:32 +02:00
committed by GitHub
parent 6dcf6a4b08
commit ab274d635e
81 changed files with 2900 additions and 1051 deletions
@@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { test, expect, type Page } from "../support/fixtures"; import { test, expect, type Page } from "../support/fixtures";
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent"; import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
function visibleComposer(page: Page) { function visibleComposer(page: Page) {
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first(); 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 expect(agentComposer).toBeEditable({ timeout: 30_000 });
await agentComposer.fill("Preserve this thought"); await agentComposer.fill("Preserve this thought");
await page.getByRole("button", { name: "Open explorer" }).click(); await openChangesPanel(page);
await page.getByTestId("explorer-tab-changes").click();
const changedFile = page.getByText("changed file.ts", { exact: true }).first(); const changedFile = page.getByText("changed file.ts", { exact: true }).first();
await expect(changedFile).toBeVisible({ timeout: 30_000 }); await expect(changedFile).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
@@ -24,7 +24,10 @@ import {
} from "../support/helpers/composer"; } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client"; 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 { getServerId } from "../support/helpers/server-id";
import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention"; import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention";
@@ -755,7 +758,7 @@ async function expectRenderedBefore(first: Locator, second: Locator): Promise<vo
async function openWorkspaceDraft(page: Page, workspaceId: string): Promise<void> { async function openWorkspaceDraft(page: Page, workspaceId: string): Promise<void> {
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId)); await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab-inline").click(); await createAgentTabFromMenu(page);
await expectComposerVisible(page); await expectComposerVisible(page);
} }
@@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
await row.click(); await row.click();
await expect(row).toHaveAttribute("aria-selected", "true"); 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({ await page.screenshot({
path: testInfo.outputPath("pure-black-selected-workspace.png"), path: testInfo.outputPath("pure-black-selected-workspace.png"),
fullPage: true, fullPage: true,
+82 -86
View File
@@ -8,7 +8,7 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id"; import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client"; import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace"; import { createTempGitRepo } from "../support/helpers/workspace";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
interface DirtyWorkspace { interface DirtyWorkspace {
id: string; id: string;
@@ -29,6 +29,25 @@ interface CleanupTask {
const cleanupTasks: CleanupTask[] = []; const cleanupTasks: CleanupTask[] = [];
const APP_SETTINGS_KEY = "@paseo:app-settings"; 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<string | null> {
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<void> { async function failNextDiscardRequest(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
const serverSocket = browserSocket.connectToServer(); 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(); await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox(); const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox();
expect(menuBounds).not.toBeNull(); 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); expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10);
await page.getByTestId("diff-file-0-open-file").click(); 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-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-duplicate").click(); await page.getByTestId("diff-file-0-duplicate").click();
await expect 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); .toBe(AFTER);
await page.getByTestId("changes-toggle-view-mode").click(); 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 page.getByTestId("diff-folder-src-duplicate").click();
await expect 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); .toBe(AFTER);
}); });
@@ -299,24 +318,25 @@ test("changes context menu recursively collapses descendant folders", async ({ p
await openWorkspaceChanges(page, workspace); await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click(); await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); const tree = changesTree(page);
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); 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 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 tree.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
await expect(page.getByText("root.ts", { exact: true })).toHaveCount(0); await expect(tree.getByText("root.ts", { exact: true })).toHaveCount(0);
await page.getByTestId("diff-folder-src/zz-folder-toggle").click(); await tree.getByTestId("diff-folder-src/zz-folder-toggle").click();
await expect(page.getByText("root.ts", { exact: true })).toBeVisible(); await expect(tree.getByText("root.ts", { exact: true })).toBeVisible();
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
await expect(page.getByText("changed.ts", { exact: true })).toHaveCount(0); await expect(tree.getByText("changed.ts", { exact: true })).toHaveCount(0);
await page.getByTestId("diff-folder-src/zz-folder/nested-toggle").click(); await tree.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
await expect(page.getByText("changed.ts", { exact: true })).toBeVisible(); await expect(tree.getByText("changed.ts", { exact: true })).toBeVisible();
}); });
test("changes context menus expose folder revert and restore a file after confirmation", async ({ 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 openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click(); 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"); const folderRevert = page.getByTestId("diff-folder-src-revert");
await expect(folderRevert).toBeVisible(); await expect(folderRevert).toBeVisible();
const revertLabelColor = await folderRevert 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 expect(folderRevert.locator("svg")).toHaveCSS("stroke", revertLabelColor);
await page.keyboard.press("Escape"); 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<string>((resolve) => { const cancelledConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => { page.once("dialog", async (dialog) => {
const message = dialog.message(); 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(); await page.getByTestId("diff-file-0-revert").click();
expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts"); 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 await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
.toBe(AFTER); .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<string>((resolve) => { const confirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => { page.once("dialog", async (dialog) => {
const message = dialog.message(); 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(); await page.getByTestId("diff-file-0-revert").click();
expect(await confirmation).toContain("src/use-mounted-tab-set.ts"); 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 await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
.toBe(BEFORE); .toBe(BEFORE);
@@ -451,49 +472,39 @@ test("shows a revert error returned by the daemon", async ({ page }) => {
.toBe(AFTER); .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 }); const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page); await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace); 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 }); const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(visiblePanel).toBeVisible(); await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible(); await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts"); 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(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 visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0); await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click(); await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); 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 expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await workingDiffLayoutToggle.click(); await workingDiffLayoutToggle.click();
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff"); await expect(visiblePanel.getByRole("button", { name: "Switch to unified diff" })).toBeVisible();
await visiblePanel.getByTestId("working-diff-options-menu").click(); await visiblePanel.getByRole("button", { name: "Diff options" }).click();
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace"); await expect(page.getByText("Hide whitespace", { exact: true })).toBeVisible();
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines"); await expect(page.getByText("Wrap long lines", { exact: true })).toBeVisible();
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh"); await expect(page.getByText(/^Refresh/)).toBeVisible();
await page.getByTestId("working-diff-toggle-wrap-lines").click(); await page.getByText("Wrap long lines", { exact: true }).click();
await visiblePanel.getByTestId("working-diff-options-menu").click(); await visiblePanel.getByRole("button", { name: "Diff options" }).click();
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText( await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible();
"Scroll long lines",
);
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click(); await page.getByRole("button", { name: "Expand all files" }).click();
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByRole("button", { name: "Collapse all files" }).click();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click(); await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1); await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE); 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({ await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
timeout: 30_000, 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.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).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 }) => { 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 scrollToLowerUnwrappedDiffRows(page);
await page.getByTestId("changes-toggle-view-mode").click(); await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible(); const tree = changesTree(page);
await expect(page.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS( 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", "user-select",
"none", "none",
); );
await expect(page.getByTestId("diff-file-0")).toBeVisible(); await expect(tree.getByTestId("diff-file-0")).toBeVisible();
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1); await expect(tree.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1); await expect(tree.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1);
const folderToggleBounds = await page.getByTestId("diff-folder-src-toggle").boundingBox(); const folderToggleBounds = await tree.getByTestId("diff-folder-src-toggle").boundingBox();
const folderChevronBounds = await page const folderChevronBounds = await tree
.getByTestId("diff-folder-src-toggle") .getByTestId("diff-folder-src-toggle")
.locator("svg") .locator("svg")
.boundingBox(); .boundingBox();
@@ -566,11 +563,11 @@ test("changes diff switches between flat and tree file lists", async ({ page })
folderToggleBounds!.y + folderToggleBounds!.height / 2, folderToggleBounds!.y + folderToggleBounds!.height / 2,
0, 0,
); );
const folderLabelBounds = await page const folderLabelBounds = await tree
.getByTestId("diff-folder-src") .getByTestId("diff-folder-src")
.getByText("src", { exact: true }) .getByText("src", { exact: true })
.boundingBox(); .boundingBox();
const fileLabelBounds = await page const fileLabelBounds = await tree
.getByTestId("diff-file-0") .getByTestId("diff-file-0")
.getByText("use-mounted-tab-set.ts", { exact: true }) .getByText("use-mounted-tab-set.ts", { exact: true })
.boundingBox(); .boundingBox();
@@ -578,28 +575,28 @@ test("changes diff switches between flat and tree file lists", async ({ page })
expect(fileLabelBounds).not.toBeNull(); expect(fileLabelBounds).not.toBeNull();
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(16, 0); 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 folderToggle.click();
await expect(folderToggle).toHaveAttribute("aria-selected", "true"); 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 folderToggle.click();
await expect(folderToggle).toHaveAttribute("aria-selected", "true"); 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 fileToggle.click({ button: "right" });
await expect(fileToggle).toHaveAttribute("aria-selected", "true"); await expect(fileToggle).toHaveAttribute("aria-selected", "true");
await expect(folderToggle).toHaveAttribute("aria-selected", "false"); await expect(folderToggle).toHaveAttribute("aria-selected", "false");
await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible(); await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible();
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Collapse all" }).click(); await page.getByRole("button", { name: "Collapse all files" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0); await expect(content.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await page.getByRole("button", { name: "Expand all" }).click(); await page.getByRole("button", { name: "Expand all files" }).click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible(); await expect(content.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click(); await tree.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0); await expect(tree.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByTestId("changes-toggle-view-mode").click(); await page.getByTestId("changes-toggle-view-mode").click();
await expectFlatFileList(page); await expectFlatFileList(page);
@@ -801,14 +798,13 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
await page.setViewportSize({ width: 1400, height: 900 }); await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await page.getByRole("button", { name: "Open explorer" }).click();
await openChangesInVisibleExplorer(page); await openChangesInVisibleExplorer(page);
await page.getByTestId("diff-file-0").click(); await page.getByTestId("diff-file-0").click();
await expectExpandedMountedTabDiff(page); await expectExpandedMountedTabDiff(page);
} }
async function openChangesInVisibleExplorer(page: Page): Promise<void> { async function openChangesInVisibleExplorer(page: Page): Promise<void> {
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 }); await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
} }
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises"; import { writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { test, expect } from "../support/fixtures"; import { test, expect } from "../support/fixtures";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
const COMMIT_SUBJECT = "Show commit timestamps"; 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" }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
await workspace.navigateTo(); await workspace.navigateTo();
await page.getByRole("button", { name: "Open explorer" }).click(); await openChangesPanel(page);
const commitsSection = page.getByRole("button", { name: /Commits/i }); const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 }); await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click(); 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 page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo(); await workspace.navigateTo();
await page.getByRole("button", { name: "Open explorer" }).click(); await openChangesPanel(page);
const commitsSection = page.getByRole("button", { name: /Commits/i }); const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 }); await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click(); 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-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible(); 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 page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0); await expect(panel).toHaveCount(0);
await commitRow.click(); await commitRow.click();
+10 -14
View File
@@ -38,10 +38,7 @@ function fitsViewportWidth(element: HTMLElement): boolean {
} }
async function replaceEditorText(page: Page, content: string): Promise<void> { async function replaceEditorText(page: Page, content: string): Promise<void> {
const contentElement = editor(page); await editor(page).fill(content);
await contentElement.click();
await contentElement.press("Control+A");
await contentElement.type(content);
} }
async function openWorkspaceFile(page: Page, filename: string): Promise<void> { async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
@@ -154,9 +151,8 @@ test.describe("CodeMirror workspace file editing", () => {
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await openAgentRoute(page, session); 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 openWorkspaceFile(page, "target.ts");
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
await page await page
.getByTestId(`workspace-tab-agent_${session.agentId}`) .getByTestId(`workspace-tab-agent_${session.agentId}`)
@@ -347,14 +343,13 @@ test.describe("CodeMirror workspace file editing", () => {
await replaceEditorText(page, "const localWins = 5;\n"); await replaceEditorText(page, "const localWins = 5;\n");
await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8"); await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8");
await expect(page.getByTestId("file-conflict-alert")).toBeVisible(); 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"]) { for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) {
await openWorkspaceFile(page, fileName); 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(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 replaceEditorText(page, "const discarded = 7;\n");
await writeFile(sourcePath, "const diskWins = 8;\n", "utf8"); await writeFile(sourcePath, "const diskWins = 8;\n", "utf8");
@@ -441,20 +436,21 @@ test.describe("CodeMirror workspace file editing", () => {
await workspace.navigateTo(); await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md"); 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 expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
await writeFile(markdownPath, "# Updated heading\n", "utf8"); 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 selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible(); await expect(page.getByTestId("file-source-editor")).toBeVisible();
await replaceEditorText(page, "# Saved from source\n"); await replaceEditorText(page, "# Saved from source\n");
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n"); await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
await selectFileView(page, "Preview"); 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"); await openWorkspaceFile(page, "pixel.png");
const image = page.getByTestId("workspace-file-pane").locator("img"); const image = visibleFilePane.locator("img");
await expect(image).toBeVisible(); await expect(image).toBeVisible();
const initialSource = await image.getAttribute("src"); const initialSource = await image.getAttribute("src");
await writeFile(imagePath, BLUE_PIXEL); await writeFile(imagePath, BLUE_PIXEL);
@@ -3,6 +3,7 @@ import path from "node:path";
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures"; import { test } from "../support/fixtures";
import { openFileExplorer } from "../support/helpers/file-explorer"; import { openFileExplorer } from "../support/helpers/file-explorer";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { gotoWorkspace } from "../support/helpers/launcher"; import { gotoWorkspace } from "../support/helpers/launcher";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; 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 expect(filesMenu.getByText("Delete", { exact: true })).toHaveCount(0);
await page.keyboard.press("Escape"); 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 expect(page.getByTestId("diff-file-0")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0); await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
@@ -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();
}
});
});
@@ -1,28 +1,22 @@
import { writeFile } from "node:fs/promises"; import { writeFile } from "node:fs/promises";
import path from "node:path"; 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 { gotoWorkspace, clickNewTerminal } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { expectExplorerEntryVisible } from "../support/helpers/file-explorer"; import { expectExplorerEntryVisible, openFileExplorer } from "../support/helpers/file-explorer";
import { expectNoTerminalTabs, clickFirstTerminalTab } from "../support/helpers/workspace-tabs"; import {
expectNoTerminalTabs,
clickFirstTerminalTab,
openChangesPanel,
} from "../support/helpers/workspace-tabs";
// Model B: two workspaces can back the SAME directory. What follows from that // Model B: two workspaces can back the SAME directory. What follows from that
// split is the contract these specs pin: // split is the contract these specs pin:
// - The right sidebar (file browser / git changes) reads the directory, so it // - Files and Changes read the directory, so their content is IDENTICAL
// is IDENTICAL across same-directory workspaces. // across same-directory workspaces.
// - Tabs (agents, terminals) are owned by the workspace, so they are // - Tabs (agents, terminals) are owned by the workspace, so they are
// INDEPENDENT across same-directory workspaces. // 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<void> {
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( async function createSecondWorkspaceOnSameDir(
seeded: SeededWorkspace, seeded: SeededWorkspace,
title: string, title: string,
@@ -41,7 +35,7 @@ async function createSecondWorkspaceOnSameDir(
test.describe("Same-directory workspaces", () => { test.describe("Same-directory workspaces", () => {
test.describe.configure({ timeout: 180_000 }); 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, page,
}) => { }) => {
const seeded = await seedWorkspace({ repoPrefix: "same-dir-shared-" }); 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 // Workspace A: the new file shows in both the file browser and the git
// changes view. // changes view.
await gotoWorkspace(page, seeded.workspaceId); await gotoWorkspace(page, seeded.workspaceId);
await openExplorerTab(page, "files"); await openFileExplorer(page);
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md"); await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
await openExplorerTab(page, "changes"); await openChangesPanel(page);
await expect( await expect(
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(), page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
).toBeVisible({ timeout: 30_000 }); ).toBeVisible({ timeout: 30_000 });
// Workspace B (same directory): the SAME change is visible. The right // Workspace B (same directory): the SAME directory-backed content is
// sidebar content does not differ between the two views. // visible even though the panel tabs belong to a different workspace.
await gotoWorkspace(page, secondWorkspaceId); await gotoWorkspace(page, secondWorkspaceId);
await openExplorerTab(page, "files"); await openFileExplorer(page);
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md"); await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
await openExplorerTab(page, "changes"); await openChangesPanel(page);
await expect( await expect(
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(), page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
).toBeVisible({ timeout: 30_000 }); ).toBeVisible({ timeout: 30_000 });
@@ -1,26 +1,39 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures"; import { expect, test, type Page } from "../support/fixtures";
test.use({ viewport: { width: 1600, height: 900 } }); 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); const handle = page.getByTestId(testID);
await expect(handle).toBeVisible(); await expect(handle).toBeVisible();
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0); await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
await handle.hover(); await hoverTarget.hover();
const highlight = page.getByTestId(`${testID}-highlight`); const highlight = page.getByTestId(`${testID}-highlight`);
await expect(highlight).toBeVisible(); 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)"); 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-" }); const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" });
await workspace.navigateTo(); 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 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",
);
}); });
@@ -13,6 +13,7 @@ import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
import { getServerId } from "../support/helpers/server-id"; import { getServerId } from "../support/helpers/server-id";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { escapeRegex } from "../support/helpers/regex"; 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"; 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(); 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-" }); const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" });
try { try {
@@ -339,44 +340,19 @@ test.describe("Half-screen desktop layout", () => {
await waitForSidebarProject(page, path.basename(workspace.repoPath)); await waitForSidebarProject(page, path.basename(workspace.repoPath));
await openWorkspaceFromSidebar(page, workspace.workspaceId); await openWorkspaceFromSidebar(page, workspace.workspaceId);
await page.getByTestId("workspace-explorer-toggle").first().click(); await openFilesPanel(page);
await expect( const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
page.getByTestId("explorer-tab-files").filter({ visible: true }).first(), await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
).toBeVisible(); await expect(explorerToggle).toHaveAccessibleName("Close explorer");
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible(); await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(page.getByTestId("explorer-close")).toBeVisible(); await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
const centerBounds = await page.getByTestId("workspace-tabs-row").first().boundingBox(); await explorerToggle.click();
const headerGlyphBounds = await page await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
.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),
0, 0,
); );
await expect(explorerToggle).toHaveAccessibleName("Open explorer");
await expect await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
.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();
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
} }
@@ -3,7 +3,10 @@ import { expectComposerVisible, submitMessage } from "../support/helpers/compose
import { delayCreatedAgentInitialTailResponse } from "../support/helpers/agent-timeline-gate"; import { delayCreatedAgentInitialTailResponse } from "../support/helpers/agent-timeline-gate";
import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace"; import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace";
import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-client"; 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 { getServerId } from "../support/helpers/server-id";
import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { buildHostWorkspaceRoute } from "@/utils/host-routes";
@@ -56,7 +59,7 @@ test.describe("Workspace agent title handoff", () => {
try { try {
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId)); await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab-inline").click(); await createAgentTabFromMenu(page);
await expectComposerVisible(page); await expectComposerVisible(page);
const prompt = "Keep the optimistic agent pane visible during handoff"; const prompt = "Keep the optimistic agent pane visible during handoff";
@@ -99,7 +102,7 @@ test.describe("Workspace agent title handoff", () => {
try { try {
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId)); await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab-inline").click(); await createAgentTabFromMenu(page);
await expectComposerVisible(page); await expectComposerVisible(page);
const promptTitle = "Investigate optimistic tab title handoff"; const promptTitle = "Investigate optimistic tab title handoff";
@@ -12,6 +12,7 @@ import {
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { expectExplorerEntryVisible } from "../support/helpers/file-explorer"; import { expectExplorerEntryVisible } from "../support/helpers/file-explorer";
import { getServerId } from "../support/helpers/server-id"; import { getServerId } from "../support/helpers/server-id";
import { openFilesPanel } from "../support/helpers/workspace-tabs";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
@@ -24,14 +25,8 @@ function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; 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<void> { async function openFilesTab(page: Page): Promise<void> {
const openButton = page.getByRole("button", { name: "Open explorer" }).first(); await openFilesPanel(page);
if (await openButton.isVisible().catch(() => false)) {
await openButton.click();
}
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
} }
@@ -79,6 +79,12 @@ async function getVisibleDraftTabCount(page: Page): Promise<number> {
} }
async function closeFirstVisibleDraftTab(page: Page): Promise<void> { async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
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({ const closeButton = page.locator('[data-testid^="workspace-draft-close-"]').filter({
visible: true, visible: true,
}); });
@@ -1,8 +1,8 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { escapeRegex } from "./regex"; 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 // The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
// ExplorerSidebar), not in the workspace header. It renders as a button whose
// accessible name carries the current branch ("Current branch: <name>. Press to // accessible name carries the current branch ("Current branch: <name>. Press to
// switch branch."). Matching on the accessible name keeps these helpers tied 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 // 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(); .first();
} }
// Opens the right-side explorer and lands on the Changes tab, where the branch // Opens the fixed pane and lands on the Changes tab, where the switcher and diff live.
// 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.
export async function openChangesPanel(page: Page): Promise<void> { export async function openChangesPanel(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible({ await openWorkspaceChangesPanel(page);
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 expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({ await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
@@ -1,4 +1,5 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { openFilesPanel } from "./workspace-tabs";
function fileExplorerTree(page: Page) { function fileExplorerTree(page: Page) {
return page.getByTestId("file-explorer-tree-scroll"); return page.getByTestId("file-explorer-tree-scroll");
@@ -9,8 +10,7 @@ function fileExplorerEntry(page: Page, name: string) {
} }
export async function openFileExplorer(page: Page): Promise<void> { export async function openFileExplorer(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open explorer" }).first().click(); await openFilesPanel(page);
await page.getByTestId("explorer-tab-files").click();
await expect(fileExplorerTree(page)).toBeVisible({ timeout: 30_000 }); await expect(fileExplorerTree(page)).toBeVisible({ timeout: 30_000 });
} }
+7 -11
View File
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes"; import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes";
import { createTempGitRepo } from "./workspace"; import { createTempGitRepo } from "./workspace";
import { getServerId } from "./server-id"; import { getServerId } from "./server-id";
import { createAgentTabFromMenu } from "./workspace-tabs";
// ─── Navigation ──────────────────────────────────────────────────────────── // ─── Navigation ────────────────────────────────────────────────────────────
@@ -69,10 +70,10 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
// ─── Tab bar assertions ─────────────────────────────────────────────────── // ─── 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<void> { export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect( 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(); ).toBeVisible();
} }
@@ -85,14 +86,9 @@ export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void>
// ─── Tab creation actions ───────────────────────────────────────────────── // ─── 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<void> { export async function clickNewChat(page: Page): Promise<void> {
const button = page await createAgentTabFromMenu(page);
.getByTestId("workspace-new-agent-tab-inline")
.filter({ visible: true })
.first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
} }
/** Open the new-tab menu and click "New terminal". */ /** Open the new-tab menu and click "New terminal". */
@@ -129,9 +125,9 @@ export async function waitForTabWithTitle(
).toBeVisible({ timeout }); ).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<void> { export async function assertSingleNewTabButton(page: Page): Promise<void> {
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(); const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1); expect(count).toBeGreaterThanOrEqual(1);
} }
+2 -3
View File
@@ -1,10 +1,9 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { getStateLabel } from "@/git/pull-request-panel/data"; import { getStateLabel } from "@/git/pull-request-panel/data";
import { openPullRequestPanel } from "./workspace-tabs";
export async function openPrPane(page: Page): Promise<void> { export async function openPrPane(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open explorer" }).click(); await openPullRequestPanel(page);
await page.getByTestId("explorer-tab-pr").click();
await expect(page.getByTestId("pr-pane")).toBeVisible({ timeout: 15_000 });
} }
export async function expectPrPaneTitle(page: Page, title: string): Promise<void> { export async function expectPrPaneTitle(page: Page, title: string): Promise<void> {
@@ -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<void> {
await page.getByRole("button", { name: "Open explorer" }).click();
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
}
export async function hideExplorerSplit(page: Page): Promise<void> {
await page.getByRole("button", { name: "Close explorer" }).click();
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
}
export async function resizeExplorerSplit(page: Page, deltaX: number): Promise<void> {
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<void> {
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<void> {
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<SplitPaneGeometry> {
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,
};
});
}
@@ -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<string[]> { export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
const tabs = page.locator('[data-testid^="workspace-tab-"]'); 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 }); 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<void> {
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<void> {
const tabRow = await ensureExplorerPane(page);
await selectWorkspaceTab(tabRow.getByTestId("workspace-tab-working_diff"));
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
timeout: 30_000,
});
}
export async function openFilesPanel(page: Page): Promise<void> {
const tabRow = await ensureExplorerPane(page);
const filesTab = tabRow.getByTestId("workspace-tab-files");
if ((await filesTab.count()) === 0) {
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
await visibleTestId(page, "workspace-new-tab-menu-files").first().click();
}
await selectWorkspaceTab(filesTab);
await expect(visibleTestId(page, "file-explorer-tree-scroll").first()).toBeVisible({
timeout: 30_000,
});
}
export async function openPullRequestPanel(page: Page): Promise<void> {
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<void> { export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({ await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
timeout: 30_000, 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, timeout: 30_000,
}); });
} }
/** Open the `+` menu in the tab row and pick "New agent". */
export async function createAgentTabFromMenu(page: Page): Promise<void> {
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<string[]> { export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true }); const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true });
const count = await tabs.count(); const count = await tabs.count();
+1 -1
View File
@@ -445,7 +445,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
); );
const handleToolCallOpenFile = useStableEvent((filePath: string) => { const handleToolCallOpenFile = useStableEvent((filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath }, "main"); handleInlinePathPress({ raw: filePath, path: filePath }, "side");
}); });
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent( const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
+5 -42
View File
@@ -5,9 +5,7 @@ import { isNative, isWeb } from "@/constants/platform";
import { MarkdownTextSpan } from "@/components/markdown-text"; import { MarkdownTextSpan } from "@/components/markdown-text";
import { MarkdownLinkText } from "@/components/markdown/link-text"; import { MarkdownLinkText } from "@/components/markdown/link-text";
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context"; import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useStableEvent } from "@/hooks/use-stable-event";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup"; import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
import { useAssistantFileLinkResolverContext } from "./provider"; import { useAssistantFileLinkResolverContext } from "./provider";
@@ -32,21 +30,13 @@ export function AssistantMarkdownLink({
monoSurface, monoSurface,
children, children,
}: AssistantMarkdownLinkProps) { }: AssistantMarkdownLinkProps) {
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source); const { target, onHoverIn, onPress } = useFileLink(source);
const { configRef } = useAssistantFileLinkResolverContext(); const { configRef } = useAssistantFileLinkResolverContext();
const workspaceRoot = configRef.current.workspaceRoot; const workspaceRoot = configRef.current.workspaceRoot;
const tooltipPath = useMemo( const tooltipPath = useMemo(
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null), () => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
[target, workspaceRoot], [target, workspaceRoot],
); );
const handleAnchorClickCapture = useStableEvent((event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!isModifiedOpenEvent(event)) {
return;
}
event.stopPropagation();
onAuxPress();
});
const linkPress = useMemo<AssistantLinkPress>( const linkPress = useMemo<AssistantLinkPress>(
() => ({ onPress, accessibilityRole: "link" }), () => ({ onPress, accessibilityRole: "link" }),
[onPress], [onPress],
@@ -93,7 +83,7 @@ export function AssistantMarkdownLink({
{...(unwrapForMarkdownCopy ? { "data-paseo-markdown-unwrap": "true" } : {})} {...(unwrapForMarkdownCopy ? { "data-paseo-markdown-unwrap": "true" } : {})}
href={source.href} href={source.href}
title={source.title} title={source.title}
onClickCapture={handleAnchorClickCapture} onClickCapture={preventAnchorNavigation}
onAuxClickCapture={preventAnchorNavigation} onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE} style={LINK_ANCHOR_STYLE}
> >
@@ -209,8 +199,6 @@ const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
display: "inline-flex" as ViewStyle["display"], display: "inline-flex" as ViewStyle["display"],
}; };
const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"];
function FileLinkHoverTooltip({ function FileLinkHoverTooltip({
filePath, filePath,
children, children,
@@ -228,17 +216,9 @@ function FileLinkHoverTooltip({
</TooltipTrigger> </TooltipTrigger>
{filePath ? ( {filePath ? (
<TooltipContent side="top" align="start" maxWidth={520}> <TooltipContent side="top" align="start" maxWidth={520}>
<View style={styles.tooltipBody}> <Text selectable={false} style={styles.tooltipPath}>
<Text selectable={false} style={styles.tooltipPath}> {filePath}
{filePath} </Text>
</Text>
<View style={styles.tooltipHintRow}>
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
<Text selectable={false} style={styles.tooltipHintText}>
click for side pane
</Text>
</View>
</View>
</TooltipContent> </TooltipContent>
) : null} ) : null}
</Tooltip> </Tooltip>
@@ -255,27 +235,10 @@ function preventAnchorNavigation(event: MouseEvent<HTMLAnchorElement>): void {
event.preventDefault(); event.preventDefault();
} }
function isModifiedOpenEvent(event: MouseEvent<HTMLElement>): boolean {
return event.metaKey || event.ctrlKey;
}
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
tooltipBody: {
gap: theme.spacing[1],
},
tooltipPath: { tooltipPath: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, 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,
},
})); }));
@@ -136,7 +136,6 @@ describe("useFileLink", () => {
expect(result.current).toBe(first); expect(result.current).toBe(first);
expect(result.current.onHoverIn).toBe(first.onHoverIn); expect(result.current.onHoverIn).toBe(first.onHoverIn);
expect(result.current.onPress).toBe(first.onPress); expect(result.current.onPress).toBe(first.onPress);
expect(result.current.onAuxPress).toBe(first.onAuxPress);
expect(result.current.open).toBe(first.open); expect(result.current.open).toBe(first.open);
}); });
@@ -177,7 +176,7 @@ describe("useFileLink", () => {
lineStart: undefined, lineStart: undefined,
lineEnd: undefined, lineEnd: undefined,
}, },
disposition: "main", disposition: "side",
}, },
]); ]);
}); });
@@ -20,7 +20,6 @@ export interface UseFileLinkResult {
target: InlinePathTarget | null; target: InlinePathTarget | null;
onHoverIn: () => void; onHoverIn: () => void;
onPress: () => void; onPress: () => void;
onAuxPress: () => void;
open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
} }
@@ -118,9 +117,6 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
}); });
const onPress = useStableEvent(() => { const onPress = useStableEvent(() => {
open(stableSource, "main");
});
const onAuxPress = useStableEvent(() => {
open(stableSource, "side"); open(stableSource, "side");
}); });
@@ -131,10 +127,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
return query.data ?? null; return query.data ?? null;
}, [query.data, resolution]); }, [query.data, resolution]);
return useMemo( return useMemo(() => ({ target, onHoverIn, onPress, open }), [target, onHoverIn, onPress, open]);
() => ({ target, onHoverIn, onPress, onAuxPress, open }),
[target, onHoverIn, onPress, onAuxPress, open],
);
} }
export function useAssistantFileLinkActions(): AssistantFileLinkActions { export function useAssistantFileLinkActions(): AssistantFileLinkActions {
+12 -103
View File
@@ -13,18 +13,8 @@ import { Gesture } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { formatPrTabLabel, PullRequestTabIcon } from "@/git/pull-request-panel";
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 { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store"; import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store";
import { useToast } from "@/contexts/toast-context";
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures"; import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { HEADER_INNER_HEIGHT } from "@/constants/layout"; 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 { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { RetainedPanelActivity } from "@/components/retained-panel"; import { RetainedPanelActivity } from "@/components/retained-panel";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout"; import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
import { import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout"; } from "@/components/sidebar-resize-handle-layout";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { PullRequestContent } from "@/panels/pull-request";
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target"; import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
import { useDraftStore } from "@/stores/draft-store";
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {} function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -318,31 +305,19 @@ function ExplorerSidebarContent({
}: SidebarContentProps) { }: SidebarContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast();
const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right"); const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right");
const canQueryPullRequest = isGit && Boolean(workspaceRoot); const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({
const prPane = usePrPaneData({
serverId, serverId,
cwd: workspaceRoot, cwd: workspaceRoot,
enabled: canQueryPullRequest && isOpen, isGit,
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen, requested: activeTab === "pr",
enabled: isOpen,
timelineEnabled: activeTab === "pr",
}); });
const hasPullRequest = prPane.prNumber !== null;
const showPrTab = hasPullRequest || (activeTab === "pr" && prPane.isLoading);
const requestedTab: ExplorerTab = const requestedTab: ExplorerTab =
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab; !isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab; const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab;
const prTabLabel = formatPrTabLabel(prPane.prNumber); 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 ( return (
<View style={styles.sidebarContent} pointerEvents="auto"> <View style={styles.sidebarContent} pointerEvents="auto">
@@ -436,10 +411,9 @@ function ExplorerSidebarContent({
{resolvedTab === "pr" && ( {resolvedTab === "pr" && (
<PrTabContent <PrTabContent
serverId={serverId} serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot} cwd={workspaceRoot}
prPane={prPane} prPane={prPane}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
onRetry={handlePrRetry}
/> />
)} )}
</View> </View>
@@ -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<SidebarContentProps, "serverId" | "workspaceId">) {
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({ function ChangedFilesPane({
serverId, serverId,
workspaceId, workspaceId,
@@ -496,6 +434,7 @@ function ChangedFilesPane({
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return ( return (
<GitDiffPane <GitDiffPane
host="explorer"
serverId={serverId} serverId={serverId}
workspaceId={workspaceId} workspaceId={workspaceId}
cwd={workspaceRoot} cwd={workspaceRoot}
@@ -524,37 +463,7 @@ function FilesPane({
); );
} }
interface PrTabContentProps { const PrTabContent = PullRequestContent;
serverId: string;
cwd: string;
prPane: UsePrPaneDataResult;
workspaceAttachmentScopeKey: string;
onRetry: () => void;
}
function PrTabContent({
serverId,
cwd,
prPane,
workspaceAttachmentScopeKey,
onRetry,
}: PrTabContentProps) {
if (prPane.data) {
return (
<PullRequestPane
serverId={serverId}
cwd={cwd}
data={prPane.data}
activityLoading={prPane.activityLoading}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
/>
);
}
if (prPane.error) {
return <PullRequestPaneError onRetry={onRetry} />;
}
return <PullRequestPaneSkeleton />;
}
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to // 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 // avoid the "Unable to find node on an unmounted component" crash when Unistyles
+1 -1
View File
@@ -1459,7 +1459,7 @@ export const AssistantMessage = memo(function AssistantMessage({
const fileLinkActions = useAssistantFileLinkActions(); const fileLinkActions = useAssistantFileLinkActions();
const handleMarkdownLinkPress = useStableEvent((url: string) => { 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. // react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens. // We already handled it above, so return false to avoid duplicate opens.
return false; return false;
@@ -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);
});
});
@@ -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;
}
},
};
}
+23 -12
View File
@@ -1,19 +1,22 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { View, type PointerEvent as RNPointerEvent } from "react-native"; import { View, type PointerEvent as RNPointerEvent } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; 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 { export interface ResizeHandleProps {
testID?: string;
direction: "horizontal" | "vertical"; direction: "horizontal" | "vertical";
groupId: string; groupId: string;
index: number; index: number;
sizes: number[]; sizes: number[];
onPreviewResizeSplit: (groupId: string, sizes: number[]) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void; onResizeSplit: (groupId: string, sizes: number[]) => void;
} }
interface PointerState { interface PointerState {
containerSize: number; containerSize: number;
pointerStart: number; pointerStart: number;
drag: ResizeHandleDrag;
} }
function resetWindowHorizontalScroll() { function resetWindowHorizontalScroll() {
@@ -25,10 +28,12 @@ function resetWindowHorizontalScroll() {
} }
export function ResizeHandle({ export function ResizeHandle({
testID,
direction, direction,
groupId, groupId,
index, index,
sizes, sizes,
onPreviewResizeSplit,
onResizeSplit, onResizeSplit,
}: ResizeHandleProps) { }: ResizeHandleProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -68,6 +73,12 @@ export function ResizeHandle({
containerSize, containerSize,
pointerStart: pointerStart:
direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY, 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) { if (pointerStatesRef.current.size === 1) {
@@ -113,14 +124,7 @@ export function ResizeHandle({
const deltaRatio = const deltaRatio =
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize; (pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
onResizeSplit( pointerState.drag.move(deltaRatio);
groupId,
computeResizeHandleSizes({
sizes,
index,
deltaRatio,
}),
);
} }
function handlePointerUp(upEvent: PointerEvent) { function handlePointerUp(upEvent: PointerEvent) {
@@ -128,6 +132,7 @@ export function ResizeHandle({
return; return;
} }
pointerStatesRef.current.get(pointerId)?.drag.finish();
cleanup(); cleanup();
} }
@@ -135,7 +140,7 @@ export function ResizeHandle({
window.addEventListener("pointerup", handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp); window.addEventListener("pointercancel", handlePointerUp);
}, },
[direction, groupId, index, onResizeSplit, sizes], [direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes],
); );
const handlePointerEnter = useCallback(() => { const handlePointerEnter = useCallback(() => {
@@ -181,8 +186,14 @@ export function ResizeHandle({
); );
return ( return (
<View style={handleStyle}> <View style={handleStyle} testID={testID}>
{highlighted && <View pointerEvents="none" style={highlightStyle} />} {highlighted && (
<View
pointerEvents="none"
style={highlightStyle}
testID={testID ? `${testID}-highlight` : undefined}
/>
)}
<View <View
role="separator" role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"} aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
@@ -1,7 +1,6 @@
import { memo, useId, useMemo, useCallback, useState, type ReactNode } from "react"; import { memo, useMemo, useCallback, useState, type ReactNode } from "react";
import { Text, View, type ViewStyle } from "react-native"; import { Text, View, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native"; import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native";
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual"; import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
@@ -28,11 +27,7 @@ import {
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { StatusRing } from "@/components/status-ring"; import { StatusRing } from "@/components/status-ring";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title"; import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
// The scrim spans more than the kebab so the fade starts left of the diff stat. Solid from
// SCRIM_SOLID_OFFSET rightward, which keeps the kebab itself off the gradient entirely.
const SCRIM_WIDTH = 48;
const SCRIM_SOLID_OFFSET = "55%";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const needsInputColorMapping = (theme: Theme) => ({ const needsInputColorMapping = (theme: Theme) => ({
@@ -45,38 +40,6 @@ const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolder = withUnistyles(Folder); const ThemedFolder = withUnistyles(Folder);
const ThemedFolderGit2 = withUnistyles(FolderGit2); 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 (
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
{/* Same color at both ends, varying only stopOpacity. Interpolating a hex toward
`transparent` goes through black in some engines and leaves a grey fringe. */}
<Stop offset="0%" stopColor={color} stopOpacity={0} />
<Stop offset={SCRIM_SOLID_OFFSET} stopColor={color} stopOpacity={1} />
<Stop offset="100%" stopColor={color} stopOpacity={1} />
</SvgLinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
);
}
const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
const scrimColorMappings: Record<SidebarSurfaceBackdrop, (theme: Theme) => { 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({ export function SidebarWorkspaceRowFrame({
workspace, workspace,
isDragging = false, isDragging = false,
@@ -365,13 +328,6 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
top: 0, top: 0,
right: 0, right: 0,
}, },
trailingActionScrim: {
position: "absolute",
top: 0,
bottom: 0,
right: 0,
width: SCRIM_WIDTH,
},
})); }));
export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) { export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
@@ -476,40 +432,14 @@ export function SidebarWorkspaceTrailingActionOverlay({
if (!visible || !children) return null; if (!visible || !children) return null;
return ( return (
<> <>
{scrimBackdrop ? <TrailingActionScrim backdrop={scrimBackdrop} /> : null} {scrimBackdrop ? (
<TrailingActionScrim backdrop={scrimBackdrop} testID="sidebar-workspace-trailing-scrim" />
) : null}
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View> <View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>
</> </>
); );
} }
/**
* 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 (
<View
style={sidebarWorkspaceRowStyles.trailingActionScrim}
pointerEvents="none"
testID="sidebar-workspace-trailing-scrim"
>
<ThemedTrailingActionScrimSvg
gradientId={gradientId}
uniProps={scrimColorMappings[backdrop]}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
workspaceRowContent: { workspaceRowContent: {
position: "relative", position: "relative",
+135 -47
View File
@@ -28,6 +28,11 @@ import {
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle"; import { ResizeHandle } from "@/components/resize-handle";
@@ -139,6 +144,9 @@ interface SplitPaneDropData {
paneId: string; paneId: string;
} }
const EMPTY_SPLIT_NODES: SplitNode[] = [];
const EMPTY_SPLIT_SIZES: number[] = [];
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData { function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab"; 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 }) { function SplitGroupChild({
const childStyle = useMemo(() => [styles.groupChild, { flex }], [flex]); resizeFlex,
return <View style={childStyle}>{children}</View>; index,
hidden,
children,
}: {
resizeFlex: SharedValue<number[]>;
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 (
<Animated.View
style={[childStyle, resizeStyle]}
testID={hidden ? "split-group-child-hidden" : "split-group-child"}
>
{children}
</Animated.View>
);
}
/**
* 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({ function SplitNodeView({
@@ -773,6 +840,23 @@ function SplitNodeView({
const storedGroupSizes = useWorkspaceLayoutStore((state) => const storedGroupSizes = useWorkspaceLayoutStore((state) =>
groupId ? state.splitSizesByWorkspace[workspaceKey]?.[groupId] : undefined, 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( const groupStyle = useMemo(
() => [ () => [
@@ -784,56 +868,56 @@ function SplitNodeView({
if (node.kind === "pane") { if (node.kind === "pane") {
return ( return (
<WindowChromeRegion corners={windowChromeCorners}> <RetainedPanel active={node.pane.hidden !== true}>
<SplitPaneView <WindowChromeRegion corners={windowChromeCorners}>
pane={node.pane} <SplitPaneView
uiTabs={uiTabs} pane={node.pane}
isFocused={node.pane.id === focusedPaneId} uiTabs={uiTabs}
normalizedServerId={normalizedServerId} isFocused={node.pane.id === focusedPaneId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedServerId={normalizedServerId}
isWorkspaceFocused={isWorkspaceFocused} normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey} isWorkspaceFocused={isWorkspaceFocused}
setHoveredCloseTabKey={setHoveredCloseTabKey} hoveredCloseTabKey={hoveredCloseTabKey}
closingTabIds={closingTabIds} setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab} closingTabIds={closingTabIds}
onCloseTab={onCloseTab} onNavigateTab={onNavigateTab}
onCopyResumeCommand={onCopyResumeCommand} onCloseTab={onCloseTab}
onCopyAgentId={onCopyAgentId} onCopyResumeCommand={onCopyResumeCommand}
onCopyTerminalId={onCopyTerminalId} onCopyAgentId={onCopyAgentId}
onCopyFilePath={onCopyFilePath} onCopyTerminalId={onCopyTerminalId}
onReloadAgent={onReloadAgent} onCopyFilePath={onCopyFilePath}
onRenameTab={onRenameTab} onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft} onRenameTab={onRenameTab}
onCloseTabsToRight={onCloseTabsToRight} onCloseTabsToLeft={onCloseTabsToLeft}
onCloseOtherTabs={onCloseOtherTabs} onCloseTabsToRight={onCloseTabsToRight}
onCreateDraftTab={onCreateDraftTab} onCloseOtherTabs={onCloseOtherTabs}
onCreateTerminalTab={onCreateTerminalTab} onCreateDraftTab={onCreateDraftTab}
onCreateBrowserTab={onCreateBrowserTab} onCreateTerminalTab={onCreateTerminalTab}
showCreateBrowserTab={showCreateBrowserTab} onCreateBrowserTab={onCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel} showCreateBrowserTab={showCreateBrowserTab}
onFocusPane={onFocusPane} buildPaneContentModel={buildPaneContentModel}
onSplitPane={onSplitPane} onFocusPane={onFocusPane}
onSplitPaneEmpty={onSplitPaneEmpty} onSplitPane={onSplitPane}
onReorderTabsInPane={onReorderTabsInPane} onSplitPaneEmpty={onSplitPaneEmpty}
renderPaneEmptyState={renderPaneEmptyState} onReorderTabsInPane={onReorderTabsInPane}
activeDragTabId={activeDragTabId} renderPaneEmptyState={renderPaneEmptyState}
showDropZones={showDropZones} activeDragTabId={activeDragTabId}
dropPreview={dropPreview} showDropZones={showDropZones}
tabDropPreview={tabDropPreview} dropPreview={dropPreview}
focusModeEnabled={focusModeEnabled} tabDropPreview={tabDropPreview}
onExitFocusMode={onExitFocusMode} focusModeEnabled={focusModeEnabled}
/> onExitFocusMode={onExitFocusMode}
</WindowChromeRegion> />
</WindowChromeRegion>
</RetainedPanel>
); );
} }
const groupSizes = storedGroupSizes ?? node.group.sizes;
return ( return (
<View style={groupStyle}> <View style={groupStyle}>
{node.group.children.map((child, index) => ( {node.group.children.map((child, index) => (
<Fragment key={getNodeKey(child)}> <Fragment key={getNodeKey(child)}>
<SplitGroupChild flex={groupSizes[index] ?? 1}> <SplitGroupChild resizeFlex={resizeFlex} index={index} hidden={isSplitNodeHidden(child)}>
<SplitNodeView <SplitNodeView
node={child} node={child}
workspaceKey={workspaceKey} workspaceKey={workspaceKey}
@@ -876,12 +960,16 @@ function SplitNodeView({
onExitFocusMode={onExitFocusMode} onExitFocusMode={onExitFocusMode}
/> />
</SplitGroupChild> </SplitGroupChild>
{index < node.group.children.length - 1 ? ( {index < node.group.children.length - 1 &&
!isSplitNodeHidden(child) &&
!isSplitNodeHidden(node.group.children[index + 1]) ? (
<ResizeHandle <ResizeHandle
testID="workspace-split-resize-handle"
direction={node.group.direction} direction={node.group.direction}
groupId={node.group.id} groupId={node.group.id}
index={index} index={index}
sizes={groupSizes} sizes={groupSizes}
onPreviewResizeSplit={previewResizeSplit}
onResizeSplit={onResizeSplit} onResizeSplit={onResizeSplit}
/> />
) : null} ) : null}
@@ -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);
});
});
@@ -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;
}
+117
View File
@@ -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 (
<View style={styles.container} testID={testID} onLayout={handleLayout}>
<View style={styles.content} testID={testID ? `${testID}-content` : undefined}>
{content}
</View>
<Animated.View
style={[styles.rail, railWidthStyle]}
testID={testID ? `${testID}-tree` : undefined}
>
<SidebarResizeHandle
edge="left"
gesture={resizeGesture}
pressed={resizePressed}
testID={testID ? `${testID}-resize-handle` : "tree-rail-resize-handle"}
/>
{tree}
</Animated.View>
</View>
);
}
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,
},
}));
@@ -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 (
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
{/* Vary opacity rather than interpolating toward `transparent`, which crosses black in
some engines and leaves a grey fringe. */}
<Stop offset="0%" stopColor={color} stopOpacity={0} />
<Stop offset={SCRIM_SOLID_OFFSET} stopColor={color} stopOpacity={1} />
<Stop offset="100%" stopColor={color} stopOpacity={1} />
</SvgLinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
);
}
const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
const backdropColorMappings: Record<SurfaceBackdrop, (theme: Theme) => { 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 (
<View style={styles.scrim} pointerEvents="none" testID={testID}>
<ThemedTrailingActionScrimSvg
gradientId={gradientId}
uniProps={backdropColorMappings[backdrop]}
/>
</View>
);
}
const styles = StyleSheet.create({
scrim: {
position: "absolute",
top: 0,
bottom: 0,
right: 0,
width: SCRIM_WIDTH,
},
});
@@ -46,4 +46,32 @@ describe("focused chat target", () => {
}), }),
).toBeNull(); ).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",
});
});
}); });
@@ -10,6 +10,29 @@ export interface FocusedChatTarget {
draftKey: string; draftKey: string;
} }
function resolveChatTab(
serverId: string,
tab: ReturnType<typeof collectAllTabs>[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: { export function resolveFocusedChatTarget(input: {
serverId: string; serverId: string;
layout: WorkspaceLayout | undefined; layout: WorkspaceLayout | undefined;
@@ -22,27 +45,15 @@ export function resolveFocusedChatTarget(input: {
if (!focusedTabId) { if (!focusedTabId) {
return null; return null;
} }
const tab = collectAllTabs(input.layout.root).find( const tabs = collectAllTabs(input.layout.root);
(candidate) => candidate.tabId === focusedTabId, 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;
} }
@@ -57,6 +57,7 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 48, minHeight: 48,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[3], gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
@@ -66,6 +67,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
message: { message: {
flex: 1, flex: 1,
flexBasis: 180,
minWidth: 0, minWidth: 0,
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
@@ -80,6 +82,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
actions: { actions: {
flexShrink: 0, flexShrink: 0,
marginLeft: "auto",
flexDirection: "row", flexDirection: "row",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
+139 -8
View File
@@ -12,6 +12,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { DiffStat } from "@/components/diff-stat"; import { DiffStat } from "@/components/diff-stat";
import { TreeRail } from "@/components/tree-rail";
import { import {
View, View,
Text, Text,
@@ -1575,6 +1576,7 @@ interface GitDiffPaneProps {
workspaceId?: string | null; workspaceId?: string | null;
cwd: string; cwd: string;
enabled?: boolean; enabled?: boolean;
host: "explorer" | "panel";
onOpenFile?: (path: string) => void; onOpenFile?: (path: string) => void;
onAddToChat?: (path: string) => void; onAddToChat?: (path: string) => void;
} }
@@ -1660,6 +1662,29 @@ interface ChangesTabToggleProps {
onPress: () => void; 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 <ChangesTabToggle isMobile={isMobile} selected={selected} onPress={onPress} />;
}
interface DiffModeMenuProps { interface DiffModeMenuProps {
diffMode: "uncommitted" | "base"; diffMode: "uncommitted" | "base";
committedDescription?: string; committedDescription?: string;
@@ -1749,17 +1774,21 @@ function ChangesTabToggle({ isMobile, selected, onPress }: ChangesTabToggleProps
interface DiffViewModeToggleProps { interface DiffViewModeToggleProps {
viewMode: "flat" | "tree"; viewMode: "flat" | "tree";
isMobile: boolean; isMobile: boolean;
toggleStyle: PressableStyleFn; toggleStyle?: PressableStyleFn;
onToggle: () => void; onToggle: () => void;
} }
function DiffViewModeToggle({ export function DiffViewModeToggle({
viewMode, viewMode,
isMobile, isMobile,
toggleStyle, toggleStyle,
onToggle, onToggle,
}: DiffViewModeToggleProps) { }: DiffViewModeToggleProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const defaultToggleStyle = useMemo(
() => buildToggleButtonStyle(viewMode === "tree", styles.expandAllButton),
[viewMode],
);
const label = const label =
viewMode === "flat" viewMode === "flat"
? t("workspace.git.diff.showTreeView") ? t("workspace.git.diff.showTreeView")
@@ -1771,7 +1800,7 @@ function DiffViewModeToggle({
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={label} accessibilityLabel={label}
testID="changes-toggle-view-mode" testID="changes-toggle-view-mode"
style={toggleStyle} style={toggleStyle ?? defaultToggleStyle}
onPress={onToggle} onPress={onToggle}
> >
{viewMode === "flat" ? ( {viewMode === "flat" ? (
@@ -2140,6 +2169,8 @@ interface SharedDiffViewProps {
}; };
} }
type WorkingTreeDiffMode = Extract<SharedDiffViewProps["mode"], { kind: "working_tree" }>;
export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffViewProps) { export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffViewProps) {
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const { layout, wrapLines, codeFontSize, monoFontFamily } = displayPreferences; 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 <SharedDiffView files={files} displayPreferences={displayPreferences} mode={mode} />;
}
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 (
<TreeRail testID="changes-tree-rail">
{children}
<ChangedFilesTree
workspaceId={workspaceId}
cwd={cwd}
files={files}
displayPreferences={displayPreferences}
workingTreeMode={workingTreeMode}
/>
</TreeRail>
);
}
function resolveWorkingTreeMode<T>(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({ function useDiffTabNavigation({
serverId, serverId,
workspaceId, workspaceId,
@@ -3000,6 +3106,7 @@ export function GitDiffPane({
workspaceId, workspaceId,
cwd, cwd,
enabled, enabled,
host,
onOpenFile, onOpenFile,
onAddToChat, onAddToChat,
}: GitDiffPaneProps) { }: GitDiffPaneProps) {
@@ -3049,11 +3156,13 @@ export function GitDiffPane({
}); });
const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager"); const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager");
const { const {
changesTabOpen, changesTabOpen: workspaceChangesTabOpen,
toggleChanges: handleToggleChangesTab, toggleChanges: handleToggleChangesTab,
openCommit: handleCommitPress, openCommit: handleCommitPress,
onChangesFilePress, onChangesFilePress: workspaceOnChangesFilePress,
} = useDiffTabNavigation({ serverId, workspaceId, cwd, isMobile }); } = useDiffTabNavigation({ serverId, workspaceId, cwd, isMobile });
const changesTabOpen = resolveChangesTabOpen(host, workspaceChangesTabOpen);
const onChangesFilePress = resolveChangesFilePress(host, workspaceOnChangesFilePress);
const refreshSupported = useSessionStore( const refreshSupported = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, (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 = (
<DiffBodyContent <DiffBodyContent
isStatusLoading={isStatusLoading} isStatusLoading={isStatusLoading}
statusErrorMessage={statusErrorMessage} statusErrorMessage={statusErrorMessage}
@@ -3299,10 +3417,22 @@ export function GitDiffPane({
<SharedDiffView <SharedDiffView
files={files} files={files}
displayPreferences={sharedDisplayPreferences} displayPreferences={sharedDisplayPreferences}
mode={workingTreeMode} mode={primaryWorkingTreeMode}
/> />
</DiffBodyContent> </DiffBodyContent>
); );
const bodyContent = (
<GitDiffTreeRail
shown={shouldShowTreeRail(viewMode, isMobile)}
workspaceId={workspaceId}
cwd={cwd}
files={files}
displayPreferences={sharedDisplayPreferences}
workingTreeMode={workingTreeMode}
>
{diffContent}
</GitDiffTreeRail>
);
return ( return (
<View <View
@@ -3335,7 +3465,8 @@ export function GitDiffPane({
onSelectBase={handleSelectBase} onSelectBase={handleSelectBase}
/> />
<View style={styles.diffStatusButtons}> <View style={styles.diffStatusButtons}>
<ChangesTabToggle <ChangesTabToggleForHost
host={host}
isMobile={isMobile} isMobile={isMobile}
selected={changesTabOpen} selected={changesTabOpen}
onPress={handleToggleChangesTab} onPress={handleToggleChangesTab}
+9
View File
@@ -129,6 +129,15 @@ describe("translation resources", () => {
expect(countMatchingEnglishStrings(zhCN)).toBeLessThan(maxFallbackStrings); 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", () => { it("preserves interpolation placeholders in every language", () => {
expect(findInterpolationMismatches(ar)).toEqual([]); expect(findInterpolationMismatches(ar)).toEqual([]);
expect(findInterpolationMismatches(es)).toEqual([]); expect(findInterpolationMismatches(es)).toEqual([]);
+15
View File
@@ -587,6 +587,7 @@ export const ar: TranslationResources = {
renameAgent: "إعادة تسمية الوكيل", renameAgent: "إعادة تسمية الوكيل",
}, },
actions: { actions: {
newTab: "علامة تبويب جديدة",
newAgent: "وكيل جديد", newAgent: "وكيل جديد",
newTerminal: "محطة جديدة", newTerminal: "محطة جديدة",
preparingTerminal: "إعداد علامة التبويب المحطة الطرفية", preparingTerminal: "إعداد علامة التبويب المحطة الطرفية",
@@ -595,6 +596,9 @@ export const ar: TranslationResources = {
exitFocusMode: "إنهاء وضع التركيز", exitFocusMode: "إنهاء وضع التركيز",
splitRight: "تقسيم الجزء الأيمن", splitRight: "تقسيم الجزء الأيمن",
splitDown: "تقسيم الجزء لأسفل", splitDown: "تقسيم الجزء لأسفل",
changes: "التغييرات",
files: "الملفات",
pullRequest: "طلب السحب",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles…",
pinTarget: "تثبيت", pinTarget: "تثبيت",
@@ -1658,6 +1662,17 @@ export const ar: TranslationResources = {
reloadMessage: "ستفقد تغييراتك المحلية.", reloadMessage: "ستفقد تغييراتك المحلية.",
}, },
}, },
files: {
label: "الملفات",
subtitle: "ملفات مساحة العمل",
tooltip: "تصفح ملفات مساحة العمل",
},
pullRequest: {
label: "طلب السحب",
subtitle: "تفاصيل طلب السحب",
emptyTitle: "لا يوجد طلب سحب بعد",
emptyDescription: "أنشئ طلب سحب لنسخة العمل هذه لعرض تفاصيله هنا.",
},
diff: { diff: {
changesLabel: "التغييرات", changesLabel: "التغييرات",
changesSubtitle: "فروقات شجرة العمل", changesSubtitle: "فروقات شجرة العمل",
+15
View File
@@ -585,6 +585,7 @@ export const en = {
renameAgent: "Rename agent", renameAgent: "Rename agent",
}, },
actions: { actions: {
newTab: "New tab",
newAgent: "New agent", newAgent: "New agent",
newTerminal: "New terminal", newTerminal: "New terminal",
preparingTerminal: "Preparing terminal tab", preparingTerminal: "Preparing terminal tab",
@@ -593,6 +594,9 @@ export const en = {
exitFocusMode: "Exit focus mode", exitFocusMode: "Exit focus mode",
splitRight: "Split pane right", splitRight: "Split pane right",
splitDown: "Split pane down", splitDown: "Split pane down",
changes: "Changes",
files: "Files",
pullRequest: "Pull request",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles…",
pinTarget: "Pin", pinTarget: "Pin",
@@ -1668,6 +1672,17 @@ export const en = {
reloadMessage: "Your local changes will be lost.", 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: { diff: {
changesLabel: "Changes", changesLabel: "Changes",
changesSubtitle: "Working tree diff", changesSubtitle: "Working tree diff",
+15
View File
@@ -592,6 +592,7 @@ export const es: TranslationResources = {
renameAgent: "Cambiar nombre del agente", renameAgent: "Cambiar nombre del agente",
}, },
actions: { actions: {
newTab: "Nueva pestaña",
newAgent: "Nuevo agente", newAgent: "Nuevo agente",
newTerminal: "Nueva terminal", newTerminal: "Nueva terminal",
preparingTerminal: "Preparando la pestaña del terminal", preparingTerminal: "Preparando la pestaña del terminal",
@@ -600,6 +601,9 @@ export const es: TranslationResources = {
exitFocusMode: "Salir del modo de concentración", exitFocusMode: "Salir del modo de concentración",
splitRight: "Panel dividido a la derecha", splitRight: "Panel dividido a la derecha",
splitDown: "Dividir panel hacia abajo", splitDown: "Dividir panel hacia abajo",
changes: "Cambios",
files: "Archivos",
pullRequest: "Solicitud de extracción",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles…",
pinTarget: "Fijar", pinTarget: "Fijar",
@@ -1701,6 +1705,17 @@ export const es: TranslationResources = {
reloadMessage: "Se perderán tus cambios locales.", 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: { diff: {
changesLabel: "Cambios", changesLabel: "Cambios",
changesSubtitle: "Diferencias del árbol de trabajo", changesSubtitle: "Diferencias del árbol de trabajo",
+16
View File
@@ -592,6 +592,7 @@ export const fr: TranslationResources = {
renameAgent: "Renommer l'agent", renameAgent: "Renommer l'agent",
}, },
actions: { actions: {
newTab: "Nouvel onglet",
newAgent: "Nouvel agent", newAgent: "Nouvel agent",
newTerminal: "Nouveau terminal", newTerminal: "Nouveau terminal",
preparingTerminal: "Préparation de l'onglet du terminal", preparingTerminal: "Préparation de l'onglet du terminal",
@@ -600,6 +601,9 @@ export const fr: TranslationResources = {
exitFocusMode: "Quitter le mode concentration", exitFocusMode: "Quitter le mode concentration",
splitRight: "Volet divisé à droite", splitRight: "Volet divisé à droite",
splitDown: "Diviser le volet vers le bas", splitDown: "Diviser le volet vers le bas",
changes: "Modifications",
files: "Fichiers",
pullRequest: "Demande de fusion",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles…",
pinTarget: "Épingler", pinTarget: "Épingler",
@@ -1705,6 +1709,18 @@ export const fr: TranslationResources = {
reloadMessage: "Vos modifications locales seront perdues.", 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: { diff: {
changesLabel: "Modifications", changesLabel: "Modifications",
changesSubtitle: "Différences de l'arbre de travail", changesSubtitle: "Différences de l'arbre de travail",
+16
View File
@@ -592,6 +592,7 @@ export const ja: TranslationResources = {
renameAgent: "エージェントの名前を変更", renameAgent: "エージェントの名前を変更",
}, },
actions: { actions: {
newTab: "新しいタブ",
newAgent: "新しいエージェント", newAgent: "新しいエージェント",
newTerminal: "新しいターミナル", newTerminal: "新しいターミナル",
preparingTerminal: "ターミナルタブを準備中", preparingTerminal: "ターミナルタブを準備中",
@@ -600,6 +601,9 @@ export const ja: TranslationResources = {
exitFocusMode: "フォーカスモードを終了", exitFocusMode: "フォーカスモードを終了",
splitRight: "右にペインを分割", splitRight: "右にペインを分割",
splitDown: "下にペインを分割", splitDown: "下にペインを分割",
changes: "変更",
files: "ファイル",
pullRequest: "プルリクエスト",
terminalProfilesMenu: "ターミナルプロファイル", terminalProfilesMenu: "ターミナルプロファイル",
editTerminalProfiles: "プロファイルを編集…", editTerminalProfiles: "プロファイルを編集…",
pinTarget: "ピン留め", pinTarget: "ピン留め",
@@ -1674,6 +1678,18 @@ export const ja: TranslationResources = {
reloadMessage: "ローカルの変更は失われます。", reloadMessage: "ローカルの変更は失われます。",
}, },
}, },
files: {
label: "ファイル",
subtitle: "ワークスペースのファイル",
tooltip: "ワークスペースのファイルを参照",
},
pullRequest: {
label: "プルリクエスト",
subtitle: "プルリクエストの詳細",
emptyTitle: "プルリクエストはまだありません",
emptyDescription:
"このチェックアウトのプルリクエストを作成すると、ここに詳細が表示されます。",
},
diff: { diff: {
changesLabel: "変更", changesLabel: "変更",
changesSubtitle: "作業ツリーの差分", changesSubtitle: "作業ツリーの差分",
+15
View File
@@ -589,6 +589,7 @@ export const ko: TranslationResources = {
renameAgent: "에이전트 이름 변경", renameAgent: "에이전트 이름 변경",
}, },
actions: { actions: {
newTab: "새 탭",
newAgent: "새 에이전트", newAgent: "새 에이전트",
newTerminal: "새 터미널", newTerminal: "새 터미널",
preparingTerminal: "터미널 탭 준비 중", preparingTerminal: "터미널 탭 준비 중",
@@ -597,6 +598,9 @@ export const ko: TranslationResources = {
exitFocusMode: "집중 모드 종료", exitFocusMode: "집중 모드 종료",
splitRight: "창을 오른쪽으로 분할", splitRight: "창을 오른쪽으로 분할",
splitDown: "창을 아래로 분할", splitDown: "창을 아래로 분할",
changes: "변경 사항",
files: "파일",
pullRequest: "풀 리퀘스트",
terminalProfilesMenu: "터미널 프로필", terminalProfilesMenu: "터미널 프로필",
editTerminalProfiles: "프로필 편집…", editTerminalProfiles: "프로필 편집…",
pinTarget: "고정", pinTarget: "고정",
@@ -1668,6 +1672,17 @@ export const ko: TranslationResources = {
reloadMessage: "로컬 변경사항이 손실됩니다.", reloadMessage: "로컬 변경사항이 손실됩니다.",
}, },
}, },
files: {
label: "파일",
subtitle: "워크스페이스 파일",
tooltip: "워크스페이스 파일 탐색",
},
pullRequest: {
label: "풀 리퀘스트",
subtitle: "풀 리퀘스트 세부 정보",
emptyTitle: "아직 풀 리퀘스트가 없습니다",
emptyDescription: "이 체크아웃에 풀 리퀘스트를 만들면 세부 정보가 여기에 표시됩니다.",
},
diff: { diff: {
changesLabel: "변경 사항", changesLabel: "변경 사항",
changesSubtitle: "작업 트리 diff", changesSubtitle: "작업 트리 diff",
+15
View File
@@ -591,6 +591,7 @@ export const ptBR: TranslationResources = {
renameAgent: "Renomear agente", renameAgent: "Renomear agente",
}, },
actions: { actions: {
newTab: "Nova aba",
newAgent: "Novo agente", newAgent: "Novo agente",
newTerminal: "Novo terminal", newTerminal: "Novo terminal",
preparingTerminal: "Preparando aba de terminal", preparingTerminal: "Preparando aba de terminal",
@@ -599,6 +600,9 @@ export const ptBR: TranslationResources = {
exitFocusMode: "Sair do modo de foco", exitFocusMode: "Sair do modo de foco",
splitRight: "Dividir painel à direita", splitRight: "Dividir painel à direita",
splitDown: "Dividir painel abaixo", splitDown: "Dividir painel abaixo",
changes: "Alterações",
files: "Arquivos",
pullRequest: "Pull request",
terminalProfilesMenu: "Perfis de terminal", terminalProfilesMenu: "Perfis de terminal",
editTerminalProfiles: "Editar perfis…", editTerminalProfiles: "Editar perfis…",
pinTarget: "Fixar", pinTarget: "Fixar",
@@ -1687,6 +1691,17 @@ export const ptBR: TranslationResources = {
reloadMessage: "Suas alterações locais serão perdidas.", 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: { diff: {
changesLabel: "Alterações", changesLabel: "Alterações",
changesSubtitle: "Diff da árvore de trabalho", changesSubtitle: "Diff da árvore de trabalho",
+16
View File
@@ -591,6 +591,7 @@ export const ru: TranslationResources = {
renameAgent: "Переименовать агента", renameAgent: "Переименовать агента",
}, },
actions: { actions: {
newTab: "Новая вкладка",
newAgent: "Новый агент", newAgent: "Новый агент",
newTerminal: "Новый терминал", newTerminal: "Новый терминал",
preparingTerminal: "Подготовка вкладки терминала", preparingTerminal: "Подготовка вкладки терминала",
@@ -599,6 +600,9 @@ export const ru: TranslationResources = {
exitFocusMode: "Выйти из режима фокусировки", exitFocusMode: "Выйти из режима фокусировки",
splitRight: "Разделить панель справа", splitRight: "Разделить панель справа",
splitDown: "Разделить панель вниз", splitDown: "Разделить панель вниз",
changes: "Изменения",
files: "Файлы",
pullRequest: "Запрос на слияние",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles…",
pinTarget: "Закрепить", pinTarget: "Закрепить",
@@ -1692,6 +1696,18 @@ export const ru: TranslationResources = {
reloadMessage: "Локальные изменения будут потеряны.", reloadMessage: "Локальные изменения будут потеряны.",
}, },
}, },
files: {
label: "Файлы",
subtitle: "Файлы рабочего пространства",
tooltip: "Просмотр файлов рабочего пространства",
},
pullRequest: {
label: "Запрос на слияние",
subtitle: "Сведения о запросе на слияние",
emptyTitle: "Запроса на слияние пока нет",
emptyDescription:
"Создайте запрос на слияние для этой рабочей копии, чтобы увидеть здесь его сведения.",
},
diff: { diff: {
changesLabel: "Изменения", changesLabel: "Изменения",
changesSubtitle: "Различия рабочего дерева", changesSubtitle: "Различия рабочего дерева",
+15
View File
@@ -587,6 +587,7 @@ export const zhCN: TranslationResources = {
renameAgent: "重命名 Agent", renameAgent: "重命名 Agent",
}, },
actions: { actions: {
newTab: "新建标签页",
newAgent: "新建 Agent", newAgent: "新建 Agent",
newTerminal: "新建 Terminal", newTerminal: "新建 Terminal",
preparingTerminal: "正在准备 Terminal 标签", preparingTerminal: "正在准备 Terminal 标签",
@@ -595,6 +596,9 @@ export const zhCN: TranslationResources = {
exitFocusMode: "退出专注模式", exitFocusMode: "退出专注模式",
splitRight: "向右拆分窗格", splitRight: "向右拆分窗格",
splitDown: "向下拆分窗格", splitDown: "向下拆分窗格",
changes: "更改",
files: "文件",
pullRequest: "拉取请求",
terminalProfilesMenu: "Terminal profiles", terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…", editTerminalProfiles: "Edit profiles…",
pinTarget: "固定", pinTarget: "固定",
@@ -1638,6 +1642,17 @@ export const zhCN: TranslationResources = {
reloadMessage: "本地更改将丢失。", reloadMessage: "本地更改将丢失。",
}, },
}, },
files: {
label: "文件",
subtitle: "工作区文件",
tooltip: "浏览工作区文件",
},
pullRequest: {
label: "拉取请求",
subtitle: "拉取请求详情",
emptyTitle: "尚无拉取请求",
emptyDescription: "为此检出创建拉取请求后,可在此处查看其详情。",
},
diff: { diff: {
changesLabel: "更改", changesLabel: "更改",
changesSubtitle: "工作区差异", changesSubtitle: "工作区差异",
+28 -188
View File
@@ -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 { Text, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FileDiff, GitCommitHorizontal } from "lucide-react-native"; import { FileDiff, GitCommitHorizontal } from "lucide-react-native";
@@ -7,25 +7,13 @@ import invariant from "tiny-invariant";
import { useRetainedPanelActive } from "@/components/retained-panel"; import { useRetainedPanelActive } from "@/components/retained-panel";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context"; import { DiffLayoutToggle, GitDiffPane, resolveDiffLayout, SharedDiffView } from "@/git/diff-pane";
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 { useCommitDiffFiles } from "@/git/use-diff-files"; import { useCommitDiffFiles } from "@/git/use-diff-files";
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { usePaneContext } from "@/panels/pane-context"; import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
@@ -56,6 +44,9 @@ function useDiffPanelPreferences() {
const toggleHideWhitespace = useCallback(() => { const toggleHideWhitespace = useCallback(() => {
void updatePreferences({ hideWhitespace: !preferences.hideWhitespace }); void updatePreferences({ hideWhitespace: !preferences.hideWhitespace });
}, [preferences.hideWhitespace, updatePreferences]); }, [preferences.hideWhitespace, updatePreferences]);
const toggleViewMode = useCallback(() => {
void updatePreferences({ viewMode: preferences.viewMode === "flat" ? "tree" : "flat" });
}, [preferences.viewMode, updatePreferences]);
return { return {
preferences, preferences,
@@ -65,6 +56,7 @@ function useDiffPanelPreferences() {
toggleLayout, toggleLayout,
toggleWrapLines, toggleWrapLines,
toggleHideWhitespace, toggleHideWhitespace,
toggleViewMode,
}; };
} }
@@ -84,186 +76,34 @@ function PanelState({
); );
} }
function WorkingDiffBody({ function WorkingDiffPanel() {
cwd,
isConnected,
workingDiff,
hideWhitespace,
displayPreferences,
mode,
}: {
cwd: string | null | undefined;
isConnected: boolean;
workingDiff: ReturnType<typeof useWorkingDiff>;
hideWhitespace: boolean;
displayPreferences: ReturnType<typeof useDiffPanelPreferences>["displayPreferences"];
mode: Extract<ComponentProps<typeof SharedDiffView>["mode"], { kind: "working_tab" }>;
}) {
const { t } = useTranslation(); 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) { if (!cwd) {
return <PanelState message={t("panels.diff.directoryMissing")} />; return <PanelState message={t("panels.diff.directoryMissing")} />;
} }
if (!isConnected) {
return <PanelState message={t("workspace.terminal.hostDisconnected")} />;
}
if (workingDiff.isStatusLoading) {
return <PanelState message={t("workspace.git.diff.checkingRepository")} />;
}
if (workingDiff.statusErrorMessage) {
return (
<PanelState
message={workingDiff.statusErrorMessage}
tone="error"
testID="working-diff-error"
/>
);
}
if (workingDiff.notGit) {
return <PanelState message={t("workspace.git.diff.notRepository")} />;
}
if (workingDiff.diffTooLarge) {
return <DiffTooLargeState />;
}
if (workingDiff.diffPayloadError) {
return (
<PanelState message={t("panels.diff.loadError")} tone="error" testID="working-diff-error" />
);
}
if (workingDiff.isDiffLoading && workingDiff.files.length === 0) {
return <PanelState message={t("workspace.tabs.loading")} testID="working-diff-loading" />;
}
if (workingDiff.files.length === 0) {
return (
<PanelState
message={
hideWhitespace ? t("workspace.git.diff.emptyHiddenWhitespace") : t("panels.diff.empty")
}
testID="working-diff-empty"
/>
);
}
return (
<SharedDiffView files={workingDiff.files} displayPreferences={displayPreferences} mode={mode} />
);
}
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<string[] | null>(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 ( return (
<View style={styles.container} testID="working-diff-panel"> <View style={styles.container} testID="working-diff-panel">
<View style={styles.toolbar}> <GitDiffPane
<DiffModeMenu serverId={serverId}
diffMode={workingDiff.diffMode} workspaceId={workspaceId}
committedDescription={baseRefLabel || undefined} cwd={cwd}
testIDPrefix="working-diff" enabled={isActive}
onSelectUncommitted={workingDiff.selectUncommitted} host="panel"
onSelectBase={workingDiff.selectBase} onOpenFile={handleOpenFile}
/> onAddToChat={canAddToChat ? addFile : undefined}
<View style={styles.toolbarActions} testID="working-diff-toolbar"> />
{panelPreferences.canUseSplitLayout ? (
<DiffLayoutToggle
layout={panelPreferences.preferences.layout}
isMobile={panelPreferences.isCompact}
testID="working-diff-toggle-layout"
onToggle={panelPreferences.toggleLayout}
/>
) : null}
{workingDiff.files.length > 0 ? (
<DiffFilesToolbar
allFileDiffsExpanded={allFilesExpanded}
isMobile={panelPreferences.isCompact}
testID="working-diff-toggle-expand-all"
onToggleExpandAll={toggleExpandAll}
/>
) : null}
<DiffOptionsMenu
hideWhitespace={panelPreferences.preferences.hideWhitespace}
isMobile={panelPreferences.isCompact}
isRefreshing={isRefreshing}
refreshSupported={refreshSupported}
testIDPrefix="working-diff"
wrapLines={panelPreferences.preferences.wrapLines}
onRefresh={refresh}
onToggleHideWhitespace={panelPreferences.toggleHideWhitespace}
onToggleWrapLines={panelPreferences.toggleWrapLines}
/>
</View>
</View>
<View style={styles.body}>
<WorkingDiffBody
cwd={cwd}
isConnected={isConnected}
workingDiff={workingDiff}
hideWhitespace={panelPreferences.preferences.hideWhitespace}
displayPreferences={panelPreferences.displayPreferences}
mode={mode}
/>
</View>
</View> </View>
); );
} }
+35 -8
View File
@@ -1,5 +1,5 @@
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { useMemo } from "react"; import { useCallback, useMemo } from "react";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FilePane } from "@/file-pane/pane"; import { FilePane } from "@/file-pane/pane";
@@ -7,6 +7,9 @@ import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry"; import type { PanelRegistration } from "@/panels/panel-registry";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { createMaterialFileIcon } from "@/components/material-file-icon"; 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 = { const CENTERED_PADDED_STYLE = {
flex: 1, flex: 1,
@@ -30,8 +33,14 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
function FilePanel() { function FilePanel() {
const { t } = useTranslation(); 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 workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
const handleOpenFile = useCallback(
(path: string) => retargetCurrentTab({ kind: "file", path }),
[retargetCurrentTab],
);
invariant(target.kind === "file", "FilePanel requires file target"); invariant(target.kind === "file", "FilePanel requires file target");
if (!workspaceDirectory) { if (!workspaceDirectory) {
return ( return (
@@ -40,13 +49,31 @@ function FilePanel() {
</View> </View>
); );
} }
if (isCompact) {
return (
<FilePane
serverId={serverId}
workspaceRoot={workspaceDirectory}
location={target}
navigationRevision={fileNavigationRevision ?? 0}
/>
);
}
return ( return (
<FilePane <TreeRail testID="file-tree-rail">
serverId={serverId} <FilePane
workspaceRoot={workspaceDirectory} serverId={serverId}
location={target} workspaceRoot={workspaceDirectory}
navigationRevision={fileNavigationRevision ?? 0} location={target}
/> navigationRevision={fileNavigationRevision ?? 0}
/>
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceDirectory}
onOpenFile={handleOpenFile}
/>
</TreeRail>
); );
} }
+65
View File
@@ -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 (
<View style={CENTERED_PADDED_STYLE}>
<Text>{t("panels.file.directoryMissing")}</Text>
</View>
);
}
return (
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
export const filesPanelRegistration: PanelRegistration<"files"> = {
kind: "files",
component: FilesPanel,
useDescriptor: useFilesPanelDescriptor,
};
@@ -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),
};
}
@@ -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 (
<View style={CENTERED_PADDED_STYLE}>
<Text>{t("panels.file.directoryMissing")}</Text>
</View>
);
}
return (
<PullRequestContent serverId={serverId} workspaceId={workspaceId} cwd={cwd} prPane={prPane} />
);
}
export const pullRequestPanelRegistration: PanelRegistration<"pull_request"> = {
kind: "pull_request",
component: PullRequestPanel,
useDescriptor: usePullRequestPanelDescriptor,
};
@@ -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();
});
});
@@ -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 (
<PullRequestPane
serverId={input.serverId}
cwd={input.cwd}
data={input.prPane.data}
activityLoading={input.prPane.activityLoading}
workspaceAttachmentScopeKey={buildWorkspaceAttachmentScopeKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
cwd: input.cwd,
})}
/>
);
}
const contentState = resolvePullRequestContentState(input.prPane);
if (contentState === "error") {
return <PullRequestPaneError onRetry={onRetry} />;
}
if (contentState === "loading") {
return <PullRequestPaneSkeleton />;
}
return (
<View style={EMPTY_STYLE} testID="pull-request-empty-state">
<Text style={EMPTY_TITLE_STYLE}>{t("panels.pullRequest.emptyTitle")}</Text>
<Text>{t("panels.pullRequest.emptyDescription")}</Text>
</View>
);
}
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;
@@ -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";
}
@@ -3,10 +3,12 @@ import { browserPanelRegistration } from "@/desktop/browser/panel";
import { commitDiffPanelRegistration, workingDiffPanelRegistration } from "@/panels/diff-panel"; import { commitDiffPanelRegistration, workingDiffPanelRegistration } from "@/panels/diff-panel";
import { draftPanelRegistration } from "@/panels/draft-panel"; import { draftPanelRegistration } from "@/panels/draft-panel";
import { filePanelRegistration } from "@/panels/file-panel"; import { filePanelRegistration } from "@/panels/file-panel";
import { filesPanelRegistration } from "@/panels/files-panel";
import { registerPanel } from "@/panels/panel-registry"; import { registerPanel } from "@/panels/panel-registry";
import { setupPanelRegistration } from "@/panels/setup-panel"; import { setupPanelRegistration } from "@/panels/setup-panel";
import { terminalPanelRegistration } from "@/panels/terminal-panel"; import { terminalPanelRegistration } from "@/panels/terminal-panel";
import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel"; import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel";
import { pullRequestPanelRegistration } from "@/panels/pull-request-panel";
let panelsRegistered = false; let panelsRegistered = false;
@@ -21,6 +23,8 @@ export function ensurePanelsRegistered(): void {
registerPanel(terminalPanelRegistration); registerPanel(terminalPanelRegistration);
registerPanel(browserPanelRegistration); registerPanel(browserPanelRegistration);
registerPanel(filePanelRegistration); registerPanel(filePanelRegistration);
registerPanel(filesPanelRegistration);
registerPanel(pullRequestPanelRegistration);
registerPanel(commitDiffPanelRegistration); registerPanel(commitDiffPanelRegistration);
registerPanel(workingDiffPanelRegistration); registerPanel(workingDiffPanelRegistration);
panelsRegistered = true; panelsRegistered = true;
@@ -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 };
}
@@ -20,13 +20,15 @@ import {
CopyX, CopyX,
ArrowLeftToLine, ArrowLeftToLine,
ArrowRightToLine, ArrowRightToLine,
ChevronDown,
Columns2, Columns2,
Copy, Copy,
Pencil, Pencil,
RotateCw, RotateCw,
Rows2, Rows2,
Globe, Globe,
FileDiff,
FolderTree,
GitPullRequest,
Plus, Plus,
SquarePen, SquarePen,
SquareTerminal, SquareTerminal,
@@ -59,7 +61,7 @@ import {
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; 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 type { ShortcutKey } from "@/utils/format-shortcut";
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
import { import {
@@ -75,6 +77,7 @@ import {
type WorkspaceTabMenuLabels, type WorkspaceTabMenuLabels,
} from "@/screens/workspace/workspace-tab-menu"; } from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { RenderProfile } from "@/utils/render-profiler"; import { RenderProfile } from "@/utils/render-profiler";
import { useDaemonConfig } from "@/hooks/use-daemon-config"; 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 type { PinnedTabTarget } from "@/workspace-pins/target";
import { PinnedTargetsRow } from "@/workspace-pins/pinned-targets-row"; import { PinnedTargetsRow } from "@/workspace-pins/pinned-targets-row";
import { PinnableMenuItem } from "@/workspace-pins/pinnable-menu-item"; 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 DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36; 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 ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedX = withUnistyles(X); const ThemedX = withUnistyles(X);
@@ -104,21 +122,29 @@ const ThemedCopyX = withUnistyles(CopyX);
const ThemedPencil = withUnistyles(Pencil); const ThemedPencil = withUnistyles(Pencil);
const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedGlobe = withUnistyles(Globe); const ThemedGlobe = withUnistyles(Globe);
const ThemedColumns2 = withUnistyles(Columns2); const ThemedColumns2 = withUnistyles(Columns2);
const ThemedRows2 = withUnistyles(Rows2); const ThemedRows2 = withUnistyles(Rows2);
const ThemedPlus = withUnistyles(Plus); 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 foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const AGENT_ICON = <ThemedSquarePen size={14} uniProps={mutedColorMapping} />; const AGENT_ICON = <ThemedSquarePen size={14} uniProps={mutedColorMapping} />;
const TERMINAL_ICON = <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />; const TERMINAL_ICON = <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />;
const BROWSER_ICON = <ThemedGlobe size={14} uniProps={mutedColorMapping} />; const BROWSER_ICON = <ThemedGlobe size={14} uniProps={mutedColorMapping} />;
const CHANGES_ICON = <ThemedFileDiff size={14} uniProps={mutedColorMapping} />;
const FILES_ICON = <ThemedFolderTree size={14} uniProps={mutedColorMapping} />;
const PULL_REQUEST_ICON = <ThemedGitPullRequest size={14} uniProps={mutedColorMapping} />;
const DRAFT_TARGET: PinnedTabTarget = { kind: "draft" }; const DRAFT_TARGET: PinnedTabTarget = { kind: "draft" };
const TERMINAL_TARGET: PinnedTabTarget = { kind: "terminal" }; const TERMINAL_TARGET: PinnedTabTarget = { kind: "terminal" };
const BROWSER_TARGET: PinnedTabTarget = { kind: "browser" }; 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) { function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
@@ -169,67 +195,32 @@ function PinnableProfileMenuItem({ profile, disabled, onLaunch }: PinnableProfil
); );
} }
interface WorkspaceInlineAddTabButtonProps { interface TabTargetLauncherOptions {
shortcutKeys: ShortcutKey[][] | null; normalizedServerId: string;
onCreateAgentTab: () => void;
onLayout: (event: LayoutChangeEvent) => void;
}
function WorkspaceInlineAddTabButton({
shortcutKeys,
onCreateAgentTab,
onLayout,
}: WorkspaceInlineAddTabButtonProps) {
const { t } = useTranslation();
const tooltipText = t("workspace.tabs.actions.newAgent");
return (
<View style={styles.inlineAddButton} onLayout={onLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-agent-tab-inline"
onPress={onCreateAgentTab}
accessibilityRole="button"
accessibilityLabel={tooltipText}
style={inlineAddActionButtonStyle}
>
<ThemedPlus size={14} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>{tooltipText}</Text>
{shortcutKeys ? (
<Shortcut chord={shortcutKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
</View>
);
}
interface WorkspaceTabRowExtrasProps {
onCreateAgentTab: () => void; onCreateAgentTab: () => void;
onCreateTerminal: () => void; onCreateTerminal: () => void;
onCreateBrowser: () => void; onCreateBrowser: () => void;
onOpenChanges: () => void;
onOpenFiles: () => void;
onOpenPullRequest: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => 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, onCreateAgentTab,
onCreateTerminal, onCreateTerminal,
onCreateBrowser, onCreateBrowser,
onOpenChanges,
onOpenFiles,
onOpenPullRequest,
onCreateTerminalWithProfile, onCreateTerminalWithProfile,
onEditProfiles, }: TabTargetLauncherOptions) {
normalizedServerId,
showCreateBrowserTab,
terminalDisabled,
}: WorkspaceTabRowExtrasProps) {
const { t } = useTranslation();
const { config } = useDaemonConfig(normalizedServerId); const { config } = useDaemonConfig(normalizedServerId);
const profiles = useMemo( const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles), () => resolveTerminalProfiles(config?.terminalProfiles),
@@ -241,9 +232,20 @@ function WorkspaceTabRowExtras({
createDraft: onCreateAgentTab, createDraft: onCreateAgentTab,
createTerminal: onCreateTerminal, createTerminal: onCreateTerminal,
createBrowser: onCreateBrowser, createBrowser: onCreateBrowser,
openChanges: onOpenChanges,
openFiles: onOpenFiles,
openPullRequest: onOpenPullRequest,
createTerminalWithProfile: onCreateTerminalWithProfile, createTerminalWithProfile: onCreateTerminalWithProfile,
}), }),
[onCreateAgentTab, onCreateBrowser, onCreateTerminal, onCreateTerminalWithProfile], [
onCreateAgentTab,
onCreateBrowser,
onCreateTerminal,
onCreateTerminalWithProfile,
onOpenChanges,
onOpenFiles,
onOpenPullRequest,
],
); );
const onLaunch = useCallback( const onLaunch = useCallback(
@@ -253,32 +255,74 @@ function WorkspaceTabRowExtras({
[handlers, profiles], [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 ( return (
<> <View style={styles.inlineAddButton} onLayout={onLayout}>
<DropdownMenu> <DropdownMenu>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef"> <TooltipTrigger asChild triggerRefProp="triggerRef">
<DropdownMenuTrigger <DropdownMenuTrigger
testID="workspace-new-tab-menu-trigger" testID="workspace-new-tab-menu-trigger"
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.actions.moreActions")} accessibilityLabel={tooltipText}
style={newTabActionButtonStyle} style={inlineAddActionButtonStyle}
> >
<ThemedChevronDown size={14} uniProps={mutedColorMapping} /> <ThemedPlus size={14} uniProps={mutedColorMapping} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}> <TooltipContent side="bottom" align="center" offset={8}>
<Text style={styles.newTabTooltipText}>{t("workspace.tabs.actions.moreActions")}</Text> <Text style={styles.newTabTooltipText}>{tooltipText}</Text>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenuContent side="bottom" align="end" offset={4} minWidth={200}> <DropdownMenuContent side="bottom" align="start" offset={4} minWidth={200}>
<PinnableMenuItem <PinnableMenuItem
testID="workspace-new-tab-menu-agent" testID="workspace-new-tab-menu-agent"
target={DRAFT_TARGET} target={DRAFT_TARGET}
label={t("workspace.tabs.actions.newAgent")} label={t("workspace.tabs.actions.newAgent")}
leading={AGENT_ICON} leading={AGENT_ICON}
shortcut={shortcutKeys}
onSelect={onCreateAgentTab} onSelect={onCreateAgentTab}
/> />
<PinnableMenuItem <PinnableMenuItem
@@ -298,6 +342,31 @@ function WorkspaceTabRowExtras({
onSelect={onCreateBrowser} onSelect={onCreateBrowser}
/> />
) : null} ) : null}
{isGit ? (
<PinnableMenuItem
testID="workspace-new-tab-menu-changes"
target={CHANGES_TARGET}
label={t("workspace.tabs.actions.changes")}
leading={CHANGES_ICON}
onSelect={onOpenChanges}
/>
) : null}
<PinnableMenuItem
testID="workspace-new-tab-menu-files"
target={FILES_TARGET}
label={t("workspace.tabs.actions.files")}
leading={FILES_ICON}
onSelect={onOpenFiles}
/>
{showPullRequest ? (
<PinnableMenuItem
testID="workspace-new-tab-menu-pull-request"
target={PULL_REQUEST_TARGET}
label={t("workspace.tabs.actions.pullRequest")}
leading={PULL_REQUEST_ICON}
onSelect={onOpenPullRequest}
/>
) : null}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel> <DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
{profiles.map((profile) => ( {profiles.map((profile) => (
@@ -314,11 +383,24 @@ function WorkspaceTabRowExtras({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<PinnedTargetsRow launchers={launchers} testIdPrefix="workspace-pinned-target" /> </View>
</>
); );
} }
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 <PinnedTargetsRow launchers={launchers} testIdPrefix="workspace-pinned-target" />;
}
function TabContextMenuItem({ function TabContextMenuItem({
entry, entry,
}: { }: {
@@ -445,7 +527,15 @@ interface WorkspaceDesktopTabsRowProps {
function getFallbackTabLabel( function getFallbackTabLabel(
tab: WorkspaceTabDescriptor, 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 { ): string {
if (tab.target.kind === "draft") { if (tab.target.kind === "draft") {
return labels.newAgent; return labels.newAgent;
@@ -462,6 +552,12 @@ function getFallbackTabLabel(
if (tab.target.kind === "working_diff") { if (tab.target.kind === "working_diff") {
return labels.changes; return labels.changes;
} }
if (tab.target.kind === "files") {
return labels.files;
}
if (tab.target.kind === "pull_request") {
return labels.pullRequest;
}
return labels.agent; return labels.agent;
} }
@@ -487,16 +583,30 @@ function useMiddleClickClose(onClose: () => void) {
return ref; 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({ function TabHandleContent({
presentation, presentation,
isHighlighted, isHighlighted,
showLabel, showLabel,
backdrop,
tabLabelSkeletonStyle, tabLabelSkeletonStyle,
tabLabelStyle, tabLabelStyle,
}: { }: {
presentation: WorkspaceTabPresentation; presentation: WorkspaceTabPresentation;
isHighlighted: boolean; isHighlighted: boolean;
showLabel: boolean; showLabel: boolean;
backdrop: SurfaceBackdrop;
tabLabelSkeletonStyle: React.ComponentProps<typeof View>["style"]; tabLabelSkeletonStyle: React.ComponentProps<typeof View>["style"];
tabLabelStyle: React.ComponentProps<typeof Text>["style"]; tabLabelStyle: React.ComponentProps<typeof Text>["style"];
}) { }) {
@@ -508,7 +618,7 @@ function TabHandleContent({
return ( return (
<View style={styles.tabHandle} dataSet={tabHandleDataSet}> <View style={styles.tabHandle} dataSet={tabHandleDataSet}>
<View style={styles.tabIcon}> <View style={styles.tabIcon}>
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} backdrop="surface0" /> <WorkspaceTabIcon presentation={presentation} active={isHighlighted} backdrop={backdrop} />
</View> </View>
{showLabel && presentation.titleState === "loading" ? ( {showLabel && presentation.titleState === "loading" ? (
<View style={tabLabelSkeletonStyle} /> <View style={tabLabelSkeletonStyle} />
@@ -562,9 +672,18 @@ function TabChip({
const middleClickRef = useMiddleClickClose( const middleClickRef = useMiddleClickClose(
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]), useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
); );
const isCompact = useIsCompactFormFactor();
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const isHighlighted = isActive || hovered || isCloseHovered; // An active tab in a pane that does not have focus stays legible but quiet: it keeps the fill of
const showTrailingAffordance = showCloseButton || presentation.modified; // 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 const closeButtonDragBlockers = isWeb
? ({ ? ({
onPointerDown: (event: { stopPropagation?: () => void }) => { onPointerDown: (event: { stopPropagation?: () => void }) => {
@@ -579,6 +698,9 @@ function TabChip({
const tabChipStyle = useCallback( const tabChipStyle = useCallback(
() => [ () => [
styles.tab, styles.tab,
isActiveFocused && styles.tabActive,
isActive && !isFocused && styles.tabActiveUnfocused,
!isActive && isHovered && styles.tabHovered,
isWeb && isDragging && ({ cursor: "grabbing" } as object), isWeb && isDragging && ({ cursor: "grabbing" } as object),
{ {
minWidth: resolvedTabWidth, minWidth: resolvedTabWidth,
@@ -586,14 +708,14 @@ function TabChip({
maxWidth: resolvedTabWidth, maxWidth: resolvedTabWidth,
}, },
], ],
[isDragging, resolvedTabWidth], [isActive, isActiveFocused, isDragging, isFocused, isHovered, resolvedTabWidth],
); );
const handleTabHoverIn = useCallback(() => { const handleTabPointerEnter = useCallback(() => {
setHovered(true); setHovered(true);
}, []); }, []);
const handleTabHoverOut = useCallback(() => { const handleTabPointerLeave = useCallback(() => {
setHovered(false); setHovered(false);
}, []); }, []);
@@ -621,38 +743,20 @@ function TabChip({
[onCloseTab, tab.tabId], [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 tabAccessibilityState = useMemo(() => ({ selected: isActive }), [isActive]);
const tabFocusIndicatorStyle = useMemo( const tabLabelSkeletonStyle = styles.tabLabelSkeleton;
() => [styles.tabFocusIndicator, !isFocused && styles.tabFocusIndicatorUnfocused],
[isFocused],
);
const tabLabelSkeletonStyle = useMemo(
() => [
styles.tabLabelSkeleton,
showTrailingAffordance && styles.tabLabelSkeletonWithCloseButton,
],
[showTrailingAffordance],
);
const tabLabelStyle = useMemo( const tabLabelStyle = useMemo(
() => [ () => [styles.tabLabel, isHighlighted && styles.tabLabelActive],
styles.tabLabel, [isHighlighted],
isHighlighted && styles.tabLabelActive,
showTrailingAffordance && styles.tabLabelWithCloseButton,
],
[isHighlighted, showTrailingAffordance],
); );
return ( return (
<View ref={middleClickRef}> <View
ref={middleClickRef}
style={styles.tabHoverFrame}
onPointerEnter={handleTabPointerEnter}
onPointerLeave={handleTabPointerLeave}
>
<ContextMenu key={tab.key}> <ContextMenu key={tab.key}>
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef"> <TooltipTrigger asChild triggerRefProp="triggerRef">
@@ -663,8 +767,6 @@ function TabChip({
triggerRef={dragHandleProps?.setActivatorNodeRef as unknown as undefined} triggerRef={dragHandleProps?.setActivatorNodeRef as unknown as undefined}
enabledOnMobile={false} enabledOnMobile={false}
style={tabChipStyle} style={tabChipStyle}
onHoverIn={handleTabHoverIn}
onHoverOut={handleTabHoverOut}
onPressIn={handleNavigateTab} onPressIn={handleNavigateTab}
onPress={handleNavigateTab} onPress={handleNavigateTab}
accessibilityRole="button" accessibilityRole="button"
@@ -672,53 +774,22 @@ function TabChip({
accessibilityState={tabAccessibilityState} accessibilityState={tabAccessibilityState}
aria-selected={isActive} aria-selected={isActive}
> >
{isActive && <View style={tabFocusIndicatorStyle} />}
<TabHandleContent <TabHandleContent
presentation={presentation} presentation={presentation}
isHighlighted={isHighlighted} isHighlighted={isHighlighted}
showLabel={showLabel} showLabel={showLabel}
backdrop={chipBackdrop}
tabLabelSkeletonStyle={tabLabelSkeletonStyle} tabLabelSkeletonStyle={tabLabelSkeletonStyle}
tabLabelStyle={tabLabelStyle} tabLabelStyle={tabLabelStyle}
/> />
{presentation.modified ? (
{showTrailingAffordance ? ( <View
<Pressable style={styles.tabModifiedIndicator}
{...(closeButtonDragBlockers as object | undefined)} accessibilityLabel={t("workspace.tabs.modified")}
testID={closeButtonTestId} testID={`workspace-tab-modified-${buildDeterministicWorkspaceTabId(tab.target)}`}
disabled={isClosingTab}
onPressIn={handleCloseButtonPressIn}
onHoverIn={handleCloseButtonHoverIn}
onHoverOut={handleCloseButtonHoverOut}
onPress={handleCloseButtonPress}
style={closeButtonStyle}
> >
{({ hovered: closeHovered, pressed }) => { <View style={styles.tabModifiedDot} />
const highlighted = closeHovered || pressed; </View>
if (isClosingTab) {
return (
<ThemedLoadingSpinner
size={12}
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
/>
);
}
if (highlighted || !presentation.modified) {
return (
<ThemedX
size={12}
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
/>
);
}
return (
<View
style={styles.tabModifiedDot}
accessibilityLabel={t("workspace.tabs.modified")}
testID={`workspace-tab-modified-${buildDeterministicWorkspaceTabId(tab.target)}`}
/>
);
}}
</Pressable>
) : null} ) : null}
</ContextMenuTrigger> </ContextMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
@@ -740,6 +811,46 @@ function TabChip({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{showCloseButton ? (
<View
pointerEvents={showCloseControl ? "auto" : "none"}
style={[
styles.tabTrailingOverlay,
showCloseControl ? styles.tabTrailingOverlayShown : styles.tabTrailingOverlayHidden,
]}
>
<TrailingActionScrim backdrop={chipBackdrop} />
<Pressable
{...(closeButtonDragBlockers as object | undefined)}
testID={closeButtonTestId}
disabled={isClosingTab}
onPressIn={handleCloseButtonPressIn}
onHoverIn={handleCloseButtonHoverIn}
onHoverOut={handleCloseButtonHoverOut}
onPress={handleCloseButtonPress}
style={styles.tabCloseButton}
>
{({ hovered: closeHovered, pressed }) => {
const highlighted = closeHovered || pressed;
if (isClosingTab) {
return (
<ThemedLoadingSpinner
size={12}
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
/>
);
}
return (
<ThemedX
size={12}
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
/>
);
}}
</Pressable>
</View>
) : null}
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}> <ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
{menuEntries.map((entry) => {menuEntries.map((entry) =>
entry.kind === "separator" ? ( entry.kind === "separator" ? (
@@ -798,6 +909,19 @@ export function WorkspaceDesktopTabsRow({
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0); const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0); const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0); const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(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) => { const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
updateMeasuredWidth(setTabsContainerWidth, event); updateMeasuredWidth(setTabsContainerWidth, event);
@@ -824,13 +948,13 @@ export function WorkspaceDesktopTabsRow({
(inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) + (inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) +
(focusModeEnabled ? exitFocusModeWidth : 0), (focusModeEnabled ? exitFocusModeWidth : 0),
), ),
rowPaddingHorizontal: 0, rowPaddingHorizontal: TAB_ROW_PADDING_HORIZONTAL,
tabGap: 0, tabGap: TAB_CHIP_GAP,
maxTabWidth: 200, maxTabWidth: TAB_MAX_WIDTH,
tabIconWidth: 14, tabIconWidth: TAB_ICON_WIDTH,
tabHorizontalPadding: 12, tabHorizontalPadding: TAB_CHIP_HORIZONTAL_PADDING,
estimatedCharWidth: 7, estimatedCharWidth: 7,
closeButtonWidth: 22, closeButtonWidth: TAB_CLOSE_BUTTON_RESERVED_WIDTH,
}), }),
[exitFocusModeWidth, focusModeEnabled, inlineAddButtonWidth, tabsActionsWidth], [exitFocusModeWidth, focusModeEnabled, inlineAddButtonWidth, tabsActionsWidth],
); );
@@ -842,6 +966,8 @@ export function WorkspaceDesktopTabsRow({
terminal: t("workspace.tabs.fallback.terminal"), terminal: t("workspace.tabs.fallback.terminal"),
agent: t("workspace.tabs.fallback.agent"), agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"), changes: t("panels.diff.changesLabel"),
files: t("panels.files.label"),
pullRequest: t("panels.pullRequest.label"),
}), }),
[t], [t],
); );
@@ -917,6 +1043,25 @@ export function WorkspaceDesktopTabsRow({
onCreateBrowserTab({ paneId }); onCreateBrowserTab({ paneId });
}, [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 terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness;
const renderTab = useCallback( const renderTab = useCallback(
@@ -1053,22 +1198,36 @@ export function WorkspaceDesktopTabsRow({
getItemData={getTabDragData} getItemData={getTabDragData}
renderItem={renderTab} renderItem={renderTab}
/> />
<WorkspaceInlineAddTabButton
shortcutKeys={newTabKeys}
onCreateAgentTab={handleCreateAgentTab}
onLayout={handleInlineAddButtonLayout}
/>
</ScrollView> </ScrollView>
<WorkspaceNewTabButton
shortcutKeys={newTabKeys}
onCreateAgentTab={handleCreateAgentTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowser}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onOpenChanges={handleOpenChanges}
onOpenFiles={handleOpenFiles}
onOpenPullRequest={handleOpenPullRequest}
onEditProfiles={handleEditProfiles}
normalizedServerId={normalizedServerId}
showCreateBrowserTab={showCreateBrowserTab}
terminalDisabled={terminalDisabled}
isGit={isGit}
showPullRequest={showPullRequest}
onLayout={handleInlineAddButtonLayout}
/>
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}> <View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
<WorkspaceTabRowExtras <WorkspacePinnedTargets
onCreateAgentTab={handleCreateAgentTab} onCreateAgentTab={handleCreateAgentTab}
onCreateTerminal={handleCreateTerminal} onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowser} onCreateBrowser={handleCreateBrowser}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile} onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onEditProfiles={handleEditProfiles} onOpenChanges={handleOpenChanges}
onOpenFiles={handleOpenFiles}
onOpenPullRequest={handleOpenPullRequest}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
showCreateBrowserTab={showCreateBrowserTab} isGit={isGit}
terminalDisabled={terminalDisabled} showPullRequest={showPullRequest}
/> />
{showPaneSplitActions ? ( {showPaneSplitActions ? (
<> <>
@@ -1251,7 +1410,8 @@ const styles = StyleSheet.create((theme) => ({
}, },
tabsContent: { tabsContent: {
flexDirection: "row", flexDirection: "row",
alignItems: "stretch", alignItems: "center",
paddingHorizontal: TAB_ROW_PADDING_HORIZONTAL,
}, },
tabsActions: { tabsActions: {
flexDirection: "row", flexDirection: "row",
@@ -1272,18 +1432,30 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[1], paddingHorizontal: theme.spacing[1],
}, },
tab: { tab: {
paddingHorizontal: theme.spacing[3], height: TAB_CHIP_HEIGHT,
paddingVertical: theme.spacing[2], paddingHorizontal: TAB_CHIP_HORIZONTAL_PADDING,
borderRightWidth: 1, borderRadius: theme.borderRadius.md,
borderRightColor: theme.colors.border,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
userSelect: "none", userSelect: "none",
}, },
tabHovered: {
backgroundColor: theme.colors.surface1,
},
tabActive: {
backgroundColor: theme.colors.surface2,
},
tabActiveUnfocused: {
backgroundColor: theme.colors.surface1,
},
tabHoverFrame: {
position: "relative",
},
tabSlot: { tabSlot: {
position: "relative", position: "relative",
overflow: "visible", overflow: "visible",
marginHorizontal: TAB_CHIP_GAP / 2,
}, },
tabHandle: { tabHandle: {
flexDirection: "row", flexDirection: "row",
@@ -1294,34 +1466,29 @@ const styles = StyleSheet.create((theme) => ({
userSelect: "none", userSelect: "none",
}, },
tabIcon: { tabIcon: {
width: TAB_ICON_WIDTH,
height: TAB_ICON_WIDTH,
flexShrink: 0, flexShrink: 0,
alignItems: "center",
justifyContent: "center",
}, },
tabFocusIndicator: { // The chip box stops at the slot's padding box, so the gap between two chips runs from
position: "absolute", // -TAB_CHIP_GAP to 0. Centre a TAB_DROP_INDICATOR_WIDTH pill in it.
top: 0,
left: 0,
right: 0,
height: 2,
backgroundColor: theme.colors.accent,
},
tabFocusIndicatorUnfocused: {
backgroundColor: theme.colors.borderAccent,
},
tabDropIndicator: { tabDropIndicator: {
position: "absolute", position: "absolute",
top: theme.spacing[2], top: theme.spacing[0.5],
bottom: theme.spacing[2], bottom: theme.spacing[0.5],
width: 5, width: TAB_DROP_INDICATOR_WIDTH,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent, backgroundColor: theme.colors.accent,
zIndex: 10, zIndex: 10,
pointerEvents: "none", pointerEvents: "none",
}, },
tabDropIndicatorBefore: { tabDropIndicatorBefore: {
left: -3, left: -TAB_CHIP_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2,
}, },
tabDropIndicatorAfter: { tabDropIndicatorAfter: {
right: -3, right: -TAB_CHIP_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2,
}, },
tabLabel: { tabLabel: {
flexShrink: 1, flexShrink: 1,
@@ -1341,28 +1508,44 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface3,
opacity: 0.9, opacity: 0.9,
}, },
tabLabelSkeletonWithCloseButton: {
width: LOADING_TAB_LABEL_SKELETON_WIDTH,
},
tabLabelWithCloseButton: {
paddingRight: 0,
},
tabLabelActive: { tabLabelActive: {
color: theme.colors.foreground, 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: { tabCloseButton: {
position: "absolute",
right: 4,
width: 18, width: 18,
height: 18, height: 18,
marginLeft: 0,
borderRadius: theme.borderRadius.sm, borderRadius: theme.borderRadius.sm,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
zIndex: 1,
}, },
tabCloseButtonShown: { tabModifiedIndicator: {
opacity: 1, position: "absolute",
}, right: 8,
tabCloseButtonActive: { top: 0,
backgroundColor: theme.colors.surface3, bottom: 0,
alignItems: "center",
justifyContent: "center",
}, },
tabModifiedDot: { tabModifiedDot: {
width: 8, width: 8,
@@ -74,6 +74,8 @@ import { traceInstant } from "@/performance/native-trace";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { import {
collectAllTabs, collectAllTabs,
createDefaultLayout,
findPaneById,
getFocusedBrowserId, getFocusedBrowserId,
type WorkspaceLayout, type WorkspaceLayout,
useWorkspaceLayoutStore, useWorkspaceLayoutStore,
@@ -152,10 +154,7 @@ import {
deriveWorkspaceAgentVisibility, deriveWorkspaceAgentVisibility,
workspaceAgentVisibilityEqual, workspaceAgentVisibilityEqual,
} from "@/workspace-tabs/agent-visibility"; } from "@/workspace-tabs/agent-visibility";
import { import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
deriveWorkspacePaneState,
resolveSideFileOpenPlacement,
} from "@/screens/workspace/workspace-pane-state";
import { import {
buildWorkspacePaneContentModel, buildWorkspacePaneContentModel,
WorkspacePaneContent, WorkspacePaneContent,
@@ -204,6 +203,7 @@ import {
} from "@/workspace/file-open"; } from "@/workspace/file-open";
import { RenderProfile } from "@/utils/render-profiler"; import { RenderProfile } from "@/utils/render-profiler";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; 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_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
@@ -344,6 +344,8 @@ function getFallbackTabOptionLabel(
browser: string; browser: string;
agent: string; agent: string;
changes: string; changes: string;
files: string;
pullRequest: string;
}, },
): string { ): string {
if (tab.target.kind === "draft") { if (tab.target.kind === "draft") {
@@ -364,6 +366,12 @@ function getFallbackTabOptionLabel(
if (tab.target.kind === "working_diff") { if (tab.target.kind === "working_diff") {
return labels.changes; 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") { if (tab.target.kind === "commit_diff") {
return tab.target.sha.slice(0, 7); return tab.target.sha.slice(0, 7);
} }
@@ -379,6 +387,8 @@ function getFallbackTabOptionDescription(
terminal: string; terminal: string;
browser: string; browser: string;
changes: string; changes: string;
files: string;
pullRequest: string;
}, },
): string { ): string {
if (tab.target.kind === "draft") { if (tab.target.kind === "draft") {
@@ -405,6 +415,12 @@ function getFallbackTabOptionDescription(
if (tab.target.kind === "working_diff") { if (tab.target.kind === "working_diff") {
return labels.changes; return labels.changes;
} }
if (tab.target.kind === "files") {
return labels.files;
}
if (tab.target.kind === "pull_request") {
return labels.pullRequest;
}
return tab.target.path; return tab.target.path;
} }
@@ -603,6 +619,8 @@ function MobileWorkspaceTabOption({
browser: t("workspace.tabs.fallback.browser"), browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"), agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"), changes: t("panels.diff.changesLabel"),
files: t("panels.files.label"),
pullRequest: t("panels.pullRequest.label"),
}), }),
[t], [t],
); );
@@ -1627,6 +1645,75 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
return `${serverId}:${workspaceId}`; 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<typeof useWorkspaceLayoutStore.getState>,
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 { interface WorkspaceTerminalTabActionsInput {
persistenceKey: string | null; persistenceKey: string | null;
focusWorkspacePane: (workspaceKey: string, paneId: string) => void; focusWorkspacePane: (workspaceKey: string, paneId: string) => void;
@@ -1900,8 +1987,14 @@ function WorkspaceScreenContent({
workspace: workspaceDescriptor, workspace: workspaceDescriptor,
checkoutState: workspaceHeaderCheckoutState, 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 }), selectIsFileExplorerOpen(state, { isCompact: isMobile }),
); );
const toggleFileExplorerForCheckout = usePanelStore( const toggleFileExplorerForCheckout = usePanelStore(
@@ -1920,15 +2013,14 @@ function WorkspaceScreenContent({
}; };
}, [isGitCheckout, normalizedServerId, workspaceDirectory]); }, [isGitCheckout, normalizedServerId, workspaceDirectory]);
const handleToggleExplorer = useCallback(() => { const isDesktopExplorerOpen = useWorkspaceLayoutStore((state) =>
if (!activeExplorerCheckout) { isWorkspaceExplorerPaneOpen(state, persistenceKey),
return; );
} const isExplorerOpen = resolveExplorerOpen(
toggleFileExplorerForCheckout({ isMobile,
isCompact: isMobile, isCompactExplorerOpen,
checkout: activeExplorerCheckout, isDesktopExplorerOpen,
}); );
}, [activeExplorerCheckout, isMobile, toggleFileExplorerForCheckout]);
const hasDiffStat = useMemo(() => Boolean(workspaceDescriptor?.diffStat), [workspaceDescriptor]); const hasDiffStat = useMemo(() => Boolean(workspaceDescriptor?.diffStat), [workspaceDescriptor]);
const explorerToggleStyle = useCallback( const explorerToggleStyle = useCallback(
@@ -1996,6 +2088,14 @@ function WorkspaceScreenContent({
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane); const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty); const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane); 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 paneFocusSuppressedRef = useRef(false);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit); const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
@@ -2373,58 +2473,30 @@ function WorkspaceScreenContent({
], ],
); );
const handleOpenFileFromChatInSidePane = useCallback( const handleOpenAssistantFileInExplorerPane = useCallback(
(input: { (input: { location: WorkspaceFileLocation; parentTabId?: string | null }) => {
location: WorkspaceFileLocation;
sourcePaneId?: string;
parentTabId?: string | null;
}) => {
const location = normalizeWorkspaceFileLocation(input.location); const location = normalizeWorkspaceFileLocation(input.location);
if (!location) { if (!location) {
return; return;
} }
if (!persistenceKey || isMobile || !input.sourcePaneId) { if (!persistenceKey || isMobile || !supportsDesktopPaneSplits()) {
handleOpenFileFromChat(location, { parentTabId: input.parentTabId }); handleOpenFileFromChat(location, { parentTabId: input.parentTabId });
return; return;
} }
const target: WorkspaceTabTarget = createWorkspaceFileTabTarget(location); const target: WorkspaceTabTarget = createWorkspaceFileTabTarget(location);
const placement = resolveSideFileOpenPlacement({ const tabId = useWorkspaceLayoutStore
layout: workspaceLayout, .getState()
sourcePaneId: input.sourcePaneId, .openTabInExplorerPaneFocused(persistenceKey, {
tabs: uiTabs, target,
target, parentTabId: input.parentTabId,
});
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 = input.parentTabId
? openWorkspaceChildTabFocused(persistenceKey, target, input.parentTabId)
: openWorkspaceTabFocused(persistenceKey, target);
if (tabId) { if (tabId) {
requestFileNavigation(tabId); requestFileNavigation(tabId);
navigateToTabId(tabId); navigateToTabId(tabId);
} }
}, },
[ [handleOpenFileFromChat, isMobile, navigateToTabId, persistenceKey, requestFileNavigation],
handleOpenFileFromChat,
isMobile,
focusWorkspacePane,
navigateToTabId,
openWorkspaceChildTabFocused,
openWorkspaceTabFocused,
persistenceKey,
requestFileNavigation,
splitWorkspacePaneEmpty,
uiTabs,
workspaceLayout,
],
); );
const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({ const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({
@@ -2442,9 +2514,8 @@ function WorkspaceScreenContent({
focusWorkspacePane(persistenceKey, paneId); focusWorkspacePane(persistenceKey, paneId);
} }
if (request.disposition === "side") { if (request.disposition === "side") {
handleOpenFileFromChatInSidePane({ handleOpenAssistantFileInExplorerPane({
location: request.location, location: request.location,
sourcePaneId: paneId ?? undefined,
parentTabId, parentTabId,
}); });
return; return;
@@ -2523,6 +2594,8 @@ function WorkspaceScreenContent({
browser: t("workspace.tabs.fallback.browser"), browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"), agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"), changes: t("panels.diff.changesLabel"),
files: t("panels.files.label"),
pullRequest: t("panels.pullRequest.label"),
}), }),
[t], [t],
); );
@@ -6,11 +6,11 @@ const metrics = {
actionsReservedWidth: 120, actionsReservedWidth: 120,
rowPaddingHorizontal: 8, rowPaddingHorizontal: 8,
tabGap: 4, tabGap: 4,
maxTabWidth: 200, maxTabWidth: 160,
tabIconWidth: 14, tabIconWidth: 14,
tabHorizontalPadding: 12, tabHorizontalPadding: 8,
estimatedCharWidth: 7, estimatedCharWidth: 7,
closeButtonWidth: 22, closeButtonWidth: 0,
}; };
describe("computeWorkspaceTabLayout", () => { describe("computeWorkspaceTabLayout", () => {
@@ -25,7 +25,7 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.requiresHorizontalScrollFallback).toBe(false); expect(result.requiresHorizontalScrollFallback).toBe(false);
expect(result.items).toHaveLength(3); expect(result.items).toHaveLength(3);
expect(result.items.every((item) => item.showLabel)).toBe(true); 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", () => { 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); 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({ const result = computeWorkspaceTabLayout({
viewportWidth: 743, viewportWidth: 743,
tabLabelLengths: [8, 8, 8, 8], tabLabelLengths: [8, 8, 8, 8],
@@ -55,32 +55,32 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.closeButtonPolicy).toBe("all"); expect(result.closeButtonPolicy).toBe("all");
expect(result.requiresHorizontalScrollFallback).toBe(false); 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", () => { it("collapses to icon-only before allowing horizontal scroll fallback", () => {
const result = computeWorkspaceTabLayout({ const result = computeWorkspaceTabLayout({
viewportWidth: 388, viewportWidth: 268,
tabLabelLengths: [14, 14, 14, 14], tabLabelLengths: [14, 14, 14, 14],
metrics, metrics,
}); });
expect(result.closeButtonPolicy).toBe("all"); expect(result.closeButtonPolicy).toBe("all");
expect(result.requiresHorizontalScrollFallback).toBe(false); 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); expect(result.items.every((item) => !item.showLabel)).toBe(true);
}); });
it("allows horizontal scroll only when icon-only tabs still cannot fit", () => { it("allows horizontal scroll only when icon-only tabs still cannot fit", () => {
const result = computeWorkspaceTabLayout({ const result = computeWorkspaceTabLayout({
viewportWidth: 300, viewportWidth: 267,
tabLabelLengths: [14, 14, 14, 14], tabLabelLengths: [14, 14, 14, 14],
metrics, metrics,
}); });
expect(result.closeButtonPolicy).toBe("all"); expect(result.closeButtonPolicy).toBe("all");
expect(result.requiresHorizontalScrollFallback).toBe(true); 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); expect(result.items.every((item) => !item.showLabel)).toBe(true);
}); });
@@ -151,6 +151,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "working_diff") { if (tab.target.kind === "working_diff") {
return `workspace-working-diff-close-${encodeFilePathForPathSegment(buildDeterministicWorkspaceTabId(tab.target))}`; 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)}`; return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
} }
+18 -22
View File
@@ -11,16 +11,16 @@ import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
import { import {
buildOpenFileExplorerPatch, buildOpenFileExplorerPatch,
buildToggleFileExplorerPatch, buildToggleFileExplorerPatch,
clampExplorerFilesSplitRatio, clampTreeRailWidth,
clampExplorerWidth, clampExplorerWidth,
clampSidebarWidth, clampSidebarWidth,
DEFAULT_EXPLORER_FILES_SPLIT_RATIO, DEFAULT_TREE_RAIL_WIDTH,
DEFAULT_EXPLORER_SIDEBAR_WIDTH, DEFAULT_EXPLORER_SIDEBAR_WIDTH,
DEFAULT_SIDEBAR_WIDTH, DEFAULT_SIDEBAR_WIDTH,
MAX_EXPLORER_FILES_SPLIT_RATIO, MAX_TREE_RAIL_WIDTH,
MAX_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH,
MIN_EXPLORER_FILES_SPLIT_RATIO, MIN_TREE_RAIL_WIDTH,
MIN_EXPLORER_SIDEBAR_WIDTH, MIN_EXPLORER_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH,
migratePanelState, migratePanelState,
@@ -51,15 +51,16 @@ export type {
SortOption, SortOption,
} from "./state"; } from "./state";
export { export {
DEFAULT_EXPLORER_FILES_SPLIT_RATIO, DEFAULT_TREE_RAIL_WIDTH,
DEFAULT_EXPLORER_SIDEBAR_WIDTH, DEFAULT_EXPLORER_SIDEBAR_WIDTH,
DEFAULT_SIDEBAR_WIDTH, DEFAULT_SIDEBAR_WIDTH,
MAX_EXPLORER_FILES_SPLIT_RATIO, MAX_TREE_RAIL_WIDTH,
MAX_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH,
MIN_EXPLORER_FILES_SPLIT_RATIO, MIN_TREE_RAIL_WIDTH,
MIN_EXPLORER_SIDEBAR_WIDTH, MIN_EXPLORER_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH,
clampTreeRailWidth,
selectIsAgentListOpen, selectIsAgentListOpen,
selectIsFileExplorerOpen, selectIsFileExplorerOpen,
selectPanelVisibility, selectPanelVisibility,
@@ -88,7 +89,7 @@ export interface PanelState {
explorerWidth: number; explorerWidth: number;
explorerSortOption: SortOption; explorerSortOption: SortOption;
explorerShowHiddenFiles: boolean; explorerShowHiddenFiles: boolean;
explorerFilesSplitRatio: number; treeRailWidth: number;
// Actions // Actions
toggleFocusMode: () => void; toggleFocusMode: () => void;
@@ -117,7 +118,7 @@ export interface PanelState {
setExplorerWidth: (width: number) => void; setExplorerWidth: (width: number) => void;
setExplorerSortOption: (option: SortOption) => void; setExplorerSortOption: (option: SortOption) => void;
toggleExplorerShowHiddenFiles: () => void; toggleExplorerShowHiddenFiles: () => void;
setExplorerFilesSplitRatio: (ratio: number) => void; setTreeRailWidth: (width: number) => void;
} }
const DEFAULT_DESKTOP_OPEN = isWeb; const DEFAULT_DESKTOP_OPEN = isWeb;
@@ -153,7 +154,7 @@ export const usePanelStore = create<PanelState>()(
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH, explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
explorerSortOption: "name", explorerSortOption: "name",
explorerShowHiddenFiles: true, explorerShowHiddenFiles: true,
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO, treeRailWidth: DEFAULT_TREE_RAIL_WIDTH,
toggleFocusMode: () => toggleFocusMode: () =>
set((state) => ({ set((state) => ({
@@ -303,16 +304,11 @@ export const usePanelStore = create<PanelState>()(
setExplorerSortOption: (option) => set({ explorerSortOption: option }), setExplorerSortOption: (option) => set({ explorerSortOption: option }),
toggleExplorerShowHiddenFiles: () => toggleExplorerShowHiddenFiles: () =>
set((state) => ({ explorerShowHiddenFiles: !state.explorerShowHiddenFiles })), set((state) => ({ explorerShowHiddenFiles: !state.explorerShowHiddenFiles })),
setExplorerFilesSplitRatio: (ratio) => setTreeRailWidth: (width) => set({ treeRailWidth: clampTreeRailWidth(width) }),
set({
explorerFilesSplitRatio: Number.isFinite(ratio)
? clampExplorerFilesSplitRatio(ratio)
: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
}),
}), }),
{ {
name: "panel-state", name: "panel-state",
version: 12, version: 13,
storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema), storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema),
migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }), migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }),
partialize: (state) => ({ partialize: (state) => ({
@@ -326,7 +322,7 @@ export const usePanelStore = create<PanelState>()(
explorerWidth: state.explorerWidth, explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption, explorerSortOption: state.explorerSortOption,
explorerShowHiddenFiles: state.explorerShowHiddenFiles, 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 explorerTabByCheckout = usePanelStore((state) => state.explorerTabByCheckout);
const explorerWidth = usePanelStore((state) => state.explorerWidth); const explorerWidth = usePanelStore((state) => state.explorerWidth);
const explorerSortOption = usePanelStore((state) => state.explorerSortOption); 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 setExplorerTab = usePanelStore((state) => state.setExplorerTab);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const activateExplorerTabForCheckout = usePanelStore( const activateExplorerTabForCheckout = usePanelStore(
@@ -364,7 +360,7 @@ export function usePanelState(isMobile: boolean) {
); );
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth); const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
const setExplorerSortOption = usePanelStore((state) => state.setExplorerSortOption); const setExplorerSortOption = usePanelStore((state) => state.setExplorerSortOption);
const setExplorerFilesSplitRatio = usePanelStore((state) => state.setExplorerFilesSplitRatio); const setTreeRailWidth = usePanelStore((state) => state.setTreeRailWidth);
return { return {
isAgentListOpen, isAgentListOpen,
@@ -377,12 +373,12 @@ export function usePanelState(isMobile: boolean) {
explorerTabByCheckout, explorerTabByCheckout,
explorerWidth, explorerWidth,
explorerSortOption, explorerSortOption,
explorerFilesSplitRatio, treeRailWidth,
setExplorerTab, setExplorerTab,
setExplorerTabForCheckout, setExplorerTabForCheckout,
activateExplorerTabForCheckout, activateExplorerTabForCheckout,
setExplorerWidth, setExplorerWidth,
setExplorerSortOption, setExplorerSortOption,
setExplorerFilesSplitRatio, setTreeRailWidth,
}; };
} }
@@ -7,6 +7,7 @@ import {
import { import {
buildOpenFileExplorerPatch, buildOpenFileExplorerPatch,
buildToggleFileExplorerPatch, buildToggleFileExplorerPatch,
DEFAULT_TREE_RAIL_WIDTH,
migratePanelState, migratePanelState,
selectIsAgentListOpen, selectIsAgentListOpen,
selectIsFileExplorerOpen, selectIsFileExplorerOpen,
@@ -119,6 +120,13 @@ describe("panel-store migration", () => {
expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] }); 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", () => { it("drops persisted compact panel state so cold starts return to content", () => {
const state = migratePanelState( const state = migratePanelState(
{ mobileView: "agent-list", mobilePanel: { target: "file-explorer", revision: 42 } }, { mobileView: "agent-list", mobilePanel: { target: "file-explorer", revision: 42 } },
+16 -10
View File
@@ -31,9 +31,9 @@ export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint. // Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint.
export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000; export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000;
export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38; export const DEFAULT_TREE_RAIL_WIDTH = 320;
export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2; export const MIN_TREE_RAIL_WIDTH = 200;
export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8; export const MAX_TREE_RAIL_WIDTH = 600;
export interface PanelVisibilityState { export interface PanelVisibilityState {
isAgentListOpen: boolean; isAgentListOpen: boolean;
@@ -70,8 +70,8 @@ export function clampExplorerWidth(width: number): number {
return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH); return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH);
} }
export function clampExplorerFilesSplitRatio(ratio: number): number { export function clampTreeRailWidth(width: number): number {
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO); return clampNumber(width, MIN_TREE_RAIL_WIDTH, MAX_TREE_RAIL_WIDTH);
} }
export function selectPanelVisibility( export function selectPanelVisibility(
@@ -190,6 +190,7 @@ export const PanelPersistedStateSchema = z.strictObject({
explorerSortOption: z.enum(["name", "modified", "size"]).optional(), explorerSortOption: z.enum(["name", "modified", "size"]).optional(),
explorerShowHiddenFiles: z.boolean().optional(), explorerShowHiddenFiles: z.boolean().optional(),
explorerFilesSplitRatio: z.number().optional(), explorerFilesSplitRatio: z.number().optional(),
treeRailWidth: z.number().optional(),
}); });
type MigratablePanelState = z.infer<typeof PanelPersistedStateSchema>; type MigratablePanelState = z.infer<typeof PanelPersistedStateSchema>;
@@ -198,11 +199,6 @@ function migratePanelV2Explorer(state: MigratablePanelState, isWeb: boolean): vo
if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) { if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) {
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH; 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 { 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( export function migratePanelState(
persistedState: unknown, persistedState: unknown,
version: number, version: number,
@@ -302,6 +307,7 @@ export function migratePanelState(
if (typeof state.explorerShowHiddenFiles !== "boolean") { if (typeof state.explorerShowHiddenFiles !== "boolean") {
state.explorerShowHiddenFiles = true; state.explorerShowHiddenFiles = true;
} }
migrateTreeRailWidth(state, version);
if (version < 12) { if (version < 12) {
// Compact panel position is transient UI state. Cold starts always begin // Compact panel position is transient UI state. Cold starts always begin
// at content, regardless of what an older version persisted. // at content, regardless of what an older version persisted.
@@ -13,6 +13,7 @@ export interface SplitPane {
id: string; id: string;
tabIds: string[]; tabIds: string[];
focusedTabId: string | null; focusedTabId: string | null;
hidden?: boolean;
} }
export interface SplitGroup { export interface SplitGroup {
@@ -240,6 +241,7 @@ function createPaneNode(input: {
id: string; id: string;
tabs?: WorkspaceTab[]; tabs?: WorkspaceTab[];
focusedTabId?: string | null; focusedTabId?: string | null;
hidden?: boolean;
}): SplitNodeInternal { }): SplitNodeInternal {
const normalizedTabs = normalizeWorkspaceTabs(input.tabs ?? []); const normalizedTabs = normalizeWorkspaceTabs(input.tabs ?? []);
const tabIds = normalizedTabs.map((tab) => tab.tabId); const tabIds = normalizedTabs.map((tab) => tab.tabId);
@@ -254,6 +256,7 @@ function createPaneNode(input: {
tabs: normalizedTabs, tabs: normalizedTabs,
tabIds, tabIds,
focusedTabId, focusedTabId,
...(input.hidden === true ? { hidden: true } : {}),
}, },
}; };
} }
@@ -511,7 +514,7 @@ function insertChildIntoGroup(
function listPaneIds(node: SplitNodeInternal): string[] { function listPaneIds(node: SplitNodeInternal): string[] {
if (node.kind === "pane") { if (node.kind === "pane") {
return [node.pane.id]; return node.pane.hidden === true ? [] : [node.pane.id];
} }
const next: string[] = []; const next: string[] = [];
for (const child of node.group.children) { for (const child of node.group.children) {
@@ -562,6 +565,7 @@ function normalizePaneAfterTabChange(pane: SplitPaneInternal): SplitPaneInternal
tabs, tabs,
tabIds, tabIds,
focusedTabId, focusedTabId,
...(pane.hidden === true ? { hidden: true } : {}),
}; };
} }
@@ -584,6 +588,7 @@ function normalizePaneNode(rawPane: SplitPaneInternal | undefined): SplitNodeInt
id: paneId, id: paneId,
tabs: mergedTabs, tabs: mergedTabs,
focusedTabId: trimNonEmpty(rawPane?.focusedTabId) ?? null, focusedTabId: trimNonEmpty(rawPane?.focusedTabId) ?? null,
hidden: rawPane?.hidden === true,
}); });
} }
@@ -756,6 +761,7 @@ function focusTabInPane(root: SplitNodeInternal, paneId: string, tabId: string):
pane: normalizePaneAfterTabChange({ pane: normalizePaneAfterTabChange({
...node.pane, ...node.pane,
focusedTabId: tabId, focusedTabId: tabId,
hidden: undefined,
}), }),
}; };
}); });
@@ -972,7 +978,7 @@ export function collectAllTabs(root: SplitNode): WorkspaceTab[] {
export function collectAllPanes(root: SplitNode): SplitPane[] { export function collectAllPanes(root: SplitNode): SplitPane[] {
const internalRoot = asInternalNode(root); const internalRoot = asInternalNode(root);
if (internalRoot.kind === "pane") { if (internalRoot.kind === "pane") {
return [internalRoot.pane]; return internalRoot.pane.hidden === true ? [] : [internalRoot.pane];
} }
return internalRoot.group.children.flatMap((child) => collectAllPanes(child)); return internalRoot.group.children.flatMap((child) => collectAllPanes(child));
} }
@@ -995,6 +1001,7 @@ function stripEphemeralTabsFromNode(node: SplitNodeInternal): SplitNodeInternal
id: node.pane.id, id: node.pane.id,
tabs: nextTabs, tabs: nextTabs,
focusedTabId: node.pane.focusedTabId, focusedTabId: node.pane.focusedTabId,
hidden: node.pane.hidden,
}); });
} }
return createGroupNode({ return createGroupNode({
@@ -1028,7 +1035,7 @@ export function getFocusedBrowserId(layout: WorkspaceLayout | null | undefined):
return null; return null;
} }
const focusedPane = findPaneById(layout.root, layout.focusedPaneId); const focusedPane = findPaneById(layout.root, layout.focusedPaneId);
if (!focusedPane?.focusedTabId) { if (!focusedPane?.focusedTabId || focusedPane.hidden === true) {
return null; return null;
} }
const focusedTab = collectAllTabs(layout.root).find( const focusedTab = collectAllTabs(layout.root).find(
@@ -1221,7 +1228,11 @@ export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout
return null; 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; return null;
} }
@@ -1448,7 +1459,8 @@ export function splitPaneEmptyInLayout(
export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): WorkspaceLayout | null { export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): WorkspaceLayout | null {
const layout = asInternalLayout(input.layout); const layout = asInternalLayout(input.layout);
const sourcePane = findPaneContainingTab(layout.root, input.tabId); 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; return null;
} }
@@ -1472,19 +1484,59 @@ export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): Worksp
} }
export function focusPaneInLayout(input: FocusPaneInLayoutInput): WorkspaceLayout | null { 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; return null;
} }
if (input.layout.focusedPaneId === input.paneId) { if (input.layout.focusedPaneId === input.paneId && pane.hidden !== true) {
return null; return null;
} }
return withNormalizedParentTabMap({ 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, focusedPaneId: input.paneId,
parentTabIdByTabId: input.layout.parentTabIdByTabId, 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 { export function resizeSplitInLayout(input: ResizeSplitInLayoutInput): WorkspaceLayout {
const layout = asInternalLayout(input.layout); const layout = asInternalLayout(input.layout);
return withNormalizedParentTabMap({ return withNormalizedParentTabMap({
@@ -84,6 +84,7 @@ function createPane(input: {
id: string; id: string;
tabIds: string[]; tabIds: string[];
focusedTabId?: string | null; focusedTabId?: string | null;
hidden?: boolean;
targetsByTabId?: Record<string, WorkspaceTab["target"]>; targetsByTabId?: Record<string, WorkspaceTab["target"]>;
}): SplitNode { }): SplitNode {
const tabs = input.tabIds.map((tabId) => createTab(tabId, input.targetsByTabId?.[tabId])); const tabs = input.tabIds.map((tabId) => createTab(tabId, input.targetsByTabId?.[tabId]));
@@ -93,6 +94,7 @@ function createPane(input: {
id: input.id, id: input.id,
tabIds: input.tabIds, tabIds: input.tabIds,
focusedTabId: input.focusedTabId ?? input.tabIds[input.tabIds.length - 1] ?? null, focusedTabId: input.focusedTabId ?? input.tabIds[input.tabIds.length - 1] ?? null,
...(input.hidden === true ? { hidden: true } : {}),
tabs, tabs,
} as SplitPane, } as SplitPane,
}; };
@@ -200,6 +202,14 @@ describe("workspace-layout-store helpers", () => {
expect(getFocusedBrowserId({ root, focusedPaneId: "main" })).toBeNull(); 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", () => { describe("workspace-layout-store tree transforms", () => {
@@ -314,6 +324,8 @@ describe("workspace-layout-store actions", () => {
pinnedAgentIdsByWorkspace: {}, pinnedAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {}, focusRestorationByWorkspace: {},
explorerPaneIdByWorkspace: {},
acknowledgedPullRequestByWorkspace: {},
}); });
}); });
@@ -335,6 +347,142 @@ describe("workspace-layout-store actions", () => {
await expect(AsyncStorage.getItem("workspace-layout-state")).resolves.not.toBeNull(); 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", () => { it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
@@ -641,6 +789,36 @@ describe("workspace-layout-store actions", () => {
expect(findPaneById(layout.root, newPaneId)?.focusedTabId).toBe(draftTabId); 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", () => { it("focusTab moves workspace focus to the pane containing the tab", () => {
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
@@ -671,6 +849,44 @@ describe("workspace-layout-store actions", () => {
expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId); 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", () => { it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212"); useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212");
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
@@ -946,6 +1162,26 @@ describe("workspace-layout-store actions", () => {
expect(layout.focusedPaneId).toBe(splitPaneId); 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", () => { it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc"); useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc");
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
@@ -1170,6 +1406,8 @@ describe("workspace-layout-store actions", () => {
expect(persisted).toEqual({ expect(persisted).toEqual({
layoutByWorkspace: { [workspaceKey]: layout }, layoutByWorkspace: { [workspaceKey]: layout },
splitSizesByWorkspace: currentState.splitSizesByWorkspace, splitSizesByWorkspace: currentState.splitSizesByWorkspace,
explorerPaneIdByWorkspace: {},
acknowledgedPullRequestByWorkspace: {},
}); });
expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([ expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([
{ {
@@ -1302,6 +1540,8 @@ describe("workspace-layout-store actions", () => {
expect(partialize?.(state)).toEqual({ expect(partialize?.(state)).toEqual({
layoutByWorkspace: {}, layoutByWorkspace: {},
splitSizesByWorkspace: {}, splitSizesByWorkspace: {},
explorerPaneIdByWorkspace: {},
acknowledgedPullRequestByWorkspace: {},
}); });
}); });
@@ -1338,6 +1578,8 @@ describe("workspace-layout-store actions", () => {
expect(partialize?.(state)).toEqual({ expect(partialize?.(state)).toEqual({
layoutByWorkspace: {}, layoutByWorkspace: {},
splitSizesByWorkspace: {}, splitSizesByWorkspace: {},
explorerPaneIdByWorkspace: {},
acknowledgedPullRequestByWorkspace: {},
}); });
}); });
@@ -32,6 +32,7 @@ import {
reorderFocusedPaneTabsInLayout, reorderFocusedPaneTabsInLayout,
reorderPaneTabsInLayout, reorderPaneTabsInLayout,
retargetTabInLayout, retargetTabInLayout,
setPaneHiddenInLayout,
splitPaneEmptyInLayout, splitPaneEmptyInLayout,
splitPaneInLayout, splitPaneInLayout,
stripEphemeralTabsFromLayout, stripEphemeralTabsFromLayout,
@@ -75,6 +76,8 @@ interface WorkspaceLayoutStore {
pendingAgentIdsByWorkspace: Record<string, Set<string>>; pendingAgentIdsByWorkspace: Record<string, Set<string>>;
hiddenAgentIdsByWorkspace: Record<string, Set<string>>; hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>; focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
explorerPaneIdByWorkspace: Record<string, string | null>;
acknowledgedPullRequestByWorkspace: Record<string, string>;
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
openChildTabFocused: ( openChildTabFocused: (
workspaceKey: string, workspaceKey: string,
@@ -82,6 +85,12 @@ interface WorkspaceLayoutStore {
parentTabId: string, parentTabId: string,
) => string | null; ) => string | null;
openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => 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; closeTab: (workspaceKey: string, tabId: string) => void;
focusTab: (workspaceKey: string, tabId: string) => void; focusTab: (workspaceKey: string, tabId: string) => void;
retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null; retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null;
@@ -107,6 +116,9 @@ interface WorkspaceLayoutStore {
) => string | null; ) => string | null;
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void; moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
focusPane: (workspaceKey: string, paneId: string) => void; focusPane: (workspaceKey: string, paneId: string) => void;
hidePane: (workspaceKey: string, paneId: string) => void;
showPane: (workspaceKey: string, paneId: string) => void;
setExplorerPaneId: (workspaceKey: string, paneId: string | null) => void;
unfocusPane: (workspaceKey: string) => string | null; unfocusPane: (workspaceKey: string) => string | null;
restorePaneFocus: (workspaceKey: string, token: string) => void; restorePaneFocus: (workspaceKey: string, token: string) => void;
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void; resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
@@ -118,6 +130,11 @@ interface WorkspaceLayoutStore {
purgeWorkspace: (workspaceKey: string) => void; purgeWorkspace: (workspaceKey: string) => void;
} }
interface EnsureExplorerPaneResult {
paneId: string;
created: boolean;
}
interface WorkspaceFocusRestorationState { interface WorkspaceFocusRestorationState {
restorePaneId: string | null; restorePaneId: string | null;
tokens: string[]; 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("terminal"), terminalId: z.string() }),
z.strictObject({ kind: z.literal("browser"), browserId: 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({ z.strictObject({
kind: z.literal("file"), kind: z.literal("file"),
path: z.string(), path: z.string(),
@@ -179,6 +198,7 @@ const SplitNodeStorageSchema: z.ZodType<SplitNode> = z.lazy(() =>
tabIds: z.array(z.string()), tabIds: z.array(z.string()),
focusedTabId: z.string().nullable(), focusedTabId: z.string().nullable(),
tabs: z.array(WorkspaceTabStorageSchema).optional(), tabs: z.array(WorkspaceTabStorageSchema).optional(),
hidden: z.boolean().optional(),
}), }),
}), }),
z.strictObject({ z.strictObject({
@@ -200,6 +220,8 @@ const WorkspaceLayoutStorageSchema: z.ZodType<WorkspaceLayout> = z.strictObject(
const WorkspaceLayoutPersistedStateSchema = z.strictObject({ const WorkspaceLayoutPersistedStateSchema = z.strictObject({
layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema), layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema),
splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(), 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 { function trimNonEmpty(value: string | null | undefined): string | null {
@@ -308,6 +330,8 @@ export function createWorkspaceLayoutStore(
pendingAgentIdsByWorkspace: {}, pendingAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {}, focusRestorationByWorkspace: {},
explorerPaneIdByWorkspace: {},
acknowledgedPullRequestByWorkspace: {},
openTabFocused: (workspaceKey, target) => { openTabFocused: (workspaceKey, target) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTarget = normalizeWorkspaceTabTarget(target); const normalizedTarget = normalizeWorkspaceTabTarget(target);
@@ -408,6 +432,104 @@ export function createWorkspaceLayoutStore(
return result.tabId; 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) => { closeTab: (workspaceKey, tabId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTabId = trimNonEmpty(tabId); 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) => { unfocusPane: (workspaceKey) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) { 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) { if (!restorePaneId) {
return { return {
focusRestorationByWorkspace: remainingRestorations, focusRestorationByWorkspace: remainingRestorations,
@@ -991,7 +1175,9 @@ export function createWorkspaceLayoutStore(
normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace || normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace || normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace || normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.focusRestorationByWorkspace; normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
normalizedWorkspaceKey in state.explorerPaneIdByWorkspace ||
normalizedWorkspaceKey in state.acknowledgedPullRequestByWorkspace;
if (!hasAny) { if (!hasAny) {
return state; return state;
} }
@@ -1007,6 +1193,12 @@ export function createWorkspaceLayoutStore(
state.hiddenAgentIdsByWorkspace; state.hiddenAgentIdsByWorkspace;
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } = const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
state.focusRestorationByWorkspace; state.focusRestorationByWorkspace;
const { [normalizedWorkspaceKey]: _explorerPane, ...explorerPaneIdByWorkspace } =
state.explorerPaneIdByWorkspace;
const {
[normalizedWorkspaceKey]: _acknowledgedPullRequest,
...acknowledgedPullRequestByWorkspace
} = state.acknowledgedPullRequestByWorkspace;
return { return {
layoutByWorkspace, layoutByWorkspace,
splitSizesByWorkspace, splitSizesByWorkspace,
@@ -1014,6 +1206,8 @@ export function createWorkspaceLayoutStore(
pendingAgentIdsByWorkspace, pendingAgentIdsByWorkspace,
hiddenAgentIdsByWorkspace, hiddenAgentIdsByWorkspace,
focusRestorationByWorkspace, focusRestorationByWorkspace,
explorerPaneIdByWorkspace,
acknowledgedPullRequestByWorkspace,
}; };
}); });
}, },
@@ -1034,6 +1228,8 @@ export function createWorkspaceLayoutStore(
return { return {
layoutByWorkspace, layoutByWorkspace,
splitSizesByWorkspace: state.splitSizesByWorkspace, splitSizesByWorkspace: state.splitSizesByWorkspace,
explorerPaneIdByWorkspace: state.explorerPaneIdByWorkspace,
acknowledgedPullRequestByWorkspace: state.acknowledgedPullRequestByWorkspace,
}; };
}, },
merge: (persistedState, currentState) => { merge: (persistedState, currentState) => {
@@ -1045,6 +1241,9 @@ export function createWorkspaceLayoutStore(
...currentState, ...currentState,
layoutByWorkspace: result.data.layoutByWorkspace, layoutByWorkspace: result.data.layoutByWorkspace,
splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {}, splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {},
explorerPaneIdByWorkspace: result.data.explorerPaneIdByWorkspace ?? {},
acknowledgedPullRequestByWorkspace:
result.data.acknowledgedPullRequestByWorkspace ?? {},
}; };
}, },
}, },
@@ -2,13 +2,14 @@ import { describe, expect, it } from "vitest";
import type { SplitNode } from "@/stores/workspace-layout-store"; import type { SplitNode } from "@/stores/workspace-layout-store";
import { findAdjacentPane } from "./split-navigation"; import { findAdjacentPane } from "./split-navigation";
function createPaneNode(id: string): SplitNode { function createPaneNode(id: string, hidden = false): SplitNode {
return { return {
kind: "pane", kind: "pane",
pane: { pane: {
id, id,
tabIds: [], tabIds: [],
focusedTabId: null, focusedTabId: null,
...(hidden ? { hidden: true } : {}),
}, },
}; };
} }
@@ -80,4 +81,15 @@ describe("findAdjacentPane", () => {
expect(findAdjacentPane(root, "top", "down")).toBe("bottom-left"); expect(findAdjacentPane(root, "top", "down")).toBe("bottom-left");
expect(findAdjacentPane(root, "bottom-right", "up")).toBe("top"); 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();
});
}); });
@@ -171,6 +171,9 @@ function collectPaneBounds(
bounds: { left: number; top: number; right: number; bottom: number }, bounds: { left: number; top: number; right: number; bottom: number },
): PaneBounds[] { ): PaneBounds[] {
if (node.kind === "pane") { if (node.kind === "pane") {
if (node.pane.hidden === true) {
return [];
}
return [ return [
{ {
paneId: node.pane.id, paneId: node.pane.id,
+50 -4
View File
@@ -1,6 +1,13 @@
import { useMemo, type ReactElement } from "react"; import { useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next"; 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 { withUnistyles } from "react-native-unistyles";
import { import {
getTerminalProfileIcon, getTerminalProfileIcon,
@@ -27,6 +34,8 @@ export interface ResolvedPin {
interface UsePinnedLaunchersInput { interface UsePinnedLaunchersInput {
serverId: string; serverId: string;
onLaunch: (target: PinnedTabTarget) => void; onLaunch: (target: PinnedTabTarget) => void;
isGit?: boolean;
hasPullRequest?: boolean;
} }
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); 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 ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe); const ThemedGlobe = withUnistyles(Globe);
const ThemedFileDiff = withUnistyles(FileDiff);
const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
function ProviderPinIcon({ function ProviderPinIcon({
iconKey, iconKey,
@@ -57,7 +69,12 @@ export function ProfileIcon({ iconKey }: { iconKey: string | undefined }): React
return <ThemedProviderPinIcon iconKey={iconKey} size={14} uniProps={mutedColorMapping} />; return <ThemedProviderPinIcon iconKey={iconKey} size={14} uniProps={mutedColorMapping} />;
} }
export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInput): ResolvedPin[] { export function usePinnedLaunchers({
serverId,
onLaunch,
isGit,
hasPullRequest,
}: UsePinnedLaunchersInput): ResolvedPin[] {
const { t } = useTranslation(); const { t } = useTranslation();
const pinned = usePinnedTargetsStore((state) => state.pinned); const pinned = usePinnedTargetsStore((state) => state.pinned);
const { config } = useDaemonConfig(serverId); const { config } = useDaemonConfig(serverId);
@@ -69,7 +86,9 @@ export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInp
return useMemo(() => { return useMemo(() => {
const resolved: ResolvedPin[] = []; const resolved: ResolvedPin[] = [];
for (const target of pinned) { for (const target of pinned) {
if (!isPinnedTargetAvailable(target, { isElectron: getIsElectron() })) { if (
!isPinnedTargetAvailable(target, { isElectron: getIsElectron(), isGit, hasPullRequest })
) {
continue; continue;
} }
if (target.kind === "draft") { if (target.kind === "draft") {
@@ -99,6 +118,33 @@ export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInp
}); });
continue; continue;
} }
if (target.kind === "working_diff") {
resolved.push({
key: pinnedTargetKey(target),
label: t("workspace.tabs.actions.changes"),
icon: <ThemedFileDiff size={14} uniProps={mutedColorMapping} />,
onPress: () => onLaunch(target),
});
continue;
}
if (target.kind === "files") {
resolved.push({
key: pinnedTargetKey(target),
label: t("workspace.tabs.actions.files"),
icon: <ThemedFolderTree size={14} uniProps={mutedColorMapping} />,
onPress: () => onLaunch(target),
});
continue;
}
if (target.kind === "pull_request") {
resolved.push({
key: pinnedTargetKey(target),
label: t("workspace.tabs.actions.pullRequest"),
icon: <ThemedGitPullRequest size={14} uniProps={mutedColorMapping} />,
onPress: () => onLaunch(target),
});
continue;
}
const profile = profiles.find((entry) => entry.id === target.profileId); const profile = profiles.find((entry) => entry.id === target.profileId);
if (!profile) { if (!profile) {
continue; continue;
@@ -111,5 +157,5 @@ export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInp
}); });
} }
return resolved; return resolved;
}, [onLaunch, pinned, profiles, t]); }, [hasPullRequest, isGit, onLaunch, pinned, profiles, t]);
} }
@@ -4,9 +4,11 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pin, PinOff } from "lucide-react-native"; import { Pin, PinOff } from "lucide-react-native";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { pinnedTargetKey, type PinnedTabTarget } from "@/workspace-pins/target"; import { pinnedTargetKey, type PinnedTabTarget } from "@/workspace-pins/target";
import { usePinnedTargetsStore } from "@/workspace-pins/store"; import { usePinnedTargetsStore } from "@/workspace-pins/store";
@@ -22,6 +24,8 @@ interface PinnableMenuItemProps {
disabled?: boolean; disabled?: boolean;
onSelect?: () => void; onSelect?: () => void;
testID?: string; testID?: string;
/** Chord for the keyboard action that runs this target directly, shown beside the pin toggle. */
shortcut?: ShortcutKey[][] | null;
} }
export function PinnableMenuItem({ export function PinnableMenuItem({
@@ -31,6 +35,7 @@ export function PinnableMenuItem({
disabled, disabled,
onSelect, onSelect,
testID, testID,
shortcut,
}: PinnableMenuItemProps): ReactElement { }: PinnableMenuItemProps): ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
@@ -58,28 +63,31 @@ export function PinnableMenuItem({
const trailing = useMemo( const trailing = useMemo(
() => ( () => (
<View style={slotStyle} pointerEvents={showToggle ? "auto" : "none"}> <View style={styles.trailingRow}>
<Pressable {shortcut ? <Shortcut chord={shortcut} /> : null}
onPress={handleTogglePin} <View style={slotStyle} pointerEvents={showToggle ? "auto" : "none"}>
hitSlop={8} <Pressable
style={styles.pinToggleButton} onPress={handleTogglePin}
accessibilityRole="button" hitSlop={8}
accessibilityLabel={ style={styles.pinToggleButton}
isPinned accessibilityRole="button"
? t("workspace.tabs.actions.unpinTarget") accessibilityLabel={
: t("workspace.tabs.actions.pinTarget") isPinned
} ? t("workspace.tabs.actions.unpinTarget")
testID={`workspace-pin-toggle-${pinnedTargetKey(target)}`} : t("workspace.tabs.actions.pinTarget")
> }
{isPinned ? ( testID={`workspace-pin-toggle-${pinnedTargetKey(target)}`}
<ThemedPinOff size={14} uniProps={mutedColorMapping} /> >
) : ( {isPinned ? (
<ThemedPin size={14} uniProps={mutedColorMapping} /> <ThemedPinOff size={14} uniProps={mutedColorMapping} />
)} ) : (
</Pressable> <ThemedPin size={14} uniProps={mutedColorMapping} />
)}
</Pressable>
</View>
</View> </View>
), ),
[handleTogglePin, isPinned, showToggle, slotStyle, t, target], [handleTogglePin, isPinned, shortcut, showToggle, slotStyle, t, target],
); );
return ( 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: { pinToggleSlot: {
width: 22, width: 22,
height: 22, height: 22,
+4 -1
View File
@@ -8,7 +8,7 @@ const PROFILES: readonly TerminalProfile[] = [
]; ];
interface RecordedLaunch { interface RecordedLaunch {
action: "draft" | "terminal" | "browser" | "profile"; action: "draft" | "terminal" | "browser" | "changes" | "files" | "pull_request" | "profile";
profile?: TerminalProfileInput; profile?: TerminalProfileInput;
} }
@@ -18,6 +18,9 @@ function recordingHandlers() {
createDraft: () => launches.push({ action: "draft" }), createDraft: () => launches.push({ action: "draft" }),
createTerminal: () => launches.push({ action: "terminal" }), createTerminal: () => launches.push({ action: "terminal" }),
createBrowser: () => launches.push({ action: "browser" }), 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 }), createTerminalWithProfile: (profile) => launches.push({ action: "profile", profile }),
}; };
return { launches, handlers }; return { launches, handlers };
+15
View File
@@ -7,6 +7,9 @@ export interface TabTargetHandlers {
createDraft: () => void; createDraft: () => void;
createTerminal: () => void; createTerminal: () => void;
createBrowser: () => void; createBrowser: () => void;
openChanges: () => void;
openFiles: () => void;
openPullRequest: () => void;
createTerminalWithProfile: (profile: TerminalProfileInput) => void; createTerminalWithProfile: (profile: TerminalProfileInput) => void;
} }
@@ -27,6 +30,18 @@ export function runPinnedTabTarget(
handlers.createBrowser(); handlers.createBrowser();
return; 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); const profile = profiles.find((entry) => entry.id === target.profileId);
if (!profile) { if (!profile) {
return; return;
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
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" },
]);
});
});
+3
View File
@@ -16,6 +16,9 @@ const PinnedTabTargetSchema = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("draft") }), z.strictObject({ kind: z.literal("draft") }),
z.strictObject({ kind: z.literal("terminal") }), z.strictObject({ kind: z.literal("terminal") }),
z.strictObject({ kind: z.literal("browser") }), 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() }), z.strictObject({ kind: z.literal("profile"), profileId: z.string() }),
]); ]);
const PinnedTargetsPersistedStateSchema = z.strictObject({ const PinnedTargetsPersistedStateSchema = z.strictObject({
+14 -2
View File
@@ -2,13 +2,25 @@ export type PinnedTabTarget =
| { kind: "draft" } | { kind: "draft" }
| { kind: "terminal" } | { kind: "terminal" }
| { kind: "browser" } | { kind: "browser" }
| { kind: "working_diff" }
| { kind: "files" }
| { kind: "pull_request" }
| { kind: "profile"; profileId: string }; | { kind: "profile"; profileId: string };
export function isPinnedTargetAvailable( export function isPinnedTargetAvailable(
target: PinnedTabTarget, target: PinnedTabTarget,
environment: { isElectron: boolean }, environment: { isElectron: boolean; isGit?: boolean; hasPullRequest?: boolean },
): 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 { export function pinnedTargetKey(target: PinnedTabTarget): string {
@@ -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", () => { describe("commit diff tab identity", () => {
it("keys a commit diff tab by its sha", () => { it("keys a commit diff tab by its sha", () => {
expect(buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" })).toBe( expect(buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" })).toBe(
@@ -49,6 +49,9 @@ function normalizeSimpleWorkspaceTabTarget(value: WorkspaceTabTarget): Workspace
const browserId = trimNonEmpty(value.browserId); const browserId = trimNonEmpty(value.browserId);
return browserId ? { kind: "browser", browserId } : null; return browserId ? { kind: "browser", browserId } : null;
} }
case "files":
case "pull_request":
return { kind: value.kind };
case "setup": { case "setup": {
const workspaceId = trimNonEmpty(value.workspaceId); const workspaceId = trimNonEmpty(value.workspaceId);
return workspaceId ? { kind: "setup", workspaceId } : null; return workspaceId ? { kind: "setup", workspaceId } : null;
@@ -121,6 +124,12 @@ function secondaryWorkspaceTabTargetsEqual(
if (left.kind === "working_diff" && right.kind === "working_diff") { if (left.kind === "working_diff" && right.kind === "working_diff") {
return left.focusPath === right.focusPath && left.focusRequestId === right.focusRequestId; 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") { if (left.kind === "setup" && right.kind === "setup") {
return left.workspaceId === right.workspaceId; return left.workspaceId === right.workspaceId;
} }
@@ -188,6 +197,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
if (target.kind === "working_diff") { if (target.kind === "working_diff") {
return "working_diff"; return "working_diff";
} }
if (target.kind === "files" || target.kind === "pull_request") {
return target.kind;
}
return `file_${target.path}`; return `file_${target.path}`;
} }
+2
View File
@@ -22,6 +22,8 @@ export type WorkspaceTabTarget =
| { kind: "provider_subagent"; parentAgentId: string; subagentId: string } | { kind: "provider_subagent"; parentAgentId: string; subagentId: string }
| { kind: "terminal"; terminalId: string } | { kind: "terminal"; terminalId: string }
| { kind: "browser"; browserId: string } | { kind: "browser"; browserId: string }
| { kind: "files" }
| { kind: "pull_request" }
| WorkspaceFileTabTarget | WorkspaceFileTabTarget
| WorkspaceWorkingDiffTabTarget | WorkspaceWorkingDiffTabTarget
| { kind: "setup"; workspaceId: string } | { kind: "setup"; workspaceId: string }