feat(desktop): pointer session drag/drop + row/tab menus with close others/right/all

This commit is contained in:
Brooklyn Nicholson 2026-07-13 17:28:32 -04:00
parent e6fea77d14
commit ac4f596ca2
7 changed files with 610 additions and 117 deletions

View file

@ -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<HTMLElement>('[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<HTMLElement>) {
let zones: EngineZone[] = []
let strips: StripSnapshot[] = []
let surfaces: SurfaceSnapshot[] = []
let composers: ZoneRect[] = []
let zoneHost = new Map<string, null | string>()
// 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<HTMLElement>('[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 })
}
}
})
}

View file

@ -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<typeof Sidebar> {
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<SidebarNavItem[]>(
() =>
navContributions.flatMap(c => {
const data = c.data as Partial<SidebarNavContribution> | undefined
if (!data?.path?.startsWith('/') || !data.label) {
return []
}
const codicon = data.codicon || 'plug'
return [
{
id: c.id,
label: data.label,
icon: (props: { className?: string }) => <Codicon name={codicon} {...props} />,
route: data.path
}
]
}),
[navContributions]
)
const panesFlipped = useStore($panesFlipped)
const agentsGrouped = useStore($sidebarAgentsGrouped)
const pinnedSessionIds = useStore($pinnedSessionIds)
@ -1031,15 +1069,12 @@ export function ChatSidebar({
return (
<Sidebar
className={cn(
// Visibility is the layout tree's job (a hidden zone is display:none;
// the narrow overlay renders the live instance) — the sidebar always
// paints itself fully.
'relative h-full min-w-0 overflow-hidden border-t-0 border-b-0 text-foreground transition-none',
panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0',
sidebarOpen
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0',
// While floated by PaneShell's hover-reveal, force visible + interactive
// — on hover (group-hover/reveal) or when keyboard-pinned (data-forced).
'in-data-[pane-hover-reveal=open]:pointer-events-auto in-data-[pane-hover-reveal=open]:border-(--sidebar-edge-border) in-data-[pane-hover-reveal=open]:bg-(--ui-sidebar-surface-background) in-data-[pane-hover-reveal=open]:opacity-100',
'group-hover/reveal:pointer-events-auto group-hover/reveal:border-(--sidebar-edge-border) group-hover/reveal:bg-(--ui-sidebar-surface-background) group-hover/reveal:opacity-100'
'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
)}
collapsible="none"
>
@ -1047,18 +1082,19 @@ export function ChatSidebar({
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]">
<SidebarGroupContent>
<SidebarMenu className="gap-px">
{SIDEBAR_NAV.map(item => {
{[...SIDEBAR_NAV, ...contributedNav].map(item => {
const isInteractive = Boolean(item.action) || Boolean(item.route)
const active =
(item.id === 'skills' && currentView === 'skills') ||
(item.id === 'messaging' && currentView === 'messaging') ||
(item.id === 'artifacts' && currentView === 'artifacts')
(item.id === 'artifacts' && currentView === 'artifacts') ||
// Contributed rows light up at their own route.
(Boolean(item.route) && pathname === item.route)
const isNewSession = item.id === 'new-session'
return (
<SidebarMenuItem key={item.id}>
const button = (
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
@ -1090,19 +1126,41 @@ export function ChatSidebar({
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{contentVisible && (
<>
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
size="sm"
/>
)}
</>
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
size="sm"
/>
)}
</SidebarMenuButton>
)
// New session + route-backed pages can open in a split —
// right-click for the directional "Open in split" submenu.
return (
<SidebarMenuItem key={item.id}>
{isNewSession || item.route ? (
<ContextMenu>
<ContextMenuTrigger asChild>{button}</ContextMenuTrigger>
<ContextMenuContent aria-label={s.nav[item.id] ?? item.label}>
<SplitSubmenu
kit={CONTEXT_SPLIT_KIT}
label={s.row.openInSplit}
onSplit={dir => {
if (isNewSession) {
onNewSessionSplit(dir)
} else if (item.route) {
openRouteTile(item.route, dir)
}
}}
/>
</ContextMenuContent>
</ContextMenu>
) : (
button
)}
</SidebarMenuItem>
)
})}
@ -1110,7 +1168,7 @@ export function ChatSidebar({
</SidebarGroupContent>
</SidebarGroup>
{contentVisible && showSessionSections && (
{showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label={s.searchAria}
@ -1122,7 +1180,7 @@ export function ChatSidebar({
</div>
)}
{contentVisible && showSessionSections && (
{showSessionSections && (
<div className={cn('flex min-h-0 flex-1 flex-col pb-1.75', SCROLL_Y)}>
{trimmedQuery && (
<SidebarSessionsSection
@ -1392,13 +1450,11 @@ export function ChatSidebar({
</div>
)}
{contentVisible && !showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
{!showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
{contentVisible && (
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
)}
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
</SidebarContent>
<ProjectDialog />
</Sidebar>

View file

@ -35,6 +35,12 @@ import { getProfileSoul, updateProfileSoul } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import {
REORDER_DRAG_TRANSITION_CSS,
REORDER_RAIL_TRANSITION,
reorderCommitHaptic,
reorderStepHaptic
} from '@/lib/reorder'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import {
@ -67,12 +73,11 @@ const RAIL_GAP = 4 // px — matches gap-1 between squares.
// select. Drag-reorder and long-press-recolor live only on the squares path.
const PROFILE_DROPDOWN_THRESHOLD = 13
// easeOutBack — a little overshoot so squares spring into their new slot rather
// than sliding in flat. Neighbors reflow on RAIL_TRANSITION; the dragged square
// glides between snapped cells on the snappier DRAG_TRANSITION.
const SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
const RAIL_TRANSITION = { duration: 300, easing: SPRING }
const DRAG_TRANSITION = `transform 200ms ${SPRING}`
// Neighbors reflow on RAIL_TRANSITION; the dragged square glides between
// snapped cells on the snappier DRAG_TRANSITION. Both come from the SHARED
// reorder primitive (lib/reorder.ts) so every reorder strip feels identical.
const RAIL_TRANSITION = REORDER_RAIL_TRANSITION
const DRAG_TRANSITION = REORDER_DRAG_TRANSITION_CSS
// The rail is a single horizontal strip of fixed cells. Pin drags to the x-axis
// (no cross-axis scrollbar), snap to whole cells so a square steps slot-to-slot
@ -174,7 +179,7 @@ export function ProfileRail() {
if (id && id !== lastOverRef.current) {
lastOverRef.current = id
triggerHaptic('selection')
reorderStepHaptic()
}
}
@ -191,7 +196,7 @@ export function ProfileRail() {
if (from >= 0 && to >= 0) {
setProfileOrder(arrayMove(ids, from, to))
triggerHaptic('success')
reorderCommitHaptic()
}
}

View file

@ -16,6 +16,9 @@ const activeGateway = vi.fn<() => { request: typeof request } | null>(() => ({ r
vi.mock('@/hermes', () => ({
renameSession: (...args: unknown[]) => renameSession(...(args as [])),
// profile.ts calls this at import (its $activeGatewayProfile subscribe fires
// immediately), pulled in transitively via session-states.
setApiRequestProfile: () => {},
HermesGateway: class {}
}))

View file

@ -1,9 +1,17 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { closeAllTreeTabs, closeOtherTreeTabs, closeTreeTabsToRight, treeTabCloseTargets } from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { CopyButton } from '@/components/ui/copy-button'
import {
Dialog,
@ -13,7 +21,13 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes'
import { useI18n } from '@/i18n'
@ -22,6 +36,7 @@ import { exportSession } from '@/lib/session-export'
import { activeGateway } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { $activeSessionId, $selectedStoredSessionId, setSessions } from '@/store/session'
import { $sessionTiles, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import type { SessionTitleResponse } from '../../types'
@ -80,10 +95,31 @@ interface SessionActions {
onBranch?: () => void
onArchive?: () => void
onDelete?: () => void
/** Close this surface (a tile tab) omitted where nothing closes (sidebar
* rows, the main tab). */
onClose?: () => void
/** TAB surfaces: the session is already a tab, so "Open in new tab" is
* nonsense there sidebar rows/dropdowns keep it. */
surface?: 'row' | 'tab'
/** The tab's layout-tree pane id (`session-tile:<id>` or `workspace`) enables
* the Close-others / to-the-right / all tab verbs. Tab surfaces only. */
tabPaneId?: string
/** The MAIN tab's escape hatch: hide the zone's tab bar (it sticky-shows
* once a tab is ever gained; this is the explicit off switch). */
onHideTabBar?: () => void
}
type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem
/** A menu flavour (dropdown / context) — item + separator components. */
interface MenuKit {
Item: MenuItem
Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator
}
const DROPDOWN_KIT: MenuKit = { Item: DropdownMenuItem, Separator: DropdownMenuSeparator }
const CONTEXT_KIT: MenuKit = { Item: ContextMenuItem, Separator: ContextMenuSeparator }
interface ItemSpec {
className?: string
disabled: boolean
@ -101,26 +137,45 @@ function useSessionActions({
onPin,
onBranch,
onArchive,
onDelete
onDelete,
onClose,
onHideTabBar,
surface = 'row',
tabPaneId
}: SessionActions) {
const { t } = useI18n()
const r = t.sidebar.row
const [renameOpen, setRenameOpen] = useState(false)
const tiles = useStore($sessionTiles)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const pinItem: ItemSpec = {
disabled: !onPin,
icon: 'pin',
label: pinned ? r.unpin : r.pin,
onSelect: () => {
triggerHaptic('selection')
onPin?.()
}
}
// Already showing as a tab somewhere (a tile, or loaded in main — main IS
// a tab): offering "Open in new tab" again is noise.
const alreadyTabbed = sessionId === selectedStoredSessionId || tiles.some(tile => tile.storedSessionId === sessionId)
const items: ItemSpec[] = [
const spec = (partial: Omit<ItemSpec, 'onSelect'> & { onSelect: () => void }): ItemSpec => partial
// OPEN — where else this session can go. A tab surface IS a tab already,
// so it only offers the window hop (and its own Close, below).
const openItems: ItemSpec[] = [
...(surface === 'row' && !alreadyTabbed
? [
spec({
disabled: !sessionId,
icon: 'browser',
label: r.openInNewTab,
onSelect: () => {
triggerHaptic('selection')
// Stack into the MAIN zone as a tab (center dock; the strip
// sticky-shows on gain) — the door to the tab bar.
openSessionTile(sessionId, 'center')
}
})
]
: []),
...(canOpenSessionWindow()
? [
{
spec({
disabled: !sessionId,
icon: 'link-external',
label: r.newWindow,
@ -128,28 +183,14 @@ function useSessionActions({
triggerHaptic('selection')
void openSessionInNewWindow(sessionId)
}
}
})
]
: []),
{
disabled: !sessionId,
icon: 'cloud-download',
label: r.export,
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { profile, title })
}
},
{
disabled: !onBranch,
icon: 'git-branch',
label: r.branchFrom,
onSelect: () => {
triggerHaptic('selection')
onBranch?.()
}
},
{
: [])
]
// IDENTITY — name/mark/reference the session.
const identityItems: ItemSpec[] = [
spec({
disabled: !sessionId,
icon: 'edit',
label: r.rename,
@ -157,8 +198,96 @@ function useSessionActions({
triggerHaptic('selection')
setRenameOpen(true)
}
},
{
}),
spec({
disabled: !onPin,
icon: 'pin',
label: pinned ? r.unpin : r.pin,
onSelect: () => {
triggerHaptic('selection')
onPin?.()
}
})
]
// WORK — derive/extract from the session.
const workItems: ItemSpec[] = [
spec({
disabled: !onBranch,
icon: 'git-branch',
label: r.branchFrom,
onSelect: () => {
triggerHaptic('selection')
onBranch?.()
}
}),
spec({
disabled: !sessionId,
icon: 'cloud-download',
label: r.export,
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { profile, title })
}
})
]
// TAB — close verbs that act on the strip (tabs only; a row isn't a tab).
const closeTargets = surface === 'tab' && tabPaneId ? treeTabCloseTargets(tabPaneId) : null
const tabCloseItems: ItemSpec[] =
surface === 'tab'
? [
...(onClose
? [
spec({
disabled: false,
icon: 'close',
label: t.common.close,
onSelect: () => {
triggerHaptic('selection')
onClose()
}
})
]
: []),
...(tabPaneId
? [
spec({
disabled: !closeTargets?.others,
icon: 'close-all',
label: t.zones.closeOthers,
onSelect: () => {
triggerHaptic('selection')
closeOtherTreeTabs(tabPaneId)
}
}),
spec({
disabled: !closeTargets?.right,
icon: 'arrow-right',
label: t.zones.closeToRight,
onSelect: () => {
triggerHaptic('selection')
closeTreeTabsToRight(tabPaneId)
}
}),
spec({
disabled: !closeTargets?.all,
icon: 'clear-all',
label: t.zones.closeAll,
onSelect: () => {
triggerHaptic('selection')
closeAllTreeTabs(tabPaneId)
}
})
]
: [])
]
: []
// DANGER — put it away / destroy it (delete stays last, destructive-red).
const dangerItems: ItemSpec[] = [
spec({
disabled: !onArchive,
icon: 'archive',
label: r.archive,
@ -166,7 +295,7 @@ function useSessionActions({
triggerHaptic('selection')
onArchive?.()
}
},
}),
{
className: 'text-destructive focus:text-destructive',
disabled: !onDelete,
@ -187,11 +316,13 @@ function useSessionActions({
</Item>
)
const renderItems = (Item: MenuItem) => (
const renderItems = (kit: MenuKit) => (
<>
{renderMenuItem(Item, pinItem)}
{openItems.map(item => renderMenuItem(kit.Item, item))}
{openItems.length > 0 && <kit.Separator />}
{identityItems.map(item => renderMenuItem(kit.Item, item))}
<CopyButton
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
appearance={kit.Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
disabled={!sessionId}
errorMessage={r.copyIdFailed}
iconClassName="size-3.5 text-current"
@ -200,7 +331,30 @@ function useSessionActions({
onCopyError={err => notifyError(err, r.copyIdFailed)}
text={sessionId}
/>
{items.map(spec => renderMenuItem(Item, spec))}
<kit.Separator />
{workItems.map(item => renderMenuItem(kit.Item, item))}
{tabCloseItems.length > 0 && (
<>
<kit.Separator />
{tabCloseItems.map(item => renderMenuItem(kit.Item, item))}
</>
)}
<kit.Separator />
{dangerItems.map(item => renderMenuItem(kit.Item, item))}
{onHideTabBar && (
<>
<kit.Separator />
{renderMenuItem(kit.Item, {
disabled: false,
icon: 'eye-closed',
label: r.hideTabBar,
onSelect: () => {
triggerHaptic('selection')
onHideTabBar()
}
})}
</>
)}
</>
)
@ -225,10 +379,11 @@ interface SessionActionsMenuProps
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
const { t } = useI18n()
const { renameDialog, renderItems } = useSessionActions(actions)
const [open, setOpen] = useState(false)
return (
<>
<DropdownMenu>
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
@ -236,7 +391,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
className="w-40"
sideOffset={sideOffset}
>
{renderItems(DropdownMenuItem)}
{renderItems(DROPDOWN_KIT)}
</DropdownMenuContent>
</DropdownMenu>
{renameDialog}
@ -257,7 +412,7 @@ export function SessionContextMenu({ children, ...actions }: SessionContextMenuP
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
{renderItems(ContextMenuItem)}
{renderItems(CONTEXT_KIT)}
</ContextMenuContent>
</ContextMenu>
{renameDialog}

View file

@ -1,7 +1,7 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { startSessionDrag } from '@/app/chat/session-drag'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@ -14,6 +14,7 @@ import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
import { openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
@ -92,7 +93,7 @@ export function SidebarSessionRow({
>
<SidebarRowShell
actions={
<div className="relative z-2 grid w-[1.375rem] place-items-center">
<div className="relative z-2 grid w-[1.375rem] place-items-center" data-row-actions>
{!isWorking && (
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
{age}
@ -130,21 +131,19 @@ export function SidebarSessionRow({
className
)}
data-working={isWorking ? 'true' : undefined}
draggable
onDragStart={event => {
// Reorder drags belong to dnd-kit (the grab handle) — cancel the
// native drag so the two DnD systems don't fight.
if ((event.target as HTMLElement).closest('[data-reorder-handle]')) {
event.preventDefault()
onPointerDown={event => {
// Reorder drags belong to dnd-kit (the grab handle); the ⋯ actions
// cluster keeps its own gestures. Everything else on the row —
// including the row-body BUTTON, the natural grab surface — is a
// session drag source: a POINTER drag on the shared drag session
// (never native HTML5 DnD: no macOS snap-back, Esc aborts
// instantly). Sub-threshold releases stay ordinary clicks, so
// resume / pin / open-in-window are untouched.
if ((event.target as HTMLElement).closest('[data-reorder-handle], [data-row-actions]')) {
return
}
writeSessionDrag(event.dataTransfer, {
id: session.id,
profile: session.profile || 'default',
title
})
startSessionDrag({ id: session.id, profile: session.profile || 'default', title }, event)
}}
ref={ref}
style={style}
@ -153,7 +152,40 @@ export function SidebarSessionRow({
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
<SidebarRowBody
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
// Middle-click = open in a new tab (browser muscle memory). Swallow
// the mousedown so Chromium doesn't enter autoscroll mode.
onAuxClick={event => {
if (event.button === 1) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
openSessionTile(session.id, 'center')
}
}}
onClick={event => {
const mod = event.metaKey || event.ctrlKey
// ⇧⌘-click → pop into its own window (needs standalone windows).
if (mod && event.shiftKey && canOpenSessionWindow()) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
void openSessionInNewWindow(session.id)
return
}
// ⌘/⌃-click → open in a new tab (stack into main).
if (mod) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
openSessionTile(session.id, 'center')
return
}
// ⇧-click → pin.
if (event.shiftKey) {
event.preventDefault()
event.stopPropagation()
@ -163,21 +195,9 @@ export function SidebarSessionRow({
return
}
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
// window — the universal "open in a new window" gesture. Archive
// lives in the row's ⋯ and right-click menus. Falls through to a
// normal resume when standalone windows aren't available (web embed).
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
void openSessionInNewWindow(session.id)
return
}
onResume()
}}
onMouseDown={event => event.button === 1 && event.preventDefault()}
>
{reorderable ? (
<SidebarRowGrab

View file

@ -0,0 +1,92 @@
import { Codicon } from '@/components/ui/codicon'
import {
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger
} from '@/components/ui/context-menu'
import {
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
import type { SplitDir } from '@/store/session-states'
/** The leaf + submenu components for one menu flavour, so the split submenu
* renders in either the `` dropdown or a right-click context menu. */
export interface SplitMenuKit {
Item: typeof DropdownMenuItem | typeof ContextMenuItem
Sub: typeof DropdownMenuSub | typeof ContextMenuSub
SubContent: typeof DropdownMenuSubContent | typeof ContextMenuSubContent
SubTrigger: typeof DropdownMenuSubTrigger | typeof ContextMenuSubTrigger
}
export const DROPDOWN_SPLIT_KIT: SplitMenuKit = {
Item: DropdownMenuItem,
Sub: DropdownMenuSub,
SubContent: DropdownMenuSubContent,
SubTrigger: DropdownMenuSubTrigger
}
export const CONTEXT_SPLIT_KIT: SplitMenuKit = {
Item: ContextMenuItem,
Sub: ContextMenuSub,
SubContent: ContextMenuSubContent,
SubTrigger: ContextMenuSubTrigger
}
// Ordered so the default (right) sits first, one hop away.
const SPLIT_DIRS: { dir: SplitDir; icon: string; label: string }[] = [
{ dir: 'right', icon: 'arrow-right', label: 'Right' },
{ dir: 'bottom', icon: 'arrow-down', label: 'Down' },
{ dir: 'left', icon: 'arrow-left', label: 'Left' },
{ dir: 'top', icon: 'arrow-up', label: 'Up' }
]
interface SplitSubmenuProps {
kit: SplitMenuKit
label: string
onSplit: (dir: SplitDir) => void
disabled?: boolean
/** Dismiss the owning menu after the row's default (right) split the
* dropdown is controlled and can; a context menu can't, so it's a no-op. */
close?: () => void
}
/**
* "Open in split ▸": clicking the row splits right (the common case), and the
* submenu picks any edge. Shared by session rows and page nav rows.
*/
export function SplitSubmenu({ close, disabled, kit, label, onSplit }: SplitSubmenuProps) {
const { Item, Sub, SubContent, SubTrigger } = kit
const split = (dir: SplitDir) => {
triggerHaptic('selection')
onSplit(dir)
}
return (
<Sub>
<SubTrigger
disabled={disabled}
onClick={() => {
split('right')
close?.()
}}
>
<Codicon name="split-horizontal" size="0.875rem" />
<span>{label}</span>
</SubTrigger>
<SubContent>
{SPLIT_DIRS.map(({ dir, icon, label: dirLabel }) => (
<Item key={dir} onSelect={() => split(dir)}>
<Codicon name={icon} size="0.875rem" />
<span>{dirLabel}</span>
</Item>
))}
</SubContent>
</Sub>
)
}