Merge remote-tracking branch 'origin/main' into feat/nested-git-repos
# Conflicts: # packages/ui/src/lib/addSelectionToChat.test.ts
This commit is contained in:
@@ -220,7 +220,7 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const electron = spawnProcess('npx', ['electron', './main.mjs'], {
|
||||
const electron = spawnProcess('bun', ['x', 'electron', './main.mjs'], {
|
||||
cwd: electronDir,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@legendapp/list": "3.3.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
"@pierre/diffs": "1.3.0-beta.6",
|
||||
@@ -103,6 +104,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.0",
|
||||
"globals": "^16.3.0",
|
||||
"happy-dom": "^18.0.1",
|
||||
"nodemon": "^3.1.7",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { SessionActivityDuration } from '@/components/session/SessionActivityDuration';
|
||||
import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils';
|
||||
import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems';
|
||||
import { useSwitcherItems } from '@/components/session/sidebar/shell/useSwitcherItems';
|
||||
import { useTabletLayout } from '@/lib/device';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { resetStreamingState } from '@/sync/streaming';
|
||||
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||
import { useGlobalSessionStatusStore, replaceGlobalSessionStatusById } from '@/sync/global-session-status';
|
||||
import { resetSessionOrdering } from '@/sync/session-ordering';
|
||||
import { resetSessionActivityTiming } from '@/sync/session-activity-timing';
|
||||
import { syncDesktopSettings } from '@/lib/persistence';
|
||||
@@ -57,7 +57,7 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
||||
// Cross-project session list (mobile sessions sheet & co) belongs to the
|
||||
// previous instance — drop it so stale sessions can't linger after a switch.
|
||||
useGlobalSessionsStore.getState().resetForRuntimeSwitch();
|
||||
useGlobalSessionStatusStore.setState({ statusById: new Map() });
|
||||
replaceGlobalSessionStatusById(new Map());
|
||||
resetSessionOrdering();
|
||||
// Turn timings belong to the previous instance's sessions, and the reset also
|
||||
// restarts the resume window so the switch is treated as a fresh load.
|
||||
|
||||
@@ -18,8 +18,8 @@ import { StatusRowContainer } from './StatusRowContainer';
|
||||
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
|
||||
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
||||
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { useScrollShadow } from '@/components/ui/useScrollShadow';
|
||||
import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll';
|
||||
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
||||
import { TimelineDialog } from './TimelineDialog';
|
||||
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
||||
@@ -151,11 +151,15 @@ type ChatViewportProps = {
|
||||
currentSessionKey: string;
|
||||
isDesktopExpandedInput: boolean;
|
||||
isMobile: boolean;
|
||||
stickyUserHeader: boolean;
|
||||
directory?: string;
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
messageListRef: React.RefObject<MessageListHandle | null>;
|
||||
pendingRevealWork: boolean;
|
||||
registerList: (list: TimelineListHandle | null) => void;
|
||||
anchorMessageId: string | null;
|
||||
onAnchorReady: (messageId: string, anchorIndex: number) => void;
|
||||
onAnchorSizeChanged: (messageId: string) => void;
|
||||
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||
onTimelineDataChange: () => void;
|
||||
renderedMessages: SessionMessageRecord[];
|
||||
isLoadingOlder: boolean;
|
||||
sessionIsWorking: boolean;
|
||||
@@ -167,10 +171,11 @@ type ChatViewportProps = {
|
||||
confirmedAt?: number;
|
||||
fallbackTimestamp?: number;
|
||||
} | null;
|
||||
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
handleHistoryScroll: () => void;
|
||||
scrollToBottom: () => void;
|
||||
endPinningReleased: boolean;
|
||||
// One-shot fade for content that replaced the hydration skeleton;
|
||||
// cached sessions render instantly without it.
|
||||
revealContent: boolean;
|
||||
sessionQuestions: QuestionRequest[];
|
||||
sessionPermissions: PermissionRequest[];
|
||||
isProgrammaticFollowActive: boolean;
|
||||
@@ -190,21 +195,24 @@ const ChatViewport = React.memo(({
|
||||
currentSessionKey,
|
||||
isDesktopExpandedInput,
|
||||
isMobile,
|
||||
stickyUserHeader,
|
||||
directory,
|
||||
scrollRef,
|
||||
messageListRef,
|
||||
pendingRevealWork,
|
||||
registerList,
|
||||
anchorMessageId,
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
onIsAtEndChange,
|
||||
onTimelineDataChange,
|
||||
renderedMessages,
|
||||
isLoadingOlder,
|
||||
sessionIsWorking,
|
||||
streamingMessageId,
|
||||
activeStreamingPhase,
|
||||
retryOverlay,
|
||||
handleMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
handleHistoryScroll,
|
||||
scrollToBottom,
|
||||
endPinningReleased,
|
||||
revealContent,
|
||||
sessionQuestions,
|
||||
sessionPermissions,
|
||||
isProgrammaticFollowActive,
|
||||
@@ -315,83 +323,96 @@ const ChatViewport = React.memo(({
|
||||
scrollRef.current?.focus({ preventScroll: true });
|
||||
}, [scrollRef]);
|
||||
|
||||
// Everything that used to sit beside the list inside the scroll container
|
||||
// now renders as the list's header/footer, so it keeps scrolling with the
|
||||
// rows exactly as before.
|
||||
const listHeader = React.useMemo(() => (
|
||||
showLoadOlderButton ? (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onLoadOlder}
|
||||
disabled={isLoadingOlder}
|
||||
>
|
||||
{isLoadingOlder && (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
)}
|
||||
{t('chat.history.loadOlder')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
), [isLoadingOlder, onLoadOlder, showLoadOlderButton, t]);
|
||||
|
||||
const listFooter = React.useMemo(() => (
|
||||
<>
|
||||
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
|
||||
<div>
|
||||
{sessionQuestions.map((question) => (
|
||||
<QuestionCard key={question.id} question={question} />
|
||||
))}
|
||||
{sessionPermissions.map((permission) => (
|
||||
<PermissionCard key={permission.id} permission={permission} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
||||
|
||||
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
||||
</>
|
||||
), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]);
|
||||
|
||||
const scrollContainerProps = React.useMemo(() => ({
|
||||
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target',
|
||||
style: CHAT_SCROLL_STYLE,
|
||||
tabIndex: 0,
|
||||
onClick: focusScrollContainer,
|
||||
'data-scrollbar': 'chat',
|
||||
'data-scroll-shadow': 'true',
|
||||
'data-orientation': 'vertical',
|
||||
}), [focusScrollContainer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-0',
|
||||
isDesktopExpandedInput
|
||||
? 'absolute inset-0 opacity-0 pointer-events-none'
|
||||
: 'flex-1'
|
||||
: 'flex-1',
|
||||
revealContent && !isDesktopExpandedInput && 'oc-chat-hydration-reveal',
|
||||
)}
|
||||
aria-hidden={isDesktopExpandedInput}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<ScrollShadow
|
||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
||||
ref={scrollRef}
|
||||
style={CHAT_SCROLL_STYLE}
|
||||
observeMutations={false}
|
||||
hideTopShadow={isMobile && stickyUserHeader}
|
||||
tabIndex={0}
|
||||
onClick={focusScrollContainer}
|
||||
onScroll={handleHistoryScroll}
|
||||
data-scroll-shadow="true"
|
||||
data-scrollbar="chat"
|
||||
>
|
||||
<div className="relative z-0 min-h-full">
|
||||
{showLoadOlderButton && (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onLoadOlder}
|
||||
disabled={isLoadingOlder}
|
||||
>
|
||||
{isLoadingOlder && (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
)}
|
||||
{t('chat.history.loadOlder')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<MessageList
|
||||
key={currentSessionKey}
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
disableStaging={pendingRevealWork}
|
||||
messages={renderedMessages}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
activeStreamingMessageId={streamingMessageId}
|
||||
activeStreamingPhase={activeStreamingPhase}
|
||||
retryOverlay={retryOverlay}
|
||||
onMessageContentChange={handleMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
isLoadingOlder={isLoadingOlder}
|
||||
scrollToBottom={scrollToBottom}
|
||||
scrollRef={scrollRef}
|
||||
directory={directory}
|
||||
/>
|
||||
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
|
||||
<div>
|
||||
{sessionQuestions.map((question) => (
|
||||
<QuestionCard key={question.id} question={question} />
|
||||
))}
|
||||
{sessionPermissions.map((permission) => (
|
||||
<PermissionCard key={permission.id} permission={permission} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
||||
|
||||
<div className="mb-3">
|
||||
<StatusRowContainer />
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
||||
<MessageList
|
||||
key={currentSessionKey}
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
messages={renderedMessages}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
activeStreamingMessageId={streamingMessageId}
|
||||
activeStreamingPhase={activeStreamingPhase}
|
||||
retryOverlay={retryOverlay}
|
||||
isLoadingOlder={isLoadingOlder}
|
||||
scrollToBottom={scrollToBottom}
|
||||
endPinningReleased={endPinningReleased}
|
||||
directory={directory}
|
||||
registerList={registerList}
|
||||
anchorMessageId={anchorMessageId}
|
||||
onAnchorReady={onAnchorReady}
|
||||
onAnchorSizeChanged={onAnchorSizeChanged}
|
||||
// Zero end inset: the footer spacer already reserves the
|
||||
// zone the floating status row covers; adding its height
|
||||
// again produced a double-tall blank band at rest.
|
||||
composerOverlayHeight={0}
|
||||
onIsAtEndChange={onIsAtEndChange}
|
||||
onTimelineDataChange={onTimelineDataChange}
|
||||
listHeader={listHeader}
|
||||
listFooter={listFooter}
|
||||
scrollContainerProps={scrollContainerProps}
|
||||
/>
|
||||
<OverlayScrollbar containerRef={scrollRef} disableHorizontal suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
||||
{showPromptNavigator && promptTurnIds.length >= 2 ? (
|
||||
<PromptNavigatorRail
|
||||
turnIds={promptTurnIds}
|
||||
@@ -411,21 +432,18 @@ const ChatViewport = React.memo(({
|
||||
&& prev.currentSessionKey === next.currentSessionKey
|
||||
&& 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
|
||||
&& prev.renderedMessages === next.renderedMessages
|
||||
&& prev.isLoadingOlder === next.isLoadingOlder
|
||||
&& prev.sessionIsWorking === next.sessionIsWorking
|
||||
&& prev.streamingMessageId === next.streamingMessageId
|
||||
&& prev.activeStreamingPhase === next.activeStreamingPhase
|
||||
&& prev.retryOverlay === next.retryOverlay
|
||||
&& prev.handleMessageContentChange === next.handleMessageContentChange
|
||||
&& prev.getAnimationHandlers === next.getAnimationHandlers
|
||||
&& prev.handleHistoryScroll === next.handleHistoryScroll
|
||||
&& prev.scrollToBottom === next.scrollToBottom
|
||||
&& prev.endPinningReleased === next.endPinningReleased
|
||||
&& prev.revealContent === next.revealContent
|
||||
&& prev.sessionQuestions === next.sessionQuestions
|
||||
&& prev.sessionPermissions === next.sessionPermissions
|
||||
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
|
||||
@@ -799,6 +817,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
return () => setWorkStatusPanelVisible(false);
|
||||
}, [setWorkStatusPanelVisible, showWorkStatusPanel]);
|
||||
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
||||
// Session keys that showed the hydration skeleton this app run; their
|
||||
// content gets a one-shot reveal fade once it replaces the skeleton.
|
||||
const hydrationRevealKeyRef = React.useRef<string | null>(null);
|
||||
|
||||
const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
|
||||
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
|
||||
|
||||
@@ -891,23 +913,67 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
activeTurnChangeRef.current(turnId);
|
||||
}, []);
|
||||
|
||||
// The composer sits below the timeline, but the status/working row floats
|
||||
// OVER the timeline's bottom edge; its measured height keeps the live
|
||||
// streaming line above it and reserves matching end inset in the list.
|
||||
const [statusOverlayHeight, setStatusOverlayHeight] = React.useState(0);
|
||||
const composerOverlayHeight = statusOverlayHeight;
|
||||
const statusOverlayObserverRef = React.useRef<ResizeObserver | null>(null);
|
||||
const onStatusOverlayNode = React.useCallback((node: HTMLDivElement | null) => {
|
||||
statusOverlayObserverRef.current?.disconnect();
|
||||
statusOverlayObserverRef.current = null;
|
||||
if (!node || !globalThis.ResizeObserver) {
|
||||
setStatusOverlayHeight(0);
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
// +8 for the mb-2 gap between the row and the composer, which the
|
||||
// node's own box does not include.
|
||||
const height = node.getBoundingClientRect().height + 8;
|
||||
setStatusOverlayHeight((prev) => (Math.abs(prev - height) < 1 ? prev : height));
|
||||
};
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(node);
|
||||
statusOverlayObserverRef.current = observer;
|
||||
update();
|
||||
}, []);
|
||||
React.useEffect(() => () => {
|
||||
statusOverlayObserverRef.current?.disconnect();
|
||||
statusOverlayObserverRef.current = null;
|
||||
}, []);
|
||||
const lastUserMessageId = React.useMemo(() => {
|
||||
for (let index = sessionMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = sessionMessages[index];
|
||||
if (message.info.role === 'user') {
|
||||
return message.info.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [sessionMessages]);
|
||||
|
||||
const {
|
||||
scrollRef,
|
||||
notifyContentChange: handleMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
scrollNode,
|
||||
registerList,
|
||||
anchorMessageId,
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
onIsAtEndChange,
|
||||
onManualNavigation,
|
||||
onTimelineDataChange,
|
||||
goToBottom,
|
||||
scrollToBottomOnSend,
|
||||
releaseAutoFollow,
|
||||
restoreSnapshot,
|
||||
isPinned,
|
||||
isFollowingProgrammatically,
|
||||
showScrollButton,
|
||||
} = useChatAutoFollow({
|
||||
userOwnsScroll,
|
||||
} = useChatTimelineScroll({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
sessionMessageCount,
|
||||
sessionIsWorking,
|
||||
isMobile,
|
||||
composerOverlayHeight,
|
||||
lastUserMessageId,
|
||||
onActiveTurnChange: handleActiveTurnChange,
|
||||
});
|
||||
|
||||
@@ -922,33 +988,49 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
messageListRef,
|
||||
loadMoreMessages,
|
||||
goToBottom,
|
||||
releaseAutoFollow,
|
||||
releaseAutoFollow: onManualNavigation,
|
||||
isPinned,
|
||||
showScrollButton,
|
||||
});
|
||||
// The list owns the scroll element, so the shadows and the load-older
|
||||
// trigger bind to its node rather than to a wrapper we render.
|
||||
const scrollNodeRef = React.useMemo(() => ({ current: scrollNode }), [scrollNode]);
|
||||
useScrollShadow(scrollNodeRef, {
|
||||
observeMutations: false,
|
||||
hideTopShadow: isMobile && stickyUserHeader,
|
||||
});
|
||||
|
||||
const handleHistoryScroll = timelineController.handleHistoryScroll;
|
||||
React.useEffect(() => {
|
||||
if (!scrollNode) return;
|
||||
const onScroll = () => handleHistoryScroll();
|
||||
scrollNode.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
scrollNode.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [handleHistoryScroll, scrollNode]);
|
||||
|
||||
const resumeToLatestInstant = React.useCallback(() => {
|
||||
goToBottom('instant');
|
||||
}, [goToBottom]);
|
||||
|
||||
// Mobile loads older history via an explicit top button instead of a
|
||||
// scroll-position trigger (see handleHistoryScroll in the controller).
|
||||
const showLoadOlderButton = isMobileSurfaceRuntime()
|
||||
&& timelineController.historySignals.canLoadEarlier;
|
||||
const timelineLoadEarlier = timelineController.loadEarlier;
|
||||
const handleLoadOlderClick = React.useCallback(() => {
|
||||
// Loading older history is an explicit move INTO the past: release
|
||||
// live follow first, or the prepend's content growth would trigger an
|
||||
// end correction and throw the viewport to the bottom.
|
||||
onManualNavigation();
|
||||
void timelineLoadEarlier({ userInitiated: true });
|
||||
}, [timelineLoadEarlier]);
|
||||
}, [onManualNavigation, timelineLoadEarlier]);
|
||||
|
||||
React.useEffect(() => {
|
||||
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
||||
}, [timelineController.handleActiveTurnChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (sessionPermissions.length === 0 && sessionQuestions.length === 0) {
|
||||
return;
|
||||
}
|
||||
handleMessageContentChange('permission');
|
||||
}, [handleMessageContentChange, sessionPermissions, sessionQuestions]);
|
||||
|
||||
const navigation = useChatTurnNavigation({
|
||||
sessionId: currentSessionId,
|
||||
turnIds: timelineController.turnIds,
|
||||
@@ -958,7 +1040,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
resumeToBottom: timelineController.resumeToBottomInstant,
|
||||
});
|
||||
const handlePromptNavigatorSelect = React.useCallback((turnId: string) => {
|
||||
void navigation.scrollToTurnId(turnId, { behavior: 'smooth' });
|
||||
// Instant on purpose: a long smooth scroll through a virtualized
|
||||
// timeline gets cancelled by row remounts and lands mid-way or on the
|
||||
// wrong message; a teleport always arrives.
|
||||
void navigation.scrollToTurnId(turnId, { behavior: 'auto' });
|
||||
}, [navigation]);
|
||||
const canLoadEarlierPrompts = timelineController.historySignals.canLoadEarlier;
|
||||
const showPromptNavigator = !isMobile
|
||||
@@ -1071,6 +1156,15 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
const isSessionHydrating =
|
||||
Boolean(currentSessionId)
|
||||
&& !hasRenderableSessionSnapshot;
|
||||
React.useEffect(() => {
|
||||
if (isSessionHydrating || hydrationRevealKeyRef.current === null) return;
|
||||
// One-shot: forget the key after the reveal animation has played so a
|
||||
// later (now cached) visit to the same session opens instantly.
|
||||
const timer = setTimeout(() => {
|
||||
hydrationRevealKeyRef.current = null;
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isSessionHydrating, currentSessionKey]);
|
||||
const retrySessionLoad = React.useCallback(() => {
|
||||
if (!messagesEnabled || !currentSessionId) return;
|
||||
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
|
||||
@@ -1084,7 +1178,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
lastScrolledSessionKeyRef.current = currentSessionKey;
|
||||
if (hasHashTarget) {
|
||||
// Hash navigation handler will scroll to target; we just release auto-follow.
|
||||
releaseAutoFollow();
|
||||
onManualNavigation();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1096,7 +1190,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
} else {
|
||||
window.requestAnimationFrame(run);
|
||||
}
|
||||
}, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]);
|
||||
}, [active, currentSessionId, currentSessionKey, onManualNavigation, restoreSnapshot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!messagesEnabled || !currentSessionId) return;
|
||||
@@ -1191,7 +1285,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
return <DraftWelcome exiting={draftPresentationExiting} />;
|
||||
}
|
||||
|
||||
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
|
||||
const showHydrationSkeleton = isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking;
|
||||
if (showHydrationSkeleton) {
|
||||
hydrationRevealKeyRef.current = currentSessionKey ?? currentSessionId ?? null;
|
||||
}
|
||||
if (showHydrationSkeleton) {
|
||||
if (sessionMessageLoadState.status === 'error') {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
|
||||
@@ -1211,6 +1309,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-chat-hydration-skeleton=""
|
||||
className={cn(
|
||||
'relative min-h-0',
|
||||
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
|
||||
@@ -1265,21 +1364,24 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
|
||||
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||
isMobile={isMobile}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
directory={effectiveSessionDirectory}
|
||||
scrollRef={scrollRef}
|
||||
registerList={registerList}
|
||||
anchorMessageId={anchorMessageId}
|
||||
onAnchorReady={onAnchorReady}
|
||||
onAnchorSizeChanged={onAnchorSizeChanged}
|
||||
onIsAtEndChange={onIsAtEndChange}
|
||||
onTimelineDataChange={onTimelineDataChange}
|
||||
messageListRef={messageListRef}
|
||||
pendingRevealWork={timelineController.pendingRevealWork}
|
||||
renderedMessages={timelineController.renderedMessages}
|
||||
isLoadingOlder={timelineController.isLoadingOlder}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
streamingMessageId={streamingMessageId}
|
||||
activeStreamingPhase={activeStreamingPhase}
|
||||
retryOverlay={retryOverlay}
|
||||
handleMessageContentChange={handleMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
handleHistoryScroll={timelineController.handleHistoryScroll}
|
||||
scrollToBottom={resumeToLatestInstant}
|
||||
endPinningReleased={userOwnsScroll}
|
||||
revealContent={hydrationRevealKeyRef.current !== null && hydrationRevealKeyRef.current === (currentSessionKey ?? currentSessionId ?? null)}
|
||||
sessionQuestions={sessionQuestions}
|
||||
sessionPermissions={sessionPermissions}
|
||||
isProgrammaticFollowActive={isFollowingProgrammatically}
|
||||
@@ -1314,10 +1416,37 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
)}
|
||||
>
|
||||
{!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && (
|
||||
<ScrollToBottomButton
|
||||
visible={timelineController.showScrollToBottom}
|
||||
onClick={navigation.resumeToLatest}
|
||||
/>
|
||||
<>
|
||||
<ScrollToBottomButton
|
||||
visible={timelineController.showScrollToBottom}
|
||||
working={sessionIsWorking}
|
||||
onClick={navigation.resumeToLatest}
|
||||
/>
|
||||
{/* Same anchor and column as the pill, so the status
|
||||
row and the pill it hands off to share the exact
|
||||
distance from the input and the same left edge. */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
|
||||
userOwnsScroll && 'opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="chat-input-column">
|
||||
{/* The glass chip itself is rendered inside
|
||||
StatusRow (its root is a size container
|
||||
that cannot shrink-wrap). */}
|
||||
<div
|
||||
ref={onStatusOverlayNode}
|
||||
className={cn(
|
||||
'[&:not(:has(*))]:hidden',
|
||||
userOwnsScroll ? 'pointer-events-none' : 'pointer-events-auto',
|
||||
)}
|
||||
>
|
||||
<StatusRowContainer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{promptReadOnly ? (
|
||||
<ReadOnlyPromptBanner />
|
||||
@@ -1325,6 +1454,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
<ChatInput
|
||||
active={active}
|
||||
scrollToBottom={scrollToBottomOnSend}
|
||||
scrollToLatest={resumeToLatestInstant}
|
||||
draftPresentationExiting={draftPresentationExiting}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -48,7 +48,7 @@ import type { SnippetAutocompleteHandle } from './SnippetAutocomplete';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ModelControls } from './ModelControls';
|
||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||
import { StatusRow } from './StatusRow';
|
||||
import { ComposerStatusBar } from './ComposerStatusBar';
|
||||
import { PendingChangesBar } from './PendingChangesBar';
|
||||
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
||||
import { MobileAgentButton } from './MobileAgentButton';
|
||||
@@ -220,11 +220,15 @@ const MemoModelControls = React.memo(ModelControls);
|
||||
const MemoComposerDictation = React.memo(ComposerDictation);
|
||||
const MemoMobileAgentButton = React.memo(MobileAgentButton);
|
||||
const MemoMobileModelButton = React.memo(MobileModelButton);
|
||||
const MemoStatusRow = React.memo(StatusRow);
|
||||
const MemoComposerStatusBar = React.memo(ComposerStatusBar);
|
||||
|
||||
interface ChatInputProps {
|
||||
onOpenSettings?: () => void;
|
||||
scrollToBottom?: () => void;
|
||||
// Queued sends do not create a user row (the queue delivers later), so
|
||||
// the anchor-arming scrollToBottom is wrong for them; this returns the
|
||||
// viewport to the live edge instead.
|
||||
scrollToLatest?: () => void;
|
||||
active?: boolean;
|
||||
draftPresentationExiting?: boolean;
|
||||
}
|
||||
@@ -243,6 +247,7 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
|
||||
const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onOpenSettings,
|
||||
scrollToBottom,
|
||||
scrollToLatest,
|
||||
active = true,
|
||||
draftPresentationExiting = false,
|
||||
}) => {
|
||||
@@ -898,6 +903,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
// Sending while the agent works must still take the reader to the
|
||||
// live edge — a queued message produces no user row yet, so the
|
||||
// anchor path has nothing to claim and would leave the viewport
|
||||
// parked mid-history.
|
||||
scrollToLatest?.();
|
||||
|
||||
// Clear input and attachments
|
||||
// Note: confirmedMentionsRef is NOT cleared here because queued messages
|
||||
// are processed later in handleSubmit which reads the ref via extractInlineFileMentions.
|
||||
@@ -910,7 +921,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
if (!isMobile) {
|
||||
composerRef.current?.focus();
|
||||
}
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant, scrollToLatest]);
|
||||
|
||||
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
||||
setMessage(content);
|
||||
@@ -1289,6 +1300,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
...additionalParts.flatMap(p => p.attachments ?? []),
|
||||
];
|
||||
|
||||
// Arm the timeline anchor BEFORE the optimistic user row can commit;
|
||||
// arming after (or a frame later) races the commit and the anchor
|
||||
// never claims the new message.
|
||||
scrollToBottom?.();
|
||||
|
||||
const sendPromise = sendMessage(
|
||||
primaryText,
|
||||
providerIdToSend,
|
||||
@@ -1307,14 +1323,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
scrollToBottom?.();
|
||||
} else {
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollToBottom?.();
|
||||
});
|
||||
}
|
||||
|
||||
void sendPromise.then(() => {
|
||||
// Record what this session was pointed at, so the work-status panel
|
||||
// can show it as a context source long after the message scrolled
|
||||
@@ -2648,9 +2656,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
sessionId={currentSessionId}
|
||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||
/>
|
||||
<MemoStatusRow
|
||||
<MemoComposerStatusBar
|
||||
showAbortStatus={showAbortStatus}
|
||||
showAssistantStatus={false}
|
||||
showTodos={composerStatusExtrasEnabled}
|
||||
leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges
|
||||
? null
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
||||
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import MessageBody from './message/MessageBody';
|
||||
import type { AgentMentionInfo } from './message/types';
|
||||
import type { StreamPhase, ToolPopupContent } from './message/types';
|
||||
@@ -132,8 +131,6 @@ interface ChatMessageProps {
|
||||
info: Message;
|
||||
parts: Part[];
|
||||
};
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
animationHandlers?: AnimationHandlers;
|
||||
scrollToBottom?: () => void;
|
||||
turnGroupingContext?: TurnGroupingContext;
|
||||
assistantHeaderMessageId?: string;
|
||||
@@ -148,8 +145,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
message,
|
||||
previousMessage,
|
||||
nextMessage,
|
||||
onContentChange,
|
||||
animationHandlers,
|
||||
turnGroupingContext,
|
||||
assistantHeaderMessageId,
|
||||
isInActiveTurn = false,
|
||||
@@ -850,35 +845,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
});
|
||||
}, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]);
|
||||
|
||||
const resolvedAnimationHandlers = animationHandlers ?? null;
|
||||
const hasAnnouncedAuxiliaryScrollRef = React.useRef(false);
|
||||
|
||||
const animationCompletedRef = React.useRef(false);
|
||||
const hasRequestedReservationRef = React.useRef(false);
|
||||
const animationStartNotifiedRef = React.useRef(false);
|
||||
const hasTriggeredReservationOnceRef = React.useRef(false);
|
||||
const hasEverStreamedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
animationCompletedRef.current = false;
|
||||
hasRequestedReservationRef.current = false;
|
||||
animationStartNotifiedRef.current = false;
|
||||
hasTriggeredReservationOnceRef.current = false;
|
||||
hasAnnouncedAuxiliaryScrollRef.current = false;
|
||||
hasEverStreamedRef.current = false;
|
||||
}, [message.info.id]);
|
||||
|
||||
const handleAuxiliaryContentComplete = React.useCallback(() => {
|
||||
if (isUser) {
|
||||
return;
|
||||
}
|
||||
if (hasAnnouncedAuxiliaryScrollRef.current) {
|
||||
return;
|
||||
}
|
||||
hasAnnouncedAuxiliaryScrollRef.current = true;
|
||||
onContentChange?.('structural');
|
||||
}, [isUser, onContentChange]);
|
||||
|
||||
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
|
||||
|
||||
const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
|
||||
@@ -901,114 +873,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
hasEverStreamedRef.current = true;
|
||||
}
|
||||
|
||||
const hasReasoningParts = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return false;
|
||||
}
|
||||
return visibleParts.some((part) => part.type === 'reasoning');
|
||||
}, [isUser, visibleParts]);
|
||||
|
||||
const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current;
|
||||
const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!resolvedAnimationHandlers?.onStreamingCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldReserveAnimationSpace) {
|
||||
if (hasRequestedReservationRef.current) {
|
||||
if (hasReasoningParts && resolvedAnimationHandlers?.onReasoningBlock) {
|
||||
resolvedAnimationHandlers.onReasoningBlock();
|
||||
} else if (resolvedAnimationHandlers?.onReservationCancelled) {
|
||||
resolvedAnimationHandlers.onReservationCancelled();
|
||||
}
|
||||
hasRequestedReservationRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTriggeredReservationOnceRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasTriggeredReservationOnceRef.current = true;
|
||||
resolvedAnimationHandlers.onStreamingCandidate();
|
||||
hasRequestedReservationRef.current = true;
|
||||
}, [resolvedAnimationHandlers, shouldReserveAnimationSpace, hasReasoningParts]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!resolvedAnimationHandlers?.onAnimationStart) {
|
||||
return;
|
||||
}
|
||||
if (!allowAnimation) {
|
||||
return;
|
||||
}
|
||||
if (animationStartNotifiedRef.current) {
|
||||
return;
|
||||
}
|
||||
resolvedAnimationHandlers.onAnimationStart();
|
||||
animationStartNotifiedRef.current = true;
|
||||
}, [resolvedAnimationHandlers, allowAnimation]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = resolvedAnimationHandlers?.onAnimatedHeightChange;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldTrackHeight = allowAnimation || shouldReserveAnimationSpace;
|
||||
if (!shouldTrackHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = messageContainerRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
|
||||
handler(element.getBoundingClientRect().height);
|
||||
return;
|
||||
}
|
||||
|
||||
let rafId: number | null = null;
|
||||
const notifyHeight = (height: number) => {
|
||||
if (typeof window === 'undefined') {
|
||||
handler(height);
|
||||
return;
|
||||
}
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = window.requestAnimationFrame(() => {
|
||||
handler(height);
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
notifyHeight(entry.contentRect.height);
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
notifyHeight(element.getBoundingClientRect().height);
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]);
|
||||
|
||||
if (shouldHideUserMessage) {
|
||||
return null;
|
||||
@@ -1070,13 +935,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
allowAnimation={allowAnimation}
|
||||
onContentChange={onContentChange}
|
||||
shouldShowHeader={false}
|
||||
hasTextContent={hasTextContent}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
copiedMessage={copiedMessage}
|
||||
showReasoningTraces={showReasoningTraces}
|
||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||
agentMention={agentMention}
|
||||
onRevert={handleRevert}
|
||||
onFork={isUser ? handleFork : undefined}
|
||||
@@ -1106,13 +969,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
allowAnimation={allowAnimation}
|
||||
onContentChange={onContentChange}
|
||||
shouldShowHeader={false}
|
||||
hasTextContent={hasTextContent}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
copiedMessage={copiedMessage}
|
||||
showReasoningTraces={showReasoningTraces}
|
||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||
agentMention={agentMention}
|
||||
onRevert={handleRevert}
|
||||
onFork={isUser ? handleFork : undefined}
|
||||
@@ -1152,12 +1013,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
allowAnimation={allowAnimation}
|
||||
onContentChange={onContentChange}
|
||||
shouldShowHeader={shouldShowHeader}
|
||||
hasTextContent={hasTextContent}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
copiedMessage={copiedMessage}
|
||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||
showReasoningTraces={showReasoningTraces}
|
||||
agentMention={agentMention}
|
||||
turnGroupingContext={turnGroupingContext}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import React from "react";
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDirectorySync } from "@/sync/sync-context";
|
||||
import type { Todo } from "@opencode-ai/sdk/v2/client";
|
||||
import { useUIStore } from "@/stores/useUIStore";
|
||||
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
|
||||
import { isVSCodeRuntime } from "@/lib/desktop";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
// The bar that sits in the composer stack: pending-changes accessory, abort
|
||||
// status, and the todos dropdown. Deliberately a separate component from
|
||||
// StatusRow — that one is the floating assistant-status chip above the
|
||||
// composer, and sharing markup meant every restyle of the chip (glass,
|
||||
// placement) silently restyled this bar and its dropdown too.
|
||||
|
||||
type TodoItem = Todo & { id?: string };
|
||||
|
||||
const COMPOSER_STATUS_BAR_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "composer-status-bar" };
|
||||
|
||||
const statusConfig = {
|
||||
in_progress: { textClassName: "text-foreground" },
|
||||
pending: { textClassName: "text-foreground" },
|
||||
completed: { textClassName: "text-muted-foreground line-through" },
|
||||
cancelled: { textClassName: "text-muted-foreground line-through" },
|
||||
};
|
||||
|
||||
const priorityClassName = {
|
||||
high: "text-[var(--status-warning)]",
|
||||
medium: "text-muted-foreground",
|
||||
low: "text-muted-foreground/70",
|
||||
};
|
||||
|
||||
const priorityIcon = {
|
||||
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
};
|
||||
|
||||
const statusLabelKey = {
|
||||
in_progress: "chat.statusRow.todo.status.inProgress",
|
||||
pending: "chat.statusRow.todo.status.pending",
|
||||
completed: "chat.statusRow.todo.status.completed",
|
||||
cancelled: "chat.statusRow.todo.status.cancelled",
|
||||
};
|
||||
|
||||
const priorityLabelKey = {
|
||||
high: "chat.statusRow.todo.priority.high",
|
||||
medium: "chat.statusRow.todo.priority.medium",
|
||||
low: "chat.statusRow.todo.priority.low",
|
||||
};
|
||||
|
||||
// SAFETY: todo.status / todo.priority arrive from the SDK as open strings;
|
||||
// lookups treat them as candidate keys and every call site falls back to a
|
||||
// default entry when the value is outside the known set.
|
||||
const knownStatus = (status: string) =>
|
||||
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
|
||||
status as keyof typeof statusConfig;
|
||||
const knownPriority = (priority: string) =>
|
||||
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
|
||||
priority as keyof typeof priorityClassName;
|
||||
|
||||
const TodoItemRow: React.FC<{ todo: TodoItem }> = ({ todo }) => {
|
||||
const { t } = useI18n();
|
||||
const config = statusConfig[knownStatus(todo.status)] || statusConfig.pending;
|
||||
// SAFETY: the label keys are literal members of the i18n dictionary; the
|
||||
// lookup narrows an open SDK string with a known fallback, and t() accepts
|
||||
// only the generated key union.
|
||||
const statusKey = (statusLabelKey[knownStatus(todo.status)] ?? statusLabelKey.pending) as Parameters<typeof t>[0];
|
||||
// SAFETY: same literal-member narrowing as statusKey above.
|
||||
const priorityKey = (priorityLabelKey[knownPriority(todo.priority)] ?? priorityLabelKey.medium) as Parameters<typeof t>[0];
|
||||
|
||||
const statusIcon =
|
||||
todo.status === "in_progress" ? (
|
||||
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true" />
|
||||
) : todo.status === "completed" ? (
|
||||
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true" />
|
||||
) : (
|
||||
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center min-w-0 py-0.5 gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex-shrink-0">{statusIcon}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
{t(statusKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className={cn("flex-1 typography-ui-label", config.textClassName)}>
|
||||
{todo.content}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
|
||||
priorityClassName[knownPriority(todo.priority)] ?? priorityClassName.medium,
|
||||
)}
|
||||
>
|
||||
{priorityIcon[knownPriority(todo.priority)] ?? priorityIcon.medium}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{t(priorityKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EMPTY_TODOS: TodoItem[] = [];
|
||||
|
||||
interface ComposerStatusBarProps {
|
||||
showAbortStatus?: boolean;
|
||||
showTodos?: boolean;
|
||||
leftAccessory?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
|
||||
showAbortStatus,
|
||||
showTodos = true,
|
||||
leftAccessory,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
|
||||
[currentSessionId],
|
||||
),
|
||||
);
|
||||
const liveTodos = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
|
||||
return state.todo[currentSessionId] ?? EMPTY_TODOS;
|
||||
},
|
||||
[currentSessionId, showTodos],
|
||||
),
|
||||
);
|
||||
const persistedSessionTodos = useTodosPersistStore(
|
||||
React.useCallback(
|
||||
(state) => (showTodos && currentSessionId && currentSessionDirectory
|
||||
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
|
||||
: undefined),
|
||||
[currentSessionDirectory, currentSessionId, showTodos],
|
||||
),
|
||||
);
|
||||
const todos: TodoItem[] = React.useMemo(() => {
|
||||
if (!currentSessionId) return EMPTY_TODOS;
|
||||
if (liveTodos.length > 0) return liveTodos;
|
||||
return persistedSessionTodos ?? EMPTY_TODOS;
|
||||
}, [liveTodos, persistedSessionTodos, currentSessionId]);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const isCompact = isMobile || isVSCodeRuntime();
|
||||
|
||||
const visibleTodos = React.useMemo(() => {
|
||||
return todos.filter((todo) => todo.status !== "cancelled");
|
||||
}, [todos]);
|
||||
|
||||
const activeTodo = React.useMemo(() => {
|
||||
return (
|
||||
visibleTodos.find((todo) => todo.status === "in_progress") ||
|
||||
visibleTodos.find((todo) => todo.status === "pending") ||
|
||||
null
|
||||
);
|
||||
}, [visibleTodos]);
|
||||
|
||||
const progress = React.useMemo(() => {
|
||||
const total = todos.filter((todo) => todo.status !== "cancelled").length;
|
||||
const completed = todos.filter((todo) => todo.status === "completed").length;
|
||||
return { completed, total };
|
||||
}, [todos]);
|
||||
|
||||
const statusSummary = React.useMemo(() => {
|
||||
const active = visibleTodos.filter((todo) => todo.status === "in_progress").length;
|
||||
const left = visibleTodos.filter((todo) => todo.status === "in_progress" || todo.status === "pending").length;
|
||||
return { active, left };
|
||||
}, [visibleTodos]);
|
||||
|
||||
const hasTodoContent = showTodos && statusSummary.left > 0;
|
||||
const hasLeftAccessory = Boolean(leftAccessory);
|
||||
const hasContent = Boolean(showAbortStatus) || hasTodoContent || hasLeftAccessory;
|
||||
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (!isExpanded) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
// SAFETY: mousedown targets are DOM nodes; contains() only needs Node.
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isExpanded]);
|
||||
|
||||
const toggleExpanded = () => setIsExpanded((prev) => !prev);
|
||||
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
|
||||
active: statusSummary.active,
|
||||
left: statusSummary.left,
|
||||
});
|
||||
|
||||
const todoTrigger = hasTodoContent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
|
||||
aria-label={todoSummaryLabel}
|
||||
title={todoSummaryLabel}
|
||||
>
|
||||
{!isCompact && activeTodo ? (
|
||||
<span className="composer-status-bar__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
|
||||
{activeTodo.content}
|
||||
</span>
|
||||
) : (
|
||||
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
|
||||
)}
|
||||
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
|
||||
{statusSummary.active}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Icon name="time" className="h-3.5 w-3.5" />
|
||||
{statusSummary.left}
|
||||
</span>
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
if (!hasContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2" style={COMPOSER_STATUS_BAR_CONTAINER_STYLE}>
|
||||
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
|
||||
{/* Left: abort status | pending-changes accessory */}
|
||||
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
|
||||
{showAbortStatus ? (
|
||||
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
|
||||
<span className="flex items-center gap-1.5 typography-ui-label">
|
||||
<Icon name="close-circle" aria-hidden="true" />
|
||||
{t('chat.statusRow.aborted')}
|
||||
</span>
|
||||
</div>
|
||||
) : leftAccessory ? (
|
||||
leftAccessory
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Right: todos dropdown */}
|
||||
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory && "pr-1.5")} ref={popoverRef}>
|
||||
{todoTrigger}
|
||||
|
||||
{isExpanded && hasTodoContent && (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "min(28rem, calc(100cqw - 4ch))",
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
color: "var(--surface-elevated-foreground)",
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-0 bottom-full mb-1 z-50",
|
||||
"w-max min-w-[200px] rounded-xl p-1",
|
||||
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
|
||||
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
|
||||
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
|
||||
"duration-150",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
|
||||
<span>{t('chat.statusRow.tasksTitle')}</span>
|
||||
<span className="typography-meta tabular-nums">
|
||||
{progress.completed}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-1 max-h-[200px] overflow-y-auto">
|
||||
{visibleTodos.map((todo, index) => (
|
||||
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,562 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { TextPart } from '@opencode-ai/sdk/v2';
|
||||
|
||||
type OperationCounts = {
|
||||
innerHTMLWrites: number;
|
||||
spriteIconInnerHTMLWrites: number;
|
||||
querySelectorAllCalls: number;
|
||||
appendCalls: number;
|
||||
replaceCalls: number;
|
||||
removeCalls: number;
|
||||
getBoundingClientRectCalls: number;
|
||||
viewBoxWrites: number;
|
||||
resizeObserverCreates: number;
|
||||
resizeObserverObserveCalls: number;
|
||||
geometrySequence: Array<'read' | 'write'>;
|
||||
};
|
||||
|
||||
type FixtureMetrics = OperationCounts & {
|
||||
renderers: number;
|
||||
markdownBlocks: number;
|
||||
mermaidBlocks: number;
|
||||
mermaidRenderedCount: number;
|
||||
mermaidSvgCount: number;
|
||||
};
|
||||
|
||||
const fixture = [
|
||||
'# Synthetic mount fixture',
|
||||
'',
|
||||
'A paragraph with **bold text**, a table, and a stable link.',
|
||||
'',
|
||||
'| name | value |',
|
||||
'| --- | ---: |',
|
||||
'| alpha | 1 |',
|
||||
'| beta | 2 |',
|
||||
'',
|
||||
'```typescript',
|
||||
'const answer = 42;',
|
||||
'console.log(answer);',
|
||||
'```',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph TD',
|
||||
' A[Start] --> B[Finish]',
|
||||
'```',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph LR',
|
||||
' Client[Client] --> Server[Server]',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const fixtureWorkload = {
|
||||
rendererCount: 3,
|
||||
domBlocksPerRenderer: 1,
|
||||
mermaidBlocksPerRenderer: 2,
|
||||
};
|
||||
|
||||
let windowInstance: Window;
|
||||
let previousGlobals: Map<string, PropertyDescriptor | undefined>;
|
||||
let activeCounts: OperationCounts | null = null;
|
||||
let animationFrameQueue: FrameRequestCallback[] = [];
|
||||
let notifyResize: ((entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) | null = null;
|
||||
let MarkdownRenderer: React.ComponentType<{
|
||||
content: string;
|
||||
messageId: string;
|
||||
part?: TextPart;
|
||||
isAnimated?: boolean;
|
||||
isStreaming?: boolean;
|
||||
enableFileReferences?: boolean;
|
||||
}>;
|
||||
let clearDetachedMarkdownDomCache: () => void;
|
||||
let detachedMarkdownDomCacheStats: () => { sessions: number; entries: number };
|
||||
|
||||
const makeCounts = (): OperationCounts => ({
|
||||
innerHTMLWrites: 0,
|
||||
spriteIconInnerHTMLWrites: 0,
|
||||
querySelectorAllCalls: 0,
|
||||
appendCalls: 0,
|
||||
replaceCalls: 0,
|
||||
removeCalls: 0,
|
||||
getBoundingClientRectCalls: 0,
|
||||
viewBoxWrites: 0,
|
||||
resizeObserverCreates: 0,
|
||||
resizeObserverObserveCalls: 0,
|
||||
geometrySequence: [],
|
||||
});
|
||||
|
||||
const installGlobal = (name: string, value: Window[keyof Window]): void => {
|
||||
previousGlobals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
|
||||
const waitForSettledEffects = async (): Promise<void> => {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
const flushAnimationFrame = async (): Promise<void> => {
|
||||
const callbacks = animationFrameQueue;
|
||||
animationFrameQueue = [];
|
||||
await act(async () => {
|
||||
for (const callback of callbacks) callback(windowInstance.performance.now());
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const flushDeferredMermaidInitialization = async (): Promise<void> => {
|
||||
await flushAnimationFrame();
|
||||
await flushAnimationFrame();
|
||||
};
|
||||
|
||||
const mountFixture = async (rendererCount: number): Promise<{
|
||||
root: Root;
|
||||
host: HTMLDivElement;
|
||||
operations: OperationCounts;
|
||||
counts: FixtureMetrics;
|
||||
}> => {
|
||||
const counts = makeCounts();
|
||||
activeCounts = counts;
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<>
|
||||
{Array.from({ length: rendererCount }, (_, index) => (
|
||||
<MarkdownRenderer
|
||||
key={`fixture-${index}`}
|
||||
content={fixture}
|
||||
messageId={`fixture-message-${index}`}
|
||||
isAnimated={false}
|
||||
enableFileReferences={false}
|
||||
/>
|
||||
))}
|
||||
</>,
|
||||
);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
await act(async () => waitForSettledEffects());
|
||||
|
||||
const mermaidBlocks = host.querySelectorAll('[data-markdown="mermaid-block"]').length;
|
||||
const mermaidRenderedCount = host.querySelectorAll('[data-mermaid-render]').length;
|
||||
const mermaidSvgCount = host.querySelectorAll('[data-markdown="mermaid"] svg').length;
|
||||
return {
|
||||
root,
|
||||
host,
|
||||
operations: counts,
|
||||
counts: {
|
||||
...counts,
|
||||
renderers: rendererCount,
|
||||
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
|
||||
mermaidBlocks,
|
||||
mermaidRenderedCount,
|
||||
mermaidSvgCount,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const runFixture = async (rendererCount: number): Promise<FixtureMetrics> => {
|
||||
const { root, host, operations } = await mountFixture(rendererCount);
|
||||
await flushDeferredMermaidInitialization();
|
||||
const counts: FixtureMetrics = {
|
||||
...operations,
|
||||
renderers: rendererCount,
|
||||
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
|
||||
mermaidBlocks: host.querySelectorAll('[data-markdown="mermaid-block"]').length,
|
||||
mermaidRenderedCount: host.querySelectorAll('[data-mermaid-render]').length,
|
||||
mermaidSvgCount: host.querySelectorAll('[data-markdown="mermaid"] svg').length,
|
||||
};
|
||||
await act(async () => root.unmount());
|
||||
return counts;
|
||||
};
|
||||
|
||||
const initializePerformanceDom = async (): Promise<void> => {
|
||||
windowInstance = new Window({ url: 'http://localhost/' });
|
||||
windowInstance.document.write('<!doctype html><html><head></head><body></body></html>');
|
||||
windowInstance.document.close();
|
||||
previousGlobals = new Map();
|
||||
installGlobal('window', windowInstance);
|
||||
installGlobal('document', windowInstance.document);
|
||||
installGlobal('navigator', windowInstance.navigator);
|
||||
installGlobal('customElements', windowInstance.customElements);
|
||||
for (const name of ['Document', 'Element', 'HTMLElement', 'SVGElement', 'Node', 'Text', 'NodeFilter', 'MutationObserver', 'DOMParser', 'XMLSerializer', 'HTMLAnchorElement', 'HTMLButtonElement']) {
|
||||
// SAFETY: these names are the DOM constructors installed by this happy-dom Window.
|
||||
const globalValue = windowInstance[name as keyof Window];
|
||||
if (globalValue === undefined) throw new Error(`happy-dom global is unavailable: ${name}`);
|
||||
installGlobal(name, globalValue);
|
||||
}
|
||||
Object.defineProperty(windowInstance, 'matchMedia', { configurable: true, value: () => ({ matches: false, media: '', onchange: null, addListener: () => undefined, removeListener: () => undefined, addEventListener: () => undefined, removeEventListener: () => undefined, dispatchEvent: () => false }) });
|
||||
Object.defineProperty(windowInstance, 'requestAnimationFrame', { configurable: true, value: (callback: FrameRequestCallback) => {
|
||||
animationFrameQueue.push(callback);
|
||||
return animationFrameQueue.length;
|
||||
} });
|
||||
Object.defineProperty(windowInstance, 'cancelAnimationFrame', { configurable: true, value: () => undefined });
|
||||
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const elementPrototype = Element.prototype;
|
||||
const nodePrototype = Node.prototype;
|
||||
const documentPrototype = Document.prototype;
|
||||
const innerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
|
||||
if (!innerHTMLDescriptor?.set || !innerHTMLDescriptor.get) throw new Error('happy-dom innerHTML descriptor unavailable');
|
||||
Object.defineProperty(Element.prototype, 'innerHTML', {
|
||||
configurable: true,
|
||||
get: innerHTMLDescriptor.get,
|
||||
set(value: string) {
|
||||
if (activeCounts) {
|
||||
activeCounts.innerHTMLWrites += 1;
|
||||
if (value.includes('href="#oc-')) activeCounts.spriteIconInnerHTMLWrites += 1;
|
||||
}
|
||||
innerHTMLDescriptor.set?.call(this, value);
|
||||
},
|
||||
});
|
||||
const originalQuerySelectorAll = elementPrototype.querySelectorAll;
|
||||
Object.defineProperty(elementPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
|
||||
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
|
||||
return originalQuerySelectorAll.call(this, selectors);
|
||||
} });
|
||||
const originalDocumentQuerySelectorAll = documentPrototype.querySelectorAll;
|
||||
Object.defineProperty(documentPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
|
||||
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
|
||||
return originalDocumentQuerySelectorAll.call(this, selectors);
|
||||
} });
|
||||
const originalAppendChild = nodePrototype.appendChild;
|
||||
Object.defineProperty(nodePrototype, 'appendChild', { configurable: true, value: function (node: Node): Node {
|
||||
if (activeCounts) activeCounts.appendCalls += 1;
|
||||
return originalAppendChild.call(this, node);
|
||||
} });
|
||||
const originalReplaceWith = elementPrototype.replaceWith;
|
||||
Object.defineProperty(elementPrototype, 'replaceWith', { configurable: true, value: function (...nodes: (Node | string)[]): void {
|
||||
if (activeCounts) activeCounts.replaceCalls += 1;
|
||||
return originalReplaceWith.apply(this, nodes);
|
||||
} });
|
||||
const originalRemove = elementPrototype.remove;
|
||||
Object.defineProperty(elementPrototype, 'remove', { configurable: true, value: function (): void {
|
||||
if (activeCounts) activeCounts.removeCalls += 1;
|
||||
return originalRemove.call(this);
|
||||
} });
|
||||
const originalGetBoundingClientRect = elementPrototype.getBoundingClientRect;
|
||||
Object.defineProperty(elementPrototype, 'getBoundingClientRect', { configurable: true, value: function (): DOMRect {
|
||||
if (activeCounts) {
|
||||
activeCounts.getBoundingClientRectCalls += 1;
|
||||
activeCounts.geometrySequence.push('read');
|
||||
}
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
} });
|
||||
const svgSetAttribute = SVGElement.prototype.setAttribute;
|
||||
Object.defineProperty(SVGElement.prototype, 'setAttribute', { configurable: true, value: function (name: string, value: string): void {
|
||||
if (name === 'viewBox' && activeCounts && this.closest('[data-markdown="mermaid"]')) {
|
||||
activeCounts.viewBoxWrites += 1;
|
||||
activeCounts.geometrySequence.push('write');
|
||||
}
|
||||
return svgSetAttribute.call(this, name, value);
|
||||
} });
|
||||
class CountingResizeObserver {
|
||||
constructor(callback: (entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) {
|
||||
if (activeCounts) activeCounts.resizeObserverCreates += 1;
|
||||
notifyResize = callback;
|
||||
}
|
||||
|
||||
observe(): void {
|
||||
if (activeCounts) activeCounts.resizeObserverObserveCalls += 1;
|
||||
}
|
||||
|
||||
unobserve(): void {}
|
||||
|
||||
disconnect(): void {}
|
||||
}
|
||||
installGlobal('ResizeObserver', CountingResizeObserver);
|
||||
|
||||
const fakeState = {
|
||||
openContextPreview: () => undefined,
|
||||
codeBlockLineWrap: false,
|
||||
mermaidRenderingMode: 'svg',
|
||||
};
|
||||
type UIStateSelection = typeof fakeState[keyof typeof fakeState];
|
||||
const { mock } = await import('bun:test');
|
||||
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
|
||||
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => null }));
|
||||
mock.module('@/stores/useUIStore', () => ({ useUIStore: Object.assign((selector: (state: typeof fakeState) => UIStateSelection) => selector(fakeState), { getState: () => fakeState }) }));
|
||||
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
|
||||
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
|
||||
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
|
||||
mock.module('@/lib/url', () => ({ getUrlScheme: () => null, isAppLinkUrl: () => false, isExternalHttpUrl: () => false, openConfirmedAppLinkUrl: async () => false, openExternalUrl: async () => undefined, getExternalFaviconUrl: () => null, isLoopbackHttpUrl: () => false }));
|
||||
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
|
||||
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
|
||||
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
|
||||
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '', normalizeFilePath: (value: string) => value, isAbsoluteFilePath: (value: string) => value.startsWith('/') }));
|
||||
mock.module('@/lib/clipboard', () => ({ copyTextToClipboard: async () => undefined }));
|
||||
mock.module('beautiful-mermaid', () => ({
|
||||
renderMermaidASCII: () => 'diagram',
|
||||
renderMermaidSVG: () => '<svg viewBox="0 0 240 120" width="240" height="120"><path d="M0 0h1v1z" /></svg>',
|
||||
}));
|
||||
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
|
||||
mock.module('./markdown/markdown-worker', () => ({
|
||||
highlightCodeInWorker: async () => null,
|
||||
highlightLinesInWorker: async () => null,
|
||||
highlightTokensInWorker: async () => null,
|
||||
}));
|
||||
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: React.ReactNode }) => children }));
|
||||
const imported = await import('./MarkdownRendererImpl');
|
||||
MarkdownRenderer = imported.MarkdownRenderer;
|
||||
const { detachedMarkdownDomCache } = await import('./markdown/detachedMarkdownDomCache');
|
||||
clearDetachedMarkdownDomCache = () => detachedMarkdownDomCache.clear();
|
||||
detachedMarkdownDomCacheStats = () => detachedMarkdownDomCache.stats();
|
||||
};
|
||||
|
||||
await initializePerformanceDom();
|
||||
|
||||
afterAll(() => {
|
||||
for (const [name, descriptor] of previousGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
|
||||
describe('MarkdownRenderer DOM mount performance contract', () => {
|
||||
test('builds Markdown sprite controls without parsing SVG markup', async () => {
|
||||
const mounted = await mountFixture(1);
|
||||
|
||||
const spriteControlCount = mounted.host.querySelectorAll('[data-md-action] use[href^="#oc-"]').length;
|
||||
const spriteIconInnerHTMLWrites = mounted.operations.spriteIconInnerHTMLWrites;
|
||||
await act(async () => mounted.root.unmount());
|
||||
|
||||
expect(spriteControlCount).toBeGreaterThan(0);
|
||||
expect(spriteIconInnerHTMLWrites).toBe(0);
|
||||
});
|
||||
|
||||
test('reuses settled Markdown DOM without parsing or decorating it again', async () => {
|
||||
clearDetachedMarkdownDomCache();
|
||||
const content = '# Cached viewport\n\nA settled paragraph.';
|
||||
const part: TextPart = {
|
||||
id: 'part-cache',
|
||||
sessionID: 'session-cache',
|
||||
messageID: 'message-cache',
|
||||
type: 'text',
|
||||
text: content,
|
||||
time: { start: 0, end: 1 },
|
||||
};
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const render = (root: Root) => root.render(
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
messageId="message-cache"
|
||||
part={part}
|
||||
isAnimated={false}
|
||||
enableFileReferences={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstCounts = makeCounts();
|
||||
activeCounts = firstCounts;
|
||||
const firstRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
render(firstRoot);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
const originalBlock = host.querySelector('[data-md-block]');
|
||||
expect(originalBlock).not.toBeNull();
|
||||
expect(firstCounts.innerHTMLWrites).toBeGreaterThan(0);
|
||||
await act(async () => firstRoot.unmount());
|
||||
|
||||
const secondCounts = makeCounts();
|
||||
activeCounts = secondCounts;
|
||||
const secondRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
render(secondRoot);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
expect(host.querySelector('[data-md-block]')).toBe(originalBlock);
|
||||
expect(secondCounts.innerHTMLWrites).toBe(0);
|
||||
await act(async () => secondRoot.unmount());
|
||||
clearDetachedMarkdownDomCache();
|
||||
});
|
||||
|
||||
test('does not cache streaming, unfinished, or Mermaid DOM', async () => {
|
||||
clearDetachedMarkdownDomCache();
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const renderScoped = (
|
||||
root: Root,
|
||||
content: string,
|
||||
partId: string,
|
||||
isStreaming = false,
|
||||
) => root.render(
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
messageId="message-cache"
|
||||
part={{
|
||||
id: partId,
|
||||
sessionID: 'session-cache',
|
||||
messageID: 'message-cache',
|
||||
type: 'text',
|
||||
text: content,
|
||||
time: { start: 0, end: 1 },
|
||||
}}
|
||||
isAnimated={false}
|
||||
isStreaming={isStreaming}
|
||||
enableFileReferences={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const streamingRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
renderScoped(streamingRoot, 'streaming content', 'part-streaming', true);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
await act(async () => streamingRoot.unmount());
|
||||
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
|
||||
|
||||
const unfinalizedRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
unfinalizedRoot.render(
|
||||
<MarkdownRenderer
|
||||
content="unfinalized content"
|
||||
messageId="message-unfinalized"
|
||||
part={{
|
||||
id: 'part-unfinalized',
|
||||
sessionID: 'session-cache',
|
||||
messageID: 'message-unfinalized',
|
||||
type: 'text',
|
||||
text: 'unfinalized content',
|
||||
}}
|
||||
isAnimated={false}
|
||||
enableFileReferences={false}
|
||||
/>,
|
||||
);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
await act(async () => unfinalizedRoot.unmount());
|
||||
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
|
||||
|
||||
const mermaidRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
renderScoped(mermaidRoot, '```mermaid\ngraph TD\nA --> B\n```', 'part-mermaid');
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
await act(async () => mermaidRoot.unmount());
|
||||
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
|
||||
|
||||
clearDetachedMarkdownDomCache();
|
||||
});
|
||||
|
||||
test('does not detach Markdown DOM that intersects the active selection', async () => {
|
||||
clearDetachedMarkdownDomCache();
|
||||
const content = 'selected content';
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
messageId="message-selected"
|
||||
part={{
|
||||
id: 'part-selected',
|
||||
sessionID: 'session-selected',
|
||||
messageID: 'message-selected',
|
||||
type: 'text',
|
||||
text: content,
|
||||
time: { start: 0, end: 1 },
|
||||
}}
|
||||
isAnimated={false}
|
||||
enableFileReferences={false}
|
||||
/>,
|
||||
);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
const markdown = host.querySelector<HTMLElement>('[data-markdown-content]');
|
||||
if (!markdown) throw new Error('Expected mounted Markdown content');
|
||||
const originalGetSelection = window.getSelection;
|
||||
Object.defineProperty(window, 'getSelection', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
rangeCount: 1,
|
||||
isCollapsed: false,
|
||||
getRangeAt: () => ({ intersectsNode: (node: Node) => node === markdown }),
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
await act(async () => root.unmount());
|
||||
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
|
||||
} finally {
|
||||
Object.defineProperty(window, 'getSelection', { configurable: true, value: originalGetSelection });
|
||||
clearDetachedMarkdownDomCache();
|
||||
}
|
||||
});
|
||||
|
||||
test('defers and batches Mermaid controller initialization after Markdown mount', async () => {
|
||||
const mounted = await mountFixture(fixtureWorkload.rendererCount);
|
||||
const critical = mounted.counts;
|
||||
|
||||
expect(critical.getBoundingClientRectCalls).toBe(0);
|
||||
expect(critical.viewBoxWrites).toBe(0);
|
||||
expect(critical.resizeObserverCreates).toBe(0);
|
||||
expect(mounted.host.querySelectorAll('[data-markdown="mermaid"] svg')).toHaveLength(6);
|
||||
|
||||
await flushDeferredMermaidInitialization();
|
||||
const metrics = {
|
||||
...mounted.operations,
|
||||
renderers: fixtureWorkload.rendererCount,
|
||||
markdownBlocks: mounted.host.querySelectorAll('[data-md-block]').length,
|
||||
mermaidBlocks: mounted.host.querySelectorAll('[data-markdown="mermaid-block"]').length,
|
||||
mermaidRenderedCount: mounted.host.querySelectorAll('[data-mermaid-render]').length,
|
||||
mermaidSvgCount: mounted.host.querySelectorAll('[data-markdown="mermaid"] svg').length,
|
||||
};
|
||||
|
||||
expect(metrics.renderers).toBe(3);
|
||||
expect(metrics.markdownBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.domBlocksPerRenderer);
|
||||
expect(metrics.mermaidBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.mermaidBlocksPerRenderer);
|
||||
expect(metrics.mermaidRenderedCount).toBeGreaterThan(0);
|
||||
expect(metrics.innerHTMLWrites).toBeGreaterThan(0);
|
||||
expect(metrics.querySelectorAllCalls).toBeGreaterThan(0);
|
||||
expect(metrics.appendCalls).toBeGreaterThan(0);
|
||||
expect(metrics.getBoundingClientRectCalls).toBe(metrics.mermaidRenderedCount);
|
||||
expect(metrics.viewBoxWrites).toBe(metrics.mermaidRenderedCount);
|
||||
expect(metrics.resizeObserverCreates).toBe(1);
|
||||
expect(metrics.resizeObserverObserveCalls).toBe(metrics.mermaidRenderedCount);
|
||||
expect(metrics.geometrySequence.lastIndexOf('read')).toBeLessThan(metrics.geometrySequence.indexOf('write'));
|
||||
|
||||
const viewport = mounted.host.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]');
|
||||
if (!viewport || !notifyResize) throw new Error('Expected initialized Mermaid viewport and shared observer');
|
||||
const readsBeforeResize = mounted.operations.getBoundingClientRectCalls;
|
||||
const writesBeforeResize = mounted.operations.viewBoxWrites;
|
||||
notifyResize([{ target: viewport, contentRect: { width: 320, height: 180 } }]);
|
||||
expect(mounted.operations.getBoundingClientRectCalls).toBe(readsBeforeResize);
|
||||
expect(mounted.operations.viewBoxWrites).toBe(writesBeforeResize + 1);
|
||||
console.log(JSON.stringify({ fixture: fixtureWorkload, baseline: metrics }));
|
||||
await act(async () => mounted.root.unmount());
|
||||
});
|
||||
|
||||
test('cancels deferred Mermaid initialization when the renderer unmounts first', async () => {
|
||||
const mounted = await mountFixture(1);
|
||||
await act(async () => mounted.root.unmount());
|
||||
await flushDeferredMermaidInitialization();
|
||||
|
||||
expect(mounted.operations.getBoundingClientRectCalls).toBe(0);
|
||||
expect(mounted.operations.viewBoxWrites).toBe(0);
|
||||
expect(mounted.operations.resizeObserverCreates).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps DOM operation fanout linear when renderer count doubles', async () => {
|
||||
const three = await runFixture(3);
|
||||
const six = await runFixture(6);
|
||||
|
||||
expect(six.mermaidBlocks).toBe(three.mermaidBlocks * 2);
|
||||
expect(six.mermaidRenderedCount).toBe(three.mermaidRenderedCount * 2);
|
||||
expect(six.innerHTMLWrites).toBeLessThanOrEqual(three.innerHTMLWrites * 2 + 6);
|
||||
expect(six.querySelectorAllCalls).toBeLessThanOrEqual(three.querySelectorAllCalls * 2 + 12);
|
||||
expect(six.appendCalls).toBeLessThanOrEqual(three.appendCalls * 2 + 12);
|
||||
expect(six.getBoundingClientRectCalls).toBe(three.getBoundingClientRectCalls * 2);
|
||||
expect(six.viewBoxWrites).toBe(three.viewBoxWrites * 2);
|
||||
expect(three.resizeObserverCreates).toBe(1);
|
||||
expect(six.resizeObserverCreates).toBe(1);
|
||||
expect(six.resizeObserverObserveCalls).toBe(three.resizeObserverObserveCalls * 2);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,377 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { localPathFromFileUrl, parseFileReference, type ParsedFileReference } from './fileReferenceParser';
|
||||
|
||||
const parse = (value: string): ParsedFileReference | null => parseFileReference(value);
|
||||
|
||||
type FakeElement = {
|
||||
childNodes: FakeElement[];
|
||||
children: FakeElement[];
|
||||
parentNode: FakeElement | null;
|
||||
attributes: Map<string, string>;
|
||||
style: { display: string; setProperty: () => void };
|
||||
innerHTML: string;
|
||||
setAttribute: (name: string, value: string) => void;
|
||||
getAttribute: (name: string) => string | null;
|
||||
appendChild: (child: FakeElement) => FakeElement;
|
||||
replaceWith: (replacement: FakeElement) => void;
|
||||
remove: () => void;
|
||||
querySelector: (selector: string) => FakeElement | null;
|
||||
querySelectorAll: <T>(selector: string) => T[];
|
||||
addEventListener: () => void;
|
||||
removeEventListener: () => void;
|
||||
contains: (child: FakeElement) => boolean;
|
||||
isEqualNode: () => boolean;
|
||||
};
|
||||
|
||||
type FakeDocument = { createElement: () => FakeElement };
|
||||
type FakeJsxProps = {
|
||||
ref?: { current: FakeElement | null };
|
||||
children?: FakeElement | FakeElement[];
|
||||
className?: string;
|
||||
'data-markdown-content'?: boolean;
|
||||
};
|
||||
|
||||
let syncRenderCalls = 0;
|
||||
let morphCalls = 0;
|
||||
let decorateCalls = 0;
|
||||
let mermaidRegistryCreates = 0;
|
||||
let mermaidRegistryCleanups = 0;
|
||||
let cachedRendererBlocks: Array<{ id: string; html: string }> | null = null;
|
||||
let renderedRendererBlocks: Array<{ id: string; html: string }> = [];
|
||||
let renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
|
||||
let currentContextVersion = 0;
|
||||
const layoutEffects: Array<() => void> = [];
|
||||
const passiveEffects: Array<() => void | (() => void)> = [];
|
||||
let hookCursor = 0;
|
||||
let hookStates: Array<{ current: null } | undefined> = [];
|
||||
let activeFakeDocument: FakeDocument | null = null;
|
||||
|
||||
const makeFakeElement = (ownerDocument: { createElement: () => FakeElement }): FakeElement => {
|
||||
void ownerDocument;
|
||||
let html = '';
|
||||
const element: FakeElement = {
|
||||
childNodes: [],
|
||||
children: [],
|
||||
parentNode: null,
|
||||
attributes: new Map(),
|
||||
style: { display: '', setProperty: () => undefined },
|
||||
get innerHTML() {
|
||||
return html;
|
||||
},
|
||||
set innerHTML(value: string) {
|
||||
html = value;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, value);
|
||||
},
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
},
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.childNodes.push(child);
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
replaceWith(replacement) {
|
||||
if (!this.parentNode) return;
|
||||
const parent = this.parentNode;
|
||||
const index = parent.children.indexOf(this);
|
||||
if (index < 0) return;
|
||||
replacement.parentNode = parent;
|
||||
parent.children[index] = replacement;
|
||||
parent.childNodes[index] = replacement;
|
||||
this.parentNode = null;
|
||||
},
|
||||
remove() {
|
||||
if (!this.parentNode) return;
|
||||
const parent = this.parentNode;
|
||||
parent.children = parent.children.filter((child) => child !== this);
|
||||
parent.childNodes = parent.childNodes.filter((child) => child !== this);
|
||||
this.parentNode = null;
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === '[data-markdown-content]') {
|
||||
return this.children.find((child) => child.getAttribute('data-markdown-content') === '') ?? null;
|
||||
}
|
||||
if (selector === '[data-markdown="mermaid-block"]' && html.includes('data-markdown="mermaid-block"')) {
|
||||
return this;
|
||||
}
|
||||
for (const child of this.children) {
|
||||
const match = child.querySelector(selector);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
contains(child) {
|
||||
return child === this || this.children.some((candidate) => candidate.contains(child));
|
||||
},
|
||||
isEqualNode: () => false,
|
||||
};
|
||||
return element;
|
||||
};
|
||||
|
||||
const installRendererDom = () => {
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousMutationObserver = Object.getOwnPropertyDescriptor(globalThis, 'MutationObserver');
|
||||
const documentStub: FakeDocument = { createElement: () => makeFakeElement(documentStub) };
|
||||
activeFakeDocument = documentStub;
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: documentStub });
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
matchMedia: () => ({ matches: false }),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||
configurable: true,
|
||||
value: class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument);
|
||||
else Reflect.deleteProperty(globalThis, 'document');
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousMutationObserver) Object.defineProperty(globalThis, 'MutationObserver', previousMutationObserver);
|
||||
else Reflect.deleteProperty(globalThis, 'MutationObserver');
|
||||
activeFakeDocument = null;
|
||||
};
|
||||
};
|
||||
|
||||
const rendererThemes = [{
|
||||
metadata: { id: 'renderer-test' },
|
||||
colors: {
|
||||
surface: { elevated: '#fff', foreground: '#000', mutedForeground: '#666', muted: '#eee' },
|
||||
interactive: { border: '#ccc' },
|
||||
primary: { base: '#00f' },
|
||||
},
|
||||
}, {
|
||||
metadata: { id: 'renderer-test-next' },
|
||||
colors: {
|
||||
surface: { elevated: '#eee', foreground: '#111', mutedForeground: '#555', muted: '#ddd' },
|
||||
interactive: { border: '#bbb' },
|
||||
primary: { base: '#f00' },
|
||||
},
|
||||
}];
|
||||
let rendererThemeIndex = 0;
|
||||
const rendererTheme = () => rendererThemes[rendererThemeIndex] ?? rendererThemes[0];
|
||||
const rendererUiState = {
|
||||
codeBlockLineWrap: false,
|
||||
mermaidRenderingMode: 'svg',
|
||||
setCodeBlockLineWrap: () => undefined,
|
||||
openContextPreview: () => undefined,
|
||||
};
|
||||
|
||||
const fakeReact = {
|
||||
useCallback: <T>(callback: T): T => {
|
||||
hookCursor += 1;
|
||||
return callback;
|
||||
},
|
||||
useEffect: (effect: () => void | (() => void)) => { passiveEffects.push(effect); },
|
||||
useLayoutEffect: (effect: () => void) => { layoutEffects.push(effect); },
|
||||
useMemo: <T>(factory: () => T): T => {
|
||||
hookCursor += 1;
|
||||
return factory();
|
||||
},
|
||||
useRef: <T>(current: T) => {
|
||||
void current;
|
||||
const index = hookCursor;
|
||||
hookCursor += 1;
|
||||
if (!hookStates[index]) hookStates[index] = { current: null };
|
||||
// SAFETY: this test hook preserves one mutable ref slot per hook index.
|
||||
return hookStates[index] as { current: T };
|
||||
},
|
||||
memo: <T>(component: T): T => component,
|
||||
};
|
||||
|
||||
const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => {
|
||||
const ref = props?.ref;
|
||||
// SAFETY: the renderer test installs the typed fake document before JSX is
|
||||
// evaluated; this branch only supplies its fake element factory.
|
||||
const fakeDocument = activeFakeDocument;
|
||||
if (!fakeDocument) throw new Error('Renderer fake document is not installed');
|
||||
const element = ref?.current ?? makeFakeElement(fakeDocument);
|
||||
if (!ref?.current) {
|
||||
element.childNodes.length = 0;
|
||||
element.children.length = 0;
|
||||
}
|
||||
if (props) {
|
||||
if (ref) ref.current = element;
|
||||
if (props.className) element.setAttribute('class', props.className);
|
||||
if (props['data-markdown-content']) element.setAttribute('data-markdown-content', '');
|
||||
}
|
||||
const jsxChildren = props?.children;
|
||||
const allChildren = jsxChildren === undefined ? children : Array.isArray(jsxChildren) ? jsxChildren : [jsxChildren];
|
||||
for (const child of allChildren) {
|
||||
if (child) element.appendChild(child);
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
mock.module('react', () => ({ default: fakeReact }));
|
||||
mock.module('react/jsx-runtime', () => ({ jsx: fakeJsx, jsxs: fakeJsx, Fragment: 'fragment' }));
|
||||
mock.module('react/jsx-dev-runtime', () => ({ jsxDEV: fakeJsx, Fragment: 'fragment' }));
|
||||
mock.module('beautiful-mermaid', () => ({
|
||||
renderMermaidASCII: () => '',
|
||||
renderMermaidSVG: (_source: string, colors: { bg: string }) => colors.bg,
|
||||
}));
|
||||
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
|
||||
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => `${key}:${currentContextVersion}` }) }));
|
||||
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
|
||||
mock.module('@/lib/url', () => ({
|
||||
getUrlScheme: () => null,
|
||||
isAppLinkUrl: () => false,
|
||||
isExternalHttpUrl: () => false,
|
||||
openConfirmedAppLinkUrl: async () => false,
|
||||
openExternalUrl: async () => undefined,
|
||||
}));
|
||||
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => ({ currentTheme: rendererTheme() }) }));
|
||||
mock.module('@/lib/theme/themes', () => ({ getDefaultTheme: () => rendererTheme() }));
|
||||
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: FakeElement | FakeElement[] }) => children }));
|
||||
type RendererUiSelectorResult = boolean | string | (() => void);
|
||||
const fakeUseUIStore = Object.assign(
|
||||
(selector: (state: typeof rendererUiState) => RendererUiSelectorResult) => selector(rendererUiState),
|
||||
{ getState: () => rendererUiState },
|
||||
);
|
||||
mock.module('@/stores/useUIStore', () => ({ useUIStore: fakeUseUIStore }));
|
||||
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
|
||||
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
|
||||
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
|
||||
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
|
||||
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
|
||||
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '' }));
|
||||
mock.module('./markdown/markdownCore', () => ({
|
||||
getCachedMarkdownBlocks: () => cachedRendererBlocks,
|
||||
renderMarkdownBlocks: () => renderMarkdownBlocksForTest(),
|
||||
renderMarkdownSync: () => {
|
||||
syncRenderCalls += 1;
|
||||
return '<p>cold</p>';
|
||||
},
|
||||
}));
|
||||
mock.module('./markdown/markdownTheme', () => ({ ensureMarkdownShikiTheme: () => undefined }));
|
||||
mock.module('./markdown/markdownSyntaxVars', () => ({ getMarkdownSyntaxVars: () => ({}) }));
|
||||
mock.module('./markdown/detachedMarkdownDomCache', () => ({
|
||||
detachedMarkdownDomCache: {
|
||||
take: () => null,
|
||||
store: () => undefined,
|
||||
},
|
||||
}));
|
||||
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'runtime' }));
|
||||
type TestDecorateContext = {
|
||||
labels: { copy: string };
|
||||
codeBlockLineWrap: boolean;
|
||||
renderMermaid: (source: string) => { svg?: string };
|
||||
};
|
||||
mock.module('./markdown/decorate', () => ({
|
||||
attachMarkdownInteractions: () => () => undefined,
|
||||
applyMarkdownCodeBlockWrapState: () => undefined,
|
||||
decorateMarkdown: (root: FakeElement, ctx: TestDecorateContext) => {
|
||||
decorateCalls += 1;
|
||||
if (root.getAttribute('data-test-decoration-marker') === 'true') return;
|
||||
root.setAttribute('data-test-decoration-marker', 'true');
|
||||
root.setAttribute(
|
||||
'data-test-decoration',
|
||||
`${ctx.labels.copy}|${ctx.codeBlockLineWrap}|${ctx.renderMermaid('test').svg ?? ''}`,
|
||||
);
|
||||
},
|
||||
getMarkdownCodeText: () => '',
|
||||
}));
|
||||
mock.module('./markdown/textPosition', () => ({ findTextPosition: () => null }));
|
||||
mock.module('./markdown/mermaidViewer', () => ({
|
||||
createMermaidViewerRegistry: () => {
|
||||
mermaidRegistryCreates += 1;
|
||||
return {
|
||||
refresh: () => undefined,
|
||||
cleanup: () => { mermaidRegistryCleanups += 1; },
|
||||
};
|
||||
},
|
||||
MERMAID_BLOCK_SELECTOR: '[data-markdown="mermaid-block"]',
|
||||
shouldRefreshMermaidViewers: (container: Pick<FakeElement, 'querySelector'>) => container.querySelector('[data-markdown="mermaid-block"]') !== null,
|
||||
}));
|
||||
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
|
||||
mock.module('morphdom', () => ({ default: () => { morphCalls += 1; } }));
|
||||
|
||||
const { MarkdownRenderer } = await import('./MarkdownRendererImpl');
|
||||
|
||||
const resetRendererTestState = () => {
|
||||
cachedRendererBlocks = null;
|
||||
renderedRendererBlocks = [];
|
||||
renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
|
||||
syncRenderCalls = 0;
|
||||
morphCalls = 0;
|
||||
decorateCalls = 0;
|
||||
mermaidRegistryCreates = 0;
|
||||
mermaidRegistryCleanups = 0;
|
||||
hookCursor = 0;
|
||||
hookStates = [];
|
||||
layoutEffects.length = 0;
|
||||
passiveEffects.length = 0;
|
||||
currentContextVersion = 0;
|
||||
rendererThemeIndex = 0;
|
||||
rendererUiState.codeBlockLineWrap = false;
|
||||
};
|
||||
|
||||
const beginRendererRender = () => {
|
||||
hookCursor = 0;
|
||||
return renderMarkdownForTest();
|
||||
};
|
||||
|
||||
const rendererRoot = (value: ReturnType<typeof renderMarkdownForTest>): FakeElement => {
|
||||
if (!(value instanceof Object) || !('childNodes' in value) || !('getAttribute' in value)) {
|
||||
throw new Error('Renderer test did not return its fake JSX root');
|
||||
}
|
||||
// SAFETY: the structural check confirms this ReactNode is the object
|
||||
// returned by the mocked JSX runtime.
|
||||
const candidate = value as object;
|
||||
// SAFETY: the mocked JSX runtime creates the complete FakeElement shape.
|
||||
return candidate as FakeElement;
|
||||
};
|
||||
|
||||
const runRendererLayoutEffects = () => {
|
||||
const pending = layoutEffects.splice(0);
|
||||
for (const effect of pending) effect();
|
||||
};
|
||||
|
||||
const runRendererPassiveEffects = () => passiveEffects.splice(0).map((effect) => effect());
|
||||
|
||||
const findBlock = (root: FakeElement, id: string): FakeElement | null => {
|
||||
if (root.getAttribute('data-md-id') === id) return root;
|
||||
for (const child of root.children) {
|
||||
const match = findBlock(child, id);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderMarkdownForTest = () => MarkdownRenderer({
|
||||
content: 'cached markdown',
|
||||
messageId: 'message-1',
|
||||
isAnimated: false,
|
||||
isStreaming: false,
|
||||
});
|
||||
|
||||
const withRendererDom = async (run: () => void | Promise<void>): Promise<void> => {
|
||||
const restoreDom = installRendererDom();
|
||||
const previousThemeIndex = rendererThemeIndex;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
rendererThemeIndex = previousThemeIndex;
|
||||
restoreDom();
|
||||
}
|
||||
};
|
||||
|
||||
describe('parseFileReference', () => {
|
||||
test('returns null for empty or whitespace input', () => {
|
||||
expect(parse('')).toBeNull();
|
||||
@@ -72,11 +440,7 @@ describe('parseFileReference', () => {
|
||||
});
|
||||
|
||||
test('preserves line:col form (does not interpret as range)', () => {
|
||||
expect(parse('src/foo.ts:42:8')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
column: 8,
|
||||
});
|
||||
expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 });
|
||||
});
|
||||
|
||||
test('preserves hash form', () => {
|
||||
@@ -110,3 +474,120 @@ describe('localPathFromFileUrl', () => {
|
||||
expect(localPathFromFileUrl('file:///tmp/bad%ZZpath')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownRenderer warm settled path', () => {
|
||||
test('installs cached blocks without sync fallback and skips same-ID morph', async () => {
|
||||
await withRendererDom(async () => {
|
||||
resetRendererTestState();
|
||||
cachedRendererBlocks = [{ id: 'full:cached', html: '<p>cached</p>' }];
|
||||
renderedRendererBlocks = cachedRendererBlocks;
|
||||
syncRenderCalls = 0;
|
||||
morphCalls = 0;
|
||||
decorateCalls = 0;
|
||||
|
||||
// SAFETY: the test JSX adapter returns the fake element assigned to the
|
||||
// renderer container ref and exposes the DOM members used below.
|
||||
const root = rendererRoot(beginRendererRender());
|
||||
runRendererLayoutEffects();
|
||||
expect(syncRenderCalls).toBe(0);
|
||||
const block = findBlock(root, 'full:cached');
|
||||
expect(block).not.toBeNull();
|
||||
expect(block?.innerHTML).toBe('<p>cached</p>');
|
||||
expect(block?.getAttribute('data-md-block')).toBe('');
|
||||
expect(block?.getAttribute('data-md-id')).toBe('full:cached');
|
||||
expect(block?.style.display).toBe('contents');
|
||||
expect(decorateCalls).toBe(1);
|
||||
|
||||
runRendererPassiveEffects();
|
||||
await Promise.resolve();
|
||||
expect(morphCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('recreates the Mermaid registry after StrictMode-like cleanup without remounting blocks', () => {
|
||||
return withRendererDom(() => {
|
||||
resetRendererTestState();
|
||||
const mermaidHtml = '<div data-markdown="mermaid-block"><svg></svg></div>';
|
||||
cachedRendererBlocks = [{ id: 'full:mermaid', html: mermaidHtml }];
|
||||
renderedRendererBlocks = cachedRendererBlocks;
|
||||
mermaidRegistryCreates = 0;
|
||||
mermaidRegistryCleanups = 0;
|
||||
morphCalls = 0;
|
||||
|
||||
const root = rendererRoot(beginRendererRender());
|
||||
runRendererLayoutEffects();
|
||||
expect(mermaidRegistryCreates).toBe(1);
|
||||
const cleanups = runRendererPassiveEffects();
|
||||
for (const cleanup of cleanups) cleanup?.();
|
||||
expect(mermaidRegistryCleanups).toBe(1);
|
||||
|
||||
beginRendererRender();
|
||||
runRendererLayoutEffects();
|
||||
expect(mermaidRegistryCreates).toBe(2);
|
||||
expect(findBlock(root, 'full:mermaid')).not.toBeNull();
|
||||
expect(morphCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('redecorates a same-ID block when decoration context changes before async completion', async () => {
|
||||
await withRendererDom(async () => {
|
||||
resetRendererTestState();
|
||||
cachedRendererBlocks = [{
|
||||
id: 'full:context',
|
||||
html: '<div data-markdown="mermaid-block"><p>cached</p></div>',
|
||||
}];
|
||||
renderedRendererBlocks = cachedRendererBlocks;
|
||||
|
||||
const root = rendererRoot(beginRendererRender());
|
||||
runRendererLayoutEffects();
|
||||
const block = findBlock(root, 'full:context');
|
||||
const firstDecorationId = block?.getAttribute('data-md-decoration-id');
|
||||
expect(firstDecorationId).not.toBeNull();
|
||||
const firstDecorateCalls = decorateCalls;
|
||||
|
||||
rendererThemeIndex = 1;
|
||||
currentContextVersion = 1;
|
||||
rendererUiState.codeBlockLineWrap = true;
|
||||
beginRendererRender();
|
||||
runRendererLayoutEffects();
|
||||
runRendererPassiveEffects();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(decorateCalls).toBeGreaterThan(firstDecorateCalls);
|
||||
expect(syncRenderCalls).toBe(0);
|
||||
expect(morphCalls).toBe(0);
|
||||
const updatedBlock = findBlock(root, 'full:context');
|
||||
expect(updatedBlock?.getAttribute('data-md-decoration-id')).not.toBe(firstDecorationId);
|
||||
expect(updatedBlock?.getAttribute('data-test-decoration')).toContain(':1|true|#eee');
|
||||
expect(updatedBlock?.getAttribute('data-test-decoration-marker')).toBe('true');
|
||||
expect(mermaidRegistryCleanups).toBeGreaterThan(0);
|
||||
expect(mermaidRegistryCreates).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects an older async render after a newer layout commit', async () => {
|
||||
await withRendererDom(async () => {
|
||||
resetRendererTestState();
|
||||
cachedRendererBlocks = [{ id: 'full:initial', html: '<p>initial</p>' }];
|
||||
let resolveOldRender: ((blocks: Array<{ id: string; html: string }>) => void) | undefined;
|
||||
const oldRender = new Promise<Array<{ id: string; html: string }>>((resolve) => {
|
||||
resolveOldRender = resolve;
|
||||
});
|
||||
renderMarkdownBlocksForTest = () => oldRender;
|
||||
|
||||
beginRendererRender();
|
||||
runRendererLayoutEffects();
|
||||
runRendererPassiveEffects();
|
||||
|
||||
cachedRendererBlocks = [{ id: 'full:new', html: '<p>new</p>' }];
|
||||
beginRendererRender();
|
||||
runRendererLayoutEffects();
|
||||
expect(resolveOldRender).toBeDefined();
|
||||
resolveOldRender?.([{ id: 'full:old-late', html: '<p>old late</p>' }]);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(morphCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -20,7 +20,12 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/l
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
||||
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { renderMarkdownBlocks, renderMarkdownSync, type MarkdownImageMode } from './markdown/markdownCore';
|
||||
import {
|
||||
getCachedMarkdownBlocks,
|
||||
renderMarkdownBlocks,
|
||||
renderMarkdownSync,
|
||||
type MarkdownImageMode,
|
||||
} from './markdown/markdownCore';
|
||||
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
|
||||
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
|
||||
import {
|
||||
@@ -45,6 +50,8 @@ import {
|
||||
} from './fileReferenceParser';
|
||||
import { fileReferenceExists } from './fileReferenceStat';
|
||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||
import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
const useCurrentMermaidTheme = () => {
|
||||
const themeSystem = useOptionalThemeSystem();
|
||||
@@ -345,6 +352,13 @@ const useFileReferenceInteractions = ({
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
// Wait for the real directory: annotating against an empty/fallback
|
||||
// directory issues stat probes under the wrong cache key (and the wrong
|
||||
// server directory), and the pass reruns anyway once the directory
|
||||
// resolves — every link ended up verified twice.
|
||||
if (enabled && !effectiveDirectory) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
|
||||
// On mobile surfaces, file-reference highlighting is disabled entirely — not
|
||||
@@ -398,6 +412,19 @@ const useFileReferenceInteractions = ({
|
||||
};
|
||||
|
||||
const annotateFileLinks = () => {
|
||||
annotationWriteDepth += 1;
|
||||
try {
|
||||
annotateFileLinksInner();
|
||||
} finally {
|
||||
// Let the mutation events from our own writes flush before the
|
||||
// observer starts listening for real content changes again.
|
||||
queueMicrotask(() => {
|
||||
annotationWriteDepth -= 1;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const annotateFileLinksInner = () => {
|
||||
if (fileReferencesEnabled) {
|
||||
wrapBlockCodePathTokens(container);
|
||||
}
|
||||
@@ -526,7 +553,12 @@ const useFileReferenceInteractions = ({
|
||||
|
||||
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
|
||||
|
||||
// Our own annotation writes (path-token wrapping, attribute updates) fire
|
||||
// childList mutations too; observing them re-ran the whole pass — every
|
||||
// link was scanned and verified twice per render.
|
||||
let annotationWriteDepth = 0;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (annotationWriteDepth > 0) return;
|
||||
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
|
||||
});
|
||||
observer.observe(container, {
|
||||
@@ -659,6 +691,19 @@ const useMermaidInlineInteractions = ({
|
||||
// so a stable diagram is laid out once and served from cache thereafter.
|
||||
const MERMAID_RENDER_CACHE = new Map<string, MermaidRender>();
|
||||
const MERMAID_RENDER_CACHE_MAX = 100;
|
||||
const MARKDOWN_DECORATION_ID_ATTR = 'data-md-decoration-id';
|
||||
const MARKDOWN_DECORATION_IDS = new WeakMap<DecorateContext, string>();
|
||||
let nextMarkdownDecorationId = 0;
|
||||
const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
|
||||
|
||||
const getMarkdownDecorationId = (ctx: DecorateContext): string => {
|
||||
const existing = MARKDOWN_DECORATION_IDS.get(ctx);
|
||||
if (existing) return existing;
|
||||
const id = `decoration-${nextMarkdownDecorationId}`;
|
||||
nextMarkdownDecorationId += 1;
|
||||
MARKDOWN_DECORATION_IDS.set(ctx, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
const cachedMermaidRender = (key: string, compute: () => MermaidRender): MermaidRender => {
|
||||
const existing = MERMAID_RENDER_CACHE.get(key);
|
||||
@@ -743,6 +788,7 @@ const useMorphdomMarkdown = ({
|
||||
imageMode = 'inline',
|
||||
syntaxVars,
|
||||
ctx,
|
||||
domCacheKey,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
text: string;
|
||||
@@ -750,12 +796,20 @@ const useMorphdomMarkdown = ({
|
||||
imageMode?: MarkdownImageMode;
|
||||
syntaxVars: Record<string, string>;
|
||||
ctx: DecorateContext;
|
||||
domCacheKey?: DetachedMarkdownDomKey | null;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
ensureMarkdownShikiTheme();
|
||||
}, []);
|
||||
|
||||
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
|
||||
const renderRevisionRef = React.useRef(0);
|
||||
// Only DOM that was actually restored or completed by the async pipeline is
|
||||
// eligible for capture. A fallback from an earlier content revision is not.
|
||||
const mountedDomRef = React.useRef<{
|
||||
key: DetachedMarkdownDomKey;
|
||||
copiedLabel: string;
|
||||
} | null>(null);
|
||||
const refreshMermaidViewers = React.useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
@@ -771,6 +825,63 @@ const useMorphdomMarkdown = ({
|
||||
mermaidViewerRef.current.refresh();
|
||||
}, [containerRef]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
renderRevisionRef.current += 1;
|
||||
mountedDomRef.current = null;
|
||||
}, [ctx, imageMode, streaming, text]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!domCacheKey) return;
|
||||
const container = containerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
if (!target || target.childNodes.length > 0) return;
|
||||
|
||||
const cached = detachedMarkdownDomCache.take(domCacheKey);
|
||||
if (cached) {
|
||||
target.appendChild(cached);
|
||||
const decorationId = getMarkdownDecorationId(ctx);
|
||||
for (const block of Array.from(target.children)) {
|
||||
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
}
|
||||
for (const [key, value] of Object.entries(syntaxVars)) target.style.setProperty(key, value);
|
||||
applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
|
||||
mountedDomRef.current = {
|
||||
key: domCacheKey,
|
||||
copiedLabel: ctx.labels.copied,
|
||||
};
|
||||
streamPerfCount('ui.markdown_renderer.dom_cache.hit');
|
||||
}
|
||||
}, [containerRef, ctx, domCacheKey, syntaxVars, text.length]);
|
||||
|
||||
// Restoration follows the cache identity above, but capture must only happen
|
||||
// when this renderer lifecycle ends. Combining both in one keyed effect would
|
||||
// detach the live DOM on ordinary content, theme, or locale updates.
|
||||
React.useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
if (!target) return;
|
||||
return () => {
|
||||
const mountedDom = mountedDomRef.current;
|
||||
if (!mountedDom) return;
|
||||
// Viewer controllers and transient interaction state belong to the
|
||||
// current renderer instance and must not cross the cache boundary.
|
||||
if (target.childNodes.length === 0 || shouldRefreshMermaidViewers(target)) return;
|
||||
if (Array.from(target.children).some((block) => !block.hasAttribute('data-md-id'))) return;
|
||||
if (target.querySelector('[data-md-copy-pending]')) return;
|
||||
const selection = window.getSelection();
|
||||
if (selection?.rangeCount && !selection.isCollapsed && selection.getRangeAt(0).intersectsNode(target)) return;
|
||||
const openMenu = target.querySelector<HTMLElement>('[data-md-menu]:not(.hidden)');
|
||||
const copiedButton = Array.from(target.querySelectorAll<HTMLButtonElement>('[data-md-action]'))
|
||||
.some((button) => button.getAttribute('title') === mountedDom.copiedLabel);
|
||||
if (openMenu || copiedButton) return;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(...Array.from(target.childNodes));
|
||||
detachedMarkdownDomCache.store({ ...mountedDom.key, fragment });
|
||||
streamPerfCount('ui.markdown_renderer.dom_cache.capture');
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
// Synchronous first paint: while the async parse is in-flight, show escaped
|
||||
// plain text immediately so there is no blank frame on initial mount. Only
|
||||
// runs when the target is empty — subsequent updates keep the prior rich DOM
|
||||
@@ -780,25 +891,40 @@ const useMorphdomMarkdown = ({
|
||||
const container = containerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
if (!target) return;
|
||||
const decorationId = getMarkdownDecorationId(ctx);
|
||||
if (text && target.childNodes.length === 0) {
|
||||
const block = document.createElement('div');
|
||||
block.setAttribute('data-md-block', '');
|
||||
// `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 = renderMarkdownSync(text, imageMode);
|
||||
// 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);
|
||||
if (shouldRefreshMermaidViewers(block)) {
|
||||
refreshMermaidViewers();
|
||||
const cachedBlocks = !streaming ? getCachedMarkdownBlocks(text, imageMode) : null;
|
||||
if (cachedBlocks) {
|
||||
let hasMermaidBlock = false;
|
||||
for (const cachedBlock of cachedBlocks) {
|
||||
const block = document.createElement('div');
|
||||
block.setAttribute('data-md-block', '');
|
||||
block.style.display = 'contents';
|
||||
block.innerHTML = cachedBlock.html;
|
||||
decorateMarkdown(block, ctx);
|
||||
block.setAttribute('data-md-id', cachedBlock.id);
|
||||
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
hasMermaidBlock ||= shouldRefreshMermaidViewers(block);
|
||||
target.appendChild(block);
|
||||
}
|
||||
if (hasMermaidBlock) refreshMermaidViewers();
|
||||
} else {
|
||||
const block = document.createElement('div');
|
||||
block.setAttribute('data-md-block', '');
|
||||
block.style.display = 'contents';
|
||||
block.innerHTML = renderMarkdownSync(text, imageMode);
|
||||
decorateMarkdown(block, ctx);
|
||||
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
target.appendChild(block);
|
||||
if (shouldRefreshMermaidViewers(block)) refreshMermaidViewers();
|
||||
}
|
||||
} else if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(target)) {
|
||||
// StrictMode re-runs this setup after the cleanup probe. The DOM remains,
|
||||
// but the viewer registry does not, so recreate it without reinstalling
|
||||
// or re-decorating ordinary blocks.
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
}, [containerRef, text, imageMode, ctx, refreshMermaidViewers]);
|
||||
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
mermaidViewerRef.current?.cleanup();
|
||||
@@ -810,27 +936,70 @@ const useMorphdomMarkdown = ({
|
||||
if (!container) return;
|
||||
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
let active = true;
|
||||
const renderRevision = renderRevisionRef.current;
|
||||
const decorationId = getMarkdownDecorationId(ctx);
|
||||
|
||||
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
|
||||
if (!active) return;
|
||||
if (!active || renderRevisionRef.current !== renderRevision) return;
|
||||
const existing = Array.from(target.children) as HTMLElement[];
|
||||
|
||||
// Reconcile per block: only re-morph blocks whose content changed, leaving
|
||||
// stable leading blocks untouched. Keeps per-stream-step DOM work bounded
|
||||
// to the trailing (growing) block instead of the whole message.
|
||||
let enteredThisPass = 0;
|
||||
blocks.forEach((block, index) => {
|
||||
let el = existing[index];
|
||||
let isNewBlock = false;
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.setAttribute('data-md-block', '');
|
||||
el.style.display = 'contents';
|
||||
target.appendChild(el);
|
||||
isNewBlock = true;
|
||||
}
|
||||
if (el.getAttribute('data-md-id') === block.id) {
|
||||
if (el.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId) {
|
||||
const hasMermaidBlock = shouldRefreshMermaidViewers(el);
|
||||
if (hasMermaidBlock) {
|
||||
mermaidViewerRef.current?.cleanup();
|
||||
mermaidViewerRef.current = null;
|
||||
}
|
||||
const replacement = document.createElement('div');
|
||||
replacement.setAttribute('data-md-block', '');
|
||||
replacement.style.display = 'contents';
|
||||
replacement.innerHTML = block.html;
|
||||
decorateMarkdown(replacement, ctx);
|
||||
replacement.setAttribute('data-md-id', block.id);
|
||||
replacement.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
el.replaceWith(replacement);
|
||||
if (hasMermaidBlock || shouldRefreshMermaidViewers(replacement)) refreshMermaidViewers();
|
||||
}
|
||||
if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(el)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (el.getAttribute('data-md-id') === block.id) return;
|
||||
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = block.html;
|
||||
decorateMarkdown(temp, ctx);
|
||||
if (isNewBlock && streaming && index > 0) {
|
||||
// A freshly committed block enters with a short reveal. The class
|
||||
// goes on the block's children — the wrapper is display:contents
|
||||
// and cannot animate — and the transform never changes layout, so
|
||||
// row measurement stays exact. Skipped for the first block so a
|
||||
// full initial render does not shimmer. Several blocks committed
|
||||
// in one tick cascade with a small stagger instead of popping in
|
||||
// together.
|
||||
const delayMs = Math.min(enteredThisPass, 4) * 55;
|
||||
enteredThisPass += 1;
|
||||
for (const child of Array.from(temp.children)) {
|
||||
child.classList.add('oc-md-block-enter');
|
||||
if (delayMs > 0 && child instanceof HTMLElement) {
|
||||
child.style.setProperty('--oc-md-enter-delay', `${delayMs}ms`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const hadMermaidBlock = shouldRefreshMermaidViewers(el);
|
||||
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
|
||||
morphdom(el, temp, {
|
||||
@@ -838,12 +1007,12 @@ const useMorphdomMarkdown = ({
|
||||
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
|
||||
});
|
||||
el.setAttribute('data-md-id', block.id);
|
||||
el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
});
|
||||
|
||||
// Remove any trailing block elements no longer present.
|
||||
const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
|
||||
let removedMermaidBlock = false;
|
||||
for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
|
||||
@@ -856,13 +1025,15 @@ const useMorphdomMarkdown = ({
|
||||
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
|
||||
mountedDomRef.current = domCacheKey
|
||||
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
|
||||
: null;
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
|
||||
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -943,6 +1114,33 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
||||
const { locale } = useI18n();
|
||||
const imageMode: MarkdownImageMode = variant === 'assistant' ? 'label' : 'inline';
|
||||
const settledPart = part
|
||||
&& (part.type === 'text' || part.type === 'reasoning')
|
||||
&& part.time?.end !== undefined
|
||||
? part
|
||||
: null;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
// Memoized on scalar identities, not the part object: sync-store reducers
|
||||
// recreate part objects on unrelated updates, and an object-identity dep
|
||||
// re-ran the async render pipeline for identical content.
|
||||
const settledSessionID = settledPart?.sessionID;
|
||||
const settledMessageID = settledPart?.messageID;
|
||||
const settledPartID = settledPart?.id;
|
||||
const domCacheKey = React.useMemo<DetachedMarkdownDomKey | null>(() => {
|
||||
// Streaming, unfinished, oversized, and identity-less Markdown continues
|
||||
// through the normal rendering pipeline and never retains detached DOM.
|
||||
if (isStreaming || !settledSessionID || !settledMessageID || !settledPartID || content.length === 0 || content.length > MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS) return null;
|
||||
// content.length is a cheap fingerprint: an edited or reverted part that
|
||||
// re-materializes under the same id must not restore the old DOM.
|
||||
return {
|
||||
scope: `${runtimeKey}\0${settledSessionID}`,
|
||||
id: `${settledMessageID}\0${settledPartID}\0${imageMode}\0${content.length}`,
|
||||
locale,
|
||||
directory: effectiveDirectory,
|
||||
};
|
||||
}, [content.length, effectiveDirectory, imageMode, isStreaming, locale, runtimeKey, settledSessionID, settledMessageID, settledPartID]);
|
||||
// Identity for the fade-in wrapper: a new part/message restarts the animation.
|
||||
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
|
||||
@@ -950,9 +1148,10 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
containerRef,
|
||||
text: content,
|
||||
streaming: live,
|
||||
imageMode: variant === 'assistant' ? 'label' : 'inline',
|
||||
imageMode,
|
||||
syntaxVars,
|
||||
ctx,
|
||||
domCacheKey,
|
||||
});
|
||||
|
||||
const markdownContent = (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
|
||||
>
|
||||
<Icon name="file-edit" className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
|
||||
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
|
||||
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">
|
||||
<span className="composer-status-bar__changed-label min-w-0 typography-ui-label text-foreground truncate">
|
||||
{t('chat.pendingChanges.changedInWorkspace')}
|
||||
</span>
|
||||
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
|
||||
|
||||
@@ -1,123 +1,18 @@
|
||||
import React from "react";
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDirectorySync } from "@/sync/sync-context";
|
||||
import type { Todo } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
// Compat aliases for old TodoItem shape
|
||||
type TodoItem = Todo & { id?: string };
|
||||
type TodoStatus = string;
|
||||
type TodoPriority = string;
|
||||
import { useUIStore } from "@/stores/useUIStore";
|
||||
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
|
||||
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
||||
import { isVSCodeRuntime } from "@/lib/desktop";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
// The floating assistant-status chip that hovers above the composer while the
|
||||
// agent works ("Claude is working…", abort notice). ONLY that. The composer's
|
||||
// own bar — pending changes, todos dropdown — is ComposerStatusBar: they used
|
||||
// to share this component, and every restyle of this chip (glass, placement)
|
||||
// silently dragged the composer bar and its dropdown along with it.
|
||||
|
||||
const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" };
|
||||
|
||||
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
|
||||
in_progress: {
|
||||
textClassName: "text-foreground",
|
||||
},
|
||||
pending: {
|
||||
textClassName: "text-foreground",
|
||||
},
|
||||
completed: {
|
||||
textClassName: "text-muted-foreground line-through",
|
||||
},
|
||||
cancelled: {
|
||||
textClassName: "text-muted-foreground line-through",
|
||||
},
|
||||
};
|
||||
|
||||
const priorityClassName: Record<TodoPriority, string> = {
|
||||
high: "text-[var(--status-warning)]",
|
||||
medium: "text-muted-foreground",
|
||||
low: "text-muted-foreground/70",
|
||||
};
|
||||
|
||||
const priorityIcon: Record<TodoPriority, React.ReactNode> = {
|
||||
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true"/>,
|
||||
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
|
||||
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
|
||||
};
|
||||
|
||||
const statusLabelKey: Record<TodoStatus, string> = {
|
||||
in_progress: "chat.statusRow.todo.status.inProgress",
|
||||
pending: "chat.statusRow.todo.status.pending",
|
||||
completed: "chat.statusRow.todo.status.completed",
|
||||
cancelled: "chat.statusRow.todo.status.cancelled",
|
||||
};
|
||||
|
||||
const priorityLabelKey: Record<TodoPriority, string> = {
|
||||
high: "chat.statusRow.todo.priority.high",
|
||||
medium: "chat.statusRow.todo.priority.medium",
|
||||
low: "chat.statusRow.todo.priority.low",
|
||||
};
|
||||
|
||||
interface TodoItemRowProps {
|
||||
todo: TodoItem;
|
||||
}
|
||||
|
||||
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
|
||||
const { t } = useI18n();
|
||||
const config = statusConfig[todo.status] || statusConfig.pending;
|
||||
const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
|
||||
const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
|
||||
|
||||
const statusIcon =
|
||||
todo.status === "in_progress" ? (
|
||||
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true"/>
|
||||
) : todo.status === "completed" ? (
|
||||
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true"/>
|
||||
) : (
|
||||
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true"/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center min-w-0 py-0.5 gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex-shrink-0">{statusIcon}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
{t(statusKey as never)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 typography-ui-label",
|
||||
config.textClassName
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
|
||||
priorityClassName[todo.priority] ?? priorityClassName.medium
|
||||
)}
|
||||
>
|
||||
{priorityIcon[todo.priority] ?? priorityIcon.medium}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{t(priorityKey as never)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EMPTY_TODOS: TodoItem[] = [];
|
||||
|
||||
interface StatusRowProps {
|
||||
// Working state
|
||||
isWorking?: boolean;
|
||||
statusText?: string | null;
|
||||
isGenericStatus?: boolean;
|
||||
@@ -125,17 +20,10 @@ interface StatusRowProps {
|
||||
wasAborted?: boolean;
|
||||
abortActive?: boolean;
|
||||
retryInfo?: { attempt?: number; next?: number } | null;
|
||||
// Abort state (for mobile/vscode)
|
||||
showAbort?: boolean;
|
||||
onAbort?: () => void;
|
||||
// Abort status display
|
||||
showAbortStatus?: boolean;
|
||||
showAssistantStatus?: boolean;
|
||||
showTodos?: boolean;
|
||||
agentName?: string;
|
||||
modelName?: string | null;
|
||||
providerId?: string | null;
|
||||
leftAccessory?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
@@ -146,192 +34,43 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
wasAborted,
|
||||
abortActive,
|
||||
retryInfo,
|
||||
showAbort,
|
||||
onAbort,
|
||||
showAbortStatus,
|
||||
showAssistantStatus = true,
|
||||
showTodos = true,
|
||||
agentName,
|
||||
modelName,
|
||||
providerId,
|
||||
leftAccessory,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
|
||||
[currentSessionId],
|
||||
),
|
||||
);
|
||||
const liveTodos = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
|
||||
return state.todo[currentSessionId] ?? EMPTY_TODOS;
|
||||
},
|
||||
[currentSessionId, showTodos],
|
||||
),
|
||||
);
|
||||
const persistedSessionTodos = useTodosPersistStore(
|
||||
React.useCallback(
|
||||
(state) => (showTodos && currentSessionId && currentSessionDirectory
|
||||
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
|
||||
: undefined),
|
||||
[currentSessionDirectory, currentSessionId, showTodos],
|
||||
),
|
||||
);
|
||||
const todos: TodoItem[] = React.useMemo(() => {
|
||||
if (!currentSessionId) return EMPTY_TODOS;
|
||||
if (liveTodos.length > 0) return liveTodos;
|
||||
return persistedSessionTodos ?? EMPTY_TODOS;
|
||||
}, [liveTodos, persistedSessionTodos, currentSessionId]);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const isCompact = isMobile || isVSCodeRuntime();
|
||||
|
||||
// Filter out cancelled todos for display and keep original order.
|
||||
// This prevents items from jumping around when status changes.
|
||||
const visibleTodos = React.useMemo(() => {
|
||||
return todos.filter((todo) => todo.status !== "cancelled");
|
||||
}, [todos]);
|
||||
|
||||
// Find the current active todo (first in_progress, or first pending)
|
||||
const activeTodo = React.useMemo(() => {
|
||||
return (
|
||||
visibleTodos.find((t) => t.status === "in_progress") ||
|
||||
visibleTodos.find((t) => t.status === "pending") ||
|
||||
null
|
||||
);
|
||||
}, [visibleTodos]);
|
||||
|
||||
// Calculate progress
|
||||
const progress = React.useMemo(() => {
|
||||
const total = todos.filter((t) => t.status !== "cancelled").length;
|
||||
const completed = todos.filter((t) => t.status === "completed").length;
|
||||
return { completed, total };
|
||||
}, [todos]);
|
||||
|
||||
const statusSummary = React.useMemo(() => {
|
||||
const active = visibleTodos.filter((t) => t.status === "in_progress").length;
|
||||
const left = visibleTodos.filter((t) => t.status === "in_progress" || t.status === "pending").length;
|
||||
return { active, left };
|
||||
}, [visibleTodos]);
|
||||
|
||||
const hasTodoContent = showTodos && statusSummary.left > 0;
|
||||
const hasAssistantContent = showAssistantStatus && (
|
||||
isWorking ||
|
||||
Boolean(wasAborted) ||
|
||||
Boolean(showAbortStatus)
|
||||
);
|
||||
const hasLeftAccessory = Boolean(leftAccessory);
|
||||
// Original logic from ChatInput
|
||||
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
||||
const hasContent = isWorking || Boolean(wasAborted) || Boolean(showAbortStatus);
|
||||
|
||||
const hasContent = hasAssistantContent || hasTodoContent || hasLeftAccessory;
|
||||
|
||||
// Close popover when clicking outside
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (!isExpanded) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isExpanded]);
|
||||
|
||||
const toggleExpanded = () => setIsExpanded((prev) => !prev);
|
||||
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
|
||||
active: statusSummary.active,
|
||||
left: statusSummary.left,
|
||||
});
|
||||
|
||||
// Abort button for mobile/vscode
|
||||
const abortButton = showAbort && onAbort ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
|
||||
aria-label={t('chat.statusRow.actions.stopGeneratingAria')}
|
||||
>
|
||||
<Icon name="close-circle" aria-hidden="true"/>
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
// Todo trigger button
|
||||
const todoTrigger = hasTodoContent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
|
||||
aria-label={todoSummaryLabel}
|
||||
title={todoSummaryLabel}
|
||||
>
|
||||
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
|
||||
{!isCompact && activeTodo ? (
|
||||
<span className="status-row__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
|
||||
{activeTodo.content}
|
||||
</span>
|
||||
) : (
|
||||
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
|
||||
)}
|
||||
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
|
||||
{statusSummary.active}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Icon name="time" className="h-3.5 w-3.5" />
|
||||
{statusSummary.left}
|
||||
</span>
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
// Don't render if nothing to show
|
||||
if (!hasContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
// This row must land exactly where the assistant turn footer (mt-2
|
||||
// inside the message) appears when the turn completes. Measured against
|
||||
// the live DOM: the gap ABOVE already matches (message pb-2 = footer
|
||||
// mt-2 = 8px), but the chat is bottom-anchored and the finished message
|
||||
// carries ~12px more structure BELOW its footer than this row has — so
|
||||
// the swap used to lift the line up. mb-6 (24px) reserves that space
|
||||
// under this row instead (verified: row top 636 == footer top 636).
|
||||
// The reservation belongs to the assistant-status swap only: a row that
|
||||
// renders just an accessory (the pending-changes bar) takes the normal
|
||||
// 8px, or it floats a stray gap above the composer.
|
||||
className={cn(showAssistantStatus ? "mb-6" : "mb-2", !hasLeftAccessory && "chat-column")}
|
||||
// The row renders inside the composer-anchored overlay, which owns the
|
||||
// distance to the input and the horizontal column (the same ones the
|
||||
// scroll-to-bottom pill uses).
|
||||
style={STATUS_ROW_CONTAINER_STYLE}
|
||||
>
|
||||
{/* h-8 matches the turn footer's real row height: its h-8 action
|
||||
buttons define the footer line, with the meta text centered in it. */}
|
||||
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
|
||||
{/* Left: Abort status | Working placeholder | leftAccessory */}
|
||||
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
|
||||
{showAssistantStatus && showAbortStatus ? (
|
||||
{/* The glass chip lives here, not on the container: the root above is
|
||||
an inline-size query container, whose width ignores its children —
|
||||
a shrink-to-fit wrapper around it always collapsed to zero. */}
|
||||
<div className="oc-glass-popover inline-flex w-max max-w-full items-center gap-2 h-8 whitespace-nowrap rounded-full [corner-shape:round] px-3">
|
||||
<div className="flex items-center min-w-0 gap-2 overflow-x-hidden">
|
||||
{showAbortStatus ? (
|
||||
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
|
||||
<span className="flex items-center gap-1.5 typography-ui-label">
|
||||
<Icon name="close-circle" aria-hidden="true"/>
|
||||
{t('chat.statusRow.aborted')}
|
||||
</span>
|
||||
</div>
|
||||
) : showAssistantStatus && shouldRenderPlaceholder ? (
|
||||
) : shouldRenderPlaceholder ? (
|
||||
<WorkingPlaceholder
|
||||
key={currentSessionId ?? "no-session"}
|
||||
isWorking={isWorking}
|
||||
@@ -343,50 +82,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
modelName={modelName}
|
||||
providerId={providerId}
|
||||
/>
|
||||
) : leftAccessory ? (
|
||||
leftAccessory
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Right: Abort (mobile only) + Todo */}
|
||||
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory ? "pr-1.5" : "-mr-3")} ref={popoverRef}>
|
||||
{abortButton}
|
||||
{todoTrigger}
|
||||
|
||||
{/* Popover dropdown */}
|
||||
{isExpanded && hasTodoContent && (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "min(28rem, calc(100cqw - 4ch))",
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
color: "var(--surface-elevated-foreground)",
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-0 bottom-full mb-1 z-50",
|
||||
"w-max min-w-[200px] rounded-xl p-1",
|
||||
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
|
||||
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
|
||||
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
|
||||
"duration-150"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
|
||||
<span>{t('chat.statusRow.tasksTitle')}</span>
|
||||
<span className="typography-meta tabular-nums">
|
||||
{progress.completed}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Todo list */}
|
||||
<div className="px-1 max-h-[200px] overflow-y-auto">
|
||||
{visibleTodos.map((todo, index) => (
|
||||
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,8 +46,6 @@ export const StatusRowContainer: React.FC = React.memo(() => {
|
||||
wasAborted={wasAborted || working.wasAborted}
|
||||
abortActive={wasAborted || working.abortActive}
|
||||
retryInfo={working.retryInfo}
|
||||
showAssistantStatus
|
||||
showTodos={false}
|
||||
agentName={currentAgentName}
|
||||
modelName={modelDisplayName}
|
||||
providerId={activeModel?.providerId ?? null}
|
||||
|
||||
@@ -26,9 +26,6 @@ import { QuestionCard } from '../QuestionCard';
|
||||
|
||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||
|
||||
/** Stable no-op so ChatMessage memoization keeps working in the read-only peek. */
|
||||
const NOOP_CONTENT_CHANGE = (): void => {};
|
||||
|
||||
/**
|
||||
* The `/btw` peek panel.
|
||||
*
|
||||
@@ -446,7 +443,6 @@ const BtwMessages: React.FC<{
|
||||
message={record}
|
||||
previousMessage={data.messageRecords[index - 1]}
|
||||
nextMessage={data.messageRecords[index + 1]}
|
||||
onContentChange={NOOP_CONTENT_CHANGE}
|
||||
isInActiveTurn={index === data.messageRecords.length - 1}
|
||||
activeStreamingPhase={
|
||||
record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null
|
||||
|
||||
@@ -1,33 +1,85 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||
|
||||
/**
|
||||
* Compact one-line mirror of the status row for the pill: same label, none of
|
||||
* the status row's animation machinery (which does not survive being squeezed
|
||||
* into a 32px chip).
|
||||
*/
|
||||
const PillWorkingStatus: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { activeModel, working } = useAssistantStatus();
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
|
||||
const modelName = React.useMemo(() => {
|
||||
if (!activeModel) return null;
|
||||
const provider = providers.find((candidate) => candidate.id === activeModel.providerId);
|
||||
return getProviderModelDisplayName(provider, activeModel.modelId) || null;
|
||||
}, [activeModel, providers]);
|
||||
|
||||
if (!working.isWorking || !working.statusText) return null;
|
||||
const status = working.statusText;
|
||||
const label = modelName && modelName.trim().length > 0
|
||||
? t('chat.statusRow.modelStatus', { model: modelName.trim(), status })
|
||||
: status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
return (
|
||||
<span className="min-w-0 truncate pr-3 text-sm text-muted-foreground">
|
||||
{label}
|
||||
<span className="animate-pulse"> …</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface ScrollToBottomButtonProps {
|
||||
visible: boolean;
|
||||
/** The session is still streaming: the pill carries the status label
|
||||
while the floating status row is hidden away from the live edge. */
|
||||
working?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, onClick }) => {
|
||||
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, working = false, onClick }) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-full left-1/2 -translate-x-1/2 mb-2 transition-all duration-150',
|
||||
visible ? 'opacity-100 translate-y-0 scale-100 pointer-events-auto' : 'opacity-0 translate-y-2 scale-95 pointer-events-none',
|
||||
'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
|
||||
visible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className="size-8 rounded-full [corner-shape:round] p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
|
||||
aria-label={t('chat.scrollToBottom.aria')}
|
||||
>
|
||||
<Icon name="arrow-down" className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* The same column that centres the composer, so the pill's left
|
||||
edge lines up exactly with the input frame. */}
|
||||
<div className="chat-input-column">
|
||||
{/* The soft shadow lives on this wrapper, away from the glass
|
||||
button's backdrop-filter: sharing one element made the
|
||||
shadow intermittently drop after hide/show cycles. */}
|
||||
<div className="inline-flex max-w-full rounded-full shadow-[0_2px_6px_-2px_rgb(0_0_0_/_0.10)] dark:shadow-[0_2px_6px_-2px_rgb(0_0_0_/_0.35)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={t('chat.scrollToBottom.aria')}
|
||||
className={cn(
|
||||
// Glass material with a hairline real border — much
|
||||
// lighter than the oc-glass-floating stack.
|
||||
'oc-glass-popover inline-flex h-8 max-w-full items-center rounded-full [corner-shape:round] text-left',
|
||||
'border border-black/[0.06] dark:border-white/[0.08]',
|
||||
visible ? 'pointer-events-auto' : 'pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<Icon name="arrow-down" className="h-4 w-4" />
|
||||
</span>
|
||||
{working && visible ? <PillWorkingStatus /> : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import ProgressiveGroup from '../message/parts/ProgressiveGroup';
|
||||
import type { TurnActivityRecord } from '../lib/turns/types';
|
||||
import type { ToolPopupContent } from '../message/types';
|
||||
import type { StreamPhase } from '../message/types';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
|
||||
interface DiffStats {
|
||||
additions: number;
|
||||
@@ -21,7 +20,6 @@ interface TurnActivityProps {
|
||||
expandedTools: Set<string>;
|
||||
onToggleTool: (toolId: string) => void;
|
||||
onShowPopup: (content: ToolPopupContent) => void;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
streamPhase: StreamPhase;
|
||||
showHeader: boolean;
|
||||
animateRows?: boolean;
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
CHAT_LIST_ANCHOR_OFFSET,
|
||||
getAnchoredTurnMetrics,
|
||||
getRowBottom,
|
||||
resolveChatListAnchoredEndSpace,
|
||||
resolveTimelineIsAtEnd,
|
||||
type TimelineListMeasurementState,
|
||||
} from './timelineScrollAnchoring';
|
||||
|
||||
const buildState = ({
|
||||
positions,
|
||||
sizes,
|
||||
scroll = 0,
|
||||
scrollLength = 700,
|
||||
}: {
|
||||
readonly positions: readonly number[];
|
||||
readonly sizes: readonly number[];
|
||||
readonly scroll?: number;
|
||||
readonly scrollLength?: number;
|
||||
}): TimelineListMeasurementState => ({
|
||||
data: positions.map((_, index) => index),
|
||||
scroll,
|
||||
scrollLength,
|
||||
positionAtIndex: (index) => positions[index],
|
||||
sizeAtIndex: (index) => sizes[index],
|
||||
});
|
||||
|
||||
describe('getRowBottom', () => {
|
||||
test('measures row bottoms from list row position and size', () => {
|
||||
const state = buildState({ positions: [0, 120], sizes: [80, 40] });
|
||||
|
||||
expect(getRowBottom(state, 1)).toBe(160);
|
||||
});
|
||||
|
||||
test('returns null for unmeasured rows', () => {
|
||||
const state = buildState({ positions: [0], sizes: [80] });
|
||||
|
||||
expect(getRowBottom(state, 5)).toBeNull();
|
||||
});
|
||||
|
||||
test('treats a zero-height row as one pixel tall', () => {
|
||||
const state = buildState({ positions: [0, 120], sizes: [120, 0] });
|
||||
|
||||
expect(getRowBottom(state, 1)).toBe(121);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnchoredTurnMetrics', () => {
|
||||
test('returns null for an empty timeline', () => {
|
||||
const state = buildState({ positions: [], sizes: [] });
|
||||
|
||||
expect(getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 0,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
test('treats the active turn as fitting when it fits above the composer', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 300, 460],
|
||||
sizes: [240, 80, 140],
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.turnHeight).toBe(300);
|
||||
expect(metrics?.usableViewportHeight).toBe(564);
|
||||
expect(metrics?.overflowsUsableViewport).toBe(false);
|
||||
expect(metrics?.targetScrollToRevealEnd).toBe(36);
|
||||
expect(metrics?.scrollDeltaToRevealEnd).toBe(36);
|
||||
});
|
||||
|
||||
test('targets the real row end instead of any temporary reserved tail', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 1720, 1880],
|
||||
sizes: [1600, 80, 120],
|
||||
scroll: 1900,
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.lastBottom).toBe(2000);
|
||||
expect(metrics?.targetScrollToRevealEnd).toBe(1436);
|
||||
expect(metrics?.scrollDeltaToRevealEnd).toBe(0);
|
||||
});
|
||||
|
||||
test('reports overflow only for the current anchored turn', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 900, 1180],
|
||||
sizes: [800, 220, 300],
|
||||
scroll: 900,
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.turnHeight).toBe(580);
|
||||
expect(metrics?.usableViewportHeight).toBe(564);
|
||||
expect(metrics?.overflowsUsableViewport).toBe(true);
|
||||
});
|
||||
|
||||
test('returns the minimal positive scroll delta needed to reveal the turn end', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 900, 1180],
|
||||
sizes: [800, 220, 360],
|
||||
scroll: 900,
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.lastBottom).toBe(1540);
|
||||
expect(metrics?.visibleUsableBottom).toBe(1464);
|
||||
expect(metrics?.scrollDeltaToRevealEnd).toBe(76);
|
||||
});
|
||||
|
||||
test('subtracts composer height from usable viewport height', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 300],
|
||||
sizes: [120, 470],
|
||||
scrollLength: 700,
|
||||
});
|
||||
|
||||
const withoutComposer = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 0,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
const withComposer = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 220,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(withoutComposer?.overflowsUsableViewport).toBe(false);
|
||||
expect(withComposer?.overflowsUsableViewport).toBe(true);
|
||||
});
|
||||
|
||||
test('clamps an out-of-range anchor index to the last row', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 300],
|
||||
sizes: [240, 80],
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 99,
|
||||
composerOverlayHeight: 0,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.anchorTop).toBe(300);
|
||||
expect(metrics?.turnHeight).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTimelineIsAtEnd', () => {
|
||||
test('uses a tight distance band against the full content length', () => {
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
|
||||
});
|
||||
|
||||
test('falls back to the list flags when distances are unavailable', () => {
|
||||
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
|
||||
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
|
||||
});
|
||||
|
||||
test('reports nothing without a state', () => {
|
||||
expect(resolveTimelineIsAtEnd(undefined)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveChatListAnchoredEndSpace', () => {
|
||||
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'a' }];
|
||||
|
||||
test('returns nothing when no anchor is set', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, null, (row) => row.id)).toBe(undefined);
|
||||
});
|
||||
|
||||
test('returns nothing when the anchor is not in the list', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, 'z', (row) => row.id)).toBe(undefined);
|
||||
});
|
||||
|
||||
test('resolves the last occurrence so a resent message anchors to its live row', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, 'a', (row) => row.id)).toEqual({
|
||||
anchorIndex: 2,
|
||||
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
});
|
||||
});
|
||||
|
||||
test('honours an explicit anchor offset', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, 'b', (row) => row.id, { anchorOffset: 40 })).toEqual({
|
||||
anchorIndex: 1,
|
||||
anchorOffset: 40,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// Anchored-turn scroll geometry for the chat timeline.
|
||||
//
|
||||
// The timeline has three mutually exclusive scroll modes:
|
||||
//
|
||||
// • `following-end` — stay pinned to the live edge as content grows.
|
||||
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
|
||||
// of the viewport and the reply streams into reserved space below it. The
|
||||
// viewport does NOT move until the turn outgrows the usable viewport.
|
||||
// • `free-scrolling` — the user took over; nothing moves the scroll
|
||||
// position until they opt back in.
|
||||
//
|
||||
// This module is pure geometry: it reads measurements from the virtualized
|
||||
// list and answers "how far, if at all, must we scroll to reveal the end of
|
||||
// the anchored turn". Keeping it free of DOM and React makes the mode machine
|
||||
// testable without a renderer.
|
||||
//
|
||||
// "Usable viewport" is the visible height minus the composer overlay (the
|
||||
// composer floats over the list) minus the anchor offset, so a turn is only
|
||||
// considered overflowing when it genuinely cannot be read.
|
||||
|
||||
export type TimelineScrollMode = 'following-end' | 'anchoring-new-turn' | 'free-scrolling';
|
||||
|
||||
// Distance from the top of the viewport at which an anchored user message
|
||||
// parks. Small enough to read as "at the top", large enough not to collide
|
||||
// with the timeline's top fade.
|
||||
export const CHAT_LIST_ANCHOR_OFFSET = 16;
|
||||
|
||||
export interface TimelineListMeasurementState {
|
||||
readonly data: readonly unknown[];
|
||||
readonly scroll: number;
|
||||
readonly scrollLength: number;
|
||||
readonly positionAtIndex: (index: number) => number | undefined;
|
||||
readonly sizeAtIndex: (index: number) => number | undefined;
|
||||
}
|
||||
|
||||
export interface AnchoredTurnMetrics {
|
||||
readonly anchorTop: number;
|
||||
readonly lastBottom: number;
|
||||
readonly turnHeight: number;
|
||||
readonly usableViewportHeight: number;
|
||||
readonly visibleUsableBottom: number;
|
||||
readonly overflowsUsableViewport: boolean;
|
||||
readonly targetScrollToRevealEnd: number;
|
||||
readonly scrollDeltaToRevealEnd: number;
|
||||
}
|
||||
|
||||
export const getRowBottom = (
|
||||
state: TimelineListMeasurementState,
|
||||
index: number,
|
||||
): number | null => {
|
||||
const top = state.positionAtIndex(index);
|
||||
const height = state.sizeAtIndex(index);
|
||||
if (
|
||||
typeof top !== 'number'
|
||||
|| typeof height !== 'number'
|
||||
|| !Number.isFinite(top)
|
||||
|| !Number.isFinite(height)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Rows measured at zero height would make an anchored turn look empty and
|
||||
// suppress the reveal scroll; treat them as one pixel tall instead.
|
||||
return top + Math.max(1, height);
|
||||
};
|
||||
|
||||
export const getAnchoredTurnMetrics = ({
|
||||
state,
|
||||
anchorIndex,
|
||||
composerOverlayHeight,
|
||||
anchorOffset,
|
||||
}: {
|
||||
readonly state: TimelineListMeasurementState;
|
||||
readonly anchorIndex: number;
|
||||
readonly composerOverlayHeight: number;
|
||||
readonly anchorOffset: number;
|
||||
}): AnchoredTurnMetrics | null => {
|
||||
if (state.data.length === 0) return null;
|
||||
|
||||
const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1));
|
||||
const anchorTop = state.positionAtIndex(boundedAnchorIndex);
|
||||
// The LAST row bottom, not the content length: the reserved anchored end
|
||||
// space lives past it, and targeting that reserved tail would scroll the
|
||||
// real content off the top.
|
||||
const lastBottom = getRowBottom(state, state.data.length - 1);
|
||||
if (typeof anchorTop !== 'number' || !Number.isFinite(anchorTop) || lastBottom === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usableViewportHeight = Math.max(
|
||||
0,
|
||||
state.scrollLength - composerOverlayHeight - anchorOffset,
|
||||
);
|
||||
const turnHeight = Math.max(0, lastBottom - anchorTop);
|
||||
const visibleUsableBottom = state.scroll + usableViewportHeight;
|
||||
const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight);
|
||||
// Never negative: revealing the end must not scroll the timeline backwards.
|
||||
const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll);
|
||||
|
||||
return {
|
||||
anchorTop,
|
||||
lastBottom,
|
||||
turnHeight,
|
||||
usableViewportHeight,
|
||||
visibleUsableBottom,
|
||||
overflowsUsableViewport: turnHeight > usableViewportHeight,
|
||||
targetScrollToRevealEnd,
|
||||
scrollDeltaToRevealEnd,
|
||||
};
|
||||
};
|
||||
|
||||
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
|
||||
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
|
||||
// follow while the user had genuinely scrolled away, yanking them back on the
|
||||
// next stream chunk. Distance is measured against the full content length —
|
||||
// reserved anchored end space included — so a parked anchored turn counts as
|
||||
// the live edge.
|
||||
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
|
||||
|
||||
export const resolveTimelineIsAtEnd = (
|
||||
state: {
|
||||
readonly contentLength?: number;
|
||||
readonly scroll?: number;
|
||||
readonly scrollLength?: number;
|
||||
readonly isNearEnd?: boolean;
|
||||
readonly isAtEnd?: boolean;
|
||||
} | undefined,
|
||||
): boolean | undefined => {
|
||||
if (!state) return undefined;
|
||||
const { contentLength, scroll, scrollLength } = state;
|
||||
if (
|
||||
typeof contentLength === 'number'
|
||||
&& typeof scroll === 'number'
|
||||
&& typeof scrollLength === 'number'
|
||||
&& Number.isFinite(contentLength)
|
||||
) {
|
||||
return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
|
||||
}
|
||||
return state.isNearEnd ?? state.isAtEnd;
|
||||
};
|
||||
|
||||
export interface ChatListAnchoredEndSpace {
|
||||
readonly anchorIndex: number;
|
||||
readonly anchorOffset: number;
|
||||
}
|
||||
|
||||
// Finds the anchored row from the BACK of the list: a retried or re-sent
|
||||
// message id can appear more than once, and the live one is always the last.
|
||||
export const resolveChatListAnchoredEndSpace = <Item, AnchorId>(
|
||||
items: readonly Item[],
|
||||
anchorId: AnchorId | null,
|
||||
getAnchorId: (item: Item) => AnchorId | null,
|
||||
options: { readonly anchorOffset?: number } = {},
|
||||
): ChatListAnchoredEndSpace | undefined => {
|
||||
if (anchorId === null) return undefined;
|
||||
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (item !== undefined && getAnchorId(item) === anchorId) {
|
||||
return {
|
||||
anchorIndex: index,
|
||||
anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { commitStreamedText } from './streamTextCommit';
|
||||
|
||||
describe('commitStreamedText', () => {
|
||||
test('holds an incomplete short paragraph entirely', () => {
|
||||
expect(commitStreamedText('An unfinished thought abo')).toBe('');
|
||||
});
|
||||
|
||||
test('commits up to the last complete line', () => {
|
||||
expect(commitStreamedText('First paragraph.\n\nSecond par')).toBe('First paragraph.\n\n');
|
||||
});
|
||||
|
||||
test('reveals code fences line by line', () => {
|
||||
const text = '```py\nprint("a")\nprint("b';
|
||||
expect(commitStreamedText(text)).toBe('```py\nprint("a")\n');
|
||||
});
|
||||
|
||||
test('releases a long held paragraph at the last sentence boundary', () => {
|
||||
const sentence = 'A finished sentence lives here. ';
|
||||
const text = sentence.repeat(12) + 'and an unfinished trail';
|
||||
expect(commitStreamedText(text)).toBe(sentence.repeat(12));
|
||||
});
|
||||
|
||||
test('falls back to the last word boundary without sentences', () => {
|
||||
const words = 'word '.repeat(70);
|
||||
const text = words + 'unfinishe';
|
||||
expect(commitStreamedText(text)).toBe(words);
|
||||
});
|
||||
|
||||
test('keeps unbreakable runs intact rather than splitting them', () => {
|
||||
const run = 'x'.repeat(400);
|
||||
expect(commitStreamedText(run)).toBe(run);
|
||||
});
|
||||
|
||||
test('empty input stays empty', () => {
|
||||
expect(commitStreamedText('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// Block-level streaming reveal.
|
||||
//
|
||||
// Token-by-token streaming mutates the trailing paragraph in place on every
|
||||
// tick: words rewrap, the last line jitters, and the reader's eye fights the
|
||||
// motion. Committing only up to the last COMPLETE line keeps every rendered
|
||||
// block immutable once it appears — prose arrives a paragraph at a time (a
|
||||
// markdown paragraph is one logical line), code fences reveal line by line,
|
||||
// tables row by row — and the only remaining motion is the follow scroll.
|
||||
//
|
||||
// A paragraph with no newline for a long stretch must not stall the stream,
|
||||
// so once the held tail outgrows a threshold it is committed at the last
|
||||
// sentence boundary (falling back to the last word boundary).
|
||||
|
||||
const HOLD_MAX_CHARS = 320;
|
||||
|
||||
const SENTENCE_END = /[.!?…][)"'»”’]?\s/g;
|
||||
|
||||
export const commitStreamedText = (text: string): string => {
|
||||
if (text.length === 0) return text;
|
||||
|
||||
const lastNewline = text.lastIndexOf('\n');
|
||||
const committed = lastNewline === -1 ? '' : text.slice(0, lastNewline + 1);
|
||||
const held = text.slice(committed.length);
|
||||
|
||||
if (held.length <= HOLD_MAX_CHARS) {
|
||||
return committed;
|
||||
}
|
||||
|
||||
// The held paragraph got long: release it up to the last finished
|
||||
// sentence so the block still never mutates mid-sentence.
|
||||
let lastSentenceEnd = -1;
|
||||
for (const match of held.matchAll(SENTENCE_END)) {
|
||||
lastSentenceEnd = match.index + match[0].length;
|
||||
}
|
||||
if (lastSentenceEnd > 0) {
|
||||
return committed + held.slice(0, lastSentenceEnd);
|
||||
}
|
||||
|
||||
// No sentence boundary either (a URL, a very long token run): release up
|
||||
// to the last word boundary, keeping only the incomplete word held.
|
||||
const lastSpace = held.lastIndexOf(' ');
|
||||
if (lastSpace > 0) {
|
||||
return committed + held.slice(0, lastSpace + 1);
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
@@ -64,8 +64,7 @@ describe('buildLiveStreamingEntry', () => {
|
||||
const entry = turnEntry(assistant);
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId: 'assistant_other',
|
||||
liveParts: [textPart('part_live', 'live')],
|
||||
livePartsByMessageId: { assistant_other: [textPart('part_live', 'live')] },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
@@ -79,8 +78,7 @@ describe('buildLiveStreamingEntry', () => {
|
||||
const liveParts = [reasoningPart('part_1_live', 'thinking')];
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId: 'assistant_1',
|
||||
liveParts,
|
||||
livePartsByMessageId: { assistant_1: liveParts },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
@@ -102,8 +100,7 @@ describe('buildLiveStreamingEntry', () => {
|
||||
const liveParts = [textPart('part_1_live', 'live')];
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId: 'assistant_1',
|
||||
liveParts,
|
||||
livePartsByMessageId: { assistant_1: liveParts },
|
||||
showTextJustificationActivity: false,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
@@ -121,8 +118,7 @@ describe('buildLiveStreamingEntry', () => {
|
||||
const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming');
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId: 'assistant_1',
|
||||
liveParts: [synthetic, visible],
|
||||
livePartsByMessageId: { assistant_1: [synthetic, visible] },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
@@ -131,4 +127,39 @@ describe('buildLiveStreamingEntry', () => {
|
||||
if (next.kind !== 'turn') return;
|
||||
expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]);
|
||||
});
|
||||
|
||||
test('keeps a finished step message on its live parts after the stream moves on', () => {
|
||||
const finished = message('assistant_1', 'assistant', 'user_1', []);
|
||||
const streaming = message('assistant_2', 'assistant', 'user_1', []);
|
||||
const entry = turnEntry(finished);
|
||||
if (entry.kind !== 'turn') return;
|
||||
entry.turn.assistantMessageIds = ['assistant_1', 'assistant_2'];
|
||||
entry.turn.assistantMessages = [finished, streaming];
|
||||
const finishedLive = [textPart('part_tool_done', 'tool output')];
|
||||
const streamingLive = [textPart('part_streaming', 'streaming')];
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
livePartsByMessageId: { assistant_1: finishedLive, assistant_2: streamingLive },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
|
||||
expect(next.kind).toBe('turn');
|
||||
if (next.kind !== 'turn') return;
|
||||
expect(next.turn.assistantMessages[0]?.parts).toEqual(finishedLive);
|
||||
expect(next.turn.assistantMessages[1]?.parts).toEqual(streamingLive);
|
||||
});
|
||||
|
||||
test('never erases record parts with an empty live array', () => {
|
||||
const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'kept')]);
|
||||
const entry = turnEntry(assistant);
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
livePartsByMessageId: { assistant_1: [] },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
|
||||
expect(next).toBe(entry);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,13 @@ export type StreamingTailEntry =
|
||||
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
|
||||
|
||||
type BuildLiveStreamingEntryOptions = {
|
||||
activeStreamingMessageId: string | null | undefined;
|
||||
liveParts: Part[];
|
||||
// Live parts for EVERY message of the streaming tail, not only the one
|
||||
// currently streaming: when the stream moves to the next step message, the
|
||||
// previous message's base record can still lag behind the part store, and
|
||||
// rendering it from that stale snapshot briefly drops its completed tool
|
||||
// parts — remounting them (and replaying their reveal animation) once the
|
||||
// record catches up.
|
||||
livePartsByMessageId: Readonly<Record<string, Part[]>>;
|
||||
showTextJustificationActivity: boolean;
|
||||
showTurnChangedFiles: boolean;
|
||||
mergeHiddenUserTurns?: { planModeEnabled: boolean };
|
||||
@@ -24,10 +29,12 @@ type BuildLiveStreamingEntryOptions = {
|
||||
|
||||
const withLiveParts = (
|
||||
message: ChatMessageEntry,
|
||||
activeStreamingMessageId: string,
|
||||
liveParts: Part[],
|
||||
livePartsByMessageId: Readonly<Record<string, Part[]>>,
|
||||
): ChatMessageEntry => {
|
||||
if (message.info.id !== activeStreamingMessageId || message.parts === liveParts) {
|
||||
const liveParts = livePartsByMessageId[message.info.id];
|
||||
// An empty live array is ambiguous — the store may simply not have loaded
|
||||
// this message's parts — and must never erase parts the record does have.
|
||||
if (!liveParts || liveParts.length === 0 || message.parts === liveParts) {
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -41,13 +48,10 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
|
||||
entry: TEntry,
|
||||
options: BuildLiveStreamingEntryOptions,
|
||||
): TEntry => {
|
||||
const activeStreamingMessageId = options.activeStreamingMessageId;
|
||||
if (!activeStreamingMessageId) {
|
||||
return entry;
|
||||
}
|
||||
const livePartsByMessageId = options.livePartsByMessageId;
|
||||
|
||||
if (entry.kind === 'ungrouped') {
|
||||
const message = withLiveParts(entry.message, activeStreamingMessageId, options.liveParts);
|
||||
const message = withLiveParts(entry.message, livePartsByMessageId);
|
||||
if (message === entry.message) {
|
||||
return entry;
|
||||
}
|
||||
@@ -59,7 +63,7 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
|
||||
|
||||
let changed = false;
|
||||
const assistantMessages = entry.turn.assistantMessages.map((message) => {
|
||||
const next = withLiveParts(message, activeStreamingMessageId, options.liveParts);
|
||||
const next = withLiveParts(message, livePartsByMessageId);
|
||||
if (next !== message) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -43,28 +43,30 @@ export type DecorateContext = {
|
||||
onPreviewLoopback?: (url: string) => void;
|
||||
};
|
||||
|
||||
// Reference the app's icon sprite (injected into <body> by the shared Icon
|
||||
// component) so DOM-built controls use the same themed icons as the rest of
|
||||
// the app. Sprite symbols are registered under `#oc-<name>`.
|
||||
const spriteIcon = (name: IconName): string =>
|
||||
`<svg class="remixicon size-3.5" viewBox="0 0 24 24" aria-hidden="true"><use href="#oc-${name}"></use></svg>`;
|
||||
|
||||
const ICONS = {
|
||||
copy: spriteIcon('file-copy'),
|
||||
check: spriteIcon('check'),
|
||||
download: spriteIcon('download'),
|
||||
zoomIn: spriteIcon('add'),
|
||||
zoomOut: spriteIcon('subtract'),
|
||||
fit: spriteIcon('refresh'),
|
||||
textWrap: spriteIcon('text-wrap'),
|
||||
image: spriteIcon('file-image'),
|
||||
} as const;
|
||||
copy: 'file-copy',
|
||||
check: 'check',
|
||||
download: 'download',
|
||||
zoomIn: 'add',
|
||||
zoomOut: 'subtract',
|
||||
fit: 'refresh',
|
||||
textWrap: 'text-wrap',
|
||||
image: 'file-image',
|
||||
} as const satisfies Record<string, IconName>;
|
||||
|
||||
const ICON_BTN_CLASS =
|
||||
'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--interactive-focus-ring)]';
|
||||
|
||||
const setIconHtml = (el: Element, html: string): void => {
|
||||
el.innerHTML = html;
|
||||
const setIcon = (el: Element, icon: keyof typeof ICONS): void => {
|
||||
const iconName = ICONS[icon];
|
||||
const svg = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('class', 'remixicon size-3.5');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
const use = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'use');
|
||||
use.setAttribute('href', `#oc-${iconName}`);
|
||||
svg.appendChild(use);
|
||||
el.replaceChildren(svg);
|
||||
};
|
||||
|
||||
const decorateImageLabels = (root: HTMLElement): void => {
|
||||
@@ -74,7 +76,7 @@ const decorateImageLabels = (root: HTMLElement): void => {
|
||||
icon.className = 'inline-flex shrink-0';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.setAttribute('data-openchamber-markdown-image-label-icon', 'true');
|
||||
setIconHtml(icon, ICONS.image);
|
||||
setIcon(icon, 'image');
|
||||
label.prepend(icon);
|
||||
}
|
||||
};
|
||||
@@ -86,7 +88,7 @@ const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string):
|
||||
button.setAttribute('data-md-action', slot);
|
||||
button.setAttribute('title', title);
|
||||
button.setAttribute('aria-label', title);
|
||||
setIconHtml(button, ICONS[icon]);
|
||||
setIcon(button, icon);
|
||||
return button;
|
||||
};
|
||||
|
||||
@@ -131,6 +133,9 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
const code = pre.querySelector<HTMLElement>(':scope > code');
|
||||
if (!code || code.hasAttribute('data-md-code-lines')) return;
|
||||
|
||||
// The real gutter takes over the reserved footprint.
|
||||
pre.removeAttribute('data-md-gutter-reserved');
|
||||
|
||||
const text = code.textContent ?? '';
|
||||
const hasTrailingNewline = text.endsWith('\n');
|
||||
const lines = hasTrailingNewline ? text.slice(0, -1).split('\n') : text.split('\n');
|
||||
@@ -196,11 +201,11 @@ export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: bool
|
||||
};
|
||||
|
||||
const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => {
|
||||
setIconHtml(button, ICONS.check);
|
||||
setIcon(button, 'check');
|
||||
button.setAttribute('title', copiedTitle);
|
||||
button.setAttribute('aria-label', copiedTitle);
|
||||
window.setTimeout(() => {
|
||||
setIconHtml(button, ICONS[restore]);
|
||||
setIcon(button, restore);
|
||||
button.setAttribute('title', restoreTitle);
|
||||
button.setAttribute('aria-label', restoreTitle);
|
||||
}, 2000);
|
||||
@@ -263,7 +268,15 @@ const decorateCodeBlocks = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
pre.style.margin = '0';
|
||||
pre.style.background = 'transparent';
|
||||
pre.classList.add('min-w-0', 'w-full', 'flex-1');
|
||||
if (!ctx.deferCodeLineNumberSync) layoutCodeLines(pre);
|
||||
if (!ctx.deferCodeLineNumberSync) {
|
||||
layoutCodeLines(pre);
|
||||
} else {
|
||||
// Streaming defers the per-line gutter markup, but the gutter's
|
||||
// horizontal footprint is reserved immediately — otherwise the
|
||||
// end-of-stream decorate pass shifts every code line right by the
|
||||
// gutter column and the finished message visibly jumps.
|
||||
pre.setAttribute('data-md-gutter-reserved', '');
|
||||
}
|
||||
body.appendChild(pre);
|
||||
wrapper.appendChild(header);
|
||||
wrapper.appendChild(body);
|
||||
@@ -492,7 +505,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
preview.setAttribute('data-md-url', href);
|
||||
preview.setAttribute('title', ctx.labels.previewTitle);
|
||||
preview.setAttribute('aria-label', ctx.labels.previewLabel);
|
||||
setIconHtml(preview, ICONS.download);
|
||||
setIcon(preview, 'download');
|
||||
anchor.parentNode?.insertBefore(preview, anchor.nextSibling);
|
||||
}
|
||||
}
|
||||
@@ -554,7 +567,12 @@ export const attachMarkdownInteractions = (
|
||||
if (action === 'copy-code') {
|
||||
const code = actionEl.closest('[data-component="markdown-code"]')?.querySelector('code');
|
||||
const text = code ? getMarkdownCodeText(code) : '';
|
||||
if (text) void copyTextToClipboard(text).then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy));
|
||||
if (text) {
|
||||
actionEl.setAttribute('data-md-copy-pending', '');
|
||||
void copyTextToClipboard(text)
|
||||
.then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy))
|
||||
.finally(() => actionEl.removeAttribute('data-md-copy-pending'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
import { DetachedMarkdownDomCache, type DetachedMarkdownDom } from './detachedMarkdownDomCache';
|
||||
|
||||
Object.assign(globalThis, { document: new Window().document });
|
||||
|
||||
const keyFor = ({ scope, id, locale, directory }: DetachedMarkdownDom) => ({ scope, id, locale, directory });
|
||||
|
||||
const createEntry = (
|
||||
document: Document,
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
partId: string,
|
||||
): DetachedMarkdownDom => {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const node = document.createElement('p');
|
||||
node.textContent = `${messageId}:${partId}`;
|
||||
fragment.appendChild(node);
|
||||
return {
|
||||
scope: `runtime:${sessionId}`,
|
||||
id: `${messageId}:${partId}`,
|
||||
locale: 'en',
|
||||
directory: '/repo-a',
|
||||
fragment,
|
||||
};
|
||||
};
|
||||
|
||||
describe('DetachedMarkdownDomCache', () => {
|
||||
test('consumes the original DOM fragment once and rejects another locale', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const entry = createEntry(document, 'session-a', 'message-a', 'part-a');
|
||||
const originalNode = entry.fragment.firstChild;
|
||||
|
||||
cache.store(entry);
|
||||
expect(cache.take({ ...keyFor(entry), locale: 'zh' })).toBeNull();
|
||||
cache.store(entry);
|
||||
const restored = cache.take(keyFor(entry));
|
||||
expect(restored?.firstChild).toBe(originalNode);
|
||||
expect(cache.take(keyFor(entry))).toBeNull();
|
||||
});
|
||||
|
||||
test('bounds entries per session and evicts the least recently used session', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
cache.store(createEntry(document, 'session-a', 'message-1', 'part'));
|
||||
cache.store(createEntry(document, 'session-a', 'message-2', 'part'));
|
||||
cache.store(createEntry(document, 'session-a', 'message-3', 'part'));
|
||||
cache.store(createEntry(document, 'session-b', 'message-4', 'part'));
|
||||
cache.store(createEntry(document, 'session-c', 'message-5', 'part'));
|
||||
expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
|
||||
expect(cache.take({
|
||||
scope: 'runtime:session-a',
|
||||
id: 'message-2:part',
|
||||
locale: 'en',
|
||||
directory: '/repo-a',
|
||||
})).toBeNull();
|
||||
expect(cache.take({
|
||||
scope: 'runtime:session-c',
|
||||
id: 'message-5:part',
|
||||
locale: 'en',
|
||||
directory: '/repo-a',
|
||||
})).not.toBeNull();
|
||||
});
|
||||
|
||||
test('isolates identities by runtime and replaces an identity without growing stats', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const first = createEntry(document, 'session', 'message', 'part');
|
||||
const replacement = createEntry(document, 'session', 'message', 'part');
|
||||
const replacementNode = replacement.fragment.firstChild;
|
||||
const otherRuntime = createEntry(document, 'other-runtime-session', 'message', 'part');
|
||||
|
||||
cache.store(first);
|
||||
cache.store(replacement);
|
||||
cache.store(otherRuntime);
|
||||
|
||||
expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
|
||||
expect(cache.take(keyFor(otherRuntime))).not.toBeNull();
|
||||
expect(cache.take(keyFor(replacement))?.firstChild).toBe(replacementNode);
|
||||
});
|
||||
|
||||
test('does not restore file-link DOM under another directory', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const entry = createEntry(document, 'session', 'message', 'part');
|
||||
|
||||
cache.store(entry);
|
||||
|
||||
expect(cache.take({ ...keyFor(entry), directory: '/repo-b' })).toBeNull();
|
||||
});
|
||||
|
||||
test('refreshes session LRU and clears all entries', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const sessionA = createEntry(document, 'session-a', 'message-a', 'part');
|
||||
const sessionB = createEntry(document, 'session-b', 'message-b', 'part');
|
||||
const sessionC = createEntry(document, 'session-c', 'message-c', 'part');
|
||||
cache.store(sessionA);
|
||||
cache.store(sessionB);
|
||||
cache.store(sessionA);
|
||||
cache.store(sessionC);
|
||||
expect(cache.take(keyFor(sessionB))).toBeNull();
|
||||
|
||||
cache.clear();
|
||||
expect(cache.stats()).toEqual({ sessions: 0, entries: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
export type DetachedMarkdownDomKey = {
|
||||
scope: string;
|
||||
id: string;
|
||||
locale: string;
|
||||
directory: string;
|
||||
};
|
||||
|
||||
export type DetachedMarkdownDom = DetachedMarkdownDomKey & {
|
||||
// The fragment owns the original nodes. take() consumes it once by moving
|
||||
// those nodes back into a renderer; nothing is cloned or serialized.
|
||||
fragment: DocumentFragment;
|
||||
};
|
||||
|
||||
export type DetachedMarkdownDomCacheStats = {
|
||||
sessions: number;
|
||||
entries: number;
|
||||
};
|
||||
|
||||
// Holds detached, fully decorated Markdown DOM. The cache is intentionally
|
||||
// small: it accelerates recent-session and reverse-scroll remounts without
|
||||
// retaining whole session trees or depending on browser-specific byte guesses.
|
||||
type DetachedMarkdownDomCacheLimits = {
|
||||
maxSessions: number;
|
||||
maxEntriesPerSession: number;
|
||||
};
|
||||
|
||||
type SessionCache = Map<string, DetachedMarkdownDom>;
|
||||
|
||||
const DEFAULT_LIMITS: DetachedMarkdownDomCacheLimits = {
|
||||
// Eight buckets cover a broader recent-session working set without
|
||||
// coupling eviction to React commit or microtask timing.
|
||||
maxSessions: 8,
|
||||
maxEntriesPerSession: 4,
|
||||
};
|
||||
|
||||
export class DetachedMarkdownDomCache {
|
||||
private readonly maxSessions: number;
|
||||
private readonly maxEntriesPerSession: number;
|
||||
private readonly sessions = new Map<string, SessionCache>();
|
||||
|
||||
constructor(limits: DetachedMarkdownDomCacheLimits = DEFAULT_LIMITS) {
|
||||
this.maxSessions = Math.max(1, limits.maxSessions);
|
||||
this.maxEntriesPerSession = Math.max(1, limits.maxEntriesPerSession);
|
||||
}
|
||||
|
||||
store(entry: DetachedMarkdownDom): void {
|
||||
const sessionKey = entry.scope;
|
||||
const entryKey = entry.id;
|
||||
|
||||
let session = this.sessions.get(sessionKey);
|
||||
if (session === undefined) {
|
||||
session = new Map();
|
||||
this.sessions.set(sessionKey, session);
|
||||
} else {
|
||||
this.refreshSession(sessionKey, session);
|
||||
}
|
||||
|
||||
// A part has one DOM version inside its authoritative runtime/session.
|
||||
session.delete(entryKey);
|
||||
session.set(entryKey, entry);
|
||||
|
||||
while (session.size > this.maxEntriesPerSession) {
|
||||
this.removeOldestEntry(session);
|
||||
}
|
||||
while (this.sessions.size > this.maxSessions) {
|
||||
this.removeOldestSession();
|
||||
}
|
||||
}
|
||||
|
||||
take(key: DetachedMarkdownDomKey): DocumentFragment | null {
|
||||
const sessionKey = key.scope;
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) return null;
|
||||
const entryKey = key.id;
|
||||
|
||||
this.refreshSession(sessionKey, session);
|
||||
const entry = session.get(entryKey);
|
||||
if (entry === undefined) return null;
|
||||
|
||||
// A mismatched probe (different locale or directory for the same part)
|
||||
// must not destroy the entry — the matching renderer may still come for
|
||||
// it. Only a real hit transfers ownership out of the cache.
|
||||
if (entry.locale !== key.locale || entry.directory !== key.directory) return null;
|
||||
|
||||
// A fragment is a move-only resource; taking it removes cache ownership.
|
||||
session.delete(entryKey);
|
||||
if (session.size === 0) this.sessions.delete(sessionKey);
|
||||
return entry.fragment;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
stats(): DetachedMarkdownDomCacheStats {
|
||||
let entries = 0;
|
||||
for (const session of this.sessions.values()) {
|
||||
entries += session.size;
|
||||
}
|
||||
return {
|
||||
sessions: this.sessions.size,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
private refreshSession(sessionKey: string, session: SessionCache): void {
|
||||
this.sessions.delete(sessionKey);
|
||||
this.sessions.set(sessionKey, session);
|
||||
}
|
||||
|
||||
private removeOldestEntry(session: SessionCache): void {
|
||||
const oldestKey = session.keys().next().value;
|
||||
if (oldestKey === undefined) return;
|
||||
session.delete(oldestKey);
|
||||
}
|
||||
|
||||
private removeOldestSession(): void {
|
||||
const oldestKey = this.sessions.keys().next().value;
|
||||
if (oldestKey === undefined) return;
|
||||
this.sessions.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
export const detachedMarkdownDomCache = new DetachedMarkdownDomCache();
|
||||
@@ -49,7 +49,10 @@ import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from '
|
||||
const {
|
||||
__markdownImageCandidateCacheForTests,
|
||||
extractMarkdownImageCandidates,
|
||||
getCachedMarkdownBlocks,
|
||||
renderMarkdownBlocks,
|
||||
renderMarkdownSync,
|
||||
resetMarkdownHtmlCacheForTests,
|
||||
} = await import('./markdownCore');
|
||||
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
|
||||
|
||||
@@ -90,6 +93,47 @@ describe('markdown sanitization', () => {
|
||||
|
||||
});
|
||||
|
||||
describe('Markdown block cache reads', () => {
|
||||
test('returns all settled blocks synchronously after a full cache hit', async () => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
const text = '**cached** settled markdown';
|
||||
|
||||
expect(getCachedMarkdownBlocks(text)).toBeNull();
|
||||
const rendered = await renderMarkdownBlocks(text, false);
|
||||
|
||||
expect(getCachedMarkdownBlocks(text)).toEqual(rendered);
|
||||
});
|
||||
|
||||
test('returns null for a cold or partial settled miss', async () => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
const first = 'first settled block';
|
||||
const changed = 'first settled block\n\nsecond settled block';
|
||||
|
||||
await renderMarkdownBlocks(first, false);
|
||||
|
||||
expect(getCachedMarkdownBlocks(changed)).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps image mode identity out of the settled full hit', async () => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
const text = '';
|
||||
|
||||
await renderMarkdownBlocks(text, false, 'inline');
|
||||
|
||||
expect(getCachedMarkdownBlocks(text, 'label')).toBeNull();
|
||||
expect(getCachedMarkdownBlocks(text, 'inline')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('does not treat streaming live-cache entries as settled full hits', async () => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
const text = 'streaming markdown';
|
||||
|
||||
await renderMarkdownBlocks(text, true);
|
||||
|
||||
expect(getCachedMarkdownBlocks(text)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown images', () => {
|
||||
test('renders assistant images as icon-ready text without loading the source', () => {
|
||||
const html = renderMarkdownSync([
|
||||
|
||||
@@ -178,9 +178,11 @@ type MarkdownBlock = {
|
||||
raw: string;
|
||||
src: string;
|
||||
mode: 'full' | 'live';
|
||||
// When false, skip syntax highlighting for this block. Set for the actively
|
||||
// streaming open code fence so we don't re-tokenize a growing block ~40x/sec
|
||||
// (O(n^2)); it highlights once the fence closes and becomes a stable block.
|
||||
// When false, skip syntax highlighting for this block. Block-level commit
|
||||
// feeds the open fence whole lines at the throttle cadence (<=10/sec), so a
|
||||
// partial fence highlights too and streamed code arrives colored; only a
|
||||
// very large open fence falls back to plain text until it closes, keeping
|
||||
// the repeated worker re-tokenization bounded.
|
||||
highlight: boolean;
|
||||
};
|
||||
|
||||
@@ -201,6 +203,11 @@ const hasOpenFence = (raw: string): boolean => {
|
||||
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last);
|
||||
};
|
||||
|
||||
// Above this, re-highlighting the still-open fence on every committed line
|
||||
// costs more than the colored preview is worth; the block highlights in one
|
||||
// pass when the fence closes.
|
||||
const OPEN_FENCE_HIGHLIGHT_LINE_LIMIT = 300;
|
||||
|
||||
const heal = (text: string): string => {
|
||||
try {
|
||||
return remend(text, { linkMode: 'text-only' });
|
||||
@@ -250,11 +257,13 @@ const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
|
||||
const raw = token.raw ?? '';
|
||||
const isLast = i === tail;
|
||||
const openFence = token.type === 'code' && hasOpenFence(raw);
|
||||
const openFenceHighlight = openFence
|
||||
&& raw.split('\n').length <= OPEN_FENCE_HIGHLIGHT_LINE_LIMIT;
|
||||
blocks.push({
|
||||
raw,
|
||||
src: openFence ? raw : heal(raw),
|
||||
mode: isLast ? 'live' : 'full',
|
||||
highlight: !openFence,
|
||||
highlight: !openFence || openFenceHighlight,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -548,10 +557,30 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe
|
||||
live: liveBlockCache.size,
|
||||
});
|
||||
|
||||
const parseBlock = async (
|
||||
block: MarkdownBlock,
|
||||
imageMode: MarkdownImageMode,
|
||||
): Promise<string> => {
|
||||
/**
|
||||
* Read a settled render synchronously when every block is already in the full
|
||||
* cache. Cache reads retain the existing LRU `get` semantics and do not insert
|
||||
* or expand either cache.
|
||||
*/
|
||||
export const getCachedMarkdownBlocks = (
|
||||
text: string,
|
||||
imageMode: MarkdownImageMode = 'inline',
|
||||
): RenderedBlock[] | null => {
|
||||
if (!text) return [];
|
||||
|
||||
const blocks = streamBlocks(text, false);
|
||||
const rendered: RenderedBlock[] = [];
|
||||
for (const block of blocks) {
|
||||
const contentHash = contentFingerprint(block.raw);
|
||||
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
|
||||
const html = fullBlockCache.get(id);
|
||||
if (html === undefined) return null;
|
||||
rendered.push({ id, html });
|
||||
}
|
||||
return rendered;
|
||||
};
|
||||
|
||||
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = await Promise.resolve(parser.parse(block.src));
|
||||
const withMath = renderMathExpressions(parsed);
|
||||
|
||||
@@ -22,6 +22,18 @@ type MermaidViewerController = {
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
type InternalMermaidViewerController = MermaidViewerController & {
|
||||
viewport: HTMLElement;
|
||||
fitToViewport: (viewport: MermaidViewport) => void;
|
||||
};
|
||||
|
||||
type MermaidViewerRegistryState = {
|
||||
container: HTMLElement;
|
||||
controllers: Map<HTMLElement, InternalMermaidViewerController>;
|
||||
signatures: Map<HTMLElement, string>;
|
||||
disposed: boolean;
|
||||
};
|
||||
|
||||
type MermaidSvgBoundsSource = {
|
||||
viewBox?: string | null;
|
||||
width?: string | number | null;
|
||||
@@ -36,13 +48,8 @@ type MermaidViewerSignatureSource = MermaidSvgBoundsSource & {
|
||||
const isPositiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0;
|
||||
|
||||
const parseSvgNumber = (value: string | number | null | undefined): number | null => {
|
||||
if (typeof value === 'number') {
|
||||
return isPositiveFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const match = value.trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
|
||||
if (value === null || value === undefined) return null;
|
||||
const match = String(value).trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
@@ -233,10 +240,14 @@ export const zoomMermaidViewBoxAtPoint = ({
|
||||
};
|
||||
|
||||
const controllerByBlock = new WeakMap<HTMLElement, MermaidViewerController>();
|
||||
|
||||
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => (
|
||||
block instanceof HTMLElement ? controllerByBlock.get(block) ?? null : null
|
||||
);
|
||||
const controllerByViewport = new WeakMap<HTMLElement, InternalMermaidViewerController>();
|
||||
const activeControllers = new Set<InternalMermaidViewerController>();
|
||||
const pendingRegistries = new Set<MermaidViewerRegistryState>();
|
||||
// Controllers are non-essential for the static SVG. Initialize all renderers
|
||||
// from one post-presentation batch so geometry reads precede every SVG write.
|
||||
let sharedResizeObserver: ResizeObserver | null = null;
|
||||
let pendingRegistryFlushFrame: number | null = null;
|
||||
let pendingResizeFrame: number | null = null;
|
||||
|
||||
const getSvgViewport = (block: HTMLElement): HTMLElement | null => (
|
||||
block.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]')
|
||||
@@ -272,7 +283,62 @@ const isPanExcludedTarget = (target: EventTarget | null): boolean => (
|
||||
target instanceof Element && Boolean(target.closest('button, a, [role="button"]'))
|
||||
);
|
||||
|
||||
const createMermaidViewerController = (block: HTMLElement): MermaidViewerController | null => {
|
||||
const fitControllers = (controllers: readonly InternalMermaidViewerController[]): void => {
|
||||
const viewportSizes = controllers.map((controller) => getViewportSize(controller.viewport));
|
||||
controllers.forEach((controller, index) => {
|
||||
const viewport = viewportSizes[index];
|
||||
if (viewport) controller.fitToViewport(viewport);
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleActiveControllerFit = (): void => {
|
||||
if (pendingResizeFrame !== null || activeControllers.size === 0) return;
|
||||
pendingResizeFrame = window.requestAnimationFrame(() => {
|
||||
pendingResizeFrame = null;
|
||||
fitControllers(Array.from(activeControllers));
|
||||
});
|
||||
};
|
||||
|
||||
const ensureSharedResizeObserver = (): ResizeObserver | null => {
|
||||
if (sharedResizeObserver) return sharedResizeObserver;
|
||||
const ResizeObserverConstructor = globalThis.ResizeObserver;
|
||||
if (!ResizeObserverConstructor) return null;
|
||||
sharedResizeObserver = new ResizeObserverConstructor((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!(entry.target instanceof HTMLElement)) continue;
|
||||
controllerByViewport.get(entry.target)?.fitToViewport({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
return sharedResizeObserver;
|
||||
};
|
||||
|
||||
const registerController = (controller: InternalMermaidViewerController): void => {
|
||||
if (activeControllers.has(controller)) return;
|
||||
const wasEmpty = activeControllers.size === 0;
|
||||
activeControllers.add(controller);
|
||||
controllerByViewport.set(controller.viewport, controller);
|
||||
ensureSharedResizeObserver()?.observe(controller.viewport);
|
||||
if (wasEmpty) window.addEventListener('resize', scheduleActiveControllerFit);
|
||||
};
|
||||
|
||||
const unregisterController = (controller: InternalMermaidViewerController): void => {
|
||||
if (!activeControllers.delete(controller)) return;
|
||||
sharedResizeObserver?.unobserve(controller.viewport);
|
||||
controllerByViewport.delete(controller.viewport);
|
||||
if (activeControllers.size > 0) return;
|
||||
sharedResizeObserver?.disconnect();
|
||||
sharedResizeObserver = null;
|
||||
window.removeEventListener('resize', scheduleActiveControllerFit);
|
||||
if (pendingResizeFrame !== null) {
|
||||
window.cancelAnimationFrame(pendingResizeFrame);
|
||||
pendingResizeFrame = null;
|
||||
}
|
||||
};
|
||||
|
||||
const createMermaidViewerController = (block: HTMLElement): InternalMermaidViewerController | null => {
|
||||
const viewport = getSvgViewport(block);
|
||||
const svg = block.querySelector<SVGSVGElement>('[data-markdown="mermaid"] svg');
|
||||
if (!viewport || !svg) {
|
||||
@@ -301,8 +367,12 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
|
||||
svg.removeAttribute('height');
|
||||
};
|
||||
|
||||
const fitToViewport = (size: MermaidViewport): void => {
|
||||
applyViewBox(fitMermaidViewBox(contentBox, size));
|
||||
};
|
||||
|
||||
const fit = (): void => {
|
||||
applyViewBox(fitMermaidViewBox(contentBox, getViewportSize(viewport)));
|
||||
fitToViewport(getViewportSize(viewport));
|
||||
};
|
||||
|
||||
const zoomAt = (pointer: MermaidPoint, zoomFactor: number): void => {
|
||||
@@ -390,32 +460,24 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
|
||||
}
|
||||
};
|
||||
|
||||
const onResize = (): void => {
|
||||
fit();
|
||||
};
|
||||
|
||||
viewport.addEventListener('wheel', onWheel, { passive: false });
|
||||
viewport.addEventListener('pointerdown', onPointerDown);
|
||||
viewport.addEventListener('pointermove', onPointerMove);
|
||||
viewport.addEventListener('pointerup', stopPan);
|
||||
viewport.addEventListener('pointercancel', stopPan);
|
||||
window.addEventListener('resize', onResize);
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize);
|
||||
observer?.observe(viewport);
|
||||
fit();
|
||||
|
||||
return {
|
||||
const controller: InternalMermaidViewerController = {
|
||||
viewport,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
fit,
|
||||
fitToViewport,
|
||||
cleanup: () => {
|
||||
unregisterController(controller);
|
||||
viewport.removeEventListener('wheel', onWheel);
|
||||
viewport.removeEventListener('pointerdown', onPointerDown);
|
||||
viewport.removeEventListener('pointermove', onPointerMove);
|
||||
viewport.removeEventListener('pointerup', stopPan);
|
||||
viewport.removeEventListener('pointercancel', stopPan);
|
||||
window.removeEventListener('resize', onResize);
|
||||
observer?.disconnect();
|
||||
if (clearClickSuppressionTimer !== null) {
|
||||
window.clearTimeout(clearClickSuppressionTimer);
|
||||
}
|
||||
@@ -424,42 +486,97 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
|
||||
controllerByBlock.delete(block);
|
||||
},
|
||||
};
|
||||
return controller;
|
||||
};
|
||||
|
||||
export const createMermaidViewerRegistry = (container: HTMLElement): { refresh: () => void; cleanup: () => void } => {
|
||||
const controllers = new Map<HTMLElement, MermaidViewerController>();
|
||||
const signatures = new Map<HTMLElement, string>();
|
||||
|
||||
const refresh = (): void => {
|
||||
for (const [block, controller] of Array.from(controllers.entries())) {
|
||||
const signature = getBlockViewerSignature(block);
|
||||
if (!container.contains(block) || signature !== signatures.get(block)) {
|
||||
controller.cleanup();
|
||||
controllers.delete(block);
|
||||
signatures.delete(block);
|
||||
}
|
||||
const removeStaleControllers = (state: MermaidViewerRegistryState): void => {
|
||||
for (const [block, controller] of state.controllers) {
|
||||
const signature = getBlockViewerSignature(block);
|
||||
if (!state.container.contains(block) || signature !== state.signatures.get(block)) {
|
||||
controller.cleanup();
|
||||
state.controllers.delete(block);
|
||||
state.signatures.delete(block);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const block of Array.from(container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR))) {
|
||||
if (controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) {
|
||||
continue;
|
||||
}
|
||||
const controller = createMermaidViewerController(block);
|
||||
if (!controller) {
|
||||
continue;
|
||||
}
|
||||
controllers.set(block, controller);
|
||||
signatures.set(block, getBlockViewerSignature(block));
|
||||
controllerByBlock.set(block, controller);
|
||||
}
|
||||
const collectNewControllers = (state: MermaidViewerRegistryState): InternalMermaidViewerController[] => {
|
||||
if (state.disposed) return [];
|
||||
const newControllers: InternalMermaidViewerController[] = [];
|
||||
for (const block of Array.from(state.container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR))) {
|
||||
if (state.controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) continue;
|
||||
const controller = createMermaidViewerController(block);
|
||||
if (!controller) continue;
|
||||
state.controllers.set(block, controller);
|
||||
state.signatures.set(block, getBlockViewerSignature(block));
|
||||
controllerByBlock.set(block, controller);
|
||||
newControllers.push(controller);
|
||||
}
|
||||
return newControllers;
|
||||
};
|
||||
|
||||
const flushPendingRegistries = (): void => {
|
||||
const registries = Array.from(pendingRegistries);
|
||||
pendingRegistries.clear();
|
||||
const newControllers: InternalMermaidViewerController[] = [];
|
||||
for (const state of registries) {
|
||||
if (state.disposed) continue;
|
||||
removeStaleControllers(state);
|
||||
newControllers.push(...collectNewControllers(state));
|
||||
}
|
||||
fitControllers(newControllers);
|
||||
for (const controller of newControllers) registerController(controller);
|
||||
};
|
||||
|
||||
const schedulePendingRegistryFlush = (): void => {
|
||||
if (pendingRegistryFlushFrame !== null) return;
|
||||
pendingRegistryFlushFrame = window.requestAnimationFrame(() => {
|
||||
pendingRegistryFlushFrame = null;
|
||||
pendingRegistryFlushFrame = window.requestAnimationFrame(() => {
|
||||
pendingRegistryFlushFrame = null;
|
||||
flushPendingRegistries();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleRegistryRefresh = (state: MermaidViewerRegistryState): void => {
|
||||
if (state.disposed) return;
|
||||
removeStaleControllers(state);
|
||||
pendingRegistries.add(state);
|
||||
schedulePendingRegistryFlush();
|
||||
};
|
||||
|
||||
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => {
|
||||
if (!(block instanceof HTMLElement)) return null;
|
||||
const existing = controllerByBlock.get(block);
|
||||
if (existing) return existing;
|
||||
|
||||
for (const state of pendingRegistries) {
|
||||
if (!state.container.contains(block)) continue;
|
||||
flushPendingRegistries();
|
||||
return controllerByBlock.get(block) ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const createMermaidViewerRegistry = (container: HTMLElement) => {
|
||||
const state: MermaidViewerRegistryState = {
|
||||
container,
|
||||
controllers: new Map(),
|
||||
signatures: new Map(),
|
||||
disposed: false,
|
||||
};
|
||||
|
||||
const refresh = (): void => scheduleRegistryRefresh(state);
|
||||
|
||||
const cleanup = (): void => {
|
||||
for (const controller of controllers.values()) {
|
||||
state.disposed = true;
|
||||
pendingRegistries.delete(state);
|
||||
for (const controller of state.controllers.values()) {
|
||||
controller.cleanup();
|
||||
}
|
||||
controllers.clear();
|
||||
signatures.clear();
|
||||
state.controllers.clear();
|
||||
state.signatures.clear();
|
||||
};
|
||||
|
||||
refresh();
|
||||
|
||||
@@ -19,7 +19,6 @@ import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialo
|
||||
import { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
|
||||
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
@@ -419,13 +418,10 @@ interface MessageBodyProps {
|
||||
onShowPopup: (content: ToolPopupContent) => void;
|
||||
streamPhase: StreamPhase;
|
||||
allowAnimation: boolean;
|
||||
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
|
||||
|
||||
shouldShowHeader?: boolean;
|
||||
hasTextContent?: boolean;
|
||||
onCopyMessage?: () => void | boolean | Promise<void | boolean>;
|
||||
copiedMessage?: boolean;
|
||||
onAuxiliaryContentComplete?: () => void;
|
||||
showReasoningTraces?: boolean;
|
||||
agentMention?: AgentMentionInfo;
|
||||
turnGroupingContext?: TurnGroupingContext;
|
||||
@@ -1112,10 +1108,8 @@ const AssistantMessageBody = React.memo(({
|
||||
onShowPopup,
|
||||
streamPhase: _streamPhase,
|
||||
allowAnimation: _allowAnimation,
|
||||
onContentChange,
|
||||
hasTextContent = false,
|
||||
onCopyMessage,
|
||||
onAuxiliaryContentComplete,
|
||||
showReasoningTraces = false,
|
||||
turnGroupingContext,
|
||||
errorMessage,
|
||||
@@ -1423,50 +1417,6 @@ const AssistantMessageBody = React.memo(({
|
||||
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
|
||||
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
|
||||
|
||||
const hasAuxiliaryContent = hasTools || reasoningParts.length > 0;
|
||||
const isTextlessAssistantMessage = assistantTextParts.length === 0;
|
||||
const auxiliaryContentComplete = hasAuxiliaryContent && isTextlessAssistantMessage && !shouldHoldTools && !shouldHoldReasoning && allToolsFinalized && reasoningComplete;
|
||||
const auxiliaryCompletionAnnouncedRef = React.useRef(false);
|
||||
const soloReasoningScrollTriggeredRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
soloReasoningScrollTriggeredRef.current = false;
|
||||
}, [messageId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!auxiliaryContentComplete) {
|
||||
auxiliaryCompletionAnnouncedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (auxiliaryCompletionAnnouncedRef.current) {
|
||||
return;
|
||||
}
|
||||
auxiliaryCompletionAnnouncedRef.current = true;
|
||||
onAuxiliaryContentComplete?.();
|
||||
}, [auxiliaryContentComplete, onAuxiliaryContentComplete]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (awaitingMessageCompletion) {
|
||||
soloReasoningScrollTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (hasTools) {
|
||||
soloReasoningScrollTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (reasoningParts.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (shouldHoldReasoning || !reasoningComplete) {
|
||||
return;
|
||||
}
|
||||
if (soloReasoningScrollTriggeredRef.current) {
|
||||
return;
|
||||
}
|
||||
soloReasoningScrollTriggeredRef.current = true;
|
||||
onContentChange?.('structural');
|
||||
}, [awaitingMessageCompletion, hasTools, onContentChange, reasoningComplete, reasoningParts.length, shouldHoldReasoning]);
|
||||
|
||||
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
|
||||
|
||||
const handleForkClick = React.useCallback(
|
||||
@@ -1821,7 +1771,6 @@ const AssistantMessageBody = React.memo(({
|
||||
expandedTools={expandedTools}
|
||||
onToggleTool={onToggleTool}
|
||||
onShowPopup={onShowPopup}
|
||||
onContentChange={onContentChange}
|
||||
streamPhase={effectiveStreamPhase}
|
||||
showHeader={true}
|
||||
animateRows={animateActivityRows}
|
||||
@@ -1898,7 +1847,6 @@ const AssistantMessageBody = React.memo(({
|
||||
messageId={messageId}
|
||||
streamPhase={effectiveStreamPhase}
|
||||
chatRenderMode={chatRenderMode}
|
||||
onContentChange={onContentChange}
|
||||
onShowPopup={onShowPopup}
|
||||
/>
|
||||
</div>
|
||||
@@ -1933,7 +1881,6 @@ const AssistantMessageBody = React.memo(({
|
||||
messageId={messageId}
|
||||
streamPhase={effectiveStreamPhase}
|
||||
chatRenderMode={chatRenderMode}
|
||||
onContentChange={onContentChange}
|
||||
onShowPopup={onShowPopup}
|
||||
/>
|
||||
);
|
||||
@@ -1945,7 +1892,6 @@ const AssistantMessageBody = React.memo(({
|
||||
part={part}
|
||||
messageId={messageId}
|
||||
streamPhase={effectiveStreamPhase}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1989,7 +1935,6 @@ const AssistantMessageBody = React.memo(({
|
||||
onToggle={onToggleTool}
|
||||
isMobile={isMobile}
|
||||
alwaysShowActions={alwaysShowMessageActions}
|
||||
onContentChange={onContentChange}
|
||||
onShowPopup={onShowPopup}
|
||||
animateTailText={animatedToolIdsLookup.has(toolPart.id)}
|
||||
/>
|
||||
@@ -2061,7 +2006,6 @@ const AssistantMessageBody = React.memo(({
|
||||
messageActionButtons,
|
||||
renderJustificationActions,
|
||||
sessionId,
|
||||
onContentChange,
|
||||
onShowPopup,
|
||||
onToggleTool,
|
||||
shouldRenderActivityGroup,
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import type { StreamPhase, ToolPopupContent } from '../types';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
|
||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||
@@ -17,7 +16,6 @@ interface AssistantTextPartProps {
|
||||
messageId: string;
|
||||
streamPhase: StreamPhase;
|
||||
chatRenderMode?: 'sorted' | 'live';
|
||||
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { ReasoningTimelineBlock } from './ReasoningPart';
|
||||
|
||||
@@ -22,14 +21,12 @@ const cleanJustificationText = (text: string): string => {
|
||||
interface JustificationBlockProps {
|
||||
part: Part;
|
||||
messageId: string;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const JustificationBlock: React.FC<JustificationBlockProps> = ({
|
||||
part,
|
||||
messageId,
|
||||
onContentChange,
|
||||
actions,
|
||||
}) => {
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
@@ -47,7 +44,6 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
|
||||
<ReasoningTimelineBlock
|
||||
text={textContent}
|
||||
variant="justification"
|
||||
onContentChange={onContentChange}
|
||||
blockId={part.id || `${messageId}-justification`}
|
||||
time={time}
|
||||
showDuration={chatRenderMode !== 'sorted'}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cn } from '@/lib/utils';
|
||||
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
|
||||
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
||||
import type { StreamPhase } from '../types';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { ToolPopupContent } from '../types';
|
||||
import ToolPart from './ToolPart';
|
||||
import { MinDurationShineText } from './MinDurationShineText';
|
||||
@@ -40,7 +39,6 @@ interface ProgressiveGroupProps {
|
||||
expandedTools: Set<string>;
|
||||
onToggleTool: (toolId: string) => void;
|
||||
onShowPopup: (content: ToolPopupContent) => void;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
streamPhase: StreamPhase;
|
||||
showHeader: boolean;
|
||||
animateRows?: boolean;
|
||||
@@ -376,9 +374,7 @@ interface ExpandableToolRowProps {
|
||||
isMobile: boolean;
|
||||
onToggleTool: (toolId: string) => void;
|
||||
onShowPopup: (content: ToolPopupContent) => void;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
animateTailText: boolean;
|
||||
animateRows: boolean;
|
||||
}
|
||||
|
||||
const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
|
||||
@@ -387,9 +383,7 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
|
||||
isMobile,
|
||||
onToggleTool,
|
||||
onShowPopup,
|
||||
onContentChange,
|
||||
animateTailText,
|
||||
animateRows,
|
||||
}) => {
|
||||
const handleToggle = React.useCallback(() => {
|
||||
onToggleTool(activity.id);
|
||||
@@ -401,23 +395,22 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
|
||||
isExpanded={isExpanded}
|
||||
onToggle={handleToggle}
|
||||
isMobile={isMobile}
|
||||
onContentChange={onContentChange}
|
||||
onShowPopup={onShowPopup}
|
||||
animateTailText={animateTailText}
|
||||
/>
|
||||
);
|
||||
|
||||
const maybeWrapped = animateTailText ? (
|
||||
<ToolRevealOnMount animate={true} wipe>
|
||||
{content}
|
||||
</ToolRevealOnMount>
|
||||
) : content;
|
||||
|
||||
if (!animateRows) {
|
||||
return maybeWrapped;
|
||||
}
|
||||
|
||||
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>;
|
||||
// Wrappers are unconditional: a conditional wrapper changes the element
|
||||
// type at this position when animateTailText/animateRows flip (message
|
||||
// completion), remounting the tool subtree and replaying the reveal wipe.
|
||||
// Both wrappers are inert with animation off.
|
||||
return (
|
||||
<FadeInOnReveal>
|
||||
<ToolRevealOnMount animate={animateTailText} wipe>
|
||||
{content}
|
||||
</ToolRevealOnMount>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => {
|
||||
@@ -425,9 +418,7 @@ const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => {
|
||||
&& prev.isMobile === next.isMobile
|
||||
&& prev.onToggleTool === next.onToggleTool
|
||||
&& prev.onShowPopup === next.onShowPopup
|
||||
&& prev.onContentChange === next.onContentChange
|
||||
&& prev.animateTailText === next.animateTailText
|
||||
&& prev.animateRows === next.animateRows
|
||||
&& prev.activity.id === next.activity.id
|
||||
&& prev.activity.kind === next.activity.kind
|
||||
&& prev.activity.endedAt === next.activity.endedAt
|
||||
@@ -438,14 +429,12 @@ interface StaticGroupedToolRowProps {
|
||||
toolName: string;
|
||||
activities: TurnActivityPart[];
|
||||
animateTailText: boolean;
|
||||
animateRows: boolean;
|
||||
}
|
||||
|
||||
const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({
|
||||
toolName,
|
||||
activities,
|
||||
animateTailText,
|
||||
animateRows,
|
||||
}) => {
|
||||
const content = (
|
||||
<StaticToolRow
|
||||
@@ -455,23 +444,22 @@ const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({
|
||||
/>
|
||||
);
|
||||
|
||||
const maybeWrapped = animateTailText ? (
|
||||
<ToolRevealOnMount animate={true} wipe>
|
||||
{content}
|
||||
</ToolRevealOnMount>
|
||||
) : content;
|
||||
|
||||
if (!animateRows) {
|
||||
return maybeWrapped;
|
||||
}
|
||||
|
||||
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>;
|
||||
// Wrappers are unconditional: a conditional wrapper changes the element
|
||||
// type at this position when animateTailText/animateRows flip (message
|
||||
// completion), remounting the tool subtree and replaying the reveal wipe.
|
||||
// Both wrappers are inert with animation off.
|
||||
return (
|
||||
<FadeInOnReveal>
|
||||
<ToolRevealOnMount animate={animateTailText} wipe>
|
||||
{content}
|
||||
</ToolRevealOnMount>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoStaticGroupedToolRow = React.memo(StaticGroupedToolRow, (prev, next) => {
|
||||
return prev.toolName === next.toolName
|
||||
&& prev.animateTailText === next.animateTailText
|
||||
&& prev.animateRows === next.animateRows
|
||||
&& areActivityListsEqual(prev.activities, next.activities);
|
||||
});
|
||||
|
||||
@@ -795,9 +783,8 @@ export const StaticToolRow = React.memo(StaticToolRowInner, (prev, next) => {
|
||||
/**
|
||||
* Inline reasoning text block — rendered as dimmed italic markdown.
|
||||
*/
|
||||
const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhase }: {
|
||||
const InlineReasoningBlock = React.memo(({ activity, streamPhase }: {
|
||||
activity: TurnActivityPart;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
streamPhase: StreamPhase;
|
||||
}) => {
|
||||
return (
|
||||
@@ -805,7 +792,6 @@ const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhas
|
||||
part={activity.part}
|
||||
messageId={activity.messageId}
|
||||
streamPhase={streamPhase}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -813,16 +799,14 @@ const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhas
|
||||
/**
|
||||
* Inline justification text block — rendered as normal assistant text between tools.
|
||||
*/
|
||||
const InlineJustificationBlock = React.memo(({ activity, onContentChange, actions }: {
|
||||
const InlineJustificationBlock = React.memo(({ activity, actions }: {
|
||||
activity: TurnActivityPart;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
actions?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<JustificationBlock
|
||||
part={activity.part}
|
||||
messageId={activity.messageId}
|
||||
onContentChange={onContentChange}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
@@ -837,7 +821,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
expandedTools,
|
||||
onToggleTool,
|
||||
onShowPopup,
|
||||
onContentChange,
|
||||
streamPhase,
|
||||
showHeader,
|
||||
animateRows = true,
|
||||
@@ -898,7 +881,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
<InlineReasoningBlock
|
||||
activity={row.activity}
|
||||
streamPhase={streamPhase}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -909,7 +891,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
<>
|
||||
<InlineJustificationBlock
|
||||
activity={row.activity}
|
||||
onContentChange={onContentChange}
|
||||
actions={renderJustificationActions?.(row.activity)}
|
||||
/>
|
||||
</>
|
||||
@@ -924,9 +905,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
isMobile={isMobile}
|
||||
onToggleTool={onToggleTool}
|
||||
onShowPopup={onShowPopup}
|
||||
onContentChange={onContentChange}
|
||||
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
|
||||
animateRows={animateRows}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -937,7 +916,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
toolName={row.toolName}
|
||||
activities={row.activities}
|
||||
animateTailText={row.activities.some((activity) => animatedToolIds?.has(activity.id))}
|
||||
animateRows={animateRows}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -950,9 +928,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
isMobile={isMobile}
|
||||
onToggleTool={onToggleTool}
|
||||
onShowPopup={onShowPopup}
|
||||
onContentChange={onContentChange}
|
||||
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
|
||||
animateRows={animateRows}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { animate, type AnimationPlaybackControls } from 'motion';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { BusyDots } from './BusyDots';
|
||||
@@ -10,6 +9,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||
import { commitStreamedText } from '../../lib/streamTextCommit';
|
||||
import type { StreamPhase } from '../types';
|
||||
|
||||
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal';
|
||||
@@ -81,7 +81,6 @@ const getReasoningSummary = (text: string): string => {
|
||||
type ReasoningTimelineBlockProps = {
|
||||
text: string;
|
||||
variant: ReasoningVariant;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
blockId: string;
|
||||
time?: { start?: number; end?: number };
|
||||
showDuration?: boolean;
|
||||
@@ -99,7 +98,6 @@ type ExpansionState = {
|
||||
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
text,
|
||||
variant,
|
||||
onContentChange,
|
||||
blockId,
|
||||
time,
|
||||
isStreaming = false,
|
||||
@@ -123,11 +121,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
|
||||
const contentMountedRef = React.useRef(false);
|
||||
// Stable handle to onContentChange so the height-animation layout effect can
|
||||
// signal auto-follow without taking onContentChange as a dependency (which
|
||||
// would risk re-running — and thus restarting — the animation on re-render).
|
||||
const onContentChangeRef = React.useRef(onContentChange);
|
||||
onContentChangeRef.current = onContentChange;
|
||||
|
||||
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
||||
const toggleAriaLabel = isExpanded
|
||||
@@ -137,8 +130,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
const handleToggle = React.useCallback(() => {
|
||||
setShouldRenderExpandedContent(true);
|
||||
setExpansion({ expanded: !isExpanded, source: 'user' });
|
||||
onContentChange?.('structural');
|
||||
}, [isExpanded, onContentChange]);
|
||||
}, [isExpanded]);
|
||||
|
||||
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
@@ -159,13 +151,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
});
|
||||
}, [canAutoExpand]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (text.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
onContentChange?.('structural');
|
||||
}, [onContentChange, text]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isExpanded || isStreaming) {
|
||||
setShouldRenderExpandedContent(true);
|
||||
@@ -239,11 +224,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
element.style.height = '0px';
|
||||
} else {
|
||||
element.style.height = `${element.scrollHeight}px`;
|
||||
// Only the COLLAPSE animation needs the guard: it shrinks the
|
||||
// timeline and the trailing async scroll events can be misread as a
|
||||
// user scroll-away. Expansion grows the timeline and re-pins cleanly,
|
||||
// and guarding it caused a faint scroll fight while thinking streams.
|
||||
onContentChangeRef.current?.('animation');
|
||||
}
|
||||
|
||||
const animation = animate(
|
||||
@@ -436,14 +416,12 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
|
||||
type ReasoningPartProps = {
|
||||
part: Part;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
messageId: string;
|
||||
streamPhase?: StreamPhase;
|
||||
};
|
||||
|
||||
const ReasoningPart = React.memo(({
|
||||
part,
|
||||
onContentChange,
|
||||
messageId,
|
||||
streamPhase,
|
||||
}: ReasoningPartProps) => {
|
||||
@@ -454,11 +432,14 @@ const ReasoningPart = React.memo(({
|
||||
const time = partWithText.time;
|
||||
const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
|
||||
const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number';
|
||||
const throttledText = useStreamingTextThrottle({
|
||||
const throttledTextRaw = useStreamingTextThrottle({
|
||||
text: textContent,
|
||||
isStreaming,
|
||||
identityKey: `${messageId}:${part.id ?? 'reasoning'}`,
|
||||
});
|
||||
// Same block-level reveal as assistant text: a shown reasoning paragraph
|
||||
// never mutates in place.
|
||||
const throttledText = isStreaming ? commitStreamedText(throttledTextRaw) : throttledTextRaw;
|
||||
|
||||
// Show reasoning even if time.end isn't set yet (during streaming)
|
||||
// Only hide if there's no text content
|
||||
@@ -470,7 +451,6 @@ const ReasoningPart = React.memo(({
|
||||
<ReasoningTimelineBlock
|
||||
text={throttledText}
|
||||
variant="thinking"
|
||||
onContentChange={onContentChange}
|
||||
blockId={part.id || `${messageId}-reasoning`}
|
||||
time={time}
|
||||
isStreaming={isStreaming}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { toast } from '@/components/ui';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { ToolPopupContent } from '../types';
|
||||
import { PlainDiffFallback } from './PlainDiffFallback';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
@@ -82,7 +81,6 @@ interface ToolPartProps {
|
||||
onToggle: (toolId: string) => void;
|
||||
isMobile: boolean;
|
||||
alwaysShowActions?: boolean;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
animateTailText?: boolean;
|
||||
}
|
||||
@@ -1684,7 +1682,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isMobile,
|
||||
onContentChange,
|
||||
onShowPopup,
|
||||
animateTailText = true,
|
||||
}) => {
|
||||
@@ -1754,10 +1751,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
});
|
||||
}, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]);
|
||||
|
||||
const shouldNotifyStructuralChange = isFinalized || isTaskTool;
|
||||
|
||||
const onContentChangeRef = React.useRef(onContentChange);
|
||||
onContentChangeRef.current = onContentChange;
|
||||
const expandedContentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
@@ -1772,11 +1765,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
|
||||
element.style.height = isExpanded ? 'auto' : '0px';
|
||||
element.style.overflow = isExpanded ? 'visible' : 'hidden';
|
||||
|
||||
if (shouldNotifyStructuralChange) {
|
||||
onContentChangeRef.current?.('structural');
|
||||
}
|
||||
}, [isExpanded, isTaskTool, shouldNotifyStructuralChange]);
|
||||
}, [isExpanded, isTaskTool]);
|
||||
|
||||
const partMetadata = (part as unknown as { metadata?: unknown }).metadata;
|
||||
const time = stateWithData.time;
|
||||
@@ -1934,26 +1923,6 @@ 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) {
|
||||
lastTaskSummaryRenderSignatureRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = lastTaskSummaryRenderSignatureRef.current;
|
||||
lastTaskSummaryRenderSignatureRef.current = taskSummaryRenderSignature;
|
||||
if (previous === null || previous === taskSummaryRenderSignature || taskSummaryEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
onContentChangeRef.current?.('structural');
|
||||
}, [isTaskTool, taskSummaryEntries.length, taskSummaryRenderSignature]);
|
||||
|
||||
const diffStats = React.useMemo(() => {
|
||||
return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')
|
||||
? parseDiffStats(metadata)
|
||||
@@ -2351,7 +2320,6 @@ export default React.memo(ToolPart, (prev, next) => {
|
||||
&& prev.isExpanded === next.isExpanded
|
||||
&& prev.isMobile === next.isMobile
|
||||
&& prev.alwaysShowActions === next.alwaysShowActions
|
||||
&& prev.onContentChange === next.onContentChange
|
||||
&& prev.onShowPopup === next.onShowPopup
|
||||
&& prev.animateTailText === next.animateTailText;
|
||||
});
|
||||
|
||||
@@ -229,12 +229,11 @@ export function WorkingPlaceholder({
|
||||
|
||||
return (
|
||||
<div
|
||||
// Styled to mirror the turn footer's model row (text-sm,
|
||||
// muted-foreground/60, no left inset): when the turn completes this row
|
||||
// disappears and the footer appears in the same visual spot, so the two
|
||||
// must read as the same line swapping its text.
|
||||
// Full muted-foreground, matching the scroll-to-bottom pill's status
|
||||
// text: the row and the pill hand off to each other in the same spot
|
||||
// and must read as one element changing chrome.
|
||||
className={
|
||||
'flex h-full items-center text-muted-foreground/60'
|
||||
'flex h-full items-center text-muted-foreground'
|
||||
}
|
||||
role="status"
|
||||
aria-live={displayedPermission ? 'assertive' : 'polite'}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { commitStreamedText } from '../../lib/streamTextCommit';
|
||||
|
||||
export const resolveAssistantDisplayText = (input: {
|
||||
textContent: string;
|
||||
throttledTextContent: string;
|
||||
isStreaming: boolean;
|
||||
}): string => {
|
||||
return input.isStreaming ? input.throttledTextContent : input.textContent;
|
||||
// While streaming, reveal whole blocks only: rendering stops at the last
|
||||
// complete line so a shown paragraph never mutates in place. The held
|
||||
// tail lands with the next line break (or the finalize pass).
|
||||
return input.isStreaming
|
||||
? commitStreamedText(input.throttledTextContent)
|
||||
: input.textContent;
|
||||
};
|
||||
|
||||
export const shouldRenderAssistantText = (input: {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import { useSessionListSync } from '@/components/session/sidebar/list/useSessionListSync';
|
||||
|
||||
import { ChatView } from '@/components/views/ChatView';
|
||||
|
||||
@@ -35,6 +36,7 @@ const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/Se
|
||||
* crossing the threshold reloads into it (see watchHostedSurfaceViewport).
|
||||
*/
|
||||
export const MainLayout: React.FC = () => {
|
||||
useSessionListSync({ isVSCode: false });
|
||||
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
||||
const setIsMobile = useUIStore((state) => state.setIsMobile);
|
||||
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
|
||||
|
||||
@@ -41,6 +41,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { UsageWindow } from '@/types';
|
||||
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
||||
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
||||
import { useSessionListSync } from '@/components/session/sidebar/list/useSessionListSync';
|
||||
|
||||
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||
|
||||
@@ -526,8 +527,11 @@ export const VSCodeLayout: React.FC = () => {
|
||||
}
|
||||
}, [usesExpandedLayout, currentView, viewMode]);
|
||||
|
||||
useSessionListSync({ isVSCode: true });
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-full w-full bg-background text-foreground flex flex-col">
|
||||
<>
|
||||
<div ref={containerRef} className="h-full w-full bg-background text-foreground flex flex-col">
|
||||
{viewMode === 'editor' ? (
|
||||
// Editor mode: just chat, no sidebar
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -639,7 +643,8 @@ export const VSCodeLayout: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
<SessionDialogs />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -545,8 +545,9 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
? Math.min(STICKY_FADE_MIN_SIZE + scroller.scrollTop, STICKY_FADE_MAX_SIZE)
|
||||
: 0;
|
||||
stickyFadeSizeRef.current = fadeSize;
|
||||
scroller.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`);
|
||||
scroller.style.setProperty(
|
||||
const fadeRoot = scroller.closest<HTMLElement>('.oc-sticky-fade-root');
|
||||
fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`);
|
||||
fadeRoot?.style.setProperty(
|
||||
'--scroll-shadow-top-clear-size',
|
||||
`${Math.min(Math.max(fadeSize - 8, 0), STICKY_FADE_CLEAR_MAX_SIZE)}px`,
|
||||
);
|
||||
@@ -876,24 +877,23 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
|
||||
<div
|
||||
className="oc-sticky-fade-root relative flex min-h-0 flex-1"
|
||||
// SAFETY: these custom properties configure the viewport-owned edge fade.
|
||||
style={stickyHeaders ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
|
||||
onPointerDownCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
|
||||
onClickCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
|
||||
onContextMenuCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
|
||||
>
|
||||
<ScrollableOverlay
|
||||
ref={scrollRef}
|
||||
useScrollShadow={stickyHeaders}
|
||||
hideBottomScrollShadow
|
||||
scrollShadowSize={12}
|
||||
outerClassName={maxHeightClassName}
|
||||
className="oc-sticky-fade-scroller overlay-scrollbar-target--no-gutter"
|
||||
style={{
|
||||
...(stickyHeaders ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : {}),
|
||||
...maxHeightStyle,
|
||||
}}
|
||||
onScroll={stickyHeaders ? (event) => syncStickyFade(event.currentTarget) : undefined}
|
||||
>
|
||||
<div className="px-1">
|
||||
<ScrollableOverlay
|
||||
ref={scrollRef}
|
||||
useScrollShadow={stickyHeaders}
|
||||
hideBottomScrollShadow
|
||||
scrollShadowSize={12}
|
||||
outerClassName={maxHeightClassName}
|
||||
className="overlay-scrollbar-target--no-gutter"
|
||||
style={maxHeightStyle}
|
||||
onScroll={stickyHeaders ? (event) => syncStickyFade(event.currentTarget) : undefined}
|
||||
>
|
||||
<div className="px-1">
|
||||
{includeNotSelected ? (
|
||||
<>
|
||||
<button
|
||||
@@ -964,16 +964,16 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
{stickyHeaders && leadingSectionKey ? (
|
||||
<div
|
||||
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-2 px-3 py-1.5 typography-micro font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{renderSectionIdentity(leadingSectionKey)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
{stickyHeaders && leadingSectionKey ? (
|
||||
<div
|
||||
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-2 px-3 py-1.5 typography-micro font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{renderSectionIdentity(leadingSectionKey)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground">
|
||||
|
||||
@@ -152,7 +152,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
||||
// Find the nearest dialog or overflow ancestor to constrain within
|
||||
let container: HTMLElement | null = triggerRef.current.parentElement;
|
||||
while (container) {
|
||||
if (container.getAttribute('role') === 'dialog' || container.hasAttribute('data-scroll-shadow')) {
|
||||
if (container.matches('[role="dialog"], [data-scroll-shadow-scroller]')) {
|
||||
break;
|
||||
}
|
||||
const style = getComputedStyle(container);
|
||||
|
||||
@@ -302,6 +302,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const sessionGoalDefaultBudget = useUIStore(state => state.sessionGoalDefaultBudget);
|
||||
const setSessionGoalDefaultBudget = useUIStore(state => state.setSessionGoalDefaultBudget);
|
||||
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
|
||||
const streamingAutoFollowEnabled = useUIStore(state => state.streamingAutoFollowEnabled);
|
||||
const setStreamingAutoFollowEnabled = useUIStore(state => state.setStreamingAutoFollowEnabled);
|
||||
const collapsibleThinkingBlocks = useUIStore(state => state.collapsibleThinkingBlocks);
|
||||
const setCollapsibleThinkingBlocks = useUIStore(state => state.setCollapsibleThinkingBlocks);
|
||||
|
||||
@@ -1839,6 +1841,20 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
<SettingsSection
|
||||
title={t('settings.openchamber.visual.section.streaming')}
|
||||
settingsItem="chat.streaming"
|
||||
contentClassName={SETTINGS_OPTION_STACK_CLASS}
|
||||
>
|
||||
<SettingsCheckboxRow
|
||||
checked={streamingAutoFollowEnabled}
|
||||
onChange={setStreamingAutoFollowEnabled}
|
||||
label={t('settings.openchamber.visual.field.streamingAutoFollow')}
|
||||
ariaLabel={t('settings.openchamber.visual.field.streamingAutoFollowAria')}
|
||||
info={t('settings.openchamber.visual.field.streamingAutoFollowInfo')}
|
||||
settingsItem="chat.streaming-auto-follow"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || (shouldShow('promptNavigatorEnabled') && !isVSCode) || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('codeBlockLineWrap')) && (
|
||||
<SettingsSection
|
||||
|
||||
@@ -4,9 +4,8 @@ import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
|
||||
import { CollapsedActivityIndicator } from './sidebar/collapsedActivityIndicator';
|
||||
import type { CollapsedActivityState } from './sidebar/collapsedActivityState';
|
||||
import { CollapsedActivityIndicator } from './sidebar/sessions/collapsedActivityIndicator';
|
||||
import type { CollapsedActivityState } from './sidebar/sessions/collapsedActivityState';
|
||||
|
||||
interface SessionFolderItemProps<TSessionNode> {
|
||||
folder: SessionFolder;
|
||||
@@ -24,23 +23,7 @@ interface SessionFolderItemProps<TSessionNode> {
|
||||
onToggle: () => void;
|
||||
onRename: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
renderSessionNode: (
|
||||
node: TSessionNode,
|
||||
depth?: number,
|
||||
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;
|
||||
children?: React.ReactNode;
|
||||
groupDirectory?: string | null;
|
||||
projectId?: string | null;
|
||||
mobileVariant?: boolean;
|
||||
@@ -74,10 +57,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
onToggle,
|
||||
onRename,
|
||||
onDelete,
|
||||
renderSessionNode,
|
||||
getRenderExtras,
|
||||
groupDirectory,
|
||||
projectId,
|
||||
children,
|
||||
mobileVariant = false,
|
||||
alwaysShowActions = mobileVariant,
|
||||
isRenaming = false,
|
||||
@@ -97,6 +77,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
const [localDraft, setLocalDraft] = React.useState('');
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
|
||||
const renaming = isRenaming || localRenaming;
|
||||
const draft = isRenaming ? renameDraft : localDraft;
|
||||
|
||||
@@ -167,6 +148,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30',
|
||||
)}
|
||||
onClick={renaming ? undefined : (event) => {
|
||||
// SAFETY: this handler is attached to the div rendered directly above.
|
||||
(event.currentTarget as HTMLElement).blur();
|
||||
onToggle();
|
||||
}}
|
||||
@@ -346,11 +328,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
{subFolderItems}
|
||||
{/* Then sessions */}
|
||||
{sessions.length > 0 ? (
|
||||
<div className="pl-3">
|
||||
{sessions.map((node) =>
|
||||
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket, undefined, 'project', getRenderExtras?.(node)),
|
||||
)}
|
||||
</div>
|
||||
children
|
||||
) : !subFolderItems ? (
|
||||
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
|
||||
{t('sessions.sidebar.folderItem.emptyFolder')}
|
||||
@@ -362,6 +340,9 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
);
|
||||
};
|
||||
|
||||
export const SessionFolderItem = React.memo(SessionFolderItemBase) as <TSessionNode>(
|
||||
export const SessionFolderItem = (
|
||||
/* SAFETY: React.memo preserves the generic component's props and return type. */
|
||||
React.memo(SessionFolderItemBase) as <TSessionNode>(
|
||||
props: SessionFolderItemProps<TSessionNode>,
|
||||
) => React.ReactElement;
|
||||
) => React.ReactElement
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import { Icon } from '@/components/icon/Icon';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useGlobalSessionStatus } from '@/sync/sync-context';
|
||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||
import { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/hooks/useSwitcherItems';
|
||||
import { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/shell/useSwitcherItems';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { formatSessionCompactDateLabel } from './sidebar/utils';
|
||||
|
||||
@@ -1,89 +1,37 @@
|
||||
# Session Sidebar Documentation
|
||||
# Session Sidebar
|
||||
|
||||
## Refactor result
|
||||
Sidebar code is organized by the business object it owns. Shared contracts are
|
||||
kept at this root in `types.ts` and `utils.tsx`.
|
||||
|
||||
- `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components.
|
||||
- Layout (web/desktop): top navigation (`SidebarNav`: New session, Scheduled, Multi-run, Archive), then the `recent` zone, then one zone per project with a **flat** session list. There is no rendered worktree grouping level.
|
||||
- **Two grouping display modes** (`useSessionDisplayStore.sessionGroupingMode`, toggled in the view dropdown): `'by-worktree'` (default) renders the worktree-grouped `sectionsForRender` with slim PR-aware branch sub-headers inside each project zone; `'flat'` renders `flatSectionsForRender` — one merged non-archived group per project (`id: 'flat'`, `folderScopes` listing every contributing scope) with per-row branch markers. Both derive from the same `projectSections` data layer, which alone feeds bootstrap demand planning and PR polling.
|
||||
- **Project display is independent from grouping.** `'all'` keeps every project zone; `'single'` is web/desktop/PWA-only and renders one selected project under the always-present Chats section. Its project header is a non-collapsible picker ordered by the current project sort. Recent and collapse/expand-all controls are hidden without changing their persisted preferences. Opening a materialized project session updates the picker from the session's confirmed directory; changing only a draft target does not. In `'single'` + `'flat'`, active sessions reveal in batches of 20. `'single'` + `'by-worktree'` retains the ordinary per-group limits. Project display mode, session grouping, project sort, and the Recent preference are server-backed shared settings with the hydrated browser store as the migration/failure cache. The selected single project and sticky-header preference remain device-local.
|
||||
- When sticky zone headers are enabled, project headers are sticky "zone" bands (`SortableProjectItem`); on a vibrant desktop the scrolling content fades behind an unmasked, non-interactive copy of the stuck icon/title without painting a background. The transparent fade zone blocks interaction with obscured rows. The `recent` section uses the same overlay while it is the leading sticky header. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories.
|
||||
- **Activity is a dot plus a counter, never a spinner.** The row's left gutter shows a static dot — primary while the session runs (`busy`/`retry`), info while it is unread — and the metadata slot on the right swaps the goal/branch/date group for the elapsed time of the turn (`SessionActivityDuration`, ticking once per second). The readout takes the dot's color in each state — primary while running, info once it is waiting to be read — so the pair reads as one indicator. A running spinner repainted a composited layer per row every frame for the whole turn; the counter conveys the same "something is happening" at 1 fps. The counter follows the unread marker's lifetime exactly: it survives the turn ending, disappears when the session is read, and never lingers on the session being watched (which is marked read as it goes idle). Aggregate indicators for collapsed groups, folders, and projects show the dot only — a group may hold several running turns, so a single counter would have nothing to count. The same treatment applies to the mobile sessions sheet and session switcher rows. The worktree-move indicator stays a spinner: it marks a short user-initiated operation, not a session state.
|
||||
- Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread.
|
||||
- Folders render **flat** after the loose sessions: nested folders keep `parentId` in the data model but display at one level with a "Parent / Child" path label (`SessionFolderItem.displayName`); collapsing a folder hides its whole subtree. Folder actions resolve their owning scope per folder entry (folders from multiple worktree scopes can coexist under one project).
|
||||
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Restore (unarchive) is available per session (row context menu, Archive page row) and in bulk (selection bar) and writes `time.archived = 0` — the server cannot clear the field over HTTP, so the global session cache splits active/archived client-side (see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`).
|
||||
- Scheduled tasks (`ScheduledTasksDialog`, now a full-page surface on web/desktop) and per-project worktree management (`WorktreesView`, opened from the project menu) render as overlays inside `<main>` in `MainLayout`; the sidebar no longer mounts them.
|
||||
- Group-level PR-status polling/indicators and worktree-group drag-to-reorder were removed together with the worktree grouping level; `oc.sessions.groupOrder` is no longer read or written. Worktree PR/branch context lives in the Worktrees surface.
|
||||
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
|
||||
- Managed Chats never offer the worktree-move action in either the sidebar row menu or the active-session header menu because their directories are not project repositories.
|
||||
- Managed Chats use the shared Chats root as their folder scope. Their activity section renders the normal folder tree, and sessions created from a Chats folder are assigned back to that root-scoped folder after their date/session directory materializes. Per-session folder scopes created by older builds remain visible for compatibility.
|
||||
- An empty Chats section says that there are no chats yet; it never reuses the project/workspace empty message.
|
||||
- The New session keyboard command inherits the active materialized session directory. Explicit sidebar entry points, including the top New session row and the Chats `+`, open a fresh managed Chat draft instead.
|
||||
- The new-worktree keyboard command is a silent no-op while a managed Chat draft is open. It must not retarget that draft to the active project or show a Git/worktree error because Chats never participate in worktrees.
|
||||
- Directory loading is demand-driven: the sidebar publishes one complete priority plan for all known project/worktree directories, while the sync layer owns bounded execution.
|
||||
- When multiple configured projects are checkouts of the same Git repository, exactly one project owns the shared worktree topology: the configured canonical primary root when present, otherwise the first configured source for that repository. Any worktree path that is also a configured project is omitted from subordinate worktree groups, so every directory has one sidebar location while remaining part of bootstrap demand.
|
||||
- `shell/` owns sidebar chrome, navigation, search, confirmations, and switcher effects.
|
||||
- `list/` owns global-first session collection, directory bootstrap demand,
|
||||
layout-owned synchronization, authoritative cleanup, and nearby-session prefetch.
|
||||
- `projects/` owns project zones, grouping, ordering, scroller behavior, project
|
||||
view state, repository state, and worktree presentation.
|
||||
- `sessions/` owns session rows, row actions, expansion, ownership, and activity indicators.
|
||||
- `recent/` owns Recent and managed Chats activity projections.
|
||||
- `folders/` owns folder DnD, bulk actions, archived folders, and folder UI.
|
||||
|
||||
## VS Code grouping
|
||||
`MainLayout` and `VSCodeLayout` call `useSessionListSync({ isVSCode })`
|
||||
unconditionally. The hook publishes complete directory bootstrap demand,
|
||||
refreshes newly added topology, coalesces control events, and performs
|
||||
authoritative cleanup. Root-level `useGlobalSessionsPolling` remains the only
|
||||
initial and 45-second global poller. `useSessionListSync` must not create a
|
||||
second global polling lifecycle.
|
||||
|
||||
- VS Code uses the **same grouped project tree** as web/desktop (project headers + folders + pinned-first ordering), not a separate flat list. Each open VS Code workspace folder is a project header.
|
||||
- VS Code groups strictly **by open workspace**: `useSessionGrouping` funnels every non-archived session into the project's root group and emits **no per-worktree subgroups** (worktrees aren't registered in VS Code). `getSessionsForProject` buckets sessions to a workspace by exact directory match, so only sessions whose directory is an open workspace folder appear.
|
||||
- VS Code passes `hideDirectoryControls` (clean workspace headers, no worktree/close chrome) and no longer passes `showOnlyMainWorkspace`/`sharedSessionsOnly`. Folders and pinning therefore work natively, scoped to the workspace root.
|
||||
The global sessions cache is the complete source for active and archived
|
||||
coverage. Initialized directory stores only supply sessions missing from that
|
||||
cache. Live busy and retry state comes from `global-session-status`, never from
|
||||
the global cache or persisted history. A failed global or directory fetch keeps
|
||||
existing data; it is never treated as an authoritative empty list.
|
||||
|
||||
## File summaries
|
||||
Web and desktop show managed Chats before optional Recent activity. Chats use
|
||||
their shared managed root for folders and never expose worktree actions. Project
|
||||
display can be all projects or one selected project. VS Code excludes worktrees
|
||||
and managed Chats, while retaining its workspace-scoped grouped list and inline
|
||||
archived buckets.
|
||||
|
||||
### Components
|
||||
|
||||
- `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all).
|
||||
- A successful add/create/clone from the project-directory dialog transitions to a new-session draft targeted at that project, matching the project's `+` action; changing project metadata alone must not leave the visible session or draft on a different directory.
|
||||
- `SidebarNav.tsx`: Text navigation rows above the tree (New session, Scheduled, Multi-run, Archive); hidden in VS Code.
|
||||
- `SidebarActivitySections.tsx`: Global top section renderer for OpenChamber-managed `chats` followed by optional project-only `recent` sessions, styled as zone headers. The desktop sticky identity overlay follows the activity header whose sentinel has crossed the scroller edge, so a small scroll cannot relabel Chats as Recent.
|
||||
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
|
||||
- `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder.
|
||||
- `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows.
|
||||
- `SessionNodeItem.tsx`: Renders one session row/tree node with a single-line layout, inline branch label, indicators, menu actions, and nested children. Pending-question counts stay per-session while expanded and roll up hidden descendants from their owning directory stores while collapsed. Rows do not initiate directory bootstrap on mount.
|
||||
- `collapsedActivityIndicator.tsx`: Aggregate busy/unseen dot for collapsed groups and folders.
|
||||
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
|
||||
- `sortableItems.tsx`: DnD sortable wrapper for project ordering plus the sticky zone-band project header and its action affordances.
|
||||
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
|
||||
- `sessionOwnership.ts`: Resolves session directories once into shared project/worktree ownership and folder-scope indexes.
|
||||
|
||||
### Hooks
|
||||
|
||||
- `hooks/useSessionActions.ts`: Centralizes session row actions (select/open, rename, share/unshare, archive/delete, confirmations).
|
||||
- `hooks/useSessionSearchEffects.ts`: Handles search open/close UX and input focus behavior.
|
||||
- `hooks/useSessionPrefetch.ts`: Publishes directory-aware nearby/active session prefetch demand to the shared message loader. Recent may prefetch across projects without substituting the current directory.
|
||||
- `hooks/useSessionGrouping.ts`: Builds grouped session structures and search text/filter helpers.
|
||||
- `hooks/useSessionSidebarSections.ts`: Composes final per-project sections and group search metadata for rendering.
|
||||
- `hooks/useProjectSessionSelection.ts`: Resolves active/current project-session selection logic and session-directory context.
|
||||
- `hooks/useArchivedAutoFolders.ts`: Maintains archived auto-folder structure and assignment behavior.
|
||||
- `hooks/useSidebarPersistence.ts`: Persists sidebar UI state (expanded/collapsed/pinned/group order/active session) to storage + desktop settings.
|
||||
- `hooks/useProjectRepoStatus.ts`: Tracks per-project git-repo state and root branch metadata.
|
||||
- `hooks/useProjectSessionLists.ts`: Reads live and archived project buckets from the shared ownership index.
|
||||
- `hooks/useAuthoritativeSessionCleanup.ts`: Establishes the first complete active+archived list as a non-destructive baseline, then cleans persisted state only for sessions omitted by a later authoritative snapshot.
|
||||
- `hooks/useStickyProjectHeaders.ts`: Tracks which project headers are sticky/stuck via `IntersectionObserver`.
|
||||
|
||||
### Types and utilities
|
||||
|
||||
- `types.ts`: Shared sidebar types (`SessionNode`, `SessionGroup`, summary/search metadata).
|
||||
- `activitySections.ts`: Persisted top-section storage/helpers for the current `recent` session list.
|
||||
- `sessionBootstrapDemands.ts`: Builds the deduplicated directory demand plan. Selected directories rank above active projects, expanded groups, visible collapsed groups, and background/collapsed projects.
|
||||
- `utils.tsx`: Shared sidebar utilities (path normalization, dedupe, archived scope keys, project relation checks, text highlight, labels, compact/default date formatting). Shared session ranking lives in `sync/session-ordering.ts`.
|
||||
|
||||
## Loading rules
|
||||
|
||||
- Always publish every known project root and worktree directory. Collapse/visibility changes priority only; they do not opt a directory out of authoritative refresh.
|
||||
- Current directory and selected-session directory are `selected` demand and therefore run first.
|
||||
- Expanded projects/worktrees outrank merely visible and background groups.
|
||||
- The sync scheduler deduplicates, promotes, retries, and limits work. Sidebar components must not reproduce that lifecycle with mount effects.
|
||||
- Hide speculative work when the sidebar/chat surface is hidden: message prefetch, Git/PR enrichment and subscriptions, search listeners, sticky-header observation, and archived-folder derivation stop. The session row tree unmounts so row-owned status, permission, unseen, and viewport subscriptions do no background work. The outer sidebar remains mounted, preserving UI state and authoritative directory refresh for an immediate reopen; deferred derived work reruns from current state when visibility returns.
|
||||
- The sidebar does not subscribe its whole tree to the cross-directory live-session aggregate. Global create/structural/lifecycle snapshots drive rendered session metadata; the cached sync index only fills sessions not yet present globally and provides refresh fallback data. Row activity continues to come from the session-keyed live status index.
|
||||
- Session selection does not invalidate the sidebar orchestration component. Each mounted row selects only whether its own session ID is active, while parent expansion, project selection memory, and neighbor prefetch run in small effect-only subscribers.
|
||||
- Parent expansion is exclusively manual. Selecting or navigating to a subsession never expands its parent automatically. Project/worktree and `recent` trees use independent persisted context keys and receive separate stable projections, so expansion changes in one context neither invalidate nor change the other. The persisted storage key remains `v3`; older state mixed contexts and is not migrated into this contract.
|
||||
- Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics.
|
||||
- Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling.
|
||||
- Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes do not trigger a rebuild. The separate lifecycle rank invalidates ordering only on `settled ↔ active` transitions, with root sessions ranked among roots and child sessions only among siblings of the same parent.
|
||||
- CLI/server-created sessions use the low-frequency OpenChamber control event stream to refresh only the created session directory. The same event retriggers bounded worktree discovery so a newly created external worktree gains ownership without a view reload; it does not re-enable broad session or streaming subscriptions.
|
||||
- Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders.
|
||||
- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave.
|
||||
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
|
||||
- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action.
|
||||
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
|
||||
Directory demand always includes known project roots and worktrees. Visibility
|
||||
only changes priority. Row mounts must not start bootstrap work. Selection and
|
||||
activity subscriptions stay session-scoped so a structural list update does not
|
||||
make every row observe unrelated streaming updates.
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
const RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
||||
|
||||
const isSubtaskSession = (session: Session): boolean => {
|
||||
return Boolean((session as Session & { parentID?: string | null }).parentID);
|
||||
};
|
||||
|
||||
const isArchivedSession = (session: Session): boolean => {
|
||||
return Boolean(session.time?.archived);
|
||||
};
|
||||
|
||||
const getSessionUpdatedAt = (session: Session): number => {
|
||||
const updated = session.time?.updated;
|
||||
const created = session.time?.created;
|
||||
if (typeof updated === 'number' && Number.isFinite(updated)) {
|
||||
return updated;
|
||||
}
|
||||
if (typeof created === 'number' && Number.isFinite(created)) {
|
||||
return created;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Recent contains non-archived root sessions that are active now or were
|
||||
// updated within the retention window. The caller applies shared lifecycle
|
||||
// ordering after this membership filter; batching ("Show more") handles long
|
||||
// windows in the UI.
|
||||
export const deriveRecentSessions = (
|
||||
sessions: Session[],
|
||||
activeSessionIds: ReadonlySet<string>,
|
||||
now = Date.now(),
|
||||
): Session[] => {
|
||||
const minUpdatedAt = now - RECENT_SESSION_MAX_AGE_MS;
|
||||
return sessions.filter((session) => {
|
||||
if (isArchivedSession(session) || isSubtaskSession(session)) {
|
||||
return false;
|
||||
}
|
||||
return activeSessionIds.has(session.id) || getSessionUpdatedAt(session) >= minUpdatedAt;
|
||||
});
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionNode } from './types';
|
||||
|
||||
export type CollapsedActivityState = 'active' | 'unread' | null;
|
||||
|
||||
export const mergeCollapsedActivityStates = (
|
||||
current: CollapsedActivityState,
|
||||
next: CollapsedActivityState,
|
||||
): CollapsedActivityState => {
|
||||
if (current === 'active' || next === 'active') return 'active';
|
||||
if (current === 'unread' || next === 'unread') return 'unread';
|
||||
return null;
|
||||
};
|
||||
|
||||
const getSessionNodeActivityState = (
|
||||
node: SessionNode,
|
||||
activeSessionIds: Set<string>,
|
||||
unreadSessionIds: Set<string>,
|
||||
includeUnreadSubtasks: boolean,
|
||||
): CollapsedActivityState => {
|
||||
if (activeSessionIds.has(node.session.id)) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
let state: CollapsedActivityState = null;
|
||||
const isSubtask = Boolean((node.session as Session & { parentID?: string | null }).parentID);
|
||||
if (unreadSessionIds.has(node.session.id) && (includeUnreadSubtasks || !isSubtask)) {
|
||||
state = 'unread';
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
state = mergeCollapsedActivityStates(
|
||||
state,
|
||||
getSessionNodeActivityState(child, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
|
||||
);
|
||||
if (state === 'active') return state;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export const getSessionNodesActivityState = (
|
||||
nodes: SessionNode[],
|
||||
activeSessionIds: Set<string>,
|
||||
unreadSessionIds: Set<string>,
|
||||
includeUnreadSubtasks: boolean,
|
||||
): CollapsedActivityState => {
|
||||
let state: CollapsedActivityState = null;
|
||||
for (const node of nodes) {
|
||||
state = mergeCollapsedActivityStates(
|
||||
state,
|
||||
getSessionNodeActivityState(node, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
|
||||
);
|
||||
if (state === 'active') return state;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
type BulkActionCapture = {
|
||||
onCreateFolderAndMove: () => void;
|
||||
};
|
||||
|
||||
let bulkActionCapture: BulkActionCapture | null = null;
|
||||
|
||||
mock.module('./BulkActionBar', () => ({
|
||||
BulkActionBar: (props: BulkActionCapture) => {
|
||||
bulkActionCapture = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('./ConfirmDialogs', () => ({
|
||||
BulkSessionDeleteConfirmDialog: () => null,
|
||||
}));
|
||||
|
||||
const { SessionBulkActions } = await import('./SessionBulkActions');
|
||||
|
||||
describe('SessionBulkActions public behavior', () => {
|
||||
test('moves the selected sessions into a newly created folder while a row edit is active', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const originalFolders = useSessionFoldersStore.getState();
|
||||
const originalSelection = useSessionMultiSelectStore.getState();
|
||||
const cssDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'CSS');
|
||||
const renameRequests: Array<{ scopeKey: string; folder: { id: string; name: string } }> = [];
|
||||
const moved: Array<{ scopeKey: string; folderId: string; ids: string[] }> = [];
|
||||
useSessionFoldersStore.setState({
|
||||
foldersMap: {},
|
||||
addSessionsToFolder: (scopeKey, folderId, ids) => moved.push({ scopeKey, folderId, ids }),
|
||||
});
|
||||
useSessionMultiSelectStore.setState({
|
||||
enabled: true,
|
||||
selectedIds: new Set(['session-a']),
|
||||
scopeKey: 'project-a',
|
||||
anchorId: 'session-a',
|
||||
});
|
||||
Object.defineProperty(globalThis, 'CSS', {
|
||||
configurable: true,
|
||||
value: { escape: (value: string) => value },
|
||||
});
|
||||
|
||||
try {
|
||||
await act(async () => root.render(
|
||||
<I18nProvider>
|
||||
<SessionBulkActions
|
||||
getFolderScopesForProject={() => [{ scopeKey: '/workspace', directory: '/workspace' }]}
|
||||
isInlineEditing
|
||||
startFolderRename={(scopeKey, folder) => renameRequests.push({ scopeKey, folder })}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
));
|
||||
expect(bulkActionCapture).not.toBeNull();
|
||||
|
||||
await act(async () => bulkActionCapture?.onCreateFolderAndMove());
|
||||
const createdFolder = useSessionFoldersStore.getState().foldersMap['/workspace']?.[0];
|
||||
expect(createdFolder?.name).toBe('New folder');
|
||||
expect(renameRequests).toEqual([{ scopeKey: '/workspace', folder: createdFolder }]);
|
||||
expect(moved).toEqual([{ scopeKey: '/workspace', folderId: createdFolder?.id ?? '', ids: ['session-a'] }]);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
useSessionFoldersStore.setState(originalFolders, true);
|
||||
useSessionMultiSelectStore.setState(originalSelection, true);
|
||||
if (cssDescriptor) Object.defineProperty(globalThis, 'CSS', cssDescriptor);
|
||||
else Reflect.deleteProperty(globalThis, 'CSS');
|
||||
bulkActionCapture = null;
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { BulkActionBar } from './BulkActionBar';
|
||||
import { BulkSessionDeleteConfirmDialog, type BulkDeleteSessionsConfirmState } from '../shell/ConfirmDialogs';
|
||||
import { useSidebarBulkActions } from './useSidebarBulkActions';
|
||||
|
||||
type Props = {
|
||||
getFolderScopesForProject: (projectId: string) => readonly { scopeKey: string; directory: string | null }[];
|
||||
isInlineEditing: boolean;
|
||||
startFolderRename: (scopeKey: string, folder: { id: string; name: string }) => void;
|
||||
};
|
||||
|
||||
/** Owns the sidebar selection projection and its destructive confirmation. */
|
||||
export function SessionBulkActions({ getFolderScopesForProject, isInlineEditing, startFolderRename }: Props): React.ReactNode {
|
||||
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
|
||||
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
||||
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
||||
const createFolder = useSessionFoldersStore((state) => state.createFolder);
|
||||
const addSessionsToFolder = useSessionFoldersStore((state) => state.addSessionsToFolder);
|
||||
const removeSessionsFromFolders = useSessionFoldersStore((state) => state.removeSessionsFromFolders);
|
||||
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
|
||||
const unarchiveSessions = useSessionUIStore((state) => state.unarchiveSessions);
|
||||
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
|
||||
const bulk = useSidebarBulkActions({
|
||||
isInlineEditing,
|
||||
showDeletionDialog,
|
||||
foldersMap,
|
||||
getFolderScopesForProject,
|
||||
addSessionsToFolder,
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename: (scopeKey) => {
|
||||
const folder = createFolder(scopeKey, 'New folder');
|
||||
startFolderRename(scopeKey, folder);
|
||||
return folder;
|
||||
},
|
||||
archiveSessions,
|
||||
unarchiveSessions,
|
||||
deleteSessions,
|
||||
setBulkDeleteConfirm,
|
||||
});
|
||||
|
||||
return <>
|
||||
{bulk.selectionModeEnabled && bulk.hasSelection ? <BulkActionBar
|
||||
selectedCount={bulk.selectedIdsSize}
|
||||
scopeKey={bulk.derivedSelectionScope}
|
||||
scopeFolders={bulk.bulkScopeFolders}
|
||||
archivedBucket={bulk.bulkScopeIsArchived}
|
||||
onMoveToFolder={bulk.handleBulkMoveToFolder}
|
||||
onCreateFolderAndMove={bulk.handleBulkCreateFolderAndMove}
|
||||
onRemoveFromFolder={bulk.handleBulkRemoveFromFolder}
|
||||
canRemoveFromFolder={bulk.bulkCanRemoveFromFolder}
|
||||
onRestore={bulk.handleBulkRestore}
|
||||
onDelete={bulk.handleBulkDelete}
|
||||
onDone={bulk.handleExitSelectionMode}
|
||||
/> : null}
|
||||
<BulkSessionDeleteConfirmDialog
|
||||
value={bulkDeleteConfirm}
|
||||
setValue={setBulkDeleteConfirm}
|
||||
showDeletionDialog={showDeletionDialog}
|
||||
setShowDeletionDialog={setShowDeletionDialog}
|
||||
onConfirm={bulk.confirmBulkDelete}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
type DragEnd = (event: {
|
||||
active: { data: { current: { type: string; sessionId: string } } };
|
||||
over: { data: { current: { type: string; folderId: string } } } | null;
|
||||
}) => void;
|
||||
|
||||
let handleDragEnd: DragEnd | null = null;
|
||||
|
||||
mock.module('@dnd-kit/core', () => ({
|
||||
DndContext: ({ children, onDragEnd }: { children: React.ReactNode; onDragEnd: DragEnd }) => {
|
||||
handleDragEnd = onDragEnd;
|
||||
return <>{children}</>;
|
||||
},
|
||||
DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
PointerSensor: class {},
|
||||
closestCenter: () => null,
|
||||
useSensor: () => null,
|
||||
useSensors: () => [],
|
||||
useDraggable: () => ({ attributes: {}, listeners: {}, setNodeRef: () => undefined, isDragging: false }),
|
||||
useDroppable: () => ({ setNodeRef: () => undefined, isOver: false }),
|
||||
}));
|
||||
|
||||
const { SessionFolderDndScope } = await import('./sessionFolderDnd');
|
||||
|
||||
describe('SessionFolderDndScope public behavior', () => {
|
||||
test('routes a session-folder drop without depending on row edit or menu state', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const drops: Array<{ sessionId: string; folderId: string }> = [];
|
||||
|
||||
try {
|
||||
await act(async () => root.render(
|
||||
<SessionFolderDndScope
|
||||
scopeKey="/workspace"
|
||||
hasFolders
|
||||
onSessionDroppedOnFolder={(sessionId, folderId) => drops.push({ sessionId, folderId })}
|
||||
>
|
||||
{null}
|
||||
</SessionFolderDndScope>,
|
||||
));
|
||||
expect(handleDragEnd).not.toBeNull();
|
||||
|
||||
await act(async () => handleDragEnd?.({
|
||||
active: { data: { current: { type: 'session', sessionId: 'session-a' } } },
|
||||
over: { data: { current: { type: 'folder', folderId: 'folder-a' } } },
|
||||
}));
|
||||
expect(drops).toEqual([{ sessionId: 'session-a', folderId: 'folder-a' }]);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
handleDragEnd = null;
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -3,7 +3,7 @@ import {
|
||||
getArchivedScopeKey,
|
||||
resolveArchivedFolderName,
|
||||
} from '../utils';
|
||||
import type { SessionOwnershipIndex } from '../sessionOwnership';
|
||||
import type { SessionOwnershipIndex } from '../sessions/sessionOwnership';
|
||||
|
||||
type ProjectForArchivedFolders = {
|
||||
id: string;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveSelectionFolderScopes } from './useSidebarBulkActions';
|
||||
|
||||
describe('sidebar bulk project scopes', () => {
|
||||
test('uses every root and worktree scope owned by the selected project', () => {
|
||||
const scopes = resolveSelectionFolderScopes('project-a', (projectId) => projectId === 'project-a'
|
||||
? [
|
||||
{ scopeKey: '/workspace/project-a', directory: '/workspace/project-a' },
|
||||
{ scopeKey: '/workspace/project-a-worktree', directory: '/workspace/project-a-worktree' },
|
||||
]
|
||||
: []);
|
||||
|
||||
expect(scopes).toEqual(['/workspace/project-a', '/workspace/project-a-worktree']);
|
||||
});
|
||||
|
||||
test('keeps a directory scope when no project scope owns it', () => {
|
||||
expect(resolveSelectionFolderScopes('/workspace/vscode', () => [])).toEqual(['/workspace/vscode']);
|
||||
});
|
||||
});
|
||||
+15
-10
@@ -13,7 +13,7 @@ type Args = {
|
||||
* map resolves it to the project's folder scopes (root + worktrees). When
|
||||
* the scope is missing here it is treated as a plain directory scope.
|
||||
*/
|
||||
folderScopesByProject: Map<string, Array<{ scopeKey: string; directory: string | null }>>;
|
||||
getFolderScopesForProject: (projectId: string) => readonly { scopeKey: string; directory: string | null }[];
|
||||
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
|
||||
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
@@ -26,6 +26,17 @@ type Args = {
|
||||
} | null>>;
|
||||
};
|
||||
|
||||
export const resolveSelectionFolderScopes = (
|
||||
selectionScope: string | null,
|
||||
getFolderScopesForProject: Args['getFolderScopesForProject'],
|
||||
): string[] => {
|
||||
if (!selectionScope) return [];
|
||||
const projectScopes = getFolderScopesForProject(selectionScope);
|
||||
return projectScopes.length > 0
|
||||
? projectScopes.map((scope) => scope.scopeKey)
|
||||
: [selectionScope];
|
||||
};
|
||||
|
||||
/**
|
||||
* Bulk-action logic for the sidebar. The hot-path concern is that this
|
||||
* hook subscribes to `useSessionMultiSelectStore` — which can fire on
|
||||
@@ -46,7 +57,7 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
isInlineEditing,
|
||||
showDeletionDialog,
|
||||
foldersMap,
|
||||
folderScopesByProject,
|
||||
getFolderScopesForProject,
|
||||
addSessionsToFolder,
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
@@ -101,14 +112,8 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
// The selection scope is a project id; folders live per directory scope
|
||||
// (project root + each worktree). Resolve all of them, in project order.
|
||||
const selectionFolderScopes = React.useMemo<string[]>(() => {
|
||||
if (!derivedSelectionScope) return [];
|
||||
const projectScopes = folderScopesByProject.get(derivedSelectionScope);
|
||||
if (projectScopes && projectScopes.length > 0) {
|
||||
return projectScopes.map((scope) => scope.scopeKey);
|
||||
}
|
||||
// Fallback: the scope is already a directory (e.g. VS Code workspaces).
|
||||
return [derivedSelectionScope];
|
||||
}, [derivedSelectionScope, folderScopesByProject]);
|
||||
return resolveSelectionFolderScopes(derivedSelectionScope, getFolderScopesForProject);
|
||||
}, [derivedSelectionScope, getFolderScopesForProject]);
|
||||
|
||||
const bulkScopeFolders = React.useMemo(() => {
|
||||
return selectionFolderScopes.flatMap((scope) => foldersMap[scope] ?? []);
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import {
|
||||
buildAuthoritativeSessionIdentityMap,
|
||||
findRemovedAuthoritativeSessions,
|
||||
} from '../authoritativeSessionCleanup';
|
||||
|
||||
const session = (id: string, directory = '/repo'): Session => ({ id, directory }) as Session;
|
||||
|
||||
describe('authoritative session cleanup', () => {
|
||||
test('does not infer deletion from the first authoritative startup snapshot', () => {
|
||||
const current = buildAuthoritativeSessionIdentityMap([]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(null, current)).toEqual([]);
|
||||
});
|
||||
|
||||
test('finds sessions omitted after an established authoritative baseline', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([
|
||||
session('deleted'),
|
||||
session('retained'),
|
||||
]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([session('retained')]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([
|
||||
{ directory: '/repo', sessionId: 'deleted' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('treats archive membership as retained authority', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([session('archived')]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([
|
||||
{ ...session('archived'), time: { archived: 10 } } as Session,
|
||||
]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not treat a directory move as session deletion', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-a')]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-b')]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import React from 'react';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
|
||||
type SafeStorageLike = {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
};
|
||||
|
||||
type Keys = {
|
||||
sessionExpanded: string;
|
||||
projectCollapse: string;
|
||||
groupOrder: string;
|
||||
groupCollapse: string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
isVSCode: boolean;
|
||||
safeStorage: SafeStorageLike;
|
||||
keys: Keys;
|
||||
groupOrderByProject: Map<string, string[]>;
|
||||
collapsedGroups: Set<string>;
|
||||
setExpandedParents: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
setCollapsedProjects: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
};
|
||||
|
||||
export const useSidebarPersistence = (args: Args) => {
|
||||
const {
|
||||
isVSCode,
|
||||
safeStorage,
|
||||
keys,
|
||||
groupOrderByProject,
|
||||
collapsedGroups,
|
||||
setExpandedParents,
|
||||
setCollapsedProjects,
|
||||
} = args;
|
||||
|
||||
const persistCollapsedProjectsTimer = React.useRef<number | null>(null);
|
||||
const pendingCollapsedProjects = React.useRef<Set<string> | null>(null);
|
||||
|
||||
const flushCollapsedProjectsPersist = React.useCallback(() => {
|
||||
if (isVSCode) {
|
||||
return;
|
||||
}
|
||||
const collapsed = pendingCollapsedProjects.current;
|
||||
pendingCollapsedProjects.current = null;
|
||||
persistCollapsedProjectsTimer.current = null;
|
||||
if (!collapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { projects } = useProjectsStore.getState();
|
||||
const updatedProjects = projects.map((project) => ({
|
||||
...project,
|
||||
sidebarCollapsed: collapsed.has(project.id),
|
||||
}));
|
||||
void updateDesktopSettings({ projects: updatedProjects }).catch(() => {});
|
||||
}, [isVSCode]);
|
||||
|
||||
const scheduleCollapsedProjectsPersist = React.useCallback((collapsed: Set<string>) => {
|
||||
if (typeof window === 'undefined' || isVSCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingCollapsedProjects.current = collapsed;
|
||||
if (persistCollapsedProjectsTimer.current !== null) {
|
||||
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
||||
}
|
||||
persistCollapsedProjectsTimer.current = window.setTimeout(() => {
|
||||
flushCollapsedProjectsPersist();
|
||||
}, 700);
|
||||
}, [isVSCode, flushCollapsedProjectsPersist]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (typeof window !== 'undefined' && persistCollapsedProjectsTimer.current !== null) {
|
||||
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
||||
}
|
||||
persistCollapsedProjectsTimer.current = null;
|
||||
pendingCollapsedProjects.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const storedParents = safeStorage.getItem(keys.sessionExpanded);
|
||||
if (storedParents) {
|
||||
const parsed = JSON.parse(storedParents);
|
||||
if (Array.isArray(parsed)) {
|
||||
setExpandedParents(new Set(parsed.filter((item) => typeof item === 'string')));
|
||||
}
|
||||
}
|
||||
const storedProjects = safeStorage.getItem(keys.projectCollapse);
|
||||
if (storedProjects) {
|
||||
const parsed = JSON.parse(storedProjects);
|
||||
if (Array.isArray(parsed)) {
|
||||
setCollapsedProjects(new Set(parsed.filter((item) => typeof item === 'string')));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [keys.projectCollapse, keys.sessionExpanded, safeStorage, setCollapsedProjects, setExpandedParents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const serialized = Object.fromEntries(groupOrderByProject.entries());
|
||||
safeStorage.setItem(keys.groupOrder, JSON.stringify(serialized));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [groupOrderByProject, keys.groupOrder, safeStorage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
safeStorage.setItem(keys.groupCollapse, JSON.stringify(Array.from(collapsedGroups)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [collapsedGroups, keys.groupCollapse, safeStorage]);
|
||||
|
||||
return { scheduleCollapsedProjectsPersist };
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildSessionBootstrapDemands } from './sessionBootstrapDemands';
|
||||
|
||||
describe('SessionProjectCollection', () => {
|
||||
test('preserves authoritative background demand when its visible rows are absent', () => {
|
||||
const demands = buildSessionBootstrapDemands({
|
||||
knownDirectories: ['/project', '/project/worktree'],
|
||||
activeProjectDirectory: '/project',
|
||||
activeProjectId: 'project',
|
||||
collapsedProjects: new Set(),
|
||||
collapsedGroups: new Set(),
|
||||
currentDirectory: null,
|
||||
currentSessionDirectory: null,
|
||||
});
|
||||
|
||||
expect(demands.map((demand) => demand.directory)).toEqual(['/project', '/project/worktree']);
|
||||
expect(demands[0]?.priority).toBe('active-project');
|
||||
expect(demands[1]?.priority).toBe('background');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,606 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { usePrefetchSessionMessages } from '@/sync/use-sync';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useArchivedAutoFolders } from '../folders/useArchivedAutoFolders';
|
||||
import { ProjectSessionSelectionEffect } from '../projects/useProjectSessionSelection';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { useRecentSessionCollection, useSessionProjectCollection } from './sessionCollection';
|
||||
import { buildSessionBootstrapDemands } from './sessionBootstrapDemands';
|
||||
import { useChildStoreManager } from '@/sync/sync-context';
|
||||
import { createSessionOwnershipIndex } from '../sessions/sessionOwnership';
|
||||
import { useProjectSessionLists } from '../projects/useProjectSessionLists';
|
||||
import { useSessionSidebarSections } from '../projects/useSessionSidebarSections';
|
||||
import { SessionPrefetchEffect } from './useSessionPrefetch';
|
||||
import { normalizePath } from '../utils';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { SessionProjectScroller } from '../projects/SessionProjectScroller';
|
||||
import { useSessionGrouping } from '../projects/useSessionGrouping';
|
||||
import { useStickyProjectHeaders } from '../projects/useStickyProjectHeaders';
|
||||
import { SessionBulkActions } from '../folders/SessionBulkActions';
|
||||
import { RecentSessionSection } from '../recent/RecentSessionSection';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import type { useSessionProjectViewState } from '../projects/useSessionProjectViewState';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import type { DeleteSessionConfirmState } from '../sessions/useSessionActions';
|
||||
import { useExpandedParents } from '../sessions/useExpandedParents';
|
||||
import { SessionGroupSection } from '../projects/SessionGroupSection';
|
||||
import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory } from '@/lib/chatDirectories';
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
|
||||
const PR_NO_PR_RETRY_MS = 5 * 60_000;
|
||||
|
||||
const isRootSession = (session: Session): boolean => {
|
||||
// SAFETY: OpenCode attaches parentID to hierarchical session records,
|
||||
// although the SDK's base Session type does not currently declare it.
|
||||
return !(session as Session & { parentID?: string | null }).parentID;
|
||||
};
|
||||
|
||||
type Project = {
|
||||
id: string;
|
||||
path: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||
iconBackground?: string;
|
||||
};
|
||||
|
||||
type SessionProjectCollectionProps = {
|
||||
topology: {
|
||||
projects: Project[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
knownDirectories: Set<string>;
|
||||
isVSCode: boolean;
|
||||
worktreeMetadata: Map<string, WorktreeMetadata>;
|
||||
gitBranches: Map<string, string | null>;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
projectRootBranches: Map<string, string | null>;
|
||||
lastRepoStatus: boolean;
|
||||
};
|
||||
view: {
|
||||
isVisible: boolean;
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
activeProjectId: string | null;
|
||||
showInlineArchived: boolean;
|
||||
useGroupedSections: boolean;
|
||||
homeDirectory: string | null;
|
||||
mobileVariant: boolean;
|
||||
hideDirectoryControls: boolean;
|
||||
showOnlyMainWorkspace: boolean;
|
||||
isDesktopShellRuntime: boolean;
|
||||
stickyZoneHeaders: boolean;
|
||||
projectSortOrder: import('@/stores/useSessionDisplayStore').ProjectSortOrder;
|
||||
emptyState: React.ReactNode;
|
||||
searchEmptyState: React.ReactNode;
|
||||
isSessionsLoading: boolean;
|
||||
isWorktreeTopologyLoading: boolean;
|
||||
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
|
||||
projectView: ReturnType<typeof useSessionProjectViewState>['state'];
|
||||
};
|
||||
actions: {
|
||||
rowActions: {
|
||||
allowReselect: boolean;
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
isSessionSearchOpen: boolean;
|
||||
sessionSearchQuery: string;
|
||||
setSessionSearchQuery: (value: string) => void;
|
||||
setIsSessionSearchOpen: (open: boolean) => void;
|
||||
};
|
||||
alwaysShowActions: boolean;
|
||||
notifyOnSubtasks: boolean;
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
openNewWorktreeDialog: () => void;
|
||||
openWorktreesPage: (id: string) => void;
|
||||
openProjectEditDialog: (id: string) => void;
|
||||
removeProject: (id: string) => void;
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||
initialActiveSessionByProject: Map<string, string>;
|
||||
persistActiveSessionByProject: (value: Map<string, string>) => void;
|
||||
projectViewActions: Pick<
|
||||
ReturnType<typeof useSessionProjectViewState>['actions'],
|
||||
'getOrderedGroups' | 'setGroupOrderByProject' | 'toggleGroup' | 'toggleProject'
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topology, view, actions }) => {
|
||||
const { alwaysShowActions, notifyOnSubtasks, projectViewActions, rowActions, ...scrollerActions } = actions;
|
||||
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
||||
const createFolder = useSessionFoldersStore((state) => state.createFolder);
|
||||
const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder);
|
||||
const projectView = view.projectView;
|
||||
const { getOrderedGroups, setGroupOrderByProject, toggleGroup, toggleProject } = projectViewActions;
|
||||
const collection = useSessionProjectCollection({ knownDirectories: topology.knownDirectories, isVSCode: topology.isVSCode, isVisible: true });
|
||||
const [visibleSessionCountByGroup, setVisibleSessionCountByGroup] = React.useState<Map<string, number>>(new Map());
|
||||
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => {
|
||||
setVisibleSessionCountByGroup((current) => new Map(current).set(groupId, currentVisibleCount + 7));
|
||||
}, []);
|
||||
const resetGroupSessionLimit = React.useCallback((groupId: string) => {
|
||||
setVisibleSessionCountByGroup((current) => {
|
||||
if (!current.has(groupId)) return current;
|
||||
const next = new Map(current);
|
||||
next.delete(groupId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
|
||||
const projectDisplayMode = useSessionDisplayStore((state) => state.projectDisplayMode);
|
||||
const singleProjectId = useSessionDisplayStore((state) => state.singleProjectId);
|
||||
const setSingleProjectId = useSessionDisplayStore((state) => state.setSingleProjectId);
|
||||
const supportsSingleProjectMode = !topology.isVSCode && !isCapacitorApp();
|
||||
const singleProjectMode = supportsSingleProjectMode && projectDisplayMode === 'single';
|
||||
const recentSessions = useRecentSessionCollection({
|
||||
enabled: showRecentSection && !singleProjectMode,
|
||||
isVSCode: topology.isVSCode,
|
||||
pinnedSessionIds: collection.pinnedSessionIds,
|
||||
sessionOrderRanks: collection.sessionOrderRanks,
|
||||
sessions: collection.rootSessions,
|
||||
});
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = React.useState('');
|
||||
const [openSidebarMenuKey, setOpenSidebarMenuKey] = React.useState<string | null>(null);
|
||||
const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState<DeleteSessionConfirmState>(null);
|
||||
const [copiedSessionId, setCopiedSessionId] = React.useState<string | null>(null);
|
||||
const [folderRename, setFolderRename] = React.useState<{ scopeKey: string; folderId: string; draft: string } | null>(null);
|
||||
const startFolderRename = React.useCallback((scopeKey: string, folder: { id: string; name: string }) => {
|
||||
setFolderRename({ scopeKey, folderId: folder.id, draft: folder.name });
|
||||
}, []);
|
||||
const setFolderRenameDraft = React.useCallback((draft: string) => {
|
||||
setFolderRename((current) => current ? { ...current, draft } : null);
|
||||
}, []);
|
||||
const clearFolderRename = React.useCallback(() => setFolderRename(null), []);
|
||||
const { expandedParents, toggleParent } = useExpandedParents();
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const selectSessionForProject = React.useCallback((sessionId: string, sessionDirectory: string | null) => {
|
||||
if (sessionId === useSessionUIStore.getState().currentSessionId) return;
|
||||
setCurrentSession(sessionId, sessionDirectory);
|
||||
}, [setCurrentSession]);
|
||||
const prefetchSession = usePrefetchSessionMessages();
|
||||
const { buildGroupedSessions, filterSessionNodesForSearch, buildGroupSearchText } = useSessionGrouping({
|
||||
homeDirectory: view.homeDirectory,
|
||||
worktreeMetadata: topology.worktreeMetadata,
|
||||
pinnedSessionIds: collection.pinnedSessionIds,
|
||||
sessionOrderRanks: collection.sessionOrderRanks,
|
||||
gitBranches: topology.gitBranches,
|
||||
isVSCode: topology.isVSCode,
|
||||
});
|
||||
const ownership = React.useMemo(
|
||||
() => createSessionOwnershipIndex(collection.sessions, topology.projects, topology.availableWorktreesByProject, topology.isVSCode, collection.archivedSessions),
|
||||
[collection.archivedSessions, collection.sessions, topology.availableWorktreesByProject, topology.isVSCode, topology.projects],
|
||||
);
|
||||
const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({ ownership });
|
||||
const { projectSections, groupSearchDataByGroup, sectionsForRender, flatSectionsForRender } = useSessionSidebarSections({
|
||||
normalizedProjects: topology.projects,
|
||||
getSessionsForProject,
|
||||
getArchivedSessionsForProject,
|
||||
availableWorktreesByProject: topology.availableWorktreesByProject,
|
||||
projectRepoStatus: topology.projectRepoStatus,
|
||||
projectRootBranches: topology.projectRootBranches,
|
||||
lastRepoStatus: topology.lastRepoStatus,
|
||||
buildGroupedSessions,
|
||||
hasSessionSearchQuery: view.hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery: view.normalizedSessionSearchQuery,
|
||||
filterSessionNodesForSearch,
|
||||
buildGroupSearchText,
|
||||
foldersMap,
|
||||
});
|
||||
|
||||
// Second bootstrap-demand owner: the layout-level useSessionListSync keeps
|
||||
// every known directory alive at background priority even when the sidebar
|
||||
// is hidden, but only the visible collection knows which projects and
|
||||
// groups are EXPANDED. Without this owner, expanded projects bootstrapped
|
||||
// serialized at background priority (one directory at a time) instead of
|
||||
// concurrently at expanded priority.
|
||||
const childStores = useChildStoreManager();
|
||||
const expansionDemandOwner = `session-collection-expansion:${React.useId()}`;
|
||||
React.useEffect(() => {
|
||||
childStores.setBootstrapDemand(expansionDemandOwner, buildSessionBootstrapDemands({
|
||||
projectSections,
|
||||
activeProjectId: view.activeProjectId,
|
||||
collapsedProjects: projectView.collapsedProjects,
|
||||
collapsedGroups: projectView.collapsedGroups,
|
||||
currentDirectory: null,
|
||||
currentSessionDirectory: null,
|
||||
}));
|
||||
return () => childStores.clearBootstrapDemand(expansionDemandOwner);
|
||||
}, [childStores, expansionDemandOwner, projectSections, projectView.collapsedProjects, projectView.collapsedGroups, view.activeProjectId]);
|
||||
const source = view.useGroupedSections ? sectionsForRender : flatSectionsForRender;
|
||||
const sectionsForSidebarRender = React.useMemo(() => view.showInlineArchived ? source : source.map((section) => (
|
||||
section.groups.some((group) => group.isArchivedBucket)
|
||||
? { ...section, groups: section.groups.filter((group) => !group.isArchivedBucket) }
|
||||
: section
|
||||
)), [source, view.showInlineArchived]);
|
||||
const getFolderScopesForProject = React.useCallback((projectId: string) => {
|
||||
const section = flatSectionsForRender.find((entry) => entry.project.id === projectId);
|
||||
return section?.groups.find((group) => !group.isArchivedBucket)?.folderScopes ?? [];
|
||||
}, [flatSectionsForRender]);
|
||||
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
const stuckProjectHeaders = useStickyProjectHeaders({
|
||||
enabled: view.stickyZoneHeaders,
|
||||
isDesktopShellRuntime: view.isDesktopShellRuntime,
|
||||
projectSections,
|
||||
projectHeaderSentinelRefs,
|
||||
});
|
||||
useArchivedAutoFolders({
|
||||
enabled: true,
|
||||
normalizedProjects: topology.projects,
|
||||
ownership,
|
||||
isSessionsLoading: view.isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions: collection.hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading: view.isWorktreeTopologyLoading,
|
||||
unresolvedWorktreeProjectPaths: view.unresolvedWorktreeProjectPaths,
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
});
|
||||
const { github } = useRuntimeAPIs();
|
||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const ensureEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
|
||||
const setParams = useGitHubPrStatusStore((state) => state.setParams);
|
||||
const refreshTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
|
||||
const retriedRef = React.useRef(new Set<string>());
|
||||
React.useEffect(() => {
|
||||
if (!github || !githubAuthChecked || !githubAuthStatus?.connected) return;
|
||||
const targets = new Map<string, { directory: string; branch: string }>();
|
||||
const now = Date.now();
|
||||
projectSections.forEach((section) => {
|
||||
if (projectView.collapsedProjects.has(section.project.id)) return;
|
||||
section.groups.forEach((group) => {
|
||||
if (group.isArchivedBucket || group.isMain) return;
|
||||
const directory = normalizePath(group.directory ?? null);
|
||||
const branch = group.branch?.trim() || topology.gitBranches.get(directory || '')?.trim();
|
||||
if (!directory || !branch) return;
|
||||
const key = getGitHubPrStatusKey(directory, branch);
|
||||
const entry = useGitHubPrStatusStore.getState().entries[key];
|
||||
const terminal = entry?.status?.pr?.state === 'closed' || entry?.status?.pr?.state === 'merged';
|
||||
const retryKey = `${directory}::${branch}`;
|
||||
const lastChecked = Math.max(entry?.lastRefreshAt ?? 0, entry?.lastDiscoveryPollAt ?? 0);
|
||||
const retry = Boolean(entry?.isInitialStatusResolved && (!entry.status?.pr || terminal) && (!retriedRef.current.has(retryKey) || now - lastChecked >= PR_NO_PR_RETRY_MS));
|
||||
if (!entry || !entry.isInitialStatusResolved || retry) {
|
||||
if (retry) retriedRef.current.add(retryKey);
|
||||
targets.set(key, { directory, branch });
|
||||
}
|
||||
});
|
||||
});
|
||||
targets.forEach((target, key) => {
|
||||
ensureEntry(key);
|
||||
setParams(key, { ...target, remoteName: null, canShow: true, github, githubAuthChecked, githubConnected: githubAuthStatus.connected });
|
||||
});
|
||||
if (targets.size) void refreshTargets([...targets.values()], { silent: true, markInitialResolved: true });
|
||||
}, [ensureEntry, github, githubAuthChecked, githubAuthStatus?.connected, projectSections, projectView.collapsedProjects, refreshTargets, setParams, topology.gitBranches]);
|
||||
const sessionOrderIndex = React.useMemo(
|
||||
() => new Map(collection.orderedSessions.map((session, index) => [session.id, index])),
|
||||
[collection.orderedSessions],
|
||||
);
|
||||
const orderedSectionsForRender = React.useMemo(
|
||||
() => sectionsForSidebarRender.map((section) => {
|
||||
const groups = getOrderedGroups(section.project.id, section.groups);
|
||||
return groups === section.groups ? section : { ...section, groups };
|
||||
}),
|
||||
[getOrderedGroups, sectionsForSidebarRender],
|
||||
);
|
||||
let selectedSingleProjectId: string | null = null;
|
||||
if (singleProjectMode) {
|
||||
if (projectSections.some((section) => section.project.id === singleProjectId)) {
|
||||
selectedSingleProjectId = singleProjectId;
|
||||
} else if (projectSections.some((section) => section.project.id === view.activeProjectId)) {
|
||||
selectedSingleProjectId = view.activeProjectId;
|
||||
} else {
|
||||
selectedSingleProjectId = projectSections[0]?.project.id ?? null;
|
||||
}
|
||||
}
|
||||
const groupProps = React.useMemo(() => ({
|
||||
hasSessionSearchQuery: view.hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery: view.normalizedSessionSearchQuery,
|
||||
groupSearchDataByGroup,
|
||||
collapsedGroups: projectView.collapsedGroups,
|
||||
hideDirectoryControls: view.hideDirectoryControls,
|
||||
mobileVariant: view.mobileVariant,
|
||||
alwaysShowActions,
|
||||
activeProjectId: view.activeProjectId,
|
||||
notifyOnSubtasks,
|
||||
pinnedSessionIds: collection.pinnedSessionIds,
|
||||
sessionOrderIndex,
|
||||
expandedParents,
|
||||
editingId,
|
||||
editTitle,
|
||||
copiedSessionId,
|
||||
sessionBatchSize: singleProjectMode && !view.useGroupedSections ? 20 : undefined,
|
||||
setEditingId,
|
||||
setEditTitle,
|
||||
toggleParent,
|
||||
allowReselect: rowActions.allowReselect,
|
||||
onSessionSelected: rowActions.onSessionSelected,
|
||||
isSessionSearchOpen: rowActions.isSessionSearchOpen,
|
||||
sessionSearchQuery: rowActions.sessionSearchQuery,
|
||||
setSessionSearchQuery: rowActions.setSessionSearchQuery,
|
||||
setIsSessionSearchOpen: rowActions.setIsSessionSearchOpen,
|
||||
deleteSessionConfirm,
|
||||
setDeleteSessionConfirm,
|
||||
startFolderRename,
|
||||
setCopiedSessionId,
|
||||
folderRename,
|
||||
setFolderRenameDraft,
|
||||
clearFolderRename,
|
||||
}), [
|
||||
collection.pinnedSessionIds,
|
||||
alwaysShowActions,
|
||||
notifyOnSubtasks,
|
||||
projectView.collapsedGroups,
|
||||
groupSearchDataByGroup,
|
||||
sessionOrderIndex,
|
||||
editTitle,
|
||||
editingId,
|
||||
expandedParents,
|
||||
folderRename,
|
||||
setFolderRenameDraft,
|
||||
clearFolderRename,
|
||||
startFolderRename,
|
||||
deleteSessionConfirm,
|
||||
copiedSessionId,
|
||||
setCopiedSessionId,
|
||||
rowActions,
|
||||
toggleParent,
|
||||
view.hideDirectoryControls,
|
||||
view.hasSessionSearchQuery,
|
||||
view.activeProjectId,
|
||||
view.mobileVariant,
|
||||
view.normalizedSessionSearchQuery,
|
||||
view.useGroupedSections,
|
||||
singleProjectMode,
|
||||
]);
|
||||
const groupActions = React.useMemo(() => ({
|
||||
showMoreGroupSessions,
|
||||
resetGroupSessionLimit,
|
||||
setActiveProjectIdOnly: scrollerActions.setActiveProjectIdOnly,
|
||||
setSessionSwitcherOpen: scrollerActions.setSessionSwitcherOpen,
|
||||
openNewSessionDraft: scrollerActions.openNewSessionDraft,
|
||||
onToggleCollapsedGroup: toggleGroup,
|
||||
}), [
|
||||
resetGroupSessionLimit,
|
||||
showMoreGroupSessions,
|
||||
toggleGroup,
|
||||
scrollerActions.openNewSessionDraft,
|
||||
scrollerActions.setActiveProjectIdOnly,
|
||||
scrollerActions.setSessionSwitcherOpen,
|
||||
]);
|
||||
const chatGroup = React.useMemo<SessionGroup | null>(() => {
|
||||
if (topology.isVSCode) return null;
|
||||
const chatsRoot = getChatsRootForHome(view.homeDirectory)
|
||||
?? collection.chatSessions.map((session) => getChatsRootFromDirectory(session.directory)).find(Boolean)
|
||||
?? null;
|
||||
if (!chatsRoot) return null;
|
||||
const folderScopes = Array.from(new Set([
|
||||
chatsRoot,
|
||||
...collection.chatSessions.map((session) => normalizePath(session.directory ?? null)).filter(Boolean),
|
||||
])).filter((directory): directory is string => Boolean(directory))
|
||||
.map((directory) => ({ scopeKey: directory, directory }));
|
||||
return {
|
||||
id: 'managed-chats',
|
||||
label: '',
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: true,
|
||||
worktree: null,
|
||||
directory: chatsRoot,
|
||||
folderScopeKey: chatsRoot,
|
||||
folderScopes,
|
||||
draftTarget: 'chat',
|
||||
sessions: collection.chatSessions
|
||||
.filter((session) => !session.time?.archived && isRootSession(session))
|
||||
.map((session) => ({ session, children: (collection.childrenMap.get(session.id) ?? []).filter((child) => !child.time?.archived).map((child) => ({ session: child, children: [], worktree: null })), worktree: null })),
|
||||
};
|
||||
}, [collection.chatSessions, collection.childrenMap, topology.isVSCode, view.homeDirectory]);
|
||||
const renderChatsSection = React.useCallback(() => {
|
||||
if (!chatGroup) return null;
|
||||
return <SessionGroupSection
|
||||
{...groupProps}
|
||||
{...groupActions}
|
||||
group={chatGroup}
|
||||
groupKey="managed-chats"
|
||||
projectId={null}
|
||||
hideGroupLabel
|
||||
sessionBatchSize={20}
|
||||
scrollContainerRef={undefined}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
/>;
|
||||
}, [chatGroup, groupActions, groupProps, openSidebarMenuKey]);
|
||||
const handleOpenNewChat = React.useCallback(() => {
|
||||
useUIStore.getState().closeMainSurfaces();
|
||||
if (view.mobileVariant) scrollerActions.setSessionSwitcherOpen(false);
|
||||
scrollerActions.openNewSessionDraft({ selectedProjectId: CHAT_DRAFT_PROJECT_ID, directoryOverride: null });
|
||||
}, [scrollerActions, view.mobileVariant]);
|
||||
const recentSection = React.useMemo(() => (
|
||||
!topology.isVSCode ? <RecentSessionSection
|
||||
projects={topology.projects}
|
||||
availableWorktreesByProject={topology.availableWorktreesByProject}
|
||||
gitBranches={topology.gitBranches}
|
||||
homeDirectory={view.homeDirectory}
|
||||
hasSessionSearchQuery={view.hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={view.normalizedSessionSearchQuery}
|
||||
isDesktopShellRuntime={view.isDesktopShellRuntime}
|
||||
sessions={recentSessions}
|
||||
childrenMap={collection.childrenMap}
|
||||
pinnedSessionIds={collection.pinnedSessionIds}
|
||||
recentSessions={recentSessions}
|
||||
expandedParents={expandedParents}
|
||||
notifyOnSubtasks={notifyOnSubtasks}
|
||||
editingId={editingId}
|
||||
editTitle={editTitle}
|
||||
copiedSessionId={copiedSessionId}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
mobileVariant={view.mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
setEditingId={setEditingId}
|
||||
setEditTitle={setEditTitle}
|
||||
toggleParent={toggleParent}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
allowReselect={rowActions.allowReselect}
|
||||
onSessionSelected={rowActions.onSessionSelected}
|
||||
isSessionSearchOpen={rowActions.isSessionSearchOpen}
|
||||
sessionSearchQuery={rowActions.sessionSearchQuery}
|
||||
setSessionSearchQuery={rowActions.setSessionSearchQuery}
|
||||
setIsSessionSearchOpen={rowActions.setIsSessionSearchOpen}
|
||||
deleteSessionConfirm={deleteSessionConfirm}
|
||||
setDeleteSessionConfirm={setDeleteSessionConfirm}
|
||||
startFolderRename={startFolderRename}
|
||||
setCopiedSessionId={setCopiedSessionId}
|
||||
chatSessions={collection.chatSessions}
|
||||
renderChatsSection={renderChatsSection}
|
||||
onNewChat={handleOpenNewChat}
|
||||
showRecentSection={showRecentSection && !singleProjectMode}
|
||||
/> : null
|
||||
), [
|
||||
alwaysShowActions,
|
||||
collection.childrenMap,
|
||||
collection.pinnedSessionIds,
|
||||
copiedSessionId,
|
||||
deleteSessionConfirm,
|
||||
editTitle,
|
||||
editingId,
|
||||
expandedParents,
|
||||
notifyOnSubtasks,
|
||||
openSidebarMenuKey,
|
||||
recentSessions,
|
||||
rowActions,
|
||||
showRecentSection,
|
||||
singleProjectMode,
|
||||
handleOpenNewChat,
|
||||
renderChatsSection,
|
||||
startFolderRename,
|
||||
toggleParent,
|
||||
topology.availableWorktreesByProject,
|
||||
topology.gitBranches,
|
||||
topology.isVSCode,
|
||||
topology.projects,
|
||||
collection.chatSessions,
|
||||
view.hasSessionSearchQuery,
|
||||
view.homeDirectory,
|
||||
view.isDesktopShellRuntime,
|
||||
view.mobileVariant,
|
||||
view.normalizedSessionSearchQuery,
|
||||
]);
|
||||
const scrollerModel = React.useMemo(() => ({
|
||||
topContent: recentSection,
|
||||
hasSharedSessions: Boolean(recentSection),
|
||||
sectionsForRender: orderedSectionsForRender,
|
||||
projectSections,
|
||||
activeProjectId: view.activeProjectId,
|
||||
singleProjectMode,
|
||||
singleProjectId: selectedSingleProjectId,
|
||||
emptyState: view.emptyState,
|
||||
searchEmptyState: view.searchEmptyState,
|
||||
projectRepoStatus: topology.projectRepoStatus,
|
||||
stuckProjectHeaders,
|
||||
projectHeaderSentinelRefs,
|
||||
state: { editingId, openSidebarMenuKey, setOpenSidebarMenuKey, visibleSessionCountByGroup },
|
||||
groupProps,
|
||||
}), [
|
||||
groupProps,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
projectSections,
|
||||
orderedSectionsForRender,
|
||||
stuckProjectHeaders,
|
||||
topology.projectRepoStatus,
|
||||
view.activeProjectId,
|
||||
view.emptyState,
|
||||
view.searchEmptyState,
|
||||
visibleSessionCountByGroup,
|
||||
recentSection,
|
||||
singleProjectMode,
|
||||
selectedSingleProjectId,
|
||||
]);
|
||||
const scrollerView = React.useMemo(() => ({
|
||||
homeDirectory: view.homeDirectory,
|
||||
collapsedProjects: projectView.collapsedProjects,
|
||||
showOnlyMainWorkspace: view.showOnlyMainWorkspace,
|
||||
hasSessionSearchQuery: view.hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery: view.normalizedSessionSearchQuery,
|
||||
hideDirectoryControls: view.hideDirectoryControls,
|
||||
isDesktopShellRuntime: view.isDesktopShellRuntime,
|
||||
stickyZoneHeaders: view.stickyZoneHeaders,
|
||||
mobileVariant: view.mobileVariant,
|
||||
alwaysShowActions,
|
||||
projectSortOrder: view.projectSortOrder,
|
||||
}), [
|
||||
projectView.collapsedProjects,
|
||||
view.homeDirectory,
|
||||
view.hasSessionSearchQuery,
|
||||
view.hideDirectoryControls,
|
||||
view.isDesktopShellRuntime,
|
||||
view.mobileVariant,
|
||||
alwaysShowActions,
|
||||
view.normalizedSessionSearchQuery,
|
||||
view.projectSortOrder,
|
||||
view.showOnlyMainWorkspace,
|
||||
view.stickyZoneHeaders,
|
||||
]);
|
||||
const scrollerActionSet = React.useMemo(() => ({
|
||||
group: groupActions,
|
||||
toggleProject,
|
||||
setActiveProjectIdOnly: scrollerActions.setActiveProjectIdOnly,
|
||||
setSessionSwitcherOpen: scrollerActions.setSessionSwitcherOpen,
|
||||
openNewSessionDraft: scrollerActions.openNewSessionDraft,
|
||||
openNewWorktreeDialog: scrollerActions.openNewWorktreeDialog,
|
||||
openWorktreesPage: scrollerActions.openWorktreesPage,
|
||||
openProjectEditDialog: scrollerActions.openProjectEditDialog,
|
||||
removeProject: scrollerActions.removeProject,
|
||||
reorderProjects: scrollerActions.reorderProjects,
|
||||
setGroupOrderByProject,
|
||||
renderProjectStatusIndicator: scrollerActions.renderProjectStatusIndicator,
|
||||
setSingleProjectId,
|
||||
}), [
|
||||
groupActions,
|
||||
scrollerActions.openNewSessionDraft,
|
||||
scrollerActions.openNewWorktreeDialog,
|
||||
scrollerActions.openProjectEditDialog,
|
||||
scrollerActions.openWorktreesPage,
|
||||
scrollerActions.removeProject,
|
||||
scrollerActions.reorderProjects,
|
||||
scrollerActions.setActiveProjectIdOnly,
|
||||
scrollerActions.setSessionSwitcherOpen,
|
||||
setGroupOrderByProject,
|
||||
toggleProject,
|
||||
scrollerActions.renderProjectStatusIndicator,
|
||||
setSingleProjectId,
|
||||
]);
|
||||
return <>
|
||||
<ProjectSessionSelectionEffect
|
||||
projectSections={projectSections}
|
||||
activeProjectId={view.activeProjectId}
|
||||
initialActiveSessionByProject={actions.initialActiveSessionByProject}
|
||||
persistActiveSessionByProject={actions.persistActiveSessionByProject}
|
||||
mobileVariant={view.mobileVariant}
|
||||
openNewSessionDraft={actions.openNewSessionDraft}
|
||||
setSessionSwitcherOpen={actions.setSessionSwitcherOpen}
|
||||
sessionOwnerBySessionId={ownership.bySessionId}
|
||||
handleSessionSelect={selectSessionForProject}
|
||||
/>
|
||||
<SessionPrefetchEffect
|
||||
sortedSessions={collection.orderedSessions}
|
||||
recentSessions={recentSessions}
|
||||
prefetchSession={prefetchSession}
|
||||
/>
|
||||
<SessionProjectScroller model={scrollerModel} view={scrollerView} actions={scrollerActionSet} />
|
||||
<SessionBulkActions
|
||||
getFolderScopesForProject={getFolderScopesForProject}
|
||||
isInlineEditing={editingId !== null}
|
||||
startFolderRename={startFolderRename}
|
||||
/>
|
||||
</>;
|
||||
};
|
||||
|
||||
export const SessionProjectCollection: React.FC<SessionProjectCollectionProps> = (props) => props.view.isVisible ? <VisibleSessionProjects {...props} /> : null;
|
||||
+18
@@ -44,4 +44,22 @@ describe("buildSessionBootstrapDemands", () => {
|
||||
expect(byDirectory.get("/repo/wt-a")?.priority).toBe("expanded")
|
||||
expect(byDirectory.get("/repo/wt-b")?.priority).toBe("selected")
|
||||
})
|
||||
|
||||
test("keeps the complete known topology demanded without a visible section projection", () => {
|
||||
const demands = buildSessionBootstrapDemands({
|
||||
knownDirectories: ["/repo", "/repo/wt-a", "/repo/wt-b"],
|
||||
activeProjectDirectory: "/repo",
|
||||
activeProjectId: "project-a",
|
||||
collapsedProjects: new Set(),
|
||||
collapsedGroups: new Set(),
|
||||
currentDirectory: null,
|
||||
currentSessionDirectory: null,
|
||||
})
|
||||
|
||||
expect(demands.map(({ directory, priority }) => [directory, priority])).toEqual([
|
||||
["/repo", "active-project"],
|
||||
["/repo/wt-a", "background"],
|
||||
["/repo/wt-b", "background"],
|
||||
])
|
||||
})
|
||||
})
|
||||
+12
-5
@@ -1,5 +1,5 @@
|
||||
import type { DirectoryBootstrapDemand, DirectoryBootstrapPriority } from "@/sync/child-store"
|
||||
import { normalizePath } from "./utils"
|
||||
import { normalizePath } from "../utils"
|
||||
|
||||
type BootstrapProjectSection = {
|
||||
project: { id: string; normalizedPath: string }
|
||||
@@ -11,16 +11,18 @@ type BootstrapProjectSection = {
|
||||
}>
|
||||
}
|
||||
|
||||
const PRIORITY_RANK: Record<DirectoryBootstrapPriority, number> = {
|
||||
const PRIORITY_RANK = {
|
||||
selected: 0,
|
||||
"active-project": 1,
|
||||
expanded: 2,
|
||||
visible: 3,
|
||||
background: 4,
|
||||
}
|
||||
} satisfies Record<DirectoryBootstrapPriority, number>
|
||||
|
||||
export function buildSessionBootstrapDemands(input: {
|
||||
projectSections: BootstrapProjectSection[]
|
||||
projectSections?: BootstrapProjectSection[]
|
||||
knownDirectories?: Iterable<string>
|
||||
activeProjectDirectory?: string | null
|
||||
activeProjectId: string | null
|
||||
collapsedProjects: ReadonlySet<string>
|
||||
collapsedGroups: ReadonlySet<string>
|
||||
@@ -40,7 +42,12 @@ export function buildSessionBootstrapDemands(input: {
|
||||
byDirectory.set(normalizedDirectory, { directory: normalizedDirectory, priority, reason })
|
||||
}
|
||||
|
||||
for (const section of input.projectSections) {
|
||||
for (const directory of input.knownDirectories ?? []) {
|
||||
add(directory, "background", "known-project")
|
||||
}
|
||||
add(input.activeProjectDirectory, "active-project", "project-expanded")
|
||||
|
||||
for (const section of input.projectSections ?? []) {
|
||||
const projectExpanded = !input.collapsedProjects.has(section.project.id)
|
||||
let projectPriority: DirectoryBootstrapPriority = "background"
|
||||
if (section.project.id === input.activeProjectId) {
|
||||
@@ -0,0 +1,333 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { Event } from '@opencode-ai/sdk/v2/client';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { deriveRecentSessions } from '../recent/activitySections';
|
||||
import { applyGlobalSessionStatusEvent, useGlobalSessionStatusStore , replaceGlobalSessionStatusById} from '@/sync/global-session-status';
|
||||
import {
|
||||
buildSidebarSessionProjection,
|
||||
getDescendantIds,
|
||||
partitionSidebarSessions,
|
||||
projectSidebarActiveSessions,
|
||||
projectSidebarCollection,
|
||||
useRecentSessionCollection,
|
||||
} from './sessionCollection';
|
||||
|
||||
const installMinimalDom = () => {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const setGlobal = (name: string, value: unknown) => {
|
||||
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ElementStub {}
|
||||
const documentStub: Record<string, unknown> = {
|
||||
nodeType: 9, defaultView: globalThis, activeElement: null,
|
||||
addEventListener: () => undefined, removeEventListener: () => undefined,
|
||||
};
|
||||
const container = {
|
||||
nodeType: 1, tagName: 'DIV', nodeName: 'DIV', namespaceURI: 'http://www.w3.org/1999/xhtml', ownerDocument: documentStub,
|
||||
addEventListener: () => undefined, removeEventListener: () => undefined,
|
||||
};
|
||||
documentStub.documentElement = container;
|
||||
documentStub.body = container;
|
||||
setGlobal('document', documentStub);
|
||||
setGlobal('window', globalThis);
|
||||
setGlobal('Element', ElementStub);
|
||||
setGlobal('HTMLElement', ElementStub);
|
||||
setGlobal('HTMLIFrameElement', ElementStub);
|
||||
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
return {
|
||||
container: container as unknown as Element,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const session = (id: string, directory: string | null): Session => {
|
||||
// SAFETY: Sidebar projection reads only id, directory, and time from session fixtures.
|
||||
return {
|
||||
id,
|
||||
directory,
|
||||
time: { created: 1, updated: 1 },
|
||||
} as Session;
|
||||
};
|
||||
|
||||
describe('projectSidebarActiveSessions', () => {
|
||||
test('keeps global precedence and order, then appends missing live sessions', () => {
|
||||
const global = [session('global-b', '/workspace/b'), session('global-a', '/workspace/a')];
|
||||
const live = [session('global-a', '/workspace/a'), session('live-c', '/workspace/c')];
|
||||
|
||||
expect(projectSidebarActiveSessions({
|
||||
globalActiveSessions: global,
|
||||
liveSessions: live,
|
||||
knownDirectories: new Set(['/workspace/a', '/workspace/b', '/workspace/c']),
|
||||
isVSCode: false,
|
||||
}).map((entry) => entry.id)).toEqual(['global-b', 'global-a', 'live-c']);
|
||||
});
|
||||
|
||||
test('filters unknown VS Code directories', () => {
|
||||
const sessions = [session('known', '/workspace/known'), session('unknown', '/workspace/unknown')];
|
||||
|
||||
expect(projectSidebarActiveSessions({
|
||||
globalActiveSessions: sessions,
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/known']),
|
||||
isVSCode: true,
|
||||
}).map((entry) => entry.id)).toEqual(['known']);
|
||||
});
|
||||
|
||||
test('allows missing or unknown directories for web when no directories are known', () => {
|
||||
const sessions = [session('unknown', '/workspace/unknown'), session('empty', null)];
|
||||
|
||||
expect(projectSidebarActiveSessions({
|
||||
globalActiveSessions: sessions,
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(),
|
||||
isVSCode: false,
|
||||
}).map((entry) => entry.id)).toEqual(['unknown', 'empty']);
|
||||
});
|
||||
|
||||
test('keeps archived sessions despite directory filtering', () => {
|
||||
const archived = session('archived', '/workspace/unknown');
|
||||
archived.time.archived = 1;
|
||||
|
||||
expect(projectSidebarActiveSessions({
|
||||
globalActiveSessions: [archived],
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/known']),
|
||||
isVSCode: true,
|
||||
}).map((entry) => entry.id)).toEqual(['archived']);
|
||||
});
|
||||
|
||||
test('does not replace a filtered global record with a live duplicate', () => {
|
||||
expect(projectSidebarActiveSessions({
|
||||
globalActiveSessions: [session('same', '/workspace/unknown')],
|
||||
liveSessions: [session('same', '/workspace/known')],
|
||||
knownDirectories: new Set(['/workspace/known']),
|
||||
isVSCode: true,
|
||||
})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectSidebarCollection', () => {
|
||||
test('returns the same structural projection for unchanged inputs without module caching', () => {
|
||||
const globalActiveSessions = [session('a', '/workspace/a'), session('b', '/workspace/b')];
|
||||
const input = {
|
||||
globalActiveSessions,
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/a', '/workspace/b']),
|
||||
isVSCode: false,
|
||||
};
|
||||
|
||||
const beforeSelection = projectSidebarCollection(input);
|
||||
const afterSelection = projectSidebarCollection(input);
|
||||
|
||||
expect(afterSelection).toEqual(beforeSelection);
|
||||
});
|
||||
|
||||
test('rebuilds when a structural session collection input changes', () => {
|
||||
const input = {
|
||||
globalActiveSessions: [session('a', '/workspace/a')],
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/a']),
|
||||
isVSCode: false,
|
||||
};
|
||||
|
||||
const before = projectSidebarCollection(input);
|
||||
const after = projectSidebarCollection({
|
||||
...input,
|
||||
globalActiveSessions: [session('a', '/workspace/a'), session('b', '/workspace/a')],
|
||||
});
|
||||
|
||||
expect(after).not.toBe(before);
|
||||
expect(after.map((entry) => entry.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('keeps project membership independent from Recent active membership', () => {
|
||||
const input = {
|
||||
globalActiveSessions: [session('old-root', '/workspace/a')],
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/a']),
|
||||
isVSCode: false,
|
||||
};
|
||||
const projectBefore = projectSidebarCollection(input);
|
||||
const recentBefore = deriveRecentSessions(projectBefore, new Set(), 200_000_000);
|
||||
const projectAfter = projectSidebarCollection(input);
|
||||
const recentAfter = deriveRecentSessions(projectAfter, new Set(['old-root']), 200_000_000);
|
||||
|
||||
expect(projectAfter).toEqual(projectBefore);
|
||||
expect(recentBefore).toEqual([]);
|
||||
expect(recentAfter.map((entry) => entry.id)).toEqual(['old-root']);
|
||||
});
|
||||
|
||||
test('keeps managed Chats in a dedicated projection and out of project and Recent ownership', () => {
|
||||
const managed = session('managed', '/home/.config/openchamber/chats/2026-08-24/session-managed');
|
||||
const project = session('project', '/workspace/a');
|
||||
const projects = projectSidebarCollection({
|
||||
globalActiveSessions: [managed, project],
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/a']),
|
||||
isVSCode: false,
|
||||
});
|
||||
|
||||
expect(projects.map((entry) => entry.id)).toEqual(['project']);
|
||||
expect(partitionSidebarSessions([managed, project], false).chatSessions.map((entry) => entry.id)).toEqual(['managed']);
|
||||
expect(deriveRecentSessions(projects, new Set(['managed', 'project']), 200_000_000)
|
||||
.map((entry) => entry.id)).toEqual(['project']);
|
||||
});
|
||||
|
||||
test('keeps managed Chats out of the VS Code sidebar', () => {
|
||||
const managed = session('managed', '/home/.config/openchamber/chats/2026-08-24/session-managed');
|
||||
|
||||
expect(partitionSidebarSessions([managed], true)).toEqual({ projectSessions: [], chatSessions: [] });
|
||||
expect(projectSidebarCollection({
|
||||
globalActiveSessions: [managed],
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(),
|
||||
isVSCode: true,
|
||||
})).toEqual([]);
|
||||
});
|
||||
|
||||
test('excludes a /btw fork before project ownership and restores it when the marker is removed', () => {
|
||||
const fork = {
|
||||
...session('fork', '/home/.config/openchamber/chats/2026-08-24/session-fork'),
|
||||
metadata: { openchamber: { kind: 'btw', originalSessionID: 'parent' } },
|
||||
};
|
||||
const project = session('project', '/workspace/a');
|
||||
const input = {
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/a']),
|
||||
isVSCode: false,
|
||||
};
|
||||
|
||||
expect(projectSidebarCollection({ ...input, globalActiveSessions: [fork, project] }).map((entry) => entry.id)).toEqual(['project']);
|
||||
expect(partitionSidebarSessions([fork], false).chatSessions).toEqual([]);
|
||||
|
||||
const promoted = {
|
||||
...fork,
|
||||
metadata: { openchamber: {} },
|
||||
};
|
||||
expect(partitionSidebarSessions([promoted], false).chatSessions.map((entry) => entry.id)).toEqual(['fork']);
|
||||
});
|
||||
|
||||
test('keeps a ranked managed root and its active child in the Chats hierarchy', () => {
|
||||
const managedRoot = { ...session('managed-root', '/home/.config/openchamber/chats/2026-08-24/session-root'), time: { created: 1, updated: 1 } };
|
||||
const managedChild = {
|
||||
...session('managed-child', '/home/.config/openchamber/chats/2026-08-24/session-root'),
|
||||
parentID: 'managed-root',
|
||||
time: { created: 2, updated: 2 },
|
||||
};
|
||||
const projectRoot = { ...session('project-root', '/workspace/a'), time: { created: 3, updated: 3 } };
|
||||
|
||||
const projection = buildSidebarSessionProjection({
|
||||
globalActiveSessions: [projectRoot, managedRoot, managedChild],
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/a']),
|
||||
isVSCode: false,
|
||||
pinnedSessionIds: new Set(),
|
||||
sessionOrderRanks: new Map([['managed-root', 10]]),
|
||||
});
|
||||
|
||||
expect(projection.projectSessions.map((entry) => entry.id)).toEqual(['project-root']);
|
||||
expect(projection.chatSessions.map((entry) => entry.id)).toEqual(['managed-root', 'managed-child']);
|
||||
expect(projection.orderedSessions.map((entry) => entry.id)).toEqual(['managed-root', 'managed-child', 'project-root']);
|
||||
expect(projection.childrenMap.get('managed-root')?.map((entry) => entry.id)).toEqual(['managed-child']);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('useRecentSessionCollection', () => {
|
||||
test('updates mounted Recent membership when global active status changes', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const oldSession = { ...session('old-root', '/workspace/a'), time: { created: 1, updated: 1 } };
|
||||
let renderedIds: string[] = [];
|
||||
let renderCount = 0;
|
||||
let timeReadCount = 0;
|
||||
Object.defineProperty(oldSession, 'time', {
|
||||
get: () => {
|
||||
timeReadCount += 1;
|
||||
return { created: 1, updated: 1 };
|
||||
},
|
||||
});
|
||||
timeReadCount = 0;
|
||||
const Harness = () => {
|
||||
renderCount += 1;
|
||||
const recent = useRecentSessionCollection({
|
||||
enabled: true,
|
||||
isVSCode: false,
|
||||
pinnedSessionIds: new Set(),
|
||||
sessionOrderRanks: new Map(),
|
||||
sessions: [oldSession],
|
||||
});
|
||||
renderedIds = recent.map((entry) => entry.id);
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
replaceGlobalSessionStatusById(new Map());
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(renderedIds).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
// SAFETY: This fixture matches the SDK event shape consumed by the status event reducer.
|
||||
applyGlobalSessionStatusEvent('/workspace/a', {
|
||||
type: 'session.status',
|
||||
properties: { sessionID: 'old-root', status: { type: 'busy' } },
|
||||
} as Event);
|
||||
});
|
||||
expect(renderedIds).toEqual(['old-root']);
|
||||
const activeRenderCount = renderCount;
|
||||
const activeDeriveOperationCount = timeReadCount;
|
||||
|
||||
await act(async () => {
|
||||
// SAFETY: This fixture matches the SDK event shape consumed by the status event reducer.
|
||||
applyGlobalSessionStatusEvent('/other-workspace', {
|
||||
type: 'session.status',
|
||||
properties: { sessionID: 'old-root', status: { type: 'retry', attempt: 2, message: 'waiting' } },
|
||||
} as Event);
|
||||
});
|
||||
expect(renderCount).toBe(activeRenderCount);
|
||||
expect(timeReadCount).toBe(activeDeriveOperationCount);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
replaceGlobalSessionStatusById(new Map());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('getDescendantIds', () => {
|
||||
test('returns a depth-first subtree without exposing session entities', () => {
|
||||
const childA = session('child-a', '/workspace/a');
|
||||
const grandchild = session('grandchild', '/workspace/a');
|
||||
const childB = session('child-b', '/workspace/a');
|
||||
const childrenMap = new Map([
|
||||
['root', [childA, childB]],
|
||||
['child-a', [grandchild]],
|
||||
]);
|
||||
|
||||
expect(getDescendantIds(childrenMap, 'root'))
|
||||
.toEqual(['child-a', 'grandchild', 'child-b']);
|
||||
});
|
||||
|
||||
test('cuts a parent cycle with deterministic unique descendants and excludes the root', () => {
|
||||
const childA = session('a', '/workspace/a');
|
||||
const childB = session('b', '/workspace/a');
|
||||
const childC = session('c', '/workspace/a');
|
||||
const childrenMap = new Map([
|
||||
['root', [childA]],
|
||||
['a', [childB, childC]],
|
||||
['b', [childA]],
|
||||
]);
|
||||
|
||||
expect(getDescendantIds(childrenMap, 'root')).toEqual(['a', 'b', 'c']);
|
||||
expect(new Set(getDescendantIds(childrenMap, 'root')).size).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useAllLiveSessions } from '@/sync/sync-context';
|
||||
import {
|
||||
EMPTY_SESSION_ORDER_RANKS,
|
||||
orderSessionsByLifecycleScopes,
|
||||
useSessionOrderingStore,
|
||||
} from '@/sync/session-ordering';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
||||
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { deriveRecentSessions } from '../recent/activitySections';
|
||||
import { normalizePath } from '../utils';
|
||||
import { isChatDirectoryPath } from '@/lib/chatDirectories';
|
||||
import { isBtwSession } from '@/lib/sessionBtwMetadata';
|
||||
import type { GlobalSessionStructure } from '@/stores/globalSessionStructure';
|
||||
import { countSyncPerformance } from '@/sync/performance-diagnostics';
|
||||
|
||||
type ProjectSidebarActiveSessionsArgs = {
|
||||
globalActiveSessions: Session[];
|
||||
liveSessions: Session[];
|
||||
knownDirectories: Set<string>;
|
||||
isVSCode: boolean;
|
||||
};
|
||||
|
||||
type SidebarSessionPartitions = {
|
||||
projectSessions: Session[];
|
||||
chatSessions: Session[];
|
||||
};
|
||||
|
||||
const parentIdOf = (session: Session): string | null => {
|
||||
// SAFETY: OpenCode session payloads expose parentID although the SDK base Session omits it.
|
||||
return (session as Session & { parentID?: string | null }).parentID ?? null;
|
||||
};
|
||||
|
||||
// This boundary owns session visibility before Recent or projects take
|
||||
// ownership. Temporary /btw forks never leak into any sidebar projection.
|
||||
export const partitionSidebarSessions = (
|
||||
sessions: readonly Session[],
|
||||
isVSCode: boolean,
|
||||
): SidebarSessionPartitions => {
|
||||
const projectSessions: Session[] = [];
|
||||
const chatSessions: Session[] = [];
|
||||
for (const session of sessions) {
|
||||
if (isBtwSession(session)) continue;
|
||||
if (isChatDirectoryPath(session.directory)) {
|
||||
if (isVSCode) continue;
|
||||
chatSessions.push(session);
|
||||
continue;
|
||||
}
|
||||
projectSessions.push(session);
|
||||
}
|
||||
return { projectSessions, chatSessions };
|
||||
};
|
||||
|
||||
const EMPTY_ACTIVE_SESSION_IDS: ReadonlySet<string> = new Set();
|
||||
|
||||
const isKnownActiveSessionDirectory = (
|
||||
session: Session,
|
||||
knownDirectories: Set<string>,
|
||||
isVSCode: boolean,
|
||||
): boolean => {
|
||||
if (session.time?.archived) return true;
|
||||
const directory = normalizePath(resolveGlobalSessionDirectory(session))?.toLowerCase();
|
||||
if (!directory) return !isVSCode;
|
||||
if (knownDirectories.size === 0) return !isVSCode;
|
||||
return knownDirectories.has(directory);
|
||||
};
|
||||
|
||||
// Global sessions provide complete sidebar coverage; initialized directory
|
||||
// stores only fill gaps until the global cache catches up.
|
||||
export const projectSidebarActiveSessions = ({
|
||||
globalActiveSessions,
|
||||
liveSessions,
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
}: ProjectSidebarActiveSessionsArgs): Session[] => {
|
||||
const sessions = [...globalActiveSessions];
|
||||
const knownIds = new Set(globalActiveSessions.map((session) => session.id));
|
||||
|
||||
for (const session of liveSessions) {
|
||||
if (knownIds.has(session.id)) continue;
|
||||
sessions.push(session);
|
||||
}
|
||||
|
||||
return partitionSidebarSessions(sessions, isVSCode).projectSessions
|
||||
.filter((session) => isKnownActiveSessionDirectory(session, knownDirectories, isVSCode));
|
||||
};
|
||||
|
||||
export const projectSidebarCollection = (args: ProjectSidebarActiveSessionsArgs): Session[] => {
|
||||
return projectSidebarActiveSessions(args);
|
||||
};
|
||||
|
||||
const mergeSidebarSessionSources = (
|
||||
globalActiveSessions: readonly Session[],
|
||||
liveSessions: readonly Session[],
|
||||
): Session[] => {
|
||||
const sessions = [...globalActiveSessions];
|
||||
const knownIds = new Set(globalActiveSessions.map((session) => session.id));
|
||||
for (const session of liveSessions) {
|
||||
if (knownIds.has(session.id)) continue;
|
||||
knownIds.add(session.id);
|
||||
sessions.push(session);
|
||||
}
|
||||
return sessions;
|
||||
};
|
||||
|
||||
// The collection owns hierarchy membership. Consumers receive this narrow
|
||||
// resolver instead of retaining the collection's mutable indexing detail.
|
||||
export const getDescendantIds = (
|
||||
childrenMap: ReadonlyMap<string, readonly Session[]>,
|
||||
sessionId: string,
|
||||
): string[] => {
|
||||
const descendants: string[] = [];
|
||||
const visited = new Set<string>([sessionId]);
|
||||
const visit = (parentId: string): void => {
|
||||
for (const child of childrenMap.get(parentId) ?? []) {
|
||||
if (visited.has(child.id)) continue;
|
||||
visited.add(child.id);
|
||||
descendants.push(child.id);
|
||||
visit(child.id);
|
||||
}
|
||||
};
|
||||
visit(sessionId);
|
||||
return descendants;
|
||||
};
|
||||
|
||||
type SidebarSessionProjectionArgs = ProjectSidebarActiveSessionsArgs & {
|
||||
pinnedSessionIds: Set<string>;
|
||||
sessionOrderRanks: ReadonlyMap<string, number>;
|
||||
};
|
||||
|
||||
type SidebarSessionStructureArgs = Omit<ProjectSidebarActiveSessionsArgs, 'globalActiveSessions'> & {
|
||||
globalActiveSessions?: readonly Session[];
|
||||
globalStructure?: GlobalSessionStructure;
|
||||
};
|
||||
|
||||
const buildSidebarSessionStructure = ({
|
||||
globalActiveSessions,
|
||||
liveSessions,
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
globalStructure,
|
||||
}: SidebarSessionStructureArgs) => {
|
||||
countSyncPerformance('sidebarStructureBuilds');
|
||||
const indexedGlobalSessions = globalActiveSessions ?? [];
|
||||
const visibleSessions = mergeSidebarSessionSources(indexedGlobalSessions, liveSessions);
|
||||
const partition = partitionSidebarSessions(visibleSessions, isVSCode);
|
||||
const projectSessions = partition.projectSessions
|
||||
.filter((session) => isKnownActiveSessionDirectory(session, knownDirectories, isVSCode));
|
||||
const sessions = [...projectSessions, ...partition.chatSessions];
|
||||
const sessionById = new Map(sessions.map((session) => [session.id, session]));
|
||||
const projectSessionIds = new Set(projectSessions.map((session) => session.id));
|
||||
const indexedRootIds = globalStructure?.activeRootIds ?? [];
|
||||
const indexedRootIdSet = new Set(indexedRootIds);
|
||||
const rootSessions = [
|
||||
...indexedRootIds.flatMap((sessionId) => {
|
||||
if (!projectSessionIds.has(sessionId)) return [];
|
||||
const session = sessionById.get(sessionId);
|
||||
return session ? [session] : [];
|
||||
}),
|
||||
...projectSessions.filter((session) => (
|
||||
!indexedRootIdSet.has(session.id) && !parentIdOf(session)
|
||||
)),
|
||||
];
|
||||
return {
|
||||
chatSessionIds: new Set(partition.chatSessions.map((session) => session.id)),
|
||||
projectSessions,
|
||||
rootSessions,
|
||||
sessionById,
|
||||
sessions,
|
||||
hierarchy: globalStructure ? {
|
||||
rootIds: globalStructure.activeRootIds,
|
||||
childrenByParentId: globalStructure.activeChildrenByParentId,
|
||||
} : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const orderSidebarSessionStructure = (
|
||||
structure: ReturnType<typeof buildSidebarSessionStructure>,
|
||||
pinnedSessionIds: Set<string>,
|
||||
sessionOrderRanks: ReadonlyMap<string, number>,
|
||||
) => {
|
||||
const orderedSessions = orderSessionsByLifecycleScopes(
|
||||
structure.sessions,
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
structure.hierarchy,
|
||||
);
|
||||
const childrenMap = new Map<string, Session[]>();
|
||||
for (const session of orderedSessions) {
|
||||
const parentID = parentIdOf(session);
|
||||
if (!parentID) continue;
|
||||
const siblings = childrenMap.get(parentID) ?? [];
|
||||
siblings.push(session);
|
||||
childrenMap.set(parentID, siblings);
|
||||
}
|
||||
return {
|
||||
chatSessions: orderedSessions.filter((session) => structure.chatSessionIds.has(session.id)),
|
||||
childrenMap,
|
||||
orderedSessions,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSidebarSessionProjection = ({
|
||||
globalActiveSessions,
|
||||
liveSessions,
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
}: SidebarSessionProjectionArgs) => {
|
||||
const structure = buildSidebarSessionStructure({
|
||||
globalActiveSessions,
|
||||
liveSessions,
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
});
|
||||
const ordering = orderSidebarSessionStructure(structure, pinnedSessionIds, sessionOrderRanks);
|
||||
return {
|
||||
...ordering,
|
||||
projectSessions: structure.projectSessions,
|
||||
sessionById: structure.sessionById,
|
||||
};
|
||||
};
|
||||
|
||||
type UseSessionProjectCollectionArgs = {
|
||||
knownDirectories: Set<string>;
|
||||
isVSCode: boolean;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
// The collection owns the global-first/live-gap merge and lifecycle ordering.
|
||||
// Selection state intentionally never enters this boundary: rows subscribe to
|
||||
// active state themselves, leaving this projection referentially stable.
|
||||
export const useSessionProjectCollection = ({
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
isVisible,
|
||||
}: UseSessionProjectCollectionArgs) => {
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const globalStructure = useGlobalSessionsStore((state) => state.structure);
|
||||
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||
const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
|
||||
const liveSessions = useAllLiveSessions();
|
||||
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
|
||||
const sessionOrderRanks = useSessionOrderingStore(React.useCallback(
|
||||
(state) => isVisible ? state.rankById : EMPTY_SESSION_ORDER_RANKS,
|
||||
[isVisible],
|
||||
));
|
||||
const structure = React.useMemo(() => buildSidebarSessionStructure({
|
||||
globalActiveSessions,
|
||||
globalStructure,
|
||||
liveSessions,
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
}), [globalActiveSessions, globalStructure, isVSCode, knownDirectories, liveSessions]);
|
||||
const ordering = React.useMemo(
|
||||
() => orderSidebarSessionStructure(structure, pinnedSessionIds, sessionOrderRanks),
|
||||
[pinnedSessionIds, sessionOrderRanks, structure],
|
||||
);
|
||||
const { chatSessions, orderedSessions } = ordering;
|
||||
const sessions = structure.projectSessions;
|
||||
const sessionById = React.useMemo(() => new Map(
|
||||
[...structure.sessions, ...archivedSessions].map((session) => [session.id, session]),
|
||||
), [archivedSessions, structure.sessions]);
|
||||
const childrenMap = React.useMemo(() => {
|
||||
const children = new Map(ordering.childrenMap);
|
||||
for (const session of archivedSessions) {
|
||||
// SAFETY: OpenCode's session records carry parentID for sub-session
|
||||
// hierarchy; the SDK's base Session type does not currently expose it.
|
||||
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||
if (!parentID) continue;
|
||||
const siblings = children.get(parentID) ?? [];
|
||||
siblings.push(session);
|
||||
children.set(parentID, siblings);
|
||||
}
|
||||
return children;
|
||||
}, [archivedSessions, ordering.childrenMap]);
|
||||
const getDescendantIdsForAction = React.useCallback(
|
||||
(sessionId: string, options: { includeArchived: boolean }) => getDescendantIds(childrenMap, sessionId)
|
||||
.filter((id) => options.includeArchived || !sessionById.get(id)?.time?.archived),
|
||||
[childrenMap, sessionById],
|
||||
);
|
||||
|
||||
return {
|
||||
archivedSessions,
|
||||
childrenMap,
|
||||
chatSessions,
|
||||
getDescendantIds: getDescendantIdsForAction,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
liveSessions,
|
||||
orderedSessions,
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
sessions,
|
||||
rootSessions: structure.rootSessions,
|
||||
};
|
||||
};
|
||||
|
||||
type UseRecentSessionCollectionArgs = {
|
||||
enabled: boolean;
|
||||
isVSCode: boolean;
|
||||
pinnedSessionIds: Set<string>;
|
||||
sessionOrderRanks: ReadonlyMap<string, number>;
|
||||
sessions: Session[];
|
||||
};
|
||||
|
||||
// Recent is a separate high-frequency collection view. Its active membership
|
||||
// never participates in project ownership or project section projection.
|
||||
export const useRecentSessionCollection = ({
|
||||
enabled,
|
||||
isVSCode,
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
sessions,
|
||||
}: UseRecentSessionCollectionArgs): Session[] => {
|
||||
const activeSessionIdSet = useGlobalSessionStatusStore(
|
||||
React.useCallback(
|
||||
(state) => enabled && !isVSCode ? state.activeSessionIds : EMPTY_ACTIVE_SESSION_IDS,
|
||||
[enabled, isVSCode],
|
||||
),
|
||||
);
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (!enabled || isVSCode) return [];
|
||||
countSyncPerformance('recentCandidatesVisited', sessions.length);
|
||||
return orderSessionsByLifecycleScopes(
|
||||
deriveRecentSessions(sessions, activeSessionIdSet),
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
);
|
||||
}, [activeSessionIdSet, enabled, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions]);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildKnownSessionDirectories } from './sessionListDirectories';
|
||||
|
||||
describe('buildKnownSessionDirectories', () => {
|
||||
test('normalizes project roots and optionally includes worktrees', () => {
|
||||
const worktrees = new Map([
|
||||
['/repo', [{ path: '/repo/worktree', projectDirectory: '/repo', branch: 'worktree', label: 'worktree' }]],
|
||||
]);
|
||||
|
||||
expect([...buildKnownSessionDirectories([{ path: '/Repo' }], worktrees)]).toEqual([
|
||||
'/repo',
|
||||
'/repo/worktree',
|
||||
]);
|
||||
expect([...buildKnownSessionDirectories([{ path: '/Repo' }], worktrees, { includeWorktrees: false })]).toEqual([
|
||||
'/repo',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { normalizePath } from '../utils';
|
||||
|
||||
export const buildKnownSessionDirectories = (
|
||||
projects: Array<{ path: string }>,
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
|
||||
options?: { includeWorktrees?: boolean },
|
||||
): Set<string> => {
|
||||
const directories = new Set<string>();
|
||||
for (const project of projects) {
|
||||
const normalized = normalizePath(project.path)?.toLowerCase();
|
||||
if (normalized) directories.add(normalized);
|
||||
}
|
||||
if (options?.includeWorktrees === false) {
|
||||
return directories;
|
||||
}
|
||||
for (const worktrees of availableWorktreesByProject.values()) {
|
||||
for (const worktree of worktrees) {
|
||||
const normalized = normalizePath(worktree.path)?.toLowerCase();
|
||||
if (normalized) directories.add(normalized);
|
||||
}
|
||||
}
|
||||
return directories;
|
||||
};
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import React from 'react';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
import {
|
||||
buildAuthoritativeSessionIdentityMap,
|
||||
findRemovedAuthoritativeSessions,
|
||||
} from './authoritativeSessionCleanup';
|
||||
|
||||
// SAFETY: cleanup identity tests only consume the SDK session ID and directory fields.
|
||||
const session = (id: string, directory = '/repo'): Session => ({ id, directory }) as Session;
|
||||
|
||||
const cleanups: Array<{ runtimeKey: string; directory: string; sessionId: string }> = [];
|
||||
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'runtime' }));
|
||||
mock.module('@/sync/session-deletion-cleanup', () => ({
|
||||
cleanupPersistedSessionState: (identity: { runtimeKey: string; directory: string; sessionId: string }) => cleanups.push(identity),
|
||||
}));
|
||||
const { useAuthoritativeSessionCleanup } = await import('./useAuthoritativeSessionCleanup');
|
||||
|
||||
const CleanupProbe: React.FC<{ sessions: Session[]; revision: number }> = ({ sessions, revision }) => {
|
||||
useAuthoritativeSessionCleanup({ enabled: true, hasAuthoritativeGlobalSessions: true, sessions });
|
||||
return React.createElement('span', null, revision);
|
||||
};
|
||||
|
||||
describe('authoritative session cleanup', () => {
|
||||
let root: Root;
|
||||
let dom: ReturnType<typeof installHookTestDom>;
|
||||
|
||||
beforeEach(() => {
|
||||
cleanups.length = 0;
|
||||
dom = installHookTestDom();
|
||||
root = createRoot(dom.container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
dom.restore();
|
||||
});
|
||||
|
||||
test('does not infer deletion from the first authoritative startup snapshot', () => {
|
||||
const current = buildAuthoritativeSessionIdentityMap([]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(null, current)).toEqual([]);
|
||||
});
|
||||
|
||||
test('finds sessions omitted after an established authoritative baseline', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([
|
||||
session('deleted'),
|
||||
session('retained'),
|
||||
]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([session('retained')]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([
|
||||
{ directory: '/repo', sessionId: 'deleted' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('treats archive membership as retained authority', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([session('archived')]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([
|
||||
// SAFETY: cleanup identity tests only consume the SDK session ID and directory fields.
|
||||
{ ...session('archived'), time: { archived: 10 } } as Session,
|
||||
]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not treat a directory move as session deletion', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-a')]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-b')]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
||||
});
|
||||
|
||||
test('uses the first mounted complete snapshot as a baseline, then cleans an omission once', () => {
|
||||
const baseline = [session('deleted'), session('retained')];
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: baseline, revision: 0 })));
|
||||
expect(cleanups).toEqual([]);
|
||||
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: [session('retained')], revision: 1 })));
|
||||
expect(cleanups).toEqual([{ runtimeKey: 'runtime', directory: '/repo', sessionId: 'deleted' }]);
|
||||
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: [session('retained')], revision: 2 })));
|
||||
expect(cleanups).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('retains archive and move identities, preserves the same-array baseline on unrelated rerender, and resets on remount', () => {
|
||||
const baseline = [session('session', '/repo-a')];
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: baseline, revision: 0 })));
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: baseline, revision: 1 })));
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: [{ ...session('session', '/repo-a'), time: { created: 0, updated: 0, archived: 1 } }], revision: 2 })));
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: [session('session', '/repo-b')], revision: 3 })));
|
||||
expect(cleanups).toEqual([]);
|
||||
|
||||
act(() => root.unmount());
|
||||
root = createRoot(dom.container);
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: [], revision: 4 })));
|
||||
expect(cleanups).toEqual([]);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { cleanupPersistedSessionState } from '@/sync/session-deletion-cleanup';
|
||||
import {
|
||||
buildAuthoritativeSessionIdentityMap,
|
||||
findRemovedAuthoritativeSessions,
|
||||
} from '../authoritativeSessionCleanup';
|
||||
} from './authoritativeSessionCleanup';
|
||||
|
||||
export const useAuthoritativeSessionCleanup = (args: {
|
||||
enabled?: boolean;
|
||||
@@ -0,0 +1,247 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import React from 'react';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
type Event =
|
||||
| { type: 'scheduled-task-ran' }
|
||||
| { type: 'session-created'; directory: string };
|
||||
|
||||
type LifecycleState = {
|
||||
demands: Array<{ owner: string; directories: string[] }>;
|
||||
clearedOwners: string[];
|
||||
globalRefreshes: number;
|
||||
directoryRefreshes: string[][];
|
||||
cleanupInputs: Array<{ enabled: boolean; hasAuthoritativeGlobalSessions: boolean; sessionCount: number; sessions: unknown[] }>;
|
||||
listener: ((event: Event) => void) | null;
|
||||
subscriptions: number;
|
||||
unsubscriptions: number;
|
||||
};
|
||||
const state: LifecycleState = {
|
||||
demands: [],
|
||||
clearedOwners: [],
|
||||
globalRefreshes: 0,
|
||||
directoryRefreshes: [],
|
||||
cleanupInputs: [],
|
||||
listener: null,
|
||||
subscriptions: 0,
|
||||
unsubscriptions: 0,
|
||||
};
|
||||
const childStores = {
|
||||
setBootstrapDemand: (owner: string, demands: Array<{ directory: string }>) => {
|
||||
state.demands.push({ owner, directories: demands.map((demand) => demand.directory) });
|
||||
},
|
||||
clearBootstrapDemand: (owner: string) => state.clearedOwners.push(owner),
|
||||
};
|
||||
type GlobalSessionsState = { activeSessions: never[]; archivedSessions: never[]; status: 'ready' };
|
||||
const globalSessions: GlobalSessionsState = { activeSessions: [], archivedSessions: [], status: 'ready' };
|
||||
|
||||
mock.module('@/sync/sync-context', () => ({
|
||||
useChildStoreManager: () => childStores,
|
||||
}));
|
||||
mock.module('@/sync/sync-refs', () => ({ getAllSyncSessions: () => [] }));
|
||||
mock.module('@/stores/useGlobalSessionsStore', () => ({
|
||||
useGlobalSessionsStore: <T,>(selector: (value: GlobalSessionsState) => T): T => selector(globalSessions),
|
||||
refreshGlobalSessions: () => { state.globalRefreshes += 1; },
|
||||
refreshGlobalSessionsForDirectories: (directories: string[]) => { state.directoryRefreshes.push(directories); },
|
||||
}));
|
||||
mock.module('@/lib/openchamberEvents', () => ({
|
||||
subscribeOpenchamberEvents: (listener: (event: Event) => void) => {
|
||||
state.subscriptions += 1;
|
||||
state.listener = listener;
|
||||
return () => {
|
||||
state.unsubscriptions += 1;
|
||||
state.listener = null;
|
||||
};
|
||||
},
|
||||
}));
|
||||
mock.module('./useAuthoritativeSessionCleanup', () => ({
|
||||
useAuthoritativeSessionCleanup: (input: { enabled: boolean; hasAuthoritativeGlobalSessions: boolean; sessions: unknown[] }) => {
|
||||
state.cleanupInputs.push({
|
||||
enabled: input.enabled,
|
||||
hasAuthoritativeGlobalSessions: input.hasAuthoritativeGlobalSessions,
|
||||
sessionCount: input.sessions.length,
|
||||
sessions: input.sessions,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const { useSessionListSync } = await import('./useSessionListSync');
|
||||
|
||||
const projects = [{ id: 'project', path: '/project' }];
|
||||
const worktree: WorktreeMetadata = { path: '/worktree', projectDirectory: '/project', branch: 'feature', label: 'feature' };
|
||||
|
||||
const LifecycleProbe: React.FC<{ isVSCode: boolean }> = ({ isVSCode }) => {
|
||||
useSessionListSync({ isVSCode });
|
||||
return null;
|
||||
};
|
||||
|
||||
const LifecycleHarness: React.FC<{ isVSCode: boolean; branch: 'hidden' | 'visible' | 'compact-sessions' | 'compact-chat' | 'expanded' }> = ({ isVSCode, branch }) => <>
|
||||
<LifecycleProbe isVSCode={isVSCode} />
|
||||
<span>{branch}</span>
|
||||
</>;
|
||||
|
||||
describe('useSessionListSync', () => {
|
||||
let root: Root;
|
||||
let dom: ReturnType<typeof installHookTestDom>;
|
||||
|
||||
beforeEach(() => {
|
||||
state.demands = [];
|
||||
state.clearedOwners = [];
|
||||
state.globalRefreshes = 0;
|
||||
state.directoryRefreshes = [];
|
||||
state.cleanupInputs = [];
|
||||
state.listener = null;
|
||||
state.subscriptions = 0;
|
||||
state.unsubscriptions = 0;
|
||||
dom = installHookTestDom();
|
||||
root = createRoot(dom.container);
|
||||
useProjectsStore.setState({ projects, activeProjectId: 'project' });
|
||||
useDirectoryStore.setState({ currentDirectory: '/project' });
|
||||
useSessionUIStore.setState({ currentSessionDirectory: null, availableWorktreesByProject: new Map() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
dom.restore();
|
||||
});
|
||||
|
||||
test('leaves initial global refresh to the root poller while publishing complete demand', () => {
|
||||
act(() => useSessionUIStore.setState({ availableWorktreesByProject: new Map([['/project', [worktree]]]) }));
|
||||
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||
|
||||
expect(state.globalRefreshes).toBe(0);
|
||||
expect(state.demands).toHaveLength(1);
|
||||
expect(state.demands[0]?.directories).toEqual(['/project', '/worktree']);
|
||||
expect(state.directoryRefreshes).toEqual([]);
|
||||
expect(state.subscriptions).toBe(1);
|
||||
expect(state.cleanupInputs.at(-1)).toEqual({ enabled: true, hasAuthoritativeGlobalSessions: true, sessionCount: 0, sessions: [] });
|
||||
});
|
||||
|
||||
test('refreshes every VS Code directory on first mount and only topology additions afterward', () => {
|
||||
act(() => root.render(<LifecycleProbe isVSCode />));
|
||||
act(() => useProjectsStore.setState({ projects: [...projects, { id: 'added', path: '/added' }] }));
|
||||
|
||||
expect(state.directoryRefreshes).toEqual([['/project'], ['/added']]);
|
||||
});
|
||||
|
||||
test('coalesces control events and clears the listener, timeout, and demand on unmount', async () => {
|
||||
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||
state.listener?.({ type: 'session-created', directory: '/created-a' });
|
||||
state.listener?.({ type: 'session-created', directory: '/created-b' });
|
||||
state.listener?.({ type: 'scheduled-task-ran' });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 550));
|
||||
expect(state.globalRefreshes).toBe(1);
|
||||
expect(state.directoryRefreshes).toEqual([]);
|
||||
|
||||
const owner = state.demands[0]?.owner;
|
||||
act(() => root.unmount());
|
||||
expect(state.unsubscriptions).toBe(1);
|
||||
expect(state.clearedOwners).toEqual([owner]);
|
||||
});
|
||||
|
||||
test('does not duplicate lifecycle ownership when a hidden MainLayout or compact VS Code view rerenders', () => {
|
||||
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||
const cleanupSessions = state.cleanupInputs.at(-1)?.sessions;
|
||||
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||
expect(state.globalRefreshes).toBe(0);
|
||||
expect(state.subscriptions).toBe(1);
|
||||
expect(state.demands).toHaveLength(1);
|
||||
expect(state.cleanupInputs.at(-1)?.sessions).toBe(cleanupSessions);
|
||||
|
||||
act(() => root.unmount());
|
||||
root = createRoot(dom.container);
|
||||
act(() => root.render(<LifecycleProbe isVSCode />));
|
||||
expect(state.globalRefreshes).toBe(0);
|
||||
expect(state.subscriptions).toBe(2);
|
||||
expect(state.unsubscriptions).toBe(1);
|
||||
});
|
||||
|
||||
test('cancels a pending control-event refresh before a layout remount', async () => {
|
||||
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||
state.listener?.({ type: 'session-created', directory: '/created' });
|
||||
act(() => root.unmount());
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 550));
|
||||
expect(state.directoryRefreshes).toEqual([]);
|
||||
expect(state.unsubscriptions).toBe(1);
|
||||
});
|
||||
|
||||
test('binds MainLayout ownership to real Store worktrees without duplicating lifecycle work across branches', () => {
|
||||
useProjectsStore.setState({
|
||||
projects: [{ id: 'project', path: '/project' }],
|
||||
activeProjectId: 'project',
|
||||
});
|
||||
useDirectoryStore.setState({ currentDirectory: '/project' });
|
||||
useSessionUIStore.setState({
|
||||
currentSessionDirectory: '/worktree',
|
||||
availableWorktreesByProject: new Map([['/project', [worktree]]]),
|
||||
});
|
||||
|
||||
act(() => root.render(<LifecycleHarness isVSCode={false} branch="hidden" />));
|
||||
act(() => root.render(<LifecycleHarness isVSCode={false} branch="visible" />));
|
||||
act(() => root.render(<LifecycleHarness isVSCode={false} branch="expanded" />));
|
||||
|
||||
expect(state.demands).toHaveLength(1);
|
||||
expect(state.demands[0]?.directories).toEqual(['/project', '/worktree']);
|
||||
expect(state.globalRefreshes).toBe(0);
|
||||
expect(state.subscriptions).toBe(1);
|
||||
});
|
||||
|
||||
test('binds VS Code ownership to Store projects without worktrees and refreshes its first directories once', () => {
|
||||
useProjectsStore.setState({
|
||||
projects: [{ id: 'project', path: '/project' }],
|
||||
activeProjectId: 'project',
|
||||
});
|
||||
useDirectoryStore.setState({ currentDirectory: '/project' });
|
||||
useSessionUIStore.setState({
|
||||
currentSessionDirectory: '/project',
|
||||
availableWorktreesByProject: new Map([['/project', [worktree]]]),
|
||||
});
|
||||
|
||||
act(() => root.render(<LifecycleHarness isVSCode branch="compact-sessions" />));
|
||||
act(() => root.render(<LifecycleHarness isVSCode branch="compact-chat" />));
|
||||
act(() => root.unmount());
|
||||
root = createRoot(dom.container);
|
||||
act(() => root.render(<LifecycleHarness isVSCode branch="compact-sessions" />));
|
||||
|
||||
expect(state.demands.map((demand) => demand.directories)).toEqual([['/project'], ['/project']]);
|
||||
expect(state.directoryRefreshes).toEqual([['/project'], ['/project']]);
|
||||
expect(state.globalRefreshes).toBe(0);
|
||||
expect(state.subscriptions).toBe(2);
|
||||
expect(state.unsubscriptions).toBe(1);
|
||||
});
|
||||
|
||||
test('does not rerender VS Code lifecycle ownership for worktree-map-only changes', () => {
|
||||
useProjectsStore.setState({
|
||||
projects: [{ id: 'project', path: '/project' }],
|
||||
activeProjectId: 'project',
|
||||
});
|
||||
useDirectoryStore.setState({ currentDirectory: '/project' });
|
||||
useSessionUIStore.setState({
|
||||
currentSessionDirectory: '/project',
|
||||
availableWorktreesByProject: new Map([['/project', [worktree]]]),
|
||||
});
|
||||
|
||||
act(() => root.render(<LifecycleHarness isVSCode branch="compact-sessions" />));
|
||||
const cleanupInputCount = state.cleanupInputs.length;
|
||||
const demandCount = state.demands.length;
|
||||
const directoryRefreshCount = state.directoryRefreshes.length;
|
||||
const subscriptionCount = state.subscriptions;
|
||||
|
||||
act(() => useSessionUIStore.setState({
|
||||
availableWorktreesByProject: new Map([['/project', [{ ...worktree, path: '/other-worktree' }]]]),
|
||||
}));
|
||||
|
||||
expect(state.cleanupInputs).toHaveLength(cleanupInputCount);
|
||||
expect(state.demands).toHaveLength(demandCount);
|
||||
expect(state.directoryRefreshes).toHaveLength(directoryRefreshCount);
|
||||
expect(state.subscriptions).toBe(subscriptionCount);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||
import { refreshGlobalSessions, refreshGlobalSessionsForDirectories, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useChildStoreManager } from '@/sync/sync-context';
|
||||
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { buildSessionBootstrapDemands } from './sessionBootstrapDemands';
|
||||
import { buildKnownSessionDirectories } from './sessionListDirectories';
|
||||
import { useAuthoritativeSessionCleanup } from './useAuthoritativeSessionCleanup';
|
||||
import { normalizePath } from '../utils';
|
||||
|
||||
const EMPTY_WORKTREES_BY_PROJECT = new Map();
|
||||
|
||||
type UseSessionListSyncOptions = {
|
||||
isVSCode: boolean;
|
||||
};
|
||||
|
||||
export const useSessionListSync = ({
|
||||
isVSCode,
|
||||
}: UseSessionListSyncOptions) => {
|
||||
const childStores = useChildStoreManager();
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
|
||||
const availableWorktreesByProject = useSessionUIStore((state) => isVSCode ? EMPTY_WORKTREES_BY_PROJECT : state.availableWorktreesByProject);
|
||||
const knownDirectories = React.useMemo(
|
||||
() => buildKnownSessionDirectories(projects, availableWorktreesByProject, { includeWorktrees: !isVSCode }),
|
||||
[availableWorktreesByProject, isVSCode, projects],
|
||||
);
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||
const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
|
||||
const bootstrapDemandOwner = `session-list-sync:${React.useId()}`;
|
||||
|
||||
React.useEffect(() => {
|
||||
childStores.setBootstrapDemand(bootstrapDemandOwner, buildSessionBootstrapDemands({
|
||||
knownDirectories,
|
||||
activeProjectDirectory: normalizePath(projects.find((project) => project.id === activeProjectId)?.path ?? null),
|
||||
activeProjectId,
|
||||
collapsedProjects: new Set(),
|
||||
collapsedGroups: new Set(),
|
||||
currentDirectory,
|
||||
currentSessionDirectory,
|
||||
}));
|
||||
return () => childStores.clearBootstrapDemand(bootstrapDemandOwner);
|
||||
}, [activeProjectId, bootstrapDemandOwner, childStores, currentDirectory, currentSessionDirectory, knownDirectories, projects]);
|
||||
|
||||
const knownProjectSessionDirectoriesRef = React.useRef<Set<string> | null>(null);
|
||||
React.useEffect(() => {
|
||||
const directories = new Set(knownDirectories);
|
||||
const previous = knownProjectSessionDirectoriesRef.current;
|
||||
knownProjectSessionDirectoriesRef.current = directories;
|
||||
const added = previous ? [...directories].filter((directory) => !previous.has(directory)) : isVSCode ? [...directories] : [];
|
||||
if (added.length) void refreshGlobalSessionsForDirectories(added, getAllSyncSessions());
|
||||
}, [isVSCode, knownDirectories]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let refreshAll = false;
|
||||
const directories = new Set<string>();
|
||||
const unsubscribe = subscribeOpenchamberEvents((event) => {
|
||||
if (event.type === 'scheduled-task-ran') refreshAll = true;
|
||||
else if (event.type === 'session-created') directories.add(event.directory);
|
||||
else return;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
if (refreshAll) {
|
||||
refreshAll = false;
|
||||
directories.clear();
|
||||
void refreshGlobalSessions(getAllSyncSessions());
|
||||
return;
|
||||
}
|
||||
const requested = [...directories];
|
||||
directories.clear();
|
||||
if (requested.length) void refreshGlobalSessionsForDirectories(requested, getAllSyncSessions());
|
||||
}, 500);
|
||||
});
|
||||
return () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleanupSessions = React.useMemo(
|
||||
() => [...globalActiveSessions, ...archivedSessions],
|
||||
[archivedSessions, globalActiveSessions],
|
||||
);
|
||||
useAuthoritativeSessionCleanup({
|
||||
enabled: true,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
sessions: cleanupSessions,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useSessionPrefetch } from './useSessionPrefetch';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
const session = (id: string): Session => ({
|
||||
id,
|
||||
slug: id,
|
||||
projectID: 'project',
|
||||
title: id,
|
||||
version: '1',
|
||||
directory: '/workspace',
|
||||
time: { created: 1, updated: 1 },
|
||||
});
|
||||
|
||||
describe('session prefetch demand', () => {
|
||||
test('deduplicates the same nearby session from project and Recent projections', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const current = session('current');
|
||||
const nearby = session('nearby');
|
||||
const calls: string[] = [];
|
||||
const Harness = () => {
|
||||
useSessionPrefetch({
|
||||
enabled: true,
|
||||
currentSessionId: current.id,
|
||||
sortedSessions: [current, nearby],
|
||||
recentSessions: [current, nearby],
|
||||
prefetchSession: async ({ sessionID }) => { calls.push(sessionID); },
|
||||
});
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 850)); });
|
||||
expect(calls).toEqual(['nearby']);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
+16
-16
@@ -14,7 +14,7 @@ type Args = {
|
||||
currentSessionId: string | null;
|
||||
sortedSessions: Session[];
|
||||
recentSessions?: Session[];
|
||||
prefetchSession: (sessionId: string, directory: string) => Promise<unknown>;
|
||||
prefetchSession: (target: { directory: string; sessionID: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
type PrefetchRequest = {
|
||||
@@ -23,22 +23,22 @@ type PrefetchRequest = {
|
||||
generation: number;
|
||||
};
|
||||
|
||||
const getPrefetchRequestKey = (request: Pick<PrefetchRequest, 'directory' | 'sessionId'>): string => (
|
||||
`${request.directory}\n${request.sessionId}`
|
||||
);
|
||||
|
||||
const sessionDirectory = (session: Session | null | undefined): string | null => {
|
||||
const directory = (session as (Session & { directory?: string | null }) | null | undefined)?.directory;
|
||||
return typeof directory === 'string' && directory.trim() ? directory : null;
|
||||
const directory = session?.directory?.trim();
|
||||
return directory || null;
|
||||
};
|
||||
|
||||
const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
|
||||
export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
|
||||
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
|
||||
const sessionPrefetchQueueRef = React.useRef<PrefetchRequest[]>([]);
|
||||
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
||||
const generationRef = React.useRef(0);
|
||||
const prefetchDisabled = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
|
||||
const requestKey = React.useCallback((request: Pick<PrefetchRequest, 'directory' | 'sessionId'>) => (
|
||||
`${request.directory}\n${request.sessionId}`
|
||||
), []);
|
||||
|
||||
const clearPendingPrefetches = React.useCallback(() => {
|
||||
generationRef.current += 1;
|
||||
sessionPrefetchQueueRef.current = [];
|
||||
@@ -47,7 +47,7 @@ const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions,
|
||||
}, []);
|
||||
|
||||
const pumpSessionPrefetchQueue = React.useCallback(() => {
|
||||
if (!enabled || prefetchDisabled || typeof window === 'undefined') {
|
||||
if (!enabled || prefetchDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,25 +68,25 @@ const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions,
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = requestKey(request);
|
||||
const key = getPrefetchRequestKey(request);
|
||||
sessionPrefetchInFlightRef.current.add(key);
|
||||
void prefetchSession(request.sessionId, request.directory)
|
||||
void prefetchSession({ directory: request.directory, sessionID: request.sessionId })
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
sessionPrefetchInFlightRef.current.delete(key);
|
||||
pumpSessionPrefetchQueue();
|
||||
});
|
||||
}
|
||||
}, [enabled, prefetchDisabled, prefetchSession, requestKey]);
|
||||
}, [enabled, prefetchDisabled, prefetchSession]);
|
||||
|
||||
const scheduleSessionPrefetch = React.useCallback((session: Session | null | undefined) => {
|
||||
const sessionId = session?.id;
|
||||
const directory = sessionDirectory(session);
|
||||
if (!enabled || prefetchDisabled || !sessionId || !directory || sessionId === currentSessionId || typeof window === 'undefined') {
|
||||
if (!enabled || prefetchDisabled || !sessionId || !directory || sessionId === currentSessionId) {
|
||||
return;
|
||||
}
|
||||
const request = { sessionId, directory, generation: generationRef.current };
|
||||
const key = requestKey(request);
|
||||
const key = getPrefetchRequestKey(request);
|
||||
|
||||
// Already renderable in sync
|
||||
if (getSyncSessionMaterializationStatus(sessionId, directory).renderable) {
|
||||
@@ -97,7 +97,7 @@ const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions,
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchQueueRef.current.some((candidate) => requestKey(candidate) === key)) {
|
||||
if (sessionPrefetchQueueRef.current.some((candidate) => getPrefetchRequestKey(candidate) === key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions,
|
||||
pumpSessionPrefetchQueue();
|
||||
}, SESSION_PREFETCH_HOVER_DELAY_MS);
|
||||
sessionPrefetchTimersRef.current.set(key, timer);
|
||||
}, [currentSessionId, enabled, prefetchDisabled, pumpSessionPrefetchQueue, requestKey]);
|
||||
}, [currentSessionId, enabled, prefetchDisabled, pumpSessionPrefetchQueue]);
|
||||
|
||||
React.useEffect(() => {
|
||||
clearPendingPrefetches();
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionGroupSectionProps } from './SessionGroupSection';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
type FolderCallbacks = {
|
||||
onRename: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
type RowPropsCapture = Pick<SessionGroupSectionProps,
|
||||
| 'allowReselect'
|
||||
| 'onSessionSelected'
|
||||
| 'isSessionSearchOpen'
|
||||
| 'sessionSearchQuery'
|
||||
| 'deleteSessionConfirm'
|
||||
| 'copiedSessionId'
|
||||
| 'setCopiedSessionId'
|
||||
>;
|
||||
|
||||
let folderCallbacks: FolderCallbacks | null = null;
|
||||
let rowPropsCapture: RowPropsCapture | null = null;
|
||||
|
||||
mock.module('../../SessionFolderItem', () => ({
|
||||
SessionFolderItem: (props: FolderCallbacks) => {
|
||||
folderCallbacks = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../folders/sessionFolderDnd', () => ({
|
||||
DroppableFolderWrapper: ({ children }: { children: (ref: () => void, isOver: boolean) => React.ReactNode }) => <>{children(() => undefined, false)}</>,
|
||||
SessionFolderDndScope: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
mock.module('@/sync/sync-context', () => ({
|
||||
setActiveSession: () => undefined,
|
||||
useChildStoreManager: () => ({
|
||||
subscribeBootstrap: () => () => undefined,
|
||||
getBootstrapState: () => null,
|
||||
getBootstrapFailure: () => undefined,
|
||||
requestBootstrap: () => undefined,
|
||||
}),
|
||||
useDirectoryStore: () => null,
|
||||
useGlobalSessionStatus: () => null,
|
||||
useSessionPermissions: () => null,
|
||||
useSessionQuestionCount: () => 0,
|
||||
useSyncSDK: () => null,
|
||||
useSyncDirectory: () => null,
|
||||
buildSessionMessageRecordsSnapshot: () => [],
|
||||
}));
|
||||
|
||||
mock.module('../sessions/collapsedActivityIndicator', () => ({
|
||||
CollapsedSessionActivityIndicator: () => null,
|
||||
}));
|
||||
|
||||
mock.module('../sessions/collapsedActivityState', () => ({
|
||||
useCollapsedSessionActivityState: () => null,
|
||||
}));
|
||||
|
||||
mock.module('../sessions/SessionTreeItem', () => ({
|
||||
SessionTreeItem: (props: RowPropsCapture) => {
|
||||
rowPropsCapture = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const { SessionGroupSection } = await import('./SessionGroupSection');
|
||||
|
||||
const folder: SessionFolder = {
|
||||
id: 'folder-a',
|
||||
name: 'Initial folder',
|
||||
parentId: null,
|
||||
sessionIds: [],
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const group: SessionGroupSectionProps['group'] = {
|
||||
id: 'main',
|
||||
label: 'Main',
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: true,
|
||||
worktree: null,
|
||||
directory: '/workspace',
|
||||
folderScopeKey: '/workspace',
|
||||
sessions: [],
|
||||
};
|
||||
|
||||
const groupWithSession: SessionGroupSectionProps['group'] = {
|
||||
...group,
|
||||
// SAFETY: SessionGroupSection only reads the fixture session's id in this test.
|
||||
sessions: [{ session: { id: 'session-a' } as Session, children: [], worktree: null }],
|
||||
};
|
||||
|
||||
const createProps = (): SessionGroupSectionProps => ({
|
||||
group,
|
||||
groupKey: 'project:main',
|
||||
projectId: 'project',
|
||||
hideGroupLabel: true,
|
||||
hasSessionSearchQuery: false,
|
||||
normalizedSessionSearchQuery: '',
|
||||
groupSearchDataByGroup: new WeakMap(),
|
||||
collapsedGroups: new Set(),
|
||||
hideDirectoryControls: false,
|
||||
showMoreGroupSessions: () => undefined,
|
||||
resetGroupSessionLimit: () => undefined,
|
||||
mobileVariant: false,
|
||||
alwaysShowActions: false,
|
||||
activeProjectId: null,
|
||||
setActiveProjectIdOnly: () => undefined,
|
||||
setSessionSwitcherOpen: () => undefined,
|
||||
openNewSessionDraft: () => undefined,
|
||||
pinnedSessionIds: new Set(),
|
||||
sessionOrderIndex: new Map(),
|
||||
notifyOnSubtasks: false,
|
||||
expandedParents: new Set(),
|
||||
editingId: null,
|
||||
editTitle: '',
|
||||
copiedSessionId: null,
|
||||
openSidebarMenuKey: null,
|
||||
setEditingId: () => undefined,
|
||||
setEditTitle: () => undefined,
|
||||
toggleParent: () => undefined,
|
||||
setOpenSidebarMenuKey: () => undefined,
|
||||
startFolderRename: () => undefined,
|
||||
allowReselect: false,
|
||||
isSessionSearchOpen: false,
|
||||
sessionSearchQuery: '',
|
||||
setSessionSearchQuery: () => undefined,
|
||||
setIsSessionSearchOpen: () => undefined,
|
||||
deleteSessionConfirm: null,
|
||||
setDeleteSessionConfirm: () => undefined,
|
||||
setCopiedSessionId: () => undefined,
|
||||
onToggleCollapsedGroup: () => undefined,
|
||||
folderRename: null,
|
||||
setFolderRenameDraft: () => undefined,
|
||||
clearFolderRename: () => undefined,
|
||||
});
|
||||
|
||||
describe('SessionGroupSection public behavior', () => {
|
||||
test('routes rendered folder rename and delete actions to the owning folder store', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const originalFolders = useSessionFoldersStore.getState();
|
||||
const originalUi = useUIStore.getState();
|
||||
useSessionFoldersStore.setState({ foldersMap: { '/workspace': [folder] } });
|
||||
useUIStore.setState({ showDeletionDialog: false });
|
||||
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider><SessionGroupSection {...createProps()} /></I18nProvider>));
|
||||
expect(folderCallbacks).not.toBeNull();
|
||||
|
||||
await act(async () => folderCallbacks?.onRename('Renamed folder'));
|
||||
expect(useSessionFoldersStore.getState().foldersMap['/workspace']?.[0]?.name).toBe('Renamed folder');
|
||||
|
||||
await act(async () => folderCallbacks?.onDelete());
|
||||
expect(useSessionFoldersStore.getState().foldersMap['/workspace']).toEqual([]);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
useSessionFoldersStore.setState(originalFolders, true);
|
||||
useUIStore.setState(originalUi, true);
|
||||
folderCallbacks = null;
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('propagates confirmation, search/navigation, and copy ownership changes to rendered rows', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const firstSelected = () => undefined;
|
||||
const nextSelected = () => undefined;
|
||||
const firstCopied = () => undefined;
|
||||
const nextCopied = () => undefined;
|
||||
const initialProps = createProps();
|
||||
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider><SessionGroupSection {...initialProps} group={groupWithSession} onSessionSelected={firstSelected} setCopiedSessionId={firstCopied} /></I18nProvider>));
|
||||
expect(rowPropsCapture?.onSessionSelected).toBe(firstSelected);
|
||||
expect(rowPropsCapture?.sessionSearchQuery).toBe('');
|
||||
expect(rowPropsCapture?.deleteSessionConfirm).toBeNull();
|
||||
expect(rowPropsCapture?.copiedSessionId).toBeNull();
|
||||
expect(rowPropsCapture?.setCopiedSessionId).toBe(firstCopied);
|
||||
|
||||
// SAFETY: the confirmation is only forwarded by identity to the row mock.
|
||||
const confirmation = { session: { id: 'session-a' } as Session, descendantCount: 0, descendantIds: [], archivedBucket: false };
|
||||
await act(async () => root.render(<I18nProvider><SessionGroupSection {...initialProps} group={groupWithSession} allowReselect onSessionSelected={nextSelected} isSessionSearchOpen sessionSearchQuery="search" deleteSessionConfirm={confirmation} copiedSessionId="session-a" setCopiedSessionId={nextCopied} /></I18nProvider>));
|
||||
expect(rowPropsCapture?.allowReselect).toBe(true);
|
||||
expect(rowPropsCapture?.onSessionSelected).toBe(nextSelected);
|
||||
expect(rowPropsCapture?.isSessionSearchOpen).toBe(true);
|
||||
expect(rowPropsCapture?.sessionSearchQuery).toBe('search');
|
||||
expect(rowPropsCapture?.deleteSessionConfirm).toBe(confirmation);
|
||||
expect(rowPropsCapture?.copiedSessionId).toBe('session-a');
|
||||
expect(rowPropsCapture?.setCopiedSessionId).toBe(nextCopied);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
rowPropsCapture = null;
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import { normalizeFolderRoots, selectFolderIdsForProjection } from '../sessions/sessionNodeItemUtils';
|
||||
|
||||
const folder = (id: string, parentId: string | null = null, sessionIds: string[] = []): SessionFolder => ({
|
||||
id,
|
||||
name: id,
|
||||
parentId,
|
||||
sessionIds,
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
describe('normalizeFolderRoots', () => {
|
||||
test('returns cycle and orphan folders as deterministic fallback roots without duplication', () => {
|
||||
const folders = [
|
||||
folder('cycle-a', 'cycle-b', ['session-a']),
|
||||
folder('cycle-b', 'cycle-a'),
|
||||
folder('orphan', 'missing-parent'),
|
||||
folder('root'),
|
||||
];
|
||||
|
||||
expect(normalizeFolderRoots(folders).map((entry) => entry.id))
|
||||
.toEqual(['orphan', 'root', 'cycle-a']);
|
||||
});
|
||||
|
||||
test('keeps normal nested folder root order unchanged', () => {
|
||||
const folders = [folder('root-a'), folder('child-a', 'root-a'), folder('root-b')];
|
||||
|
||||
expect(normalizeFolderRoots(folders).map((entry) => entry.id)).toEqual(['root-a', 'root-b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectFolderIdsForProjection', () => {
|
||||
const malformedFolders = [
|
||||
{ id: 'cycle-a', name: 'cycle-a', parentId: 'cycle-b', nodeCount: 0 },
|
||||
{ id: 'cycle-b', name: 'cycle-b', parentId: 'cycle-a', nodeCount: 1 },
|
||||
{ id: 'orphan', name: 'orphan', parentId: 'missing-parent', nodeCount: 0 },
|
||||
];
|
||||
|
||||
test('keeps malformed empty and nonempty folders in every projection mode', () => {
|
||||
for (const archivedBucket of [false, true]) {
|
||||
for (const searchQuery of ['', 'does-not-match']) {
|
||||
expect([...selectFolderIdsForProjection(malformedFolders, { archivedBucket, searchQuery })])
|
||||
.toEqual(['cycle-a', 'cycle-b', 'orphan']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps normal archived/search nesting semantics', () => {
|
||||
const folders = [
|
||||
{ id: 'root', name: 'root', parentId: null, nodeCount: 0 },
|
||||
{ id: 'child', name: 'matching-child', parentId: 'root', nodeCount: 1 },
|
||||
];
|
||||
|
||||
expect([...selectFolderIdsForProjection(folders, { archivedBucket: true, searchQuery: 'matching' })])
|
||||
.toEqual(['root', 'child']);
|
||||
});
|
||||
|
||||
test('keeps a fuzzy folder match and its ancestor', () => {
|
||||
const folders = [
|
||||
{ id: 'root', name: 'Root', parentId: null, nodeCount: 0 },
|
||||
{ id: 'child', name: 'Release Notes', parentId: 'root', nodeCount: 0 },
|
||||
];
|
||||
|
||||
expect([...selectFolderIdsForProjection(folders, { archivedBucket: false, searchQuery: 'release-notes' })])
|
||||
.toEqual(['root', 'child']);
|
||||
});
|
||||
});
|
||||
+264
-257
@@ -1,6 +1,6 @@
|
||||
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
|
||||
import React from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
// Archived buckets routinely grow into the hundreds/thousands; virtualize
|
||||
@@ -9,37 +9,40 @@ const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
|
||||
// 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;
|
||||
const EMPTY_FOLDERS: readonly never[] = [];
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { SessionFolderItem } from '../SessionFolderItem';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { SessionFolderItem } from '../../SessionFolderItem';
|
||||
import type { SortableDragHandleProps } from './sortableItems';
|
||||
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
|
||||
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
|
||||
import { isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import { DroppableFolderWrapper, SessionFolderDndScope } from '../folders/sessionFolderDnd';
|
||||
import type { GroupSearchData, SessionGroup, SessionNode } from '../types';
|
||||
import { isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from '../utils';
|
||||
import { compareSessionsByLifecycleOrder, EMPTY_SESSION_ORDER_RANKS } from '@/sync/session-ordering';
|
||||
import {
|
||||
collectSubtreeContainingId,
|
||||
computeNodeStructureKey,
|
||||
nodeHasPinnedMembershipChange,
|
||||
nodeContainsSessionId,
|
||||
normalizeFolderRoots,
|
||||
resolveMenuOpenSessionId,
|
||||
selectFolderIdsForProjection,
|
||||
selectFolderRootNodes,
|
||||
} from './sessionNodeItemUtils';
|
||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
} from '../sessions/sessionNodeItemUtils';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
|
||||
type FolderScope = { scopeKey: string; directory: string | null };
|
||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useChildStoreManager } from '@/sync/sync-context';
|
||||
import { canRequestNativeDirectoryAccess, requestDirectoryAccess } from '@/lib/desktop';
|
||||
import { CollapsedActivityIndicator } from './collapsedActivityIndicator';
|
||||
import {
|
||||
getSessionNodesActivityState,
|
||||
mergeCollapsedActivityStates,
|
||||
type CollapsedActivityState,
|
||||
} from './collapsedActivityState';
|
||||
import { CollapsedSessionActivityIndicator } from '../sessions/collapsedActivityIndicator';
|
||||
import { useCollapsedSessionActivityState } from '../sessions/collapsedActivityState';
|
||||
import { SessionTreeItem, type SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||
import { FolderDeleteConfirmDialog } from '../shell/ConfirmDialogs';
|
||||
|
||||
type DeleteFolderConfirm = {
|
||||
scopeKey: string;
|
||||
@@ -49,7 +52,7 @@ type DeleteFolderConfirm = {
|
||||
sessionCount: number;
|
||||
} | null;
|
||||
|
||||
type Props = {
|
||||
export type SessionGroupSectionProps = {
|
||||
group: SessionGroup;
|
||||
groupKey: string;
|
||||
projectId?: string | null;
|
||||
@@ -61,22 +64,6 @@ type Props = {
|
||||
sessionBatchSize?: number;
|
||||
collapsedGroups: Set<string>;
|
||||
hideDirectoryControls: boolean;
|
||||
collapsedFolderIds: Set<string>;
|
||||
toggleFolderCollapse: (folderId: string) => void;
|
||||
renameFolder: (scopeKey: string, folderId: string, name: string) => void;
|
||||
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,
|
||||
renderContext?: 'project' | 'recent',
|
||||
renderExtras?: SessionNodeRenderExtras,
|
||||
) => React.ReactNode;
|
||||
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number, increment?: number) => void;
|
||||
resetGroupSessionLimit: (groupKey: string) => void;
|
||||
mobileVariant: boolean;
|
||||
@@ -85,21 +72,14 @@ type Props = {
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null; targetFolderId?: string; target?: 'chat' | 'project' }) => void;
|
||||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
renamingFolderId: string | null;
|
||||
renameFolderDraft: string;
|
||||
setRenameFolderDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||
setRenamingFolderId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
pinnedSessionIds: Set<string>;
|
||||
expandedParents: Set<string>;
|
||||
sessionOrderIndex: Map<string, number>;
|
||||
notifyOnSubtasks: boolean;
|
||||
expandedParents: Set<string>;
|
||||
editingId: string | null;
|
||||
editTitle: string;
|
||||
copiedSessionId: string | null;
|
||||
openSidebarMenuKey: string | null;
|
||||
activeActivitySessionIds: Set<string>;
|
||||
unreadActivitySessionIds: Set<string>;
|
||||
notifyOnSubtasks: boolean;
|
||||
onToggleCollapsedGroup: (groupKey: string) => void;
|
||||
dragHandleProps?: SortableDragHandleProps | null;
|
||||
compactBodyPadding?: boolean;
|
||||
@@ -110,7 +90,34 @@ type Props = {
|
||||
* render of an expanded archived bucket.
|
||||
*/
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||
};
|
||||
folderRename: { scopeKey: string; folderId: string; draft: string } | null;
|
||||
setFolderRenameDraft: (draft: string) => void;
|
||||
clearFolderRename: () => void;
|
||||
} & Pick<SessionTreeItemProps,
|
||||
| 'setEditingId'
|
||||
| 'setEditTitle'
|
||||
| 'toggleParent'
|
||||
| 'setOpenSidebarMenuKey'
|
||||
| 'allowReselect'
|
||||
| 'onSessionSelected'
|
||||
| 'isSessionSearchOpen'
|
||||
| 'sessionSearchQuery'
|
||||
| 'setSessionSearchQuery'
|
||||
| 'setIsSessionSearchOpen'
|
||||
| 'deleteSessionConfirm'
|
||||
| 'setDeleteSessionConfirm'
|
||||
| 'startFolderRename'
|
||||
| 'setCopiedSessionId'
|
||||
>;
|
||||
|
||||
const CollapsedFolderActivity: React.FC<{
|
||||
nodes: SessionNode[];
|
||||
includeUnreadSubtasks: boolean;
|
||||
children: (state: ReturnType<typeof useCollapsedSessionActivityState>) => React.ReactNode;
|
||||
}> = ({ nodes, includeUnreadSubtasks, children }) => children(useCollapsedSessionActivityState({
|
||||
nodes,
|
||||
includeUnreadSubtasks,
|
||||
}));
|
||||
|
||||
const groupContainsSessionId = (group: SessionGroup, sessionId: string | null): boolean => {
|
||||
if (!sessionId) return false;
|
||||
@@ -145,26 +152,6 @@ const groupHasSessionOrderChange = (
|
||||
return group.sessions.some(visit);
|
||||
};
|
||||
|
||||
const groupHasActivityMembershipChange = (
|
||||
group: SessionGroup,
|
||||
prevSessionIds: Set<string>,
|
||||
nextSessionIds: Set<string>,
|
||||
): boolean => {
|
||||
const visit = (node: SessionNode): boolean => {
|
||||
if (prevSessionIds.has(node.session.id) !== nextSessionIds.has(node.session.id)) return true;
|
||||
return node.children.some(visit);
|
||||
};
|
||||
return group.sessions.some(visit);
|
||||
};
|
||||
|
||||
const groupHasAnyActivityMembership = (group: SessionGroup, sessionIds: Set<string>): boolean => {
|
||||
const visit = (node: SessionNode): boolean => {
|
||||
if (sessionIds.has(node.session.id)) return true;
|
||||
return node.children.some(visit);
|
||||
};
|
||||
return group.sessions.some(visit);
|
||||
};
|
||||
|
||||
const groupHasExpansionMembershipChange = (
|
||||
group: SessionGroup,
|
||||
prevExpandedParents: Set<string>,
|
||||
@@ -179,7 +166,7 @@ const groupHasExpansionMembershipChange = (
|
||||
return group.sessions.some(visit);
|
||||
};
|
||||
|
||||
const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
const areGroupPropsEqual = (prev: SessionGroupSectionProps, next: SessionGroupSectionProps): 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.
|
||||
@@ -202,45 +189,36 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
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.expandedParents !== next.expandedParents
|
||||
&& groupHasExpansionMembershipChange(next.group, prev.expandedParents, next.expandedParents)) {
|
||||
return false;
|
||||
}
|
||||
if (prev.editingId !== next.editingId
|
||||
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
|
||||
&& (groupContainsSessionId(next.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))) {
|
||||
if (prev.editTitle !== next.editTitle && groupContainsSessionId(next.group, next.editingId)) return false;
|
||||
if (prev.copiedSessionId !== next.copiedSessionId
|
||||
&& (groupContainsSessionId(next.group, prev.copiedSessionId) || groupContainsSessionId(next.group, next.copiedSessionId))) {
|
||||
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;
|
||||
const archived = next.group.isArchivedBucket === true;
|
||||
const previousMenuSessionId = resolveMenuOpenSessionId(next.group.sessions, prev.openSidebarMenuKey, 'project', archived);
|
||||
const nextMenuSessionId = resolveMenuOpenSessionId(next.group.sessions, next.openSidebarMenuKey, 'project', archived);
|
||||
if (previousMenuSessionId || nextMenuSessionId) return false;
|
||||
}
|
||||
|
||||
if (prev.activeActivitySessionIds !== next.activeActivitySessionIds
|
||||
&& groupHasActivityMembershipChange(next.group, prev.activeActivitySessionIds, next.activeActivitySessionIds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.unreadActivitySessionIds !== next.unreadActivitySessionIds
|
||||
&& groupHasActivityMembershipChange(next.group, prev.unreadActivitySessionIds, next.unreadActivitySessionIds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks
|
||||
&& groupHasAnyActivityMembership(next.group, next.unreadActivitySessionIds)) {
|
||||
return false;
|
||||
if (prev.folderRename !== next.folderRename) {
|
||||
const scopes = next.group.folderScopes?.map((scope) => scope.scopeKey)
|
||||
?? [next.group.folderScopeKey ?? normalizePath(next.group.directory ?? null)];
|
||||
if (scopes.includes(prev.folderRename?.scopeKey ?? null) || scopes.includes(next.folderRename?.scopeKey ?? null)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Other props are typically stable references from the parent. Default
|
||||
@@ -250,34 +228,37 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
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.showMoreGroupSessions === next.showMoreGroupSessions
|
||||
&& prev.resetGroupSessionLimit === next.resetGroupSessionLimit
|
||||
&& prev.mobileVariant === next.mobileVariant
|
||||
&& prev.alwaysShowActions === next.alwaysShowActions
|
||||
&& prev.activeProjectId === next.activeProjectId
|
||||
&& prev.setActiveProjectIdOnly === next.setActiveProjectIdOnly
|
||||
&& 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
|
||||
&& prev.notifyOnSubtasks === next.notifyOnSubtasks
|
||||
&& prev.setEditingId === next.setEditingId
|
||||
&& prev.setEditTitle === next.setEditTitle
|
||||
&& prev.toggleParent === next.toggleParent
|
||||
&& prev.setOpenSidebarMenuKey === next.setOpenSidebarMenuKey
|
||||
&& prev.allowReselect === next.allowReselect
|
||||
&& prev.onSessionSelected === next.onSessionSelected
|
||||
&& prev.isSessionSearchOpen === next.isSessionSearchOpen
|
||||
&& prev.sessionSearchQuery === next.sessionSearchQuery
|
||||
&& prev.setSessionSearchQuery === next.setSessionSearchQuery
|
||||
&& prev.setIsSessionSearchOpen === next.setIsSessionSearchOpen
|
||||
&& prev.deleteSessionConfirm === next.deleteSessionConfirm
|
||||
&& prev.setDeleteSessionConfirm === next.setDeleteSessionConfirm
|
||||
&& prev.startFolderRename === next.startFolderRename
|
||||
&& prev.setCopiedSessionId === next.setCopiedSessionId
|
||||
&& prev.setFolderRenameDraft === next.setFolderRenameDraft
|
||||
&& prev.clearFolderRename === next.clearFolderRename
|
||||
);
|
||||
};
|
||||
|
||||
function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNode {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
group,
|
||||
@@ -291,13 +272,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
sessionBatchSize,
|
||||
collapsedGroups,
|
||||
hideDirectoryControls,
|
||||
collapsedFolderIds,
|
||||
toggleFolderCollapse,
|
||||
renameFolder,
|
||||
deleteFolder,
|
||||
showDeletionDialog,
|
||||
setDeleteFolderConfirm,
|
||||
renderSessionNode,
|
||||
showMoreGroupSessions,
|
||||
resetGroupSessionLimit,
|
||||
mobileVariant,
|
||||
@@ -306,26 +280,28 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
setActiveProjectIdOnly,
|
||||
setSessionSwitcherOpen,
|
||||
openNewSessionDraft,
|
||||
addSessionToFolder,
|
||||
createFolderAndStartRename,
|
||||
renamingFolderId,
|
||||
renameFolderDraft,
|
||||
setRenameFolderDraft,
|
||||
setRenamingFolderId,
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
sessionOrderIndex,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
activeActivitySessionIds,
|
||||
unreadActivitySessionIds,
|
||||
notifyOnSubtasks,
|
||||
onToggleCollapsedGroup,
|
||||
dragHandleProps,
|
||||
compactBodyPadding = false,
|
||||
scrollContainerRef,
|
||||
expandedParents,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
editTitle,
|
||||
copiedSessionId,
|
||||
folderRename,
|
||||
setFolderRenameDraft,
|
||||
clearFolderRename,
|
||||
} = props;
|
||||
|
||||
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
|
||||
const renameFolder = useSessionFoldersStore((state) => state.renameFolder);
|
||||
const deleteFolder = useSessionFoldersStore((state) => state.deleteFolder);
|
||||
const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder);
|
||||
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||
const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<DeleteFolderConfirm>(null);
|
||||
const compareSessionNodes = React.useCallback((a: SessionNode, b: SessionNode) => {
|
||||
const aIndex = sessionOrderIndex.get(a.session.id);
|
||||
const bIndex = sessionOrderIndex.get(b.session.id);
|
||||
@@ -338,7 +314,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}, [pinnedSessionIds, sessionOrderIndex]);
|
||||
|
||||
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
||||
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
||||
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
|
||||
// PR state for the worktree sub-header (grouped display mode).
|
||||
const groupPrKey = React.useMemo(() => {
|
||||
@@ -413,15 +388,26 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
||||
// Merged flat groups list every contributing scope; single-scope groups
|
||||
// (archived buckets, VS Code workspaces) fall back to folderScopeKey.
|
||||
const folderScopes = React.useMemo<Array<{ scopeKey: string; directory: string | null }>>(() => {
|
||||
const folderScopes = React.useMemo<FolderScope[]>(() => {
|
||||
if (group.folderScopes && group.folderScopes.length > 0) return group.folderScopes;
|
||||
return folderScopeKey ? [{ scopeKey: folderScopeKey, directory: group.directory ?? null }] : [];
|
||||
}, [folderScopeKey, group.directory, group.folderScopes]);
|
||||
const scopeFolders = React.useMemo(
|
||||
() => folderScopes.flatMap(({ scopeKey, directory }) =>
|
||||
(foldersMap[scopeKey] ?? []).map((folder) => ({ folder, scopeKey, scopeDirectory: directory }))),
|
||||
[folderScopes, foldersMap]
|
||||
);
|
||||
// A group only needs folders and collapse state from its own scopes. The
|
||||
// shallow projection retains its reference for mutations elsewhere.
|
||||
const folderProjection = useSessionFoldersStore(useShallow(React.useCallback(
|
||||
(state) => folderScopes.map(({ scopeKey }) => state.foldersMap[scopeKey] ?? EMPTY_FOLDERS),
|
||||
[folderScopes],
|
||||
)));
|
||||
const scopeFolders = React.useMemo(() => folderScopes.flatMap(({ scopeKey, directory }, index) => {
|
||||
const folders = folderProjection[index] ?? EMPTY_FOLDERS;
|
||||
return folders.map((folder) => ({ folder, scopeKey, scopeDirectory: directory }));
|
||||
}), [folderProjection, folderScopes]);
|
||||
const collapsedFolderIds = useSessionFoldersStore(useShallow(React.useCallback(
|
||||
(state) => new Set(folderProjection.flatMap((folders) => folders
|
||||
.filter((folder) => state.collapsedFolderIds.has(folder.id))
|
||||
.map((folder) => folder.id))),
|
||||
[folderProjection],
|
||||
)));
|
||||
|
||||
const nodeBySessionId = React.useMemo(() => {
|
||||
const map = new Map<string, SessionNode>();
|
||||
@@ -443,60 +429,33 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}), [scopeFolders, nodeBySessionId, compareSessionNodes]);
|
||||
|
||||
const allFoldersForGroup = React.useMemo(() => {
|
||||
const folderMapById = new Map(allFoldersForGroupBase.map((entry) => [entry.folder.id, entry]));
|
||||
const childFolderIdsByParentId = new Map<string, string[]>();
|
||||
for (const { folder } of allFoldersForGroupBase) {
|
||||
if (!folder.parentId) continue;
|
||||
const existing = childFolderIdsByParentId.get(folder.parentId);
|
||||
if (existing) {
|
||||
existing.push(folder.id);
|
||||
} else {
|
||||
childFolderIdsByParentId.set(folder.parentId, [folder.id]);
|
||||
}
|
||||
}
|
||||
|
||||
const keepByFolderId = new Map<string, boolean>();
|
||||
const shouldKeepFolder = (folderId: string): boolean => {
|
||||
const cached = keepByFolderId.get(folderId);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const entry = folderMapById.get(folderId);
|
||||
if (!entry) {
|
||||
keepByFolderId.set(folderId, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const childFolderIds = childFolderIdsByParentId.get(folderId) ?? [];
|
||||
|
||||
// For archived buckets, hide folders with no sessions unless descendants have content.
|
||||
if (group.isArchivedBucket && entry.nodes.length === 0) {
|
||||
const hasContentInChildren = childFolderIds.some((childId) => shouldKeepFolder(childId));
|
||||
keepByFolderId.set(folderId, hasContentInChildren);
|
||||
return hasContentInChildren;
|
||||
}
|
||||
|
||||
if (!hasSessionSearchQuery) {
|
||||
keepByFolderId.set(folderId, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
const folderMatches = matchesRankQuery([entry.folder.name], normalizedSessionSearchQuery);
|
||||
if (folderMatches || entry.nodes.length > 0) {
|
||||
keepByFolderId.set(folderId, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasMatchingChildren = childFolderIds.some((childId) => shouldKeepFolder(childId));
|
||||
keepByFolderId.set(folderId, hasMatchingChildren);
|
||||
return hasMatchingChildren;
|
||||
};
|
||||
|
||||
return allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id));
|
||||
const visibleFolderIds = selectFolderIdsForProjection(
|
||||
allFoldersForGroupBase.map(({ folder, nodes }) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentId: folder.parentId,
|
||||
nodeCount: nodes.length,
|
||||
})),
|
||||
{
|
||||
archivedBucket: group.isArchivedBucket === true,
|
||||
searchQuery: hasSessionSearchQuery ? normalizedSessionSearchQuery : '',
|
||||
},
|
||||
);
|
||||
return allFoldersForGroupBase.filter(({ folder }) => visibleFolderIds.has(folder.id));
|
||||
}, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]);
|
||||
|
||||
const effectiveEditingId = editingId;
|
||||
const effectiveOpenMenuKey = openSidebarMenuKey;
|
||||
const effectiveExpandedParents = expandedParents;
|
||||
|
||||
const sessionIdsInFolders = React.useMemo(() => new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds)), [allFoldersForGroup]);
|
||||
const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
|
||||
const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
|
||||
const rootFolders = React.useMemo(() => {
|
||||
const entryById = new Map(allFoldersForGroup.map((entry) => [entry.folder.id, entry]));
|
||||
return normalizeFolderRoots(allFoldersForGroup.map((entry) => entry.folder))
|
||||
.map((folder) => entryById.get(folder.id))
|
||||
.filter((entry): entry is (typeof allFoldersForGroup)[number] => Boolean(entry));
|
||||
}, [allFoldersForGroup]);
|
||||
const childFoldersByParentId = React.useMemo(() => {
|
||||
const map = new Map<string, typeof allFoldersForGroup>();
|
||||
allFoldersForGroup.forEach((entry) => {
|
||||
@@ -507,30 +466,25 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
});
|
||||
return map;
|
||||
}, [allFoldersForGroup]);
|
||||
const folderActivityStateById = React.useMemo(() => {
|
||||
const activityNodesByFolderId = React.useMemo(() => {
|
||||
const foldersById = new Map(allFoldersForGroup.map((entry) => [entry.folder.id, entry] as const));
|
||||
const result = new Map<string, CollapsedActivityState>();
|
||||
const visit = (folderId: string, seen: Set<string>): CollapsedActivityState => {
|
||||
const result = new Map<string, SessionNode[]>();
|
||||
const visit = (folderId: string, seen: Set<string>): SessionNode[] => {
|
||||
const cached = result.get(folderId);
|
||||
if (cached !== undefined) return cached;
|
||||
if (seen.has(folderId)) return null;
|
||||
if (seen.has(folderId)) return [];
|
||||
seen.add(folderId);
|
||||
|
||||
const entry = foldersById.get(folderId);
|
||||
let state = entry
|
||||
? getSessionNodesActivityState(entry.nodes, activeActivitySessionIds, unreadActivitySessionIds, notifyOnSubtasks)
|
||||
: null;
|
||||
const nodes = entry ? [...entry.nodes] : [];
|
||||
for (const child of childFoldersByParentId.get(folderId) ?? []) {
|
||||
state = mergeCollapsedActivityStates(state, visit(child.folder.id, seen));
|
||||
if (state === 'active') break;
|
||||
nodes.push(...visit(child.folder.id, seen));
|
||||
}
|
||||
result.set(folderId, state);
|
||||
return state;
|
||||
result.set(folderId, nodes);
|
||||
return nodes;
|
||||
};
|
||||
|
||||
allFoldersForGroup.forEach(({ folder }) => visit(folder.id, new Set()));
|
||||
return result;
|
||||
}, [activeActivitySessionIds, allFoldersForGroup, childFoldersByParentId, notifyOnSubtasks, unreadActivitySessionIds]);
|
||||
}, [allFoldersForGroup, childFoldersByParentId]);
|
||||
|
||||
// Precompute the per-row "subtree contains editing session" lookup once per
|
||||
// render. The previous design walked the
|
||||
@@ -540,23 +494,23 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
const renderContextForGroup = 'project' as const;
|
||||
const subtreeContainsEditing = React.useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
collectSubtreeContainingId(sourceGroupNodes, editingId, set);
|
||||
collectSubtreeContainingId(sourceGroupNodes, effectiveEditingId, set);
|
||||
allFoldersForGroup.forEach(({ nodes }) => {
|
||||
collectSubtreeContainingId(nodes, editingId, set);
|
||||
collectSubtreeContainingId(nodes, effectiveEditingId, set);
|
||||
});
|
||||
return set;
|
||||
}, [sourceGroupNodes, allFoldersForGroup, editingId]);
|
||||
}, [sourceGroupNodes, allFoldersForGroup, effectiveEditingId]);
|
||||
|
||||
const menuOpenSessionId = React.useMemo(() => {
|
||||
if (!openSidebarMenuKey) return null;
|
||||
const fromSource = resolveMenuOpenSessionId(sourceGroupNodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
|
||||
if (!effectiveOpenMenuKey) return null;
|
||||
const fromSource = resolveMenuOpenSessionId(sourceGroupNodes, effectiveOpenMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
|
||||
if (fromSource) return fromSource;
|
||||
for (const { nodes } of allFoldersForGroup) {
|
||||
const id = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
|
||||
const id = resolveMenuOpenSessionId(nodes, effectiveOpenMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
|
||||
if (id) return id;
|
||||
}
|
||||
return null;
|
||||
}, [openSidebarMenuKey, sourceGroupNodes, allFoldersForGroup, group.isArchivedBucket]);
|
||||
}, [effectiveOpenMenuKey, sourceGroupNodes, allFoldersForGroup, group.isArchivedBucket]);
|
||||
|
||||
const buildNodeStructureKeyByNode = React.useCallback((nodes: SessionNode[]): WeakMap<SessionNode, string> => {
|
||||
const map = new WeakMap<SessionNode, string>();
|
||||
@@ -620,7 +574,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
const hasExpandedParent = shouldVirtualize && visibleSessions.some((node) => {
|
||||
if (node.children.length === 0) return false;
|
||||
const expansionKey = `project:${bucketTag}:${node.session.id}`;
|
||||
return expandedParents.has(expansionKey);
|
||||
return effectiveExpandedParents.has(expansionKey);
|
||||
});
|
||||
|
||||
const archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@@ -649,7 +603,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
if (!shouldVirtualize) return;
|
||||
const container = archivedVirtualContainerRef.current;
|
||||
if (!container) return;
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
if (!globalThis.ResizeObserver) return;
|
||||
const ro = new ResizeObserver(() => setLayoutVersion((v) => v + 1));
|
||||
ro.observe(container);
|
||||
return () => ro.disconnect();
|
||||
@@ -785,28 +739,23 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}
|
||||
|
||||
const showBranchSubtitle = !group.isMain && Boolean(group.branch);
|
||||
// SAFETY: null is the intentional no-color branch for a status line.
|
||||
const statusLine = group.branch && isBranchDifferentFromLabel(group.branch, group.label)
|
||||
? { label: group.branch, color: null as string | null }
|
||||
: null;
|
||||
const groupActivityState = isCollapsed
|
||||
? getSessionNodesActivityState(sourceGroupNodes, activeActivitySessionIds, unreadActivitySessionIds, notifyOnSubtasks)
|
||||
const groupActivityIndicator = isCollapsed
|
||||
? <CollapsedSessionActivityIndicator nodes={sourceGroupNodes} includeUnreadSubtasks={notifyOnSubtasks} />
|
||||
: null;
|
||||
const groupActivityIndicator = groupActivityState ? (
|
||||
<CollapsedActivityIndicator
|
||||
state={groupActivityState}
|
||||
activeLabel={t('sessions.sidebar.session.status.active')}
|
||||
unreadLabel={t('sessions.sidebar.session.status.unread')}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
type FolderEntry = (typeof allFoldersForGroup)[number];
|
||||
|
||||
const renderOneFolderItem = (entry: FolderEntry, displayName: string): React.ReactNode => {
|
||||
const { folder, scopeKey, scopeDirectory, nodes } = entry;
|
||||
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
|
||||
const isRenamingFolder = folderRename?.folderId === folder.id && folderRename?.scopeKey === scopeKey;
|
||||
|
||||
const isFolderCollapsed = hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id);
|
||||
return (
|
||||
const item = (collapsedActivityState: ReturnType<typeof useCollapsedSessionActivityState>) => (
|
||||
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
|
||||
{(droppableRef, isDropTarget) => (
|
||||
<SessionFolderItem
|
||||
@@ -814,7 +763,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
displayName={displayName}
|
||||
sessions={nodes}
|
||||
isCollapsed={isFolderCollapsed}
|
||||
collapsedActivityState={isFolderCollapsed ? (folderActivityStateById.get(folder.id) ?? null) : null}
|
||||
collapsedActivityState={collapsedActivityState}
|
||||
onToggle={() => toggleFolderCollapse(folder.id)}
|
||||
onRename={(name) => {
|
||||
renameFolder(scopeKey, folder.id, name);
|
||||
@@ -843,34 +792,21 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
sessionCount,
|
||||
});
|
||||
}}
|
||||
renderSessionNode={renderSessionNode}
|
||||
getRenderExtras={resolveNodeStructureKey
|
||||
? (node) => ({
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
childRenderExtrasFor,
|
||||
})
|
||||
: undefined}
|
||||
groupDirectory={scopeDirectory ?? group.directory}
|
||||
projectId={projectId}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
isRenaming={renamingFolderId === folder.id}
|
||||
renameDraft={renamingFolderId === folder.id ? renameFolderDraft : undefined}
|
||||
onRenameDraftChange={(value) => setRenameFolderDraft(value)}
|
||||
isRenaming={isRenamingFolder}
|
||||
renameDraft={isRenamingFolder ? folderRename?.draft : undefined}
|
||||
onRenameDraftChange={setFolderRenameDraft}
|
||||
onRenameSave={() => {
|
||||
const trimmed = renameFolderDraft.trim();
|
||||
const trimmed = folderRename?.draft.trim() ?? '';
|
||||
if (trimmed) {
|
||||
renameFolder(scopeKey, folder.id, trimmed);
|
||||
}
|
||||
setRenamingFolderId(null);
|
||||
setRenameFolderDraft('');
|
||||
}}
|
||||
onRenameCancel={() => {
|
||||
setRenamingFolderId(null);
|
||||
setRenameFolderDraft('');
|
||||
clearFolderRename();
|
||||
}}
|
||||
onRenameCancel={clearFolderRename}
|
||||
droppableRef={droppableRef}
|
||||
isDropTarget={isDropTarget}
|
||||
depth={0}
|
||||
@@ -886,10 +822,50 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}}
|
||||
hideActions={false}
|
||||
archivedBucket={group.isArchivedBucket === true}
|
||||
/>
|
||||
>
|
||||
{nodes.map((node) => <SessionTreeItem
|
||||
key={node.session.id}
|
||||
node={node}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
expandedParents={expandedParents}
|
||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||
notifyOnSubtasks={notifyOnSubtasks}
|
||||
editingId={editingId}
|
||||
editTitle={editTitle}
|
||||
copiedSessionId={copiedSessionId}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
groupDirectory={scopeDirectory ?? group.directory}
|
||||
projectId={projectId}
|
||||
archivedBucket={group.isArchivedBucket === true}
|
||||
renderExtras={{ subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(node), childRenderExtrasFor }}
|
||||
setEditingId={props.setEditingId}
|
||||
setEditTitle={props.setEditTitle}
|
||||
toggleParent={props.toggleParent}
|
||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||
allowReselect={props.allowReselect}
|
||||
onSessionSelected={props.onSessionSelected}
|
||||
isSessionSearchOpen={props.isSessionSearchOpen}
|
||||
sessionSearchQuery={props.sessionSearchQuery}
|
||||
setSessionSearchQuery={props.setSessionSearchQuery}
|
||||
setIsSessionSearchOpen={props.setIsSessionSearchOpen}
|
||||
deleteSessionConfirm={props.deleteSessionConfirm}
|
||||
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||
startFolderRename={props.startFolderRename}
|
||||
setCopiedSessionId={props.setCopiedSessionId}
|
||||
/>)}
|
||||
</SessionFolderItem>
|
||||
)}
|
||||
</DroppableFolderWrapper>
|
||||
);
|
||||
if (!isFolderCollapsed) return item(null);
|
||||
return <CollapsedFolderActivity
|
||||
key={folder.id}
|
||||
nodes={activityNodesByFolderId.get(folder.id) ?? nodes}
|
||||
includeUnreadSubtasks={notifyOnSubtasks}
|
||||
>{item}</CollapsedFolderActivity>;
|
||||
};
|
||||
|
||||
// Folders render flat: nested folders keep their data-model parent link but
|
||||
@@ -905,7 +881,10 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
else childEntriesByParentId.set(parentId, [entry]);
|
||||
}
|
||||
const out: React.ReactNode[] = [];
|
||||
const visited = new Set<string>();
|
||||
const visit = (entry: FolderEntry, parentPath: string) => {
|
||||
if (visited.has(entry.folder.id)) return;
|
||||
visited.add(entry.folder.id);
|
||||
const displayName = parentPath ? `${parentPath} / ${entry.folder.name}` : entry.folder.name;
|
||||
out.push(renderOneFolderItem(entry, displayName));
|
||||
const isFolderCollapsed = !hasSessionSearchQuery && collapsedFolderIds.has(entry.folder.id);
|
||||
@@ -951,6 +930,40 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const renderSessionNode = (node: SessionNode): React.ReactNode => <SessionTreeItem
|
||||
key={node.session.id}
|
||||
node={node}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
expandedParents={expandedParents}
|
||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||
notifyOnSubtasks={notifyOnSubtasks}
|
||||
editingId={editingId}
|
||||
editTitle={editTitle}
|
||||
copiedSessionId={copiedSessionId}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
groupDirectory={group.directory}
|
||||
projectId={projectId}
|
||||
archivedBucket={group.isArchivedBucket === true}
|
||||
renderExtras={{ subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(node), childRenderExtrasFor }}
|
||||
setEditingId={props.setEditingId}
|
||||
setEditTitle={props.setEditTitle}
|
||||
toggleParent={props.toggleParent}
|
||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||
allowReselect={props.allowReselect}
|
||||
onSessionSelected={props.onSessionSelected}
|
||||
isSessionSearchOpen={props.isSessionSearchOpen}
|
||||
sessionSearchQuery={props.sessionSearchQuery}
|
||||
setSessionSearchQuery={props.setSessionSearchQuery}
|
||||
setIsSessionSearchOpen={props.setIsSessionSearchOpen}
|
||||
deleteSessionConfirm={props.deleteSessionConfirm}
|
||||
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||
startFolderRename={props.startFolderRename}
|
||||
setCopiedSessionId={props.setCopiedSessionId}
|
||||
/>;
|
||||
|
||||
const body = (
|
||||
<SessionFolderDndScope
|
||||
scopeKey={folderScopes[0]?.scopeKey ?? folderScopeKey}
|
||||
@@ -979,12 +992,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
// re-renders synchronously before paint. Rendering the plain rows
|
||||
// meanwhile keeps the container's height real so the scroller
|
||||
// never collapses/clamps during the flip.
|
||||
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
childRenderExtrasFor,
|
||||
}))
|
||||
visibleSessions.map(renderSessionNode)
|
||||
) : (
|
||||
<div style={{ height: sessionVirtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{/* Absolutely positioned rows (canonical tanstack layout): with
|
||||
@@ -1017,12 +1025,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
transform: `translateY(${item.start - archivedScrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
childRenderExtrasFor,
|
||||
})}
|
||||
{renderSessionNode(node)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1030,12 +1033,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
childRenderExtrasFor,
|
||||
}))
|
||||
visibleSessions.map(renderSessionNode)
|
||||
)}
|
||||
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
|
||||
// pl-[26px] lines the text up with the worktree sub-header label
|
||||
@@ -1086,15 +1084,24 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
void compactBodyPadding;
|
||||
// Folder nesting is legacy-only: existing sub-folders keep working (path
|
||||
// labels), but the UI no longer offers creating new ones.
|
||||
void createFolderAndStartRename;
|
||||
const groupBodyPaddingClass = 'pb-2';
|
||||
const folderDeleteDialog = <FolderDeleteConfirmDialog
|
||||
value={deleteFolderConfirm}
|
||||
setValue={setDeleteFolderConfirm}
|
||||
onConfirm={() => {
|
||||
const value = deleteFolderConfirm;
|
||||
if (!value) return;
|
||||
deleteFolder(value.scopeKey, value.folderId);
|
||||
setDeleteFolderConfirm(null);
|
||||
}}
|
||||
/>;
|
||||
|
||||
if (hideGroupLabel) {
|
||||
return <div className="oc-group"><div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div></div>;
|
||||
return <><div className="oc-group"><div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div></div>{folderDeleteDialog}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="oc-group">
|
||||
<><div className="oc-group">
|
||||
<div
|
||||
className={cn('group/gh relative flex items-start justify-between gap-1 py-1 min-w-0 rounded-md', 'cursor-pointer')}
|
||||
onClick={() => onToggleCollapsedGroup(groupKey)}
|
||||
@@ -1243,7 +1250,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
|
||||
if (mobileVariant) setSessionSwitcherOpen(false);
|
||||
if (mobileVariant) setSessionSwitcherOpen(false);
|
||||
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: group.directory });
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
@@ -1258,7 +1265,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
) : null}
|
||||
</div>
|
||||
{!isCollapsed ? <div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div> : null}
|
||||
</div>
|
||||
</div>{folderDeleteDialog}</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildGroupRenderDescriptors, selectRenderedProjectSections } from './sessionProjectRender';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
|
||||
const makeGroup = (id: string, overrides: Partial<SessionGroup> = {}): SessionGroup => ({
|
||||
id,
|
||||
label: id,
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: id === 'main',
|
||||
worktree: null,
|
||||
directory: '/workspace',
|
||||
sessions: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('buildGroupRenderDescriptors', () => {
|
||||
test('renders the main group and archived bucket for the main workspace', () => {
|
||||
const section = {
|
||||
project: { id: 'project-a', normalizedPath: '/workspace' },
|
||||
groups: [makeGroup('main'), makeGroup('archived', { isArchivedBucket: true })],
|
||||
};
|
||||
|
||||
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: true })).toEqual([
|
||||
{
|
||||
group: section.groups[0],
|
||||
groupKey: 'project-a:main',
|
||||
projectId: 'project-a',
|
||||
hideGroupLabel: true,
|
||||
},
|
||||
{
|
||||
group: section.groups[1],
|
||||
groupKey: 'project-a:archived',
|
||||
projectId: 'project-a',
|
||||
hideGroupLabel: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('renders the primary group without a label and nested groups with labels', () => {
|
||||
const section = {
|
||||
project: { id: 'project-a', normalizedPath: '/workspace' },
|
||||
groups: [makeGroup('main'), makeGroup('feature')],
|
||||
};
|
||||
|
||||
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false })).toEqual([
|
||||
{
|
||||
group: section.groups[0],
|
||||
groupKey: 'project-a:main',
|
||||
projectId: 'project-a',
|
||||
hideGroupLabel: true,
|
||||
},
|
||||
{
|
||||
group: section.groups[1],
|
||||
groupKey: 'project-a:feature',
|
||||
projectId: 'project-a',
|
||||
hideGroupLabel: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps labels when a flat section has no main group', () => {
|
||||
const section = {
|
||||
project: { id: 'project-a', normalizedPath: '/workspace' },
|
||||
groups: [makeGroup('feature', { isMain: false }), makeGroup('other', { isMain: false })],
|
||||
};
|
||||
|
||||
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false }).map((descriptor) => descriptor.hideGroupLabel)).toEqual([false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('single-project scroller projection', () => {
|
||||
test('renders only the selected project from persisted display state', () => {
|
||||
const previous = useSessionDisplayStore.getState();
|
||||
const sections = [
|
||||
{ project: { id: 'project-a', normalizedPath: '/workspace/a' }, groups: [] },
|
||||
{ project: { id: 'project-b', normalizedPath: '/workspace/b' }, groups: [] },
|
||||
];
|
||||
|
||||
try {
|
||||
useSessionDisplayStore.setState({ projectDisplayMode: 'single', singleProjectId: 'project-b' });
|
||||
const state = useSessionDisplayStore.getState();
|
||||
|
||||
expect(selectRenderedProjectSections(sections, state.projectDisplayMode === 'single', state.singleProjectId)
|
||||
.map((section) => section.project.id)).toEqual(['project-b']);
|
||||
} finally {
|
||||
useSessionDisplayStore.setState(previous, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
+203
-248
@@ -10,39 +10,124 @@ import {
|
||||
} from '@dnd-kit/core';
|
||||
import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
|
||||
import type { SessionGroup } from './types';
|
||||
import type { SortableDragHandleProps } from './sortableItems';
|
||||
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
|
||||
import { formatProjectLabel } from './utils';
|
||||
import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection';
|
||||
import { buildGroupRenderDescriptors, selectRenderedProjectSections, type ProjectSection } from './sessionProjectRender';
|
||||
import { formatProjectLabel } from '../utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
|
||||
type ProjectSection = {
|
||||
project: {
|
||||
id: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||
iconBackground?: string;
|
||||
};
|
||||
groups: SessionGroup[];
|
||||
type SessionProjectScrollerState = Pick<SessionGroupSectionProps,
|
||||
| 'editingId'
|
||||
| 'openSidebarMenuKey'
|
||||
| 'setOpenSidebarMenuKey'
|
||||
> & {
|
||||
visibleSessionCountByGroup: Map<string, number>;
|
||||
};
|
||||
|
||||
type SessionProjectScrollerGroupProps = Pick<SessionGroupSectionProps,
|
||||
| 'hasSessionSearchQuery'
|
||||
| 'normalizedSessionSearchQuery'
|
||||
| 'groupSearchDataByGroup'
|
||||
| 'collapsedGroups'
|
||||
| 'hideDirectoryControls'
|
||||
| 'mobileVariant'
|
||||
| 'alwaysShowActions'
|
||||
| 'activeProjectId'
|
||||
| 'notifyOnSubtasks'
|
||||
| 'expandedParents'
|
||||
| 'editTitle'
|
||||
| 'copiedSessionId'
|
||||
| 'folderRename'
|
||||
| 'setFolderRenameDraft'
|
||||
| 'clearFolderRename'
|
||||
| 'setEditingId'
|
||||
| 'setEditTitle'
|
||||
| 'toggleParent'
|
||||
| 'allowReselect'
|
||||
| 'onSessionSelected'
|
||||
| 'isSessionSearchOpen'
|
||||
| 'sessionSearchQuery'
|
||||
| 'setSessionSearchQuery'
|
||||
| 'setIsSessionSearchOpen'
|
||||
| 'deleteSessionConfirm'
|
||||
| 'setDeleteSessionConfirm'
|
||||
| 'startFolderRename'
|
||||
| 'setCopiedSessionId'
|
||||
> & {
|
||||
pinnedSessionIds: Set<string>;
|
||||
sessionOrderIndex: Map<string, number>;
|
||||
};
|
||||
|
||||
type SessionProjectScrollerGroupActions = Pick<SessionGroupSectionProps,
|
||||
| 'showMoreGroupSessions'
|
||||
| 'resetGroupSessionLimit'
|
||||
| 'setActiveProjectIdOnly'
|
||||
| 'setSessionSwitcherOpen'
|
||||
| 'openNewSessionDraft'
|
||||
| 'onToggleCollapsedGroup'
|
||||
>;
|
||||
|
||||
type SessionProjectScrollerModel = {
|
||||
topContent?: React.ReactNode;
|
||||
hasSharedSessions?: boolean;
|
||||
sectionsForRender: ProjectSection[];
|
||||
projectSections: ProjectSection[];
|
||||
activeProjectId: string | null;
|
||||
singleProjectMode: boolean;
|
||||
singleProjectId: string | null;
|
||||
emptyState: React.ReactNode;
|
||||
searchEmptyState: React.ReactNode;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
stuckProjectHeaders: Set<string>;
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
state: SessionProjectScrollerState;
|
||||
groupProps: SessionProjectScrollerGroupProps;
|
||||
};
|
||||
|
||||
type SessionProjectScrollerView = {
|
||||
homeDirectory: string | null;
|
||||
collapsedProjects: Set<string>;
|
||||
showOnlyMainWorkspace: boolean;
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
hideDirectoryControls: boolean;
|
||||
isDesktopShellRuntime: boolean;
|
||||
stickyZoneHeaders: boolean;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
projectSortOrder: ProjectSortOrder;
|
||||
};
|
||||
|
||||
type SessionProjectScrollerActions = {
|
||||
group: SessionProjectScrollerGroupActions;
|
||||
toggleProject: (id: string) => void;
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
openNewWorktreeDialog: () => void;
|
||||
openWorktreesPage: (id: string) => void;
|
||||
openProjectEditDialog: (id: string) => void;
|
||||
removeProject: (id: string) => void;
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||
setSingleProjectId: (id: string) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
model: SessionProjectScrollerModel;
|
||||
view: SessionProjectScrollerView;
|
||||
actions: SessionProjectScrollerActions;
|
||||
};
|
||||
|
||||
const TOP_FADE_MAX_SIZE = 48;
|
||||
const TOP_FADE_MIN_SIZE = 32;
|
||||
const TOP_FADE_CLEAR_MAX_SIZE = 24;
|
||||
type ActivitySectionKey = 'chats' | 'active-now';
|
||||
|
||||
const readActivitySectionKey = (element: Element): ActivitySectionKey | null => {
|
||||
const key = element.getAttribute('data-sidebar-activity-sentinel');
|
||||
if (key === 'chats' || key === 'active-now') return key;
|
||||
return null;
|
||||
};
|
||||
|
||||
const getProjectLabel = (project: ProjectSection['project'], homeDirectory: string | null): string => (
|
||||
formatProjectLabel(
|
||||
@@ -52,62 +137,12 @@ const getProjectLabel = (project: ProjectSection['project'], homeDirectory: stri
|
||||
)
|
||||
);
|
||||
|
||||
type Props = {
|
||||
topContent?: React.ReactNode;
|
||||
sharedSessionsOnly?: boolean;
|
||||
hasSharedSessions?: boolean;
|
||||
sectionsForRender: ProjectSection[];
|
||||
projectSections: ProjectSection[];
|
||||
projectPickerSections: ProjectSection[];
|
||||
activeProjectId: string | null;
|
||||
singleProjectMode: boolean;
|
||||
singleProjectId: string | null;
|
||||
setSingleProjectId: (id: string) => void;
|
||||
showOnlyMainWorkspace: boolean;
|
||||
hasSessionSearchQuery: boolean;
|
||||
emptyState: React.ReactNode;
|
||||
searchEmptyState: React.ReactNode;
|
||||
renderGroupSessions: (
|
||||
group: SessionGroup,
|
||||
groupKey: string,
|
||||
projectId?: string | null,
|
||||
hideGroupLabel?: boolean,
|
||||
dragHandleProps?: SortableDragHandleProps | null,
|
||||
compactBodyPadding?: boolean,
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>,
|
||||
) => React.ReactNode;
|
||||
getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[];
|
||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||
homeDirectory: string | null;
|
||||
collapsedProjects: Set<string>;
|
||||
hideDirectoryControls: boolean;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
isDesktopShellRuntime: boolean;
|
||||
stickyZoneHeaders: boolean;
|
||||
stuckProjectHeaders: Set<string>;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
toggleProject: (id: string) => void;
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
openNewWorktreeDialog: () => void;
|
||||
openWorktreesPage: (id: string) => void;
|
||||
openProjectEditDialog: (id: string) => void;
|
||||
removeProject: (id: string) => void;
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
projectSortOrder: ProjectSortOrder;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
isInlineEditing: boolean;
|
||||
};
|
||||
|
||||
function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
streamPerfCount('ui.sidebar_projects_list.render');
|
||||
const { t } = useI18n();
|
||||
const enableStickyFade = props.isDesktopShellRuntime && props.stickyZoneHeaders && !props.singleProjectMode;
|
||||
const { model, view, actions } = props;
|
||||
const isInlineEditing = model.state.editingId !== null;
|
||||
const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders && !model.singleProjectMode;
|
||||
const projectSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
@@ -115,55 +150,15 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
const groupSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
);
|
||||
const selectedSingleProjectSection = props.singleProjectMode
|
||||
? props.sectionsForRender.find((section) => section.project.id === props.singleProjectId)
|
||||
: null;
|
||||
const renderedProjectSections = props.singleProjectMode
|
||||
? (selectedSingleProjectSection ? [selectedSingleProjectSection] : [])
|
||||
: props.sectionsForRender;
|
||||
const projectPickerOptions = React.useMemo(() => props.projectPickerSections.map((section) => ({
|
||||
id: section.project.id,
|
||||
projectLabel: getProjectLabel(section.project, props.homeDirectory),
|
||||
projectDescription: formatPathForDisplay(section.project.normalizedPath, props.homeDirectory),
|
||||
projectIcon: section.project.icon,
|
||||
projectColor: section.project.color,
|
||||
projectIconImage: section.project.iconImage,
|
||||
projectIconBackground: section.project.iconBackground,
|
||||
})), [props.homeDirectory, props.projectPickerSections]);
|
||||
|
||||
// Memoize getOrderedGroups per project so downstream consumers see a stable
|
||||
// array reference while inputs are unchanged (avoids O(P) fresh arrays per
|
||||
// list render invalidating the memoized group subtrees).
|
||||
const orderedGroupsCacheRef = React.useRef<Map<string, { groups: SessionGroup[]; ordered: SessionGroup[] }>>(new Map());
|
||||
const orderedGroupsCacheGetOrderedGroupsRef = React.useRef<typeof props.getOrderedGroups>(props.getOrderedGroups);
|
||||
if (orderedGroupsCacheGetOrderedGroupsRef.current !== props.getOrderedGroups) {
|
||||
orderedGroupsCacheGetOrderedGroupsRef.current = props.getOrderedGroups;
|
||||
orderedGroupsCacheRef.current.clear();
|
||||
}
|
||||
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 });
|
||||
if (cache.size > 256) {
|
||||
const firstKey = cache.keys().next().value;
|
||||
if (firstKey !== undefined) cache.delete(firstKey);
|
||||
}
|
||||
return ordered;
|
||||
};
|
||||
|
||||
// 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);
|
||||
const [leadingActivitySection, setLeadingActivitySection] = React.useState<ActivitySectionKey>('chats');
|
||||
// Keep per-scroll measurements out of React state so the interaction guard
|
||||
// can read the current fade boundary without rerendering the sidebar.
|
||||
const topFadeSizeRef = React.useRef(0);
|
||||
// Update the compositor-owned mask on every scroll, but cross the React
|
||||
// Update the viewport-owned fade on every scroll, but cross the React
|
||||
// render boundary only when the sticky identity overlay appears or hides.
|
||||
const syncTopFade = React.useCallback((scroller: HTMLElement) => {
|
||||
const hasTopScroll = scroller.scrollTop > 1;
|
||||
@@ -171,8 +166,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
? Math.min(TOP_FADE_MIN_SIZE + scroller.scrollTop, TOP_FADE_MAX_SIZE)
|
||||
: 0;
|
||||
topFadeSizeRef.current = topFadeSize;
|
||||
scroller.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
|
||||
scroller.style.setProperty(
|
||||
const fadeRoot = scroller.closest<HTMLElement>('.oc-sticky-fade-root');
|
||||
fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
|
||||
fadeRoot?.style.setProperty(
|
||||
'--scroll-shadow-top-clear-size',
|
||||
`${Math.min(Math.max(topFadeSize - 8, 0), TOP_FADE_CLEAR_MAX_SIZE)}px`,
|
||||
);
|
||||
@@ -180,81 +176,55 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
const blockObscuredInteraction = React.useCallback((
|
||||
event: React.MouseEvent<HTMLDivElement> | React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
// SAFETY: React's mouse and pointer events are dispatched from Elements.
|
||||
if ((event.target as Element).closest('[data-overlay-scrollbar-thumb], [data-sidebar-sticky-header]')) return;
|
||||
const eventY = event.clientY - event.currentTarget.getBoundingClientRect().top;
|
||||
if (eventY >= topFadeSizeRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
const hasProjectScroller = props.projectSections.length > 0 && renderedProjectSections.length > 0;
|
||||
const renderedSections = selectRenderedProjectSections(
|
||||
model.sectionsForRender,
|
||||
model.singleProjectMode,
|
||||
model.singleProjectId,
|
||||
);
|
||||
const hasProjectScroller = model.projectSections.length > 0 && renderedSections.length > 0;
|
||||
React.useLayoutEffect(() => {
|
||||
if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) {
|
||||
syncTopFade(scrollContainerRef.current);
|
||||
}
|
||||
}, [enableStickyFade, hasProjectScroller, syncTopFade]);
|
||||
React.useEffect(() => {
|
||||
const root = scrollContainerRef.current;
|
||||
if (!enableStickyFade || !root || !props.hasSharedSessions) return;
|
||||
|
||||
const sentinels = Array.from(root.querySelectorAll<HTMLElement>('[data-sidebar-activity-sentinel]'));
|
||||
if (sentinels.length === 0) return;
|
||||
const stuckSections = new Set<ActivitySectionKey>();
|
||||
const syncLeadingSection = (): void => {
|
||||
let nextSection = sentinels[0] ? readActivitySectionKey(sentinels[0]) : null;
|
||||
for (const sentinel of sentinels) {
|
||||
const key = readActivitySectionKey(sentinel);
|
||||
if (key && stuckSections.has(key)) nextSection = key;
|
||||
}
|
||||
if (nextSection) setLeadingActivitySection((current) => current === nextSection ? current : nextSection);
|
||||
};
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const rootTop = root.getBoundingClientRect().top;
|
||||
for (const entry of entries) {
|
||||
const key = readActivitySectionKey(entry.target);
|
||||
if (!key) continue;
|
||||
if (!entry.isIntersecting && entry.boundingClientRect.top < (entry.rootBounds?.top ?? rootTop)) {
|
||||
stuckSections.add(key);
|
||||
} else {
|
||||
stuckSections.delete(key);
|
||||
}
|
||||
}
|
||||
syncLeadingSection();
|
||||
}, { root, threshold: 0 });
|
||||
sentinels.forEach((sentinel) => observer.observe(sentinel));
|
||||
syncLeadingSection();
|
||||
return () => observer.disconnect();
|
||||
}, [enableStickyFade, props.hasSharedSessions, props.topContent]);
|
||||
let stuckProject: ProjectSection['project'] | null = null;
|
||||
for (const section of props.projectSections) {
|
||||
if (props.stuckProjectHeaders.has(section.project.id)) {
|
||||
for (const section of model.projectSections) {
|
||||
if (model.stuckProjectHeaders.has(section.project.id)) {
|
||||
stuckProject = section.project;
|
||||
}
|
||||
}
|
||||
// The IntersectionObserver reports the stuck header asynchronously, a frame or
|
||||
// two after the (synchronous) mask has already hidden the real header — which
|
||||
// two after the synchronous fade has already hidden the real header — which
|
||||
// otherwise leaves a one-frame gap where the title blinks out with no crisp
|
||||
// replacement. Seed the overlay with the topmost rendered project so it is
|
||||
// ready in the same frame; the observer then corrects it. When shared sessions
|
||||
// lead the list, the Recent fallback below owns the top instead of a project.
|
||||
const leadingProject =
|
||||
stuckProject ?? (props.hasSharedSessions ? null : renderedProjectSections[0]?.project ?? null);
|
||||
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, props.homeDirectory) : null;
|
||||
stuckProject ?? (model.hasSharedSessions ? null : renderedSections[0]?.project ?? null);
|
||||
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null;
|
||||
const projectPickerOptions = React.useMemo(() => model.projectSections.map((section) => ({
|
||||
id: section.project.id,
|
||||
projectLabel: getProjectLabel(section.project, view.homeDirectory),
|
||||
projectDescription: formatPathForDisplay(section.project.normalizedPath, view.homeDirectory),
|
||||
projectIcon: section.project.icon,
|
||||
projectColor: section.project.color,
|
||||
projectIconImage: section.project.iconImage,
|
||||
projectIconBackground: section.project.iconBackground,
|
||||
})), [model.projectSections, view.homeDirectory]);
|
||||
|
||||
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 ? '' : '')}>
|
||||
{props.topContent}
|
||||
{!props.hasSharedSessions ? (props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState) : null}
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
if (model.projectSections.length === 0) {
|
||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className="space-y-1 pb-1 pl-2.5 pr-2">{model.topContent}{model.emptyState}</ScrollableOverlay>;
|
||||
}
|
||||
|
||||
if (props.projectSections.length === 0) {
|
||||
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 ? '' : '')}>{props.topContent}{props.emptyState}</ScrollableOverlay>;
|
||||
}
|
||||
|
||||
if (props.sectionsForRender.length === 0) {
|
||||
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 ? '' : '')}>{props.searchEmptyState}</ScrollableOverlay>;
|
||||
if (model.sectionsForRender.length === 0) {
|
||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className="space-y-1 pb-1 pl-2.5 pr-2">{model.searchEmptyState}</ScrollableOverlay>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -265,48 +235,37 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
// rows appear below naturally.
|
||||
<div
|
||||
className="oc-sticky-fade-root relative flex min-h-0 flex-1"
|
||||
// SAFETY: this custom property configures the viewport-owned edge fade.
|
||||
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
|
||||
onPointerDownCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||
onClickCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||
onContextMenuCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||
>
|
||||
<ScrollableOverlay
|
||||
ref={scrollContainerRef}
|
||||
useScrollShadow
|
||||
hideTopScrollShadow={!enableStickyFade}
|
||||
scrollShadowSize={96}
|
||||
outerClassName="flex-1 min-h-0"
|
||||
className={cn('oc-sidebar-scroller oc-sticky-fade-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', props.mobileVariant ? '' : '')}
|
||||
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
|
||||
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
|
||||
>
|
||||
{props.topContent}
|
||||
{props.showOnlyMainWorkspace ? (
|
||||
<ScrollableOverlay
|
||||
ref={scrollContainerRef}
|
||||
useScrollShadow
|
||||
hideTopScrollShadow={!enableStickyFade}
|
||||
scrollShadowSize={96}
|
||||
outerClassName="flex-1 min-h-0"
|
||||
className="oc-sidebar-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]"
|
||||
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
|
||||
>
|
||||
{model.topContent}
|
||||
{view.showOnlyMainWorkspace ? (
|
||||
<div className="space-y-[0.6rem] py-1">
|
||||
{(() => {
|
||||
const activeSection = props.sectionsForRender.find((section) => section.project.id === props.activeProjectId) ?? props.sectionsForRender[0];
|
||||
const activeSection = renderedSections.find((section) => section.project.id === model.activeProjectId) ?? renderedSections[0];
|
||||
if (!activeSection) {
|
||||
return props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState;
|
||||
return view.hasSessionSearchQuery ? model.searchEmptyState : model.emptyState;
|
||||
}
|
||||
const primaryGroup =
|
||||
activeSection.groups.find((candidate) => candidate.isMain && candidate.sessions.length > 0)
|
||||
?? activeSection.groups.find((candidate) => candidate.sessions.length > 0)
|
||||
?? activeSection.groups.find((candidate) => candidate.isMain)
|
||||
?? activeSection.groups[0];
|
||||
if (!primaryGroup) {
|
||||
const descriptors = buildGroupRenderDescriptors(activeSection, { mainWorkspaceOnly: true });
|
||||
if (!descriptors.length) {
|
||||
return <div className="py-1 text-left typography-micro text-muted-foreground">{t('sessions.sidebar.empty.noSessions.title')}</div>;
|
||||
}
|
||||
const archivedGroup = activeSection.groups.find((candidate) => candidate.isArchivedBucket);
|
||||
const groupsToRender = [
|
||||
primaryGroup,
|
||||
...(archivedGroup && archivedGroup.id !== primaryGroup.id ? [archivedGroup] : []),
|
||||
];
|
||||
|
||||
return groupsToRender.map((group) => {
|
||||
const groupKey = `${activeSection.project.id}:${group.id}`;
|
||||
const hideGroupLabel = group.id === primaryGroup.id;
|
||||
return descriptors.map(({ group, groupKey, projectId, hideGroupLabel }) => {
|
||||
return (
|
||||
<React.Fragment key={groupKey}>
|
||||
{props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel, null, true, scrollContainerRef)}
|
||||
<SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={group} groupKey={groupKey} projectId={projectId} hideGroupLabel={hideGroupLabel} visibleSessionCount={model.state.visibleSessionCountByGroup.get(groupKey)} compactBodyPadding scrollContainerRef={scrollContainerRef} />
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
@@ -317,31 +276,31 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
sensors={projectSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
if (props.isInlineEditing) return;
|
||||
if (isInlineEditing) return;
|
||||
// Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes
|
||||
if (props.projectSortOrder !== 'manual') return;
|
||||
if (view.projectSortOrder !== 'manual') return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id);
|
||||
const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id);
|
||||
const oldIndex = model.sectionsForRender.findIndex((section) => section.project.id === active.id);
|
||||
const newIndex = model.sectionsForRender.findIndex((section) => section.project.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
props.reorderProjects(oldIndex, newIndex);
|
||||
actions.reorderProjects(oldIndex, newIndex);
|
||||
}}
|
||||
>
|
||||
<SortableContext items={renderedProjectSections.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||
{renderedProjectSections.map((section) => {
|
||||
<SortableContext items={renderedSections.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||
{renderedSections.map((section) => {
|
||||
const project = section.project;
|
||||
const projectKey = project.id;
|
||||
const projectLabel = getProjectLabel(project, props.homeDirectory);
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
||||
const isCollapsed = props.singleProjectMode ? false : props.collapsedProjects.has(projectKey);
|
||||
const isRepo = props.projectRepoStatus.get(projectKey);
|
||||
const projectLabel = getProjectLabel(project, view.homeDirectory);
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, view.homeDirectory);
|
||||
const isCollapsed = model.singleProjectMode ? false : view.collapsedProjects.has(projectKey);
|
||||
const isRepo = model.projectRepoStatus.get(projectKey);
|
||||
|
||||
return (
|
||||
<SortableProjectItem
|
||||
key={projectKey}
|
||||
id={projectKey}
|
||||
disabled={props.singleProjectMode || props.projectSortOrder !== 'manual'}
|
||||
disabled={model.singleProjectMode || view.projectSortOrder !== 'manual'}
|
||||
projectLabel={projectLabel}
|
||||
projectDescription={projectDescription}
|
||||
projectIcon={project.icon}
|
||||
@@ -350,40 +309,38 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
projectIconBackground={project.iconBackground}
|
||||
isCollapsed={isCollapsed}
|
||||
isRepo={Boolean(isRepo)}
|
||||
isDesktopShell={props.isDesktopShellRuntime}
|
||||
hideDirectoryControls={props.hideDirectoryControls}
|
||||
mobileVariant={props.mobileVariant}
|
||||
alwaysShowActions={props.alwaysShowActions}
|
||||
statusIndicator={isCollapsed ? props.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
|
||||
onToggle={() => {
|
||||
if (!props.singleProjectMode) props.toggleProject(projectKey);
|
||||
}}
|
||||
isDesktopShell={view.isDesktopShellRuntime}
|
||||
hideDirectoryControls={view.hideDirectoryControls}
|
||||
mobileVariant={view.mobileVariant}
|
||||
alwaysShowActions={view.alwaysShowActions}
|
||||
statusIndicator={isCollapsed ? actions.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
|
||||
openSidebarMenuKey={model.state.openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey}
|
||||
projectPickerOptions={model.singleProjectMode ? projectPickerOptions : undefined}
|
||||
onProjectSelect={model.singleProjectMode ? actions.setSingleProjectId : undefined}
|
||||
onToggle={() => { if (!model.singleProjectMode) actions.toggleProject(projectKey); }}
|
||||
onNewSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||||
props.openNewSessionDraft({
|
||||
if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
|
||||
if (view.mobileVariant) actions.setSessionSwitcherOpen(false);
|
||||
actions.openNewSessionDraft({
|
||||
selectedProjectId: projectKey,
|
||||
directoryOverride: project.normalizedPath,
|
||||
});
|
||||
}}
|
||||
onNewWorktreeSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.openNewWorktreeDialog();
|
||||
if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
|
||||
actions.openNewWorktreeDialog();
|
||||
}}
|
||||
onManageWorktrees={() => props.openWorktreesPage(projectKey)}
|
||||
onRenameStart={() => props.openProjectEditDialog(projectKey)}
|
||||
onClose={() => props.removeProject(projectKey)}
|
||||
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
onManageWorktrees={() => actions.openWorktreesPage(projectKey)}
|
||||
onRenameStart={() => actions.openProjectEditDialog(projectKey)}
|
||||
onClose={() => actions.removeProject(projectKey)}
|
||||
sentinelRef={(el) => { model.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
showCreateButtons
|
||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||
projectPickerOptions={props.singleProjectMode ? projectPickerOptions : undefined}
|
||||
onProjectSelect={props.singleProjectMode ? props.setSingleProjectId : undefined}
|
||||
>
|
||||
>
|
||||
{!isCollapsed ? (
|
||||
<div className="space-y-0 pt-0.5 pb-0.5">
|
||||
{(() => {
|
||||
const orderedGroups = cachedGetOrderedGroups(projectKey, section.groups);
|
||||
const orderedGroups = section.groups;
|
||||
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
|
||||
const nestedGroups = rootGroup
|
||||
? orderedGroups.filter((group) => group.id !== rootGroup.id)
|
||||
@@ -393,7 +350,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
sensors={groupSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
if (props.isInlineEditing) return;
|
||||
if (isInlineEditing) return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = nestedGroups.findIndex((item) => item.id === active.id);
|
||||
@@ -401,7 +358,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
const nextNested = arrayMove(nestedGroups, oldIndex, newIndex).map((item) => item.id);
|
||||
const next = rootGroup ? [rootGroup.id, ...nextNested] : nextNested;
|
||||
props.setGroupOrderByProject((prev) => {
|
||||
actions.setGroupOrderByProject((prev) => {
|
||||
const map = new Map(prev);
|
||||
map.set(projectKey, next);
|
||||
return map;
|
||||
@@ -411,13 +368,13 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
{/* Root/flat sessions render directly under the
|
||||
project zone header; worktree and archived
|
||||
groups keep their own slim sortable sub-header. */}
|
||||
{rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true, null, undefined, scrollContainerRef) : null}
|
||||
{rootGroup ? <SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={rootGroup} groupKey={`${projectKey}:${rootGroup.id}`} projectId={projectKey} hideGroupLabel visibleSessionCount={model.state.visibleSessionCountByGroup.get(`${projectKey}:${rootGroup.id}`)} scrollContainerRef={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, undefined, scrollContainerRef)}
|
||||
<SortableGroupItem key={group.id} id={group.id} disabled={isInlineEditing}>
|
||||
{(dragHandleProps) => <SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={group} groupKey={groupKey} projectId={projectKey} visibleSessionCount={model.state.visibleSessionCountByGroup.get(groupKey)} dragHandleProps={dragHandleProps} scrollContainerRef={scrollContainerRef} />}
|
||||
</SortableGroupItem>
|
||||
);
|
||||
})}
|
||||
@@ -435,15 +392,15 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
{enableStickyFade && (leadingProject || props.hasSharedSessions) ? (
|
||||
</ScrollableOverlay>
|
||||
{enableStickyFade && (leadingProject || model.hasSharedSessions) ? (
|
||||
<div
|
||||
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-1.5 py-1 pl-4 pr-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{leadingProject && leadingProjectLabel ? (
|
||||
<ProjectHeaderIdentity
|
||||
id={leadingProject.id}
|
||||
id={leadingProject.id}
|
||||
projectLabel={leadingProjectLabel}
|
||||
projectIcon={leadingProject.icon}
|
||||
projectColor={leadingProject.color}
|
||||
@@ -452,11 +409,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Icon name={leadingActivitySection === 'chats' ? 'chat-4' : 'history'} className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80" />
|
||||
<Icon name="history" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80" />
|
||||
<span className="truncate text-[14px] font-semibold lowercase text-foreground">
|
||||
{t(leadingActivitySection === 'chats'
|
||||
? 'sessions.sidebar.activity.chatsTitle'
|
||||
: 'sessions.sidebar.activity.recentTitle')}
|
||||
{t('sessions.sidebar.activity.recentTitle')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -466,4 +421,4 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
export const SidebarProjectsList = React.memo(SidebarProjectsListComponent);
|
||||
export const SessionProjectScroller = React.memo(SessionProjectScrollerComponent);
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { SessionGroup } from '../types';
|
||||
|
||||
export type ProjectSection = {
|
||||
project: {
|
||||
id: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||
iconBackground?: string;
|
||||
};
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
export const selectRenderedProjectSections = (
|
||||
sections: ProjectSection[],
|
||||
singleProjectMode: boolean,
|
||||
singleProjectId: string | null,
|
||||
): ProjectSection[] => singleProjectMode
|
||||
? sections.filter((section) => section.project.id === singleProjectId)
|
||||
: sections;
|
||||
|
||||
type GroupRenderDescriptor = {
|
||||
group: SessionGroup;
|
||||
groupKey: string;
|
||||
projectId: string;
|
||||
hideGroupLabel: boolean;
|
||||
};
|
||||
|
||||
export const buildGroupRenderDescriptors = (
|
||||
section: ProjectSection,
|
||||
options: { mainWorkspaceOnly: boolean },
|
||||
): GroupRenderDescriptor[] => {
|
||||
const primaryGroup = section.groups.find((group) => group.isMain && group.sessions.length > 0)
|
||||
?? section.groups.find((group) => group.sessions.length > 0)
|
||||
?? section.groups.find((group) => group.isMain)
|
||||
?? section.groups[0];
|
||||
if (!primaryGroup) return [];
|
||||
|
||||
const archivedGroup = section.groups.find((group) => group.isArchivedBucket && group.id !== primaryGroup.id);
|
||||
const groups = options.mainWorkspaceOnly
|
||||
? [primaryGroup, ...(archivedGroup ? [archivedGroup] : [])]
|
||||
: [
|
||||
...(section.groups.find((group) => group.isMain) ? [section.groups.find((group) => group.isMain)!] : []),
|
||||
...section.groups.filter((group) => !group.isMain),
|
||||
];
|
||||
|
||||
return groups.map((group) => ({
|
||||
group,
|
||||
groupKey: `${section.project.id}:${group.id}`,
|
||||
projectId: section.project.id,
|
||||
hideGroupLabel: options.mainWorkspaceOnly ? group.id === primaryGroup.id : group.isMain,
|
||||
}));
|
||||
};
|
||||
+46
-80
@@ -30,15 +30,13 @@ type ProjectIdentityProps = {
|
||||
projectIconBackground?: string;
|
||||
};
|
||||
|
||||
type ProjectPickerOption = ProjectIdentityProps & {
|
||||
projectDescription: string;
|
||||
};
|
||||
|
||||
type ProjectHeaderIdentityProps = ProjectIdentityProps & {
|
||||
isCollapsed?: boolean;
|
||||
alwaysShowActions?: boolean;
|
||||
};
|
||||
|
||||
type ProjectPickerOption = ProjectIdentityProps & { projectDescription: string };
|
||||
|
||||
export const ProjectHeaderIdentity: React.FC<ProjectHeaderIdentityProps> = ({
|
||||
id,
|
||||
projectLabel,
|
||||
@@ -121,10 +119,10 @@ export interface SortableProjectItemProps extends ProjectIdentityProps {
|
||||
children?: React.ReactNode;
|
||||
showCreateButtons?: boolean;
|
||||
hideHeader?: boolean;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
/** Aggregated activity/attention indicator shown while the project is collapsed. */
|
||||
statusIndicator?: React.ReactNode;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
projectPickerOptions?: ProjectPickerOption[];
|
||||
onProjectSelect?: (projectId: string) => void;
|
||||
}
|
||||
@@ -153,9 +151,9 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
children,
|
||||
showCreateButtons = true,
|
||||
hideHeader = false,
|
||||
statusIndicator = null,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
statusIndicator = null,
|
||||
projectPickerOptions,
|
||||
onProjectSelect,
|
||||
}) => {
|
||||
@@ -174,6 +172,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
const menuInstanceKey = `project:${id}`;
|
||||
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
|
||||
const isProjectPicker = Boolean(projectPickerOptions && onProjectSelect);
|
||||
|
||||
const handleMenuOpenChange = React.useCallback((open: boolean) => {
|
||||
if (open) setIsContextMenuOpen(false);
|
||||
@@ -235,7 +234,6 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
}
|
||||
onToggle();
|
||||
}, [onToggle]);
|
||||
const isProjectPicker = Boolean(projectPickerOptions && onProjectSelect);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -287,87 +285,55 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={projectDescription}
|
||||
className={cn(
|
||||
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md transition-[padding]',
|
||||
isRepo && !hideDirectoryControls
|
||||
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
|
||||
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
|
||||
)}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('sessions.sidebar.project.selectAria', { project: projectLabel })}
|
||||
>
|
||||
<ProjectHeaderIdentity
|
||||
id={id}
|
||||
projectLabel={projectLabel}
|
||||
projectIcon={projectIcon}
|
||||
projectColor={projectColor}
|
||||
projectIconImage={projectIconImage}
|
||||
projectIconBackground={projectIconBackground}
|
||||
/>
|
||||
<ProjectHeaderIdentity id={id} projectLabel={projectLabel} projectIcon={projectIcon} projectColor={projectColor} projectIconImage={projectIconImage} projectIconBackground={projectIconBackground} />
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="min-w-[220px] max-w-[calc(100vw-2rem)] max-h-[min(var(--available-height),70vh)] overflow-y-auto overscroll-contain"
|
||||
>
|
||||
<DropdownMenuContent align="start" className="max-h-[70vh] min-w-[220px] overflow-y-auto">
|
||||
{projectPickerOptions?.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onClick={() => onProjectSelect?.(option.id)}
|
||||
className="flex items-center justify-between gap-3"
|
||||
title={option.projectDescription}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ProjectHeaderIdentity
|
||||
id={option.id}
|
||||
projectLabel={option.projectLabel}
|
||||
projectIcon={option.projectIcon}
|
||||
projectColor={option.projectColor}
|
||||
projectIconImage={option.projectIconImage}
|
||||
projectIconBackground={option.projectIconBackground}
|
||||
/>
|
||||
</span>
|
||||
<DropdownMenuItem key={option.id} onClick={() => onProjectSelect?.(option.id)} className="flex items-center justify-between gap-3" title={option.projectDescription}>
|
||||
<ProjectHeaderIdentity {...option} />
|
||||
{option.id === id ? <Icon name="check" className="h-4 w-4 flex-shrink-0 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Tooltip delayDuration={800}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={handleToggleMouseDown}
|
||||
onClick={handleToggleClick}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
|
||||
isRepo && !hideDirectoryControls
|
||||
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
|
||||
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
|
||||
)}
|
||||
>
|
||||
<ProjectHeaderIdentity
|
||||
id={id}
|
||||
projectLabel={projectLabel}
|
||||
projectIcon={projectIcon}
|
||||
projectColor={projectColor}
|
||||
projectIconImage={projectIconImage}
|
||||
projectIconBackground={projectIconBackground}
|
||||
isCollapsed={isCollapsed}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
/>
|
||||
{statusIndicator ? (
|
||||
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{projectDescription}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
) : <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={handleToggleMouseDown}
|
||||
onClick={handleToggleClick}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
|
||||
isRepo && !hideDirectoryControls
|
||||
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
|
||||
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
|
||||
)}
|
||||
>
|
||||
<ProjectHeaderIdentity
|
||||
id={id}
|
||||
projectLabel={projectLabel}
|
||||
projectIcon={projectIcon}
|
||||
projectColor={projectColor}
|
||||
projectIconImage={projectIconImage}
|
||||
projectIconBackground={projectIconBackground}
|
||||
isCollapsed={isCollapsed}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
/>
|
||||
{statusIndicator ? (
|
||||
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{projectDescription}
|
||||
</TooltipContent>
|
||||
</Tooltip>}
|
||||
|
||||
<div className={cn(
|
||||
'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1',
|
||||
@@ -474,7 +440,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
const SortableGroupItemBase: React.FC<{
|
||||
id: string;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode | ((dragHandleProps: SortableDragHandleProps) => React.ReactNode);
|
||||
children: (dragHandleProps: SortableDragHandleProps) => React.ReactNode;
|
||||
}> = ({ id, disabled = false, children }) => {
|
||||
const {
|
||||
listeners,
|
||||
@@ -502,7 +468,7 @@ const SortableGroupItemBase: React.FC<{
|
||||
isDragging && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{typeof children === 'function' ? children(dragHandleProps) : children}
|
||||
{children(dragHandleProps)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { SessionOwnershipIndex } from '../sessionOwnership';
|
||||
import type { SessionOwnershipIndex } from '../sessions/sessionOwnership';
|
||||
|
||||
type Args = {
|
||||
ownership: SessionOwnershipIndex;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { useSessionActions } from '../sessions/useSessionActions';
|
||||
import { useSessionGrouping } from './useSessionGrouping';
|
||||
import type { SessionNode } from '../types';
|
||||
|
||||
type FixtureSession = Session & { parentID?: string };
|
||||
const session = (id: string, parentID?: string): Session => {
|
||||
const value: FixtureSession = {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: 'project',
|
||||
title: id,
|
||||
version: '1',
|
||||
directory: '/workspace',
|
||||
time: { created: 1, updated: 1 },
|
||||
};
|
||||
if (parentID) value.parentID = parentID;
|
||||
return value;
|
||||
};
|
||||
|
||||
const collectIds = (nodes: SessionNode[]): string[] => {
|
||||
const ids: string[] = [];
|
||||
const visit = (items: SessionNode[]): void => {
|
||||
for (const node of items) {
|
||||
ids.push(node.session.id);
|
||||
visit(node.children);
|
||||
}
|
||||
};
|
||||
visit(nodes);
|
||||
return ids;
|
||||
};
|
||||
|
||||
describe('useSessionGrouping malformed hierarchy fallbacks', () => {
|
||||
test('renders a deterministic cycle/orphan fallback tree without duplicate sessions', async () => {
|
||||
type GroupingCapture = { buildGroupedSessions?: ReturnType<typeof useSessionGrouping>['buildGroupedSessions'] };
|
||||
const state: GroupingCapture = {};
|
||||
const Harness = () => {
|
||||
state.buildGroupedSessions = useSessionGrouping({
|
||||
homeDirectory: null,
|
||||
worktreeMetadata: new Map(),
|
||||
pinnedSessionIds: new Set(),
|
||||
sessionOrderRanks: new Map(),
|
||||
gitBranches: new Map(),
|
||||
isVSCode: false,
|
||||
}).buildGroupedSessions;
|
||||
return null;
|
||||
};
|
||||
|
||||
renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(Harness)));
|
||||
const buildGroupedSessions = state.buildGroupedSessions;
|
||||
if (!buildGroupedSessions) throw new Error('grouping callback was not mounted');
|
||||
|
||||
const groups = buildGroupedSessions(
|
||||
[session('a', 'b'), session('b', 'a'), session('orphan', 'missing')],
|
||||
'/workspace',
|
||||
[],
|
||||
null,
|
||||
false,
|
||||
);
|
||||
const rootGroup = groups.find((group) => group.isMain);
|
||||
const ids = collectIds(rootGroup?.sessions ?? []);
|
||||
|
||||
expect(ids).toEqual(['orphan', 'a', 'b']);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
test('uses the row-local descendant snapshot for archive and hard-delete actions', async () => {
|
||||
type ActionsCapture = { handleDeleteSession?: ReturnType<typeof useSessionActions>['handleDeleteSession'] };
|
||||
const state: ActionsCapture = {};
|
||||
const Harness = () => {
|
||||
state.handleDeleteSession = useSessionActions({
|
||||
mobileVariant: false,
|
||||
allowReselect: false,
|
||||
isSessionSearchOpen: false,
|
||||
sessionSearchQuery: '',
|
||||
setSessionSearchQuery: () => undefined,
|
||||
setIsSessionSearchOpen: () => undefined,
|
||||
descendantIds: ['active-child', 'archived-child'],
|
||||
showDeletionDialog: false,
|
||||
setDeleteSessionConfirm: () => undefined,
|
||||
deleteSessionConfirm: null,
|
||||
setEditingId: () => undefined,
|
||||
setEditTitle: () => undefined,
|
||||
editingId: null,
|
||||
editTitle: '',
|
||||
copiedSessionId: null,
|
||||
setCopiedSessionId: () => undefined,
|
||||
}).handleDeleteSession;
|
||||
return null;
|
||||
};
|
||||
|
||||
renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(Harness)));
|
||||
const handleDeleteSession = state.handleDeleteSession;
|
||||
if (!handleDeleteSession) throw new Error('session actions callback was not mounted');
|
||||
|
||||
handleDeleteSession(session('root'));
|
||||
handleDeleteSession(session('root'), { hardDelete: true });
|
||||
});
|
||||
});
|
||||
+27
-11
@@ -9,11 +9,11 @@ import {
|
||||
normalizeForBranchComparison,
|
||||
normalizePath,
|
||||
} from '../utils';
|
||||
import { compareSessionsByLifecycleOrder, getSessionLifecycleOrderValue } from '@/sync/session-ordering';
|
||||
import { getSessionLifecycleOrderValue } from '@/sync/session-ordering';
|
||||
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { getWorktreeFirstSeenAt } from '../worktreeFirstSeen';
|
||||
import { getWorktreeFirstSeenAt } from './worktreeFirstSeen';
|
||||
|
||||
type Args = {
|
||||
homeDirectory: string | null;
|
||||
@@ -70,8 +70,9 @@ export const useSessionGrouping = (args: Args) => {
|
||||
projectIsRepo: boolean,
|
||||
) => {
|
||||
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
|
||||
const sortedProjectSessions = dedupeSessionsById(projectSessions)
|
||||
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, args.pinnedSessionIds, args.sessionOrderRanks));
|
||||
// `orderSessionsByLifecycleScopes` owns lifecycle ordering before project
|
||||
// ownership buckets are built. Dedupe retains that root/sibling order.
|
||||
const sortedProjectSessions = dedupeSessionsById(projectSessions);
|
||||
|
||||
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
|
||||
const childrenMap = new Map<string, Session[]>();
|
||||
@@ -86,7 +87,6 @@ export const useSessionGrouping = (args: Args) => {
|
||||
collection.push(session);
|
||||
childrenMap.set(parentID, collection);
|
||||
});
|
||||
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, args.pinnedSessionIds, args.sessionOrderRanks)));
|
||||
|
||||
const worktreeByPath = new Map<string, WorktreeMetadata>();
|
||||
availableWorktrees.forEach((meta) => {
|
||||
@@ -109,12 +109,19 @@ export const useSessionGrouping = (args: Args) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const claimedSessionIds = new Set<string>();
|
||||
const buildProjectNode = (session: Session): SessionNode => {
|
||||
claimedSessionIds.add(session.id);
|
||||
const children = childrenMap.get(session.id) ?? [];
|
||||
return { session, children: children.map((child) => buildProjectNode(child)), worktree: getSessionWorktree(session) };
|
||||
const childNodes: SessionNode[] = [];
|
||||
for (const child of children) {
|
||||
if (claimedSessionIds.has(child.id)) continue;
|
||||
childNodes.push(buildProjectNode(child));
|
||||
}
|
||||
return { session, children: childNodes, worktree: getSessionWorktree(session) };
|
||||
};
|
||||
|
||||
const roots = sortedProjectSessions.filter((session) => {
|
||||
const rootCandidates = sortedProjectSessions.filter((session) => {
|
||||
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||
if (!parentID) return true;
|
||||
const parentSession = sessionMap.get(parentID);
|
||||
@@ -122,6 +129,16 @@ export const useSessionGrouping = (args: Args) => {
|
||||
return isArchivedSession(parentSession) !== isArchivedSession(session);
|
||||
});
|
||||
|
||||
// A malformed cycle has no structural root. Start with normal roots,
|
||||
// then expose each still-unclaimed component from its first input row.
|
||||
const roots: SessionNode[] = [];
|
||||
const addRoot = (session: Session): void => {
|
||||
if (claimedSessionIds.has(session.id)) return;
|
||||
roots.push(buildProjectNode(session));
|
||||
};
|
||||
rootCandidates.forEach(addRoot);
|
||||
sortedProjectSessions.forEach(addRoot);
|
||||
|
||||
const groupedNodes = new Map<string, SessionNode[]>();
|
||||
const archivedKey = '__archived__';
|
||||
|
||||
@@ -140,9 +157,8 @@ export const useSessionGrouping = (args: Args) => {
|
||||
return archivedKey;
|
||||
};
|
||||
|
||||
roots.forEach((session) => {
|
||||
const node = buildProjectNode(session);
|
||||
const groupKey = getGroupKey(session);
|
||||
roots.forEach((node) => {
|
||||
const groupKey = getGroupKey(node.session);
|
||||
if (!groupedNodes.has(groupKey)) groupedNodes.set(groupKey, []);
|
||||
groupedNodes.get(groupKey)?.push(node);
|
||||
});
|
||||
@@ -258,7 +274,7 @@ export const useSessionGrouping = (args: Args) => {
|
||||
|
||||
return groups;
|
||||
},
|
||||
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
|
||||
[args.homeDirectory, args.worktreeMetadata, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
|
||||
);
|
||||
|
||||
return {
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { useSessionProjectViewState } from './useSessionProjectViewState';
|
||||
|
||||
class ElementStub implements Partial<Element> {
|
||||
nodeType = 1;
|
||||
}
|
||||
type DocumentStub = {
|
||||
nodeType: number;
|
||||
defaultView: typeof globalThis;
|
||||
activeElement: null;
|
||||
addEventListener: () => void;
|
||||
removeEventListener: () => void;
|
||||
documentElement?: Element;
|
||||
body?: Element;
|
||||
};
|
||||
type GlobalValue = typeof globalThis | typeof ElementStub | DocumentStub | boolean;
|
||||
type HookCapture = {
|
||||
state?: ReturnType<typeof useSessionProjectViewState>['state'];
|
||||
actions?: ReturnType<typeof useSessionProjectViewState>['actions'];
|
||||
renderCount: number;
|
||||
};
|
||||
|
||||
const installMinimalDom = () => {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const setGlobal = (name: string, value: GlobalValue) => {
|
||||
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
const documentStub: DocumentStub = {
|
||||
nodeType: 9,
|
||||
defaultView: globalThis,
|
||||
activeElement: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
// SAFETY: React's test renderer only inspects this fixture's DOM identity fields and listeners.
|
||||
const container = Object.create(ElementStub.prototype) as Element;
|
||||
Object.assign(container, {
|
||||
nodeType: 1,
|
||||
tagName: 'DIV',
|
||||
nodeName: 'DIV',
|
||||
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
||||
ownerDocument: documentStub,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
});
|
||||
documentStub.documentElement = container;
|
||||
documentStub.body = container;
|
||||
setGlobal('document', documentStub);
|
||||
setGlobal('window', globalThis);
|
||||
setGlobal('Element', ElementStub);
|
||||
setGlobal('HTMLElement', ElementStub);
|
||||
setGlobal('HTMLIFrameElement', ElementStub);
|
||||
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
return {
|
||||
container,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('useSessionProjectViewState', () => {
|
||||
beforeEach(() => {
|
||||
const storage = getDeferredSafeStorage();
|
||||
storage.removeItem('oc.sessions.projectCollapse');
|
||||
storage.removeItem('oc.sessions.groupCollapse');
|
||||
storage.removeItem('oc.sessions.groupOrder');
|
||||
});
|
||||
|
||||
test('keeps stable state/actions and ignores selection-store updates', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const capture: HookCapture = { renderCount: 0 };
|
||||
const projects = [{ id: 'project-a' }, { id: 'project-b' }];
|
||||
const Harness = () => {
|
||||
capture.renderCount += 1;
|
||||
const viewState = useSessionProjectViewState({ isVSCode: true, projects });
|
||||
capture.state = viewState.state;
|
||||
capture.actions = viewState.actions;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
const initialState = capture.state;
|
||||
const initialActions = capture.actions;
|
||||
const initialRenderCount = capture.renderCount;
|
||||
if (!initialState || !initialActions) throw new Error('hook did not mount');
|
||||
|
||||
await act(async () => {
|
||||
useSessionUIStore.setState({ currentSessionId: 'selection-only' });
|
||||
});
|
||||
expect(capture.renderCount).toBe(initialRenderCount);
|
||||
expect(capture.state).toBe(initialState);
|
||||
expect(capture.actions).toBe(initialActions);
|
||||
|
||||
await act(async () => initialActions.toggleProject('project-a'));
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
|
||||
await act(async () => initialActions.collapseAllProjects());
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a', 'project-b']));
|
||||
await act(async () => initialActions.expandAllProjects());
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set());
|
||||
|
||||
await act(async () => initialActions.toggleGroup('project-a:group-a'));
|
||||
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
|
||||
|
||||
await act(async () => {
|
||||
initialActions.setGroupOrderByProject((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set('project-a', ['group-b', 'group-a']);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
const group = (id: string): SessionGroup => ({
|
||||
id,
|
||||
label: id,
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: false,
|
||||
worktree: null,
|
||||
directory: null,
|
||||
sessions: [],
|
||||
});
|
||||
expect(capture.actions?.getOrderedGroups('project-a', [group('group-a'), group('group-b')])
|
||||
.map((item) => item.id)).toEqual(['group-b', 'group-a']);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const storage = getDeferredSafeStorage();
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.projectCollapse') ?? 'null')).toEqual([]);
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.groupCollapse') ?? 'null')).toEqual(['project-a:group-a']);
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.groupOrder') ?? 'null')).toEqual({
|
||||
'project-a': ['group-b', 'group-a'],
|
||||
});
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves malformed group storage until explicit user mutation', async () => {
|
||||
const storage = getDeferredSafeStorage();
|
||||
const malformedCollapse = '{malformed-collapse';
|
||||
const malformedOrder = JSON.stringify({ 'project-a': ['group-a', 2] });
|
||||
storage.setItem('oc.sessions.groupCollapse', malformedCollapse);
|
||||
storage.setItem('oc.sessions.groupOrder', malformedOrder);
|
||||
const dom = installMinimalDom();
|
||||
const root = createRoot(dom.container);
|
||||
const capture: HookCapture = { renderCount: 0 };
|
||||
const Harness = () => {
|
||||
capture.renderCount += 1;
|
||||
const value = useSessionProjectViewState({ isVSCode: true, projects: [{ id: 'project-a' }] });
|
||||
capture.state = value.state;
|
||||
capture.actions = value.actions;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(capture.state?.collapsedGroups).toEqual(new Set());
|
||||
expect(capture.state?.groupOrderByProject).toEqual(new Map());
|
||||
expect(storage.getItem('oc.sessions.groupCollapse')).toBe(malformedCollapse);
|
||||
expect(storage.getItem('oc.sessions.groupOrder')).toBe(malformedOrder);
|
||||
|
||||
await act(async () => capture.actions!.toggleGroup('project-a:group-a'));
|
||||
await act(async () => capture.actions!.setGroupOrderByProject(new Map([['project-a', ['group-a']]])));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.groupCollapse') ?? 'null')).toEqual(['project-a:group-a']);
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.groupOrder') ?? 'null')).toEqual({ 'project-a': ['group-a'] });
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('retains persisted project/group state while hidden and across a full remount', async () => {
|
||||
const storage = getDeferredSafeStorage();
|
||||
storage.setItem('oc.sessions.projectCollapse', JSON.stringify(['project-a']));
|
||||
storage.setItem('oc.sessions.groupCollapse', JSON.stringify(['project-a:group-a']));
|
||||
storage.setItem('oc.sessions.groupOrder', JSON.stringify({ 'project-a': ['group-b', 'group-a'] }));
|
||||
const dom = installMinimalDom();
|
||||
const root = createRoot(dom.container);
|
||||
const capture: HookCapture = { renderCount: 0 };
|
||||
const Harness = ({ hidden }: { hidden: boolean }) => {
|
||||
void hidden;
|
||||
capture.renderCount += 1;
|
||||
const value = useSessionProjectViewState({ isVSCode: true, projects: [{ id: 'project-a' }] });
|
||||
capture.state = value.state;
|
||||
capture.actions = value.actions;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
|
||||
await act(async () => root.render(React.createElement(Harness, { hidden: true })));
|
||||
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
|
||||
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
|
||||
expect(capture.state?.groupOrderByProject).toEqual(new Map([['project-a', ['group-b', 'group-a']]]));
|
||||
|
||||
await act(async () => root.render(null));
|
||||
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
|
||||
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
|
||||
expect(capture.state?.groupOrderByProject).toEqual(new Map([['project-a', ['group-b', 'group-a']]]));
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { z } from 'zod';
|
||||
import { useGroupOrdering } from './useGroupOrdering';
|
||||
|
||||
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
||||
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
||||
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
|
||||
|
||||
type Project = { id: string };
|
||||
|
||||
type SessionProjectViewStateArgs = {
|
||||
isVSCode: boolean;
|
||||
projects: readonly Project[];
|
||||
};
|
||||
|
||||
const parseStringSet = (raw: string | null): Set<string> => {
|
||||
if (!raw) return new Set();
|
||||
try {
|
||||
const parsed = z.array(z.string()).safeParse(JSON.parse(raw));
|
||||
return new Set(parsed.success ? parsed.data : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const parseGroupOrder = (raw: string | null): Map<string, string[]> => {
|
||||
if (!raw) return new Map();
|
||||
try {
|
||||
const parsed = z.record(z.string(), z.array(z.string())).safeParse(JSON.parse(raw));
|
||||
if (!parsed.success) return new Map();
|
||||
const next = new Map<string, string[]>();
|
||||
for (const [projectId, order] of Object.entries(parsed.data)) {
|
||||
next.set(projectId, order);
|
||||
}
|
||||
return next;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
};
|
||||
|
||||
export const useSessionProjectViewState = ({
|
||||
isVSCode,
|
||||
projects,
|
||||
}: SessionProjectViewStateArgs) => {
|
||||
const safeStorage = React.useMemo(() => getDeferredSafeStorage(), []);
|
||||
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(() => (
|
||||
parseStringSet(safeStorage.getItem(PROJECT_COLLAPSE_STORAGE_KEY))
|
||||
));
|
||||
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => (
|
||||
parseStringSet(safeStorage.getItem(GROUP_COLLAPSE_STORAGE_KEY))
|
||||
));
|
||||
const [groupOrderByProject, setGroupOrderByProject] = React.useState<Map<string, string[]>>(() => (
|
||||
parseGroupOrder(safeStorage.getItem(GROUP_ORDER_STORAGE_KEY))
|
||||
));
|
||||
const ignoreIntersectionUntil = React.useRef<number>(0);
|
||||
const groupCollapseDirty = React.useRef(false);
|
||||
const groupOrderDirty = React.useRef(false);
|
||||
const persistCollapsedProjectsTimer = React.useRef<number | null>(null);
|
||||
const pendingCollapsedProjects = React.useRef<Set<string> | null>(null);
|
||||
|
||||
const flushCollapsedProjectsPersist = React.useCallback(() => {
|
||||
if (isVSCode) return;
|
||||
const collapsed = pendingCollapsedProjects.current;
|
||||
pendingCollapsedProjects.current = null;
|
||||
persistCollapsedProjectsTimer.current = null;
|
||||
if (!collapsed) return;
|
||||
|
||||
const { projects: storedProjects } = useProjectsStore.getState();
|
||||
const updatedProjects = storedProjects.map((project) => ({
|
||||
...project,
|
||||
sidebarCollapsed: collapsed.has(project.id),
|
||||
}));
|
||||
void updateDesktopSettings({ projects: updatedProjects }).catch(() => {});
|
||||
}, [isVSCode]);
|
||||
|
||||
const scheduleCollapsedProjectsPersist = React.useCallback((collapsed: Set<string>) => {
|
||||
if (!globalThis.window || isVSCode) return;
|
||||
pendingCollapsedProjects.current = collapsed;
|
||||
if (persistCollapsedProjectsTimer.current !== null) {
|
||||
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
||||
}
|
||||
persistCollapsedProjectsTimer.current = window.setTimeout(() => {
|
||||
flushCollapsedProjectsPersist();
|
||||
}, 700);
|
||||
}, [flushCollapsedProjectsPersist, isVSCode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (globalThis.window && persistCollapsedProjectsTimer.current !== null) {
|
||||
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
||||
}
|
||||
persistCollapsedProjectsTimer.current = null;
|
||||
pendingCollapsedProjects.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!groupOrderDirty.current) return;
|
||||
try {
|
||||
safeStorage.setItem(GROUP_ORDER_STORAGE_KEY, JSON.stringify(Object.fromEntries(groupOrderByProject.entries())));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [groupOrderByProject, safeStorage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!groupCollapseDirty.current) return;
|
||||
try {
|
||||
safeStorage.setItem(GROUP_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(collapsedGroups)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [collapsedGroups, safeStorage]);
|
||||
|
||||
const collapseAllProjects = React.useCallback(() => {
|
||||
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||
groupCollapseDirty.current = true;
|
||||
setCollapsedGroups(new Set());
|
||||
setCollapsedProjects(() => {
|
||||
const allIds = new Set(projects.map((project) => project.id));
|
||||
try {
|
||||
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(allIds)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
scheduleCollapsedProjectsPersist(allIds);
|
||||
return allIds;
|
||||
});
|
||||
}, [projects, safeStorage, scheduleCollapsedProjectsPersist]);
|
||||
|
||||
const expandAllProjects = React.useCallback(() => {
|
||||
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||
groupCollapseDirty.current = true;
|
||||
setCollapsedGroups(new Set());
|
||||
setCollapsedProjects(() => {
|
||||
const empty = new Set<string>();
|
||||
try {
|
||||
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify([]));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
scheduleCollapsedProjectsPersist(empty);
|
||||
return empty;
|
||||
});
|
||||
}, [safeStorage, scheduleCollapsedProjectsPersist]);
|
||||
|
||||
const toggleProject = React.useCallback((projectId: string) => {
|
||||
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||
setCollapsedProjects((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(projectId)) next.delete(projectId);
|
||||
else next.add(projectId);
|
||||
try {
|
||||
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(next)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
scheduleCollapsedProjectsPersist(next);
|
||||
return next;
|
||||
});
|
||||
}, [safeStorage, scheduleCollapsedProjectsPersist]);
|
||||
|
||||
const toggleGroup = React.useCallback((key: string) => {
|
||||
groupCollapseDirty.current = true;
|
||||
setCollapsedGroups((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const updateGroupOrderByProject = React.useCallback<React.Dispatch<React.SetStateAction<Map<string, string[]>>>>((update) => {
|
||||
groupOrderDirty.current = true;
|
||||
setGroupOrderByProject(update);
|
||||
}, []);
|
||||
|
||||
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
|
||||
const state = React.useMemo(() => ({
|
||||
collapsedProjects,
|
||||
collapsedGroups,
|
||||
groupOrderByProject,
|
||||
}), [collapsedGroups, collapsedProjects, groupOrderByProject]);
|
||||
const actions = React.useMemo(() => ({
|
||||
setCollapsedProjects,
|
||||
toggleProject,
|
||||
collapseAllProjects,
|
||||
expandAllProjects,
|
||||
scheduleCollapsedProjectsPersist,
|
||||
setCollapsedGroups,
|
||||
toggleGroup,
|
||||
setGroupOrderByProject: updateGroupOrderByProject,
|
||||
getOrderedGroups,
|
||||
}), [collapseAllProjects, expandAllProjects, getOrderedGroups, scheduleCollapsedProjectsPersist, toggleGroup, toggleProject, updateGroupOrderByProject]);
|
||||
|
||||
return { state, actions };
|
||||
};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { normalizePath } from './utils';
|
||||
import { normalizePath } from '../utils';
|
||||
|
||||
// In-memory first-seen tracker for worktree directories. Worktree metadata
|
||||
// carries no creation time, so we record when a path first appears during
|
||||
@@ -0,0 +1,167 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { formatDirectoryName } from '@/lib/utils';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { SidebarActivitySections } from './SidebarActivitySections';
|
||||
import { deriveRecentActivitySections, type RecentSessionLocation } from './activitySections';
|
||||
import type { ActivityItem } from './SidebarActivitySections';
|
||||
import type { SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||
import type { SessionNode } from '../types';
|
||||
import { formatProjectLabel, normalizePath } from '../utils';
|
||||
|
||||
type Props = {
|
||||
projects: { id: string; label?: string; normalizedPath: string }[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
gitBranches: Map<string, string | null>;
|
||||
homeDirectory: string | null;
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
isDesktopShellRuntime: boolean;
|
||||
sessions: Session[];
|
||||
childrenMap: ReadonlyMap<string, readonly Session[]>;
|
||||
pinnedSessionIds: Set<string>;
|
||||
recentSessions: Session[];
|
||||
expandedParents: Set<string>;
|
||||
notifyOnSubtasks: boolean;
|
||||
editingId: string | null;
|
||||
editTitle: string;
|
||||
copiedSessionId: string | null;
|
||||
openSidebarMenuKey: string | null;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
chatSessions: Session[];
|
||||
renderChatsSection: (items: ActivityItem[]) => React.ReactNode;
|
||||
onNewChat: () => void;
|
||||
showRecentSection: boolean;
|
||||
} & Pick<SessionTreeItemProps,
|
||||
| 'setEditingId'
|
||||
| 'setEditTitle'
|
||||
| 'toggleParent'
|
||||
| 'setOpenSidebarMenuKey'
|
||||
| 'allowReselect'
|
||||
| 'onSessionSelected'
|
||||
| 'isSessionSearchOpen'
|
||||
| 'sessionSearchQuery'
|
||||
| 'setSessionSearchQuery'
|
||||
| 'setIsSessionSearchOpen'
|
||||
| 'deleteSessionConfirm'
|
||||
| 'setDeleteSessionConfirm'
|
||||
| 'startFolderRename'
|
||||
| 'setCopiedSessionId'
|
||||
>;
|
||||
|
||||
export const RecentSessionSection: React.FC<Props> = (props) => {
|
||||
const {
|
||||
projects,
|
||||
availableWorktreesByProject,
|
||||
gitBranches,
|
||||
homeDirectory,
|
||||
hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery,
|
||||
isDesktopShellRuntime,
|
||||
sessions,
|
||||
childrenMap,
|
||||
pinnedSessionIds,
|
||||
recentSessions,
|
||||
chatSessions,
|
||||
showRecentSection,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const sessionLocationById = React.useMemo(() => {
|
||||
const locations = new Map<string, RecentSessionLocation>();
|
||||
for (const session of sessions) {
|
||||
const directory = normalizePath(session.directory ?? null);
|
||||
if (!directory) continue;
|
||||
let owner: Props['projects'][number] | null = null;
|
||||
let ownerLength = -1;
|
||||
for (const project of projects) {
|
||||
const projectPath = normalizePath(project.normalizedPath);
|
||||
if (projectPath && (directory === projectPath || directory.startsWith(`${projectPath}/`)) && projectPath.length > ownerLength) {
|
||||
owner = project;
|
||||
ownerLength = projectPath.length;
|
||||
}
|
||||
}
|
||||
if (!owner) continue;
|
||||
const worktree = availableWorktreesByProject.get(owner.normalizedPath)?.find((entry) => normalizePath(entry.path) === directory);
|
||||
const projectLabel = formatProjectLabel(owner.label?.trim() || formatDirectoryName(owner.normalizedPath, homeDirectory) || owner.normalizedPath);
|
||||
const branch = worktree?.branch?.trim() || gitBranches.get(directory)?.trim() || null;
|
||||
locations.set(session.id, {
|
||||
projectId: owner.id,
|
||||
groupDirectory: directory,
|
||||
projectLabel,
|
||||
branchLabel: branch && branch !== 'HEAD' && branch !== projectLabel ? branch : null,
|
||||
});
|
||||
}
|
||||
return locations;
|
||||
}, [availableWorktreesByProject, sessions, gitBranches, homeDirectory, projects]);
|
||||
const getSessionLocation = React.useCallback(
|
||||
(sessionId: string) => sessionLocationById.get(sessionId) ?? null,
|
||||
[sessionLocationById],
|
||||
);
|
||||
const getSessionNode = React.useCallback(
|
||||
(session: Session): SessionNode => ({
|
||||
session,
|
||||
children: (childrenMap.get(session.id) ?? []).filter((child) => !child.time?.archived).map((child) => ({
|
||||
session: child,
|
||||
children: [],
|
||||
worktree: null,
|
||||
})),
|
||||
worktree: null,
|
||||
}),
|
||||
[childrenMap],
|
||||
);
|
||||
const recentSections = React.useMemo(() => deriveRecentActivitySections({
|
||||
sessions: recentSessions,
|
||||
getSessionLocation,
|
||||
getSessionNode,
|
||||
query: hasSessionSearchQuery ? normalizedSessionSearchQuery : '',
|
||||
}), [getSessionLocation, getSessionNode, hasSessionSearchQuery, normalizedSessionSearchQuery, recentSessions]);
|
||||
const sections = React.useMemo(() => [
|
||||
{
|
||||
key: 'chats' as const,
|
||||
title: t('sessions.sidebar.activity.chatsTitle'),
|
||||
items: chatSessions.map((session) => ({
|
||||
node: getSessionNode(session),
|
||||
projectId: null,
|
||||
groupDirectory: session.directory ?? null,
|
||||
secondaryMeta: null,
|
||||
})),
|
||||
},
|
||||
...(showRecentSection ? recentSections.map((section) => ({ ...section, title: t('sessions.sidebar.activity.recentTitle') })) : []),
|
||||
], [chatSessions, getSessionNode, recentSections, showRecentSection, t]);
|
||||
return (
|
||||
<SidebarActivitySections
|
||||
sections={sections}
|
||||
variant="section"
|
||||
isDesktopShellRuntime={isDesktopShellRuntime}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
expandedParents={props.expandedParents}
|
||||
hasSessionSearchQuery={props.hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={props.normalizedSessionSearchQuery}
|
||||
notifyOnSubtasks={props.notifyOnSubtasks}
|
||||
editingId={props.editingId}
|
||||
editTitle={props.editTitle}
|
||||
copiedSessionId={props.copiedSessionId}
|
||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||
mobileVariant={props.mobileVariant}
|
||||
alwaysShowActions={props.alwaysShowActions}
|
||||
onNewChat={props.onNewChat}
|
||||
renderChatsSection={props.renderChatsSection}
|
||||
setEditingId={props.setEditingId}
|
||||
setEditTitle={props.setEditTitle}
|
||||
toggleParent={props.toggleParent}
|
||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||
allowReselect={props.allowReselect}
|
||||
onSessionSelected={props.onSessionSelected}
|
||||
isSessionSearchOpen={props.isSessionSearchOpen}
|
||||
sessionSearchQuery={props.sessionSearchQuery}
|
||||
setSessionSearchQuery={props.setSessionSearchQuery}
|
||||
setIsSessionSearchOpen={props.setIsSessionSearchOpen}
|
||||
deleteSessionConfirm={props.deleteSessionConfirm}
|
||||
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||
startFolderRename={props.startFolderRename}
|
||||
setCopiedSessionId={props.setCopiedSessionId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+82
-74
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SessionNode } from './types';
|
||||
import type { SessionNode } from '../types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
collectSubtreeContainingId,
|
||||
computeNodeStructureKey,
|
||||
resolveMenuOpenSessionId,
|
||||
} from './sessionNodeItemUtils';
|
||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
} from '../sessions/sessionNodeItemUtils';
|
||||
import type { SessionNodeRenderExtras } from '../sessions/sessionNodeItemUtils';
|
||||
import { SessionTreeItem, type SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||
|
||||
export type ActivityItem = {
|
||||
node: SessionNode;
|
||||
@@ -30,27 +30,40 @@ 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',
|
||||
renderExtras?: SessionNodeRenderExtras,
|
||||
) => React.ReactNode;
|
||||
editingId: string | null;
|
||||
openSidebarMenuKey: string | null;
|
||||
expansionState?: ReadonlySet<string>;
|
||||
variant?: 'section' | 'flat';
|
||||
initialVisibleCount?: number;
|
||||
batchSize?: number;
|
||||
isDesktopShellRuntime: boolean;
|
||||
pinnedSessionIds: Set<string>;
|
||||
expandedParents: Set<string>;
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
notifyOnSubtasks: boolean;
|
||||
editingId: string | null;
|
||||
editTitle: string;
|
||||
copiedSessionId: string | null;
|
||||
openSidebarMenuKey: string | null;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
onNewChat?: () => void;
|
||||
alwaysShowActions?: boolean;
|
||||
renderChatsSection?: (items: ActivityItem[]) => React.ReactNode;
|
||||
};
|
||||
} & Pick<SessionTreeItemProps,
|
||||
| 'setEditingId'
|
||||
| 'setEditTitle'
|
||||
| 'toggleParent'
|
||||
| 'setOpenSidebarMenuKey'
|
||||
| 'allowReselect'
|
||||
| 'onSessionSelected'
|
||||
| 'isSessionSearchOpen'
|
||||
| 'sessionSearchQuery'
|
||||
| 'setSessionSearchQuery'
|
||||
| 'setIsSessionSearchOpen'
|
||||
| 'deleteSessionConfirm'
|
||||
| 'setDeleteSessionConfirm'
|
||||
| 'startFolderRename'
|
||||
| 'setCopiedSessionId'
|
||||
>;
|
||||
|
||||
type RenderExtras = SessionNodeRenderExtras;
|
||||
|
||||
@@ -59,14 +72,12 @@ const MAX_VISIBLE_RECENT_SESSIONS = 7;
|
||||
export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
const {
|
||||
sections,
|
||||
renderSessionNode,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
variant = 'section',
|
||||
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const { pinnedSessionIds } = props;
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
||||
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
|
||||
@@ -109,8 +120,8 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
|
||||
const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => {
|
||||
const subtreeContainsEditing = new Set<string>();
|
||||
collectSubtreeContainingId(nodes, editingId, subtreeContainsEditing);
|
||||
const menuOpenSessionId = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, 'recent', false);
|
||||
collectSubtreeContainingId(nodes, props.editingId, subtreeContainsEditing);
|
||||
const menuOpenSessionId = resolveMenuOpenSessionId(nodes, props.openSidebarMenuKey, 'recent', false);
|
||||
const nodeStructureKeyByNode = new WeakMap<SessionNode, string>();
|
||||
const visit = (node: SessionNode): void => {
|
||||
nodeStructureKeyByNode.set(node, computeNodeStructureKey(node));
|
||||
@@ -131,11 +142,9 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '',
|
||||
childRenderExtrasFor,
|
||||
});
|
||||
}, [editingId, openSidebarMenuKey]);
|
||||
}, [props.editingId, props.openSidebarMenuKey]);
|
||||
|
||||
const visibleSections = sections.filter((section) => (
|
||||
section.items.length > 0 || (section.key === 'chats' && props.onNewChat)
|
||||
));
|
||||
const visibleSections = sections.filter((section) => section.items.length > 0 || section.key === 'chats');
|
||||
if (visibleSections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -155,15 +164,41 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
const usesCustomRenderer = section.key === 'chats' && Boolean(props.renderChatsSection);
|
||||
const canShowFewer = !usesCustomRenderer && !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),
|
||||
const renderItem = (item: ActivityItem) => (
|
||||
<SessionTreeItem
|
||||
key={item.node.session.id}
|
||||
node={item.node}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
expandedParents={props.expandedParents}
|
||||
hasSessionSearchQuery={props.hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={props.normalizedSessionSearchQuery}
|
||||
notifyOnSubtasks={props.notifyOnSubtasks}
|
||||
editingId={props.editingId}
|
||||
editTitle={props.editTitle}
|
||||
copiedSessionId={props.copiedSessionId}
|
||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||
mobileVariant={props.mobileVariant}
|
||||
alwaysShowActions={props.alwaysShowActions}
|
||||
groupDirectory={item.groupDirectory}
|
||||
projectId={item.projectId}
|
||||
secondaryMeta={item.secondaryMeta}
|
||||
renderContext="recent"
|
||||
renderExtras={getRenderExtras(item.node)}
|
||||
setEditingId={props.setEditingId}
|
||||
setEditTitle={props.setEditTitle}
|
||||
toggleParent={props.toggleParent}
|
||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||
allowReselect={props.allowReselect}
|
||||
onSessionSelected={props.onSessionSelected}
|
||||
isSessionSearchOpen={props.isSessionSearchOpen}
|
||||
sessionSearchQuery={props.sessionSearchQuery}
|
||||
setSessionSearchQuery={props.setSessionSearchQuery}
|
||||
setIsSessionSearchOpen={props.setIsSessionSearchOpen}
|
||||
deleteSessionConfirm={props.deleteSessionConfirm}
|
||||
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||
startFolderRename={props.startFolderRename}
|
||||
setCopiedSessionId={props.setCopiedSessionId}
|
||||
/>
|
||||
);
|
||||
|
||||
if (flatVariant) {
|
||||
@@ -185,11 +220,6 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
|
||||
return (
|
||||
<div key={section.key} className="relative space-y-1">
|
||||
<div
|
||||
className="absolute h-px w-px pointer-events-none"
|
||||
data-sidebar-activity-sentinel={section.key}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className={cn(
|
||||
'relative group/chats',
|
||||
'-ml-2.5 -mr-2',
|
||||
@@ -198,14 +228,11 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection(section.key)}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-1.5 py-1 pl-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
section.key === 'chats' && props.onNewChat ? 'pr-10' : 'pr-3.5',
|
||||
)}
|
||||
className={cn('group flex w-full items-center gap-1.5 py-1 pl-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50', section.key === 'chats' ? 'pr-10' : 'pr-3.5')}
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<span className="inline-flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Icon name={section.key === 'chats' ? 'chat-4' : 'history'} className={cn('h-3.5 w-3.5 text-muted-foreground/80', 'group-hover:hidden')} />
|
||||
<Icon name={section.key === 'chats' ? 'chat-4' : 'history'} className={cn('h-3.5 w-3.5 text-muted-foreground/80', 'group-hover:hidden')} />
|
||||
<span className="hidden h-3.5 w-3.5 items-center justify-center text-muted-foreground group-hover:inline-flex">
|
||||
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
@@ -213,38 +240,19 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
<span className="text-[14px] font-semibold lowercase text-foreground">{section.title}</span>
|
||||
</button>
|
||||
{section.key === 'chats' && props.onNewChat ? (
|
||||
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
props.onNewChat?.();
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||
props.alwaysShowActions
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 pointer-events-none group-hover/chats:opacity-100 group-hover/chats:pointer-events-auto group-focus-within/chats:opacity-100 group-focus-within/chats:pointer-events-auto',
|
||||
)}
|
||||
aria-label={t('sessions.sidebar.header.actions.newSession')}
|
||||
>
|
||||
<Icon name="add" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
<p>{t('sessions.sidebar.header.actions.newSession')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); props.onNewChat?.(); }}
|
||||
className={cn('absolute right-0.5 top-1/2 z-10 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50', props.alwaysShowActions ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover/chats:opacity-100 group-hover/chats:pointer-events-auto group-focus-within/chats:opacity-100 group-focus-within/chats:pointer-events-auto')}
|
||||
aria-label={t('sessions.sidebar.header.actions.newSession')}
|
||||
>
|
||||
<Icon name="add" className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{!isCollapsed ? (
|
||||
<div className={cn('space-y-0.5')}>
|
||||
{section.key === 'chats' && props.renderChatsSection
|
||||
? props.renderChatsSection(section.items)
|
||||
: visibleItems.map(renderItem)}
|
||||
{usesCustomRenderer ? props.renderChatsSection?.(section.items) : visibleItems.map(renderItem)}
|
||||
{!usesCustomRenderer && remainingCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
+37
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { deriveRecentSessions } from './activitySections';
|
||||
import { deriveRecentActivitySections, deriveRecentSessions } from './activitySections';
|
||||
|
||||
const NOW = 200_000_000;
|
||||
const RECENT = NOW - (48 * 60 * 60 * 1000);
|
||||
@@ -37,3 +37,39 @@ describe('deriveRecentSessions', () => {
|
||||
expect(deriveRecentSessions([oldSession, recentSession], new Set(), NOW)).toEqual([recentSession]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveRecentActivitySections', () => {
|
||||
test('filters recent roots by search text and falls back to topology metadata', () => {
|
||||
const matching = {
|
||||
...session('matching', { updated: RECENT }),
|
||||
title: 'Deploy release',
|
||||
directory: '/workspace/app/worktrees/release',
|
||||
};
|
||||
const excluded = {
|
||||
...session('excluded', { updated: RECENT }),
|
||||
title: 'Investigate failure',
|
||||
directory: '/workspace/app',
|
||||
};
|
||||
|
||||
const sections = deriveRecentActivitySections({
|
||||
sessions: [matching, excluded],
|
||||
getSessionLocation: (sessionId) => sessionId === matching.id ? {
|
||||
projectId: 'app',
|
||||
groupDirectory: '/workspace/app/worktrees/release',
|
||||
projectLabel: 'App',
|
||||
branchLabel: 'release',
|
||||
} : null,
|
||||
query: 'deploy',
|
||||
});
|
||||
|
||||
expect(sections).toEqual([{
|
||||
key: 'active-now',
|
||||
items: [{
|
||||
node: { session: matching, children: [], worktree: null },
|
||||
projectId: 'app',
|
||||
groupDirectory: '/workspace/app/worktrees/release',
|
||||
secondaryMeta: { projectLabel: 'App', branchLabel: 'release' },
|
||||
}],
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionNode } from '../types';
|
||||
|
||||
export type RecentSessionLocation = {
|
||||
projectId: string | null;
|
||||
groupDirectory: string | null;
|
||||
projectLabel: string | null;
|
||||
branchLabel: string | null;
|
||||
};
|
||||
|
||||
type RecentActivitySection = {
|
||||
key: 'active-now';
|
||||
items: Array<{
|
||||
node: SessionNode;
|
||||
projectId: string | null;
|
||||
groupDirectory: string | null;
|
||||
secondaryMeta: { projectLabel?: string | null; branchLabel?: string | null } | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
const RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
||||
|
||||
const isSubtaskSession = (session: Session): boolean => {
|
||||
return Boolean((session as Session & { parentID?: string | null }).parentID);
|
||||
};
|
||||
|
||||
const isArchivedSession = (session: Session): boolean => {
|
||||
return Boolean(session.time?.archived);
|
||||
};
|
||||
|
||||
const getSessionUpdatedAt = (session: Session): number => {
|
||||
const updated = session.time?.updated;
|
||||
const created = session.time?.created;
|
||||
if (typeof updated === 'number' && Number.isFinite(updated)) {
|
||||
return updated;
|
||||
}
|
||||
if (typeof created === 'number' && Number.isFinite(created)) {
|
||||
return created;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Recent contains non-archived root sessions that are active now or were
|
||||
// updated within the retention window. The caller applies shared lifecycle
|
||||
// ordering after this membership filter; batching ("Show more") handles long
|
||||
// windows in the UI.
|
||||
export const deriveRecentSessions = (
|
||||
sessions: Session[],
|
||||
activeSessionIds: ReadonlySet<string>,
|
||||
now = Date.now(),
|
||||
): Session[] => {
|
||||
const minUpdatedAt = now - RECENT_SESSION_MAX_AGE_MS;
|
||||
return sessions.filter((session) => {
|
||||
if (isArchivedSession(session) || isSubtaskSession(session)) {
|
||||
return false;
|
||||
}
|
||||
return activeSessionIds.has(session.id) || getSessionUpdatedAt(session) >= minUpdatedAt;
|
||||
});
|
||||
};
|
||||
|
||||
export const deriveRecentActivitySections = ({
|
||||
sessions,
|
||||
getSessionLocation,
|
||||
getSessionNode,
|
||||
query,
|
||||
}: {
|
||||
sessions: Session[];
|
||||
getSessionLocation: (sessionId: string) => RecentSessionLocation | null;
|
||||
getSessionNode?: (session: Session) => SessionNode;
|
||||
query: string;
|
||||
}): RecentActivitySection[] => [{
|
||||
key: 'active-now',
|
||||
items: sessions.flatMap((session) => {
|
||||
const title = typeof session.title === 'string' ? session.title.toLowerCase() : '';
|
||||
if (query && !title.includes(query)) return [];
|
||||
const location = getSessionLocation(session.id);
|
||||
return [{
|
||||
node: getSessionNode?.(session) ?? { session, children: [], worktree: null },
|
||||
projectId: location?.projectId ?? null,
|
||||
groupDirectory: location?.groupDirectory ?? session.directory ?? null,
|
||||
secondaryMeta: location ? {
|
||||
projectLabel: location.projectLabel,
|
||||
branchLabel: location.branchLabel,
|
||||
} : null,
|
||||
}];
|
||||
}),
|
||||
}];
|
||||
+81
-113
@@ -18,18 +18,17 @@ import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { isSessionPinned, type SessionPinnedTarget } from '@/stores/useSessionPinnedStore';
|
||||
import { isSessionPinned, useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
||||
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, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context';
|
||||
import { useSessionMessageRecordsForExport } from '@/sync/use-sync';
|
||||
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
||||
import { DraggableSessionRow } from './sessionFolderDnd';
|
||||
import { DraggableSessionRow } from '../folders/sessionFolderDnd';
|
||||
import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from './types';
|
||||
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import type { SessionNode } from '../types';
|
||||
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||
@@ -51,15 +50,15 @@ import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type Folder = { id: string; name: string; sessionIds: string[] };
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
type SecondaryMeta = {
|
||||
projectLabel?: string | null;
|
||||
branchLabel?: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
export type SessionNodeItemProps = {
|
||||
node: SessionNode;
|
||||
depth?: number;
|
||||
groupDirectory?: string | null;
|
||||
@@ -79,7 +78,6 @@ type Props = {
|
||||
toggleParent: (expansionKey: string) => void;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
|
||||
handleSessionDoubleClick: (sessionId: string, sessionTitle: string) => void;
|
||||
togglePinnedSession: (target: SessionPinnedTarget) => void;
|
||||
handleShareSession: (session: Session) => void;
|
||||
copiedSessionId: string | null;
|
||||
handleCopyShareUrl: (url: string, sessionId: string) => void;
|
||||
@@ -87,27 +85,11 @@ type Props = {
|
||||
handleUnshareSession: (sessionId: string) => void;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
renamingFolderId: string | null;
|
||||
getFoldersForScope: (scopeKey: string) => Folder[];
|
||||
getSessionFolderId: (scopeKey: string, sessionId: string) => string | null;
|
||||
removeSessionFromFolder: (scopeKey: string, sessionId: string) => void;
|
||||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; sessionTitleFallback?: string; readOnly?: boolean }) => void;
|
||||
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void;
|
||||
handleRestoreSession: (session: Session) => void;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
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';
|
||||
/**
|
||||
@@ -133,9 +115,14 @@ type Props = {
|
||||
* descendant; SessionNodeItem's recursive child render uses this lookup
|
||||
* to fetch the right key for each child it produces.
|
||||
*/
|
||||
childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const areNodeWorktreeRenderSemanticsEqual = (prev: SessionNode, next: SessionNode): boolean => (
|
||||
normalizePath(prev.worktree?.path ?? null) === normalizePath(next.worktree?.path ?? null)
|
||||
&& prev.worktree?.branch === next.worktree?.branch
|
||||
);
|
||||
|
||||
// Shared row geometry: the gutter edge matches the zone-header band padding
|
||||
// (px-1.5 = 6px), the marker slot is icon-wide (14px) with a 6px gap, so row
|
||||
// text starts exactly where the zone-header label starts. Nested children
|
||||
@@ -147,7 +134,6 @@ const ROW_TEXT_LEFT_PX = ROW_GUTTER_LEFT_PX + 14 + 6;
|
||||
const cancelScrollAnchorByContainer = new WeakMap<HTMLElement, () => void>();
|
||||
|
||||
const holdSessionRowPosition = (target: HTMLElement): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const row = target.closest<HTMLElement>('[data-session-row]');
|
||||
const container = row?.closest<HTMLElement>('.overlay-scrollbar-container');
|
||||
if (!row || !container) return;
|
||||
@@ -252,7 +238,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
|
||||
);
|
||||
});
|
||||
|
||||
function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode {
|
||||
streamPerfCount('ui.sidebar_session_node.render');
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
@@ -275,7 +261,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
toggleParent,
|
||||
handleSessionSelect,
|
||||
handleSessionDoubleClick,
|
||||
togglePinnedSession,
|
||||
handleShareSession,
|
||||
copiedSessionId,
|
||||
handleCopyShareUrl,
|
||||
@@ -283,24 +268,21 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
handleUnshareSession,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
renamingFolderId,
|
||||
getFoldersForScope,
|
||||
getSessionFolderId,
|
||||
removeSessionFromFolder,
|
||||
addSessionToFolder,
|
||||
createFolderAndStartRename,
|
||||
openContextPanelTab,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
mobileVariant,
|
||||
alwaysShowActions,
|
||||
renderSessionNode,
|
||||
secondaryMeta,
|
||||
renderContext = 'project',
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
childRenderExtrasFor,
|
||||
children,
|
||||
} = props;
|
||||
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
|
||||
const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope);
|
||||
const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId);
|
||||
const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder);
|
||||
const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder);
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
const isElectron = React.useMemo(() => canUseElectronDesktopIPC(), []);
|
||||
@@ -335,6 +317,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const editingIdRef = React.useRef(editingId);
|
||||
editingIdRef.current = editingId;
|
||||
const pendingRenameRef = React.useRef<{ id: string; title: string } | null>(null);
|
||||
const pendingFolderCreateRef = React.useRef(false);
|
||||
const handleSaveEditRef = React.useRef(handleSaveEdit);
|
||||
handleSaveEditRef.current = handleSaveEdit;
|
||||
const [renameDraft, setRenameDraft] = React.useState(editTitle);
|
||||
@@ -395,17 +378,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
}, [prSummary, t]);
|
||||
const isActive = useSessionUIStore((state) => state.currentSessionId === session.id);
|
||||
|
||||
const sessionDirectory =
|
||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath(groupDirectory ?? null);
|
||||
const sessionDirectory = normalizePath(session.directory ?? null) ?? normalizePath(groupDirectory ?? null);
|
||||
// Multi-select scope: sessions are flat per project, so selection groups by
|
||||
// project (falling back to the directory when no project is known) — a
|
||||
// selection must survive mixing sessions from different worktrees.
|
||||
const selectionScopeKey = projectId ?? sessionDirectory ?? null;
|
||||
// Directory bootstrap is scheduled once at sidebar level. A row only needs
|
||||
// the lightweight store reference for scoped state and export actions.
|
||||
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: false });
|
||||
const sync = useSync();
|
||||
const loadExportRecords = useSessionMessageRecordsForExport();
|
||||
|
||||
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
|
||||
const isRowSelected = useSessionMultiSelectStore(
|
||||
@@ -455,6 +433,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false });
|
||||
const sessionGoal = getSessionGoal(resolvedSession);
|
||||
const sessionGoalGlyph = sessionGoal ? (
|
||||
// SAFETY: sessionGoalStatusLabelKey contains an i18n key for every SessionGoalStatus.
|
||||
<span
|
||||
className="inline-flex flex-shrink-0 items-center"
|
||||
title={t(sessionGoalStatusLabelKey[sessionGoal.status] as never)}
|
||||
@@ -476,7 +455,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
[isExpanded, node, sessionDirectory],
|
||||
);
|
||||
const pendingQuestionCount = useSessionQuestionCount(questionBadgeSessionScopes);
|
||||
const isSubtaskSession = Boolean((resolvedSession as Session & { parentID?: string | null }).parentID);
|
||||
const isSubtaskSession = Boolean(resolvedSession.parentID);
|
||||
const unseenCount = useSessionUnseenCount(session.id);
|
||||
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
|
||||
const sessionTimestamp = resolvedSession.time?.updated || resolvedSession.time?.created || Date.now();
|
||||
@@ -496,9 +475,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
for (const child of children) {
|
||||
try {
|
||||
if (!sessionDirectory) throw new Error('Session directory is required for export');
|
||||
await sync.loadCompleteHistory(child.session.id, sessionDirectory);
|
||||
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
|
||||
const childRecords = await loadExportRecords({ directory: sessionDirectory, sessionID: child.session.id });
|
||||
if (!childRecords) throw new Error('Session runtime changed during export');
|
||||
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
|
||||
// SAFETY: OpenCode session payloads may carry the optional agent label used by exports.
|
||||
const childAgent = (child.session as Session & { agent?: string }).agent;
|
||||
const grandChildren = await collectChildExports(child.children);
|
||||
skipped += grandChildren.skipped;
|
||||
@@ -513,7 +493,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
}
|
||||
}
|
||||
return { children: results, skipped };
|
||||
}, [collectNodeDescendantIds, directoryStore, sessionDirectory, sync, t]);
|
||||
}, [collectNodeDescendantIds, loadExportRecords, sessionDirectory, t]);
|
||||
|
||||
const showSkippedSubtasksWarning = React.useCallback((count: number) => {
|
||||
if (count <= 0) return;
|
||||
@@ -528,14 +508,11 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sync.loadCompleteHistory(session.id, sessionDirectory);
|
||||
} catch {
|
||||
const records = await loadExportRecords({ directory: sessionDirectory, sessionID: session.id }).catch(() => null);
|
||||
if (!records) {
|
||||
toast.error(t('sessions.sidebar.session.export.failedLoadHistory'));
|
||||
return;
|
||||
}
|
||||
|
||||
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
|
||||
if (records.length === 0) {
|
||||
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
|
||||
return;
|
||||
@@ -573,7 +550,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
downloadAsMarkdown(markdown, filename);
|
||||
toast.success(t('sessions.sidebar.session.export.success'));
|
||||
showSkippedSubtasksWarning(skippedSubtaskCount);
|
||||
}, [collectChildExports, directoryStore, node.children, resolvedSession.title, session.id, sessionDirectory, showSkippedSubtasksWarning, sync, t]);
|
||||
}, [collectChildExports, loadExportRecords, node.children, resolvedSession.title, session.id, sessionDirectory, showSkippedSubtasksWarning, t]);
|
||||
const handleExportSession = React.useCallback(async () => {
|
||||
if (node.children.length > 0) {
|
||||
setExportIncludeSubtasks(true);
|
||||
@@ -603,7 +580,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
// its own rename form. A click inside ANY rename form for this session
|
||||
// must not count as "outside", or the sibling instance would save and
|
||||
// exit the rename mid-edit.
|
||||
const target = e.target as HTMLElement | null;
|
||||
// SAFETY: DOM mousedown targets are Nodes; closest is used only when the target is an Element.
|
||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||
const withinRenameForm = target?.closest?.(`[data-session-rename-form="${CSS.escape(session.id)}"]`);
|
||||
if (formRef.current && !withinRenameForm) {
|
||||
handleSaveEditRef.current(renameDraftRef.current);
|
||||
@@ -846,7 +824,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
void runtimeApis?.vscode?.executeCommand('openchamber.openSessionInEditor', session.id, sessionTitle);
|
||||
};
|
||||
|
||||
const handleRowSelect = (event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const handleRowSelect = (event?: React.MouseEvent<HTMLElement>) => {
|
||||
if (suppressNextSelectRef.current) {
|
||||
suppressNextSelectRef.current = false;
|
||||
return;
|
||||
@@ -855,12 +833,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (event?.shiftKey) {
|
||||
const rows = typeof document !== 'undefined'
|
||||
? Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'))
|
||||
: [];
|
||||
const rows = Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'));
|
||||
const orderedIds = rows
|
||||
.map((el) => el.getAttribute('data-session-row'))
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
.filter((id): id is string => id !== null && id.length > 0);
|
||||
const currentAnchor = useSessionMultiSelectStore.getState().anchorId;
|
||||
const descendantsById = new Map<string, string[]>();
|
||||
descendantsById.set(session.id, collectNodeDescendantIds(node));
|
||||
@@ -881,9 +857,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
// action menu), so nothing double-fires.
|
||||
const handleRowBackgroundClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.defaultPrevented) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
// SAFETY: React click targets are DOM EventTargets; closest is valid only for HTMLElements.
|
||||
const target = event.target instanceof HTMLElement ? event.target : null;
|
||||
if (target?.closest('button, a, input, [role="menuitem"], [role="menu"]')) return;
|
||||
handleRowSelect(event as unknown as React.MouseEvent<HTMLButtonElement>);
|
||||
handleRowSelect(event);
|
||||
};
|
||||
|
||||
const handleRowMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -1053,8 +1030,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
)}
|
||||
<Separator />
|
||||
<Item onClick={() => {
|
||||
const newFolder = createFolderAndStartRename(defaultScope);
|
||||
if (!newFolder) return;
|
||||
const newFolder = createFolderAndStartRename(defaultScope);
|
||||
if (!newFolder) return;
|
||||
pendingFolderCreateRef.current = true;
|
||||
if (currentEntry && currentEntry.scope !== defaultScope) {
|
||||
removeSessionFromFolder(currentEntry.scope, session.id);
|
||||
}
|
||||
@@ -1127,7 +1105,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
);
|
||||
|
||||
const sessionMenuContent = (
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]" finalFocus={() => (renamingFolderId || editingIdRef.current) ? false : true}>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]" finalFocus={() => {
|
||||
if (pendingFolderCreateRef.current) {
|
||||
pendingFolderCreateRef.current = false;
|
||||
return false;
|
||||
}
|
||||
return editingIdRef.current ? false : true;
|
||||
}}>
|
||||
{renderSessionMenuItems({
|
||||
Item: DropdownMenuItem,
|
||||
Separator: DropdownMenuSeparator,
|
||||
@@ -1143,7 +1127,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<ContextMenu.Positioner className="app-region-no-drag z-50">
|
||||
<ContextMenu.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
finalFocus={() => (renamingFolderId || editingIdRef.current) ? false : true}
|
||||
finalFocus={() => {
|
||||
if (pendingFolderCreateRef.current) {
|
||||
pendingFolderCreateRef.current = false;
|
||||
return false;
|
||||
}
|
||||
return editingIdRef.current ? false : true;
|
||||
}}
|
||||
style={{
|
||||
color: 'var(--surface-elevated-foreground)',
|
||||
}}
|
||||
@@ -1423,27 +1413,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
{contextMenuContent}
|
||||
</ContextMenu.Root>
|
||||
</DraggableSessionRow>
|
||||
{hasChildren && isExpanded
|
||||
? node.children.map((child): React.ReactNode => {
|
||||
const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor
|
||||
? childRenderExtrasFor(child)
|
||||
: {
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: '',
|
||||
};
|
||||
return renderSessionNode(
|
||||
child,
|
||||
depth + 1,
|
||||
sessionDirectory ?? groupDirectory,
|
||||
projectId,
|
||||
archivedBucket,
|
||||
undefined,
|
||||
renderContext,
|
||||
childRenderExtras,
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{hasChildren && isExpanded ? children : null}
|
||||
<Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}>
|
||||
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
|
||||
<DialogHeader>
|
||||
@@ -1497,7 +1467,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
}
|
||||
|
||||
const getNodeSessionDirectory = (node: SessionNode): string | null => {
|
||||
return normalizePath((node.session as Session & { directory?: string | null }).directory ?? null);
|
||||
return normalizePath(node.session.directory ?? null);
|
||||
};
|
||||
|
||||
const isSecondaryMetaEqual = (prev?: SecondaryMeta | null, next?: SecondaryMeta | null): boolean => {
|
||||
@@ -1505,7 +1475,7 @@ const isSecondaryMetaEqual = (prev?: SecondaryMeta | null, next?: SecondaryMeta
|
||||
&& (prev?.branchLabel ?? null) === (next?.branchLabel ?? null);
|
||||
};
|
||||
|
||||
const getMenuSessionIdFromKey = (props: Props): string | null => {
|
||||
const getMenuSessionIdFromKey = (props: SessionNodeItemProps): string | null => {
|
||||
if (!props.openSidebarMenuKey) return null;
|
||||
const bucketTag = props.archivedBucket ? 'archived' : 'active';
|
||||
const prefix = `${props.renderContext ?? 'project'}:${bucketTag}:`;
|
||||
@@ -1514,12 +1484,12 @@ const getMenuSessionIdFromKey = (props: Props): string | null => {
|
||||
: null;
|
||||
};
|
||||
|
||||
const getRelevantMenuSessionId = (props: Props): string | null => {
|
||||
const getRelevantMenuSessionId = (props: SessionNodeItemProps): string | null => {
|
||||
return props.menuOpenSessionId ?? getMenuSessionIdFromKey(props);
|
||||
};
|
||||
|
||||
const subtreeContainsSession = (
|
||||
props: Props,
|
||||
props: SessionNodeItemProps,
|
||||
sessionId: string | null,
|
||||
precomputed: Set<string>,
|
||||
): boolean => {
|
||||
@@ -1547,7 +1517,7 @@ const hasSetMembershipChangeInNode = (
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
|
||||
const hasExpansionMembershipChange = (prev: SessionNodeItemProps, next: SessionNodeItemProps): boolean => {
|
||||
if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false;
|
||||
const prevBucketTag = prev.archivedBucket ? 'archived' : 'active';
|
||||
const nextBucketTag = next.archivedBucket ? 'archived' : 'active';
|
||||
@@ -1566,9 +1536,21 @@ const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
const areSessionRenderSemanticsEqual = (prev: Session, next: Session): boolean => (
|
||||
prev.id === next.id
|
||||
&& prev.title === next.title
|
||||
&& prev.directory === next.directory
|
||||
&& prev.parentID === next.parentID
|
||||
&& prev.share?.url === next.share?.url
|
||||
&& prev.time?.created === next.time?.created
|
||||
&& prev.time?.updated === next.time?.updated
|
||||
&& prev.time?.archived === next.time?.archived
|
||||
);
|
||||
|
||||
const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionNodeItemProps): boolean => {
|
||||
if (prev.node.session.id !== next.node.session.id) return false;
|
||||
if (prev.node.session !== next.node.session) return false;
|
||||
if (!areSessionRenderSemanticsEqual(prev.node.session, next.node.session)) return false;
|
||||
if (!areNodeWorktreeRenderSemanticsEqual(prev.node, 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;
|
||||
@@ -1631,14 +1613,6 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1646,21 +1620,15 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
&& 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.handleCopySessionId === next.handleCopySessionId
|
||||
&& 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.handleRestoreSession === next.handleRestoreSession
|
||||
&& prev.renderSessionNode === next.renderSessionNode;
|
||||
&& prev.children === next.children;
|
||||
};
|
||||
|
||||
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areSessionNodeItemPropsEqual);
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionNodeItemProps } from './SessionNodeItem';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
|
||||
const renderedRows: SessionNodeItemProps[] = [];
|
||||
|
||||
mock.module('./SessionNodeItem', () => ({
|
||||
SessionNodeItem: (props: SessionNodeItemProps) => {
|
||||
renderedRows.push(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('./hooks/useSessionActions', () => ({
|
||||
useSessionActions: (args: {
|
||||
setEditingId: (id: string | null) => void;
|
||||
setEditTitle: (title: string) => void;
|
||||
}) => ({
|
||||
copiedSessionId: null,
|
||||
handleSaveEdit: () => undefined,
|
||||
handleCancelEdit: () => undefined,
|
||||
handleSessionSelect: () => undefined,
|
||||
handleSessionDoubleClick: (id: string, title: string) => {
|
||||
args.setEditingId(id);
|
||||
args.setEditTitle(title);
|
||||
},
|
||||
handleShareSession: () => undefined,
|
||||
handleCopyShareUrl: () => undefined,
|
||||
handleCopySessionId: () => undefined,
|
||||
handleUnshareSession: () => undefined,
|
||||
handleDeleteSession: () => undefined,
|
||||
handleRestoreSession: () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { SessionTreeItem } = await import('./SessionTreeItem');
|
||||
|
||||
const session = (id: string): Session => ({
|
||||
id,
|
||||
slug: id,
|
||||
projectID: 'project',
|
||||
title: 'Shared title',
|
||||
version: '1',
|
||||
directory: '/workspace',
|
||||
time: { created: 1, updated: 1 },
|
||||
});
|
||||
|
||||
describe('SessionTreeItem public behavior', () => {
|
||||
test('coordinates duplicate project and Recent rows through their shared visible-list state', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const sharedSession = session('same-session');
|
||||
const rowNode = { session: sharedSession, children: [], worktree: null };
|
||||
const noop = () => undefined;
|
||||
|
||||
const Harness = () => {
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = React.useState('');
|
||||
const [menuKey, setMenuKey] = React.useState<string | null>(null);
|
||||
const [copiedSessionId, setCopiedSessionId] = React.useState<string | null>(null);
|
||||
const rows = [
|
||||
{ renderContext: 'project' as const, groupDirectory: '/workspace' },
|
||||
{ renderContext: 'recent' as const, groupDirectory: '/workspace' },
|
||||
];
|
||||
return <>{rows.map((context) => <SessionTreeItem
|
||||
key={context.renderContext}
|
||||
node={rowNode}
|
||||
pinnedSessionIds={new Set()}
|
||||
expandedParents={new Set()}
|
||||
hasSessionSearchQuery={false}
|
||||
normalizedSessionSearchQuery=""
|
||||
notifyOnSubtasks={false}
|
||||
editingId={editingId}
|
||||
setEditingId={setEditingId}
|
||||
editTitle={editTitle}
|
||||
setEditTitle={setEditTitle}
|
||||
toggleParent={noop}
|
||||
copiedSessionId={copiedSessionId}
|
||||
openSidebarMenuKey={menuKey}
|
||||
setOpenSidebarMenuKey={setMenuKey}
|
||||
allowReselect={false}
|
||||
isSessionSearchOpen={false}
|
||||
sessionSearchQuery=""
|
||||
setSessionSearchQuery={noop}
|
||||
setIsSessionSearchOpen={noop}
|
||||
deleteSessionConfirm={null}
|
||||
setDeleteSessionConfirm={noop}
|
||||
startFolderRename={noop}
|
||||
setCopiedSessionId={setCopiedSessionId}
|
||||
mobileVariant={false}
|
||||
alwaysShowActions={false}
|
||||
{...context}
|
||||
/>)}</>;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider><Harness /></I18nProvider>));
|
||||
expect(renderedRows).toHaveLength(2);
|
||||
|
||||
await act(async () => renderedRows[0]?.handleSessionDoubleClick(sharedSession.id, sharedSession.title));
|
||||
expect(renderedRows).toHaveLength(4);
|
||||
expect(renderedRows.slice(-2).map((row) => [row.editingId, row.editTitle]))
|
||||
.toEqual([[sharedSession.id, sharedSession.title], [sharedSession.id, sharedSession.title]]);
|
||||
|
||||
await act(async () => renderedRows[3]?.setOpenSidebarMenuKey('recent:active:same-session'));
|
||||
expect(renderedRows).toHaveLength(6);
|
||||
expect(renderedRows.slice(-2).map((row) => row.openSidebarMenuKey))
|
||||
.toEqual(['recent:active:same-session', 'recent:active:same-session']);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
renderedRows.length = 0;
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import React from 'react';
|
||||
import { SessionNodeItem } from './SessionNodeItem';
|
||||
import type { SessionNodeItemProps } from './SessionNodeItem';
|
||||
import type { SessionNode } from '../types';
|
||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import { useSessionActions, type DeleteSessionConfirmState } from './useSessionActions';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { SessionDeleteConfirmDialog } from '../shell/ConfirmDialogs';
|
||||
|
||||
type Context = {
|
||||
groupDirectory?: string | null;
|
||||
projectId?: string | null;
|
||||
archivedBucket?: boolean;
|
||||
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null;
|
||||
renderContext?: 'project' | 'recent';
|
||||
};
|
||||
|
||||
type SessionTreeItemRenderProps = Context & Pick<SessionNodeItemProps,
|
||||
| 'expandedParents'
|
||||
| 'hasSessionSearchQuery'
|
||||
| 'normalizedSessionSearchQuery'
|
||||
| 'notifyOnSubtasks'
|
||||
| 'editingId'
|
||||
| 'editTitle'
|
||||
| 'copiedSessionId'
|
||||
| 'openSidebarMenuKey'
|
||||
| 'mobileVariant'
|
||||
| 'alwaysShowActions'
|
||||
> & {
|
||||
node: SessionNode;
|
||||
pinnedSessionIds: Set<string>;
|
||||
depth?: number;
|
||||
renderExtras?: SessionNodeRenderExtras;
|
||||
};
|
||||
|
||||
export type SessionTreeItemProps = SessionTreeItemRenderProps & Pick<SessionNodeItemProps,
|
||||
| 'setEditingId'
|
||||
| 'setEditTitle'
|
||||
| 'toggleParent'
|
||||
| 'setOpenSidebarMenuKey'
|
||||
> & {
|
||||
allowReselect: boolean;
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
isSessionSearchOpen: boolean;
|
||||
sessionSearchQuery: string;
|
||||
setSessionSearchQuery: (value: string) => void;
|
||||
setIsSessionSearchOpen: (open: boolean) => void;
|
||||
deleteSessionConfirm: DeleteSessionConfirmState;
|
||||
setDeleteSessionConfirm: (value: DeleteSessionConfirmState) => void;
|
||||
startFolderRename: (scopeKey: string, folder: { id: string; name: string }) => void;
|
||||
setCopiedSessionId: (sessionId: string | null) => void;
|
||||
};
|
||||
|
||||
const EMPTY_SUBTREE_CONTAINS_EDITING: Set<string> = new Set();
|
||||
|
||||
// This is the recursive ownership boundary. Structural parents pass identity
|
||||
// and stable UI actions; the row itself remains the leaf subscriber for live UI state.
|
||||
export function SessionTreeItem({
|
||||
node,
|
||||
depth = 0,
|
||||
groupDirectory,
|
||||
projectId,
|
||||
archivedBucket = false,
|
||||
secondaryMeta,
|
||||
renderContext = 'project',
|
||||
renderExtras,
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery,
|
||||
notifyOnSubtasks,
|
||||
editingId,
|
||||
setEditingId,
|
||||
editTitle,
|
||||
setEditTitle,
|
||||
toggleParent,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
allowReselect,
|
||||
onSessionSelected,
|
||||
isSessionSearchOpen,
|
||||
sessionSearchQuery,
|
||||
setSessionSearchQuery,
|
||||
setIsSessionSearchOpen,
|
||||
deleteSessionConfirm,
|
||||
setDeleteSessionConfirm,
|
||||
startFolderRename,
|
||||
copiedSessionId,
|
||||
setCopiedSessionId,
|
||||
mobileVariant,
|
||||
alwaysShowActions,
|
||||
}: SessionTreeItemProps): React.ReactNode {
|
||||
const createFolder = useSessionFoldersStore((state) => state.createFolder);
|
||||
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
|
||||
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
||||
const descendantIds = React.useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
const visit = (current: SessionNode) => current.children.forEach((child) => {
|
||||
ids.push(child.session.id);
|
||||
visit(child);
|
||||
});
|
||||
visit(node);
|
||||
return ids;
|
||||
}, [node]);
|
||||
const createFolderAndStartRename = React.useCallback((scopeKey: string, parentId?: string | null) => {
|
||||
if (!scopeKey) return null;
|
||||
if (parentId && useSessionFoldersStore.getState().collapsedFolderIds.has(parentId)) toggleFolderCollapse(parentId);
|
||||
const folder = createFolder(scopeKey, 'New folder', parentId);
|
||||
startFolderRename(scopeKey, folder);
|
||||
return folder;
|
||||
}, [createFolder, startFolderRename, toggleFolderCollapse]);
|
||||
const sessionActions = useSessionActions({
|
||||
mobileVariant,
|
||||
allowReselect,
|
||||
onSessionSelected,
|
||||
isSessionSearchOpen,
|
||||
sessionSearchQuery,
|
||||
setSessionSearchQuery,
|
||||
setIsSessionSearchOpen,
|
||||
descendantIds,
|
||||
showDeletionDialog,
|
||||
setDeleteSessionConfirm,
|
||||
deleteSessionConfirm,
|
||||
editingId,
|
||||
setEditingId,
|
||||
editTitle,
|
||||
setEditTitle,
|
||||
copiedSessionId,
|
||||
setCopiedSessionId,
|
||||
});
|
||||
const childRenderExtrasFor = renderExtras?.childRenderExtrasFor;
|
||||
const childContext: Context = {
|
||||
groupDirectory: node.session.directory ?? groupDirectory,
|
||||
projectId,
|
||||
archivedBucket,
|
||||
renderContext,
|
||||
};
|
||||
return <>
|
||||
<SessionNodeItem
|
||||
expandedParents={expandedParents}
|
||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||
notifyOnSubtasks={notifyOnSubtasks}
|
||||
editingId={editingId}
|
||||
setEditingId={setEditingId}
|
||||
editTitle={editTitle}
|
||||
setEditTitle={setEditTitle}
|
||||
handleSaveEdit={sessionActions.handleSaveEdit}
|
||||
handleCancelEdit={sessionActions.handleCancelEdit}
|
||||
toggleParent={toggleParent}
|
||||
handleSessionSelect={sessionActions.handleSessionSelect}
|
||||
handleSessionDoubleClick={sessionActions.handleSessionDoubleClick}
|
||||
handleShareSession={sessionActions.handleShareSession}
|
||||
copiedSessionId={copiedSessionId}
|
||||
handleCopyShareUrl={sessionActions.handleCopyShareUrl}
|
||||
handleCopySessionId={sessionActions.handleCopySessionId}
|
||||
handleUnshareSession={sessionActions.handleUnshareSession}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
createFolderAndStartRename={createFolderAndStartRename}
|
||||
handleDeleteSession={sessionActions.handleDeleteSession}
|
||||
handleRestoreSession={sessionActions.handleRestoreSession}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
node={node}
|
||||
depth={depth}
|
||||
groupDirectory={groupDirectory}
|
||||
projectId={projectId}
|
||||
archivedBucket={archivedBucket}
|
||||
secondaryMeta={secondaryMeta}
|
||||
renderContext={renderContext}
|
||||
subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_CONTAINS_EDITING}
|
||||
menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null}
|
||||
nodeStructureKey={renderExtras?.nodeStructureKey ?? ''}
|
||||
>
|
||||
{node.children.map((child) => (
|
||||
<SessionTreeItem
|
||||
key={child.session.id}
|
||||
node={child}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
expandedParents={expandedParents}
|
||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||
notifyOnSubtasks={notifyOnSubtasks}
|
||||
editingId={editingId}
|
||||
setEditingId={setEditingId}
|
||||
editTitle={editTitle}
|
||||
copiedSessionId={copiedSessionId}
|
||||
setEditTitle={setEditTitle}
|
||||
toggleParent={toggleParent}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
allowReselect={allowReselect}
|
||||
onSessionSelected={onSessionSelected}
|
||||
isSessionSearchOpen={isSessionSearchOpen}
|
||||
sessionSearchQuery={sessionSearchQuery}
|
||||
setSessionSearchQuery={setSessionSearchQuery}
|
||||
setIsSessionSearchOpen={setIsSessionSearchOpen}
|
||||
deleteSessionConfirm={deleteSessionConfirm}
|
||||
setDeleteSessionConfirm={setDeleteSessionConfirm}
|
||||
startFolderRename={startFolderRename}
|
||||
setCopiedSessionId={setCopiedSessionId}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
depth={depth + 1}
|
||||
{...childContext}
|
||||
renderExtras={childRenderExtrasFor?.(child)}
|
||||
/>
|
||||
))}
|
||||
</SessionNodeItem>
|
||||
{deleteSessionConfirm?.session.id === node.session.id ? <SessionDeleteConfirmDialog
|
||||
value={deleteSessionConfirm}
|
||||
setValue={setDeleteSessionConfirm}
|
||||
showDeletionDialog={showDeletionDialog}
|
||||
setShowDeletionDialog={setShowDeletionDialog}
|
||||
onConfirm={sessionActions.confirmDeleteSession}
|
||||
/> : null}
|
||||
</>;
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useGlobalSessionStatusStore , replaceGlobalSessionStatusById} from '@/sync/global-session-status';
|
||||
import { useNotificationStore } from '@/sync/notification-store';
|
||||
import { useCollapsedSessionActivityState } from './collapsedActivityState';
|
||||
import type { SessionNode } from '../types';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
// SAFETY: the fixture supplies the minimal SDK identity used by the selector.
|
||||
const node = (id: string): SessionNode => ({ session: { id } as Session, children: [], worktree: null });
|
||||
|
||||
describe('collapsed activity scalar selector', () => {
|
||||
test('does not rerender for unrelated updates and rerenders for relevant scalar changes', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
replaceGlobalSessionStatusById(new Map());
|
||||
useNotificationStore.setState({
|
||||
list: [],
|
||||
index: { session: { unseenCount: {}, unseenHasError: {} }, project: { unseenCount: {}, unseenHasError: {} } },
|
||||
});
|
||||
type ActivityCapture = { renders: number; state: string | null };
|
||||
const capture: ActivityCapture = { renders: 0, state: null };
|
||||
const Harness = () => {
|
||||
capture.renders += 1;
|
||||
capture.state = useCollapsedSessionActivityState({ nodes: [node('relevant')], includeUnreadSubtasks: true });
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
const initialRenders = capture.renders;
|
||||
await act(async () => replaceGlobalSessionStatusById(new Map([['unrelated', { status: { type: 'busy' }, directory: '/other' }]])));
|
||||
await act(async () => useNotificationStore.getState().append({
|
||||
type: 'turn-complete', session: 'unrelated', time: Date.now(), viewed: false,
|
||||
}));
|
||||
expect(capture.renders).toBe(initialRenders);
|
||||
|
||||
await act(async () => useNotificationStore.getState().append({
|
||||
type: 'turn-complete', session: 'relevant', time: Date.now(), viewed: false,
|
||||
}));
|
||||
expect(capture.state).toBe('unread');
|
||||
const unreadRenders = capture.renders;
|
||||
await act(async () => replaceGlobalSessionStatusById(new Map([['relevant', { status: { type: 'busy' }, directory: '/workspace' }]])));
|
||||
expect(capture.state).toBe('active');
|
||||
expect(capture.renders).toBe(unreadRenders + 1);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
replaceGlobalSessionStatusById(new Map());
|
||||
useNotificationStore.setState({
|
||||
list: [],
|
||||
index: { session: { unseenCount: {}, unseenHasError: {} }, project: { unseenCount: {}, unseenHasError: {} } },
|
||||
});
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user