From 9828ddb4b16da1640cfdf92fa86e9908a5f46444 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 15 May 2026 22:16:04 +0300 Subject: [PATCH] feat: open subagent sessions read-only in context panel Adds read-only embedded chat mode without hiding permission prompts Opens subagent sessions in the context panel instead of replacing the main chat Fixes context panel message loading for embedded sessions --- packages/ui/src/App.tsx | 74 +++++++++++++------ .../ui/src/components/chat/ChatContainer.tsx | 27 +++++-- .../components/chat/message/MessageBody.tsx | 11 ++- .../chat/message/parts/ToolPart.tsx | 12 ++- .../ui/src/components/layout/ContextPanel.tsx | 9 ++- .../session/sidebar/SessionNodeItem.tsx | 2 +- packages/ui/src/components/views/ChatView.tsx | 8 +- packages/ui/src/lib/i18n/messages/en.ts | 1 + packages/ui/src/lib/i18n/messages/es.ts | 1 + packages/ui/src/lib/i18n/messages/ko.ts | 1 + packages/ui/src/lib/i18n/messages/pl.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 + packages/ui/src/lib/i18n/messages/uk.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 + packages/ui/src/stores/useUIStore.ts | 5 ++ 15 files changed, 117 insertions(+), 38 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 70c00590..d6099bf7 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -31,7 +31,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; -import { SyncProvider, useSessions } from '@/sync/sync-context'; +import { SyncProvider } from '@/sync/sync-context'; +import { useSync } from '@/sync/use-sync'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { AboutDialog } from '@/components/ui/AboutDialog'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; @@ -95,12 +96,18 @@ type AppProps = { type EmbeddedSessionChatConfig = { sessionId: string; directory: string | null; + readOnly: boolean; }; type EmbeddedVisibilityPayload = { visible?: unknown; }; +const normalizeEmbeddedDirectory = (value: string | null | undefined): string => { + if (!value) return ''; + return value.replace(/\\/g, '/').replace(/\/+$/g, ''); +}; + const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => { if (typeof window === 'undefined') { return null; @@ -125,6 +132,7 @@ const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => { return { sessionId, directory, + readOnly: params.get('readOnly') === '1' || params.get('readOnly') === 'true', }; }; @@ -136,31 +144,55 @@ const isMcpOAuthCallbackPath = (): boolean => { return window.location.pathname === MCP_OAUTH_CALLBACK_PATH; }; -const EmbeddedSessionSelectionGate: React.FC<{ - embeddedSessionChat: EmbeddedSessionChatConfig | null; +const EmbeddedSessionChatContent: React.FC<{ + embeddedSessionChat: EmbeddedSessionChatConfig; isVSCodeRuntime: boolean; -}> = ({ embeddedSessionChat, isVSCodeRuntime }) => { - const sessions = useSessions(); + embeddedBackgroundWorkEnabled: boolean; +}> = ({ embeddedSessionChat, isVSCodeRuntime, embeddedBackgroundWorkEnabled }) => { + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + const sync = useSync(); + const bootstrapKeyRef = React.useRef(null); + + const expectedDirectory = normalizeEmbeddedDirectory(embeddedSessionChat.directory); + const activeDirectory = normalizeEmbeddedDirectory(currentDirectory); React.useEffect(() => { - if (!embeddedSessionChat || isVSCodeRuntime) { + if (isVSCodeRuntime) return; + if (expectedDirectory && activeDirectory !== expectedDirectory) return; + + const bootstrapKey = `${expectedDirectory}\n${embeddedSessionChat.sessionId}`; + if (bootstrapKeyRef.current === bootstrapKey && currentSessionId === embeddedSessionChat.sessionId) { return; } - if (currentSessionId === embeddedSessionChat.sessionId) { - return; - } + bootstrapKeyRef.current = bootstrapKey; + setCurrentSession(embeddedSessionChat.sessionId, embeddedSessionChat.directory); + void sync.ensureSessionRenderable(embeddedSessionChat.sessionId, true); + }, [ + activeDirectory, + currentSessionId, + embeddedSessionChat.directory, + embeddedSessionChat.sessionId, + expectedDirectory, + isVSCodeRuntime, + setCurrentSession, + sync, + ]); - if (!sessions.some((session) => session.id === embeddedSessionChat.sessionId)) { - return; - } + if (expectedDirectory && activeDirectory !== expectedDirectory) { + return null; + } - void setCurrentSession(embeddedSessionChat.sessionId); - }, [currentSessionId, embeddedSessionChat, isVSCodeRuntime, sessions, setCurrentSession]); - - return null; + return ( + <> + + + + + + ); }; function App({ apis }: AppProps) { @@ -780,11 +812,11 @@ function App({ apis }: AppProps) {
- - - - - +
diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 58526d9f..ba96bc75 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -315,11 +315,24 @@ const HYDRATING_SKELETON_ITEMS: Array<{ }, ]; -type ChatContainerProps = { - autoOpenDraft?: boolean; +const ReadOnlyPromptBanner: React.FC = () => { + const { t } = useI18n(); + + return ( +
+
+ {t('chat.container.readOnlySubagentPromptBanner')} +
+
+ ); }; -export const ChatContainer: React.FC = ({ autoOpenDraft = true }) => { +type ChatContainerProps = { + autoOpenDraft?: boolean; + readOnly?: boolean; +}; + +export const ChatContainer: React.FC = ({ autoOpenDraft = true, readOnly = false }) => { const { t } = useI18n(); // Session UI state const currentSessionId = useSessionUIStore((s) => s.currentSessionId); @@ -751,7 +764,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr : 'bg-background' )} > - + {readOnly ? : } ); @@ -811,7 +824,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr : 'bg-background' )} > - + {readOnly ? : } ); @@ -844,7 +857,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr : 'bg-background' )} > - + {readOnly ? : } ); @@ -892,7 +905,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr onClick={navigation.resumeToLatest} /> )} - + {readOnly ? : } = ({ part }) => { const [expanded, setExpanded] = React.useState(false); - const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + const effectiveDirectory = useEffectiveDirectory(); + const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); const { t } = useI18n(); const description = typeof part.description === 'string' ? part.description.trim() : ''; @@ -151,7 +152,13 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => { type="button" className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" onClick={() => { - void setCurrentSession(taskSessionID); + if (!effectiveDirectory) return; + openContextPanelTab(effectiveDirectory, { + mode: 'chat', + dedupeKey: `session:${taskSessionID}`, + label: description || agent || t('contextPanel.mode.chat'), + readOnly: true, + }); }} > {t('chat.messageBody.subtask.openSession')} diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index b060ff2b..d74f6d70 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1080,7 +1080,8 @@ const TaskToolSummary: React.FC<{ isActive?: boolean; }> = ({ entries, isExpanded, isMobile, output, sessionId, onShowPopup, input, animateTailText = true, isActive = false }) => { const { t } = useI18n(); - const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); const showToolFileIcons = useUIStore((state) => state.showToolFileIcons); const displayEntries = entries; @@ -1092,8 +1093,13 @@ const TaskToolSummary: React.FC<{ const handleOpenSession = (event: React.MouseEvent) => { event.stopPropagation(); - if (sessionId) { - setCurrentSession(sessionId); + if (sessionId && currentDirectory) { + openContextPanelTab(currentDirectory, { + mode: 'chat', + dedupeKey: `session:${sessionId}`, + label: agentType.charAt(0).toUpperCase() + agentType.slice(1), + readOnly: true, + }); } }; diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 546b8e87..d94e65e5 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -486,7 +486,7 @@ const desktopAnnotationToFile = async ( } }; -const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null): string => { +const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null, readOnly: boolean): string => { if (typeof window === 'undefined') { return ''; } @@ -494,6 +494,11 @@ const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null const url = new URL(window.location.pathname, window.location.origin); url.searchParams.set('ocPanel', 'session-chat'); url.searchParams.set('sessionId', sessionID); + if (readOnly) { + url.searchParams.set('readOnly', '1'); + } else { + url.searchParams.delete('readOnly'); + } if (directory && directory.trim().length > 0) { url.searchParams.set('directory', directory); } else { @@ -1937,7 +1942,7 @@ export const ContextPanel: React.FC = () => { return null; } - const src = buildEmbeddedSessionChatURL(sessionID, directoryKey || null); + const src = buildEmbeddedSessionChatURL(sessionID, directoryKey || null, tab.readOnly); if (!src) { return null; } diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index cb611059..093beccd 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -75,7 +75,7 @@ type Props = { removeSessionFromFolder: (scopeKey: string, sessionId: string) => void; addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; - openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string }) => void; + openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; readOnly?: boolean }) => void; handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean }) => void; mobileVariant: boolean; alwaysShowActions: boolean; diff --git a/packages/ui/src/components/views/ChatView.tsx b/packages/ui/src/components/views/ChatView.tsx index bf2e7eb7..f1e5b21b 100644 --- a/packages/ui/src/components/views/ChatView.tsx +++ b/packages/ui/src/components/views/ChatView.tsx @@ -3,12 +3,16 @@ import { ChatContainer } from '@/components/chat/ChatContainer'; import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary'; import { useSessionUIStore } from '@/sync/session-ui-store'; -export const ChatView: React.FC = () => { +type ChatViewProps = { + readOnly?: boolean; +}; + +export const ChatView: React.FC = ({ readOnly = false }) => { const currentSessionId = useSessionUIStore((state) => state.currentSessionId); return ( - + ); }; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 916ff6a2..232cd279 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1433,6 +1433,7 @@ export const dict = { 'chat.container.returnToParent.titleNamed': 'Return to: {title}', 'chat.container.returnToParent.title': 'Return to parent session', 'chat.container.returnToParent.label': 'Parent', + 'chat.container.readOnlySubagentPromptBanner': 'Subagent sessions cannot be prompted.', 'chat.unifiedControls.title': 'Controls', 'chat.unifiedControls.model.title': 'Model', 'chat.unifiedControls.model.noRecent': 'No recent models', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 9536fcb2..bb28d4de 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1399,6 +1399,7 @@ export const dict: Record = { "chat.container.returnToParent.titleNamed": "Volver a: {title}", "chat.container.returnToParent.title": "Volver a la sesión principal", "chat.container.returnToParent.label": "Principal", + "chat.container.readOnlySubagentPromptBanner": "Las sesiones de subagentes no pueden recibir prompts.", "chat.unifiedControls.title": "Controles", "chat.unifiedControls.model.title": "Modelo", "chat.unifiedControls.model.noRecent": "No hay modelos recientes", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index c8c92b5d..174530a5 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1435,6 +1435,7 @@ export const dict: Record = { 'chat.container.returnToParent.titleNamed': '돌아가기: {title}', 'chat.container.returnToParent.title': '상위 세션으로 돌아가기', 'chat.container.returnToParent.label': '상위', + 'chat.container.readOnlySubagentPromptBanner': '하위 에이전트 세션에는 프롬프트를 보낼 수 없습니다.', 'chat.unifiedControls.title': '컨트롤', 'chat.unifiedControls.model.title': '모델', 'chat.unifiedControls.model.noRecent': '최근 모델 없음', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 7bc655da..e45f60a9 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -489,6 +489,7 @@ export const dict: Record = { 'chat.container.returnToParent.titleNamed': 'Powrót do: {title}', 'chat.container.returnToParent.title': 'Powrót do sesji nadrzędnej', 'chat.container.returnToParent.label': 'Nadrzędna', + 'chat.container.readOnlySubagentPromptBanner': 'Sesje podagentów nie mogą otrzymywać promptów.', 'chat.unifiedControls.title': 'Kontrolki', 'chat.unifiedControls.model.title': 'Model', 'chat.unifiedControls.model.noRecent': 'Brak ostatnich modeli', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 8087fcc0..87ee6229 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1399,6 +1399,7 @@ export const dict: Record = { "chat.container.returnToParent.titleNamed": "Voltar para: {title}", "chat.container.returnToParent.title": "Voltar para a sessão principal", "chat.container.returnToParent.label": "Principal", + "chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.", "chat.unifiedControls.title": "Controles", "chat.unifiedControls.model.title": "Modelo", "chat.unifiedControls.model.noRecent": "Não há modelos recentes", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index a5bc0107..3bbe3127 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1399,6 +1399,7 @@ export const dict: Record = { "chat.container.returnToParent.titleNamed": "Повернутися до: {title}", "chat.container.returnToParent.title": "Повернутися до батьківської сесії", "chat.container.returnToParent.label": "Батьківська", + "chat.container.readOnlySubagentPromptBanner": "Сесії субагентів не можна запитувати.", "chat.unifiedControls.title": "Елементи керування", "chat.unifiedControls.model.title": "Модель", "chat.unifiedControls.model.noRecent": "Немає останніх моделей", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index d0080e5f..1449e33c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1399,6 +1399,7 @@ export const dict: Record = { 'chat.container.returnToParent.titleNamed': '返回到:{title}', 'chat.container.returnToParent.title': '返回父会话', 'chat.container.returnToParent.label': '父级', + 'chat.container.readOnlySubagentPromptBanner': '无法向子代理会话发送提示。', 'chat.unifiedControls.title': '控制', 'chat.unifiedControls.model.title': '模型', 'chat.unifiedControls.model.noRecent': '没有最近使用的模型', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index e85db39f..17d137eb 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -24,6 +24,7 @@ type ContextPanelTab = { targetPath: string | null; dedupeKey: string; label: string | null; + readOnly: boolean; touchedAt: number; }; @@ -32,6 +33,7 @@ type ContextPanelTabDescriptor = { targetPath?: string | null; dedupeKey?: string | null; label?: string | null; + readOnly?: boolean; }; type ContextPanelDirectoryState = { @@ -201,6 +203,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa targetPath: normalizedTargetPath, dedupeKey, label: normalizeContextTabLabel(descriptor.label), + readOnly: descriptor.readOnly === true, touchedAt: Date.now(), }; }; @@ -239,6 +242,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => { targetPath?: unknown; dedupeKey?: unknown; label?: unknown; + readOnly?: unknown; touchedAt?: unknown; }; @@ -264,6 +268,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => { targetPath, dedupeKey, label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null), + readOnly: candidate.readOnly === true, touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt) ? candidate.touchedAt : Date.now(),