/** * Sidebar session drag — the session RESOLVER over the shared pointer drag * session (pane-shell drag-session.ts). Same machinery as a pane drag * (threshold, rAF moves, snapshots, Esc-as-top-layer with synchronous * teardown), session-specific targeting: * * - a chat zone's TAB STRIP → stack: open the session as a tab at the * divider's slot (the strip caret shows it); * - a chat zone's EDGE band → split: open the session as a tile docked on * that edge (the zone sheet morphs to the half); * - a chat zone's CENTER / the composer → link: insert an `@session` chip * into that surface's composer (ChatDropOverlay owns the visual); * - anything else (sidebar, terminal, gutters) → deny. * * Zones that don't host a chat surface are NOT targets — the overlay never * lights them, so a release there must not commit either (one truth). * * This replaced the native-HTML5 drag + SessionTileDropBridge: riding the * native DnD layer meant macOS's cancel snap-back animation, a `dragend` * held hostage until that animation finished, an Esc the page never even * saw, and window-level armor against react-dnd/dnd-kit. A pointer session * has none of those failure modes. Native DnD remains only at the true OS * boundary (Finder file drops). Known trade: a session can no longer be * dragged into a separate BrowserWindow (native DnD was the only transport * that crossed windows). */ import type { PointerEvent as ReactPointerEvent } from 'react' import { queryAllVisible } from '@/components/pane-shell/pane-visibility' import { findGroup } from '@/components/pane-shell/tree/model' import { rectContains, slotBefore, snapshotStrips, snapshotZones, startDragSession, type StripSnapshot, subZonePosition } from '@/components/pane-shell/tree/renderer/drag-session' import { $layoutTree, $treeDragging, type DropHint, isMainStripPane, isSessionStripPane, revealTreePane, SESSION_TILE_DRAG } from '@/components/pane-shell/tree/store' import type { EngineZone, ZoneRect } from '@/components/pane-shell/tree/zones-engine' import { openSessionTile, type TileDock } from '@/store/session-states' import { requestComposerInsertRefs } from './composer/focus' import { type SessionDragPayload, sessionInlineRef, sessionLabel } from './composer/inline-refs' /** A chat surface's drag-start geometry: the anchor pane id it advertises * (`data-session-anchor`) and the composer a link drop routes to * (`data-composer-target`). */ interface SurfaceSnapshot { anchor: string composerTarget: string rect: ZoneRect } const snapRect = (el: HTMLElement): ZoneRect => { const r = el.getBoundingClientRect() return { left: r.left, top: r.top, right: r.right, bottom: r.bottom } } /** Chat surfaces the pointer can land on. Inactive tabs are excluded: they stay * mounted with their layout box intact, so their rect is identical to the * visible tab's and a hit-test alone would pick whichever came first. */ function snapshotSurfaces(): SurfaceSnapshot[] { return queryAllVisible('[data-session-anchor]').map(el => ({ anchor: el.dataset.sessionAnchor || 'workspace', composerTarget: el.dataset.composerTarget || 'main', rect: snapRect(el) })) } /** A session may land in any zone hosting a MAIN tile — another chat stack, a * Browser tile, a page — never the sidebar/terminal zones. Returns the pane a * stack anchors to, plus whether the zone hosts a CHAT surface (only those * offer the link-to-composer center; a preview zone's center stacks). */ function tileZoneHost(groupId: string): { chat: boolean; pane: string } | null { const tree = $layoutTree.get() const panes = tree ? (findGroup(tree, groupId)?.panes ?? []) : [] const pane = panes.find(isSessionStripPane) ?? panes.find(isMainStripPane) return pane ? { chat: panes.some(isSessionStripPane), pane } : null } /** * Begin dragging a session — a sidebar row OR a tile's own tab (same drop * language either way: stack, split, or composer link). Sub-threshold releases * stay ordinary clicks, so `opts.onTap` (activate the tile) rides the tab's * gesture; Esc aborts instantly. A stack/split commits through * `openSessionTile`, which OPENS a new tile from a sidebar row and MOVES the * existing one when its tab is the drag source. */ export function startSessionDrag( payload: SessionDragPayload, e: ReactPointerEvent, opts?: { onTap?: () => void } ) { let zones: EngineZone[] = [] let strips: StripSnapshot[] = [] let surfaces: SurfaceSnapshot[] = [] let composers: ZoneRect[] = [] let zoneHost = new Map>() // Commit intent, updated per resolved move (the machinery flushes the final // move before commit, so these always match the released-at position). let split: { anchor: string; before?: null | string; pos: TileDock } | null = null let link: null | string = null // The drag SOURCE (sidebar row or tile tab). Captured synchronously — React // clears `currentTarget` after the pointerdown handler returns, but this runs // inside it. Dimmed while lifted so the source reads as "picked up" — the // same in-place feedback pane-tab drags use, replacing the old cursor chip. const source = e.currentTarget const restoreOpacity = source?.style.opacity ?? '' startDragSession(e, { ghost: { label: sessionLabel(payload) }, onTap: opts?.onTap, onEngage() { zones = snapshotZones() strips = snapshotStrips() surfaces = snapshotSurfaces() composers = queryAllVisible('[data-slot="composer-root"]').map(snapRect) zoneHost = new Map(zones.map(zone => [zone.id, tileZoneHost(zone.id)])) source?.style.setProperty('opacity', '0.45') // The same sentinel the zone overlay + chat surfaces key off — the // whole drop language (sheets, pills, caret, link overlay) lights up. $treeDragging.set(SESSION_TILE_DRAG) }, onEnd() { if (source) { source.style.opacity = restoreOpacity } }, resolveMove(x, y): DropHint | null { const zone = zones.find(z => rectContains(z.rect, x, y)) const host = zone ? zoneHost.get(zone.id) : null if (!zone || !host) { split = null link = null return null } // The zone's TAB STRIP stacks the session at the divider's slot. const strip = strips.find(s => s.groupId === zone.id && rectContains(s.rect, x, y)) if (strip) { // Exclude the tile's OWN tab from the slots so re-dropping it in its // home strip reorders cleanly (a no-op for a sidebar-row drag). const stack = slotBefore(strip.slots, x, `session-tile:${payload.id}`) split = { anchor: host.pane, before: stack.before, pos: 'center' } link = null return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos: 'center', stack } } // The composer (and everything in it) is always the link/attach drop; // elsewhere the shared radial targeting decides center vs edge. const pos = composers.some(rect => rectContains(rect, x, y)) ? 'center' : subZonePosition(zones, zone.id, x, y) const surface = surfaces.find(s => rectContains(s.rect, x, y)) if (pos === 'center' && host.chat) { split = null link = surface?.composerTarget ?? 'main' } else if (pos === 'center') { // A preview/page zone has no composer to link to — its center stacks // the session as a tab, same as dropping on the strip's tail. split = { anchor: host.pane, pos: 'center' } link = null } else { split = { anchor: surface?.anchor ?? host.pane, pos } link = null } return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos } }, onCommit() { if (split) { openSessionTile(payload.id, split.pos, split.anchor, split.before) // A tile for this session may already exist (openSessionTile is // idempotent — e.g. persisted from an earlier run): a drop must never // feel dead, so front/unhide/un-dismiss it either way. revealTreePane(`session-tile:${payload.id}`) } else if (link) { // The "link to chat" drop: an @session chip in that surface's composer. requestComposerInsertRefs([sessionInlineRef(payload)], { target: link }) } } }) }