mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-04 05:40:58 +00:00
The split view (2+ sessions side by side) was lost on every refresh: its pane set lived only in React state, and the one-shot ?split= deep link is consumed on load. Persist the live session set to sessionStorage while the split is the active view, and restore it on load when no ?split= deep link is present, so a refresh brings the split back. sessionStorage (not localStorage) is deliberate: it is scoped per browser tab, so a split opened in its own tab and the in-window split never clobber each other, and a fresh unrelated tab restores nothing — while still surviving a refresh of the same tab. The ?split= URL stays the shareable, cross-tab channel. Only an explicit close (the split's back button) clears the persisted set; detours to a single session keep it, so the split is treated as the user's lasting context until they close it. Controlled hosts (which own their split lifecycle) never auto-persist or auto-restore. Co-authored-by: wenshao <wenshao@example.com>
137 lines
4.5 KiB
TypeScript
137 lines
4.5 KiB
TypeScript
import { expect, test, type Page, type TestInfo } from '@playwright/test';
|
|
import {
|
|
createWebShellDaemonScenario,
|
|
installMockDaemon,
|
|
type MockDaemonController,
|
|
type WebShellDaemonScenario,
|
|
} from './utils/mockDaemon';
|
|
|
|
const WORKSPACE_CWD = '/tmp/qwen-web-shell-e2e';
|
|
const MAIN_SESSION = 'split-main-session';
|
|
const SESSION_A = 'split-session-a';
|
|
const SESSION_B = 'split-session-b';
|
|
const STORAGE_KEY = 'qwen-webshell-split-sessions';
|
|
|
|
function createSplitScenario(): WebShellDaemonScenario {
|
|
const at = '2026-07-03T00:00:00.000Z';
|
|
return createWebShellDaemonScenario({
|
|
workspaceCwd: WORKSPACE_CWD,
|
|
sessionId: MAIN_SESSION,
|
|
sessions: [
|
|
{
|
|
sessionId: MAIN_SESSION,
|
|
workspaceCwd: WORKSPACE_CWD,
|
|
createdAt: at,
|
|
updatedAt: at,
|
|
displayName: 'Main Session',
|
|
clientCount: 1,
|
|
hasActivePrompt: false,
|
|
},
|
|
{
|
|
sessionId: SESSION_A,
|
|
workspaceCwd: WORKSPACE_CWD,
|
|
createdAt: at,
|
|
updatedAt: at,
|
|
displayName: 'Session A',
|
|
clientCount: 0,
|
|
hasActivePrompt: false,
|
|
},
|
|
{
|
|
sessionId: SESSION_B,
|
|
workspaceCwd: WORKSPACE_CWD,
|
|
createdAt: at,
|
|
updatedAt: at,
|
|
displayName: 'Session B',
|
|
clientCount: 0,
|
|
hasActivePrompt: false,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
async function installScenario(
|
|
page: Page,
|
|
scenario: WebShellDaemonScenario,
|
|
testInfo: TestInfo,
|
|
): Promise<MockDaemonController> {
|
|
return installMockDaemon(page, scenario, {
|
|
baseURL: String(testInfo.project.use.baseURL),
|
|
});
|
|
}
|
|
|
|
test('restores the split across a reload and isolates it per tab @smoke', async ({
|
|
page,
|
|
context,
|
|
}, testInfo) => {
|
|
// Wide viewport so the split stays unfolded (it folds below the large-screen
|
|
// breakpoint).
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
|
|
const scenario = createSplitScenario();
|
|
await installScenario(page, scenario, testInfo);
|
|
|
|
// Open the split via the deep link — the exact URL "open in new tab" produces
|
|
// (path reset to `/`, sessions in `?split=`).
|
|
await page.goto(`/?split=${SESSION_A},${SESSION_B}`);
|
|
|
|
const split = page.locator('[data-testid="split-view"]');
|
|
await expect(split).toBeVisible();
|
|
await expect(page.locator('[data-testid="chat-pane"]')).toHaveCount(2);
|
|
|
|
// The session set lands in per-tab storage…
|
|
await expect
|
|
.poll(async () =>
|
|
page.evaluate((key) => window.sessionStorage.getItem(key), STORAGE_KEY),
|
|
)
|
|
.toBe(JSON.stringify([SESSION_A, SESSION_B]));
|
|
|
|
// …and the one-shot deep-link param is consumed so a bookmark isn't sticky.
|
|
await expect.poll(async () => new URL(page.url()).search).toBe('');
|
|
|
|
// Reload (URL is now bare `/`): the split comes back from storage.
|
|
await page.reload();
|
|
await expect(page.locator('[data-testid="split-view"]')).toBeVisible();
|
|
await expect(page.locator('[data-testid="chat-pane"]')).toHaveCount(2);
|
|
|
|
// A brand-new tab has its own sessionStorage, so it must NOT inherit tab 1's
|
|
// split. (If persistence used localStorage, this tab would wrongly reopen it.)
|
|
const page2 = await context.newPage();
|
|
await page2.setViewportSize({ width: 1440, height: 900 });
|
|
await installScenario(page2, scenario, testInfo);
|
|
await page2.goto(`/session/${MAIN_SESSION}`);
|
|
await expect(page2.locator('[data-web-shell-root]')).toBeVisible();
|
|
await expect(page2.locator('[data-testid="split-view"]')).toHaveCount(0);
|
|
});
|
|
|
|
test('leaving the split clears storage so a refresh does not restore it', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
const scenario = createSplitScenario();
|
|
await installScenario(page, scenario, testInfo);
|
|
|
|
await page.goto(`/?split=${SESSION_A},${SESSION_B}`);
|
|
await expect(page.locator('[data-testid="split-view"]')).toBeVisible();
|
|
await expect
|
|
.poll(async () =>
|
|
page.evaluate((key) => window.sessionStorage.getItem(key), STORAGE_KEY),
|
|
)
|
|
.toBe(JSON.stringify([SESSION_A, SESSION_B]));
|
|
|
|
// Leave via the split's back button.
|
|
await page
|
|
.locator('[data-testid="split-view"] header button')
|
|
.first()
|
|
.click();
|
|
await expect(page.locator('[data-testid="split-view"]')).toHaveCount(0);
|
|
await expect
|
|
.poll(async () =>
|
|
page.evaluate((key) => window.sessionStorage.getItem(key), STORAGE_KEY),
|
|
)
|
|
.toBeNull();
|
|
|
|
// A refresh now lands on the normal view, not the split.
|
|
await page.reload();
|
|
await expect(page.locator('[data-web-shell-root]')).toBeVisible();
|
|
await expect(page.locator('[data-testid="split-view"]')).toHaveCount(0);
|
|
});
|