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 ( - + ); };