feat(desktop): drag sidebar rows by the title, not just the grabber

The grabber in the lead column was the only way to reorder a session or a
project, and it only appears on hover — a 14px target for the whole gesture.
The row's title is the obvious thing to grab, so put the sortable listeners
on the row shell.

For a session that means two drags share one press, since the title already
starts the drag into the layout. They need no arbitration: each declines
outside its own region. Over the sidebar only the reorder has a target (the
session drop denies — side chrome hosts no main tile); over the tree only the
session drop does (no sortable row there). Whichever the release lands on is
the one that commits.

Rows exclude their own controls through one data-row-actions selector, now
owned by SidebarRowShell instead of restated per row.
This commit is contained in:
Brooklyn Nicholson 2026-08-09 03:23:08 -05:00
parent e128f1c131
commit 35b82fdef3
3 changed files with 35 additions and 9 deletions

View File

@ -55,7 +55,9 @@ export function SidebarDateDivider({ className, label, ...props }: React.Compone
)
}
/** Outer grid — sole owner of row height. */
/** Outer grid sole owner of row height. The trailing `actions` slot is
* marked `data-row-actions` so a row-wide drag gesture can exclude it with
* one selector: it holds real controls, never grab surface. */
export function SidebarRowShell({
actions,
children,
@ -65,7 +67,11 @@ export function SidebarRowShell({
return (
<div className={cn(rowMinH, 'grid grid-cols-[minmax(0,1fr)_auto] items-stretch rounded-md', className)} {...props}>
{children}
{actions ? <div className="flex shrink-0 items-center self-center">{actions}</div> : null}
{actions ? (
<div className="flex shrink-0 items-center self-center" data-row-actions>
{actions}
</div>
) : null}
</div>
)
}

View File

@ -125,6 +125,18 @@ export function ProjectOverviewRow({
</>
}
className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
// The label is grab surface too, not just the lead's grabber — same
// listeners, minus the controls that keep their own gestures. A project
// row has no rival drag (its title navigates on CLICK), so the sortable
// owns the press outright.
{...dragHandleProps}
onPointerDown={event => {
if ((event.target as HTMLElement).closest('[data-reorder-handle], [data-row-actions]')) {
return
}
dragHandleProps?.onPointerDown?.(event)
}}
ref={rowRef}
>
<SidebarRowCluster className="min-w-0 flex-1">
@ -141,6 +153,7 @@ export function ProjectOverviewRow({
<button
aria-label={s.projects.toggle(project.label, !open)}
className="flex flex-1 items-center self-stretch bg-transparent p-0"
data-row-actions
onClick={toggleOpen}
type="button"
>

View File

@ -140,19 +140,26 @@ function SidebarSessionRowImpl({
className
)}
data-working={liveTurn ? 'true' : undefined}
// The row runs BOTH drags off one press, and each declines outside its
// own region — so no timing/arbitration rule is needed and neither can
// steal the other's gesture. Over the sidebar only the reorder has a
// target (the session drop denies: side chrome hosts no main tile);
// over the tree only the session drop does (no sortable row there).
// Whichever one the release lands on is the one that commits.
{...dragHandleProps}
onPointerDown={event => {
// Reorder drags belong to dnd-kit (the grab handle); the ⋯ actions
// cluster keeps its own gestures. Everything else on the row —
// including the row-body BUTTON, the natural grab surface — is a
// session drag source: a POINTER drag on the shared drag session
// (never native HTML5 DnD: no macOS snap-back, Esc aborts
// instantly). Sub-threshold releases stay ordinary clicks, so
// resume / pin / open-in-window are untouched.
// The grabber already carries these same listeners, and the ⋯
// cluster keeps its own gestures.
if ((event.target as HTMLElement).closest('[data-reorder-handle], [data-row-actions]')) {
return
}
// A POINTER drag on the shared drag session (never native HTML5 DnD:
// no macOS snap-back, Esc aborts instantly). Sub-threshold releases
// stay ordinary clicks, so resume / pin / open-in-window are
// untouched.
startSessionDrag({ id: session.id, profile: session.profile || 'default', title }, event)
dragHandleProps?.onPointerDown?.(event)
}}
// Hovering a row from another profile (the all-profiles view) telegraphs
// a cross-profile resume — start that backend's spawn now so the click