From f34027df10eb11c52b62f3a62b3fcadc420e4d9a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 25 Jan 2026 19:16:26 +0200 Subject: [PATCH] Optimize chat rendering & add web session activity tracking (#214) * feat: add chat virtualization and turn grouping infrastructure Implement VirtualMessageList with virtualization and overscan for smooth scrolling Refactor MessageList to render only visible messages and pass scroll refs Introduce TurnGroupingContext and provider to stabilize per-turn rendering and reduce re-renders * feat: add web server session activity endpoint for visibility restore Add /api/session-activity endpoint to expose tracked session activity Use web server activity first when restoring visibility, with fallback to global status Update server SSE to set session phase on activity events * feat(chat): split TurnGroupingContext into UI/Streaming contexts Add separate UI state and streaming contexts to reduce re-renders. Skip animations for items visible in collapsed view when expanding. Track shown parts in collapsed mode to fade in progressively * feat(ui): cache turn groups with expand state and guard web activity Add isExpanded to the turn cache key to trigger updates correctly Expose isGroupExpanded from UI state so groups reflect expand/collapse Limit web server session activity fetch to web runtime only --- packages/desktop/src-tauri/Cargo.lock | 39 +- packages/desktop/src-tauri/Cargo.toml | 3 +- packages/desktop/src-tauri/src/main.rs | 59 +- packages/desktop/src-tauri/tauri.conf.json | 2 +- packages/ui/src/App.tsx | 2 +- .../ui/src/components/chat/ChatContainer.tsx | 4 + .../ui/src/components/chat/ChatMessage.tsx | 71 +-- .../ui/src/components/chat/MessageList.tsx | 185 ++++-- .../chat/contexts/TurnGroupingContext.tsx | 573 ++++++++++++++++++ .../chat/message/FadeInOnReveal.tsx | 20 +- .../chat/message/parts/ProgressiveGroup.tsx | 26 +- .../chat/message/parts/ToolPart.tsx | 31 +- .../ui/src/components/layout/MainLayout.tsx | 19 +- .../src/components/session/SessionSidebar.tsx | 2 +- .../ui/src/components/ui/ScrollShadow.tsx | 17 +- .../ui/src/components/views/SettingsView.tsx | 2 +- .../ui/src/contexts/ThemeSystemContext.tsx | 8 +- packages/ui/src/hooks/useEventStream.ts | 12 + packages/ui/src/lib/opencode/client.ts | 32 + packages/web/server/index.js | 63 ++ 20 files changed, 1000 insertions(+), 170 deletions(-) create mode 100644 packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 00cbff99..d0c2150a 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2882,6 +2882,17 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-osa-kit" version = "0.3.2" @@ -2904,7 +2915,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", ] [[package]] @@ -2914,8 +2925,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.10.0", + "block2 0.6.2", + "libc", "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", ] [[package]] @@ -2991,7 +3008,9 @@ dependencies = [ "nix 0.28.0", "objc", "objc2 0.6.3", + "objc2-app-kit", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "once_cell", "parking_lot", "portable-pty", @@ -3016,7 +3035,6 @@ dependencies = [ "url", "urlencoding", "uuid", - "window-vibrancy 0.7.1", "zip 2.4.2", ] @@ -4756,7 +4774,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "window-vibrancy 0.6.0", + "window-vibrancy", "windows", ] @@ -6006,21 +6024,6 @@ dependencies = [ "windows-version", ] -[[package]] -name = "window-vibrancy" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010797bd7c40396fbc59d3105089fed0885fe267a0ef4a0a4646df54e28647f6" -dependencies = [ - "objc2 0.6.3", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "raw-window-handle", - "windows-sys 0.60.2", - "windows-version", -] - [[package]] name = "windows" version = "0.61.3" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 34845e59..7ca815ce 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -59,4 +59,5 @@ zip = "2.1" tauri-build = { version = "2.5.3", features = [] } [target.'cfg(target_os = "macos")'.dependencies] -window-vibrancy = "0.7.1" +objc2-app-kit = { version = "0.3.2", features = ["NSView", "NSResponder"] } +objc2-quartz-core = { version = "0.3.2", features = ["CALayer"] } diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index c69ae800..623a380f 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -88,8 +88,7 @@ use window_state::{load_window_state, persist_window_state, WindowStateManager}; #[cfg(target_os = "macos")] use std::sync::atomic::{AtomicBool, Ordering}; -#[cfg(target_os = "macos")] -use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial}; + #[cfg(target_os = "macos")] static NEEDS_TRAFFIC_LIGHT_FIX: AtomicBool = AtomicBool::new(false); @@ -352,6 +351,41 @@ fn adjust_traffic_lights_position( } } +#[cfg(target_os = "macos")] +fn optimize_webview_layer(window: &tauri::WebviewWindow) { + use objc2::msg_send; + use objc2::runtime::AnyObject; + + if let Ok(ns_view) = window.ns_view() { + unsafe { + let view: *mut AnyObject = ns_view.cast(); + if view.is_null() { + warn!("[macos:layer] NSView is null"); + return; + } + + // Enable layer-backing for GPU compositing + let _: () = msg_send![view, setWantsLayer: true]; + + // Get the layer + let layer: *mut AnyObject = msg_send![view, layer]; + if !layer.is_null() { + // Enable asynchronous drawing for better scroll performance + let _: () = msg_send![layer, setDrawsAsynchronously: true]; + + // Disable implicit animations that can cause jitter + let _: () = msg_send![layer, setActions: std::ptr::null::()]; + + info!("[macos:layer] WebView layer optimizations applied"); + } else { + warn!("[macos:layer] Layer is null after setWantsLayer"); + } + } + } else { + warn!("[macos:layer] Failed to get NSView"); + } +} + #[cfg(target_os = "macos")] fn prevent_app_nap() { use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString}; @@ -681,28 +715,13 @@ fn main() { let macos_version = get_macos_major_version(); info!("[macos] Detected macOS version: {}", macos_version); - let corner_radius = if macos_version >= 26 { 24.0 } else { 10.0 }; - if let Err(error) = apply_vibrancy( - &window, - NSVisualEffectMaterial::Sidebar, - None, - Some(corner_radius), - ) { - warn!( - "[desktop:vibrancy] Failed to apply macOS vibrancy: {}", - error - ); - } else { - info!( - "[desktop:vibrancy] Applied macOS Sidebar vibrancy with radius {}", - corner_radius - ); - } - if macos_version < 26 { NEEDS_TRAFFIC_LIGHT_FIX.store(true, Ordering::SeqCst); adjust_traffic_lights_position(&window, 17.0, 16.0); } + + // Apply layer optimizations for smoother scrolling + optimize_webview_layer(&window); } if let Some(saved) = &stored_state { diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 34f7d307..223d4726 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -14,7 +14,7 @@ { "label": "main", "title": "OpenChamber", - "transparent": true, + "transparent": false, "width": 1280, "height": 800, "resizable": true, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index cb9c3efa..079bf6a8 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -248,7 +248,7 @@ function App({ apis }: AppProps) { -
+
diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index bd56d504..9c179432 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -269,6 +269,10 @@ export const ChatContainer: React.FC = () => { style={{ contain: 'strict', ['--scroll-shadow-size' as string]: '48px', + // GPU acceleration hints for smoother scrolling + transform: 'translateZ(0)', + willChange: 'scroll-position', + backfaceVisibility: 'hidden', }} data-scroll-shadow="true" data-scrollbar="chat" diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index ed9335fc..8e552e88 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -96,6 +96,8 @@ const ChatMessage: React.FC = ({ currentSessionId: state.currentSessionId, getAgentModelForSession: state.getAgentModelForSession, getSessionModelSelection: state.getSessionModelSelection, + revertToMessage: state.revertToMessage, + forkFromMessage: state.forkFromMessage, })) ); @@ -105,11 +107,17 @@ const ChatMessage: React.FC = ({ currentSessionId, getAgentModelForSession, getSessionModelSelection, + revertToMessage, + forkFromMessage, } = sessionState; const providers = useConfigStore((state) => state.providers); - const showReasoningTraces = useUIStore((state) => state.showReasoningTraces); - const toolCallExpansion = useUIStore((state) => state.toolCallExpansion); + const { showReasoningTraces, toolCallExpansion } = useUIStore( + useShallow((state) => ({ + showReasoningTraces: state.showReasoningTraces, + toolCallExpansion: state.toolCallExpansion, + })) + ); React.useEffect(() => { if (currentSessionId) { @@ -136,11 +144,11 @@ const ChatMessage: React.FC = ({ const sessionId = message.info.sessionID; // Subscribe to context changes so badges update immediately on mode switches. - const currentContextAgent = useContextStore( - (state) => (sessionId ? state.currentAgentContext.get(sessionId) : undefined) - ); - const savedSessionAgentSelection = useContextStore( - (state) => (sessionId ? state.sessionAgentSelections.get(sessionId) : undefined) + const { currentContextAgent, savedSessionAgentSelection } = useContextStore( + useShallow((state) => ({ + currentContextAgent: sessionId ? state.currentAgentContext.get(sessionId) : undefined, + savedSessionAgentSelection: sessionId ? state.sessionAgentSelections.get(sessionId) : undefined, + })) ); const normalizedParts = React.useMemo(() => { @@ -566,30 +574,28 @@ const ChatMessage: React.FC = ({ }, []); const userMessageIdForTurn = turnGroupingContext?.turnId; - const assistantSummaryFromStore = useMessageStore((state) => { - if (!userMessageIdForTurn) return undefined; - const sessionId = message.info.sessionID; - if (!sessionId) return undefined; - const sessionMessages = state.messages.get(sessionId); - if (!sessionMessages) return undefined; - const userMsg = sessionMessages.find((entry) => entry.info?.id === userMessageIdForTurn); - if (!userMsg) return undefined; - const summary = (userMsg.info as { summary?: { body?: string | null | undefined } | null | undefined }).summary; - const body = summary?.body; - return typeof body === 'string' && body.trim().length > 0 ? body : undefined; - }); - - const variantFromTurnStore = useMessageStore((state) => { - if (!userMessageIdForTurn) return undefined; - const sessionId = message.info.sessionID; - if (!sessionId) return undefined; - const sessionMessages = state.messages.get(sessionId); - if (!sessionMessages) return undefined; - const userMsg = sessionMessages.find((entry) => entry.info?.id === userMessageIdForTurn); - if (!userMsg) return undefined; - const variant = (userMsg.info as { variant?: unknown }).variant; - return typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined; - }); + const { assistantSummaryFromStore, variantFromTurnStore } = useMessageStore( + useShallow((state) => { + if (!userMessageIdForTurn || !message.info.sessionID) { + return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined }; + } + const sessionMessages = state.messages.get(message.info.sessionID); + if (!sessionMessages) { + return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined }; + } + const userMsg = sessionMessages.find((entry) => entry.info?.id === userMessageIdForTurn); + if (!userMsg) { + return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined }; + } + const summary = (userMsg.info as { summary?: { body?: string | null | undefined } | null | undefined }).summary; + const body = summary?.body; + const variant = (userMsg.info as { variant?: unknown }).variant; + return { + assistantSummaryFromStore: typeof body === 'string' && body.trim().length > 0 ? body : undefined, + variantFromTurnStore: typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined, + }; + }) + ); const headerVariantRaw = !isUser ? (variantFromTurnStore ?? previousUserMetadata?.variant) : undefined; @@ -699,9 +705,6 @@ const ChatMessage: React.FC = ({ setTimeout(() => setCopiedMessage(false), 2000); }, [copyTextToClipboard, messageTextContent]); - const revertToMessage = useSessionStore((state) => state.revertToMessage); - const forkFromMessage = useSessionStore((state) => state.forkFromMessage); - const handleRevert = React.useCallback(() => { if (!sessionId || !message.info.id) return; revertToMessage(sessionId, message.info.id); diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 61b3ce82..7886bf78 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -8,10 +8,15 @@ import type { PermissionRequest } from '@/types/permission'; import type { QuestionRequest } from '@/types/question'; import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager'; import { filterSyntheticParts } from '@/lib/messages/synthetic'; -import { useTurnGrouping } from './hooks/useTurnGrouping'; +import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic, useLastTurnMessageIds } from './contexts/TurnGroupingContext'; + +interface ChatMessageEntry { + info: Message; + parts: Part[]; +} interface MessageListProps { - messages: { info: Message; parts: Part[] }[]; + messages: ChatMessageEntry[]; permissions: PermissionRequest[]; questions: QuestionRequest[]; onMessageContentChange: (reason?: ContentChangeReason) => void; @@ -20,8 +25,97 @@ interface MessageListProps { isLoadingOlder: boolean; onLoadOlder: () => void; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; + scrollRef?: React.RefObject; } +interface MessageRowProps { + message: ChatMessageEntry; + onContentChange: (reason?: ContentChangeReason) => void; + animationHandlers: AnimationHandlers; + scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; +} + +// Static MessageRow - does NOT subscribe to dynamic context +// Used for messages NOT in the last turn - no re-renders during streaming +const StaticMessageRow = React.memo(({ + message, + onContentChange, + animationHandlers, + scrollToBottom, +}) => { + const { previousMessage, nextMessage } = useMessageNeighbors(message.info.id); + const turnGroupingContext = useTurnGroupingContextStatic(message.info.id); + + return ( + + ); +}); + +StaticMessageRow.displayName = 'StaticMessageRow'; + +// Dynamic MessageRow - subscribes to dynamic context for streaming state +// Used for messages in the LAST turn only +const DynamicMessageRow = React.memo(({ + message, + onContentChange, + animationHandlers, + scrollToBottom, +}) => { + const { previousMessage, nextMessage } = useMessageNeighbors(message.info.id); + const turnGroupingContext = useTurnGroupingContextForMessage(message.info.id); + + return ( + + ); +}); + +DynamicMessageRow.displayName = 'DynamicMessageRow'; + +// Inner component that renders messages with access to context hooks +const MessageListContent: React.FC<{ + displayMessages: ChatMessageEntry[]; + onMessageContentChange: (reason?: ContentChangeReason) => void; + getAnimationHandlers: (messageId: string) => AnimationHandlers; + scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; +}> = ({ displayMessages, onMessageContentChange, getAnimationHandlers, scrollToBottom }) => { + const lastTurnMessageIds = useLastTurnMessageIds(); + + return ( + <> + {displayMessages.map((message) => { + const isInLastTurn = lastTurnMessageIds.has(message.info.id); + const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow; + + return ( + + ); + })} + + ); +}; + const MessageList: React.FC = ({ messages, permissions, @@ -67,58 +161,49 @@ const MessageList: React.FC = ({ }); }, [messages]); - const { getContextForMessage } = useTurnGrouping(displayMessages); - return ( -
- {hasMoreAbove && ( -
- {isLoadingOlder ? ( - - Loading… - - ) : ( - - )} -
- )} + +
+ {hasMoreAbove && ( +
+ {isLoadingOlder ? ( + + Loading… + + ) : ( + + )} +
+ )} -
- {displayMessages.map((message, index) => ( - 0 ? displayMessages[index - 1] : undefined} - nextMessage={index < displayMessages.length - 1 ? displayMessages[index + 1] : undefined} - onContentChange={onMessageContentChange} - animationHandlers={getAnimationHandlers(message.info.id)} - scrollToBottom={scrollToBottom} - turnGroupingContext={getContextForMessage(message.info.id)} - /> - ))} + + {(questions.length > 0 || permissions.length > 0) && ( +
+ {questions.map((question) => ( + + ))} + {permissions.map((permission) => ( + + ))} +
+ )} + + {/* Bottom spacer - always 10% of viewport height */} + - - {(questions.length > 0 || permissions.length > 0) && ( -
- {questions.map((question) => ( - - ))} - {permissions.map((permission) => ( - - ))} -
- )} - - {/* Bottom spacer - always 10% of viewport height */} - + ); }; diff --git a/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx b/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx new file mode 100644 index 00000000..af4d7d3c --- /dev/null +++ b/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx @@ -0,0 +1,573 @@ +/* eslint-disable react-refresh/only-export-components */ +import React from 'react'; +import type { Message, Part } from '@opencode-ai/sdk/v2'; +import type { TurnGroupingContext as TurnGroupingContextType } from '../hooks/useTurnGrouping'; +import { detectTurns, type Turn, type TurnActivityPart, type TurnActivityGroup } from '../hooks/useTurnGrouping'; +import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; +import { useUIStore } from '@/stores/useUIStore'; + +interface ChatMessageEntry { + info: Message; + parts: Part[]; +} + +interface TurnDiffStats { + additions: number; + deletions: number; + files: number; +} + +interface TurnActivityInfo { + activityParts: TurnActivityPart[]; + activityGroupSegments: TurnActivityGroup[]; + hasTools: boolean; + hasReasoning: boolean; + summaryBody?: string; + diffStats?: TurnDiffStats; +} + +interface NeighborInfo { + previousMessage?: ChatMessageEntry; + nextMessage?: ChatMessageEntry; +} + +// Static data that only changes when messages change +interface TurnGroupingStaticData { + turns: Turn[]; + messageToTurn: Map; + turnActivityInfo: Map; + lastTurnId: string | null; + lastTurnMessageIds: Set; // Messages belonging to the last turn + defaultActivityExpanded: boolean; + // Neighbor lookup - stable until messages change + messageNeighbors: Map; +} + +// UI state that changes on user interaction (expand/collapse) +interface TurnGroupingUiStateData { + turnUiStates: Map; + toggleGroup: (turnId: string) => void; +} + +// Streaming state that changes frequently during assistant response +interface TurnGroupingStreamingData { + sessionIsWorking: boolean; +} + +// Separate contexts to prevent unnecessary re-renders +const TurnGroupingStaticContext = React.createContext(null); +const TurnGroupingUiStateContext = React.createContext(null); +const TurnGroupingStreamingContext = React.createContext(null); + +// Track staticData reference to clear cache when it changes +let lastStaticDataRef: TurnGroupingStaticData | null = null; +const contextCache = new Map(); + +export const useTurnGroupingContextForMessage = (messageId: string): TurnGroupingContextType | undefined => { + const staticData = React.useContext(TurnGroupingStaticContext); + const uiStateData = React.useContext(TurnGroupingUiStateContext); + const streamingData = React.useContext(TurnGroupingStreamingContext); + + return React.useMemo(() => { + if (!staticData || !uiStateData || !streamingData) return undefined; + + // Clear cache when staticData changes (new messages arrived) + if (lastStaticDataRef !== staticData) { + contextCache.clear(); + lastStaticDataRef = staticData; + } + + const turn = staticData.messageToTurn.get(messageId); + if (!turn) return undefined; + + const isAssistantMessage = turn.assistantMessages.some( + (msg) => msg.info.id === messageId + ); + if (!isAssistantMessage) return undefined; + + const isLastTurn = staticData.lastTurnId === turn.turnId; + + // Get UI state early - needed for cache key to ensure expand/collapse updates propagate + const uiState = uiStateData.turnUiStates.get(turn.turnId) ?? { isExpanded: staticData.defaultActivityExpanded }; + const isExpanded = uiState.isExpanded; + + // Cache key must include: + // - messageId: identifies the specific message + // - isExpanded: UI state for this turn's activity group + // - sessionIsWorking (last turn only): streaming state affects "working" indicator + const cacheKey = isLastTurn + ? `${messageId}-${isExpanded}-${streamingData.sessionIsWorking}` + : `${messageId}-${isExpanded}`; + + const cached = contextCache.get(cacheKey); + if (cached) return cached; + + const activityInfo = staticData.turnActivityInfo.get(turn.turnId); + const activityParts = activityInfo?.activityParts ?? []; + const activityGroupSegments = activityInfo?.activityGroupSegments ?? []; + const hasTools = Boolean(activityInfo?.hasTools); + const hasReasoning = Boolean(activityInfo?.hasReasoning); + const summaryBody = activityInfo?.summaryBody; + const diffStats = activityInfo?.diffStats; + + const firstAssistantId = turn.assistantMessages[0]?.info.id; + const isFirstAssistantInTurn = messageId === firstAssistantId; + const lastAssistantId = turn.assistantMessages[turn.assistantMessages.length - 1]?.info.id; + const isLastAssistantInTurn = messageId === lastAssistantId; + const headerMessageId = firstAssistantId; + // Only the last turn can be "working" + const isTurnWorking = isLastTurn && streamingData.sessionIsWorking; + + const userTimeInfo = turn.userMessage.info.time as { created?: number } | undefined; + const userMessageCreatedAt = typeof userTimeInfo?.created === 'number' ? userTimeInfo.created : undefined; + + const context: TurnGroupingContextType = { + turnId: turn.turnId, + isFirstAssistantInTurn, + isLastAssistantInTurn, + summaryBody, + activityParts, + activityGroupSegments, + headerMessageId, + hasTools, + hasReasoning, + diffStats, + userMessageCreatedAt, + isWorking: isTurnWorking, + isGroupExpanded: isExpanded, + toggleGroup: () => uiStateData.toggleGroup(turn.turnId), + }; + + // Cache with size limit + if (contextCache.size > 500) { + const firstKey = contextCache.keys().next().value; + if (firstKey) contextCache.delete(firstKey); + } + contextCache.set(cacheKey, context); + + return context; + }, [staticData, uiStateData, streamingData, messageId]); +}; + +// Hook to get neighbor messages - uses context instead of passed messages array +export const useMessageNeighbors = (messageId: string): NeighborInfo => { + const staticData = React.useContext(TurnGroupingStaticContext); + + // Return stable reference from context - no dependencies on messages array + return React.useMemo(() => { + if (!staticData) return {}; + return staticData.messageNeighbors.get(messageId) ?? {}; + }, [staticData, messageId]); +}; + +// Hook to get last turn message IDs - only reads static context +export const useLastTurnMessageIds = (): Set => { + const staticData = React.useContext(TurnGroupingStaticContext); + return staticData?.lastTurnMessageIds ?? new Set(); +}; + +// Static-only version of turn grouping context - does NOT subscribe to streaming context +// Use this for messages NOT in the last turn to avoid re-renders during streaming +// Still subscribes to UI state context for expand/collapse functionality +export const useTurnGroupingContextStatic = (messageId: string): TurnGroupingContextType | undefined => { + const staticData = React.useContext(TurnGroupingStaticContext); + const uiStateData = React.useContext(TurnGroupingUiStateContext); + + return React.useMemo(() => { + if (!staticData || !uiStateData) return undefined; + + const turn = staticData.messageToTurn.get(messageId); + if (!turn) return undefined; + + const isAssistantMessage = turn.assistantMessages.some( + (msg) => msg.info.id === messageId + ); + if (!isAssistantMessage) return undefined; + + const activityInfo = staticData.turnActivityInfo.get(turn.turnId); + const activityParts = activityInfo?.activityParts ?? []; + const activityGroupSegments = activityInfo?.activityGroupSegments ?? []; + const hasTools = Boolean(activityInfo?.hasTools); + const hasReasoning = Boolean(activityInfo?.hasReasoning); + const summaryBody = activityInfo?.summaryBody; + const diffStats = activityInfo?.diffStats; + + const firstAssistantId = turn.assistantMessages[0]?.info.id; + const isFirstAssistantInTurn = messageId === firstAssistantId; + const lastAssistantId = turn.assistantMessages[turn.assistantMessages.length - 1]?.info.id; + const isLastAssistantInTurn = messageId === lastAssistantId; + const headerMessageId = firstAssistantId; + + const uiState = uiStateData.turnUiStates.get(turn.turnId) ?? { isExpanded: staticData.defaultActivityExpanded }; + + const userTimeInfo = turn.userMessage.info.time as { created?: number } | undefined; + const userMessageCreatedAt = typeof userTimeInfo?.created === 'number' ? userTimeInfo.created : undefined; + + // For static context, isWorking is always false (turn is completed) + const context: TurnGroupingContextType = { + turnId: turn.turnId, + isFirstAssistantInTurn, + isLastAssistantInTurn, + summaryBody, + activityParts, + activityGroupSegments, + headerMessageId, + hasTools, + hasReasoning, + diffStats, + userMessageCreatedAt, + isWorking: false, + isGroupExpanded: uiState.isExpanded, + toggleGroup: () => uiStateData.toggleGroup(turn.turnId), + }; + + return context; + }, [staticData, uiStateData, messageId]); +}; + +interface TurnGroupingProviderProps { + messages: ChatMessageEntry[]; + children: React.ReactNode; +} + +const ACTIVITY_STANDALONE_TOOL_NAMES = new Set(['task']); + +const isActivityStandaloneTool = (toolName: unknown): boolean => { + return typeof toolName === 'string' && ACTIVITY_STANDALONE_TOOL_NAMES.has(toolName.toLowerCase()); +}; + +const ENABLE_TEXT_JUSTIFICATION_ACTIVITY = false; + +const extractFinalAssistantText = (turn: Turn): string | undefined => { + for (const assistantMsg of turn.assistantMessages) { + const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish; + if (infoFinish === 'stop') { + const textPart = assistantMsg.parts.find(p => p.type === 'text'); + if (textPart) { + const textContent = (textPart as { text?: string | null | undefined }).text ?? + (textPart as { content?: string | null | undefined }).content; + if (typeof textContent === 'string' && textContent.trim().length > 0) { + return textContent; + } + } + } + } + return undefined; +}; + +const getTurnActivityInfo = (turn: Turn): TurnActivityInfo => { + interface SummaryDiff { + additions?: number | null | undefined; + deletions?: number | null | undefined; + file?: string | null | undefined; + } + interface UserSummaryPayload { + body?: string | null | undefined; + diffs?: SummaryDiff[] | null | undefined; + } + + const summaryBody = extractFinalAssistantText(turn); + + let diffStats: TurnDiffStats | undefined; + + const summary = (turn.userMessage.info as { summary?: UserSummaryPayload | null | undefined }).summary; + const diffs = summary?.diffs; + if (Array.isArray(diffs) && diffs.length > 0) { + let additions = 0; + let deletions = 0; + let files = 0; + + diffs.forEach((diff) => { + if (!diff) return; + const diffAdditions = typeof diff.additions === 'number' ? diff.additions : 0; + const diffDeletions = typeof diff.deletions === 'number' ? diff.deletions : 0; + if (diffAdditions !== 0 || diffDeletions !== 0) { + files += 1; + } + additions += diffAdditions; + deletions += diffDeletions; + }); + + if (files > 0) { + diffStats = { additions, deletions, files }; + } + } + + let hasTools = false; + let hasReasoning = false; + + turn.assistantMessages.forEach((msg) => { + msg.parts.forEach((part) => { + if (part.type === 'tool') hasTools = true; + else if (part.type === 'reasoning') hasReasoning = true; + }); + }); + + const activityParts: TurnActivityPart[] = []; + let syntheticIdCounter = 0; + + turn.assistantMessages.forEach((msg) => { + const messageId = msg.info.id; + const infoFinish = (msg.info as { finish?: string | null | undefined }).finish; + const hasStopFinishInMessage = ENABLE_TEXT_JUSTIFICATION_ACTIVITY ? infoFinish === 'stop' : false; + + msg.parts.forEach((part) => { + const baseId = (typeof part.id === 'string' && part.id.trim().length > 0) + ? part.id + : `${messageId}-activity-${syntheticIdCounter++}`; + + if (part.type === 'tool') { + const state = (part as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state; + const time = state?.time; + const end = typeof time?.end === 'number' ? time.end : undefined; + + activityParts.push({ + id: baseId, + turnId: turn.turnId, + messageId, + kind: 'tool', + part, + endedAt: end, + }); + return; + } + + if (part.type === 'reasoning') { + const text = (part as { text?: string | null | undefined; content?: string | null | undefined }).text + ?? (part as { text?: string | null | undefined; content?: string | null | undefined }).content; + if (typeof text !== 'string' || text.trim().length === 0) return; + const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time; + const end = typeof time?.end === 'number' ? time.end : undefined; + + activityParts.push({ + id: baseId, + turnId: turn.turnId, + messageId, + kind: 'reasoning', + part, + endedAt: end, + }); + return; + } + + if ( + ENABLE_TEXT_JUSTIFICATION_ACTIVITY && + part.type === 'text' && + (hasTools || hasReasoning) && + !hasStopFinishInMessage + ) { + const text = (part as { text?: string | null | undefined; content?: string | null | undefined }).text ?? + (part as { text?: string | null | undefined; content?: string | null | undefined }).content; + if (typeof text !== 'string' || text.trim().length === 0) return; + const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time; + const end = typeof time?.end === 'number' ? time.end : undefined; + + activityParts.push({ + id: baseId, + turnId: turn.turnId, + messageId, + kind: 'justification', + part, + endedAt: end, + }); + } + }); + }); + + const activityGroupSegments: TurnActivityGroup[] = []; + const activityByPart = new WeakMap(); + activityParts.forEach((activity) => { + activityByPart.set(activity.part, activity); + }); + + const taskMessageById = new Map(); + const taskOrder: string[] = []; + const partsByAfterTool = new Map(); + let currentAfterToolPartId: string | null = null; + + turn.assistantMessages.forEach((msg) => { + const messageId = msg.info.id; + + msg.parts.forEach((part) => { + if (part.type === 'tool') { + const toolName = (part as { tool?: unknown }).tool; + if (isActivityStandaloneTool(toolName)) { + const toolPartId = typeof part.id === 'string' && part.id.trim().length > 0 + ? part.id + : `${messageId}-task-${taskOrder.length + 1}`; + + if (!taskMessageById.has(toolPartId)) { + taskMessageById.set(toolPartId, messageId); + taskOrder.push(toolPartId); + } + currentAfterToolPartId = toolPartId; + return; + } + } + + const activity = activityByPart.get(part); + if (!activity) return; + + if (activity.kind === 'tool') { + const toolName = (activity.part as { tool?: unknown }).tool; + if (isActivityStandaloneTool(toolName)) return; + } + + const list = partsByAfterTool.get(currentAfterToolPartId) ?? []; + list.push(activity); + partsByAfterTool.set(currentAfterToolPartId, list); + }); + }); + + const pickAnchorForStartSegment = (segmentParts: TurnActivityPart[]): string | undefined => { + if (segmentParts.length === 0) return undefined; + + const countByMessage = new Map(); + segmentParts.forEach((activity) => { + countByMessage.set(activity.messageId, (countByMessage.get(activity.messageId) ?? 0) + 1); + }); + + let firstWithAny: string | undefined; + let cumulative = 0; + for (const msg of turn.assistantMessages) { + const count = countByMessage.get(msg.info.id) ?? 0; + if (count > 0 && !firstWithAny) firstWithAny = msg.info.id; + cumulative += count; + if (cumulative >= 2) return msg.info.id; + } + return firstWithAny; + }; + + const orderedKeys: Array = [null, ...taskOrder]; + + orderedKeys.forEach((afterToolPartId) => { + const segmentParts = partsByAfterTool.get(afterToolPartId) ?? []; + if (segmentParts.length === 0) return; + + const anchorMessageId = afterToolPartId === null + ? pickAnchorForStartSegment(segmentParts) + : taskMessageById.get(afterToolPartId); + + if (!anchorMessageId) return; + + activityGroupSegments.push({ + id: `${turn.turnId}:${anchorMessageId}:${afterToolPartId ?? 'start'}`, + anchorMessageId, + afterToolPartId, + parts: segmentParts, + }); + }); + + return { + activityParts, + activityGroupSegments, + hasTools, + hasReasoning, + summaryBody, + diffStats, + }; +}; + +// Build neighbor lookup map from messages +const buildNeighborMap = (messages: ChatMessageEntry[]): Map => { + const map = new Map(); + messages.forEach((message, index) => { + map.set(message.info.id, { + previousMessage: index > 0 ? messages[index - 1] : undefined, + nextMessage: index < messages.length - 1 ? messages[index + 1] : undefined, + }); + }); + return map; +}; + +export const TurnGroupingProvider: React.FC = ({ messages, children }) => { + const { isWorking: sessionIsWorking } = useCurrentSessionActivity(); + const toolCallExpansion = useUIStore((state) => state.toolCallExpansion); + const defaultActivityExpanded = toolCallExpansion === 'activity' || toolCallExpansion === 'detailed'; + + // Static data - only changes when messages change + const staticValue = React.useMemo(() => { + const turns = detectTurns(messages); + const lastTurnId = turns.length > 0 ? turns[turns.length - 1]!.turnId : null; + + const messageToTurn = new Map(); + turns.forEach((turn) => { + messageToTurn.set(turn.userMessage.info.id, turn); + turn.assistantMessages.forEach((msg) => { + messageToTurn.set(msg.info.id, turn); + }); + }); + + const turnActivityInfo = new Map(); + turns.forEach((turn) => { + turnActivityInfo.set(turn.turnId, getTurnActivityInfo(turn)); + }); + + const messageNeighbors = buildNeighborMap(messages); + + // Build set of message IDs belonging to the last turn + const lastTurnMessageIds = new Set(); + if (turns.length > 0) { + const lastTurn = turns[turns.length - 1]!; + lastTurnMessageIds.add(lastTurn.userMessage.info.id); + lastTurn.assistantMessages.forEach((msg) => { + lastTurnMessageIds.add(msg.info.id); + }); + } + + return { + turns, + messageToTurn, + turnActivityInfo, + lastTurnId, + lastTurnMessageIds, + defaultActivityExpanded, + messageNeighbors, + }; + }, [messages, defaultActivityExpanded]); + + // UI state for expansion toggles + const [turnUiStates, setTurnUiStates] = React.useState>( + () => new Map() + ); + + // Reset turn UI states when expansion preference changes + React.useEffect(() => { + setTurnUiStates(new Map()); + }, [toolCallExpansion]); + + const toggleGroup = React.useCallback((turnId: string) => { + setTurnUiStates((prev) => { + const next = new Map(prev); + const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded }; + next.set(turnId, { isExpanded: !current.isExpanded }); + return next; + }); + }, [defaultActivityExpanded]); + + // UI state - changes on user interaction (expand/collapse) + const uiStateValue = React.useMemo(() => ({ + turnUiStates, + toggleGroup, + }), [turnUiStates, toggleGroup]); + + // Streaming state - changes frequently during assistant response + const streamingValue = React.useMemo(() => ({ + sessionIsWorking, + }), [sessionIsWorking]); + + return ( + + + + {children} + + + + ); +}; + +// Clear context cache on unmount or session change +export const clearTurnGroupingCache = (): void => { + contextCache.clear(); +}; diff --git a/packages/ui/src/components/chat/message/FadeInOnReveal.tsx b/packages/ui/src/components/chat/message/FadeInOnReveal.tsx index 928a27f8..f3c06e89 100644 --- a/packages/ui/src/components/chat/message/FadeInOnReveal.tsx +++ b/packages/ui/src/components/chat/message/FadeInOnReveal.tsx @@ -9,11 +9,23 @@ interface FadeInOnRevealProps { const FADE_ANIMATION_ENABLED = true; +// Context to allow parent components (like VirtualMessageList) to disable animations +// for items entering the viewport due to scrolling rather than new content +const FadeInDisabledContext = React.createContext(false); + +export const FadeInDisabledProvider: React.FC<{ disabled: boolean; children: React.ReactNode }> = ({ disabled, children }) => ( + + {children} + +); + export const FadeInOnReveal: React.FC = ({ children, className, skipAnimation }) => { - const [visible, setVisible] = React.useState(skipAnimation ?? false); + const contextDisabled = React.useContext(FadeInDisabledContext); + const shouldSkip = skipAnimation || contextDisabled; + const [visible, setVisible] = React.useState(shouldSkip); React.useEffect(() => { - if (!FADE_ANIMATION_ENABLED || skipAnimation) { + if (!FADE_ANIMATION_ENABLED || shouldSkip) { return; } @@ -36,9 +48,9 @@ export const FadeInOnReveal: React.FC = ({ children, classN window.cancelAnimationFrame(frame); } }; - }, [skipAnimation]); + }, [shouldSkip]); - if (!FADE_ANIMATION_ENABLED || skipAnimation) { + if (!FADE_ANIMATION_ENABLED || shouldSkip) { return <>{children}; } diff --git a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx index 9b7c0709..5111a490 100644 --- a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx +++ b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx @@ -80,6 +80,9 @@ const ProgressiveGroup: React.FC = ({ const [expansionKey, setExpansionKey] = React.useState(0); + // Track which parts have already been shown in collapsed view (for fade-in animation) + const shownInCollapsedRef = React.useRef>(new Set()); + React.useEffect(() => { if (previousExpandedRef.current === isExpanded) return; const wasCollapsed = previousExpandedRef.current === false; @@ -89,6 +92,8 @@ const ProgressiveGroup: React.FC = ({ if (isExpanded && wasCollapsed) { setExpansionKey((k) => k + 1); setJustExpandedFromCollapsed(true); + // Clear collapsed tracking when expanding (will restart when collapsed again) + shownInCollapsedRef.current.clear(); // Reset after a short delay (after animations would have started) const timer = setTimeout(() => setJustExpandedFromCollapsed(false), 50); return () => clearTimeout(timer); @@ -216,11 +221,24 @@ const ProgressiveGroup: React.FC = ({ const animationKey = `${partId}-exp${expansionKey}`; - // Skip animation if: - // - We just expanded from collapsed AND - // - This part was already visible in collapsed state + // Determine if animation should be skipped: + // 1. When expanding from collapsed: skip for items that were already visible + // 2. When collapsed: skip for items already shown before (track in ref) const wasVisibleInCollapsed = activity.part.id ? visibleInCollapsedIds.has(activity.part.id) : false; - const skipAnimation = justExpandedFromCollapsed && wasVisibleInCollapsed; + + let skipAnimation = false; + if (justExpandedFromCollapsed && wasVisibleInCollapsed) { + // Expanding: don't animate items that were already visible in collapsed state + skipAnimation = true; + } else if (!isExpanded && activity.part.id) { + // Collapsed: animate only items that haven't been shown yet + if (shownInCollapsedRef.current.has(activity.part.id)) { + skipAnimation = true; + } else { + // Mark as shown for future renders + shownInCollapsedRef.current.add(activity.part.id); + } + } switch (activity.kind) { case 'tool': diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index b6f988cc..8e62c2db 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -407,7 +407,7 @@ interface DiffPreviewProps { input?: ToolStateWithMetadata['input']; } -const DiffPreview: React.FC = ({ diff, syntaxTheme, input }) => ( +const DiffPreview: React.FC = React.memo(({ diff, syntaxTheme, input }) => (
{parseDiffToUnified(diff).map((hunk, hunkIdx) => (
@@ -468,7 +468,9 @@ const DiffPreview: React.FC = ({ diff, syntaxTheme, input }) =
))}
-); +)); + +DiffPreview.displayName = 'DiffPreview'; interface WriteInputPreviewProps { content: string; @@ -477,9 +479,12 @@ interface WriteInputPreviewProps { displayPath: string; } -const WriteInputPreview: React.FC = ({ content, syntaxTheme, filePath, displayPath }) => { - const lines = content.split('\n'); - const language = getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined); +const WriteInputPreview: React.FC = React.memo(({ content, syntaxTheme, filePath, displayPath }) => { + const lines = React.useMemo(() => content.split('\n'), [content]); + const language = React.useMemo( + () => getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined), + [content, filePath] + ); const lineCount = Math.max(lines.length, 1); const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`; @@ -526,7 +531,9 @@ const WriteInputPreview: React.FC = ({ content, syntaxTh
); -}; +}); + +WriteInputPreview.displayName = 'WriteInputPreview'; interface ImagePreviewProps { content: string; @@ -534,7 +541,7 @@ interface ImagePreviewProps { displayPath: string; } -const ImagePreview: React.FC = ({ content, filePath, displayPath }) => { +const ImagePreview: React.FC = React.memo(({ content, filePath, displayPath }) => { const mimeType = getImageMimeType(filePath); const isSvg = filePath.toLowerCase().endsWith('.svg'); @@ -566,7 +573,9 @@ const ImagePreview: React.FC = ({ content, filePath, displayP
); -}; +}); + +ImagePreview.displayName = 'ImagePreview'; interface ToolExpandedContentProps { part: ToolPartType; @@ -578,7 +587,7 @@ interface ToolExpandedContentProps { hasNextTool: boolean; } -const ToolExpandedContent: React.FC = ({ +const ToolExpandedContent: React.FC = React.memo(({ part, state, syntaxTheme, @@ -950,7 +959,9 @@ const ToolExpandedContent: React.FC = ({ )}
); -}; +}); + +ToolExpandedContent.displayName = 'ToolExpandedContent'; const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxTheme, isMobile, onContentChange, hasPrevTool = false, hasNextTool = false }) => { const state = part.state; diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index fdfe8678..efbf2a78 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -31,19 +31,6 @@ export const MainLayout: React.FC = () => { } = useUIStore(); const { isMobile } = useDeviceInfo(); - const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { - if (typeof window === 'undefined') { - return false; - } - return typeof window.opencodeDesktop !== 'undefined'; - }); - - React.useEffect(() => { - if (typeof window === 'undefined') { - return; - } - setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined'); - }, []); useEdgeSwipe({ enabled: true }); @@ -322,7 +309,7 @@ export const MainLayout: React.FC = () => { className={cn( 'main-content-safe-area h-[100dvh]', isMobile ? 'flex flex-col' : 'flex', - isDesktopRuntime ? 'bg-transparent' : 'bg-background' + 'bg-background' )} > @@ -405,7 +392,7 @@ export const MainLayout: React.FC = () => { {/* Multi-Run Launcher: replaces tabs content only */} {isMultiRunLauncherOpen && ( -
+
{ {/* Settings view: full screen overlay */} {isSettingsActive && ( -
+
setSettingsDialogOpen(false)} />
)} diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 3d841495..363b10a1 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -1531,7 +1531,7 @@ export const SessionSidebar: React.FC = ({
{!hideDirectoryControls && ( diff --git a/packages/ui/src/components/ui/ScrollShadow.tsx b/packages/ui/src/components/ui/ScrollShadow.tsx index 31d258b7..f1db4fd9 100644 --- a/packages/ui/src/components/ui/ScrollShadow.tsx +++ b/packages/ui/src/components/ui/ScrollShadow.tsx @@ -106,10 +106,20 @@ export const ScrollShadow = React.forwardRef( const el = internalRef.current; if (!el) return; - const handleScroll = () => checkOverflow(); - const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => checkOverflow()) : null; + // Throttle with RAF to avoid excessive calls during rapid DOM changes + let rafId: number | null = null; + const throttledCheck = () => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + checkOverflow(); + }); + }; + + const handleScroll = () => checkOverflow(); // Scroll should be immediate + const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(throttledCheck) : null; const mutationObserver = - typeof MutationObserver !== "undefined" ? new MutationObserver(() => checkOverflow()) : null; + typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null; checkOverflow(); @@ -118,6 +128,7 @@ export const ScrollShadow = React.forwardRef( mutationObserver?.observe(el, { childList: true, subtree: true, characterData: true }); return () => { + if (rafId !== null) cancelAnimationFrame(rafId); el.removeEventListener("scroll", handleScroll); resizeObserver?.disconnect(); mutationObserver?.disconnect(); diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 6f953a38..3686610f 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -346,7 +346,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile return ( -
+
{/* Header with tabs and close button */}
{ const task = (async (): Promise => { try { + // Try web server's tracked activity first - more reliable on visibility restore + // because it tracks activity even when UI is not listening to SSE. + // Only available in web runtime (desktop/vscode use native events instead). + if (isWebRuntime()) { + const webServerActivity = await opencodeClient.getWebServerSessionActivity(); + if (webServerActivity && Object.keys(webServerActivity).length > 0) { + applyStatusMap(webServerActivity); + return; + } + } + + // Fallback to OpenCode's global session status const globalStatusMap = await opencodeClient.getGlobalSessionStatus(); if (globalStatusMap && Object.keys(globalStatusMap).length > 0) { applyStatusMap(globalStatusMap); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 19c78e9d..c97fa48b 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -815,6 +815,38 @@ class OpencodeService { return this.getSessionStatusForDirectory(null); } + /** + * Get session activity from web server's in-memory tracking. + * This is more reliable than getGlobalSessionStatus on visibility restore + * because the web server tracks activity even when UI is not listening to SSE. + */ + async getWebServerSessionActivity(): Promise< + Record | null + > { + try { + // Web server endpoint - use relative path that works with both dev and prod + const response = await fetch('/api/session-activity', { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + return null; + } + + const data = await response.json().catch(() => null); + if (!data || typeof data !== 'object') { + return null; + } + + return data as Record; + } catch { + return null; + } + } + // Tools async listToolIds(options?: { directory?: string | null }): Promise { try { diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 972f976d..a45eee30 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1193,6 +1193,61 @@ const isAnyUiVisible = () => globalVisibilityState === true; const isUiVisible = (token) => uiVisibilityByToken.get(token)?.visible === true; +// Session activity tracking (mirrors desktop session_activity.rs) +const sessionActivityPhases = new Map(); // sessionId -> { phase: 'idle'|'busy'|'cooldown', updatedAt: number } +const sessionActivityCooldowns = new Map(); // sessionId -> timeoutId +const SESSION_COOLDOWN_DURATION_MS = 2000; + +const setSessionActivityPhase = (sessionId, phase) => { + if (!sessionId || typeof sessionId !== 'string') return; + + // Cancel existing cooldown timer + const existingTimer = sessionActivityCooldowns.get(sessionId); + if (existingTimer) { + clearTimeout(existingTimer); + sessionActivityCooldowns.delete(sessionId); + } + + const current = sessionActivityPhases.get(sessionId); + if (current?.phase === phase) return; // No change + + sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() }); + + // Schedule transition from cooldown to idle + if (phase === 'cooldown') { + const timer = setTimeout(() => { + const now = sessionActivityPhases.get(sessionId); + if (now?.phase === 'cooldown') { + sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: Date.now() }); + } + sessionActivityCooldowns.delete(sessionId); + }, SESSION_COOLDOWN_DURATION_MS); + sessionActivityCooldowns.set(sessionId, timer); + } +}; + +const getSessionActivitySnapshot = () => { + const result = {}; + for (const [sessionId, data] of sessionActivityPhases) { + result[sessionId] = { type: data.phase }; + } + return result; +}; + +const resetAllSessionActivityToIdle = () => { + // Cancel all cooldown timers + for (const timer of sessionActivityCooldowns.values()) { + clearTimeout(timer); + } + sessionActivityCooldowns.clear(); + + // Reset all phases to idle + const now = Date.now(); + for (const [sessionId] of sessionActivityPhases) { + sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: now }); + } +}; + const resolveVapidSubject = async () => { const configured = process.env.OPENCHAMBER_VAPID_SUBJECT; if (typeof configured === 'string' && configured.trim().length > 0) { @@ -2691,6 +2746,12 @@ async function main(options = {}) { }); }); + // Session activity status endpoint - returns tracked activity phases for all sessions + // Used by UI on visibility restore to get accurate status without waiting for SSE + app.get('/api/session-activity', (_req, res) => { + res.json(getSessionActivitySnapshot()); + }); + app.get('/api/openchamber/update-check', async (_req, res) => { try { const { checkForUpdates } = await import('./lib/package-manager.js'); @@ -2912,6 +2973,7 @@ async function main(options = {}) { void maybeSendPushForTrigger(payload); const activity = deriveSessionActivity(payload); if (activity) { + setSessionActivityPhase(activity.sessionId, activity.phase); writeSseEvent(res, { type: 'openchamber:session-activity', properties: { @@ -3032,6 +3094,7 @@ async function main(options = {}) { void maybeSendPushForTrigger(payload); const activity = deriveSessionActivity(payload); if (activity) { + setSessionActivityPhase(activity.sessionId, activity.phase); writeSseEvent(res, { type: 'openchamber:session-activity', properties: {