feat(desktop): drag sessions between Pinned and Sessions to pin/unpin#145
Merged
Conversation
Dragging a sidebar session row between the PINNED and SESSIONS sections did nothing: each section owns a private dnd-kit DndContext (scoped to in-section reorder from the grab handle), and the row body's native drag only targets the composer. Pinning required the context menu or shift-click. The native row drag already carries application/x-hermes-session, so the two sections become native drop zones for it: drop a row on Pinned to pin, drop a pinned row on Sessions to unpin — matching Claude Desktop's drag-to-pin gesture. A marker MIME (application/x-hermes-session-pinned) rides the drag because dragover can only inspect types, letting each zone engage only for drags it would act on; the payload now also carries the durable pin id (lineage root) so drop targets key the store the same way the context-menu path does. The hovered section highlights, drops land on the header too (works while collapsed or empty), and the target section auto-opens so the landed row is visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🔎 Lint report:
|
OmarB97
pushed a commit
that referenced
this pull request
Jun 10, 2026
…ns render, drag hint Three gaps from the drag-to-pin feature (#145): Pinned messaging/cron-adjacent rows now render: sessionByAnyId only indexed cron + recents, so pinning a messaging-platform row (menu, shift-click, or drag) stored an id the Pinned section could never resolve — the pin silently vanished. Messaging sessions join the index, and pinned rows are filtered out of their platform section so pinning MOVES the row (matching how recents behave) instead of duplicating it. Drops are positional: rows carry data-session-id, the drop zone hands the drop event to its handler, and sessionDropAnchor() resolves the row under the pointer (top half = before, bottom half = after). Drag-to-pin inserts at that index in the raw pinned-id store (translated via the anchor's durable pin id, since rendered rows can be a subset of stored ids); drag-to-unpin splices into the saved flat-list order, falling back to the old surface-at-top reconcile for header drops, fresh anchors, or grouped/ALL-profiles views. The empty-Pinned hint now teaches the gesture: 'Drag a chat here, or shift-click to pin' (en/zh/zh-hant/ja). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Dragging a session row between the sidebar's Pinned and Sessions sections silently does nothing (operator video, 2026-06-10): each section owns a private dnd-kit
DndContextscoped to in-section reorder from the hover grab handle, and the row body's native HTML5 drag only targets the composer (inline session refs). So the drag gesture exists — the row ghost follows the pointer — but the drop is a no-op, and pin/unpin requires the context menu or shift-click. Claude Desktop supports drag-to-pin/unpin; Hermes should match.What changed
apps/desktop/src/app/chat/composer/inline-refs.ts—SessionDragPayloadgainspinId(durable lineage-root id, what the pin store keys on) andpinned.writeSessionDragadditionally sets a marker MIMEapplication/x-hermes-session-pinnedfor pinned rows — dragover events expose onlytypes(payload data is sealed until drop), so pin-state must ride as a type for zones to filter mid-drag. NewdragSessionIsPinned();readSessionDragnormalizespinId(falls back to the live id for old payloads).apps/desktop/src/app/chat/sidebar/session-row.tsx— the row's nativedragstartpayload now includespinId: sessionPinId(session)andpinned: isPinned.apps/desktop/src/app/chat/sidebar/use-session-drop-zone.ts(new) —useSessionDropZone({ acceptPinned, onDropSession }): native drop target with an enter/leave depth counter (mirrorsuse-file-drop-zone),dropEffect = 'copy'to match the drag'seffectAllowed, and a guard so stray leave events from unaccepted drags can't drive the depth negative. A zone onlypreventDefaults drags it would act on, so no-op drops never advertise.apps/desktop/src/app/chat/sidebar/index.tsx—ChatSidebarwires two zones: Pinned accepts unpinned-row drags →pinSession(pinId); Sessions accepts pinned-row drags →unpinSession(pinId). Both auto-open the target section on drop.SidebarSessionsSectionspreads the handlers on itsSidebarGrouproot (header included — drops work while collapsed or empty) and highlights while targeted (bg-(--ui-control-hover-background)+ inset ring).apps/desktop/src/app/chat/sidebar/use-session-drop-zone.test.tsx(new) — 9 tests: payload round-trip incl. pin metadata + legacy-payload fallback, accept/ignore by pin-state, non-session drags ignored, nested-children depth counting, stray-leave wedge guard.Existing behaviors unchanged: grab-handle reorder within each section (dnd-kit), drag-into-composer (the same native drag), shift-click and context-menu pin.
How to review
use-session-drop-zone.tsfirst — the accept rule (dragHasSession && dragSessionIsPinned === acceptPinned) is the heart of the change.inline-refs.ts— note the marker-MIME rationale (types-only visibility during dragover) and thatreadSessionDragstays backward-compatible.index.tsx— the twouseSessionDropZonecalls and theSidebarGroupspread; confirm the highlight classes only attach whendropActive.session-row.tsx— two added payload fields; the dnd-kit handle still cancels the native drag via[data-reorder-handle], so the two DnD systems stay separated.Evidence
$pinnedSessionIds(['s-pin-1'] → []) and the row re-rendered at the top of Sessions while Pinned fell back to its empty-state hint;$pinnedSessionIds = ['s-2']and the row rendered under Pinned;DataTransfer.typesduring the pinned-row drag:["application/x-hermes-session","application/x-hermes-session-pinned"]; unpinned-row drag carried only the session MIME.npx vitest run --environment jsdom src/app/chat/sidebar/use-session-drop-zone.test.tsx: 9/9 pass.Verification
npm run type-check(tsc -b): pass.npx eslinton all five touched files: pass, no warnings.npx vitest run --environment jsdomfull renderer suite atorigin/main(559b472) + this diff: 485 tests, 476 passed / 9 failed; the sortedFAILline set is byte-identical (diff-verified) to the baseline run with this change stashed — the known baseline-red 9 (prompt-actions, gateway-boot, model-settings, toolset-config, streaming, pane-shell) plus the.cjs/node-pty file-level entries vitest always flags (those run undernpm run test:desktop:platforms).npm run build(tsc -b + production vite build): pass.Risks / gaps
dragstart, so a row pinned/unpinned mid-drag by another surface could stale the flag; the drop handlers are idempotent against the store (insertUniqueIddedups, unpin filters), so the worst case is a no-op drop. Non-actionable.inline-refs.tsdrag plumbing and sidebar section structure; the paired upstream PR is opened from anupstream/main-based branch per the dual-PR mandate (link tracked on the MeshBoard task).Collaborators