From 4d8f02ebe6f8674d3f19205ae95959700bf92bef Mon Sep 17 00:00:00 2001 From: czs914893385 Date: Tue, 13 Oct 2026 14:00:57 +0800 Subject: [PATCH] feat(desktop): reorder sessions by dragging the session list --- .../components/WorkspaceSessionFilterMenu.tsx | 5 +- .../sections/sessions/SessionsSection.scss | 34 ++ .../sections/sessions/SessionsSection.tsx | 55 +- .../sections/sessions/sessionManualReorder.ts | 567 ++++++++++++++++++ .../sessions/sessionRowManualReorder.test.tsx | 536 +++++++++++++++++ .../NavPanel/workspaceSessionView.test.ts | 53 +- .../NavPanel/workspaceSessionView.ts | 127 +++- .../utils/sessionManualOrder.test.ts | 205 +++++++ .../src/flow_chat/utils/sessionManualOrder.ts | 116 ++++ src/web-ui/src/locales/en-US/common.json | 3 +- src/web-ui/src/locales/zh-CN/common.json | 3 +- src/web-ui/src/locales/zh-TW/common.json | 3 +- 12 files changed, 1696 insertions(+), 11 deletions(-) create mode 100644 src/web-ui/src/app/components/NavPanel/sections/sessions/sessionManualReorder.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowManualReorder.test.tsx create mode 100644 src/web-ui/src/flow_chat/utils/sessionManualOrder.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/sessionManualOrder.ts diff --git a/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx b/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx index 0904229423..8c6e5b216b 100644 --- a/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx @@ -9,6 +9,7 @@ import { subscribeOverlayInteraction, createOverlayPortal, Icon, IconButton, Men import { DEFAULT_WORKSPACE_SESSION_VIEW, hasWorkspaceSessionFilters, + WORKSPACE_SESSION_ORDERING_MODES, type WorkspaceSessionEnvironment, type WorkspaceSessionOrdering, type WorkspaceSessionShow, @@ -116,7 +117,9 @@ const WorkspaceSessionFilterMenu: React.FC = () => { const definitions = useMemo>(() => ({ ordering: { - kind: 'single', value: view.ordering, options: ['updated', 'status', 'created', 'name'] as WorkspaceSessionOrdering[], choose: value => view.setOrdering(value as WorkspaceSessionOrdering), + // The full mode list, so `manual` shows up as the current value and can be + // left again once a drag or a row-menu move switched the list to it. + kind: 'single', value: view.ordering, options: WORKSPACE_SESSION_ORDERING_MODES, choose: value => view.setOrdering(value as WorkspaceSessionOrdering), }, show: { kind: 'single', value: view.show, options: ['all', 'unread', 'attention'] as WorkspaceSessionShow[], choose: value => view.setShow(value as WorkspaceSessionShow), diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index b92701a367..50b2ef0d50 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -910,3 +910,37 @@ @include nav-font.nav-panel-text-icon; } } + +// ── Manual (drag) reordering ──────────────────────────────────────────────────── +// The dragged row and the drop slot live on the row itself; the drop line is the +// same accent rail the workspace reorder uses, drawn on the slot's edge. Flat +// selectors on purpose: `&__inline-item` above is the row's one definition and a +// second nested block would fork it. +.openbitfun-nav-panel__inline-item.is-manual-dragging { + opacity: 0.42; +} + +.openbitfun-nav-panel__inline-item.is-manual-drop-before, +.openbitfun-nav-panel__inline-item.is-manual-drop-after { + background: color-mix(in srgb, var(--openbitfun-color-accent-default) 8%, transparent); +} + +.openbitfun-nav-panel__inline-item-manual-drop-line { + position: absolute; + left: 0; + right: 0; + z-index: 2; + height: 2px; + margin: 0; + background: var(--openbitfun-color-accent-default); + border-radius: 999px; + pointer-events: none; + + .is-manual-drop-before & { + top: 0; + } + + .is-manual-drop-after & { + bottom: 0; + } +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index a344a93a3b..5b95b55437 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -108,6 +108,13 @@ import { import { useSessionRowRemovalTransition } from './sessionRowShift'; import { SessionStatusIndicator } from './SessionStatusIndicator'; import { createSessionsNavSelector } from './sessionsNavSelector'; +import { + orderSessionsForManualView, + sessionManualOrderScopeKey, + switchSessionViewToManualOrdering, + useSessionManualOrder, + useSessionManualReorder, +} from './sessionManualReorder'; import './SessionsSection.scss'; const log = createLogger('SessionsSection'); @@ -738,6 +745,11 @@ const SessionsSection: React.FC = ({ }; }, [isExportScopeMenu, openMenuSessionId, updateContextSessionMenuPosition]); + // Manual ordering is per scope: one workspace group, or the flat "all sessions" + // projection, which renders without a workspace id. + const manualSessionOrderScopeKey = sessionManualOrderScopeKey(workspaceId); + const manualSessionOrder = useSessionManualOrder(manualSessionOrderScopeKey); + const sessions = useMemo( () => Array.from(flowChatState.sessions.values()) @@ -788,15 +800,22 @@ const SessionsSection: React.FC = ({ sessionNavStatusService.getSortTimestamp, ); + // Manual ordering is the only mode that does not compare two rows: the stored + // order of this scope decides, and a row it has never seen keeps its place. + const isManualOrdering = sessionOrdering === 'manual'; + const orderForCurrentView = (list: Session[]): Session[] => isManualOrdering + ? orderSessionsForManualView(list, manualSessionOrder) + : [...list].sort(compareForCurrentView); + for (const [pid, list] of childMap) { - childMap.set(pid, [...list].sort(compareForCurrentView)); + childMap.set(pid, orderForCurrentView(list)); } return { - topLevelSessions: [...parents].sort(compareForCurrentView), + topLevelSessions: orderForCurrentView(parents), childrenByParent: childMap, }; - }, [orderingRevision, runningSessionIds, sessionOrdering, sessions]); + }, [manualSessionOrder, orderingRevision, runningSessionIds, sessionOrdering, sessions]); const topLevelSessions = useMemo( () => allTopLevelSessions.filter(session => matchesWorkspaceSessionView( @@ -1004,6 +1023,22 @@ const SessionsSection: React.FC = ({ [visibleItems], ); + const sessionReorder = useSessionManualReorder({ + scopeKey: manualSessionOrderScopeKey, + // A draggable row would take the drag that selects text in the inline title + // editor, so the whole list stops being draggable while a row is edited. + enabled: useWorkspaceViewPreferences && editingSessionId === null, + // Loaded rows decide the stored order, but only the rendered ones can be + // reordered: the rows behind the render boundary must not swallow a step. + topLevelSessions: allTopLevelSessions, + childrenByParent, + renderedIds: visibleSessionIds, + listRef: sessionListRef, + // A committed reorder switches the view to the manual order it just wrote, + // without a notice: the mode control already shows the new mode. + onManualOrderCommit: switchSessionViewToManualOrdering, + }); + const visibleRowSignature = useMemo( () => visibleItems.map(item => item.session.sessionId).join('|'), [visibleItems], @@ -1733,6 +1768,8 @@ const SessionsSection: React.FC = ({ : false, }); const showAssistantIdentity = Boolean(assistantIdentity && !isChildSession); + const isDraggingRow = sessionReorder.isDragging(session.sessionId); + const dropPosition = sessionReorder.dropPositionFor(session.sessionId); const row = (
= ({ isRowActive && 'is-active', isEditing && 'is-editing', openMenuSessionId === session.sessionId && 'is-menu-open', + isDraggingRow && 'is-manual-dragging', + dropPosition && `is-manual-drop-${dropPosition}`, ] .filter(Boolean) .join(' ')} @@ -1760,8 +1799,13 @@ const SessionsSection: React.FC = ({ data-session-kind={relationship.kind} data-session-level={String(level)} data-session-active={isRowActive ? 'true' : 'false'} + {...sessionReorder.rowDragProps(session.sessionId)} onPointerDown={event => handleSessionOpenPointerDown(event, session)} - onClick={() => handleSwitch(session.sessionId)} + onClick={() => { + // A committed drop must not open the row it reordered. + if (sessionReorder.isRowClickSuppressed()) return; + handleSwitch(session.sessionId); + }} onContextMenu={event => handleContextMenu(event, session.sessionId)} > {showAssistantIdentity && assistantIdentity ? ( @@ -2098,6 +2142,9 @@ const SessionsSection: React.FC = ({ )} )} + {dropPosition ? ( +
); // Always wrapped, even while editing or with a row menu open: swapping diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionManualReorder.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionManualReorder.ts new file mode 100644 index 0000000000..9efc700979 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionManualReorder.ts @@ -0,0 +1,567 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import type * as React from 'react'; +import { + manualOrderScopeKey, + moveSessionInOrder, + orderSessionsManually, + reconcileManualOrder, + type ManualOrderScopeKey, +} from '@/flow_chat/utils/sessionManualOrder'; +import { readManualOrder, useWorkspaceSessionViewStore, writeManualOrder } from '../../workspaceSessionView'; + +/** + * Manual (drag-and-drop) ordering for the session nav list. + * + * The order itself lives in localStorage behind the session-view preference + * module; this module owns everything the rows need on top of it: + * + * - the drag payload, the drop geometry and the order arithmetic (pure), + * - a revision store so a written order re-renders every session list, + * - `useSessionManualReorder`, the drag state machine a section wires to its rows. + * + * Two independent notions of "group" keep a drop honest: + * + * - the *scope* (`workspace:` or `all`) decides where an order is stored. + * A drag never crosses scopes: the payload carries its scope and a row that + * belongs to another scope refuses the drop before `preventDefault`, so the + * browser keeps showing "no drop" and no stored order is touched. + * - the *sibling group* keeps a top-level row among top-level rows and a child + * row among its own siblings, so a drop can never move a row out of the + * parent block it is rendered inside. + */ + +export const SESSION_ROW_DRAG_MIME_TYPE = 'application/x-openbitfun-session'; +const SESSION_ROW_DRAG_PLAIN_MIME_TYPE = 'text/plain'; +export const SESSION_ROW_DROP_SETTLE_MS = 160; +const SESSION_ROW_DROP_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; +/** Top-level rows of one scope share a single sibling group. */ +export const SESSION_TOP_LEVEL_GROUP_KEY = 'top'; +const SESSION_CHILD_GROUP_PREFIX = 'child:'; +/** + * A committed drop does not produce a click in the browsers we ship on. This + * window keeps the row's open handler honest if one ever does. + */ +const SESSION_DRAG_CLICK_GUARD_MS = 150; + +export type SessionDropPosition = 'before' | 'after'; + +export interface SessionDragPayload { + sessionId: string; + scopeKey: ManualOrderScopeKey; + groupKey: string; +} + +/** The minimum a row has to expose to be reorderable. */ +export interface SessionReorderNode { + sessionId: string; +} + +interface SessionRowIdentity extends SessionReorderNode { + groupKey: string; +} + +/** + * `workspace:` for a workspace group row and `all` for the flat + * "all sessions" projection (which renders without a workspace id). + */ +export const sessionManualOrderScopeKey = (workspaceId?: string | null): ManualOrderScopeKey => + manualOrderScopeKey({ workspaceId }); + +/** Reorders one group's items by the stored order; `item => item.sessionId` is the row identity. */ +export function orderSessionsForManualView( + items: readonly T[], + order: readonly string[], +): T[] { + return orderSessionsManually(items, order, item => item.sessionId); +} + +export function isSameSessionOrder(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((id, index) => id === right[index]); +} + +/** Insertion above or below the row is the pointer against the row's vertical midline. */ +export function resolveSessionDropPosition( + clientY: number, + rectTop: number, + rectHeight: number, +): SessionDropPosition { + return clientY >= rectTop + rectHeight / 2 ? 'after' : 'before'; +} + +/** + * The order a drop produces. `sequence` is the scope's rows in display order and + * the dragged row is re-inserted next to the target row — the target index is + * resolved on the sequence *without* the dragged row, which makes the result + * independent of which side the row came from. Children stay with their parent + * block: only the relative order of rows inside one group changes. + */ +export function resolveSessionDropOrder(input: { + sequence: readonly string[]; + sessionId: string; + targetId: string; + position: SessionDropPosition; +}): string[] { + const { sequence, sessionId, targetId, position } = input; + const withoutDragged = sequence.filter(id => id !== sessionId); + const targetIndex = withoutDragged.indexOf(targetId); + if (targetIndex < 0) return [...sequence]; + return moveSessionInOrder( + withoutDragged, + sessionId, + position === 'after' ? targetIndex + 1 : targetIndex, + ); +} + +/** + * Rewrites `sequence` so the rows of `groupIds` follow `orderedGroupIds`, keeping + * every other id in its slot. + */ +export function applyGroupSequence( + sequence: readonly string[], + groupIds: readonly string[], + orderedGroupIds: readonly string[], +): string[] { + const inGroup = new Set(groupIds); + const remaining = [...orderedGroupIds]; + return sequence.map(id => (inGroup.has(id) ? remaining.shift() ?? id : id)); +} + +/** + * Persists a new order for the rows the caller knows about while keeping the + * slots of everything else (rows of the scope that this list has not loaded + * yet), so a drag can never drop the manual order of the sessions behind a + * metadata page boundary. + */ +export function writeSessionScopeOrder( + scopeKey: ManualOrderScopeKey, + sequence: readonly string[], + nextSequence: readonly string[], +): void { + const stored = readManualOrder(scopeKey); + const known = new Set(stored); + const fullOrder = [...stored, ...sequence.filter(id => !known.has(id))]; + writeManualOrder(scopeKey, applyGroupSequence(fullOrder, sequence, nextSequence)); + notifyManualOrderWrite(); +} + +/** Rewrites the stored order without the ids that were just removed from the list. */ +export function pruneSessionScopeOrder( + scopeKey: ManualOrderScopeKey, + removedIds: readonly string[], +): void { + const stored = readManualOrder(scopeKey); + if (stored.length === 0) return; + const removed = new Set(removedIds); + const kept = stored.filter(id => !removed.has(id)); + if (kept.length === stored.length) return; + // `reconcileManualOrder` bounds the stored order: ids that are no longer in + // the list are dropped, ids that are still there keep their relative order. + writeManualOrder(scopeKey, reconcileManualOrder(stored, kept)); + notifyManualOrderWrite(); +} + +let manualOrderRevision = 0; +const manualOrderRevisionListeners = new Set<() => void>(); + +function subscribeManualOrderRevision(listener: () => void): () => void { + manualOrderRevisionListeners.add(listener); + return () => { + manualOrderRevisionListeners.delete(listener); + }; +} + +const getManualOrderRevisionSnapshot = (): number => manualOrderRevision; + +function notifyManualOrderWrite(): void { + manualOrderRevision += 1; + for (const listener of [...manualOrderRevisionListeners]) listener(); +} + +/** + * The stored order of one scope, re-read whenever any list writes it. Listing a + * scope is enough to keep its own rows in sync after another section reordered it. + */ +export function useSessionManualOrder(scopeKey: ManualOrderScopeKey): string[] { + const revision = useSyncExternalStore( + subscribeManualOrderRevision, + getManualOrderRevisionSnapshot, + getManualOrderRevisionSnapshot, + ); + return useMemo(() => { + // The stored order lives outside React, so the revision is the only signal + // that another list wrote this scope's order and this one has to re-read it. + void revision; + return readManualOrder(scopeKey); + }, [revision, scopeKey]); +} + +/** + * Hand a committed reorder over to the manual ordering mode, so the order the + * user just arranged is the one the list shows. Silent on purpose: dragging is a + * frequent gesture and a toast per drop is noise. Switching only when the mode + * actually differs keeps a repeat reorder from touching the view preferences. + */ +export function switchSessionViewToManualOrdering(): void { + const view = useWorkspaceSessionViewStore.getState(); + if (view.ordering === 'manual') return; + view.setOrdering('manual'); +} + +export interface SessionRowDragProps { + draggable: boolean; + 'aria-grabbed'?: boolean; + onDragStart: React.DragEventHandler; + onDragOver: React.DragEventHandler; + onDragLeave: React.DragEventHandler; + onDrop: React.DragEventHandler; + onDragEnd: React.DragEventHandler; +} + +export interface SessionManualReorderHandle { + scopeKey: ManualOrderScopeKey; + /** The row currently being dragged, for the row state and `aria-grabbed`. */ + isDragging(sessionId: string): boolean; + /** Where the drop line is drawn for this row, or null when it is not the target. */ + dropPositionFor(sessionId: string): SessionDropPosition | null; + /** Drag state plus the handlers a row wires; `draggable` is false for a row that cannot reorder. */ + rowDragProps(sessionId: string): SessionRowDragProps; + /** A click emitted by the drop gesture itself must not open the row. */ + isRowClickSuppressed(): boolean; +} + +export interface SessionManualReorderOptions { + scopeKey: ManualOrderScopeKey; + /** False outside the workspace view preferences (assistant sections): rows stay static. */ + enabled: boolean; + /** The scope's top-level rows in display order. */ + topLevelSessions: readonly SessionReorderNode[]; + /** Each parent's child rows in display order; children reorder among siblings only. */ + childrenByParent: ReadonlyMap; + /** + * Ids the list actually renders right now. Only these can take part in a + * reorder: the loaded rows behind a metadata page boundary, a display limit or + * an active filter are ordered by the same stored order, but moving a row + * against one of them would change nothing on screen. + */ + renderedIds: ReadonlySet; + /** The list element that owns the rows, used for the drop's FLIP settle. */ + listRef: React.RefObject; + /** Called once a committed reorder needs the manual ordering mode to be visible. */ + onManualOrderCommit?: () => void; +} + +export function useSessionManualReorder( + options: SessionManualReorderOptions, +): SessionManualReorderHandle { + const { + scopeKey, + enabled, + topLevelSessions, + childrenByParent, + renderedIds, + listRef, + onManualOrderCommit, + } = options; + + const rows = useMemo(() => { + const identity: SessionRowIdentity[] = []; + for (const parent of topLevelSessions) { + identity.push({ sessionId: parent.sessionId, groupKey: SESSION_TOP_LEVEL_GROUP_KEY }); + for (const child of childrenByParent.get(parent.sessionId) ?? []) { + identity.push({ + sessionId: child.sessionId, + groupKey: `${SESSION_CHILD_GROUP_PREFIX}${parent.sessionId}`, + }); + } + } + return identity; + }, [childrenByParent, topLevelSessions]); + + const sequence = useMemo(() => rows.map(row => row.sessionId), [rows]); + const groupKeyBySessionId = useMemo( + () => new Map(rows.map(row => [row.sessionId, row.groupKey])), + [rows], + ); + const groupIdsOf = useCallback( + (sessionId: string): string[] => { + const groupKey = groupKeyBySessionId.get(sessionId); + if (groupKey === undefined) return []; + return rows + .filter(row => row.groupKey === groupKey && renderedIds.has(row.sessionId)) + .map(row => row.sessionId); + }, + [groupKeyBySessionId, renderedIds, rows], + ); + + const sequenceRef = useRef(sequence); + useEffect(() => { + sequenceRef.current = [...sequence]; + }, [sequence]); + const commitRef = useRef(onManualOrderCommit); + useEffect(() => { + commitRef.current = onManualOrderCommit; + }, [onManualOrderCommit]); + + const [draggingSessionId, setDraggingSessionId] = useState(null); + const [dropTarget, setDropTarget] = useState<{ sessionId: string; position: SessionDropPosition } | null>(null); + const draggingRef = useRef(null); + const dropTargetRef = useRef<{ sessionId: string; position: SessionDropPosition } | null>(null); + const pendingRectsRef = useRef | null>(null); + const animationsRef = useRef>(new Map()); + const lastDropAtRef = useRef(0); + + // Rows the list has already shown. Only ids observed to disappear are pruned + // from the stored order: a metadata page boundary is not a removal. + const seenIdsRef = useRef<{ scopeKey: ManualOrderScopeKey; ids: Set }>({ + scopeKey, + ids: new Set(), + }); + useEffect(() => { + if (seenIdsRef.current.scopeKey !== scopeKey) { + seenIdsRef.current = { scopeKey, ids: new Set() }; + } + const seen = seenIdsRef.current.ids; + const present = new Set(sequence); + const removed: string[] = []; + for (const id of seen) { + if (!present.has(id)) removed.push(id); + } + for (const id of present) seen.add(id); + if (removed.length > 0) pruneSessionScopeOrder(scopeKey, removed); + }, [scopeKey, sequence]); + + useEffect(() => () => { + animationsRef.current.forEach(animation => animation.cancel()); + animationsRef.current.clear(); + }, []); + + // FLIP settle: rows that swapped places start from their previous slot. + useLayoutEffect(() => { + const previousRects = pendingRectsRef.current; + if (!previousRects) return; + pendingRectsRef.current = null; + if (typeof window.matchMedia === 'function' + && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + return; + } + const list = listRef.current; + if (!list) return; + + for (const element of list.querySelectorAll('[data-session-id]')) { + const sessionId = element.dataset.sessionId; + if (!sessionId || typeof element.animate !== 'function') continue; + const previousRect = previousRects.get(sessionId); + if (!previousRect) continue; + const deltaY = previousRect.top - element.getBoundingClientRect().top; + if (Math.abs(deltaY) < 0.5) continue; + + animationsRef.current.get(sessionId)?.cancel(); + const animation = element.animate( + [ + { transform: `translateY(${deltaY}px)` }, + { transform: 'translateY(0)' }, + ], + { duration: SESSION_ROW_DROP_SETTLE_MS, easing: SESSION_ROW_DROP_EASING }, + ); + animationsRef.current.set(sessionId, animation); + animation.addEventListener('finish', () => { + if (animationsRef.current.get(sessionId) === animation) { + animationsRef.current.delete(sessionId); + } + }, { once: true }); + } + }, [listRef, sequence]); + + const commitOrder = useCallback((current: readonly string[], next: readonly string[]) => { + if (isSameSessionOrder(current, next)) { + // Dropped where it already was: nothing moved, so no settle either. + pendingRectsRef.current = null; + return; + } + writeSessionScopeOrder(scopeKey, current, next); + commitRef.current?.(); + }, [scopeKey]); + + const handleDragStart = useCallback( + (sessionId: string) => (event: React.DragEvent) => { + const groupKey = groupKeyBySessionId.get(sessionId); + if (!enabled || groupKey === undefined || groupIdsOf(sessionId).length < 2) return; + + const payload: SessionDragPayload = { sessionId, scopeKey, groupKey }; + const serializedPayload = JSON.stringify(payload); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData(SESSION_ROW_DRAG_MIME_TYPE, serializedPayload); + event.dataTransfer.setData(SESSION_ROW_DRAG_PLAIN_MIME_TYPE, serializedPayload); + draggingRef.current = payload; + setDraggingSessionId(sessionId); + }, + [enabled, groupIdsOf, groupKeyBySessionId, scopeKey], + ); + + const handleDragEnd = useCallback(() => { + draggingRef.current = null; + dropTargetRef.current = null; + setDraggingSessionId(null); + setDropTarget(null); + }, []); + + const handleDragOver = useCallback( + (sessionId: string) => (event: React.DragEvent) => { + const dragged = draggingRef.current; + const groupKey = groupKeyBySessionId.get(sessionId); + // Browsers hide dataTransfer data during dragover, but `types` stays readable. + const isSessionDrag = event.dataTransfer.types.includes(SESSION_ROW_DRAG_MIME_TYPE); + // Another scope, another sibling group or the dragged row itself: refuse the + // drop here and now, so nothing downstream can move or corrupt an order. + if ( + !dragged + || !isSessionDrag + || dragged.scopeKey !== scopeKey + || dragged.groupKey !== groupKey + || dragged.sessionId === sessionId + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + + const rect = event.currentTarget.getBoundingClientRect(); + const position = resolveSessionDropPosition(event.clientY, rect.top, rect.height); + setDropTarget(current => { + if (current?.sessionId === sessionId && current.position === position) return current; + const next = { sessionId, position }; + dropTargetRef.current = next; + return next; + }); + }, + [groupKeyBySessionId, scopeKey], + ); + + const handleDragLeave = useCallback( + (sessionId: string) => (event: React.DragEvent) => { + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; + setDropTarget(current => { + if (current?.sessionId !== sessionId) return current; + dropTargetRef.current = null; + return null; + }); + }, + [], + ); + + const handleDrop = useCallback( + (sessionId: string) => (event: React.DragEvent) => { + // Reading dataTransfer data is allowed on drop. + const payloadText = event.dataTransfer.getData(SESSION_ROW_DRAG_MIME_TYPE) + || event.dataTransfer.getData(SESSION_ROW_DRAG_PLAIN_MIME_TYPE); + if (!payloadText) return; + + let payload: SessionDragPayload; + try { + payload = JSON.parse(payloadText) as SessionDragPayload; + } catch { + return; + } + + const groupKey = groupKeyBySessionId.get(sessionId); + const isSameScope = payload?.scopeKey === scopeKey; + if ( + !payload?.sessionId + || !isSameScope + || groupKey === undefined + || payload.groupKey !== groupKey + || payload.sessionId === sessionId + ) { + // Dropped from another scope or onto another group: leave every order as it was. + handleDragEnd(); + return; + } + + event.preventDefault(); + event.stopPropagation(); + + // Read the insertion side off this event: a fast drop can land without a + // dragover, and a dragleave may already have cleared the target. The tracked + // target is only a fallback for a row that reports no height at all. + const rect = event.currentTarget.getBoundingClientRect(); + const position = rect.height > 0 + ? resolveSessionDropPosition(event.clientY, rect.top, rect.height) + : dropTargetRef.current?.sessionId === sessionId + ? dropTargetRef.current.position + : 'after'; + const current = sequenceRef.current; + const next = resolveSessionDropOrder({ + sequence: current, + sessionId: payload.sessionId, + targetId: sessionId, + position, + }); + const list = listRef.current; + pendingRectsRef.current = list + ? new Map(Array.from( + list.querySelectorAll('[data-session-id]'), + element => [element.dataset.sessionId ?? '', element.getBoundingClientRect()] as const, + )) + : null; + + lastDropAtRef.current = Date.now(); + handleDragEnd(); + commitOrder(current, next); + }, + [commitOrder, groupKeyBySessionId, handleDragEnd, listRef, scopeKey], + ); + + const canReorderSession = useCallback( + (sessionId: string): boolean => { + if (!enabled) return false; + // The row itself has to be on screen, next to at least one rendered sibling: + // dragging against a row behind the render boundary would move nothing the + // user can see, so those rows never become drag sources or drop targets. + const groupIds = groupIdsOf(sessionId); + return groupIds.includes(sessionId) && groupIds.length > 1; + }, + [enabled, groupIdsOf], + ); + + const isRowClickSuppressed = useCallback( + () => Date.now() - lastDropAtRef.current < SESSION_DRAG_CLICK_GUARD_MS, + [], + ); + + const rowDragProps = useCallback( + (sessionId: string): SessionRowDragProps => ({ + draggable: canReorderSession(sessionId), + 'aria-grabbed': draggingSessionId === sessionId ? true : undefined, + onDragStart: handleDragStart(sessionId), + onDragOver: handleDragOver(sessionId), + onDragLeave: handleDragLeave(sessionId), + onDrop: handleDrop(sessionId), + onDragEnd: handleDragEnd, + }), + [ + canReorderSession, + draggingSessionId, + handleDragEnd, + handleDragLeave, + handleDragOver, + handleDragStart, + handleDrop, + ], + ); + + return useMemo(() => ({ + scopeKey, + isDragging: sessionId => draggingSessionId === sessionId, + dropPositionFor: sessionId => (dropTarget?.sessionId === sessionId ? dropTarget.position : null), + rowDragProps, + isRowClickSuppressed, + }), [ + draggingSessionId, + dropTarget, + isRowClickSuppressed, + rowDragProps, + scopeKey, + ]); +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowManualReorder.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowManualReorder.test.tsx new file mode 100644 index 0000000000..9209eba5db --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowManualReorder.test.tsx @@ -0,0 +1,536 @@ +// @vitest-environment jsdom + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import React, { act, useCallback, useEffect, useMemo, useRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { notificationService } from '@/shared/notification-system'; +import { useWorkspaceSessionViewStore, readManualOrder } from '../../workspaceSessionView'; +import { + SESSION_ROW_DRAG_MIME_TYPE, + SESSION_ROW_DROP_SETTLE_MS, + SESSION_TOP_LEVEL_GROUP_KEY, + applyGroupSequence, + isSameSessionOrder, + orderSessionsForManualView, + resolveSessionDropOrder, + resolveSessionDropPosition, + sessionManualOrderScopeKey, + switchSessionViewToManualOrdering, + useSessionManualOrder, + useSessionManualReorder, + type SessionManualReorderHandle, +} from './sessionManualReorder'; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const ROW_HEIGHT = 26; + +interface FakeDataTransfer { + effectAllowed: string; + dropEffect: string; + readonly types: string[]; + setData(type: string, value: string): void; + getData(type: string): string; +} + +function createDataTransfer(): FakeDataTransfer { + const data: Record = {}; + return { + effectAllowed: 'none', + dropEffect: 'none', + get types() { + return Object.keys(data); + }, + setData(type: string, value: string) { + data[type] = value; + }, + getData(type: string) { + return data[type] ?? ''; + }, + }; +} + +function fireDrag( + element: HTMLElement, + type: 'dragstart' | 'dragover' | 'dragleave' | 'drop' | 'dragend', + options: { dataTransfer: FakeDataTransfer; clientY?: number }, +): Event { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { value: options.dataTransfer }); + Object.defineProperty(event, 'clientY', { value: options.clientY ?? 0 }); + act(() => { + element.dispatchEvent(event); + }); + return event; +} + +interface TestListProps { + scopeKey: string; + /** Loaded rows of the scope, in display order. */ + parents: string[]; + /** The subset of loaded rows the list actually renders; defaults to all of them. */ + rendered?: string[]; + children?: Record; + enabled?: boolean; + onCommit?: () => void; + onHandle?: (handle: SessionManualReorderHandle) => void; +} + +/** + * The smallest faithful copy of the section's own projection: the rows the hook + * sees are the rows in their current (manual) display order, exactly like + * `allTopLevelSessions`/`childrenByParent` are in `SessionsSection`, while + * `rendered` stands in for `visibleSessionIds` (display limit / active filters). + */ +const TestList: React.FC = ({ + scopeKey, + parents, + rendered, + children, + enabled = true, + onCommit, + onHandle, +}) => { + const listRef = useRef(null); + const manualOrder = useSessionManualOrder(scopeKey); + const orderedParents = useMemo( + () => orderSessionsForManualView(parents.map(sessionId => ({ sessionId })), manualOrder), + [manualOrder, parents], + ); + const orderedChildren = useMemo( + () => new Map(Object.entries(children ?? {}).map(([parentId, ids]) => [ + parentId, + orderSessionsForManualView(ids.map(sessionId => ({ sessionId })), manualOrder), + ] as const)), + [children, manualOrder], + ); + const renderedIds = useMemo(() => { + if (rendered) return new Set(rendered); + const all = new Set(parents); + for (const childIds of Object.values(children ?? {})) { + for (const childId of childIds) all.add(childId); + } + return all; + }, [children, parents, rendered]); + // The switch the section wires to a commit runs here too, so a test can see the + // ordering mode it produces next to whatever the caller wanted to observe. + const commit = useCallback(() => { + onCommit?.(); + switchSessionViewToManualOrdering(); + }, [onCommit]); + const handle = useSessionManualReorder({ + scopeKey, + enabled, + topLevelSessions: orderedParents, + childrenByParent: orderedChildren, + renderedIds, + listRef, + onManualOrderCommit: commit, + }); + useEffect(() => { + onHandle?.(handle); + }); + + const renderRow = (sessionId: string) => { + const position = handle.dropPositionFor(sessionId); + return ( +
+ {position ? ( +
+ ); + }; + + return ( +
+ {orderedParents + .filter(parent => renderedIds.has(parent.sessionId)) + .map(parent => ( + + {renderRow(parent.sessionId)} + {(orderedChildren.get(parent.sessionId) ?? []) + .filter(child => renderedIds.has(child.sessionId)) + .map(child => renderRow(child.sessionId))} + + ))} +
+ ); +}; + +describe('session manual reorder helpers', () => { + it('picks the insertion side from the pointer against the row midline', () => { + expect(resolveSessionDropPosition(10, 0, 26)).toBe('before'); + expect(resolveSessionDropPosition(12, 0, 26)).toBe('before'); + expect(resolveSessionDropPosition(13, 0, 26)).toBe('after'); + expect(resolveSessionDropPosition(26, 0, 26)).toBe('after'); + }); + + it('re-inserts a dragged top-level row on either side of its target', () => { + const sequence = ['p1', 'c1', 'p2', 'c2', 'p3']; + + expect(resolveSessionDropOrder({ + sequence, sessionId: 'p1', targetId: 'p3', position: 'after', + })).toEqual(['c1', 'p2', 'c2', 'p3', 'p1']); + + expect(resolveSessionDropOrder({ + sequence, sessionId: 'p3', targetId: 'p2', position: 'before', + })).toEqual(['p1', 'c1', 'p3', 'p2', 'c2']); + + // Dropping a row back onto itself is the identity. + expect(isSameSessionOrder( + resolveSessionDropOrder({ sequence, sessionId: 'p2', targetId: 'p2', position: 'after' }), + sequence, + )).toBe(true); + }); + + it('keeps a child inside its own parent block', () => { + const sequence = ['p1', 'c1', 'c2', 'c3', 'p2']; + const moved = resolveSessionDropOrder({ + sequence, sessionId: 'c1', targetId: 'c3', position: 'after', + }); + + expect(moved).toEqual(['p1', 'c2', 'c3', 'c1', 'p2']); + // A child move never reorders the parents. + expect(moved.filter(id => id.startsWith('p'))).toEqual(['p1', 'p2']); + }); + + it('rewrites only the slots the moved group occupies', () => { + expect(applyGroupSequence(['a', 'b', 'c', 'd'], ['b', 'd'], ['d', 'b'])) + .toEqual(['a', 'd', 'c', 'b']); + }); + + it('applies the stored order through the shared session identity', () => { + const items = [{ sessionId: 'a' }, { sessionId: 'b' }, { sessionId: 'c' }]; + expect(orderSessionsForManualView(items, ['c', 'b', 'a']).map(item => item.sessionId)) + .toEqual(['c', 'b', 'a']); + }); + + it('keys a scope per workspace and keeps the flat list on the shared scope', () => { + expect(sessionManualOrderScopeKey('workspace-1')).toBe('workspace:workspace-1'); + expect(sessionManualOrderScopeKey(undefined)).toBe('all'); + expect(SESSION_TOP_LEVEL_GROUP_KEY).toBe('top'); + }); +}); + +describe('SessionsSection manual drag reorder', () => { + let container: HTMLDivElement; + let root: Root; + let handle: SessionManualReorderHandle | null; + let commitSpy: ReturnType; + let animateSpy: ReturnType; + let noticeSpies: ReturnType[]; + + const captureHandle = (next: SessionManualReorderHandle) => { + handle = next; + }; + + /** A reorder is silent: the mode control shows the switch, no toast announces it. */ + const expectNoNotice = () => { + for (const spy of noticeSpies) expect(spy).not.toHaveBeenCalled(); + }; + + beforeEach(() => { + localStorage.clear(); + // The view store outlives a test file; start every case without a drag order + // and on the default ordering, so the switch under test is not a no-op. + useWorkspaceSessionViewStore.setState({ manualOrder: {}, ordering: 'updated' }); + handle = null; + commitSpy = vi.fn(); + animateSpy = vi.fn(() => ({ + cancel: () => {}, + addEventListener: () => {}, + })); + noticeSpies = [ + vi.spyOn(notificationService, 'info'), + vi.spyOn(notificationService, 'success'), + vi.spyOn(notificationService, 'warning'), + vi.spyOn(notificationService, 'error'), + ]; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + // jsdom has no layout: stack rows by their index so the drop geometry and the + // FLIP settle measure something real. + HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement): DOMRect { + const parent = this.parentElement; + const index = parent ? Array.prototype.indexOf.call(parent.children, this) : 0; + const top = index * ROW_HEIGHT; + return { + top, + bottom: top + ROW_HEIGHT, + height: ROW_HEIGHT, + left: 0, + right: 100, + width: 100, + x: 0, + y: top, + toJSON: () => ({}), + } as DOMRect; + }; + (HTMLElement.prototype as unknown as { animate: unknown }).animate = animateSpy; + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + const renderLists = (lists: TestListProps[]) => { + act(() => { + root.render( + <> + {lists.map(list => ( + + ))} + , + ); + }); + }; + + const rowOf = (sessionId: string): HTMLElement => + document.querySelector(`[data-session-id="${sessionId}"]`)!; + + const renderedOrder = (scopeKey: string): string[] => + Array.from( + container.querySelectorAll(`[data-testid="list-${scopeKey}"] [data-session-id]`), + element => element.dataset.sessionId ?? '', + ); + + it('reorders the rows of one scope and settles them with the FLIP animation', () => { + renderLists([{ scopeKey: 'workspace:one', parents: ['a', 'b', 'c'] }]); + + expect(renderedOrder('workspace:one')).toEqual(['a', 'b', 'c']); + expect(rowOf('a').getAttribute('draggable')).toBe('true'); + + const transfer = createDataTransfer(); + fireDrag(rowOf('a'), 'dragstart', { dataTransfer: transfer }); + expect(transfer.getData(SESSION_ROW_DRAG_MIME_TYPE)).toContain('"sessionId":"a"'); + expect(rowOf('a').getAttribute('aria-grabbed')).toBe('true'); + + // Below the target's midline: the dragged row lands after it. + const dragOver = fireDrag(rowOf('c'), 'dragover', { dataTransfer: transfer, clientY: 70 }); + expect(dragOver.defaultPrevented).toBe(true); + expect(rowOf('c').className).toContain('is-manual-drop-after'); + expect(container.querySelector('.openbitfun-nav-panel__inline-item-manual-drop-line')).not.toBeNull(); + + const dropped = fireDrag(rowOf('c'), 'drop', { dataTransfer: transfer, clientY: 70 }); + expect(dropped.defaultPrevented).toBe(true); + expect(readManualOrder('workspace:one')).toEqual(['b', 'c', 'a']); + expect(renderedOrder('workspace:one')).toEqual(['b', 'c', 'a']); + expect(commitSpy).toHaveBeenCalledTimes(1); + // The commit hands the view over to the manual order it just wrote, quietly. + expect(useWorkspaceSessionViewStore.getState().ordering).toBe('manual'); + expectNoNotice(); + expect(handle!.isDragging('a')).toBe(false); + expect(handle!.dropPositionFor('c')).toBeNull(); + // The drop gesture must not reach the row's open handler. + expect(handle!.isRowClickSuppressed()).toBe(true); + + // Rows that changed slot start from their previous position for ~160ms. + expect(animateSpy).toHaveBeenCalled(); + expect(animateSpy.mock.calls[0][1]).toMatchObject({ duration: SESSION_ROW_DROP_SETTLE_MS }); + + fireDrag(rowOf('a'), 'dragend', { dataTransfer: transfer }); + expect(handle!.isDragging('a')).toBe(false); + }); + + it('drops above the row when the pointer is over its upper half', () => { + renderLists([{ scopeKey: 'workspace:one', parents: ['a', 'b', 'c'] }]); + + const transfer = createDataTransfer(); + fireDrag(rowOf('c'), 'dragstart', { dataTransfer: transfer }); + fireDrag(rowOf('a'), 'dragover', { dataTransfer: transfer, clientY: 0 }); + expect(rowOf('a').className).toContain('is-manual-drop-before'); + + fireDrag(rowOf('a'), 'drop', { dataTransfer: transfer, clientY: 0 }); + expect(readManualOrder('workspace:one')).toEqual(['c', 'a', 'b']); + expect(renderedOrder('workspace:one')).toEqual(['c', 'a', 'b']); + }); + + it('drops above the row even when the row never saw a dragover', () => { + renderLists([{ scopeKey: 'workspace:one', parents: ['a', 'b', 'c'] }]); + + // The drop event alone decides the side: no dragover, so no tracked target + // and no drop line to fall back on. + expect(handle!.dropPositionFor('b')).toBeNull(); + const transfer = createDataTransfer(); + fireDrag(rowOf('c'), 'dragstart', { dataTransfer: transfer }); + fireDrag(rowOf('b'), 'drop', { dataTransfer: transfer, clientY: 30 }); + + // 'b' spans 26-52, so 30 is its upper half: 'c' lands above it. + expect(readManualOrder('workspace:one')).toEqual(['a', 'c', 'b']); + expect(renderedOrder('workspace:one')).toEqual(['a', 'c', 'b']); + }); + + it('refuses a drop from another scope and leaves both orders untouched', () => { + renderLists([ + { scopeKey: 'workspace:one', parents: ['one-a', 'one-b'] }, + { scopeKey: 'workspace:two', parents: ['two-a', 'two-b'] }, + ]); + + const transfer = createDataTransfer(); + fireDrag(rowOf('one-a'), 'dragstart', { dataTransfer: transfer }); + + const dragOver = fireDrag(rowOf('two-b'), 'dragover', { dataTransfer: transfer, clientY: 70 }); + // No preventDefault: the browser keeps showing "no drop" over the foreign row. + expect(dragOver.defaultPrevented).toBe(false); + expect(rowOf('two-b').className).not.toContain('is-manual-drop'); + + fireDrag(rowOf('two-b'), 'drop', { dataTransfer: transfer, clientY: 70 }); + expect(readManualOrder('workspace:two')).toEqual([]); + expect(readManualOrder('workspace:one')).toEqual([]); + expect(renderedOrder('workspace:two')).toEqual(['two-a', 'two-b']); + expect(renderedOrder('workspace:one')).toEqual(['one-a', 'one-b']); + }); + + it('reorders a child among its own siblings only', () => { + renderLists([{ + scopeKey: 'workspace:one', + parents: ['p1', 'p2'], + children: { p1: ['c1', 'c2'], p2: ['d1'] }, + }]); + + const transfer = createDataTransfer(); + fireDrag(rowOf('c1'), 'dragstart', { dataTransfer: transfer }); + + // A child of another parent is not a sibling: no drop. + expect(fireDrag(rowOf('d1'), 'dragover', { dataTransfer: transfer, clientY: 70 }).defaultPrevented).toBe(false); + + expect(fireDrag(rowOf('c2'), 'dragover', { dataTransfer: transfer, clientY: 70 }).defaultPrevented).toBe(true); + + fireDrag(rowOf('c2'), 'drop', { dataTransfer: transfer, clientY: 70 }); + expect(renderedOrder('workspace:one')).toEqual(['p1', 'c2', 'c1', 'p2', 'd1']); + expect(readManualOrder('workspace:one')).toEqual(['p1', 'c2', 'c1', 'p2', 'd1']); + }); + + it('keeps the stored order bounded when a session leaves the list', () => { + renderLists([{ scopeKey: 'workspace:one', parents: ['a', 'b', 'c'] }]); + + const transfer = createDataTransfer(); + fireDrag(rowOf('c'), 'dragstart', { dataTransfer: transfer }); + fireDrag(rowOf('a'), 'drop', { dataTransfer: transfer, clientY: 0 }); + expect(readManualOrder('workspace:one')).toEqual(['c', 'a', 'b']); + + renderLists([{ scopeKey: 'workspace:one', parents: ['a', 'c'] }]); + + expect(readManualOrder('workspace:one')).toEqual(['c', 'a']); + expect(renderedOrder('workspace:one')).toEqual(['c', 'a']); + }); + + it('keeps a drag inside the rendered rows and off a loaded-but-hidden sibling', () => { + // Five rows are rendered (the default expansion is 5), while the prefetch keeps + // eight loaded. The three hidden rows are ordered by the same stored order, but + // they can neither start nor receive a drag, and their slots survive one. + const loaded = ['r1', 'r2', 'r3', 'r4', 'r5', 'b0', 'b1', 'b2']; + const rendered = loaded.slice(0, 5); + renderLists([{ scopeKey: 'workspace:one', parents: loaded, rendered }]); + + expect(renderedOrder('workspace:one')).toEqual(rendered); + // A row behind the render boundary is not a drag source and has no target. + expect(handle!.rowDragProps('b0').draggable).toBe(false); + expect(handle!.rowDragProps('r5').draggable).toBe(true); + expect(container.querySelector('[data-session-id="b0"]')).toBeNull(); + expect(handle!.dropPositionFor('b0')).toBeNull(); + + // A drop between two rendered rows still works... + const transfer = createDataTransfer(); + fireDrag(rowOf('r1'), 'dragstart', { dataTransfer: transfer }); + fireDrag(rowOf('r3'), 'drop', { dataTransfer: transfer, clientY: 70 }); + + expect(renderedOrder('workspace:one')).toEqual(['r2', 'r3', 'r1', 'r4', 'r5']); + // ...and leaves the slots of the loaded-but-hidden rows exactly where they were. + expect(readManualOrder('workspace:one')).toEqual(['r2', 'r3', 'r1', 'r4', 'r5', 'b0', 'b1', 'b2']); + // The last rendered row can only move to a rendered position: it never passes + // the hidden siblings, so the stored order keeps them behind it. + const secondTransfer = createDataTransfer(); + fireDrag(rowOf('r5'), 'dragstart', { dataTransfer: secondTransfer }); + fireDrag(rowOf('r4'), 'drop', { dataTransfer: secondTransfer, clientY: 70 }); + expect(renderedOrder('workspace:one')).toEqual(['r2', 'r3', 'r1', 'r5', 'r4']); + expect(readManualOrder('workspace:one')).toEqual(['r2', 'r3', 'r1', 'r5', 'r4', 'b0', 'b1', 'b2']); + }); + + it('leaves rows static outside the workspace view preferences', () => { + renderLists([{ scopeKey: 'workspace:one', parents: ['a', 'b'], enabled: false }]); + + expect(rowOf('a').getAttribute('draggable')).toBe('false'); + expect(handle!.rowDragProps('a').draggable).toBe(false); + + // A row that cannot reorder never starts a drag, so the sibling refuses it too. + const transfer = createDataTransfer(); + fireDrag(rowOf('a'), 'dragstart', { dataTransfer: transfer }); + expect(transfer.getData(SESSION_ROW_DRAG_MIME_TYPE)).toBe(''); + expect(fireDrag(rowOf('b'), 'dragover', { dataTransfer: transfer, clientY: 70 }).defaultPrevented).toBe(false); + + expect(readManualOrder('workspace:one')).toEqual([]); + expect(renderedOrder('workspace:one')).toEqual(['a', 'b']); + }); +}); + +describe('SessionsSection manual reorder wiring', () => { + // jsdom turns `import.meta.url` into an http URL, so the section source is read + // from disk by walking up from the vitest working directory instead. + const source = (): string => { + const relativePath = 'src/app/components/NavPanel/sections/sessions/SessionsSection.tsx'; + let directory = process.cwd(); + for (let depth = 0; depth < 4; depth += 1) { + const candidate = join(directory, relativePath); + if (existsSync(candidate)) { + return readFileSync(candidate, 'utf8').replace(/\r\n/g, '\n'); + } + directory = dirname(directory); + } + throw new Error(`SessionsSection.tsx not found from ${process.cwd()}`); + }; + + it('orders the visible list by the stored manual order of its scope', () => { + const text = source(); + expect(text).toContain("} from './sessionManualReorder';"); + expect(text).toContain('useSessionManualOrder(manualSessionOrderScopeKey)'); + expect(text).toContain("const isManualOrdering = sessionOrdering === 'manual';"); + expect(text).toContain('? orderSessionsForManualView(list, manualSessionOrder)'); + expect(text).toContain(': [...list].sort(compareForCurrentView);'); + expect(text).toContain('topLevelSessions: orderForCurrentView(parents),'); + }); + + it('wires the row drag handlers, the drop line and the click guard', () => { + const text = source(); + expect(text).toContain('{...sessionReorder.rowDragProps(session.sessionId)}'); + expect(text).toContain('is-manual-drop-${dropPosition}'); + expect(text).toContain('openbitfun-nav-panel__inline-item-manual-drop-line'); + expect(text).toContain('sessionReorder.isRowClickSuppressed()'); + expect(text).toContain('sessionReorder.isDragging(session.sessionId)'); + // The inline title editor keeps the text selection drag. + expect(text).toContain('enabled: useWorkspaceViewPreferences && editingSessionId === null,'); + }); + + it('switches the session view to manual ordering when a reorder commits, without a notice', () => { + const text = source(); + expect(text).toContain('onManualOrderCommit: switchSessionViewToManualOrdering,'); + // The drag is the only way to reorder: no row-menu move items, no toast. + expect(text).not.toContain('nav-session-menu-move-'); + expect(text).not.toContain('moveByOffset'); + expect(text).not.toContain('notificationService.info('); + expect(text).not.toContain('manualOrderSwitched'); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/workspaceSessionView.test.ts b/src/web-ui/src/app/components/NavPanel/workspaceSessionView.test.ts index a9c74a06e3..83c15a15af 100644 --- a/src/web-ui/src/app/components/NavPanel/workspaceSessionView.test.ts +++ b/src/web-ui/src/app/components/NavPanel/workspaceSessionView.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from 'vitest'; +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from 'vitest'; import type { Session } from '@/flow_chat/types/flow-chat'; import { compareWorkspaceNavSessions, @@ -10,8 +12,15 @@ import { getNextWorkspaceSessionGrouping, matchesWorkspaceSessionView, normalizeWorkspaceSessionGrouping, + normalizeWorkspaceSessionOrdering, + readManualOrder, + useWorkspaceSessionViewStore, + WORKSPACE_SESSION_ORDERING_MODES, + writeManualOrder, } from './workspaceSessionView'; +const WORKSPACE_SESSION_VIEW_STORAGE_KEY = 'openbitfun.workspace-session-view.v2'; + const session = (overrides: Partial): Session => ({ sessionId: 'session', title: 'Session', @@ -98,3 +107,45 @@ describe('workspace session view model', () => { }, false)).toBe(false); }); }); + +describe('workspace session ordering preference', () => { + afterEach(() => { + window.localStorage.clear(); + useWorkspaceSessionViewStore.setState({ manualOrder: {} }); + }); + + it('accepts the manual mode and normalizes unavailable persisted values', () => { + expect(WORKSPACE_SESSION_ORDERING_MODES).toEqual(['updated', 'status', 'created', 'name', 'manual']); + for (const mode of WORKSPACE_SESSION_ORDERING_MODES) { + expect(normalizeWorkspaceSessionOrdering(mode)).toBe(mode); + } + expect(normalizeWorkspaceSessionOrdering('workspace')).toBe('updated'); + expect(normalizeWorkspaceSessionOrdering(undefined)).toBe('updated'); + expect(normalizeWorkspaceSessionOrdering(null)).toBe('updated'); + }); + + it('persists the manual drag order per scope next to the other preferences', () => { + expect(readManualOrder('all')).toEqual([]); + + writeManualOrder('workspace:ws-1', ['s2', 's1']); + writeManualOrder('all', ['s3']); + + expect(readManualOrder('workspace:ws-1')).toEqual(['s2', 's1']); + expect(readManualOrder('all')).toEqual(['s3']); + const persisted = JSON.parse(window.localStorage.getItem(WORKSPACE_SESSION_VIEW_STORAGE_KEY) ?? 'null'); + expect(persisted.state.manualOrder).toEqual({ 'workspace:ws-1': ['s2', 's1'], all: ['s3'] }); + }); + + it('drops unusable stored scopes instead of failing to hydrate', () => { + window.localStorage.setItem( + WORKSPACE_SESSION_VIEW_STORAGE_KEY, + JSON.stringify({ state: { manualOrder: { all: ['s1', 4, 's1'], 'workspace:ws-1': 's2' } }, version: 3 }), + ); + + expect(() => useWorkspaceSessionViewStore.persist.rehydrate()).not.toThrow(); + + expect(readManualOrder('all')).toEqual(['s1']); + expect(readManualOrder('workspace:ws-1')).toEqual([]); + expect(useWorkspaceSessionViewStore.getState().manualOrder).toEqual({ all: ['s1'] }); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts b/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts index 7a3443d96e..85fe7e402e 100644 --- a/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts +++ b/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts @@ -2,9 +2,14 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import type { Session } from '@/flow_chat/types/flow-chat'; import { isWorktreeIsolatedSession } from '@/flow_chat/utils/sessionOrdering'; +import type { ManualOrderScopeKey, SessionOrderingMode } from '@/flow_chat/utils/sessionManualOrder'; export type WorkspaceSessionGrouping = 'grouped' | 'all'; -export type WorkspaceSessionOrdering = 'updated' | 'status' | 'created' | 'name'; +/** + * Navigation ordering modes. `manual` defers to the drag order stored per scope, + * and the alias keeps every mode assignable to the shared ordering contract. + */ +export type WorkspaceSessionOrdering = SessionOrderingMode; export type WorkspaceSessionShow = 'all' | 'unread' | 'attention'; export type WorkspaceSessionStatus = 'running' | 'attention' | 'error' | 'completed' | 'idle'; export type WorkspaceSessionEnvironment = 'local' | 'remote' | 'detached'; @@ -26,10 +31,15 @@ export interface WorkspaceSessionViewPreferences { filters: WorkspaceSessionFilters; } +/** Drag order per scope key; only scopes the user actually reordered are listed. */ +type WorkspaceSessionManualOrder = Record; + interface WorkspaceSessionViewState extends WorkspaceSessionViewPreferences { + manualOrder: WorkspaceSessionManualOrder; collapseAllRequestId: number; setGrouping: (grouping: WorkspaceSessionGrouping) => void; setOrdering: (ordering: WorkspaceSessionOrdering) => void; + setManualOrder: (scopeKey: ManualOrderScopeKey, order: readonly string[]) => void; setShow: (show: WorkspaceSessionShow) => void; toggleHiddenStatus: (value: WorkspaceSessionStatus) => void; toggleHiddenEnvironment: (value: WorkspaceSessionEnvironment) => void; @@ -62,6 +72,76 @@ export const getNextWorkspaceSessionGrouping = ( export const normalizeWorkspaceSessionGrouping = (value: unknown): WorkspaceSessionGrouping => value === 'all' ? 'all' : 'grouped'; +export const WORKSPACE_SESSION_ORDERING_MODES: readonly WorkspaceSessionOrdering[] = [ + 'updated', + 'status', + 'created', + 'name', + 'manual', +]; + +/** Unknown or legacy persisted values fall back to recency instead of breaking the list. */ +export const normalizeWorkspaceSessionOrdering = (value: unknown): WorkspaceSessionOrdering => + WORKSPACE_SESSION_ORDERING_MODES.includes(value as WorkspaceSessionOrdering) + ? value as WorkspaceSessionOrdering + : DEFAULT_WORKSPACE_SESSION_VIEW.ordering; + +/** Shared stand-in for "no drag order stored yet", so selectors keep a stable reference. */ +const EMPTY_MANUAL_ORDER: string[] = []; + +/** A stored order only holds non-empty session ids, each at most once. */ +function sanitizeManualOrder(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const result: string[] = []; + for (const id of value) { + if (typeof id !== 'string' || id.length === 0 || seen.has(id)) continue; + seen.add(id); + result.push(id); + } + return result; +} + +function isSanitizedManualOrder(value: unknown): value is string[] { + return Array.isArray(value) + && value.every(id => typeof id === 'string' && id.length > 0) + && new Set(value).size === value.length; +} + +/** Tolerant parse of the persisted scope map: unusable entries are dropped, never thrown on. */ +function normalizeWorkspaceSessionManualOrder(value: unknown): WorkspaceSessionManualOrder { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const normalized: WorkspaceSessionManualOrder = {}; + for (const [scopeKey, order] of Object.entries(value as Record)) { + const sanitized = sanitizeManualOrder(order); + if (sanitized.length > 0) normalized[scopeKey] = sanitized; + } + return normalized; +} + +const sameManualOrder = (left: readonly string[], right: readonly string[]): boolean => + left.length === right.length && left.every((id, index) => id === right[index]); + +/** + * Returns the current map when the write changes nothing, so a repeated reconcile + * during render cannot loop through the store. An empty order drops the scope + * entirely instead of keeping an unused key in storage forever. + */ +function withManualOrder( + current: WorkspaceSessionManualOrder, + scopeKey: ManualOrderScopeKey, + order: readonly string[], +): WorkspaceSessionManualOrder { + const next = sanitizeManualOrder(order); + const existing = sanitizeManualOrder(current[scopeKey]); + if (sameManualOrder(existing, next)) return current; + + const updated = { ...current }; + if (next.length === 0) delete updated[scopeKey]; + else updated[scopeKey] = next; + return updated; +} + const toggleArrayValue = (values: T[], value: T): T[] => values.includes(value) ? values.filter(item => item !== value) : [...values, value]; @@ -69,9 +149,14 @@ export const useWorkspaceSessionViewStore = create()( persist( (set) => ({ ...DEFAULT_WORKSPACE_SESSION_VIEW, + manualOrder: {}, collapseAllRequestId: 0, setGrouping: grouping => set({ grouping }), setOrdering: ordering => set({ ordering }), + setManualOrder: (scopeKey, order) => set(state => { + const manualOrder = withManualOrder(state.manualOrder, scopeKey, order); + return manualOrder === state.manualOrder ? state : { manualOrder }; + }), setShow: show => set({ show }), toggleHiddenStatus: value => set(state => ({ filters: { ...state.filters, hiddenStatuses: toggleArrayValue(state.filters.hiddenStatuses, value) }, @@ -95,7 +180,23 @@ export const useWorkspaceSessionViewStore = create()( name: 'openbitfun.workspace-session-view.v2', version: 3, storage: createJSONStorage(() => localStorage), - partialize: ({ grouping, ordering, show, filters }) => ({ grouping, ordering, show, filters }), + partialize: ({ grouping, ordering, show, filters, manualOrder }) => ( + { grouping, ordering, show, filters, manualOrder } + ), + merge: (persistedState, currentState): WorkspaceSessionViewState => { + const state = persistedState && typeof persistedState === 'object' + ? persistedState as Partial + : {}; + return { + ...currentState, + ...state, + grouping: normalizeWorkspaceSessionGrouping(state.grouping ?? currentState.grouping), + ordering: normalizeWorkspaceSessionOrdering(state.ordering ?? currentState.ordering), + manualOrder: normalizeWorkspaceSessionManualOrder( + state.manualOrder ?? currentState.manualOrder, + ), + }; + }, migrate: (persistedState: unknown) => { const state = persistedState && typeof persistedState === 'object' ? persistedState as Partial & { grouping?: unknown } @@ -109,6 +210,28 @@ export const useWorkspaceSessionViewStore = create()( ), ); +/** + * Drag order of one scope, as persisted with the rest of the session view + * preferences. The stored array is returned as-is so selectors keep a stable + * reference; treat it as read-only and pass changes through `writeManualOrder`. + */ +export function readManualOrder(scopeKey: ManualOrderScopeKey): string[] { + const stored = useWorkspaceSessionViewStore.getState().manualOrder[scopeKey]; + return isSanitizedManualOrder(stored) ? stored : EMPTY_MANUAL_ORDER; +} + +/** + * Persist the drag order of one scope. Best effort: storage being unavailable + * must not break the drag interaction, and the store keeps the order in memory. + */ +export function writeManualOrder(scopeKey: ManualOrderScopeKey, order: readonly string[]): void { + try { + useWorkspaceSessionViewStore.getState().setManualOrder(scopeKey, order); + } catch { + // Presentation-only preference; the in-memory store already holds the order. + } +} + export function deriveWorkspaceSessionStatus( session: Session, isRunning: boolean, diff --git a/src/web-ui/src/flow_chat/utils/sessionManualOrder.test.ts b/src/web-ui/src/flow_chat/utils/sessionManualOrder.test.ts new file mode 100644 index 0000000000..9e297203cb --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/sessionManualOrder.test.ts @@ -0,0 +1,205 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkspaceSessionOrdering } from '@/app/components/NavPanel/workspaceSessionView'; +import { + manualOrderScopeKey, + moveSessionInOrder, + orderSessionsManually, + reconcileManualOrder, +} from './sessionManualOrder'; +import type { SessionOrderingMode } from './sessionManualOrder'; + +const STORAGE_KEY = 'openbitfun.workspace-session-view.v2'; + +type SessionViewPreferencesModule = typeof import('@/app/components/NavPanel/workspaceSessionView'); + +/** Reloads the preference module as a fresh launch would, with an optional stored blob. */ +async function loadPreferences(storedBlob?: string): Promise { + vi.resetModules(); + window.localStorage.clear(); + if (storedBlob !== undefined) window.localStorage.setItem(STORAGE_KEY, storedBlob); + return import('@/app/components/NavPanel/workspaceSessionView'); +} + +const itemIds = (items: readonly T[], idOf: (item: T) => string): string[] => items.map(idOf); + +describe('manual session order', () => { + it('files the aggregate list under a different scope than each workspace', () => { + expect(manualOrderScopeKey({ workspaceId: 'ws-1' })).toBe('workspace:ws-1'); + expect(manualOrderScopeKey({ workspaceId: null })).toBe('all'); + expect(manualOrderScopeKey({ workspaceId: '' })).toBe('all'); + expect(manualOrderScopeKey({})).toBe('all'); + }); + + it('clamps a move to both ends of the list', () => { + const order = ['a', 'b', 'c']; + + expect(moveSessionInOrder(order, 'a', 99)).toEqual(['b', 'c', 'a']); + expect(moveSessionInOrder(order, 'c', -5)).toEqual(['c', 'a', 'b']); + expect(moveSessionInOrder(order, 'b', 0)).toEqual(['b', 'a', 'c']); + expect(moveSessionInOrder(order, 'b', 2)).toEqual(['a', 'c', 'b']); + }); + + it('inserts an unknown id at the clamped index instead of dropping it', () => { + expect(moveSessionInOrder(['a', 'b'], 'new', 1)).toEqual(['a', 'new', 'b']); + expect(moveSessionInOrder(['a', 'b'], 'new', 9)).toEqual(['a', 'b', 'new']); + expect(moveSessionInOrder(['a', 'b'], 'new', -2)).toEqual(['new', 'a', 'b']); + }); + + it('de-duplicates stored ids and never mutates the array it was given', () => { + const order = Object.freeze(['a', 'b', 'a', 'c']); + const moved = moveSessionInOrder(order, 'b', 0); + + expect(moved).toEqual(['b', 'a', 'c']); + expect(moved).not.toBe(order); + expect(order).toEqual(['a', 'b', 'a', 'c']); + expect(moveSessionInOrder(order, 'c', 1)).toEqual(['a', 'c', 'b']); + }); + + it('reconciles away deleted sessions while keeping the relative order', () => { + expect(reconcileManualOrder(['a', 'b', 'a', 'c', 'd'], ['c', 'd', 'a'])).toEqual(['a', 'c', 'd']); + expect(reconcileManualOrder(['a', 'b'], [])).toEqual([]); + expect(reconcileManualOrder([], ['a'])).toEqual([]); + }); + + it('keeps sessions missing from the order on top in their incoming order', () => { + const items = [ + { sessionId: 'a' }, + { sessionId: 'newest' }, + { sessionId: 'b' }, + { sessionId: 'newer' }, + { sessionId: 'c' }, + ]; + const idOf = (item: { sessionId: string }) => item.sessionId; + + expect(itemIds(orderSessionsManually(items, ['c', 'a', 'b'], idOf), idOf)) + .toEqual(['newest', 'newer', 'c', 'a', 'b']); + expect(itemIds(items, idOf)).toEqual(['a', 'newest', 'b', 'newer', 'c']); + }); + + it('skips ordered ids no item matches and ignores duplicates in the order', () => { + const items = [{ sessionId: 'a' }, { sessionId: 'b' }]; + const idOf = (item: { sessionId: string }) => item.sessionId; + + const ordered = orderSessionsManually(items, ['gone', 'b', 'b', 'a'], idOf); + expect(itemIds(ordered, idOf)).toEqual(['b', 'a']); + expect(ordered).not.toBe(items); + expect(itemIds(items, idOf)).toEqual(['a', 'b']); + }); + + it('returns the incoming order when nothing was ordered yet', () => { + const items = [{ sessionId: 'a' }, { sessionId: 'b' }]; + const idOf = (item: { sessionId: string }) => item.sessionId; + + expect(itemIds(orderSessionsManually(items, [], idOf), idOf)).toEqual(['a', 'b']); + }); + + it('keeps every navigation ordering mode inside the frozen ordering contract', () => { + const navOrderingModes: WorkspaceSessionOrdering[] = [ + 'updated', + 'status', + 'created', + 'name', + 'manual', + ]; + const frozenModes: SessionOrderingMode[] = navOrderingModes; + + expect(frozenModes).toEqual(['updated', 'status', 'created', 'name', 'manual']); + }); +}); + +describe('manual order persistence', () => { + let preferences: SessionViewPreferencesModule; + + beforeEach(async () => { + preferences = await loadPreferences(); + }); + + afterEach(() => { + vi.resetModules(); + window.localStorage.clear(); + }); + + it('reads an empty order until the user reorders a scope', () => { + expect(preferences.readManualOrder('all')).toEqual([]); + expect(preferences.readManualOrder('workspace:ws-1')).toEqual([]); + expect(preferences.useWorkspaceSessionViewStore.getState().manualOrder).toEqual({}); + }); + + it('stores the order with the rest of the session view preferences', () => { + preferences.writeManualOrder('workspace:ws-1', ['s2', 's1']); + + expect(preferences.readManualOrder('workspace:ws-1')).toEqual(['s2', 's1']); + expect(preferences.readManualOrder('all')).toEqual([]); + const persisted = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null'); + expect(persisted.state.manualOrder).toEqual({ 'workspace:ws-1': ['s2', 's1'] }); + }); + + it('restores the stored order on the next launch', async () => { + preferences.writeManualOrder('all', ['s3', 's1']); + const blob = window.localStorage.getItem(STORAGE_KEY) ?? undefined; + + const reloaded = await loadPreferences(blob); + + expect(reloaded.readManualOrder('all')).toEqual(['s3', 's1']); + }); + + it('sanitizes blank, duplicate and non-string ids on write', () => { + preferences.writeManualOrder('all', ['a', 'a', '', 'b', 7 as unknown as string]); + + expect(preferences.readManualOrder('all')).toEqual(['a', 'b']); + }); + + it('drops a scope whose order became empty', () => { + preferences.writeManualOrder('all', ['a']); + preferences.writeManualOrder('all', reconcileManualOrder(['a'], [])); + + expect(preferences.readManualOrder('all')).toEqual([]); + const persisted = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null'); + expect(persisted.state.manualOrder).toEqual({}); + }); + + it('keeps the stored map when a repeated write changes nothing', () => { + const store = preferences.useWorkspaceSessionViewStore; + preferences.writeManualOrder('all', ['a', 'b']); + const firstWrite = store.getState().manualOrder; + + preferences.writeManualOrder('all', ['b', 'a']); + const secondWrite = store.getState().manualOrder; + expect(secondWrite).not.toBe(firstWrite); + expect(secondWrite).toEqual({ all: ['b', 'a'] }); + + preferences.writeManualOrder('all', ['b', 'a']); + expect(store.getState().manualOrder).toBe(secondWrite); + }); + + it('tolerates corrupted or legacy storage without throwing', async () => { + const cases: [string, string[]][] = [ + ['{not json', []], + [JSON.stringify({ state: { manualOrder: { all: 's1' } }, version: 3 }), []], + [JSON.stringify({ state: { manualOrder: { all: [1, 2, null] } }, version: 3 }), []], + [JSON.stringify({ state: { manualOrder: { all: ['s1', 2, 's1', null, 's2'] } }, version: 3 }), ['s1', 's2']], + [JSON.stringify({ state: { manualOrder: [] }, version: 3 }), []], + [JSON.stringify({ state: null, version: 1 }), []], + [JSON.stringify({ state: 'broken', version: 2 }), []], + ]; + + for (const [blob, expected] of cases) { + const module = await loadPreferences(blob); + + expect(module.readManualOrder('all')).toEqual(expected); + expect(module.useWorkspaceSessionViewStore.getState().manualOrder) + .toEqual(expected.length > 0 ? { all: expected } : {}); + } + }); + + it('accepts manual as a persisted ordering and falls back for unknown values', async () => { + const manualMode = await loadPreferences(JSON.stringify({ state: { ordering: 'manual' }, version: 3 })); + expect(manualMode.useWorkspaceSessionViewStore.getState().ordering).toBe('manual'); + + const legacyMode = await loadPreferences(JSON.stringify({ state: { ordering: 'workspace', grouping: 'workspace' }, version: 1 })); + expect(legacyMode.useWorkspaceSessionViewStore.getState().ordering).toBe('updated'); + expect(legacyMode.useWorkspaceSessionViewStore.getState().grouping).toBe('grouped'); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/sessionManualOrder.ts b/src/web-ui/src/flow_chat/utils/sessionManualOrder.ts new file mode 100644 index 0000000000..10f4bd0398 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/sessionManualOrder.ts @@ -0,0 +1,116 @@ +/** + * Manual (drag-and-drop) session order: the ordering maths and the storage scope + * key only. + * + * The left navigation moves a row and reports the resulting list of ids; the + * session-view preference module persists that list per scope. Keeping both sides + * on plain id arrays means the sidebar and the preference store agree on what an + * order is without sharing storage code. + */ + +export type SessionOrderingMode = 'updated' | 'status' | 'created' | 'name' | 'manual'; + +/** `workspace:` for one workspace list, `all` for the aggregate list. */ +export type ManualOrderScopeKey = string; + +const AGGREGATE_SCOPE_KEY: ManualOrderScopeKey = 'all'; + +/** + * Storage scope a session list writes its drag order to. A missing workspace id + * addresses the aggregate view, which is the only list that spans workspaces. + */ +export function manualOrderScopeKey(scope: { workspaceId?: string | null }): ManualOrderScopeKey { + const workspaceId = scope.workspaceId; + return typeof workspaceId === 'string' && workspaceId.length > 0 + ? `workspace:${workspaceId}` + : AGGREGATE_SCOPE_KEY; +} + +/** An order never holds the same session twice: the first occurrence wins. */ +function dedupeIds(order: readonly string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const id of order) { + if (seen.has(id)) continue; + seen.add(id); + result.push(id); + } + return result; +} + +function clampIndex(index: number, max: number): number { + if (!Number.isFinite(index)) return 0; + return Math.min(Math.max(Math.trunc(index), 0), max); +} + +/** + * Move `sessionId` to `targetIndex`, clamped to the positions the result can + * actually hold. An id that is not listed yet is inserted at the clamped index + * instead of being dropped. Always returns a fresh array, so the caller can write + * the result back without touching the array it passed in. + */ +export function moveSessionInOrder( + order: readonly string[], + sessionId: string, + targetIndex: number, +): string[] { + const unique = dedupeIds(order); + const currentIndex = unique.indexOf(sessionId); + if (currentIndex < 0) { + const insertIndex = clampIndex(targetIndex, unique.length); + return [...unique.slice(0, insertIndex), sessionId, ...unique.slice(insertIndex)]; + } + + const moved = unique.slice(); + moved.splice(currentIndex, 1); + moved.splice(clampIndex(targetIndex, moved.length), 0, sessionId); + return moved; +} + +/** + * Drop ids that are no longer present and de-duplicate, keeping the relative + * order of the rest. Runs after sessions were deleted or archived so the stored + * order cannot grow without bound. + */ +export function reconcileManualOrder( + order: readonly string[], + presentIds: readonly string[], +): string[] { + const present = new Set(presentIds); + const seen = new Set(); + const result: string[] = []; + for (const id of order) { + if (seen.has(id) || !present.has(id)) continue; + seen.add(id); + result.push(id); + } + return result; +} + +/** + * Apply the stored order to a list of items. Items that the order does not know + * come first and keep their incoming relative order, so a session created after + * the last drag is visible at the top instead of being pushed to the bottom. + * Ordered ids that no item matches are skipped. + */ +export function orderSessionsManually( + items: readonly T[], + order: readonly string[], + idOf: (item: T) => string, +): T[] { + const rank = new Map(); + order.forEach((id, index) => { + if (!rank.has(id)) rank.set(id, index); + }); + + const unlisted: T[] = []; + const listed: { item: T; rank: number }[] = []; + for (const item of items) { + const itemRank = rank.get(idOf(item)); + if (itemRank === undefined) unlisted.push(item); + else listed.push({ item, rank: itemRank }); + } + + listed.sort((left, right) => left.rank - right.rank); + return [...unlisted, ...listed.map(entry => entry.item)]; +} diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 94aeebc63f..90e1f80c3e 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -314,7 +314,8 @@ "updated": "Recently updated", "status": "Status", "created": "Recently created", - "name": "Name" + "name": "Name", + "manual": "Manual" }, "show": { "label": "Show", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index fe110a9a0b..56d2fae83a 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -314,7 +314,8 @@ "updated": "最近更新", "status": "状态", "created": "最新创建", - "name": "名称" + "name": "名称", + "manual": "手动排序" }, "show": { "label": "显示", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 68c9607446..dac35ed416 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -314,7 +314,8 @@ "updated": "最近更新", "status": "狀態", "created": "最新建立", - "name": "名稱" + "name": "名稱", + "manual": "手動排序" }, "show": { "label": "顯示",