fix(web): stop session-tab flicker when same-task sessions have diverged env ids (#1428)
* fix(web): stop session-tab flicker when same-task sessions have diverged env ids * test(web): fail fast on missing dockview bridge and clear diverged-env interval
This commit is contained in:
@@ -468,18 +468,45 @@ const VALID_COMPONENTS = new Set(Object.keys(components));
|
||||
// useEnvSwitchCleanup — backup layout switch for external session changes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useEnvSwitchCleanup(effectiveSessionId: string | null, effectiveEnvId: string | null) {
|
||||
function useEnvSwitchCleanup(
|
||||
effectiveSessionId: string | null,
|
||||
effectiveEnvId: string | null,
|
||||
activeTaskId: string | null,
|
||||
) {
|
||||
const prevEnvRef = useRef<string | null | undefined>(undefined);
|
||||
const prevTaskRef = useRef<string | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
const newEnvId = effectiveEnvId;
|
||||
const newTaskId = activeTaskId;
|
||||
if (prevEnvRef.current === undefined) {
|
||||
prevEnvRef.current = newEnvId;
|
||||
prevTaskRef.current = newTaskId;
|
||||
return;
|
||||
}
|
||||
if (prevEnvRef.current === newEnvId) {
|
||||
prevTaskRef.current = newTaskId;
|
||||
return;
|
||||
}
|
||||
|
||||
// Every session of a task shares ONE task_environment_id by design — the
|
||||
// backend reuses the task's environment for each session it launches (see
|
||||
// assignLaunchTaskEnvironmentID). A same-task env-id *change* between two
|
||||
// real envs therefore only happens via a launch race, and acting on it is
|
||||
// destructive: performEnvSwitch rebuilds the env-keyed layout and strips
|
||||
// the sibling session's chat panel (keepSessionId). With the active session
|
||||
// bouncing between the two envs the session tabs are repeatedly removed and
|
||||
// re-added — the "flicker between the old and new session". Sessions of one
|
||||
// task render in one shared layout, so keep the current layout and do NOT
|
||||
// advance prevEnvRef (preserve the task's stable env for a later real
|
||||
// task switch).
|
||||
const taskChanged = prevTaskRef.current !== newTaskId;
|
||||
if (!taskChanged && prevEnvRef.current && newEnvId) {
|
||||
return;
|
||||
}
|
||||
if (prevEnvRef.current === newEnvId) return;
|
||||
|
||||
const oldEnvId = prevEnvRef.current;
|
||||
prevEnvRef.current = newEnvId;
|
||||
prevTaskRef.current = newTaskId;
|
||||
|
||||
// Portal cleanup is handled synchronously inside switchEnvLayout (in the
|
||||
// dockview store action) before any fromJSON call. This hook serves as a
|
||||
@@ -489,7 +516,7 @@ function useEnvSwitchCleanup(effectiveSessionId: string | null, effectiveEnvId:
|
||||
if (newEnvId) {
|
||||
performLayoutSwitch(oldEnvId, newEnvId, effectiveSessionId);
|
||||
}
|
||||
}, [effectiveEnvId, effectiveSessionId]);
|
||||
}, [effectiveEnvId, effectiveSessionId, activeTaskId]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -521,6 +548,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
|
||||
|
||||
const effectiveSessionId =
|
||||
useAppStore((state) => state.tasks.activeSessionId) ?? sessionId ?? null;
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const effectiveEnvId = useAppStore((state) =>
|
||||
effectiveSessionId ? (state.environmentIdBySessionId[effectiveSessionId] ?? null) : null,
|
||||
);
|
||||
@@ -568,7 +596,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
|
||||
// IMPORTANT: this must run BEFORE useAutoSessionTab so the old layout is
|
||||
// saved before a new session tab is created — otherwise the new session's
|
||||
// panel could leak into the old session's persisted layout.
|
||||
useEnvSwitchCleanup(effectiveSessionId, effectiveEnvId);
|
||||
useEnvSwitchCleanup(effectiveSessionId, effectiveEnvId, activeTaskId);
|
||||
|
||||
// Auto-create a session tab when a session becomes active
|
||||
useAutoSessionTab(effectiveSessionId);
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
/**
|
||||
* Regression: opening / switching to a second agent session in a task that
|
||||
* already has one open must NOT make the UI flicker uncontrollably between the
|
||||
* old and new session.
|
||||
*
|
||||
* Root cause of the original bug: every session of a task is supposed to share
|
||||
* one `task_environment_id` (the backend reuses the task's environment), but a
|
||||
* launch race can hand a second session a *different* env id. Dockview layouts
|
||||
* are keyed by env, so a same-task env change triggered a full env-layout
|
||||
* switch that strips the sibling session's chat panel (keepSessionId). With the
|
||||
* active session bouncing between the two envs, the tabs were repeatedly
|
||||
* removed and re-added — the "flicker between the old and new session" users
|
||||
* reported. Closing one tab stopped it; reopening it restarted it.
|
||||
*
|
||||
* These tests assert the active session settles and, critically, that two
|
||||
* same-task sessions whose env ids differ keep BOTH tabs and never tear the
|
||||
* chat panel down.
|
||||
*/
|
||||
|
||||
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
|
||||
|
||||
type E2EStore = {
|
||||
getState: () => {
|
||||
tasks: { activeSessionId: string | null };
|
||||
};
|
||||
setState: (updater: (s: { environmentIdBySessionId: Record<string, string> }) => void) => void;
|
||||
subscribe: (cb: () => void) => () => void;
|
||||
};
|
||||
|
||||
type DockApi = {
|
||||
panels: Array<{ id: string }>;
|
||||
onDidRemovePanel: (cb: (p: { id: string }) => void) => void;
|
||||
};
|
||||
|
||||
type FlickerWindow = {
|
||||
__KANDEV_E2E_STORE__?: E2EStore;
|
||||
__dockviewApi__?: DockApi;
|
||||
__activeLog__?: Array<string | null>;
|
||||
__panelRemovals__?: string[];
|
||||
__divergeIntervalId__?: number;
|
||||
};
|
||||
|
||||
type SetupResult = {
|
||||
task: { id: string };
|
||||
session: SessionPage;
|
||||
session1Id: string;
|
||||
session2Id: string;
|
||||
};
|
||||
|
||||
async function createTaskWithTwoSessions(
|
||||
testPage: Page,
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
title: string,
|
||||
): Promise<SetupResult> {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
title,
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
return DONE_STATES.includes(sessions[0]?.state ?? "");
|
||||
},
|
||||
{ timeout: 30_000, message: "Waiting for first session to finish" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const card = kanban.taskCardByTitle(title);
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await card.click();
|
||||
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await session.openNewSessionDialog();
|
||||
await expect(session.newSessionDialog()).toBeVisible({ timeout: 5_000 });
|
||||
await session.newSessionPromptInput().fill("/e2e:simple-message");
|
||||
await session.newSessionStartButton().click();
|
||||
await expect(session.newSessionDialog()).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
return sessions.filter((s) => DONE_STATES.includes(s.state)).length;
|
||||
},
|
||||
{ timeout: 60_000, message: "Waiting for both sessions to finish" },
|
||||
)
|
||||
.toBe(2);
|
||||
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
const sorted = sessions.sort(
|
||||
(a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime(),
|
||||
);
|
||||
return { task, session, session1Id: sorted[0].id, session2Id: sorted[1].id };
|
||||
}
|
||||
|
||||
/** Longest run of strict A,B,A,B alternation in a value sequence. */
|
||||
function longestAlternationRun(values: Array<string | null>): number {
|
||||
let best = 0;
|
||||
let run = 0;
|
||||
for (let i = 1; i < values.length; i++) {
|
||||
if (values[i] !== values[i - 1] && (i < 2 || values[i] === values[i - 2])) {
|
||||
run = run === 0 ? 2 : run + 1;
|
||||
} else {
|
||||
run = values[i] !== values[i - 1] ? 2 : 0;
|
||||
}
|
||||
best = Math.max(best, run);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
test.describe("Session flicker", () => {
|
||||
test("opening a second session does not flicker the active session", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const { session, session1Id, session2Id } = await createTaskWithTwoSessions(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Flicker Second Session",
|
||||
);
|
||||
|
||||
await session.sessionTabBySessionId(session1Id).click();
|
||||
await testPage.waitForTimeout(300);
|
||||
|
||||
// Observe every change of the active session over the settle window.
|
||||
await testPage.evaluate(() => {
|
||||
const w = window as unknown as FlickerWindow;
|
||||
const store = w.__KANDEV_E2E_STORE__;
|
||||
if (!store) throw new Error("E2E store bridge missing");
|
||||
const log: Array<string | null> = [];
|
||||
w.__activeLog__ = log;
|
||||
let last = store.getState().tasks.activeSessionId;
|
||||
log.push(last);
|
||||
store.subscribe(() => {
|
||||
const v = store.getState().tasks.activeSessionId;
|
||||
if (v !== last) {
|
||||
last = v;
|
||||
log.push(v);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await session.sessionTabBySessionId(session2Id).click();
|
||||
await testPage.waitForTimeout(2_500);
|
||||
|
||||
const activeLog = await testPage.evaluate(
|
||||
() => (window as unknown as FlickerWindow).__activeLog__ ?? [],
|
||||
);
|
||||
|
||||
// One deliberate switch (A -> B), no sustained oscillation.
|
||||
expect(longestAlternationRun(activeLog)).toBeLessThan(4);
|
||||
expect(activeLog.length).toBeLessThan(5);
|
||||
await expect(session.sessionTabBySessionId(session1Id)).toBeVisible();
|
||||
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible();
|
||||
});
|
||||
|
||||
test("two same-task sessions with diverged env ids keep both tabs and the chat", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const { session, session1Id, session2Id } = await createTaskWithTwoSessions(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Flicker Diverged Env",
|
||||
);
|
||||
|
||||
await session.sessionTabBySessionId(session1Id).click();
|
||||
await testPage.waitForTimeout(300);
|
||||
|
||||
// Simulate the backend launch race: session #2 ends up with a DIFFERENT
|
||||
// task_environment_id than session #1. Keep re-forcing it so trailing WS
|
||||
// env-sync events can't reset it during the test window. Also record any
|
||||
// session chat-panel teardown.
|
||||
await testPage.evaluate((sid2) => {
|
||||
const w = window as unknown as FlickerWindow;
|
||||
const store = w.__KANDEV_E2E_STORE__;
|
||||
if (!store) throw new Error("E2E store bridge missing");
|
||||
// Fail fast if the dockview bridge is absent — otherwise the removal
|
||||
// listener below would never register and the teardown assertion would
|
||||
// pass trivially (false green).
|
||||
const dockviewApi = w.__dockviewApi__;
|
||||
if (!dockviewApi) throw new Error("Dockview API bridge missing");
|
||||
w.__divergeIntervalId__ = window.setInterval(() => {
|
||||
store.setState((s) => {
|
||||
s.environmentIdBySessionId[sid2] = "diverged-env-for-session-2";
|
||||
});
|
||||
}, 30);
|
||||
const removals: string[] = [];
|
||||
w.__panelRemovals__ = removals;
|
||||
dockviewApi.onDidRemovePanel((p: { id: string }) => {
|
||||
if (p.id.startsWith("session:")) removals.push(p.id);
|
||||
});
|
||||
}, session2Id);
|
||||
await testPage.waitForTimeout(100);
|
||||
|
||||
// Switch to session #2 (now on a diverged env) and back — this is what used
|
||||
// to strip the sibling tab and tear the chat down.
|
||||
await session.sessionTabBySessionId(session2Id).click();
|
||||
await testPage.waitForTimeout(800);
|
||||
await session.sessionTabBySessionId(session1Id).click();
|
||||
await testPage.waitForTimeout(800);
|
||||
await session.sessionTabBySessionId(session2Id).click();
|
||||
await testPage.waitForTimeout(1_500);
|
||||
|
||||
// Both tabs must remain present throughout, and the active session's chat
|
||||
// must still be visible (dockview keeps inactive panels mounted-but-hidden,
|
||||
// so assert on a *visible* session-chat rather than the first match).
|
||||
await expect(session.sessionTabBySessionId(session1Id)).toBeVisible();
|
||||
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible();
|
||||
await expect(testPage.locator('[data-testid="session-chat"]:visible').first()).toBeVisible();
|
||||
|
||||
// Both session chat panels must still be mounted, and the layout must not
|
||||
// have torn any of them down as part of a (spurious) env switch — that
|
||||
// teardown is the flicker.
|
||||
const panels = await testPage.evaluate(() => {
|
||||
const api = (window as unknown as { __dockviewApi__?: { panels: Array<{ id: string }> } })
|
||||
.__dockviewApi__;
|
||||
return (api?.panels ?? []).map((p) => p.id).filter((id) => id.startsWith("session:"));
|
||||
});
|
||||
expect(panels).toContain(`session:${session1Id}`);
|
||||
expect(panels).toContain(`session:${session2Id}`);
|
||||
|
||||
const removals = await testPage.evaluate(() => {
|
||||
const w = window as unknown as FlickerWindow;
|
||||
// Stop forcing the diverged env so the interval can't outlive the test.
|
||||
if (w.__divergeIntervalId__ !== undefined) {
|
||||
clearInterval(w.__divergeIntervalId__);
|
||||
w.__divergeIntervalId__ = undefined;
|
||||
}
|
||||
return w.__panelRemovals__ ?? [];
|
||||
});
|
||||
expect(removals).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user