From 3581cc1616198628eff0dec3771195001bb2ee6d Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 21 Sep 2026 00:22:59 +0800 Subject: [PATCH] fix(flowchat): Scope side tabs to parent sessions Keep BTW and subagent tabs in workspace snapshots, but only show tabs owned by the active main session. Restore their visibility when the user switches back to the parent session. Collapse the right panel when no visible content remains and expand it when content returns. Update panel ownership expectations. --- .../panels/content-canvas/ContentCanvas.tsx | 14 ++++++++-- .../content-canvas/editor-area/EditorArea.tsx | 3 ++ .../editor-area/EditorGroup.tsx | 28 ++++++++++++------- .../hooks/canvasPanelOwnership.test.tsx | 10 +++---- .../hooks/usePanelTabCoordinator.ts | 12 +++++--- .../panels/content-canvas/types/content.ts | 11 ++++++++ .../panels/content-canvas/types/index.ts | 1 + src/web-ui/src/app/scenes/session/AuxPane.tsx | 13 +++++++-- 8 files changed, 68 insertions(+), 24 deletions(-) diff --git a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx index 1b636c9133..1ba02ece3b 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx @@ -3,7 +3,7 @@ * Shared content surface. The containing scene or panel owns its layout. */ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { EditorArea } from './editor-area'; import { AnchorZone } from './anchor-zone'; import { MissionControl } from './mission-control'; @@ -17,6 +17,7 @@ import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext' import { openMainSession } from '@/flow_chat/services/sessionActivation'; import { isSamePath } from '@/shared/utils/pathUtils'; import './ContentCanvas.scss'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; export interface ContentCanvasProps { /** Workspace path */ workspacePath?: string; @@ -71,6 +72,11 @@ export const ContentCanvas: React.FC = ({ const activeBtwSessionTab = useCanvasStore(state => selectActiveBtwSessionTab(state as any)); const activeBtwSessionData = activeBtwSessionTab?.content.data as BtwSessionPanelData | undefined; const { workspace: currentWorkspace } = useCurrentWorkspace(); + const activeSessionId = useSyncExternalStore( + flowChatStore.subscribe.bind(flowChatStore), + () => flowChatStore.getState().activeSessionId, + () => flowChatStore.getState().activeSessionId, + ); const currentWorkspaceId = currentWorkspace?.id; const lastSyncedBtwTabIdRef = useRef(null); // Initialize hooks @@ -131,9 +137,10 @@ export const ContentCanvas: React.FC = ({ const hasRenderableTabs = useMemo(() => { const groups = [primaryGroup, secondaryGroup, tertiaryGroup]; return groups.some(group => - group.tabs.some(tab => !tab.isHidden || tab.content.type === 'terminal') + group.tabs.some(tab => !tab.isHidden && (tab.content.type !== 'btw-session' || tab.content.data?.parentSessionId === activeSessionId) + || tab.content.type === 'terminal') ); - }, [primaryGroup, secondaryGroup, tertiaryGroup]); + }, [primaryGroup, secondaryGroup, tertiaryGroup, activeSessionId]); // Handle anchor close const handleAnchorClose = useCallback(() => { @@ -177,6 +184,7 @@ export const ContentCanvas: React.FC = ({
void; @@ -21,6 +22,7 @@ export interface EditorAreaProps { } export const EditorArea: React.FC = ({ + activeSessionId, workspacePath, isSceneActive = true, onOpenMissionControl, @@ -127,6 +129,7 @@ export const EditorArea: React.FC = ({ const renderEditorGroup = (groupId: EditorGroupId, group: typeof primaryGroup) => ( & { documentId: string }) { @@ -59,6 +60,7 @@ function CanvasContentView({ documentId, ...props }: React.ComponentProps = ({ + activeSessionId, groupId, group, isActive, @@ -115,7 +118,12 @@ export const EditorGroup: React.FC = ({ }) => { const { t } = useTranslation('components'); const mode = useContext(CanvasStoreModeContext); - const visibleTabs = useMemo(() => group.tabs.filter(t => !t.isHidden), [group.tabs]); + const visibleTabs = useMemo(() => group.tabs.filter(t => + !t.isHidden && isCanvasTabVisibleForSession(t, activeSessionId), + ), [group.tabs, activeSessionId]); + const effectiveActiveTabId = visibleTabs.some(t => t.id === group.activeTabId) + ? group.activeTabId + : visibleTabs[0]?.id ?? null; const activeTabContentRef = useRef(null); const activeTabAnimationRef = useRef(null); const previousActiveTabIdRef = useRef(group.activeTabId); @@ -165,11 +173,11 @@ export const EditorGroup: React.FC = ({ // unmounts its transcript while inactive and owns reading state until close. const tabsToRender = useMemo(() => { const result = group.tabs.filter(t => - (!t.isHidden && (t.content.type === 'btw-session' || t.id === group.activeTabId || cachedTabsRef.current.has(t.id))) || + (!t.isHidden && isCanvasTabVisibleForSession(t, activeSessionId) && (t.content.type === 'btw-session' || t.id === effectiveActiveTabId || cachedTabsRef.current.has(t.id))) || (t.isHidden && isKeepAliveTerminalTab(t)) ); return result; - }, [group.tabs, group.activeTabId, isKeepAliveTerminalTab]); + }, [group.tabs, effectiveActiveTabId, activeSessionId, isKeepAliveTerminalTab]); const handleContentChange = useCallback((content: PanelContent | null) => { if (content && group.activeTabId) { @@ -239,9 +247,9 @@ export const EditorGroup: React.FC = ({ > {/* Tab bar */} = ({ tabsToRender.map((tab) => (
onTabFileDeletedFromDiskChange(tab.id, missing) diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx b/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx index 00648792a3..1088faa5bc 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx @@ -206,7 +206,7 @@ describe('canvas host panel ownership', () => { expect(appManager.getState().layout.rightPanelCollapsed).toBe(true); }); - it('preserves manual collapse across content changes, workspace restore and host remount', async () => { + it('restores panel visibility when workspace content returns', async () => { switchAgentCanvasWorkspace(null, 'workspace-a'); useAgentCanvasStore.getState().addTab(content('session'), 'active'); await act(async () => root.render()); @@ -222,10 +222,10 @@ describe('canvas host panel ownership', () => { root.render(); }); expect(useAgentCanvasStore.getState().primaryGroup.tabs).toHaveLength(1); - expect(appManager.getState().layout.rightPanelCollapsed).toBe(true); + expect(appManager.getState().layout.rightPanelCollapsed).toBe(false); await act(async () => root.render(null)); await act(async () => root.render()); - expect(appManager.getState().layout.rightPanelCollapsed).toBe(true); + expect(appManager.getState().layout.rightPanelCollapsed).toBe(false); }); it('keeps an explicitly opened empty panel and counts all editor groups when closing', async () => { @@ -245,7 +245,7 @@ describe('canvas host panel ownership', () => { expect(appManager.getState().layout.rightPanelCollapsed).toBe(true); }); - it('preserves an open panel when workspace snapshots change before the shell rerenders', async () => { + it('collapses when a workspace snapshot has no visible content before the shell rerenders', async () => { switchAgentCanvasWorkspace(null, 'workspace-a'); useAgentCanvasStore.getState().addTab(content('workspace-a'), 'active'); appManager.updateLayout({ rightPanelCollapsed: false }); @@ -253,7 +253,7 @@ describe('canvas host panel ownership', () => { await act(async () => switchAgentCanvasWorkspace('workspace-a', 'workspace-b')); expect(useAgentCanvasStore.getState().workspaceKey).toBe('workspace-b'); expect(useAgentCanvasStore.getState().primaryGroup.tabs).toHaveLength(0); - expect(appManager.getState().layout.rightPanelCollapsed).toBe(false); + expect(appManager.getState().layout.rightPanelCollapsed).toBe(true); await act(async () => switchAgentCanvasWorkspace('workspace-b', 'workspace-a')); expect(useAgentCanvasStore.getState().primaryGroup.tabs).toHaveLength(1); expect(appManager.getState().layout.rightPanelCollapsed).toBe(false); diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts b/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts index 6a1449a19d..6524c92a7f 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts @@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'; interface UsePanelTabCoordinatorOptions { visibleTabCount: number; - /** Changing workspace restores content without changing the user's layout. */ scopeKey?: string; expandEventName: string; onExpand: () => void; @@ -27,13 +26,18 @@ export function usePanelTabCoordinator({ const previous = previousRef.current; previousRef.current = { visibleTabCount, scopeKey }; if ( - previous.scopeKey === scopeKey - && previous.visibleTabCount > 0 + previous.visibleTabCount > 0 && visibleTabCount === 0 ) { onCollapse(); + } else if ( + previous.visibleTabCount === 0 + && visibleTabCount > 0 + && previous.scopeKey !== undefined + ) { + onExpand(); } - }, [visibleTabCount, scopeKey, onCollapse]); + }, [visibleTabCount, scopeKey, onExpand, onCollapse]); // Compatibility for callers that explicitly request this panel, including // actions that reveal an existing tab without creating another one. diff --git a/src/web-ui/src/app/components/panels/content-canvas/types/content.ts b/src/web-ui/src/app/components/panels/content-canvas/types/content.ts index 9429f961d7..f75d0d59dd 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/types/content.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/types/content.ts @@ -5,6 +5,17 @@ export type { PanelContentType, PanelContent } from '../../base/types'; import type { PanelContentType } from '../../base/types'; +import type { CanvasTab } from './tab'; + +/** BTW and subagent tabs are scoped to the primary session that opened them. */ +export const isCanvasTabVisibleForSession = ( + tab: Pick, + activeSessionId?: string | null, +): boolean => { + if (tab.content.type !== 'btw-session') return true; + const parentSessionId = tab.content.data?.parentSessionId; + return Boolean(activeSessionId && parentSessionId === activeSessionId); +}; /** * File viewer types (code, markdown, images, etc.). diff --git a/src/web-ui/src/app/components/panels/content-canvas/types/index.ts b/src/web-ui/src/app/components/panels/content-canvas/types/index.ts index 33d29edba7..6f1706e779 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/types/index.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/types/index.ts @@ -46,5 +46,6 @@ export type { export { FILE_VIEWER_TYPES, isFileViewerType, + isCanvasTabVisibleForSession, TAB_EVENTS, } from './content'; diff --git a/src/web-ui/src/app/scenes/session/AuxPane.tsx b/src/web-ui/src/app/scenes/session/AuxPane.tsx index fbd604d32c..0f85b30514 100644 --- a/src/web-ui/src/app/scenes/session/AuxPane.tsx +++ b/src/web-ui/src/app/scenes/session/AuxPane.tsx @@ -5,7 +5,7 @@ * Renamed from panels/ContentPanel. All logic preserved. */ -import { forwardRef, useEffect, useRef, useImperativeHandle, useCallback } from 'react'; +import { forwardRef, useEffect, useRef, useImperativeHandle, useCallback, useSyncExternalStore } from 'react'; import { ContentCanvas, useCanvasStore } from '../../components/panels/content-canvas'; import { usePanelTabCoordinator } from '../../components/panels/content-canvas/hooks/usePanelTabCoordinator'; import { TAB_EVENTS } from '../../components/panels/content-canvas/types'; @@ -20,6 +20,8 @@ import { useI18n } from '@/infrastructure/i18n'; import type { PanelContent as OldPanelContent } from '../../components/panels/base/types'; import type { PanelContent } from '../../components/panels/content-canvas/types'; import { createLogger } from '@/shared/utils/logger'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { isCanvasTabVisibleForSession } from '../../components/panels/content-canvas/types'; import './AuxPane.scss'; @@ -44,6 +46,11 @@ const AuxPane = forwardRef( const { t } = useI18n('components'); const { workspace } = useCurrentWorkspace(); const workspaceId = workspace?.id; + const activeSessionId = useSyncExternalStore( + flowChatStore.subscribe.bind(flowChatStore), + () => flowChatStore.getState().activeSessionId, + () => flowChatStore.getState().activeSessionId, + ); // Fine-grained selectors so unrelated store changes do not re-render. const addTab = useCanvasStore(state => state.addTab); @@ -57,7 +64,9 @@ const AuxPane = forwardRef( const canvasWorkspaceKey = useCanvasStore(state => state.workspaceKey); const { expandPanel, collapsePanel } = usePanelTabCoordinator({ visibleTabCount: [primaryGroup, secondaryGroup, tertiaryGroup] - .reduce((count, group) => count + group.tabs.filter(tab => !tab.isHidden).length, 0), + .reduce((count, group) => count + group.tabs.filter(tab => + !tab.isHidden && isCanvasTabVisibleForSession(tab, activeSessionId), + ).length, 0), scopeKey: canvasWorkspaceKey, expandEventName: TAB_EVENTS.EXPAND_RIGHT_PANEL, onExpand: expandSessionAuxPane,