Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 58d06f511f test(app): reproduce stale embedded Markdown images 2026-10-19 10:17:39 +02:00
c575464e8f Skip saving unchanged workspace layouts (#7358)
* perf(app): throttle workspace layout writes and skip no-op persists

Zustand persist calls setItem after every set, even when the updater
returns the same state. The layout store re-normalized every workspace
tree, re-validated the envelope, stringified it, and wrote to
AsyncStorage on each agent status change because reconcileTabs runs from
an effect keyed on agent visibility.

The draft store's throttled writer moves to storage/ as a generic
throttled persist storage, gains a referential short-circuit for a value
whose partialized state was already accepted, and now backs both the
draft and layout stores. The layout store's partialize reuses its
previous projection while every persisted slice is the same reference,
and the store is flushed alongside drafts when the app leaves the
foreground.

Before: 50 no-op reconciles produced 50 writes. After: 0 writes; 50 real
sets inside one interval produce 2 writes (leading and trailing).

Fixes #4814

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(app): build layout stores with options and flush before rehydrating

Six call sites still passed the id factories positionally, which this
branch replaced with an options object, so those stores silently used
default ids. The once-only PR test also rehydrated a second store before
the throttled write landed; flush the source first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(app): avoid shadowing layout options

---------

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 10:02:25 +02:00
paseo-ai[bot] 9a0b95aa10 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-19 07:09:09 +00:00
paseo-bot[bot]andaz33zy 9be1547741 fix(app): show a promoted git action once in the compact menu (#7355)
The compact Changes toolbar flattens every git action into one menu, and
`secondary` keeps the actions the policy can promote to `primary`. When the
policy promoted one of them - Pull behind origin, Push ahead of it, the merge
and PR actions - it rendered twice, under the same React key.

Dedupe by id in `actionsWithoutPrimary` so the split button and the command
center share one rule, cover it in policy.test.ts against real buildGitActions
output, and pin the rendered menu in a compact Changes browser spec.

(cherry picked from commit 9bb26cc471)

Co-authored-by: az33zy <10743275+az33zy@users.noreply.github.com>
2026-10-19 08:54:23 +02:00
11 changed files with 478 additions and 44 deletions
+1 -1
View File
@@ -1 +1 @@
sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
sha256-ObBdUmNSW+vDjzA7EbCDfZrB0nYDmcWmWe+UCoTMh/M=
@@ -0,0 +1,115 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo, resolveTempRoot } from "../support/helpers/workspace";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
// The compact toolbar flattens every git action into one menu, and the policy keeps the actions
// it can promote to `primary` in `secondary` as well. A promoted action must still show once.
const cleanupTasks: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const task of cleanupTasks.splice(0)) {
await task();
}
});
async function createRemoteRepo(prefix: string): Promise<string> {
const repo = await createTempGitRepo(prefix, {
withRemote: true,
files: [{ path: "src/app.ts", content: "export const app = 1;\n" }],
});
cleanupTasks.push(repo.cleanup);
// The helper puts the bare remote inside the checkout; keep it out of the status.
await writeFile(path.join(repo.path, ".git/info/exclude"), "remote.git/\n");
return repo.path;
}
async function registerWorkspace(repoPath: string): Promise<string> {
const client = await connectSeedClient();
cleanupTasks.push(async () => {
await client.close().catch(() => undefined);
});
const created = await client.createWorkspace({ source: { kind: "directory", path: repoPath } });
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${repoPath}`);
}
return created.workspace.id;
}
async function openCompactActionsMenu(page: Page, workspaceId: string) {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
await page.reload();
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
const trigger = explorer.getByTestId("changes-actions-menu-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const menu = page.getByTestId("changes-primary-cta-menu");
await expect(menu).toBeVisible();
return menu;
}
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
const repoPath = await createRemoteRepo("compact-menu-pull-");
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", repoPath]);
});
// Advance origin so the clean checkout is behind it and Pull is the promoted action.
const other = await mkdtemp(path.join(await resolveTempRoot(), "compact-menu-other-"));
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", other]);
});
execFileSync("git", ["clone", path.join(repoPath, "remote.git"), other], { stdio: "ignore" });
execFileSync("git", ["config", "user.email", "e2e@paseo.test"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Paseo E2E"], { cwd: other, stdio: "ignore" });
await writeFile(path.join(other, "src/incoming.ts"), "export const incoming = 1;\n");
execFileSync("git", ["add", "--all"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Incoming commit"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["push", "origin", "main"], { cwd: other, stdio: "ignore" });
execFileSync("git", ["fetch", "origin"], { cwd: repoPath, stdio: "ignore" });
const workspaceId = await registerWorkspace(repoPath);
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Pull",
"Push",
"Pull and push",
"Archive workspace",
]);
});
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
const repoPath = await createRemoteRepo("compact-menu-push-");
cleanupTasks.push(async () => {
execFileSync("rm", ["-rf", repoPath]);
});
const workspaceId = await registerWorkspace(repoPath);
// Commit so the clean checkout is ahead of origin and Push is the promoted action.
await writeFile(path.join(repoPath, "src/mine.ts"), "export const mine = 1;\n");
execFileSync("git", ["add", "--all"], { cwd: repoPath, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Local commit"], { cwd: repoPath, stdio: "ignore" });
const menu = await openCompactActionsMenu(page, workspaceId);
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
await expect(menu.getByRole("menuitem")).toHaveText([
"Push",
"Pull",
"Pull and push",
"Archive workspace",
]);
});
@@ -0,0 +1,91 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
const WHITE = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
"base64",
);
const BLACK = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
function embeddedImage(page: Page): Locator {
return page.getByRole("button", { name: "hero", exact: true });
}
function displayedPixel(image: Locator): Promise<number[]> {
return image.evaluate((element: HTMLImageElement) => {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1;
const context = canvas.getContext("2d")!;
context.drawImage(element, 0, 0, 1, 1);
return Array.from(context.getImageData(0, 0, 1, 1).data);
});
}
async function createGuide(repoPath: string): Promise<void> {
await writeFile(path.join(repoPath, "hero.png"), WHITE);
await writeFile(path.join(repoPath, "guide.md"), "# Guide\n\n![hero](./hero.png)\n");
}
async function openGuidePreview(page: Page): Promise<void> {
await openFileExplorer(page);
await openFileFromExplorer(page, "guide.md");
await expect.poll(() => displayedPixel(embeddedImage(page))).toEqual([255, 255, 255, 255]);
}
async function replaceEmbeddedImage(repoPath: string): Promise<void> {
const imagePath = path.join(repoPath, "hero.png");
await writeFile(imagePath, BLACK);
expect(await readFile(imagePath)).toEqual(BLACK);
}
async function expectReplacementInPreview(page: Page): Promise<void> {
await expect
.configure({ soft: true })
.poll(() => displayedPixel(embeddedImage(page)), { timeout: 5000 })
.toEqual([0, 0, 0, 255]);
}
async function remountMarkdownPreview(page: Page): Promise<void> {
await page.getByRole("button", { name: "Source", exact: true }).click();
await page.getByRole("button", { name: "Preview", exact: true }).click();
await expect.poll(() => displayedPixel(embeddedImage(page))).toEqual([0, 0, 0, 255]);
}
async function expectReplacementWhenOpenedDirectly(page: Page): Promise<void> {
await openFileFromExplorer(page, "hero.png");
// The standalone image viewer has no accessible name for its image.
const image = page.getByTestId("image-file-preview-canvas").locator("img");
await expect.poll(() => displayedPixel(image)).toEqual([0, 0, 0, 255]);
}
test("embedded Markdown image reflects a file replacement while Preview stays open", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "markdown-image-refresh-" });
await createGuide(workspace.repoPath);
await workspace.navigateTo();
await openGuidePreview(page);
const originalSource = await embeddedImage(page).getAttribute("src");
await replaceEmbeddedImage(workspace.repoPath);
await expectReplacementInPreview(page);
await test.info().attach("replacement-observation", {
body: JSON.stringify({
displayedPixel: await displayedPixel(embeddedImage(page)),
sourceUnchanged: originalSource === (await embeddedImage(page).getAttribute("src")),
replacementBytesOnDisk: (await readFile(path.join(workspace.repoPath, "hero.png"))).equals(
BLACK,
),
}),
contentType: "application/json",
});
await remountMarkdownPreview(page);
await expectReplacementWhenOpenedDirectly(page);
});
@@ -1,4 +1,4 @@
import type { GitAction, GitActions } from "@/git/policy";
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type {
WorkspacePanelPlacement,
@@ -581,9 +581,7 @@ export function buildWorkspaceCommandCenterContributions(
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
];
const primary = source.gitActions.primary;
for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue;
for (const [index, action] of actionsWithoutPrimary(source.gitActions).entries()) {
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
@@ -15,7 +15,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { GitAction, GitActions } from "@/git/policy";
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import { useGitActionRunner } from "@/git/use-actions";
import { buttonControlHeight, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
@@ -129,14 +129,10 @@ export function GitActionsSplitButton({
[theme.colors.surface2],
);
const menuOnlyActions = useMemo(
() => [
...(gitActions.primary ? [gitActions.primary] : []),
...gitActions.secondary,
...gitActions.menu,
],
[gitActions.menu, gitActions.primary, gitActions.secondary],
);
const menuOnlyActions = [
...(gitActions.primary ? [gitActions.primary] : []),
...actionsWithoutPrimary(gitActions),
];
if (menuOnly) {
if (menuOnlyActions.length === 0) {
+54 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { buildGitActions, type BuildGitActionsInput } from "./policy";
import { actionsWithoutPrimary, buildGitActions, type BuildGitActionsInput } from "./policy";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
@@ -190,6 +190,59 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
});
it("drops a promoted remote action from the remaining menu actions", () => {
const pull = buildGitActions(createInput({ hasRemote: true, behindOfOrigin: 2 }));
expect(pull.primary?.id).toBe("pull");
expect(actionsWithoutPrimary(pull).map((action) => action.id)).toEqual([
"push",
"pull-and-push",
"archive-workspace",
]);
const push = buildGitActions(createInput({ hasRemote: true, aheadOfOrigin: 1 }));
expect(push.primary?.id).toBe("push");
expect(actionsWithoutPrimary(push).map((action) => action.id)).toEqual([
"pull",
"pull-and-push",
"archive-workspace",
]);
});
it("drops a promoted merge action from the remaining menu actions", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: false,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
}),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id: "merge-pr-merge", label: "Merge PR (merge)" });
expect(actionsWithoutPrimary(actions).map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
"archive-workspace",
]);
});
it("keeps push clickable with a clearer message when the branch diverged", () => {
const actions = buildGitActions(
createInput({
+12
View File
@@ -312,6 +312,18 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
};
}
/**
* The actions a flattened list shows after the primary. `secondary` keeps the actions the policy
* can promote to `primary`, and the translator rebuilds those objects, so a flattening consumer
* has to drop the promoted one by id or it renders it twice.
*/
export function actionsWithoutPrimary(gitActions: GitActions): GitAction[] {
const primaryActionId = gitActions.primary?.id;
return [...gitActions.secondary, ...gitActions.menu].filter(
(action) => action.id !== primaryActionId,
);
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shouldPromoteArchive) {
return "archive-workspace";
@@ -63,3 +63,66 @@ describe("createValidatedPersistStorage", () => {
expect(backing.values.has("settings")).toBe(false);
});
});
it("skips repeated immutable states only when requested", async () => {
let writes = 0;
const backing: StateStorage = {
getItem: () => null,
setItem: () => {
writes += 1;
},
removeItem: () => {},
};
const value = { state: { enabled: true }, version: 1 };
const regular = createValidatedPersistStorage(backing, StateSchema);
await regular.setItem("regular", value);
await regular.setItem("regular", value);
expect(writes).toBe(2);
const deduplicated = createValidatedPersistStorage(backing, StateSchema, { skipUnchanged: true });
await deduplicated.setItem("first", value);
await deduplicated.setItem("first", { ...value });
expect(writes).toBe(3);
await deduplicated.setItem("second", value);
expect(writes).toBe(4);
await deduplicated.setItem("first", { ...value, version: 2 });
expect(writes).toBe(5);
});
it("retries an unchanged state after a failed save", async () => {
let attempts = 0;
const backing = new MemoryStorage();
const storage = createValidatedPersistStorage(
{
getItem: (name) => backing.getItem(name),
setItem: (name, value) => {
attempts += 1;
if (attempts === 1) return Promise.reject(new Error("storage unavailable"));
backing.setItem(name, value);
},
removeItem: (name) => backing.removeItem(name),
},
StateSchema,
{ skipUnchanged: true },
);
const value = { state: { enabled: true }, version: 1 };
await expect(storage.setItem("layout", value)).rejects.toThrow("storage unavailable");
await storage.setItem("layout", value);
expect(attempts).toBe(2);
expect(backing.getItem("layout")).toBe(JSON.stringify(value));
});
it("saves the same state again after removal or rehydration clears invalid storage", async () => {
const backing = new MemoryStorage();
const storage = createValidatedPersistStorage(backing, StateSchema, { skipUnchanged: true });
const value = { state: { enabled: true }, version: 1 };
await storage.setItem("layout", value);
await storage.removeItem("layout");
await storage.setItem("layout", value);
expect(backing.getItem("layout")).toBe(JSON.stringify(value));
backing.values.set("layout", "{");
await expect(storage.getItem("layout")).resolves.toBeNull();
await storage.setItem("layout", value);
expect(backing.getItem("layout")).toBe(JSON.stringify(value));
});
@@ -1,17 +1,22 @@
import { z } from "zod";
import type { PersistStorage, StateStorage } from "zustand/middleware";
import type { PersistStorage, StateStorage, StorageValue } from "zustand/middleware";
export function createValidatedPersistStorage<State>(
backingStorage: StateStorage,
stateSchema: z.ZodType<State>,
// Opt in only when callers preserve state references until values change.
options: { skipUnchanged?: boolean } = {},
): PersistStorage<State> {
const envelopeSchema = z.strictObject({
state: stateSchema,
version: z.number().int().nonnegative().optional(),
});
const accepted = new Map<string, StorageValue<State>>();
return {
getItem: async (name) => {
accepted.delete(name);
const raw = await backingStorage.getItem(name);
if (raw === null) return null;
@@ -31,13 +36,31 @@ export function createValidatedPersistStorage<State>(
return result.data;
},
setItem: async (name, value) => {
const previous = accepted.get(name);
if (
options.skipUnchanged &&
previous &&
previous.state === value.state &&
previous.version === value.version
)
return;
const result = envelopeSchema.safeParse(value);
if (!result.success) {
accepted.delete(name);
await backingStorage.removeItem(name);
return;
}
await backingStorage.setItem(name, JSON.stringify(result.data));
if (options.skipUnchanged) accepted.set(name, value);
try {
await backingStorage.setItem(name, JSON.stringify(result.data));
} catch (error) {
if (accepted.get(name) === value) accepted.delete(name);
throw error;
}
},
removeItem: (name) => {
accepted.delete(name);
return backingStorage.removeItem(name);
},
removeItem: (name) => backingStorage.removeItem(name),
};
}
@@ -0,0 +1,54 @@
import { indexedDB, IDBKeyRange } from "fake-indexeddb";
import { expect, it } from "vitest";
import type { StateStorage } from "zustand/middleware";
import { createWorkspaceLayoutStore } from "./workspace-layout-store";
Object.assign(globalThis, { indexedDB, IDBKeyRange });
it("skips unchanged reconciliations and immediately saves real layout changes", async () => {
const values = new Map<string, string>();
let writes = 0;
const storage: StateStorage = {
getItem: (name) => values.get(name) ?? null,
setItem: (name, value) => {
writes += 1;
values.set(name, value);
},
removeItem: (name) => {
values.delete(name);
},
};
const store = createWorkspaceLayoutStore(undefined, storage);
await store.persist.rehydrate();
const workspaceKey = "server:workspace";
const openAgent = (agentId: string) =>
store.getState().openTab({
workspaceKey,
target: { kind: "agent", agentId },
intent: "reveal",
});
openAgent("agent-1");
const layout = store.getState().layoutByWorkspace[workspaceKey];
const writesBefore = writes;
expect(writesBefore).toBe(1);
for (let index = 0; index < 50; index += 1) {
store.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
autoOpenAgentIds: ["agent-1"],
knownTerminalIds: [],
standaloneTerminalIds: [],
});
}
expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layout);
expect(writes).toBe(writesBefore);
openAgent("agent-2");
expect(writes).toBe(writesBefore + 1);
const restored = createWorkspaceLayoutStore(undefined, storage);
await restored.persist.rehydrate();
expect(restored.getState().layoutByWorkspace[workspaceKey]).toEqual(
store.getState().layoutByWorkspace[workspaceKey],
);
});
@@ -1,7 +1,7 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useState } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { persist, type StateStorage } from "zustand/middleware";
import type { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "./workspace-layout-storage";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
@@ -224,6 +224,7 @@ const MAX_TREE_DEPTH = 5;
const LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH = 1440;
const WORKSPACE_LAYOUT_PERSIST_VERSION = 2;
type WorkspaceLayoutPersistedState = z.infer<typeof WorkspaceLayoutPersistedStateSchema>;
function convertLegacyExplorerSidebarRatios(
ratiosByWorkspace: Record<string, number>,
@@ -939,8 +940,57 @@ function createExplorerSidebarPane(
return targetPaneId ? splitPaneEmpty(workspaceKey, { targetPaneId, position: "right" }) : null;
}
const PERSISTED_SLICES = [
"layoutByWorkspace",
"pinnedAgentIdsByWorkspace",
"splitSizesByWorkspace",
"explorerSidebarWidthByWorkspace",
"explorerSidebarPaneIdByWorkspace",
"sidePaneIdByWorkspace",
"pullRequestTabAutoOpenedByWorkspace",
] as const;
// Zustand persist re-runs partialize after every set, including ones that return the state
// untouched. Reusing the previous projection while every persisted slice is the same
// reference lets validated storage skip unchanged writes before validation and serialization.
function createPersistedStateProjection() {
let previous: { source: WorkspaceLayoutStore; state: WorkspaceLayoutPersistedState } | null =
null;
return (source: WorkspaceLayoutStore): WorkspaceLayoutPersistedState => {
const last = previous;
if (last && PERSISTED_SLICES.every((slice) => last.source[slice] === source[slice])) {
return last.state;
}
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
for (const key in source.layoutByWorkspace) {
// Strip ephemeral (commit diff) tabs before persisting so they are
// dropped on reload rather than restored pointing at a rebased SHA.
layoutByWorkspace[key] = stripEphemeralTabsFromLayout(
normalizeLayout(source.layoutByWorkspace[key]),
);
}
const state: WorkspaceLayoutPersistedState = {
layoutByWorkspace,
pinnedAgentIdsByWorkspace: Object.fromEntries(
Object.entries(source.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [
key,
Array.from(agentIds),
]),
),
splitSizesByWorkspace: source.splitSizesByWorkspace,
explorerSidebarWidthByWorkspace: source.explorerSidebarWidthByWorkspace,
explorerPaneIdByWorkspace: source.explorerSidebarPaneIdByWorkspace,
sidePaneIdByWorkspace: source.sidePaneIdByWorkspace,
pullRequestTabAutoOpenedByWorkspace: source.pullRequestTabAutoOpenedByWorkspace,
};
previous = { source, state };
return state;
};
}
export function createWorkspaceLayoutStore(
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
storage: StateStorage = AsyncStorage,
) {
return create<WorkspaceLayoutStore>()(
persist(
@@ -2117,33 +2167,12 @@ export function createWorkspaceLayoutStore(
{
name: "workspace-layout-state",
version: WORKSPACE_LAYOUT_PERSIST_VERSION,
storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema),
storage: createValidatedPersistStorage(storage, WorkspaceLayoutPersistedStateSchema, {
skipUnchanged: true,
}),
migrate: (persistedState, version) =>
migrateWorkspaceLayoutPersistedState(persistedState, version, ids),
partialize: (state) => {
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
for (const key in state.layoutByWorkspace) {
// Strip ephemeral (commit diff) tabs before persisting so they are
// dropped on reload rather than restored pointing at a rebased SHA.
layoutByWorkspace[key] = stripEphemeralTabsFromLayout(
normalizeLayout(state.layoutByWorkspace[key]),
);
}
return {
layoutByWorkspace,
pinnedAgentIdsByWorkspace: Object.fromEntries(
Object.entries(state.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [
key,
Array.from(agentIds),
]),
),
splitSizesByWorkspace: state.splitSizesByWorkspace,
explorerSidebarWidthByWorkspace: state.explorerSidebarWidthByWorkspace,
explorerPaneIdByWorkspace: state.explorerSidebarPaneIdByWorkspace,
sidePaneIdByWorkspace: state.sidePaneIdByWorkspace,
pullRequestTabAutoOpenedByWorkspace: state.pullRequestTabAutoOpenedByWorkspace,
};
},
partialize: createPersistedStateProjection(),
merge: (persistedState, currentState) => {
const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState);
if (!result.success) {