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 @@ -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', () => ({
Expand All @@ -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;
}) => <div data-owner-row={workspace.id} data-context-active={isActive} data-selected={isSelected} />,
draggable?: boolean;
}) => (
<div
data-owner-row={workspace.id}
data-context-active={isActive}
data-selected={isSelected}
data-row-draggable={draggable ? 'true' : 'false'}
/>
),
}));
vi.mock('../sessions/SessionsSection', () => ({
default: ({ workspaceScopes }: { workspaceScopes: { workspaceId: string }[] }) => (
Expand All @@ -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(<WorkspaceListSection variant="all" />));
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isWorkspaceBackedSessionGroupSelected,
projectWorkspaceBackedSessionGroups,
type SessionNavigationScope,
type WorkspaceBackedSessionGroup,
} from '../../sessionNavigationProjection';
import './WorkspaceListSection.scss';

Expand All @@ -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<WorkspaceListSectionProps> = ({ variant }) => {
const { t } = useI18n('common');
Expand Down Expand Up @@ -86,9 +94,32 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ 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(':');

Expand Down Expand Up @@ -227,7 +258,7 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
);

try {
await reorderOpenedWorkspacesInSection(variant, payload.workspaceId, workspaceId, position);
await reorderOpenedWorkspacesInSection(reorderSection, payload.workspaceId, workspaceId, position);
} catch (error) {
pendingDropRectsRef.current = null;
notificationService.error(
Expand All @@ -237,7 +268,62 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ 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 (
<div
data-openbitfun-component="workspace-list-section"
data-openbitfun-part="item"
data-openbitfun-state={isSelected ? 'selected' : undefined}
key={workspace.id}
ref={pinned ? undefined : (element) => {
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}
>
<WorkspaceItem
workspace={workspace}
isActive={isActive}
isSelected={isSelected}
isSingle={!draggable}
draggable={draggable}
isDragging={draggedWorkspaceId === workspace.id}
onDragStart={draggable ? handleDragStart(workspace.id) : undefined}
onDragEnd={draggable ? handleDragEnd : undefined}
/>
{dropTarget?.workspaceId === workspace.id ? (
<div data-openbitfun-component="workspace-list-section" data-openbitfun-part="dropLine" className="openbitfun-nav-panel__workspace-drop-line" aria-hidden="true" />
) : null}
</div>
);
};

return (
<div data-openbitfun-component="workspace-list-section" data-openbitfun-part="root" data-openbitfun-state={draggedWorkspaceId ? 'dragging' : undefined}
Expand All @@ -256,53 +342,10 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
{emptyLabel}
</div>
) : (
visibleGroups.map(group => {
const workspace = group.workspace;
const isActive = isWorkspaceBackedSessionGroupActive(workspace, activeWorkspace);
const isSelected = isWorkspaceBackedSessionGroupSelected(workspace, {
activeTab, contentScope, surfaceId, activeWorkspaceId,
});
return (
<div
data-openbitfun-component="workspace-list-section"
data-openbitfun-part="item"
data-openbitfun-state={isSelected ? 'selected' : undefined}
key={workspace.id}
ref={(element) => {
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); }}
>
<WorkspaceItem
workspace={workspace}
isActive={isActive}
isSelected={isSelected}
isSingle={workspaces.length === 1}
draggable={workspaces.length > 1}
isDragging={draggedWorkspaceId === workspace.id}
onDragStart={handleDragStart(workspace.id)}
onDragEnd={handleDragEnd}
/>
{dropTarget?.workspaceId === workspace.id ? (
<div data-openbitfun-component="workspace-list-section" data-openbitfun-part="dropLine" className="openbitfun-nav-panel__workspace-drop-line" aria-hidden="true" />
) : null}
</div>
);
})
<>
{pinnedGroups.map(group => renderGroupRow(group, true))}
{visibleReorderableGroups.map(group => renderGroupRow(group, false))}
</>
)}
{grouping === 'all' && workspaceScopes.length > 0 && (
<div className="openbitfun-nav-panel__workspace-all-sessions" data-testid="nav-workspace-all-sessions">
Expand Down
5 changes: 4 additions & 1 deletion tests/e2e/specs/l0-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>(
':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"]',
Expand Down
Loading