mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-10 17:27:10 +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>
112 lines
3.5 KiB
TypeScript
112 lines
3.5 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
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', () => {
|
|
expect(
|
|
buildSplitUrl(['a', 'b'], 'https://host:7777/session/other?x=1'),
|
|
).toBe('https://host:7777/?x=1&split=a%2Cb');
|
|
});
|
|
|
|
it('preserves the daemon/token query a dev deployment relies on', () => {
|
|
const url = buildSplitUrl(
|
|
['s1', 's2'],
|
|
'http://localhost:5173/?daemon=http://localhost:9000&token=secret',
|
|
);
|
|
expect(url).toContain('daemon=http');
|
|
expect(url).toContain('token=secret');
|
|
expect(url).toContain('split=s1%2Cs2');
|
|
});
|
|
|
|
it('resets the path so no single-session deep-link competes', () => {
|
|
expect(
|
|
new URL(buildSplitUrl(['a'], 'https://host/session/x')).pathname,
|
|
).toBe('/');
|
|
});
|
|
|
|
it('carries the daemon token in the fragment when provided', () => {
|
|
const url = new URL(
|
|
buildSplitUrl(['a', 'b'], 'https://host/', 'secret-tok'),
|
|
);
|
|
expect(url.searchParams.get('split')).toBe('a,b');
|
|
// In the hash, not the query — never sent to the server / logs.
|
|
expect(url.search).not.toContain('secret-tok');
|
|
expect(new URLSearchParams(url.hash.slice(1)).get('token')).toBe(
|
|
'secret-tok',
|
|
);
|
|
});
|
|
|
|
it('adds no fragment when no token is given', () => {
|
|
expect(buildSplitUrl(['a'], 'https://host/')).not.toContain('#');
|
|
});
|
|
});
|
|
|
|
describe('parseSplitSessionIds', () => {
|
|
it('reads the comma-separated ids', () => {
|
|
expect(parseSplitSessionIds('?split=a,b,c')).toEqual(['a', 'b', 'c']);
|
|
});
|
|
|
|
it('round-trips with buildSplitUrl', () => {
|
|
const url = new URL(buildSplitUrl(['s1', 's2'], 'https://host/'));
|
|
expect(parseSplitSessionIds(url.search)).toEqual(['s1', 's2']);
|
|
});
|
|
|
|
it('returns an empty array when the param is absent or empty', () => {
|
|
expect(parseSplitSessionIds('')).toEqual([]);
|
|
expect(parseSplitSessionIds('?split=')).toEqual([]);
|
|
expect(parseSplitSessionIds('?other=1')).toEqual([]);
|
|
});
|
|
|
|
it('trims and drops blank ids', () => {
|
|
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([]);
|
|
});
|
|
});
|