Skip to content

feat(desktop): drag sessions between Pinned and Sessions to pin/unpin#145

Merged
OmarB97 merged 1 commit into
mainfrom
feat/sidebar-drag-pin
Jun 10, 2026
Merged

feat(desktop): drag sessions between Pinned and Sessions to pin/unpin#145
OmarB97 merged 1 commit into
mainfrom
feat/sidebar-drag-pin

Conversation

@OmarB97

@OmarB97 OmarB97 commented Jun 10, 2026

Copy link
Copy Markdown
Owner

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 DndContext scoped 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.tsSessionDragPayload gains pinId (durable lineage-root id, what the pin store keys on) and pinned. writeSessionDrag additionally sets a marker MIME application/x-hermes-session-pinned for pinned rows — dragover events expose only types (payload data is sealed until drop), so pin-state must ride as a type for zones to filter mid-drag. New dragSessionIsPinned(); readSessionDrag normalizes pinId (falls back to the live id for old payloads).
  • apps/desktop/src/app/chat/sidebar/session-row.tsx — the row's native dragstart payload now includes pinId: sessionPinId(session) and pinned: 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 (mirrors use-file-drop-zone), dropEffect = 'copy' to match the drag's effectAllowed, and a guard so stray leave events from unaccepted drags can't drive the depth negative. A zone only preventDefaults drags it would act on, so no-op drops never advertise.
  • apps/desktop/src/app/chat/sidebar/index.tsxChatSidebar wires two zones: Pinned accepts unpinned-row drags → pinSession(pinId); Sessions accepts pinned-row drags → unpinSession(pinId). Both auto-open the target section on drop. SidebarSessionsSection spreads the handlers on its SidebarGroup root (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

  1. use-session-drop-zone.ts first — the accept rule (dragHasSession && dragSessionIsPinned === acceptPinned) is the heart of the change.
  2. inline-refs.ts — note the marker-MIME rationale (types-only visibility during dragover) and that readSessionDrag stays backward-compatible.
  3. index.tsx — the two useSessionDropZone calls and the SidebarGroup spread; confirm the highlight classes only attach when dropActive.
  4. 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

  • Live render verification in real Chromium against the vite dev renderer (sidebar mounted with seeded session/layout stores reproducing the operator's video scenario):
    • dragging the pinned "KO Plex Overhaul Project feat: add HERMES_LLM_BASE_URL fallback for MeshBoard stream-tap proxy #23" row over Sessions lit the section highlight; drop removed it from $pinnedSessionIds (['s-pin-1'] → []) and the row re-rendered at the top of Sessions while Pinned fell back to its empty-state hint;
    • dragging "What model are you?…" over the now-empty Pinned section lit its highlight; drop landed $pinnedSessionIds = ['s-2'] and the row rendered under Pinned;
    • DataTransfer.types during 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.
  • Full renderer suite failure set is byte-identical to baseline (see Verification) — this diff adds 0 failures.

Verification

  • npm run type-check (tsc -b): pass.
  • npx eslint on all five touched files: pass, no warnings.
  • npx vitest run --environment jsdom full renderer suite at origin/main (559b472) + this diff: 485 tests, 476 passed / 9 failed; the sorted FAIL line 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 under npm run test:desktop:platforms).
  • npm run build (tsc -b + production vite build): pass.

Risks / gaps

  • Dropping a session that lives outside the recents slice (e.g. a messaging-platform row) pins its id but the Pinned section can't resolve it for rendering — identical behavior to the existing context-menu Pin on those rows, so no regression; tracked on the MeshBoard task hermes-desktop-drag-sessions-between-sessions-and as a known pre-existing limitation.
  • Drag-to-pin appends to the end of the pinned list rather than inserting at the pointer position; in-section grab-handle reorder covers ordering. Accepted scope cut to keep the native-DnD path simple — noted on the same MeshBoard task for a possible polish follow-up.
  • The pinned-state marker rides the drag from dragstart, so a row pinned/unpinned mid-drag by another surface could stale the flag; the drop handlers are idempotent against the store (insertUniqueId dedups, unpin filters), so the worst case is a no-op drop. Non-actionable.
  • Upstream port: NousResearch/hermes-agent carries the same inline-refs.ts drag plumbing and sidebar section structure; the paired upstream PR is opened from an upstream/main-based branch per the dual-PR mandate (link tracked on the MeshBoard task).

Collaborators

  • @OmarB97 (operator — reported the issue with a screen recording)
  • Claude Fable 5 (ko-mac.claude — implementation, tests, verification)

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>
@github-actions

Copy link
Copy Markdown

🔎 Lint report: feat/sidebar-drag-pin vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 10583 on HEAD, 10583 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 5544 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@OmarB97 OmarB97 merged commit 410c71f into main Jun 10, 2026
14 of 20 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant