From b4cd16f55b027a19ebb89069f60949a4bc094dfa Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 2 Mar 2026 02:11:33 +0200 Subject: [PATCH] feat(ui): polish chat and git workflows with mobile UX and reliability fixes (#569) * feat: add chat option for user message rendering mode * feat: add chat option to toggle sticky user header * feat(ui): overhaul context panel with reusable tabs and embedded session chat Enable parallel context workflows with persistent tabbed views and isolated session chat while reducing resize and background runtime overhead. * feat: polish context panel and git sidebar tabs Refined context panel tab behavior and visuals for smoother switching and resizing Reused the new tabs component in right sidebar and git sidebar with fit layout Improved git section spacing, selection controls, and bulk revert confirmation flow * feat: open diff files in editor at changed lines Add edit actions in diff views to open files at the first changed line Support per-file open-in-editor from All Files headers and icon-only action in single-file view Improve file jump UX with load-aware navigation and reduced visual blink during line targeting * fix: stabilize pill tabs and prevent git commit pathspec failures Unified sortable tab variants to match animated styling behavior with responsive spacing and cleaner sidebar chrome Fixed active tab pill measurement so size/position recalculates correctly when dropdowns reopen Commit API now filters stale file paths before staging to avoid pathspec errors on deleted files * fix: align user message action row spacing and hover behavior * fix: persist user message view preferences in settings Save plain-text and sticky-header toggles to settings.json when changed Restore both chat display preferences from settings.json on startup Validate and accept both preference fields in the settings API * fix: improve git and sidebar tab layout on mobile * fix: refine mobile user message action row spacing Show mobile user-message actions in a consistent external row for sticky and non-sticky modes Tune button row height and vertical position to match both mobile variants Reduce sticky-header gradient tail and tighten assistant gap after user messages * fix: improve chat action hover zones and mobile top shadow logic Expand desktop trigger area so user action buttons reveal across the full row Add sticky-header phantom hover row so inline actions appear from the whole button lane Hide chat top scroll shadow on mobile only when sticky user headers are enabled * fix: remove commit message input scrollbar flicker Added optional scrollbar class support to shared textarea wrapper. Disabled overlay scrollbar for Git commit message input. Kept auto-resize behavior while preventing one-line empty-state micro-scroll. * feat: make model provider groups collapsible in selector Add collapsible provider headers in the chat model dropdown Persist expanded/collapsed provider state across sessions Refine provider header UX with inline chevrons and no hover highlight * feat: arrange chat settings into a compact two-column layout Places User Message Rendering next to Mermaid Rendering. Places Diff Layout next to Diff View Mode. Reduces right-column spacing to better match other settings sections. * fix: show worktree branch edit controls in draft sessions Detect worktree mode from current directory when session metadata is not yet bound Enable immediate branch rename UI in Git sidebar without session switching * feat: add beta badge to side panel menu action --- packages/ui/src/App.tsx | 190 +++++- .../ui/src/components/chat/ChatContainer.tsx | 17 +- .../ui/src/components/chat/ChatMessage.tsx | 108 ++-- .../ui/src/components/chat/MessageList.tsx | 10 +- .../ui/src/components/chat/ModelControls.tsx | 91 ++- .../components/chat/message/MessageBody.tsx | 204 ++++--- .../chat/message/parts/UserTextPart.tsx | 47 +- .../ui/src/components/layout/ContextPanel.tsx | 463 ++++++++++++--- packages/ui/src/components/layout/Header.tsx | 163 ++++-- .../ui/src/components/layout/MainLayout.tsx | 4 +- .../layout/ProjectActionsButton.tsx | 37 +- .../ui/src/components/layout/RightSidebar.tsx | 97 +++- .../components/layout/RightSidebarTabs.tsx | 34 +- packages/ui/src/components/layout/Sidebar.tsx | 99 +++- .../sections/openchamber/OpenChamberPage.tsx | 4 +- .../openchamber/OpenChamberVisualSettings.tsx | 316 ++++++---- .../skills/catalog/SkillsCatalogPage.tsx | 25 +- .../src/components/session/SessionSidebar.tsx | 24 + .../ui/src/components/ui/animated-tabs.tsx | 143 ----- .../src/components/ui/sortable-tabs-strip.tsx | 440 ++++++++++++++ packages/ui/src/components/ui/textarea.tsx | 10 +- packages/ui/src/components/views/DiffView.tsx | 170 +++++- .../ui/src/components/views/FilesView.tsx | 403 +++++++++---- packages/ui/src/components/views/GitView.tsx | 218 +++++-- .../views/git/BranchIntegrationSection.tsx | 51 +- .../ui/src/components/views/git/ChangeRow.tsx | 4 +- .../components/views/git/ChangesSection.tsx | 211 ++++--- .../src/components/views/git/CommitInput.tsx | 11 +- .../components/views/git/CommitSection.tsx | 28 +- .../ui/src/components/views/git/GitHeader.tsx | 4 +- .../views/git/IntegrateCommitsSection.tsx | 8 +- .../views/git/PullRequestSection.tsx | 25 +- .../ui/src/contexts/ThemeSystemContext.tsx | 152 +++++ packages/ui/src/hooks/useEventStream.ts | 16 +- .../ui/src/hooks/usePushVisibilityBeacon.ts | 7 +- .../ui/src/hooks/useQueuedMessageAutoSend.ts | 10 +- .../ui/src/hooks/useServerSessionStatus.ts | 22 +- .../ui/src/hooks/useSessionAutoCleanup.ts | 7 + .../ui/src/hooks/useSessionStatusBootstrap.ts | 9 +- packages/ui/src/index.css | 25 + packages/ui/src/lib/desktop.ts | 2 + packages/ui/src/lib/persistence.ts | 16 + packages/ui/src/stores/useUIStore.ts | 544 ++++++++++++++++-- packages/web/server/index.js | 9 + packages/web/server/lib/git/service.js | 48 +- 45 files changed, 3551 insertions(+), 975 deletions(-) delete mode 100644 packages/ui/src/components/ui/animated-tabs.tsx create mode 100644 packages/ui/src/components/ui/sortable-tabs-strip.tsx 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 ( +