diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 9bd41304..4a68b79a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { MainLayout } from '@/components/layout/MainLayout'; import { VSCodeLayout } from '@/components/layout/VSCodeLayout'; import { AgentManagerView } from '@/components/views/agent-manager'; +import { ChatView } from '@/components/views'; import { FireworksProvider } from '@/contexts/FireworksContext'; import { Toaster } from '@/components/ui/sonner'; import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel'; @@ -54,17 +55,60 @@ type AppProps = { apis: RuntimeAPIs; }; +type EmbeddedSessionChatConfig = { + sessionId: string; + directory: string | null; +}; + +type EmbeddedVisibilityPayload = { + visible?: unknown; +}; + +const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => { + if (typeof window === 'undefined') { + return null; + } + + const params = new URLSearchParams(window.location.search); + if (params.get('ocPanel') !== 'session-chat') { + return null; + } + + const sessionIdRaw = params.get('sessionId'); + const sessionId = typeof sessionIdRaw === 'string' ? sessionIdRaw.trim() : ''; + if (!sessionId) { + return null; + } + + const directoryRaw = params.get('directory'); + const directory = typeof directoryRaw === 'string' && directoryRaw.trim().length > 0 + ? directoryRaw.trim() + : null; + + return { + sessionId, + directory, + }; +}; + function App({ apis }: AppProps) { const { initializeApp, isInitialized, isConnected } = useConfigStore(); const { error, clearError, loadSessions } = useSessionStore(); + const currentSessionId = useSessionStore((state) => state.currentSessionId); + const setCurrentSession = useSessionStore((state) => state.setCurrentSession); + const sessions = useSessionStore((state) => state.sessions); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + const setDirectory = useDirectoryStore((state) => state.setDirectory); const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory); const [showMemoryDebug, setShowMemoryDebug] = React.useState(false); const { uiFont, monoFont } = useFontPreferences(); const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState(() => apis.runtime.isVSCode); const [showCliOnboarding, setShowCliOnboarding] = React.useState(false); + const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true); const appReadyDispatchedRef = React.useRef(false); + const embeddedSessionChat = React.useMemo(() => readEmbeddedSessionChatConfig(), []); + const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible; React.useEffect(() => { setIsVSCodeRuntime(apis.runtime.isVSCode); @@ -76,8 +120,12 @@ function App({ apis }: AppProps) { }, [apis]); React.useEffect(() => { + if (embeddedSessionChat) { + return; + } + void refreshGitHubAuthStatus(apis.github, { force: true }); - }, [apis.github, refreshGitHubAuthStatus]); + }, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]); React.useEffect(() => { if (typeof document === 'undefined') { @@ -166,6 +214,95 @@ function App({ apis }: AppProps) { syncDirectoryAndSessions(); }, [currentDirectory, isSwitchingDirectory, loadSessions, isConnected, isVSCodeRuntime]); + React.useEffect(() => { + if (!embeddedSessionChat || typeof window === 'undefined') { + return; + } + + const applyVisibility = (payload?: EmbeddedVisibilityPayload) => { + const nextVisible = payload?.visible === true; + setIsEmbeddedVisible(nextVisible); + }; + + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) { + return; + } + + const data = event.data as { type?: unknown; payload?: EmbeddedVisibilityPayload }; + if (data?.type !== 'openchamber:embedded-visibility') { + return; + } + + applyVisibility(data.payload); + }; + + const scopedWindow = window as unknown as { + __openchamberSetEmbeddedVisibility?: (payload?: EmbeddedVisibilityPayload) => void; + }; + + scopedWindow.__openchamberSetEmbeddedVisibility = applyVisibility; + window.addEventListener('message', handleMessage); + + return () => { + window.removeEventListener('message', handleMessage); + if (scopedWindow.__openchamberSetEmbeddedVisibility === applyVisibility) { + delete scopedWindow.__openchamberSetEmbeddedVisibility; + } + }; + }, [embeddedSessionChat]); + + React.useEffect(() => { + if (!embeddedSessionChat?.directory || isVSCodeRuntime) { + return; + } + + if (currentDirectory === embeddedSessionChat.directory) { + return; + } + + setDirectory(embeddedSessionChat.directory, { showOverlay: false }); + }, [currentDirectory, embeddedSessionChat, isVSCodeRuntime, setDirectory]); + + React.useEffect(() => { + if (!embeddedSessionChat || isVSCodeRuntime) { + return; + } + + if (currentSessionId === embeddedSessionChat.sessionId) { + return; + } + + if (!sessions.some((session) => session.id === embeddedSessionChat.sessionId)) { + return; + } + + void setCurrentSession(embeddedSessionChat.sessionId); + }, [currentSessionId, embeddedSessionChat, isVSCodeRuntime, sessions, setCurrentSession]); + + React.useEffect(() => { + if (!embeddedSessionChat || typeof window === 'undefined') { + return; + } + + const handleStorage = (event: StorageEvent) => { + if (event.storageArea !== window.localStorage) { + return; + } + + if (event.key !== 'ui-store') { + return; + } + + void useUIStore.persist.rehydrate(); + }; + + window.addEventListener('storage', handleStorage); + return () => { + window.removeEventListener('storage', handleStorage); + }; + }, [embeddedSessionChat]); + React.useEffect(() => { if (typeof window === 'undefined') return; if (!isInitialized || isSwitchingDirectory) return; @@ -175,13 +312,13 @@ function App({ apis }: AppProps) { window.dispatchEvent(new Event('openchamber:app-ready')); }, [isInitialized, isSwitchingDirectory]); - useEventStream(); + useEventStream({ enabled: embeddedBackgroundWorkEnabled }); // Server-authoritative session status polling // Replaces SSE-dependent status updates with reliable HTTP polling - useServerSessionStatus(); + useServerSessionStatus({ enabled: embeddedBackgroundWorkEnabled }); - usePushVisibilityBeacon(); + usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled }); useWindowTitle(); @@ -197,6 +334,10 @@ function App({ apis }: AppProps) { const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); React.useEffect(() => { + if (embeddedSessionChat) { + return; + } + if (!isTauriShell()) { return; } @@ -206,15 +347,19 @@ function App({ apis }: AppProps) { } void tauri.core.invoke('desktop_set_auto_worktree_menu', { enabled: settingsAutoCreateWorktree }); - }, [settingsAutoCreateWorktree]); + }, [embeddedSessionChat, settingsAutoCreateWorktree]); - useSessionStatusBootstrap(); - useSessionAutoCleanup(); - useQueuedMessageAutoSend(); + useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled }); + useSessionAutoCleanup({ enabled: embeddedBackgroundWorkEnabled }); + useQueuedMessageAutoSend({ enabled: embeddedBackgroundWorkEnabled }); React.useEffect(() => { + if (embeddedSessionChat) { + return; + } + const handleKeyDown = (e: KeyboardEvent) => { if (hasModifier(e) && e.shiftKey && e.key === 'D') { e.preventDefault(); @@ -224,16 +369,24 @@ function App({ apis }: AppProps) { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, []); + }, [embeddedSessionChat]); React.useEffect(() => { + if (embeddedSessionChat) { + return; + } + if (error) { setTimeout(() => clearError(), 5000); } - }, [error, clearError]); + }, [clearError, embeddedSessionChat, error]); React.useEffect(() => { + if (embeddedSessionChat) { + return; + } + if (!isDesktopShell() || !isDesktopLocalOriginActive()) { return; } @@ -269,7 +422,7 @@ function App({ apis }: AppProps) { cancelled = true; window.clearInterval(interval); }; - }, []); + }, [embeddedSessionChat]); const handleCliAvailable = React.useCallback(() => { setShowCliOnboarding(false); @@ -286,6 +439,21 @@ function App({ apis }: AppProps) { ); } + if (embeddedSessionChat) { + return ( + + + +
+ + +
+
+
+
+ ); + } + // VS Code runtime - simplified layout without git/terminal views if (isVSCodeRuntime) { // Check if this is the Agent Manager panel diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 8d251642..a447d0e2 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -106,6 +106,7 @@ export const ChatContainer: React.FC = () => { isTimelineDialogOpen, setTimelineDialogOpen, isExpandedInput, + stickyUserHeader, } = useUIStore(); const sessionMessages = useSessionStore( @@ -593,14 +594,14 @@ export const ChatContainer: React.FC = () => { aria-hidden={isDesktopExpandedInput} >
- +
= ({ } = sessionState; const providers = useConfigStore((state) => state.providers); - const { showReasoningTraces, toolCallExpansion } = useUIStore( + const { showReasoningTraces, toolCallExpansion, stickyUserHeader } = useUIStore( useShallow((state) => ({ showReasoningTraces: state.showReasoningTraces, toolCallExpansion: state.toolCallExpansion, + stickyUserHeader: state.stickyUserHeader, })) ); @@ -164,6 +165,8 @@ const ChatMessage: React.FC = ({ const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]); const isUser = messageRole.isUser; + const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader); + const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow; const sessionId = message.info.sessionID; @@ -940,12 +943,16 @@ const ChatMessage: React.FC = ({ return null; } + const assistantTopPaddingClass = !isUser && shouldShowHeader + ? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0') + : 'pt-0'; + return ( <>
= ({ {isUser ? ( displayParts.length === 0 ? null : ( -
-
- +
+
+
+ +
+ {useExternalUserActionsRow ? ( + + ) : null}
+ {showStickyInlineHoverRow ? ) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 42dc06cc..deb48e8f 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -15,6 +15,7 @@ import { filterSyntheticParts } from '@/lib/messages/synthetic'; import { detectTurns, type Turn } from './hooks/useTurnGrouping'; import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic } from './contexts/TurnGroupingContext'; import { useSessionStore } from '@/stores/useSessionStore'; +import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; import { FadeInDisabledProvider } from './message/FadeInOnReveal'; @@ -414,7 +415,7 @@ const TurnBlock: React.FC = ({
) : ( @@ -528,7 +529,8 @@ const MessageListContent: React.FC<{ onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; -}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom }) => { + stickyUserHeader: boolean; +}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader }) => { return ( <> {entries.map((entry) => ( @@ -538,7 +540,7 @@ const MessageListContent: React.FC<{ onMessageContentChange={onMessageContentChange} getAnimationHandlers={getAnimationHandlers} scrollToBottom={scrollToBottom} - stickyUserHeader + stickyUserHeader={stickyUserHeader} /> ))} @@ -560,6 +562,7 @@ const MessageList = React.forwardRef(({ scrollRef, }, ref) => { const { isMobile } = useDeviceInfo(); + const stickyUserHeader = useUIStore(state => state.stickyUserHeader); React.useEffect(() => { if (permissions.length === 0 && questions.length === 0) { @@ -1044,6 +1047,7 @@ const MessageList = React.forwardRef(({ onMessageContentChange={onMessageContentChange} getAnimationHandlers={getAnimationHandlers} scrollToBottom={scrollToBottom} + stickyUserHeader={stickyUserHeader} /> )} diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 2c6e97d4..e09c0e30 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -361,6 +361,8 @@ export const ModelControls: React.FC = ({ const { toggleFavoriteModel, isFavoriteModel, + collapsedModelProviders, + toggleModelProviderCollapsed, addRecentModel, addRecentAgent, addRecentEffort, @@ -370,6 +372,10 @@ export const ModelControls: React.FC = ({ setSettingsPage, } = useUIStore(); const hiddenModels = useUIStore((state) => state.hiddenModels); + const collapsedProviderSet = React.useMemo( + () => new Set(collapsedModelProviders.map((providerId) => providerId.trim()).filter(Boolean)), + [collapsedModelProviders] + ); // Separate state for agent selector to avoid conflict with model selector const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false); @@ -2104,6 +2110,9 @@ export const ModelControls: React.FC = ({ }; const renderModelSelector = () => { + const normalizedDesktopQuery = desktopModelQuery.trim(); + const forceExpandProviders = normalizedDesktopQuery.length > 0; + // Filter favorites const filteredFavorites = favoriteModelsList.filter(({ model, providerID }) => { const provider = providers.find(p => p.id === providerID); @@ -2132,7 +2141,22 @@ export const ModelControls: React.FC = ({ }) .filter((provider) => provider.models.length > 0); - const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0; + const providerSections = filteredProviders.map((provider) => { + const providerId = typeof provider.id === 'string' ? provider.id : ''; + const isExpanded = forceExpandProviders || !collapsedProviderSet.has(providerId); + const models = Array.isArray(provider.models) ? (provider.models as ProviderModel[]) : []; + return { + provider, + isExpanded, + models, + visibleModels: isExpanded ? models : [], + }; + }); + + const hasResults = + filteredFavorites.length > 0 || + filteredRecents.length > 0 || + filteredProviders.length > 0; // Build flat list for keyboard navigation type FlatModelItem = { model: ProviderModel; providerID: string; modelID: string; section: string }; @@ -2144,8 +2168,8 @@ export const ModelControls: React.FC = ({ filteredRecents.forEach(({ model, providerID, modelID }) => { flatModelList.push({ model, providerID, modelID, section: 'recent' }); }); - filteredProviders.forEach((provider) => { - (provider.models as ProviderModel[]).forEach((model) => { + providerSections.forEach(({ provider, visibleModels }) => { + visibleModels.forEach((model) => { flatModelList.push({ model, providerID: provider.id as string, modelID: model.id as string, section: 'provider' }); }); }); @@ -2245,7 +2269,10 @@ export const ModelControls: React.FC = ({
{/* Scrollable content */} - +
= ({ )} {/* All Providers - Flat List */} - {filteredProviders.map((provider, index) => ( + {providerSections.map(({ provider, isExpanded, visibleModels }, index) => ( {index > 0 && } - { + if (forceExpandProviders) { + return; + } + toggleModelProviderCollapsed(String(provider.id)); + setModelSelectedIndex(0); + }} + onKeyDown={(event) => { + if (forceExpandProviders) { + return; + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggleModelProviderCollapsed(String(provider.id)); + setModelSelectedIndex(0); + } + }} + className={cn( + 'typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex w-full items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30', + 'bg-[var(--surface-elevated)] text-left transition-colors', + forceExpandProviders ? 'cursor-default' : 'cursor-pointer' + )} + aria-expanded={isExpanded} + title={forceExpandProviders ? undefined : (isExpanded ? 'Collapse provider' : 'Expand provider')} > - - {provider.name} - - {(provider.models as ProviderModel[]).map((model: ProviderModel) => { +
+ + {provider.name} + + {isExpanded ? ( + + ) : ( + + )} + +
+
+ {isExpanded && visibleModels.map((model: ProviderModel) => { const idx = currentFlatIndex++; return renderModelRow(model, provider.id as string, model.id as string, 'provider', idx, modelSelectedIndex === idx); })} diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index d7ca5298..59523d5d 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -286,6 +286,8 @@ interface MessageBodyProps { onRevert?: () => void; onFork?: () => void; errorMessage?: string; + userActionsMode?: 'inline' | 'external-content' | 'external-actions'; + stickyUserHeaderEnabled?: boolean; } const UserMessageBody: React.FC<{ @@ -300,7 +302,9 @@ const UserMessageBody: React.FC<{ agentMention?: AgentMentionInfo; onRevert?: () => void; onFork?: () => void; -}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork }) => { + userActionsMode?: 'inline' | 'external-content' | 'external-actions'; + stickyUserHeaderEnabled?: boolean; +}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }) => { const [copyHintVisible, setCopyHintVisible] = React.useState(false); const copyHintTimeoutRef = React.useRef(null); @@ -326,6 +330,8 @@ const UserMessageBody: React.FC<{ const isMessageCopied = Boolean(copiedMessage); const isTouchContext = Boolean(hasTouchInput ?? isMobile); const hasCopyableText = Boolean(hasTextContent); + const showUserContent = userActionsMode !== 'external-actions'; + const showUserActions = userActionsMode !== 'external-content'; const clearCopyHintTimeout = React.useCallback(() => { if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') { @@ -371,6 +377,113 @@ const UserMessageBody: React.FC<{ [hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint] ); + const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || onFork) && showUserActions ? ( +
+
+ {onRevert && ( + + + + + Revert from here + + )} + {onFork && ( + + + + + Fork from here + + )} + {canCopyMessage && hasCopyableText && ( + + + + + Copy message + + )} +
+
+ ) : null; + + if (!showUserContent) { + return <>{actionsBlock}; + } + return (
- {(canCopyMessage && hasCopyableText) || onRevert || onFork ? ( -
-
- {onRevert && ( - - - - - Revert from here - - )} - {onFork && ( - - - - - Fork from here - - )} - {canCopyMessage && hasCopyableText && ( - - - - - Copy message - - )} -
-
- ) : null} + {actionsBlock}
); }; @@ -1421,6 +1449,8 @@ const MessageBody: React.FC = ({ isUser, ...props }) => { agentMention={props.agentMention} onRevert={props.onRevert} onFork={props.onFork} + userActionsMode={props.userActionsMode} + stickyUserHeaderEnabled={props.stickyUserHeaderEnabled} /> ); } diff --git a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx index 24548f50..19623ec6 100644 --- a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx @@ -3,6 +3,7 @@ import { cn } from '@/lib/utils'; import type { Part } from '@opencode-ai/sdk/v2'; import type { AgentMentionInfo } from '../types'; import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; +import { useUIStore } from '@/stores/useUIStore'; type PartWithText = Part & { text?: string; content?: string; value?: string }; @@ -18,6 +19,10 @@ const buildMentionUrl = (name: string): string => { return `https://opencode.ai/docs/agents/#${encoded}`; }; +const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => { + return mode === 'markdown' ? 'markdown' : 'plain'; +}; + const UserTextPart: React.FC = ({ part, messageId, agentMention }) => { const CLAMP_LINES = 2; const partWithText = part as PartWithText; @@ -27,6 +32,8 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti const [isExpanded, setIsExpanded] = React.useState(false); const [isTruncated, setIsTruncated] = React.useState(false); const [collapseZoneHeight, setCollapseZoneHeight] = React.useState(0); + const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode); + const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode); const textRef = React.useRef(null); const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => { @@ -91,7 +98,7 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti } }, [collapseZoneHeight, hasActiveSelectionInElement, isExpanded, isTruncated]); - const processedContent = React.useMemo(() => { + const processedMarkdownContent = React.useMemo(() => { if (!agentMention?.token || !textContent.includes(agentMention.token)) { return textContent; } @@ -100,6 +107,31 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti return textContent.replace(agentMention.token, mentionHtml); }, [agentMention, textContent]); + const plainTextContent = React.useMemo(() => { + if (!agentMention?.token || !textContent.includes(agentMention.token)) { + return textContent; + } + + const idx = textContent.indexOf(agentMention.token); + const before = textContent.slice(0, idx); + const after = textContent.slice(idx + agentMention.token.length); + return ( + <> + {before} + event.stopPropagation()} + > + {agentMention.token} + + {after} + + ); + }, [agentMention, textContent]); + if (!textContent || textContent.trim().length === 0) { return null; } @@ -109,16 +141,21 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti
- + {normalizedRenderingMode === 'markdown' ? ( + + ) : ( + plainTextContent + )}
); diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 721e9a28..d28b892b 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -1,8 +1,11 @@ import React from 'react'; -import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react'; +import { RiArrowLeftRightLine, RiChat4Line, RiCloseLine, RiDonutChartFill, RiFileTextLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react'; +import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { Button } from '@/components/ui/button'; +import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { DiffView, FilesView, PlanView } from '@/components/views'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { cn } from '@/lib/utils'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; @@ -12,6 +15,7 @@ import { ContextPanelContent } from './ContextSidebarTab'; const CONTEXT_PANEL_MIN_WIDTH = 360; const CONTEXT_PANEL_MAX_WIDTH = 1400; const CONTEXT_PANEL_DEFAULT_WIDTH = 600; +const CONTEXT_TAB_LABEL_MAX_CHARS = 24; const normalizeDirectoryKey = (value: string): string => { if (!value) return ''; @@ -52,23 +56,133 @@ const getRelativePathLabel = (filePath: string | null, directory: string): strin return normalizedFile; }; +const getModeLabel = (mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'): string => { + if (mode === 'chat') return 'Chat'; + if (mode === 'file') return 'Files'; + if (mode === 'diff') return 'Diff'; + if (mode === 'plan') return 'Plan'; + return 'Context'; +}; + +const getFileNameFromPath = (path: string | null): string | null => { + if (!path) { + return null; + } + + const normalized = path.replace(/\\/g, '/').trim(); + if (!normalized) { + return null; + } + + const segments = normalized.split('/').filter(Boolean); + if (segments.length === 0) { + return normalized; + } + + return segments[segments.length - 1] || null; +}; + +const getTabLabel = (tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'; label: string | null; targetPath: string | null }): string => { + if (tab.label) { + return tab.label; + } + + if (tab.mode === 'file') { + return getFileNameFromPath(tab.targetPath) || 'Files'; + } + + return getModeLabel(tab.mode); +}; + +const getTabIcon = (tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'; targetPath: string | null }): React.ReactNode | undefined => { + if (tab.mode === 'file') { + return tab.targetPath + ? + : undefined; + } + + if (tab.mode === 'diff') { + return ; + } + + if (tab.mode === 'plan') { + return ; + } + + if (tab.mode === 'context') { + return ; + } + + if (tab.mode === 'chat') { + return ; + } + + return undefined; +}; + +const getSessionIDFromDedupeKey = (dedupeKey: string | undefined): string | null => { + if (!dedupeKey || !dedupeKey.startsWith('session:')) { + return null; + } + + const sessionID = dedupeKey.slice('session:'.length).trim(); + return sessionID || null; +}; + +const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null): string => { + if (typeof window === 'undefined') { + return ''; + } + + const url = new URL(window.location.pathname, window.location.origin); + url.searchParams.set('ocPanel', 'session-chat'); + url.searchParams.set('sessionId', sessionID); + if (directory && directory.trim().length > 0) { + url.searchParams.set('directory', directory); + } else { + url.searchParams.delete('directory'); + } + + url.hash = ''; + return url.toString(); +}; + +const truncateTabLabel = (value: string, maxChars: number): string => { + if (value.length <= maxChars) { + return value; + } + + return `${value.slice(0, maxChars - 3)}...`; +}; + export const ContextPanel: React.FC = () => { const effectiveDirectory = useEffectiveDirectory() ?? ''; const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]); const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined)); const closeContextPanel = useUIStore((state) => state.closeContextPanel); + const closeContextPanelTab = useUIStore((state) => state.closeContextPanelTab); const toggleContextPanelExpanded = useUIStore((state) => state.toggleContextPanelExpanded); const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth); + const setActiveContextPanelTab = useUIStore((state) => state.setActiveContextPanelTab); + const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs); + const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile); + const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath); + const { themeMode, lightThemeId, darkThemeId, currentTheme } = useThemeSystem(); - const isOpen = Boolean(panelState?.isOpen && panelState?.mode); + const tabs = React.useMemo(() => panelState?.tabs ?? [], [panelState?.tabs]); + const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? tabs[tabs.length - 1] ?? null; + const isOpen = Boolean(panelState?.isOpen && activeTab); const isExpanded = Boolean(isOpen && panelState?.expanded); const width = clampWidth(panelState?.width ?? CONTEXT_PANEL_DEFAULT_WIDTH); const [isResizing, setIsResizing] = React.useState(false); const startXRef = React.useRef(0); const startWidthRef = React.useRef(width); + const resizingWidthRef = React.useRef(null); + const activeResizePointerIDRef = React.useRef(null); const panelRef = React.useRef(null); + const chatFrameRefs = React.useRef>(new Map()); const wasOpenRef = React.useRef(false); React.useEffect(() => { @@ -85,39 +199,73 @@ export const ContextPanel: React.FC = () => { return () => window.cancelAnimationFrame(frame); }, [isOpen]); - React.useEffect(() => { - if (!isResizing || !directoryKey) { + const applyLiveWidth = React.useCallback((nextWidth: number) => { + const panel = panelRef.current; + if (!panel) { return; } - const handlePointerMove = (event: PointerEvent) => { - const delta = startXRef.current - event.clientX; - setContextPanelWidth(directoryKey, startWidthRef.current + delta); - }; - - const handlePointerUp = () => { - setIsResizing(false); - }; - - window.addEventListener('pointermove', handlePointerMove); - window.addEventListener('pointerup', handlePointerUp, { once: true }); - - return () => { - window.removeEventListener('pointermove', handlePointerMove); - window.removeEventListener('pointerup', handlePointerUp); - }; - }, [directoryKey, isResizing, setContextPanelWidth]); + panel.style.setProperty('--oc-context-panel-width', `${nextWidth}px`); + }, []); const handleResizeStart = React.useCallback((event: React.PointerEvent) => { if (!isOpen || isExpanded || !directoryKey) { return; } + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // ignore; fallback listeners still handle drag + } + + activeResizePointerIDRef.current = event.pointerId; setIsResizing(true); startXRef.current = event.clientX; startWidthRef.current = width; + resizingWidthRef.current = width; + applyLiveWidth(width); event.preventDefault(); - }, [directoryKey, isExpanded, isOpen, width]); + }, [applyLiveWidth, directoryKey, isExpanded, isOpen, width]); + + const handleResizeMove = React.useCallback((event: React.PointerEvent) => { + if (!isResizing || activeResizePointerIDRef.current !== event.pointerId) { + return; + } + + const delta = startXRef.current - event.clientX; + const nextWidth = clampWidth(startWidthRef.current + delta); + if (resizingWidthRef.current === nextWidth) { + return; + } + + resizingWidthRef.current = nextWidth; + applyLiveWidth(nextWidth); + }, [applyLiveWidth, isResizing]); + + const handleResizeEnd = React.useCallback((event: React.PointerEvent) => { + if (activeResizePointerIDRef.current !== event.pointerId || !directoryKey) { + return; + } + + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // ignore + } + + const finalWidth = resizingWidthRef.current ?? width; + setIsResizing(false); + activeResizePointerIDRef.current = null; + resizingWidthRef.current = null; + setContextPanelWidth(directoryKey, finalWidth); + }, [directoryKey, setContextPanelWidth, width]); + + React.useEffect(() => { + if (!isResizing) { + resizingWidthRef.current = null; + } + }, [isResizing]); const handleClose = React.useCallback(() => { if (!directoryKey) { @@ -143,50 +291,190 @@ export const ContextPanel: React.FC = () => { handleClose(); }, [handleClose]); - const activeFilePath = useFilesViewTabsStore((state) => (directoryKey ? (state.byRoot[directoryKey]?.selectedPath ?? null) : null)); + React.useEffect(() => { + if (!directoryKey || !activeTab) { + return; + } - const panelTitle = panelState?.mode === 'diff' ? 'Diff' : panelState?.mode === 'file' ? 'File' : panelState?.mode === 'context' ? 'Context' : panelState?.mode === 'plan' ? 'Plan' : 'Panel'; - const effectivePath = panelState?.mode === 'file' ? (activeFilePath ?? panelState?.targetPath ?? null) : panelState?.mode === 'context' ? null : (panelState?.targetPath ?? null); - const pathLabel = getRelativePathLabel(effectivePath, effectiveDirectory); + if (activeTab.mode === 'file' && activeTab.targetPath) { + setSelectedFilePath(directoryKey, activeTab.targetPath); + return; + } - const content = panelState?.mode === 'diff' - ? - : panelState?.mode === 'file' - ? - : panelState?.mode === 'context' + if (activeTab.mode === 'diff' && activeTab.targetPath) { + setPendingDiffFile(activeTab.targetPath); + } + }, [activeTab, directoryKey, setPendingDiffFile, setSelectedFilePath]); + + const activeChatTabID = activeTab?.mode === 'chat' ? activeTab.id : null; + + const postThemeSyncToEmbeddedChat = React.useCallback(() => { + if (typeof window === 'undefined') { + return; + } + + const payload = { + themeMode, + lightThemeId, + darkThemeId, + currentTheme, + }; + + for (const frame of chatFrameRefs.current.values()) { + const frameWindow = frame.contentWindow; + if (!frameWindow) { + continue; + } + + const directThemeSync = (frameWindow as unknown as { + __openchamberApplyThemeSync?: (themePayload: typeof payload) => void; + }).__openchamberApplyThemeSync; + + if (typeof directThemeSync === 'function') { + try { + directThemeSync(payload); + continue; + } catch { + // fallback to postMessage below + } + } + + frameWindow.postMessage( + { + type: 'openchamber:theme-sync', + payload, + }, + window.location.origin, + ); + } + }, [currentTheme, darkThemeId, lightThemeId, themeMode]); + + const postEmbeddedVisibilityToChats = React.useCallback(() => { + if (typeof window === 'undefined') { + return; + } + + for (const [tabID, frame] of chatFrameRefs.current.entries()) { + const frameWindow = frame.contentWindow; + if (!frameWindow) { + continue; + } + + const payload = { visible: activeChatTabID === tabID }; + const directVisibilitySync = (frameWindow as unknown as { + __openchamberSetEmbeddedVisibility?: (visibilityPayload: typeof payload) => void; + }).__openchamberSetEmbeddedVisibility; + + if (typeof directVisibilitySync === 'function') { + try { + directVisibilitySync(payload); + continue; + } catch { + // fallback to postMessage below + } + } + + frameWindow.postMessage( + { + type: 'openchamber:embedded-visibility', + payload, + }, + window.location.origin, + ); + } + }, [activeChatTabID]); + + React.useLayoutEffect(() => { + const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat'); + if (!hasAnyChatTab) { + return; + } + + postThemeSyncToEmbeddedChat(); + postEmbeddedVisibilityToChats(); + }, [darkThemeId, lightThemeId, postEmbeddedVisibilityToChats, postThemeSyncToEmbeddedChat, tabs, themeMode]); + + const tabItems = React.useMemo(() => tabs.map((tab) => { + const rawLabel = getTabLabel(tab); + const label = truncateTabLabel(rawLabel, CONTEXT_TAB_LABEL_MAX_CHARS); + const tabPathLabel = getRelativePathLabel(tab.targetPath, effectiveDirectory); + return { + id: tab.id, + label, + icon: getTabIcon(tab), + title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel, + closeLabel: `Close ${label} tab`, + }; + }), [effectiveDirectory, tabs]); + + const activeNonChatContent = activeTab?.mode === 'diff' + ? + : activeTab?.mode === 'context' ? - : panelState?.mode === 'plan' + : activeTab?.mode === 'plan' ? : null; + const chatTabs = React.useMemo( + () => tabs.filter((tab) => tab.mode === 'chat'), + [tabs], + ); + const hasFileTabs = React.useMemo( + () => tabs.some((tab) => tab.mode === 'file'), + [tabs], + ); + + const isFileTabActive = activeTab?.mode === 'file'; + const header = ( -
-
- {panelTitle} - {pathLabel ? {pathLabel} : null} +
+ { + if (!directoryKey) { + return; + } + setActiveContextPanelTab(directoryKey, tabID); + }} + onClose={(tabID) => { + if (!directoryKey) { + return; + } + closeContextPanelTab(directoryKey, tabID); + }} + onReorder={(activeTabID, overTabID) => { + if (!directoryKey) { + return; + } + reorderContextPanelTabs(directoryKey, activeTabID, overTabID); + }} + layoutMode="scrollable" + /> +
+ +
- -
); @@ -196,13 +484,16 @@ export const ContextPanel: React.FC = () => { const panelStyle: React.CSSProperties = isExpanded ? { - ['--oc-context-panel-width' as string]: '100vw', + ['--oc-context-panel-width' as string]: '100%', + width: '100%', + minWidth: '100%', + maxWidth: '100%', } : { - width: `${width}px`, - minWidth: `${width}px`, - maxWidth: `${width}px`, - ['--oc-context-panel-width' as string]: `${width}px`, + width: 'var(--oc-context-panel-width)', + minWidth: 'var(--oc-context-panel-width)', + maxWidth: 'var(--oc-context-panel-width)', + ['--oc-context-panel-width' as string]: `${isResizing ? (resizingWidthRef.current ?? width) : width}px`, }; return ( @@ -228,13 +519,57 @@ export const ContextPanel: React.FC = () => { isResizing && 'bg-primary' )} onPointerDown={handleResizeStart} + onPointerMove={handleResizeMove} + onPointerUp={handleResizeEnd} + onPointerCancel={handleResizeEnd} role="separator" aria-orientation="vertical" aria-label="Resize context panel" /> )} {header} -
{content}
+
+ {hasFileTabs ? ( +
+ +
+ ) : null} + {chatTabs.map((tab) => { + const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey); + if (!sessionID) { + return null; + } + + const src = buildEmbeddedSessionChatURL(sessionID, directoryKey || null); + if (!src) { + return null; + } + + return ( +