fix(app): prevent and recover workspace layout crash loops

This commit is contained in:
Mohamed Boudra
2026-09-14 23:31:31 +02:00
parent 0aca3b605a
commit f7dc069ae5
13 changed files with 477 additions and 190 deletions
@@ -1,5 +1,5 @@
import { expect, test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
@@ -12,6 +12,7 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) =
await page.setViewportSize({ width: 1400, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await page.keyboard.press(CHANGES_SHORTCUT);
@@ -70,6 +70,7 @@ test.describe("Composer control density across tab switches", () => {
});
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await clickNewChat(page);
const draftTabs = page
.locator('[data-testid^="workspace-tab-draft"]')
@@ -1,6 +1,10 @@
import { expect, test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
import {
clickNewChat,
gotoWorkspace,
pressDirectNewTabShortcut,
} from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
@@ -11,6 +15,7 @@ test.describe("Direct terminal shortcut pane placement", () => {
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const focusedPaneChild = page
@@ -1,7 +1,7 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace } from "../support/helpers/launcher";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
@@ -43,6 +43,7 @@ test.describe("New tab keyboard launcher", () => {
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const launcher = visibleNewTabPanel(page);
@@ -107,9 +107,10 @@ test.describe("explorer pane tab placement", () => {
let agentId = "";
try {
await test.step("empty workspace seeds a draft in the main pane", async () => {
await test.step("choose Agent from the empty workspace launcher", async () => {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await openAgentDraftFromLauncher(page);
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
});
@@ -222,3 +223,120 @@ test.describe("explorer pane tab placement", () => {
}
});
});
async function closeOnlyDraft(page: Page): Promise<void> {
await draftTabChip(page).hover();
await page.getByRole("button", { name: "Close", exact: true }).click();
}
async function moveOnlyDraftIntoRightSplit(page: Page): Promise<void> {
await page.getByRole("button", { name: "More actions", exact: true }).click();
await page.getByRole("menuitem", { name: "Split pane right", exact: true }).click();
const target = await emptyPaneBox(page);
await dragChipTo(page, draftTabChip(page), {
x: target.x + target.width / 2,
y: target.y + target.height / 2,
});
await expect(visible(page, "workspace-tabs-row")).toHaveCount(1);
}
async function expectNewLauncher(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-panel").getByRole("button", { name: "Agent", exact: true }),
).toBeVisible();
await expect(draftTabChip(page)).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toHaveCount(0);
}
async function openAgentDraftFromLauncher(page: Page): Promise<void> {
await expectNewLauncher(page);
await page
.getByTestId("workspace-new-tab-panel")
.getByRole("button", { name: "Agent", exact: true })
.click();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
}
async function persistHiddenGeneratedExplorer(page: Page): Promise<void> {
await page.evaluate(() => {
const stored = JSON.parse(localStorage.getItem("workspace-layout-state")!);
const key = Object.keys(stored.state.layoutByWorkspace)[0];
const tabs = [
{ tabId: "files", target: { kind: "files" }, createdAt: 1 },
{ tabId: "changes_tree", target: { kind: "changes_tree" }, createdAt: 1 },
...Array.from({ length: 2250 }, (_, index) => ({
tabId: `draft_saved_${index}`,
target: { kind: "draft", draftId: `draft_saved_${index}` },
createdAt: index + 2,
})),
];
stored.state.layoutByWorkspace[key] = {
root: {
kind: "pane",
pane: {
id: "pane_generated_report_equivalent",
hidden: true,
tabIds: tabs.map((tab) => tab.tabId),
tabs,
focusedTabId: tabs[tabs.length - 1].tabId,
},
},
focusedPaneId: null,
};
stored.state.explorerPaneIdByWorkspace[key] = "pane_generated_report_equivalent";
localStorage.setItem("workspace-layout-state", JSON.stringify(stored));
});
}
// Explorer cannot replace the ordinary workspace canvas, including on restore.
test("closing the last split-born tab with hidden Explorer keeps a usable workspace", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "last-pane-hidden-explorer-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await openAgentDraftFromLauncher(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await openAgentDraftFromLauncher(page);
await moveOnlyDraftIntoRightSplit(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await page.reload();
await expectNewLauncher(page);
} finally {
await workspace.cleanup();
}
});
test("visible Explorer does not replace the last ordinary workspace pane", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "last-pane-visible-explorer-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click();
await openAgentDraftFromLauncher(page);
await moveOnlyDraftIntoRightSplit(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await page.reload();
await expectNewLauncher(page);
} finally {
await workspace.cleanup();
}
});
test("reloading a saved hidden generated Explorer recovers a usable workspace", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "saved-hidden-explorer-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await expectNewLauncher(page);
await persistHiddenGeneratedExplorer(page);
await page.reload();
await expectNewLauncher(page);
} finally {
await workspace.cleanup();
}
});
@@ -156,6 +156,7 @@ test.describe("Tab creation", () => {
const newTabs = page
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
await expect(newTabs.first()).toBeVisible();
const countBefore = await newTabs.count();
await pressNewTabShortcut(page);
@@ -11,6 +11,7 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace } from "../support/helpers/seed-client";
@@ -132,13 +133,14 @@ test.describe("Workspace navigation regression", () => {
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
});
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
test("shows the New launcher after returning from settings and closing the last agent draft tab", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "workspace-settings-back-tab-" });
await workspace.navigateTo();
await clickNewChat(page);
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
await openSettings(page);
@@ -148,7 +150,12 @@ test.describe("Workspace navigation regression", () => {
await closeFirstVisibleDraftTab(page);
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(0);
await expect(
page
.getByTestId("workspace-new-tab-panel")
.getByRole("button", { name: "Agent", exact: true }),
).toBeVisible();
});
test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => {
@@ -740,6 +740,7 @@ function TabChip({
dragHandleProps: DraggableListDragHandleProps | undefined;
}) {
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
const { t } = useTranslation();
const middleClickRef = useMiddleClickClose(
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
);
@@ -889,6 +890,8 @@ function TabChip({
<Pressable
{...(closeButtonDragBlockers as object | undefined)}
testID={closeButtonTestId}
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.menu.close")}
disabled={isClosingTab}
onPressIn={handleCloseButtonPressIn}
onHoverIn={handleCloseButtonHoverIn}
@@ -11,7 +11,6 @@ import {
workspaceTabTargetsEqual,
} from "@/workspace-tabs/identity";
import { createNewWorkspaceTab } from "@/workspace-tabs/new-tab";
import { generateDraftId } from "@/stores/draft-keys";
export interface SplitPane {
id: string;
@@ -145,12 +144,16 @@ interface OpenTabInLayoutResult {
}
interface RetargetTabInLayoutInput {
explorerSidebarPaneId: string | null;
layout: WorkspaceLayout;
tabId: string;
target: WorkspaceTabTarget;
}
interface ReplaceTabTargetInLayoutInput extends RetargetTabInLayoutInput {
interface ReplaceTabTargetInLayoutInput extends Omit<
RetargetTabInLayoutInput,
"explorerSidebarPaneId"
> {
createTabId: () => string;
state?: JsonValue;
}
@@ -161,6 +164,7 @@ interface RetargetTabInLayoutResult {
}
interface ConvertDraftToAgentInLayoutInput {
explorerSidebarPaneId: string | null;
layout: WorkspaceLayout;
tabId: string;
agentId: string;
@@ -179,7 +183,7 @@ interface ReorderFocusedPaneTabsInLayoutInput {
interface CloseTabInLayoutInput {
layout: WorkspaceLayout;
tabId: string;
preserveEmptyPaneId?: string | null;
explorerSidebarPaneId: string | null;
}
interface ClosePaneInLayoutInput {
@@ -1157,14 +1161,54 @@ function restoreEmptyPanesInNode(
});
}
export function restoreEmptyPanesInLayout(
function findOrdinaryPane(
root: SplitNodeInternal,
explorerSidebarPaneId: string | null,
): SplitPaneInternal | null {
if (root.kind === "pane") return root.pane.id === explorerSidebarPaneId ? null : root.pane;
for (const child of root.group.children) {
const pane = findOrdinaryPane(child, explorerSidebarPaneId);
if (pane) return pane;
}
return null;
}
/** Restores the workspace canvas and empty tabs without changing Explorer or saved content. */
export function restoreWorkspaceLayout(
layout: WorkspaceLayout,
explorerSidebarPaneId: string | null = null,
explorerSidebarPaneId: string | null,
createNodeId = defaultWorkspaceLayoutIds.createNodeId,
): WorkspaceLayout {
const normalized = normalizeLayout(layout);
let root = asInternalNode(normalized.root);
let focusedPaneId = normalized.focusedPaneId;
if (!collectAllPanes(root).some((pane) => pane.id !== explorerSidebarPaneId)) {
const ordinaryPane = findOrdinaryPane(root, explorerSidebarPaneId);
if (ordinaryPane) {
root = updatePaneInTree(root, {
paneId: ordinaryPane.id,
updater: (pane) => ({ ...pane, hidden: false }),
});
focusedPaneId = ordinaryPane.id;
} else {
// Explorer may have a generated persisted id; its role, not its spelling,
// determines whether it can serve as the workspace canvas.
focusedPaneId = DEFAULT_PANE_ID;
if (explorerSidebarPaneId === DEFAULT_PANE_ID) {
focusedPaneId = createNodeId("pane");
}
root = createGroupNode({
id: createNodeId("group"),
direction: "horizontal",
children: [createPaneNode({ id: focusedPaneId, tabs: [] }), root],
sizes: [0.78, 0.22],
});
}
}
return {
...normalized,
root: restoreEmptyPanesInNode(asInternalNode(normalized.root), explorerSidebarPaneId),
focusedPaneId,
root: restoreEmptyPanesInNode(root, explorerSidebarPaneId),
};
}
@@ -1470,8 +1514,11 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
return null;
}
const preserveEmptyPaneId =
input.preserveEmptyPaneId ??
(pane.id === DEFAULT_PANE_ID || pane.id === EXPLORER_SIDEBAR_PANE_ID ? pane.id : null);
pane.id === DEFAULT_PANE_ID ||
pane.id === input.explorerSidebarPaneId ||
isLastVisibleOrdinaryPane(input.layout, pane.id, input.explorerSidebarPaneId)
? pane.id
: null;
const closeSuccessorTabId = getCloseSuccessorTabId({
pane,
@@ -1514,6 +1561,17 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
return nextLayoutWithParentMap;
}
function isLastVisibleOrdinaryPane(
layout: WorkspaceLayout,
paneId: string,
explorerSidebarPaneId: string | null | undefined,
): boolean {
const ordinaryPanes = collectAllPanes(layout.root).filter(
(pane) => pane.id !== explorerSidebarPaneId,
);
return ordinaryPanes.length === 1 && ordinaryPanes[0].id === paneId;
}
/**
* Whether dismissing this pane would do anything — removing it, or hiding it if it
* is the Explorer sidebar. A workspace always has somewhere to look, so the last pane the
@@ -1533,10 +1591,9 @@ export function canDismissPaneInLayout(
if (!pane || pane.hidden === true) {
return false;
}
const ordinaryPanes = collectAllPanes(layout.root).filter(
(candidate) => candidate.id !== explorerSidebarPaneId,
);
return paneId === explorerSidebarPaneId ? ordinaryPanes.length > 0 : ordinaryPanes.length > 1;
return paneId === explorerSidebarPaneId
? collectAllPanes(layout.root).some((candidate) => candidate.id !== explorerSidebarPaneId)
: !isLastVisibleOrdinaryPane(layout, paneId, explorerSidebarPaneId);
}
/**
@@ -1553,14 +1610,7 @@ export function closePaneInLayout(input: ClosePaneInLayoutInput): WorkspaceLayou
if (!panePath) {
return null;
}
const visibleOrdinaryPaneIds = listPaneIds(layout.root).filter(
(paneId) => paneId !== input.explorerSidebarPaneId,
);
if (
input.paneId !== input.explorerSidebarPaneId &&
visibleOrdinaryPaneIds.length <= 1 &&
visibleOrdinaryPaneIds.includes(input.paneId)
) {
if (isLastVisibleOrdinaryPane(input.layout, input.paneId, input.explorerSidebarPaneId)) {
return null;
}
@@ -1643,6 +1693,7 @@ export function retargetTabInLayout(
closeTabInLayout({
layout: input.layout,
tabId: input.tabId,
explorerSidebarPaneId: input.explorerSidebarPaneId,
}) ?? input.layout;
return {
layout:
@@ -1806,6 +1857,7 @@ export function convertDraftToAgentInLayout(
closeTabInLayout({
layout: input.layout,
tabId: input.tabId,
explorerSidebarPaneId: input.explorerSidebarPaneId,
}) ?? input.layout;
return {
layout:
@@ -2275,6 +2327,7 @@ function collapseStaleEntityTabs(input: {
snapshot: WorkspaceTabSnapshot;
visibleAgentIds: Set<string>;
knownTerminalIds: Set<string>;
explorerSidebarPaneId: string | null;
}): WorkspaceLayout {
const { snapshot, visibleAgentIds, knownTerminalIds } = input;
let nextLayout = input.layout;
@@ -2284,6 +2337,7 @@ function collapseStaleEntityTabs(input: {
closeTabInLayout({
layout: nextLayout,
tabId: tab.tabId,
explorerSidebarPaneId: input.explorerSidebarPaneId,
}) ?? nextLayout;
}
if (
@@ -2295,6 +2349,7 @@ function collapseStaleEntityTabs(input: {
closeTabInLayout({
layout: nextLayout,
tabId: tab.tabId,
explorerSidebarPaneId: input.explorerSidebarPaneId,
}) ?? nextLayout;
}
}
@@ -2360,43 +2415,6 @@ function addMissingEntityTabs(input: {
return nextLayout;
}
function seedDraftForEmptyWorkspace(input: {
layout: WorkspaceLayout;
snapshot: WorkspaceTabSnapshot;
activeAgentIds: Set<string>;
knownTerminalIds: Set<string>;
explorerSidebarPaneId: string | null;
}): WorkspaceLayout {
const ready = input.snapshot.agentsHydrated && input.snapshot.terminalsHydrated;
const creatingContent =
input.snapshot.hasActivePendingDraftCreate === true ||
input.snapshot.hasActivePendingTerminalCreate === true;
const hasWorkspaceEntities = input.activeAgentIds.size > 0 || input.knownTerminalIds.size > 0;
const explorerTabIds = new Set(
input.explorerSidebarPaneId
? (findPaneById(input.layout.root, input.explorerSidebarPaneId)?.tabIds ?? [])
: [],
);
const hasContentTab = collectAllTabs(input.layout.root).some(
(tab) => tab.target.kind !== "new_tab" && !explorerTabIds.has(tab.tabId),
);
if (!ready || creatingContent || hasWorkspaceEntities || hasContentTab) {
return input.layout;
}
const draftId = generateDraftId();
return (
createTabInLayout({
layout: input.layout,
target: { kind: "draft", draftId },
now: Date.now(),
placement: FOCUSED_PANE_PLACEMENT,
explorerSidebarPaneId: input.explorerSidebarPaneId,
createTabId: () => draftId,
})?.layout ?? input.layout
);
}
export function reconcileWorkspaceTabs(
state: WorkspaceTabReconcileState,
snapshot: WorkspaceTabSnapshot,
@@ -2465,6 +2483,7 @@ export function reconcileWorkspaceTabs(
closeTabInLayout({
layout: nextLayout,
tabId: tab.tabId,
explorerSidebarPaneId: state.explorerSidebarPaneId,
}) ?? nextLayout;
}
}
@@ -2474,6 +2493,7 @@ export function reconcileWorkspaceTabs(
snapshot,
visibleAgentIds,
knownTerminalIds,
explorerSidebarPaneId: state.explorerSidebarPaneId,
});
nextLayout = addMissingEntityTabs({
@@ -2486,14 +2506,6 @@ export function reconcileWorkspaceTabs(
explorerSidebarPaneId: state.explorerSidebarPaneId,
});
nextLayout = seedDraftForEmptyWorkspace({
layout: nextLayout,
snapshot,
activeAgentIds,
knownTerminalIds,
explorerSidebarPaneId: state.explorerSidebarPaneId,
});
if (reconciledFocusedTabId) {
nextLayout =
focusTabInLayout({
@@ -1085,6 +1085,118 @@ describe("workspace-layout-store actions", () => {
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]).toBe(before);
});
it.each(["hideExplorerSidebar", "showExplorerSidebar"] as const)(
"retains the last ordinary split after %s",
(setExplorerVisibility) => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const tabId = store.openTab({
workspaceKey,
target: { kind: "draft", draftId: "draft-origin" },
intent: "new",
}) as string;
store[setExplorerVisibility](workspaceKey);
const splitPaneId = store.splitPaneEmpty(workspaceKey, {
targetPaneId: "main",
position: "right",
}) as string;
store.moveTabToPane(workspaceKey, tabId, splitPaneId);
expect(
findPaneById(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, "main"),
).toBeNull();
store.closeTab(workspaceKey, tabId);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(
collectAllPanes(layout.root)
.filter((pane) => pane.id !== "explorer")
.map((pane) => pane.id),
).toEqual([splitPaneId]);
expect(findPaneById(layout.root, splitPaneId)?.tabIds).toHaveLength(1);
expect(layout.focusedPaneId).toBe(splitPaneId);
},
);
it.each(["explorer", "pane_generated_explorer"])(
"restores an ordinary pane beside saved hidden %s without losing tabs",
async (explorerId) => {
const workspaceKey = createWorkspaceKey();
const saved = createPane({
id: explorerId,
hidden: true,
tabIds: ["files", "draft_saved"],
targetsByTabId: { files: { kind: "files" } },
stateByTabId: { draft_saved: { savedContent: "preserve me" } },
});
await AsyncStorage.setItem(
"workspace-layout-state",
JSON.stringify({
version: 2,
state: {
layoutByWorkspace: { [workspaceKey]: { root: saved, focusedPaneId: null } },
explorerPaneIdByWorkspace: { [workspaceKey]: explorerId },
},
}),
);
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
await restored.persist.rehydrate();
const state = restored.getState();
const layout = state.layoutByWorkspace[workspaceKey];
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]);
expect(layout.focusedPaneId).toBe("main");
expect(findPaneById(layout.root, explorerId)).toEqual(
saved.kind === "pane" ? saved.pane : null,
);
expect(state.explorerSidebarPaneIdByWorkspace[workspaceKey]).toBe(explorerId);
const snapshot = {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
standaloneTerminalIds: [],
};
state.reconcileTabs(workspaceKey, snapshot);
const afterFirstReconcile = restored.getState().layoutByWorkspace[workspaceKey];
state.reconcileTabs(workspaceKey, snapshot);
expect(restored.getState().layoutByWorkspace[workspaceKey]).toEqual(afterFirstReconcile);
expect(
collectAllTabs(afterFirstReconcile.root).filter((tab) => tab.target.kind === "draft"),
).toHaveLength(1);
expect(
collectAllTabs(afterFirstReconcile.root).find((tab) => tab.tabId === "draft_saved")?.state,
).toEqual({ savedContent: "preserve me" });
},
);
it("reveals a saved hidden ordinary pane without replacing its content", async () => {
const workspaceKey = createWorkspaceKey();
const saved = createPane({
id: "pane_saved_ordinary",
hidden: true,
tabIds: ["draft_saved"],
stateByTabId: { draft_saved: { savedContent: "preserve me" } },
});
await AsyncStorage.setItem(
"workspace-layout-state",
JSON.stringify({
version: 2,
state: {
layoutByWorkspace: { [workspaceKey]: { root: saved, focusedPaneId: null } },
},
}),
);
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
await restored.persist.rehydrate();
const layout = restored.getState().layoutByWorkspace[workspaceKey];
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["pane_saved_ordinary"]);
expect(layout.focusedPaneId).toBe("pane_saved_ordinary");
expect(findPaneById(layout.root, "pane_saved_ordinary")?.tabIds).toEqual(["draft_saved"]);
expect(collectAllTabs(layout.root).find((tab) => tab.tabId === "draft_saved")?.state).toEqual({
savedContent: "preserve me",
});
});
it("migrates legacy layouts to include the hidden registered explorer pane", async () => {
const legacyLayout = createDefaultLayout();
await AsyncStorage.setItem(
@@ -3081,6 +3193,13 @@ describe("workspace-layout-store actions", () => {
intent: "reveal",
});
store.closeTab(workspaceKey, tabId!);
store.reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
standaloneTerminalIds: [],
});
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const mainTab = collectAllTabs(layout.root).find(
@@ -3370,95 +3489,10 @@ describe("workspace-layout-store actions", () => {
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("agent_agent-1");
});
it("reconcileTabs lands on a draft when the hydrated workspace is empty", () => {
const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
});
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const mainTab = collectAllTabs(layout.root).find(
(tab) => findPaneContainingTab(layout.root, tab.tabId)?.id === "main",
);
expect(mainTab?.target.kind).toBe("draft");
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(mainTab?.tabId);
});
it("reconcileTabs stays stable when startup removes a duplicate agent kept in Explorer", () => {
it("reconcileTabs initializes the New launcher once when the hydrated workspace is empty", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
workspaceLayoutStore.setState((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
root: {
kind: "group",
group: {
id: "root",
direction: "horizontal",
sizes: [0.78, 0.22],
children: [
createPane({
id: "main",
tabIds: ["new", "draft-origin-agent"],
focusedTabId: "draft-origin-agent",
targetsByTabId: {
new: { kind: "new_tab" },
"draft-origin-agent": { kind: "agent", agentId: "agent-1" },
},
}),
createPane({
id: "explorer",
tabIds: ["files", "changes_tree", "agent_agent-1"],
focusedTabId: "agent_agent-1",
targetsByTabId: {
files: { kind: "files" },
changes_tree: { kind: "changes_tree" },
"agent_agent-1": { kind: "agent", agentId: "agent-1" },
},
}),
],
},
},
focusedPaneId: "main",
},
},
explorerSidebarPaneIdByWorkspace: {
...state.explorerSidebarPaneIdByWorkspace,
[workspaceKey]: "explorer",
},
}));
store.reconcileTabs(workspaceKey, {
agentsHydrated: false,
terminalsHydrated: false,
activeAgentIds: [],
autoOpenAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
});
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].focusedPaneId).toBe(
"main",
);
workspaceLayoutStore.setState((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
...state.layoutByWorkspace[workspaceKey],
focusedPaneId: "explorer",
},
},
}));
const emptySnapshot = {
const snapshot = {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
@@ -3467,21 +3501,117 @@ describe("workspace-layout-store actions", () => {
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
};
store.reconcileTabs(workspaceKey, emptySnapshot);
store.reconcileTabs(workspaceKey, emptySnapshot);
const afterSecondReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
store.reconcileTabs(workspaceKey, emptySnapshot);
const afterThirdReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(afterThirdReconcile).toBe(afterSecondReconcile);
const mainTabs = collectAllTabs(afterThirdReconcile.root).filter(
(tab) => findPaneContainingTab(afterThirdReconcile.root, tab.tabId)?.id === "main",
);
expect(mainTabs).toEqual([
expect.objectContaining({ target: expect.objectContaining({ kind: "draft" }) }),
store.reconcileTabs(workspaceKey, snapshot);
const initialized = workspaceLayoutStore.getState();
const layout = initialized.layoutByWorkspace[workspaceKey];
expect(collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([
{ kind: "new_tab" },
{ kind: "files" },
{ kind: "changes_tree" },
]);
expect(layout.focusedPaneId).toBe("main");
store.reconcileTabs(workspaceKey, snapshot);
expect(workspaceLayoutStore.getState()).toBe(initialized);
});
it.each([
["main", "explorer"],
["pane_ordinary", "pane_generated_explorer"],
])(
"reconcileTabs stays stable when startup removes a duplicate from %s beside %s",
(ordinaryPaneId, explorerPaneId) => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
workspaceLayoutStore.setState((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
root: {
kind: "group",
group: {
id: "root",
direction: "horizontal",
sizes: [0.78, 0.22],
children: [
createPane({
id: ordinaryPaneId,
tabIds: ["draft-origin-agent"],
focusedTabId: "draft-origin-agent",
targetsByTabId: {
"draft-origin-agent": { kind: "agent", agentId: "agent-1" },
},
}),
createPane({
id: explorerPaneId,
tabIds: ["files", "changes_tree", "agent_agent-1"],
focusedTabId: "agent_agent-1",
targetsByTabId: {
files: { kind: "files" },
changes_tree: { kind: "changes_tree" },
"agent_agent-1": { kind: "agent", agentId: "agent-1" },
},
}),
],
},
},
focusedPaneId: ordinaryPaneId,
},
},
explorerSidebarPaneIdByWorkspace: {
...state.explorerSidebarPaneIdByWorkspace,
[workspaceKey]: explorerPaneId,
},
}));
store.reconcileTabs(workspaceKey, {
agentsHydrated: false,
terminalsHydrated: false,
activeAgentIds: [],
autoOpenAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
});
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].focusedPaneId).toBe(
ordinaryPaneId,
);
workspaceLayoutStore.setState((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
...state.layoutByWorkspace[workspaceKey],
focusedPaneId: explorerPaneId,
},
},
}));
const emptySnapshot = {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
};
store.reconcileTabs(workspaceKey, emptySnapshot);
store.reconcileTabs(workspaceKey, emptySnapshot);
const afterSecondReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
store.reconcileTabs(workspaceKey, emptySnapshot);
const afterThirdReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(afterThirdReconcile).toBe(afterSecondReconcile);
const mainTabs = collectAllTabs(afterThirdReconcile.root).filter(
(tab) => findPaneContainingTab(afterThirdReconcile.root, tab.tabId)?.id === ordinaryPaneId,
);
expect(mainTabs).toEqual([expect.objectContaining({ target: { kind: "new_tab" } })]);
},
);
it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
const workspaceKey = createWorkspaceKey();
@@ -36,7 +36,7 @@ import {
openTabInLayoutBackground,
replaceTabTargetInLayout,
revealTargetInLayout,
restoreEmptyPanesInLayout,
restoreWorkspaceLayout,
reconcileWorkspaceTabs,
removePaneFromTree,
removeTabFromTree,
@@ -634,7 +634,7 @@ function ensurePersistedExplorerSidebarPane(input: {
paneId: split.paneId,
hidden: true,
});
const seededLayout = restoreEmptyPanesInLayout(
const seededLayout = restoreWorkspaceLayout(
stripEphemeralTabsFromLayout(hiddenLayout ?? split.layout),
split.paneId,
);
@@ -991,14 +991,10 @@ export function createWorkspaceLayoutStore(
},
};
}
const preserveEmptyPaneId =
closingPane?.id === "main" || closingPane?.id === explorerSidebarPaneId
? closingPane.id
: null;
const closedLayout = closeTabInLayout({
layout,
tabId: normalizedTabId,
preserveEmptyPaneId,
explorerSidebarPaneId,
});
const nextLayoutBeforeFocusNormalization =
closedLayout &&
@@ -1156,8 +1152,13 @@ export function createWorkspaceLayoutStore(
return null;
}
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
const result = convertDraftToAgentInLayout({
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
layout,
explorerSidebarPaneId: resolveExplorerSidebarPaneId(
layout,
get().explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
),
tabId: normalizedTabId,
agentId: normalizedAgentId,
});
@@ -1224,6 +1225,7 @@ export function createWorkspaceLayoutStore(
}
}
if (
state.layoutByWorkspace[normalizedWorkspaceKey] &&
nextLayout === rawLayout &&
pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace
) {
@@ -1400,7 +1402,7 @@ export function createWorkspaceLayoutStore(
}
const restoredLayout =
normalizedToPaneId === explorerSidebarPaneId
? restoreEmptyPanesInLayout(nextLayout, explorerSidebarPaneId)
? restoreWorkspaceLayout(nextLayout, explorerSidebarPaneId)
: nextLayout;
const normalizedNextLayout = keepWorkspaceFocusOutOfExplorerSidebar(
restoredLayout,
@@ -1813,21 +1815,23 @@ export function createWorkspaceLayoutStore(
for (const [workspaceKey, persistedLayout] of Object.entries(
result.data.layoutByWorkspace,
)) {
const strippedLayout = stripEphemeralTabsFromLayout(persistedLayout);
const restoredLayout = restoreWorkspaceLayout(
stripEphemeralTabsFromLayout(persistedLayout),
resolveExplorerSidebarPaneId(
persistedLayout,
explorerSidebarPaneIdByWorkspace[workspaceKey],
),
ids.createNodeId,
);
const explorerSidebar = ensurePersistedExplorerSidebarPane({
layout: strippedLayout,
layout: restoredLayout,
registeredPaneId: explorerSidebarPaneIdByWorkspace[workspaceKey],
ids,
});
if (!explorerSidebar) {
layoutByWorkspace[workspaceKey] = restoreEmptyPanesInLayout(strippedLayout);
continue;
layoutByWorkspace[workspaceKey] = explorerSidebar?.layout ?? restoredLayout;
if (explorerSidebar) {
explorerSidebarPaneIdByWorkspace[workspaceKey] = explorerSidebar.paneId;
}
layoutByWorkspace[workspaceKey] = restoreEmptyPanesInLayout(
explorerSidebar.layout,
explorerSidebar.paneId,
);
explorerSidebarPaneIdByWorkspace[workspaceKey] = explorerSidebar.paneId;
}
return {
...currentState,
@@ -781,6 +781,7 @@ async function openSmokeWorkspace({ appPath, env, page, daemonHome }) {
});
await page.getByRole("button", { name: "Sandbox smoke workspace", exact: true }).click();
await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${workspace.workspaceId}`));
await page.getByRole("button", { name: "Agent", exact: true }).click();
await page.getByRole("textbox", { name: "Message agent..." }).click();
console.log(`Packaged desktop smoke: renderer opened workspace ${workspace.workspaceId}`);
}