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:
+53
-21
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': '최근 모델 없음',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': '没有最近使用的模型',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user