Make the workspace right pane a first-class tab host (#3287)

* feat(app): add persistent workspace side panes

Keep the common review and file-browsing workflow beside the active agent without tearing down pane contents. Workspace tabs now share one compact chip language, assistant file links route into the retained right pane, and diff/file trees share stable geometry.

* fix(app): localize pull request empty state

* test(app): update explorer pane coverage

* fix(app): preserve explorer pane workflows

* test(app): make explorer tab selection idempotent

* fix(app): persist workspace review pins

* fix(app): preserve sidebar scrim drag hook

* fix(app): restore companion pane interactions

* test(app): follow selected sidebar surface
This commit is contained in:
Mohamed Boudra
2026-08-16 18:18:32 +02:00
committed by GitHub
parent 6dcf6a4b08
commit ab274d635e
81 changed files with 2900 additions and 1051 deletions
+28 -188
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState, type ComponentProps, type ReactNode } from "react";
import { useCallback, useMemo, type ReactNode } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { FileDiff, GitCommitHorizontal } from "lucide-react-native";
@@ -7,25 +7,13 @@ import invariant from "tiny-invariant";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import {
DiffFilesToolbar,
DiffLayoutToggle,
DiffModeMenu,
DiffOptionsMenu,
resolveDiffLayout,
SharedDiffView,
} from "@/git/diff-pane";
import { DiffTooLargeState } from "@/git/diff-too-large-state";
import { DiffLayoutToggle, GitDiffPane, resolveDiffLayout, SharedDiffView } from "@/git/diff-pane";
import { useCommitDiffFiles } from "@/git/use-diff-files";
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
@@ -56,6 +44,9 @@ function useDiffPanelPreferences() {
const toggleHideWhitespace = useCallback(() => {
void updatePreferences({ hideWhitespace: !preferences.hideWhitespace });
}, [preferences.hideWhitespace, updatePreferences]);
const toggleViewMode = useCallback(() => {
void updatePreferences({ viewMode: preferences.viewMode === "flat" ? "tree" : "flat" });
}, [preferences.viewMode, updatePreferences]);
return {
preferences,
@@ -65,6 +56,7 @@ function useDiffPanelPreferences() {
toggleLayout,
toggleWrapLines,
toggleHideWhitespace,
toggleViewMode,
};
}
@@ -84,186 +76,34 @@ function PanelState({
);
}
function WorkingDiffBody({
cwd,
isConnected,
workingDiff,
hideWhitespace,
displayPreferences,
mode,
}: {
cwd: string | null | undefined;
isConnected: boolean;
workingDiff: ReturnType<typeof useWorkingDiff>;
hideWhitespace: boolean;
displayPreferences: ReturnType<typeof useDiffPanelPreferences>["displayPreferences"];
mode: Extract<ComponentProps<typeof SharedDiffView>["mode"], { kind: "working_tab" }>;
}) {
function WorkingDiffPanel() {
const { t } = useTranslation();
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const isActive = useRetainedPanelActive();
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
invariant(target.kind === "working_diff", "WorkingDiffPanel requires working_diff target");
const handleOpenFile = useCallback(
(path: string) => openFileInWorkspace({ location: { path }, disposition: "side" }),
[openFileInWorkspace],
);
if (!cwd) {
return <PanelState message={t("panels.diff.directoryMissing")} />;
}
if (!isConnected) {
return <PanelState message={t("workspace.terminal.hostDisconnected")} />;
}
if (workingDiff.isStatusLoading) {
return <PanelState message={t("workspace.git.diff.checkingRepository")} />;
}
if (workingDiff.statusErrorMessage) {
return (
<PanelState
message={workingDiff.statusErrorMessage}
tone="error"
testID="working-diff-error"
/>
);
}
if (workingDiff.notGit) {
return <PanelState message={t("workspace.git.diff.notRepository")} />;
}
if (workingDiff.diffTooLarge) {
return <DiffTooLargeState />;
}
if (workingDiff.diffPayloadError) {
return (
<PanelState message={t("panels.diff.loadError")} tone="error" testID="working-diff-error" />
);
}
if (workingDiff.isDiffLoading && workingDiff.files.length === 0) {
return <PanelState message={t("workspace.tabs.loading")} testID="working-diff-loading" />;
}
if (workingDiff.files.length === 0) {
return (
<PanelState
message={
hideWhitespace ? t("workspace.git.diff.emptyHiddenWhitespace") : t("panels.diff.empty")
}
testID="working-diff-empty"
/>
);
}
return (
<SharedDiffView files={workingDiff.files} displayPreferences={displayPreferences} mode={mode} />
);
}
function WorkingDiffPanel() {
const { t } = useTranslation();
const toast = useToast();
const { serverId, workspaceId, tabId, target } = usePaneContext();
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isActive = useRetainedPanelActive();
const panelPreferences = useDiffPanelPreferences();
const [expandedPaths, setExpandedPaths] = useState<string[] | null>(null);
invariant(target.kind === "working_diff", "WorkingDiffPanel requires working_diff target");
const workingDiff = useWorkingDiff({
serverId,
workspaceId,
cwd: cwd ?? "",
ignoreWhitespace: panelPreferences.preferences.hideWhitespace,
enabled: Boolean(cwd) && isActive,
queryScope: `working-diff-tab:${tabId}`,
});
usePublishWorkingDiffAttachment({
serverId,
workspaceId,
cwd: cwd ?? "",
attachment: workingDiff.reviewAttachment,
enabled: Boolean(cwd) && isActive,
});
const refreshSupported = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
);
const runRefresh = useCheckoutGitActionsStore((state) => state.refresh);
const isRefreshing =
useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd: cwd ?? "", actionId: "refresh" }),
) === "pending";
const refresh = useCallback(() => {
if (!cwd || isRefreshing) {
return;
}
void runRefresh({ serverId, cwd }).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
});
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]);
const expandedPathSet = useMemo(
() => (expandedPaths === null ? null : new Set(expandedPaths)),
[expandedPaths],
);
const allFilesExpanded =
workingDiff.files.length > 0 &&
(expandedPathSet === null || workingDiff.files.every((file) => expandedPathSet.has(file.path)));
const toggleExpandAll = useCallback(() => {
setExpandedPaths(allFilesExpanded ? [] : null);
}, [allFilesExpanded]);
const mode = useMemo(
() => ({
kind: "working_tab" as const,
expandedPaths,
reviewActions: workingDiff.reviewActions,
focusPath: target.focusPath,
focusRequestId: target.focusRequestId,
onExpandedPathsChange: setExpandedPaths,
}),
[expandedPaths, target.focusPath, target.focusRequestId, workingDiff.reviewActions],
);
const baseRefLabel = workingDiff.baseRef?.replace(/^refs\/(heads|remotes)\//, "") ?? "";
return (
<View style={styles.container} testID="working-diff-panel">
<View style={styles.toolbar}>
<DiffModeMenu
diffMode={workingDiff.diffMode}
committedDescription={baseRefLabel || undefined}
testIDPrefix="working-diff"
onSelectUncommitted={workingDiff.selectUncommitted}
onSelectBase={workingDiff.selectBase}
/>
<View style={styles.toolbarActions} testID="working-diff-toolbar">
{panelPreferences.canUseSplitLayout ? (
<DiffLayoutToggle
layout={panelPreferences.preferences.layout}
isMobile={panelPreferences.isCompact}
testID="working-diff-toggle-layout"
onToggle={panelPreferences.toggleLayout}
/>
) : null}
{workingDiff.files.length > 0 ? (
<DiffFilesToolbar
allFileDiffsExpanded={allFilesExpanded}
isMobile={panelPreferences.isCompact}
testID="working-diff-toggle-expand-all"
onToggleExpandAll={toggleExpandAll}
/>
) : null}
<DiffOptionsMenu
hideWhitespace={panelPreferences.preferences.hideWhitespace}
isMobile={panelPreferences.isCompact}
isRefreshing={isRefreshing}
refreshSupported={refreshSupported}
testIDPrefix="working-diff"
wrapLines={panelPreferences.preferences.wrapLines}
onRefresh={refresh}
onToggleHideWhitespace={panelPreferences.toggleHideWhitespace}
onToggleWrapLines={panelPreferences.toggleWrapLines}
/>
</View>
</View>
<View style={styles.body}>
<WorkingDiffBody
cwd={cwd}
isConnected={isConnected}
workingDiff={workingDiff}
hideWhitespace={panelPreferences.preferences.hideWhitespace}
displayPreferences={panelPreferences.displayPreferences}
mode={mode}
/>
</View>
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
cwd={cwd}
enabled={isActive}
host="panel"
onOpenFile={handleOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
</View>
);
}