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 ? (
+
+ ) : null}
+
+ );
+ };
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 ? (
-
- ) : null}
-
- );
- })
+ <>
+ {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"]',