From b03dc804eb8cf9a1c15fbeca1e9f511e9daec2f1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 13 Aug 2026 23:48:01 +0200 Subject: [PATCH] feat(app): make pinned workspaces sortable (#3341) Reuse the existing workspace drag interaction for pinned rows and persist their local order across grouping modes. Keep pressed and dragged row scrims aligned with the active backdrop, and preserve memoized row rendering on web. --- .../app/e2e/browser/sidebar-reorder.spec.ts | 40 ++- .../app/e2e/support/helpers/seed-client.ts | 1 + .../components/draggable-list.web.test.tsx | 90 ++++++ .../app/src/components/draggable-list.web.tsx | 78 ++++- .../src/components/sidebar-workspace-list.tsx | 90 +++++- .../src/components/sidebar/sidebar-model.tsx | 4 + .../sidebar/sidebar-projection.test.ts | 1 + .../components/sidebar/sidebar-projection.ts | 2 + .../sidebar/sidebar-status-list.tsx | 268 ++++++++++++++---- .../sidebar/sidebar-workspace-row-content.tsx | 27 +- .../sidebar/sidebar-workspace-row.tsx | 12 +- .../use-long-press-drag-interaction.ts | 15 +- .../app/src/hooks/use-sidebar-pins.test.ts | 26 ++ packages/app/src/hooks/use-sidebar-pins.ts | 13 +- .../src/stores/sidebar-order-store.test.ts | 9 + .../app/src/stores/sidebar-order-store.ts | 21 +- 16 files changed, 602 insertions(+), 95 deletions(-) create mode 100644 packages/app/src/components/draggable-list.web.test.tsx diff --git a/packages/app/e2e/browser/sidebar-reorder.spec.ts b/packages/app/e2e/browser/sidebar-reorder.spec.ts index c0d199148..6129d37a6 100644 --- a/packages/app/e2e/browser/sidebar-reorder.spec.ts +++ b/packages/app/e2e/browser/sidebar-reorder.spec.ts @@ -18,7 +18,25 @@ async function visibleBoundingBox(row: Locator) { return box; } -async function quickDragFirstRowAfterSecond(rows: Locator) { +async function pressProjectRow(rows: Locator) { + await rows.page().mouse.down(); +} + +async function pressWorkspaceRow(rows: Locator) { + const solidScrimStop = rows + .nth(0) + .getByTestId("sidebar-workspace-trailing-scrim") + .locator("stop") + .nth(1); + const hoverScrimColor = await solidScrimStop.getAttribute("stop-color"); + await rows.page().mouse.down(); + await expect.poll(() => solidScrimStop.getAttribute("stop-color")).not.toBe(hoverScrimColor); +} + +async function quickDragFirstRowAfterSecond( + rows: Locator, + pressRow: (rows: Locator) => Promise, +) { await expect(rows).toHaveCount(2); const before = await rowTestIds(rows); const sourceBox = await visibleBoundingBox(rows.nth(0)); @@ -29,15 +47,19 @@ async function quickDragFirstRowAfterSecond(rows: Locator) { const target = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 }; await page.mouse.move(source.x, source.y); - await page.mouse.down(); + const trailingScrim = rows.nth(0).getByTestId("sidebar-workspace-trailing-scrim"); + await pressRow(rows); await page.mouse.move(source.x, source.y + 7); + await expect(trailingScrim).toHaveCount(0); await page.mouse.move(target.x, target.y, { steps: 4 }); await page.mouse.up(); await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]); } -test("projects and workspaces reorder with an immediate mouse drag", async ({ page }) => { +test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({ + page, +}) => { const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" }); const secondProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-second-" }); @@ -61,6 +83,7 @@ test("projects and workspaces reorder with an immediate mouse drag", async ({ pa const secondProjectTestId = `sidebar-project-row-${projectEquivalenceViewKey(secondProject.projectKey)}`; await quickDragFirstRowAfterSecond( page.locator(`[data-testid="${firstProjectTestId}"], [data-testid="${secondProjectTestId}"]`), + pressProjectRow, ); const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`; const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`; @@ -68,6 +91,17 @@ test("projects and workspaces reorder with an immediate mouse drag", async ({ pa page.locator( `[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`, ), + pressWorkspaceRow, + ); + + await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true); + await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true); + const secondProjectWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondProject.workspaceId}`; + await quickDragFirstRowAfterSecond( + page.locator( + `[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondProjectWorkspaceTestId}"]`, + ), + pressWorkspaceRow, ); } finally { await firstProject.cleanup(); diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 71a27dde5..ff4aa0190 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -42,6 +42,7 @@ export interface SeedDaemonClient { fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{ entries: SeedWorkspaceDescriptor[]; }>; + setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>; listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>; createWorkspace(input: { source: diff --git a/packages/app/src/components/draggable-list.web.test.tsx b/packages/app/src/components/draggable-list.web.test.tsx new file mode 100644 index 000000000..4ccca3829 --- /dev/null +++ b/packages/app/src/components/draggable-list.web.test.tsx @@ -0,0 +1,90 @@ +/** + * @vitest-environment jsdom + */ +import React, { memo } from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DraggableListDragHandleProps } from "./draggable-list.types"; +import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list.web"; + +interface Item { + id: string; +} + +const items: Item[] = [{ id: "one" }]; + +function keyExtractor(item: Item): string { + return item.id; +} + +function onDragEnd() {} + +let root: Root | null = null; +let container: HTMLElement | null = null; +let rowRenderCount = 0; + +const Row = memo(function Row({ + dragHandleProps: _dragHandleProps, +}: { + dragHandleProps?: DraggableListDragHandleProps; +}) { + rowRenderCount += 1; + return
row
; +}); + +function renderRow(info: DraggableRenderItemInfo) { + return ; +} + +function renderReplacementRow(info: DraggableRenderItemInfo) { + return ; +} + +beforeEach(() => { + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + rowRenderCount = 0; +}); + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; + vi.unstubAllGlobals(); +}); + +describe("DraggableList web row stability", () => { + it("keeps drag handle props stable when the render callback changes", () => { + act(() => { + root?.render( + , + ); + }); + expect(rowRenderCount).toBe(1); + + act(() => { + root?.render( + , + ); + }); + + expect(rowRenderCount).toBe(1); + }); +}); diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index 7c1e683e5..92a4d2287 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -34,6 +34,61 @@ const DRAG_ACTIVATION_CONFIG = { touchHoldTolerance: 8, }; +function areRecordsEqual( + left: Record | undefined, + right: Record | undefined, +): boolean { + if (left === right) return true; + if (!left || !right) return false; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every((key) => Object.is(left[key], right[key])); +} + +function useShallowStableRecord( + value: Record | undefined, +): Record | undefined { + const stableRef = useRef(value); + if (!areRecordsEqual(stableRef.current, value)) { + stableRef.current = value; + } + return stableRef.current; +} + +function useStableListenerRecord( + listeners: Record | undefined, +): Record | undefined { + const latestListenersRef = useRef(listeners); + latestListenersRef.current = listeners; + const listenerKeys = Object.keys(listeners ?? {}).sort(); + const listenerKeySignature = listenerKeys.join("\u0000"); + const stableListenersRef = useRef<{ + keySignature: string; + listeners: Record | undefined; + }>(undefined); + if (stableListenersRef.current?.keySignature !== listenerKeySignature) { + stableListenersRef.current = { + keySignature: listenerKeySignature, + listeners: + listenerKeys.length === 0 + ? undefined + : Object.fromEntries( + listenerKeys.map((key) => [ + key, + (...args: unknown[]) => { + const listener = latestListenersRef.current?.[key]; + if (typeof listener === "function") { + return Reflect.apply(listener, undefined, args); + } + }, + ]), + ), + }; + } + return stableListenersRef.current.listeners; +} + interface SortableItemProps { id: string; item: T; @@ -92,19 +147,28 @@ function SortableItemInner({ }), [combinedTransform, transition, isDragging], ); + const stableAttributes = useShallowStableRecord(attributes as unknown as Record); + const stableListeners = useStableListenerRecord( + listeners as unknown as Record | undefined, + ); + const dragHandleProps = useMemo( + () => + useDragHandle + ? { + attributes: stableAttributes, + listeners: stableListeners, + setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void, + } + : undefined, + [setActivatorNodeRef, stableAttributes, stableListeners, useDragHandle], + ); const info: DraggableRenderItemInfo = { item, index, drag, isActive: activeId === id, - dragHandleProps: useDragHandle - ? { - attributes: attributes as unknown as Record, - listeners: listeners as unknown as Record, - setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void, - } - : undefined, + dragHandleProps, }; const wrapperProps = useDragHandle diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 124783733..68de9d73d 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -33,6 +33,7 @@ import { } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; +import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; import { type GestureType } from "react-native-gesture-handler"; import * as Clipboard from "expo-clipboard"; @@ -616,6 +617,7 @@ function ProjectMenuItems({ function WorkspaceRowRightGroup({ workspace, + backdrop, isHovered, isTouchPlatform, isCreating, @@ -634,6 +636,7 @@ function WorkspaceRowRightGroup({ onTogglePin, }: { workspace: SidebarWorkspaceEntry; + backdrop: SidebarSurfaceBackdrop; isHovered: boolean; isTouchPlatform: boolean; isCreating: boolean; @@ -681,7 +684,10 @@ function WorkspaceRowRightGroup({ - + {onArchive ? ( state.getPinnedWorkspaceOrder); + const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder); + const handlePinnedWorkspaceReorder = useCallback( + (reorderedWorkspaces: SidebarWorkspacePlacement[]) => { + const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); + const currentOrder = getPinnedWorkspaceOrder(); + if ( + !hasVisibleOrderChanged({ + currentOrder, + reorderedVisibleKeys: reorderedWorkspaceKeys, + }) + ) { + return; + } + + setPinnedWorkspaceOrder( + mergeWithRemainder({ + currentOrder, + reorderedVisibleKeys: reorderedWorkspaceKeys, + }), + ); + }, + [getPinnedWorkspaceOrder, setPinnedWorkspaceOrder], + ); // Status mode drops the project grouping, so its rows carry their own project // icon. Project mode fetches the same icons inside ProjectModeList for its // project headers, so only the active mode requests them. @@ -1964,7 +1996,10 @@ export function SidebarWorkspaceList({ hostBadgeByServerId={hostBadgeByServerId} supportsPinningByServerId={supportsPinningByServerId} onToggleWorkspacePin={onToggleWorkspacePin} + onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder} listHeaderComponent={listHeaderComponent} + parentGestureRef={parentGestureRef} + dragGestureHostPresented={dragGestureHostPresented} /> ) : ( ); @@ -2001,7 +2037,10 @@ function SidebarStatusModeWrapper({ hostBadgeByServerId, supportsPinningByServerId, onToggleWorkspacePin, + onPinnedWorkspaceReorder, listHeaderComponent, + parentGestureRef, + dragGestureHostPresented, }: { statusGroups: StatusGroup[]; pinnedGroups: PinnedSidebarGroups; @@ -2012,17 +2051,25 @@ function SidebarStatusModeWrapper({ hostBadgeByServerId: ReadonlyMap; supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; + onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void; listHeaderComponent?: ReactElement | null; + parentGestureRef?: MutableRefObject; + dragGestureHostPresented?: boolean; }) { const showShortcutBadges = useShowShortcutBadges(); + const pinnedWorkspaces = useMemo( + () => + pinnedGroups.pinnedChats.flatMap((workspace) => { + const entry = workspaceEntriesByKey.get(workspace.workspaceKey); + return entry ? [entry] : []; + }), + [pinnedGroups.pinnedChats, workspaceEntriesByKey], + ); return ( { - const entry = workspaceEntriesByKey.get(workspace.workspaceKey); - return entry ? [entry] : []; - })} + pinnedWorkspaces={pinnedWorkspaces} projectIconByProjectViewKey={projectIconByProjectViewKey} shortcutIndexByWorkspaceKey={_projectShortcutIndex} showShortcutBadges={showShortcutBadges} @@ -2030,7 +2077,10 @@ function SidebarStatusModeWrapper({ hostBadgeByServerId={hostBadgeByServerId} supportsPinningByServerId={supportsPinningByServerId} onToggleWorkspacePin={onToggleWorkspacePin} + onPinnedWorkspaceReorder={onPinnedWorkspaceReorder} listHeaderComponent={listHeaderComponent} + parentGestureRef={parentGestureRef} + dragGestureHostPresented={dragGestureHostPresented} /> ); } @@ -2053,12 +2103,14 @@ function ProjectModeList({ supportsMultiplicityByServerId, supportsPinningByServerId, onToggleWorkspacePin, + onPinnedWorkspaceReorder, }: Omit & { pathname: string; hostBadgeByServerId: ReadonlyMap; supportsMultiplicityByServerId: ReadonlyMap; supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; + onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void; }) { const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const { t } = useTranslation(); @@ -2296,10 +2348,14 @@ function ProjectModeList({ ); const renderPinnedChat = useCallback( - (workspace: SidebarWorkspacePlacement) => { + ({ + item: workspace, + drag, + isActive, + dragHandleProps, + }: DraggableRenderItemInfo) => { return ( ); }, @@ -2341,7 +2400,20 @@ function ProjectModeList({ {pinnedCollapsed ? null : ( <> - {visiblePinnedChats.map(renderPinnedChat)} + {canTogglePinnedChats ? ( state.collapsedStatusGroupKeys, ); const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned); + const pinnedWorkspaceOrder = useSidebarOrderStore((state) => state.pinnedWorkspaceOrder); const toggleProjectCollapsed = useSidebarCollapsedSectionsStore( (state) => state.toggleProjectCollapsed, ); @@ -58,6 +60,7 @@ export function SidebarModelProvider({ buildSidebarProjection({ projects: list.projects, pinnedKeys, + pinnedWorkspaceOrder, workspaceEntriesByKey: projectionWorkspaceEntriesByKey, projectNamesByViewKey: list.projectNamesByViewKey, groupMode, @@ -73,6 +76,7 @@ export function SidebarModelProvider({ list.projects, pinnedCollapsed, pinnedKeys, + pinnedWorkspaceOrder, projectionWorkspaceEntriesByKey, ], ); diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts index b5d15d245..5eb82a9a8 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.test.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -66,6 +66,7 @@ function projectionInput(options?: { pinnedWorkspaceKeys: [pinned.placement.workspaceKey], pinnedAtByKey: { [pinned.placement.workspaceKey]: "2026-07-12T12:00:00.000Z" }, }, + pinnedWorkspaceOrder: [], workspaceEntriesByKey: new Map([ [pinned.entry.workspaceKey, pinned.entry], [unpinned.entry.workspaceKey, unpinned.entry], diff --git a/packages/app/src/components/sidebar/sidebar-projection.ts b/packages/app/src/components/sidebar/sidebar-projection.ts index be96f5597..d6c0c0e3a 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.ts @@ -24,6 +24,7 @@ export interface SidebarProjection { export function buildSidebarProjection(input: { projects: SidebarProjectEntry[]; pinnedKeys: PinnedSidebarKeys; + pinnedWorkspaceOrder: string[]; workspaceEntriesByKey: ReadonlyMap; projectNamesByViewKey: Map; groupMode: SidebarGroupMode; @@ -34,6 +35,7 @@ export function buildSidebarProjection(input: { const pinnedGroups = splitPinnedSidebarGroups({ projects: input.projects, keys: input.pinnedKeys, + pinnedWorkspaceOrder: input.pinnedWorkspaceOrder, }); const pinnedWorkspaceKeys = new Set(input.pinnedKeys.pinnedWorkspaceKeys); const statusGroups = diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 3bb406cf5..a1ca1324e 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -1,7 +1,23 @@ -import { memo, useCallback, useMemo, useState, type ReactNode } from "react"; +import { + memo, + useCallback, + useMemo, + useState, + type MutableRefObject, + type ReactNode, + type Ref, +} from "react"; import { useTranslation } from "react-i18next"; -import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native"; +import { + View, + Text, + Pressable, + ScrollView, + type GestureResponderEvent, + type PressableStateCallbackType, +} from "react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; +import type { GestureType } from "react-native-gesture-handler"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; @@ -10,6 +26,7 @@ import type { HostBadgeModel } from "@/hosts/appearance"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; import { StyleSheet } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; +import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { withUnistyles } from "react-native-unistyles"; import { ChevronDown, @@ -59,6 +76,9 @@ import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header" import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin"; +import { DraggableList, type DraggableRenderItemInfo } from "@/components/draggable-list"; +import type { DraggableListDragHandleProps } from "@/components/draggable-list.types"; +import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; // Themed icon wrappers const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -85,6 +105,12 @@ const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedCircleDot = withUnistyles(CircleDot); const ThemedCircleX = withUnistyles(CircleX); +const EMPTY_SHORTCUT_INDEX = new Map(); + +function statusWorkspaceKeyExtractor(workspace: SidebarWorkspaceEntry): string { + return workspace.workspaceKey; +} + interface StatusWorkspaceListProps { groups: StatusGroup[]; pinnedWorkspaces: SidebarWorkspaceEntry[]; @@ -95,7 +121,10 @@ interface StatusWorkspaceListProps { hostBadgeByServerId: ReadonlyMap; supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; + onPinnedWorkspaceReorder: (workspaces: SidebarWorkspaceEntry[]) => void; listHeaderComponent?: ReactNode; + parentGestureRef?: MutableRefObject; + dragGestureHostPresented?: boolean; } export function SidebarStatusWorkspaceList({ @@ -108,7 +137,10 @@ export function SidebarStatusWorkspaceList({ hostBadgeByServerId, supportsPinningByServerId, onToggleWorkspacePin, + onPinnedWorkspaceReorder, listHeaderComponent, + parentGestureRef, + dragGestureHostPresented, }: StatusWorkspaceListProps) { const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedStatusGroupKeys, @@ -124,7 +156,44 @@ export function SidebarStatusWorkspaceList({ toggleExpanded: togglePinnedWorkspacesExpanded, } = useLimitedSidebarGroup(pinnedWorkspaces); - const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map(); + const statusShortcutIndex = showShortcutBadges + ? shortcutIndexByWorkspaceKey + : EMPTY_SHORTCUT_INDEX; + const renderPinnedWorkspace = useCallback( + ({ + item: workspace, + drag, + isActive, + dragHandleProps, + }: DraggableRenderItemInfo) => ( + + ), + [ + hostBadgeByServerId, + onToggleWorkspacePin, + onWorkspacePress, + projectIconByProjectViewKey, + showShortcutBadges, + statusShortcutIndex, + supportsPinningByServerId, + ], + ); const content = ( <> {pinnedWorkspaces.length > 0 ? ( @@ -132,23 +201,18 @@ export function SidebarStatusWorkspaceList({ {pinnedCollapsed ? null : ( <> - {visiblePinnedWorkspaces.map((workspace) => ( - - ))} + {canTogglePinnedWorkspaces ? ( void; + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; }) { const activeWorkspaceSelection = useActiveWorkspaceSelection(); const selected = @@ -465,6 +535,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({ reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace} inStatusGroup={inStatusGroup} onPress={handlePress} + drag={drag} + isDragging={isDragging} + dragHandleProps={dragHandleProps} /> ); }); @@ -482,6 +555,9 @@ function StatusWorkspaceRowWithMenu({ reserveIdleStatusIndicatorSpace = true, inStatusGroup = true, onPress, + drag, + isDragging = false, + dragHandleProps, }: { workspace: SidebarWorkspaceEntry; hostBadge: HostBadgeModel | null; @@ -499,6 +575,9 @@ function StatusWorkspaceRowWithMenu({ */ inStatusGroup?: boolean; onPress: () => void; + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; }) { const { t } = useTranslation(); const toast = useToast(); @@ -620,6 +699,9 @@ function StatusWorkspaceRowWithMenu({ onTogglePin={onTogglePin} reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace} inStatusGroup={inStatusGroup} + drag={drag} + isDragging={isDragging} + dragHandleProps={dragHandleProps} /> void; + isArchiving: boolean; + archiveLabel?: string; + archiveStatus?: "idle" | "pending" | "success"; + archivePendingLabel?: string; + onArchive?: () => void; + onCopyBranchName?: () => void; + onCopyPath?: () => void; + onRename?: () => void; + onMarkAsRead?: () => void; + archiveShortcutKeys?: ShortcutKey[][] | null; + isPinned?: boolean; + onTogglePin?: () => void; + reserveIdleStatusIndicatorSpace?: boolean; + /** Pinned rows are flat under their own header; status-group rows indent from theirs. */ + inStatusGroup?: boolean; + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; +} + +function StatusWorkspaceRowInner(props: StatusWorkspaceRowInnerProps) { + if (props.drag) { + return ; + } + return ; +} + +function DraggableStatusWorkspaceRowInner( + props: StatusWorkspaceRowInnerProps & { drag: () => void }, +) { + const dragInteraction = useLongPressDragInteraction({ + drag: props.drag, + menuController: null, + }); + return ; +} + +function StatusWorkspaceRowInnerContent({ workspace, hostBadge, projectName, @@ -658,47 +786,51 @@ function StatusWorkspaceRowInner({ onTogglePin, reserveIdleStatusIndicatorSpace = true, inStatusGroup = true, -}: { - workspace: SidebarWorkspaceEntry; - hostBadge: HostBadgeModel | null; - projectName: string; - projectIconDataUri: string | null; - selected: boolean; - shortcutNumber: number | null; - showShortcutBadge: boolean; - onPress: () => void; - isArchiving: boolean; - archiveLabel?: string; - archiveStatus?: "idle" | "pending" | "success"; - archivePendingLabel?: string; - onArchive?: () => void; - onCopyBranchName?: () => void; - onCopyPath?: () => void; - onRename?: () => void; - onMarkAsRead?: () => void; - archiveShortcutKeys?: ShortcutKey[][] | null; - isPinned?: boolean; - onTogglePin?: () => void; - reserveIdleStatusIndicatorSpace?: boolean; - /** - * Whether the row sits under a status header, which is what it indents from. Pinned rows - * are a flat list under their own header and sit flush. - */ - inStatusGroup?: boolean; + isDragging = false, + dragHandleProps, + dragInteraction, +}: StatusWorkspaceRowInnerProps & { + dragInteraction?: ReturnType; }) { const isTouchPlatform = platformIsNative; const [isPressed, setIsPressed] = useState(false); const trailing = useSidebarWorkspaceTrailing(); + const { + role: _dragRole, + tabIndex: _dragTabIndex, + "aria-roledescription": _dragRoleDescription, + ...dragAttributes + } = dragHandleProps?.attributes ?? {}; const isDesktop = !isTouchPlatform; const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null; const accessibilityState = useMemo(() => ({ selected }), [selected]); - const handlePressIn = useCallback(() => setIsPressed(true), []); - const handlePressOut = useCallback(() => setIsPressed(false), []); + const didLongPressRef = dragInteraction?.didLongPressRef; + const startDragPress = dragInteraction?.handlePressIn; + const moveDragPress = dragInteraction?.handleTouchMove; + const endDragPress = dragInteraction?.handlePressOut; + const handlePress = useCallback(() => { + if (didLongPressRef?.current) { + didLongPressRef.current = false; + return; + } + onPress(); + }, [didLongPressRef, onPress]); + const handlePressIn = useCallback( + (event: GestureResponderEvent) => { + setIsPressed(true); + startDragPress?.(event); + }, + [startDragPress], + ); + const handlePressOut = useCallback(() => { + setIsPressed(false); + endDragPress?.(); + }, [endDragPress]); return ( - + {({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => { const showShortcut = showShortcutBadge && shortcutNumber !== null; const { @@ -720,9 +852,17 @@ function StatusWorkspaceRowInner({ selected, isHovered, inStatusGroup, + isDragging, }); + const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered }); return ( - + } + style={styles.workspaceRowContainer} + {...hoverHandlers} + > - + {kebab.showKebab && onArchive ? ( ({ workspaceRowPressed: { backgroundColor: theme.colors.surface2, }, + workspaceRowDragging: { + backgroundColor: theme.colors.surface2, + borderWidth: 1, + borderColor: theme.colors.border, + transform: [{ scale: 1.02 }], + zIndex: 3, + ...theme.shadow.md, + }, sidebarRowSelected: { backgroundColor: theme.colors.surfaceSidebarHover, }, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index 40ef6e0a4..2e6b5ac5e 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -70,7 +70,11 @@ function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; col const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg); -const scrimColorMapping = (theme: Theme) => ({ color: theme.colors.surfaceSidebarHover }); +const scrimColorMappings: Record { color: string }> = { + surfaceSidebar: (theme) => ({ color: theme.colors.surfaceSidebar }), + surfaceSidebarHover: (theme) => ({ color: theme.colors.surfaceSidebarHover }), + surface2: (theme) => ({ color: theme.colors.surface2 }), +}; export function SidebarWorkspaceRowFrame({ workspace, @@ -109,7 +113,7 @@ export function SidebarWorkspaceRowFrame({ disabled={contextMenuOpen} > {children({ - isHovered: isHovered && !contextMenuOpen, + isHovered: isHovered && !contextMenuOpen && !isDragging, contextMenuOpen, onContextMenuOpenChange: handleContextMenuOpenChange, hoverHandlers, @@ -458,18 +462,18 @@ export function SidebarWorkspaceTrailingActionBase({ export function SidebarWorkspaceTrailingActionOverlay({ visible, - scrim = false, + scrimBackdrop, children, }: { visible: boolean; /** Fade the row into the kebab when something (the diff stat) is still rendered behind it. */ - scrim?: boolean; + scrimBackdrop?: SidebarSurfaceBackdrop; children: ReactNode; }) { if (!visible || !children) return null; return ( <> - {scrim ? : null} + {scrimBackdrop ? : null} {children} ); @@ -484,14 +488,21 @@ export function SidebarWorkspaceTrailingActionOverlay({ * Anchored to the trailing slot, which is position:relative. Wider than the slot on purpose: * the fade has to start before the diff stat does or the diff's left edge cuts off hard. */ -function TrailingActionScrim() { +function TrailingActionScrim({ backdrop }: { backdrop: SidebarSurfaceBackdrop }) { // useId's output contains characters that are not legal inside url(#...) — React 19 wraps // ids in guillemets, React 18 in colons — and an unresolvable fill paints nothing at all. // Keep the per-instance uniqueness, drop everything a fragment reference can't carry. const gradientId = `sidebar-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; return ( - - + + ); } diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index e7a34a5f3..87c28cf64 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -6,6 +6,7 @@ import { useMutation } from "@tanstack/react-query"; import * as Clipboard from "expo-clipboard"; import type { HostBadgeModel } from "@/hosts/appearance"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import type { DraggableListDragHandleProps } from "@/components/draggable-list.types"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { AdaptiveRenameModal } from "@/components/rename-modal"; @@ -310,6 +311,7 @@ function WorkspaceRowBody({ selected, isHovered, }); + const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered }); return ( - + {onArchive ? ( void; menuController: ReturnType | null; }) { + const { drag, menuController } = input; const didLongPressRef = useRef(false); const dragArmedRef = useRef(false); const dragActivatedRef = useRef(false); @@ -32,20 +33,20 @@ export function useLongPressDragInteraction(input: { }, []); const openContextMenuAtStartPoint = useCallback(() => { - if (!input.menuController || !touchStartRef.current) { + if (!menuController || !touchStartRef.current) { return; } const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; - input.menuController.setAnchorRect({ + menuController.setAnchorRect({ x: touchStartRef.current.x, y: touchStartRef.current.y + statusBarHeight, width: 0, height: 0, }); - input.menuController.setOpen(true); + menuController.setOpen(true); menuOpenedRef.current = true; didLongPressRef.current = true; - }, [input.menuController]); + }, [menuController]); const handleLongPress = useCallback(() => { // Manual timers own long-press behavior on mobile. @@ -87,10 +88,10 @@ export function useLongPressDragInteraction(input: { dragActivatedRef.current = true; didLongPressRef.current = true; void Haptics.selectionAsync().catch(() => {}); - input.drag(); + drag(); }, DRAG_ARM_DELAY_MS); - if (!input.menuController) { + if (!menuController) { return; } @@ -112,7 +113,7 @@ export function useLongPressDragInteraction(input: { void Haptics.selectionAsync().catch(() => {}); openContextMenuAtStartPoint(); }, CONTEXT_MENU_DELAY_MS); - }, [clearTimers, input, openContextMenuAtStartPoint]); + }, [clearTimers, drag, menuController, openContextMenuAtStartPoint]); const handleDragIntent = useCallback( (_details: { dx: number; dy: number; distance: number }) => { diff --git a/packages/app/src/hooks/use-sidebar-pins.test.ts b/packages/app/src/hooks/use-sidebar-pins.test.ts index 80af801e0..f418027fc 100644 --- a/packages/app/src/hooks/use-sidebar-pins.test.ts +++ b/packages/app/src/hooks/use-sidebar-pins.test.ts @@ -39,6 +39,7 @@ describe("splitPinnedSidebarGroups", () => { pinnedWorkspaceKeys: ["w1"], pinnedAtByKey: { w1: "2026-01-01T00:00:00Z" }, }, + pinnedWorkspaceOrder: [], }); expect(result.pinnedChats).toHaveLength(1); expect(result.unpinnedProjects).toHaveLength(0); @@ -49,6 +50,7 @@ describe("splitPinnedSidebarGroups", () => { const result = splitPinnedSidebarGroups({ projects, keys: { pinnedWorkspaceKeys: [], pinnedAtByKey: {} }, + pinnedWorkspaceOrder: [], }); expect(result.unpinnedProjects).toHaveLength(1); }); @@ -61,6 +63,7 @@ describe("splitPinnedSidebarGroups", () => { pinnedWorkspaceKeys: ["w1"], pinnedAtByKey: { w1: "2026-01-01T00:00:00Z" }, }, + pinnedWorkspaceOrder: [], }); expect(result.pinnedChats.map((w) => w.workspaceKey)).toEqual(["w1"]); expect(result.unpinnedProjects[0]?.workspaces.map((w) => w.workspaceKey)).toEqual(["w2"]); @@ -77,6 +80,7 @@ describe("splitPinnedSidebarGroups", () => { newer: "2026-02-01T00:00:00Z", }, }, + pinnedWorkspaceOrder: [], }); expect(result.pinnedChats.map((workspace) => workspace.workspaceKey)).toEqual([ @@ -84,4 +88,26 @@ describe("splitPinnedSidebarGroups", () => { "older", ]); }); + + it("applies the saved order while keeping a newly pinned chat first", () => { + const projects = [project("p1", [placement("older"), placement("newer"), placement("new")])]; + const result = splitPinnedSidebarGroups({ + projects, + keys: { + pinnedWorkspaceKeys: ["older", "newer", "new"], + pinnedAtByKey: { + older: "2026-01-01T00:00:00Z", + newer: "2026-02-01T00:00:00Z", + new: "2026-03-01T00:00:00Z", + }, + }, + pinnedWorkspaceOrder: ["older", "newer"], + }); + + expect(result.pinnedChats.map((workspace) => workspace.workspaceKey)).toEqual([ + "new", + "older", + "newer", + ]); + }); }); diff --git a/packages/app/src/hooks/use-sidebar-pins.ts b/packages/app/src/hooks/use-sidebar-pins.ts index 37524b246..fade0507d 100644 --- a/packages/app/src/hooks/use-sidebar-pins.ts +++ b/packages/app/src/hooks/use-sidebar-pins.ts @@ -5,6 +5,7 @@ import type { SidebarProjectEntry, SidebarWorkspacePlacement, } from "@/hooks/use-sidebar-workspaces-list"; +import { applyStoredOrdering } from "@/hooks/sidebar-workspaces-view-model"; import { useSessionStore } from "@/stores/session-store"; export interface PinnedSidebarKeys { @@ -101,8 +102,9 @@ export function usePinnedSidebarKeys(projects: SidebarProjectEntry[]): PinnedSid export function splitPinnedSidebarGroups(input: { projects: SidebarProjectEntry[]; keys: PinnedSidebarKeys; + pinnedWorkspaceOrder: string[]; }): PinnedSidebarGroups { - const { projects, keys } = input; + const { projects, keys, pinnedWorkspaceOrder } = input; if (keys.pinnedWorkspaceKeys.length === 0) { return { pinnedChats: [], unpinnedProjects: projects }; } @@ -138,5 +140,12 @@ export function splitPinnedSidebarGroups(input: { ), ); - return { pinnedChats, unpinnedProjects }; + return { + pinnedChats: applyStoredOrdering({ + items: pinnedChats, + storedOrder: pinnedWorkspaceOrder, + getKey: (workspace) => workspace.workspaceKey, + }), + unpinnedProjects, + }; } diff --git a/packages/app/src/stores/sidebar-order-store.test.ts b/packages/app/src/stores/sidebar-order-store.test.ts index d28e03f73..c58931ca6 100644 --- a/packages/app/src/stores/sidebar-order-store.test.ts +++ b/packages/app/src/stores/sidebar-order-store.test.ts @@ -16,9 +16,18 @@ describe("migrateSidebarOrderState", () => { expect(migrated).toEqual({ projectOrder: ["project-a"], + pinnedWorkspaceOrder: [], workspaceOrderByProject: { "project-a": ["host-a:main", "host-a:feature", "host-b:main"], }, }); }); + + it("normalizes pinned workspace order", () => { + const migrated = migrateSidebarOrderState({ + pinnedWorkspaceOrder: [" host-a:one ", "host-a:one", "", "host-b:two"], + }); + + expect(migrated.pinnedWorkspaceOrder).toEqual(["host-a:one", "host-b:two"]); + }); }); diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index bd6bdbbf3..27e7a190c 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -6,15 +6,19 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora interface SidebarOrderStoreState { projectOrder: string[]; + pinnedWorkspaceOrder: string[]; workspaceOrderByProject: Record; getProjectOrder: () => string[]; setProjectOrder: (keys: string[]) => void; + getPinnedWorkspaceOrder: () => string[]; + setPinnedWorkspaceOrder: (keys: string[]) => void; getWorkspaceOrder: (projectViewKey: string) => string[]; setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void; } interface SidebarOrderPersistedState { projectOrder?: string[]; + pinnedWorkspaceOrder?: string[]; workspaceOrderByProject?: Record; projectOrderByServerId?: Record; workspaceOrderByServerAndProject?: Record; @@ -23,6 +27,7 @@ interface SidebarOrderPersistedState { const StringArrayRecordSchema = z.record(z.string(), z.array(z.string())); const SidebarOrderPersistedStateSchema = z.strictObject({ projectOrder: z.array(z.string()).optional(), + pinnedWorkspaceOrder: z.array(z.string()).optional(), workspaceOrderByProject: StringArrayRecordSchema.optional(), projectOrderByServerId: StringArrayRecordSchema.optional(), workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(), @@ -79,11 +84,12 @@ function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): export function migrateSidebarOrderState(persistedState: unknown): { projectOrder: string[]; + pinnedWorkspaceOrder: string[]; workspaceOrderByProject: Record; } { const result = SidebarOrderPersistedStateSchema.safeParse(persistedState); if (!result.success) { - return { projectOrder: [], workspaceOrderByProject: {} }; + return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} }; } const state: SidebarOrderPersistedState = result.data; @@ -113,19 +119,29 @@ export function migrateSidebarOrderState(persistedState: unknown): { workspaceOrderByProject[scope.projectViewKey] = merged; } - return { projectOrder, workspaceOrderByProject }; + return { + projectOrder, + pinnedWorkspaceOrder: normalizeKeys(state.pinnedWorkspaceOrder ?? []), + workspaceOrderByProject, + }; } export const useSidebarOrderStore = create()( persist( (set, get) => ({ projectOrder: [], + pinnedWorkspaceOrder: [], workspaceOrderByProject: {}, getProjectOrder: () => get().projectOrder, setProjectOrder: (keys) => { const normalized = normalizeKeys(keys); set({ projectOrder: normalized }); }, + getPinnedWorkspaceOrder: () => get().pinnedWorkspaceOrder, + setPinnedWorkspaceOrder: (keys) => { + const normalized = normalizeKeys(keys); + set({ pinnedWorkspaceOrder: normalized }); + }, getWorkspaceOrder: (projectViewKey) => { const scope = projectViewKey.trim(); if (!scope) return []; @@ -148,6 +164,7 @@ export const useSidebarOrderStore = create()( storage: createValidatedPersistStorage(AsyncStorage, SidebarOrderPersistedStateSchema), partialize: (state) => ({ projectOrder: state.projectOrder, + pinnedWorkspaceOrder: state.pinnedWorkspaceOrder, workspaceOrderByProject: state.workspaceOrderByProject, }), version: 1,