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
16 changes: 13 additions & 3 deletions src/web-ui/src/app/layout/FloatingMiniChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,15 @@ export const FloatingMiniChat: React.FC = () => {
return { isStreaming };
}, [flowChatState]);

const handleOpen = useCallback(() => setIsOpen(true), []);
const handleOpen = useCallback(() => {
// Sync the active session into modernFlowChatStore so the panel shows
// up-to-date content (it may have been streaming while the panel was closed).
const state = flowChatStore.getState();
if (state.activeSessionId) {
syncSessionToModernStore(state.activeSessionId);
}
setIsOpen(true);
}, []);

const handleClose = useCallback(() => {
setIsOpen(false);
Expand Down Expand Up @@ -277,9 +285,11 @@ export const FloatingMiniChat: React.FC = () => {
</Tooltip>
</div>

{/* FlowChat body */}
{/* FlowChat body — only mounted while the panel is open to avoid
running a second VirtualMessageList and store sync in the background
while the agent is actively streaming in another scene. */}
<div className="bitfun-fmc__body">
<ModernFlowChatContainer />
{isOpen && <ModernFlowChatContainer />}
</div>

{/* Input bar */}
Expand Down
7 changes: 3 additions & 4 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useActiveSessionState } from '../hooks/useActiveSessionState';
import { RichTextInput, type MentionState } from './RichTextInput';
import { FileMentionPicker } from './FileMentionPicker';
import { globalEventBus } from '../../infrastructure/event-bus';
import { useSessionDerivedState, useSessionStateMachineActions, useSessionStateMachine } from '../hooks/useSessionStateMachine';
import { useSessionDerivedState, useSessionStateMachineActions } from '../hooks/useSessionStateMachine';
import { SessionExecutionEvent } from '../state-machine/types';
import TokenUsageIndicator from './TokenUsageIndicator';
import { ModelSelector } from './ModelSelector';
Expand Down Expand Up @@ -126,7 +126,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({
inputState.value.trim()
);
const { transition, setQueuedInput } = useSessionStateMachineActions(effectiveTargetSessionId);
const stateMachine = useSessionStateMachine(effectiveTargetSessionId);

const { workspace, workspacePath } = useCurrentWorkspace();

Expand Down Expand Up @@ -503,7 +502,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
}, [isAssistantWorkspace, modeState.current]);

React.useEffect(() => {
const queuedInput = stateMachine?.context?.queuedInput;
const queuedInput = derivedState?.queuedInput;
if (!queuedInput?.trim() || !effectiveTargetSessionId) {
return;
}
Expand All @@ -527,7 +526,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
stateMachine?.context?.queuedInput,
derivedState?.queuedInput,
effectiveTargetSessionId,
]);

Expand Down
4 changes: 2 additions & 2 deletions src/web-ui/src/flow_chat/components/FlowToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const FlowToolCard: React.FC<FlowToolCardProps> = React.memo(({
prevProps.toolItem.userConfirmed === nextProps.toolItem.userConfirmed &&
prevProps.toolItem.isParamsStreaming === nextProps.toolItem.isParamsStreaming &&
prevProgress === nextProgress &&
JSON.stringify(prevProps.toolItem.partialParams) === JSON.stringify(nextProps.toolItem.partialParams) &&
JSON.stringify(prevProps.toolItem.toolResult) === JSON.stringify(nextProps.toolItem.toolResult)
prevProps.toolItem.partialParams === nextProps.toolItem.partialParams &&
prevProps.toolItem.toolResult === nextProps.toolItem.toolResult
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface ExploreGroupRendererProps {
turnId: string;
}

export const ExploreGroupRenderer: React.FC<ExploreGroupRendererProps> = ({
export const ExploreGroupRenderer: React.FC<ExploreGroupRendererProps> = React.memo(({
data,
turnId,
}) => {
Expand Down Expand Up @@ -216,7 +216,7 @@ export const ExploreGroupRenderer: React.FC<ExploreGroupRendererProps> = ({
</div>
</div>
);
};
});

/**
* Explore item renderer inside the explore region.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1044,7 +1044,7 @@ export const VirtualMessageList = forwardRef<VirtualMessageListRef>((_, ref) =>
mutationObserverRef.current = new MutationObserver(() => {
if (mutationPending) return;
mutationPending = true;
Promise.resolve().then(() => {
requestAnimationFrame(() => {
mutationPending = false;
scheduleHeightMeasure(2);
scheduleVisibleTurnMeasure(2);
Expand Down Expand Up @@ -1885,7 +1885,7 @@ export const VirtualMessageList = forwardRef<VirtualMessageListRef>((_, ref) =>
// content before sticky pin logic can finish.
initialTopMostItemIndex={latestUserMessageIndex}

overscan={{ main: 1200, reverse: 1200 }}
overscan={{ main: 600, reverse: 600 }}

atBottomThreshold={50}
atBottomStateChange={handleAtBottomStateChange}
Expand All @@ -1894,7 +1894,7 @@ export const VirtualMessageList = forwardRef<VirtualMessageListRef>((_, ref) =>

defaultItemHeight={200}

increaseViewportBy={{ top: 1200, bottom: 1200 }}
increaseViewportBy={{ top: 600, bottom: 600 }}

scrollerRef={handleScrollerRef}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Explore-group expansion state for Modern FlowChat.
*/

import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import type { VirtualItem } from '../../store/modernFlowChatStore';

type ExploreGroupVirtualItem = Extract<VirtualItem, { type: 'explore-group' }>;
Expand All @@ -23,6 +23,8 @@ export function useExploreGroupState(
virtualItems: VirtualItem[],
): UseExploreGroupStateResult {
const [exploreGroupStates, setExploreGroupStates] = useState<Map<string, boolean>>(new Map());
const virtualItemsRef = useRef(virtualItems);
virtualItemsRef.current = virtualItems;

const onExploreGroupToggle = useCallback((groupId: string) => {
setExploreGroupStates(prev => {
Expand All @@ -45,7 +47,7 @@ export function useExploreGroupState(
}, []);

const onExpandAllInTurn = useCallback((turnId: string) => {
const groupIds = virtualItems
const groupIds = virtualItemsRef.current
.filter((item): item is ExploreGroupVirtualItem => (
item.type === 'explore-group' && item.turnId === turnId
))
Expand All @@ -56,7 +58,7 @@ export function useExploreGroupState(
[...new Set(groupIds)].forEach(id => next.set(id, true));
return next;
});
}, [virtualItems]);
}, []);

const onCollapseGroup = useCallback((groupId: string) => {
setExploreGroupStates(prev => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* - Toolbar mode: compact floating bar
*/

import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
import React, { createContext, useContext, useState, useCallback, useRef, useMemo, ReactNode } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { PhysicalSize, PhysicalPosition } from '@tauri-apps/api/dpi';
import { currentMonitor } from '@tauri-apps/api/window';
Expand Down Expand Up @@ -303,7 +303,7 @@ export const ToolbarModeProvider: React.FC<ToolbarModeProviderProps> = ({ childr
setToolbarState(prev => ({ ...prev, ...updates }));
}, []);

const value: ToolbarModeContextType = {
const value: ToolbarModeContextType = useMemo(() => ({
isToolbarMode,
isExpanded,
isPinned,
Expand All @@ -314,8 +314,20 @@ export const ToolbarModeProvider: React.FC<ToolbarModeProviderProps> = ({ childr
setPinned,
togglePinned,
toolbarState,
updateToolbarState
};
updateToolbarState,
}), [
isToolbarMode,
isExpanded,
isPinned,
enableToolbarMode,
disableToolbarMode,
toggleToolbarMode,
toggleExpanded,
setPinned,
togglePinned,
toolbarState,
updateToolbarState,
]);

return (
<ToolbarModeContext.Provider value={value}>
Expand Down
26 changes: 16 additions & 10 deletions src/web-ui/src/flow_chat/services/storeSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,40 +24,46 @@ export function syncSessionToModernStore(sessionId: string): void {

const modernStore = useModernFlowChatStore.getState();
modernStore.setActiveSession(session);

setTimeout(() => {
modernStore.updateVirtualItems();
}, 0);
}

/**
* Start auto sync
* Listens to old Store changes and automatically syncs to new Store
*
* Performance optimization: relies on FlowChatStore's immutable updates, each update creates a new session reference
* Performance optimization: relies on FlowChatStore's immutable updates, each update creates a new session reference.
* Uses reference comparison to skip redundant syncs — if the active session object hasn't changed, no work is done.
*/
export function startAutoSync(): () => void {
let lastSyncedSessionId: string | null = null;
let lastSyncedSession: object | null = null;

const unsubscribe = flowChatStore.subscribe((state) => {
const modernStore = useModernFlowChatStore.getState();

if (state.activeSessionId) {
const session = state.sessions.get(state.activeSessionId);
if (session) {
if (session && (session !== lastSyncedSession || state.activeSessionId !== lastSyncedSessionId)) {
lastSyncedSessionId = state.activeSessionId;
lastSyncedSession = session;
modernStore.setActiveSession(session);
}
} else {
} else if (lastSyncedSessionId !== null) {
lastSyncedSessionId = null;
lastSyncedSession = null;
modernStore.clear();
}
});

const currentState = flowChatStore.getState();
if (currentState.activeSessionId) {
const session = currentState.sessions.get(currentState.activeSessionId);
if (session) {
lastSyncedSessionId = currentState.activeSessionId;
lastSyncedSession = session;
const modernStore = useModernFlowChatStore.getState();
modernStore.setActiveSession(session);
}
}

return unsubscribe;
}
25 changes: 19 additions & 6 deletions src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ function createInitialContext(): SessionStateMachineContext {
}

export class SessionStateMachineImpl {
private static readonly MAX_HISTORY_LENGTH = 100;

private sessionId: string;
private currentState: SessionExecutionState;
private context: SessionStateMachineContext;
Expand All @@ -57,14 +59,16 @@ export class SessionStateMachineImpl {
}

getSnapshot(): SessionStateMachine {
const { pendingToolConfirmations, ...rest } = this.context;
const clonedRest = structuredClone(rest);
return {
sessionId: this.sessionId,
currentState: this.currentState,
context: JSON.parse(JSON.stringify({
...this.context,
pendingToolConfirmations: Array.from(this.context.pendingToolConfirmations),
})),
transitionHistory: [...this.transitionHistory],
context: {
...clonedRest,
pendingToolConfirmations: new Set(pendingToolConfirmations),
},
transitionHistory: this.transitionHistory.slice(-SessionStateMachineImpl.MAX_HISTORY_LENGTH),
};
}

Expand Down Expand Up @@ -130,6 +134,10 @@ export class SessionStateMachineImpl {

this.updateContext(event, payload);

if (this.transitionHistory.length > SessionStateMachineImpl.MAX_HISTORY_LENGTH * 2) {
this.transitionHistory = this.transitionHistory.slice(-SessionStateMachineImpl.MAX_HISTORY_LENGTH);
}

this.transitionHistory.push({
from: fromState,
event,
Expand All @@ -141,7 +149,12 @@ export class SessionStateMachineImpl {

await this.runSideEffects(event, payload);

this.notifyListeners();
// TEXT_CHUNK_RECEIVED is a self-loop (PROCESSING→PROCESSING / FINISHING→FINISHING)
// that only increments stats.textCharsGenerated. Skip the expensive snapshot clone
// and listener broadcast to avoid per-chunk overhead during streaming.
if (event !== SessionExecutionEvent.TEXT_CHUNK_RECEIVED) {
this.notifyListeners();
}

return true;
}
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/flow_chat/state-machine/derivedState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export function deriveSessionState(
currentState === SessionExecutionState.FINISHING ||
currentState === SessionExecutionState.ERROR) &&
draftTrimmed.length > 0),
queuedInput: context.queuedInput ?? null,

hasError: isError,
errorType: context.errorMessage ? detectErrorType(context.errorMessage) : null,
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/flow_chat/state-machine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ export interface SessionDerivedState {
canCancel: boolean;
canSendNewMessage: boolean;
hasQueuedInput: boolean;

queuedInput: string | null;

hasError: boolean;
errorType: 'network' | 'model' | 'permission' | 'unknown' | null;
canRetry: boolean;
Expand Down
9 changes: 5 additions & 4 deletions src/web-ui/src/flow_chat/store/modernFlowChatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import { immer } from 'zustand/middleware/immer';
import type { Session, DialogTurn, ModelRound, FlowItem, FlowToolItem } from '../types/flow-chat';
import { isCollapsibleTool, READ_TOOL_NAMES, SEARCH_TOOL_NAMES } from '../tool-cards';
Expand Down Expand Up @@ -278,11 +279,11 @@ export const useModernFlowChatStore = create<ModernFlowChatState>()(
visibleTurnInfo: null,

setActiveSession: (session) => {
const items = sessionToVirtualItems(session);
set((state) => {
state.activeSession = session;
state.virtualItems = items;
});

get().updateVirtualItems();
},

updateVirtualItems: () => {
Expand Down Expand Up @@ -327,9 +328,9 @@ export const useVisibleTurnInfo = () =>
* Get actions (does not trigger re-render)
*/
export const useFlowChatActions = () =>
useModernFlowChatStore(state => ({
useModernFlowChatStore(useShallow(state => ({
setActiveSession: state.setActiveSession,
updateVirtualItems: state.updateVirtualItems,
setVisibleTurnInfo: state.setVisibleTurnInfo,
clear: state.clear,
}));
})));
Loading