fix: bootstrap embedded chat prompting state and review read-only handling

Pass initial subagent prompting settings into embedded chat URLs and views
Prevent inactive embedded chats from stealing focus on load
Treat review sessions as independent conversations when resolving prompt read-only state
This commit is contained in:
Bohdan Triapitsyn
2026-08-13 22:01:11 +03:00
parent e99f6560be
commit a9edc71e06
9 changed files with 80 additions and 17 deletions
+13 -2
View File
@@ -106,6 +106,7 @@ type EmbeddedSessionChatConfig = {
sessionId: string;
directory: string | null;
readOnly: boolean;
allowPromptingSubagentSessions?: boolean;
};
type EmbeddedVisibilityPayload = {
@@ -138,6 +139,9 @@ const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
sessionId,
directory,
readOnly: params.get('readOnly') === '1' || params.get('readOnly') === 'true',
allowPromptingSubagentSessions: params.has('allowPromptingSubagentSessions')
? params.get('allowPromptingSubagentSessions') === '1'
: undefined,
};
};
@@ -199,7 +203,11 @@ const EmbeddedSessionChatContent: React.FC<{
<>
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
<OpenCodeUpdateToast />
<ChatView readOnly={embeddedSessionChat.readOnly} />
<ChatView
active={embeddedBackgroundWorkEnabled}
readOnly={embeddedSessionChat.readOnly}
initialAllowPromptingSubagentSessions={embeddedSessionChat.allowPromptingSubagentSessions}
/>
<Toaster />
</>
);
@@ -228,7 +236,10 @@ function App({ apis }: AppProps) {
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
// Embedded chats start inactive until the parent panel identifies the active
// tab. Otherwise a newly loaded background tab can focus its composer first
// and steal keyboard input from the main chat.
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(false);
const [initRetryExhausted, setInitRetryExhausted] = React.useState(false);
const [initRetryEpoch, setInitRetryEpoch] = React.useState(0);
const [runtimeEndpointEpoch, setRuntimeEndpointEpoch] = React.useState(0);
@@ -535,9 +535,10 @@ type ChatContainerProps = {
active?: boolean;
autoOpenDraft?: boolean;
readOnly?: boolean;
initialAllowPromptingSubagentSessions?: boolean;
};
export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, autoOpenDraft = true, readOnly = false }) => {
export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, autoOpenDraft = true, readOnly = false, initialAllowPromptingSubagentSessions }) => {
const { t } = useI18n();
// Session UI state
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
@@ -568,6 +569,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
const promptNavigatorEnabled = useUIStore((state) => state.promptNavigatorEnabled);
const allowPromptingSubagentSessions = useUIStore((state) => state.allowPromptingSubagentSessions);
const [embeddedAllowPrompting, setEmbeddedAllowPrompting] = React.useState(initialAllowPromptingSubagentSessions);
const isTimelineDialogOpen = useUIStore((s) => s.isTimelineDialogOpen);
const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen);
@@ -800,7 +802,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
{t('chat.container.returnToParent.label')}
</Button>
) : null;
const promptReadOnly = resolveChatPromptReadOnly(currentSession, allowPromptingSubagentSessions, readOnly);
const promptReadOnly = resolveChatPromptReadOnly(
currentSession,
embeddedAllowPrompting ?? allowPromptingSubagentSessions,
readOnly,
);
React.useEffect(() => {
// VS Code/Cursor/Positron webviews delete window.parent (and window.top).
@@ -813,6 +819,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
const parentWindow = window.parent;
const applySetting = (value: boolean) => {
setEmbeddedAllowPrompting(value);
useUIStore.getState().setAllowPromptingSubagentSessions(value);
};
const scopedWindow = window as typeof window & {
@@ -1100,7 +1107,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
{workStatusOverlayMountable ? (
<WorkStatusPanel
@@ -1144,7 +1151,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
</div>
</div>
<div className="relative z-10 bg-background">
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
@@ -1198,7 +1205,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
: 'bg-background'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
@@ -1233,7 +1240,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
: 'bg-background'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
@@ -1291,7 +1298,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
onClick={navigation.resumeToLatest}
/>
)}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
{/* Inside the chat column, not beside it: as a row sibling it took
@@ -221,6 +221,7 @@ const MemoStatusRow = React.memo(StatusRow);
interface ChatInputProps {
onOpenSettings?: () => void;
scrollToBottom?: () => void;
active?: boolean;
}
const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => {
@@ -234,7 +235,7 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
return createChatDraftIdentity(getRuntimeKey(), directory, sessionId);
};
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom, active = true }) => {
const { t } = useI18n();
// Track if we restored a draft on mount (for text selection)
const initialDraftRef = React.useRef<string | null>(null);
@@ -2039,10 +2040,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
React.useEffect(() => {
if (currentSessionId && composerRef.current && !isMobile) {
if (active && currentSessionId && composerRef.current && !isMobile) {
composerRef.current.focus();
}
}, [currentSessionId, isMobile]);
}, [active, currentSessionId, isMobile]);
React.useEffect(() => {
if (!isMobile) {
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
import { withReviewSessionMarker } from '@/lib/sessionReviewMetadata';
const session = (parentID?: string): Session => ({
id: 'session',
@@ -27,4 +28,14 @@ describe('resolveChatPromptReadOnly', () => {
expect(resolveChatPromptReadOnly(session(), true, true)).toBe(true);
expect(resolveChatPromptReadOnly(session(), true, false)).toBe(false);
});
test('treats a marked code review as an independent session even with a stale parent ID', () => {
const reviewSession = {
...session('original'),
metadata: withReviewSessionMarker({}, 'original'),
} as Session;
expect(resolveChatPromptReadOnly(reviewSession, false, false)).toBe(false);
expect(resolveChatPromptReadOnly(reviewSession, true, true)).toBe(true);
});
});
@@ -1,10 +1,18 @@
import type { Session } from '@opencode-ai/sdk/v2';
import { isReviewSession } from '@/lib/sessionReviewMetadata';
export const resolveChatPromptReadOnly = (
session: Session | null | undefined,
allowPromptingSubagentSessions: boolean,
readOnly: boolean,
): boolean => {
// Review sessions are independent conversations even if an older server or
// cached record still carries parentID. Their explicit metadata is the
// authority; only the surface itself may make them read-only.
if (isReviewSession(session)) {
return readOnly;
}
if (session?.parentID) {
return !allowPromptingSubagentSessions;
}
@@ -2528,8 +2528,8 @@ export const ContextPanel: React.FC = () => {
lightThemeId,
darkThemeId,
currentTheme,
});
}, [currentTheme, darkThemeId, directoryKey, lightThemeId, themeMode]);
}, { allowPromptingSubagentSessions });
}, [allowPromptingSubagentSessions, currentTheme, darkThemeId, directoryKey, lightThemeId, themeMode]);
const activeChatSrc = activeChatTab && activeChatSessionID
? getEmbeddedChatSrc(activeChatTab.id, activeChatSessionID, activeChatTab.readOnly)
@@ -128,6 +128,17 @@ describe('embedded session chat URL', () => {
expect(new URL(second).searchParams.get('themeVariant')).toBe('dark');
});
test('bootstraps subagent prompting before the embedded chat first renders', () => {
const src = buildEmbeddedSessionChatURL('ses_1', '/repo', false, {
mode: 'system',
lightThemeId: 'light-a',
darkThemeId: 'dark-a',
currentTheme: makeTheme('dark-a', 'dark'),
}, { allowPromptingSubagentSessions: true });
expect(new URL(src).searchParams.get('allowPromptingSubagentSessions')).toBe('1');
});
test('rebuilds cached src when readOnly changes for an existing tab', () => {
const cache = new Map<string, EmbeddedSessionChatURLCacheEntry>();
const theme = {
@@ -8,6 +8,10 @@ export type EmbeddedSessionChatThemeBootstrap = {
currentTheme: Theme;
};
export type EmbeddedSessionChatSettingsBootstrap = {
allowPromptingSubagentSessions: boolean;
};
export type EmbeddedSessionChatURLCacheEntry = {
signature: string;
src: string;
@@ -111,6 +115,7 @@ export const buildEmbeddedSessionChatURL = (
directory: string | null,
readOnly: boolean,
theme: EmbeddedSessionChatThemeBootstrap,
settings?: EmbeddedSessionChatSettingsBootstrap,
): string => {
if (typeof window === 'undefined') {
return '';
@@ -134,6 +139,9 @@ export const buildEmbeddedSessionChatURL = (
url.searchParams.set('lightThemeId', theme.lightThemeId);
url.searchParams.set('darkThemeId', theme.darkThemeId);
url.searchParams.set('themeVariant', theme.currentTheme.metadata.variant === 'dark' ? 'dark' : 'light');
if (settings) {
url.searchParams.set('allowPromptingSubagentSessions', settings.allowPromptingSubagentSessions ? '1' : '0');
}
url.hash = '';
return url.toString();
@@ -146,6 +154,7 @@ export const getOrCreateEmbeddedSessionChatURL = (
directory: string | null,
readOnly: boolean,
theme: EmbeddedSessionChatThemeBootstrap,
settings?: EmbeddedSessionChatSettingsBootstrap,
): string => {
const signature = buildEmbeddedSessionChatURLSignature(sessionID, directory, readOnly);
const existing = cache.get(tabID);
@@ -153,7 +162,7 @@ export const getOrCreateEmbeddedSessionChatURL = (
return existing.src;
}
const src = buildEmbeddedSessionChatURL(sessionID, directory, readOnly, theme);
const src = buildEmbeddedSessionChatURL(sessionID, directory, readOnly, theme, settings);
cache.set(tabID, { signature, src });
return src;
};
@@ -6,14 +6,19 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
type ChatViewProps = {
active?: boolean;
readOnly?: boolean;
initialAllowPromptingSubagentSessions?: boolean;
};
export const ChatView: React.FC<ChatViewProps> = ({ active = true, readOnly = false }) => {
export const ChatView: React.FC<ChatViewProps> = ({ active = true, readOnly = false, initialAllowPromptingSubagentSessions }) => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
return (
<ChatErrorBoundary sessionId={currentSessionId || undefined}>
<ChatContainer active={active} readOnly={readOnly} />
<ChatContainer
active={active}
readOnly={readOnly}
initialAllowPromptingSubagentSessions={initialAllowPromptingSubagentSessions}
/>
</ChatErrorBoundary>
);
};