Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<HTMLButtonElement>('[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,
Expand Down
42 changes: 35 additions & 7 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2280,21 +2286,43 @@ 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
// view with those sessions on load. Consume the param once so a later reload
// 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]);
Comment on lines +2320 to +2325

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Navigation away from the split view via sidebar session click, new-session creation, or approval-notice button calls setMainView('chat') directly — clearSplitSessions() is never invoked. Storage retains ["A","B"], so a page refresh restores the split the user intentionally navigated away from.

Failure scenario: user in split with sessions A,B → clicks session C in sidebar → setMainView('chat') at line ~6081 bypasses handleSplitExit → refresh → loadSplitSessions() returns ["A","B"] → user is thrown back into the abandoned split.

Multiple setMainView('chat') call sites (~10) share this gap. Consider extending the mirror effect to clear storage when mainView transitions away from 'split' outside a shrink-fold, or routing all intentional departures through handleSplitExit.

Additionally, no App-level unit test asserts that this save effect actually writes to sessionStorage — the write path is only covered by the E2E Playwright spec.

Suggested change
useEffect(() => {
if (externalSplitControlled) return;
if (mainView === 'split' && splitSessionIds.length > 0) {
saveSplitSessions(splitSessionIds);
}
}, [mainView, splitSessionIds, externalSplitControlled]);
useEffect(() => {
if (externalSplitControlled) return;
if (mainView === 'split' && splitSessionIds.length > 0) {
saveSplitSessions(splitSessionIds);
} else if (mainView !== 'split' && !splitFoldedByShrinkRef.current) {
clearSplitSessions();
}
}, [mainView, splitSessionIds, externalSplitControlled]);

— qwen3.7-max via Qwen Code /review

// 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
Expand Down
137 changes: 137 additions & 0 deletions packages/web-shell/client/e2e/web-shell.split-persist.spec.ts
Original file line number Diff line number Diff line change
@@ -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<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);
});
47 changes: 45 additions & 2 deletions packages/web-shell/client/utils/splitUrl.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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([]);
});
});
49 changes: 49 additions & 0 deletions packages/web-shell/client/utils/splitUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading