authorgravatar for me@mwolson.orgMike Olson <me@mwolson.org> 2026-04-10 22:25:43-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-10 19:25:43-07:00
log97880e88705fb74648677a850aadc7b6aeac5253
tree94723ae25f7a058d8a7fd651115aebf25d64894b
parentb80e8476b86b6ce2b39c7c5564fed92fd80b4890
signature Signed by PGP key B5690EEEBB952194

fix(web): resolve logical-to-physical key mismatch in project drag reorder (#1904)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

3 files changed, 144 insertions(+), 14 deletions(-)

apps/web/src/components/Sidebar.tsx+3-1
......@@ -2696,7 +2696,9 @@ export default function Sidebar() {
26962696 const activeProject = sidebarProjects.find((project) => project.projectKey === active.id);
26972697 const overProject = sidebarProjects.find((project) => project.projectKey === over.id);
26982698 if (!activeProject || !overProject) return;
2699 reorderProjects(activeProject.projectKey, overProject.projectKey);
2699 const activeMemberKeys = activeProject.memberProjectRefs.map(scopedProjectKey);
2700 const overMemberKeys = overProject.memberProjectRefs.map(scopedProjectKey);
2701 reorderProjects(activeMemberKeys, overMemberKeys);
27002702 },
27012703 [sidebarProjectSortOrder, reorderProjects, sidebarProjects],
27022704 );
apps/web/src/uiStateStore.test.ts+108-1
......@@ -58,11 +58,118 @@ describe("uiStateStore pure functions", () => {
5858 projectOrder: [project1, project2, project3],
5959 });
6060
61 const next = reorderProjects(initialState, project1, project3);
61 const next = reorderProjects(initialState, [project1], [project3]);
6262
6363 expect(next.projectOrder).toEqual([project2, project3, project1]);
6464 });
6565
66 it("reorderProjects is a no-op when dragged key is not in projectOrder", () => {
67 const project1 = ProjectId.make("project-1");
68 const project2 = ProjectId.make("project-2");
69 const initialState = makeUiState({
70 projectOrder: [project1, project2],
71 });
72
73 const next = reorderProjects(initialState, [ProjectId.make("missing")], [project2]);
74
75 expect(next).toBe(initialState);
76 });
77
78 it("reorderProjects moves all member keys of a multi-member group together", () => {
79 const keyALocal = "env-local:proj-a";
80 const keyARemote = "env-remote:proj-a";
81 const keyB = "env-local:proj-b";
82 const keyC = "env-local:proj-c";
83 const initialState = makeUiState({
84 projectOrder: [keyALocal, keyARemote, keyB, keyC],
85 });
86
87 const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]);
88
89 expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]);
90 });
91
92 it("reorderProjects handles member keys scattered across projectOrder", () => {
93 const keyALocal = "env-local:proj-a";
94 const keyB = "env-local:proj-b";
95 const keyARemote = "env-remote:proj-a";
96 const keyC = "env-local:proj-c";
97 const initialState = makeUiState({
98 projectOrder: [keyALocal, keyB, keyARemote, keyC],
99 });
100
101 const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]);
102
103 expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]);
104 });
105
106 it("reorderProjects places group after target when dragged from before a non-last target", () => {
107 const keyALocal = "env-local:proj-a";
108 const keyARemote = "env-remote:proj-a";
109 const keyB = "env-local:proj-b";
110 const keyC = "env-local:proj-c";
111 const keyD = "env-local:proj-d";
112 const initialState = makeUiState({
113 projectOrder: [keyALocal, keyARemote, keyB, keyC, keyD],
114 });
115
116 const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]);
117
118 expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote, keyD]);
119 });
120
121 it("reorderProjects places group before target when dragged from after", () => {
122 const keyB = "env-local:proj-b";
123 const keyC = "env-local:proj-c";
124 const keyALocal = "env-local:proj-a";
125 const keyARemote = "env-remote:proj-a";
126 const initialState = makeUiState({
127 projectOrder: [keyB, keyC, keyALocal, keyARemote],
128 });
129
130 const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyB]);
131
132 expect(next.projectOrder).toEqual([keyALocal, keyARemote, keyB, keyC]);
133 });
134
135 it("reorderProjects with multi-member target inserts after first target occurrence", () => {
136 const keyALocal = "env-local:proj-a";
137 const keyARemote = "env-remote:proj-a";
138 const keyBLocal = "env-local:proj-b";
139 const keyBRemote = "env-remote:proj-b";
140 const initialState = makeUiState({
141 projectOrder: [keyALocal, keyARemote, keyBLocal, keyBRemote],
142 });
143
144 const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyBLocal, keyBRemote]);
145
146 // Target members may become non-contiguous; this is fine because the
147 // sidebar groups by logical key using first-occurrence positioning.
148 expect(next.projectOrder).toEqual([keyBLocal, keyALocal, keyARemote, keyBRemote]);
149 });
150
151 it("reorderProjects is a no-op when dragged group equals target group", () => {
152 const key1 = "env-local:proj-a";
153 const key2 = "env-remote:proj-a";
154 const initialState = makeUiState({
155 projectOrder: [key1, key2, "env-local:proj-b"],
156 });
157
158 const next = reorderProjects(initialState, [key1, key2], [key1, key2]);
159
160 expect(next).toBe(initialState);
161 });
162
163 it("reorderProjects is a no-op when dragged keys are not in projectOrder", () => {
164 const initialState = makeUiState({
165 projectOrder: ["env-local:proj-a", "env-local:proj-b"],
166 });
167
168 const next = reorderProjects(initialState, ["env-local:missing"], ["env-local:proj-b"]);
169
170 expect(next).toBe(initialState);
171 });
172
66173 it("syncProjects preserves current project order during snapshot recovery", () => {
67174 const project1 = ProjectId.make("project-1");
68175 const project2 = ProjectId.make("project-2");
apps/web/src/uiStateStore.ts+33-12
......@@ -482,23 +482,41 @@ export function setProjectExpanded(state: UiState, projectId: string, expanded:
482482
483483export function reorderProjects(
484484 state: UiState,
485 draggedProjectId: string,
486 targetProjectId: string,
485 draggedProjectIds: readonly string[],
486 targetProjectIds: readonly string[],
487487): UiState {
488 if (draggedProjectId === targetProjectId) {
488 if (draggedProjectIds.length === 0) {
489489 return state;
490490 }
491 const draggedIndex = state.projectOrder.findIndex((projectId) => projectId === draggedProjectId);
492 const targetIndex = state.projectOrder.findIndex((projectId) => projectId === targetProjectId);
493 if (draggedIndex < 0 || targetIndex < 0) {
491 const draggedSet = new Set(draggedProjectIds);
492 const targetSet = new Set(targetProjectIds);
493 if (draggedProjectIds.every((id) => targetSet.has(id))) {
494494 return state;
495495 }
496
497 const originalTargetIndex = state.projectOrder.findIndex((id) => targetSet.has(id));
498 if (originalTargetIndex < 0) {
499 return state;
500 }
501
496502 const projectOrder = [...state.projectOrder];
497 const [draggedProject] = projectOrder.splice(draggedIndex, 1);
498 if (!draggedProject) {
503
504 const removed: string[] = [];
505 let draggedBeforeTarget = 0;
506 for (let i = projectOrder.length - 1; i >= 0; i--) {
507 if (draggedSet.has(projectOrder[i]!)) {
508 removed.unshift(projectOrder.splice(i, 1)[0]!);
509 if (i < originalTargetIndex) {
510 draggedBeforeTarget++;
511 }
512 }
513 }
514 if (removed.length === 0) {
499515 return state;
500516 }
501 projectOrder.splice(targetIndex, 0, draggedProject);
517
518 const insertIndex = originalTargetIndex - Math.max(0, draggedBeforeTarget - 1);
519 projectOrder.splice(insertIndex, 0, ...removed);
502520 return {
503521 ...state,
504522 projectOrder,
......@@ -514,7 +532,10 @@ interface UiStateStore extends UiState {
514532 setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
515533 toggleProject: (projectId: string) => void;
516534 setProjectExpanded: (projectId: string, expanded: boolean) => void;
517 reorderProjects: (draggedProjectId: string, targetProjectId: string) => void;
535 reorderProjects: (
536 draggedProjectIds: readonly string[],
537 targetProjectIds: readonly string[],
538 ) => void;
518539}
519540
520541export const useUiStateStore = create<UiStateStore>((set) => ({
......@@ -531,8 +552,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({
531552 toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),
532553 setProjectExpanded: (projectId, expanded) =>
533554 set((state) => setProjectExpanded(state, projectId, expanded)),
534 reorderProjects: (draggedProjectId, targetProjectId) =>
535 set((state) => reorderProjects(state, draggedProjectId, targetProjectId)),
555 reorderProjects: (draggedProjectIds, targetProjectIds) =>
556 set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)),
536557}));
537558
538559useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state));