fix(chat): enable in-place subtask navigation in embedded session-chat iframe (#2138)

This commit is contained in:
Andrey Meshkov
2026-07-13 08:32:18 +03:00
committed by GitHub
parent 3362ebbfa3
commit 7888dd65b3
10 changed files with 316 additions and 26 deletions
@@ -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;
}
};