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
This commit is contained in:
Mohamed Boudra
2026-08-22 12:20:21 +02:00
committed by GitHub
parent b1455fb4b3
commit 3029b5991a
31 changed files with 527 additions and 66 deletions
+3 -1
View File
@@ -83,7 +83,9 @@ Running and successful setup never seed tabs.
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions 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 — 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 ## The name
+21 -3
View File
@@ -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); 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 ({ test("split canvas creates a review on the changed side and keeps it in that column", async ({
page, 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); 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"); const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await useUnwrappedDiffLines(page); await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace); 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 expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await clickFirstChangedLine(page); 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 ({ 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"); if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(fontSize * 1.5); const lineHeight = Math.round(fontSize * 1.5);
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x; 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(); await expect(page.getByTestId("inline-review-editor")).toBeVisible();
} }
@@ -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();
}
});
@@ -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();
}
});
@@ -9,6 +9,18 @@ import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-cli
let workspace: SeededWorkspace; let workspace: SeededWorkspace;
async function effectiveBackground(locator: import("@playwright/test").Locator): Promise<string> {
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 () => { test.beforeAll(async () => {
workspace = await seedWorkspace({ workspace = await seedWorkspace({
repoPrefix: "file-tree-rail-toggle-", repoPrefix: "file-tree-rail-toggle-",
@@ -21,6 +33,21 @@ test.afterAll(async () => {
}); });
test.describe("File panel tree rail", () => { 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) => { test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
await gotoWorkspace(page, workspace.workspaceId); await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page); await openFileExplorer(page);
@@ -156,7 +156,7 @@ test.describe("Tab creation", () => {
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/; const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`)); await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`)); 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(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
await expect(agent).toBeFocused(); await expect(agent).toBeFocused();
+15 -1
View File
@@ -22,6 +22,12 @@ function emptyState(scope: Locator): Locator {
return scope.getByTestId("workspace-pane-empty-state"); 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 { function sidePanelToggle(page: Page): Locator {
return page.getByTestId("workspace-explorer-toggle").first(); 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 test.step("revealing the Side panel seeds nothing", async () => {
await sidePanelToggle(page).click(); await sidePanelToggle(page).click();
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 }); 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 expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
await capture(page, testInfo, "01-side-panel-reveals-empty"); await capture(page, testInfo, "01-side-panel-reveals-empty");
}); });
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
export interface MockAgentOptions { export interface MockAgentOptions {
repoPrefix: string; repoPrefix: string;
title: string; title: string;
repo?: Parameters<typeof seedWorkspace>[0]["repo"];
port?: number; port?: number;
initialPrompt?: string; initialPrompt?: string;
model?: string; model?: string;
@@ -29,7 +30,11 @@ export interface MockAgentOptions {
export async function seedMockAgentWorkspace( export async function seedMockAgentWorkspace(
options: MockAgentOptions, options: MockAgentOptions,
): Promise<MockAgentWorkspace> { ): Promise<MockAgentWorkspace> {
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port }); const workspace = await seedWorkspace({
repoPrefix: options.repoPrefix,
repo: options.repo,
port: options.port,
});
try { try {
const agent = await workspace.client.createAgent({ const agent = await workspace.client.createAgent({
provider: "mock", provider: "mock",
@@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor {
projectDisplayName: string; projectDisplayName: string;
projectRootPath: string; projectRootPath: string;
workspaceDirectory: string; workspaceDirectory: string;
diffStat: { additions: number; deletions: number } | null;
labels?: string[]; labels?: string[];
} }
@@ -1662,7 +1662,6 @@ function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
flex: 1, flex: 1,
backgroundColor: theme.colors.surfaceSidebar,
}, },
desktopSplit: { desktopSplit: {
flex: 1, flex: 1,
@@ -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);
});
});
@@ -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 (
<Pressable
testID="composer-diff-stat-pill"
accessibilityRole="button"
accessibilityLabel={t("workspace.git.diff.openChangesTab")}
onPress={onPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={bodyStyle}
>
<DiffStat additions={additions} deletions={deletions} />
</Pressable>
);
}
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 (
<ComposerDiffStatPill
additions={diffStat.additions}
deletions={diffStat.deletions}
onPress={onPress}
/>
);
});
@@ -0,0 +1,24 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
type DiffStat = NonNullable<WorkspaceDescriptor["diffStat"]>;
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));
}
@@ -495,7 +495,14 @@ export function DiffSurface(props: DiffSurfaceProps) {
schedulePaint(); schedulePaint();
return; 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; selectionRef.current = null;
reviewActions.onStartComment(hit.target); reviewActions.onStartComment(hit.target);
schedulePaint(); schedulePaint();
+1
View File
@@ -1729,6 +1729,7 @@ export const ar: TranslationResources = {
label: "الملفات", label: "الملفات",
subtitle: "ملفات مساحة العمل", subtitle: "ملفات مساحة العمل",
tooltip: "تصفح ملفات مساحة العمل", tooltip: "تصفح ملفات مساحة العمل",
chooseFile: "اختر ملفًا",
}, },
pullRequest: { pullRequest: {
label: "طلب السحب", label: "طلب السحب",
+1
View File
@@ -1739,6 +1739,7 @@ export const en = {
label: "Files", label: "Files",
subtitle: "Workspace files", subtitle: "Workspace files",
tooltip: "Browse workspace files", tooltip: "Browse workspace files",
chooseFile: "Choose a file",
}, },
pullRequest: { pullRequest: {
label: "Pull request", label: "Pull request",
+1
View File
@@ -1775,6 +1775,7 @@ export const es: TranslationResources = {
label: "Archivos", label: "Archivos",
subtitle: "Archivos del espacio de trabajo", subtitle: "Archivos del espacio de trabajo",
tooltip: "Explorar archivos del espacio de trabajo", tooltip: "Explorar archivos del espacio de trabajo",
chooseFile: "Elige un archivo",
}, },
pullRequest: { pullRequest: {
label: "Solicitud de extracción", label: "Solicitud de extracción",
+1
View File
@@ -1779,6 +1779,7 @@ export const fr: TranslationResources = {
label: "Fichiers", label: "Fichiers",
subtitle: "Fichiers de l’espace de travail", subtitle: "Fichiers de l’espace de travail",
tooltip: "Parcourir les fichiers de l’espace de travail", tooltip: "Parcourir les fichiers de l’espace de travail",
chooseFile: "Choisissez un fichier",
}, },
pullRequest: { pullRequest: {
label: "Demande de fusion", label: "Demande de fusion",
+1
View File
@@ -1746,6 +1746,7 @@ export const ja: TranslationResources = {
label: "ファイル", label: "ファイル",
subtitle: "ワークスペースのファイル", subtitle: "ワークスペースのファイル",
tooltip: "ワークスペースのファイルを参照", tooltip: "ワークスペースのファイルを参照",
chooseFile: "ファイルを選択",
}, },
pullRequest: { pullRequest: {
label: "プルリクエスト", label: "プルリクエスト",
+1
View File
@@ -1739,6 +1739,7 @@ export const ko: TranslationResources = {
label: "파일", label: "파일",
subtitle: "워크스페이스 파일", subtitle: "워크스페이스 파일",
tooltip: "워크스페이스 파일 탐색", tooltip: "워크스페이스 파일 탐색",
chooseFile: "파일 선택",
}, },
pullRequest: { pullRequest: {
label: "풀 리퀘스트", label: "풀 리퀘스트",
+1
View File
@@ -1761,6 +1761,7 @@ export const ptBR: TranslationResources = {
label: "Arquivos", label: "Arquivos",
subtitle: "Arquivos do espaço de trabalho", subtitle: "Arquivos do espaço de trabalho",
tooltip: "Explorar arquivos do espaço de trabalho", tooltip: "Explorar arquivos do espaço de trabalho",
chooseFile: "Escolha um arquivo",
}, },
pullRequest: { pullRequest: {
label: "Pull request", label: "Pull request",
+1
View File
@@ -1760,6 +1760,7 @@ export const ru: TranslationResources = {
label: "Файлы", label: "Файлы",
subtitle: "Файлы рабочего пространства", subtitle: "Файлы рабочего пространства",
tooltip: "Просмотр файлов рабочего пространства", tooltip: "Просмотр файлов рабочего пространства",
chooseFile: "Выберите файл",
}, },
pullRequest: { pullRequest: {
label: "PR", label: "PR",
+1
View File
@@ -1709,6 +1709,7 @@ export const zhCN: TranslationResources = {
label: "文件", label: "文件",
subtitle: "工作区文件", subtitle: "工作区文件",
tooltip: "浏览工作区文件", tooltip: "浏览工作区文件",
chooseFile: "选择文件",
}, },
pullRequest: { pullRequest: {
label: "拉取请求", label: "拉取请求",
@@ -1158,7 +1158,7 @@ describe("direct new-tab target shortcuts", () => {
const targetCases = [ const targetCases = [
["a", "KeyA", "workspace.tab.target.agent"], ["a", "KeyA", "workspace.tab.target.agent"],
["b", "KeyB", "workspace.tab.target.browser"], ["b", "KeyB", "workspace.tab.target.browser"],
["c", "KeyC", "workspace.tab.target.changes"], ["g", "KeyG", "workspace.tab.target.changes"],
["e", "KeyE", "workspace.tab.target.files"], ["e", "KeyE", "workspace.tab.target.files"],
] as const; ] as const;
@@ -1192,9 +1192,9 @@ describe("direct new-tab target shortcuts", () => {
it("uses the existing override map for target matching and display", () => { it("uses the existing override map for target matching and display", () => {
const bindingId = "workspace-tab-target-agent-ctrl-shift-a-non-mac"; 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({ 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" }, context: { ...desktopNonMac, focusScope: "other" },
bindings: buildEffectiveBindings(overrides), bindings: buildEffectiveBindings(overrides),
}); });
@@ -1208,6 +1208,6 @@ describe("direct new-tab target shortcuts", () => {
expect(original.match).toBeNull(); expect(original.match).toBeNull();
expect( expect(
resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, desktopNonMac), resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, desktopNonMac),
).toEqual([["ctrl", "shift", "G"]]); ).toEqual([["ctrl", "shift", "H"]]);
}); });
}); });
@@ -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", id: "workspace-tab-target-changes-cmd-shift-c-mac",
action: "workspace.tab.target.changes", action: "workspace.tab.target.changes",
combo: "Cmd+Shift+C", combo: "Cmd+Shift+G",
when: { mac: true, commandCenter: false }, when: { mac: true, commandCenter: false },
help: { help: {
id: "workspace-tab-target-changes", 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", id: "workspace-tab-target-changes-ctrl-shift-c-non-mac",
action: "workspace.tab.target.changes", action: "workspace.tab.target.changes",
combo: "Ctrl+Shift+C", combo: "Ctrl+Shift+G",
when: { mac: false, commandCenter: false, terminal: false }, when: { mac: false, commandCenter: false, terminal: false },
help: { help: {
id: "workspace-tab-target-changes", id: "workspace-tab-target-changes",
+26 -9
View File
@@ -25,6 +25,7 @@ import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel"; import { useRetainedPanelActive } from "@/components/retained-panel";
import { Composer } from "@/composer"; import { Composer } from "@/composer";
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
import { resolveComposerTrackTailClearance } from "@/composer/pill-styles"; import { resolveComposerTrackTailClearance } from "@/composer/pill-styles";
import { getActiveMessageSubmissions } from "@/composer/submission/model"; import { getActiveMessageSubmissions } from "@/composer/submission/model";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
@@ -748,6 +749,7 @@ function AgentPanelBody({
return ( return (
<ChatAgentContent <ChatAgentContent
serverId={serverId} serverId={serverId}
workspaceId={workspaceId}
agentId={agentId} agentId={agentId}
isPaneFocused={isPaneFocused} isPaneFocused={isPaneFocused}
client={client} client={client}
@@ -760,6 +762,7 @@ function AgentPanelBody({
function ChatAgentContent({ function ChatAgentContent({
serverId, serverId,
workspaceId,
agentId, agentId,
isPaneFocused, isPaneFocused,
client, client,
@@ -768,6 +771,7 @@ function ChatAgentContent({
onOpenWorkspaceFile, onOpenWorkspaceFile,
}: { }: {
serverId: string; serverId: string;
workspaceId: string;
agentId?: string; agentId?: string;
isPaneFocused: boolean; isPaneFocused: boolean;
client: ReturnType<typeof useHostRuntimeClient>; client: ReturnType<typeof useHostRuntimeClient>;
@@ -1164,6 +1168,7 @@ function ChatAgentContent({
return ( return (
<ChatAgentReadyContent <ChatAgentReadyContent
serverId={serverId} serverId={serverId}
workspaceId={workspaceId}
agentId={agentId} agentId={agentId}
isPaneFocused={isPaneFocused} isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent} isArchivingCurrentAgent={isArchivingCurrentAgent}
@@ -1192,6 +1197,7 @@ function ChatAgentContent({
const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
serverId, serverId,
workspaceId,
agentId, agentId,
isPaneFocused, isPaneFocused,
isArchivingCurrentAgent, isArchivingCurrentAgent,
@@ -1216,6 +1222,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile, onOpenWorkspaceFile,
}: { }: {
serverId: string; serverId: string;
workspaceId: string;
agentId: string; agentId: string;
isPaneFocused: boolean; isPaneFocused: boolean;
isArchivingCurrentAgent: boolean; isArchivingCurrentAgent: boolean;
@@ -1240,8 +1247,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isCompactFormFactor = useIsCompactFormFactor();
const composerTrackTailClearance = resolveComposerTrackTailClearance(isCompactFormFactor);
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId }); const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
const tasks = useSessionStore((state): TodoEntry[] | undefined => const tasks = useSessionStore((state): TodoEntry[] | undefined =>
state.sessions[serverId]?.agentTasks.get(agentId), state.sessions[serverId]?.agentTasks.get(agentId),
@@ -1252,9 +1257,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
rows: subagentRows, rows: subagentRows,
}); });
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent; const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
const showAgentTracks = const hasVisibleAgentTracks = hasAgentTracks({
hasActiveComposer &&
hasAgentTracks({
subagentRows, subagentRows,
tasks, tasks,
archiveFinishedStatus: archiveFinishedSubagents.status, archiveFinishedStatus: archiveFinishedSubagents.status,
@@ -1329,18 +1332,21 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
<AgentStreamSection <AgentStreamSection
streamViewRef={streamViewRef} streamViewRef={streamViewRef}
serverId={serverId} serverId={serverId}
workspaceId={workspaceId}
agentId={agentId} agentId={agentId}
agent={effectiveAgent} agent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest} routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
bottomOverlayTailClearance={showAgentTracks ? composerTrackTailClearance : 0} hasActiveComposer={hasActiveComposer}
hasVisibleAgentTracks={hasVisibleAgentTracks}
toast={toastApi} toast={toastApi}
onOpenWorkspaceFile={onOpenWorkspaceFile} onOpenWorkspaceFile={onOpenWorkspaceFile}
/> />
</RenderProfile> </RenderProfile>
{showAgentTracks ? ( {hasActiveComposer ? (
<AgentTracks <AgentTracks
serverId={serverId} serverId={serverId}
workspaceId={workspaceId}
subagentRows={subagentRows} subagentRows={subagentRows}
tasks={tasks} tasks={tasks}
archiveFinishedStatus={archiveFinishedSubagents.status} archiveFinishedStatus={archiveFinishedSubagents.status}
@@ -1409,24 +1415,35 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const AgentStreamSection = memo(function AgentStreamSection({ const AgentStreamSection = memo(function AgentStreamSection({
streamViewRef, streamViewRef,
serverId, serverId,
workspaceId,
agentId, agentId,
agent, agent,
routeBottomAnchorRequest, routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory, hasAppliedAuthoritativeHistory,
bottomOverlayTailClearance, hasActiveComposer,
hasVisibleAgentTracks,
toast, toast,
onOpenWorkspaceFile, onOpenWorkspaceFile,
}: { }: {
streamViewRef: React.RefObject<AgentStreamViewHandle | null>; streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
serverId: string; serverId: string;
workspaceId: string;
agentId?: string; agentId?: string;
agent: AgentScreenAgent; agent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest; routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean; hasAppliedAuthoritativeHistory: boolean;
bottomOverlayTailClearance: number; hasActiveComposer: boolean;
hasVisibleAgentTracks: boolean;
toast: ReturnType<typeof useToastHost>["api"]; toast: ReturnType<typeof useToastHost>["api"];
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; 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) => const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
); );
+27 -6
View File
@@ -1,4 +1,6 @@
import { memo, useCallback, type ReactElement } from "react"; 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 { AgentTaskList } from "@/composer/task-list";
import { ComposerTrackBar } from "@/composer/tracks"; import { ComposerTrackBar } from "@/composer/tracks";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
@@ -18,26 +20,29 @@ import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openSupportingTab } from "@/workspace-tabs/side-panel"; 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 * 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 * transcript. Each pill owns its action while tab placement stays behind the workspace boundary.
* the agent's stream.
*/ */
export const AgentTracks = memo(function AgentTracks({ export const AgentTracks = memo(function AgentTracks({
serverId, serverId,
workspaceId,
subagentRows, subagentRows,
tasks, tasks,
archiveFinishedStatus, archiveFinishedStatus,
onArchiveFinished, onArchiveFinished,
}: { }: {
serverId: string; serverId: string;
workspaceId: string;
subagentRows: SubagentRow[]; subagentRows: SubagentRow[];
tasks: TodoEntry[] | undefined; tasks: TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus; archiveFinishedStatus: ArchiveFinishedStatus;
onArchiveFinished: () => void; onArchiveFinished: () => void;
}): ReactElement | null { }): ReactElement | null {
const { workspaceId, tabId, openTab } = usePaneContext(); const { tabId, openTab } = usePaneContext();
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const canSplit = supportsDesktopPaneSplits() && !isCompact; const canSplit = supportsDesktopPaneSplits() && !isCompact;
const openInSidePanelByDefault = useSettings( const openInSidePanelByDefault = useSettings(
@@ -87,13 +92,25 @@ export const AgentTracks = memo(function AgentTracks({
}, },
[canSplit, isCompact, openInSidePanelByDefault, openTab, tabId, workspaceKey], [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 null;
} }
return ( return (
<ComposerTrackBar> <ComposerTrackBar>
<AgentTaskList tasks={tasks} />
<SubagentsTrack <SubagentsTrack
rows={subagentRows} rows={subagentRows}
onOpenSubagent={handleOpenSubagent} onOpenSubagent={handleOpenSubagent}
@@ -103,7 +120,11 @@ export const AgentTracks = memo(function AgentTracks({
archiveFinishedStatus={archiveFinishedStatus} archiveFinishedStatus={archiveFinishedStatus}
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined} onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
/> />
<AgentTaskList tasks={tasks} /> <WorkspaceDiffStatPill
serverId={serverId}
workspaceId={workspaceId}
onPress={handleOpenChanges}
/>
</ComposerTrackBar> </ComposerTrackBar>
); );
}); });
+35 -8
View File
@@ -1,6 +1,6 @@
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { FolderTree } from "lucide-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 { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
@@ -9,14 +9,11 @@ import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry"; import type { PanelRegistration } from "@/panels/panel-registry";
import { useAddFileToChat } from "@/panels/use-add-file-to-chat"; import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { TreeRail } from "@/components/tree-rail";
import { useIsCompactFormFactor } from "@/constants/layout";
const ThemedFolderTree = withUnistyles(FolderTree); const ThemedFolderTree = withUnistyles(FolderTree);
const CENTERED_PADDED_STYLE = { const FILE_TREE_WIDTH = 220;
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 16,
} as const;
function useFilesPanelDescriptor() { function useFilesPanelDescriptor() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -35,6 +32,7 @@ function FilesPanel() {
const { serverId, workspaceId, target, retargetCurrentTab } = usePaneContext(); const { serverId, workspaceId, target, retargetCurrentTab } = usePaneContext();
const workspaceRoot = useWorkspaceDirectory(serverId, workspaceId); const workspaceRoot = useWorkspaceDirectory(serverId, workspaceId);
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
const isCompact = useIsCompactFormFactor();
invariant(target.kind === "files", "FilesPanel requires files target"); invariant(target.kind === "files", "FilesPanel requires files target");
const onOpenFile = useCallback( const onOpenFile = useCallback(
(path: string) => retargetCurrentTab({ kind: "file", path }), (path: string) => retargetCurrentTab({ kind: "file", path }),
@@ -42,11 +40,12 @@ function FilesPanel() {
); );
if (!workspaceRoot) { if (!workspaceRoot) {
return ( return (
<View style={CENTERED_PADDED_STYLE}> <View style={styles.centerState}>
<Text>{t("panels.file.directoryMissing")}</Text> <Text>{t("panels.file.directoryMissing")}</Text>
</View> </View>
); );
} }
if (isCompact) {
return ( return (
<FileExplorerPane <FileExplorerPane
serverId={serverId} serverId={serverId}
@@ -57,6 +56,21 @@ function FilesPanel() {
/> />
); );
} }
return (
<TreeRail testID="files-tree-rail" width={FILE_TREE_WIDTH}>
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.files.chooseFile")}</Text>
</View>
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
</TreeRail>
);
}
export const filesPanelRegistration: PanelRegistration<"files"> = { export const filesPanelRegistration: PanelRegistration<"files"> = {
kind: "files", kind: "files",
@@ -64,3 +78,16 @@ export const filesPanelRegistration: PanelRegistration<"files"> = {
component: FilesPanel, component: FilesPanel,
useDescriptor: useFilesPanelDescriptor, 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,
},
}));
+2 -6
View File
@@ -20,7 +20,6 @@ import {
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { useWorkspaceFocusRestoration } from "@/workspace/focus";
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store"; import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout"; import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
import { import {
@@ -441,7 +440,6 @@ export function InlineReviewEditor({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const inputRef = useRef<EditingTextInputHandle | null>(null); const inputRef = useRef<EditingTextInputHandle | null>(null);
const focus = useWorkspaceFocusRestoration();
const [body, setBody] = useState(initialBody); const [body, setBody] = useState(initialBody);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const trimmedBody = body.trim(); const trimmedBody = body.trim();
@@ -452,13 +450,11 @@ export function InlineReviewEditor({
}, []); }, []);
const handleFocus = useCallback(() => { const handleFocus = useCallback(() => {
focus.unfocus();
setIsFocused(true); setIsFocused(true);
}, [focus]); }, []);
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setIsFocused(false); setIsFocused(false);
focus.restore(); }, []);
}, [focus]);
const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]); const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]);
useEffect(() => { useEffect(() => {
@@ -106,8 +106,10 @@ import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useSettings } from "@/hooks/use-settings";
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id"; import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { openSupportingTab } from "@/workspace-tabs/side-panel";
const DROPDOWN_WIDTH = 220; const DROPDOWN_WIDTH = 220;
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36; const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
@@ -1169,6 +1171,10 @@ function ResolvedWorkspaceDesktopTabsRow({
}: ResolvedWorkspaceDesktopTabsRowProps) { }: ResolvedWorkspaceDesktopTabsRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const isCompact = useIsCompactFormFactor();
const openInSidePanelByDefault = useSettings(
(settings) => settings.openSupportingTabsInSidePanel,
);
const newTabKeys = useShortcutKeys("workspace-tab-new"); const newTabKeys = useShortcutKeys("workspace-tab-new");
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0); const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0); const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
@@ -1450,6 +1456,14 @@ function ResolvedWorkspaceDesktopTabsRow({
[openNewTab, paneId, workspaceKey], [openNewTab, paneId, workspaceKey],
); );
const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]); 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 handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]);
const handleOpenPullRequest = useCallback( const handleOpenPullRequest = useCallback(
() => openPanelTarget(PULL_REQUEST_TARGET), () => openPanelTarget(PULL_REQUEST_TARGET),
@@ -1484,7 +1498,7 @@ function ResolvedWorkspaceDesktopTabsRow({
case "workspace.tab.target.changes": case "workspace.tab.target.changes":
if (!isGit) return true; if (!isGit) return true;
setNewTabMenuOpen(false); setNewTabMenuOpen(false);
handleOpenChanges(); handleOpenChangesShortcut();
return true; return true;
case "workspace.tab.target.files": case "workspace.tab.target.files":
setNewTabMenuOpen(false); setNewTabMenuOpen(false);
@@ -1497,7 +1511,7 @@ function ResolvedWorkspaceDesktopTabsRow({
[ [
handleCreateAgentTab, handleCreateAgentTab,
handleCreateBrowser, handleCreateBrowser,
handleOpenChanges, handleOpenChangesShortcut,
handleOpenFiles, handleOpenFiles,
isFocused, isFocused,
isGit, isGit,
@@ -122,13 +122,6 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
<View style={styles.container} testID="workspace-pane-empty-state"> <View style={styles.container} testID="workspace-pane-empty-state">
<View style={styles.rail}> <View style={styles.rail}>
<View style={styles.stack}> <View style={styles.stack}>
<LauncherRow
label={t("workspace.tabs.actions.files")}
icon={FILES_ICON}
onPress={openFiles}
>
<LauncherShortcut actionId="workspace-tab-target-files" />
</LauncherRow>
{showChanges ? ( {showChanges ? (
<LauncherRow <LauncherRow
label={t("workspace.tabs.actions.changes")} label={t("workspace.tabs.actions.changes")}
@@ -138,6 +131,13 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
<LauncherShortcut actionId="workspace-tab-target-changes" /> <LauncherShortcut actionId="workspace-tab-target-changes" />
</LauncherRow> </LauncherRow>
) : null} ) : null}
<LauncherRow
label={t("workspace.tabs.actions.files")}
icon={FILES_ICON}
onPress={openFiles}
>
<LauncherShortcut actionId="workspace-tab-target-files" />
</LauncherRow>
{showPullRequest ? ( {showPullRequest ? (
<LauncherRow <LauncherRow
label={t("workspace.tabs.actions.pullRequest")} label={t("workspace.tabs.actions.pullRequest")}
@@ -146,14 +146,14 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
/> />
) : null} ) : null}
<LauncherRow <LauncherRow
label={t("workspace.tabs.actions.newAgent")} label={t("workspace.tabs.fallback.agent")}
icon={AGENT_ICON} icon={AGENT_ICON}
onPress={createAgent} onPress={createAgent}
> >
<LauncherShortcut actionId="workspace-tab-target-agent" /> <LauncherShortcut actionId="workspace-tab-target-agent" />
</LauncherRow> </LauncherRow>
<LauncherRow <LauncherRow
label={t("workspace.tabs.actions.newTerminal")} label={t("workspace.tabs.fallback.terminal")}
icon={TERMINAL_ICON} icon={TERMINAL_ICON}
onPress={createTerminal} onPress={createTerminal}
> >