Files
aiturk-hermes-ide/apps/desktop/src/app/chat/session-drag.ts
T

206 lines
8.2 KiB
TypeScript

/**
* 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<HTMLElement>,
opts?: { onTap?: () => void }
) {
let zones: EngineZone[] = []
let strips: StripSnapshot[] = []
let surfaces: SurfaceSnapshot[] = []
let composers: ZoneRect[] = []
let zoneHost = new Map<string, ReturnType<typeof tileZoneHost>>()
// 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 })
}
}
})
}