fix: keep agent tab before PR tab (#1360)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user