From d2f1e4dfe0747a3e228502fe5f86fe8a06ddc2df Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 18 Oct 2026 16:28:50 +0800 Subject: [PATCH 1/2] feat(nav): pin the assistant above the workspace order The sessions section mixed assistants and project workspaces in one ordering list, so the assistant could sit between projects and took part in drag reordering. Pin assistant groups above the workspace list and keep them out of the drag order: project drags now rewrite project positions only. A hidden or not-yet-created assistant has no opened workspace, so the pin simply disappears and the workspace list stays untouched. Projects keep their own ordering, and the flat view still folds project sessions into the aggregate list below the pin. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../workspaces/WorkspaceListSection.test.tsx | 123 ++++++++++++++- .../workspaces/WorkspaceListSection.tsx | 143 ++++++++++++------ tests/e2e/specs/l0-navigation.spec.ts | 5 +- 3 files changed, 214 insertions(+), 57 deletions(-) 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..7516b0ad4a 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,108 @@ 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('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"]', From 6fb4f0045fe98e632dc5cdde33f2ea39db7cd22b Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 18 Oct 2026 16:32:27 +0800 Subject: [PATCH 2/2] test(nav): cover the flat session list without a pinned assistant A hidden or never-created assistant must not disturb the flat view: no pinned row, no empty workspace placeholder, and the aggregate project session list stays in place. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../workspaces/WorkspaceListSection.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 7516b0ad4a..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 @@ -154,6 +154,22 @@ describe('assistant pinning', () => { } }); + 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 = [];