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: {