fix(desktop): session-tab drag, focus sync, and pop-out isolation

Make every session tab speak the same drag language as a sidebar row, and
keep tab focus 1:1 across the sidebar, tiles, and main:

- Drag a session tile's OR the main workspace tab onto a composer to link the
  chat (@session chip), or onto a zone/edge to stack/split — tabDrag now
  returns whether it took the drag, so the workspace tab defers to the generic
  pane move on a fresh draft (nothing to link).
- A lone tool panel (terminal/logs) dragged to its own zone keeps its header,
  so it stays draggable/closable instead of stranding a dead, tab-less zone.
- The sidebar highlight follows the FOCUSED session (interacted tile, else the
  main selection) rather than only the main one.
- Clicking an already-open session jumps to its tab — an open tile, or the
  workspace tab when it's the main session and focus sits on a tile — instead
  of a dead no-op reload.
- Secondary (single-chat pop-out) windows boot to the default tree with no
  tiles and never persist their stripped-down layout back, so they can't
  inherit or clobber the primary window's tabs/splits.

The pointer drag ghost is extracted to a shared lib/drag-ghost and the drop
affordances drop their now-unused labels.
This commit is contained in:
Brooklyn Nicholson 2026-07-15 03:13:33 -04:00
parent db488df1c3
commit 092a97ef75
24 changed files with 659 additions and 261 deletions

View file

@ -1,39 +1,32 @@
import { useRef } from 'react'
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS, DropPill } from '@/components/ui/drop-affordance'
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
const ICONS: Record<'files' | 'session', string> = {
files: 'cloud-upload',
session: 'comment-discussion'
}
/**
* Full-bleed affordance shown while files or a session are dragged over the chat
* area. Always `pointer-events-none` so the drop lands on the real element
* underneath and the drop-zone handler claims it the overlay is purely visual.
* Copy adapts to whatever is being dragged; the last kind is held through the
* fade-out so the label doesn't blank.
* The label names the outcome (attach files / link this chat); the last kind is
* held through the fade-out so it doesn't blank.
*/
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
const { t } = useI18n()
const lastKind = useRef<'files' | 'session'>('files')
const lastKind = useRef<DragKind>(kind)
if (kind) {
lastKind.current = kind
}
const resolvedKind = kind ?? lastKind.current
const icon = ICONS[resolvedKind]
const label = resolvedKind === 'files' ? t.composer.dropFiles : t.composer.dropSession
const shown = kind ?? lastKind.current
return (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center transition-opacity duration-150 ease-out',
kind ? 'opacity-100' : 'opacity-0'
)}
data-slot="chat-drop-overlay"
@ -45,9 +38,11 @@ export function ChatDropOverlay({ kind }: { kind: DragKind }) {
'absolute inset-2 border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)]'
)}
/>
<DropPill className="relative" icon={icon}>
{label}
</DropPill>
{shown && (
<span className="relative text-[11px] font-medium uppercase tracking-wide text-foreground">
{shown === 'session' ? t.composer.dropSession : t.composer.dropFiles}
</span>
)}
</div>
)
}

View file

