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:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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')
|
||||
|
||||
+6
-3
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user