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.
This commit is contained in:
Mohamed Boudra
2026-08-13 23:48:01 +02:00
committed by GitHub
parent 373a98c64d
commit b03dc804eb
16 changed files with 602 additions and 95 deletions
@@ -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<void>,
) {
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();
@@ -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:
@@ -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 <div>row</div>;
});
function renderRow(info: DraggableRenderItemInfo<Item>) {
return <Row dragHandleProps={info.dragHandleProps} />;
}
function renderReplacementRow(info: DraggableRenderItemInfo<Item>) {
return <Row dragHandleProps={info.dragHandleProps} />;
}
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(
<DraggableList
data={items}
keyExtractor={keyExtractor}
renderItem={renderRow}
onDragEnd={onDragEnd}
useDragHandle
/>,
);
});
expect(rowRenderCount).toBe(1);
act(() => {
root?.render(
<DraggableList
data={items}
keyExtractor={keyExtractor}
renderItem={renderReplacementRow}
onDragEnd={onDragEnd}
useDragHandle
/>,
);
});
expect(rowRenderCount).toBe(1);
});
});
@@ -34,6 +34,61 @@ const DRAG_ACTIVATION_CONFIG = {
touchHoldTolerance: 8,
};
function areRecordsEqual(
left: Record<string, unknown> | undefined,
right: Record<string, unknown> | 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<string, unknown> | undefined,
): Record<string, unknown> | undefined {
const stableRef = useRef(value);
if (!areRecordsEqual(stableRef.current, value)) {
stableRef.current = value;
}
return stableRef.current;
}
function useStableListenerRecord(
listeners: Record<string, unknown> | undefined,
): Record<string, unknown> | 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<string, unknown> | 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<T> {
id: string;
item: T;
@@ -92,19 +147,28 @@ function SortableItemInner<T>({
}),
[combinedTransform, transition, isDragging],
);
const stableAttributes = useShallowStableRecord(attributes as unknown as Record<string, unknown>);
const stableListeners = useStableListenerRecord(
listeners as unknown as Record<string, unknown> | 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<T> = {
item,
index,
drag,
isActive: activeId === id,
dragHandleProps: useDragHandle
? {
attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
}
: undefined,
dragHandleProps,
};
const wrapperProps = useDragHandle
@@ -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({
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
>
{onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
@@ -1124,6 +1130,7 @@ function WorkspaceRowInner({
selected,
isHovered,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View
{...dragAttributes}
@@ -1168,7 +1175,7 @@ function WorkspaceRowInner({
leadingProjectName={leadingProjectName}
leadingProjectIconDataUri={leadingProjectIconDataUri}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -1178,6 +1185,7 @@ function WorkspaceRowInner({
>
<WorkspaceRowRightGroup
workspace={workspace}
backdrop={backdrop}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
isCreating={isCreating}
@@ -1941,6 +1949,30 @@ export function SidebarWorkspaceList({
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
const onToggleWorkspacePin = useSidebarWorkspacePinController();
const getPinnedWorkspaceOrder = useSidebarOrderStore((state) => 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}
/>
) : (
<ProjectModeList
@@ -1985,6 +2020,7 @@ export function SidebarWorkspaceList({
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
/>
);
@@ -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<string, HostBadgeModel>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
listHeaderComponent?: ReactElement | null;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
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 (
<SidebarStatusWorkspaceList
groups={statusGroups}
pinnedWorkspaces={pinnedGroups.pinnedChats.flatMap((workspace) => {
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<SidebarWorkspaceListProps, "statusGroups" | "groupMode" | "isRefreshing" | "onRefresh"> & {
pathname: string;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
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<SidebarWorkspacePlacement>) => {
return (
<MemoWorkspaceRowItem
key={workspace.workspaceKey}
workspace={workspace}
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
@@ -2316,6 +2372,9 @@ function ProjectModeList({
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
/>
);
},
@@ -2341,7 +2400,20 @@ function ProjectModeList({
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : (
<>
{visiblePinnedChats.map(renderPinnedChat)}
<DraggableList
testID="sidebar-pinned-list"
data={visiblePinnedChats}
keyExtractor={workspaceKeyExtractor}
renderItem={renderPinnedChat}
onDragEnd={onPinnedWorkspaceReorder}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented}
containerStyle={styles.workspaceListContainer}
/>
{canTogglePinnedChats ? (
<SidebarGroupToggleRow
expanded={pinnedChatsExpanded}
@@ -9,6 +9,7 @@ import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { usePinnedSidebarKeys, type PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import type { SidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { buildSidebarProjection } from "./sidebar-projection";
@@ -41,6 +42,7 @@ export function SidebarModelProvider({
(state) => 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,
],
);
@@ -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],
@@ -24,6 +24,7 @@ export interface SidebarProjection {
export function buildSidebarProjection(input: {
projects: SidebarProjectEntry[];
pinnedKeys: PinnedSidebarKeys;
pinnedWorkspaceOrder: string[];
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByViewKey: Map<string, string>;
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 =
@@ -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<string, number>();
function statusWorkspaceKeyExtractor(workspace: SidebarWorkspaceEntry): string {
return workspace.workspaceKey;
}
interface StatusWorkspaceListProps {
groups: StatusGroup[];
pinnedWorkspaces: SidebarWorkspaceEntry[];
@@ -95,7 +121,10 @@ interface StatusWorkspaceListProps {
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspaceEntry[]) => void;
listHeaderComponent?: ReactNode;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
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<SidebarWorkspaceEntry>) => (
<StatusWorkspaceRow
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
inStatusGroup={false}
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
/>
),
[
hostBadgeByServerId,
onToggleWorkspacePin,
onWorkspacePress,
projectIconByProjectViewKey,
showShortcutBadges,
statusShortcutIndex,
supportsPinningByServerId,
],
);
const content = (
<>
{pinnedWorkspaces.length > 0 ? (
@@ -132,23 +201,18 @@ export function SidebarStatusWorkspaceList({
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : (
<>
{visiblePinnedWorkspaces.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
inStatusGroup={false}
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
/>
))}
<DraggableList
testID="sidebar-pinned-list"
data={visiblePinnedWorkspaces}
keyExtractor={statusWorkspaceKeyExtractor}
renderItem={renderPinnedWorkspace}
onDragEnd={onPinnedWorkspaceReorder}
scrollEnabled={false}
useDragHandle
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented}
/>
{canTogglePinnedWorkspaces ? (
<SidebarGroupToggleRow
expanded={pinnedWorkspacesExpanded}
@@ -423,6 +487,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
onWorkspacePress,
drag,
isDragging = false,
dragHandleProps,
}: {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
@@ -439,6 +506,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
*/
inStatusGroup?: boolean;
onWorkspacePress?: () => 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}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
@@ -635,7 +717,53 @@ function StatusWorkspaceRowWithMenu({
);
}
function StatusWorkspaceRowInner({
interface StatusWorkspaceRowInnerProps {
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;
/** 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 <DraggableStatusWorkspaceRowInner {...props} drag={props.drag} />;
}
return <StatusWorkspaceRowInnerContent {...props} />;
}
function DraggableStatusWorkspaceRowInner(
props: StatusWorkspaceRowInnerProps & { drag: () => void },
) {
const dragInteraction = useLongPressDragInteraction({
drag: props.drag,
menuController: null,
});
return <StatusWorkspaceRowInnerContent {...props} dragInteraction={dragInteraction} />;
}
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<typeof useLongPressDragInteraction>;
}) {
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 (
<SidebarWorkspaceRowFrame workspace={workspace}>
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ 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 (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<SidebarWorkspaceContextMenu
contextMenuOpen={contextMenuOpen}
onContextMenuOpenChange={onContextMenuOpenChange}
@@ -749,8 +889,9 @@ function StatusWorkspaceRowInner({
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={handlePressIn}
onTouchMove={moveDragPress}
onPressOut={handlePressOut}
onPress={onPress}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
@@ -759,7 +900,7 @@ function StatusWorkspaceRowInner({
leadingProjectName={projectName}
leadingProjectIconDataUri={projectIconDataUri}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isPressed, selected, isHovered })}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
@@ -769,6 +910,7 @@ function StatusWorkspaceRowInner({
{renderSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
backdrop={backdrop}
trailing={trailing}
showBase={showTrailing}
showKebab={showKebabInSlot}
@@ -798,6 +940,7 @@ function StatusWorkspaceRowInner({
function StatusWorkspaceActionSlot({
workspace,
backdrop,
trailing,
showBase,
showKebab,
@@ -816,6 +959,7 @@ function StatusWorkspaceActionSlot({
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
trailing: SidebarWorkspaceTrailing;
showBase: boolean;
showKebab: boolean;
@@ -839,7 +983,10 @@ function StatusWorkspaceActionSlot({
<SidebarWorkspaceTrailingActionBase visible={showBase}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
>
{kebab.showKebab && onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
@@ -867,17 +1014,20 @@ function getStatusWorkspaceRowStyle({
selected,
isHovered,
inStatusGroup,
isDragging,
}: {
isPressed: boolean;
selected: boolean;
isHovered: boolean;
inStatusGroup: boolean;
isDragging: boolean;
}) {
return [
styles.workspaceRow,
inStatusGroup && sidebarWorkspaceRowStyles.rowIndented,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -972,6 +1122,14 @@ const styles = StyleSheet.create((theme) => ({
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,
},
@@ -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<SidebarSurfaceBackdrop, (theme: Theme) => { 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 ? <TrailingActionScrim /> : null}
{scrimBackdrop ? <TrailingActionScrim backdrop={scrimBackdrop} /> : null}
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>
</>
);
@@ -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 (
<View style={sidebarWorkspaceRowStyles.trailingActionScrim} pointerEvents="none">
<ThemedTrailingActionScrimSvg gradientId={gradientId} uniProps={scrimColorMapping} />
<View
style={sidebarWorkspaceRowStyles.trailingActionScrim}
pointerEvents="none"
testID="sidebar-workspace-trailing-scrim"
>
<ThemedTrailingActionScrimSvg
gradientId={gradientId}
uniProps={scrimColorMappings[backdrop]}
/>
</View>
);
}
@@ -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 (
<View
{...(draggable ? dragAttributes : {})}
@@ -353,7 +355,7 @@ function WorkspaceRowBody({
workspace={workspace}
hostBadge={hostBadge}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -362,6 +364,7 @@ function WorkspaceRowBody({
>
<WorkspaceRowTrailingActions
workspace={workspace}
backdrop={backdrop}
trailing={trailing}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
@@ -389,6 +392,7 @@ function WorkspaceRowBody({
function WorkspaceRowTrailingActions({
workspace,
backdrop,
trailing,
isHovered,
isTouchPlatform,
@@ -406,6 +410,7 @@ function WorkspaceRowTrailingActions({
onRename,
}: {
workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
trailing: SidebarWorkspaceTrailing;
isHovered: boolean;
isTouchPlatform: boolean;
@@ -450,7 +455,10 @@ function WorkspaceRowTrailingActions({
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
>
{onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
@@ -9,6 +9,7 @@ export function useLongPressDragInteraction(input: {
drag: () => void;
menuController: ReturnType<typeof useContextMenu> | 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 }) => {
@@ -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",
]);
});
});
+11 -2
View File
@@ -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,
};
}
@@ -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"]);
});
});
+19 -2
View File
@@ -6,15 +6,19 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora
interface SidebarOrderStoreState {
projectOrder: string[];
pinnedWorkspaceOrder: string[];
workspaceOrderByProject: Record<string, string[]>;
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<string, string[]>;
projectOrderByServerId?: Record<string, string[]>;
workspaceOrderByServerAndProject?: Record<string, string[]>;
@@ -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<string, string[]>;
} {
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<SidebarOrderStoreState>()(
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<SidebarOrderStoreState>()(
storage: createValidatedPersistStorage(AsyncStorage, SidebarOrderPersistedStateSchema),
partialize: (state) => ({
projectOrder: state.projectOrder,
pinnedWorkspaceOrder: state.pinnedWorkspaceOrder,
workspaceOrderByProject: state.workspaceOrderByProject,
}),
version: 1,