@ -1,4 +1,5 @@
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { translateNow } from '@/i18n'
import { contextPath } from '@/lib/chat-runtime'
import type { DroppedFile } from '../hooks/use-composer-actions'
@ -16,10 +17,14 @@ export interface SessionDragPayload {
title: string
}
/** A session's friendly display label — its title, or a localized fallback. */
export const sessionLabel = ({ id, title }: SessionDragPayload) =>
title || translateNow('sidebar.row.untitledChat', id.slice(0, 8))
/** Build a `@session:<profile>/<id>` chip. Value carries the metadata the agent
* needs to resolve the link (session_search); label shows the friendly title. */
export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput {
return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` }
export function sessionInlineRef(payload: SessionDragPayload): InlineRefInput {
return { kind: 'session', label: sessionLabel(payload), value: `${payload.profile || 'default'}/${payload.id}` }
}
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {

View file

@ -1,6 +1,7 @@
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
import { type DragEvent as ReactDragEvent, useCallback, useEffect, useRef, useState } from 'react'
import { dragHasAttachments } from '@/app/chat/composer/inline-refs'
import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers'
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions'
@ -27,16 +28,48 @@ interface FileDropZoneOptions {
* `onDrop` would fire.
*
* Spread `dropHandlers` onto the container; render an overlay off `dragKind`.
* Esc aborts an in-flight drag, matching the sidebar session drag.
*/
export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) {
const [dragKind, setDragKind] = useState<DragKind>(null)
const depth = useRef(0)
const aborted = useRef(false)
const reset = useCallback(() => {
depth.current = 0
setDragKind(null)
}, [])
// Esc aborts a file drag — the same "never mind" a session drag gets. Native
// DnD can't be cancelled at the OS level, so we drop the overlay and arm a
// guard that swallows the trailing drop instead. Top escape layer + capture
// stop so it doesn't also fire a handler behind the drag (see drag-session).
useEffect(() => {
if (dragKind === null) {
return
}
const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.drag)
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
return
}
event.preventDefault()
event.stopPropagation()
aborted.current = true
reset()
}
window.addEventListener('keydown', onKey, true)
return () => {
window.removeEventListener('keydown', onKey, true)
releaseLayer()
}
}, [dragKind, reset])
const onDragEnter = useCallback(
(event: ReactDragEvent) => {
const kind = enabled ? dragKindOf(event) : null
@ -46,6 +79,12 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt
}
event.preventDefault()
// A genuinely new drag (not a nested-child re-enter) re-arms after abort.
if (depth.current === 0) {
aborted.current = false
}
depth.current += 1
setDragKind(kind)
},
@ -78,9 +117,12 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt
return
}
// An outer layer may have already claimed this drop via preventDefault —
// reset the hover state but don't ALSO act on it.
const claimed = event.defaultPrevented
// Only an Esc abort swallows the drop — NOT `event.defaultPrevented`. The
// file tree's app-wide react-dnd HTML5Backend preventDefaults every native
// file drop in the capture phase, so that flag is always set here (every
// Finder drop would no-op). Genuine nested targets claim via stopPropagation
// and never reach this bubble handler anyway.
const claimed = aborted.current
event.preventDefault()
reset()

View file

@ -7,8 +7,9 @@
*/
import type { ReadableAtom } from 'nanostores'
import type { ReactElement, ReactNode } from 'react'
import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store'
import { registry } from '@/contrib/registry'
import type { TileDock } from '@/store/session-states'
@ -33,6 +34,14 @@ export interface PaneMirror<T> {
render: (key: string) => ReactNode
/** Wrap the tile's TAB (domain context menu — session verbs). */
tabWrap?: (key: string, tab: ReactElement) => ReactNode
/** Override the tile's TAB drag (session drop language: stack/split/link).
* Returns whether it took the drag (see PaneChrome.tabDrag). */
tabDrag?: (
key: string,
event: ReactPointerEvent<HTMLElement>,
onTap: () => void,
double?: DoubleTapContext
) => boolean
/** Wired as the pane's closer (tab Close). */
close: (key: string) => void
}
@ -62,9 +71,17 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
area: 'panes',
title,
data: {
dock: { before: cfg.before?.(tile), pane: cfg.anchor?.(tile) ?? 'workspace', pos: cfg.dir?.(tile) ?? 'right' },
dock: {
before: cfg.before?.(tile),
pane: cfg.anchor?.(tile) ?? 'workspace',
pos: cfg.dir?.(tile) ?? 'right'
},
minWidth: cfg.minWidth,
placement: 'main',
tabDrag: cfg.tabDrag
? (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) =>
cfg.tabDrag!(key, event, onTap, double)
: undefined, // returns boolean (handled) — see PaneChrome.tabDrag
tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined
},
render: () => cfg.render(key)

View file

@ -29,6 +29,7 @@ import type { PointerEvent as ReactPointerEvent } from 'react'
import { findGroup } from '@/components/pane-shell/tree/model'
import {
type DoubleTapContext,
rectContains,
slotBefore,
snapshotStrips,
@ -37,12 +38,18 @@ import {
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 {
$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'
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
@ -77,12 +84,18 @@ function chatZonePane(groupId: string): null | string {
}
/**
* 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.
* 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) and `opts.double`
* (hide the tab bar) ride the tab's gestures; 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>) {
export function startSessionDrag(
payload: SessionDragPayload,
e: ReactPointerEvent<HTMLElement>,
opts?: { double?: DoubleTapContext; onTap?: () => void }
) {
let zones: EngineZone[] = []
let strips: StripSnapshot[] = []
let surfaces: SurfaceSnapshot[] = []
@ -94,8 +107,17 @@ export function startSessionDrag(payload: SessionDragPayload, e: ReactPointerEve
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: payload.title || `chat ${payload.id.slice(0, 8)}` },
double: opts?.double,
ghost: { label: sessionLabel(payload) },
onTap: opts?.onTap,
onEngage() {
zones = snapshotZones()
@ -103,11 +125,18 @@ export function startSessionDrag(payload: SessionDragPayload, e: ReactPointerEve
surfaces = snapshotSurfaces()
composers = [...document.querySelectorAll<HTMLElement>('[data-slot="composer-root"]')].map(snapRect)
zoneHost = new Map(zones.map(zone => [zone.id, chatZonePane(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
@ -123,7 +152,9 @@ export function startSessionDrag(payload: SessionDragPayload, e: ReactPointerEve
const strip = strips.find(s => s.groupId === zone.id && rectContains(s.rect, x, y))
if (strip) {
const stack = slotBefore(strip.slots, x)
// 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, before: stack.before, pos: 'center' }
link = null

View file

@ -50,9 +50,11 @@ import {
sessionTileDelegate
} from '@/store/session-states'
import type { SessionDragPayload } from './composer/inline-refs'
import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
import { useComposerActions } from './hooks/use-composer-actions'
import { paneMirror } from './pane-mirror'
import { startSessionDrag } from './session-drag'
import { useSessionTileActions } from './session-tile-actions'
import { type SessionView, SessionViewProvider } from './session-view'
import { SessionContextMenu } from './sidebar/session-actions-menu'
@ -255,6 +257,13 @@ function tileTitle(storedSessionId: string): string {
return stored ? sessionTitle(stored) : 'Session'
}
/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
function tileDragPayload(storedSessionId: string): SessionDragPayload {
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) }
}
// ---------------------------------------------------------------------------
// Close confirmation — a BUSY tab (streaming, or blocked on clarify/approval
// input) doesn't close silently.
@ -416,5 +425,12 @@ export const watchSessionTiles = paneMirror<SessionTile>({
{tab}
</SessionTabMenu>
),
// A tile's tab drags like a sidebar row — stack / split / drop-to-link — with
// its tap (activate) + double-tap (hide bar) preserved. Always takes the drag.
tabDrag: (storedSessionId, event, onTap, double) => {
startSessionDrag(tileDragPayload(storedSessionId), event, { double, onTap })
return true
},
close: requestCloseSessionTile
})

View file

@ -87,7 +87,6 @@ import {
$messagingPlatformTotals,
$messagingSessions,
$messagingTruncated,
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
$sessionsLoading,
@ -96,7 +95,7 @@ import {
sessionPinId,
setCurrentCwd
} from '@/store/session'
import type { SplitDir } from '@/store/session-states'
import { $focusedStoredSessionId, type SplitDir } from '@/store/session-states'
import {
type AppView,
@ -277,7 +276,9 @@ export function ChatSidebar({
const pinsOpen = useStore($sidebarPinsOpen)
const agentsOpen = useStore($sidebarRecentsOpen)
const cronOpen = useStore($sidebarCronOpen)
const selectedSessionId = useStore($selectedStoredSessionId)
// The sidebar highlight tracks the FOCUSED session — the interacted tile's
// tab, else the main selection — so it stays 1:1 with whatever tab is active.
const selectedSessionId = useStore($focusedStoredSessionId)
const sessions = useStore($sessions)
const cronSessions = useStore($cronSessions)
const cronJobs = useStore($cronJobs)
@ -1095,46 +1096,46 @@ export function ChatSidebar({
const isNewSession = item.id === 'new-session'
const button = (
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
// no-drag: these rows sit directly under the titlebar's
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
// 6px of clearance. Drag regions win hit-testing over DOM
// (pointer-events can't override), and on Linux/WSLg the
// resolved region has been observed to swallow clicks on the
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
// thread.tsx.
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
)}
onClick={() => {
// A plain new session lands in whatever profile the live
// gateway is on (= the active switcher context). null →
// no swap. The switcher header is the single place to
// change which profile that is.
if (isNewSession) {
$newChatProfile.set(null)
}
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
// no-drag: these rows sit directly under the titlebar's
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
// 6px of clearance. Drag regions win hit-testing over DOM
// (pointer-events can't override), and on Linux/WSLg the
// resolved region has been observed to swallow clicks on the
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
// thread.tsx.
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
)}
onClick={() => {
// A plain new session lands in whatever profile the live
// gateway is on (= the active switcher context). null →
// no swap. The switcher header is the single place to
// change which profile that is.
if (isNewSession) {
$newChatProfile.set(null)
}
onNavigate(item)
}}
tooltip={s.nav[item.id] ?? item.label}
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
<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>
onNavigate(item)
}}
tooltip={s.nav[item.id] ?? item.label}
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
<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 —

View file

@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'
import { computed } from 'nanostores'
import type { CSSProperties, ReactElement } from 'react'
import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react'
import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail'
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
@ -8,6 +8,7 @@ import { type StatusbarItem } from '@/app/shell/statusbar-controls'
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { allPaneIds, group, split } from '@/components/pane-shell/tree/model'
import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer'
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
import {
$layoutTree,
bindTreeSideVisibility,
@ -52,7 +53,9 @@ import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/prev
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchRouteTiles } from '../chat/route-tile'
import { startSessionDrag } from '../chat/session-drag'
import {
SessionTileCloseConfirm,
stackSessionTilesIntoMain,
@ -91,6 +94,35 @@ const renderWorkspacePane = () => <WiredPane part="chatRoutes" />
// the loaded primary session; no menu on a fresh draft).
const wrapWorkspaceTab = (tab: ReactElement) => <WorkspaceTabMenu>{tab}</WorkspaceTabMenu>
/** The `@session` payload for the workspace tab the loaded primary session,
* or null on a fresh draft / full-page view (nothing to link). */
const workspaceDragPayload = (): SessionDragPayload | null => {
const selected = $selectedStoredSessionId.get()
if (!selected || $workspaceIsPage.get()) {
return null
}
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, selected))
return { id: selected, profile: stored?.profile ?? '', title: stored ? storedSessionTitle(stored) : '' }
}
// The main tab drags like a session tile — drop it on a composer to link the
// chat, on a zone/edge to stack/split. Defers (`false`) to the generic pane
// move when there's no loaded session to carry.
const workspaceTabDrag = (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) => {
const payload = workspaceDragPayload()
if (!payload) {
return false
}
startSessionDrag(payload, event, { double, onTap })
return true
}
registry.registerMany([
{
id: 'sessions',
@ -115,7 +147,13 @@ registry.registerMany([
area: 'panes',
// Live-retitled to the loaded session by syncWorkspaceTitle below.
title: 'New session',
data: { placement: 'main', minWidth: '22vw', tabWrap: wrapWorkspaceTab, uncloseable: true },
data: {
placement: 'main',
minWidth: '22vw',
tabDrag: workspaceTabDrag,
tabWrap: wrapWorkspaceTab,
uncloseable: true
},
render: renderWorkspacePane
},
{
@ -370,6 +408,7 @@ const syncWorkspaceTitle = () => {
headerVeto: $workspaceIsPage.get(),
placement: 'main',
minWidth: '22vw',
tabDrag: workspaceTabDrag,
tabWrap: wrapWorkspaceTab,
uncloseable: true
},

View file

@ -54,6 +54,7 @@ import {
setCurrentProvider,
setMessages
} from '@/store/session'
import { focusOpenSession } from '@/store/session-states'
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
import { isSecondaryWindow } from '@/store/windows'
import { useSkinCommand } from '@/themes/use-skin-command'
@ -716,7 +717,13 @@ export function ContribWiring({ children }: { children: ReactNode }) {
onReload: reloadFromMessage,
onRemoveAttachment: id => void composer.removeAttachment(id),
onRestoreToMessage: restoreToMessage,
onResumeSession: sessionId => navigate(sessionRoute(sessionId)),
// Already on screen (open tile, or the main session)? Jump to its tab;
// otherwise load it into main.
onResumeSession: sessionId => {
if (!focusOpenSession(sessionId)) {
navigate(sessionRoute(sessionId))
}
},
onRetryResume: sessionId => void resumeSession(sessionId, true),
onSteer: steerPrompt,
onSubmit: submitText,

View file

@ -220,7 +220,10 @@ export function insertAtGroup(
pos: DropPosition,
/** Center drops only: stack BEFORE this pane id (`null`/omitted = append)
* the tab-strip insertion divider's slot. */
before?: null | string
before?: null | string,
/** Front the inserted pane TRUE for a gesture (drop/reveal), FALSE for silent
* adoption (logs stacking into the terminal zone must not steal its tab). */
activate: boolean = true
): LayoutNode | null {
const walk = (n: LayoutNode): LayoutNode => {
if (n.type === 'group') {
@ -236,8 +239,11 @@ export function insertAtGroup(
// a stack you can't see is a trap, and once a zone has ever stacked
// the bar STAYS when it drops back to one tab — the auto-hide flicker
// while dragging tabs around felt broken. Hiding is the user's call
// (double-click / zone menu).
return { ...n, panes, active: paneId, headerHidden: false }
// (double-click / zone menu). Active moves only on a gesture; an empty
// target has no prior tab, so the newcomer takes it regardless.
const active = activate || n.panes.length === 0 ? paneId : n.active
return { ...n, panes, active, headerHidden: false }
}
const orientation: Orientation = pos === 'left' || pos === 'right' ? 'row' : 'column'
@ -385,9 +391,7 @@ export function adjacentGroup(
const index = parent.children.indexOf(path[i + 1])
const siblings = forward
? parent.children.slice(index + 1)
: parent.children.slice(0, index).reverse()
const siblings = forward ? parent.children.slice(index + 1) : parent.children.slice(0, index).reverse()
for (const sibling of siblings) {
const hit = edgeGroup(sibling, OPPOSITE_EDGE[side], viable)

View file

@ -30,6 +30,7 @@
import type { PointerEvent as ReactPointerEvent } from 'react'
import { createDragGhost, type DragGhost } from '@/lib/drag-ghost'
import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers'
import { reorderCommitHaptic, reorderStepHaptic } from '@/lib/reorder'
@ -71,7 +72,7 @@ export function radialPosition(
return 'center'
}
return Math.abs(dx) >= Math.abs(dy) ? (dx < 0 ? 'left' : 'right') : (dy < 0 ? 'top' : 'bottom')
return Math.abs(dx) >= Math.abs(dy) ? (dx < 0 ? 'left' : 'right') : dy < 0 ? 'top' : 'bottom'
}
/** Sub-zone drop position within the zone `groupId` (radial hit-testing). */
@ -177,28 +178,11 @@ export interface DragSessionSpec {
onTap?(): void
double?: DoubleTapContext
/** Floating chip following the pointer for drags whose source doesn't
* stay visibly "held" (a sidebar row, unlike a dimmed tab). */
* stay visibly "held" (a sidebar row, unlike a dimmed tab). See
* `@/lib/drag-ghost`. */
ghost?: { label: string }
}
/** The engaged drag's ghost chip: plain DOM (no React), themed via the same
* tokens as DropPill, moved with a transform trivially cheap, and removal
* on Esc is synchronous. */
function createGhost(label: string): HTMLElement {
const ghost = document.createElement('div')
ghost.textContent = label
ghost.style.cssText =
'position:fixed;left:0;top:0;z-index:9999;pointer-events:none;max-width:16rem;overflow:hidden;' +
'text-overflow:ellipsis;white-space:nowrap;padding:0.25rem 0.75rem;border-radius:9999px;' +
'border:1px solid color-mix(in srgb,var(--dt-composer-ring) 45%,transparent);' +
'background:color-mix(in srgb,var(--dt-card) 92%,transparent);color:var(--ui-text-primary);' +
'font-size:0.75rem;font-weight:500;box-shadow:0 4px 16px rgba(0,0,0,0.25);will-change:transform'
document.body.appendChild(ghost)
return ghost
}
/** After an ENGAGED drag, the release still synthesizes a `click` on the
* capture element swallow exactly that one so a drag can never double as
* an activation (row resume, tab close). Committed drags see the click in
@ -242,7 +226,7 @@ export function startDragSession(e: ReactPointerEvent<HTMLElement>, spec: DragSe
const restoreSelect = document.body.style.userSelect
let engaged = false
let releaseEscapeLayer: (() => void) | null = null
let ghost: HTMLElement | null = null
let ghost: DragGhost | null = null
let cursor: string | null = null
// rAF-coalesced move processing: the raw handler only records the latest
// point; all hit testing happens at most once per frame.
@ -287,7 +271,7 @@ export function startDragSession(e: ReactPointerEvent<HTMLElement>, spec: DragSe
releaseEscapeLayer = pushEscapeLayer(ESCAPE_PRIORITY.drag)
if (spec.ghost) {
ghost = createGhost(spec.ghost.label)
ghost = createDragGhost(spec.ghost.label)
}
spec.onEngage(x, y)
@ -302,9 +286,7 @@ export function startDragSession(e: ReactPointerEvent<HTMLElement>, spec: DragSe
engage(x, y)
}
if (ghost) {
ghost.style.transform = `translate3d(${x + 14}px, ${y + 12}px, 0)`
}
ghost?.moveTo(x, y)
const hint = spec.resolveMove(x, y, shift)
@ -344,7 +326,7 @@ export function startDragSession(e: ReactPointerEvent<HTMLElement>, spec: DragSe
document.body.style.cursor = restoreCursor
document.body.style.userSelect = restoreSelect
ghost?.remove()
ghost?.destroy()
ghost = null
releaseEscapeLayer?.()
releaseEscapeLayer = null
@ -428,13 +410,19 @@ const TEAR_OFF_SLACK_PX = 18
* light up, the target's tab strip stacks at its divider slot, Shift extends
* the highlight range, release drops into the ClosestCenter primary zone.
* Esc aborts either mode.
*
* `ghostLabel` opts into the pointer-following chip (`@/lib/drag-ghost`) the
* same "what am I holding" affordance sessions use. The in-strip dim only
* marks a tab; a header/edit-body drag or a torn-off tab has no held source
* near the pointer, so the chip carries the pane title along with it.
*/
export function startPaneDrag(
paneId: string,
e: ReactPointerEvent<HTMLElement>,
onTap?: () => void,
reorder?: ReorderContext,
double?: DoubleTapContext
double?: DoubleTapContext,
ghostLabel?: string
) {
if (e.button !== 0) {
return
@ -486,6 +474,7 @@ export function startPaneDrag(
startDragSession(e, {
double,
ghost: ghostLabel ? { label: ghostLabel } : undefined,
onTap,
onEngage(x, y) {

View file

@ -14,6 +14,8 @@ import type { Contribution } from '@/contrib/types'
import type { GroupNode, LayoutNode } from '../model'
import { allPaneIds } from '../model'
import type { DoubleTapContext } from './drag-session'
export const MIN_PANE_PX = 80
/** Optional CSS sizing a pane contributes (`data.width` / `data.minWidth`).
@ -46,6 +48,12 @@ interface PaneChrome {
* pin/branch/rename/archive/delete). The wrapper must render `tab` as its
* interactive child; the zone's own strip menu still owns non-tab space. */
tabWrap?: (tab: React.ReactElement) => React.ReactNode
/** Override this pane's TAB drag (a session tab drags like a sidebar row
* stack / split / composer-link not the generic pane move). Given the
* tab's tap (activate) + double-tap (hide header) so those gestures survive.
* Returns whether it took the drag; `false` (or absent) defers to
* `startPaneDrag` e.g. the workspace tab on a fresh draft, nothing to link. */
tabDrag?: (event: React.PointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) => boolean
/** Suppress the zone header while THIS pane is active full-page views
* (artifacts/skills/plugin pages) are not tab-able surfaces. The flag is
* live: the workspace contribution re-registers it on route changes. */

View file

@ -15,8 +15,14 @@ import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, u
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { DecodeText } from '@/components/ui/decode-text'
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS, DropPill } from '@/components/ui/drop-affordance'
import { PANE_TAB_STRIP_LINE, PANE_TAB_STRIP_LINE_LEFT, PANE_TAB_STRIP_LINE_RIGHT, PaneTab, PaneTabLabel } from '@/components/ui/pane-tab'
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance'
import {
PANE_TAB_STRIP_LINE,
PANE_TAB_STRIP_LINE_LEFT,
PANE_TAB_STRIP_LINE_RIGHT,
PaneTab,
PaneTabLabel
} from '@/components/ui/pane-tab'
import { ContribBoundary } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { useI18n } from '@/i18n'
@ -173,21 +179,25 @@ export function TreeGroup({
// ONE header style: the app's compact pane-header. DEFAULT is contextual —
// a single pane isn't a "tab", so its header auto-hides; a stack shows its
// chips. EXCEPTION: a lone TILE (closeable, placement 'main' — a session/page
// split) always shows its header, so it has a tab + close X — a tile in its
// own zone was otherwise unclosable (the "3rd tile has no tab" trap). Chrome
// panes (sessions/files/terminal…) and the uncloseable workspace keep the
// clean no-tab default. Double-click toggles it either way; a minimized
// group always shows its header (it IS the header).
const hasLoneTile = shown.some(id => {
const chrome = paneChrome(paneFor(id))
// chips. EXCEPTIONS force a lone pane to keep its header (tab + close X):
// - a TILE (closeable, placement 'main' — a session/page split), else a
// tile in its own zone is unclosable (the "3rd tile has no tab" trap);
// - a TOOL PANEL (terminal/logs — a collapse pane) dragged out of the main
// stack, else it's a dead zone with no tab to grab or ✕ to close.
// The uncloseable workspace and side chrome (sessions/files) keep the clean
// no-tab default. Double-click toggles it either way; a minimized group
// always shows its header (it IS the header).
const forceLoneHeader =
shown.some(id => {
const chrome = paneChrome(paneFor(id))
return !chrome.uncloseable && chrome.placement === 'main'
})
return !chrome.uncloseable && chrome.placement === 'main'
}) ||
(shown.length === 1 && isCollapsePane(shown[0]))
// A full-page view (headerVeto) suppresses the strip while it's the active
// pane — a page is not a tab-able surface; the bar returns with the chat.
const headerHidden = paneChrome(active).headerVeto || (node.headerHidden ?? (shown.length <= 1 && !hasLoneTile))
const headerHidden = paneChrome(active).headerVeto || (node.headerHidden ?? (shown.length <= 1 && !forceLoneHeader))
// A group collapses ALONG its parent split's axis. In a row that means the
// WIDTH collapses — a full-width horizontal header would strand a tall
@ -362,7 +372,9 @@ export function TreeGroup({
)}
data-zone-tabstrip={node.id}
onContextMenu={e => {
setMenuPane((e.target as HTMLElement).closest('[data-tree-tab]')?.getAttribute('data-tree-tab') ?? undefined)
setMenuPane(
(e.target as HTMLElement).closest('[data-tree-tab]')?.getAttribute('data-tree-tab') ?? undefined
)
}}
onPointerDown={e =>
// Tap the header to collapse to it / expand back — the DetailPane
@ -373,7 +385,8 @@ export function TreeGroup({
e,
() => minimizable && toggleCollapse(),
undefined,
hideHeaderDoubleTap
hideHeaderDoubleTap,
active?.title ?? activeId
)
}
ref={stripRef}
@ -396,25 +409,43 @@ export function TreeGroup({
data-tree-tab={paneId}
key={paneId}
onClose={closeable ? () => closeTab(paneId) : undefined}
onPointerDown={e =>
startPaneDrag(
paneId,
e,
() => {
// Tabs ACTIVATE (restoring a collapsed group).
// Minimize lives on the chevron / single-pane label
// — overloading the active tab made double-click a
// minimize/restore/hide lottery.
if (node.minimized) {
restoreTreePane(paneId)
}
onPointerDown={e => {
// Tabs ACTIVATE (restoring a collapsed group). Minimize
// lives on the chevron / single-pane label — overloading
// the active tab made double-click a minimize/restore/hide
// lottery.
const onTap = () => {
if (node.minimized) {
restoreTreePane(paneId)
}
activateTreePane(node.id, paneId)
},
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
hideHeaderDoubleTap
)
}
activateTreePane(node.id, paneId)
}
// Claim the press so the STRIP's own pane-drag handler
// (parent onPointerDown) can't also fire. startPaneDrag
// does this internally; the session drag (shared with
// sidebar rows) doesn't, so do it here for both paths.
if (e.button === 0) {
e.preventDefault()
e.stopPropagation()
}
// A pane may own its tab drag (a session tab speaks the
// session drop language — link/stack/split); `false` defers
// to the generic pane move (the workspace tab on a fresh
// draft has no session to link).
if (!chrome.tabDrag?.(e, onTap, hideHeaderDoubleTap)) {
startPaneDrag(
paneId,
e,
onTap,
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
hideHeaderDoubleTap,
title
)
}
}}
role="tab"
style={{ cursor: 'grab' }}
>
@ -472,7 +503,7 @@ export function TreeGroup({
// barely-tinted wash; the light blur reads as "edit mode" the same
// way the zone editor's backdrop does.
className="absolute inset-x-0 bottom-0 z-50 flex cursor-grab items-center justify-center outline-1 -outline-offset-2 outline-dashed backdrop-blur-[2px]"
onPointerDown={e => startPaneDrag(activeId, e)}
onPointerDown={e => startPaneDrag(activeId, e, undefined, undefined, undefined, active?.title ?? activeId)}
style={{
top: headerVisible ? 28 : 0,
background:
@ -490,7 +521,7 @@ export function TreeGroup({
{/* FancyZones drop overlay its own component so the per-frame drop
hint re-renders only this (tiny) node, not the whole zone. */}
<ZoneDropOverlay isEmpty={isEmpty} node={node} />
<ZoneDropOverlay node={node} />
</div>
)
}
@ -565,26 +596,16 @@ const REGION: Record<DropPosition, CSSProperties> = {
top: { bottom: '50%', left: REGION_PAD, right: REGION_PAD, top: REGION_PAD }
}
const EDGE_ARROW: Record<Exclude<DropPosition, 'center'>, string> = {
bottom: 'arrow-down',
left: 'arrow-left',
right: 'arrow-right',
top: 'arrow-up'
}
/**
* The FancyZones drop overlay for one zone. Split out of TreeGroup so the
* per-pointermove `$dropHint` churn re-renders only this lightweight node
* the zone's header, body, and menu-direction walk stay put during a drag.
*
* ONE dashed sheet per zone, in the attachment dropzone's design language
* (DROP_SHEET_CLASS + DropPill the composer drop and the zone targets speak
* identically): a quiet outline over every eligible zone, accent-lit over the
* target, morphing to the hovered half for an edge split. The pill names the
* outcome; edges get their arrow.
* ONE dashed sheet per zone (DROP_SHEET_CLASS the composer drop and the zone
* targets speak identically): a quiet outline over every eligible zone,
* accent-lit over the target, morphing to the hovered half for an edge split.
*/
function ZoneDropOverlay({ isEmpty, node }: { isEmpty: boolean; node: GroupNode }) {
const { t } = useI18n()
function ZoneDropOverlay({ node }: { node: GroupNode }) {
const dragging = useStore($treeDragging)
const hint = useStore($dropHint)
@ -618,22 +639,11 @@ function ZoneDropOverlay({ isEmpty, node }: { isEmpty: boolean; node: GroupNode
// Sub-positions only exist for a single-zone target (a Shift-span merges).
const pos = primary && !multi ? (hint?.pos ?? 'center') : 'center'
// Session drag over a CHAT zone's CENTER: the "link to chat" overlay inside
// the surface (ChatDropOverlay — the same sheet + pill) owns that region;
// this sheet fades out so the two never stack. A non-chat zone's center has
// no chat to link, so it shows the normal stack sheet. Edges act like a tab.
// the surface (ChatDropOverlay — the same sheet) owns that region; this sheet
// fades out so the two never stack. A non-chat zone's center has no chat to
// link, so it shows the normal stack sheet. Edges act like a tab.
const centerLink = sessionDrag && primary && pos === 'center' && chatZone
const pill =
!primary || centerLink
? null
: multi
? { icon: 'combine', label: t.zones.spanHere }
: pos !== 'center'
? { icon: EDGE_ARROW[pos], label: sessionDrag ? t.zones.openHere : t.zones.splitHere }
: isDragSource
? { icon: 'discard', label: t.zones.staysHere }
: { icon: 'layers', label: isEmpty ? t.zones.moveHere : t.zones.stackHere }
return (
<div
className="pointer-events-none absolute inset-0 z-40"
@ -646,7 +656,7 @@ function ZoneDropOverlay({ isEmpty, node }: { isEmpty: boolean; node: GroupNode
// backdrop-filter, and a blur interpolating while the insets glide
// re-blurs half a zone every frame — the single most expensive
// paint in the whole drag.
'absolute flex items-center justify-center transition-[top,right,bottom,left,background-color,border-color,opacity] duration-150 ease-out',
'absolute transition-[top,right,bottom,left,background-color,border-color,opacity] duration-150 ease-out',
// Blur only the live target — idle outlines must not fog the app.
active && !centerLink && DROP_SHEET_BLUR_CLASS,
centerLink && 'opacity-0'
@ -660,9 +670,7 @@ function ZoneDropOverlay({ isEmpty, node }: { isEmpty: boolean; node: GroupNode
: 'color-mix(in srgb, var(--ui-accent) 5%, color-mix(in srgb, var(--dt-card) 25%, transparent))',
borderColor: `color-mix(in srgb, var(--ui-accent) ${active ? 75 : 28}%, transparent)`
}}
>
{pill && <DropPill icon={pill.icon}>{pill.label}</DropPill>}
</div>
/>
</div>
)
}

View file

@ -13,6 +13,7 @@ import { translateNow } from '@/i18n'
import { readJson, readKey, writeJson, writeKey } from '@/lib/storage'
import { notify } from '@/store/notifications'
import { clearAllPaneSizeOverrides } from '@/store/panes'
import { isSecondaryWindow } from '@/store/windows'
import {
allPaneIds,
@ -55,11 +56,19 @@ function loadPersisted(): LayoutNode | null {
}
function persist(tree: LayoutNode | null) {
// A secondary window (single-chat pop-out) shares the origin's localStorage;
// writing its stripped-down DEFAULT tree back would wipe the primary's layout.
if (isSecondaryWindow()) {
return
}
writeJson(STORAGE_KEY, tree)
}
/** The live tree (null until a default is declared). */
export const $layoutTree = atom<LayoutNode | null>(loadPersisted())
/** The live tree (null until a default is declared). A secondary window ignores
* the persisted (primary) layout and boots to the default nothing but its
* own routed session. */
export const $layoutTree = atom<LayoutNode | null>(isSecondaryWindow() ? null : loadPersisted())
/**
* Which layout preset the current tree came from; `'custom'` after the user
@ -234,7 +243,9 @@ export function trackActiveTreeGroup(): () => void {
}
const isUncloseablePane = (paneId: string): boolean =>
Boolean((registry.getArea('panes').find(c => c.id === paneId)?.data as { uncloseable?: boolean } | undefined)?.uncloseable)
Boolean(
(registry.getArea('panes').find(c => c.id === paneId)?.data as { uncloseable?: boolean } | undefined)?.uncloseable
)
/** W "main tabs always": close the MAIN (workspace) zone's active tab, unless
* it's the uncloseable workspace itself. Returns false when there's nothing to
@ -643,7 +654,8 @@ function adoptMissingPanes(target: LayoutNode, source: LayoutNode): LayoutNode {
const targetId = (sibling ? findGroupOfPane(next, sibling)?.id : undefined) ?? groupLeafIds(next)[0]
if (targetId) {
next = insertAtGroup(next, targetId, paneId, 'center') ?? next
// Silent adoption: don't steal the target zone's active tab (logs).
next = insertAtGroup(next, targetId, paneId, 'center', null, false) ?? next
have.add(paneId)
}
}
@ -742,7 +754,8 @@ function adoptContributedPanes(): void {
const target = findGroupOfPane(next, anchor ?? '')?.id
if (target) {
next = insertAtGroup(next, target, pane.id, dock?.pos ?? 'center', dock?.before) ?? next
// Silent adoption: don't front over the zone's active tab — a reveal does.
next = insertAtGroup(next, target, pane.id, dock?.pos ?? 'center', dock?.before, false) ?? next
// An adopted pane ARRIVES with its chip showing — a surprise zone with
// zero chrome has no obvious handle to drag or close. (Explicit reveal;
@ -981,7 +994,34 @@ function paneGroup(paneId: string) {
export function setPaneCollapsed(paneId: string, collapsed: boolean) {
const group = paneGroup(paneId)
if (group && Boolean(group.minimized) !== collapsed) {
if (!group) {
return
}
// SHARED zone (terminal + logs, or a tool panel stacked with the workspace):
// one minimized flag but per-pane toggle stores — so "collapsed" is the
// ZONE's. Open → reveal + front; close acts ONLY for the on-screen tab. An
// inactive toggle folding its visible sibling is what re-collapsed the zone
// on every boot (broke collapse persistence).
if (group.panes.length > 1) {
if (collapsed && group.active === paneId) {
if (group.panes.some(isUncloseablePane)) {
// Workspace can't minimize (strands the app) → tab-switch to a sibling
// (guaranteed to exist by length > 1).
const at = group.panes.indexOf(paneId)
activateTreePane(group.id, group.panes[at - 1] ?? group.panes[at + 1])
} else {
toggleTreeGroupMinimized(group.id, true) // pure tool zone folds as a unit
}
} else if (!collapsed) {
revealTreePane(paneId)
}
return
}
if (Boolean(group.minimized) !== collapsed) {
toggleTreeGroupMinimized(group.id, collapsed)
if (!collapsed) {

View file

@ -1,29 +1,11 @@
/**
* Shared drag-and-drop visual language ONE dashed sheet + ONE floating pill,
* used by every drop affordance (the composer file/session overlay, the layout
* zone targets) so "you can drop here" reads identically everywhere.
* Shared drag-and-drop visual language ONE dashed accent sheet, used by every
* drop affordance (the composer file/session overlay, the layout zone targets)
* so "you can drop here" reads identically everywhere.
*/
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
/** The sheet: a dashed region marking where a drop would land. */
export const DROP_SHEET_CLASS = 'rounded-2xl border-2 border-dashed'
/** Soft blur for the LIVE sheet only — idle outlines must not fog the app. */
export const DROP_SHEET_BLUR_CLASS = 'backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]'
/** The pill: icon + label floating over a sheet, naming the outcome. */
export function DropPill({ children, className, icon }: React.ComponentProps<'div'> & { icon: string }) {
return (
<div
className={cn(
'flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer',
className
)}
>
<Codicon className="shrink-0 text-(--ui-accent)" name={icon} size="1rem" />
{children}
</div>
)
}

View file

@ -0,0 +1,92 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PaneTab, PaneTabLabel } from './pane-tab'
afterEach(cleanup)
describe('PaneTab close gestures', () => {
it('middle-click (button 1) closes', () => {
const onClose = vi.fn()
render(
<PaneTab onClose={onClose}>
<PaneTabLabel>tab</PaneTabLabel>
</PaneTab>
)
fireEvent(screen.getByText('tab'), new MouseEvent('auxclick', { bubbles: true, button: 1 }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('⌘-click (metaKey + button 0) closes — the Mac middle-click equivalent', () => {
const onClose = vi.fn()
render(
<PaneTab onClose={onClose}>
<PaneTabLabel>tab</PaneTabLabel>
</PaneTab>
)
fireEvent.pointerDown(screen.getByText('tab'), { button: 0, metaKey: true })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('⌘-click preempts the shell drag/activate pointerdown handler', () => {
const onClose = vi.fn()
const onPointerDown = vi.fn()
render(
<PaneTab onClose={onClose} onPointerDown={onPointerDown}>
<PaneTabLabel>tab</PaneTabLabel>
</PaneTab>
)
fireEvent.pointerDown(screen.getByText('tab'), { button: 0, metaKey: true })
expect(onClose).toHaveBeenCalledTimes(1)
expect(onPointerDown).not.toHaveBeenCalled()
})
it('⌘-click swallows the follow-up activation click (capture phase)', () => {
const onClose = vi.fn()
const onActivate = vi.fn()
render(
<PaneTab onClose={onClose}>
<PaneTabLabel as="button" onClick={onActivate}>
tab
</PaneTabLabel>
</PaneTab>
)
fireEvent.click(screen.getByText('tab'), { button: 0, metaKey: true })
expect(onActivate).not.toHaveBeenCalled()
})
it('plain left-click neither closes nor blocks activation', () => {
const onClose = vi.fn()
const onActivate = vi.fn()
const onPointerDown = vi.fn()
render(
<PaneTab onClose={onClose} onPointerDown={onPointerDown}>
<PaneTabLabel as="button" onClick={onActivate}>
tab
</PaneTabLabel>
</PaneTab>
)
fireEvent.pointerDown(screen.getByText('tab'), { button: 0 })
fireEvent.click(screen.getByText('tab'), { button: 0 })
expect(onClose).not.toHaveBeenCalled()
expect(onPointerDown).toHaveBeenCalledTimes(1)
expect(onActivate).toHaveBeenCalledTimes(1)
})
it('does nothing without an onClose (uncloseable workspace tab)', () => {
const onPointerDown = vi.fn()
render(
<PaneTab onPointerDown={onPointerDown}>
<PaneTabLabel>tab</PaneTabLabel>
</PaneTab>
)
fireEvent.pointerDown(screen.getByText('tab'), { button: 0, metaKey: true })
expect(onPointerDown).toHaveBeenCalledTimes(1)
})
})

View file

@ -12,14 +12,12 @@ export const PANE_TAB_STRIP_LINE_RIGHT = 'shadow-[inset_-1px_0_0_var(--ui-stroke
const TAB =
'group/tab relative flex shrink-0 items-center border-transparent bg-(--tab-bg) text-[0.6875rem] font-medium [-webkit-app-region:no-drag]'
const TAB_HORIZONTAL =
'h-full min-w-0 max-w-48 border-b not-first:border-l not-first:border-l-(--ui-stroke-quaternary)'
const TAB_HORIZONTAL = 'h-full min-w-0 max-w-48 border-b not-first:border-l not-first:border-l-(--ui-stroke-quaternary)'
const TAB_VERTICAL =
'w-full max-h-48 justify-center not-first:border-t not-first:border-t-(--ui-stroke-quaternary) [writing-mode:vertical-rl]'
const TAB_ACTIVE =
'text-foreground [--tab-bg:var(--pane-tab-active-bg,var(--ui-editor-surface-background))]'
const TAB_ACTIVE = 'text-foreground [--tab-bg:var(--pane-tab-active-bg,var(--ui-editor-surface-background))]'
// Inactive = gutter. Hover = 4% translucent wash (VS Code/GitHub alpha hover),
// not an opaque recolor — and never touch borders.
@ -29,7 +27,8 @@ const TAB_IDLE =
interface PaneTabProps extends React.ComponentProps<'div'> {
active?: boolean
dirty?: boolean
/** Middle-click close (no hover X — too easy to hit on small tabs). */
/** Close gesture, no hover X (too easy to hit on small tabs): middle-click,
* or -click as the trackpad-friendly Mac equivalent. */
onClose?: () => void
/** Vertical rail form (collapsed sidebar zones). */
vertical?: boolean
@ -37,6 +36,12 @@ interface PaneTabProps extends React.ComponentProps<'div'> {
side?: 'left' | 'right'
}
/** -click (metaKey + primary button) the Mac has no middle button, so this
* is the trackpad equivalent of middle-click-to-close. Guarded on metaKey so
* it never collides with left-click (activate/drag) or -click (macOS context
* menu). */
const isMetaClose = (event: { button: number; metaKey: boolean }) => event.button === 0 && event.metaKey
/**
* Editor tab shell preview rail + zone headers + collapsed vertical rails.
*
@ -51,6 +56,8 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
onClose,
onAuxClick,
onMouseDown,
onPointerDown,
onClickCapture,
vertical = false,
side = 'left',
children,
@ -84,6 +91,17 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
onAuxClick?.(event)
}}
onClickCapture={event => {
// Sites whose tab activates on the label's own onClick (the preview
// rail) fire it AFTER our pointerdown close — swallow that stray click
// in the capture phase so it can't re-select the just-closed tab.
if (onClose && isMetaClose(event)) {
event.preventDefault()
event.stopPropagation()
}
onClickCapture?.(event)
}}
onMouseDown={event => {
if (onClose && event.button === 1) {
event.preventDefault()
@ -91,6 +109,20 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
onMouseDown?.(event)
}}
onPointerDown={event => {
// ⌘-click closes. Preempt here — the tab strips activate/drag on
// pointerdown (drag-session onTap), so we must claim the press before
// the shell's own handler starts a drag, and skip it entirely.
if (onClose && isMetaClose(event)) {
event.preventDefault()
event.stopPropagation()
onClose()
return
}
onPointerDown?.(event)
}}
ref={ref}
{...props}
>

View file

@ -561,8 +561,7 @@ export const en: Translations = {
localDesc: 'Start a private Hermes backend on localhost. This is the default and works offline.',
remoteTitle: 'Remote gateway',
remoteDesc: 'Connect this desktop shell to a remote Hermes backend.',
remoteAuthHint:
'Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.',
remoteAuthHint: 'Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.',
cloudTitle: 'Hermes Cloud',
cloudDesc: 'Sign in once to Hermes Cloud and pick from the agents on your account — no URL to paste.',
cloudSignInTitle: 'Hermes Cloud',
@ -1662,6 +1661,7 @@ export const en: Translations = {
renameTitle: 'Rename session',
renameDesc: 'Give this chat a memorable title. Leave empty to clear.',
untitledPlaceholder: 'Untitled session',
untitledChat: id => `Chat ${id}`,
ageNow: 'now',
ageDay: 'd',
ageHour: 'h',
@ -2311,7 +2311,8 @@ export const en: Translations = {
minimize: 'Minimize',
restore: 'Restore',
closeRunningTitle: 'Close running tab?',
closeRunningBody: 'This chat is still working (or waiting on your input). Closing the tab hides it — the session keeps its progress and can be reopened from the sidebar.',
closeRunningBody:
'This chat is still working (or waiting on your input). Closing the tab hides it — the session keeps its progress and can be reopened from the sidebar.',
closeRunningConfirm: 'Close tab',
closeOthers: 'Close others',
closeToRight: 'Close to the right',
@ -2322,12 +2323,6 @@ export const en: Translations = {
dirDown: 'down',
dirLeft: 'left',
dirRight: 'right',
stackHere: 'Stack here',
moveHere: 'Move here',
splitHere: 'Split here',
openHere: 'Open here',
spanHere: 'Span here',
staysHere: 'Stays here',
pluginDisabled: pluginId => `Plugin "${pluginId}" disabled`,
pluginDisabledBody: 'Re-enable it in Settings → Plugins to bring the pane back.',
missingPane: paneId => `missing pane: ${paneId}`,

View file

@ -97,7 +97,8 @@ export const ja = defineLocale({
remoteSignInHint: signInLabel =>
`保存済みのリモートブラウザセッションからサインアウトし、${signInLabel}を開きます。代わりにバンドルされたバックエンドに切り替えるには「ローカルゲートウェイを使用」を選択してください。`,
signOutAndSignIn: 'サインアウトして再サインイン',
remoteFailureHint: '「ゲートウェイ設定」でゲートウェイの URL とサインインを確認するか、ローカルゲートウェイに切り替えてください。',
remoteFailureHint:
'「ゲートウェイ設定」でゲートウェイの URL とサインインを確認するか、ローカルゲートウェイに切り替えてください。',
hideRecentLogs: '最近のログを非表示',
showRecentLogs: '最近のログを表示',
signedInTitle: 'サインインしました',
@ -1578,6 +1579,7 @@ export const ja = defineLocale({
renameTitle: 'セッションの名前を変更',
renameDesc: 'このチャットにわかりやすいタイトルをつけてください。空欄にするとクリアされます。',
untitledPlaceholder: '無題のセッション',
untitledChat: id => `セッション ${id}`,
ageNow: 'たった今',
ageDay: '日',
ageHour: '時間',
@ -2238,12 +2240,6 @@ export const ja = defineLocale({
dirDown: '下',
dirLeft: '左',
dirRight: '右',
stackHere: 'ここに重ねる',
moveHere: 'ここに移動',
splitHere: 'ここに分割',
openHere: 'ここに開く',
spanHere: 'ここにまたがる',
staysHere: 'ここにとどまる',
pluginDisabled: pluginId => `プラグイン「${pluginId}」を無効化しました`,
pluginDisabledBody: '設定 → プラグイン で再有効化するとペインが戻ります。',
missingPane: paneId => `ペインが見つかりません: ${paneId}`,

View file

@ -1384,6 +1384,7 @@ export interface Translations {
renameTitle: string
renameDesc: string
untitledPlaceholder: string
untitledChat: (id: string) => string
ageNow: string
ageDay: string
ageHour: string
@ -1947,12 +1948,6 @@ export interface Translations {
dirDown: string
dirLeft: string
dirRight: string
stackHere: string
moveHere: string
splitHere: string
openHere: string
spanHere: string
staysHere: string
pluginDisabled: (pluginId: string) => string
pluginDisabledBody: string
missingPane: (paneId: string) => string

View file

@ -1529,6 +1529,7 @@ export const zhHant = defineLocale({
renameTitle: '重新命名工作階段',
renameDesc: '為此聊天取一個好記的標題。留空則清除。',
untitledPlaceholder: '未命名工作階段',
untitledChat: id => `工作階段 ${id}`,
ageNow: '剛才',
ageDay: '天',
ageHour: '時',
@ -2171,12 +2172,6 @@ export const zhHant = defineLocale({
dirDown: '下',
dirLeft: '左',
dirRight: '右',
stackHere: '堆疊到此處',
moveHere: '移動到此處',
splitHere: '在此分割',
openHere: '在此開啟',
spanHere: '跨越到此處',
staysHere: '留在此處',
pluginDisabled: pluginId => `外掛「${pluginId}」已停用`,
pluginDisabledBody: '在 設定 → 外掛 中重新啟用即可恢復面板。',
missingPane: paneId => `缺少面板:${paneId}`,

View file

@ -316,7 +316,8 @@ export const zh: Translations = {
},
plugins: {
title: '桌面插件',
blurb: '加载到此应用中的界面扩展——随构建捆绑,或放入 desktop-plugins 文件夹(包括 Hermes 编写的插件)。禁用会即时卸载插件并在重启后保持。',
blurb:
'加载到此应用中的界面扩展——随构建捆绑,或放入 desktop-plugins 文件夹(包括 Hermes 编写的插件)。禁用会即时卸载插件并在重启后保持。',
count: n => `已安装 ${n}`,
openFolder: '打开插件文件夹',
rescan: '重新扫描',
@ -1836,6 +1837,7 @@ export const zh: Translations = {
renameTitle: '重命名会话',
renameDesc: '给这个对话起一个好记的标题。留空则清除。',
untitledPlaceholder: '无标题会话',
untitledChat: id => `会话 ${id}`,
ageNow: '刚刚',
ageDay: '天',
ageHour: '时',
@ -2483,12 +2485,6 @@ export const zh: Translations = {
dirDown: '下',
dirLeft: '左',
dirRight: '右',
stackHere: '堆叠到此处',
moveHere: '移动到此处',
splitHere: '在此拆分',
openHere: '在此打开',
spanHere: '跨越到此处',
staysHere: '留在此处',
pluginDisabled: pluginId => `插件“${pluginId}”已禁用`,
pluginDisabledBody: '在 设置 → 插件 中重新启用即可恢复面板。',
missingPane: paneId => `缺少面板:${paneId}`,

View file

@ -0,0 +1,43 @@
/**
* A flat, pointer-following drag chip the shared "what am I holding"
* affordance for in-app pointer drags. Plain DOM (no React) so it survives a
* pointer-capture drag without re-renders and tears down synchronously on Esc.
*
* Flat by design: a solid app surface with the dragged item's label, no
* border / radius / shadow, dimmed it copies the real row/tab it represents
* rather than reading as a separate pill. Any pointer drag whose source does
* not stay visibly "held" can reuse this (the drag primitive in
* `pane-shell/tree/renderer/drag-session.ts`, and anything built on it).
*/
/** How far (px) the chip trails the pointer so it never sits under the cursor. */
const OFFSET_X = 14
const OFFSET_Y = 12
export interface DragGhost {
/** Reposition the chip near the current pointer point. */
moveTo(x: number, y: number): void
/** Remove the chip from the DOM. Idempotent. */
destroy(): void
}
export function createDragGhost(label: string): DragGhost {
const el = document.createElement('div')
el.textContent = label
el.style.cssText =
'position:fixed;left:0;top:0;z-index:9999;pointer-events:none;max-width:16rem;overflow:hidden;' +
'text-overflow:ellipsis;white-space:nowrap;padding:0.25rem 0.625rem;opacity:0.6;' +
'background:var(--ui-sidebar-surface-background,var(--dt-card));color:var(--ui-text-primary);' +
'font-size:0.75rem;font-weight:500;will-change:transform'
document.body.appendChild(el)
return {
moveTo(x, y) {
el.style.transform = `translate3d(${x + OFFSET_X}px, ${y + OFFSET_Y}px, 0)`
},
destroy() {
el.remove()
}
}
}

View file

@ -19,12 +19,19 @@
import { atom, computed } from 'nanostores'
import type { ClientSessionState } from '@/app/types'
import { findGroup } from '@/components/pane-shell/tree/model'
import { $activeTreeGroup, $layoutTree, noteActiveTreeGroup } from '@/components/pane-shell/tree/store'
import { findGroup, findGroupOfPane } from '@/components/pane-shell/tree/model'
import {
$activeTreeGroup,
$layoutTree,
moveTreePane,
noteActiveTreeGroup,
revealTreePane
} from '@/components/pane-shell/tree/store'
import { readJson, writeJson } from '@/lib/storage'
import { $activeGatewayProfile, normalizeProfileKey } from './profile'
import { $activeSessionId, $selectedStoredSessionId } from './session'
import { isSecondaryWindow } from './windows'
// ---------------------------------------------------------------------------
// Reactive per-runtime session state (view mirror of the wiring cache).
@ -48,7 +55,6 @@ export function dropSessionState(runtimeId: string) {
$sessionStates.set(rest)
}
// ---------------------------------------------------------------------------
// Session tiles.
// ---------------------------------------------------------------------------
@ -135,9 +141,17 @@ const profileKey = () => normalizeProfileKey($activeGatewayProfile.get())
// Runtime ids are process-scoped — never trust a persisted one, so the live
// atom hydrates from the stored (runtime-less) tiles for the active profile.
export const $sessionTiles = atom<SessionTile[]>([...(tilesByProfile[profileKey()] ?? [])])
// A secondary window (single-chat pop-out) shows ONLY its routed session — no
// tiles, and no repopulation on a profile switch.
export const $sessionTiles = atom<SessionTile[]>(isSecondaryWindow() ? [] : [...(tilesByProfile[profileKey()] ?? [])])
function persistTiles() {
// Shares the origin's storage; a secondary window holds no tiles, so a write
// back would only wipe the primary's set.
if (isSecondaryWindow()) {
return
}
writeJson(TILES_KEY, Object.keys(tilesByProfile).length === 0 ? null : tilesByProfile)
}
@ -156,10 +170,13 @@ function saveTiles(tiles: SessionTile[]) {
// Profile switch: surface the new profile's tiles with runtime ids cleared so
// they re-resume against the now-current gateway. (Fires immediately on
// subscribe; harmless — the init value already matches.)
$activeGatewayProfile.subscribe(() => {
$sessionTiles.set([...(tilesByProfile[profileKey()] ?? [])])
})
// subscribe; harmless — the init value already matches.) A secondary window
// never carries tiles, so it stays out of this entirely.
if (!isSecondaryWindow()) {
$activeGatewayProfile.subscribe(() => {
$sessionTiles.set([...(tilesByProfile[profileKey()] ?? [])])
})
}
export function patchSessionTile(storedSessionId: string, patch: Partial<SessionTile>) {
saveTiles($sessionTiles.get().map(t => (t.storedSessionId === storedSessionId ? { ...t, ...patch } : t)))
@ -213,12 +230,18 @@ export function sessionTileDelegate(): SessionTileDelegate | null {
return delegate
}
/** Open (or front) a tile for a stored session, docked on `dir` (default
* right; `center` = stack into the anchor's zone, `before` = strip slot).
* Idempotent an already-open tile keeps its original placement. The
* session LOADED IN MAIN never opens as a tile (same transcript twice,
* fighting over one runtime silly). */
export function openSessionTile(storedSessionId: string, dir: TileDock = 'right', anchor?: string, before?: null | string) {
/** Open a tile for a stored session, or MOVE an existing one to the new dock
* (`dir`; `center` = stack into the anchor's zone, `before` = strip slot). The
* move path is what lets a tile's own TAB be dragged like a sidebar row drop
* it on a zone/edge/strip and the tile goes there (drop-on-a-composer links
* instead, handled by the drag resolver). The session LOADED IN MAIN never
* opens as a tile (same transcript twice, fighting one runtime silly). */
export function openSessionTile(
storedSessionId: string,
dir: TileDock = 'right',
anchor?: string,
before?: null | string
) {
const tiles = $sessionTiles.get()
if (storedSessionId === $selectedStoredSessionId.get()) {
@ -227,7 +250,49 @@ export function openSessionTile(storedSessionId: string, dir: TileDock = 'right'
if (!tiles.some(t => t.storedSessionId === storedSessionId)) {
saveTiles([...tiles, { anchor, before, dir, storedSessionId }])
return
}
// Already open: relocate the existing pane to the drop target (pane-mirror
// only docks on first adoption, so a re-drag must move the tree pane itself).
const tree = $layoutTree.get()
const target = tree ? findGroupOfPane(tree, anchor ?? 'workspace')?.id : null
if (target) {
moveTreePane(`${TILE_PANE_PREFIX}${storedSessionId}`, { before: before ?? null, groupId: target, pos: dir })
}
}
/** If a session is already ON SCREEN an open tile OR the one loaded in main
* front its tab (and focus its zone) and return true. A sidebar click on an
* already-open chat JUMPS to its tab instead of reloading it; `false` means the
* caller must load it into main. Covers the two dead clicks: an open tile, and
* the main session while focus sits on a tile (route unchanged no reload). */
export function focusOpenSession(storedSessionId: string): boolean {
if ($sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) {
const paneId = `${TILE_PANE_PREFIX}${storedSessionId}`
revealTreePane(paneId) // un-dismiss + adopt + front in its group
const tree = $layoutTree.get()
const group = tree ? findGroupOfPane(tree, paneId) : null
if (group) {
noteActiveTreeGroup(group.id)
}
return true
}
// Already the main session: front the workspace tab and drop tile focus so
// the readouts + sidebar highlight come home (a no-op when main is focused).
if (storedSessionId === $selectedStoredSessionId.get()) {
revealTreePane('workspace')
noteActiveTreeGroup(null)
return true
}
return false
}
// Closed-tab stack for ⌘⇧T reopen (in-memory) — keyed PER PROFILE like the
@ -323,8 +388,13 @@ export const $focusedSessionState = computed([$focusedRuntimeId, $sessionStates]
// A PRIMARY navigation (sidebar resume, route change, new chat) moves focus
// home to the workspace — a previously-clicked tile must not keep owning the
// titlebar/statusbar readouts for a session switch it had no part in.
$selectedStoredSessionId.listen(() => noteActiveTreeGroup(null))
// titlebar/statusbar readouts for a session switch it had no part in. It also
// FRONTS the workspace tab: the resumed chat loads in the workspace pane, so a
// zone parked on a tile tab must switch back or the click looks dead.
$selectedStoredSessionId.listen(() => {
noteActiveTreeGroup(null)
revealTreePane('workspace')
})
// Dev hook for automation (mirrors __HERMES_LAYOUT_TREE__).
if (import.meta.env.DEV && typeof window !== 'undefined') {