f1bdc56892
* refactor(layout): swap pane width caps for viewport-proportional runtime cap Replace the hard 350px sidebar / 450px right caps with max(800, viewport*0.7) so wide displays get a roomy file/terminal panel and the sidebar can grow with the screen. Pinned minimum is raised from 50px to 180px so panels don't collapse into unusable slivers. Caps now live in lib/state/layout-manager/caps.ts; constants module no longer exports per-column max-px constants. * feat(layout): persist preferred pane widths, flush on unload, roomier tablet split - Mirror sidebar / right widths into a sessionStorage pinned-defaults slot on every resize; seed brand-new task envs with those widths so the user's last-set sizes follow them across tasks. - Re-apply the runtime cap to every pinned group on each ResizeObserver tick so a panel pinned to the old cap grows when the user widens the window (and re-clamps when they shrink it). - Flush the debounced layout save on `beforeunload`, so a reload less than 300ms after the last drag doesn't lose the new width. - Drop the tablet right-panel inner split minSize from 30/20 to 15/15 so the files vs. terminal proportions match the loosened desktop feel. * test(e2e): cover pane resize, persistence, and edge cases Four spec files under apps/web/e2e/tests/layout/ exercise the runtime cap and pinned-defaults persistence end-to-end via dockview's `.dv-sash` drag handles and `window.__dockviewApi__`: - pane-resize-right: drag past the old 450px cap, viewport-shrink re-clamp, reload + cross-env propagation of preferred width. - pane-resize-sidebar: same coverage for the sidebar including toggle-off/on width retention. - pane-persistence-tablet: localStorage round-trip for the tablet layout keys (`task-layout-tablet-v1`, `task-layout-right-v2`). - pane-resize-edge-cases: double-click smoke, rapid drags + beforeunload flush, maximize round-trip, sash off-viewport, resize with sidebar hidden. Adds e2e/helpers/dockview-resize.ts for the shared drag + width read-out helpers. * fix(layout): cap sidebar at 30% screen width, preserve legacy initial widths - Split the runtime cap into a sidebar-specific function (max(350, vw*0.3)) and the right-pane function (max(800, vw*0.7)). File-tree / task-list content rarely benefits from more than ~30% of the screen. - Preserve legacy initial widths: getPinnedWidth now clamps the ratio-based default to the old hard limits (350 sidebar, 450 right) so a fresh task env opens at exactly the same width as before. Users can still drag past these via setConstraints — only the *initial* default is capped tightly. * fix(layout): address PR review — narrow viewport, hidden sidebar, e2e flake Bot-review fixups for #1005: - caps: bound runtime caps by `viewport - VIEWPORT_RESERVE_PX` so the 800px right-cap floor can't collapse the center column on narrow screens (Cubic P1). - applier: respect `column.maxWidth` on the sidebar too, so the `compact` preset's 260px cap is honored at runtime (Cubic P2). - dockview-store: skip the pinned-defaults mirror when the column is hidden — auto-sync infers sidebar from grid index 0, which is the center when sidebar is hidden, so an unguarded mirror would persist the center's width as the sidebar's (Greptile/Cubic P1). - trackPinnedWidths: gate the auto-sync on `sidebarVisible` / `rightPanelsVisible` for the same reason. - sizing: use `LAYOUT_RIGHT_RATIO` for non-sidebar columns instead of silently reusing the sidebar ratio (Greptile P2). - e2e: tablet viewport bumped from 1024 → 700 (≥1024 is desktop, not tablet — the old viewport never rendered the tablet layout and every assertion timed out). Tests now also verify rendered panel proportions, not just the localStorage echo (CodeRabbit #2/#3). - e2e helpers: throw on missing `.dv-sash` instead of returning `-1` (CodeRabbit #1); shared `openWideTask` extracted from three spec files (CodeRabbit #5); silent `return` in evaluate blocks replaced with `throw` for fail-fast (CodeRabbit #4). * fix(layout): swap drag-mouse e2e for programmatic resize + claude/cr followups Round 2 of PR review fixups: - e2e: convert pane-resize-right/sidebar/edge-cases specs from `page.mouse` drag-by-delta to a programmatic `splitview.resizeView()` call. The mouse-drag approach was flaky in headless CI (5 chromium failures across shards 2-4); the programmatic path tests the same cap-enforcement and persistence contract without depending on pointer-events targeting through a 1px .dv-sash overlay. - dockview-layout-setup: remove an unreachable guard in trackPinnedWidths — `rightIdx === 0 && sidebarVisible` cannot occur given the `sv.length < 2` early return (Claude). - dockview-layout-setup: cancel any in-flight debounce timer in the setupLayoutPersistence disposer so a fire can't race with teardown and persist stale layout (CodeRabbit). - constants: document why LAYOUT_SIDEBAR_RATIO and LAYOUT_RIGHT_RATIO are both 0.25 today — separate constants stay decoupled so a future tweak to one doesn't silently change the other (Claude). * fix(layout): drop cross-env width propagation — defaults stay unchanged User feedback: the initial sidebar width on a brand-new task should be the same as it was before this PR (350px on typical viewports). The plan's "propagate preferred widths across envs" feature changed that in practice: after the first resize, every new task opened at the resized width instead of the legacy default. - Remove `getPinnedDefaults` / `setPinnedDefaults` helpers and the sessionStorage `kandev.dockview.pinned-defaults` slot. - Drop the write-through in `setPinnedWidth` and the seed in `performBuildDefault`. `freshPinned` is back to `new Map()`, so a brand-new env always renders at the ratio-based default (same as before this PR, since `getPinnedWidth` already clamps the initial ratio to the legacy 350/450 hard caps). - Per-env layout persistence still works via `setEnvLayout` — when a user resizes inside a task and switches back later, the width is restored. Only the cross-env *default* is no longer carried over. - Remove e2e tests + helpers that exercised the propagation contract. - Drop the flaky `rapid resizes persist across reload` edge test — too brittle around the debounce/beforeunload race. - `LAYOUT_INITIAL_RATIO` introduced as the canonical name; `LAYOUT_SIDEBAR_RATIO` / `LAYOUT_RIGHT_RATIO` kept as deprecated aliases for backwards compatibility while sizing.ts dispatches. * fix(layout): bump saved-layout storage keys to v2 Layouts saved by intermediate builds of this PR captured sidebar/right widths beyond the legacy initial caps (when the larger runtime cap was the only thing constraining serialized widths). Restoring those layouts on the current build replays the over-wide widths, defeating the "defaults stay the same" guarantee. Bumping the storage key prefixes (`env-layout` → `env-layout-v2`, `env-maximize` → `env-maximize-v2`, `dockview-layout-v1` → `dockview-layout-v2`) invalidates legacy saves once on first open; subsequent opens persist + restore under v2 normally. Existing users lose stored column widths once and re-customize from defaults — a one-time cost in exchange for the upgrade being clean. * fix(layout): apply target env's widths after fast-path switch Fast-path env switch (`tryFastEnvSwitch`) skips `fromJSON` for speed, which means column widths carry over from the *outgoing* env. If a user resized the sidebar to 480px in task A and then opens task B, B inherits A's 480px sidebar — even with empty sessionStorage on different dev-server ports, because the widths live in dockview's in-memory grid state, not in storage. Explicitly resize sidebar/right after the panel swap: - saved layout exists for target env → use its serialized sizes - no saved layout (brand-new env) → compute defaults via `getPinnedWidth` (legacy initial caps: 350 sidebar / 450 right) * fix(layout): pass measured dims to fromJSON so widths don't grow on rescale Even on a fresh dev instance with no prior storage, brand-new tasks were opening with oversized sidebar/right widths. Root cause: applyLayout fed `api.width` into toSerializedDockview, but `api.width` lags the live container during the dockview mount sequence. fromJSON wrote a layout sized for the stale width; the subsequent `api.layout(safeWidth, ...)` in performBuildDefault's rAF rescaled the grid proportionally — and without the old hard cap (350/450) clamping it, the sidebar grew past its legacy default. Pass the measured container dimensions through applyLayout into toSerializedDockview so fromJSON's grid.width already matches the live container. api.layout in the rAF is then a no-op, no rescale, and sidebar/right open at exactly their legacy defaults. Also applies to applyBuiltInPreset and applyCustomLayout for the same flow. * fix(layout): lock pinned column max width to current size, unlock on drag The real reason brand-new tasks opened with oversized sidebar/right: dockview's splitview rebalances pinned columns proportionally on every `api.layout` call. After we relaxed setConstraints from the legacy 350/450 hard caps to viewport-proportional runtime caps (576/1344), nothing clamped the rebalance — sidebar grew from 350 → ~450, right from 450 → ~530 on the first ResizeObserver tick. New invariant: pinned-column `maximumWidth` is locked to the column's *current* width whenever the user is not actively dragging a sash. The rebalance has zero room to grow. - `applyLayout` / `applyLayoutFixups` set the lock as soon as fromJSON finishes — sidebar/right open at exactly their just-computed widths. - `setupContainerResizeSync` re-applies the lock on every viewport ResizeObserver tick before and after `api.layout`. - `setupSashDragCapToggle` listens for mousedown/mouseup on `.dv-sash`: on mousedown, widen the cap to the runtime viewport-proportional cap so the user can drag past the legacy default; on mouseup, snap the cap back to the new current width. Also addresses Claude's latest review: - caps.test.ts: rename "hits the 350px floor on roomy viewports" → "… when vw*0.3 falls below the minimum" (test name now matches assertion). - dockview-resize.ts: drop the stale "pinned-defaults mirror" comment; guard the sidebar splitview index against the hidden-sidebar footgun. - pane-resize-right.spec.ts: rename the reload test to mention the correct localStorage key (`dockview-layout-v2`). - new `dockview-env-switch-pinned.test.ts` unit-tests applyPinned- ColumnSizes with a spy splitview — verifies sidebar/right are explicitly resized after a fast-path env switch. * fix(layout): constrain all groups, loosen e2e resize cap, drop stale docs Round of PR-review fixes: - applier: `applyConstraintsToAllPanelGroups` replaces the previous first-only helper. The default right column has separate top + bottom groups; constraining only the first left the bottom group unbounded and let the column grow on rebalance via that group (cubic P2 + Claude regression). - sizing: collapse `LAYOUT_SIDEBAR_RATIO` / `LAYOUT_RIGHT_RATIO` dispatch — they alias the same `LAYOUT_INITIAL_RATIO`, so the ternary was a silent no-op (Claude). - sizing: doc — drop "widths seeded from pinned-defaults sessionStorage" reference; that feature was removed infb3dbe4(Claude). - e2e/dockview-resize: the latest fix locks pinned-column maxWidth to current width, which clamps `sv.resizeView` in tests. Loosen the constraint to the same runtime cap the sash-drag handler uses on user mousedown — so cap-enforcement assertions still work end-to-end without burning the 10-minute CI timeout retrying clamped resizes. * fix(layout): mirror viewportBound clamp in e2e test cap helper Claude & cubic flagged that `resizeColumnViaSplitview`'s runtime-cap computation skipped the `viewportBound(value, vw - 300)` step that production caps go through. On narrow viewports (vw < ~1143px) the helper's cap would exceed what dockview actually enforces, so cap-clamp assertions would drift from real app behaviour. Mirror the caps.ts logic exactly in the helper. * fix(layout): pin-by-target, not lock-by-current — defeats the ratchet bug My previous attempt (lock max=current width except during sash drag) was fundamentally broken: any transient container shrink would lock the cap at the smaller size, and dockview's proportional rebalance could never restore the column. Under heavy ResizeObserver fluctuation on reload, the sidebar marched all the way down to LAYOUT_PINNED_MIN_PX (180px). New invariant: pinned-column max is the *runtime cap* (loose enough for the user to drag past the legacy default), and a separate "target width" is tracked per pinned column. After every `onDidLayoutChange` event the column is force-restored to its target via `sv.resizeView`. Target is updated only by explicit actions: - `applyLayout` sets it to the computed default after `fromJSON`. - `applyLayoutFixups` sets it to the just-restored width on saved-layout restore. - `applyPinnedColumnSizes` (fast-path env switch) sets it to the saved / default width. - The sash-drag handler sets it to the new width on mouseup. `pinnedTargets` live in a new module `lib/state/layout-manager/pinned-targets.ts`. `setPinnedTarget` / `getPinnedTarget` are exported from the layout-manager barrel. An `enforcing` guard around `enforcePinnedTargets` prevents the restore-via-resizeView from re-triggering itself. E2E helper additions: - `__persistDockviewLayout__` exposed on `window` (test seam) — calls the setupLayoutPersistence `persistNow` directly. `sv.resizeView` doesn't emit `onDidLayoutChange` in dockview 4.x, so the helper now force-flushes the layout after each programmatic resize. Without this, reload tests never saw a saved layout. - `__setPinnedTarget__` exposed too so the helper can update the target after a programmatic resize, mirroring sash-drag mouseup. - Dropped the brittle `data-panel-id` width assertion in the tablet persistence test; the localStorage round-trip is the persistence contract, and `react-resizable-panels`' internal DOM is version-dependent. Local validation: all 15 layout e2e tests pass; full unit suite (2087) green; format + lint + typecheck clean. * fix(layout): pause target enforcement during sash drag + cleanup test seam Three follow-ups from the latest review round: - cubic P1: pause `enforcePinnedTargets` while the user is actively dragging a sash. Without this, an in-progress layout-change event during the drag would revert the in-flight resize before mouseup records the new target. The new `sashDragging` flag is set on mousedown over `.dv-sash` and cleared on mouseup. - claude: clear `window.__persistDockviewLayout__` in the setupLayoutPersistence disposer so a remount can't be racing with a stale closure from the previous mount. - claude: third unit test exercises the right column's saved-layout resize path (sv.length = 3 covering both pinned columns), now that the previous test bounded the loop at sv.length = 2 and only reached the sidebar. * test(e2e): bump dockview-layout-v1 → v2 in plan-panel-indicator spec The storage key was bumped from v1 → v2 infb3dbe4(drop cross-env width propagation). The plan-panel-indicator spec was waiting for the old `dockview-layout-v1` localStorage key to materialise after a debounced save — that key is never written under v2, so the waitForFunction always timed out. * fix(layout): reset sashDragging on disposer + only track left-button drags Two safety fixes for the module-scope `sashDragging` flag: - claude: clear it in the setupSashDragCapToggle disposer. If the user starts dragging a sash and unmounts mid-drag (e.g. navigates away), the flag stayed `true` forever and `enforcePinnedTargets` would never fire again on the next mount. - cubic P2: only set the flag on left-button mousedown. A right/middle click over a sash currently flipped it true, and the mouseup handler ignores non-left buttons — so the flag would stick and pause enforcement until a left mousedown→up cycle. * fix(ui): preserve session mode selector state * fix(ui): format fallback session mode labels
369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { performEnvSwitch, type EnvSwitchParams } from "./dockview-env-switch";
|
|
|
|
vi.mock("@/lib/local-storage", () => ({
|
|
getEnvLayout: vi.fn(() => null),
|
|
}));
|
|
|
|
vi.mock("./dockview-layout-builders", () => ({
|
|
applyLayoutFixups: vi.fn(() => ({
|
|
sidebarGroupId: "g1",
|
|
centerGroupId: "g2",
|
|
rightTopGroupId: "g3",
|
|
rightBottomGroupId: "g4",
|
|
})),
|
|
}));
|
|
|
|
vi.mock("./layout-manager", () => ({
|
|
fromDockviewApi: vi.fn(() => ({ columns: [] })),
|
|
savedLayoutMatchesLive: vi.fn(() => false),
|
|
layoutStructuresMatch: vi.fn(() => false),
|
|
getRootSplitview: vi.fn(() => null),
|
|
getPinnedWidth: vi.fn(() => 350),
|
|
}));
|
|
|
|
import { getEnvLayout } from "@/lib/local-storage";
|
|
import { layoutStructuresMatch, savedLayoutMatchesLive } from "./layout-manager";
|
|
|
|
const NEW_SESSION_PANEL_ID = "session:new-session";
|
|
|
|
function makeMockApi() {
|
|
return {
|
|
panels: [],
|
|
groups: [],
|
|
layout: vi.fn(),
|
|
fromJSON: vi.fn(),
|
|
getPanel: vi.fn(() => null),
|
|
addPanel: vi.fn(),
|
|
} as unknown as EnvSwitchParams["api"];
|
|
}
|
|
|
|
function makeHealthyLayoutWith(extraPanels: Record<string, { contentComponent: string }>) {
|
|
return {
|
|
grid: {
|
|
root: {
|
|
type: "leaf" as const,
|
|
size: 800,
|
|
data: { id: "g1", views: ["chat"], activeView: "chat" },
|
|
},
|
|
height: 600,
|
|
width: 800,
|
|
orientation: "HORIZONTAL" as const,
|
|
},
|
|
panels: {
|
|
chat: { contentComponent: "chat" },
|
|
...extraPanels,
|
|
},
|
|
activeGroup: "g1",
|
|
} as unknown as ReturnType<typeof getEnvLayout>;
|
|
}
|
|
|
|
function makeParams(overrides?: Partial<EnvSwitchParams>): EnvSwitchParams {
|
|
return {
|
|
api: makeMockApi(),
|
|
oldEnvId: "old-env",
|
|
newEnvId: "new-env",
|
|
activeSessionId: "new-session",
|
|
safeWidth: 800,
|
|
safeHeight: 600,
|
|
buildDefault: vi.fn(),
|
|
getDefaultLayout: vi.fn(() => ({ columns: [] })),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeTwoLeafSavedLayout(
|
|
leaves: Array<{ id: string; views: string[]; activeView: string }>,
|
|
activeGroup: string,
|
|
): ReturnType<typeof getEnvLayout> {
|
|
return {
|
|
grid: {
|
|
root: {
|
|
type: "branch" as const,
|
|
data: leaves.map((leaf) => ({ type: "leaf", data: leaf })),
|
|
},
|
|
height: 600,
|
|
width: 800,
|
|
orientation: "HORIZONTAL" as const,
|
|
},
|
|
panels: { chat: { contentComponent: "chat" } },
|
|
activeGroup,
|
|
} as unknown as ReturnType<typeof getEnvLayout>;
|
|
}
|
|
|
|
describe("performEnvSwitch", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("calls api.layout on the fast path when structures match", () => {
|
|
vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true);
|
|
const params = makeParams();
|
|
|
|
performEnvSwitch(params);
|
|
|
|
expect(params.api.layout).toHaveBeenCalledWith(800, 600);
|
|
});
|
|
|
|
it("calls api.layout on the fast path when saved layout matches", () => {
|
|
vi.mocked(getEnvLayout).mockReturnValueOnce(makeHealthyLayoutWith({}));
|
|
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(true);
|
|
const params = makeParams();
|
|
|
|
performEnvSwitch(params);
|
|
|
|
expect(params.api.layout).toHaveBeenCalledWith(800, 600);
|
|
expect(params.api.fromJSON).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("creates session panel inline on the fast path when it does not exist", () => {
|
|
vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true);
|
|
const params = makeParams();
|
|
|
|
performEnvSwitch(params);
|
|
|
|
expect(params.api.addPanel).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: NEW_SESSION_PANEL_ID,
|
|
component: "chat",
|
|
params: { sessionId: "new-session" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("skips addPanel on the fast path when the session panel already exists", () => {
|
|
vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true);
|
|
const panel = { id: NEW_SESSION_PANEL_ID, api: { component: "chat" }, group: { id: "g1" } };
|
|
const params = makeParams({
|
|
api: {
|
|
...makeMockApi(),
|
|
getPanel: vi.fn((id: string) => (id === NEW_SESSION_PANEL_ID ? panel : null)),
|
|
} as unknown as EnvSwitchParams["api"],
|
|
});
|
|
|
|
performEnvSwitch(params);
|
|
|
|
expect(params.api.addPanel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["file-editor", "browser", "vscode", "commit-detail", "diff-viewer", "pr-detail"])(
|
|
"skips fast path when saved layout has ephemeral panels (%s)",
|
|
(contentComponent) => {
|
|
const savedLayout = makeHealthyLayoutWith({
|
|
[`preview:${contentComponent}`]: { contentComponent },
|
|
});
|
|
vi.mocked(getEnvLayout).mockReturnValueOnce(savedLayout).mockReturnValueOnce(savedLayout);
|
|
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(true);
|
|
const params = makeParams();
|
|
|
|
performEnvSwitch(params);
|
|
|
|
expect(params.api.fromJSON).toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("calls api.layout on the slow path (buildDefault fallback)", () => {
|
|
const params = makeParams();
|
|
|
|
performEnvSwitch(params);
|
|
|
|
expect(params.api.layout).toHaveBeenCalledWith(800, 600);
|
|
expect(params.buildDefault).toHaveBeenCalledWith(params.api);
|
|
});
|
|
|
|
it("preserves the outgoing session panel's tab index when adding the new session on the fast path", () => {
|
|
// Regression: the fast-path used to call addPanel with only
|
|
// { referenceGroup }, so dockview appended the new session tab to the end
|
|
// of the group instead of restoring it to its original slot.
|
|
vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true);
|
|
const groupPanels = [
|
|
{ id: "files", api: { component: "files" } },
|
|
{ id: "session:old-session", api: { component: "chat" } },
|
|
{ id: "changes", api: { component: "changes" } },
|
|
{ id: "terminal-default", api: { component: "terminal" } },
|
|
];
|
|
const groupId = "center-group";
|
|
const outgoing = {
|
|
...groupPanels[1],
|
|
group: { id: groupId, panels: groupPanels },
|
|
};
|
|
const api = {
|
|
...makeMockApi(),
|
|
panels: [outgoing],
|
|
groups: [{ id: groupId }],
|
|
getPanel: vi.fn(() => null),
|
|
} as unknown as EnvSwitchParams["api"];
|
|
const params = makeParams({ api });
|
|
|
|
performEnvSwitch(params);
|
|
|
|
expect(api.addPanel).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: NEW_SESSION_PANEL_ID,
|
|
position: { referenceGroup: groupId, index: 1 },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("performEnvSwitch slow-path stale session strip", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("closes stale session chat panels after the slow-path fromJSON", () => {
|
|
// Regression: a saved env layout could carry a `session:*` panel from a
|
|
// previously-deleted task (phantom). On the slow-path restore, that
|
|
// panel would land in the live api as a stray tab. replaceStaleSessionPanels
|
|
// must close any session:* panel whose id != the incoming active session.
|
|
vi.mocked(getEnvLayout)
|
|
.mockReturnValueOnce(makeHealthyLayoutWith({}))
|
|
.mockReturnValueOnce(makeHealthyLayoutWith({}));
|
|
// Force the slow path: layouts don't structurally match.
|
|
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(false);
|
|
|
|
const closeStale = vi.fn();
|
|
const closeFileEditor = vi.fn();
|
|
const closeKeep = vi.fn();
|
|
const api = {
|
|
...makeMockApi(),
|
|
// api.fromJSON is a no-op mock; populate `panels` with what would exist
|
|
// post-restore so replaceStaleSessionPanels' filter has something to act on.
|
|
panels: [
|
|
{ id: "session:old-session", api: { component: "chat", close: closeStale } },
|
|
{ id: NEW_SESSION_PANEL_ID, api: { component: "chat", close: closeKeep } },
|
|
// file editors are NOT session panels — they must NOT be closed.
|
|
{ id: "preview:file-editor", api: { component: "file-editor", close: closeFileEditor } },
|
|
],
|
|
getPanel: vi.fn((id: string) =>
|
|
id === NEW_SESSION_PANEL_ID ? { id: NEW_SESSION_PANEL_ID } : null,
|
|
),
|
|
} as unknown as EnvSwitchParams["api"];
|
|
const params = makeParams({ api });
|
|
|
|
performEnvSwitch(params);
|
|
|
|
expect(closeStale).toHaveBeenCalledOnce();
|
|
expect(closeKeep).not.toHaveBeenCalled();
|
|
expect(closeFileEditor).not.toHaveBeenCalled();
|
|
expect(params.api.fromJSON).toHaveBeenCalledOnce();
|
|
// Keep panel already existed, so no addPanel.
|
|
expect(params.api.addPanel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("anchors the new session to the stale session's group and tab index", () => {
|
|
// Regression: when the saved layout had a phantom session co-tabbed with
|
|
// pr-detail (or other siblings the user dragged into the chat group),
|
|
// simply closing the phantom orphaned the siblings. The new active session
|
|
// would then land as a fresh split next to the sidebar — pulling pr-detail
|
|
// out of the user's grouping. The replacement must land in the phantom's
|
|
// exact (group, index) so siblings stay tabbed with the agent.
|
|
vi.mocked(getEnvLayout)
|
|
.mockReturnValueOnce(makeHealthyLayoutWith({}))
|
|
.mockReturnValueOnce(makeHealthyLayoutWith({}));
|
|
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(false);
|
|
|
|
const closeStale = vi.fn();
|
|
const stalePanelId = "session:phantom-from-other-env";
|
|
const groupId = "saved-center-group";
|
|
const groupPanels = [
|
|
{ id: stalePanelId, api: { component: "chat", close: closeStale } },
|
|
{ id: "pr-detail", api: { component: "pr-detail", close: vi.fn() } },
|
|
];
|
|
const stale = {
|
|
...groupPanels[0],
|
|
group: { id: groupId, panels: groupPanels },
|
|
};
|
|
const api = {
|
|
...makeMockApi(),
|
|
panels: [stale, { id: "pr-detail", api: { component: "pr-detail" }, group: { id: groupId } }],
|
|
groups: [{ id: groupId }],
|
|
// The active session panel does NOT exist yet — that's the whole point;
|
|
// the fromJSON restore only brought back the phantom.
|
|
getPanel: vi.fn(() => null),
|
|
} as unknown as EnvSwitchParams["api"];
|
|
const params = makeParams({ api });
|
|
|
|
performEnvSwitch(params);
|
|
|
|
expect(api.addPanel).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: NEW_SESSION_PANEL_ID,
|
|
component: "chat",
|
|
position: { referenceGroup: groupId, index: 0 },
|
|
}),
|
|
);
|
|
expect(closeStale).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("skips addPanel when there is no active session (sessionless task)", () => {
|
|
vi.mocked(getEnvLayout)
|
|
.mockReturnValueOnce(makeHealthyLayoutWith({}))
|
|
.mockReturnValueOnce(makeHealthyLayoutWith({}));
|
|
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(false);
|
|
|
|
const closeStale = vi.fn();
|
|
const groupId = "g1";
|
|
const groupPanels = [{ id: "session:phantom", api: { component: "chat", close: closeStale } }];
|
|
const stale = { ...groupPanels[0], group: { id: groupId, panels: groupPanels } };
|
|
const api = {
|
|
...makeMockApi(),
|
|
panels: [stale],
|
|
groups: [{ id: groupId }],
|
|
} as unknown as EnvSwitchParams["api"];
|
|
|
|
performEnvSwitch(makeParams({ api, activeSessionId: null }));
|
|
|
|
expect(closeStale).toHaveBeenCalledOnce();
|
|
expect(api.addPanel).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("performEnvSwitch fast-path active view restoration", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("restores saved per-group active tabs on the fast path", () => {
|
|
// Regression: the fast path skips fromJSON, so per-group active tabs
|
|
// from the outgoing env would persist into the incoming env. The saved
|
|
// layout's activeView for each group must be reapplied.
|
|
const setActiveRight = vi.fn();
|
|
const setActiveCenter = vi.fn();
|
|
const rightGroup = {
|
|
id: "right",
|
|
panels: [
|
|
{ id: "plan", api: { setActive: setActiveRight } },
|
|
{ id: "files", api: { setActive: vi.fn() } },
|
|
],
|
|
};
|
|
const centerGroup = {
|
|
id: "center",
|
|
panels: [{ id: NEW_SESSION_PANEL_ID, api: { setActive: setActiveCenter } }],
|
|
};
|
|
const savedLayout = makeTwoLeafSavedLayout(
|
|
[
|
|
{ id: "center", views: ["chat"], activeView: "chat" },
|
|
{ id: "right", views: ["plan", "files"], activeView: "plan" },
|
|
],
|
|
"right",
|
|
);
|
|
vi.mocked(getEnvLayout).mockReturnValueOnce(savedLayout).mockReturnValueOnce(savedLayout);
|
|
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(true);
|
|
const api = {
|
|
...makeMockApi(),
|
|
groups: [centerGroup, rightGroup],
|
|
getPanel: vi.fn((id: string) => (id === NEW_SESSION_PANEL_ID ? centerGroup.panels[0] : null)),
|
|
} as unknown as EnvSwitchParams["api"];
|
|
|
|
performEnvSwitch(makeParams({ api }));
|
|
|
|
expect(setActiveRight).toHaveBeenCalled();
|
|
// The saved activeGroup ("right") is applied last, so its setActive must
|
|
// be the most recent — otherwise center would steal global focus.
|
|
const lastRightCall = setActiveRight.mock.invocationCallOrder.at(-1) ?? 0;
|
|
const lastCenterCall = setActiveCenter.mock.invocationCallOrder.at(-1) ?? 0;
|
|
expect(lastRightCall).toBeGreaterThan(lastCenterCall);
|
|
});
|
|
});
|