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
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
+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);
});
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();
}
@@ -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;
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 () => {
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);
@@ -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();
+15 -1
View File
@@ -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");
});
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
export interface MockAgentOptions {
repoPrefix: string;
title: string;
repo?: Parameters<typeof seedWorkspace>[0]["repo"];
port?: number;
initialPrompt?: string;
model?: string;
@@ -29,7 +30,11 @@ export interface MockAgentOptions {
export async function seedMockAgentWorkspace(
options: MockAgentOptions,
): 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 {
const agent = await workspace.client.createAgent({
provider: "mock",
@@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor {
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
diffStat: { additions: number; deletions: number } | null;
labels?: string[];
}
@@ -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,
@@ -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();
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();
+1
View File
@@ -1729,6 +1729,7 @@ export const ar: TranslationResources = {
label: "الملفات",
subtitle: "ملفات مساحة العمل",
tooltip: "تصفح ملفات مساحة العمل",
chooseFile: "اختر ملفًا",
},
pullRequest: {
label: "طلب السحب",
+1
View File
@@ -1739,6 +1739,7 @@ export const en = {
label: "Files",
subtitle: "Workspace files",
tooltip: "Browse workspace files",
chooseFile: "Choose a file",
},
pullRequest: {
label: "Pull request",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1746,6 +1746,7 @@ export const ja: TranslationResources = {
label: "ファイル",
subtitle: "ワークスペースのファイル",
tooltip: "ワークスペースのファイルを参照",
chooseFile: "ファイルを選択",
},
pullRequest: {
label: "プルリクエスト",
+1
View File
@@ -1739,6 +1739,7 @@ export const ko: TranslationResources = {
label: "파일",
subtitle: "워크스페이스 파일",
tooltip: "워크스페이스 파일 탐색",
chooseFile: "파일 선택",
},
pullRequest: {
label: "풀 리퀘스트",
+1
View File
@@ -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",
+1
View File
@@ -1760,6 +1760,7 @@ export const ru: TranslationResources = {
label: "Файлы",
subtitle: "Файлы рабочего пространства",
tooltip: "Просмотр файлов рабочего пространства",
chooseFile: "Выберите файл",
},
pullRequest: {
label: "PR",
+1
View File
@@ -1709,6 +1709,7 @@ export const zhCN: TranslationResources = {
label: "文件",
subtitle: "工作区文件",
tooltip: "浏览工作区文件",
chooseFile: "选择文件",
},
pullRequest: {
label: "拉取请求",
@@ -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"]]);
});
});
@@ -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",
+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 { 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 (
<ChatAgentContent
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
isPaneFocused={isPaneFocused}
client={client}
@@ -760,6 +762,7 @@ function AgentPanelBody({
function ChatAgentContent({
serverId,
workspaceId,
agentId,
isPaneFocused,
client,
@@ -768,6 +771,7 @@ function ChatAgentContent({
onOpenWorkspaceFile,
}: {
serverId: string;
workspaceId: string;
agentId?: string;
isPaneFocused: boolean;
client: ReturnType<typeof useHostRuntimeClient>;
@@ -1164,6 +1168,7 @@ function ChatAgentContent({
return (
<ChatAgentReadyContent
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
@@ -1192,6 +1197,7 @@ function ChatAgentContent({
const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
serverId,
workspaceId,
agentId,
isPaneFocused,
isArchivingCurrentAgent,
@@ -1216,6 +1222,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile,
}: {
serverId: string;
workspaceId: string;
agentId: string;
isPaneFocused: boolean;
isArchivingCurrentAgent: boolean;
@@ -1240,8 +1247,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => 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,9 +1257,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
rows: subagentRows,
});
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
const showAgentTracks =
hasActiveComposer &&
hasAgentTracks({
const hasVisibleAgentTracks = hasAgentTracks({
subagentRows,
tasks,
archiveFinishedStatus: archiveFinishedSubagents.status,
@@ -1329,18 +1332,21 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
agent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
bottomOverlayTailClearance={showAgentTracks ? composerTrackTailClearance : 0}
hasActiveComposer={hasActiveComposer}
hasVisibleAgentTracks={hasVisibleAgentTracks}
toast={toastApi}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</RenderProfile>
{showAgentTracks ? (
{hasActiveComposer ? (
<AgentTracks
serverId={serverId}
workspaceId={workspaceId}
subagentRows={subagentRows}
tasks={tasks}
archiveFinishedStatus={archiveFinishedSubagents.status}
@@ -1409,24 +1415,35 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const AgentStreamSection = memo(function AgentStreamSection({
streamViewRef,
serverId,
workspaceId,
agentId,
agent,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
bottomOverlayTailClearance,
hasActiveComposer,
hasVisibleAgentTracks,
toast,
onOpenWorkspaceFile,
}: {
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
serverId: string;
workspaceId: string;
agentId?: string;
agent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
bottomOverlayTailClearance: number;
hasActiveComposer: boolean;
hasVisibleAgentTracks: boolean;
toast: ReturnType<typeof useToastHost>["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,
);
+27 -6
View File
@@ -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 (
<ComposerTrackBar>
<AgentTaskList tasks={tasks} />
<SubagentsTrack
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
@@ -103,7 +120,11 @@ export const AgentTracks = memo(function AgentTracks({
archiveFinishedStatus={archiveFinishedStatus}
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
/>
<AgentTaskList tasks={tasks} />
<WorkspaceDiffStatPill
serverId={serverId}
workspaceId={workspaceId}
onPress={handleOpenChanges}
/>
</ComposerTrackBar>
);
});
+35 -8
View File
@@ -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,11 +40,12 @@ function FilesPanel() {
);
if (!workspaceRoot) {
return (
<View style={CENTERED_PADDED_STYLE}>
<View style={styles.centerState}>
<Text>{t("panels.file.directoryMissing")}</Text>
</View>
);
}
if (isCompact) {
return (
<FileExplorerPane
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"> = {
kind: "files",
@@ -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,
},
}));
+2 -6
View File
@@ -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<EditingTextInputHandle | null>(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(() => {
@@ -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<number>(0);
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(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,
@@ -122,13 +122,6 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
<View style={styles.container} testID="workspace-pane-empty-state">
<View style={styles.rail}>
<View style={styles.stack}>
<LauncherRow
label={t("workspace.tabs.actions.files")}
icon={FILES_ICON}
onPress={openFiles}
>
<LauncherShortcut actionId="workspace-tab-target-files" />
</LauncherRow>
{showChanges ? (
<LauncherRow
label={t("workspace.tabs.actions.changes")}
@@ -138,6 +131,13 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
<LauncherShortcut actionId="workspace-tab-target-changes" />
</LauncherRow>
) : null}
<LauncherRow
label={t("workspace.tabs.actions.files")}
icon={FILES_ICON}
onPress={openFiles}
>
<LauncherShortcut actionId="workspace-tab-target-files" />
</LauncherRow>
{showPullRequest ? (
<LauncherRow
label={t("workspace.tabs.actions.pullRequest")}
@@ -146,14 +146,14 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
/>
) : null}
<LauncherRow
label={t("workspace.tabs.actions.newAgent")}
label={t("workspace.tabs.fallback.agent")}
icon={AGENT_ICON}
onPress={createAgent}
>
<LauncherShortcut actionId="workspace-tab-target-agent" />
</LauncherRow>
<LauncherRow
label={t("workspace.tabs.actions.newTerminal")}
label={t("workspace.tabs.fallback.terminal")}
icon={TERMINAL_ICON}
onPress={createTerminal}
>