fix(chat): enable in-place subtask navigation in embedded session-chat iframe (#2138)
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { updateBrowserURL } from './serializeRoute';
|
||||
import type { AppRouteState } from './serializeRoute';
|
||||
import { isEmbeddedSessionChat, resetEmbeddedSessionChatCache } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
type HistoryStub = {
|
||||
state: unknown;
|
||||
lastURL: string | null;
|
||||
replaceState(state: unknown, _title: string, url?: string): void;
|
||||
pushState(state: unknown, _title: string, url?: string): void;
|
||||
};
|
||||
|
||||
const installWindow = (href: string): HistoryStub => {
|
||||
const url = new URL(href);
|
||||
const history: HistoryStub = {
|
||||
state: null,
|
||||
lastURL: null,
|
||||
replaceState(state, _title, url) {
|
||||
this.state = state;
|
||||
this.lastURL = url ?? null;
|
||||
},
|
||||
pushState(state, _title, url) {
|
||||
this.state = state;
|
||||
this.lastURL = url ?? null;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
location: {
|
||||
href: url.toString(),
|
||||
origin: url.origin,
|
||||
pathname: url.pathname,
|
||||
search: url.search,
|
||||
},
|
||||
history,
|
||||
},
|
||||
});
|
||||
return history;
|
||||
};
|
||||
|
||||
const historyOf = (): HistoryStub =>
|
||||
(globalThis.window as unknown as { history: HistoryStub }).history;
|
||||
|
||||
beforeEach(() => {
|
||||
installWindow('http://127.0.0.1:5173/app');
|
||||
resetEmbeddedSessionChatCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: originalWindow,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: originalWindow,
|
||||
});
|
||||
});
|
||||
|
||||
const sessionState = (sessionId: string): AppRouteState => ({
|
||||
sessionId,
|
||||
tab: 'chat',
|
||||
isSettingsOpen: false,
|
||||
settingsPath: '',
|
||||
diffFile: null,
|
||||
});
|
||||
|
||||
describe('updateBrowserURL embedded-session-chat guard', () => {
|
||||
test('is a no-op in the embedded session-chat iframe (mirrors isVSCodeContext)', () => {
|
||||
// The embedded iframe's URL identity (ocPanel/sessionId/directory/
|
||||
// readOnly) must never be rewritten. updateBrowserURL rebuilds the
|
||||
// query string from scratch using only session/tab/settings/file —
|
||||
// which would strip ocPanel and break isEmbeddedSessionChat().
|
||||
// The guard prevents this, exactly like isVSCodeContext() does for
|
||||
// VS Code webviews.
|
||||
const history = installWindow(
|
||||
'http://127.0.0.1:5173/app?ocPanel=session-chat&sessionId=ses_child&directory=%2Frepo&readOnly=1',
|
||||
);
|
||||
|
||||
updateBrowserURL(sessionState('ses_grandchild'), { replace: true, force: true });
|
||||
|
||||
// No URL update happened — history was never touched.
|
||||
expect(history.lastURL).toBeNull();
|
||||
});
|
||||
|
||||
test('rewrites the URL normally outside the embedded iframe', () => {
|
||||
installWindow('http://127.0.0.1:5173/app');
|
||||
|
||||
updateBrowserURL(sessionState('ses_main'), { replace: true, force: true });
|
||||
|
||||
const writtenURL = historyOf().lastURL ?? '';
|
||||
expect(writtenURL).toContain('session=ses_main');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmbeddedSessionChat caching', () => {
|
||||
test('caches the first result so URL rewrites cannot flip it (mirrors VS Code stable global)', () => {
|
||||
// VS Code detects its webview via the stable `window.__VSCODE_CONFIG__`
|
||||
// global — it never changes. The embedded iframe's identity is equally
|
||||
// fixed at mount (the parent builds the src); caching the first read
|
||||
// makes detection just as stable, surviving any URL rewrite.
|
||||
//
|
||||
// We need a fresh module cache for this test. Since the cache is
|
||||
// module-level, we test the invariant: once true, always true.
|
||||
installWindow(
|
||||
'http://127.0.0.1:5173/app?ocPanel=session-chat&sessionId=ses_child&directory=%2Frepo&readOnly=1',
|
||||
);
|
||||
|
||||
// First read: caches true.
|
||||
expect(isEmbeddedSessionChat()).toBe(true);
|
||||
|
||||
// Even if the URL were rewritten (the guard above prevents this, but
|
||||
// defense in depth), the cached value stays true.
|
||||
installWindow('http://127.0.0.1:5173/app?session=ses_grandchild');
|
||||
expect(isEmbeddedSessionChat()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
import { ROUTE_PARAMS } from './types';
|
||||
|
||||
/**
|
||||
@@ -100,7 +101,8 @@ function routeMatchesURL(state: AppRouteState): boolean {
|
||||
|
||||
/**
|
||||
* Update the browser URL using pushState or replaceState.
|
||||
* Does nothing if URL already matches or in VS Code context.
|
||||
* Does nothing if URL already matches, in VS Code context, or in the
|
||||
* embedded session-chat iframe (whose URL identity is fixed at mount).
|
||||
*/
|
||||
export function updateBrowserURL(
|
||||
state: AppRouteState,
|
||||
@@ -110,8 +112,10 @@ export function updateBrowserURL(
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip URL updates in VS Code webview
|
||||
if (isVSCodeContext()) {
|
||||
// Both VS Code webviews and embedded session-chat iframes carry session
|
||||
// identity outside the route params (`__VSCODE_CONFIG__` / `?ocPanel=…`).
|
||||
// Rebuilding the URL here would strip those params, so skip entirely.
|
||||
if (isVSCodeContext() || isEmbeddedSessionChat()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user