mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(desktop): pointer session drag/drop + row/tab menus with close others/right/all
This commit is contained in:
parent
e6fea77d14
commit
ac4f596ca2
7 changed files with 610 additions and 117 deletions
162
apps/desktop/src/app/chat/session-drag.ts
Normal file
162
apps/desktop/src/app/chat/session-drag.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
92
apps/desktop/src/app/chat/sidebar/split-submenu.tsx
Normal file
92
apps/desktop/src/app/chat/sidebar/split-submenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue