fix(chat): enable in-place subtask navigation in embedded session-chat iframe (#2138)
This commit is contained in:
@@ -54,6 +54,7 @@ import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
import { SyncAppEffects } from '@/apps/AppEffects';
|
||||
import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset';
|
||||
import { useAppFontEffects } from '@/apps/useAppFontEffects';
|
||||
@@ -117,15 +118,11 @@ const normalizeEmbeddedDirectory = (value: string | null | undefined): string =>
|
||||
};
|
||||
|
||||
const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
if (typeof window === 'undefined' || !isEmbeddedSessionChat()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('ocPanel') !== 'session-chat') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionIdRaw = params.get('sessionId');
|
||||
const sessionId = typeof sessionIdRaw === 'string' ? sessionIdRaw.trim() : '';
|
||||
if (!sessionId) {
|
||||
@@ -171,7 +168,12 @@ const EmbeddedSessionChatContent: React.FC<{
|
||||
if (expectedDirectory && activeDirectory !== expectedDirectory) return;
|
||||
|
||||
const bootstrapKey = `${expectedDirectory}\n${embeddedSessionChat.sessionId}`;
|
||||
if (bootstrapKeyRef.current === bootstrapKey && currentSessionId === embeddedSessionChat.sessionId) {
|
||||
// Skip if this session was already bootstrapped and a session is still
|
||||
// active — allows in-place navigation (e.g. "Open subtask") to change
|
||||
// currentSessionId without this effect forcing it back. Only re-bootstrap
|
||||
// when currentSessionId was cleared (store init, draft, delete/archive,
|
||||
// runtime-switch remount).
|
||||
if (bootstrapKeyRef.current === bootstrapKey && currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import { usePlanDetection } from '@/hooks/usePlanDetection';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { getEmbeddedSessionChatOriginSessionId } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
|
||||
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||
@@ -564,13 +565,22 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
|
||||
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
|
||||
|
||||
// In the embedded session-chat iframe, hide "Return to parent" when
|
||||
// viewing the panel's anchor session (the one recorded in the URL). Going
|
||||
// up from the anchor would show the primary session that's already in the
|
||||
// main chat. Drilling into a deeper subtask (currentSessionId ≠ anchor)
|
||||
// re-enables the button to navigate back to the embedded session.
|
||||
const embeddedPanelAnchorSessionId = getEmbeddedSessionChatOriginSessionId();
|
||||
const hideReturnToParent =
|
||||
embeddedPanelAnchorSessionId !== null && currentSessionId === embeddedPanelAnchorSessionId;
|
||||
|
||||
const handleReturnToParentSession = React.useCallback(() => {
|
||||
if (!parentSession) return;
|
||||
const parentDirectory = (parentSession as Session & { directory?: string | null }).directory ?? null;
|
||||
setCurrentSession(parentSession.id, parentDirectory);
|
||||
}, [parentSession, setCurrentSession]);
|
||||
|
||||
const returnToParentButton = parentSession ? (
|
||||
const returnToParentButton = parentSession && !hideReturnToParent ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
sendImplementationResponseToReviewer,
|
||||
sendReviewFeedbackToOriginal,
|
||||
} from '@/lib/reviewFlow';
|
||||
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
|
||||
|
||||
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
|
||||
@@ -252,7 +253,11 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
|
||||
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
||||
onClick={() => {
|
||||
if (!effectiveDirectory) return;
|
||||
if (isMobile || isVSCodeRuntime()) {
|
||||
// In contexts with no ContextPanel (embedded
|
||||
// session-chat iframe) or single-surface layouts
|
||||
// (mobile, VS Code), navigate in place. Otherwise
|
||||
// open a new side-panel tab.
|
||||
if (isEmbeddedSessionChat() || isMobile || isVSCodeRuntime()) {
|
||||
setCurrentSession(taskSessionID, effectiveDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getDiffPatchEntries, getPatchText, type DiffPatchEntry } from './toolDiffUtils';
|
||||
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
|
||||
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal';
|
||||
const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS);
|
||||
@@ -1374,7 +1375,10 @@ const TaskToolSummary: React.FC<{
|
||||
const handleOpenSession = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (sessionId && currentDirectory) {
|
||||
if (isMobile || runtime?.runtime.isVSCode) {
|
||||
// In contexts with no ContextPanel (embedded session-chat iframe)
|
||||
// or single-surface layouts (mobile, VS Code), navigate in place.
|
||||
// Otherwise open a new side-panel tab.
|
||||
if (isEmbeddedSessionChat() || isMobile || runtime?.runtime.isVSCode) {
|
||||
setCurrentSession(sessionId, currentDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import type { Theme } from '@/types/theme';
|
||||
import { buildEmbeddedSessionChatURL, getOrCreateEmbeddedSessionChatURL, type EmbeddedSessionChatURLCacheEntry } from './contextPanelEmbeddedChat';
|
||||
import {
|
||||
buildEmbeddedSessionChatURL,
|
||||
getOrCreateEmbeddedSessionChatURL,
|
||||
getEmbeddedSessionChatOriginSessionId,
|
||||
isEmbeddedSessionChat,
|
||||
resetEmbeddedSessionChatCache,
|
||||
type EmbeddedSessionChatURLCacheEntry,
|
||||
} from './contextPanelEmbeddedChat';
|
||||
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
@@ -32,6 +39,7 @@ const makeTheme = (id: string, variant: 'light' | 'dark'): Theme => ({
|
||||
|
||||
beforeEach(() => {
|
||||
installWindowLocation();
|
||||
resetEmbeddedSessionChatCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -99,3 +107,69 @@ describe('embedded session chat URL', () => {
|
||||
expect(new URL(readOnly).searchParams.get('readOnly')).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmbeddedSessionChat', () => {
|
||||
test('is true only for the session-chat panel search param', () => {
|
||||
installWindowLocation('http://127.0.0.1:5173/app?ocPanel=session-chat&sessionId=ses_1');
|
||||
resetEmbeddedSessionChatCache();
|
||||
expect(isEmbeddedSessionChat()).toBe(true);
|
||||
|
||||
installWindowLocation('http://127.0.0.1:5173/app?sessionId=ses_1');
|
||||
resetEmbeddedSessionChatCache();
|
||||
expect(isEmbeddedSessionChat()).toBe(false);
|
||||
|
||||
installWindowLocation('http://127.0.0.1:5173/app');
|
||||
resetEmbeddedSessionChatCache();
|
||||
expect(isEmbeddedSessionChat()).toBe(false);
|
||||
});
|
||||
|
||||
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.
|
||||
installWindowLocation('http://127.0.0.1:5173/app?ocPanel=session-chat&sessionId=ses_1');
|
||||
resetEmbeddedSessionChatCache();
|
||||
|
||||
// First read: caches true.
|
||||
expect(isEmbeddedSessionChat()).toBe(true);
|
||||
|
||||
// Even if the URL were rewritten, the cached value stays true.
|
||||
installWindowLocation('http://127.0.0.1:5173/app?session=ses_grandchild');
|
||||
expect(isEmbeddedSessionChat()).toBe(true);
|
||||
|
||||
// Still true after another rewrite.
|
||||
installWindowLocation('http://127.0.0.1:5173/app');
|
||||
expect(isEmbeddedSessionChat()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmbeddedSessionChatOriginSessionId', () => {
|
||||
test('returns the URL sessionId when embedded', () => {
|
||||
installWindowLocation('http://127.0.0.1:5173/app?ocPanel=session-chat&sessionId=ses_child&directory=%2Frepo');
|
||||
resetEmbeddedSessionChatCache();
|
||||
expect(getEmbeddedSessionChatOriginSessionId()).toBe('ses_child');
|
||||
});
|
||||
|
||||
test('returns null outside the embedded iframe', () => {
|
||||
installWindowLocation('http://127.0.0.1:5173/app?session=ses_main');
|
||||
resetEmbeddedSessionChatCache();
|
||||
expect(getEmbeddedSessionChatOriginSessionId()).toBeNull();
|
||||
|
||||
installWindowLocation('http://127.0.0.1:5173/app? sessionId=ses_orphan');
|
||||
resetEmbeddedSessionChatCache();
|
||||
expect(getEmbeddedSessionChatOriginSessionId()).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when embedded URL is missing sessionId param', () => {
|
||||
installWindowLocation('http://127.0.0.1:5173/app?ocPanel=session-chat&directory=%2Frepo');
|
||||
resetEmbeddedSessionChatCache();
|
||||
expect(getEmbeddedSessionChatOriginSessionId()).toBeNull();
|
||||
});
|
||||
|
||||
test('trims whitespace', () => {
|
||||
installWindowLocation('http://127.0.0.1:5173/app?ocPanel=session-chat&sessionId=%20%20ses_child%20%20');
|
||||
resetEmbeddedSessionChatCache();
|
||||
expect(getEmbeddedSessionChatOriginSessionId()).toBe('ses_child');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,3 +70,60 @@ export const getOrCreateEmbeddedSessionChatURL = (
|
||||
cache.set(tabID, { signature, src });
|
||||
return src;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when the current document is the embedded session-chat iframe
|
||||
* (`?ocPanel=session-chat`). Used to distinguish the embedded iframe from
|
||||
* the main app so callers can route behavior accordingly (e.g. in-place
|
||||
* subtask navigation instead of opening a new side-panel tab, or skipping
|
||||
* URL rewrites that would strip the iframe's identity params).
|
||||
*
|
||||
* Cached on first call (per JS realm): an iframe's embedded-ness is fixed
|
||||
* at mount by the parent and cannot change during its lifetime — a parent
|
||||
* src swap is a full browser reload, starting a fresh realm.
|
||||
*/
|
||||
let embeddedSessionChatCached: boolean | null = null;
|
||||
|
||||
export const isEmbeddedSessionChat = (): boolean => {
|
||||
if (embeddedSessionChatCached !== null) {
|
||||
return embeddedSessionChatCached;
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
embeddedSessionChatCached = false;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
embeddedSessionChatCached =
|
||||
new URLSearchParams(window.location.search).get('ocPanel') === 'session-chat';
|
||||
return embeddedSessionChatCached;
|
||||
} catch {
|
||||
embeddedSessionChatCached = false;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset the module-level cache. Intended for tests that simulate different
|
||||
* JS realms by swapping `window.location` in the same process.
|
||||
*/
|
||||
export const resetEmbeddedSessionChatCache = (): void => {
|
||||
embeddedSessionChatCached = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The session ID recorded in the embedded iframe's URL
|
||||
* (`?ocPanel=session-chat&sessionId=…`), i.e. the session the panel was
|
||||
* opened to show. Returns `null` outside the embedded iframe or when the
|
||||
* URL is malformed.
|
||||
*/
|
||||
export const getEmbeddedSessionChatOriginSessionId = (): string | null => {
|
||||
if (!isEmbeddedSessionChat()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const sid = new URLSearchParams(window.location.search).get('sessionId');
|
||||
return sid && sid.trim().length > 0 ? sid.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
|
||||
export const readEmbeddedThemeSearchParams = (): URLSearchParams | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
if (!isEmbeddedSessionChat()) {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('ocPanel') === 'session-chat' ? params : null;
|
||||
return new URLSearchParams(window.location.search);
|
||||
};
|
||||
|
||||
const getSystemPreference = (): boolean => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router';
|
||||
import type { RouteState, AppRouteState } from '@/lib/router';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { resolveSettingsSlug } from '@/lib/settings/metadata';
|
||||
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
|
||||
/**
|
||||
* Check if running in VS Code webview context.
|
||||
@@ -29,9 +30,18 @@ function isVSCodeContext(): boolean {
|
||||
* - Web: Full bidirectional sync
|
||||
* - Desktop: Full bidirectional sync
|
||||
* - VS Code: State-only (no URL updates, reads initial params)
|
||||
* - Embedded session-chat iframe (`?ocPanel=session-chat`): No URL updates.
|
||||
* The iframe's session identity is fixed at mount (the parent builds the
|
||||
* src with `sessionId`); in-place subtask navigation must NOT rewrite the
|
||||
* URL, otherwise `ocPanel` (and `directory`/`readOnly`) get stripped and
|
||||
* `isEmbeddedSessionChat()` starts returning false, breaking subsequent
|
||||
* "Open subtask" clicks.
|
||||
*/
|
||||
export function useRouter(): void {
|
||||
const isVSCode = React.useMemo(() => isVSCodeContext(), []);
|
||||
// Captured once at mount: the iframe's embedded-ness never changes during
|
||||
// its lifetime (a parent src swap is a full reload).
|
||||
const isEmbeddedChat = React.useMemo(() => isEmbeddedSessionChat(), []);
|
||||
|
||||
// Track initialization to avoid duplicate applies
|
||||
const initializedRef = React.useRef(false);
|
||||
@@ -115,14 +125,14 @@ export function useRouter(): void {
|
||||
*/
|
||||
const syncURLFromState = React.useCallback(
|
||||
(options: { replace?: boolean } = {}) => {
|
||||
if (isVSCode || isApplyingRouteRef.current) {
|
||||
if (isVSCode || isEmbeddedChat || isApplyingRouteRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = getCurrentAppState();
|
||||
updateBrowserURL(state, options);
|
||||
},
|
||||
[isVSCode, getCurrentAppState]
|
||||
[isVSCode, isEmbeddedChat, getCurrentAppState]
|
||||
);
|
||||
|
||||
// Initialize: parse URL and apply route on mount
|
||||
@@ -148,7 +158,7 @@ export function useRouter(): void {
|
||||
// Use the parsed route values instead of an immediate store snapshot so
|
||||
// deep links do not briefly normalize `?session=...` back to `/` while
|
||||
// the session's directory/message bootstrap is still catching up.
|
||||
if (!isVSCode) {
|
||||
if (!isVSCode && !isEmbeddedChat) {
|
||||
updateBrowserURL({
|
||||
...getCurrentAppState(),
|
||||
sessionId: route.sessionId ?? useSessionUIStore.getState().currentSessionId,
|
||||
@@ -160,11 +170,11 @@ export function useRouter(): void {
|
||||
};
|
||||
|
||||
void initializeRoute();
|
||||
}, [applyRoute, getCurrentAppState, isVSCode]);
|
||||
}, [applyRoute, getCurrentAppState, isVSCode, isEmbeddedChat]);
|
||||
|
||||
// Subscribe to session changes
|
||||
React.useEffect(() => {
|
||||
if (isVSCode) {
|
||||
if (isVSCode || isEmbeddedChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,11 +193,11 @@ export function useRouter(): void {
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [isVSCode, syncURLFromState]);
|
||||
}, [isVSCode, isEmbeddedChat, syncURLFromState]);
|
||||
|
||||
// Subscribe to UI store changes (tab, settings)
|
||||
React.useEffect(() => {
|
||||
if (isVSCode) {
|
||||
if (isVSCode || isEmbeddedChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,11 +230,11 @@ export function useRouter(): void {
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [isVSCode, syncURLFromState]);
|
||||
}, [isVSCode, isEmbeddedChat, syncURLFromState]);
|
||||
|
||||
// Listen for browser back/forward navigation
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || isVSCode) {
|
||||
if (typeof window === 'undefined' || isVSCode || isEmbeddedChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -254,5 +264,5 @@ export function useRouter(): void {
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, [applyRoute, isVSCode, setActiveMainTab, setSettingsDialogOpen]);
|
||||
}, [applyRoute, isVSCode, isEmbeddedChat, setActiveMainTab, setSettingsDialogOpen]);
|
||||
}
|
||||
|
||||
@@ -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