From a9edc71e0610f778bfc6c97901fd88690317514d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 13 Aug 2026 22:00:15 +0300 Subject: [PATCH] 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 --- packages/ui/src/App.tsx | 15 +++++++++++-- .../ui/src/components/chat/ChatContainer.tsx | 21 ++++++++++++------- packages/ui/src/components/chat/ChatInput.tsx | 7 ++++--- .../chat/chatPromptReadOnly.test.ts | 11 ++++++++++ .../src/components/chat/chatPromptReadOnly.ts | 8 +++++++ .../ui/src/components/layout/ContextPanel.tsx | 4 ++-- .../layout/contextPanelEmbeddedChat.test.ts | 11 ++++++++++ .../layout/contextPanelEmbeddedChat.ts | 11 +++++++++- packages/ui/src/components/views/ChatView.tsx | 9 ++++++-- 9 files changed, 80 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index ec79df9d..7c4d3d07 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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<{ <> - + ); @@ -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(() => 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); diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index d2e7ffd1..ff38aeba 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -535,9 +535,10 @@ type ChatContainerProps = { active?: boolean; autoOpenDraft?: boolean; readOnly?: boolean; + initialAllowPromptingSubagentSessions?: boolean; }; -export const ChatContainer: React.FC = ({ active = true, autoOpenDraft = true, readOnly = false }) => { +export const ChatContainer: React.FC = ({ 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 = ({ 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 = ({ active = true, aut {t('chat.container.returnToParent.label')} ) : 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 = ({ 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 = ({ active = true, aut : 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]' )} > - {promptReadOnly ? : } + {promptReadOnly ? : } {workStatusOverlayMountable ? ( = ({ active = true, aut
- {promptReadOnly ? : } + {promptReadOnly ? : }
); @@ -1198,7 +1205,7 @@ export const ChatContainer: React.FC = ({ active = true, aut : 'bg-background' )} > - {promptReadOnly ? : } + {promptReadOnly ? : } ); @@ -1233,7 +1240,7 @@ export const ChatContainer: React.FC = ({ active = true, aut : 'bg-background' )} > - {promptReadOnly ? : } + {promptReadOnly ? : } ); @@ -1291,7 +1298,7 @@ export const ChatContainer: React.FC = ({ active = true, aut onClick={navigation.resumeToLatest} /> )} - {promptReadOnly ? : } + {promptReadOnly ? : } {/* Inside the chat column, not beside it: as a row sibling it took diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 5ff1d8ab..1df58ebb 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ onOpenSettings, scrollToBottom }) => { +const ChatInputComponent: React.FC = ({ onOpenSettings, scrollToBottom, active = true }) => { const { t } = useI18n(); // Track if we restored a draft on mount (for text selection) const initialDraftRef = React.useRef(null); @@ -2039,10 +2040,10 @@ const ChatInputComponent: React.FC = ({ 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) { diff --git a/packages/ui/src/components/chat/chatPromptReadOnly.test.ts b/packages/ui/src/components/chat/chatPromptReadOnly.test.ts index 38ccc362..c78f7afc 100644 --- a/packages/ui/src/components/chat/chatPromptReadOnly.test.ts +++ b/packages/ui/src/components/chat/chatPromptReadOnly.test.ts @@ -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); + }); }); diff --git a/packages/ui/src/components/chat/chatPromptReadOnly.ts b/packages/ui/src/components/chat/chatPromptReadOnly.ts index 1fcc78af..2e1f5c2e 100644 --- a/packages/ui/src/components/chat/chatPromptReadOnly.ts +++ b/packages/ui/src/components/chat/chatPromptReadOnly.ts @@ -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; } diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 6b173209..43872d90 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -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) diff --git a/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts b/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts index 08992565..1c2cb866 100644 --- a/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts +++ b/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts @@ -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(); const theme = { diff --git a/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts b/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts index 57789111..05068ca6 100644 --- a/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts +++ b/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts @@ -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; }; diff --git a/packages/ui/src/components/views/ChatView.tsx b/packages/ui/src/components/views/ChatView.tsx index 152998f4..bcba8dc7 100644 --- a/packages/ui/src/components/views/ChatView.tsx +++ b/packages/ui/src/components/views/ChatView.tsx @@ -6,14 +6,19 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; type ChatViewProps = { active?: boolean; readOnly?: boolean; + initialAllowPromptingSubagentSessions?: boolean; }; -export const ChatView: React.FC = ({ active = true, readOnly = false }) => { +export const ChatView: React.FC = ({ active = true, readOnly = false, initialAllowPromptingSubagentSessions }) => { const currentSessionId = useSessionUIStore((state) => state.currentSessionId); return ( - + ); };