feat: open Changes as a workspace tab (#2298)

Open the complete working comparison in a desktop workspace tab while preserving inline sidebar diffs when the tab is closed.

Working and commit comparisons share one diff panel, and workspace layout is the sole owner of live tab persistence and identity.

Refs #1520
This commit is contained in:
nikuscs
2026-07-22 20:57:18 +02:00
committed by GitHub
parent 76a5edb020
commit 512c9b31a8
61 changed files with 1581 additions and 2012 deletions
+6 -12
View File
@@ -28,26 +28,20 @@ test("commit history shows dates and shares diff layout preferences", async ({
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true }); const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 }); await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute( const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
"aria-selected", await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 }); await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
await panel.getByTestId("commit-diff-layout-split").click(); await layoutToggle.click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute( await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0); await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible(); await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click(); await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0); await expect(panel).toHaveCount(0);
await commitRow.click(); await commitRow.click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute( await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
"aria-selected", "Switch to unified diff",
"true",
{ timeout: 30_000 }, { timeout: 30_000 },
); );
+81 -1
View File
@@ -10,6 +10,7 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
interface DirtyWorkspace { interface DirtyWorkspace {
id: string; id: string;
repoPath: string;
} }
interface WorkspaceFixtureOptions { interface WorkspaceFixtureOptions {
@@ -241,6 +242,80 @@ test("changes file actions open from the kebab and right-click", async ({ page }
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible(); await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
}); });
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const changesTabToggle = page.getByTestId("changes-open-tab");
await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab");
await changesTabToggle.click();
await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab");
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await workingDiffLayoutToggle.click();
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff");
await visiblePanel.getByTestId("working-diff-options-menu").click();
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace");
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines");
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh");
await page.getByTestId("working-diff-toggle-wrap-lines").click();
await visiblePanel.getByTestId("working-diff-options-menu").click();
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText(
"Scroll long lines",
);
await page.keyboard.press("Escape");
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect(visiblePanel).toContainText("zz-deleted.ts");
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText(
"zz-deleted.ts",
);
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("Deleted");
await changesTabToggle.click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
});
test("changes diff switches between flat and tree file lists", async ({ page }) => { test("changes diff switches between flat and tree file lists", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff(); const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page); await useUnwrappedDiffLines(page);
@@ -266,6 +341,11 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expect(page.getByTestId("diff-folder-src")).toBeVisible(); await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible(); await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByRole("button", { name: "Collapse all" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByRole("button", { name: "Expand all" }).click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click(); await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0); await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -495,7 +575,7 @@ async function createWorkspaceWithMountedTabDiff(
if (!createdWorkspace.workspace) { if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`); throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
} }
return { id: createdWorkspace.workspace.id }; return { id: createdWorkspace.workspace.id, repoPath: repo.path };
} }
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> { async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
+1 -1
View File
@@ -99,7 +99,7 @@ import {
useWorkspaceAttachmentsStore, useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store"; } from "@/attachments/workspace-attachments-store";
import type { WorkspaceComposerAttachment } from "@/attachments/types"; import type { WorkspaceComposerAttachment } from "@/attachments/types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
@@ -9,7 +9,7 @@ import {
useActiveWorkspaceSelection, useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store"; } from "@/stores/navigation-active-workspace-store";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks"; import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { import {
@@ -4,10 +4,8 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware";
import { mountBrowserAutomationHandler } from "./handler"; import { mountBrowserAutomationHandler } from "./handler";
import type { DesktopHostBridge } from "@/desktop/host"; import type { DesktopHostBridge } from "@/desktop/host";
import { useBrowserStore } from "@/stores/browser-store"; import { useBrowserStore } from "@/stores/browser-store";
import { import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
buildWorkspaceTabPersistenceKey, import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract< type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage, SessionOutboundMessage,
@@ -6,11 +6,8 @@ import {
resizeResidentBrowserWebview, resizeResidentBrowserWebview,
} from "@/components/browser-webview-resident"; } from "@/components/browser-webview-resident";
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store"; import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
import { import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
buildWorkspaceTabPersistenceKey, import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
collectAllTabs,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract< type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage, SessionOutboundMessage,
@@ -1,5 +1,5 @@
import type { Agent } from "@/stores/session-store"; import type { Agent } from "@/stores/session-store";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store"; import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft"; export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
export type ClientSlashCommandExecution = "immediate" | "insert"; export type ClientSlashCommandExecution = "immediate" | "insert";
@@ -1,4 +1,4 @@
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
@@ -36,10 +36,8 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout"; import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
import { import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
buildWorkspaceTabPersistenceKey, import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target"; import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
import { useDraftStore } from "@/stores/draft-store"; import { useDraftStore } from "@/stores/draft-store";
@@ -76,7 +76,7 @@ import {
type SplitPane, type SplitPane,
type WorkspaceLayout, type WorkspaceLayout,
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab } from "@/workspace-tabs/model";
import { RenderProfile } from "@/utils/render-profiler"; import { RenderProfile } from "@/utils/render-profiler";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
@@ -50,7 +50,7 @@ import {
useIsCompactFormFactor, useIsCompactFormFactor,
} from "@/constants/layout"; } from "@/constants/layout";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store"; import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSIONS = new Map();
const EMPTY_ONLINE_SERVER_IDS: string[] = []; const EMPTY_ONLINE_SERVER_IDS: string[] = [];
@@ -3,7 +3,7 @@ import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import { resolveFocusedChatTarget } from "./focused-chat-target"; import { resolveFocusedChatTarget } from "./focused-chat-target";
function layoutWithTarget( function layoutWithTarget(
target: import("@/stores/workspace-tabs-store").WorkspaceTab["target"], target: import("@/workspace-tabs/model").WorkspaceTab["target"],
): WorkspaceLayout { ): WorkspaceLayout {
return { return {
root: { root: {
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -12,6 +12,7 @@ interface UseCheckoutDiffQueryOptions {
baseRef?: string; baseRef?: string;
ignoreWhitespace?: boolean; ignoreWhitespace?: boolean;
enabled?: boolean; enabled?: boolean;
queryScope?: string;
} }
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">; type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
@@ -44,6 +45,7 @@ export function useCheckoutDiffQuery({
baseRef, baseRef,
ignoreWhitespace, ignoreWhitespace,
enabled = true, enabled = true,
queryScope,
}: UseCheckoutDiffQueryOptions) { }: UseCheckoutDiffQueryOptions) {
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const normalizedCompare = useMemo( const normalizedCompare = useMemo(
@@ -53,10 +55,17 @@ export function useCheckoutDiffQuery({
const compareMode = normalizedCompare.mode; const compareMode = normalizedCompare.mode;
const compareBaseRef = normalizedCompare.baseRef; const compareBaseRef = normalizedCompare.baseRef;
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace; const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
const queryKey = useMemo( const queryKey = useMemo(() => {
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace), const comparisonKey = checkoutDiffQueryKey(
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace], serverId,
); cwd,
compareMode,
compareBaseRef,
compareIgnoreWhitespace,
);
const normalizedScope = queryScope?.trim();
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]); const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
const routeEnabled = Boolean(enabled && isConnected && cwd); const routeEnabled = Boolean(enabled && isConnected && cwd);
+169
View File
@@ -0,0 +1,169 @@
import { useCallback, useEffect, useMemo } from "react";
import {
buildWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import {
buildReviewDraftKey,
buildReviewDraftScopeKey,
useInlineReviewController,
useResolvedDiffMode,
useReviewAttachmentSnapshot,
useSetDiffModeOverride,
} from "@/review";
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
interface UseWorkingDiffOptions {
serverId: string;
workspaceId?: string;
cwd: string;
ignoreWhitespace: boolean;
enabled: boolean;
queryScope?: string;
}
export function useWorkingDiff({
serverId,
workspaceId,
cwd,
ignoreWhitespace,
enabled,
queryScope,
}: UseWorkingDiffOptions) {
const {
status,
isLoading: isStatusLoading,
isError: isStatusError,
error: statusError,
} = useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null;
const isGit = Boolean(gitStatus);
const notGit = status !== null && !status.isGit && !status.error;
const statusErrorMessage =
status?.error?.message ??
(isStatusError && statusError instanceof Error ? statusError.message : null);
const baseRef = gitStatus?.baseRef ?? undefined;
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const currentBranchName =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
const reviewDraftScopeKey = useMemo(
() =>
buildReviewDraftScopeKey({
serverId,
workspaceId,
cwd,
baseRef,
ignoreWhitespace,
}),
[baseRef, cwd, ignoreWhitespace, serverId, workspaceId],
);
const diffMode = useResolvedDiffMode({
scopeKey: reviewDraftScopeKey,
hasUncommittedChanges,
});
const setDiffModeOverride = useSetDiffModeOverride();
const selectDiffMode = useCallback(
(mode: "uncommitted" | "base") => {
setDiffModeOverride({
scopeKey: reviewDraftScopeKey,
override: { serverId, cwd, mode, isDirtyAtSelection: hasUncommittedChanges },
});
},
[cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride],
);
const selectUncommitted = useCallback(() => selectDiffMode("uncommitted"), [selectDiffMode]);
const selectBase = useCallback(() => selectDiffMode("base"), [selectDiffMode]);
const {
files,
payloadError: diffPayloadError,
isLoading: isDiffLoading,
} = useCheckoutDiffQuery({
serverId,
cwd,
mode: diffMode,
baseRef,
ignoreWhitespace,
enabled: enabled && isGit,
queryScope,
});
const reviewDraftKey = useMemo(
() =>
buildReviewDraftKey({
serverId,
workspaceId,
cwd,
mode: diffMode,
baseRef,
ignoreWhitespace,
}),
[baseRef, cwd, diffMode, ignoreWhitespace, serverId, workspaceId],
);
const reviewActions = useInlineReviewController({ reviewDraftKey });
const reviewAttachment = useReviewAttachmentSnapshot({
key: reviewDraftKey,
diffFiles: files,
cwd,
mode: diffMode,
baseRef,
});
return {
status,
isStatusLoading,
isGit,
notGit,
statusErrorMessage,
baseRef,
currentBranchName,
diffMode,
selectUncommitted,
selectBase,
files,
diffPayloadError,
isDiffLoading,
reviewActions,
reviewAttachment,
};
}
export function usePublishWorkingDiffAttachment({
serverId,
workspaceId,
cwd,
attachment,
enabled,
}: {
serverId: string;
workspaceId?: string;
cwd: string;
attachment: ReturnType<typeof useWorkingDiff>["reviewAttachment"];
enabled: boolean;
}) {
const scopeKey = useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
[cwd, serverId, workspaceId],
);
const setWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.setWorkspaceAttachments,
);
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.clearWorkspaceAttachments,
);
useEffect(() => {
if (!enabled) {
return;
}
const attachments = attachment ? [attachment] : [];
setWorkspaceAttachments({ scopeKey, attachments });
return () => {
const current = useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey];
if (current === attachments) {
clearWorkspaceAttachments({ scopeKey });
}
};
}, [attachment, clearWorkspaceAttachments, enabled, scopeKey, setWorkspaceAttachments]);
}
+4
View File
@@ -758,6 +758,8 @@ export const ar: TranslationResources = {
}, },
}, },
diff: { diff: {
openChangesTab: "فتح علامة تبويب التغييرات",
closeChangesTab: "إغلاق علامة تبويب التغييرات",
binaryFile: "ملف ثنائي", binaryFile: "ملف ثنائي",
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه", tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
unified: "الفرق الموحدة", unified: "الفرق الموحدة",
@@ -1494,6 +1496,8 @@ export const ar: TranslationResources = {
changesLabel: "التغييرات", changesLabel: "التغييرات",
changesSubtitle: "فروقات شجرة العمل", changesSubtitle: "فروقات شجرة العمل",
commitSubtitle: "فروقات الالتزام", commitSubtitle: "فروقات الالتزام",
uncommittedSubtitle: "تغييرات غير ملتزم بها",
baseSubtitle: "مقارنة مع {{baseRef}}",
directoryMissing: "لم يتم العثور على دليل Workspace.", directoryMissing: "لم يتم العثور على دليل Workspace.",
empty: "لا توجد تغييرات", empty: "لا توجد تغييرات",
loadError: "فشل تحميل الفروقات", loadError: "فشل تحميل الفروقات",
+4
View File
@@ -768,6 +768,8 @@ export const en = {
}, },
}, },
diff: { diff: {
openChangesTab: "Open Changes tab",
closeChangesTab: "Close Changes tab",
binaryFile: "Binary file", binaryFile: "Binary file",
tooLarge: "Diff too large to display", tooLarge: "Diff too large to display",
unified: "Unified diff", unified: "Unified diff",
@@ -1505,6 +1507,8 @@ export const en = {
changesLabel: "Changes", changesLabel: "Changes",
changesSubtitle: "Working tree diff", changesSubtitle: "Working tree diff",
commitSubtitle: "Commit diff", commitSubtitle: "Commit diff",
uncommittedSubtitle: "Uncommitted changes",
baseSubtitle: "Compared with {{baseRef}}",
directoryMissing: "Workspace directory not found.", directoryMissing: "Workspace directory not found.",
empty: "No changes", empty: "No changes",
loadError: "Failed to load diff", loadError: "Failed to load diff",
+4
View File
@@ -789,6 +789,8 @@ export const es: TranslationResources = {
}, },
}, },
diff: { diff: {
openChangesTab: "Abrir la pestaña Cambios",
closeChangesTab: "Cerrar la pestaña Cambios",
binaryFile: "archivo binario", binaryFile: "archivo binario",
tooLarge: "La diferencia es demasiado grande para mostrarse", tooLarge: "La diferencia es demasiado grande para mostrarse",
unified: "Diferencia unificada", unified: "Diferencia unificada",
@@ -1537,6 +1539,8 @@ export const es: TranslationResources = {
changesLabel: "Cambios", changesLabel: "Cambios",
changesSubtitle: "Diferencias del árbol de trabajo", changesSubtitle: "Diferencias del árbol de trabajo",
commitSubtitle: "Diferencias del commit", commitSubtitle: "Diferencias del commit",
uncommittedSubtitle: "Cambios sin confirmar",
baseSubtitle: "Comparado con {{baseRef}}",
directoryMissing: "No se encontró el directorio de Workspace.", directoryMissing: "No se encontró el directorio de Workspace.",
empty: "Sin cambios", empty: "Sin cambios",
loadError: "No se pudieron cargar las diferencias", loadError: "No se pudieron cargar las diferencias",
+4
View File
@@ -788,6 +788,8 @@ export const fr: TranslationResources = {
}, },
}, },
diff: { diff: {
openChangesTab: "Ouvrir l'onglet Modifications",
closeChangesTab: "Fermer l'onglet Modifications",
binaryFile: "Fichier binaire", binaryFile: "Fichier binaire",
tooLarge: "Diff trop grand pour être affiché", tooLarge: "Diff trop grand pour être affiché",
unified: "Différentiel unifié", unified: "Différentiel unifié",
@@ -1540,6 +1542,8 @@ export const fr: TranslationResources = {
changesLabel: "Modifications", changesLabel: "Modifications",
changesSubtitle: "Différences de l'arbre de travail", changesSubtitle: "Différences de l'arbre de travail",
commitSubtitle: "Différences du commit", commitSubtitle: "Différences du commit",
uncommittedSubtitle: "Modifications non validées",
baseSubtitle: "Comparé à {{baseRef}}",
directoryMissing: "Répertoire Workspace introuvable.", directoryMissing: "Répertoire Workspace introuvable.",
empty: "Aucune modification", empty: "Aucune modification",
loadError: "Échec du chargement des différences", loadError: "Échec du chargement des différences",
+4
View File
@@ -769,6 +769,8 @@ export const ja: TranslationResources = {
}, },
}, },
diff: { diff: {
openChangesTab: "変更タブを開く",
closeChangesTab: "変更タブを閉じる",
binaryFile: "バイナリファイル", binaryFile: "バイナリファイル",
tooLarge: "差分が大きすぎて表示できません", tooLarge: "差分が大きすぎて表示できません",
unified: "ユニファイド差分", unified: "ユニファイド差分",
@@ -1510,6 +1512,8 @@ export const ja: TranslationResources = {
changesLabel: "変更", changesLabel: "変更",
changesSubtitle: "作業ツリーの差分", changesSubtitle: "作業ツリーの差分",
commitSubtitle: "コミット差分", commitSubtitle: "コミット差分",
uncommittedSubtitle: "未コミットの変更",
baseSubtitle: "{{baseRef}} との比較",
directoryMissing: "ワークスペースディレクトリが見つかりません。", directoryMissing: "ワークスペースディレクトリが見つかりません。",
empty: "変更はありません", empty: "変更はありません",
loadError: "差分の読み込みに失敗しました", loadError: "差分の読み込みに失敗しました",
+4
View File
@@ -780,6 +780,8 @@ export const ptBR: TranslationResources = {
}, },
}, },
diff: { diff: {
openChangesTab: "Abrir a aba Alterações",
closeChangesTab: "Fechar a aba Alterações",
binaryFile: "Arquivo binário", binaryFile: "Arquivo binário",
tooLarge: "Diff grande demais para exibir", tooLarge: "Diff grande demais para exibir",
unified: "Diff unificado", unified: "Diff unificado",
@@ -1523,6 +1525,8 @@ export const ptBR: TranslationResources = {
changesLabel: "Alterações", changesLabel: "Alterações",
changesSubtitle: "Diff da árvore de trabalho", changesSubtitle: "Diff da árvore de trabalho",
commitSubtitle: "Diff do commit", commitSubtitle: "Diff do commit",
uncommittedSubtitle: "Alterações não commitadas",
baseSubtitle: "Comparado com {{baseRef}}",
directoryMissing: "Diretório do workspace não encontrado.", directoryMissing: "Diretório do workspace não encontrado.",
empty: "Nenhuma alteração", empty: "Nenhuma alteração",
loadError: "Falha ao carregar diff", loadError: "Falha ao carregar diff",
+4
View File
@@ -780,6 +780,8 @@ export const ru: TranslationResources = {
}, },
}, },
diff: { diff: {
openChangesTab: "Открыть вкладку «Изменения»",
closeChangesTab: "Закрыть вкладку «Изменения»",
binaryFile: "Бинарный файл", binaryFile: "Бинарный файл",
tooLarge: "Разница слишком велика для отображения", tooLarge: "Разница слишком велика для отображения",
unified: "Единый дифференциал", unified: "Единый дифференциал",
@@ -1528,6 +1530,8 @@ export const ru: TranslationResources = {
changesLabel: "Изменения", changesLabel: "Изменения",
changesSubtitle: "Различия рабочего дерева", changesSubtitle: "Различия рабочего дерева",
commitSubtitle: "Различия коммита", commitSubtitle: "Различия коммита",
uncommittedSubtitle: "Незафиксированные изменения",
baseSubtitle: "Сравнение с {{baseRef}}",
directoryMissing: "Каталог Workspace не найден.", directoryMissing: "Каталог Workspace не найден.",
empty: "Нет изменений", empty: "Нет изменений",
loadError: "Не удалось загрузить различия", loadError: "Не удалось загрузить различия",
+4
View File
@@ -750,6 +750,8 @@ export const zhCN: TranslationResources = {
}, },
}, },
diff: { diff: {
openChangesTab: "打开“更改”标签页",
closeChangesTab: "关闭“更改”标签页",
binaryFile: "二进制文件", binaryFile: "二进制文件",
tooLarge: "Diff 过大,无法显示", tooLarge: "Diff 过大,无法显示",
unified: "Unified diff", unified: "Unified diff",
@@ -1475,6 +1477,8 @@ export const zhCN: TranslationResources = {
changesLabel: "更改", changesLabel: "更改",
changesSubtitle: "工作区差异", changesSubtitle: "工作区差异",
commitSubtitle: "提交差异", commitSubtitle: "提交差异",
uncommittedSubtitle: "未提交的更改",
baseSubtitle: "与 {{baseRef}} 比较",
directoryMissing: "未找到 workspace 目录。", directoryMissing: "未找到 workspace 目录。",
empty: "没有更改", empty: "没有更改",
loadError: "加载差异失败", loadError: "加载差异失败",
+1 -1
View File
@@ -73,7 +73,7 @@ import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { type Agent, useSessionStore } from "@/stores/session-store"; import { type Agent, useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { import {
useHideFinishedProviderSubagents, useHideFinishedProviderSubagents,
@@ -1,184 +0,0 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { GitCommitHorizontal } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings";
import { SharedDiffView } from "@/git/diff-pane";
import { useCommitDiffFiles } from "@/git/use-diff-files";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
function CommitDiffPanel() {
const { t } = useTranslation();
const { serverId, workspaceId, target } = usePaneContext();
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const { settings } = useAppSettings();
const { preferences, updatePreferences } = useChangesPreferences();
const isCompact = useIsCompactFormFactor();
invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target");
const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({
serverId,
cwd: cwd ?? "",
sha: target.sha,
enabled: Boolean(cwd),
});
const canUseSplitLayout = isWeb && !isCompact;
const effectiveLayout = canUseSplitLayout ? preferences.layout : "unified";
const layoutOptions = useMemo(
() => [
{
value: "unified" as const,
label: t("workspace.git.diff.unified"),
testID: "commit-diff-layout-unified",
},
{
value: "split" as const,
label: t("workspace.git.diff.split"),
testID: "commit-diff-layout-split",
},
],
[t],
);
const handleLayoutChange = useCallback(
(layout: "unified" | "split") => {
void updatePreferences({ layout });
},
[updatePreferences],
);
const displayPreferences = useMemo(
() => ({
layout: effectiveLayout,
wrapLines: preferences.wrapLines,
codeFontSize: settings.codeFontSize,
monoFontFamily: settings.monoFontFamily,
}),
[effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily],
);
const commitMode = useMemo(() => ({ kind: "commit" as const }), []);
if (!cwd) {
return (
<View style={styles.centerState}>
<Text style={styles.mutedText}>{t("panels.diff.directoryMissing")}</Text>
</View>
);
}
if (capabilityMissing) {
return (
<View style={styles.centerState} testID="commit-diff-capability-missing">
<Text style={styles.mutedText}>{t("panels.diff.capabilityMissing")}</Text>
</View>
);
}
let bodyContent: ReactNode;
if (error) {
bodyContent = (
<View style={styles.centerState} testID="commit-diff-error">
<Text style={styles.errorText}>{t("panels.diff.loadError")}</Text>
</View>
);
} else if (isLoading && files.length === 0) {
bodyContent = (
<View style={styles.centerState} testID="commit-diff-loading">
<Text style={styles.mutedText}>{t("workspace.tabs.loading")}</Text>
</View>
);
} else if (files.length === 0) {
bodyContent = (
<View style={styles.centerState} testID="commit-diff-empty">
<Text style={styles.mutedText}>{t("panels.diff.empty")}</Text>
</View>
);
} else {
bodyContent = (
<SharedDiffView files={files} displayPreferences={displayPreferences} mode={commitMode} />
);
}
return (
<View style={styles.container} testID="commit-diff-panel">
{canUseSplitLayout ? (
<View style={styles.toolbar} testID="commit-diff-toolbar">
<SegmentedControl
options={layoutOptions}
value={preferences.layout}
onValueChange={handleLayoutChange}
size="sm"
testID="commit-diff-layout-control"
/>
</View>
) : null}
<View style={styles.body}>{bodyContent}</View>
</View>
);
}
function useCommitDiffPanelDescriptor(
target: Extract<WorkspaceTabTarget, { kind: "commit_diff" }>,
): PanelDescriptor {
const { t } = useTranslation();
return {
label: target.sha.slice(0, 7),
subtitle: t("panels.diff.commitSubtitle"),
tooltip: target.sha,
titleState: "ready",
icon: ThemedGitCommitHorizontal,
statusBucket: null,
};
}
export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
kind: "commit_diff",
component: CommitDiffPanel,
useDescriptor: useCommitDiffPanelDescriptor,
};
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
minHeight: 0,
},
toolbar: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
flexShrink: 0,
},
body: {
flex: 1,
minHeight: 0,
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[16],
},
mutedText: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
errorText: {
fontSize: theme.fontSize.base,
color: theme.colors.destructive,
textAlign: "center",
},
}));
+409
View File
@@ -0,0 +1,409 @@
import { useCallback, useMemo, useState, type ComponentProps, type ReactNode } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { FileDiff, GitCommitHorizontal } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
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 { 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 { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
const ThemedFileDiff = withUnistyles(FileDiff);
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
function useDiffPanelPreferences() {
const { settings } = useAppSettings();
const { preferences, updatePreferences } = useChangesPreferences();
const isCompact = useIsCompactFormFactor();
const canUseSplitLayout = isWeb && !isCompact;
const effectiveLayout = resolveDiffLayout(preferences.layout, canUseSplitLayout);
const displayPreferences = useMemo(
() => ({
layout: effectiveLayout,
wrapLines: preferences.wrapLines,
codeFontSize: settings.codeFontSize,
monoFontFamily: settings.monoFontFamily,
}),
[effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily],
);
const toggleLayout = useCallback(() => {
void updatePreferences({ layout: preferences.layout === "unified" ? "split" : "unified" });
}, [preferences.layout, updatePreferences]);
const toggleWrapLines = useCallback(() => {
void updatePreferences({ wrapLines: !preferences.wrapLines });
}, [preferences.wrapLines, updatePreferences]);
const toggleHideWhitespace = useCallback(() => {
void updatePreferences({ hideWhitespace: !preferences.hideWhitespace });
}, [preferences.hideWhitespace, updatePreferences]);
return {
preferences,
isCompact,
canUseSplitLayout,
displayPreferences,
toggleLayout,
toggleWrapLines,
toggleHideWhitespace,
};
}
function PanelState({
message,
tone = "muted",
testID,
}: {
message: string;
tone?: "muted" | "error";
testID?: string;
}) {
return (
<View style={styles.centerState} testID={testID}>
<Text style={tone === "error" ? styles.errorText : styles.mutedText}>{message}</Text>
</View>
);
}
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" }>;
}) {
const { t } = useTranslation();
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.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>
</View>
);
}
function CommitDiffPanel() {
const { t } = useTranslation();
const { serverId, workspaceId, target } = usePaneContext();
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const panelPreferences = useDiffPanelPreferences();
invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target");
const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({
serverId,
cwd: cwd ?? "",
sha: target.sha,
enabled: Boolean(cwd),
});
const mode = useMemo(() => ({ kind: "commit" as const }), []);
let body: ReactNode;
if (!cwd) {
body = <PanelState message={t("panels.diff.directoryMissing")} />;
} else if (capabilityMissing) {
body = (
<PanelState
message={t("panels.diff.capabilityMissing")}
testID="commit-diff-capability-missing"
/>
);
} else if (error) {
body = (
<PanelState message={t("panels.diff.loadError")} tone="error" testID="commit-diff-error" />
);
} else if (isLoading && files.length === 0) {
body = <PanelState message={t("workspace.tabs.loading")} testID="commit-diff-loading" />;
} else if (files.length === 0) {
body = <PanelState message={t("panels.diff.empty")} testID="commit-diff-empty" />;
} else {
body = (
<SharedDiffView
files={files}
displayPreferences={panelPreferences.displayPreferences}
mode={mode}
/>
);
}
return (
<View style={styles.container} testID="commit-diff-panel">
{panelPreferences.canUseSplitLayout ? (
<View style={styles.toolbar}>
<View style={styles.toolbarActions} testID="commit-diff-toolbar">
<DiffLayoutToggle
layout={panelPreferences.preferences.layout}
isMobile={panelPreferences.isCompact}
testID="commit-diff-toggle-layout"
onToggle={panelPreferences.toggleLayout}
/>
</View>
</View>
) : null}
<View style={styles.body}>{body}</View>
</View>
);
}
function useWorkingDiffPanelDescriptor(): PanelDescriptor {
const { t } = useTranslation();
return {
label: t("panels.diff.changesLabel"),
subtitle: t("panels.diff.changesSubtitle"),
tooltip: t("panels.diff.changesSubtitle"),
titleState: "ready",
icon: ThemedFileDiff,
statusBucket: null,
};
}
function useCommitDiffPanelDescriptor(
target: Extract<WorkspaceTabTarget, { kind: "commit_diff" }>,
): PanelDescriptor {
const { t } = useTranslation();
return {
label: target.sha.slice(0, 7),
subtitle: t("panels.diff.commitSubtitle"),
tooltip: target.sha,
titleState: "ready",
icon: ThemedGitCommitHorizontal,
statusBucket: null,
};
}
export const workingDiffPanelRegistration: PanelRegistration<"working_diff"> = {
kind: "working_diff",
component: WorkingDiffPanel,
useDescriptor: useWorkingDiffPanelDescriptor,
};
export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
kind: "commit_diff",
component: CommitDiffPanel,
useDescriptor: useCommitDiffPanelDescriptor,
};
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
minHeight: 0,
},
toolbar: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
flexShrink: 0,
},
toolbarActions: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
body: {
flex: 1,
minHeight: 0,
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[16],
},
mutedText: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
errorText: {
fontSize: theme.fontSize.base,
color: theme.colors.destructive,
textAlign: "center",
},
}));
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { createContext, useContext, type ReactNode } from "react"; import React, { createContext, useContext, type ReactNode } from "react";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface PaneContextValue { export interface PaneContextValue {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
export interface PanelIconProps { export interface PanelIconProps {
+2 -1
View File
@@ -1,6 +1,6 @@
import { agentPanelRegistration } from "@/panels/agent-panel"; import { agentPanelRegistration } from "@/panels/agent-panel";
import { browserPanelRegistration } from "@/panels/browser-panel"; import { browserPanelRegistration } from "@/panels/browser-panel";
import { commitDiffPanelRegistration } from "@/panels/commit-diff-panel"; import { commitDiffPanelRegistration, workingDiffPanelRegistration } from "@/panels/diff-panel";
import { draftPanelRegistration } from "@/panels/draft-panel"; import { draftPanelRegistration } from "@/panels/draft-panel";
import { filePanelRegistration } from "@/panels/file-panel"; import { filePanelRegistration } from "@/panels/file-panel";
import { registerPanel } from "@/panels/panel-registry"; import { registerPanel } from "@/panels/panel-registry";
@@ -22,5 +22,6 @@ export function ensurePanelsRegistered(): void {
registerPanel(browserPanelRegistration); registerPanel(browserPanelRegistration);
registerPanel(filePanelRegistration); registerPanel(filePanelRegistration);
registerPanel(commitDiffPanelRegistration); registerPanel(commitDiffPanelRegistration);
registerPanel(workingDiffPanelRegistration);
panelsRegistered = true; panelsRegistered = true;
} }
+1 -1
View File
@@ -13,7 +13,7 @@ import invariant from "tiny-invariant";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { usePaneContext } from "@/panels/pane-context"; import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { import {
@@ -78,7 +78,7 @@ import type { MessagePayload } from "@/composer/types";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
import { import {
getWorkspaceNamingAttachments, getWorkspaceNamingAttachments,
@@ -1,6 +1,6 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store"; import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab } from "@/workspace-tabs/model";
import { selectVisibleAgentIds } from "./visible-agent-ids"; import { selectVisibleAgentIds } from "./visible-agent-ids";
test("selects only the active agent tab in every visible pane", () => { test("selects only the active agent tab in every visible pane", () => {
@@ -1,5 +1,5 @@
import { collectAllPanes, type WorkspaceLayout } from "@/stores/workspace-layout-store"; import { collectAllPanes, type WorkspaceLayout } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab } from "@/workspace-tabs/model";
import { deriveWorkspacePaneState } from "./workspace-pane-state"; import { deriveWorkspacePaneState } from "./workspace-pane-state";
export function selectVisibleAgentIds(input: { export function selectVisibleAgentIds(input: {
@@ -444,7 +444,7 @@ interface WorkspaceDesktopTabsRowProps {
function getFallbackTabLabel( function getFallbackTabLabel(
tab: WorkspaceTabDescriptor, tab: WorkspaceTabDescriptor,
labels: { newAgent: string; setup: string; terminal: string; agent: string }, labels: { newAgent: string; setup: string; terminal: string; agent: string; changes: string },
): string { ): string {
if (tab.target.kind === "draft") { if (tab.target.kind === "draft") {
return labels.newAgent; return labels.newAgent;
@@ -458,6 +458,9 @@ function getFallbackTabLabel(
if (tab.target.kind === "file") { if (tab.target.kind === "file") {
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
} }
if (tab.target.kind === "working_diff") {
return labels.changes;
}
return labels.agent; return labels.agent;
} }
@@ -836,6 +839,7 @@ export function WorkspaceDesktopTabsRow({
setup: t("workspace.tabs.fallback.setup"), setup: t("workspace.tabs.fallback.setup"),
terminal: t("workspace.tabs.fallback.terminal"), terminal: t("workspace.tabs.fallback.terminal"),
agent: t("workspace.tabs.fallback.agent"), agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
}), }),
[t], [t],
); );
@@ -2,7 +2,7 @@ import {
createWorkspaceFileTabTarget, createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation, normalizeWorkspaceFileLocation,
} from "@/workspace/file-open"; } from "@/workspace/file-open";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
interface OpenWorkspaceFileFromExplorerInput { interface OpenWorkspaceFileFromExplorerInput {
filePath: string; filePath: string;
@@ -5,7 +5,7 @@ import {
resolveSideFileOpenPlacement, resolveSideFileOpenPlacement,
} from "@/screens/workspace/workspace-pane-state"; } from "@/screens/workspace/workspace-pane-state";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store"; import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab } from "@/workspace-tabs/model";
function createTab(tabId: string, target: WorkspaceTab["target"]): WorkspaceTab { function createTab(tabId: string, target: WorkspaceTab["target"]): WorkspaceTab {
return { return {
@@ -3,7 +3,7 @@ import {
type SplitPane, type SplitPane,
type WorkspaceLayout, type WorkspaceLayout,
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { import {
buildDeterministicWorkspaceTabId, buildDeterministicWorkspaceTabId,
@@ -74,14 +74,17 @@ import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { import {
buildWorkspaceTabPersistenceKey,
collectAllTabs, collectAllTabs,
getFocusedBrowserId, getFocusedBrowserId,
type WorkspaceLayout, type WorkspaceLayout,
useWorkspaceLayoutStore, useWorkspaceLayoutStore,
useWorkspaceLayoutStoreHydrated, useWorkspaceLayoutStoreHydrated,
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTab,
type WorkspaceTabTarget,
} from "@/workspace-tabs/model";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -345,6 +348,7 @@ function getFallbackTabOptionLabel(
terminal: string; terminal: string;
browser: string; browser: string;
agent: string; agent: string;
changes: string;
}, },
): string { ): string {
if (tab.target.kind === "draft") { if (tab.target.kind === "draft") {
@@ -362,6 +366,9 @@ function getFallbackTabOptionLabel(
if (tab.target.kind === "file") { if (tab.target.kind === "file") {
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
} }
if (tab.target.kind === "working_diff") {
return labels.changes;
}
if (tab.target.kind === "commit_diff") { if (tab.target.kind === "commit_diff") {
return tab.target.sha.slice(0, 7); return tab.target.sha.slice(0, 7);
} }
@@ -376,6 +383,7 @@ function getFallbackTabOptionDescription(
agent: string; agent: string;
terminal: string; terminal: string;
browser: string; browser: string;
changes: string;
}, },
): string { ): string {
if (tab.target.kind === "draft") { if (tab.target.kind === "draft") {
@@ -399,6 +407,9 @@ function getFallbackTabOptionDescription(
if (tab.target.kind === "commit_diff") { if (tab.target.kind === "commit_diff") {
return tab.target.sha.slice(0, 7); return tab.target.sha.slice(0, 7);
} }
if (tab.target.kind === "working_diff") {
return labels.changes;
}
return tab.target.path; return tab.target.path;
} }
@@ -671,6 +682,7 @@ function MobileWorkspaceTabOption({
terminal: t("workspace.tabs.fallback.terminal"), terminal: t("workspace.tabs.fallback.terminal"),
browser: t("workspace.tabs.fallback.browser"), browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"), agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
}), }),
[t], [t],
); );
@@ -2514,6 +2526,7 @@ function WorkspaceScreenContent({
terminal: t("workspace.tabs.fallback.terminal"), terminal: t("workspace.tabs.fallback.terminal"),
browser: t("workspace.tabs.fallback.browser"), browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"), agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
}), }),
[t], [t],
); );
@@ -1,5 +1,8 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu"; import {
buildWorkspaceDesktopTabActions,
buildWorkspaceTabMenuEntries,
} from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
function createAgentTab(): WorkspaceTabDescriptor { function createAgentTab(): WorkspaceTabDescriptor {
@@ -253,6 +256,37 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(onCopyFilePath).toHaveBeenCalledWith("/some/path.ts"); expect(onCopyFilePath).toHaveBeenCalledWith("/some/path.ts");
}); });
it("uses a Changes close id for the working diff tab", () => {
const actions = buildWorkspaceDesktopTabActions({
tab: {
key: "working_diff_abc",
tabId: "working_diff_abc",
kind: "working_diff",
target: {
kind: "working_diff",
focusPath: "src/example.ts",
focusRequestId: 1,
},
},
index: 0,
tabCount: 1,
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onCopyFilePath: vi.fn(),
onReloadAgent: vi.fn(),
onRenameTab: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsToLeft: vi.fn(),
onCloseTabsToRight: vi.fn(),
onCloseOtherTabs: vi.fn(),
});
expect(actions.closeButtonTestId).toMatch(/^workspace-working-diff-close-/);
expect(actions.menuEntries).not.toContainEqual(
expect.objectContaining({ kind: "item", key: "copy-file-path" }),
);
});
it("uses the same rename entry shape for agent and terminal tabs", () => { it("uses the same rename entry shape for agent and terminal tabs", () => {
const terminalTab: WorkspaceTabDescriptor = { const terminalTab: WorkspaceTabDescriptor = {
key: "terminal_abc", key: "terminal_abc",
@@ -144,6 +144,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "commit_diff") { if (tab.target.kind === "commit_diff") {
return `workspace-commit-diff-close-${encodeFilePathForPathSegment(tab.target.sha)}`; return `workspace-commit-diff-close-${encodeFilePathForPathSegment(tab.target.sha)}`;
} }
if (tab.target.kind === "working_diff") {
return `workspace-working-diff-close-${encodeFilePathForPathSegment(buildDeterministicWorkspaceTabId(tab.target))}`;
}
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
} }
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model"; import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab } from "@/workspace-tabs/model";
describe("deriveWorkspaceTabModel", () => { describe("deriveWorkspaceTabModel", () => {
it("keeps normalized tabs in stored order and preserves targets", () => { it("keeps normalized tabs in stored order and preserves targets", () => {
@@ -1,4 +1,4 @@
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
import { import {
deriveWorkspacePaneState, deriveWorkspacePaneState,
type WorkspaceDerivedTab, type WorkspaceDerivedTab,
@@ -1,4 +1,4 @@
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
export interface WorkspaceTabDescriptor { export interface WorkspaceTabDescriptor {
key: string; key: string;
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { import {
navigateToLastWorkspace, navigateToLastWorkspace,
navigateToWorkspace, navigateToWorkspace,
@@ -11,7 +11,7 @@ import {
resolveWorkspaceMapKeyByIdentity, resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-identity"; } from "@/utils/workspace-identity";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab"; import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab";
export interface RouteSelectionInput { export interface RouteSelectionInput {
@@ -1,7 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import type { ComposerAttachment } from "@/attachments/types"; import type { ComposerAttachment } from "@/attachments/types";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store"; import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
export interface PendingWorkspaceDraftSubmission { export interface PendingWorkspaceDraftSubmission {
serverId: string; serverId: string;
@@ -1,5 +1,5 @@
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants"; import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids"; import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids"; import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
@@ -285,9 +285,14 @@ function normalizeWorkspaceTab(value: unknown): WorkspaceTab | null {
const tab = value as WorkspaceTab; const tab = value as WorkspaceTab;
const target = normalizeWorkspaceTabTarget(tab.target); const target = normalizeWorkspaceTabTarget(tab.target);
if (!target) {
return null;
}
const tabId = const tabId =
trimNonEmpty(tab.tabId) ?? (target ? buildDeterministicWorkspaceTabId(target) : null); target.kind === "working_diff"
if (!target || !tabId) { ? buildDeterministicWorkspaceTabId(target)
: (trimNonEmpty(tab.tabId) ?? buildDeterministicWorkspaceTabId(target));
if (!tabId) {
return null; return null;
} }
@@ -15,9 +15,8 @@ vi.mock("@react-native-async-storage/async-storage", () => {
}; };
}); });
import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
import { import {
buildWorkspaceTabPersistenceKey,
collectAllPanes, collectAllPanes,
collectAllTabs, collectAllTabs,
createWorkspaceLayoutStore, createWorkspaceLayoutStore,
@@ -27,8 +26,10 @@ import {
getFocusedBrowserId, getFocusedBrowserId,
getTreeDepth, getTreeDepth,
insertSplit, insertSplit,
normalizeLayout,
removePaneFromTree, removePaneFromTree,
removeTabFromTree, removeTabFromTree,
stripEphemeralTabsFromLayout,
type SplitNode, type SplitNode,
type SplitPane, type SplitPane,
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
@@ -1128,6 +1129,73 @@ describe("workspace-layout-store actions", () => {
]); ]);
}); });
it("persists working diff tabs while stripping commit diff tabs", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, {
kind: "working_diff",
focusPath: "src/a.ts",
});
store.openTabFocused(workspaceKey, { kind: "commit_diff", sha: "abc123" });
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
expect(partialize).toBeTypeOf("function");
if (!partialize) {
throw new Error("Workspace layout partialize function is missing");
}
const currentState = workspaceLayoutStore.getState();
const layout = stripEphemeralTabsFromLayout(currentState.layoutByWorkspace[workspaceKey]);
const persisted = partialize(currentState);
expect(persisted).toEqual({
layoutByWorkspace: { [workspaceKey]: layout },
splitSizesByWorkspace: currentState.splitSizesByWorkspace,
});
expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([
{
kind: "working_diff",
focusPath: "src/a.ts",
},
]);
});
it("canonicalizes comparison-specific working diff tab ids from persisted layouts", () => {
const legacyTabId = "working_diff_uncommitted_0_n";
const layout = normalizeLayout({
root: {
kind: "pane",
pane: {
id: "main",
tabIds: [legacyTabId],
focusedTabId: legacyTabId,
tabs: [
{
tabId: legacyTabId,
createdAt: 1,
target: {
kind: "working_diff",
focusPath: "src/a.ts",
mode: "uncommitted",
baseRef: null,
ignoreWhitespace: false,
},
},
],
},
},
focusedPaneId: "main",
});
expect(collectAllTabs(layout.root)).toEqual([
{
tabId: "working_diff",
target: { kind: "working_diff", focusPath: "src/a.ts" },
createdAt: 1,
},
]);
});
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => { it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
useWorkspaceLayoutIds( useWorkspaceLayoutIds(
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
@@ -2,11 +2,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { create } from "zustand"; import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware"; import { createJSONStorage, persist } from "zustand/middleware";
import { import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
buildWorkspaceTabPersistenceKey,
type WorkspaceTab,
type WorkspaceTabTarget,
} from "@/stores/workspace-tabs-store";
import { import {
defaultWorkspaceLayoutIds, defaultWorkspaceLayoutIds,
type WorkspaceLayoutIdSource, type WorkspaceLayoutIdSource,
@@ -47,7 +43,6 @@ import {
} from "@/stores/workspace-layout-actions"; } from "@/stores/workspace-layout-actions";
import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity"; import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
export { buildWorkspaceTabPersistenceKey };
export { export {
collectAllPanes, collectAllPanes,
collectAllTabs, collectAllTabs,
@@ -1,6 +1,6 @@
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { create } from "zustand"; import { create } from "zustand";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
export type WorkspaceCreationMethod = "open_project" | "create_worktree"; export type WorkspaceCreationMethod = "open_project" | "create_worktree";
@@ -5,8 +5,9 @@ import {
deriveWorkspaceAgentVisibility, deriveWorkspaceAgentVisibility,
type WorkspaceAgentVisibility, type WorkspaceAgentVisibility,
} from "@/workspace-tabs/agent-visibility"; } from "@/workspace-tabs/agent-visibility";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { selectSubagentsForParent } from "@/subagents/select"; import { selectSubagentsForParent } from "@/subagents/select";
import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store"; import { useWorkspaceLayoutStore } from "./workspace-layout-store";
import { useSessionStore, type Agent } from "./session-store"; import { useSessionStore, type Agent } from "./session-store";
vi.mock("@react-native-async-storage/async-storage", () => { vi.mock("@react-native-async-storage/async-storage", () => {
@@ -1,124 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
applyCloseTab,
applyEnsureTab,
applyFocusTab,
applyOpenDraftTab,
applyOpenOrFocusTab,
applyPurgeWorkspace,
applyReorderTabs,
applyRetargetTab,
initialWorkspaceTabsCoreState,
migrateWorkspaceTabsState,
partializeWorkspaceTabsState,
selectWorkspaceTabs,
type WorkspaceTab,
type WorkspaceTabsCoreState,
type WorkspaceTabTarget,
} from "./state";
export { buildWorkspaceTabPersistenceKey } from "./state";
export type { WorkspaceDraftTabSetup, WorkspaceTab, WorkspaceTabTarget } from "./state";
interface WorkspaceTabsState extends WorkspaceTabsCoreState {
openDraftTab: (input: {
serverId: string;
workspaceId: string;
draftId: string;
}) => string | null;
ensureTab: (input: {
serverId: string;
workspaceId: string;
target: WorkspaceTabTarget;
}) => string | null;
openOrFocusTab: (input: {
serverId: string;
workspaceId: string;
target: WorkspaceTabTarget;
}) => string | null;
focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
retargetTab: (input: {
serverId: string;
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
}) => string | null;
reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
purgeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
}
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
persist(
(set, get) => ({
...initialWorkspaceTabsCoreState,
openDraftTab: ({ serverId, workspaceId, draftId }) => {
let resolved: string | null = null;
set((state) => {
const result = applyOpenDraftTab(state, {
serverId,
workspaceId,
draftId,
now: Date.now(),
});
resolved = result.tabId;
return result.state;
});
return resolved;
},
ensureTab: ({ serverId, workspaceId, target }) => {
let resolved: string | null = null;
set((state) => {
const result = applyEnsureTab(state, {
serverId,
workspaceId,
target,
now: Date.now(),
});
resolved = result.tabId;
return result.state;
});
return resolved;
},
openOrFocusTab: ({ serverId, workspaceId, target }) => {
let resolved: string | null = null;
set((state) => {
const result = applyOpenOrFocusTab(state, {
serverId,
workspaceId,
target,
now: Date.now(),
});
resolved = result.tabId;
return result.state;
});
return resolved;
},
focusTab: (input) => set((state) => applyFocusTab(state, input)),
closeTab: (input) => set((state) => applyCloseTab(state, input)),
retargetTab: ({ serverId, workspaceId, tabId, target }) => {
let resolved: string | null = null;
set((state) => {
const result = applyRetargetTab(state, { serverId, workspaceId, tabId, target });
resolved = result.tabId;
return result.state;
});
return resolved;
},
reorderTabs: (input) => set((state) => applyReorderTabs(state, input)),
getWorkspaceTabs: (input) => selectWorkspaceTabs(get(), input),
purgeWorkspace: (input) => set((state) => applyPurgeWorkspace(state, input)),
}),
{
name: "workspace-tabs-state",
version: 5,
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => partializeWorkspaceTabsState(state, { now: Date.now() }),
migrate: (persistedState) =>
migrateWorkspaceTabsState(persistedState, { now: Date.now() }) as WorkspaceTabsState,
},
),
);
@@ -1,432 +0,0 @@
import { describe, expect, it } from "vitest";
import {
applyCloseTab,
applyEnsureTab,
applyFocusTab,
applyOpenDraftTab,
applyOpenOrFocusTab,
applyRetargetTab,
buildWorkspaceTabPersistenceKey,
initialWorkspaceTabsCoreState,
migrateWorkspaceTabsState,
type WorkspaceTabsCoreState,
} from "./state";
const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/worktree";
const WORKSPACE_KEY = `${SERVER_ID}:${WORKSPACE_ID}`;
const NOW = 1_700_000_000_000;
function emptyState(): WorkspaceTabsCoreState {
return {
uiTabsByWorkspace: {},
tabOrderByWorkspace: {},
focusedTabIdByWorkspace: {},
};
}
describe("buildWorkspaceTabPersistenceKey", () => {
it("preserves opaque workspace ids instead of normalizing them like paths", () => {
expect(
buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: " setup\\workspace\\ ",
}),
).toBe("server-1:setup\\workspace\\");
});
});
describe("workspace-tabs-store reducers", () => {
it("keeps a promoted draft tab in-place by mutating target without changing tab id", () => {
const draftTabId = "draft_123";
let state = emptyState();
state = applyEnsureTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "left" },
now: NOW,
}).state;
state = applyOpenDraftTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: draftTabId,
now: NOW,
}).state;
state = applyEnsureTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "right" },
now: NOW,
}).state;
state = applyFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: draftTabId,
});
const beforeOrder = state.tabOrderByWorkspace[WORKSPACE_KEY] ?? [];
const retargeted = applyRetargetTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: draftTabId,
target: { kind: "agent", agentId: "created" },
});
const afterOrder = retargeted.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? [];
const tabs = retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? [];
const retargetedTab = tabs.find((tab) => tab.tabId === draftTabId) ?? null;
expect(retargeted.tabId).toBe(draftTabId);
expect(afterOrder).toEqual(beforeOrder);
expect(retargeted.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(draftTabId);
expect(retargetedTab?.target).toEqual({ kind: "agent", agentId: "created" });
});
it("ensureTab adds non-focused membership while openOrFocusTab focuses", () => {
let state = emptyState();
const ensured = applyEnsureTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "terminal", terminalId: "term-1" },
now: NOW,
});
state = ensured.state;
expect(ensured.tabId).toBe("terminal_term-1");
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBeUndefined();
const focused = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "terminal", terminalId: "term-1" },
now: NOW,
});
expect(focused.tabId).toBe("terminal_term-1");
expect(focused.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe("terminal_term-1");
});
it("ensureTab deduplicates by target when a retargeted tab already exists", () => {
const draftTabId = "draft_x";
let state = emptyState();
state = applyOpenDraftTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: draftTabId,
now: NOW,
}).state;
state = applyRetargetTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: draftTabId,
target: { kind: "agent", agentId: "created-agent" },
}).state;
const ensured = applyEnsureTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "created-agent" },
now: NOW,
});
const tabs = ensured.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? [];
const order = ensured.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? [];
const matchingTabs = tabs.filter(
(tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent",
);
expect(ensured.tabId).toBe(draftTabId);
expect(matchingTabs).toHaveLength(1);
expect(order).toEqual([draftTabId]);
});
it("openDraftTab creates a draft tab and deduplicates by draftId", () => {
let state = emptyState();
const first = applyOpenDraftTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: "draft-1",
now: NOW,
});
state = first.state;
const second = applyOpenDraftTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: "draft-2",
now: NOW,
});
state = second.state;
expect(first.tabId).toBe("draft-1");
expect(second.tabId).toBe("draft-2");
expect(state.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual([first.tabId, second.tabId]);
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toEqual([
{
tabId: "draft-1",
target: { kind: "draft", draftId: "draft-1" },
createdAt: NOW,
},
{
tabId: "draft-2",
target: { kind: "draft", draftId: "draft-2" },
createdAt: NOW,
},
]);
});
it("keeps draft setup on a retargeted tab", () => {
let state = emptyState();
const ensured = applyEnsureTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "agent-1" },
now: NOW,
});
expect(ensured.tabId).toBe("agent_agent-1");
state = ensured.state;
const retargeted = applyRetargetTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: ensured.tabId!,
target: {
kind: "draft",
draftId: "draft-replacement",
setup: {
provider: "mock",
cwd: "/repo/worktree",
modeId: "load-test",
model: "ten-second-stream",
thinkingOptionId: null,
featureValues: { effort: "high" },
},
},
});
expect(retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({
kind: "draft",
draftId: "draft-replacement",
setup: {
provider: "mock",
cwd: "/repo/worktree",
modeId: "load-test",
model: "ten-second-stream",
thinkingOptionId: null,
featureValues: { effort: "high" },
},
});
});
it("updates an existing draft tab when the setup changes", () => {
let state = emptyState();
const first = applyEnsureTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "draft", draftId: "draft-1" },
now: NOW,
});
state = first.state;
const second = applyEnsureTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: {
kind: "draft",
draftId: "draft-1",
setup: {
provider: "mock",
cwd: "/repo/worktree",
modeId: "load-test",
model: "ten-second-stream",
thinkingOptionId: null,
featureValues: {},
},
},
now: NOW,
});
state = second.state;
expect(second.tabId).toBe(first.tabId);
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({
kind: "draft",
draftId: "draft-1",
setup: {
provider: "mock",
cwd: "/repo/worktree",
modeId: "load-test",
model: "ten-second-stream",
thinkingOptionId: null,
featureValues: {},
},
});
});
it("retargeting a background draft keeps the currently focused tab focused", () => {
const draftTabId = "draft_background";
let state = emptyState();
state = applyOpenDraftTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: draftTabId,
now: NOW,
}).state;
const file = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
now: NOW,
});
state = file.state;
state = applyRetargetTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: draftTabId,
target: { kind: "agent", agentId: "created-agent" },
}).state;
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(file.tabId);
});
it("openOrFocusTab re-focuses an existing file tab after the workspace focus changed", () => {
let state = emptyState();
const fileResult = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
now: NOW,
});
state = fileResult.state;
const terminalResult = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "terminal", terminalId: "term-1" },
now: NOW,
});
state = terminalResult.state;
expect(fileResult.tabId).toBe("file_/repo/worktree/src/index.ts");
expect(terminalResult.tabId).toBe("terminal_term-1");
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(terminalResult.tabId);
const reopened = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
now: NOW,
});
expect(reopened.tabId).toBe(fileResult.tabId);
expect(reopened.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(fileResult.tabId);
});
it("builds a deterministic setup tab keyed by workspace id", () => {
const result = applyOpenOrFocusTab(initialWorkspaceTabsCoreState, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "setup", workspaceId: WORKSPACE_ID },
now: NOW,
});
expect(result.tabId).toBe(`setup_${WORKSPACE_ID}`);
expect(result.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(result.tabId);
});
it("opens a commit diff tab with a commit-specific id", () => {
let state = emptyState();
const commit = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "commit_diff", sha: "abc123" },
now: NOW,
});
state = commit.state;
expect(commit.tabId).toBe("commit_diff_abc123");
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
});
it("closeTab focuses the most-recent remaining tab when the focused tab is removed", () => {
let state = emptyState();
const first = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "left" },
now: NOW,
});
state = first.state;
const second = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "right" },
now: NOW,
});
state = second.state;
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(second.tabId);
state = applyCloseTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: second.tabId!,
});
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(first.tabId);
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
});
});
describe("migrateWorkspaceTabsState commit diff coercion", () => {
// This legacy store no longer enforces the "commit diff tabs are ephemeral"
// guarantee — that now lives in the workspace-layout store's partialize (see
// stripEphemeralTabsFromLayout). This migration only needs to carry old commit
// diff targets forward to the dedicated `commit_diff` tab shape.
it("migrates a legacy commit diff tab to the dedicated target shape", () => {
const persisted = {
state: {
uiTabsByWorkspace: {
[WORKSPACE_KEY]: [
{
tabId: "commit_diff_abc123",
target: { kind: "diff", diffTarget: { kind: "commit", sha: "abc123" } },
createdAt: NOW,
},
],
},
},
};
const migrated = migrateWorkspaceTabsState(persisted, { now: NOW });
const tabs = migrated.uiTabsByWorkspace[WORKSPACE_KEY] ?? [];
expect(tabs).toHaveLength(1);
expect(tabs[0]?.target).toEqual({ kind: "commit_diff", sha: "abc123" });
expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual(["commit_diff_abc123"]);
});
it("drops a legacy working diff tab during migration", () => {
const persisted = {
state: {
uiTabsByWorkspace: {
[WORKSPACE_KEY]: [
{
tabId: "diff_working:base:main",
target: {
kind: "diff",
diffTarget: { kind: "working", mode: "base", baseRef: "main" },
},
createdAt: NOW,
},
],
},
},
};
const migrated = migrateWorkspaceTabsState(persisted, { now: NOW });
expect(migrated.uiTabsByWorkspace[WORKSPACE_KEY]).toBeUndefined();
expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toBeUndefined();
});
});
@@ -1,799 +0,0 @@
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import {
buildDeterministicWorkspaceTabId,
normalizeWorkspaceDraftTabSetup,
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/workspace-tabs/identity";
import type { WorkspaceFileTabTarget } from "@/workspace/file-open";
export interface WorkspaceDraftTabSetup {
provider: AgentProvider;
cwd: string;
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
featureValues: Record<string, unknown>;
}
export type WorkspaceTabTarget =
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
| { kind: "agent"; agentId: string }
| { kind: "provider_subagent"; parentAgentId: string; subagentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "browser"; browserId: string }
| WorkspaceFileTabTarget
| { kind: "setup"; workspaceId: string }
| { kind: "commit_diff"; sha: string };
export interface WorkspaceTab {
tabId: string;
target: WorkspaceTabTarget;
createdAt: number;
}
export interface WorkspaceTabsCoreState {
uiTabsByWorkspace: Record<string, WorkspaceTab[]>;
tabOrderByWorkspace: Record<string, string[]>;
focusedTabIdByWorkspace: Record<string, string>;
}
export const initialWorkspaceTabsCoreState: WorkspaceTabsCoreState = {
uiTabsByWorkspace: {},
tabOrderByWorkspace: {},
focusedTabIdByWorkspace: {},
};
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function buildWorkspaceTabPersistenceKey(input: {
serverId: string;
workspaceId: string;
}): string | null {
const serverId = trimNonEmpty(input.serverId);
const workspaceId = trimNonEmpty(input.workspaceId);
if (!serverId || !workspaceId) {
return null;
}
// workspaceId is opaque; do not parse this key back into a path.
return `${serverId}:${workspaceId}`;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
return isPlainRecord(value) ? value : undefined;
}
function normalizeTabOrder(list: unknown): string[] {
if (!Array.isArray(list)) {
return [];
}
const next: string[] = [];
const used = new Set<string>();
for (const value of list) {
const tabId = trimNonEmpty(typeof value === "string" ? value : null);
if (!tabId || used.has(tabId)) {
continue;
}
used.add(tabId);
next.push(tabId);
}
return next;
}
function ensureInOrder(input: { current: string[]; tabId: string }): string[] {
if (input.current.includes(input.tabId)) {
return input.current;
}
return [...input.current, input.tabId];
}
function retargetTabAtIndex(
tab: WorkspaceTab,
index: number,
targetIndex: number,
normalizedTarget: WorkspaceTabTarget,
): WorkspaceTab {
return index === targetIndex ? { ...tab, target: normalizedTarget } : tab;
}
function buildNextTabsForEnsure(args: {
currentTabs: WorkspaceTab[];
existingIndex: number;
effectiveTabId: string;
normalizedTarget: WorkspaceTabTarget;
createdAt: number;
}): WorkspaceTab[] {
const { currentTabs, existingIndex, effectiveTabId, normalizedTarget, createdAt } = args;
if (existingIndex < 0) {
return [...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt }];
}
const existing = currentTabs[existingIndex];
if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) {
return currentTabs;
}
return currentTabs.map((tab, index) =>
retargetTabAtIndex(tab, index, existingIndex, normalizedTarget),
);
}
export interface EnsureTabInput {
serverId: string;
workspaceId: string;
target: WorkspaceTabTarget;
now: number;
}
export interface EnsureTabResult {
state: WorkspaceTabsCoreState;
tabId: string | null;
}
export function applyEnsureTab(
state: WorkspaceTabsCoreState,
input: EnsureTabInput,
): EnsureTabResult {
const key = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
if (!key || !normalizedTarget) {
return { state, tabId: null };
}
const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget);
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
const tabWithSameTarget =
currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? null;
const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId;
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId });
const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId);
const nextTabs = buildNextTabsForEnsure({
currentTabs,
existingIndex,
effectiveTabId,
normalizedTarget,
createdAt: input.now,
});
const uiTabsByWorkspace =
nextTabs === currentTabs
? state.uiTabsByWorkspace
: { ...state.uiTabsByWorkspace, [key]: nextTabs };
const tabOrderByWorkspace =
nextOrder === currentOrder
? state.tabOrderByWorkspace
: { ...state.tabOrderByWorkspace, [key]: nextOrder };
if (
uiTabsByWorkspace === state.uiTabsByWorkspace &&
tabOrderByWorkspace === state.tabOrderByWorkspace
) {
return { state, tabId: effectiveTabId };
}
return {
state: { ...state, uiTabsByWorkspace, tabOrderByWorkspace },
tabId: effectiveTabId,
};
}
export interface FocusTabInput {
serverId: string;
workspaceId: string;
tabId: string;
}
export function applyFocusTab(
state: WorkspaceTabsCoreState,
input: FocusTabInput,
): WorkspaceTabsCoreState {
const key = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
const normalizedTabId = trimNonEmpty(input.tabId);
if (!key || !normalizedTabId) {
return state;
}
if (state.focusedTabIdByWorkspace[key] === normalizedTabId) {
return state;
}
return {
...state,
focusedTabIdByWorkspace: {
...state.focusedTabIdByWorkspace,
[key]: normalizedTabId,
},
};
}
export function applyOpenOrFocusTab(
state: WorkspaceTabsCoreState,
input: EnsureTabInput,
): EnsureTabResult {
const ensured = applyEnsureTab(state, input);
if (!ensured.tabId) {
return ensured;
}
const focused = applyFocusTab(ensured.state, {
serverId: input.serverId,
workspaceId: input.workspaceId,
tabId: ensured.tabId,
});
return { state: focused, tabId: ensured.tabId };
}
export interface OpenDraftTabInput {
serverId: string;
workspaceId: string;
draftId: string;
now: number;
}
export function applyOpenDraftTab(
state: WorkspaceTabsCoreState,
input: OpenDraftTabInput,
): EnsureTabResult {
const normalizedDraftId = trimNonEmpty(input.draftId);
if (!normalizedDraftId) {
return { state, tabId: null };
}
return applyOpenOrFocusTab(state, {
serverId: input.serverId,
workspaceId: input.workspaceId,
target: { kind: "draft", draftId: normalizedDraftId },
now: input.now,
});
}
export interface CloseTabInput {
serverId: string;
workspaceId: string;
tabId: string;
}
export function applyCloseTab(
state: WorkspaceTabsCoreState,
input: CloseTabInput,
): WorkspaceTabsCoreState {
const key = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
const normalizedTabId = trimNonEmpty(input.tabId);
if (!key || !normalizedTabId) {
return state;
}
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId);
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
const nextOrder = currentOrder.filter((value) => value !== normalizedTabId);
let nextUiTabsByWorkspace: Record<string, WorkspaceTab[]>;
if (nextTabs.length === 0) {
const { [key]: _removed, ...rest } = state.uiTabsByWorkspace;
nextUiTabsByWorkspace = rest;
} else if (nextTabs.length === currentTabs.length) {
nextUiTabsByWorkspace = state.uiTabsByWorkspace;
} else {
nextUiTabsByWorkspace = { ...state.uiTabsByWorkspace, [key]: nextTabs };
}
let nextTabOrderByWorkspace: Record<string, string[]>;
if (nextOrder.length === 0) {
const { [key]: _removed, ...rest } = state.tabOrderByWorkspace;
nextTabOrderByWorkspace = rest;
} else if (nextOrder.length === currentOrder.length) {
nextTabOrderByWorkspace = state.tabOrderByWorkspace;
} else {
nextTabOrderByWorkspace = { ...state.tabOrderByWorkspace, [key]: nextOrder };
}
const currentFocused = state.focusedTabIdByWorkspace[key] ?? null;
const nextFocused =
currentFocused !== normalizedTabId ? currentFocused : (nextOrder[nextOrder.length - 1] ?? null);
const nextFocusedByWorkspace = (() => {
if (!nextFocused) {
const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace;
return rest;
}
return { ...state.focusedTabIdByWorkspace, [key]: nextFocused };
})();
const tabsChanged = nextTabs.length !== currentTabs.length;
const orderChanged = nextOrder.length !== currentOrder.length;
const focusChanged =
(state.focusedTabIdByWorkspace[key] ?? null) !== (nextFocusedByWorkspace[key] ?? null);
if (!tabsChanged && !orderChanged && !focusChanged) {
return state;
}
return {
uiTabsByWorkspace: nextUiTabsByWorkspace,
tabOrderByWorkspace: nextTabOrderByWorkspace,
focusedTabIdByWorkspace: nextFocusedByWorkspace,
};
}
export interface RetargetTabInput {
serverId: string;
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
}
export function applyRetargetTab(
state: WorkspaceTabsCoreState,
input: RetargetTabInput,
): EnsureTabResult {
const key = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
const normalizedTabId = trimNonEmpty(input.tabId);
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
if (!key || !normalizedTabId || !normalizedTarget) {
return { state, tabId: null };
}
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId);
if (index < 0) {
return { state, tabId: null };
}
const currentTarget = currentTabs[index]?.target;
if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) {
return { state, tabId: null };
}
const nextTabs = currentTabs.map((tab, tabIndex) =>
tabIndex === index ? Object.assign({}, tab, { target: normalizedTarget }) : tab,
);
return {
state: {
...state,
uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs },
},
tabId: normalizedTabId,
};
}
export interface ReorderTabsInput {
serverId: string;
workspaceId: string;
tabIds: string[];
}
export function applyReorderTabs(
state: WorkspaceTabsCoreState,
input: ReorderTabsInput,
): WorkspaceTabsCoreState {
const key = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
if (!key) {
return state;
}
const normalized = normalizeTabOrder(input.tabIds);
const current = state.tabOrderByWorkspace[key] ?? [];
if (current.length === normalized.length) {
let same = true;
for (let i = 0; i < current.length; i += 1) {
if (current[i] !== normalized[i]) {
same = false;
break;
}
}
if (same) {
return state;
}
}
return {
...state,
tabOrderByWorkspace: {
...state.tabOrderByWorkspace,
[key]: normalized,
},
};
}
export interface PurgeWorkspaceInput {
serverId: string;
workspaceId: string;
}
export function applyPurgeWorkspace(
state: WorkspaceTabsCoreState,
input: PurgeWorkspaceInput,
): WorkspaceTabsCoreState {
const key = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
if (!key) {
return state;
}
if (
!(key in state.uiTabsByWorkspace) &&
!(key in state.tabOrderByWorkspace) &&
!(key in state.focusedTabIdByWorkspace)
) {
return state;
}
const { [key]: _tabs, ...remainingUiTabsByWorkspace } = state.uiTabsByWorkspace;
const { [key]: _order, ...remainingTabOrderByWorkspace } = state.tabOrderByWorkspace;
const { [key]: _focused, ...remainingFocusedTabIdByWorkspace } = state.focusedTabIdByWorkspace;
return {
...state,
uiTabsByWorkspace: remainingUiTabsByWorkspace,
tabOrderByWorkspace: remainingTabOrderByWorkspace,
focusedTabIdByWorkspace: remainingFocusedTabIdByWorkspace,
};
}
export function selectWorkspaceTabs(
state: WorkspaceTabsCoreState,
input: { serverId: string; workspaceId: string },
): WorkspaceTab[] {
const key = buildWorkspaceTabPersistenceKey(input);
if (!key) {
return [];
}
return state.uiTabsByWorkspace[key] ?? [];
}
interface MigrationRawSources {
rawUiTabsByWorkspace: Record<string, unknown>;
rawFocused: Record<string, unknown>;
rawOrder: Record<string, unknown>;
legacyOrder: Record<string, unknown>;
}
function extractMigrationRawSources(persistedState: unknown): MigrationRawSources {
const top = toObjectRecord(persistedState) ?? {};
const rawState = toObjectRecord(top.state) ?? top;
return {
rawUiTabsByWorkspace:
toObjectRecord(
rawState.uiTabsByWorkspace ??
rawState.openTabsByWorkspace ??
top.uiTabsByWorkspace ??
top.openTabsByWorkspace,
) ?? {},
rawFocused:
toObjectRecord(
rawState.focusedTabIdByWorkspace ??
rawState.lastFocusedTabByWorkspace ??
top.focusedTabIdByWorkspace,
) ?? {},
rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {},
legacyOrder:
toObjectRecord(
rawState.tabOrderByWorkspace ??
rawState.tabOrderLegacyByWorkspace ??
top.tabOrderLegacyByWorkspace,
) ?? {},
};
}
function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTarget | null {
const kind = typeof raw.kind === "string" ? raw.kind : null;
if (kind === "draft" && typeof raw.draftId === "string") {
const setup = normalizeWorkspaceDraftTabSetup(raw.setup);
return normalizeWorkspaceTabTarget({
kind: "draft",
draftId: raw.draftId,
...(setup ? { setup } : {}),
});
}
if (kind === "agent" && typeof raw.agentId === "string") {
return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId });
}
if (
kind === "provider_subagent" &&
typeof raw.parentAgentId === "string" &&
typeof raw.subagentId === "string"
) {
return normalizeWorkspaceTabTarget({
kind: "provider_subagent",
parentAgentId: raw.parentAgentId,
subagentId: raw.subagentId,
});
}
if (kind === "terminal" && typeof raw.terminalId === "string") {
return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId });
}
if (kind === "browser" && typeof raw.browserId === "string") {
return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId });
}
if (kind === "file" && typeof raw.path === "string") {
return normalizeWorkspaceTabTarget({
kind: "file",
path: raw.path,
lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined,
lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined,
});
}
if (kind === "setup" && typeof raw.workspaceId === "string") {
return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });
}
return coercePersistedDiffTabTargetByKind(kind, raw);
}
function coercePersistedDiffTabTargetByKind(
kind: string | null,
raw: Record<string, unknown>,
): WorkspaceTabTarget | null {
if (kind === "commit_diff" && typeof raw.sha === "string") {
return normalizeWorkspaceTabTarget({ kind: "commit_diff", sha: raw.sha });
}
return kind === "diff" ? coercePersistedDiffTabTarget(raw) : null;
}
// NOTE: This legacy store no longer persists diff tabs — live tab persistence is
// owned by the workspace-layout store, which is where the "commit diff tabs are
// ephemeral on reload" guarantee is enforced (see stripEphemeralTabsFromLayout).
// This coercion exists only so this store's migration stays type-complete for any
// old diff-shaped entries left in `workspace-tabs-state` blobs. Working-tree diff
// tabs are dropped because they no longer exist as workspace targets; commit diff
// tabs migrate to the dedicated `commit_diff` target shape.
function coercePersistedDiffTabTarget(raw: Record<string, unknown>): WorkspaceTabTarget | null {
const diffTarget = toObjectRecord(raw.diffTarget);
if (!diffTarget || diffTarget.kind !== "commit" || typeof diffTarget.sha !== "string") {
return null;
}
return normalizeWorkspaceTabTarget({
kind: "commit_diff",
sha: diffTarget.sha,
});
}
function migrateSingleTab(rawTab: unknown, now: number): WorkspaceTab | null {
const record = toObjectRecord(rawTab);
if (!record) {
return null;
}
const rawTarget = toObjectRecord(record.target);
const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null;
if (!normalizedTarget) {
return null;
}
const rawTabId = trimNonEmpty(typeof record.tabId === "string" ? record.tabId : null);
const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget);
const rawCreatedAt = record.createdAt;
return {
tabId,
target: normalizedTarget,
createdAt: typeof rawCreatedAt === "number" ? rawCreatedAt : now,
};
}
interface MigratedTabsForKey {
nextUiTabs: WorkspaceTab[];
orderFromTabs: string[];
}
function migrateUiTabsForKey(rawEntries: unknown, now: number): MigratedTabsForKey {
const entries = Array.isArray(rawEntries) ? rawEntries : [];
const nextUiTabs: WorkspaceTab[] = [];
const orderFromTabs: string[] = [];
const usedOrder = new Set<string>();
for (const rawTab of entries) {
const migrated = migrateSingleTab(rawTab, now);
if (!migrated) {
continue;
}
if (!usedOrder.has(migrated.tabId)) {
usedOrder.add(migrated.tabId);
orderFromTabs.push(migrated.tabId);
}
nextUiTabs.push(migrated);
}
return { nextUiTabs, orderFromTabs };
}
function mergeExplicitTabOrder(
tabOrderByWorkspace: Record<string, string[]>,
rawOrder: Record<string, unknown>,
): void {
for (const key in rawOrder) {
const normalizedOrder = normalizeTabOrder(rawOrder[key]);
if (normalizedOrder.length === 0) {
continue;
}
const existing = tabOrderByWorkspace[key] ?? [];
tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedOrder]);
}
}
function convertLegacyOrderEntry(entry: unknown): string | null {
const raw = typeof entry === "string" ? entry.trim() : "";
if (!raw) {
return null;
}
if (raw.startsWith("agent:")) {
const agentId = raw.slice("agent:".length).trim();
return agentId ? `agent_${agentId}` : null;
}
if (raw.startsWith("terminal:")) {
const terminalId = raw.slice("terminal:".length).trim();
return terminalId ? `terminal_${terminalId}` : null;
}
return null;
}
function normalizeLegacyOrderList(list: unknown[]): string[] {
const result: string[] = [];
for (const entry of list) {
const converted = convertLegacyOrderEntry(entry);
if (converted) {
result.push(converted);
}
}
return result;
}
function mergeLegacyTabOrder(
tabOrderByWorkspace: Record<string, string[]>,
legacyOrder: Record<string, unknown>,
): void {
for (const key in legacyOrder) {
const list = legacyOrder[key];
if (!Array.isArray(list) || list.length === 0) {
continue;
}
const normalizedLegacyOrder = normalizeLegacyOrderList(list);
if (normalizedLegacyOrder.length === 0) {
continue;
}
const existing = tabOrderByWorkspace[key] ?? [];
tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedLegacyOrder]);
}
}
function resolveFocusedTabId(rawValue: unknown): string | null {
if (typeof rawValue === "string") {
return trimNonEmpty(rawValue);
}
if (!rawValue || typeof rawValue !== "object") {
return null;
}
const value = rawValue as {
kind?: string;
agentId?: string;
terminalId?: string;
draftId?: string;
};
if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) {
return `agent_${value.agentId.trim()}`;
}
if (
value.kind === "terminal" &&
typeof value.terminalId === "string" &&
value.terminalId.trim()
) {
return `terminal_${value.terminalId.trim()}`;
}
if (value.kind === "draft" && typeof value.draftId === "string" && value.draftId.trim()) {
return value.draftId.trim();
}
return null;
}
function migrateFocusedTabIds(
focusedTabIdByWorkspace: Record<string, string>,
rawFocused: Record<string, unknown>,
): void {
for (const key in rawFocused) {
const resolved = resolveFocusedTabId(rawFocused[key]);
if (resolved) {
focusedTabIdByWorkspace[key] = resolved;
}
}
}
export function migrateWorkspaceTabsState(
persistedState: unknown,
options: { now: number },
): WorkspaceTabsCoreState {
const { rawUiTabsByWorkspace, rawFocused, rawOrder, legacyOrder } =
extractMigrationRawSources(persistedState);
const uiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
const tabOrderByWorkspace: Record<string, string[]> = {};
const focusedTabIdByWorkspace: Record<string, string> = {};
for (const key in rawUiTabsByWorkspace) {
const { nextUiTabs, orderFromTabs } = migrateUiTabsForKey(
rawUiTabsByWorkspace[key],
options.now,
);
if (nextUiTabs.length > 0) {
uiTabsByWorkspace[key] = nextUiTabs;
}
if (orderFromTabs.length > 0) {
tabOrderByWorkspace[key] = orderFromTabs;
}
}
mergeExplicitTabOrder(tabOrderByWorkspace, rawOrder);
mergeLegacyTabOrder(tabOrderByWorkspace, legacyOrder);
migrateFocusedTabIds(focusedTabIdByWorkspace, rawFocused);
return {
uiTabsByWorkspace,
tabOrderByWorkspace,
focusedTabIdByWorkspace,
};
}
export function partializeWorkspaceTabsState(
state: WorkspaceTabsCoreState,
options: { now: number },
): WorkspaceTabsCoreState {
const nextUiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
for (const key in state.uiTabsByWorkspace) {
const tabs = (state.uiTabsByWorkspace[key] ?? [])
.map((tab) => {
const normalizedTarget = normalizeWorkspaceTabTarget(tab.target);
const normalizedTabId = trimNonEmpty(tab.tabId);
if (!normalizedTarget || !normalizedTabId) {
return null;
}
return {
tabId: normalizedTabId,
target: normalizedTarget,
createdAt: typeof tab.createdAt === "number" ? tab.createdAt : options.now,
} satisfies WorkspaceTab;
})
.filter((tab): tab is WorkspaceTab => tab !== null);
if (tabs.length > 0) {
nextUiTabsByWorkspace[key] = tabs;
}
}
const nextTabOrderByWorkspace: Record<string, string[]> = {};
for (const key in state.tabOrderByWorkspace) {
const order = normalizeTabOrder(state.tabOrderByWorkspace[key]);
if (order.length > 0) {
nextTabOrderByWorkspace[key] = order;
}
}
const nextFocusedTabIdByWorkspace: Record<string, string> = {};
for (const key in state.focusedTabIdByWorkspace) {
const focusedTabId = trimNonEmpty(state.focusedTabIdByWorkspace[key]);
if (focusedTabId) {
nextFocusedTabIdByWorkspace[key] = focusedTabId;
}
}
return {
uiTabsByWorkspace: nextUiTabsByWorkspace,
tabOrderByWorkspace: nextTabOrderByWorkspace,
focusedTabIdByWorkspace: nextFocusedTabIdByWorkspace,
};
}
@@ -1,8 +1,5 @@
import { generateDraftId } from "@/stores/draft-keys"; import { generateDraftId } from "@/stores/draft-keys";
import { import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model";
buildWorkspaceTabPersistenceKey,
type WorkspaceTabTarget,
} from "@/stores/workspace-tabs-store";
export interface PrepareWorkspaceTabInput { export interface PrepareWorkspaceTabInput {
serverId: string; serverId: string;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab"; import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab";
const SERVER_ID = "server-1"; const SERVER_ID = "server-1";
@@ -44,6 +44,42 @@ describe("provider subagent tab identity", () => {
}); });
}); });
describe("working diff tab identity", () => {
const target = {
kind: "working_diff" as const,
focusPath: "src/example.ts",
focusRequestId: 1,
};
it("normalizes file focus navigation", () => {
expect(
normalizeWorkspaceTabTarget({
...target,
focusPath: " src\\example.ts ",
}),
).toEqual(target);
});
it("treats focus as navigation state rather than tab identity", () => {
expect(workspaceTabTargetsEqual(target, target)).toBe(true);
expect(workspaceTabTargetsEqual(target, { ...target, focusPath: "src/other.ts" })).toBe(false);
expect(workspaceTabTargetsEqual(target, { ...target, focusRequestId: 2 })).toBe(false);
const workingDiffId = buildDeterministicWorkspaceTabId(target);
const otherFocusId = buildDeterministicWorkspaceTabId({
...target,
focusPath: "src/other.ts",
});
const fileId = buildDeterministicWorkspaceTabId({
kind: "file",
path: target.focusPath,
});
expect(workingDiffId).toBe("working_diff");
expect(workingDiffId).toBe(otherFocusId);
expect(workingDiffId).not.toBe(fileId);
});
});
describe("commit diff tab identity", () => { describe("commit diff tab identity", () => {
it("keys a commit diff tab by its sha", () => { it("keys a commit diff tab by its sha", () => {
expect(buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" })).toBe( expect(buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" })).toBe(
+35 -3
View File
@@ -1,7 +1,5 @@
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { normalizeWorkspaceFileLocation, workspaceFileLocationsEqual } from "@/workspace/file-open"; import { normalizeWorkspaceFileLocation, workspaceFileLocationsEqual } from "@/workspace/file-open";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
type WorkspaceDraftTabSetup = NonNullable<Extract<WorkspaceTabTarget, { kind: "draft" }>["setup"]>;
export function normalizeWorkspaceTabTarget( export function normalizeWorkspaceTabTarget(
value: WorkspaceTabTarget | null | undefined, value: WorkspaceTabTarget | null | undefined,
@@ -31,6 +29,9 @@ export function normalizeWorkspaceTabTarget(
if (value.kind === "file") { if (value.kind === "file") {
return normalizeFileTabTarget(value); return normalizeFileTabTarget(value);
} }
if (value.kind === "working_diff") {
return normalizeWorkingDiffTabTarget(value);
}
return normalizeSimpleWorkspaceTabTarget(value); return normalizeSimpleWorkspaceTabTarget(value);
} }
@@ -104,12 +105,22 @@ export function workspaceTabTargetsEqual(
if (left.kind === "terminal" && right.kind === "terminal") { if (left.kind === "terminal" && right.kind === "terminal") {
return left.terminalId === right.terminalId; return left.terminalId === right.terminalId;
} }
return secondaryWorkspaceTabTargetsEqual(left, right);
}
function secondaryWorkspaceTabTargetsEqual(
left: WorkspaceTabTarget,
right: WorkspaceTabTarget,
): boolean {
if (left.kind === "browser" && right.kind === "browser") { if (left.kind === "browser" && right.kind === "browser") {
return left.browserId === right.browserId; return left.browserId === right.browserId;
} }
if (left.kind === "file" && right.kind === "file") { if (left.kind === "file" && right.kind === "file") {
return workspaceFileLocationsEqual(left, right); return workspaceFileLocationsEqual(left, right);
} }
if (left.kind === "working_diff" && right.kind === "working_diff") {
return left.focusPath === right.focusPath && left.focusRequestId === right.focusRequestId;
}
if (left.kind === "setup" && right.kind === "setup") { if (left.kind === "setup" && right.kind === "setup") {
return left.workspaceId === right.workspaceId; return left.workspaceId === right.workspaceId;
} }
@@ -174,6 +185,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
if (target.kind === "commit_diff") { if (target.kind === "commit_diff") {
return `commit_diff_${target.sha}`; return `commit_diff_${target.sha}`;
} }
if (target.kind === "working_diff") {
return "working_diff";
}
return `file_${target.path}`; return `file_${target.path}`;
} }
@@ -192,6 +206,24 @@ function normalizeFileTabTarget(
return location ? { kind: "file", ...location } : null; return location ? { kind: "file", ...location } : null;
} }
function normalizeWorkingDiffTabTarget(
value: Extract<WorkspaceTabTarget, { kind: "working_diff" }>,
): WorkspaceTabTarget | null {
const focusPath = trimNonEmpty(value.focusPath)?.replace(/\\/g, "/") ?? null;
const focusRequestId = normalizePositiveInteger(value.focusRequestId);
return {
kind: "working_diff" as const,
...(focusPath ? { focusPath } : {}),
...(focusRequestId ? { focusRequestId } : {}),
};
}
function normalizePositiveInteger(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: null;
}
function trimOptionalString(value: string | null | undefined): string | null { function trimOptionalString(value: string | null | undefined): string | null {
return value == null ? null : trimNonEmpty(value); return value == null ? null : trimNonEmpty(value);
} }
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { buildWorkspaceTabPersistenceKey } from "./model";
describe("buildWorkspaceTabPersistenceKey", () => {
it("trims and joins opaque server and workspace ids", () => {
expect(
buildWorkspaceTabPersistenceKey({
serverId: " server-1 ",
workspaceId: " setup\\workspace\\ ",
}),
).toBe("server-1:setup\\workspace\\");
});
it("rejects incomplete identities", () => {
expect(buildWorkspaceTabPersistenceKey({ serverId: "", workspaceId: "workspace" })).toBeNull();
expect(buildWorkspaceTabPersistenceKey({ serverId: "server", workspaceId: " " })).toBeNull();
});
});
+46
View File
@@ -0,0 +1,46 @@
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceFileTabTarget } from "@/workspace/file-open";
export interface WorkspaceDraftTabSetup {
provider: AgentProvider;
cwd: string;
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
featureValues: Record<string, unknown>;
}
export interface WorkspaceWorkingDiffTabTarget {
kind: "working_diff";
focusPath?: string;
focusRequestId?: number;
}
export type WorkspaceTabTarget =
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
| { kind: "agent"; agentId: string }
| { kind: "provider_subagent"; parentAgentId: string; subagentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "browser"; browserId: string }
| WorkspaceFileTabTarget
| WorkspaceWorkingDiffTabTarget
| { kind: "setup"; workspaceId: string }
| { kind: "commit_diff"; sha: string };
export interface WorkspaceTab {
tabId: string;
target: WorkspaceTabTarget;
createdAt: number;
}
export function buildWorkspaceTabPersistenceKey(input: {
serverId: string;
workspaceId: string;
}): string | null {
const serverId = input.serverId.trim();
const workspaceId = input.workspaceId.trim();
if (!serverId || !workspaceId) {
return null;
}
return `${serverId}:${workspaceId}`;
}
@@ -8,11 +8,8 @@ import {
type WorktreeArchiveWarningLabels, type WorktreeArchiveWarningLabels,
} from "@/git/worktree-archive-warning"; } from "@/git/worktree-archive-warning";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import { import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
buildWorkspaceTabPersistenceKey, import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive"; import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void { function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
@@ -20,7 +17,6 @@ function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: str
if (workspaceKey) { if (workspaceKey) {
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey); useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
} }
useWorkspaceTabsStore.getState().purgeWorkspace(input);
} }
export interface ArchiveWorkspaceInput { export interface ArchiveWorkspaceInput {