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; 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); await expect(rows).toHaveCount(2);
const before = await rowTestIds(rows); const before = await rowTestIds(rows);
const sourceBox = await visibleBoundingBox(rows.nth(0)); 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 }; 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.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 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.move(target.x, target.y, { steps: 4 });
await page.mouse.up(); await page.mouse.up();
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]); 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 firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-second-" }); 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)}`; const secondProjectTestId = `sidebar-project-row-${projectEquivalenceViewKey(secondProject.projectKey)}`;
await quickDragFirstRowAfterSecond( await quickDragFirstRowAfterSecond(
page.locator(`[data-testid="${firstProjectTestId}"], [data-testid="${secondProjectTestId}"]`), page.locator(`[data-testid="${firstProjectTestId}"], [data-testid="${secondProjectTestId}"]`),
pressProjectRow,
); );
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`; const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`; 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( page.locator(
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`, `[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 { } finally {
await firstProject.cleanup(); await firstProject.cleanup();
@@ -42,6 +42,7 @@ export interface SeedDaemonClient {
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{ fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[]; entries: SeedWorkspaceDescriptor[];
}>; }>;
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>; listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
createWorkspace(input: { createWorkspace(input: {
source: 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, 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> { interface SortableItemProps<T> {
id: string; id: string;
item: T; item: T;
@@ -92,19 +147,28 @@ function SortableItemInner<T>({
}), }),
[combinedTransform, transition, isDragging], [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> = { const info: DraggableRenderItemInfo<T> = {
item, item,
index, index,
drag, drag,
isActive: activeId === id, isActive: activeId === id,
dragHandleProps: useDragHandle dragHandleProps,
? {
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,
}; };
const wrapperProps = useDragHandle const wrapperProps = useDragHandle
@@ -33,6 +33,7 @@ import {
} from "@/stores/navigation-active-workspace-store"; } from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
@@ -616,6 +617,7 @@ function ProjectMenuItems({
function WorkspaceRowRightGroup({ function WorkspaceRowRightGroup({
workspace, workspace,
backdrop,
isHovered, isHovered,
isTouchPlatform, isTouchPlatform,
isCreating, isCreating,
@@ -634,6 +636,7 @@ function WorkspaceRowRightGroup({
onTogglePin, onTogglePin,
}: { }: {
workspace: SidebarWorkspaceEntry; workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
isHovered: boolean; isHovered: boolean;
isTouchPlatform: boolean; isTouchPlatform: boolean;
isCreating: boolean; isCreating: boolean;
@@ -681,7 +684,10 @@ function WorkspaceRowRightGroup({
<SidebarWorkspaceTrailingActionBase visible={showTrailing}> <SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} /> <SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase> </SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}> <SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
>
{onArchive ? ( {onArchive ? (
<SidebarWorkspaceMenu <SidebarWorkspaceMenu
{...kebab.menuProps} {...kebab.menuProps}
@@ -1124,6 +1130,7 @@ function WorkspaceRowInner({
selected, selected,
isHovered, isHovered,
}); });
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return ( return (
<View <View
{...dragAttributes} {...dragAttributes}
@@ -1168,7 +1175,7 @@ function WorkspaceRowInner({
leadingProjectName={leadingProjectName} leadingProjectName={leadingProjectName}
leadingProjectIconDataUri={leadingProjectIconDataUri} leadingProjectIconDataUri={leadingProjectIconDataUri}
serviceSummary={serviceSummary} serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })} backdrop={backdrop}
isHovered={isHovered} isHovered={isHovered}
isLoading={isArchiving || isCreating} isLoading={isArchiving || isCreating}
isCreating={isCreating} isCreating={isCreating}
@@ -1178,6 +1185,7 @@ function WorkspaceRowInner({
> >
<WorkspaceRowRightGroup <WorkspaceRowRightGroup
workspace={workspace} workspace={workspace}
backdrop={backdrop}
isHovered={isHovered} isHovered={isHovered}
isTouchPlatform={isTouchPlatform} isTouchPlatform={isTouchPlatform}
isCreating={isCreating} isCreating={isCreating}
@@ -1941,6 +1949,30 @@ export function SidebarWorkspaceList({
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity"); const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning"); const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
const onToggleWorkspacePin = useSidebarWorkspacePinController(); 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 // Status mode drops the project grouping, so its rows carry their own project
// icon. Project mode fetches the same icons inside ProjectModeList for its // icon. Project mode fetches the same icons inside ProjectModeList for its
// project headers, so only the active mode requests them. // project headers, so only the active mode requests them.
@@ -1964,7 +1996,10 @@ export function SidebarWorkspaceList({
hostBadgeByServerId={hostBadgeByServerId} hostBadgeByServerId={hostBadgeByServerId}
supportsPinningByServerId={supportsPinningByServerId} supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin} onToggleWorkspacePin={onToggleWorkspacePin}
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
listHeaderComponent={listHeaderComponent} listHeaderComponent={listHeaderComponent}
parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
/> />
) : ( ) : (
<ProjectModeList <ProjectModeList
@@ -1985,6 +2020,7 @@ export function SidebarWorkspaceList({
supportsMultiplicityByServerId={supportsMultiplicityByServerId} supportsMultiplicityByServerId={supportsMultiplicityByServerId}
supportsPinningByServerId={supportsPinningByServerId} supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin} onToggleWorkspacePin={onToggleWorkspacePin}
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
/> />
); );
@@ -2001,7 +2037,10 @@ function SidebarStatusModeWrapper({
hostBadgeByServerId, hostBadgeByServerId,
supportsPinningByServerId, supportsPinningByServerId,
onToggleWorkspacePin, onToggleWorkspacePin,
onPinnedWorkspaceReorder,
listHeaderComponent, listHeaderComponent,
parentGestureRef,
dragGestureHostPresented,
}: { }: {
statusGroups: StatusGroup[]; statusGroups: StatusGroup[];
pinnedGroups: PinnedSidebarGroups; pinnedGroups: PinnedSidebarGroups;
@@ -2012,17 +2051,25 @@ function SidebarStatusModeWrapper({
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>; hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsPinningByServerId: ReadonlyMap<string, boolean>; supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin; onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
listHeaderComponent?: ReactElement | null; listHeaderComponent?: ReactElement | null;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean;
}) { }) {
const showShortcutBadges = useShowShortcutBadges(); const showShortcutBadges = useShowShortcutBadges();
const pinnedWorkspaces = useMemo(
() =>
pinnedGroups.pinnedChats.flatMap((workspace) => {
const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
return entry ? [entry] : [];
}),
[pinnedGroups.pinnedChats, workspaceEntriesByKey],
);
return ( return (
<SidebarStatusWorkspaceList <SidebarStatusWorkspaceList
groups={statusGroups} groups={statusGroups}
pinnedWorkspaces={pinnedGroups.pinnedChats.flatMap((workspace) => { pinnedWorkspaces={pinnedWorkspaces}
const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
return entry ? [entry] : [];
})}
projectIconByProjectViewKey={projectIconByProjectViewKey} projectIconByProjectViewKey={projectIconByProjectViewKey}
shortcutIndexByWorkspaceKey={_projectShortcutIndex} shortcutIndexByWorkspaceKey={_projectShortcutIndex}
showShortcutBadges={showShortcutBadges} showShortcutBadges={showShortcutBadges}
@@ -2030,7 +2077,10 @@ function SidebarStatusModeWrapper({
hostBadgeByServerId={hostBadgeByServerId} hostBadgeByServerId={hostBadgeByServerId}
supportsPinningByServerId={supportsPinningByServerId} supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin} onToggleWorkspacePin={onToggleWorkspacePin}
onPinnedWorkspaceReorder={onPinnedWorkspaceReorder}
listHeaderComponent={listHeaderComponent} listHeaderComponent={listHeaderComponent}
parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
/> />
); );
} }
@@ -2053,12 +2103,14 @@ function ProjectModeList({
supportsMultiplicityByServerId, supportsMultiplicityByServerId,
supportsPinningByServerId, supportsPinningByServerId,
onToggleWorkspacePin, onToggleWorkspacePin,
onPinnedWorkspaceReorder,
}: Omit<SidebarWorkspaceListProps, "statusGroups" | "groupMode" | "isRefreshing" | "onRefresh"> & { }: Omit<SidebarWorkspaceListProps, "statusGroups" | "groupMode" | "isRefreshing" | "onRefresh"> & {
pathname: string; pathname: string;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>; hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>; supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
supportsPinningByServerId: ReadonlyMap<string, boolean>; supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin; onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
}) { }) {
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -2296,10 +2348,14 @@ function ProjectModeList({
); );
const renderPinnedChat = useCallback( const renderPinnedChat = useCallback(
(workspace: SidebarWorkspacePlacement) => { ({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
return ( return (
<MemoWorkspaceRowItem <MemoWorkspaceRowItem
key={workspace.workspaceKey}
workspace={workspace} workspace={workspace}
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null} workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null} hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
@@ -2316,6 +2372,9 @@ function ProjectModeList({
selectionEnabled={selectionEnabled} selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection} activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress} onWorkspacePress={onWorkspacePress}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
/> />
); );
}, },
@@ -2341,7 +2400,20 @@ function ProjectModeList({
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} /> <PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : ( {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 ? ( {canTogglePinnedChats ? (
<SidebarGroupToggleRow <SidebarGroupToggleRow
expanded={pinnedChatsExpanded} 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 { usePinnedSidebarKeys, type PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-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 type { SidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { buildSidebarProjection } from "./sidebar-projection"; import { buildSidebarProjection } from "./sidebar-projection";
@@ -41,6 +42,7 @@ export function SidebarModelProvider({
(state) => state.collapsedStatusGroupKeys, (state) => state.collapsedStatusGroupKeys,
); );
const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned); const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned);
const pinnedWorkspaceOrder = useSidebarOrderStore((state) => state.pinnedWorkspaceOrder);
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore( const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.toggleProjectCollapsed, (state) => state.toggleProjectCollapsed,
); );
@@ -58,6 +60,7 @@ export function SidebarModelProvider({
buildSidebarProjection({ buildSidebarProjection({
projects: list.projects, projects: list.projects,
pinnedKeys, pinnedKeys,
pinnedWorkspaceOrder,
workspaceEntriesByKey: projectionWorkspaceEntriesByKey, workspaceEntriesByKey: projectionWorkspaceEntriesByKey,
projectNamesByViewKey: list.projectNamesByViewKey, projectNamesByViewKey: list.projectNamesByViewKey,
groupMode, groupMode,
@@ -73,6 +76,7 @@ export function SidebarModelProvider({
list.projects, list.projects,
pinnedCollapsed, pinnedCollapsed,
pinnedKeys, pinnedKeys,
pinnedWorkspaceOrder,
projectionWorkspaceEntriesByKey, projectionWorkspaceEntriesByKey,
], ],
); );
@@ -66,6 +66,7 @@ function projectionInput(options?: {
pinnedWorkspaceKeys: [pinned.placement.workspaceKey], pinnedWorkspaceKeys: [pinned.placement.workspaceKey],
pinnedAtByKey: { [pinned.placement.workspaceKey]: "2026-07-12T12:00:00.000Z" }, pinnedAtByKey: { [pinned.placement.workspaceKey]: "2026-07-12T12:00:00.000Z" },
}, },
pinnedWorkspaceOrder: [],
workspaceEntriesByKey: new Map([ workspaceEntriesByKey: new Map([
[pinned.entry.workspaceKey, pinned.entry], [pinned.entry.workspaceKey, pinned.entry],
[unpinned.entry.workspaceKey, unpinned.entry], [unpinned.entry.workspaceKey, unpinned.entry],
@@ -24,6 +24,7 @@ export interface SidebarProjection {
export function buildSidebarProjection(input: { export function buildSidebarProjection(input: {
projects: SidebarProjectEntry[]; projects: SidebarProjectEntry[];
pinnedKeys: PinnedSidebarKeys; pinnedKeys: PinnedSidebarKeys;
pinnedWorkspaceOrder: string[];
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>; workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByViewKey: Map<string, string>; projectNamesByViewKey: Map<string, string>;
groupMode: SidebarGroupMode; groupMode: SidebarGroupMode;
@@ -34,6 +35,7 @@ export function buildSidebarProjection(input: {
const pinnedGroups = splitPinnedSidebarGroups({ const pinnedGroups = splitPinnedSidebarGroups({
projects: input.projects, projects: input.projects,
keys: input.pinnedKeys, keys: input.pinnedKeys,
pinnedWorkspaceOrder: input.pinnedWorkspaceOrder,
}); });
const pinnedWorkspaceKeys = new Set(input.pinnedKeys.pinnedWorkspaceKeys); const pinnedWorkspaceKeys = new Set(input.pinnedKeys.pinnedWorkspaceKeys);
const statusGroups = 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 { 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 { NestableScrollContainer } from "react-native-draggable-flatlist";
import type { GestureType } from "react-native-gesture-handler";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; 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 { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { withUnistyles } from "react-native-unistyles"; import { withUnistyles } from "react-native-unistyles";
import { import {
ChevronDown, ChevronDown,
@@ -59,6 +76,9 @@ import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin"; 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 // Themed icon wrappers
const foregroundMutedColorMapping = (theme: Theme) => ({ const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -85,6 +105,12 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot); const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX); const ThemedCircleX = withUnistyles(CircleX);
const EMPTY_SHORTCUT_INDEX = new Map<string, number>();
function statusWorkspaceKeyExtractor(workspace: SidebarWorkspaceEntry): string {
return workspace.workspaceKey;
}
interface StatusWorkspaceListProps { interface StatusWorkspaceListProps {
groups: StatusGroup[]; groups: StatusGroup[];
pinnedWorkspaces: SidebarWorkspaceEntry[]; pinnedWorkspaces: SidebarWorkspaceEntry[];
@@ -95,7 +121,10 @@ interface StatusWorkspaceListProps {
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>; hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsPinningByServerId: ReadonlyMap<string, boolean>; supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin; onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspaceEntry[]) => void;
listHeaderComponent?: ReactNode; listHeaderComponent?: ReactNode;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean;
} }
export function SidebarStatusWorkspaceList({ export function SidebarStatusWorkspaceList({
@@ -108,7 +137,10 @@ export function SidebarStatusWorkspaceList({
hostBadgeByServerId, hostBadgeByServerId,
supportsPinningByServerId, supportsPinningByServerId,
onToggleWorkspacePin, onToggleWorkspacePin,
onPinnedWorkspaceReorder,
listHeaderComponent, listHeaderComponent,
parentGestureRef,
dragGestureHostPresented,
}: StatusWorkspaceListProps) { }: StatusWorkspaceListProps) {
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore( const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedStatusGroupKeys, (state) => state.collapsedStatusGroupKeys,
@@ -124,7 +156,44 @@ export function SidebarStatusWorkspaceList({
toggleExpanded: togglePinnedWorkspacesExpanded, toggleExpanded: togglePinnedWorkspacesExpanded,
} = useLimitedSidebarGroup(pinnedWorkspaces); } = 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 = ( const content = (
<> <>
{pinnedWorkspaces.length > 0 ? ( {pinnedWorkspaces.length > 0 ? (
@@ -132,23 +201,18 @@ export function SidebarStatusWorkspaceList({
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} /> <PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : ( {pinnedCollapsed ? null : (
<> <>
{visiblePinnedWorkspaces.map((workspace) => ( <DraggableList
<StatusWorkspaceRow testID="sidebar-pinned-list"
key={workspace.workspaceKey} data={visiblePinnedWorkspaces}
workspace={workspace} keyExtractor={statusWorkspaceKeyExtractor}
{...buildStatusRowProjectPresentation({ renderItem={renderPinnedWorkspace}
workspace, onDragEnd={onPinnedWorkspaceReorder}
projectIconByProjectViewKey, scrollEnabled={false}
hostBadgeByServerId, useDragHandle
})} nestable={platformIsNative}
inStatusGroup={false} simultaneousGestureRef={parentGestureRef}
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null} gestureHostPresented={dragGestureHostPresented}
showShortcutBadge={showShortcutBadges} />
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
/>
))}
{canTogglePinnedWorkspaces ? ( {canTogglePinnedWorkspaces ? (
<SidebarGroupToggleRow <SidebarGroupToggleRow
expanded={pinnedWorkspacesExpanded} expanded={pinnedWorkspacesExpanded}
@@ -423,6 +487,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
reserveIdleStatusIndicatorSpace = true, reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true, inStatusGroup = true,
onWorkspacePress, onWorkspacePress,
drag,
isDragging = false,
dragHandleProps,
}: { }: {
workspace: SidebarWorkspaceEntry; workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null; hostBadge: HostBadgeModel | null;
@@ -439,6 +506,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
*/ */
inStatusGroup?: boolean; inStatusGroup?: boolean;
onWorkspacePress?: () => void; onWorkspacePress?: () => void;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) { }) {
const activeWorkspaceSelection = useActiveWorkspaceSelection(); const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected = const selected =
@@ -465,6 +535,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace} reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
inStatusGroup={inStatusGroup} inStatusGroup={inStatusGroup}
onPress={handlePress} onPress={handlePress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/> />
); );
}); });
@@ -482,6 +555,9 @@ function StatusWorkspaceRowWithMenu({
reserveIdleStatusIndicatorSpace = true, reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true, inStatusGroup = true,
onPress, onPress,
drag,
isDragging = false,
dragHandleProps,
}: { }: {
workspace: SidebarWorkspaceEntry; workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null; hostBadge: HostBadgeModel | null;
@@ -499,6 +575,9 @@ function StatusWorkspaceRowWithMenu({
*/ */
inStatusGroup?: boolean; inStatusGroup?: boolean;
onPress: () => void; onPress: () => void;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
@@ -620,6 +699,9 @@ function StatusWorkspaceRowWithMenu({
onTogglePin={onTogglePin} onTogglePin={onTogglePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace} reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
inStatusGroup={inStatusGroup} inStatusGroup={inStatusGroup}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/> />
<AdaptiveRenameModal <AdaptiveRenameModal
visible={isRenameOpen} 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, workspace,
hostBadge, hostBadge,
projectName, projectName,
@@ -658,47 +786,51 @@ function StatusWorkspaceRowInner({
onTogglePin, onTogglePin,
reserveIdleStatusIndicatorSpace = true, reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true, inStatusGroup = true,
}: { isDragging = false,
workspace: SidebarWorkspaceEntry; dragHandleProps,
hostBadge: HostBadgeModel | null; dragInteraction,
projectName: string; }: StatusWorkspaceRowInnerProps & {
projectIconDataUri: string | null; dragInteraction?: ReturnType<typeof useLongPressDragInteraction>;
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;
}) { }) {
const isTouchPlatform = platformIsNative; const isTouchPlatform = platformIsNative;
const [isPressed, setIsPressed] = useState(false); const [isPressed, setIsPressed] = useState(false);
const trailing = useSidebarWorkspaceTrailing(); const trailing = useSidebarWorkspaceTrailing();
const {
role: _dragRole,
tabIndex: _dragTabIndex,
"aria-roledescription": _dragRoleDescription,
...dragAttributes
} = dragHandleProps?.attributes ?? {};
const isDesktop = !isTouchPlatform; const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null; const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const accessibilityState = useMemo(() => ({ selected }), [selected]); const accessibilityState = useMemo(() => ({ selected }), [selected]);
const handlePressIn = useCallback(() => setIsPressed(true), []); const didLongPressRef = dragInteraction?.didLongPressRef;
const handlePressOut = useCallback(() => setIsPressed(false), []); 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 ( return (
<SidebarWorkspaceRowFrame workspace={workspace}> <SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => { {({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null; const showShortcut = showShortcutBadge && shortcutNumber !== null;
const { const {
@@ -720,9 +852,17 @@ function StatusWorkspaceRowInner({
selected, selected,
isHovered, isHovered,
inStatusGroup, inStatusGroup,
isDragging,
}); });
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return ( return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}> <View
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<SidebarWorkspaceContextMenu <SidebarWorkspaceContextMenu
contextMenuOpen={contextMenuOpen} contextMenuOpen={contextMenuOpen}
onContextMenuOpenChange={onContextMenuOpenChange} onContextMenuOpenChange={onContextMenuOpenChange}
@@ -749,8 +889,9 @@ function StatusWorkspaceRowInner({
style={workspaceRowStyle} style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed} highlightStyle={styles.workspaceRowPressed}
onPressIn={handlePressIn} onPressIn={handlePressIn}
onTouchMove={moveDragPress}
onPressOut={handlePressOut} onPressOut={handlePressOut}
onPress={onPress} onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`} testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
> >
<SidebarWorkspaceRowContent <SidebarWorkspaceRowContent
@@ -759,7 +900,7 @@ function StatusWorkspaceRowInner({
leadingProjectName={projectName} leadingProjectName={projectName}
leadingProjectIconDataUri={projectIconDataUri} leadingProjectIconDataUri={projectIconDataUri}
serviceSummary={serviceSummary} serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isPressed, selected, isHovered })} backdrop={backdrop}
isHovered={isHovered} isHovered={isHovered}
isLoading={isArchiving} isLoading={isArchiving}
shortcutNumber={shortcutNumber} shortcutNumber={shortcutNumber}
@@ -769,6 +910,7 @@ function StatusWorkspaceRowInner({
{renderSlot ? ( {renderSlot ? (
<StatusWorkspaceActionSlot <StatusWorkspaceActionSlot
workspace={workspace} workspace={workspace}
backdrop={backdrop}
trailing={trailing} trailing={trailing}
showBase={showTrailing} showBase={showTrailing}
showKebab={showKebabInSlot} showKebab={showKebabInSlot}
@@ -798,6 +940,7 @@ function StatusWorkspaceRowInner({
function StatusWorkspaceActionSlot({ function StatusWorkspaceActionSlot({
workspace, workspace,
backdrop,
trailing, trailing,
showBase, showBase,
showKebab, showKebab,
@@ -816,6 +959,7 @@ function StatusWorkspaceActionSlot({
archiveShortcutKeys, archiveShortcutKeys,
}: { }: {
workspace: SidebarWorkspaceEntry; workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
trailing: SidebarWorkspaceTrailing; trailing: SidebarWorkspaceTrailing;
showBase: boolean; showBase: boolean;
showKebab: boolean; showKebab: boolean;
@@ -839,7 +983,10 @@ function StatusWorkspaceActionSlot({
<SidebarWorkspaceTrailingActionBase visible={showBase}> <SidebarWorkspaceTrailingActionBase visible={showBase}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} /> <SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase> </SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}> <SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
>
{kebab.showKebab && onArchive ? ( {kebab.showKebab && onArchive ? (
<SidebarWorkspaceMenu <SidebarWorkspaceMenu
{...kebab.menuProps} {...kebab.menuProps}
@@ -867,17 +1014,20 @@ function getStatusWorkspaceRowStyle({
selected, selected,
isHovered, isHovered,
inStatusGroup, inStatusGroup,
isDragging,
}: { }: {
isPressed: boolean; isPressed: boolean;
selected: boolean; selected: boolean;
isHovered: boolean; isHovered: boolean;
inStatusGroup: boolean; inStatusGroup: boolean;
isDragging: boolean;
}) { }) {
return [ return [
styles.workspaceRow, styles.workspaceRow,
inStatusGroup && sidebarWorkspaceRowStyles.rowIndented, inStatusGroup && sidebarWorkspaceRowStyles.rowIndented,
selected && styles.sidebarRowSelected, selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered, isHovered && styles.workspaceRowHovered,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed, isPressed && styles.workspaceRowPressed,
]; ];
} }
@@ -972,6 +1122,14 @@ const styles = StyleSheet.create((theme) => ({
workspaceRowPressed: { workspaceRowPressed: {
backgroundColor: theme.colors.surface2, 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: { sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surfaceSidebarHover,
}, },
@@ -70,7 +70,11 @@ function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; col
const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg); 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({ export function SidebarWorkspaceRowFrame({
workspace, workspace,
@@ -109,7 +113,7 @@ export function SidebarWorkspaceRowFrame({
disabled={contextMenuOpen} disabled={contextMenuOpen}
> >
{children({ {children({
isHovered: isHovered && !contextMenuOpen, isHovered: isHovered && !contextMenuOpen && !isDragging,
contextMenuOpen, contextMenuOpen,
onContextMenuOpenChange: handleContextMenuOpenChange, onContextMenuOpenChange: handleContextMenuOpenChange,
hoverHandlers, hoverHandlers,
@@ -458,18 +462,18 @@ export function SidebarWorkspaceTrailingActionBase({
export function SidebarWorkspaceTrailingActionOverlay({ export function SidebarWorkspaceTrailingActionOverlay({
visible, visible,
scrim = false, scrimBackdrop,
children, children,
}: { }: {
visible: boolean; visible: boolean;
/** Fade the row into the kebab when something (the diff stat) is still rendered behind it. */ /** Fade the row into the kebab when something (the diff stat) is still rendered behind it. */
scrim?: boolean; scrimBackdrop?: SidebarSurfaceBackdrop;
children: ReactNode; children: ReactNode;
}) { }) {
if (!visible || !children) return null; if (!visible || !children) return null;
return ( return (
<> <>
{scrim ? <TrailingActionScrim /> : null} {scrimBackdrop ? <TrailingActionScrim backdrop={scrimBackdrop} /> : null}
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View> <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: * 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. * 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 // 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. // 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. // Keep the per-instance uniqueness, drop everything a fragment reference can't carry.
const gradientId = `sidebar-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; const gradientId = `sidebar-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
return ( return (
<View style={sidebarWorkspaceRowStyles.trailingActionScrim} pointerEvents="none"> <View
<ThemedTrailingActionScrimSvg gradientId={gradientId} uniProps={scrimColorMapping} /> style={sidebarWorkspaceRowStyles.trailingActionScrim}
pointerEvents="none"
testID="sidebar-workspace-trailing-scrim"
>
<ThemedTrailingActionScrimSvg
gradientId={gradientId}
uniProps={scrimColorMappings[backdrop]}
/>
</View> </View>
); );
} }
@@ -6,6 +6,7 @@ import { useMutation } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import type { HostBadgeModel } from "@/hosts/appearance"; import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; 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 { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { AdaptiveRenameModal } from "@/components/rename-modal"; import { AdaptiveRenameModal } from "@/components/rename-modal";
@@ -310,6 +311,7 @@ function WorkspaceRowBody({
selected, selected,
isHovered, isHovered,
}); });
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return ( return (
<View <View
{...(draggable ? dragAttributes : {})} {...(draggable ? dragAttributes : {})}
@@ -353,7 +355,7 @@ function WorkspaceRowBody({
workspace={workspace} workspace={workspace}
hostBadge={hostBadge} hostBadge={hostBadge}
serviceSummary={serviceSummary} serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })} backdrop={backdrop}
isHovered={isHovered} isHovered={isHovered}
isLoading={isArchiving || isCreating} isLoading={isArchiving || isCreating}
isCreating={isCreating} isCreating={isCreating}
@@ -362,6 +364,7 @@ function WorkspaceRowBody({
> >
<WorkspaceRowTrailingActions <WorkspaceRowTrailingActions
workspace={workspace} workspace={workspace}
backdrop={backdrop}
trailing={trailing} trailing={trailing}
isHovered={isHovered} isHovered={isHovered}
isTouchPlatform={isTouchPlatform} isTouchPlatform={isTouchPlatform}
@@ -389,6 +392,7 @@ function WorkspaceRowBody({
function WorkspaceRowTrailingActions({ function WorkspaceRowTrailingActions({
workspace, workspace,
backdrop,
trailing, trailing,
isHovered, isHovered,
isTouchPlatform, isTouchPlatform,
@@ -406,6 +410,7 @@ function WorkspaceRowTrailingActions({
onRename, onRename,
}: { }: {
workspace: SidebarWorkspaceEntry; workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
trailing: SidebarWorkspaceTrailing; trailing: SidebarWorkspaceTrailing;
isHovered: boolean; isHovered: boolean;
isTouchPlatform: boolean; isTouchPlatform: boolean;
@@ -450,7 +455,10 @@ function WorkspaceRowTrailingActions({
<SidebarWorkspaceTrailingActionBase visible={showTrailing}> <SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} /> <SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase> </SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}> <SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
>
{onArchive ? ( {onArchive ? (
<SidebarWorkspaceMenu <SidebarWorkspaceMenu
{...kebab.menuProps} {...kebab.menuProps}
@@ -9,6 +9,7 @@ export function useLongPressDragInteraction(input: {
drag: () => void; drag: () => void;
menuController: ReturnType<typeof useContextMenu> | null; menuController: ReturnType<typeof useContextMenu> | null;
}) { }) {
const { drag, menuController } = input;
const didLongPressRef = useRef(false); const didLongPressRef = useRef(false);
const dragArmedRef = useRef(false); const dragArmedRef = useRef(false);
const dragActivatedRef = useRef(false); const dragActivatedRef = useRef(false);
@@ -32,20 +33,20 @@ export function useLongPressDragInteraction(input: {
}, []); }, []);
const openContextMenuAtStartPoint = useCallback(() => { const openContextMenuAtStartPoint = useCallback(() => {
if (!input.menuController || !touchStartRef.current) { if (!menuController || !touchStartRef.current) {
return; return;
} }
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
input.menuController.setAnchorRect({ menuController.setAnchorRect({
x: touchStartRef.current.x, x: touchStartRef.current.x,
y: touchStartRef.current.y + statusBarHeight, y: touchStartRef.current.y + statusBarHeight,
width: 0, width: 0,
height: 0, height: 0,
}); });
input.menuController.setOpen(true); menuController.setOpen(true);
menuOpenedRef.current = true; menuOpenedRef.current = true;
didLongPressRef.current = true; didLongPressRef.current = true;
}, [input.menuController]); }, [menuController]);
const handleLongPress = useCallback(() => { const handleLongPress = useCallback(() => {
// Manual timers own long-press behavior on mobile. // Manual timers own long-press behavior on mobile.
@@ -87,10 +88,10 @@ export function useLongPressDragInteraction(input: {
dragActivatedRef.current = true; dragActivatedRef.current = true;
didLongPressRef.current = true; didLongPressRef.current = true;
void Haptics.selectionAsync().catch(() => {}); void Haptics.selectionAsync().catch(() => {});
input.drag(); drag();
}, DRAG_ARM_DELAY_MS); }, DRAG_ARM_DELAY_MS);
if (!input.menuController) { if (!menuController) {
return; return;
} }
@@ -112,7 +113,7 @@ export function useLongPressDragInteraction(input: {
void Haptics.selectionAsync().catch(() => {}); void Haptics.selectionAsync().catch(() => {});
openContextMenuAtStartPoint(); openContextMenuAtStartPoint();
}, CONTEXT_MENU_DELAY_MS); }, CONTEXT_MENU_DELAY_MS);
}, [clearTimers, input, openContextMenuAtStartPoint]); }, [clearTimers, drag, menuController, openContextMenuAtStartPoint]);
const handleDragIntent = useCallback( const handleDragIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => { (_details: { dx: number; dy: number; distance: number }) => {
@@ -39,6 +39,7 @@ describe("splitPinnedSidebarGroups", () => {
pinnedWorkspaceKeys: ["w1"], pinnedWorkspaceKeys: ["w1"],
pinnedAtByKey: { w1: "2026-01-01T00:00:00Z" }, pinnedAtByKey: { w1: "2026-01-01T00:00:00Z" },
}, },
pinnedWorkspaceOrder: [],
}); });
expect(result.pinnedChats).toHaveLength(1); expect(result.pinnedChats).toHaveLength(1);
expect(result.unpinnedProjects).toHaveLength(0); expect(result.unpinnedProjects).toHaveLength(0);
@@ -49,6 +50,7 @@ describe("splitPinnedSidebarGroups", () => {
const result = splitPinnedSidebarGroups({ const result = splitPinnedSidebarGroups({
projects, projects,
keys: { pinnedWorkspaceKeys: [], pinnedAtByKey: {} }, keys: { pinnedWorkspaceKeys: [], pinnedAtByKey: {} },
pinnedWorkspaceOrder: [],
}); });
expect(result.unpinnedProjects).toHaveLength(1); expect(result.unpinnedProjects).toHaveLength(1);
}); });
@@ -61,6 +63,7 @@ describe("splitPinnedSidebarGroups", () => {
pinnedWorkspaceKeys: ["w1"], pinnedWorkspaceKeys: ["w1"],
pinnedAtByKey: { w1: "2026-01-01T00:00:00Z" }, pinnedAtByKey: { w1: "2026-01-01T00:00:00Z" },
}, },
pinnedWorkspaceOrder: [],
}); });
expect(result.pinnedChats.map((w) => w.workspaceKey)).toEqual(["w1"]); expect(result.pinnedChats.map((w) => w.workspaceKey)).toEqual(["w1"]);
expect(result.unpinnedProjects[0]?.workspaces.map((w) => w.workspaceKey)).toEqual(["w2"]); expect(result.unpinnedProjects[0]?.workspaces.map((w) => w.workspaceKey)).toEqual(["w2"]);
@@ -77,6 +80,7 @@ describe("splitPinnedSidebarGroups", () => {
newer: "2026-02-01T00:00:00Z", newer: "2026-02-01T00:00:00Z",
}, },
}, },
pinnedWorkspaceOrder: [],
}); });
expect(result.pinnedChats.map((workspace) => workspace.workspaceKey)).toEqual([ expect(result.pinnedChats.map((workspace) => workspace.workspaceKey)).toEqual([
@@ -84,4 +88,26 @@ describe("splitPinnedSidebarGroups", () => {
"older", "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, SidebarProjectEntry,
SidebarWorkspacePlacement, SidebarWorkspacePlacement,
} from "@/hooks/use-sidebar-workspaces-list"; } from "@/hooks/use-sidebar-workspaces-list";
import { applyStoredOrdering } from "@/hooks/sidebar-workspaces-view-model";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
export interface PinnedSidebarKeys { export interface PinnedSidebarKeys {
@@ -101,8 +102,9 @@ export function usePinnedSidebarKeys(projects: SidebarProjectEntry[]): PinnedSid
export function splitPinnedSidebarGroups(input: { export function splitPinnedSidebarGroups(input: {
projects: SidebarProjectEntry[]; projects: SidebarProjectEntry[];
keys: PinnedSidebarKeys; keys: PinnedSidebarKeys;
pinnedWorkspaceOrder: string[];
}): PinnedSidebarGroups { }): PinnedSidebarGroups {
const { projects, keys } = input; const { projects, keys, pinnedWorkspaceOrder } = input;
if (keys.pinnedWorkspaceKeys.length === 0) { if (keys.pinnedWorkspaceKeys.length === 0) {
return { pinnedChats: [], unpinnedProjects: projects }; 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({ expect(migrated).toEqual({
projectOrder: ["project-a"], projectOrder: ["project-a"],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { workspaceOrderByProject: {
"project-a": ["host-a:main", "host-a:feature", "host-b:main"], "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 { interface SidebarOrderStoreState {
projectOrder: string[]; projectOrder: string[];
pinnedWorkspaceOrder: string[];
workspaceOrderByProject: Record<string, string[]>; workspaceOrderByProject: Record<string, string[]>;
getProjectOrder: () => string[]; getProjectOrder: () => string[];
setProjectOrder: (keys: string[]) => void; setProjectOrder: (keys: string[]) => void;
getPinnedWorkspaceOrder: () => string[];
setPinnedWorkspaceOrder: (keys: string[]) => void;
getWorkspaceOrder: (projectViewKey: string) => string[]; getWorkspaceOrder: (projectViewKey: string) => string[];
setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void; setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void;
} }
interface SidebarOrderPersistedState { interface SidebarOrderPersistedState {
projectOrder?: string[]; projectOrder?: string[];
pinnedWorkspaceOrder?: string[];
workspaceOrderByProject?: Record<string, string[]>; workspaceOrderByProject?: Record<string, string[]>;
projectOrderByServerId?: Record<string, string[]>; projectOrderByServerId?: Record<string, string[]>;
workspaceOrderByServerAndProject?: Record<string, string[]>; workspaceOrderByServerAndProject?: Record<string, string[]>;
@@ -23,6 +27,7 @@ interface SidebarOrderPersistedState {
const StringArrayRecordSchema = z.record(z.string(), z.array(z.string())); const StringArrayRecordSchema = z.record(z.string(), z.array(z.string()));
const SidebarOrderPersistedStateSchema = z.strictObject({ const SidebarOrderPersistedStateSchema = z.strictObject({
projectOrder: z.array(z.string()).optional(), projectOrder: z.array(z.string()).optional(),
pinnedWorkspaceOrder: z.array(z.string()).optional(),
workspaceOrderByProject: StringArrayRecordSchema.optional(), workspaceOrderByProject: StringArrayRecordSchema.optional(),
projectOrderByServerId: StringArrayRecordSchema.optional(), projectOrderByServerId: StringArrayRecordSchema.optional(),
workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(), workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(),
@@ -79,11 +84,12 @@ function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string):
export function migrateSidebarOrderState(persistedState: unknown): { export function migrateSidebarOrderState(persistedState: unknown): {
projectOrder: string[]; projectOrder: string[];
pinnedWorkspaceOrder: string[];
workspaceOrderByProject: Record<string, string[]>; workspaceOrderByProject: Record<string, string[]>;
} { } {
const result = SidebarOrderPersistedStateSchema.safeParse(persistedState); const result = SidebarOrderPersistedStateSchema.safeParse(persistedState);
if (!result.success) { if (!result.success) {
return { projectOrder: [], workspaceOrderByProject: {} }; return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} };
} }
const state: SidebarOrderPersistedState = result.data; const state: SidebarOrderPersistedState = result.data;
@@ -113,19 +119,29 @@ export function migrateSidebarOrderState(persistedState: unknown): {
workspaceOrderByProject[scope.projectViewKey] = merged; workspaceOrderByProject[scope.projectViewKey] = merged;
} }
return { projectOrder, workspaceOrderByProject }; return {
projectOrder,
pinnedWorkspaceOrder: normalizeKeys(state.pinnedWorkspaceOrder ?? []),
workspaceOrderByProject,
};
} }
export const useSidebarOrderStore = create<SidebarOrderStoreState>()( export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
projectOrder: [], projectOrder: [],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: {}, workspaceOrderByProject: {},
getProjectOrder: () => get().projectOrder, getProjectOrder: () => get().projectOrder,
setProjectOrder: (keys) => { setProjectOrder: (keys) => {
const normalized = normalizeKeys(keys); const normalized = normalizeKeys(keys);
set({ projectOrder: normalized }); set({ projectOrder: normalized });
}, },
getPinnedWorkspaceOrder: () => get().pinnedWorkspaceOrder,
setPinnedWorkspaceOrder: (keys) => {
const normalized = normalizeKeys(keys);
set({ pinnedWorkspaceOrder: normalized });
},
getWorkspaceOrder: (projectViewKey) => { getWorkspaceOrder: (projectViewKey) => {
const scope = projectViewKey.trim(); const scope = projectViewKey.trim();
if (!scope) return []; if (!scope) return [];
@@ -148,6 +164,7 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
storage: createValidatedPersistStorage(AsyncStorage, SidebarOrderPersistedStateSchema), storage: createValidatedPersistStorage(AsyncStorage, SidebarOrderPersistedStateSchema),
partialize: (state) => ({ partialize: (state) => ({
projectOrder: state.projectOrder, projectOrder: state.projectOrder,
pinnedWorkspaceOrder: state.pinnedWorkspaceOrder,
workspaceOrderByProject: state.workspaceOrderByProject, workspaceOrderByProject: state.workspaceOrderByProject,
}), }),
version: 1, version: 1,