perf: isolate chat streaming renders and reduce sidebar render cost (#1672)

Reworks the chat and session-sidebar render paths to cut render cascades, memory
  churn, and UI jank on large sessions and big session trees. Behavior is preserved;
  the changes are about *when* and *how much* the UI re-renders.

  ## Chat streaming
  - Freeze the streaming message's parts in the bulk turn projection during streaming,
    and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream
    no longer re-runs the whole-session projection or re-renders unrelated rows.
    session with referential reuse of unchanged turns.
  - Memoize message rows with field-aware comparators instead of reference equality.
  - Replace the manual child-session polling in the task tool with the live SSE
    stream + a one-shot load, removing a fetch/settle state machine.

  ## History loading & scroll
  - Load an initial page fast, then prepend one older page in the background so the
    scroll container has headroom and "load older on scroll-up" fires before the user
    hits the absolute top.
  - Compensate scroll synchronously (in a layout effect, before paint) for prepends —
    including background prepends that don't originate from a user scroll — so the
    viewport stays stable instead of judder-correcting on the next frame.

  ## Markdown rendering
  - Render markdown synchronously *styled* on first paint (paragraphs, lists, code
    cards, tables, inline code) instead of raw escaped text; the async pass then only
    upgrades syntax-highlight colors. Eliminates the flash of full-width raw text.
  - Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown
    chunk, avoiding a late stylesheet injection on first render.

  ## Sidebar
  - Hoist per-row recursive tree walks out of row comparators into per-group
    precomputed sets/keys; batch live-session lookups into a single map; add a
    group-level memo boundary.
  - Isolate rename drafts so per-keystroke typing doesn't repaint the row tree.

  ## Sync layer
  - Add a staleness guard so a slow message fetch can't repopulate a session the user
    navigated away from.
  - Throw on fetch failure for authoritative loaders so a transient blip can't read as
    an empty server response.

  ## Cleanup
  - Remove dead code (unused hooks, params, duplicated inline types) surfaced while
    reworking the above.

  ## Known issue
  - A rare, purely cosmetic first-paint width flash can still appear on large sessions;
    it has no behavioral or data impact and is tracked for a follow-up runtime trace.
This commit is contained in:
bashrusakh
2026-06-18 00:43:16 +03:00
committed by GitHub
parent 077a766f94
commit 59ecd86b4b
47 changed files with 3168 additions and 1829 deletions
@@ -1,5 +1,7 @@
import React from 'react';
import type { Message, Part, Session } from '@opencode-ai/sdk/v2';
import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
import { ChatInput } from './ChatInput';
import { DraftPresetChips } from './DraftPresetChips';
@@ -23,14 +25,8 @@ import { useDeviceInfo } from '@/lib/device';
import { Button } from '@/components/ui/button';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
import { Icon } from "@/components/icon/Icon";
import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
import { cn, formatDirectoryName } from '@/lib/utils';
import { useProjectsStore } from '@/stores/useProjectsStore';
import {
collectVisibleSessionIdsForBlockingRequests,
flattenBlockingRequests,
} from './lib/blockingRequests';
// New sync system imports
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -38,22 +34,21 @@ import { useStreamingStore } from '@/sync/streaming';
import {
useSessionMessageCount,
useSessionMessageRecords,
useSessions,
useDirectorySync,
useSyncDirectory,
useDirectorySync,
useSessionStatus,
useScopedBlockingPermissions,
useScopedBlockingQuestions,
useParentSession,
} from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache';
import { getSessionMaterializationStatus } from '@/sync/materialization';
import { usePlanDetection } from '@/hooks/usePlanDetection';
import { getAllSyncSessions } from '@/sync/sync-refs';
import { useI18n } from '@/lib/i18n';
import { isVSCodeRuntime } from '@/lib/desktop';
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
const EMPTY_QUESTIONS: QuestionRequest[] = [];
const IDLE_SESSION_STATUS = { type: 'idle' as const };
const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom';
const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
@@ -140,6 +135,7 @@ type ChatViewportProps = {
isDesktopExpandedInput: boolean;
isMobile: boolean;
stickyUserHeader: boolean;
directory?: string;
scrollRef: React.RefObject<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>;
pendingRevealWork: boolean;
@@ -168,6 +164,7 @@ const ChatViewport = React.memo(({
isDesktopExpandedInput,
isMobile,
stickyUserHeader,
directory,
scrollRef,
messageListRef,
pendingRevealWork,
@@ -235,6 +232,7 @@ const ChatViewport = React.memo(({
isLoadingOlder={isLoadingOlder}
scrollToBottom={scrollToBottom}
scrollRef={scrollRef}
directory={directory}
/>
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
<div>
@@ -263,6 +261,7 @@ const ChatViewport = React.memo(({
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput
&& prev.isMobile === next.isMobile
&& prev.stickyUserHeader === next.stickyUserHeader
&& prev.directory === next.directory
&& prev.scrollRef === next.scrollRef
&& prev.messageListRef === next.messageListRef
&& prev.pendingRevealWork === next.pendingRevealWork
@@ -407,7 +406,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
effectiveSessionDirectory,
);
// Messages from sync system
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory);
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, {
suspendPartUpdates: Boolean(streamingMessageId),
suspendPartUpdatesForMessageId: streamingMessageId,
});
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
const sessionPrefetchInfo = React.useSyncExternalStore(
React.useCallback(
@@ -423,55 +425,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
React.useCallback(() => undefined, []),
);
// Sessions from sync system
const sessions = useSessions(effectiveSessionDirectory);
// Plan detection - watches messages for plan creation and signals store
usePlanDetection(currentSessionId ?? '', sessionMessages);
// Session status from sync system
const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '', effectiveSessionDirectory) ?? IDLE_SESSION_STATUS;
// Permissions & questions from sync system
const allPermissions = useDirectorySync(
React.useCallback((s) => s.permission ?? {}, []),
effectiveSessionDirectory,
);
const allQuestions = useDirectorySync(
React.useCallback((s) => s.question ?? {}, []),
effectiveSessionDirectory,
);
// Scoped blocking requests — only subscribe to permissions/questions for
// the current session + descendant subagent sessions, not all sessions in
// the directory.
const sessionPermissions = useScopedBlockingPermissions(currentSessionId, effectiveSessionDirectory);
const sessionQuestions = useScopedBlockingQuestions(currentSessionId, effectiveSessionDirectory);
// Convert Record → Map for blockingRequests helpers
const permissionsMap = React.useMemo(() => {
const m = new Map<string, PermissionRequest[]>();
for (const [k, v] of Object.entries(allPermissions)) m.set(k, v as PermissionRequest[]);
return m;
}, [allPermissions]);
const questionsMap = React.useMemo(() => {
const m = new Map<string, QuestionRequest[]>();
for (const [k, v] of Object.entries(allQuestions)) m.set(k, v as QuestionRequest[]);
return m;
}, [allQuestions]);
const scopedSessionIds = React.useMemo(
() => collectVisibleSessionIdsForBlockingRequests(
sessions.map((session) => ({ id: session.id, parentID: session.parentID })),
currentSessionId,
),
[sessions, currentSessionId],
);
const sessionPermissions = React.useMemo(() => {
if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS;
return flattenBlockingRequests(permissionsMap, scopedSessionIds);
}, [permissionsMap, scopedSessionIds]);
const sessionQuestions = React.useMemo(() => {
if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS;
return flattenBlockingRequests(questionsMap, scopedSessionIds);
}, [questionsMap, scopedSessionIds]);
const sessionIsWorking = React.useMemo(() => {
if (!currentSessionId || sessionPermissions.length > 0 || sessionQuestions.length > 0) {
return false;
@@ -561,15 +526,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
return project ? getProjectDisplayLabel(project) : null;
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
const parentSession = React.useMemo(() => {
if (!currentSessionId) return null;
const current = sessions.find((session) => session.id === currentSessionId);
const parentID = current?.parentID;
if (!parentID) return null;
return sessions.find((session) => session.id === parentID)
?? getAllSyncSessions().find((session) => session.id === parentID)
?? null;
}, [currentSessionId, sessions]);
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
const handleReturnToParentSession = React.useCallback(() => {
if (!parentSession) return;
@@ -943,6 +900,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile}
stickyUserHeader={stickyUserHeader}
directory={effectiveSessionDirectory}
scrollRef={scrollRef}
messageListRef={messageListRef}
pendingRevealWork={timelineController.pendingRevealWork}
+15 -21
View File
@@ -74,6 +74,7 @@ import { sessionEvents } from '@/lib/sessionEvents';
import { fetchResponseStyleInstruction } from '@/lib/responseStyle';
import { wrapSystemReminder } from '@/lib/systemReminder';
import { getSyncMessages } from '@/sync/sync-refs';
import { EMPTY_REVERTED_MESSAGE_DOCK_STATE, buildRevertedMessageDockState, type RevertedMessageDockState } from './revertedMessageDockState';
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
import { isSyntheticPart } from '@/lib/messages/synthetic';
import {
@@ -89,11 +90,10 @@ import {
buildAttachmentCitationText,
findAttachmentCitationRanges,
} from './attachmentCitations';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { Part } from '@opencode-ai/sdk/v2/client';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = [];
const EMPTY_MESSAGES: Message[] = [];
const FILE_MENTION_TOKEN = /^@[^\s]+$/;
// Single-line URL pasted over a selection becomes a markdown link.
const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
@@ -363,34 +363,28 @@ const RevertedMessageDock: React.FC<RevertedMessageDockProps> = React.memo(({ se
const [restoringId, setRestoringId] = React.useState<string | null>(null);
const [forkingId, setForkingId] = React.useState<string | null>(null);
const [collapsed, setCollapsed] = React.useState(true);
const revertMessageID = useDirectorySync(
const revertedStateRef = React.useRef<RevertedMessageDockState>(EMPTY_REVERTED_MESSAGE_DOCK_STATE);
const revertedState = useDirectorySync(
React.useCallback((state) => {
if (!sessionId) return undefined;
const session = state.session.find((item) => item.id === sessionId);
return (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
const next = buildRevertedMessageDockState(state, sessionId, revertedStateRef.current);
revertedStateRef.current = next;
return next;
}, [sessionId]),
directory,
);
const sessionMessages = useDirectorySync(
React.useCallback((state) => (sessionId ? state.message[sessionId] ?? EMPTY_MESSAGES : EMPTY_MESSAGES), [sessionId]),
directory,
);
const partsByMessage = useDirectorySync(React.useCallback((state) => state.part, []), directory);
const revertMessageID = revertedState.revertMessageID;
const userMessages = React.useMemo(
() => sessionMessages.filter((message): message is Message & { role: 'user' } => message.role === 'user'),
[sessionMessages],
() => revertedState.records.map((record) => record.message),
[revertedState],
);
const noTextContent = t('chat.revertPopover.noTextContent');
const items = React.useMemo(() => {
if (!revertMessageID) return [];
return userMessages
.filter((message) => message.id >= revertMessageID)
.map((message) => ({
id: message.id,
text: getRevertedPreview(partsByMessage[message.id] ?? [], noTextContent),
}));
}, [noTextContent, partsByMessage, revertMessageID, userMessages]);
return revertedState.records.map((record) => ({
id: record.message.id,
text: getRevertedPreview(record.parts, noTextContent),
}));
}, [noTextContent, revertMessageID, revertedState]);
const firstRevertedMessageId = items[0]?.id;
React.useEffect(() => {
@@ -1,5 +1,4 @@
import React from 'react';
import 'katex/dist/katex.min.css';
import morphdom from 'morphdom';
import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
import type { Part } from '@opencode-ai/sdk/v2';
@@ -19,7 +18,7 @@ import type { EditorAPI } from '@/lib/api/types';
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
import { fallbackHtml, renderMarkdownBlocks } from './markdown/markdownCore';
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme';
import {
attachMarkdownInteractions,
@@ -1032,10 +1031,16 @@ const useMorphdomMarkdown = ({
// `display:contents` keeps margin-collapsing/spacing identical to a flat
// HTML body — the wrapper exists only for per-block reconciliation.
block.style.display = 'contents';
block.innerHTML = fallbackHtml(text);
block.innerHTML = renderMarkdownSync(text);
// Decorate synchronously too: wrap code blocks in their framed card,
// mark inline code, build table controls, etc. The async pass re-decorates
// its own DOM before morphing, so without this the first paint shows bare
// <pre>/tables that "snap" into their decorated form a tick later. Matching
// the structure here keeps the async morph to syntax colors only.
decorateMarkdown(block, ctx);
target.appendChild(block);
}
}, [containerRef, text]);
}, [containerRef, text, ctx]);
React.useEffect(() => {
const container = containerRef.current;
+25 -96
View File
@@ -6,19 +6,19 @@ import ChatMessage from './ChatMessage';
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
import TurnItem from './components/TurnItem';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
import { useTurnRecords } from './hooks/useTurnRecords';
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
import { buildLiveStreamingEntry } from './lib/turns/streamingTailEntry';
import { getNormalizedMessageForDisplay, hasCompactionPart } from './lib/messageDisplayNormalization';
import { useUIStore } from '@/stores/useUIStore';
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
import type { StreamPhase } from './message/types';
import { normalizeParts } from './message/partUtils';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
import { useSessionParts } from '@/sync/sync-context';
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
@@ -88,13 +88,6 @@ const resolveMessageRole = (message: ChatMessageEntry): string | null => {
?? null;
};
const hasCompactionPart = (message: ChatMessageEntry): boolean => {
return message.parts.some((part) => {
const type = (part as { type?: unknown } | null | undefined)?.type;
return type === 'compaction';
});
};
const getPartText = (part: Part): string => {
const text = (part as { text?: unknown }).text;
if (typeof text === 'string') {
@@ -107,40 +100,6 @@ const getPartText = (part: Part): string => {
return '';
};
const normalizeCompactionCommandMessage = (message: ChatMessageEntry): ChatMessageEntry => {
if (!hasCompactionPart(message)) {
return message;
}
let changedParts = false;
const nextParts = message.parts.map((part) => {
const type = (part as { type?: unknown } | null | undefined)?.type;
if (type !== 'compaction') {
return part;
}
changedParts = true;
return { type: 'text', text: '/compact' } as Part;
});
const info = message.info as unknown as { clientRole?: string | null | undefined };
const needsClientRole = info.clientRole !== 'user';
if (!changedParts && !needsClientRole) {
return message;
}
return {
...message,
info: needsClientRole
? ({
...(message.info as unknown as Record<string, unknown>),
clientRole: 'user',
} as unknown as typeof message.info)
: message.info,
parts: changedParts ? nextParts : message.parts,
};
};
const normalizeCompactionSummaryMessage = (
message: ChatMessageEntry,
compactionCommandIds: Set<string>,
@@ -391,39 +350,6 @@ const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeD
};
};
const normalizeMessageParts = (message: ChatMessageEntry): ChatMessageEntry => {
const parts = normalizeParts(message.parts);
if (parts.length === message.parts.length) {
return message;
}
return {
...message,
parts,
};
};
const normalizedMessageBySource = new WeakMap<ChatMessageEntry, ChatMessageEntry>();
const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => {
const cached = normalizedMessageBySource.get(message);
if (cached) {
return cached;
}
const normalizedPartMessage = normalizeMessageParts(message);
const normalizedCompactionMessage = normalizeCompactionCommandMessage(normalizedPartMessage);
const filteredParts = filterSyntheticParts(normalizedCompactionMessage.parts);
const normalized = filteredParts === normalizedCompactionMessage.parts
? normalizedCompactionMessage
: {
...normalizedCompactionMessage,
parts: filteredParts,
};
normalizedMessageBySource.set(message, normalized);
return normalized;
};
interface MessageListProps {
sessionKey: string;
disableStaging?: boolean;
@@ -442,6 +368,7 @@ interface MessageListProps {
isLoadingOlder: boolean;
scrollToBottom?: () => void;
scrollRef?: React.RefObject<HTMLDivElement | null>;
directory?: string;
}
export interface MessageListHandle {
@@ -1007,11 +934,10 @@ type StaticHistoryListProps = {
chatRenderMode: 'sorted' | 'live';
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingPhase?: StreamPhase | null;
reviewTransferDirection?: ReviewTransferDirection | null;
};
const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, scrollRef, virtualizerRef, virtualizerKey, virtualCache, shift, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingPhase, reviewTransferDirection }: StaticHistoryListProps) => {
const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, scrollRef, virtualizerRef, virtualizerKey, virtualCache, shift, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => {
const renderEntry = React.useCallback((entry: RenderEntry) => {
return (
<MessageListEntry
@@ -1029,11 +955,11 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, s
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={null}
activeStreamingPhase={activeStreamingPhase}
activeStreamingPhase={null}
reviewTransferDirection={reviewTransferDirection}
/>
);
}, [activeStreamingPhase, chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
}, [chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
if (!shouldVirtualize) {
return (
@@ -1074,6 +1000,7 @@ StaticHistoryList.displayName = 'StaticHistoryList';
const StreamingTailContent: React.FC<{
entry: RenderEntry;
directory?: string;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void;
@@ -1083,6 +1010,7 @@ const StreamingTailContent: React.FC<{
turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void;
chatRenderMode: 'sorted' | 'live';
showTurnChangedFiles: boolean;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
@@ -1090,6 +1018,7 @@ const StreamingTailContent: React.FC<{
reviewTransferDirection?: ReviewTransferDirection | null;
}> = ({
entry,
directory,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom,
@@ -1099,15 +1028,24 @@ const StreamingTailContent: React.FC<{
turnUiStates,
onToggleTurnGroup,
chatRenderMode,
showTurnChangedFiles,
shouldAnimateUserMessage,
onUserAnimationConsumed,
activeStreamingMessageId,
activeStreamingPhase,
reviewTransferDirection,
}) => {
const liveParts = useSessionParts(activeStreamingMessageId ?? '', directory);
const liveEntry = React.useMemo(() => buildLiveStreamingEntry(entry, {
activeStreamingMessageId,
liveParts,
showTextJustificationActivity: chatRenderMode === 'sorted',
showTurnChangedFiles,
}), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles]);
return (
<MessageListEntry
entry={entry}
entry={liveEntry}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
@@ -1141,6 +1079,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
isLoadingOlder,
scrollToBottom,
scrollRef,
directory,
}, ref) => {
streamPerfCount('ui.message_list.render');
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
@@ -1149,18 +1088,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
const showTurnChangedFiles = useUIStore((state) => state.showTurnChangedFiles);
const defaultActivityExpanded = activityRenderMode === 'summary';
const reviewTransferDirection = useGlobalSessionsStore((state) => {
const currentSession = state.activeSessions.find((session) => session.id === sessionKey);
const direction = getReviewTransferDirection(currentSession);
if (!currentSession || !direction) return null;
const targetSessionId = direction === 'review-to-original'
? getOriginalSessionID(currentSession)
: getReviewSessionID(currentSession);
if (!targetSessionId) return null;
return state.activeSessions.some((session) => session.id === targetSessionId)
? direction
: null;
return state.reviewTransferBySessionId.get(sessionKey) ?? null;
});
const [turnUiStates, setTurnUiStates] = React.useState<Map<string, TurnUiState>>(() => new Map());
const userAnimationRef = React.useRef<{
@@ -1665,12 +1593,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
chatRenderMode={chatRenderMode}
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingPhase={activeStreamingPhase}
reviewTransferDirection={reviewTransferDirection}
/>
{trailingStreamingEntry ? (
<StreamingTailContent
entry={trailingStreamingEntry}
directory={directory}
onMessageContentChange={stableTailContentChange}
getAnimationHandlers={stableGetAnimationHandlers}
scrollToBottom={stableScrollToBottom}
@@ -1680,6 +1608,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
turnUiStates={turnUiStates}
onToggleTurnGroup={toggleTurnGroup}
chatRenderMode={chatRenderMode}
showTurnChangedFiles={showTurnChangedFiles}
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
@@ -334,24 +334,67 @@ export const useChatTimelineController = ({
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
}, [messageListRef]);
// Tracks the timeline edges + height of the previous commit so a prepend
// that did NOT go through fetchOlderHistory (e.g. the background history
// prepend dispatched from useSync) can be compensated too. With
// overflow-anchor:none the browser leaves scrollTop unchanged when content
// is inserted above, so without this the viewport visibly jumps and
// auto-follow yanks it back on the next frame — a one-shot up/down judder.
const prependTrackingRef = React.useRef<{
oldestId: string | null;
newestId: string | null;
scrollHeight: number;
} | null>(null);
React.useLayoutEffect(() => {
const snap = prePrependScrollRef.current;
const container = scrollRef.current;
if (!snap || !container) return;
prePrependScrollRef.current = null;
if (!container) return;
// When a viewport anchor is available, delegate to MessageList
// restoreViewportAnchor which falls back to virtualizer-aware
// scrollHistoryIndexIntoView when the element is not in the DOM.
if (snap.anchor && restoreViewportAnchor(snap.anchor)) {
return;
const snap = prePrependScrollRef.current;
if (snap) {
prePrependScrollRef.current = null;
// When a viewport anchor is available, delegate to MessageList
// restoreViewportAnchor which falls back to virtualizer-aware
// scrollHistoryIndexIntoView when the element is not in the DOM.
if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) {
// Fallback: height-delta compensation
const delta = container.scrollHeight - snap.height;
if (delta > 0) {
container.scrollTop = snap.top + delta;
}
}
} else {
// Auto-detect a prepend: the oldest message changed while the newest
// stayed the same (distinguishes a real prepend from a session
// switch, a bottom append, or a streaming part growing). Compensate
// synchronously by the exact height delta — for a bottom-pinned
// viewport this keeps it pinned, for a released one it preserves the
// read position, with no intermediate frame for auto-follow to fight.
const prev = prependTrackingRef.current;
const currentOldestId = renderedMessages[0]?.info?.id ?? null;
const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null;
const isPrepend = Boolean(
prev
&& prev.oldestId
&& currentOldestId
&& currentOldestId !== prev.oldestId
&& prev.newestId
&& currentNewestId
&& currentNewestId === prev.newestId,
);
if (isPrepend && prev) {
const delta = container.scrollHeight - prev.scrollHeight;
if (delta > 0) {
container.scrollTop = container.scrollTop + delta;
}
}
}
// Fallback: height-delta compensation
const delta = container.scrollHeight - snap.height;
if (delta > 0) {
container.scrollTop = snap.top + delta;
}
prependTrackingRef.current = {
oldestId: renderedMessages[0]?.info?.id ?? null,
newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null,
scrollHeight: container.scrollHeight,
};
}, [renderedMessages, scrollRef, restoreViewportAnchor]);
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => false, []);
@@ -1,6 +1,7 @@
import React from 'react';
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types';
import { buildProjectionCacheKey, getCachedProjection, setCachedProjection } from '../lib/turns/turnProjectionCache';
import { streamPerfMeasure } from '@/stores/utils/streamDebug';
interface UseTurnRecordsOptions {
@@ -46,6 +47,18 @@ export const useTurnRecords = (
}, [options.sessionKey, options.showTextJustificationActivity, options.showTurnChangedFiles]);
const projection = React.useMemo(() => {
const sessionKey = options.sessionKey ?? '';
const cached = getCachedProjection(
sessionKey,
messages,
options.showTextJustificationActivity,
options.showTurnChangedFiles,
);
if (cached) {
previousProjectionRef.current = cached;
return cached;
}
return streamPerfMeasure('ui.turns.projection_ms', () => {
const nextProjection = projectTurnRecords(messages, {
previousProjection: previousProjectionRef.current,
@@ -53,9 +66,18 @@ export const useTurnRecords = (
showTurnChangedFiles: options.showTurnChangedFiles,
});
previousProjectionRef.current = nextProjection;
const cacheKey = buildProjectionCacheKey(
sessionKey,
messages,
options.showTextJustificationActivity,
options.showTurnChangedFiles,
);
setCachedProjection(cacheKey, nextProjection);
return nextProjection;
});
}, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles]);
}, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles, options.sessionKey]);
const staticTurns = React.useMemo(() => {
const nextStatic = projection.turns.length <= 1
@@ -0,0 +1,79 @@
import type { Part } from '@opencode-ai/sdk/v2';
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import { normalizeParts } from '../message/partUtils';
import type { ChatMessageEntry } from './turns/types';
export const hasCompactionPart = (message: ChatMessageEntry): boolean => {
return message.parts.some((part) => {
const type = (part as { type?: unknown } | null | undefined)?.type;
return type === 'compaction';
});
};
const normalizeCompactionCommandMessage = (message: ChatMessageEntry): ChatMessageEntry => {
if (!hasCompactionPart(message)) {
return message;
}
let changedParts = false;
const nextParts = message.parts.map((part) => {
const type = (part as { type?: unknown } | null | undefined)?.type;
if (type !== 'compaction') {
return part;
}
changedParts = true;
return { type: 'text', text: '/compact' } as Part;
});
const info = message.info as unknown as { clientRole?: string | null | undefined };
const needsClientRole = info.clientRole !== 'user';
if (!changedParts && !needsClientRole) {
return message;
}
return {
...message,
info: needsClientRole
? ({
...(message.info as unknown as Record<string, unknown>),
clientRole: 'user',
} as unknown as typeof message.info)
: message.info,
parts: changedParts ? nextParts : message.parts,
};
};
const normalizeMessageParts = (message: ChatMessageEntry): ChatMessageEntry => {
const parts = normalizeParts(message.parts);
if (parts.length === message.parts.length) {
return message;
}
return {
...message,
parts,
};
};
const normalizedMessageBySource = new WeakMap<ChatMessageEntry, ChatMessageEntry>();
export const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => {
const cached = normalizedMessageBySource.get(message);
if (cached) {
return cached;
}
const normalizedPartMessage = normalizeMessageParts(message);
const normalizedCompactionMessage = normalizeCompactionCommandMessage(normalizedPartMessage);
const filteredParts = filterSyntheticParts(normalizedCompactionMessage.parts);
const normalized = filteredParts === normalizedCompactionMessage.parts
? normalizedCompactionMessage
: {
...normalizedCompactionMessage,
parts: filteredParts,
};
normalizedMessageBySource.set(message, normalized);
return normalized;
};
@@ -106,6 +106,27 @@ describe('projectTurnRecords', () => {
expect(next.turns[1]).not.toBe(initial.turns[1]);
});
test('hydrates updated turns when a previous projection exists but no turn is reusable', () => {
const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
const initial = projectTurnRecords([user, assistant]);
const updatedAssistant = {
...assistant,
parts: [{ id: 'tool_1', type: 'tool', tool: 'bash', state: { status: 'completed' } } as Part],
};
const next = projectTurnRecords([user, updatedAssistant], {
previousProjection: initial,
});
expect(next.turns).toHaveLength(1);
expect(next.turns[0]).not.toBe(initial.turns[0]);
expect(next.turns[0]?.hasTools).toBe(true);
expect(next.turns[0]?.activityParts).toHaveLength(1);
expect(next.turns[0]?.stream.isStreaming).toBe(true);
expect(next.turns[0]?.stream.isRetrying).toBe(false);
});
test('reuses the whole turns array when every turn is unchanged', () => {
const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
@@ -115,12 +115,43 @@ const canReusePreviousTurn = (previous: TurnRecord, next: TurnRecord): boolean =
&& areSameMessageRefs(previous.assistantMessages, next.assistantMessages);
};
const stabilizeTurnRecords = (
const hydrateTurnRecord = (
turn: TurnRecord,
effectiveOptions: ProjectTurnRecordsOptions,
): TurnRecord => {
turn.summary = projectTurnSummary(turn.assistantMessages);
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
turn.diffStats = projectTurnDiffStats(turn.userMessage);
turn.changedFiles = effectiveOptions.showTurnChangedFiles
? projectTurnChangedFiles(turn.userMessage)
: undefined;
const activity = projectTurnActivity({
turnId: turn.turnId,
assistantMessages: turn.assistantMessages,
summarySourceMessageId: turn.summary.sourceMessageId,
summarySourcePartId: turn.summary.sourcePartId,
showTextJustificationActivity: effectiveOptions.showTextJustificationActivity,
});
turn.activityParts = activity.activityParts;
turn.activitySegments = activity.activitySegments;
turn.hasTools = activity.hasTools;
turn.hasReasoning = activity.hasReasoning;
turn.stream = buildTurnStreamState(turn.userMessage, turn.assistantMessages);
turn.startedAt = turn.stream.startedAt;
turn.completedAt = turn.stream.completedAt;
turn.durationMs = turn.stream.durationMs;
return turn;
};
const hydrateStableTurnRecords = (
turns: TurnRecord[],
previousProjection?: TurnProjectionResult | null,
effectiveOptions: ProjectTurnRecordsOptions,
): TurnRecord[] => {
const previousProjection = effectiveOptions.previousProjection;
if (!previousProjection || previousProjection.turns.length === 0 || turns.length === 0) {
return turns;
return turns.map((turn) => hydrateTurnRecord(turn, effectiveOptions));
}
let canReuseTurnArray = previousProjection.turns.length === turns.length;
@@ -137,14 +168,14 @@ const stabilizeTurnRecords = (
}
canReuseTurnArray = false;
return turn;
return hydrateTurnRecord(turn, effectiveOptions);
});
if (canReuseTurnArray && reusedAnyTurn) {
return previousProjection.turns;
}
return reusedAnyTurn ? nextTurns : turns;
return nextTurns;
};
export const projectTurnRecords = (
@@ -214,33 +245,7 @@ export const projectTurnRecords = (
groupedMessageIds.add(message.info.id);
});
turns.forEach((turn) => {
turn.summary = projectTurnSummary(turn.assistantMessages);
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
turn.diffStats = projectTurnDiffStats(turn.userMessage);
turn.changedFiles = effectiveOptions.showTurnChangedFiles
? projectTurnChangedFiles(turn.userMessage)
: undefined;
const activity = projectTurnActivity({
turnId: turn.turnId,
assistantMessages: turn.assistantMessages,
summarySourceMessageId: turn.summary.sourceMessageId,
summarySourcePartId: turn.summary.sourcePartId,
showTextJustificationActivity: effectiveOptions.showTextJustificationActivity,
});
turn.activityParts = activity.activityParts;
turn.activitySegments = activity.activitySegments;
turn.hasTools = activity.hasTools;
turn.hasReasoning = activity.hasReasoning;
turn.stream = buildTurnStreamState(turn.userMessage, turn.assistantMessages);
turn.startedAt = turn.stream.startedAt;
turn.completedAt = turn.stream.completedAt;
turn.durationMs = turn.stream.durationMs;
});
const stableTurns = stabilizeTurnRecords(turns, effectiveOptions.previousProjection);
const stableTurns = hydrateStableTurnRecords(turns, effectiveOptions);
const projection = projectTurnIndexes(stableTurns);
const ungroupedMessageIds = new Set<string>();
messages.forEach((message) => {
@@ -0,0 +1,134 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { buildLiveStreamingEntry, type StreamingTailEntry } from './streamingTailEntry';
import type { ChatMessageEntry, TurnRecord } from './types';
const message = (id: string, role: 'user' | 'assistant', parentID?: string, parts: Part[] = []): ChatMessageEntry => ({
info: {
id,
role,
sessionID: 'ses_1',
...(parentID ? { parentID } : {}),
time: { created: 1 },
} as Message,
parts,
});
const textPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
} as Part);
const syntheticTextPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
synthetic: true,
} as Part);
const reasoningPart = (id: string, text: string): Part => ({
id,
type: 'reasoning',
text,
} as Part);
const turnEntry = (assistant: ChatMessageEntry): StreamingTailEntry => {
const user = message('user_1', 'user');
return {
kind: 'turn',
key: 'turn:user_1',
isLastTurn: true,
turn: {
turnId: 'user_1',
userMessageId: 'user_1',
userMessage: user,
headerMessageId: assistant.info.id,
messages: [],
assistantMessageIds: [assistant.info.id],
assistantMessages: [assistant],
activityParts: [],
activitySegments: [],
summary: {},
hasTools: false,
hasReasoning: false,
stream: { isStreaming: true, isRetrying: false },
} satisfies TurnRecord,
};
};
describe('buildLiveStreamingEntry', () => {
test('returns the same entry when the active message is not in the tail', () => {
const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'old')]);
const entry = turnEntry(assistant);
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_other',
liveParts: [textPart('part_live', 'live')],
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next).toBe(entry);
});
test('rebuilds only the streaming turn with live parts', () => {
const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'hel')]);
const entry = turnEntry(assistant);
const liveParts = [reasoningPart('part_1_live', 'thinking')];
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts,
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next).not.toBe(entry);
expect(next.kind).toBe('turn');
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toBe(liveParts);
expect(next.turn.activityParts.length).toBeGreaterThan(0);
});
test('updates an ungrouped streaming message with live parts', () => {
const stale = message('assistant_1', 'assistant', undefined, [textPart('part_1', 'old')]);
const entry: StreamingTailEntry = {
kind: 'ungrouped',
key: 'msg:assistant_1',
message: stale,
};
const liveParts = [textPart('part_1_live', 'live')];
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts,
showTextJustificationActivity: false,
showTurnChangedFiles: false,
});
expect(next).not.toBe(entry);
expect(next.kind).toBe('ungrouped');
if (next.kind !== 'ungrouped') return;
expect(next.message.parts).toBe(liveParts);
});
test('normalizes live tail parts with the display filtering path', () => {
const stale = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'old')]);
const entry = turnEntry(stale);
const visible = textPart('part_visible', 'visible');
const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming');
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts: [synthetic, visible],
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next.kind).toBe('turn');
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]);
});
});
@@ -0,0 +1,86 @@
import type { Part } from '@opencode-ai/sdk/v2';
import { getNormalizedMessageForDisplay } from '../messageDisplayNormalization';
import { projectTurnRecords } from './projectTurnRecords';
import type { ChatMessageEntry, TurnRecord } from './types';
export type StreamingTailEntry =
| {
kind: 'ungrouped';
key: string;
message: ChatMessageEntry;
previousMessage?: ChatMessageEntry;
nextMessage?: ChatMessageEntry;
}
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
type BuildLiveStreamingEntryOptions = {
activeStreamingMessageId: string | null | undefined;
liveParts: Part[];
showTextJustificationActivity: boolean;
showTurnChangedFiles: boolean;
};
const withLiveParts = (
message: ChatMessageEntry,
activeStreamingMessageId: string,
liveParts: Part[],
): ChatMessageEntry => {
if (message.info.id !== activeStreamingMessageId || message.parts === liveParts) {
return message;
}
return getNormalizedMessageForDisplay({
...message,
parts: liveParts,
});
};
export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
entry: TEntry,
options: BuildLiveStreamingEntryOptions,
): TEntry => {
const activeStreamingMessageId = options.activeStreamingMessageId;
if (!activeStreamingMessageId) {
return entry;
}
if (entry.kind === 'ungrouped') {
const message = withLiveParts(entry.message, activeStreamingMessageId, options.liveParts);
if (message === entry.message) {
return entry;
}
return {
...entry,
message,
};
}
let changed = false;
const assistantMessages = entry.turn.assistantMessages.map((message) => {
const next = withLiveParts(message, activeStreamingMessageId, options.liveParts);
if (next !== message) {
changed = true;
}
return next;
});
if (!changed) {
return entry;
}
const projection = projectTurnRecords([entry.turn.userMessage, ...assistantMessages], {
showTextJustificationActivity: options.showTextJustificationActivity,
showTurnChangedFiles: options.showTurnChangedFiles,
});
const turn = projection.turns[0] ?? {
...entry.turn,
assistantMessages,
assistantMessageIds: assistantMessages.map((message) => message.info.id),
};
return {
...entry,
turn,
};
};
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { buildProjectionCacheKey } from './turnProjectionCache';
import type { ChatMessageEntry } from './types';
const createEntry = (text: string): ChatMessageEntry => ({
info: { id: 'msg_1', role: 'assistant' } as Message,
parts: [{ id: 'prt_1', type: 'text', text } as Part],
});
describe('turnProjectionCache', () => {
test('keeps the cache key stable for unchanged message and part references', () => {
const messages = [createEntry('hello')];
const first = buildProjectionCacheKey('session_1', messages, false, false);
const second = buildProjectionCacheKey('session_1', messages, false, false);
expect(second).toBe(first);
});
test('changes the cache key when streaming replaces a part with the same id and count', () => {
const before = [createEntry('hel')];
const after = [
{
info: before[0].info,
parts: [{ id: 'prt_1', type: 'text', text: 'hello' } as Part],
},
];
const beforeKey = buildProjectionCacheKey('session_1', before, false, false);
const afterKey = buildProjectionCacheKey('session_1', after, false, false);
expect(afterKey).not.toBe(beforeKey);
});
});
@@ -0,0 +1,86 @@
import type { ChatMessageEntry, TurnProjectionResult } from './types';
import { isVSCodeRuntime } from '@/lib/desktop';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
const TURN_PROJECTION_CACHE_MAX = 30;
const VSCODE_TURN_PROJECTION_CACHE_MAX = 4;
const MOBILE_TURN_PROJECTION_CACHE_MAX = 4;
const projectionCache = new Map<string, TurnProjectionResult>();
const objectVersionByRef = new WeakMap<object, number>();
let nextObjectVersion = 1;
const getProjectionCacheMax = () => {
if (isVSCodeRuntime()) return VSCODE_TURN_PROJECTION_CACHE_MAX;
if (isMobileSurfaceRuntime()) return MOBILE_TURN_PROJECTION_CACHE_MAX;
return TURN_PROJECTION_CACHE_MAX;
};
const getObjectVersion = (value: object): number => {
const cached = objectVersionByRef.get(value);
if (cached !== undefined) return cached;
const next = nextObjectVersion;
nextObjectVersion += 1;
objectVersionByRef.set(value, next);
return next;
};
const buildMessagesVersionSignature = (messages: ChatMessageEntry[]): string => {
return messages.map((message) => {
const infoVersion = getObjectVersion(message.info as object);
const partsVersion = getObjectVersion(message.parts);
const partVersions = message.parts.map((part) => getObjectVersion(part as object)).join(',');
return `${infoVersion}:${partsVersion}:${partVersions}`;
}).join(';');
};
export const buildProjectionCacheKey = (
sessionKey: string,
messages: ChatMessageEntry[],
showTextJustificationActivity: boolean,
showTurnChangedFiles: boolean,
): string => {
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined;
const lastMessageId = lastMessage?.info?.id ?? '';
const lastMessagePartCount = lastMessage?.parts?.length ?? 0;
return [
sessionKey,
messages.length,
lastMessageId,
lastMessagePartCount,
buildMessagesVersionSignature(messages),
showTextJustificationActivity ? '1' : '0',
showTurnChangedFiles ? '1' : '0',
].join('|');
};
export const getCachedProjection = (
sessionKey: string,
messages: ChatMessageEntry[],
showTextJustificationActivity: boolean,
showTurnChangedFiles: boolean,
): TurnProjectionResult | undefined => {
const key = buildProjectionCacheKey(sessionKey, messages, showTextJustificationActivity, showTurnChangedFiles);
const cached = projectionCache.get(key);
if (cached) {
// LRU re-order: move hit to the end (most recent) so it survives
// eviction longer than entries that haven't been read recently.
projectionCache.delete(key);
projectionCache.set(key, cached);
}
return cached;
};
export const setCachedProjection = (
key: string,
projection: TurnProjectionResult,
): void => {
projectionCache.delete(key);
const max = getProjectionCacheMax();
while (projectionCache.size >= max) {
const oldest = projectionCache.keys().next().value;
if (typeof oldest !== 'string') break;
projectionCache.delete(oldest);
}
projectionCache.set(key, projection);
};
@@ -88,7 +88,11 @@ const decorateCodeBlocks = (root: HTMLElement, labels: DecorateLabels): void =>
// Already wrapped (idempotent across morphdom passes).
if (parent.closest('[data-component="markdown-code"]')) continue;
const language = pre.getAttribute('data-md-lang') ?? 'text';
// `data-md-lang` is stamped by the async highlight pass; on the synchronous
// first paint it isn't set yet, so fall back to the `language-*` class marked
// emits — keeps the card header label stable instead of flashing 'text'.
const classLang = pre.querySelector('code')?.className.match(/language-([\w+#.-]+)/)?.[1];
const language = pre.getAttribute('data-md-lang') ?? classLang ?? 'text';
const wrapper = document.createElement('div');
wrapper.setAttribute('data-component', 'markdown-code');
@@ -306,16 +306,6 @@ const sanitize = (html: string): string => {
return DOMPurify.sanitize(html, SANITIZE_CONFIG) as unknown as string;
};
const escapeHtml = (text: string): string =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
export const fallbackHtml = (markdown: string): string =>
escapeHtml(markdown).replace(/\r\n?/g, '\n').replace(/\n/g, '<br>');
// ---------------------------------------------------------------------------
// Per-block HTML cache (LRU, mirrors OpenCode's checksum cache)
@@ -349,6 +339,22 @@ const parseBlock = async (block: MarkdownBlock): Promise<string> => {
return sanitize(highlighted);
};
/**
* Synchronous styled render for the first paint, before the async pipeline
* (Shiki-in-worker highlight) resolves. Produces the SAME structural HTML as
* `renderMarkdownBlocks` minus syntax coloring: paragraphs, lists, code blocks
* and bold all render at their final width, so the async pass only upgrades
* code-block colors no flash of full-width raw markdown source. `parser.parse`
* is synchronous (marked is not configured `async`), so this never blocks on a
* worker round-trip.
*/
export const renderMarkdownSync = (text: string): string => {
if (!text) return '';
const parsed = parser.parse(text) as string;
const withMath = renderMathExpressions(parsed);
return sanitize(withMath);
};
export type RenderedBlock = {
// Stable identity across renders for per-block DOM reconciliation. Encodes
// content + mode + highlight so any change forces that block (and only that
@@ -12,11 +12,7 @@ import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useDirectorySync, useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context';
import { getSyncChildStores } from '@/sync/sync-refs';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionActivity } from '@/hooks/useSessionActivity';
import { opencodeClient } from '@/lib/opencode/client';
import { isVSCodeRuntime } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Button } from '@/components/ui/button';
@@ -164,17 +160,6 @@ const normalizeToolName = (toolName: string | undefined | null): string => {
};
const MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
const TASK_TOOL_POLL_FAST_MS = 1200;
const TASK_TOOL_POLL_IDLE_MS = 3200;
const TASK_TOOL_POLL_HIDDEN_MS = 6000;
const TASK_TOOL_INITIAL_FETCH_LIMIT = 500;
const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160;
const TASK_TOOL_IDLE_FETCH_LIMIT = 80;
const VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT = 30;
const VSCODE_TASK_TOOL_ACTIVE_FETCH_LIMIT = 30;
const VSCODE_TASK_TOOL_IDLE_FETCH_LIMIT = 30;
const TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS = 3;
const TASK_TOOL_SETTLE_GRACE_MS = 2500;
const TASK_TOOL_FALLBACK_RETRY_MS = 3000;
const GIT_REFRESH_MUTATING_TOOLS = new Set([
'bash',
@@ -1025,41 +1010,6 @@ const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[])
return entries;
};
const buildTaskSessionMessagesSignature = (messages: SessionMessageWithParts[]): string => {
if (!Array.isArray(messages) || messages.length === 0) {
return '0';
}
const lastMessage = messages[messages.length - 1];
const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : '';
const lastMessageUpdated =
typeof lastMessage?.info?.time?.completed === 'number'
? lastMessage.info.time.completed
: typeof lastMessage?.info?.time?.created === 'number'
? lastMessage.info.time.created
: 0;
const lastParts = Array.isArray(lastMessage?.parts) ? lastMessage.parts : [];
const lastPart = lastParts[lastParts.length - 1] as Record<string, unknown> | undefined;
const tailType = typeof lastPart?.type === 'string' ? lastPart.type : '';
const tailId = typeof lastPart?.id === 'string' ? lastPart.id : '';
const tailTextLength = (() => {
const textCandidate = lastPart?.text;
if (typeof textCandidate === 'string') {
return textCandidate.length;
}
const stateCandidate = lastPart?.state;
if (stateCandidate && typeof stateCandidate === 'object') {
const stateStatus = (stateCandidate as Record<string, unknown>).status;
if (typeof stateStatus === 'string') {
return stateStatus.length;
}
}
return 0;
})();
return `${messages.length}:${lastMessageId}:${lastMessageUpdated}:${lastParts.length}:${tailType}:${tailId}:${tailTextLength}`;
};
const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
const title = entry.state?.title;
if (typeof title === 'string' && title.trim().length > 0) {
@@ -1117,6 +1067,144 @@ const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => {
return /^[A-Za-z0-9_-]+$/.test(baseName);
};
const getTaskSummaryEntryRenderSignature = (entry: TaskToolSummaryEntry): string => {
const toolName = normalizeToolName(entry.tool);
const status = entry.state?.status ?? '';
const label = getTaskSummaryLabel(entry);
return `${entry.id ?? ''}\u0001${toolName}\u0001${status}\u0001${label}`;
};
const areTaskSummaryEntriesRenderEqual = (
prevEntries: TaskToolSummaryEntry[],
nextEntries: TaskToolSummaryEntry[],
): boolean => {
if (prevEntries === nextEntries) return true;
if (prevEntries.length !== nextEntries.length) return false;
for (let index = 0; index < prevEntries.length; index += 1) {
if (getTaskSummaryEntryRenderSignature(prevEntries[index]) !== getTaskSummaryEntryRenderSignature(nextEntries[index])) {
return false;
}
}
return true;
};
const TaskSummaryEntryRow = React.memo(({
entry,
isMobile,
animateTailText,
showToolFileIcons,
}: {
entry: TaskToolSummaryEntry;
isMobile: boolean;
animateTailText: boolean;
showToolFileIcons: boolean;
}) => {
const normalizedToolName = normalizeToolName(entry.tool);
const toolName = normalizedToolName.length > 0 ? normalizedToolName : 'tool';
const label = getTaskSummaryLabel(entry);
const hasLabel = label.trim().length > 0;
const status = entry.state?.status;
const displayName = getToolMetadata(toolName).displayName;
return (
<ToolRevealOnMount animate={animateTailText} wipe>
<div className={cn('flex gap-2 min-w-0 w-full', isMobile ? 'items-start' : 'items-center')}>
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
<span
className="typography-meta text-foreground/80 flex-shrink-0"
style={{ color: 'var(--tools-title)' }}
title={displayName}
>
{displayName}
</span>
{hasLabel ? (
status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons)
) : (
status === 'error' ? (
<span className={cn(
'typography-meta flex-1 min-w-0 text-[var(--status-error)]',
isMobile ? 'whitespace-normal break-words' : 'truncate',
)}>
{label}
</span>
) : (
<Text
variant={animateTailText ? 'generate-effect' : 'static'}
className={cn(
'typography-meta flex-1 min-w-0 text-muted-foreground/70',
isMobile ? 'whitespace-normal break-words' : 'truncate',
)}
style={{ color: 'var(--tools-description)' }}
title={label}
>
{label}
</Text>
)
)
) : null}
</div>
</ToolRevealOnMount>
);
}, (prev, next) => {
return prev.isMobile === next.isMobile
&& prev.animateTailText === next.animateTailText
&& prev.showToolFileIcons === next.showToolFileIcons
&& getTaskSummaryEntryRenderSignature(prev.entry) === getTaskSummaryEntryRenderSignature(next.entry);
});
TaskSummaryEntryRow.displayName = 'TaskSummaryEntryRow';
const TaskSummaryEntriesList = React.memo(({
entries,
isExpanded,
isMobile,
animateTailText,
showToolFileIcons,
}: {
entries: TaskToolSummaryEntry[];
isExpanded: boolean;
isMobile: boolean;
animateTailText: boolean;
showToolFileIcons: boolean;
}) => {
const visibleEntries = isExpanded ? entries : entries.slice(-6);
const hiddenCount = Math.max(0, entries.length - visibleEntries.length);
const visibleStartIndex = entries.length - visibleEntries.length;
return (
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal>
<div className="w-full min-w-0 space-y-1">
{hiddenCount > 0 ? (
<div className="typography-micro text-muted-foreground/70">+{hiddenCount} more</div>
) : null}
{visibleEntries.map((entry, idx) => {
const absoluteIndex = isExpanded ? idx : visibleStartIndex + idx;
const rowKey = entry.id ?? `${getTaskSummaryEntryRenderSignature(entry)}:${absoluteIndex}`;
return (
<TaskSummaryEntryRow
key={rowKey}
entry={entry}
isMobile={isMobile}
animateTailText={animateTailText}
showToolFileIcons={showToolFileIcons}
/>
);
})}
</div>
</ToolScrollableSection>
);
}, (prev, next) => {
return prev.isExpanded === next.isExpanded
&& prev.isMobile === next.isMobile
&& prev.animateTailText === next.animateTailText
&& prev.showToolFileIcons === next.showToolFileIcons
&& areTaskSummaryEntriesRenderEqual(prev.entries, next.entries);
});
TaskSummaryEntriesList.displayName = 'TaskSummaryEntriesList';
const stripTaskMetadataFromOutput = (output: string): string => {
// Strip only a trailing <task_metadata>...</task_metadata> block.
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
@@ -1233,7 +1321,6 @@ const TaskToolSummary: React.FC<{
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
const runtime = React.useContext(RuntimeAPIContext);
const displayEntries = entries;
const trimmedOutput = typeof output === 'string'
? stripTaskMetadataFromOutput(output)
@@ -1262,7 +1349,7 @@ const TaskToolSummary: React.FC<{
? input.subagent_type
: 'subagent';
if (displayEntries.length === 0 && !hasOutput && !sessionId) {
if (entries.length === 0 && !hasOutput && !sessionId) {
return (
<div className="relative pr-2 pb-2 pt-2 space-y-2 pl-[1.4375rem]">
<div className="typography-meta text-muted-foreground/70">
@@ -1272,9 +1359,6 @@ const TaskToolSummary: React.FC<{
);
}
const visibleEntries = isExpanded ? displayEntries : displayEntries.slice(-6);
const hiddenCount = Math.max(0, displayEntries.length - visibleEntries.length);
return (
<div
className={cn(
@@ -1283,65 +1367,14 @@ const TaskToolSummary: React.FC<{
'before:top-[-0.25rem] before:bottom-0'
)}
>
{displayEntries.length > 0 ? (
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal>
<div className="w-full min-w-0 space-y-1">
{hiddenCount > 0 ? (
<div className="typography-micro text-muted-foreground/70">+{hiddenCount} more</div>
) : null}
{visibleEntries.map((entry, idx) => {
const normalizedToolName = normalizeToolName(entry.tool);
const toolName = normalizedToolName.length > 0 ? normalizedToolName : 'tool';
const label = getTaskSummaryLabel(entry);
const hasLabel = label.trim().length > 0;
const status = entry.state?.status;
const displayName = getToolMetadata(toolName).displayName;
return (
<ToolRevealOnMount key={entry.id ?? `${toolName}-${idx}`} animate={animateTailText} wipe>
<div className={cn("flex gap-2 min-w-0 w-full", isMobile ? 'items-start' : 'items-center')}>
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
<span
className="typography-meta text-foreground/80 flex-shrink-0"
style={{ color: 'var(--tools-title)' }}
title={displayName}
>
{displayName}
</span>
{hasLabel ? (
status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons)
) : (
status === 'error' ? (
<span className={cn(
'typography-meta flex-1 min-w-0 text-[var(--status-error)]',
isMobile ? 'whitespace-normal break-words' : 'truncate'
)}>
{label}
</span>
) : (
<Text
variant={animateTailText ? 'generate-effect' : 'static'}
className={cn(
'typography-meta flex-1 min-w-0 text-muted-foreground/70',
isMobile ? 'whitespace-normal break-words' : 'truncate'
)}
style={{ color: 'var(--tools-description)' }}
title={label}
>
{label}
</Text>
)
)
) : null}
</div>
</ToolRevealOnMount>
);
})}
</div>
</ToolScrollableSection>
{entries.length > 0 ? (
<TaskSummaryEntriesList
entries={entries}
isExpanded={isExpanded}
isMobile={isMobile}
animateTailText={animateTailText}
showToolFileIcons={showToolFileIcons}
/>
) : null}
{sessionId && (
@@ -1357,7 +1390,7 @@ const TaskToolSummary: React.FC<{
)}
{hasOutput ? (
<div className={cn('space-y-1', (displayEntries.length > 0 || sessionId) && 'pt-1')}
<div className={cn('space-y-1', (entries.length > 0 || sessionId) && 'pt-1')}
>
<button
type="button"
@@ -2147,12 +2180,11 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
isTaskTool,
parentSessionId: currentSessionId ?? undefined,
taskStartTime: taskSessionResolutionStart,
isTaskFinalized: isFinalized,
sessions: storeState.session,
sessionStatusMap: storeState.session_status,
hasRetried: taskFallbackRetried,
});
}, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, isFinalized, taskFallbackRetried]),
}, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, taskFallbackRetried]),
currentDirectory,
);
@@ -2172,49 +2204,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
return buildTaskSummaryEntriesFromSession(childSessionMessages);
}, [childSessionMessages, isTaskTool, taskSessionId]);
const childSessionHasInFlightTools = React.useMemo(() => {
if (!isTaskTool || !taskSessionId || !Array.isArray(childSessionMessages) || childSessionMessages.length === 0) {
return false;
}
for (const message of childSessionMessages) {
if (message?.info?.role !== 'assistant') {
continue;
}
const parts = Array.isArray(message.parts) ? message.parts : [];
for (const childPart of parts) {
if (childPart?.type !== 'tool') {
continue;
}
const childStatus =
typeof childPart === 'object' && childPart !== null && 'state' in childPart
? (childPart.state as { status?: string } | undefined)?.status
: undefined;
if (childStatus === 'running' || childStatus === 'pending' || childStatus === 'started') {
return true;
}
}
}
return false;
}, [childSessionMessages, isTaskTool, taskSessionId]);
const childSessionActivity = useSessionActivity(taskSessionId, currentDirectory);
const [taskChildSeenActive, setTaskChildSeenActive] = React.useState(false);
const [taskChildPollingStopped, setTaskChildPollingStopped] = React.useState(false);
const [taskPendingFinalFetch, setTaskPendingFinalFetch] = React.useState(false);
const taskPollNoChangeCountRef = React.useRef(0);
const taskPollLastSignatureRef = React.useRef<string>('');
const taskFinalFetchDoneRef = React.useRef(false);
React.useEffect(() => {
setTaskChildSeenActive(false);
setTaskChildPollingStopped(false);
setTaskPendingFinalFetch(false);
taskPollNoChangeCountRef.current = 0;
taskPollLastSignatureRef.current = '';
taskFinalFetchDoneRef.current = false;
setTaskFallbackRetried(false);
}, [taskSessionId]);
@@ -2251,148 +2241,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
taskSessionResolutionStart,
]);
React.useEffect(() => {
if (hasFinalMetadataTaskSummary || !isTaskTool || !taskSessionId) {
return;
}
const childSessionIsActive =
childSessionActivity.phase === 'busy'
|| childSessionActivity.phase === 'retry'
|| childSessionHasInFlightTools;
if (childSessionIsActive) {
if (!taskChildSeenActive) {
setTaskChildSeenActive(true);
}
if (taskChildPollingStopped) {
setTaskChildPollingStopped(false);
}
if (taskPendingFinalFetch) {
setTaskPendingFinalFetch(false);
}
return;
}
// Always stop polling if already done.
if (taskChildPollingStopped && taskFinalFetchDoneRef.current) {
return;
}
// Normal settle path: child went idle after we saw it active, and we have entries.
// Schedule a grace period before marking polling as stopped.
if (taskChildSeenActive && childSessionTaskSummaryEntries.length > 0 && !taskChildPollingStopped) {
if (typeof window === 'undefined') {
setTaskChildPollingStopped(true);
return;
}
const timer = window.setTimeout(() => {
setTaskChildPollingStopped(true);
}, TASK_TOOL_SETTLE_GRACE_MS);
return () => {
window.clearTimeout(timer);
};
}
// Final-fetch path: child went idle before parent saw it active, or we have no
// entries yet. First stop polling after the settle grace period. A separate
// effect performs the final fetch once polling has fully stopped, avoiding
// races with any in-flight polling response.
if (!taskChildPollingStopped && !taskFinalFetchDoneRef.current) {
if (typeof window === 'undefined') {
setTaskPendingFinalFetch(true);
setTaskChildPollingStopped(true);
return;
}
const timer = window.setTimeout(() => {
setTaskPendingFinalFetch(true);
setTaskChildPollingStopped(true);
}, TASK_TOOL_SETTLE_GRACE_MS);
return () => {
window.clearTimeout(timer);
};
}
}, [
childSessionActivity.phase,
childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length,
currentDirectory,
hasFinalMetadataTaskSummary,
activeLatched,
isFinalized,
isTaskTool,
taskPendingFinalFetch,
taskChildPollingStopped,
taskChildSeenActive,
taskSessionId,
]);
React.useEffect(() => {
if (hasFinalMetadataTaskSummary || !isTaskTool || !taskSessionId || !taskChildPollingStopped || !taskPendingFinalFetch || taskFinalFetchDoneRef.current) {
return;
}
let cancelled = false;
const capturedSessionId = taskSessionId;
const runFinalFetch = async () => {
try {
const scopedClient = opencodeClient.getScopedSdkClient(currentDirectory);
const response = await scopedClient.session.messages({
sessionID: capturedSessionId,
limit: isVSCodeRuntime() ? VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT : TASK_TOOL_INITIAL_FETCH_LIMIT,
});
if (cancelled) {
return;
}
const messages = response.data ?? [];
if (Array.isArray(messages) && messages.length > 0) {
const childStores = getSyncChildStores();
childStores.update(currentDirectory, (prev) => {
const records = messages as SessionMessageWithParts[];
const partPatch: Record<string, import('@opencode-ai/sdk/v2').Part[]> = { ...prev.part };
for (const rec of records) {
partPatch[rec.info.id] = rec.parts;
}
return {
message: { ...prev.message, [capturedSessionId]: records.map((r) => r.info) as import('@opencode-ai/sdk/v2').Message[] },
part: partPatch,
};
});
}
taskFinalFetchDoneRef.current = true;
setTaskPendingFinalFetch(false);
} catch {
if (cancelled) {
return;
}
setTaskPendingFinalFetch(false);
setTaskChildPollingStopped(false);
}
};
void runFinalFetch();
return () => {
cancelled = true;
};
}, [
currentDirectory,
hasFinalMetadataTaskSummary,
isTaskTool,
taskChildPollingStopped,
taskPendingFinalFetch,
taskSessionId,
]);
React.useEffect(() => {
if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') {
setLocalFinalizedAt(undefined);
@@ -2425,147 +2273,25 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}
return metadataTaskSummaryEntries;
}, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]);
const taskSummaryRenderSignature = React.useMemo(() => {
return taskSummaryEntries.map(getTaskSummaryEntryRenderSignature).join('\u0000');
}, [taskSummaryEntries]);
const lastTaskSummaryRenderSignatureRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!isTaskTool || !taskSessionId) {
return;
}
const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry';
if (hasFinalMetadataTaskSummary) {
return;
}
const shouldPoll =
!taskChildPollingStopped
&& (childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0);
const shouldFetchSnapshot = !taskPendingFinalFetch && (childSessionTaskSummaryEntries.length === 0 || shouldPoll);
if (!shouldFetchSnapshot) {
return;
}
let cancelled = false;
let pollTimer: number | undefined;
const isVisible = () => {
if (typeof document === 'undefined') {
return true;
}
return document.visibilityState === 'visible';
};
const resolveFetchLimit = (isInitialFetch: boolean) => {
if (isVSCodeRuntime()) {
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
return VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT;
}
if (isActive || childSessionHasInFlightTools || childSessionActive) {
return VSCODE_TASK_TOOL_ACTIVE_FETCH_LIMIT;
}
return VSCODE_TASK_TOOL_IDLE_FETCH_LIMIT;
}
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
return TASK_TOOL_INITIAL_FETCH_LIMIT;
}
if (isActive || childSessionHasInFlightTools || childSessionActive) {
return TASK_TOOL_ACTIVE_FETCH_LIMIT;
}
return TASK_TOOL_IDLE_FETCH_LIMIT;
};
const resolvePollDelay = () => {
if (!isVisible()) {
return TASK_TOOL_POLL_HIDDEN_MS;
}
if (taskPollNoChangeCountRef.current >= TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS) {
return TASK_TOOL_POLL_IDLE_MS;
}
return TASK_TOOL_POLL_FAST_MS;
};
const scheduleNextPoll = () => {
if (!shouldPoll || typeof window === 'undefined' || cancelled) {
return;
}
pollTimer = window.setTimeout(() => {
pollTimer = undefined;
void fetchSessionMessages(false);
}, resolvePollDelay());
};
const fetchSessionMessages = async (isInitialFetch: boolean) => {
try {
const scopedClient = opencodeClient.getScopedSdkClient(currentDirectory);
const response = await scopedClient.session.messages({
sessionID: taskSessionId,
limit: resolveFetchLimit(isInitialFetch),
});
const messages = response.data ?? [];
if (cancelled || !Array.isArray(messages) || messages.length === 0) {
return;
}
const nextSignature = buildTaskSessionMessagesSignature(messages as SessionMessageWithParts[]);
if (nextSignature === taskPollLastSignatureRef.current) {
taskPollNoChangeCountRef.current += 1;
return;
}
taskPollLastSignatureRef.current = nextSignature;
taskPollNoChangeCountRef.current = 0;
// Inject fetched subagent messages into sync child store
const childStores = getSyncChildStores();
childStores.update(currentDirectory, (prev) => {
const records = messages as SessionMessageWithParts[];
const partPatch: Record<string, import('@opencode-ai/sdk/v2').Part[]> = { ...prev.part };
for (const rec of records) {
partPatch[rec.info.id] = rec.parts;
}
return {
message: { ...prev.message, [taskSessionId]: records.map((r) => r.info) as import('@opencode-ai/sdk/v2').Message[] },
part: partPatch,
};
});
} catch {
// Ignore transient subagent fetch errors.
} finally {
scheduleNextPoll();
}
};
void fetchSessionMessages(true);
return () => {
cancelled = true;
if (typeof pollTimer === 'number') {
window.clearTimeout(pollTimer);
}
};
}, [
childSessionActivity.phase,
childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length,
currentDirectory,
hasFinalMetadataTaskSummary,
isActive,
isTaskTool,
taskPendingFinalFetch,
taskChildPollingStopped,
taskSessionId,
]);
const taskSummaryLenRef = React.useRef<number>(taskSummaryEntries.length);
React.useEffect(() => {
if (!isTaskTool) {
lastTaskSummaryRenderSignatureRef.current = null;
return;
}
if (taskSummaryLenRef.current === taskSummaryEntries.length) {
const previous = lastTaskSummaryRenderSignatureRef.current;
lastTaskSummaryRenderSignatureRef.current = taskSummaryRenderSignature;
if (previous === null || previous === taskSummaryRenderSignature || taskSummaryEntries.length === 0) {
return;
}
taskSummaryLenRef.current = taskSummaryEntries.length;
onContentChange?.('structural');
}, [isTaskTool, onContentChange, taskSummaryEntries.length]);
onContentChangeRef.current?.('structural');
}, [isTaskTool, taskSummaryEntries.length, taskSummaryRenderSignature]);
const diffStats = React.useMemo(() => {
return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')
@@ -31,13 +31,16 @@ describe('resolveFallbackTaskSessionId', () => {
expect(result).toBeUndefined();
});
it('returns undefined when task is finalized', () => {
it('returns undefined when multiple idle candidates are ambiguous', () => {
const result = resolveFallbackTaskSessionId({
isTaskTool: true,
parentSessionId,
taskStartTime,
sessions: [],
isTaskFinalized: true,
sessions: [
makeSession({ id: 'child-a', parentID: parentSessionId, time: { created: taskStartTime + 100 } }),
makeSession({ id: 'child-b', parentID: parentSessionId, time: { created: taskStartTime + 200 } }),
],
sessionStatusMap: {},
});
expect(result).toBeUndefined();
});
@@ -29,8 +29,6 @@ export interface ResolveFallbackParams {
parentSessionId: string | undefined;
/** When the task tool started (ms timestamp) */
taskStartTime: number | undefined;
/** True when the task tool is finalized (completed/error/etc.) */
isTaskFinalized?: boolean;
/** Sessions from the directory store */
sessions: Session[];
/** Session status map from the sync store */
@@ -45,8 +43,8 @@ export interface ResolveFallbackParams {
*
* Returns `undefined` when:
* - Not a task tool
* - Task is finalized
* - Parent session is unknown
* - Task start time is unknown
* - No unambiguous match found
*/
export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): string | undefined {
@@ -54,7 +52,6 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
isTaskTool,
parentSessionId,
taskStartTime,
isTaskFinalized = false,
sessions,
sessionStatusMap,
hasRetried = false,
@@ -64,6 +61,10 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
return undefined;
}
if (typeof taskStartTime !== 'number') {
return undefined;
}
// Filter candidate sessions: parentID matches the current session.
let candidates = sessions.filter((session) => {
if (!session?.id || session.parentID !== parentSessionId) {
@@ -72,19 +73,14 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
return true;
});
// When the task is still running, apply no time window — late-appearing
// child sessions should still match. Once finalized, restrict to sessions
// created within a generous window around the task start to avoid binding
// to stale siblings. If taskStartTime is unavailable (cross-OpenCode
// sessions), skip the time filter entirely.
if (typeof taskStartTime === 'number' && isTaskFinalized) {
const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS;
const latestAllowed = taskStartTime + windowMs;
candidates = candidates.filter((session) => {
const created = session.time?.created;
return typeof created === 'number' && created >= taskStartTime - 2_000 && created <= latestAllowed;
});
}
// Apply the time window even while running. Without it, a newly rendered task
// can briefly bind to the previous child session before its own child exists.
const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS;
const latestAllowed = taskStartTime + windowMs;
candidates = candidates.filter((session) => {
const created = session.time?.created;
return typeof created === 'number' && created >= taskStartTime && created <= latestAllowed;
});
if (candidates.length === 0) {
return undefined;
@@ -105,18 +101,6 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
return liveCandidates[0].id;
}
// All idle: pick the most recently created child session.
// This handles the common case where a delegation completed and the
// user is viewing the task tool result inline.
if (liveCandidates.length === 0 && candidates.length > 1) {
const sorted = [...candidates].sort((a, b) => {
const aCreated = typeof a.time?.created === 'number' ? a.time.created : 0;
const bCreated = typeof b.time?.created === 'number' ? b.time.created : 0;
return bCreated - aCreated;
});
return sorted[0].id;
}
// Ambiguous — do not guess
return undefined;
}
@@ -0,0 +1,89 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from '@/sync/types';
import { EMPTY_REVERTED_MESSAGE_DOCK_STATE, buildRevertedMessageDockState } from './revertedMessageDockState';
const message = (id: string, role: 'user' | 'assistant'): Message => ({
id,
role,
sessionID: 'ses_1',
time: { created: 1 },
} as Message);
const textPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
} as Part);
const state = (partial: Partial<State>): Pick<State, 'session' | 'message' | 'part'> => ({
session: [],
message: {},
part: {},
...partial,
});
describe('buildRevertedMessageDockState', () => {
test('returns a shared empty state when the session is not reverted', () => {
const first = buildRevertedMessageDockState(state({}), 'ses_1');
const second = buildRevertedMessageDockState(
state({ part: { assistant_1: [textPart('part_1', 'streaming')] } }),
'ses_1',
first,
);
expect(first).toBe(EMPTY_REVERTED_MESSAGE_DOCK_STATE);
expect(second).toBe(EMPTY_REVERTED_MESSAGE_DOCK_STATE);
});
test('reuses the previous state when unrelated assistant parts change', () => {
const user = message('user_1', 'user');
const userParts = [textPart('part_user', 'hello')];
const first = buildRevertedMessageDockState(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_1' } } as State['session'][number]],
message: { ses_1: [user, message('assistant_1', 'assistant')] },
part: { user_1: userParts, assistant_1: [textPart('part_a', 'a')] },
}),
'ses_1',
);
const second = buildRevertedMessageDockState(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_1' } } as State['session'][number]],
message: { ses_1: [user, message('assistant_1', 'assistant')] },
part: { user_1: userParts, assistant_1: [textPart('part_a2', 'updated')] },
}),
'ses_1',
first,
);
expect(second).toBe(first);
});
test('updates when a reverted user message part changes', () => {
const user = message('user_1', 'user');
const first = buildRevertedMessageDockState(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_1' } } as State['session'][number]],
message: { ses_1: [user] },
part: { user_1: [textPart('part_user', 'hello')] },
}),
'ses_1',
);
const second = buildRevertedMessageDockState(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_1' } } as State['session'][number]],
message: { ses_1: [user] },
part: { user_1: [textPart('part_user_updated', 'updated')] },
}),
'ses_1',
first,
);
expect(second).not.toBe(first);
expect(second.records).toHaveLength(1);
});
});
@@ -0,0 +1,73 @@
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from '@/sync/types';
export type RevertedMessageRecord = {
message: Message & { role: 'user' };
parts: Part[];
};
export type RevertedMessageDockState = {
revertMessageID?: string;
records: RevertedMessageRecord[];
};
const EMPTY_PARTS: Part[] = [];
const EMPTY_REVERTED_RECORDS: RevertedMessageRecord[] = [];
export const EMPTY_REVERTED_MESSAGE_DOCK_STATE: RevertedMessageDockState = {
revertMessageID: undefined,
records: EMPTY_REVERTED_RECORDS,
};
const isUserMessage = (message: Message): message is Message & { role: 'user' } => {
return message.role === 'user';
};
const areRecordsEqual = (left: RevertedMessageRecord[], right: RevertedMessageRecord[]): boolean => {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index]?.message !== right[index]?.message || left[index]?.parts !== right[index]?.parts) {
return false;
}
}
return true;
};
export const buildRevertedMessageDockState = (
state: Pick<State, 'session' | 'message' | 'part'>,
sessionId: string | null,
previous: RevertedMessageDockState = EMPTY_REVERTED_MESSAGE_DOCK_STATE,
): RevertedMessageDockState => {
if (!sessionId) {
return EMPTY_REVERTED_MESSAGE_DOCK_STATE;
}
const session = state.session.find((item) => item.id === sessionId);
const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
if (!revertMessageID) {
return EMPTY_REVERTED_MESSAGE_DOCK_STATE;
}
const messages = state.message[sessionId] ?? [];
const records: RevertedMessageRecord[] = [];
for (const message of messages) {
if (!isUserMessage(message) || message.id < revertMessageID) {
continue;
}
records.push({
message,
parts: state.part[message.id] ?? EMPTY_PARTS,
});
}
const next = records.length === 0 ? EMPTY_REVERTED_RECORDS : records;
if (previous.revertMessageID === revertMessageID && areRecordsEqual(previous.records, next)) {
return previous;
}
return {
revertMessageID,
records: next,
};
};
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon";
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
interface SessionFolderItemProps<TSessionNode> {
folder: SessionFolder;
@@ -19,7 +20,17 @@ interface SessionFolderItemProps<TSessionNode> {
groupDir?: string | null,
projectId?: string | null,
archivedBucket?: boolean,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeChildRenderExtras,
) => React.ReactNode;
/**
* Returns the precomputed per-row render extras for a given node. The
* group precomputes subtree-contains lookups once, then resolves a
* per-node structure key here so SessionNodeItem's React.memo comparator
* can answer with a single string compare instead of a recursive walk.
*/
getRenderExtras?: (node: TSessionNode) => SessionNodeRenderExtras<TSessionNode> | undefined;
groupDirectory?: string | null;
projectId?: string | null;
mobileVariant?: boolean;
@@ -54,6 +65,7 @@ const SessionFolderItemBase = <TSessionNode,>({
onRename,
onDelete,
renderSessionNode,
getRenderExtras,
groupDirectory,
projectId,
mobileVariant = false,
@@ -320,7 +332,7 @@ const SessionFolderItemBase = <TSessionNode,>({
{/* Then sessions */}
{sessions.length > 0 ? (
sessions.map((node) =>
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket),
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket, undefined, 'project', getRenderExtras?.(node)),
)
) : !subFolderItems ? (
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
@@ -41,9 +41,11 @@ import { SidebarActivitySections } from './sidebar/SidebarActivitySections';
import { SidebarFooter } from './sidebar/SidebarFooter';
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
import { SessionNodeItem } from './sidebar/SessionNodeItem';
import type { SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useShallow } from 'zustand/react/shallow';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import { checkIsGitRepository } from '@/lib/gitApi';
import type { WorktreeMetadata } from '@/types/worktree';
import type { SortableDragHandleProps } from './sidebar/sortableItems';
import {
@@ -55,7 +57,7 @@ import {
type DeleteSessionConfirmState,
} from './sidebar/ConfirmDialogs';
import { BulkActionBar } from './sidebar/BulkActionBar';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import { useSidebarBulkActions } from './sidebar/hooks/useSidebarBulkActions';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { type SessionGroup, type SessionNode } from './sidebar/types';
import {
@@ -153,6 +155,14 @@ const isKnownActiveSessionDirectory = (
const SIDEBAR_PR_NO_PR_RETRY_MS = 5 * 60_000;
const EMPTY_SUBTREE_SET: Set<string> = new Set();
const useStableRenderCallback = <Args extends unknown[], Return>(handler: (...args: Args) => Return): ((...args: Args) => Return) => {
const handlerRef = React.useRef(handler);
handlerRef.current = handler;
return React.useCallback((...args: Args) => handlerRef.current(...args), []);
};
interface SessionSidebarProps {
mobileVariant?: boolean;
onSessionSelected?: (sessionId: string) => void;
@@ -383,6 +393,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
syncSessionsSnapshotRef.current = liveSessions;
}, [syncSessionStructureSignature, liveSessions]);
// Batched live-session index. Building this here turns the per-row
// `useSession(session.id)` reads in SessionNodeItem (each of which
// iterates all child-stores via `findLiveSession`) into a single
// Map lookup. With M visible rows, that changes an O(M × child-stores)
// work to O(child-stores) once per Sidebar render.
const liveSessionById = React.useMemo(
() => new Map(liveSessions.map((session) => [session.id, session] as const)),
[liveSessions],
);
const projectWorktreeDiscoveryKey = React.useMemo(
() => projects
.map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`)
@@ -399,6 +419,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
}, []);
// Tracks the last project list we already kicked off discovery for.
// A re-mount with the same project set shouldn't fan out another
// burst of `checkIsGitRepository` / `listProjectWorktrees` calls.
const discoveredProjectsRef = React.useRef<string>('');
React.useEffect(() => {
let cancelled = false;
@@ -409,24 +433,39 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
const allWorktrees: WorktreeMetadata[] = [];
await Promise.all(
projectEntries.map(async (project) => {
// Constrain fanout: previously `Promise.all(projects.map(...))` could
// spawn dozens of concurrent `git worktree list` and
// `checkIsGitRepository` calls on cold start, each touching the
// worktree process. Concurrency=3 keeps startup latency low while
// bounding peak worktree-process load.
const worktreeConcurrency = 3;
let cursor = 0;
const workers = Array.from({ length: worktreeConcurrency }, async () => {
while (true) {
const nextIndex = cursor;
cursor += 1;
if (nextIndex >= projectEntries.length) return;
const project = projectEntries[nextIndex];
const projectPath = normalizePath(project.path);
if (!projectPath) return;
if (!projectPath) continue;
try {
// Use store-cached isGitRepo when available; fall back to direct check for initial worktree discovery
// Use store-cached isGitRepo when available; fall back to
// a direct check for projects the Git store hasn't seen yet.
// Forcing `ensureStatus` here also warms the store so the
// PR/render paths downstream can read isGitRepo for free.
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
const isGitRepo = cachedIsGitRepo ?? await import('@/lib/gitApi').then(m => m.checkIsGitRepository(projectPath));
if (!isGitRepo) return;
const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath);
if (!isGitRepo) continue;
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
if (cancelled || worktrees.length === 0) return;
if (cancelled || worktrees.length === 0) continue;
worktreesByProject.set(projectPath, worktrees);
allWorktrees.push(...worktrees);
} catch {
// ignore discovery errors
}
}),
);
}
});
await Promise.all(workers);
if (cancelled) return;
@@ -436,6 +475,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
});
};
// Skip if we already discovered worktrees for this exact project set.
if (discoveredProjectsRef.current === projectWorktreeDiscoveryKey) {
return;
}
discoveredProjectsRef.current = projectWorktreeDiscoveryKey;
void discoverWorktrees();
return () => {
@@ -508,11 +552,29 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [sessions, pinnedSessionIds]);
const sessionOrderIndex = React.useMemo(
() => new Map(sortedSessions.map((session, index) => [session.id, index])),
// Stable signature: id + updatedAt joined. When this string is
// unchanged, the relative ordering of sessions is identical and the
// derived `sessionOrderIndex` Map can return the previous reference.
// Without this, a fresh `sortedSessions` array (cheap to rebuild) would
// still hand a new Map identity to the entire SessionGroupSection
// memo chain, invalidating sourceGroupNodes, nodeBySessionId, and the
// rest of the down-stream useMemo chain.
const sessionOrderSignature = React.useMemo(
() => sortedSessions.map((s) => `${s.id}:${s.time?.updated ?? 0}`).join('|'),
[sortedSessions],
);
const sessionOrderIndexRef = React.useRef<{ signature: string; map: Map<string, number> } | null>(null);
const sessionOrderIndex = React.useMemo(() => {
const cached = sessionOrderIndexRef.current;
if (cached && cached.signature === sessionOrderSignature) {
return cached.map;
}
const next = new Map(sortedSessions.map((session, index) => [session.id, index]));
sessionOrderIndexRef.current = { signature: sessionOrderSignature, map: next };
return next;
}, [sessionOrderSignature, sortedSessions]);
const childrenMap = React.useMemo(() => {
const map = new Map<string, Session[]>();
sortedSessions.forEach((session) => {
@@ -703,6 +765,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
[collapsedFolderIds, toggleFolderCollapse, createFolder, t],
);
const stableHandleSessionSelect = useStableRenderCallback(handleSessionSelect);
const stableHandleSessionDoubleClick = useStableRenderCallback(handleSessionDoubleClick);
const stableHandleSaveEdit = useStableRenderCallback(handleSaveEdit);
const stableHandleCancelEdit = useStableRenderCallback(handleCancelEdit);
const stableHandleShareSession = useStableRenderCallback(handleShareSession);
const stableHandleCopyShareUrl = useStableRenderCallback(handleCopyShareUrl);
const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession);
const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession);
const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename);
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => {
setVisibleSessionCountByGroup((prev) => {
const next = new Map(prev);
@@ -881,6 +953,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
sessions,
archivedSessions,
availableWorktreesByProject,
normalizedProjects,
});
useArchivedAutoFolders({
@@ -930,7 +1003,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</div>
);
const { currentSessionDirectory } = useProjectSessionSelection({
useProjectSessionSelection({
projectSections,
activeProjectId,
activeSessionByProject,
@@ -942,8 +1015,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
openNewSessionDraft,
setActiveMainTab,
setSessionSwitcherOpen,
sessions,
worktreeMetadata,
});
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
@@ -1214,15 +1285,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
projectHeaderSentinelRefs,
});
const renderSessionNode = React.useCallback(
const renderSessionNode = useStableRenderCallback(
(
node: SessionNode,
depth = 0,
depth: number = 0,
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket = false,
archivedBucket: boolean = false,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext: 'project' | 'recent' = 'project',
renderExtras?: SessionNodeRenderExtras,
): React.ReactNode => (
<SessionNodeItem
node={node}
@@ -1240,16 +1312,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setEditingId={setEditingId}
editTitle={editTitle}
setEditTitle={setEditTitle}
handleSaveEdit={handleSaveEdit}
handleCancelEdit={handleCancelEdit}
handleSaveEdit={stableHandleSaveEdit}
handleCancelEdit={stableHandleCancelEdit}
toggleParent={toggleParent}
handleSessionSelect={handleSessionSelect}
handleSessionDoubleClick={handleSessionDoubleClick}
handleSessionSelect={stableHandleSessionSelect}
handleSessionDoubleClick={stableHandleSessionDoubleClick}
togglePinnedSession={togglePinnedSession}
handleShareSession={handleShareSession}
handleShareSession={stableHandleShareSession}
copiedSessionId={copiedSessionId}
handleCopyShareUrl={handleCopyShareUrl}
handleUnshareSession={handleUnshareSession}
handleCopyShareUrl={stableHandleCopyShareUrl}
handleUnshareSession={stableHandleUnshareSession}
openSidebarMenuKey={openSidebarMenuKey}
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
renamingFolderId={renamingFolderId}
@@ -1257,50 +1329,22 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
getSessionFolderId={getSessionFolderId}
removeSessionFromFolder={removeSessionFromFolder}
addSessionToFolder={addSessionToFolder}
createFolderAndStartRename={createFolderAndStartRename}
createFolderAndStartRename={stableCreateFolderAndStartRename}
openContextPanelTab={openContextPanelTab}
handleDeleteSession={handleDeleteSession}
handleDeleteSession={stableHandleDeleteSession}
mobileVariant={mobileVariant}
alwaysShowActions={alwaysShowSidebarActions}
renderSessionNode={renderSessionNode}
secondaryMeta={secondaryMeta}
renderContext={renderContext}
subtreeContainsActive={renderExtras?.subtreeContainsActive ?? EMPTY_SUBTREE_SET}
subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_SET}
menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null}
nodeStructureKey={renderExtras?.nodeStructureKey ?? ''}
childRenderExtrasFor={renderExtras?.childRenderExtrasFor}
liveSessionById={liveSessionById}
/>
),
[
currentSessionId,
pinnedSessionIds,
expandedParents,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
notifyOnSubtasks,
editingId,
setEditingId,
editTitle,
setEditTitle,
handleSaveEdit,
handleCancelEdit,
toggleParent,
handleSessionSelect,
handleSessionDoubleClick,
togglePinnedSession,
handleShareSession,
copiedSessionId,
handleCopyShareUrl,
handleUnshareSession,
openSidebarMenuKey,
setOpenSidebarMenuKey,
renamingFolderId,
getFoldersForScope,
getSessionFolderId,
removeSessionFromFolder,
addSessionToFolder,
createFolderAndStartRename,
openContextPanelTab,
handleDeleteSession,
mobileVariant,
alwaysShowSidebarActions,
],
);
const toggleCollapsedGroup = React.useCallback((key: string) => {
@@ -1335,7 +1379,15 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}, [prVisualSummaryMap]);
const renderGroupSessions = React.useCallback(
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => (
(
group: SessionGroup,
groupKey: string,
projectId?: string | null,
hideGroupLabel?: boolean,
dragHandleProps?: SortableDragHandleProps | null,
compactBodyPadding?: boolean,
scrollContainerRef?: React.RefObject<HTMLElement | null>,
) => (
<SessionGroupSection
group={group}
groupKey={groupKey}
@@ -1355,7 +1407,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
showDeletionDialog={showDeletionDialog}
setDeleteFolderConfirm={setDeleteFolderConfirm}
renderSessionNode={renderSessionNode}
currentSessionDirectory={currentSessionDirectory}
projectRepoStatus={projectRepoStatus}
lastRepoStatus={lastRepoStatusRef.current}
showMoreGroupSessions={showMoreGroupSessions}
@@ -1368,16 +1419,23 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setSessionSwitcherOpen={setSessionSwitcherOpen}
openNewSessionDraft={openNewSessionDraftFromTree}
addSessionToFolder={addSessionToFolder}
createFolderAndStartRename={createFolderAndStartRename}
createFolderAndStartRename={stableCreateFolderAndStartRename}
renamingFolderId={renamingFolderId}
renameFolderDraft={renameFolderDraft}
setRenameFolderDraft={setRenameFolderDraft}
setRenamingFolderId={setRenamingFolderId}
pinnedSessionIds={pinnedSessionIds}
expandedParents={expandedParents}
sessionOrderIndex={sessionOrderIndex}
currentSessionId={currentSessionId}
editingId={editingId}
editTitle={editTitle}
openSidebarMenuKey={openSidebarMenuKey}
liveSessionById={liveSessionById}
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
onToggleCollapsedGroup={toggleCollapsedGroup}
dragHandleProps={dragHandleProps}
scrollContainerRef={scrollContainerRef}
/>
),
[
@@ -1393,7 +1451,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
deleteFolder,
showDeletionDialog,
renderSessionNode,
currentSessionDirectory,
projectRepoStatus,
showMoreGroupSessions,
resetGroupSessionLimit,
@@ -1405,11 +1462,17 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setSessionSwitcherOpen,
openNewSessionDraftFromTree,
addSessionToFolder,
createFolderAndStartRename,
stableCreateFolderAndStartRename,
renamingFolderId,
renameFolderDraft,
pinnedSessionIds,
expandedParents,
sessionOrderIndex,
currentSessionId,
editingId,
editTitle,
openSidebarMenuKey,
liveSessionById,
prVisualStateByDirectoryBranch,
toggleCollapsedGroup,
],
@@ -1419,174 +1482,40 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<SidebarActivitySections
sections={activitySections}
renderSessionNode={renderSessionNode}
currentSessionId={currentSessionId}
editingId={editingId}
openSidebarMenuKey={openSidebarMenuKey}
variant="section"
/>
) : null;
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
const selectedIds = useSessionMultiSelectStore((state) => state.selectedIds);
const selectionScopeKey = useSessionMultiSelectStore((state) => state.scopeKey);
const multiSelectStoreApi = useSessionMultiSelectStore;
const handleToggleSelectionMode = React.useCallback(() => {
useSessionMultiSelectStore.getState().toggleMode();
}, []);
const handleExitSelectionMode = React.useCallback(() => {
useSessionMultiSelectStore.getState().disable();
}, []);
const bulkScopeIsArchived = React.useMemo(() => {
if (selectedIds.size === 0) return false;
if (typeof document === 'undefined') return false;
let sawActive = false;
let sawArchived = false;
for (const id of selectedIds) {
const rows = document.querySelectorAll<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
for (const row of rows) {
if (row.getAttribute('data-session-archived') === '1') sawArchived = true;
else sawActive = true;
}
}
return sawArchived && !sawActive;
}, [selectedIds]);
const derivedSelectionScope = React.useMemo(() => {
if (selectionScopeKey) return selectionScopeKey;
if (selectedIds.size === 0) return null;
if (typeof document === 'undefined') return null;
for (const id of selectedIds) {
const row = document.querySelector<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
const scope = row?.getAttribute('data-session-scope');
if (scope && scope.length > 0) return scope;
}
return null;
}, [selectedIds, selectionScopeKey]);
const bulkScopeFolders = React.useMemo(() => {
if (!derivedSelectionScope) return [];
return foldersMap[derivedSelectionScope] ?? [];
}, [foldersMap, derivedSelectionScope]);
const bulkCanRemoveFromFolder = React.useMemo(() => {
if (!derivedSelectionScope || selectedIds.size === 0) return false;
const scopeFolders = foldersMap[derivedSelectionScope] ?? [];
for (const folder of scopeFolders) {
for (const id of folder.sessionIds) {
if (selectedIds.has(id)) return true;
}
}
return false;
}, [foldersMap, derivedSelectionScope, selectedIds]);
const handleBulkMoveToFolder = React.useCallback((folderId: string) => {
if (!derivedSelectionScope || selectedIds.size === 0) return;
addSessionsToFolder(derivedSelectionScope, folderId, Array.from(selectedIds));
}, [addSessionsToFolder, selectedIds, derivedSelectionScope]);
const handleBulkCreateFolderAndMove = React.useCallback(() => {
if (!derivedSelectionScope || selectedIds.size === 0) return;
const newFolder = createFolderAndStartRename(derivedSelectionScope);
if (!newFolder) return;
addSessionsToFolder(derivedSelectionScope, newFolder.id, Array.from(selectedIds));
}, [addSessionsToFolder, createFolderAndStartRename, selectedIds, derivedSelectionScope]);
const handleBulkRemoveFromFolder = React.useCallback(() => {
if (!derivedSelectionScope || selectedIds.size === 0) return;
removeSessionsFromFolders(derivedSelectionScope, Array.from(selectedIds));
}, [removeSessionsFromFolders, selectedIds, derivedSelectionScope]);
const executeBulkDelete = React.useCallback(async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
if (bulkScopeIsArchived) {
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) {
toast.success(deletedIds.length === 1
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }));
}
} else {
const { archivedIds, failedIds } = await archiveSessions(ids);
if (archivedIds.length > 0) {
toast.success(archivedIds.length === 1
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
: t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
}
}
useSessionMultiSelectStore.getState().clear();
}, [archiveSessions, bulkScopeIsArchived, deleteSessions, selectedIds, t]);
const handleBulkDelete = React.useCallback(() => {
const count = selectedIds.size;
if (count === 0) return;
if (!showDeletionDialog) {
void executeBulkDelete();
return;
}
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog]);
const confirmBulkDelete = React.useCallback(async () => {
setBulkDeleteConfirm(null);
await executeBulkDelete();
}, [executeBulkDelete]);
React.useEffect(() => {
if (!selectionModeEnabled) return;
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || '');
const listener = (event: KeyboardEvent) => {
if (isInlineEditing) return;
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
const modifier = isMac ? event.metaKey : event.ctrlKey;
if (event.key === 'Escape') {
event.preventDefault();
useSessionMultiSelectStore.getState().disable();
return;
}
if (modifier && event.key === 'Backspace') {
event.preventDefault();
handleBulkDelete();
return;
}
if (modifier && (event.key === 'a' || event.key === 'A')) {
const rows = typeof document !== 'undefined'
? Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'))
: [];
if (rows.length === 0) return;
event.preventDefault();
const currentScope = multiSelectStoreApi.getState().scopeKey;
const targetScope = currentScope
?? rows[0]?.getAttribute('data-session-scope')
?? null;
const scopeFilter = (el: HTMLElement): boolean => {
if (!targetScope) return true;
return el.getAttribute('data-session-scope') === targetScope;
};
const ids = rows
.filter(scopeFilter)
.map((el) => el.getAttribute('data-session-row'))
.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (ids.length === 0) return;
multiSelectStoreApi.getState().replaceAll(ids, targetScope || null);
}
};
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}, [handleBulkDelete, isInlineEditing, multiSelectStoreApi, selectionModeEnabled]);
const {
selectionModeEnabled,
hasSelection,
selectedIdsSize,
bulkScopeIsArchived,
derivedSelectionScope,
bulkScopeFolders,
bulkCanRemoveFromFolder,
handleToggleSelectionMode,
handleExitSelectionMode,
handleBulkMoveToFolder,
handleBulkCreateFolderAndMove,
handleBulkRemoveFromFolder,
handleBulkDelete,
confirmBulkDelete,
} = useSidebarBulkActions({
isInlineEditing,
showDeletionDialog,
foldersMap,
addSessionsToFolder,
removeSessionsFromFolders,
createFolderAndStartRename,
archiveSessions,
deleteSessions,
setBulkDeleteConfirm,
});
const handleOpenMultiRunFromHeader = React.useCallback(() => {
setActiveMainTab('chat');
if (mobileVariant) {
@@ -1670,9 +1599,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
isInlineEditing={isInlineEditing}
/>
{selectionModeEnabled && selectedIds.size > 0 ? (
{selectionModeEnabled && hasSelection ? (
<BulkActionBar
selectedCount={selectedIds.size}
selectedCount={selectedIdsSize}
scopeKey={derivedSelectionScope}
scopeFolders={bulkScopeFolders}
archivedBucket={bulkScopeIsArchived}
@@ -5,6 +5,11 @@ import type { Session } from '@opencode-ai/sdk/v2';
// Archived buckets routinely grow into the hundreds/thousands; virtualize
// when we cross this row count so the DOM stays bounded.
const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
// Active/worktree groups can also grow large (a single worktree with 80+
// sessions), and unlike the archive they're interactive from the start.
// Virtualize eagerly for non-archived groups to keep the rendered row
// count bounded. With overscan ~8 the visible behavior is identical.
const ACTIVE_VIRTUALIZE_THRESHOLD = 30;
// Compact rows in the archived bucket without nested subagents render
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
const ARCHIVED_ROW_ESTIMATE_PX = 28;
@@ -18,6 +23,13 @@ import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDn
import type { SortableDragHandleProps } from './sortableItems';
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
import {
collectSubtreeContainingId,
computeNodeStructureKey,
nodeContainsSessionId,
resolveMenuOpenSessionId,
} from './sessionNodeItemUtils';
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
@@ -50,8 +62,16 @@ type Props = {
deleteFolder: (scopeKey: string, folderId: string) => void;
showDeletionDialog: boolean;
setDeleteFolderConfirm: React.Dispatch<React.SetStateAction<DeleteFolderConfirm>>;
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null) => React.ReactNode;
currentSessionDirectory: string | null;
renderSessionNode: (
node: SessionNode,
depth?: number,
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket?: boolean,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeRenderExtras,
) => React.ReactNode;
projectRepoStatus: Map<string, boolean | null>;
lastRepoStatus: boolean;
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void;
@@ -70,7 +90,13 @@ type Props = {
setRenameFolderDraft: React.Dispatch<React.SetStateAction<string>>;
setRenamingFolderId: React.Dispatch<React.SetStateAction<string | null>>;
pinnedSessionIds: Set<string>;
expandedParents: Set<string>;
sessionOrderIndex: Map<string, number>;
currentSessionId: string | null;
editingId: string | null;
editTitle: string;
openSidebarMenuKey: string | null;
liveSessionById: Map<string, Session>;
prVisualStateByDirectoryBranch: Map<string, {
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
number: number;
@@ -97,9 +123,196 @@ type Props = {
onToggleCollapsedGroup: (groupKey: string) => void;
dragHandleProps?: SortableDragHandleProps | null;
compactBodyPadding?: boolean;
/**
* Optional scroll container ref threaded from the outer ScrollableOverlay.
* When provided, the virtualization effect can resolve the scrolling
* ancestor synchronously and skip the getComputedStyle walk on every
* render of an expanded archived bucket.
*/
scrollContainerRef?: React.RefObject<HTMLElement | null>;
};
export function SessionGroupSection(props: Props): React.ReactNode {
const groupContainsSessionId = (group: SessionGroup, sessionId: string | null): boolean => {
if (!sessionId) return false;
return group.sessions.some((node) => nodeContainsSessionId(node, sessionId));
};
const groupHasPinnedMembershipChange = (
group: SessionGroup,
prevPinnedSessionIds: Set<string>,
nextPinnedSessionIds: Set<string>,
): boolean => {
const visit = (node: SessionNode): boolean => {
const sessionId = node.session.id;
if (prevPinnedSessionIds.has(sessionId) !== nextPinnedSessionIds.has(sessionId)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const groupHasSessionOrderChange = (
group: SessionGroup,
prevSessionOrderIndex: Map<string, number>,
nextSessionOrderIndex: Map<string, number>,
): boolean => {
const visit = (node: SessionNode): boolean => {
const sessionId = node.session.id;
if (prevSessionOrderIndex.get(sessionId) !== nextSessionOrderIndex.get(sessionId)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const groupHasExpansionMembershipChange = (
group: SessionGroup,
prevExpandedParents: Set<string>,
nextExpandedParents: Set<string>,
): boolean => {
const bucketTag = group.isArchivedBucket ? 'archived' : 'active';
const visit = (node: SessionNode): boolean => {
const key = `project:${bucketTag}:${node.session.id}`;
if (prevExpandedParents.has(key) !== nextExpandedParents.has(key)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const groupHasResolvedSessionChange = (
group: SessionGroup,
prevLiveSessionById: Map<string, Session>,
nextLiveSessionById: Map<string, Session>,
): boolean => {
const visit = (node: SessionNode): boolean => {
const sessionId = node.session.id;
if ((prevLiveSessionById.get(sessionId) ?? node.session) !== (nextLiveSessionById.get(sessionId) ?? node.session)) {
return true;
}
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const getProjectRepoStatusValue = (props: Props): boolean | null | undefined => {
if (!props.projectId) return undefined;
return props.projectRepoStatus.has(props.projectId)
? props.projectRepoStatus.get(props.projectId)
: undefined;
};
const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
// Bail on Object.is for the props that drive the most work: the group
// itself, its key, and the group-level chrome. These change rarely and
// any change should force a re-render of this group.
if (prev.group !== next.group) return false;
if (prev.groupKey !== next.groupKey) return false;
if (prev.projectId !== next.projectId) return false;
if (prev.hideGroupLabel !== next.hideGroupLabel) return false;
if (prev.compactBodyPadding !== next.compactBodyPadding) return false;
if (prev.groupSearchDataByGroup !== next.groupSearchDataByGroup) return false;
if (prev.visibleSessionCount !== next.visibleSessionCount) return false;
if (prev.collapsedGroups !== next.collapsedGroups
&& prev.collapsedGroups.has(prev.groupKey) !== next.collapsedGroups.has(next.groupKey)) {
return false;
}
if (prev.projectRepoStatus !== next.projectRepoStatus
&& getProjectRepoStatusValue(prev) !== getProjectRepoStatusValue(next)) {
return false;
}
if (prev.pinnedSessionIds !== next.pinnedSessionIds
&& groupHasPinnedMembershipChange(next.group, prev.pinnedSessionIds, next.pinnedSessionIds)) {
return false;
}
if (prev.expandedParents !== next.expandedParents
&& groupHasExpansionMembershipChange(next.group, prev.expandedParents, next.expandedParents)) {
return false;
}
if (prev.sessionOrderIndex !== next.sessionOrderIndex
&& groupHasSessionOrderChange(next.group, prev.sessionOrderIndex, next.sessionOrderIndex)) {
return false;
}
if (prev.currentSessionId !== next.currentSessionId
&& (groupContainsSessionId(prev.group, prev.currentSessionId) || groupContainsSessionId(next.group, next.currentSessionId))) {
return false;
}
if (prev.editingId !== next.editingId
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
return false;
}
if (prev.editTitle !== next.editTitle
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
return false;
}
if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) {
const prevMenuSessionId = resolveMenuOpenSessionId(prev.group.sessions, prev.openSidebarMenuKey, 'project', Boolean(prev.group.isArchivedBucket));
const nextMenuSessionId = resolveMenuOpenSessionId(next.group.sessions, next.openSidebarMenuKey, 'project', Boolean(next.group.isArchivedBucket));
if (prevMenuSessionId || nextMenuSessionId) return false;
}
if (prev.liveSessionById !== next.liveSessionById
&& groupHasResolvedSessionChange(next.group, prev.liveSessionById, next.liveSessionById)) {
return false;
}
// Per-row / per-state props. The PR-visual-state map flips frequently
// during bootstrap but a single group's value is usually stable, so we
// compare only the value this group actually consumes instead of the
// whole map reference.
if (prev.prVisualStateByDirectoryBranch !== next.prVisualStateByDirectoryBranch) {
const prevVal = prev.group?.directory && prev.group?.branch
? prev.prVisualStateByDirectoryBranch.get(`${prev.group.directory}::${prev.group.branch.trim()}`)
: undefined;
const nextVal = next.group?.directory && next.group?.branch
? next.prVisualStateByDirectoryBranch.get(`${next.group.directory}::${next.group.branch.trim()}`)
: undefined;
if (!Object.is(prevVal, nextVal)) return false;
}
// Other props are typically stable references from the parent. Default
// to reference equality (the cheap path) and only re-render when the
// parent actually swapped something.
return (
prev.hasSessionSearchQuery === next.hasSessionSearchQuery
&& prev.normalizedSessionSearchQuery === next.normalizedSessionSearchQuery
&& prev.hideDirectoryControls === next.hideDirectoryControls
&& prev.collapsedFolderIds === next.collapsedFolderIds
&& prev.toggleFolderCollapse === next.toggleFolderCollapse
&& prev.renameFolder === next.renameFolder
&& prev.deleteFolder === next.deleteFolder
&& prev.showDeletionDialog === next.showDeletionDialog
&& prev.setDeleteFolderConfirm === next.setDeleteFolderConfirm
&& prev.renderSessionNode === next.renderSessionNode
&& prev.lastRepoStatus === next.lastRepoStatus
&& prev.showMoreGroupSessions === next.showMoreGroupSessions
&& prev.resetGroupSessionLimit === next.resetGroupSessionLimit
&& prev.mobileVariant === next.mobileVariant
&& prev.alwaysShowActions === next.alwaysShowActions
&& prev.activeProjectId === next.activeProjectId
&& prev.setActiveProjectIdOnly === next.setActiveProjectIdOnly
&& prev.setActiveMainTab === next.setActiveMainTab
&& prev.setSessionSwitcherOpen === next.setSessionSwitcherOpen
&& prev.openNewSessionDraft === next.openNewSessionDraft
&& prev.addSessionToFolder === next.addSessionToFolder
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
&& prev.renamingFolderId === next.renamingFolderId
&& prev.renameFolderDraft === next.renameFolderDraft
&& prev.setRenameFolderDraft === next.setRenameFolderDraft
&& prev.setRenamingFolderId === next.setRenamingFolderId
&& prev.onToggleCollapsedGroup === next.onToggleCollapsedGroup
&& prev.dragHandleProps === next.dragHandleProps
&& prev.scrollContainerRef === next.scrollContainerRef
);
};
function SessionGroupSectionBase(props: Props): React.ReactNode {
const { t } = useI18n();
const {
group,
@@ -138,10 +351,14 @@ export function SessionGroupSection(props: Props): React.ReactNode {
setRenamingFolderId,
pinnedSessionIds,
sessionOrderIndex,
currentSessionId,
editingId,
openSidebarMenuKey,
prVisualStateByDirectoryBranch,
onToggleCollapsedGroup,
dragHandleProps,
compactBodyPadding = false,
scrollContainerRef,
} = props;
const compareSessionNodes = React.useCallback((a: SessionNode, b: SessionNode) => {
@@ -254,6 +471,79 @@ export function SessionGroupSection(props: Props): React.ReactNode {
const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
// Precompute per-row "subtree contains active session" and "subtree contains
// editing session" lookups once per render. The previous design walked the
// node tree inside SessionNodeItem.areEqual for every row, which is O(M^2)
// across the whole sidebar. These sets let areEqual answer with a single
// Set.has lookup, so the cost is O(M) once per SessionGroupSection render.
const renderContextForGroup = 'project' as const;
const subtreeContainsActive = React.useMemo(() => {
const set = new Set<string>();
collectSubtreeContainingId(sourceGroupNodes, currentSessionId, set);
allFoldersForGroup.forEach(({ nodes }) => {
collectSubtreeContainingId(nodes, currentSessionId, set);
});
return set;
}, [sourceGroupNodes, allFoldersForGroup, currentSessionId]);
const subtreeContainsEditing = React.useMemo(() => {
const set = new Set<string>();
collectSubtreeContainingId(sourceGroupNodes, editingId, set);
allFoldersForGroup.forEach(({ nodes }) => {
collectSubtreeContainingId(nodes, editingId, set);
});
return set;
}, [sourceGroupNodes, allFoldersForGroup, editingId]);
const menuOpenSessionId = React.useMemo(() => {
if (!openSidebarMenuKey) return null;
const fromSource = resolveMenuOpenSessionId(sourceGroupNodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
if (fromSource) return fromSource;
for (const { nodes } of allFoldersForGroup) {
const id = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
if (id) return id;
}
return null;
}, [openSidebarMenuKey, sourceGroupNodes, allFoldersForGroup, group.isArchivedBucket]);
const buildNodeStructureKeyByNode = React.useCallback((nodes: SessionNode[]): WeakMap<SessionNode, string> => {
const map = new WeakMap<SessionNode, string>();
const visit = (node: SessionNode): void => {
map.set(node, computeNodeStructureKey(node));
for (const child of node.children) {
visit(child);
}
};
nodes.forEach(visit);
return map;
}, []);
const nodeStructureKeyBySourceNode = React.useMemo(
() => buildNodeStructureKeyByNode(sourceGroupNodes),
[buildNodeStructureKeyByNode, sourceGroupNodes],
);
const nodeStructureKeyByFolderNode = React.useMemo(
() => {
const map = new WeakMap<SessionNode, string>();
allFoldersForGroup.forEach(({ nodes }) => {
nodes.forEach((node) => map.set(node, computeNodeStructureKey(node)));
});
return map;
},
[allFoldersForGroup],
);
const resolveNodeStructureKey = React.useCallback((node: SessionNode): string => {
return nodeStructureKeyBySourceNode.get(node) ?? nodeStructureKeyByFolderNode.get(node) ?? '';
}, [nodeStructureKeyBySourceNode, nodeStructureKeyByFolderNode]);
const childRenderExtrasFor = React.useCallback((child: SessionNode) => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(child),
}), [subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]);
const totalSessions = ungroupedSessions.length;
const visibleSessions = group.isArchivedBucket
? ungroupedSessions
@@ -263,15 +553,21 @@ export function SessionGroupSection(props: Props): React.ReactNode {
const remainingCount = totalSessions - visibleSessions.length;
const canShowLess = !group.isArchivedBucket && !hasSessionSearchQuery && totalSessions > maxVisible && remainingCount === 0;
// Virtualize the archived bucket once it grows past a threshold. The
// archived list is the only group that can routinely hit hundreds or
// thousands of rows (projects accumulate archived sessions over time);
// every other group renders eagerly because they're small. All hooks
// below MUST stay above the search-empty early-return so they fire in
// the same order every render — rules-of-hooks.
// Virtualize large groups. Archived buckets grow into the hundreds or
// thousands of rows; active/worktree groups can also hit 80+ sessions
// when a single worktree accumulates over time. Both paths share the
// same virtua Virtualizer; the threshold just controls when we mount
// it. The visible behavior is identical because virtua uses overscan
// (8) for the buffer zone. All hooks below MUST stay above the
// search-empty early-return so they fire in the same order every
// render — rules-of-hooks.
const shouldVirtualizeArchived = group.isArchivedBucket === true
&& !hasSessionSearchQuery
&& visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD;
const shouldVirtualizeActive = group.isArchivedBucket !== true
&& !hasSessionSearchQuery
&& visibleSessions.length >= ACTIVE_VIRTUALIZE_THRESHOLD;
const shouldVirtualize = shouldVirtualizeArchived || shouldVirtualizeActive;
const archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null);
const archivedScrollRef = React.useRef<HTMLElement | null>(null);
@@ -284,22 +580,31 @@ export function SessionGroupSection(props: Props): React.ReactNode {
// element and renders rows in the wrong subset / position.
const [archivedScrollMargin, setArchivedScrollMargin] = React.useState(0);
// Find the nearest scrolling ancestor by walking up the DOM. The sidebar
// routes its scroll through `ScrollableOverlay` higher up the tree;
// threading a ref through every intermediate component would be invasive
// for this single use case.
// Resolve the scrolling ancestor and measure the virtual container's offset
// from its content origin, both on every render. The container ref is null
// while the archived bucket is collapsed (the body isn't mounted), so a
// Resolve the scrolling ancestor. When the parent has threaded a
// `scrollContainerRef` (Layer 1.4), use it directly to skip the
// `getComputedStyle` walk on every render of an expanded archived
// bucket — the walk is one of the more expensive operations in the
// hot path because it forces a style recalc on every parent up the
// tree. Fall back to the legacy walk only when the ref is missing.
//
// We also still re-run when the archive flips between expanded/collapsed,
// and on a ResizeObserver-driven layout change of the container, so a
// dep-gated effect that only fires when shouldVirtualizeArchived flips
// would miss the eventual mount and leave the scroll element null forever.
// Running on every render lets us pick up the container as soon as
// expanding the bucket mounts it; the cached scroll element is reused as
// long as it still contains the container. Both state setters compare
// before writing, so a stable layout produces no state churn.
// would miss the eventual mount and leave the scroll element null.
const [, setLayoutVersion] = React.useState(0);
React.useEffect(() => {
if (!shouldVirtualize) return;
const container = archivedVirtualContainerRef.current;
if (!container) return;
if (typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(() => setLayoutVersion((v) => v + 1));
ro.observe(container);
return () => ro.disconnect();
}, [shouldVirtualize]);
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useLayoutEffect(() => {
if (!shouldVirtualizeArchived) {
if (!shouldVirtualize) {
if (archivedScrollEl !== null) setArchivedScrollEl(null);
archivedScrollRef.current = null;
if (archivedScrollMargin !== 0) setArchivedScrollMargin(0);
@@ -312,7 +617,15 @@ export function SessionGroupSection(props: Props): React.ReactNode {
return;
}
let scrollEl: HTMLElement | null = archivedScrollEl;
if (!scrollEl || !scrollEl.contains(container)) {
const providedScrollEl = scrollContainerRef?.current ?? null;
if (providedScrollEl && providedScrollEl.contains(container)) {
scrollEl = providedScrollEl;
if (scrollEl !== archivedScrollEl) {
archivedScrollRef.current = scrollEl;
setArchivedScrollEl(scrollEl);
return;
}
} else if (!scrollEl || !scrollEl.contains(container)) {
// Walk up to find the nearest scrolling ancestor. Only happens on
// first mount or if the DOM tree restructured.
let el: HTMLElement | null = container.parentElement;
@@ -327,8 +640,6 @@ export function SessionGroupSection(props: Props): React.ReactNode {
if (scrollEl !== archivedScrollEl) {
archivedScrollRef.current = scrollEl;
setArchivedScrollEl(scrollEl);
// setState triggers a re-render; bail out and let the next pass
// measure the margin against the fresh element.
return;
}
}
@@ -339,11 +650,9 @@ export function SessionGroupSection(props: Props): React.ReactNode {
setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset));
});
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
return null;
}
const collectGroupSessions = (nodes: SessionNode[]): Session[] => {
// Hooks below MUST stay above the search-empty early-return so they
// fire in the same order every render — rules-of-hooks.
const collectGroupSessions = React.useCallback((nodes: SessionNode[]): Session[] => {
const collected: Session[] = [];
const visit = (list: SessionNode[]) => {
list.forEach((node) => {
@@ -353,9 +662,52 @@ export function SessionGroupSection(props: Props): React.ReactNode {
};
visit(nodes);
return collected;
};
}, []);
// The "delete all in group" handler closes over the full list of
// sessions in this group. Memoize so the recursive flatten only runs
// when the underlying source group nodes change, not on every render.
const allGroupSessions = React.useMemo(
() => (group.isArchivedBucket ? collectGroupSessions(sourceGroupNodes) : []),
[collectGroupSessions, sourceGroupNodes, group.isArchivedBucket],
);
// Precompute the per-folder "delete all sessions in folder" list once
// per render. The previous design ran a recursive `collectFolderSessions`
// walk inside each folder's render, which is O(F × (S + F)) per group
// render. With F=50 folders and S=200 archived sessions this is
// significant; the precompute makes it O(F + S) once.
const folderSessionsForDeleteById = React.useMemo(() => {
if (!group.isArchivedBucket) return new Map<string, Session[]>();
const result = new Map<string, Session[]>();
const childIdsByParentId = new Map<string, string[]>();
for (const { folder } of allFoldersForGroup) {
if (!folder.parentId) continue;
const existing = childIdsByParentId.get(folder.parentId) ?? [];
existing.push(folder.id);
childIdsByParentId.set(folder.parentId, existing);
}
const visit = (targetFolderId: string, seen: Set<string>): Session[] => {
if (seen.has(targetFolderId)) return [];
seen.add(targetFolderId);
const directEntry = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId);
const collected: Session[] = directEntry ? collectGroupSessions(directEntry.nodes) : [];
const childIds = childIdsByParentId.get(targetFolderId) ?? [];
for (const childId of childIds) {
collected.push(...visit(childId, seen));
}
return collected;
};
for (const { folder } of allFoldersForGroup) {
result.set(folder.id, visit(folder.id, new Set()));
}
return result;
}, [allFoldersForGroup, collectGroupSessions, group.isArchivedBucket]);
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
return null;
}
const allGroupSessions = collectGroupSessions(sourceGroupNodes);
const isGitProject = projectId && projectRepoStatus.has(projectId)
? Boolean(projectRepoStatus.get(projectId))
: lastRepoStatus;
@@ -437,15 +789,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
const subFolderItems = directSubFolders.length > 0
? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))}</>
: undefined;
const collectFolderSessions = (targetFolderId: string): Session[] => {
const directNodes = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId)?.nodes ?? [];
const childFolders = allFoldersForGroup.filter(({ folder: candidate }) => candidate.parentId === targetFolderId);
return [
...collectGroupSessions(directNodes),
...childFolders.flatMap(({ folder: child }) => collectFolderSessions(child.id)),
];
};
const folderSessionsForDelete = group.isArchivedBucket ? collectFolderSessions(folder.id) : [];
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
return (
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
@@ -485,6 +829,15 @@ export function SessionGroupSection(props: Props): React.ReactNode {
});
}}
renderSessionNode={renderSessionNode}
getRenderExtras={resolveNodeStructureKey
? (node) => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
childRenderExtrasFor,
})
: undefined}
groupDirectory={group.directory}
projectId={projectId}
mobileVariant={mobileVariant}
@@ -546,7 +899,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
}}
>
{renderFolderItems()}
{shouldVirtualizeArchived ? (
{shouldVirtualize ? (
<div ref={archivedVirtualContainerRef}>
<Virtualizer
data={visibleSessions}
@@ -555,11 +908,23 @@ export function SessionGroupSection(props: Props): React.ReactNode {
scrollRef={archivedScrollRef}
startMargin={archivedScrollMargin}
>
{(node) => renderSessionNode(node, 0, group.directory, projectId, true) as React.ReactElement}
{(node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
childRenderExtrasFor,
}) as React.ReactElement}
</Virtualizer>
</div>
) : (
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true))
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
childRenderExtrasFor,
}))
)}
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
<div className="py-1 text-left typography-micro text-muted-foreground">
@@ -846,3 +1211,5 @@ export function SessionGroupSection(props: Props): React.ReactNode {
</div>
);
}
export const SessionGroupSection = React.memo(SessionGroupSectionBase, areGroupPropsEqual);
@@ -21,10 +21,12 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Icon } from "@/components/icon/Icon";
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import type { ChildSessionExport } from '@/lib/exportSession';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionPermissions } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from './sessionFolderDnd';
import { nodeContainsSessionId } from './sessionNodeItemUtils';
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
import type { SessionNode } from './types';
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
@@ -61,7 +63,7 @@ type Props = {
setEditingId: (id: string | null) => void;
editTitle: string;
setEditTitle: (value: string) => void;
handleSaveEdit: () => void;
handleSaveEdit: (titleOverride?: string) => void;
handleCancelEdit: () => void;
toggleParent: (expansionKey: string) => void;
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void;
@@ -83,133 +85,58 @@ type Props = {
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean }) => void;
mobileVariant: boolean;
alwaysShowActions: boolean;
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: SecondaryMeta | null, renderContext?: 'project' | 'recent') => React.ReactNode;
renderSessionNode: (
node: SessionNode,
depth?: number,
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket?: boolean,
secondaryMeta?: SecondaryMeta | null,
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeRenderExtras,
) => React.ReactNode;
secondaryMeta?: SecondaryMeta | null;
renderContext?: 'project' | 'recent';
};
const getNodeChildSignature = (node: SessionNode): string => {
if (node.children.length === 0) {
return '';
}
return node.children
.map((child) => `${child.session.id}:${child.children.length}`)
.join('|');
};
const treeContainsSessionId = (node: SessionNode, sessionId: string | null): boolean => {
if (!sessionId) {
return false;
}
if (node.session.id === sessionId) {
return true;
}
for (const child of node.children) {
if (treeContainsSessionId(child, sessionId)) {
return true;
}
}
return false;
};
const treeContainsMenuKey = (
node: SessionNode,
menuKey: string | null,
renderContext: 'project' | 'recent',
archivedBucket: boolean,
): boolean => {
if (!menuKey) {
return false;
}
const nodeMenuKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${node.session.id}`;
if (nodeMenuKey === menuKey) {
return true;
}
for (const child of node.children) {
if (treeContainsMenuKey(child, menuKey, renderContext, archivedBucket)) {
return true;
}
}
return false;
};
const areEqual = (prev: Props, next: Props): boolean => {
const prevSession = prev.node.session;
const nextSession = next.node.session;
const prevSessionId = prevSession.id;
const nextSessionId = nextSession.id;
if (prevSessionId !== nextSessionId) return false;
if (prev.node.session !== next.node.session) return false;
if (getNodeChildSignature(prev.node) !== getNodeChildSignature(next.node)) return false;
if (prev.depth !== next.depth) return false;
if (prev.groupDirectory !== next.groupDirectory) return false;
if (prev.projectId !== next.projectId) return false;
if (prev.archivedBucket !== next.archivedBucket) return false;
if (prev.currentSessionId !== next.currentSessionId) {
const prevActiveInTree = treeContainsSessionId(prev.node, prev.currentSessionId);
const nextActiveInTree = treeContainsSessionId(next.node, next.currentSessionId);
if (prevActiveInTree || nextActiveInTree) {
return false;
}
}
if (prev.pinnedSessionIds.has(prevSessionId) !== next.pinnedSessionIds.has(nextSessionId)) return false;
// Expansion is keyed per render context, so compare the composite key
// matching the one isExpanded reads from in render. If a session appears
// in two contexts (project + recent), they have independent state.
{
const prevRenderContext = prev.renderContext ?? 'project';
const nextRenderContext = next.renderContext ?? 'project';
const prevArchived = prev.archivedBucket ?? false;
const nextArchived = next.archivedBucket ?? false;
const prevExpansionKey = `${prevRenderContext}:${prevArchived ? 'archived' : 'active'}:${prevSessionId}`;
const nextExpansionKey = `${nextRenderContext}:${nextArchived ? 'archived' : 'active'}:${nextSessionId}`;
if (prev.expandedParents.has(prevExpansionKey) !== next.expandedParents.has(nextExpansionKey)) return false;
}
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
if (prev.editingId !== next.editingId) {
const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId);
const nextEditingInTree = treeContainsSessionId(next.node, next.editingId);
if (prevEditingInTree || nextEditingInTree) {
return false;
}
}
if (prev.editTitle !== next.editTitle) {
const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId);
const nextEditingInTree = treeContainsSessionId(next.node, next.editingId);
if (prevEditingInTree || nextEditingInTree) {
return false;
}
}
if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false;
const prevMenuInTree = treeContainsMenuKey(prev.node, prev.openSidebarMenuKey, prev.renderContext ?? 'project', prev.archivedBucket ?? false);
const nextMenuInTree = treeContainsMenuKey(next.node, next.openSidebarMenuKey, next.renderContext ?? 'project', next.archivedBucket ?? false);
if (prevMenuInTree !== nextMenuInTree) return false;
const prevDirectory = normalizePath((prevSession as Session & { directory?: string | null }).directory ?? null)
?? normalizePath(prev.groupDirectory ?? null);
const nextDirectory = normalizePath((nextSession as Session & { directory?: string | null }).directory ?? null)
?? normalizePath(next.groupDirectory ?? null);
if (prevDirectory !== nextDirectory) return false;
if ((prev.secondaryMeta?.projectLabel ?? null) !== (next.secondaryMeta?.projectLabel ?? null)) return false;
if ((prev.secondaryMeta?.branchLabel ?? null) !== (next.secondaryMeta?.branchLabel ?? null)) return false;
if (prev.mobileVariant !== next.mobileVariant) return false;
if (prev.alwaysShowActions !== next.alwaysShowActions) return false;
if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
if (prev.renamingFolderId !== next.renamingFolderId) return false;
return true;
/**
* Precomputed set of session IDs whose subtree contains the current
* active session. Computed once per SessionGroupSection render (when
* currentSessionId changes) instead of being recomputed in every row's
* React.memo comparator.
*/
subtreeContainsActive: Set<string>;
/**
* Precomputed set of session IDs whose subtree contains the session
* currently being edited. Same rationale as subtreeContainsActive.
*/
subtreeContainsEditing: Set<string>;
/**
* Precomputed session ID of the row whose sidebar menu is open, or null
* if no menu is open. Only one row can have its menu open at a time.
*/
menuOpenSessionId: string | null;
/**
* Precomputed structural key for this node. Encodes the IDs and child
* counts of all descendants so a reference-only change to `node` (e.g.
* a fresh tree rebuild) can be detected with a single string compare
* instead of a recursive walk per row.
*/
nodeStructureKey: string;
/**
* Resolves the per-row render extras for each child node. SessionGroupSection
* walks the whole tree once to precompute the structure key for every
* descendant; SessionNodeItem's recursive child render uses this lookup
* to fetch the right key for each child it produces.
*/
childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
/**
* Batched index of live session objects keyed by id. The previous
* implementation called `useSession(session.id)` per row, which used
* `findLiveSession` to iterate every child-store on every SSE event.
* With M visible rows that's M×child-stores per event; the batched
* map turns it into a single Map.get per row. The parent falls back
* to `useSession` only when this map returns undefined.
*/
liveSessionById: Map<string, Session>;
};
function SessionNodeItemComponent(props: Props): React.ReactNode {
@@ -255,6 +182,11 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
renderSessionNode,
secondaryMeta,
renderContext = 'project',
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
childRenderExtrasFor,
liveSessionById,
} = props;
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
@@ -302,11 +234,21 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const pendingRenameRef = React.useRef<{ id: string; title: string } | null>(null);
const handleSaveEditRef = React.useRef(handleSaveEdit);
handleSaveEditRef.current = handleSaveEdit;
const [renameDraft, setRenameDraft] = React.useState(editTitle);
const renameDraftRef = React.useRef(renameDraft);
renameDraftRef.current = renameDraft;
const renameTargetRef = React.useRef<string | null>(null);
const formRef = React.useRef<HTMLFormElement>(null);
const session = node.session;
const liveSession = useSession(session.id);
const resolvedSession = liveSession ?? session;
// Batched live-session lookup. `liveSessionById` is built once per
// Sidebar render from the same `useAllLiveSessions` selector that
// `useSession` would have iterated per child-store, so a Map.get
// here is equivalent in observed state but O(1) per row instead of
// O(child-stores). Falls back to the row session when the live map
// hasn't seen this id yet (sub-render latency between when a session
// is created and when the SSE-driven aggregate picks it up).
const resolvedSession = liveSessionById.get(session.id) ?? session;
const sessionDirectory =
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
@@ -477,13 +419,25 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
if (editingId !== session.id) return;
const handleDocMouseDown = (e: MouseEvent) => {
if (formRef.current && !formRef.current.contains(e.target as Node)) {
handleSaveEditRef.current();
handleSaveEditRef.current(renameDraftRef.current);
}
};
document.addEventListener('mousedown', handleDocMouseDown);
return () => document.removeEventListener('mousedown', handleDocMouseDown);
}, [editingId, session.id]);
React.useLayoutEffect(() => {
if (editingId !== session.id) {
if (renameTargetRef.current === session.id) {
renameTargetRef.current = null;
}
return;
}
if (renameTargetRef.current === session.id) return;
renameTargetRef.current = session.id;
setRenameDraft(editTitle);
}, [editingId, editTitle, session.id]);
if (editingId === session.id) {
return (
<div
@@ -496,24 +450,21 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
className="flex w-full items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
handleSaveEdit();
handleSaveEdit(renameDraft);
}}
>
<input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
autoFocus
placeholder={t('sessions.sidebar.session.menu.rename')}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') {
event.stopPropagation();
handleCancelEdit();
return;
}
if (event.key === ' ' || event.key === 'Enter') {
event.stopPropagation();
}
}}
/>
<button
@@ -1030,18 +981,18 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
) : (
<button
type="button"
onPointerDown={handleRowPointerDown}
onPointerUp={handleRowPointerEnd}
onPointerCancel={handleRowPointerEnd}
onMouseDown={handleRowMouseDown}
onClick={(event) => handleRowSelect(event)}
onPointerDown={handleRowPointerDown}
onPointerUp={handleRowPointerEnd}
onPointerCancel={handleRowPointerEnd}
onMouseDown={handleRowMouseDown}
onClick={(event) => handleRowSelect(event)}
onDoubleClick={(e) => {
e.stopPropagation();
handleSessionDoubleClick(session.id, sessionTitle);
}}
className={cn(
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none transition-[padding]',
isTouchPressed && 'bg-interactive-hover/70',
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none transition-[padding]',
isTouchPressed && 'bg-interactive-hover/70',
alwaysShowActions
? (isVSCode ? revealPaddingClass : alwaysActionPaddingClass)
: revealPaddingClass
@@ -1159,7 +1110,26 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
</ContextMenu.Root>
</DraggableSessionRow>
{hasChildren && isExpanded
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext))
? node.children.map((child): React.ReactNode => {
const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor
? childRenderExtrasFor(child)
: {
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: '',
};
return renderSessionNode(
child,
depth + 1,
sessionDirectory ?? groupDirectory,
projectId,
archivedBucket,
undefined,
renderContext,
childRenderExtras,
);
})
: null}
<Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}>
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
@@ -1213,4 +1183,194 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
);
}
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areEqual);
const getNodeSessionDirectory = (node: SessionNode): string | null => {
return normalizePath((node.session as Session & { directory?: string | null }).directory ?? null);
};
const isSecondaryMetaEqual = (prev?: SecondaryMeta | null, next?: SecondaryMeta | null): boolean => {
return (prev?.projectLabel ?? null) === (next?.projectLabel ?? null)
&& (prev?.branchLabel ?? null) === (next?.branchLabel ?? null);
};
const getMenuSessionIdFromKey = (props: Props): string | null => {
if (!props.openSidebarMenuKey) return null;
const bucketTag = props.archivedBucket ? 'archived' : 'active';
const prefix = `${props.renderContext ?? 'project'}:${bucketTag}:`;
return props.openSidebarMenuKey.startsWith(prefix)
? props.openSidebarMenuKey.slice(prefix.length)
: null;
};
const getRelevantMenuSessionId = (props: Props): string | null => {
return props.menuOpenSessionId ?? getMenuSessionIdFromKey(props);
};
const subtreeContainsSession = (
props: Props,
sessionId: string | null,
precomputed: Set<string>,
): boolean => {
if (!sessionId) return false;
if (precomputed.has(props.node.session.id)) return true;
return nodeContainsSessionId(props.node, sessionId);
};
const hasSetMembershipChangeInNode = (
prevNode: SessionNode,
nextNode: SessionNode,
prevSet: Set<string>,
nextSet: Set<string>,
getKey: (node: SessionNode) => string,
): boolean => {
if (prevNode.session.id !== nextNode.session.id) return true;
const key = getKey(prevNode);
if (prevSet.has(key) !== nextSet.has(key)) return true;
if (prevNode.children.length !== nextNode.children.length) return true;
for (let i = 0; i < prevNode.children.length; i += 1) {
if (hasSetMembershipChangeInNode(prevNode.children[i], nextNode.children[i], prevSet, nextSet, getKey)) {
return true;
}
}
return false;
};
const hasResolvedSessionChangeInNode = (
prevNode: SessionNode,
nextNode: SessionNode,
prevLiveSessionById: Map<string, Session>,
nextLiveSessionById: Map<string, Session>,
): boolean => {
if (prevNode.session.id !== nextNode.session.id) return true;
const sessionId = prevNode.session.id;
if ((prevLiveSessionById.get(sessionId) ?? prevNode.session) !== (nextLiveSessionById.get(sessionId) ?? nextNode.session)) {
return true;
}
if (prevNode.children.length !== nextNode.children.length) return true;
for (let i = 0; i < prevNode.children.length; i += 1) {
if (hasResolvedSessionChangeInNode(prevNode.children[i], nextNode.children[i], prevLiveSessionById, nextLiveSessionById)) {
return true;
}
}
return false;
};
const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false;
const prevBucketTag = prev.archivedBucket ? 'archived' : 'active';
const nextBucketTag = next.archivedBucket ? 'archived' : 'active';
return hasSetMembershipChangeInNode(
prev.node,
next.node,
prev.expandedParents,
next.expandedParents,
(node) => `${prev.renderContext ?? 'project'}:${prevBucketTag}:${node.session.id}`,
) || hasSetMembershipChangeInNode(
prev.node,
next.node,
prev.expandedParents,
next.expandedParents,
(node) => `${next.renderContext ?? 'project'}:${nextBucketTag}:${node.session.id}`,
);
};
const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
if (prev.node.session.id !== next.node.session.id) return false;
if (prev.depth !== next.depth) return false;
if (prev.groupDirectory !== next.groupDirectory) return false;
if (prev.projectId !== next.projectId) return false;
if (prev.archivedBucket !== next.archivedBucket) return false;
if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
if (prev.mobileVariant !== next.mobileVariant) return false;
if (prev.alwaysShowActions !== next.alwaysShowActions) return false;
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
if (prev.nodeStructureKey !== next.nodeStructureKey) return false;
if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false;
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false;
if (prev.liveSessionById !== next.liveSessionById
&& hasResolvedSessionChangeInNode(prev.node, next.node, prev.liveSessionById, next.liveSessionById)) {
return false;
}
if (prev.pinnedSessionIds !== next.pinnedSessionIds
&& hasSetMembershipChangeInNode(prev.node, next.node, prev.pinnedSessionIds, next.pinnedSessionIds, (node) => node.session.id)) {
return false;
}
if (prev.expandedParents !== next.expandedParents && hasExpansionMembershipChange(prev, next)) {
return false;
}
if (prev.currentSessionId !== next.currentSessionId
&& (
subtreeContainsSession(prev, prev.currentSessionId, prev.subtreeContainsActive)
|| subtreeContainsSession(next, next.currentSessionId, next.subtreeContainsActive)
)) {
return false;
}
if (prev.editingId !== next.editingId
&& (
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|| subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing)
)) {
return false;
}
if (prev.editTitle !== next.editTitle
&& (
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|| subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing)
)) {
return false;
}
if (prev.copiedSessionId !== next.copiedSessionId
&& (
nodeContainsSessionId(prev.node, prev.copiedSessionId)
|| nodeContainsSessionId(next.node, next.copiedSessionId)
)) {
return false;
}
if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) {
const prevMenuSessionId = getRelevantMenuSessionId(prev);
const nextMenuSessionId = getRelevantMenuSessionId(next);
if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) {
return false;
}
}
if (prev.renamingFolderId !== next.renamingFolderId) {
const prevMenuSessionId = getRelevantMenuSessionId(prev);
const nextMenuSessionId = getRelevantMenuSessionId(next);
if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) {
return false;
}
}
return prev.setEditingId === next.setEditingId
&& prev.setEditTitle === next.setEditTitle
&& prev.handleSaveEdit === next.handleSaveEdit
&& prev.handleCancelEdit === next.handleCancelEdit
&& prev.toggleParent === next.toggleParent
&& prev.handleSessionSelect === next.handleSessionSelect
&& prev.handleSessionDoubleClick === next.handleSessionDoubleClick
&& prev.togglePinnedSession === next.togglePinnedSession
&& prev.handleShareSession === next.handleShareSession
&& prev.handleCopyShareUrl === next.handleCopyShareUrl
&& prev.handleUnshareSession === next.handleUnshareSession
&& prev.setOpenSidebarMenuKey === next.setOpenSidebarMenuKey
&& prev.getFoldersForScope === next.getFoldersForScope
&& prev.getSessionFolderId === next.getSessionFolderId
&& prev.removeSessionFromFolder === next.removeSessionFromFolder
&& prev.addSessionToFolder === next.addSessionToFolder
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
&& prev.openContextPanelTab === next.openContextPanelTab
&& prev.handleDeleteSession === next.handleDeleteSession
&& prev.renderSessionNode === next.renderSessionNode;
};
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areSessionNodeItemPropsEqual);
@@ -3,6 +3,12 @@ import { cn } from '@/lib/utils';
import type { SessionNode } from './types';
import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon";
import {
collectSubtreeContainingId,
computeNodeStructureKey,
resolveMenuOpenSessionId,
} from './sessionNodeItemUtils';
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
type ActivityItem = {
node: SessionNode;
@@ -22,17 +28,34 @@ type ActivitySection = {
type Props = {
sections: ActivitySection[];
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null, renderContext?: 'project' | 'recent') => React.ReactNode;
renderSessionNode: (
node: SessionNode,
depth?: number,
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket?: boolean,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeRenderExtras,
) => React.ReactNode;
currentSessionId: string | null;
editingId: string | null;
openSidebarMenuKey: string | null;
variant?: 'section' | 'flat';
initialVisibleCount?: number;
batchSize?: number;
};
type RenderExtras = SessionNodeRenderExtras;
const MAX_VISIBLE_RECENT_SESSIONS = 7;
export function SidebarActivitySections({
sections,
renderSessionNode,
currentSessionId,
editingId,
openSidebarMenuKey,
variant = 'section',
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
@@ -77,6 +100,36 @@ export function SidebarActivitySections({
});
}, [batchSize]);
const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => {
const subtreeContainsActive = new Set<string>();
collectSubtreeContainingId(nodes, currentSessionId, subtreeContainsActive);
const subtreeContainsEditing = new Set<string>();
collectSubtreeContainingId(nodes, editingId, subtreeContainsEditing);
const menuOpenSessionId = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, 'recent', false);
const nodeStructureKeyByNode = new WeakMap<SessionNode, string>();
const visit = (node: SessionNode): void => {
nodeStructureKeyByNode.set(node, computeNodeStructureKey(node));
node.children.forEach(visit);
};
nodes.forEach(visit);
const childRenderExtrasFor = (child: SessionNode): RenderExtras => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: nodeStructureKeyByNode.get(child) ?? '',
childRenderExtrasFor,
});
return (node: SessionNode): RenderExtras => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '',
childRenderExtrasFor,
});
}, [currentSessionId, editingId, openSidebarMenuKey]);
const visibleSections = sections.filter((section) => section.items.length > 0);
if (visibleSections.length === 0) {
return null;
@@ -93,11 +146,22 @@ export function SidebarActivitySections({
const visibleItems = section.items.slice(0, visibleLimit);
const remainingCount = section.items.length - visibleItems.length;
const canShowFewer = !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0;
const getRenderExtras = buildRenderExtras(visibleItems.map((item) => item.node));
const renderItem = (item: ActivityItem) => renderSessionNode(
item.node,
0,
item.groupDirectory,
item.projectId,
false,
item.secondaryMeta,
'recent',
getRenderExtras(item.node),
);
if (flatVariant) {
return (
<div key={section.key} className="space-y-0.5">
{visibleItems.map((item) => renderSessionNode(item.node, 0, item.groupDirectory, item.projectId, false, item.secondaryMeta, 'recent'))}
{visibleItems.map(renderItem)}
{remainingCount > 0 ? (
<button
type="button"
@@ -126,7 +190,7 @@ export function SidebarActivitySections({
</button>
{!isCollapsed ? (
<div className={cn('space-y-0.5 pl-7')}>
{visibleItems.map((item) => renderSessionNode(item.node, 0, item.groupDirectory, item.projectId, false, item.secondaryMeta, 'recent'))}
{visibleItems.map(renderItem)}
{remainingCount > 0 ? (
<button
type="button"
@@ -42,7 +42,15 @@ type Props = {
hasSessionSearchQuery: boolean;
emptyState: React.ReactNode;
searchEmptyState: React.ReactNode;
renderGroupSessions: (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => React.ReactNode;
renderGroupSessions: (
group: SessionGroup,
groupKey: string,
projectId?: string | null,
hideGroupLabel?: boolean,
dragHandleProps?: SortableDragHandleProps | null,
compactBodyPadding?: boolean,
scrollContainerRef?: React.RefObject<HTMLElement | null>,
) => React.ReactNode;
homeDirectory: string | null;
collapsedProjects: Set<string>;
hideDirectoryControls: boolean;
@@ -78,6 +86,39 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
// Threaded into SessionGroupSection so the archived-bucket virtualizer
// can resolve the scrolling ancestor synchronously (no getComputedStyle
// walk) and skip the cost of a style recalc on every render.
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
// Memoize the result of getOrderedGroups. The callback is stable
// (deps: [groupOrderByProject]) and `section.groups` is a stable
// reference from useSessionSidebarSections, but the caller discards
// the result on every render and the callback allocates a new array
// each time. With many projects and many sidebar re-renders this
// builds O(P) arrays per render. The cache returns the same array
// reference when the inputs haven't changed, so the downstream
// orderedGroups.filter/find work and any consumer-memoization see a
// stable reference.
const orderedGroupsCacheRef = React.useRef<Map<string, { groups: SessionGroup[]; ordered: SessionGroup[] }>>(new Map());
const cachedGetOrderedGroups = (projectId: string, groups: SessionGroup[]): SessionGroup[] => {
const cache = orderedGroupsCacheRef.current;
const hit = cache.get(projectId);
if (hit && hit.groups === groups) {
return hit.ordered;
}
const ordered = props.getOrderedGroups(projectId, groups);
cache.set(projectId, { groups, ordered });
// Bound the cache so re-ordering projects (which replaces the
// projects list and invalidates every projectId) doesn't grow
// unboundedly.
if (cache.size > 256) {
const firstKey = cache.keys().next().value;
if (firstKey !== undefined) cache.delete(firstKey);
}
return ordered;
};
if (props.sharedSessionsOnly) {
return (
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pr-2', props.mobileVariant ? '' : '')}>
@@ -96,7 +137,7 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
}
return (
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>
<ScrollableOverlay ref={scrollContainerRef} useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>
{props.topContent}
{props.showOnlyMainWorkspace ? (
<div className="space-y-[0.6rem] py-1">
@@ -124,7 +165,7 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
const hideGroupLabel = group.id === primaryGroup.id;
return (
<React.Fragment key={groupKey}>
{props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel, null, true)}
{props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel, null, true, scrollContainerRef)}
</React.Fragment>
);
});
@@ -158,7 +199,7 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
const isCollapsed = props.collapsedProjects.has(projectKey);
const isActiveProject = projectKey === props.activeProjectId;
const isRepo = props.projectRepoStatus.get(projectKey);
const orderedGroups = props.getOrderedGroups(projectKey, section.groups);
const orderedGroups = cachedGetOrderedGroups(projectKey, section.groups);
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
const nestedGroups = rootGroup
? orderedGroups.filter((group) => group.id !== rootGroup.id)
@@ -223,13 +264,13 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
});
}}
>
{rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true) : null}
{rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true, null, undefined, scrollContainerRef) : null}
<SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
{nestedGroups.map((group) => {
const groupKey = `${projectKey}:${group.id}`;
return (
<SortableGroupItem key={group.id} id={group.id} disabled={props.isInlineEditing}>
{(dragHandleProps) => props.renderGroupSessions(group, groupKey, projectKey, false, dragHandleProps)}
{(dragHandleProps) => props.renderGroupSessions(group, groupKey, projectKey, false, dragHandleProps, undefined, scrollContainerRef)}
</SortableGroupItem>
);
})}
@@ -61,6 +61,16 @@ export const useProjectRepoStatus = (args: Args): void => {
// any single project's branch settles (the old N² cascade).
const resolvedInputKeyByProjectId = React.useRef<Map<string, string>>(new Map());
// TTL cache: when a project's `gitRepoStatus` refreshes (it can fire on
// every background poll even if the branch is unchanged), skip the
// `getRootBranch` re-resolution if we resolved the same input within
// the TTL window. 5 minutes matches the polling interval that typically
// drives these refreshes, so we always serve cached results on
// background updates and only re-resolve on cold start or actual
// branch changes (those still invalidate via the input-key check).
const rootBranchCacheRef = React.useRef<Map<string, { branch: string; at: number }>>(new Map());
const ROOT_BRANCH_TTL_MS = 5 * 60_000;
React.useEffect(() => {
let cancelled = false;
@@ -73,13 +83,16 @@ export const useProjectRepoStatus = (args: Args): void => {
for (const id of resolvedInputKeyByProjectId.current.keys()) {
if (!validIds.has(id)) {
resolvedInputKeyByProjectId.current.delete(id);
rootBranchCacheRef.current.delete(id);
}
}
const now = Date.now();
const pending = normalizedProjects.filter((project) => {
const status = gitRepoStatus.get(project.normalizedPath);
if (status?.isGitRepo === false) {
resolvedInputKeyByProjectId.current.delete(project.id);
rootBranchCacheRef.current.delete(project.id);
return false;
}
if (status?.isGitRepo !== true || status.branch === null) {
@@ -88,7 +101,21 @@ export const useProjectRepoStatus = (args: Args): void => {
const currentBranch = status.branch.trim();
const currentInputKey = `${project.normalizedPath}\0${currentBranch}`;
const lastInputKey = resolvedInputKeyByProjectId.current.get(project.id);
return lastInputKey === undefined || lastInputKey !== currentInputKey;
if (lastInputKey === currentInputKey) {
// We've already resolved this exact (path, branch) pair.
// The TTL cache is just an extra protection for the case
// where the input key was reset by a transient blip —
// keep the existing map entry fresh so future re-renders
// hit the cache instead of refetching.
const cached = rootBranchCacheRef.current.get(project.id);
if (cached) cached.at = now;
return false;
}
// Same input? Serve from TTL cache if it's still warm.
if (lastInputKey !== undefined && now - (rootBranchCacheRef.current.get(project.id)?.at ?? 0) < ROOT_BRANCH_TTL_MS) {
return false;
}
return true;
});
if (pending.length === 0) {
@@ -113,6 +140,7 @@ export const useProjectRepoStatus = (args: Args): void => {
return;
}
const nowAfter = Date.now();
setProjectRootBranches((prev) => {
const next = new Map(prev);
resolved.forEach(({ id, branch }) => {
@@ -122,8 +150,11 @@ export const useProjectRepoStatus = (args: Args): void => {
});
return next;
});
resolved.forEach(({ id, inputKey }) => {
resolved.forEach(({ id, inputKey, branch }) => {
resolvedInputKeyByProjectId.current.set(id, inputKey);
if (branch) {
rootBranchCacheRef.current.set(id, { branch, at: nowAfter });
}
});
};
void run();
@@ -133,5 +164,9 @@ export const useProjectRepoStatus = (args: Args): void => {
cancelled = true;
clearTimeout(timer);
};
// ROOT_BRANCH_TTL_MS is a module-level constant; intentionally not
// in the deps array since it never changes during the component
// lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
};
@@ -5,11 +5,22 @@ import { dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '..
type WorktreeMeta = { path: string };
type NormalizedProject = { id: string; normalizedPath: string };
type Args = {
isVSCode: boolean;
sessions: Session[];
archivedSessions: Session[];
availableWorktreesByProject: Map<string, WorktreeMeta[]>;
/**
* The set of normalized projects the sidebar will render. Used in
* Layer 4.13 to precompute the allowed directory set so the per-row
* `sessionsByDirectory` Map only contains buckets the sidebar will
* actually consume. With 10 projects × 5 worktrees and 100 sessions
* per directory this drops the Map from N entries to the small
* subset the sidebar needs.
*/
normalizedProjects: NormalizedProject[];
};
export const useProjectSessionLists = (args: Args) => {
@@ -18,8 +29,32 @@ export const useProjectSessionLists = (args: Args) => {
sessions,
archivedSessions,
availableWorktreesByProject,
normalizedProjects,
} = args;
// Precompute the set of directories the sidebar will ever ask about:
// every project's normalized path plus the path of each registered
// worktree. Walking this set is O(P + W) per Sidebar render and lets
// us skip the bulk of `sessions` (whose directory is not associated
// with a known project) when building `sessionsByDirectory`.
const allowedDirectories = React.useMemo(() => {
const set = new Set<string>();
normalizedProjects.forEach((project) => {
if (project.normalizedPath) {
set.add(project.normalizedPath);
}
});
if (!isVSCode) {
for (const worktrees of availableWorktreesByProject.values()) {
for (const worktree of worktrees) {
const normalized = normalizePath(worktree.path);
if (normalized) set.add(normalized);
}
}
}
return set;
}, [normalizedProjects, availableWorktreesByProject, isVSCode]);
const sessionsByDirectory = React.useMemo(() => {
const next = new Map<string, Session[]>();
sessions.forEach((session) => {
@@ -27,13 +62,21 @@ export const useProjectSessionLists = (args: Args) => {
if (!directory) {
return;
}
// Skip sessions whose directory doesn't belong to any known
// project or worktree. Without this filter the Map grows with
// every session the server has ever seen, even ones for
// long-removed worktrees; the sidebar's downstream filters
// would then drop them anyway.
if (!allowedDirectories.has(directory)) {
return;
}
const collection = next.get(directory) ?? [];
collection.push(session);
next.set(directory, collection);
});
return next;
}, [sessions]);
}, [sessions, allowedDirectories]);
const getSessionsForProject = React.useCallback(
(project: { normalizedPath: string }) => {
@@ -22,11 +22,9 @@ type Args = {
openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void;
setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => void;
sessions: Session[];
worktreeMetadata: Map<string, { path?: string | null }>;
};
export const useProjectSessionSelection = (args: Args): { currentSessionDirectory: string | null } => {
export const useProjectSessionSelection = (args: Args): void => {
const {
projectSections,
activeProjectId,
@@ -39,8 +37,6 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
openNewSessionDraft,
setActiveMainTab,
setSessionSwitcherOpen,
sessions,
worktreeMetadata,
} = args;
const projectSessionMeta = React.useMemo(() => {
@@ -101,6 +97,7 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
if (previousActiveProjectRef.current === activeProjectId) {
return;
}
const section = projectSections.find((item) => item.project.id === activeProjectId);
if (!section) {
return;
@@ -173,20 +170,4 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
});
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
const currentSessionDirectory = React.useMemo(() => {
if (!currentSessionId) {
return null;
}
const metadataPath = worktreeMetadata.get(currentSessionId)?.path;
if (metadataPath) {
return normalizePath(metadataPath) ?? metadataPath;
}
const activeSession = sessions.find((session) => session.id === currentSessionId);
if (!activeSession) {
return null;
}
return normalizePath((activeSession as Session & { directory?: string | null }).directory ?? null);
}, [currentSessionId, sessions, worktreeMetadata]);
return { currentSessionDirectory };
};
@@ -105,9 +105,9 @@ export const useSessionActions = (args: Args) => {
args.setEditTitle(sessionTitle);
}, [args]);
const handleSaveEdit = React.useCallback(async () => {
const handleSaveEdit = React.useCallback(async (titleOverride?: string) => {
if (!args.editingId) return;
const trimmed = args.editTitle.trim();
const trimmed = (titleOverride ?? args.editTitle).trim();
if (trimmed) {
await args.updateSessionTitle(args.editingId, trimmed);
}
@@ -0,0 +1,237 @@
import React from 'react';
import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
type Args = {
isInlineEditing: boolean;
showDeletionDialog: boolean;
foldersMap: Record<string, SessionFolder[]>;
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
setBulkDeleteConfirm: React.Dispatch<React.SetStateAction<{
sessionCount: number;
archivedBucket: boolean;
} | null>>;
};
/**
* Bulk-action logic for the sidebar. The hot-path concern is that this
* hook subscribes to `useSessionMultiSelectStore` which can fire on
* every selection toggle and on every setRange/toggleSelected call
* but the rest of the Sidebar tree only needs the boolean
* `selectionModeEnabled` flag to decide whether to render the
* selection chrome.
*
* To keep that subscription narrow, the heavy work (folders lookup,
* DOM-attribute scanning for the active/archived scope, etc.) is
* deferred behind a `selectedIds.size > 0` check inside the hook
* itself, so toggling selection mode on/off does not force the
* downstream useMemo chain to re-evaluate when no rows are selected.
*/
export const useSidebarBulkActions = (args: Args) => {
const { t } = useI18n();
const {
isInlineEditing,
showDeletionDialog,
foldersMap,
addSessionsToFolder,
removeSessionsFromFolders,
createFolderAndStartRename,
archiveSessions,
deleteSessions,
setBulkDeleteConfirm,
} = args;
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
const selectedIdsSize = useSessionMultiSelectStore((state) => state.selectedIds.size);
const hasSelection = selectedIdsSize > 0;
const selectedIds = useSessionMultiSelectStore((state) => state.selectedIds);
const selectionScopeKey = useSessionMultiSelectStore((state) => state.scopeKey);
const handleToggleSelectionMode = React.useCallback(() => {
useSessionMultiSelectStore.getState().toggleMode();
}, []);
const handleExitSelectionMode = React.useCallback(() => {
useSessionMultiSelectStore.getState().disable();
}, []);
// All of the below short-circuit on `hasSelection` so the DOM-scanning
// and folder-lookup work only runs when there's something to act on.
const bulkScopeIsArchived = React.useMemo(() => {
if (!hasSelection) return false;
if (typeof document === 'undefined') return false;
let sawActive = false;
let sawArchived = false;
for (const id of selectedIds) {
const rows = document.querySelectorAll<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
for (const row of rows) {
if (row.getAttribute('data-session-archived') === '1') sawArchived = true;
else sawActive = true;
}
}
return sawArchived && !sawActive;
}, [hasSelection, selectedIds]);
const derivedSelectionScope = React.useMemo(() => {
if (selectionScopeKey) return selectionScopeKey;
if (!hasSelection) return null;
if (typeof document === 'undefined') return null;
for (const id of selectedIds) {
const row = document.querySelector<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
const scope = row?.getAttribute('data-session-scope');
if (scope && scope.length > 0) return scope;
}
return null;
}, [hasSelection, selectedIds, selectionScopeKey]);
const bulkScopeFolders = React.useMemo(() => {
if (!derivedSelectionScope) return [];
return foldersMap[derivedSelectionScope] ?? [];
}, [foldersMap, derivedSelectionScope]);
const bulkCanRemoveFromFolder = React.useMemo(() => {
if (!derivedSelectionScope || !hasSelection) return false;
const scopeFolders = foldersMap[derivedSelectionScope] ?? [];
for (const folder of scopeFolders) {
for (const id of folder.sessionIds) {
if (selectedIds.has(id)) return true;
}
}
return false;
}, [foldersMap, derivedSelectionScope, hasSelection, selectedIds]);
const handleBulkMoveToFolder = React.useCallback((folderId: string) => {
if (!derivedSelectionScope || !hasSelection) return;
addSessionsToFolder(derivedSelectionScope, folderId, Array.from(selectedIds));
}, [addSessionsToFolder, selectedIds, derivedSelectionScope, hasSelection]);
const handleBulkCreateFolderAndMove = React.useCallback(() => {
if (!derivedSelectionScope || !hasSelection) return;
const newFolder = createFolderAndStartRename(derivedSelectionScope);
if (!newFolder) return;
addSessionsToFolder(derivedSelectionScope, newFolder.id, Array.from(selectedIds));
}, [addSessionsToFolder, createFolderAndStartRename, selectedIds, derivedSelectionScope, hasSelection]);
const handleBulkRemoveFromFolder = React.useCallback(() => {
if (!derivedSelectionScope || !hasSelection) return;
removeSessionsFromFolders(derivedSelectionScope, Array.from(selectedIds));
}, [removeSessionsFromFolders, selectedIds, derivedSelectionScope, hasSelection]);
const executeBulkDelete = React.useCallback(async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
if (bulkScopeIsArchived) {
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) {
toast.success(deletedIds.length === 1
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }));
}
} else {
const { archivedIds, failedIds } = await archiveSessions(ids);
if (archivedIds.length > 0) {
toast.success(archivedIds.length === 1
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
: t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
}
}
useSessionMultiSelectStore.getState().clear();
}, [archiveSessions, bulkScopeIsArchived, deleteSessions, selectedIds, t]);
const handleBulkDelete = React.useCallback(() => {
if (!hasSelection) return;
const count = selectedIds.size;
if (!showDeletionDialog) {
void executeBulkDelete();
return;
}
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog, setBulkDeleteConfirm, hasSelection]);
const confirmBulkDelete = React.useCallback(async () => {
setBulkDeleteConfirm(null);
await executeBulkDelete();
// setBulkDeleteConfirm is a stable React state setter; intentionally
// omitted from deps to avoid forcing the keyboard-listener effect
// below to re-subscribe on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [executeBulkDelete]);
React.useEffect(() => {
if (!selectionModeEnabled) return;
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || '');
const listener = (event: KeyboardEvent) => {
if (isInlineEditing) return;
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
const modifier = isMac ? event.metaKey : event.ctrlKey;
if (event.key === 'Escape') {
event.preventDefault();
useSessionMultiSelectStore.getState().disable();
return;
}
if (modifier && event.key === 'Backspace') {
event.preventDefault();
handleBulkDelete();
return;
}
if (modifier && (event.key === 'a' || event.key === 'A')) {
const rows = typeof document !== 'undefined'
? Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'))
: [];
if (rows.length === 0) return;
event.preventDefault();
const currentScope = useSessionMultiSelectStore.getState().scopeKey;
const targetScope = currentScope
?? rows[0]?.getAttribute('data-session-scope')
?? null;
const scopeFilter = (el: HTMLElement): boolean => {
if (!targetScope) return true;
return el.getAttribute('data-session-scope') === targetScope;
};
const ids = rows
.filter(scopeFilter)
.map((el) => el.getAttribute('data-session-row'))
.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (ids.length === 0) return;
useSessionMultiSelectStore.getState().replaceAll(ids, targetScope || null);
}
};
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}, [handleBulkDelete, isInlineEditing, selectionModeEnabled]);
return {
selectionModeEnabled,
hasSelection,
selectedIdsSize,
bulkScopeIsArchived,
derivedSelectionScope,
bulkScopeFolders,
bulkCanRemoveFromFolder,
handleToggleSelectionMode,
handleExitSelectionMode,
handleBulkMoveToFolder,
handleBulkCreateFolderAndMove,
handleBulkRemoveFromFolder,
handleBulkDelete,
confirmBulkDelete,
};
};
@@ -0,0 +1,120 @@
import type { SessionNode } from './types';
/**
* Per-row render extras precomputed once per group render and threaded down to
* each `SessionNodeItem`. Hoisting these out of the row `React.memo` comparator
* turns an O(rows × subtree-depth) walk into per-row `Set.has`/string compares.
*
* The child variant intentionally omits `childRenderExtrasFor` the resolver is
* shared from the group and re-passed, so it does not need to recurse through
* each child's extras object.
*/
export type SessionNodeChildRenderExtras = {
subtreeContainsActive: Set<string>;
subtreeContainsEditing: Set<string>;
menuOpenSessionId: string | null;
nodeStructureKey: string;
};
export type SessionNodeRenderExtras<TNode = SessionNode> = SessionNodeChildRenderExtras & {
childRenderExtrasFor?: (child: TNode) => SessionNodeChildRenderExtras;
};
/**
* Walk `nodes` and add `node.session.id` to `result` for every node
* whose subtree contains `targetId`. This is used to precompute, once
* per SessionGroupSection render, which rows need to update when
* `currentSessionId` or `editingId` changes. With M visible rows, this
* turns an O(M × subtree-depth) walk inside `SessionNodeItem.areEqual`
* into a single O(M) `Set.has` per row.
*/
export const collectSubtreeContainingId = (
nodes: SessionNode[],
targetId: string | null,
result: Set<string>,
): void => {
if (!targetId) return;
const visit = (node: SessionNode): boolean => {
let containsTarget = node.session.id === targetId;
for (const child of node.children) {
containsTarget = visit(child) || containsTarget;
}
if (containsTarget) {
result.add(node.session.id);
}
return containsTarget;
};
for (const node of nodes) {
visit(node);
}
};
export const nodeContainsSessionId = (node: SessionNode, sessionId: string | null): boolean => {
if (!sessionId) {
return false;
}
if (node.session.id === sessionId) {
return true;
}
for (const child of node.children) {
if (nodeContainsSessionId(child, sessionId)) {
return true;
}
}
return false;
};
/**
* Build a structural key for `node` that encodes the IDs of all
* descendants. Used by `SessionNodeItem.areEqual` so a reference-only
* rebuild of the tree (which happens on every `buildGroupedSessions`
* pass) can be detected with a single string compare instead of a
* recursive walk per row.
*/
export const computeNodeStructureKey = (node: SessionNode): string => {
if (node.children.length === 0) {
return '';
}
const childKeys = node.children.map((child) => {
if (child.children.length === 0) {
return child.session.id;
}
return `${child.session.id}:${computeNodeStructureKey(child)}`;
});
return childKeys.join('|');
};
/**
* Resolve the session id whose sidebar menu is open, or null if no
* menu is open. Only one row can have its menu open at a time.
*/
export const resolveMenuOpenSessionId = (
nodes: SessionNode[],
menuKey: string | null,
renderContext: 'project' | 'recent',
archivedBucket: boolean,
): string | null => {
if (!menuKey) return null;
const bucketTag = archivedBucket ? 'archived' : 'active';
let result: string | null = null;
const visit = (node: SessionNode): boolean => {
const nodeMenuKey = `${renderContext}:${bucketTag}:${node.session.id}`;
if (nodeMenuKey === menuKey) {
result = node.session.id;
return true;
}
for (const child of node.children) {
if (visit(child)) return true;
}
return false;
};
nodes.forEach((node) => visit(node));
return result;
};
@@ -140,10 +140,11 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
el.addEventListener("scroll", handleScroll, { passive: true });
resizeObserver?.observe(el);
// checkOverflow mutates our data-scroll attributes; observing attributes
// would make the component trigger its own observer indefinitely.
mutationObserver?.observe(el, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
});
+6
View File
@@ -3,6 +3,12 @@
@import "./styles/design-system.css";
@import "./styles/typography.css";
@import "./styles/mobile.css";
/* Load KaTeX styles eagerly with the main bundle. Importing them inside the
lazy MarkdownRendererImpl chunk meant a stylesheet was injected late on the
first markdown render of a session; that late injection triggered a style
recalc during which the layered `.chat-message-column` width clamp briefly
lost, flashing message text to full width before it snapped back. */
@import "katex/dist/katex.min.css";
@source "./**/*.{ts,tsx,css}";
/* KaTeX theme overrides for light/dark mode */
@@ -2,6 +2,8 @@ import { create } from 'zustand';
import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2';
import { opencodeClient } from '@/lib/opencode/client';
import { listGlobalSessionPages } from '@/stores/globalSessions';
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error';
@@ -14,6 +16,7 @@ type GlobalSessionsState = {
activeSessions: Session[];
archivedSessions: Session[];
sessionsByDirectory: Map<string, Session[]>;
reviewTransferBySessionId: Map<string, ReviewTransferDirection>;
hasLoaded: boolean;
status: GlobalSessionsStatus;
loadSessions: (fallbackActive?: Session[]) => Promise<LoadResult>;
@@ -297,11 +300,15 @@ const applySnapshot = (
const nextSessionsByDirectory = nextActiveSessions === state.activeSessions
? state.sessionsByDirectory
: buildSessionsByDirectory(nextActiveSessions);
const nextReviewTransferMap = nextActiveSessions === state.activeSessions
? state.reviewTransferBySessionId
: buildReviewTransferMap(nextActiveSessions);
if (
nextActiveSessions === state.activeSessions
&& nextArchivedSessions === state.archivedSessions
&& nextSessionsByDirectory === state.sessionsByDirectory
&& nextReviewTransferMap === state.reviewTransferBySessionId
&& state.hasLoaded
&& state.status === status
) {
@@ -312,15 +319,32 @@ const applySnapshot = (
activeSessions: nextActiveSessions,
archivedSessions: nextArchivedSessions,
sessionsByDirectory: nextSessionsByDirectory,
reviewTransferBySessionId: nextReviewTransferMap,
hasLoaded: true,
status,
};
};
const buildReviewTransferMap = (sessions: Session[]): Map<string, ReviewTransferDirection> => {
const next = new Map<string, ReviewTransferDirection>()
const activeIds = new Set(sessions.map((s) => s.id))
for (const session of sessions) {
const direction = getReviewTransferDirection(session)
if (!direction) continue
const targetSessionId = direction === 'review-to-original'
? getOriginalSessionID(session)
: getReviewSessionID(session)
if (!targetSessionId || !activeIds.has(targetSessionId)) continue
next.set(session.id, direction)
}
return next
}
export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) => ({
activeSessions: [],
archivedSessions: [],
sessionsByDirectory: new Map(),
reviewTransferBySessionId: new Map(),
hasLoaded: false,
status: 'idle',
@@ -424,6 +448,9 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
activeSessions: nextActiveSessions,
archivedSessions: nextArchivedSessions,
sessionsByDirectory: nextSessionsByDirectory,
reviewTransferBySessionId: nextActiveSessions === state.activeSessions
? state.reviewTransferBySessionId
: buildReviewTransferMap(nextActiveSessions),
};
});
@@ -458,6 +485,9 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
sessionsByDirectory: nextActiveSessions === state.activeSessions
? state.sessionsByDirectory
: buildSessionsByDirectory(nextActiveSessions),
reviewTransferBySessionId: nextActiveSessions === state.activeSessions
? state.reviewTransferBySessionId
: buildReviewTransferMap(nextActiveSessions),
};
});
},
@@ -483,6 +513,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
activeSessions: nextActiveSessions,
archivedSessions: nextArchivedSessions,
sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions),
reviewTransferBySessionId: buildReviewTransferMap(nextActiveSessions),
};
});
},
@@ -520,6 +551,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
activeSessions: nextActiveSessions,
archivedSessions: [...movedSessions, ...remainingArchivedSessions],
sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions),
reviewTransferBySessionId: buildReviewTransferMap(nextActiveSessions),
};
});
},
+15
View File
@@ -158,3 +158,18 @@
font-weight: var(--markdown-h4-font-weight);
}
}
/* Chat message column width intentionally UNLAYERED so it always wins the
cascade. The same rule inside `@layer components` above is correct in steady
state, but a layered rule can be momentarily out-ranked during a heavy style
recalc (e.g. a large session's virtualized list re-laying-out when the
background history prepend lands, or a late-loaded CSS chunk re-establishing
layer order). That dropped the clamp for a frame and flashed message text to
full width. An unlayered duplicate of just the width keeps it robust. */
.chat-message-column {
width: min(100%, 48rem);
}
.wide-chat-layout .chat-message-column {
width: min(100%, 64rem);
}
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import type { Session } from "@opencode-ai/sdk/v2"
import {
areRequestArraysReferentiallyEqual,
collectScopedBlockingRequests,
} from "./scoped-blocking-requests"
const session = (id: string, parentID?: string): Session => ({ id, parentID }) as Session
describe("scoped blocking requests", () => {
test("collects requests for the current session subtree", () => {
const rootRequest = { id: "perm_root" }
const childRequest = { id: "perm_child" }
const grandchildRequest = { id: "perm_grandchild" }
const siblingRequest = { id: "perm_sibling" }
const empty: Array<typeof rootRequest> = []
const result = collectScopedBlockingRequests(
[
session("ses_root"),
session("ses_child", "ses_root"),
session("ses_grandchild", "ses_child"),
session("ses_sibling"),
],
{
ses_root: [rootRequest],
ses_child: [childRequest],
ses_grandchild: [grandchildRequest],
ses_sibling: [siblingRequest],
},
"ses_root",
empty,
)
expect(result).toEqual([rootRequest, childRequest, grandchildRequest])
})
test("returns the provided empty array when no scoped requests exist", () => {
const empty: Array<{ id: string }> = []
expect(collectScopedBlockingRequests([session("ses_root")], {}, "ses_root", empty)).toBe(empty)
expect(collectScopedBlockingRequests([session("ses_root")], {}, null, empty)).toBe(empty)
})
test("compares request arrays by item identity", () => {
const first = { id: "perm_1" }
const second = { id: "perm_2" }
expect(areRequestArraysReferentiallyEqual([first, second], [first, second])).toBe(true)
expect(areRequestArraysReferentiallyEqual([first, second], [second, first])).toBe(false)
expect(areRequestArraysReferentiallyEqual([first], [{ id: "perm_1" }])).toBe(false)
})
})
@@ -0,0 +1,61 @@
import type { Session } from "@opencode-ai/sdk/v2"
type BlockingRequest = { id: string }
export const computeSubtreeIds = (sessions: Session[], rootId: string): Set<string> => {
const childrenByParent = new Map<string, string[]>()
for (const session of sessions) {
if (!session.parentID) continue
const list = childrenByParent.get(session.parentID) ?? []
list.push(session.id)
childrenByParent.set(session.parentID, list)
}
const ids = new Set<string>([rootId])
const queue = [rootId]
for (const id of queue) {
const children = childrenByParent.get(id)
if (!children) continue
for (const childId of children) {
if (ids.has(childId)) continue
ids.add(childId)
queue.push(childId)
}
}
return ids
}
export const areRequestArraysReferentiallyEqual = <T extends BlockingRequest>(left: T[], right: T[]): boolean => {
if (left === right) return true
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false
}
return true
}
export const collectScopedBlockingRequests = <T extends BlockingRequest>(
sessions: Session[],
requestsBySession: Record<string, T[] | undefined>,
sessionID: string | null,
empty: T[],
): T[] => {
if (!sessionID) return empty
const scopedIds = computeSubtreeIds(sessions, sessionID)
if (scopedIds.size === 0) return empty
const seen = new Set<string>()
const result: T[] = []
for (const id of scopedIds) {
const entries = requestsBySession[id]
if (!entries) continue
for (const entry of entries) {
if (seen.has(entry.id)) continue
seen.add(entry.id)
result.push(entry)
}
}
return result.length === 0 ? empty : result
}
+73 -2
View File
@@ -13,7 +13,10 @@ import { mergeSessionDirectoryMetadata, useGlobalSessionsStore } from "@/stores/
import { useConfigStore } from "@/stores/useConfigStore"
import { registerSessionDirectory } from "./sync-refs"
import { isSyntheticPart } from "@/lib/messages/synthetic"
import { materializeSessionSnapshots } from "./materialization"
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
import { retry } from "./retry"
import { isVSCodeRuntime } from "@/lib/desktop"
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
import { sessionEvents } from "@/lib/sessionEvents"
import {
@@ -24,7 +27,7 @@ import {
type SessionMetadataRecord,
} from "@/lib/sessionReviewMetadata"
const MESSAGE_REFETCH_LIMIT = 200
const MESSAGE_REFETCH_LIMIT = 100
const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const UNREVERT_REFETCH_ATTEMPTS = 3
const UNREVERT_REFETCH_RETRY_MS = 150
@@ -1037,3 +1040,71 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro
// Clear existing attachments and restore file parts from the forked message.
restoreFilePartsToInput(fileParts)
}
// ---------------------------------------------------------------------------
// Imperative fetch path — starts message loading on the same tick as
// setCurrentSession, before the React commit cycle fires useEffect.
// ---------------------------------------------------------------------------
const FETCH_MESSAGES_LOADING = new Set<string>()
const DESKTOP_INITIAL_PAGE_SIZE = 50
const CONSTRAINED_INITIAL_PAGE_SIZE = 30
const getFetchPageSize = () => {
if (isVSCodeRuntime() || isMobileSurfaceRuntime()) return CONSTRAINED_INITIAL_PAGE_SIZE
return DESKTOP_INITIAL_PAGE_SIZE
}
export async function fetchMessagesForSession(sessionID: string, directory?: string | null): Promise<void> {
const resolvedDir = directory ?? dir()
if (!resolvedDir) return
const s = sdk()
const store = directory
? dirStoreForDirectory(directory)
: dirStore()
if (getSessionMaterializationStatus(store.getState(), sessionID).renderable) return
const loadingKey = `${resolvedDir}:${sessionID}`
if (FETCH_MESSAGES_LOADING.has(loadingKey)) return
FETCH_MESSAGES_LOADING.add(loadingKey)
try {
const result = await retry(async () => {
const response = await s.session.messages({
sessionID,
directory: resolvedDir,
limit: getFetchPageSize(),
})
return response
})
const records = (assertSdkSuccess(result, "session.messages") ?? [])
.filter((record: { info?: { id?: string } }) => !!record?.info?.id)
if (records.length === 0) return
// Staleness guard: a rapid session switch may have moved the user off this
// session while the fetch was in flight. Skip the write so a slow fetch
// can't repopulate (and un-evict) a session already navigated away from.
if (useSessionUIStore.getState().currentSessionId !== sessionID) return
store.setState((state) => {
const materialized = materializeSessionSnapshots(
state,
sessionID,
records.map((record: { info: Message; parts?: Part[] }) => ({
info: stripMessageDiffSnapshots(record.info),
parts: record.parts ?? [],
})),
{ skipPartTypes: MESSAGE_REFETCH_SKIP_PARTS },
)
return { message: materialized.message, part: materialized.part }
})
} catch {
// Transient failure — the reactive path in ChatContainer will retry
} finally {
FETCH_MESSAGES_LOADING.delete(loadingKey)
}
}
@@ -0,0 +1,68 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import { buildSessionMessageRecordsSnapshot } from './sync-context';
import { INITIAL_STATE, type State } from './types';
const message = (id: string, role: 'user' | 'assistant', parentID?: string): Message => ({
id,
role,
sessionID: 'ses_1',
...(parentID ? { parentID } : {}),
time: { created: 1 },
} as Message);
const textPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
} as Part);
const state = (partial: Partial<State>): State => ({
...INITIAL_STATE,
...partial,
});
describe('buildSessionMessageRecordsSnapshot', () => {
test('only suspends part updates for the active streaming message', () => {
const user = message('user_1', 'user');
const assistant1 = message('assistant_1', 'assistant', 'user_1');
const assistant2 = message('assistant_2', 'assistant', 'user_1');
const messages = [user, assistant1, assistant2];
const assistant1InitialParts = [textPart('assistant_1_initial', 'initial')];
const assistant2InitialParts = [textPart('assistant_2_initial', 'initial')];
const previous = buildSessionMessageRecordsSnapshot(
state({
message: { ses_1: messages },
part: {
assistant_1: assistant1InitialParts,
assistant_2: assistant2InitialParts,
},
}),
'ses_1',
undefined,
true,
'assistant_1',
);
const assistant1FinalParts = [textPart('assistant_1_final', 'final')];
const assistant2LiveParts = [textPart('assistant_2_live', 'live')];
const next = buildSessionMessageRecordsSnapshot(
state({
message: { ses_1: messages },
part: {
assistant_1: assistant1FinalParts,
assistant_2: assistant2LiveParts,
},
}),
'ses_1',
previous,
true,
'assistant_2',
);
expect(next.byId.get('assistant_1')?.parts).toBe(assistant1FinalParts);
expect(next.byId.get('assistant_2')?.parts).toBe(assistant2InitialParts);
});
});
+8
View File
@@ -51,6 +51,7 @@ import {
revertToMessage as revertToMessageAction,
unrevertSession as unrevertSessionAction,
forkFromMessage as forkFromMessageAction,
fetchMessagesForSession,
} from "./session-actions"
import { useInputStore, type SyntheticContextPart } from "./input-store"
import { useSelectionStore } from "./selection-store"
@@ -497,6 +498,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null })
writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null })
// Kick off the message fetch on the same tick, before React commits the
// state change and fires ChatContainer.useEffect. The fetch is
// fire-and-forget — any transient failure gets retried by the reactive path.
if (id) {
void fetchMessagesForSession(id, resolvedDir)
}
try {
if (resolvedDir && directoryState.currentDirectory !== resolvedDir) {
directoryState.setDirectory(resolvedDir, { showOverlay: false })
+165 -58
View File
@@ -23,7 +23,7 @@ import { bootstrapGlobal, bootstrapDirectory } from "./bootstrap"
import { retry } from "./retry"
import { updateStreamingState } from "./streaming"
import { setActionRefs } from "./session-actions"
import { setSyncRefs } from "./sync-refs"
import { setSyncRefs, getAllSyncSessions } from "./sync-refs"
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
import { syncDebug } from "./debug"
import { getReconnectCandidateSessionIds } from "./reconnect-recovery"
@@ -47,6 +47,8 @@ import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
import { setSessionPrefetch } from "./session-prefetch-cache"
import { listGlobalSessionPages } from "@/stores/globalSessions"
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
// ---------------------------------------------------------------------------
// Context
@@ -260,6 +262,7 @@ async function materializeSessionFromServer(
directory: string,
sessionID: string,
store: StoreApi<DirectoryStore>,
options?: { isStale?: () => boolean },
) {
const scopedClient = opencodeClient.getScopedSdkClient(directory)
const result = await retry(async () => {
@@ -278,6 +281,8 @@ async function materializeSessionFromServer(
complete: !cursor,
})
if (options?.isStale?.()) return
store.setState((state: DirectoryStore) => {
const materialized = materializeSessionSnapshots(
state,
@@ -326,12 +331,12 @@ type UiNotificationPayload = {
body?: unknown
tag?: unknown
kind?: unknown
sessionId?: unknown
directory?: unknown
requireHidden?: unknown
desktopNotificationDelivered?: unknown
desktopStdoutActive?: unknown
}
sessionId?: unknown
directory?: unknown
requireHidden?: unknown
desktopNotificationDelivered?: unknown
desktopStdoutActive?: unknown
}
const asOptionalString = (value: unknown): string | undefined => {
if (typeof value !== "string") return undefined
@@ -350,9 +355,9 @@ const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): b
}
const notification = properties as UiNotificationPayload
if ((notification.desktopNotificationDelivered === true || notification.desktopStdoutActive === true) && getRuntimeKey() === "local") {
return true
}
if ((notification.desktopNotificationDelivered === true || notification.desktopStdoutActive === true) && getRuntimeKey() === "local") {
return true
}
const notifications = getRegisteredRuntimeAPIs()?.notifications
if (!notifications?.notifyAgentCompletion) {
@@ -1657,10 +1662,10 @@ export function SyncProvider(props: {
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
}
},
global: {
config: globalState.config,
projects: globalState.projects,
},
global: {
config: globalState.config,
projects: globalState.projects,
},
loadSessions: (dir) => retry(async () => {
const rootSessions = (await listGlobalSessionPages(props.sdk, {
directory: dir,
@@ -1751,11 +1756,11 @@ export function SyncProvider(props: {
const generation = ++globalBootstrapGeneration
bootingRoot = true
const globalActions = useGlobalSyncStore.getState().actions
bootstrapGlobal(props.sdk, (patch) => {
if (globalBootstrapGeneration === generation) {
globalActions.set(patch)
}
})
bootstrapGlobal(props.sdk, (patch) => {
if (globalBootstrapGeneration === generation) {
globalActions.set(patch)
}
})
.then(() => {
if (globalBootstrapGeneration === generation) {
bootedAt = Date.now()
@@ -1771,7 +1776,7 @@ export function SyncProvider(props: {
bootingRoot = false
}
}
}, [props.sdk])
}, [props.sdk])
// Event pipeline — created once per mount. No class, no start/stop.
// Abort controller owned by the pipeline closure. Cleanup aborts + flushes.
@@ -2177,6 +2182,77 @@ export function useSessions(directory?: string) {
)
}
const selectPermissionRequestsBySession = (state: State) => state.permission
const selectQuestionRequestsBySession = (state: State) => state.question
type ScopedBlockingRequestCache<T extends { id: string }> = {
sessionID: string | null
sessions: Session[] | null
requestsBySession: Record<string, T[] | undefined> | null
result: T[]
}
function useScopedBlockingRequests<T extends { id: string }>(
sessionID: string | null,
directory: string | undefined,
selectRequestsBySession: (state: State) => Record<string, T[] | undefined>,
empty: T[],
): T[] {
const cacheRef = useRef<ScopedBlockingRequestCache<T>>({
sessionID: null,
sessions: null,
requestsBySession: null,
result: empty,
})
return useDirectorySync(
useCallback((state: State) => {
const requestsBySession = selectRequestsBySession(state)
const cache = cacheRef.current
if (
cache.sessionID === sessionID
&& cache.sessions === state.session
&& cache.requestsBySession === requestsBySession
) {
return cache.result
}
const next = collectScopedBlockingRequests(state.session, requestsBySession, sessionID, empty)
const result = areRequestArraysReferentiallyEqual(cache.result, next) ? cache.result : next
cacheRef.current = {
sessionID,
sessions: state.session,
requestsBySession,
result,
}
return result
}, [empty, selectRequestsBySession, sessionID]),
directory,
)
}
export function useScopedBlockingPermissions(sessionID: string | null, directory?: string): PermissionRequest[] {
return useScopedBlockingRequests(sessionID, directory, selectPermissionRequestsBySession, EMPTY_PERMISSION_REQUESTS)
}
export function useScopedBlockingQuestions(sessionID: string | null, directory?: string): QuestionRequest[] {
return useScopedBlockingRequests(sessionID, directory, selectQuestionRequestsBySession, EMPTY_QUESTION_REQUESTS)
}
export function useParentSession(sessionID: string | null, directory?: string): Session | null {
return useDirectorySync(
useCallback((state: State) => {
if (!sessionID) return null
const current = state.session.find((s) => s.id === sessionID)
if (!current?.parentID) return null
return state.session.find((s) => s.id === current.parentID)
?? getAllSyncSessions().find((s) => s.id === current.parentID)
?? null
}, [sessionID]),
directory,
)
}
const getSidebarSessionSignature = (session: Session, stableUpdatedAt: number): string => {
const directory = (session as Session & { directory?: string | null }).directory ?? ''
const parentID = (session as Session & { parentID?: string | null }).parentID ?? ''
@@ -2348,14 +2424,6 @@ const getConcatenatedTextFromParts = (parts: Part[]): string => {
return text
}
const getFirstTextFromParts = (parts: Part[]): string => {
for (const part of parts) {
const text = getPartText(part)
if (text.length > 0) return text
}
return ""
}
type SessionMessageRecord = { info: Message; parts: Part[] }
const EMPTY_SESSION_MESSAGE_RECORDS: SessionMessageRecord[] = []
@@ -2365,6 +2433,7 @@ type SessionMessageRecordsSnapshot = {
visibleMessages: Message[]
revertMessageID?: string
suspendPartUpdates: boolean
suspendedPartUpdatesMessageID?: string
list: SessionMessageRecord[]
byId: Map<string, SessionMessageRecord>
}
@@ -2376,8 +2445,12 @@ const MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX = 4
const MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES = 30
const sessionMessageRecordsCache = new WeakMap<StoreApi<DirectoryStore>, Map<string, SessionMessageRecordsSnapshot>>()
const getSessionMessageRecordsCacheKey = (sessionID: string, suspendPartUpdates: boolean): string => (
`${sessionID}\u0000${suspendPartUpdates ? 1 : 0}`
const getSessionMessageRecordsCacheKey = (
sessionID: string,
suspendPartUpdates: boolean,
suspendedPartUpdatesMessageID?: string,
): string => (
`${sessionID}\u0000${suspendPartUpdates ? 1 : 0}\u0000${suspendedPartUpdatesMessageID ?? ""}`
)
const getSessionMessageRecordsCache = (store: StoreApi<DirectoryStore>): Map<string, SessionMessageRecordsSnapshot> => {
@@ -2393,10 +2466,11 @@ const readCachedSessionMessageRecordsSnapshot = (
store: StoreApi<DirectoryStore>,
sessionID: string,
suspendPartUpdates: boolean,
suspendedPartUpdatesMessageID?: string,
): SessionMessageRecordsSnapshot | undefined => {
const cache = sessionMessageRecordsCache.get(store)
if (!cache) return undefined
const key = getSessionMessageRecordsCacheKey(sessionID, suspendPartUpdates)
const key = getSessionMessageRecordsCacheKey(sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID)
const cached = cache.get(key)
if (!cached) return undefined
cache.delete(key)
@@ -2410,7 +2484,11 @@ const rememberSessionMessageRecordsSnapshot = (
): void => {
if (!snapshot.sessionID) return
const cache = getSessionMessageRecordsCache(store)
const key = getSessionMessageRecordsCacheKey(snapshot.sessionID, snapshot.suspendPartUpdates)
const key = getSessionMessageRecordsCacheKey(
snapshot.sessionID,
snapshot.suspendPartUpdates,
snapshot.suspendedPartUpdatesMessageID,
)
const constrainedMaxMessages = isVSCodeRuntime()
? VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES
: isMobileSurfaceRuntime()
@@ -2442,17 +2520,23 @@ export function dropCachedSessionMessageRecordsSnapshots(
if (!cache) return
for (const sessionID of sessionIDs) {
if (!sessionID) continue
cache.delete(getSessionMessageRecordsCacheKey(sessionID, false))
cache.delete(getSessionMessageRecordsCacheKey(sessionID, true))
const prefix = `${sessionID}\u0000`
for (const key of [...cache.keys()]) {
if (key.startsWith(prefix)) {
cache.delete(key)
}
}
}
}
const snapshotPartsMatchState = (snapshot: SessionMessageRecordsSnapshot, state: State): boolean => {
if (snapshot.suspendPartUpdates) {
return true
}
for (const record of snapshot.list) {
if (snapshot.suspendPartUpdates) {
const suspendedID = snapshot.suspendedPartUpdatesMessageID
if (!suspendedID || record.info.id === suspendedID) {
continue
}
}
if ((state.part[record.info.id] ?? EMPTY_PARTS) !== record.parts) {
return false
}
@@ -2466,8 +2550,9 @@ const getReusableSessionMessageRecordsSnapshot = (
state: State,
sessionID: string,
suspendPartUpdates: boolean,
suspendedPartUpdatesMessageID?: string,
): SessionMessageRecordsSnapshot | undefined => {
const cached = readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates)
const cached = readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID)
if (!cached) return undefined
const sourceMessages = state.message[sessionID] ?? EMPTY_MESSAGES
const session = state.session.find((candidate) => candidate.id === sessionID)
@@ -2476,6 +2561,7 @@ const getReusableSessionMessageRecordsSnapshot = (
cached.sourceMessages === sourceMessages
&& cached.revertMessageID === revertMessageID
&& cached.suspendPartUpdates === suspendPartUpdates
&& cached.suspendedPartUpdatesMessageID === suspendedPartUpdatesMessageID
&& snapshotPartsMatchState(cached, state)
) {
return cached
@@ -2516,12 +2602,16 @@ export function buildSessionMessageRecordsSnapshot(
sessionID: string,
previous?: SessionMessageRecordsSnapshot,
suspendPartUpdates = false,
suspendedPartUpdatesMessageID?: string,
): SessionMessageRecordsSnapshot {
const { sourceMessages, visibleMessages, revertMessageID } = getVisibleMessagesForSession(state, sessionID, previous)
const nextById = new Map<string, SessionMessageRecord>()
const nextList = visibleMessages.map((message) => {
const previousRecord = previous?.byId.get(message.id)
const parts = suspendPartUpdates && previousRecord
const shouldSuspendParts = suspendPartUpdates
&& previousRecord
&& (!suspendedPartUpdatesMessageID || message.id === suspendedPartUpdatesMessageID)
const parts = shouldSuspendParts
? previousRecord.parts
: (state.part[message.id] ?? EMPTY_PARTS)
@@ -2535,6 +2625,8 @@ export function buildSessionMessageRecordsSnapshot(
const unchanged = Boolean(previous)
&& previous?.visibleMessages === visibleMessages
&& previous.suspendPartUpdates === suspendPartUpdates
&& previous.suspendedPartUpdatesMessageID === suspendedPartUpdatesMessageID
&& previous.list.length === nextList.length
&& previous.list.every((record, index) => record === nextList[index])
@@ -2548,6 +2640,7 @@ export function buildSessionMessageRecordsSnapshot(
visibleMessages,
revertMessageID,
suspendPartUpdates,
suspendedPartUpdatesMessageID,
list: nextList,
byId: nextById,
}
@@ -2577,20 +2670,21 @@ export function useSessionTextMessages(sessionID: string, directory?: string): S
}
export function useUserMessageHistory(sessionID: string, directory?: string): string[] {
const records = useSessionMessageRecords(sessionID, directory)
const userMessages = useMemo(() => records.filter((record) => record.info.role === 'user'), [records])
const store = useDirectoryStore(directory)
const snapshotRef = useRef<UserMessageHistorySnapshot>(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT)
return useMemo(() => {
const history: string[] = []
for (let index = userMessages.length - 1; index >= 0; index -= 1) {
const message = userMessages[index]
const text = getFirstTextFromParts(message.parts)
if (text.length > 0) {
history.push(text)
}
}
return history
}, [userMessages])
const getSnapshot = useCallback(() => {
const next = buildUserMessageHistorySnapshot(store.getState(), sessionID, snapshotRef.current)
snapshotRef.current = next
return next.history
}, [sessionID, store])
const subscribe = useCallback((notify: () => void) => {
if (!sessionID) return () => undefined
return store.subscribe(notify)
}, [sessionID, store])
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
/**
@@ -2603,7 +2697,7 @@ export function useUserMessageHistory(sessionID: string, directory?: string): st
export function useSessionMessageRecords(
sessionID: string,
directory?: string,
options?: { suspendPartUpdates?: boolean },
options?: { suspendPartUpdates?: boolean; suspendPartUpdatesForMessageId?: string | null },
) {
const store = useDirectoryStore(directory)
const snapshotRef = useRef<SessionMessageRecordsSnapshot>({
@@ -2612,6 +2706,7 @@ export function useSessionMessageRecords(
visibleMessages: EMPTY_MESSAGES,
revertMessageID: undefined,
suspendPartUpdates: Boolean(options?.suspendPartUpdates),
suspendedPartUpdatesMessageID: options?.suspendPartUpdatesForMessageId ?? undefined,
list: [],
byId: new Map(),
})
@@ -2623,7 +2718,14 @@ export function useSessionMessageRecords(
const state = store.getState()
const suspendPartUpdates = Boolean(options?.suspendPartUpdates)
const reusableSnapshot = getReusableSessionMessageRecordsSnapshot(store, state, sessionID, suspendPartUpdates)
const suspendedPartUpdatesMessageID = options?.suspendPartUpdatesForMessageId ?? undefined
const reusableSnapshot = getReusableSessionMessageRecordsSnapshot(
store,
state,
sessionID,
suspendPartUpdates,
suspendedPartUpdatesMessageID,
)
if (reusableSnapshot) {
snapshotRef.current = reusableSnapshot
return reusableSnapshot.list
@@ -2631,18 +2733,19 @@ export function useSessionMessageRecords(
const previousSnapshot = snapshotRef.current.sessionID === sessionID
? snapshotRef.current
: readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates)
: readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID)
const nextSnapshot = buildSessionMessageRecordsSnapshot(
state,
sessionID,
previousSnapshot,
suspendPartUpdates,
suspendedPartUpdatesMessageID,
)
snapshotRef.current = nextSnapshot
rememberSessionMessageRecordsSnapshot(store, nextSnapshot)
return nextSnapshot.list
}, [options?.suspendPartUpdates, sessionID, store])
}, [options?.suspendPartUpdates, options?.suspendPartUpdatesForMessageId, sessionID, store])
const subscribe = useCallback((notify: () => void) => {
if (!sessionID) return () => undefined
@@ -2671,6 +2774,7 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string)
const syncDirectory = useSyncDirectory()
const resolvedDirectory = directory ?? syncDirectory
const store = useDirectoryStore(resolvedDirectory)
const requestGenerationRef = React.useRef(0)
React.useEffect(() => {
if (!sessionID) return
@@ -2685,11 +2789,14 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string)
// Already loading this session for this directory
if (_ensureMessagesLoading.has(loadingKey)) return
const generation = ++requestGenerationRef.current
const isStale = () => generation !== requestGenerationRef.current
_ensureMessagesLoading.add(loadingKey)
void (async () => {
try {
await materializeSessionFromServer(resolvedDirectory, sessionID, store)
await materializeSessionFromServer(resolvedDirectory, sessionID, store, { isStale })
} catch {
// Transient failure — next navigation or reconnect will retry
} finally {
+52 -10
View File
@@ -22,10 +22,11 @@ import {
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const INITIAL_MESSAGE_PAGE_SIZE = 150
const INITIAL_MESSAGE_PAGE_SIZE = 50
const VSCODE_INITIAL_MESSAGE_PAGE_SIZE = 30
const MOBILE_INITIAL_MESSAGE_PAGE_SIZE = 30
const HISTORY_MESSAGE_PAGE_SIZE = 200
const HISTORY_MESSAGE_PAGE_SIZE = 100
const INITIAL_PAGE_EXPANSION_LIMITS = [100, 150] as const
const VSCODE_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
const MAX_SEEN_DIRS = 30
const VSCODE_SESSION_CACHE_LIMIT = 4
@@ -40,6 +41,12 @@ const seenByDirectory = new Map<string, Set<string>>()
// all request the same session during startup; coalesce them into one HTTP load.
const syncSessionInflightByKey = new Map<string, Promise<void>>()
// Per-session generation counter. When a newer syncSession request starts for
// the same session, older in-flight requests become stale and must not write
// to the store. This prevents rapid session switches (e.g. 1→2→3 in the
// sidebar) from having each completed fetch fight for focus.
const syncSessionGenerationByKey = new Map<string, number>()
type SyncMeta = {
limit: number
cursor: string | undefined
@@ -88,6 +95,9 @@ const getInitialMessagePageSize = () => {
if (isMobileSurfaceRuntime()) return MOBILE_INITIAL_MESSAGE_PAGE_SIZE
return INITIAL_MESSAGE_PAGE_SIZE
}
const getInitialPageExpansionLimits = () => isConstrainedSessionRuntime()
? VSCODE_INITIAL_PAGE_EXPANSION_LIMITS
: INITIAL_PAGE_EXPANSION_LIMITS
const getDefaultMeta = (): SyncMeta => ({ limit: getInitialMessagePageSize(), cursor: undefined, complete: false, loading: false })
function getPrefetchMeta(directory: string, sessionID: string): SyncMeta | undefined {
@@ -333,7 +343,7 @@ export function useSync() {
// Load messages for a session
const loadMessages = useCallback(
async (sessionID: string, options?: { before?: string; mode?: "replace" | "prepend" }) => {
async (sessionID: string, options?: { before?: string; mode?: "replace" | "prepend"; isStale?: () => boolean }) => {
const m = getMetaFor(sessionID)
if (m.loading) return
setMetaFor(sessionID, { loading: true })
@@ -342,13 +352,13 @@ export function useSync() {
const limit = options?.before ? HISTORY_MESSAGE_PAGE_SIZE : m.limit
let page = await fetchMessages(sessionID, limit, options?.before)
// Constrained shells keep the initial page small for switch performance. Some
// sessions have a very large final turn, so the latest 30 records can
// Keep the initial page small for switch performance. Some sessions
// have a very large final turn, so the latest records can
// contain only assistant/tool records and no user boundary. That makes
// turn projection render an empty chat until the user manually loads
// older messages. Expand only this initial tail fetch, with a hard cap.
if (!options?.before && isConstrainedSessionRuntime() && !page.complete && !hasUserMessage(page.session)) {
for (const nextLimit of VSCODE_INITIAL_PAGE_EXPANSION_LIMITS) {
if (!options?.before && !page.complete && !hasUserMessage(page.session)) {
for (const nextLimit of getInitialPageExpansionLimits()) {
if (nextLimit <= limit) continue
page = await fetchMessages(sessionID, nextLimit)
if (page.complete || hasUserMessage(page.session)) break
@@ -362,6 +372,11 @@ export function useSync() {
clearOptimistic(sessionID, messageID)
}
if (options?.isStale?.()) {
setMetaFor(sessionID, { loading: false })
return
}
const current = store.getState()
const materialized = materializeSessionSnapshots(
current,
@@ -373,6 +388,11 @@ export function useSync() {
{ skipPartTypes: SKIP_PARTS, mode: options?.mode === "prepend" ? "prepend" : "merge" },
)
if (options?.isStale?.()) {
setMetaFor(sessionID, { loading: false })
return
}
setMetaFor(sessionID, {
limit: materialized.messages.length,
cursor: merged.cursor,
@@ -404,6 +424,13 @@ export function useSync() {
const existing = syncSessionInflightByKey.get(key)
if (existing) return existing
// This is a new request. Bump generation so any older request that
// might still be finishing (e.g. from a previous component lifecycle)
// knows it is stale and should not write to the store.
const generation = (syncSessionGenerationByKey.get(key) ?? 0) + 1
syncSessionGenerationByKey.set(key, generation)
const isStale = () => syncSessionGenerationByKey.get(key) !== generation
const current = store.getState()
const m = getMetaFor(sessionID)
const materialization = getSessionMaterializationStatus(current, sessionID)
@@ -449,7 +476,7 @@ export function useSync() {
assertSdkSuccess(response, "session.get")
return response
})
if (result.data) {
if (result.data && !isStale()) {
const nextSession = stripSessionDiffSnapshots(result.data)
const s = store.getState()
const sessions = [...s.session]
@@ -459,15 +486,30 @@ export function useSync() {
} else {
sessions.splice(idx.index, 0, nextSession)
}
store.setState({ session: sessions })
if (!isStale()) {
store.setState({ session: sessions })
}
}
} catch (e) {
console.error("[sync] failed to fetch session", sessionID, e)
}
})()
: Promise.resolve(),
shouldLoadMessages ? loadMessages(sessionID) : Promise.resolve(),
shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(),
])
// Progressive mount: after the initial page resolves, if the session
// isn't stale and the server indicated more messages, dispatch a
// second fetch to prepend older history. The user sees the first page
// immediately; the rest arrive shortly after. This gives the scroll
// container headroom above the viewport so the "load older on
// scroll-up" trigger fires before the user hits the absolute top.
if (!isStale()) {
const currentMeta = getMetaFor(sessionID)
if (currentMeta.cursor && !currentMeta.complete) {
loadMessages(sessionID, { before: currentMeta.cursor, mode: "prepend", isStale })
}
}
})()
syncSessionInflightByKey.set(key, promise)
@@ -0,0 +1,98 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from './types';
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot } from './user-message-history';
const message = (id: string, role: 'user' | 'assistant'): Message => ({
id,
role,
sessionID: 'ses_1',
time: { created: 1 },
} as Message);
const textPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
} as Part);
const state = (partial: Partial<State>): Pick<State, 'session' | 'message' | 'part'> => ({
session: [],
message: {},
part: {},
...partial,
});
describe('buildUserMessageHistorySnapshot', () => {
test('returns a shared empty snapshot without a session id', () => {
expect(buildUserMessageHistorySnapshot(state({}), '')).toBe(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT);
});
test('keeps history stable when assistant parts change', () => {
const user = message('user_1', 'user');
const assistant = message('assistant_1', 'assistant');
const userParts = [textPart('part_user', 'hello')];
const first = buildUserMessageHistorySnapshot(
state({
message: { ses_1: [user, assistant] },
part: { user_1: userParts, assistant_1: [textPart('part_a', 'stream')] },
}),
'ses_1',
);
const second = buildUserMessageHistorySnapshot(
state({
message: { ses_1: [user, assistant] },
part: { user_1: userParts, assistant_1: [textPart('part_a2', 'streaming')] },
}),
'ses_1',
first,
);
expect(second).toBe(first);
expect(second.history).toEqual(['hello']);
});
test('updates history when a user part changes', () => {
const user = message('user_1', 'user');
const first = buildUserMessageHistorySnapshot(
state({
message: { ses_1: [user] },
part: { user_1: [textPart('part_user', 'hello')] },
}),
'ses_1',
);
const second = buildUserMessageHistorySnapshot(
state({
message: { ses_1: [user] },
part: { user_1: [textPart('part_user_updated', 'updated')] },
}),
'ses_1',
first,
);
expect(second).not.toBe(first);
expect(second.history).toEqual(['updated']);
});
test('excludes user messages hidden by session revert state', () => {
const beforeRevert = message('user_1', 'user');
const reverted = message('user_2', 'user');
const snapshot = buildUserMessageHistorySnapshot(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_2' } } as State['session'][number]],
message: { ses_1: [beforeRevert, reverted] },
part: {
user_1: [textPart('part_user_1', 'kept')],
user_2: [textPart('part_user_2', 'reverted')],
},
}),
'ses_1',
);
expect(snapshot.history).toEqual(['kept']);
});
});
@@ -0,0 +1,98 @@
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from './types';
type UserMessageHistoryRecord = {
message: Message;
parts: Part[];
};
export type UserMessageHistorySnapshot = {
sessionID: string;
revertMessageID?: string;
records: UserMessageHistoryRecord[];
history: string[];
};
const EMPTY_PARTS: Part[] = [];
const EMPTY_RECORDS: UserMessageHistoryRecord[] = [];
const EMPTY_HISTORY: string[] = [];
export const EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT: UserMessageHistorySnapshot = {
sessionID: '',
revertMessageID: undefined,
records: EMPTY_RECORDS,
history: EMPTY_HISTORY,
};
const getPartText = (part: Part): string => {
if (part?.type !== 'text') return '';
const text = (part as { text?: unknown }).text;
return typeof text === 'string' ? text : '';
};
const getFirstTextFromParts = (parts: Part[]): string => {
for (const part of parts) {
const text = getPartText(part);
if (text.length > 0) return text;
}
return '';
};
const areRecordsEqual = (left: UserMessageHistoryRecord[], right: UserMessageHistoryRecord[]): boolean => {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index]?.message !== right[index]?.message || left[index]?.parts !== right[index]?.parts) {
return false;
}
}
return true;
};
export const buildUserMessageHistorySnapshot = (
state: Pick<State, 'session' | 'message' | 'part'>,
sessionID: string,
previous: UserMessageHistorySnapshot = EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT,
): UserMessageHistorySnapshot => {
if (!sessionID) {
return EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT;
}
const messages = state.message[sessionID] ?? [];
const session = state.session.find((candidate) => candidate.id === sessionID);
const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
const records: UserMessageHistoryRecord[] = [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role !== 'user') {
continue;
}
if (revertMessageID && message.id >= revertMessageID) {
continue;
}
records.push({
message,
parts: state.part[message.id] ?? EMPTY_PARTS,
});
}
if (records.length === 0) {
return previous.sessionID === sessionID && previous.revertMessageID === revertMessageID && previous.records.length === 0
? previous
: { sessionID, revertMessageID, records: EMPTY_RECORDS, history: EMPTY_HISTORY };
}
if (previous.sessionID === sessionID && previous.revertMessageID === revertMessageID && areRecordsEqual(previous.records, records)) {
return previous;
}
const history: string[] = [];
for (const record of records) {
const text = getFirstTextFromParts(record.parts);
if (text.length > 0) {
history.push(text);
}
}
return { sessionID, revertMessageID, records, history };
};