Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ProcessingIndicator } from '../modern/ProcessingIndicator';
import { flowChatStore } from '../../store/FlowChatStore';
import type { FlowChatConfig, FlowChatState, Session } from '../../types/flow-chat';
import { sessionToVirtualItems } from '../../store/modernFlowChatStore';
import { FLOWCHAT_FOCUS_ITEM_EVENT, type FlowChatFocusItemRequest } from '../../events/flowchatNavigation';
import { fileTabManager } from '@/shared/services/FileTabManager';
import { createTab } from '@/shared/utils/tabUtils';
import { IconButton, type LineRange } from '@/component-library';
Expand Down Expand Up @@ -210,14 +211,16 @@ export const BtwSessionPanel: React.FC<BtwSessionPanelProps> = ({

const requestId = btwOrigin?.requestId;
const itemId = requestId ? `btw_marker_${requestId}` : undefined;
const request: FlowChatFocusItemRequest = {
sessionId: resolvedParentSessionId,
turnIndex: btwOrigin?.parentTurnIndex,
itemId,
source: 'btw-back',
};

globalEventBus.emit(
'flowchat:focus-item',
{
sessionId: resolvedParentSessionId,
turnIndex: btwOrigin?.parentTurnIndex,
itemId,
},
FLOWCHAT_FOCUS_ITEM_EVENT,
request,
'BtwSessionPanel'
);
}, [btwOrigin, parentSessionId]);
Expand Down
101 changes: 68 additions & 33 deletions src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
* Renders merged explore-only rounds as a collapsible region.
*/

import React, { useRef, useMemo, useCallback, useEffect } from 'react';
import React, { useRef, useMemo, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { FlowItem, FlowToolItem, FlowTextItem, FlowThinkingItem } from '../../types/flow-chat';
import type { ExploreGroupData } from '../../store/modernFlowChatStore';
import { FlowTextBlock } from '../FlowTextBlock';
import { FlowToolCard } from '../FlowToolCard';
import { ModelThinkingDisplay } from '../../tool-cards/ModelThinkingDisplay';
import { useToolCardHeightContract } from '../../tool-cards/useToolCardHeightContract';
import { useFlowChatContext } from './FlowChatContext';
import './ExploreRegion.scss';

Expand Down Expand Up @@ -40,27 +41,58 @@ export const ExploreGroupRenderer: React.FC<ExploreGroupRendererProps> = ({
isFollowedByCritical,
isLastGroupInTurn
} = data;
const previousGroupIdRef = useRef(groupId);

// Track auto-collapse once to prevent flicker.
const hasAutoCollapsed = useRef(false);
// Reset collapse state when the merged group changes.
const prevGroupId = useRef(groupId);

if (prevGroupId.current !== groupId) {
prevGroupId.current = groupId;
hasAutoCollapsed.current = false;
}

// Auto-collapse once critical content follows, without waiting for streaming to end.
if (isFollowedByCritical && !hasAutoCollapsed.current) {
hasAutoCollapsed.current = true;
}

const shouldAutoCollapse = hasAutoCollapsed.current;
const [hasAutoCollapsed, setHasAutoCollapsed] = useState(isFollowedByCritical);
const {
cardRootRef,
applyExpandedState,
dispatchToolCardToggle,
} = useToolCardHeightContract({
toolId: groupId,
toolName: 'explore-group',
getCardHeight: () => (
containerRef.current?.scrollHeight
?? containerRef.current?.getBoundingClientRect().height
?? null
),
});

const userExpanded = exploreGroupStates?.get(groupId) ?? false;
const shouldAutoCollapse = hasAutoCollapsed;

const isCollapsed = shouldAutoCollapse && !userExpanded;

useLayoutEffect(() => {
if (previousGroupIdRef.current !== groupId) {
previousGroupIdRef.current = groupId;
setHasAutoCollapsed(isFollowedByCritical);
return;
}

if (!isFollowedByCritical || hasAutoCollapsed) {
return;
}

if (!userExpanded) {
applyExpandedState(true, false, () => {
setHasAutoCollapsed(true);
}, {
reason: 'auto',
});
return;
}

setHasAutoCollapsed(true);
dispatchToolCardToggle();
}, [
applyExpandedState,
dispatchToolCardToggle,
groupId,
hasAutoCollapsed,
isFollowedByCritical,
userExpanded,
]);

// Auto-scroll to bottom during streaming.
useEffect(() => {
Expand Down Expand Up @@ -96,17 +128,17 @@ export const ExploreGroupRenderer: React.FC<ExploreGroupRendererProps> = ({
}, [stats, allItems.length, t]);

const handleToggle = useCallback(() => {
// Notify VirtualMessageList to avoid auto-scrolling on user action.
window.dispatchEvent(new CustomEvent('tool-card-toggle'));

if (isCollapsed) {
// Expand only the clicked group.
onExploreGroupToggle?.(groupId);
} else {
// Collapse only the current group.
onCollapseGroup?.(groupId);
applyExpandedState(false, true, () => {
onExploreGroupToggle?.(groupId);
});
return;
}
}, [isCollapsed, groupId, onExploreGroupToggle, onCollapseGroup]);

applyExpandedState(true, false, () => {
onCollapseGroup?.(groupId);
});
}, [applyExpandedState, groupId, isCollapsed, onCollapseGroup, onExploreGroupToggle]);

// Build class list.
const className = [
Expand All @@ -119,7 +151,11 @@ export const ExploreGroupRenderer: React.FC<ExploreGroupRendererProps> = ({
// Non-collapsible: just render content without header (streaming, no auto-collapse yet).
if (!shouldAutoCollapse) {
return (
<div className={className}>
<div
ref={cardRootRef}
data-tool-card-id={groupId}
className={className}
>
<div ref={containerRef} className="explore-region__content">
{allItems.map((item, idx) => (
<ExploreItemRenderer
Expand All @@ -136,7 +172,11 @@ export const ExploreGroupRenderer: React.FC<ExploreGroupRendererProps> = ({

// Collapsible: unified header + animated content wrapper.
return (
<div className={className}>
<div
ref={cardRootRef}
data-tool-card-id={groupId}
className={className}
>
<div className="explore-region__header" onClick={handleToggle}>
<ChevronRight size={14} className="explore-region__icon" />
<span className="explore-region__summary">{displaySummary}</span>
Expand Down Expand Up @@ -212,11 +252,6 @@ const ExploreItemRenderer = React.memo<ExploreItemRendererProps>(({ item, isLast

case 'thinking': {
const thinkingItem = item as FlowThinkingItem;
// Hide completed thinking inside explore groups — it adds no value
// when collapsed (the explore group summary already shows thinking count).
if (thinkingItem.status === 'completed' && !isLastItem) {
return null;
}
return (
<ModelThinkingDisplay thinkingItem={thinkingItem} isLastItem={isLastItem} />
);
Expand Down
16 changes: 15 additions & 1 deletion src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,21 @@

.explore-region__content {
position: relative;
padding: 4px 0;
// Match the pre-grouped model-round layout. A non-collapsible explore group
// should not push the first summary line downward when the round is
// re-wrapped from `model-round` into `explore-group`.
padding: 0;

// In the plain model-round layout, the first/last flow item margins can
// collapse with the parent. Once wrapped by explore-group, that collapse no
// longer happens consistently, which leaves a residual 4px vertical drift.
> :first-child {
margin-top: 0;
}

> :last-child {
margin-bottom: 0;
}

&::-webkit-scrollbar {
width: 4px;
Expand Down
Loading
Loading