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,