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
This commit is contained in:
Bohdan Triapitsyn
2026-01-25 19:16:26 +02:00
committed by GitHub
parent adbc5af95f
commit f34027df10
20 changed files with 1000 additions and 170 deletions
+21 -18
View File
@@ -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"
+2 -1
View File
@@ -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"] }
+39 -20
View File
@@ -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<R: tauri::Runtime>(
}
}
#[cfg(target_os = "macos")]
fn optimize_webview_layer<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) {
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::<AnyObject>()];
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 {
+1 -1
View File
@@ -14,7 +14,7 @@
{
"label": "main",
"title": "OpenChamber",
"transparent": true,
"transparent": false,
"width": 1280,
"height": 800,
"resizable": true,
+1 -1
View File
@@ -248,7 +248,7 @@ function App({ apis }: AppProps) {
<RuntimeAPIProvider apis={apis}>
<GitPollingProvider>
<FireworksProvider>
<div className={`h-full text-foreground ${isDesktopRuntime ? 'bg-transparent' : 'bg-background'}`}>
<div className="h-full text-foreground bg-background">
<MainLayout />
<Toaster />
<ConfigUpdateOverlay />
@@ -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"
+37 -34
View File
@@ -96,6 +96,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
currentSessionId: state.currentSessionId,
getAgentModelForSession: state.getAgentModelForSession,
getSessionModelSelection: state.getSessionModelSelection,
revertToMessage: state.revertToMessage,
forkFromMessage: state.forkFromMessage,
}))
);
@@ -105,11 +107,17 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
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<ChatMessageProps> = ({
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<ChatMessageProps> = ({
}, []);
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<ChatMessageProps> = ({
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);
+135 -50
View File
@@ -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<HTMLDivElement | null>;
}
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<MessageRowProps>(({
message,
onContentChange,
animationHandlers,
scrollToBottom,
}) => {
const { previousMessage, nextMessage } = useMessageNeighbors(message.info.id);
const turnGroupingContext = useTurnGroupingContextStatic(message.info.id);
return (
<ChatMessage
message={message}
previousMessage={previousMessage}
nextMessage={nextMessage}
onContentChange={onContentChange}
animationHandlers={animationHandlers}
scrollToBottom={scrollToBottom}
turnGroupingContext={turnGroupingContext}
/>
);
});
StaticMessageRow.displayName = 'StaticMessageRow';
// Dynamic MessageRow - subscribes to dynamic context for streaming state
// Used for messages in the LAST turn only
const DynamicMessageRow = React.memo<MessageRowProps>(({
message,
onContentChange,
animationHandlers,
scrollToBottom,
}) => {
const { previousMessage, nextMessage } = useMessageNeighbors(message.info.id);
const turnGroupingContext = useTurnGroupingContextForMessage(message.info.id);
return (
<ChatMessage
message={message}
previousMessage={previousMessage}
nextMessage={nextMessage}
onContentChange={onContentChange}
animationHandlers={animationHandlers}
scrollToBottom={scrollToBottom}
turnGroupingContext={turnGroupingContext}
/>
);
});
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 (
<RowComponent
key={message.info.id}
message={message}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
/>
);
})}
</>
);
};
const MessageList: React.FC<MessageListProps> = ({
messages,
permissions,
@@ -67,58 +161,49 @@ const MessageList: React.FC<MessageListProps> = ({
});
}, [messages]);
const { getContextForMessage } = useTurnGrouping(displayMessages);
return (
<div>
{hasMoreAbove && (
<div className="flex justify-center py-3">
{isLoadingOlder ? (
<span className="text-xs uppercase tracking-wide text-muted-foreground/80">
Loading
</span>
) : (
<button
type="button"
onClick={onLoadOlder}
className="text-xs uppercase tracking-wide text-muted-foreground/80 hover:text-foreground"
>
Load older messages
</button>
)}
</div>
)}
<TurnGroupingProvider messages={displayMessages}>
<div>
{hasMoreAbove && (
<div className="flex justify-center py-3">
{isLoadingOlder ? (
<span className="text-xs uppercase tracking-wide text-muted-foreground/80">
Loading
</span>
) : (
<button
type="button"
onClick={onLoadOlder}
className="text-xs uppercase tracking-wide text-muted-foreground/80 hover:text-foreground"
>
Load older messages
</button>
)}
</div>
)}
<div className="flex flex-col">
{displayMessages.map((message, index) => (
<ChatMessage
key={message.info.id}
message={message}
previousMessage={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)}
/>
))}
<MessageListContent
displayMessages={displayMessages}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
/>
{(questions.length > 0 || permissions.length > 0) && (
<div>
{questions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{permissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
)}
{/* Bottom spacer - always 10% of viewport height */}
<div className="flex-shrink-0" style={{ height: '10vh' }} aria-hidden="true" />
</div>
{(questions.length > 0 || permissions.length > 0) && (
<div>
{questions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{permissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
)}
{/* Bottom spacer - always 10% of viewport height */}
<div className="flex-shrink-0" style={{ height: '10vh' }} aria-hidden="true" />
</div>
</TurnGroupingProvider>
);
};
@@ -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<string, Turn>;
turnActivityInfo: Map<string, TurnActivityInfo>;
lastTurnId: string | null;
lastTurnMessageIds: Set<string>; // Messages belonging to the last turn
defaultActivityExpanded: boolean;
// Neighbor lookup - stable until messages change
messageNeighbors: Map<string, NeighborInfo>;
}
// UI state that changes on user interaction (expand/collapse)
interface TurnGroupingUiStateData {
turnUiStates: Map<string, { isExpanded: boolean }>;
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<TurnGroupingStaticData | null>(null);
const TurnGroupingUiStateContext = React.createContext<TurnGroupingUiStateData | null>(null);
const TurnGroupingStreamingContext = React.createContext<TurnGroupingStreamingData | null>(null);
// Track staticData reference to clear cache when it changes
let lastStaticDataRef: TurnGroupingStaticData | null = null;
const contextCache = new Map<string, TurnGroupingContextType>();
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<string> => {
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<string>(['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<Part, TurnActivityPart>();
activityParts.forEach((activity) => {
activityByPart.set(activity.part, activity);
});
const taskMessageById = new Map<string, string>();
const taskOrder: string[] = [];
const partsByAfterTool = new Map<string | null, TurnActivityPart[]>();
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<string, number>();
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<string | null> = [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<string, NeighborInfo> => {
const map = new Map<string, NeighborInfo>();
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<TurnGroupingProviderProps> = ({ 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<TurnGroupingStaticData>(() => {
const turns = detectTurns(messages);
const lastTurnId = turns.length > 0 ? turns[turns.length - 1]!.turnId : null;
const messageToTurn = new Map<string, Turn>();
turns.forEach((turn) => {
messageToTurn.set(turn.userMessage.info.id, turn);
turn.assistantMessages.forEach((msg) => {
messageToTurn.set(msg.info.id, turn);
});
});
const turnActivityInfo = new Map<string, TurnActivityInfo>();
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<string>();
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<Map<string, { isExpanded: boolean }>>(
() => 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<TurnGroupingUiStateData>(() => ({
turnUiStates,
toggleGroup,
}), [turnUiStates, toggleGroup]);
// Streaming state - changes frequently during assistant response
const streamingValue = React.useMemo<TurnGroupingStreamingData>(() => ({
sessionIsWorking,
}), [sessionIsWorking]);
return (
<TurnGroupingStaticContext.Provider value={staticValue}>
<TurnGroupingUiStateContext.Provider value={uiStateValue}>
<TurnGroupingStreamingContext.Provider value={streamingValue}>
{children}
</TurnGroupingStreamingContext.Provider>
</TurnGroupingUiStateContext.Provider>
</TurnGroupingStaticContext.Provider>
);
};
// Clear context cache on unmount or session change
export const clearTurnGroupingCache = (): void => {
contextCache.clear();
};
@@ -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 }) => (
<FadeInDisabledContext.Provider value={disabled}>
{children}
</FadeInDisabledContext.Provider>
);
export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ 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<FadeInOnRevealProps> = ({ children, classN
window.cancelAnimationFrame(frame);
}
};
}, [skipAnimation]);
}, [shouldSkip]);
if (!FADE_ANIMATION_ENABLED || skipAnimation) {
if (!FADE_ANIMATION_ENABLED || shouldSkip) {
return <>{children}</>;
}
@@ -80,6 +80,9 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
const [expansionKey, setExpansionKey] = React.useState(0);
// Track which parts have already been shown in collapsed view (for fade-in animation)
const shownInCollapsedRef = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
if (previousExpandedRef.current === isExpanded) return;
const wasCollapsed = previousExpandedRef.current === false;
@@ -89,6 +92,8 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
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<ProgressiveGroupProps> = ({
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':
@@ -407,7 +407,7 @@ interface DiffPreviewProps {
input?: ToolStateWithMetadata['input'];
}
const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, input }) => (
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, syntaxTheme, input }) => (
<div className="typography-code px-1 pb-1 pt-0 space-y-0">
{parseDiffToUnified(diff).map((hunk, hunkIdx) => (
<div key={hunkIdx} className="-mx-1 px-1 border-b border-border/20 last:border-b-0">
@@ -468,7 +468,9 @@ const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, input }) =
</div>
))}
</div>
);
));
DiffPreview.displayName = 'DiffPreview';
interface WriteInputPreviewProps {
content: string;
@@ -477,9 +479,12 @@ interface WriteInputPreviewProps {
displayPath: string;
}
const WriteInputPreview: React.FC<WriteInputPreviewProps> = ({ content, syntaxTheme, filePath, displayPath }) => {
const lines = content.split('\n');
const language = getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined);
const WriteInputPreview: React.FC<WriteInputPreviewProps> = 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<WriteInputPreviewProps> = ({ content, syntaxTh
</div>
</div>
);
};
});
WriteInputPreview.displayName = 'WriteInputPreview';
interface ImagePreviewProps {
content: string;
@@ -534,7 +541,7 @@ interface ImagePreviewProps {
displayPath: string;
}
const ImagePreview: React.FC<ImagePreviewProps> = ({ content, filePath, displayPath }) => {
const ImagePreview: React.FC<ImagePreviewProps> = React.memo(({ content, filePath, displayPath }) => {
const mimeType = getImageMimeType(filePath);
const isSvg = filePath.toLowerCase().endsWith('.svg');
@@ -566,7 +573,9 @@ const ImagePreview: React.FC<ImagePreviewProps> = ({ content, filePath, displayP
</div>
</div>
);
};
});
ImagePreview.displayName = 'ImagePreview';
interface ToolExpandedContentProps {
part: ToolPartType;
@@ -578,7 +587,7 @@ interface ToolExpandedContentProps {
hasNextTool: boolean;
}
const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
part,
state,
syntaxTheme,
@@ -950,7 +959,9 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
)}
</div>
);
};
});
ToolExpandedContent.displayName = 'ToolExpandedContent';
const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxTheme, isMobile, onContentChange, hasPrevTool = false, hasNextTool = false }) => {
const state = part.state;
@@ -31,19 +31,6 @@ export const MainLayout: React.FC = () => {
} = useUIStore();
const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
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'
)}
>
<CommandPalette />
@@ -405,7 +392,7 @@ export const MainLayout: React.FC = () => {
{/* Multi-Run Launcher: replaces tabs content only */}
{isMultiRunLauncherOpen && (
<div className={cn('absolute inset-0 z-10', isDesktopRuntime ? 'bg-transparent' : 'bg-background')}>
<div className={cn('absolute inset-0 z-10 bg-background')}>
<ErrorBoundary>
<MultiRunLauncher
initialPrompt={multiRunLauncherPrefillPrompt}
@@ -419,7 +406,7 @@ export const MainLayout: React.FC = () => {
{/* Settings view: full screen overlay */}
{isSettingsActive && (
<div className={cn('absolute inset-0 z-10', isDesktopRuntime ? 'bg-transparent' : 'bg-background')}>
<div className={cn('absolute inset-0 z-10 bg-background')}>
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
</div>
)}
@@ -1531,7 +1531,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<div
className={cn(
'flex h-full flex-col text-foreground overflow-x-hidden',
mobileVariant ? '' : isDesktopRuntime ? 'bg-transparent' : 'bg-sidebar',
mobileVariant ? '' : 'bg-sidebar',
)}
>
{!hideDirectoryControls && (
+14 -3
View File
@@ -106,10 +106,20 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
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<HTMLDivElement, ScrollShadowProps>(
mutationObserver?.observe(el, { childList: true, subtree: true, characterData: true });
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
el.removeEventListener("scroll", handleScroll);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
@@ -346,7 +346,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return (
<div ref={containerRef} className={cn('flex h-full flex-col overflow-hidden', isDesktopApp ? 'bg-transparent' : 'bg-background')}>
<div ref={containerRef} className={cn('flex h-full flex-col overflow-hidden bg-background')}>
{/* Header with tabs and close button */}
<div
onMouseDown={!isMobile ? handleDragStart : undefined}
@@ -6,7 +6,7 @@ import React, {
} from 'react';
import type { Theme, ThemeMode } from '@/types/theme';
import type { DesktopSettings } from '@/lib/desktop';
import { isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
import { isVSCodeRuntime } from '@/lib/desktop';
import { CSSVariableGenerator } from '@/lib/theme/cssGenerator';
import { updateDesktopSettings } from '@/lib/persistence';
import {
@@ -195,11 +195,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
}
const chromeColor = theme.colors.surface.background;
if (isDesktopRuntime()) {
document.body.style.backgroundColor = 'transparent';
} else {
document.body.style.backgroundColor = chromeColor;
}
document.body.style.backgroundColor = chromeColor;
let metaThemeColor = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement;
if (!metaThemeColor) {
+12
View File
@@ -536,6 +536,18 @@ export const useEventStream = () => {
const task = (async (): Promise<void> => {
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);
+32
View File
@@ -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<string, { type: string }> | 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<string, { type: string }>;
} catch {
return null;
}
}
// Tools
async listToolIds(options?: { directory?: string | null }): Promise<string[]> {
try {
+63
View File
@@ -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: {