diff --git a/apps/desktop/src/app/chat/session-drag.ts b/apps/desktop/src/app/chat/session-drag.ts new file mode 100644 index 000000000000..98cc8805d128 --- /dev/null +++ b/apps/desktop/src/app/chat/session-drag.ts @@ -0,0 +1,162 @@ +/** + * 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 { 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, 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 } 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 } +} + +function snapshotSurfaces(): SurfaceSnapshot[] { + return [...document.querySelectorAll('[data-session-anchor]')].map(el => ({ + anchor: el.dataset.sessionAnchor || 'workspace', + composerTarget: el.dataset.composerTarget || 'main', + rect: snapRect(el) + })) +} + +/** A session may land in a zone only if it hosts a chat surface — never the + * sidebar/terminal zones. Returns the pane a stack anchors to. */ +function chatZonePane(groupId: string): null | string { + const tree = $layoutTree.get() + const panes = tree ? (findGroup(tree, groupId)?.panes ?? []) : [] + + return panes.find(p => p === 'workspace' || p.startsWith('session-tile:')) ?? null +} + +/** + * Begin dragging a sidebar session row. Sub-threshold releases stay ordinary + * clicks (resume / pin / open-in-window all live on the row's own handlers); + * past the threshold the row is a drag source and the release commits a + * stack, a split, or a composer link — Esc aborts instantly. + */ +export function startSessionDrag(payload: SessionDragPayload, e: ReactPointerEvent) { + 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 + + startDragSession(e, { + ghost: { label: payload.title || `chat ${payload.id.slice(0, 8)}` }, + + onEngage() { + zones = snapshotZones() + strips = snapshotStrips() + surfaces = snapshotSurfaces() + composers = [...document.querySelectorAll('[data-slot="composer-root"]')].map(snapRect) + zoneHost = new Map(zones.map(zone => [zone.id, chatZonePane(zone.id)])) + // 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) + }, + + 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) { + const stack = slotBefore(strip.slots, x) + split = { anchor: host, 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') { + split = null + link = surface?.composerTarget ?? 'main' + } else { + split = { anchor: surface?.anchor ?? 'workspace', 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 }) + } + } + }) +} diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 416483dde427..b73434a77768 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -3,10 +3,12 @@ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable' import { useStore } from '@nanostores/react' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useLocation } from 'react-router-dom' import { PlatformAvatar } from '@/app/messaging/platform-icon' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { KbdGroup } from '@/components/ui/kbd' import { SearchField } from '@/components/ui/search-field' @@ -19,6 +21,7 @@ import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' +import { useContributions } from '@/contrib/react/use-contributions' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { useI18n } from '@/i18n' import { comboTokens } from '@/lib/keybinds/combo' @@ -34,8 +37,6 @@ import { $sidebarAgentsGrouped, $sidebarCronOpen, $sidebarMessagingOpenIds, - $sidebarOpen, - $sidebarOverlayMounted, $sidebarPinsOpen, $sidebarProjectOrderIds, $sidebarRecentsOpen, @@ -78,6 +79,7 @@ import { refreshWorktrees, scanAndRecordRepos } from '@/store/projects' +import { openRouteTile } from '@/store/route-tiles' import { $cronSessions, $currentCwd, @@ -94,8 +96,16 @@ import { sessionPinId, setCurrentCwd } from '@/store/session' +import type { SplitDir } from '@/store/session-states' -import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes' +import { + type AppView, + ARTIFACTS_ROUTE, + MESSAGING_ROUTE, + SIDEBAR_NAV_AREA, + type SidebarNavContribution, + SKILLS_ROUTE +} from '../../routes' import type { SidebarNavItem } from '../../types' import { countLabel } from './chrome' @@ -121,6 +131,7 @@ import { } from './projects' import { SidebarBlankState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states' import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section' +import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu' // Non-session groups (messaging platforms) stay compact: show a few rows up // front, reveal more in larger steps on demand. Keeps a busy platform from @@ -209,6 +220,8 @@ interface ChatSidebarProps extends React.ComponentProps { onArchiveSession: (sessionId: string) => void onBranchSession: (sessionId: string) => void onNewSessionInWorkspace: (path: null | string) => void + /** Create a brand-new session and open it as a tile on `dir`. */ + onNewSessionSplit: (dir: SplitDir) => void onManageCronJob: (jobId: string) => void onTriggerCronJob: (jobId: string) => void } @@ -224,15 +237,40 @@ export function ChatSidebar({ onArchiveSession, onBranchSession, onNewSessionInWorkspace, + onNewSessionSplit, onManageCronJob, onTriggerCronJob }: ChatSidebarProps) { const { t } = useI18n() const s = t.sidebar - const sidebarOpen = useStore($sidebarOpen) - // Collapsed-but-overlay-mounted → render the full sidebar, not just the nav rail. - const overlayMounted = useStore($sidebarOverlayMounted) - const contentVisible = sidebarOpen || overlayMounted + const { pathname } = useLocation() + // Contributed nav rows (plugins pairing a page with a sidebar entry) render + // below the built-ins with the same chrome; active = at their route. + const navContributions = useContributions(SIDEBAR_NAV_AREA) + + const contributedNav = useMemo( + () => + navContributions.flatMap(c => { + const data = c.data as Partial | undefined + + if (!data?.path?.startsWith('/') || !data.label) { + return [] + } + + const codicon = data.codicon || 'plug' + + return [ + { + id: c.id, + label: data.label, + icon: (props: { className?: string }) => , + route: data.path + } + ] + }), + [navContributions] + ) + const panesFlipped = useStore($panesFlipped) const agentsGrouped = useStore($sidebarAgentsGrouped) const pinnedSessionIds = useStore($pinnedSessionIds) @@ -1031,15 +1069,12 @@ export function ChatSidebar({ return (