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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -71,6 +72,11 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
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<string | null>(null);
// Initialize hooks
Expand Down Expand Up @@ -131,9 +137,10 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
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(() => {
Expand Down Expand Up @@ -177,6 +184,7 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
<div className="canvas-content-canvas__editor" data-openbitfun-component="content-canvas" data-openbitfun-part="editor">
<EditorArea
workspacePath={workspacePath}
activeSessionId={activeSessionId}
isSceneActive={isSceneActive}
onOpenMissionControl={missionControlEnabled ? handleOpenMissionControl : undefined}
onInteraction={onInteraction}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from '../types';
import './EditorArea.scss';
export interface EditorAreaProps {
activeSessionId?: string | null;
workspacePath?: string;
isSceneActive?: boolean;
onOpenMissionControl?: () => void;
Expand All @@ -21,6 +22,7 @@ export interface EditorAreaProps {
}

export const EditorArea: React.FC<EditorAreaProps> = ({
activeSessionId,
workspacePath,
isSceneActive = true,
onOpenMissionControl,
Expand Down Expand Up @@ -127,6 +129,7 @@ export const EditorArea: React.FC<EditorAreaProps> = ({
const renderEditorGroup = (groupId: EditorGroupId, group: typeof primaryGroup) => (
<EditorGroup
groupId={groupId}
activeSessionId={activeSessionId}
group={group}
isActive={activeGroupId === groupId}
isSceneActive={isSceneActive}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
PanelContent,
SplitMode,
} from '../types';
import { isCanvasTabVisibleForSession } from '../types';
import './EditorGroup.scss';

function CanvasContentView({ documentId, ...props }: React.ComponentProps<typeof FlexiblePanel> & { documentId: string }) {
Expand Down Expand Up @@ -59,6 +60,7 @@ function CanvasContentView({ documentId, ...props }: React.ComponentProps<typeof
}

export interface EditorGroupProps {
activeSessionId?: string | null;
groupId: EditorGroupId;
group: EditorGroupState;
isActive: boolean;
Expand Down Expand Up @@ -87,6 +89,7 @@ export interface EditorGroupProps {
}

export const EditorGroup: React.FC<EditorGroupProps> = ({
activeSessionId,
groupId,
group,
isActive,
Expand Down Expand Up @@ -115,7 +118,12 @@ export const EditorGroup: React.FC<EditorGroupProps> = ({
}) => {
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<HTMLDivElement | null>(null);
const activeTabAnimationRef = useRef<Animation | null>(null);
const previousActiveTabIdRef = useRef(group.activeTabId);
Expand Down Expand Up @@ -165,11 +173,11 @@ export const EditorGroup: React.FC<EditorGroupProps> = ({
// 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) {
Expand Down Expand Up @@ -239,9 +247,9 @@ export const EditorGroup: React.FC<EditorGroupProps> = ({
>
{/* Tab bar */}
<TabBar
tabs={group.tabs}
tabs={visibleTabs}
groupId={groupId}
activeTabId={group.activeTabId}
activeTabId={effectiveActiveTabId}
isActiveGroup={isActive}
onTabClick={handleVisibleTabClick}
onTabDoubleClick={onTabDoubleClick}
Expand Down Expand Up @@ -269,18 +277,18 @@ export const EditorGroup: React.FC<EditorGroupProps> = ({
tabsToRender.map((tab) => (
<div
key={tab.id}
ref={group.activeTabId === tab.id ? activeTabContentRef : undefined}
ref={effectiveActiveTabId === tab.id ? activeTabContentRef : undefined}
data-openbitfun-component="canvas-editor-group"
data-openbitfun-part="tabContent"
className="canvas-editor-group__tab-content"
style={{ display: group.activeTabId === tab.id ? 'flex' : 'none' }}
style={{ display: effectiveActiveTabId === tab.id ? 'flex' : 'none' }}
>
<CanvasContentView
documentId={`canvas:${tab.id}`}
content={tab.content as any}
isActive={isSceneActive && group.activeTabId === tab.id}
onContentChange={group.activeTabId === tab.id ? handleContentChange : undefined}
onDirtyStateChange={group.activeTabId === tab.id ? handleDirtyStateChange : undefined}
isActive={isSceneActive && effectiveActiveTabId === tab.id}
onContentChange={effectiveActiveTabId === tab.id ? handleContentChange : undefined}
onDirtyStateChange={effectiveActiveTabId === tab.id ? handleDirtyStateChange : undefined}
onFileMissingFromDiskChange={
onTabFileDeletedFromDiskChange
? (missing) => onTabFileDeletedFromDiskChange(tab.id, missing)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Hosts />));
Expand All @@ -222,10 +222,10 @@ describe('canvas host panel ownership', () => {
root.render(<Hosts />);
});
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(<Hosts />));
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 () => {
Expand All @@ -245,15 +245,15 @@ 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 });
await act(async () => root.render(<Hosts />));
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CanvasTab, 'content'>,
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.).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,6 @@ export type {
export {
FILE_VIEWER_TYPES,
isFileViewerType,
isCanvasTabVisibleForSession,
TAB_EVENTS,
} from './content';
13 changes: 11 additions & 2 deletions src/web-ui/src/app/scenes/session/AuxPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand All @@ -44,6 +46,11 @@ const AuxPane = forwardRef<AuxPaneRef, AuxPaneProps>(
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);
Expand All @@ -57,7 +64,9 @@ const AuxPane = forwardRef<AuxPaneRef, AuxPaneProps>(
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,
Expand Down
Loading