fix: keep agent tab before PR tab (#1360)

This commit is contained in:
Carlos Florêncio
2026-06-13 23:37:43 +01:00
committed by GitHub
parent c724e38be9
commit 201ebe14d6
2 changed files with 133 additions and 0 deletions
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import type { DockviewApi } from "dockview-react";
import type { TaskSession } from "@/lib/types/http";
import {
ensureSessionTabPrecedesNonSessionTabs,
findSessionAnchorGroupId,
reconcileRemovedSessionPanels,
resolveInitialPosition,
@@ -16,10 +17,25 @@ type FakePanel = {
api: { close: ReturnType<typeof vi.fn<[], void>> };
};
type MoveToOptions = {
group: unknown;
position: "center";
index: number;
skipSetActive: boolean;
};
type MoveToCall = {
panelId: string;
options: MoveToOptions;
};
const KEEP = "keep";
const KEEP_PANEL = `session:${KEEP}`;
const LEAKED_PANEL = "session:leaked";
const PENDING_SESSION_ID = "session-new";
const TEST_GROUP_CENTER = "group-center";
const CENTER_POSITION = "center";
const SIBLING_PANEL = "session:sibling";
/**
* Builds a fake DockviewApi where `panel.api.close()` mutates the underlying
@@ -68,6 +84,42 @@ function panelById(panels: FakePanel[], id: string): FakePanel | undefined {
return panels.find((p) => p.id === id);
}
function makeTabOrderApi(panelIds: string[]): {
api: DockviewApi;
moveToCalls: MoveToCall[];
} {
const moveToCalls: MoveToCall[] = [];
const group = { id: TEST_GROUP_CENTER, panels: [] as Array<{ id: string }> };
const panels = panelIds.map((id) => ({
id,
group,
api: {
moveTo: vi.fn((options: MoveToOptions) => {
moveToCalls.push({ panelId: id, options });
}),
},
}));
group.panels = panels;
return {
api: {
getPanel: (id: string) => panels.find((p) => p.id === id) ?? null,
} as unknown as DockviewApi,
moveToCalls,
};
}
function expectedMove(panelId: string, index: number): MoveToCall {
return {
panelId,
options: {
group: expect.objectContaining({ id: TEST_GROUP_CENTER }),
position: CENTER_POSITION,
index,
skipSetActive: true,
},
};
}
describe("reconcileRemovedSessionPanels", () => {
it("closes a stale tracked panel that's still live in dockview", () => {
// createdSet has session-A; A's panel is live; A is no longer in the task's
@@ -225,6 +277,56 @@ describe("resolveInitialPosition", () => {
});
});
describe("ensureSessionTabPrecedesNonSessionTabs", () => {
it("moves a restored session tab before a PR tab that was saved first", () => {
const { api, moveToCalls } = makeTabOrderApi(["pr-detail", KEEP_PANEL, "preview:file-diff"]);
ensureSessionTabPrecedesNonSessionTabs(api, KEEP);
expect(moveToCalls).toEqual([expectedMove(KEEP_PANEL, 0)]);
});
it("moves interleaved session tabs as a stable block before non-session tabs", () => {
const { api, moveToCalls } = makeTabOrderApi(["pr-detail", SIBLING_PANEL, KEEP_PANEL]);
ensureSessionTabPrecedesNonSessionTabs(api, KEEP);
expect(moveToCalls).toEqual([expectedMove(KEEP_PANEL, 0), expectedMove(SIBLING_PANEL, 0)]);
});
it("does not move when session tabs already precede non-session tabs", () => {
const { api, moveToCalls } = makeTabOrderApi([KEEP_PANEL, SIBLING_PANEL, "pr-detail"]);
ensureSessionTabPrecedesNonSessionTabs(api, KEEP);
expect(moveToCalls).toEqual([]);
});
it("does not move when the session tab already precedes non-session tabs", () => {
const { api, moveToCalls } = makeTabOrderApi([KEEP_PANEL, "pr-detail"]);
ensureSessionTabPrecedesNonSessionTabs(api, KEEP);
expect(moveToCalls).toEqual([]);
});
it("keeps earlier session tabs ahead of the active session tab", () => {
const { api, moveToCalls } = makeTabOrderApi(["session:older", "pr-detail", KEEP_PANEL]);
ensureSessionTabPrecedesNonSessionTabs(api, KEEP);
expect(moveToCalls).toEqual([expectedMove(KEEP_PANEL, 1)]);
});
it("does not move when the target session panel is absent", () => {
const { api, moveToCalls } = makeTabOrderApi(["pr-detail", SIBLING_PANEL]);
ensureSessionTabPrecedesNonSessionTabs(api, KEEP);
expect(moveToCalls).toEqual([]);
});
});
describe("shouldRebuildDefaultForPendingSession", () => {
it("does not rebuild when there is no effective session id", () => {
const api = makePositionApi({
@@ -442,6 +442,36 @@ function removeChatPlaceholder(api: DockviewApi): void {
if (chatPanel) api.removePanel(chatPanel);
}
/**
* Keep agent/session tabs before contextual siblings in their tab group.
*
* Dockview restores saved tab order verbatim. If a task previously saved
* `pr-detail` before `session:<id>`, the restored agent panel already exists,
* so `resolveInitialPosition(... index: 0)` never runs. Move session siblings
* as a stable block so multi-session ordering remains stable.
*/
export function ensureSessionTabPrecedesNonSessionTabs(api: DockviewApi, sessionId: string): void {
const panel = api.getPanel(`session:${sessionId}`);
const groupPanels = panel?.group?.panels;
if (!panel || !groupPanels) return;
const firstNonSessionIndex = groupPanels.findIndex((p) => !p.id.startsWith("session:"));
if (firstNonSessionIndex === -1) return;
const sessionPanelsToMove = groupPanels
.slice(firstNonSessionIndex + 1)
.filter((groupPanel) => groupPanel.id.startsWith("session:"));
for (const sessionPanel of sessionPanelsToMove.reverse()) {
sessionPanel.api.moveTo({
group: panel.group,
position: "center",
index: firstNonSessionIndex,
skipSetActive: true,
});
}
}
/**
* Decide whether to force-activate the session panel after it (and any
* sibling tabs) have been ensured.
@@ -690,6 +720,7 @@ function runAutoSessionTabEffect(
// "chat" placeholder. Order matters: removing chat first would empty and
// destroy the center group, collapsing the horizontal layout.
removeChatPlaceholder(api);
ensureSessionTabPrecedesNonSessionTabs(api, effectiveSessionId);
const activePanel = activateSessionPanel(
api,