Skip to content
20 changes: 6 additions & 14 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import type {

import { isGoalGateBlocked as isGoalGateBlockedFor } from './utils/goalGate';
import { keepWorkspaceSplitSessionIds } from './utils/standalone-session-routing';
import { setBoundedMapEntry } from './utils/bounded-map';
import { type SessionGitIntent } from './components/GitModePopover';
import { gitModeIntentMustReset } from './utils/gitModeIntent';
import { LocalControlQrButton } from './components/LocalControlQrButton';
Expand Down Expand Up @@ -1438,20 +1439,6 @@ const SESSION_ATTACHMENT_LIST_FEATURE = 'session_attachment_list';
const BOTTOM_PANEL_GAP_PX = 6;
const BOTTOM_PANEL_FALLBACK_INSET_PX = 40;

function setBoundedMapEntry<V>(
map: Map<string, V>,
key: string,
value: V,
): void {
map.delete(key);
map.set(key, value);
while (map.size > MAX_ARTIFACT_PANEL_SESSION_STATES) {
const oldest = map.keys().next().value;
if (!oldest) break;
map.delete(oldest);
}
}

// One preview tab per image, keyed by its content, so opening several images
// keeps a tab each while re-clicking the same image just focuses its tab.
function imageTabId(src: string): string {
Expand Down Expand Up @@ -4362,6 +4349,7 @@ export function App({
sessionAttachmentsBySessionRef.current,
logicalSessionKey,
[],
MAX_ARTIFACT_PANEL_SESSION_STATES,
);
setSessionAttachments([]);
setSessionAttachmentsLoading(false);
Expand Down Expand Up @@ -4400,6 +4388,7 @@ export function App({
sessionAttachmentsBySessionRef.current,
logicalSessionKey,
attachments,
MAX_ARTIFACT_PANEL_SESSION_STATES,
);
setSessionAttachments(attachments);
setSessionAttachmentsLoading(false);
Expand All @@ -4416,6 +4405,7 @@ export function App({
attachmentRetryCountRef.current,
logicalSessionKey,
failures,
MAX_ARTIFACT_PANEL_SESSION_STATES,
);
retryTimer = setTimeout(
() => setAttachmentRefreshNonce((nonce) => nonce + 1),
Expand All @@ -4428,6 +4418,7 @@ export function App({
sessionAttachmentsBySessionRef.current,
logicalSessionKey,
[],
MAX_ARTIFACT_PANEL_SESSION_STATES,
);
setSessionAttachments([]);
}
Expand Down Expand Up @@ -5834,6 +5825,7 @@ export function App({
artifactPanelDeferredPersistedTabsRef.current,
nextSessionId,
deferredPersistedTabs,
MAX_ARTIFACT_PANEL_SESSION_STATES,
);
} else {
artifactPanelDeferredPersistedTabsRef.current.delete(nextSessionId);
Expand Down
32 changes: 30 additions & 2 deletions packages/web-shell/client/components/RootErrorFallback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ afterEach(() => {
vi.unstubAllEnvs();
});

// Mirror the top-level wiring in index.tsx / main.tsx: the boundary wraps the
// whole App and renders RootErrorFallback (with retry) on a render-phase crash.
// Mirror the embeddable wiring in index.tsx: the boundary wraps the whole App
// and renders RootErrorFallback with an in-place retry. (The standalone entry
// main.tsx instead reloads when the daemon token survives a reload; that path
// is covered in main.test.tsx.)
function RootBoundary({
children,
language,
Expand Down Expand Up @@ -85,11 +87,13 @@ describe('RootErrorFallback', () => {
error={new Error('x')}
onRetry={() => {}}
language="zh-CN"
retryMode="reload"
/>,
);
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
'出了点问题',
);
expect(container.querySelector('button')?.textContent).toBe('重新加载');
Comment thread
wenshao marked this conversation as resolved.
});

it('catches an App-level render crash instead of white-screening', () => {
Expand All @@ -108,6 +112,30 @@ describe('RootErrorFallback', () => {
expect(alert?.textContent).toContain('app render exploded');
});

it('labels the retry button as a reload when retryMode is reload', () => {
const { container } = mount(
<RootErrorFallback
error={new Error('boom')}
onRetry={() => {}}
retryMode="reload"
/>,
);
expect(container.querySelector('button')?.textContent).toBe('Reload page');
});

it('renders the zh-CN retry label in the default reset mode', () => {
// No retryMode: the embeddable call sites (index.tsx, WebShellTranscript)
// rely on the 'reset' default this cell pins.
const { container } = mount(
<RootErrorFallback
error={new Error('boom')}
onRetry={() => {}}
language="zh-CN"
/>,
);
expect(container.querySelector('button')?.textContent).toBe('重试');
});

it('recovers when the user clicks "Try again" after the cause is gone', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
// External switch the child reads at render time. The boundary's reset()
Expand Down
12 changes: 11 additions & 1 deletion packages/web-shell/client/components/RootErrorFallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ interface RootErrorFallbackProps {
onRetry: () => void;
/** Selects the fallback copy. Defaults to English when omitted. */
language?: WebShellLanguage;
/**
* What the retry button does, so its label stays honest. Defaults to
* 'reset' (an in-place boundary retry); 'reload' is for callers whose
* retry navigates (e.g. the standalone root boundary reloads the page).
*/
retryMode?: 'reset' | 'reload';
}

interface FallbackCopy {
title: string;
body: string;
retry: string;
reload: string;
}

// This surface renders OUTSIDE the in-app I18nProvider (the boundary wraps the
Expand All @@ -22,11 +29,13 @@ const COPY: Record<WebShellLanguage, FallbackCopy> = {
title: 'Something went wrong',
body: 'An unexpected error occurred and this content could not be displayed.',
retry: 'Try again',
reload: 'Reload page',
},
'zh-CN': {
title: '出了点问题',
body: '发生意外错误,无法显示此内容。',
retry: '重试',
reload: '重新加载',
Comment thread
wenshao marked this conversation as resolved.
},
};

Expand Down Expand Up @@ -82,6 +91,7 @@ export function RootErrorFallback({
error,
onRetry,
language = 'en',
retryMode = 'reset',
}: RootErrorFallbackProps) {
const copy = COPY[language] ?? COPY.en;
return (
Expand All @@ -97,7 +107,7 @@ export function RootErrorFallback({
</p>
)}
<button type="button" style={buttonStyle} onClick={onRetry}>
{copy.retry}
{retryMode === 'reload' ? copy.reload : copy.retry}
</button>
</div>
);
Expand Down
88 changes: 74 additions & 14 deletions packages/web-shell/client/config/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,30 @@ describe('getDaemonToken', () => {
});
}

// The restore half has to re-define the property with value/writable/
// configurable or window.sessionStorage stays a throwing getter for the
// rest of the file — keep the dance in one place.
async function withSessionStorageThrowing<T>(
run: () => Promise<T>,
): Promise<T> {
const original = window.sessionStorage;
Object.defineProperty(window, 'sessionStorage', {
get() {
throw new Error('storage disabled');
},
configurable: true,
});
try {
return await run();
} finally {
Object.defineProperty(window, 'sessionStorage', {
value: original,
writable: true,
configurable: true,
});
}
}

it('reads the token from the URL fragment', async () => {
setupToken('', '#token=frag-secret');
const mod = await import('./daemon');
Expand Down Expand Up @@ -167,25 +191,61 @@ describe('getDaemonToken', () => {
});

it('degrades gracefully when sessionStorage throws', async () => {
const original = window.sessionStorage;
Object.defineProperty(window, 'sessionStorage', {
get() {
throw new Error('storage disabled');
},
configurable: true,
});
try {
await withSessionStorageThrowing(async () => {
setupToken('', '#token=frag-secret');
const mod = await import('./daemon');
// Same-load behavior is unaffected; only refresh persistence is lost.
expect(mod.getDaemonToken()).toBe('frag-secret');
} finally {
Object.defineProperty(window, 'sessionStorage', {
value: original,
writable: true,
configurable: true,
});
});

describe('hasReloadSurvivableDaemonToken', () => {
it('is true when the URL fragment carries a token', async () => {
setupToken('', '#token=frag-secret');
const mod = await import('./daemon');
expect(mod.hasReloadSurvivableDaemonToken()).toBe(true);
});

it('is true when the query parameter carries a token', async () => {
setupToken('?token=query-secret', '');
const mod = await import('./daemon');
expect(mod.hasReloadSurvivableDaemonToken()).toBe(true);
});

it('is true when a per-tab persisted token exists', async () => {
window.sessionStorage.setItem('qwen-daemon-token', 'stored-secret');
setupToken('', '#/chat');
const mod = await import('./daemon');
expect(mod.hasReloadSurvivableDaemonToken()).toBe(true);
});

it('is false when neither the URL nor storage has a token', async () => {
Comment thread
wenshao marked this conversation as resolved.
setupToken('', '#/chat');
const mod = await import('./daemon');
expect(mod.hasReloadSurvivableDaemonToken()).toBe(false);
});

it('is false when storage is unavailable and the URL has no token', async () => {
Comment thread
wenshao marked this conversation as resolved.
await withSessionStorageThrowing(async () => {
setupToken('', '#/chat');
const mod = await import('./daemon');
expect(mod.hasReloadSurvivableDaemonToken()).toBe(false);
});
}
});

it('is false when the in-memory cache holds a token a reload would lose', async () => {
await withSessionStorageThrowing(async () => {
setupToken('', '#token=boot-secret');
const mod = await import('./daemon');
// Warms the in-memory cache while the persist throws. The predicate
// must NOT consult getDaemonToken(): after boot it always reports a
// token, which would fail-open the reload.
expect(mod.getDaemonToken()).toBe('boot-secret');
// Models removeDaemonTokenFromUrl(): the URL no longer carries it.
setupToken('', '#/chat');
expect(mod.hasReloadSurvivableDaemonToken()).toBe(false);
});
});
});
});

Expand Down
46 changes: 37 additions & 9 deletions packages/web-shell/client/config/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,48 @@ export function persistDaemonToken(token: string): void {
}
}

/**
* The one parse of the URL token grammar: `#token=` (preferred — unlike a
* query param it is never sent to the server, so it stays out of access logs
* and Referer headers; this is what `qwen serve --open` uses) with `?token=`
* as legacy fallback (the dev launcher, hand-built URLs). Shared by
* `getDaemonToken()` (which caches and persists the result) and
* `hasReloadSurvivableDaemonToken()` (which must not touch the cache), so the
* accepted spellings can never drift apart.
*/
function readTokenFromLocation(): string | undefined {
if (typeof window === 'undefined') return undefined;
const fromHash = new URLSearchParams(
window.location.hash.replace(/^#/, ''),
).get('token');
return (
fromHash ||
new URLSearchParams(window.location.search).get('token') ||
undefined
);
}

/**
* Whether a fresh load of this page could still authenticate: a token in the
* URL or in the per-tab persisted copy survives a reload. Retry affordances
* that reload the page must check this first — when the token lives only in
* this module's memory (URL stripped at boot, persist threw), a reload
* strands the shell unauthenticated. Must not consult `getDaemonToken()`:
* its in-memory cache always reports a token after boot.
*/
export function hasReloadSurvivableDaemonToken(): boolean {
Comment thread
wenshao marked this conversation as resolved.
return (
readTokenFromLocation() !== undefined ||
readStoredDaemonToken() !== undefined
);
}

export function getDaemonToken(): string | undefined {
if (cachedDaemonToken) return cachedDaemonToken;
if (typeof window === 'undefined') {
return undefined;
}
// Prefer the URL fragment (#token=) — unlike a ?token= query it is never
// sent to the server, so it stays out of access logs and Referer headers
// (this is what `qwen serve --open` now uses). Fall back to ?token= for
// backward compatibility (e.g. the dev launcher / hand-built URLs).
const fromHash = new URLSearchParams(
window.location.hash.replace(/^#/, ''),
).get('token');
const fromUrl =
fromHash || new URLSearchParams(window.location.search).get('token') || '';
const fromUrl = readTokenFromLocation();
if (fromUrl) {
// Persist per-tab so the token survives navigations that do not carry it.
// sessionStorage (not localStorage) keeps the token scoped to this tab and
Expand Down
Loading
Loading