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,