From 3029b5991adec26441d5236c3ba382d48e894b53 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 22 Aug 2026 12:20:21 +0200 Subject: [PATCH] Surface workspace changes from agent panes (#3682) * feat(app): refine workspace pane actions Prioritize Changes when available, keep Files useful before a selection, and reserve desktop diff clicks for selection so comments require the explicit affordance. Move the Changes shortcut to Shift+G to avoid consuming a likely copy variant. * feat(app): improve access to workspace changes --- docs/side-panel.md | 4 +- packages/app/e2e/browser/changes-pane.spec.ts | 24 ++++- .../changes-shortcut-placement.spec.ts | 58 ++++++++++++ .../e2e/browser/composer-diff-stat.spec.ts | 92 +++++++++++++++++++ .../e2e/browser/file-tree-rail-toggle.spec.ts | 27 ++++++ packages/app/e2e/browser/launcher-tab.spec.ts | 2 +- packages/app/e2e/browser/side-panel.spec.ts | 16 +++- .../app/e2e/support/helpers/mock-agent.ts | 7 +- .../app/e2e/support/helpers/seed-client.ts | 1 + .../app/src/components/file-explorer-pane.tsx | 1 - .../app/src/composer/diff-stat-pill.test.tsx | 69 ++++++++++++++ packages/app/src/composer/diff-stat-pill.tsx | 59 ++++++++++++ .../app/src/composer/workspace-diff-stat.ts | 24 +++++ .../app/src/git/diff-document/surface.web.tsx | 9 +- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../src/keyboard/keyboard-shortcuts.test.ts | 8 +- .../app/src/keyboard/keyboard-shortcuts.ts | 6 +- packages/app/src/panels/agent-panel.tsx | 43 ++++++--- packages/app/src/panels/agent-tracks.tsx | 33 +++++-- packages/app/src/panels/files-panel.tsx | 57 +++++++++--- packages/app/src/review/surface.tsx | 8 +- .../workspace/workspace-desktop-tabs-row.tsx | 18 +++- .../workspace/workspace-pane-empty-state.tsx | 18 ++-- 31 files changed, 527 insertions(+), 66 deletions(-) create mode 100644 packages/app/e2e/browser/changes-shortcut-placement.spec.ts create mode 100644 packages/app/e2e/browser/composer-diff-stat.spec.ts create mode 100644 packages/app/src/composer/diff-stat-pill.test.tsx create mode 100644 packages/app/src/composer/diff-stat-pill.tsx create mode 100644 packages/app/src/composer/workspace-diff-stat.ts diff --git a/docs/side-panel.md b/docs/side-panel.md index fdd7000ea..98ba4166f 100644 --- a/docs/side-panel.md +++ b/docs/side-panel.md @@ -83,7 +83,9 @@ Running and successful setup never seed tabs. persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions — the header toggle, a pane's `+` menu, the empty-state launcher — are never subject -to it. +to it. The Changes keyboard shortcut and the diff-stat pill above an agent composer are +supporting opens: they follow the preference and focus an existing Changes tab wherever +the user left it. ## The name diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 4550f043a..0da78b92e 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -996,6 +996,23 @@ test("canvas diff creates, edits, and deletes an inline review without DOM code await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0); }); +test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff(); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true }); + const focusedBackground = await changesTab.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + + await startReviewOnFirstChangedLine(page); + await expect(page.getByTestId("inline-review-editor-input")).toBeFocused(); + await expect + .poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor)) + .toBe(focusedBackground); +}); + test("split canvas creates a review on the changed side and keeps it in that column", async ({ page, }) => { @@ -1095,7 +1112,7 @@ test("canvas diff copies a dragged character selection without opening a review" await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); }); -test("the first click after a canvas selection only dismisses the selection", async ({ page }) => { +test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => { const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ"); await useUnwrappedDiffLines(page); await openSelectionWorkspaceChanges(page, workspace); @@ -1105,7 +1122,7 @@ test("the first click after a canvas selection only dismisses the selection", as await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); await clickFirstChangedLine(page); - await expect(page.getByTestId("inline-review-editor")).toBeVisible(); + await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); }); test("canvas diff replaces a selection with forward and backward drags", async ({ @@ -1537,7 +1554,8 @@ async function startReviewOnFirstChangedLine( if (!bodyBounds) throw new Error("Expanded diff body has no bounds"); const lineHeight = Math.round(fontSize * 1.5); const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x; - await page.mouse.click(columnLeft + 20, bodyBounds.y + lineHeight * 1.5); + await page.mouse.move(columnLeft + 20, bodyBounds.y + lineHeight * 1.5); + await page.getByRole("button", { name: "Add review comment" }).click(); await expect(page.getByTestId("inline-review-editor")).toBeVisible(); } diff --git a/packages/app/e2e/browser/changes-shortcut-placement.spec.ts b/packages/app/e2e/browser/changes-shortcut-placement.spec.ts new file mode 100644 index 000000000..13be178f1 --- /dev/null +++ b/packages/app/e2e/browser/changes-shortcut-placement.spec.ts @@ -0,0 +1,58 @@ +import { expect, test, type Page } from "../support/fixtures"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; + +const APP_SETTINGS_KEY = "@paseo:app-settings"; +const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`; + +function mainPane(page: Page) { + return page.getByTestId("workspace-pane-main").filter({ visible: true }); +} + +function sidePanel(page: Page) { + return page.getByTestId("workspace-side-panel").filter({ visible: true }); +} + +test("Changes shortcut follows the default Side panel placement", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-side-" }); + + try { + await page.setViewportSize({ width: 1400, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + + await page.keyboard.press(CHANGES_SHORTCUT); + + await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({ + timeout: 30_000, + }); + await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(0); + } finally { + await workspace.cleanup(); + } +}); + +test("Changes shortcut uses the focused pane when Side panel placement is off", async ({ + page, +}) => { + await page.addInitScript((settingsKey) => { + localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false })); + }, APP_SETTINGS_KEY); + const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-pane-" }); + + try { + await page.setViewportSize({ width: 1400, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + + await page.keyboard.press(CHANGES_SHORTCUT); + + await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({ + timeout: 30_000, + }); + await expect(sidePanel(page)).toHaveCount(0); + } finally { + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts new file mode 100644 index 000000000..ee17cb992 --- /dev/null +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -0,0 +1,92 @@ +import { rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, test, type Page } from "../support/fixtures"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; + +const APP_SETTINGS_KEY = "@paseo:app-settings"; + +function visibleMainPane(page: Page) { + return page.getByTestId("workspace-pane-main").filter({ visible: true }); +} + +async function seedChangedAgent(repoPrefix: string) { + const workspace = await seedMockAgentWorkspace({ + repoPrefix, + title: "Composer diff stat", + repo: { withRemote: true }, + }); + try { + await rm(path.join(workspace.cwd, "remote.git"), { recursive: true }); + await writeFile( + path.join(workspace.cwd, "README.md"), + "# Temp Repo\nexport const one = 1;\nexport const two = 2;\n", + ); + await workspace.client.checkoutRefresh(workspace.cwd); + await expect + .poll(async () => { + const workspaces = await workspace.client.fetchWorkspaces(); + return ( + workspaces.entries.find((entry) => entry.id === workspace.workspaceId)?.diffStat ?? null + ); + }) + .toEqual({ additions: 2, deletions: 0 }); + return workspace; + } catch (error) { + await workspace.cleanup(); + throw error; + } +} + +test("composer diff stat opens Changes in the preferred Side panel", async ({ page }) => { + const workspace = await seedChangedAgent("composer-diff-stat-side-"); + + try { + await page.setViewportSize({ width: 1400, height: 900 }); + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + + const pill = page.getByTestId("composer-diff-stat-pill"); + await expect(pill).toBeVisible({ timeout: 30_000 }); + await expect(pill).toContainText("+2"); + await expect(pill).toContainText("-0"); + await pill.click(); + + const sidePanel = page.getByTestId("workspace-side-panel").filter({ visible: true }); + await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible({ + timeout: 30_000, + }); + await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 }); + } finally { + await workspace.cleanup(); + } +}); + +test("composer diff stat opens Changes in the focused pane when Side panel routing is off", async ({ + page, +}) => { + await page.addInitScript((settingsKey) => { + localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false })); + }, APP_SETTINGS_KEY); + const workspace = await seedChangedAgent("composer-diff-stat-tab-"); + + try { + await page.setViewportSize({ width: 1400, height: 900 }); + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + + await page.getByTestId("composer-diff-stat-pill").click(); + + const mainPane = visibleMainPane(page); + await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({ + timeout: 30_000, + }); + await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("workspace-side-panel")).toHaveCount(0); + } finally { + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/file-tree-rail-toggle.spec.ts b/packages/app/e2e/browser/file-tree-rail-toggle.spec.ts index d023d2219..14780334e 100644 --- a/packages/app/e2e/browser/file-tree-rail-toggle.spec.ts +++ b/packages/app/e2e/browser/file-tree-rail-toggle.spec.ts @@ -9,6 +9,18 @@ import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-cli let workspace: SeededWorkspace; +async function effectiveBackground(locator: import("@playwright/test").Locator): Promise { + return locator.evaluate((element) => { + let current: Element | null = element; + while (current) { + const color = getComputedStyle(current).backgroundColor; + if (color !== "rgba(0, 0, 0, 0)" && color !== "transparent") return color; + current = current.parentElement; + } + return "transparent"; + }); +} + test.beforeAll(async () => { workspace = await seedWorkspace({ repoPrefix: "file-tree-rail-toggle-", @@ -21,6 +33,21 @@ test.afterAll(async () => { }); test.describe("File panel tree rail", () => { + test("the Files panel starts with an empty content pane beside the tree", async ({ page }) => { + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + const rail = page.getByTestId("files-tree-rail").filter({ visible: true }); + await expect(rail).toBeVisible({ timeout: 30_000 }); + const content = rail.getByTestId("files-tree-rail-content"); + const tree = rail.getByTestId("files-tree-rail-tree"); + await expect(content).toHaveText("Choose a file"); + await expect(tree).toContainText("docs"); + expect(await effectiveBackground(tree.getByTestId("file-explorer-tree-scroll"))).toBe( + await effectiveBackground(content), + ); + }); + test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => { await gotoWorkspace(page, workspace.workspaceId); await openFileExplorer(page); diff --git a/packages/app/e2e/browser/launcher-tab.spec.ts b/packages/app/e2e/browser/launcher-tab.spec.ts index 59bf8d71b..e3087950e 100644 --- a/packages/app/e2e/browser/launcher-tab.spec.ts +++ b/packages/app/e2e/browser/launcher-tab.spec.ts @@ -156,7 +156,7 @@ test.describe("Tab creation", () => { const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/; await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`)); await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`)); - await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`)); + await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`)); await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`)); await expect(agent).toBeFocused(); diff --git a/packages/app/e2e/browser/side-panel.spec.ts b/packages/app/e2e/browser/side-panel.spec.ts index 64fbd7f7d..a48de5213 100644 --- a/packages/app/e2e/browser/side-panel.spec.ts +++ b/packages/app/e2e/browser/side-panel.spec.ts @@ -22,6 +22,12 @@ function emptyState(scope: Locator): Locator { return scope.getByTestId("workspace-pane-empty-state"); } +async function launcherButtonLabels(launcher: Locator): Promise<(string | null)[]> { + return launcher + .getByRole("button") + .evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label"))); +} + function sidePanelToggle(page: Page): Locator { return page.getByTestId("workspace-explorer-toggle").first(); } @@ -61,7 +67,15 @@ test.describe("Side panel", () => { await test.step("revealing the Side panel seeds nothing", async () => { await sidePanelToggle(page).click(); await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 }); - await expect(emptyState(sidePanel(page))).toBeVisible(); + const launcher = emptyState(sidePanel(page)); + await expect(launcher).toBeVisible(); + expect(await launcherButtonLabels(launcher)).toEqual([ + "Changes", + "Files", + "Agent", + "Terminal", + null, + ]); await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0); await capture(page, testInfo, "01-side-panel-reveals-empty"); }); diff --git a/packages/app/e2e/support/helpers/mock-agent.ts b/packages/app/e2e/support/helpers/mock-agent.ts index 6e4a04812..b6448c4b8 100644 --- a/packages/app/e2e/support/helpers/mock-agent.ts +++ b/packages/app/e2e/support/helpers/mock-agent.ts @@ -14,6 +14,7 @@ export interface MockAgentWorkspace { export interface MockAgentOptions { repoPrefix: string; title: string; + repo?: Parameters[0]["repo"]; port?: number; initialPrompt?: string; model?: string; @@ -29,7 +30,11 @@ export interface MockAgentOptions { export async function seedMockAgentWorkspace( options: MockAgentOptions, ): Promise { - const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port }); + const workspace = await seedWorkspace({ + repoPrefix: options.repoPrefix, + repo: options.repo, + port: options.port, + }); try { const agent = await workspace.client.createAgent({ provider: "mock", diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index d2f02d8f4..69129ddcb 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor { projectDisplayName: string; projectRootPath: string; workspaceDirectory: string; + diffStat: { additions: number; deletions: number } | null; labels?: string[]; } diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 84a215f9c..e2c3d9d5e 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -1662,7 +1662,6 @@ function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string const styles = StyleSheet.create((theme) => ({ container: { flex: 1, - backgroundColor: theme.colors.surfaceSidebar, }, desktopSplit: { flex: 1, diff --git a/packages/app/src/composer/diff-stat-pill.test.tsx b/packages/app/src/composer/diff-stat-pill.test.tsx new file mode 100644 index 000000000..96e1e1139 --- /dev/null +++ b/packages/app/src/composer/diff-stat-pill.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom + +import { act, renderHook } from "@testing-library/react"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import { seedSessionWorkspaces } from "@/test/seed-session"; +import { useWorkspaceHasDiffStat } from "./workspace-diff-stat"; + +const SERVER_ID = "diff-stat-pill"; +const WORKSPACE_ID = "workspace"; + +const workspace: WorkspaceDescriptor = { + id: WORKSPACE_ID, + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], +}; + +function setDiffStat(diffStat: WorkspaceDescriptor["diffStat"]): void { + useSessionStore.getState().setWorkspaces(SERVER_ID, (workspaces) => { + const next = new Map(workspaces); + next.set(WORKSPACE_ID, { ...workspace, diffStat }); + return next; + }); +} + +describe("useWorkspaceHasDiffStat", () => { + beforeEach(() => { + useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); + seedSessionWorkspaces(SERVER_ID, new Map([[WORKSPACE_ID, workspace]])); + }); + + afterEach(() => { + useSessionStore.getState().clearSession(SERVER_ID); + }); + + it("does not re-render its owner when only the visible counts change", () => { + let renderCount = 0; + const { result } = renderHook(() => { + renderCount += 1; + return useWorkspaceHasDiffStat(SERVER_ID, WORKSPACE_ID); + }); + + expect(result.current).toBe(false); + expect(renderCount).toBe(1); + + act(() => setDiffStat({ additions: 2, deletions: 0 })); + expect(result.current).toBe(true); + expect(renderCount).toBe(2); + + act(() => setDiffStat({ additions: 5, deletions: 3 })); + expect(result.current).toBe(true); + expect(renderCount).toBe(2); + + act(() => setDiffStat(null)); + expect(result.current).toBe(false); + expect(renderCount).toBe(3); + }); +}); diff --git a/packages/app/src/composer/diff-stat-pill.tsx b/packages/app/src/composer/diff-stat-pill.tsx new file mode 100644 index 000000000..052985fbf --- /dev/null +++ b/packages/app/src/composer/diff-stat-pill.tsx @@ -0,0 +1,59 @@ +import { memo, useCallback, useMemo, useState, type ReactElement } from "react"; +import { Pressable } from "react-native"; +import { useTranslation } from "react-i18next"; +import { DiffStat } from "@/components/diff-stat"; +import { composerPillStyles } from "@/composer/pill-styles"; +import { useVisibleWorkspaceDiffStat } from "@/composer/workspace-diff-stat"; + +interface ComposerDiffStatPillProps { + additions: number; + deletions: number; + onPress: () => void; +} + +export function ComposerDiffStatPill({ additions, deletions, onPress }: ComposerDiffStatPillProps) { + const { t } = useTranslation(); + const [isHovered, setIsHovered] = useState(false); + const handleHoverIn = useCallback(() => setIsHovered(true), []); + const handleHoverOut = useCallback(() => setIsHovered(false), []); + const bodyStyle = useMemo( + () => [composerPillStyles.body, isHovered && composerPillStyles.bodyActive], + [isHovered], + ); + + return ( + + + + ); +} + +export const WorkspaceDiffStatPill = memo(function WorkspaceDiffStatPill({ + serverId, + workspaceId, + onPress, +}: { + serverId: string; + workspaceId: string; + onPress: () => void; +}): ReactElement | null { + const diffStat = useVisibleWorkspaceDiffStat(serverId, workspaceId); + if (!diffStat) { + return null; + } + return ( + + ); +}); diff --git a/packages/app/src/composer/workspace-diff-stat.ts b/packages/app/src/composer/workspace-diff-stat.ts new file mode 100644 index 000000000..1cef419e6 --- /dev/null +++ b/packages/app/src/composer/workspace-diff-stat.ts @@ -0,0 +1,24 @@ +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { useWorkspaceFields } from "@/stores/session-store-hooks"; + +type DiffStat = NonNullable; + +export function useVisibleWorkspaceDiffStat( + serverId: string, + workspaceId: string, +): DiffStat | null { + const diffStat = useWorkspaceFields(serverId, workspaceId, (workspace) => workspace.diffStat); + return hasDiffStatChanges(diffStat) ? diffStat : null; +} + +export function useWorkspaceHasDiffStat(serverId: string, workspaceId: string): boolean { + return ( + useWorkspaceFields(serverId, workspaceId, (workspace) => + hasDiffStatChanges(workspace.diffStat), + ) ?? false + ); +} + +function hasDiffStatChanges(diffStat: WorkspaceDescriptor["diffStat"]): diffStat is DiffStat { + return Boolean(diffStat && (diffStat.additions > 0 || diffStat.deletions > 0)); +} diff --git a/packages/app/src/git/diff-document/surface.web.tsx b/packages/app/src/git/diff-document/surface.web.tsx index d7424ded7..807f914fb 100644 --- a/packages/app/src/git/diff-document/surface.web.tsx +++ b/packages/app/src/git/diff-document/surface.web.tsx @@ -495,7 +495,14 @@ export function DiffSurface(props: DiffSurfaceProps) { schedulePaint(); return; } - if (drag && !moved && hit?.kind === "cell" && hit.target && reviewActions) { + if ( + event.pointerType === "touch" && + drag && + !moved && + hit?.kind === "cell" && + hit.target && + reviewActions + ) { selectionRef.current = null; reviewActions.onStartComment(hit.target); schedulePaint(); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 114629e26..629043b31 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1729,6 +1729,7 @@ export const ar: TranslationResources = { label: "الملفات", subtitle: "ملفات مساحة العمل", tooltip: "تصفح ملفات مساحة العمل", + chooseFile: "اختر ملفًا", }, pullRequest: { label: "طلب السحب", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3beebc278..a28f938c1 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1739,6 +1739,7 @@ export const en = { label: "Files", subtitle: "Workspace files", tooltip: "Browse workspace files", + chooseFile: "Choose a file", }, pullRequest: { label: "Pull request", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 9be4b5bbd..652258ff9 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1775,6 +1775,7 @@ export const es: TranslationResources = { label: "Archivos", subtitle: "Archivos del espacio de trabajo", tooltip: "Explorar archivos del espacio de trabajo", + chooseFile: "Elige un archivo", }, pullRequest: { label: "Solicitud de extracción", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index b5c54a0d5..464abb814 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1779,6 +1779,7 @@ export const fr: TranslationResources = { label: "Fichiers", subtitle: "Fichiers de l’espace de travail", tooltip: "Parcourir les fichiers de l’espace de travail", + chooseFile: "Choisissez un fichier", }, pullRequest: { label: "Demande de fusion", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 0feb4869a..0c3b64c9d 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1746,6 +1746,7 @@ export const ja: TranslationResources = { label: "ファイル", subtitle: "ワークスペースのファイル", tooltip: "ワークスペースのファイルを参照", + chooseFile: "ファイルを選択", }, pullRequest: { label: "プルリクエスト", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index e70a966ad..6c328acdf 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1739,6 +1739,7 @@ export const ko: TranslationResources = { label: "파일", subtitle: "워크스페이스 파일", tooltip: "워크스페이스 파일 탐색", + chooseFile: "파일 선택", }, pullRequest: { label: "풀 리퀘스트", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 409875100..a708929f5 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1761,6 +1761,7 @@ export const ptBR: TranslationResources = { label: "Arquivos", subtitle: "Arquivos do espaço de trabalho", tooltip: "Explorar arquivos do espaço de trabalho", + chooseFile: "Escolha um arquivo", }, pullRequest: { label: "Pull request", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 25b94e024..ac35a6674 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1760,6 +1760,7 @@ export const ru: TranslationResources = { label: "Файлы", subtitle: "Файлы рабочего пространства", tooltip: "Просмотр файлов рабочего пространства", + chooseFile: "Выберите файл", }, pullRequest: { label: "PR", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 6fcc2e1d0..98c53402c 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1709,6 +1709,7 @@ export const zhCN: TranslationResources = { label: "文件", subtitle: "工作区文件", tooltip: "浏览工作区文件", + chooseFile: "选择文件", }, pullRequest: { label: "拉取请求", diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index e5f755f07..b0dd8670d 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -1158,7 +1158,7 @@ describe("direct new-tab target shortcuts", () => { const targetCases = [ ["a", "KeyA", "workspace.tab.target.agent"], ["b", "KeyB", "workspace.tab.target.browser"], - ["c", "KeyC", "workspace.tab.target.changes"], + ["g", "KeyG", "workspace.tab.target.changes"], ["e", "KeyE", "workspace.tab.target.files"], ] as const; @@ -1192,9 +1192,9 @@ describe("direct new-tab target shortcuts", () => { it("uses the existing override map for target matching and display", () => { const bindingId = "workspace-tab-target-agent-ctrl-shift-a-non-mac"; - const overrides = { [bindingId]: "Ctrl+Shift+G" }; + const overrides = { [bindingId]: "Ctrl+Shift+H" }; const rebound = resolveShortcut({ - event: { key: "g", code: "KeyG", ctrlKey: true, shiftKey: true }, + event: { key: "h", code: "KeyH", ctrlKey: true, shiftKey: true }, context: { ...desktopNonMac, focusScope: "other" }, bindings: buildEffectiveBindings(overrides), }); @@ -1208,6 +1208,6 @@ describe("direct new-tab target shortcuts", () => { expect(original.match).toBeNull(); expect( resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, desktopNonMac), - ).toEqual([["ctrl", "shift", "G"]]); + ).toEqual([["ctrl", "shift", "H"]]); }); }); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index cebdebf56..d2b2c31da 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -454,9 +454,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, { + // Keep the binding id stable so saved overrides from the former Cmd+Shift+C default survive. id: "workspace-tab-target-changes-cmd-shift-c-mac", action: "workspace.tab.target.changes", - combo: "Cmd+Shift+C", + combo: "Cmd+Shift+G", when: { mac: true, commandCenter: false }, help: { id: "workspace-tab-target-changes", @@ -465,9 +466,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, { + // Keep the binding id stable so saved overrides from the former Ctrl+Shift+C default survive. id: "workspace-tab-target-changes-ctrl-shift-c-non-mac", action: "workspace.tab.target.changes", - combo: "Ctrl+Shift+C", + combo: "Ctrl+Shift+G", when: { mac: false, commandCenter: false, terminal: false }, help: { id: "workspace-tab-target-changes", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 1f4c4feca..acf0c74ac 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -25,6 +25,7 @@ import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { Composer } from "@/composer"; +import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; import { resolveComposerTrackTailClearance } from "@/composer/pill-styles"; import { getActiveMessageSubmissions } from "@/composer/submission/model"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; @@ -748,6 +749,7 @@ function AgentPanelBody({ return ( ; @@ -1164,6 +1168,7 @@ function ChatAgentContent({ return ( void; }) { const { t } = useTranslation(); - const isCompactFormFactor = useIsCompactFormFactor(); - const composerTrackTailClearance = resolveComposerTrackTailClearance(isCompactFormFactor); const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId }); const tasks = useSessionStore((state): TodoEntry[] | undefined => state.sessions[serverId]?.agentTasks.get(agentId), @@ -1252,13 +1257,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ rows: subagentRows, }); const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent; - const showAgentTracks = - hasActiveComposer && - hasAgentTracks({ - subagentRows, - tasks, - archiveFinishedStatus: archiveFinishedSubagents.status, - }); + const hasVisibleAgentTracks = hasAgentTracks({ + subagentRows, + tasks, + archiveFinishedStatus: archiveFinishedSubagents.status, + }); const rawAgentInputDraft = useAgentInputDraft({ draftKey: buildDraftStoreKey({ serverId, @@ -1329,18 +1332,21 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ - {showAgentTracks ? ( + {hasActiveComposer ? ( ; serverId: string; + workspaceId: string; agentId?: string; agent: AgentScreenAgent; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; - bottomOverlayTailClearance: number; + hasActiveComposer: boolean; + hasVisibleAgentTracks: boolean; toast: ReturnType["api"]; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { + const isCompactFormFactor = useIsCompactFormFactor(); + const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId); + const hasVisibleComposerTracks = + hasActiveComposer && (hasVisibleAgentTracks || hasWorkspaceDiffStat); + const bottomOverlayTailClearance = hasVisibleComposerTracks + ? resolveComposerTrackTailClearance(isCompactFormFactor) + : 0; const streamItemsRaw = useSessionStore((state) => agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, ); diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 3f9b8172e..198e1e46e 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -1,4 +1,6 @@ import { memo, useCallback, type ReactElement } from "react"; +import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill"; +import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; import { AgentTaskList } from "@/composer/task-list"; import { ComposerTrackBar } from "@/composer/tracks"; import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; @@ -18,26 +20,29 @@ import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { openSupportingTab } from "@/workspace-tabs/side-panel"; /** - * The pane's trackers — its subagents and its task list — as a row of pills above the composer. + * The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above + * the composer. * * The row shares the composer's keyboard transform and owns the space between itself and the - * transcript. Its data remains agent state: a subagent row opens a tab and the task list reads - * the agent's stream. + * transcript. Each pill owns its action while tab placement stays behind the workspace boundary. */ export const AgentTracks = memo(function AgentTracks({ serverId, + workspaceId, subagentRows, tasks, archiveFinishedStatus, onArchiveFinished, }: { serverId: string; + workspaceId: string; subagentRows: SubagentRow[]; tasks: TodoEntry[] | undefined; archiveFinishedStatus: ArchiveFinishedStatus; onArchiveFinished: () => void; }): ReactElement | null { - const { workspaceId, tabId, openTab } = usePaneContext(); + const { tabId, openTab } = usePaneContext(); + const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId); const isCompact = useIsCompactFormFactor(); const canSplit = supportsDesktopPaneSplits() && !isCompact; const openInSidePanelByDefault = useSettings( @@ -87,13 +92,25 @@ export const AgentTracks = memo(function AgentTracks({ }, [canSplit, isCompact, openInSidePanelByDefault, openTab, tabId, workspaceKey], ); + const handleOpenChanges = useCallback(() => { + if (!workspaceKey) { + return; + } + openSupportingTab({ + isCompact, + workspaceKey, + target: { kind: "working_diff" }, + openInSidePanelByDefault, + }); + }, [isCompact, openInSidePanelByDefault, workspaceKey]); - if (!hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) { + if (!hasWorkspaceDiffStat && !hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) { return null; } return ( + - + ); }); diff --git a/packages/app/src/panels/files-panel.tsx b/packages/app/src/panels/files-panel.tsx index bfeb93a29..07d17818e 100644 --- a/packages/app/src/panels/files-panel.tsx +++ b/packages/app/src/panels/files-panel.tsx @@ -1,6 +1,6 @@ import { Text, View } from "react-native"; import { FolderTree } from "lucide-react-native"; -import { withUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import invariant from "tiny-invariant"; @@ -9,14 +9,11 @@ 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"; +import { TreeRail } from "@/components/tree-rail"; +import { useIsCompactFormFactor } from "@/constants/layout"; const ThemedFolderTree = withUnistyles(FolderTree); -const CENTERED_PADDED_STYLE = { - flex: 1, - alignItems: "center", - justifyContent: "center", - padding: 16, -} as const; +const FILE_TREE_WIDTH = 220; function useFilesPanelDescriptor() { const { t } = useTranslation(); @@ -35,6 +32,7 @@ function FilesPanel() { const { serverId, workspaceId, target, retargetCurrentTab } = usePaneContext(); const workspaceRoot = useWorkspaceDirectory(serverId, workspaceId); const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); + const isCompact = useIsCompactFormFactor(); invariant(target.kind === "files", "FilesPanel requires files target"); const onOpenFile = useCallback( (path: string) => retargetCurrentTab({ kind: "file", path }), @@ -42,19 +40,35 @@ function FilesPanel() { ); if (!workspaceRoot) { return ( - + {t("panels.file.directoryMissing")} ); } + if (isCompact) { + return ( + + ); + } return ( - + + + {t("panels.files.chooseFile")} + + + ); } @@ -64,3 +78,16 @@ export const filesPanelRegistration: PanelRegistration<"files"> = { component: FilesPanel, useDescriptor: useFilesPanelDescriptor, }; + +const styles = StyleSheet.create((theme) => ({ + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + }, + emptyText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + }, +})); diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx index 2f9e2969a..43b6af914 100644 --- a/packages/app/src/review/surface.tsx +++ b/packages/app/src/review/surface.tsx @@ -20,7 +20,6 @@ import { import { isWeb } from "@/constants/platform"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import type { Theme } from "@/styles/theme"; -import { useWorkspaceFocusRestoration } from "@/workspace/focus"; import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store"; import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout"; import { @@ -441,7 +440,6 @@ export function InlineReviewEditor({ }) { const { t } = useTranslation(); const inputRef = useRef(null); - const focus = useWorkspaceFocusRestoration(); const [body, setBody] = useState(initialBody); const [isFocused, setIsFocused] = useState(false); const trimmedBody = body.trim(); @@ -452,13 +450,11 @@ export function InlineReviewEditor({ }, []); const handleFocus = useCallback(() => { - focus.unfocus(); setIsFocused(true); - }, [focus]); + }, []); const handleBlur = useCallback(() => { setIsFocused(false); - focus.restore(); - }, [focus]); + }, []); const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]); useEffect(() => { diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index c2161bdf0..1931bab45 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -106,8 +106,10 @@ import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import { useSettings } from "@/hooks/use-settings"; import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; +import { openSupportingTab } from "@/workspace-tabs/side-panel"; const DROPDOWN_WIDTH = 220; const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36; @@ -1169,6 +1171,10 @@ function ResolvedWorkspaceDesktopTabsRow({ }: ResolvedWorkspaceDesktopTabsRowProps) { const { t } = useTranslation(); const router = useRouter(); + const isCompact = useIsCompactFormFactor(); + const openInSidePanelByDefault = useSettings( + (settings) => settings.openSupportingTabsInSidePanel, + ); const newTabKeys = useShortcutKeys("workspace-tab-new"); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState(0); @@ -1450,6 +1456,14 @@ function ResolvedWorkspaceDesktopTabsRow({ [openNewTab, paneId, workspaceKey], ); const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]); + const handleOpenChangesShortcut = useCallback(() => { + openSupportingTab({ + isCompact, + workspaceKey, + target: CHANGES_TARGET, + openInSidePanelByDefault, + }); + }, [isCompact, openInSidePanelByDefault, workspaceKey]); const handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]); const handleOpenPullRequest = useCallback( () => openPanelTarget(PULL_REQUEST_TARGET), @@ -1484,7 +1498,7 @@ function ResolvedWorkspaceDesktopTabsRow({ case "workspace.tab.target.changes": if (!isGit) return true; setNewTabMenuOpen(false); - handleOpenChanges(); + handleOpenChangesShortcut(); return true; case "workspace.tab.target.files": setNewTabMenuOpen(false); @@ -1497,7 +1511,7 @@ function ResolvedWorkspaceDesktopTabsRow({ [ handleCreateAgentTab, handleCreateBrowser, - handleOpenChanges, + handleOpenChangesShortcut, handleOpenFiles, isFocused, isGit, diff --git a/packages/app/src/screens/workspace/workspace-pane-empty-state.tsx b/packages/app/src/screens/workspace/workspace-pane-empty-state.tsx index c7ebde099..e6f59168a 100644 --- a/packages/app/src/screens/workspace/workspace-pane-empty-state.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-empty-state.tsx @@ -122,13 +122,6 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({ - - - {showChanges ? ( ) : null} + + + {showPullRequest ? ( ) : null}