From 201ebe14d6004213d6f4ebee56e6641bf4c8c349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 13 Jun 2026 23:37:43 +0100 Subject: [PATCH] fix: keep agent tab before PR tab (#1360) --- .../task/dockview-session-tabs.test.ts | 102 ++++++++++++++++++ .../components/task/dockview-session-tabs.ts | 31 ++++++ 2 files changed, 133 insertions(+) diff --git a/apps/web/components/task/dockview-session-tabs.test.ts b/apps/web/components/task/dockview-session-tabs.test.ts index 0a32a3e0f1..dce1f87c62 100644 --- a/apps/web/components/task/dockview-session-tabs.test.ts +++ b/apps/web/components/task/dockview-session-tabs.test.ts @@ -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> }; }; +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({ diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts index 32cf7a955d..13a499b870 100644 --- a/apps/web/components/task/dockview-session-tabs.ts +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -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:`, 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,