mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
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:
parent
db488df1c3
commit
092a97ef75
24 changed files with 659 additions and 261 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
92
apps/desktop/src/components/ui/pane-tab.test.tsx
Normal file
92
apps/desktop/src/components/ui/pane-tab.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
43
apps/desktop/src/lib/drag-ghost.ts
Normal file
43
apps/desktop/src/lib/drag-ghost.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue