diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.test.tsx index 4a60db7375..ed91f573a5 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.test.tsx @@ -7,10 +7,16 @@ import { sessionSceneWorkspaceKey } from '@/app/services/sessionSceneTarget'; import { useContentResourceStore } from '@/app/workbench/contentResourceStore'; import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; -const state = vi.hoisted(() => ({ grouping: 'all', activeWorkspaceId: null as string | null, workspaces: [ - { id: 'assistant', name: 'Assistant', workspaceKind: 'assistant' }, - { id: 'project', name: 'Project', workspaceKind: 'normal' }, -] })); +const state = vi.hoisted(() => { + const assistant = { id: 'assistant', name: 'Assistant', workspaceKind: 'assistant' }; + const project = { id: 'project', name: 'Project', workspaceKind: 'normal' }; + return { + grouping: 'all', + activeWorkspaceId: null as string | null, + workspaces: [assistant, project], + defaultWorkspaces: [assistant, project], + }; +}); vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); vi.mock('@/shared/notification-system', () => ({ notificationService: { error: vi.fn() } })); vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ @@ -29,11 +35,19 @@ vi.mock('../../workspaceSessionView', () => ({ useWorkspaceSessionViewStore: (selector: (value: typeof state) => unknown) => selector(state), })); vi.mock('./WorkspaceItem', () => ({ - default: ({ workspace, isActive, isSelected }: { + default: ({ workspace, isActive, isSelected, draggable }: { workspace: { id: string }; isActive: boolean; isSelected: boolean; - }) =>
, + draggable?: boolean; + }) => ( +
+ ), })); vi.mock('../sessions/SessionsSection', () => ({ default: ({ workspaceScopes }: { workspaceScopes: { workspaceId: string }[] }) => ( @@ -52,11 +66,124 @@ function sessionTab(workspaceId: string, surfaceId = 'local'): SceneTab { afterEach(() => { state.grouping = 'all'; state.activeWorkspaceId = null; + state.workspaces = [...state.defaultWorkspaces]; useSceneStore.setState({ activeTabId: null, openTabs: [] }); useContentResourceStore.setState({ resources: {} }); activateSurface('local'); }); +function renderList() { + const container = document.createElement('div'); + const root = createRoot(container); + act(() => root.render()); + return { + container, + ownerRows: () => Array.from(container.querySelectorAll('[data-owner-row]')) + .map(row => row.getAttribute('data-owner-row')), + unmount: () => act(() => root.unmount()), + }; +} + +describe('assistant pinning', () => { + it('pins the assistant above the project workspaces regardless of the stored order', () => { + state.grouping = 'grouped'; + // The persisted workspace order lists the project first. + state.workspaces = [...state.defaultWorkspaces].reverse(); + + const list = renderList(); + try { + expect(list.ownerRows()).toEqual(['assistant', 'project']); + const pinnedRow = list.container.querySelector('[data-session-group-pinned="true"]'); + expect(pinnedRow?.getAttribute('data-workspace-id')).toBe('assistant'); + expect(pinnedRow?.getAttribute('data-session-group-kind')).toBe('assistant'); + expect(pinnedRow?.querySelector('[data-owner-row="assistant"]')).not.toBeNull(); + } finally { + list.unmount(); + } + }); + + it('keeps the pinned assistant out of the project drag order', () => { + state.grouping = 'grouped'; + state.workspaces = [ + { id: 'assistant', name: 'Assistant', workspaceKind: 'assistant' }, + { id: 'first-project', name: 'First', workspaceKind: 'normal' }, + { id: 'second-project', name: 'Second', workspaceKind: 'normal' }, + ]; + + const list = renderList(); + try { + expect(list.ownerRows()).toEqual(['assistant', 'first-project', 'second-project']); + const draggable = (id: string) => list.container + .querySelector(`[data-owner-row="${id}"]`)?.getAttribute('data-row-draggable'); + expect(draggable('assistant')).toBe('false'); + expect(draggable('first-project')).toBe('true'); + expect(draggable('second-project')).toBe('true'); + } finally { + list.unmount(); + } + }); + + it('keeps the pin above the flat session list without rendering project rows', () => { + state.grouping = 'all'; + + const list = renderList(); + try { + expect(list.ownerRows()).toEqual(['assistant']); + expect(list.container.querySelector('[data-session-group-pinned="true"]') + ?.getAttribute('data-workspace-id')).toBe('assistant'); + expect(list.container.querySelector('[data-session-scopes]')?.getAttribute('data-session-scopes')) + .toBe('project'); + } finally { + list.unmount(); + } + }); + + it('renders no pin when the assistant is not created or has been hidden', () => { + state.grouping = 'grouped'; + // A hidden assistant is not an opened workspace, so it must not leave an + // empty pinned slot behind. + state.workspaces = state.defaultWorkspaces.filter(workspace => workspace.workspaceKind !== 'assistant'); + + const list = renderList(); + try { + expect(list.ownerRows()).toEqual(['project']); + expect(list.container.querySelector('[data-session-group-pinned="true"]')).toBeNull(); + expect(list.container.querySelector('[data-testid="nav-workspace-list-empty"]')).toBeNull(); + } finally { + list.unmount(); + } + }); + + it('leaves the flat session list in place when no assistant is open', () => { + state.grouping = 'all'; + state.workspaces = state.defaultWorkspaces.filter(workspace => workspace.workspaceKind !== 'assistant'); + + const list = renderList(); + try { + expect(list.ownerRows()).toEqual([]); + expect(list.container.querySelector('[data-session-group-pinned="true"]')).toBeNull(); + expect(list.container.querySelector('[data-testid="nav-workspace-list-empty"]')).toBeNull(); + expect(list.container.querySelector('[data-session-scopes]')?.getAttribute('data-session-scopes')) + .toBe('project'); + } finally { + list.unmount(); + } + }); + + it('keeps the empty state when no session group is open', () => { + state.grouping = 'grouped'; + state.workspaces = []; + + const list = renderList(); + try { + expect(list.ownerRows()).toEqual([]); + expect(list.container.querySelector('[data-testid="nav-workspace-list-empty"]')).not.toBeNull(); + } finally { + list.unmount(); + } + }); +}); + describe('assistant navigation across grouping modes', () => { it.each(['all', 'grouped'])('keeps the assistant entry and excludes its sessions from the %s list', grouping => { state.grouping = grouping; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx index 807edb50d8..0a0fff6dea 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx @@ -13,6 +13,7 @@ import { isWorkspaceBackedSessionGroupSelected, projectWorkspaceBackedSessionGroups, type SessionNavigationScope, + type WorkspaceBackedSessionGroup, } from '../../sessionNavigationProjection'; import './WorkspaceListSection.scss'; @@ -31,6 +32,13 @@ const WORKSPACE_DRAG_MIME_TYPE = 'application/x-openbitfun-workspace'; const WORKSPACE_DROP_SETTLE_MS = 160; const WORKSPACE_DROP_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; +/** + * WorkspaceListSection — the session section's group list. + * + * Projects and assistants are both workspace-backed, but they are not peers in + * navigation: the assistant identity is pinned above the workspace list and + * never joins the workspace order (see `pinnedGroups`). + */ const WorkspaceListSection: React.FC = ({ variant }) => { const { t } = useI18n('common'); @@ -86,9 +94,32 @@ const WorkspaceListSection: React.FC = ({ variant }) })) : [] ), [variant, sessionGroups]); - const visibleGroups = grouping === 'all' && variant !== 'assistants' + /** + * The assistant is a product-level identity, not one workspace among many. It + * is pinned above the workspace list and stays out of the workspace order, so + * the persisted and draggable order only ever describes project workspaces. + * + * A hidden, deleted, or never-created assistant has no opened workspace and + * therefore produces no pinned row: the pin appears and disappears with the + * assistant itself, and the workspace list is untouched either way. + */ + const pinnedGroups = variant === 'all' ? sessionGroups.filter(group => group.kind === 'assistant') + : []; + const reorderableGroups = variant === 'all' + ? sessionGroups.filter(group => group.kind === 'project') : sessionGroups; + // The flat view folds every project session into one aggregate list, so only + // the pinned assistant rows stay above it. + const visibleReorderableGroups = grouping === 'all' && variant !== 'assistants' + ? [] + : reorderableGroups; + const canReorderGroups = visibleReorderableGroups.length > 1; + /** + * Pinned assistants sit outside the drag order, so a project drag rewrites + * project positions only and leaves the assistant's stored slot untouched. + */ + const reorderSection = variant === 'all' ? 'projects' : variant; const workspaceSignature = sessionGroups.map(group => group.groupId).join(':'); @@ -227,7 +258,7 @@ const WorkspaceListSection: React.FC = ({ variant }) ); try { - await reorderOpenedWorkspacesInSection(variant, payload.workspaceId, workspaceId, position); + await reorderOpenedWorkspacesInSection(reorderSection, payload.workspaceId, workspaceId, position); } catch (error) { pendingDropRectsRef.current = null; notificationService.error( @@ -237,7 +268,62 @@ const WorkspaceListSection: React.FC = ({ variant }) } finally { setDraggedWorkspaceId(null); } - }, [reorderOpenedWorkspacesInSection, t, variant]); + }, [reorderOpenedWorkspacesInSection, reorderSection, t, variant]); + + /** + * One session-group row. Pinned assistants and project workspaces share the + * same box, but only project rows join the drag group: an assistant row never + * becomes a drop target, so dropping a project near it cannot reorder it. + */ + const renderGroupRow = (group: WorkspaceBackedSessionGroup, pinned: boolean) => { + const workspace = group.workspace; + const isActive = isWorkspaceBackedSessionGroupActive(workspace, activeWorkspace); + const isSelected = isWorkspaceBackedSessionGroupSelected(workspace, { + activeTab, contentScope, surfaceId, activeWorkspaceId, + }); + const draggable = !pinned && canReorderGroups; + return ( +
{ + if (element) workspaceRowRefs.current.set(workspace.id, element); + else workspaceRowRefs.current.delete(workspace.id); + }} + className={[ + 'openbitfun-nav-panel__workspace-drop-target', + draggable && draggedWorkspaceId && draggedWorkspaceId !== workspace.id && 'is-drag-active', + dropTarget?.workspaceId === workspace.id && 'is-drop-target', + dropTarget?.workspaceId === workspace.id && dropTarget.position === 'before' && 'is-before', + dropTarget?.workspaceId === workspace.id && dropTarget.position === 'after' && 'is-after', + ].filter(Boolean).join(' ')} + data-testid={pinned ? 'nav-workspace-pinned-item' : 'nav-workspace-drop-target'} + data-workspace-id={workspace.id} + data-workspace-list={variant} + data-session-group-kind={group.kind} + data-session-group-pinned={pinned ? 'true' : undefined} + onDragOver={draggable ? handleDragOver(workspace.id) : undefined} + onDragLeave={draggable ? handleDragLeave(workspace.id) : undefined} + onDrop={draggable ? (event) => { void handleDrop(workspace.id)(event); } : undefined} + > + + {dropTarget?.workspaceId === workspace.id ? ( + + ); + }; return (
= ({ variant }) {emptyLabel}
) : ( - visibleGroups.map(group => { - const workspace = group.workspace; - const isActive = isWorkspaceBackedSessionGroupActive(workspace, activeWorkspace); - const isSelected = isWorkspaceBackedSessionGroupSelected(workspace, { - activeTab, contentScope, surfaceId, activeWorkspaceId, - }); - return ( -
{ - if (element) workspaceRowRefs.current.set(workspace.id, element); - else workspaceRowRefs.current.delete(workspace.id); - }} - className={[ - 'openbitfun-nav-panel__workspace-drop-target', - draggedWorkspaceId && draggedWorkspaceId !== workspace.id && 'is-drag-active', - dropTarget?.workspaceId === workspace.id && 'is-drop-target', - dropTarget?.workspaceId === workspace.id && dropTarget.position === 'before' && 'is-before', - dropTarget?.workspaceId === workspace.id && dropTarget.position === 'after' && 'is-after', - ].filter(Boolean).join(' ')} - data-testid="nav-workspace-drop-target" - data-workspace-id={workspace.id} - data-workspace-list={variant} - data-session-group-kind={group.kind} - onDragOver={handleDragOver(workspace.id)} - onDragLeave={handleDragLeave(workspace.id)} - onDrop={(event) => { void handleDrop(workspace.id)(event); }} - > - 1} - isDragging={draggedWorkspaceId === workspace.id} - onDragStart={handleDragStart(workspace.id)} - onDragEnd={handleDragEnd} - /> - {dropTarget?.workspaceId === workspace.id ? ( - - ); - }) + <> + {pinnedGroups.map(group => renderGroupRow(group, true))} + {visibleReorderableGroups.map(group => renderGroupRow(group, false))} + )} {grouping === 'all' && workspaceScopes.length > 0 && (
diff --git a/tests/e2e/specs/l0-navigation.spec.ts b/tests/e2e/specs/l0-navigation.spec.ts index 006d4c4d52..4a95da66ab 100644 --- a/tests/e2e/specs/l0-navigation.spec.ts +++ b/tests/e2e/specs/l0-navigation.spec.ts @@ -202,8 +202,11 @@ describe('L0 Navigation Panel', () => { ); if (!list) return null; + // The pinned assistant row is a peer group in the same column even + // though it never joins the workspace drag order. const groups = Array.from(list.querySelectorAll( - ':scope > [data-testid="nav-workspace-drop-target"]', + ':scope > [data-testid="nav-workspace-pinned-item"], ' + + ':scope > [data-testid="nav-workspace-drop-target"]', )).filter(group => group.getBoundingClientRect().height > 0); const groupWithSession = groups.find(group => group.querySelector( '[data-testid="nav-session-item"][data-session-level="0"]',