diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 1b3bad25138..bf5e5606fa6 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -4,6 +4,7 @@ import { act, createRef } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon'; import type { WebShellApi } from './App'; +import { loadSplitSessions, saveSplitSessions } from './utils/splitUrl'; type StreamingState = 'idle' | 'responding'; @@ -786,6 +787,9 @@ function makePendingPermissionBlock( } beforeEach(() => { + // Split persistence uses sessionStorage; clear it so one test's split doesn't + // auto-restore into the next test's App mount. + sessionStorage.clear(); Object.defineProperty(window, 'matchMedia', { configurable: true, // Query-aware: report a large screen (min-width matches) so the Session @@ -2290,6 +2294,45 @@ describe('App session callbacks', () => { expect(messages?.closest('[aria-hidden="true"]')).not.toBeNull(); }); + it('restores a persisted split on load (survives a refresh)', async () => { + // Simulate the storage left behind by a split that was open before a refresh. + saveSplitSessions(['s1', 's2']); + const { container } = renderApp(); + await flush(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="split-initial"]')?.textContent, + ).toBe('s1,s2'); + }); + + it('does not open the split when nothing was persisted', async () => { + const { container } = renderApp(); + await flush(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + }); + + it('clears the persisted split when the user leaves the split view', async () => { + saveSplitSessions(['s1', 's2']); + const { container } = renderApp(); + await flush(); + // Restored into the split; leaving via its back button must clear storage + // so a later refresh doesn't bring the split back uninvited. + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + await act(async () => { + container + .querySelector('[data-testid="split-back"]') + ?.click(); + await Promise.resolve(); + }); + expect(loadSplitSessions()).toEqual([]); + }); + it('syncs the split view from external session ids without the sidebar', async () => { const { container, rerender } = renderApp({ sidebar: false, diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index a8faf5583cb..66c243129d2 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -93,7 +93,13 @@ import { getScheduledTasksByTurn, } from './components/artifacts/turnOutputSelectors'; import { useIsLargeScreen } from './hooks/useIsLargeScreen'; -import { MAX_SPLIT_PANES, parseSplitSessionIds } from './utils/splitUrl'; +import { + clearSplitSessions, + loadSplitSessions, + MAX_SPLIT_PANES, + parseSplitSessionIds, + saveSplitSessions, +} from './utils/splitUrl'; import { ScheduledTasksDialog } from './components/dialogs/ScheduledTasksDialog'; import { ExtensionsManagerPage } from './components/extensions/ExtensionsManagerPage'; import { PluginManagerPage } from './components/plugins/PluginManagerPage'; @@ -2280,6 +2286,9 @@ export function App({ // to the Session Overview — the hub the split is launched from. const handleSplitExit = useCallback(() => { notifyControlledSplitClose(); + // The user left the split of their own accord, so a refresh must not bring + // it back. (A shrink-fold is transient and deliberately doesn't clear it.) + clearSplitSessions(); openPanel('sessions'); }, [notifyControlledSplitClose, openPanel]); // A `?split=a,b` URL (opened in a new tab from the overview) enters the split @@ -2287,14 +2296,33 @@ export function App({ // or exit doesn't force the split back on. useEffect(() => { const ids = parseSplitSessionIds(window.location.search); - if (ids.length === 0) return; - const url = new URL(window.location.href); - url.searchParams.delete('split'); - window.history.replaceState(null, '', url); - if (!externalSplitControlled) { - openSplitView(ids); + if (ids.length > 0) { + const url = new URL(window.location.href); + url.searchParams.delete('split'); + window.history.replaceState(null, '', url); + if (!externalSplitControlled) { + openSplitView(ids); + } + return; } + // No `?split=` deep link: restore the in-window split the user had before a + // refresh, when one was persisted for this tab. sessionStorage is per-tab, + // so a fresh tab (or a controlled host, which owns its own lifecycle) + // restores nothing. + if (externalSplitControlled) return; + const saved = loadSplitSessions(); + if (saved.length > 0) openSplitView(saved); }, [externalSplitControlled, openSplitView]); + // Mirror the live split session set to per-tab storage while the split is the + // active view, so a refresh restores exactly these panes. Not written when the + // split is merely folded by a shrink (mainView flips to 'chat' transiently) — + // the saved set is kept so growing back, or refreshing mid-fold, still restores. + useEffect(() => { + if (externalSplitControlled) return; + if (mainView === 'split' && splitSessionIds.length > 0) { + saveSplitSessions(splitSessionIds); + } + }, [mainView, splitSessionIds, externalSplitControlled]); // If the viewport shrinks below the large-screen breakpoint, fold away the // Session Overview panel and the split view — both are large-screen-only // surfaces whose entry points are hidden on small screens. The split is only diff --git a/packages/web-shell/client/e2e/web-shell.split-persist.spec.ts b/packages/web-shell/client/e2e/web-shell.split-persist.spec.ts new file mode 100644 index 00000000000..33792623290 --- /dev/null +++ b/packages/web-shell/client/e2e/web-shell.split-persist.spec.ts @@ -0,0 +1,137 @@ +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 { + 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); +}); diff --git a/packages/web-shell/client/utils/splitUrl.test.ts b/packages/web-shell/client/utils/splitUrl.test.ts index e684caf0d6c..f4827015b67 100644 --- a/packages/web-shell/client/utils/splitUrl.test.ts +++ b/packages/web-shell/client/utils/splitUrl.test.ts @@ -1,11 +1,18 @@ +// @vitest-environment jsdom /** * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it } from 'vitest'; -import { buildSplitUrl, parseSplitSessionIds } from './splitUrl'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { + buildSplitUrl, + clearSplitSessions, + loadSplitSessions, + parseSplitSessionIds, + saveSplitSessions, +} from './splitUrl'; describe('buildSplitUrl', () => { it('opens the split for the given sessions on the same origin', () => { @@ -67,3 +74,39 @@ describe('parseSplitSessionIds', () => { expect(parseSplitSessionIds('?split=a,,%20b%20,')).toEqual(['a', 'b']); }); }); + +describe('split session persistence (sessionStorage)', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it('round-trips the saved session set', () => { + saveSplitSessions(['s1', 's2', 's3']); + expect(loadSplitSessions()).toEqual(['s1', 's2', 's3']); + }); + + it('returns an empty array when nothing is saved', () => { + expect(loadSplitSessions()).toEqual([]); + }); + + it('dedupes, drops blanks, and caps at MAX_SPLIT_PANES (6)', () => { + saveSplitSessions(['a', 'a', '', 'b', 'c', 'd', 'e', 'f', 'g']); + expect(loadSplitSessions()).toEqual(['a', 'b', 'c', 'd', 'e', 'f']); + }); + + it('clears the saved set', () => { + saveSplitSessions(['s1', 's2']); + clearSplitSessions(); + expect(loadSplitSessions()).toEqual([]); + }); + + it('falls back to [] on malformed stored JSON', () => { + sessionStorage.setItem('qwen-webshell-split-sessions', '{not json'); + expect(loadSplitSessions()).toEqual([]); + }); + + it('falls back to [] when the stored value is not an array', () => { + sessionStorage.setItem('qwen-webshell-split-sessions', '"s1"'); + expect(loadSplitSessions()).toEqual([]); + }); +}); diff --git a/packages/web-shell/client/utils/splitUrl.ts b/packages/web-shell/client/utils/splitUrl.ts index 6c8de620bf3..274b1bbebb3 100644 --- a/packages/web-shell/client/utils/splitUrl.ts +++ b/packages/web-shell/client/utils/splitUrl.ts @@ -53,3 +53,52 @@ export function parseSplitSessionIds(search: string): string[] { .map((id) => id.trim()) .filter(Boolean); } + +const SPLIT_STORAGE_KEY = 'qwen-webshell-split-sessions'; + +/** + * Persist the in-window split's session set so a refresh restores it. Uses + * `sessionStorage` (not `localStorage`) on purpose: it is scoped per browser + * tab, so a split opened in its own tab (via {@link buildSplitUrl}) and the + * in-window split never clobber each other, and a fresh unrelated tab restores + * nothing. It still survives a refresh of the same tab — the case this fixes. + */ +export function saveSplitSessions(sessions: readonly string[]): void { + const ids = Array.from(new Set(sessions.filter(Boolean))).slice( + 0, + MAX_SPLIT_PANES, + ); + try { + sessionStorage.setItem(SPLIT_STORAGE_KEY, JSON.stringify(ids)); + } catch { + // Private mode / quota / SSR — persistence is best-effort. + } +} + +/** The persisted split session set, or `[]` when absent/unavailable/malformed. */ +export function loadSplitSessions(): string[] { + try { + const raw = sessionStorage.getItem(SPLIT_STORAGE_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return Array.from( + new Set( + parsed.filter( + (id): id is string => typeof id === 'string' && id.length > 0, + ), + ), + ).slice(0, MAX_SPLIT_PANES); + } catch { + return []; + } +} + +/** Forget the persisted split (e.g. when the user leaves the split view). */ +export function clearSplitSessions(): void { + try { + sessionStorage.removeItem(SPLIT_STORAGE_KEY); + } catch { + // best-effort + } +}