Skip to content

Commit 8a248bc

Browse files
committed
feat(sidebar): reorder projects with press-and-move
Replace the 400ms long-press delay with an 8px movement threshold so dragging a project title matches ChatGPT-style desktop lists. Clicks still collapse; an insertion line shows drop placement.
1 parent 69ae4b9 commit 8a248bc

20 files changed

Lines changed: 169 additions & 88 deletions

apps/desktop/src/components/Sidebar.tsx

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,9 @@ import {
4444
hasComposerFileDrag,
4545
} from "../lib/composer-drop";
4646
import {
47-
PROJECT_REORDER_LONG_PRESS_MS,
4847
projectGroupKeyFromPoint,
4948
projectReorderInsertAfter,
50-
projectReorderMovedTooFar,
49+
projectReorderShouldArm,
5150
sameProjectReorderBucket,
5251
} from "../lib/sidebar-project-reorder";
5352
import {
@@ -145,10 +144,10 @@ type ProjectReorderPointerState = {
145144
lastX: number;
146145
lastY: number;
147146
armed: boolean;
148-
timer: number | null;
149147
dropKey: string | null;
150148
dropTop: number;
151149
dropHeight: number;
150+
insertAfter: boolean;
152151
onMove: (event: PointerEvent) => void;
153152
onUp: (event: PointerEvent) => void;
154153
onCancel: (event: PointerEvent) => void;
@@ -319,7 +318,7 @@ export function Sidebar({
319318
const [projectsDropActive, setProjectsDropActive] = useState(false);
320319
const [sidebarResizing, setSidebarResizing] = useState(false);
321320
const [draggingProjectKey, setDraggingProjectKey] = useState<string | null>(null);
322-
const [dropTargetProjectKey, setDropTargetProjectKey] = useState<string | null>(null);
321+
const [dropIndicator, setDropIndicator] = useState<{ key: string; insertAfter: boolean } | null>(null);
323322
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
324323
const menuFirstItemRef = useRef<HTMLButtonElement | null>(null);
325324
const sessionPrefetchTimerRef = useRef<number | undefined>(undefined);
@@ -784,15 +783,14 @@ export function Sidebar({
784783
const finishProjectReorderPress = useCallback((opts?: { keepClickSuppressed?: boolean }) => {
785784
const state = projectReorderRef.current;
786785
if (state) {
787-
if (state.timer != null) window.clearTimeout(state.timer);
788786
window.removeEventListener("pointermove", state.onMove, true);
789787
window.removeEventListener("pointerup", state.onUp, true);
790788
window.removeEventListener("pointercancel", state.onCancel, true);
791789
projectReorderRef.current = null;
792790
}
793791
if (!opts?.keepClickSuppressed) suppressProjectTitleClickRef.current = false;
794792
setDraggingProjectKey(null);
795-
setDropTargetProjectKey(null);
793+
setDropIndicator(null);
796794
document.documentElement.removeAttribute("data-project-reordering");
797795
}, []);
798796

@@ -833,7 +831,9 @@ export function Sidebar({
833831

834832
const beginProjectReorderPress = useCallback(
835833
(event: ReactPointerEvent<HTMLButtonElement>, projectKey: string) => {
836-
if (event.button !== 0 || projectReorderRef.current) return;
834+
if (event.button !== 0 || event.pointerType === "touch" || projectReorderRef.current) {
835+
return;
836+
}
837837

838838
const onMove = (moveEvent: PointerEvent) => {
839839
const current = projectReorderRef.current;
@@ -842,14 +842,17 @@ export function Sidebar({
842842
current.lastY = moveEvent.clientY;
843843
if (!current.armed) {
844844
if (
845-
projectReorderMovedTooFar(
845+
!projectReorderShouldArm(
846846
moveEvent.clientX - current.startX,
847847
moveEvent.clientY - current.startY,
848848
)
849849
) {
850-
finishProjectReorderPress();
850+
return;
851851
}
852-
return;
852+
current.armed = true;
853+
suppressProjectTitleClickRef.current = true;
854+
document.documentElement.setAttribute("data-project-reordering", "true");
855+
setDraggingProjectKey(current.projectKey);
853856
}
854857
moveEvent.preventDefault();
855858
const target = projectGroupKeyFromPoint(moveEvent.clientX, moveEvent.clientY);
@@ -864,13 +867,19 @@ export function Sidebar({
864867
target.key !== current.projectKey &&
865868
sameProjectReorderBucket(source.meta, destination.meta)
866869
) {
870+
const insertAfter = projectReorderInsertAfter(
871+
moveEvent.clientY,
872+
target.top,
873+
target.height,
874+
);
867875
current.dropKey = target.key;
868876
current.dropTop = target.top;
869877
current.dropHeight = target.height;
870-
setDropTargetProjectKey(target.key);
878+
current.insertAfter = insertAfter;
879+
setDropIndicator({ key: target.key, insertAfter });
871880
} else {
872881
current.dropKey = null;
873-
setDropTargetProjectKey(null);
882+
setDropIndicator(null);
874883
}
875884
};
876885

@@ -883,7 +892,7 @@ export function Sidebar({
883892
reorderProjectEntriesRef.current(
884893
current.projectKey,
885894
current.dropKey,
886-
projectReorderInsertAfter(current.lastY, current.dropTop, current.dropHeight),
895+
current.insertAfter,
887896
);
888897
}
889898
finishProjectReorderPress({ keepClickSuppressed: true });
@@ -906,23 +915,14 @@ export function Sidebar({
906915
lastX: event.clientX,
907916
lastY: event.clientY,
908917
armed: false,
909-
timer: null,
910918
dropKey: null,
911919
dropTop: 0,
912920
dropHeight: 0,
921+
insertAfter: false,
913922
onMove,
914923
onUp,
915924
onCancel,
916925
};
917-
state.timer = window.setTimeout(() => {
918-
const current = projectReorderRef.current;
919-
if (current !== state) return;
920-
current.armed = true;
921-
current.timer = null;
922-
suppressProjectTitleClickRef.current = true;
923-
document.documentElement.setAttribute("data-project-reordering", "true");
924-
setDraggingProjectKey(current.projectKey);
925-
}, PROJECT_REORDER_LONG_PRESS_MS);
926926
projectReorderRef.current = state;
927927
window.addEventListener("pointermove", onMove, true);
928928
window.addEventListener("pointerup", onUp, true);
@@ -1663,7 +1663,7 @@ export function Sidebar({
16631663
return (
16641664
<section
16651665
key={entry.key}
1666-
className={`sidebar-session-group project-group ${entry.active ? "active" : ""} ${entry.meta.archived ? "archived" : ""} ${dropProjectKey === entry.key ? "is-drop-target" : ""} ${draggingProjectKey === entry.key ? "is-dragging" : ""} ${dropTargetProjectKey === entry.key ? "is-drop-target" : ""}`}
1666+
className={`sidebar-session-group project-group ${entry.active ? "active" : ""} ${entry.meta.archived ? "archived" : ""} ${dropProjectKey === entry.key ? "is-drop-target" : ""} ${draggingProjectKey === entry.key ? "is-dragging" : ""} ${dropIndicator?.key === entry.key ? (dropIndicator.insertAfter ? "is-drop-after" : "is-drop-before") : ""}`}
16671667
aria-labelledby={projectId}
16681668
data-sidebar-project-group={entry.key}
16691669
onDragOver={(event) => {

apps/desktop/src/lib/sidebar-project-reorder.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
/** Long-press delay before a project title starts a reorder drag. */
2-
export const PROJECT_REORDER_LONG_PRESS_MS = 400;
3-
/** Pointer travel that cancels a pending long-press (scroll / click jitter). */
4-
export const PROJECT_REORDER_MOVE_CANCEL_PX = 8;
1+
/** Pointer travel that arms a title drag (click vs ChatGPT-style press-and-move). */
2+
export const PROJECT_REORDER_ARM_PX = 8;
53

6-
export function projectReorderMovedTooFar(
4+
export function projectReorderShouldArm(
75
dx: number,
86
dy: number,
9-
thresholdPx = PROJECT_REORDER_MOVE_CANCEL_PX,
7+
thresholdPx = PROJECT_REORDER_ARM_PX,
108
): boolean {
119
return dx * dx + dy * dy > thresholdPx * thresholdPx;
1210
}

apps/desktop/src/styles/sidebar-threads.css

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@
6666
min-width: 0;
6767
}
6868

69+
.sidebar-session-group.project-group {
70+
position: relative;
71+
}
72+
6973
.sidebar-session-group-header {
7074
display: flex;
7175
min-height: 28px;
@@ -78,16 +82,40 @@
7882
opacity: 0.5;
7983
}
8084

85+
.sidebar-session-group-title.project-toggle {
86+
cursor: grab;
87+
}
88+
8189
.project-group.is-dragging .sidebar-session-group-title {
8290
cursor: grabbing;
8391
}
8492

93+
.project-group.is-drop-before::before,
94+
.project-group.is-drop-after::after {
95+
position: absolute;
96+
right: 8px;
97+
left: 8px;
98+
z-index: 1;
99+
height: 2px;
100+
border-radius: 1px;
101+
background: var(--ds-accent);
102+
content: "";
103+
pointer-events: none;
104+
}
105+
106+
.project-group.is-drop-before::before {
107+
top: -1px;
108+
}
109+
110+
.project-group.is-drop-after::after {
111+
bottom: -1px;
112+
}
113+
85114
.project-group.is-drop-target > .sidebar-session-group-header {
86115
outline: 1px solid var(--ds-accent);
87116
outline-offset: -1px;
88117
background: color-mix(in oklab, var(--ds-accent) 12%, transparent);
89118
}
90-
91119
.sidebar-session-group-title {
92120
display: flex;
93121
min-width: 0;

apps/desktop/test/app-store-sidebar.test.mjs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,17 @@ test("global search stays on the conversation topbar, not the sidebar header", (
9595
assert.match(topbarSource, /ariaLabel=\{t\("nav\.search"\)\}/);
9696
});
9797

98-
test("project rows expose long-press title drag and keyboard reorder behavior", () => {
98+
test("project rows expose press-and-move title drag and keyboard reorder behavior", () => {
9999
assert.doesNotMatch(sidebarSource, /sidebar-project-drag-handle/);
100100
assert.doesNotMatch(sidebarSource, /IconGripVertical/);
101101
assert.doesNotMatch(sidebarSource, /PROJECT_DRAG_MIME/);
102-
assert.match(sidebarSource, /PROJECT_REORDER_LONG_PRESS_MS/);
102+
assert.doesNotMatch(sidebarSource, /PROJECT_REORDER_LONG_PRESS_MS/);
103+
assert.match(sidebarSource, /projectReorderShouldArm/);
103104
assert.match(sidebarSource, /beginProjectReorderPress\(event, entry\.key\)/);
104105
assert.match(sidebarSource, /onKeyDown=\{\(event\) => moveProjectWithKeyboard/);
105106
assert.match(sidebarSource, /aria-grabbed=\{draggingProjectKey === entry.key\}/);
106107
assert.match(sidebarSource, /className="sidebar-session-group-title project-toggle"/);
108+
assert.match(sidebarSource, /is-drop-before/);
107109
assert.match(storeSource, /reorderProjects: \(paths\) =>/);
108110
assert.match(storeSource, /projectSort: "manual"/);
109111
assert.match(storeSource, /persistCurrentSidebar\(get\)/);

apps/desktop/test/sidebar-project-reorder.test.mjs

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,20 @@
11
import assert from "node:assert/strict";
22
import test from "node:test";
33
import {
4-
PROJECT_REORDER_LONG_PRESS_MS,
5-
PROJECT_REORDER_MOVE_CANCEL_PX,
4+
PROJECT_REORDER_ARM_PX,
65
projectGroupKeyFromPoint,
76
projectReorderInsertAfter,
8-
projectReorderMovedTooFar,
7+
projectReorderShouldArm,
98
sameProjectReorderBucket,
109
} from "../src/lib/sidebar-project-reorder.ts";
1110

12-
test("project title reorder uses a long-press delay rather than immediate drag", () => {
13-
assert.equal(PROJECT_REORDER_LONG_PRESS_MS, 400);
14-
assert.equal(PROJECT_REORDER_MOVE_CANCEL_PX, 8);
15-
});
16-
17-
test("pending long-press cancels after a small pointer movement", () => {
18-
assert.equal(projectReorderMovedTooFar(0, 0), false);
19-
assert.equal(projectReorderMovedTooFar(4, 4), false);
20-
assert.equal(projectReorderMovedTooFar(8, 0), false);
21-
assert.equal(projectReorderMovedTooFar(9, 0), true);
22-
assert.equal(projectReorderMovedTooFar(0, 9), true);
11+
test("project title reorder arms after a small pointer movement, not a time delay", () => {
12+
assert.equal(PROJECT_REORDER_ARM_PX, 8);
13+
assert.equal(projectReorderShouldArm(0, 0), false);
14+
assert.equal(projectReorderShouldArm(4, 4), false);
15+
assert.equal(projectReorderShouldArm(8, 0), false);
16+
assert.equal(projectReorderShouldArm(9, 0), true);
17+
assert.equal(projectReorderShouldArm(0, 9), true);
2318
});
2419

2520
test("drop inserts after the target when the pointer is in the lower half", () => {

docs/adr/0228-long-press-project-title-reorder.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR 0228: Long-press the project title to reorder
22

3-
- Status: Accepted
3+
- Status: Accepted (amended by 0229)
44
- Date: 2026-09-11
55
- Amends: [ADR 0227](0227-project-group-manual-ordering.md)
66
- Related: [D402](../spec/08-meta/decisions-log.md) · [Component spec](../spec/04-ux/08-component-spec.md) · E2E-253
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# ADR 0229: Press-and-move project title reorder
2+
3+
- Status: Accepted
4+
- Date: 2026-09-11
5+
- Amends: [ADR 0228](0228-long-press-project-title-reorder.md)
6+
- Related: [D403](../spec/08-meta/decisions-log.md) · [Component spec](../spec/04-ux/08-component-spec.md) · E2E-253
7+
8+
## Context
9+
10+
ADR 0228 removed the reorder grip and armed a drag after a 400ms still
11+
press. That delay is a mobile long-press pattern. On a desktop sidebar it
12+
makes reorder slower than ChatGPT, Claude, and similar product lists, where
13+
grabbing a row and moving it starts the drag immediately.
14+
15+
## Decision
16+
17+
The project title remains the reorder control, with no grip. Pointer
18+
disambiguation is movement, not time:
19+
20+
- Mouse and pen: an 8px move while pressed arms the drag. A click with no
21+
qualifying movement still selects the project and toggles collapse.
22+
- Touch presses do not start a reorder, so a one-finger pan can scroll the
23+
list. Keyboard ArrowUp/ArrowDown on the focused title remains available.
24+
- While dragging, an accent insertion line on the target group shows
25+
before/after placement from the pointer's vertical midpoint. Escape
26+
cancels. Persistence and pin/archive buckets are unchanged.
27+
28+
## Consequences
29+
30+
- Desktop reorder matches common sidebar lists: press, move, drop.
31+
- Touch scrolling is not stolen by an accidental 8px pan on a title.
32+
- The 400ms still-press contract in ADR 0228 is replaced.
33+
34+
## References
35+
36+
- `apps/desktop/src/components/Sidebar.tsx`
37+
- `apps/desktop/src/lib/sidebar-project-reorder.ts`
38+
- `apps/desktop/test/sidebar-project-reorder.test.mjs`

docs/adr/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,4 +245,5 @@ Each ADR includes:
245245
| 0225 | Restore deferred tools from effective session context | Accepted (issue #225) |
246246
| 0226 | Reserve chat width for composer controls | Accepted |
247247
| 0227 | Project group manual ordering | Accepted (amended by 0228) |
248-
| 0228 | Long-press the project title to reorder | Accepted (amends 0227) |
248+
| 0228 | Long-press the project title to reorder | Accepted (amended by 0229) |
249+
| 0229 | Press-and-move project title reorder | Accepted (amends 0228) |

docs/spec/03-runtime/04-data-storage.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ type SidebarPreferences = {
243243
- Project keys and retained paths use normalized full paths; session keys use
244244
durable session ids. Duplicate/slash-variant paths are discarded on load.
245245
- `projectSort: "manual"` and `projectMeta[*].order` store renderer-local
246-
project presentation order. Long-pressing a project title or using ArrowUp
246+
project presentation order. Dragging a project title or using ArrowUp
247247
and ArrowDown on that title writes contiguous order values for the visible normalized paths.
248248
Missing or invalid values fall back to stable path order; pinned and archived
249249
priority remains applied before manual order. Session `manual`/`order` remain

docs/spec/04-ux/01-ui-ia.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,8 @@ destination, chat as the home surface, tools and permissions inline.
167167
overflow menu. The directory title is one full-row disclosure target;
168168
collapse/expand affects only child visibility, and adjacent groups form one
169169
dense tree rather than detached cards. Hovering or focusing the project title
170-
reveals the full project path. A 400ms still press on the title reorders the
171-
group.
170+
reveals the full project path. Pressing the title and moving 8px reorders
171+
the group.
172172
- **Project actions**: open folder reveals the project directory; rename edits
173173
the renderer-local display name while the normalized path remains the
174174
project identity; pin/unpin changes presentation priority; archive/restore
@@ -182,7 +182,7 @@ destination, chat as the home surface, tools and permissions inline.
182182
conversation action.
183183
- **Sort**: user-facing modes are Recently updated, Created date, Oldest
184184
first, and Name. Pinned rows precede unpinned rows. Project groups switch
185-
to `manual` by long-pressing a title or using ArrowUp/ArrowDown on that
185+
to `manual` by dragging a title or using ArrowUp/ArrowDown on that
186186
title. Session `manual` remains a compatibility value.
187187
- **Conversation list**: each group shows the ten most-recent sessions in the
188188
active sort order by default; the remainder folds behind a **Load N more…**

0 commit comments

Comments
 (0)