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
This commit is contained in:
Bohdan Triapitsyn
2026-05-15 22:16:04 +03:00
parent 040b6a9dc6
commit 9828ddb4b1
15 changed files with 117 additions and 38 deletions
+53 -21
View File
@@ -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<string | null>(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 (
<>
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
<OpenCodeUpdateToast />
<ChatView readOnly={embeddedSessionChat.readOnly} />
<Toaster />
</>
);
};
function App({ apis }: AppProps) {
@@ -780,11 +812,11 @@ function App({ apis }: AppProps) {
<RuntimeAPIProvider apis={apis}>
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background">
<EmbeddedSessionSelectionGate embeddedSessionChat={embeddedSessionChat} isVSCodeRuntime={isVSCodeRuntime} />
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
<OpenCodeUpdateToast />
<ChatView />
<Toaster />
<EmbeddedSessionChatContent
embeddedSessionChat={embeddedSessionChat}
isVSCodeRuntime={isVSCodeRuntime}
embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled}
/>
</div>
</TooltipProvider>
</RuntimeAPIProvider>
@@ -315,11 +315,24 @@ const HYDRATING_SKELETON_ITEMS: Array<{
},
];
type ChatContainerProps = {
autoOpenDraft?: boolean;
const ReadOnlyPromptBanner: React.FC = () => {
const { t } = useI18n();
return (
<div className="p-3">
<div className="rounded-2xl border border-border/70 bg-[var(--surface-background)] px-4 py-3 typography-ui-label text-muted-foreground">
{t('chat.container.readOnlySubagentPromptBanner')}
</div>
</div>
);
};
export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = true }) => {
type ChatContainerProps = {
autoOpenDraft?: boolean;
readOnly?: boolean;
};
export const ChatContainer: React.FC<ChatContainerProps> = ({ 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<ChatContainerProps> = ({ autoOpenDraft = tr
: 'bg-background'
)}
>
<ChatInput scrollToBottom={resumeToLatestInstant} />
{readOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
</div>
</div>
);
@@ -811,7 +824,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
: 'bg-background'
)}
>
<ChatInput scrollToBottom={resumeToLatestInstant} />
{readOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
</div>
</div>
);
@@ -844,7 +857,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
: 'bg-background'
)}
>
<ChatInput scrollToBottom={resumeToLatestInstant} />
{readOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
</div>
</div>
);
@@ -892,7 +905,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
onClick={navigation.resumeToLatest}
/>
)}
<ChatInput scrollToBottom={resumeToLatestInstant} />
{readOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
</div>
<TimelineDialog
@@ -91,7 +91,8 @@ const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null =
const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ 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')}
@@ -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,
});
}
};
@@ -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;
}
@@ -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;
@@ -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<ChatViewProps> = ({ readOnly = false }) => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
return (
<ChatErrorBoundary sessionId={currentSessionId || undefined}>
<ChatContainer />
<ChatContainer readOnly={readOnly} />
</ChatErrorBoundary>
);
};
+1
View File
@@ -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',
+1
View File
@@ -1399,6 +1399,7 @@ export const dict: Record<I18nKey, string> = {
"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",
+1
View File
@@ -1435,6 +1435,7 @@ export const dict: Record<I18nKey, string> = {
'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': '최근 모델 없음',
+1
View File
@@ -489,6 +489,7 @@ export const dict: Record<I18nKey, string> = {
'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',
@@ -1399,6 +1399,7 @@ export const dict: Record<I18nKey, string> = {
"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",
+1
View File
@@ -1399,6 +1399,7 @@ export const dict: Record<I18nKey, string> = {
"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": "Немає останніх моделей",
@@ -1399,6 +1399,7 @@ export const dict: Record<I18nKey, string> = {
'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': '没有最近使用的模型',
+5
View File
@@ -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(),