From 092a97ef7589db1f761bdb967f3f47a00207dbe9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Jul 2026 03:13:33 -0400 Subject: [PATCH] fix(desktop): session-tab drag, focus sync, and pop-out isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/app/chat/chat-drop-overlay.tsx | 27 ++-- .../src/app/chat/composer/inline-refs.ts | 9 +- .../src/app/chat/hooks/use-file-drop-zone.ts | 50 +++++- apps/desktop/src/app/chat/pane-mirror.ts | 21 ++- apps/desktop/src/app/chat/session-drag.ts | 49 ++++-- apps/desktop/src/app/chat/session-tile.tsx | 16 ++ apps/desktop/src/app/chat/sidebar/index.tsx | 85 +++++------ apps/desktop/src/app/contrib/controller.tsx | 43 +++++- apps/desktop/src/app/contrib/wiring.tsx | 9 +- .../src/components/pane-shell/tree/model.ts | 16 +- .../pane-shell/tree/renderer/drag-session.ts | 43 ++---- .../pane-shell/tree/renderer/track-model.ts | 8 + .../pane-shell/tree/renderer/tree-group.tsx | 142 +++++++++--------- .../src/components/pane-shell/tree/store.ts | 52 ++++++- .../src/components/ui/drop-affordance.tsx | 24 +-- .../src/components/ui/pane-tab.test.tsx | 92 ++++++++++++ apps/desktop/src/components/ui/pane-tab.tsx | 42 +++++- apps/desktop/src/i18n/en.ts | 13 +- apps/desktop/src/i18n/ja.ts | 10 +- apps/desktop/src/i18n/types.ts | 7 +- apps/desktop/src/i18n/zh-hant.ts | 7 +- apps/desktop/src/i18n/zh.ts | 10 +- apps/desktop/src/lib/drag-ghost.ts | 43 ++++++ apps/desktop/src/store/session-states.ts | 102 +++++++++++-- 24 files changed, 659 insertions(+), 261 deletions(-) create mode 100644 apps/desktop/src/components/ui/pane-tab.test.tsx create mode 100644 apps/desktop/src/lib/drag-ghost.ts diff --git a/apps/desktop/src/app/chat/chat-drop-overlay.tsx b/apps/desktop/src/app/chat/chat-drop-overlay.tsx index 9620af7a65b0..37d8172eb1e5 100644 --- a/apps/desktop/src/app/chat/chat-drop-overlay.tsx +++ b/apps/desktop/src/app/chat/chat-drop-overlay.tsx @@ -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(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 (
- - {label} - + {shown && ( + + {shown === 'session' ? t.composer.dropSession : t.composer.dropFiles} + + )}
) } diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts index b86230de19c3..5fd62f4cc94a 100644 --- a/apps/desktop/src/app/chat/composer/inline-refs.ts +++ b/apps/desktop/src/app/chat/composer/inline-refs.ts @@ -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:/` 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) { diff --git a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts index 0da2912768af..42e4554b992e 100644 --- a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts +++ b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts @@ -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(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() diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts index 2190fd72bf01..8e8a96836702 100644 --- a/apps/desktop/src/app/chat/pane-mirror.ts +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -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 { 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, + onTap: () => void, + double?: DoubleTapContext + ) => boolean /** Wired as the pane's closer (tab Close). */ close: (key: string) => void } @@ -62,9 +71,17 @@ export function paneMirror(cfg: PaneMirror): () => 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, 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) diff --git a/apps/desktop/src/app/chat/session-drag.ts b/apps/desktop/src/app/chat/session-drag.ts index 98cc8805d128..f99bfa47325d 100644 --- a/apps/desktop/src/app/chat/session-drag.ts +++ b/apps/desktop/src/app/chat/session-drag.ts @@ -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) { +export function startSessionDrag( + payload: SessionDragPayload, + e: ReactPointerEvent, + 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('[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 diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index f0085597e45d..04ddaf1cc630 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -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({ {tab} ), + // 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 }) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index b73434a77768..9a3fbe3a2748 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -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 = ( - { - // 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) - } + { + // 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" - > - - {s.nav[item.id] ?? item.label} - {isNewSession && ( - - )} - + onNavigate(item) + }} + tooltip={s.nav[item.id] ?? item.label} + type="button" + > + + {s.nav[item.id] ?? item.label} + {isNewSession && ( + + )} + ) // New session + route-backed pages can open in a split — diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 537ef8804bb9..c82ac082db4f 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -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 = () => // the loaded primary session; no menu on a fresh draft). const wrapWorkspaceTab = (tab: ReactElement) => {tab} +/** 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, 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 }, diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 60b70641d93c..ca7a043a9164 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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, diff --git a/apps/desktop/src/components/pane-shell/tree/model.ts b/apps/desktop/src/components/pane-shell/tree/model.ts index 8e037e927185..f1d09acdd4d3 100644 --- a/apps/desktop/src/components/pane-shell/tree/model.ts +++ b/apps/desktop/src/components/pane-shell/tree/model.ts @@ -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) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts index d41f5b2e8b3c..d37853f8e169 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts @@ -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, 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, 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, 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, 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, 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) { diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts index d6e9122082fc..d5e5b9afe43b 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts @@ -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, 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. */ diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 8f146b3827ec..8988e374a751 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -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. */} - + ) } @@ -565,26 +596,16 @@ const REGION: Record = { top: { bottom: '50%', left: REGION_PAD, right: REGION_PAD, top: REGION_PAD } } -const EDGE_ARROW: Record, 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 (
- {pill && {pill.label}} -
+ /> ) } diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index 676d5818b902..9d120f6c1334 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -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(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(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) { diff --git a/apps/desktop/src/components/ui/drop-affordance.tsx b/apps/desktop/src/components/ui/drop-affordance.tsx index 937233f86a34..de34aac4924d 100644 --- a/apps/desktop/src/components/ui/drop-affordance.tsx +++ b/apps/desktop/src/components/ui/drop-affordance.tsx @@ -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 ( -
- - {children} -
- ) -} diff --git a/apps/desktop/src/components/ui/pane-tab.test.tsx b/apps/desktop/src/components/ui/pane-tab.test.tsx new file mode 100644 index 000000000000..c36f03fb8ada --- /dev/null +++ b/apps/desktop/src/components/ui/pane-tab.test.tsx @@ -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( + + tab + + ) + + 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( + + tab + + ) + + 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( + + tab + + ) + + 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( + + + tab + + + ) + + 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( + + + tab + + + ) + + 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( + + tab + + ) + + fireEvent.pointerDown(screen.getByText('tab'), { button: 0, metaKey: true }) + expect(onPointerDown).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx index 92f4201c6964..da8baae2aec9 100644 --- a/apps/desktop/src/components/ui/pane-tab.tsx +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -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(function P onClose, onAuxClick, onMouseDown, + onPointerDown, + onClickCapture, vertical = false, side = 'left', children, @@ -84,6 +91,17 @@ export const PaneTab = React.forwardRef(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(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} > diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 8fe07c1297dc..078364e7ec01 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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}`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 6c1211d734fa..24b09aa1a2ef 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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}`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 8e340197b946..8c1ac74b5cb6 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 09d29f8e0c9f..1897d225bc38 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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}`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 324dba2f4fd5..aefcaf47ef77 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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}`, diff --git a/apps/desktop/src/lib/drag-ghost.ts b/apps/desktop/src/lib/drag-ghost.ts new file mode 100644 index 000000000000..f07a9835c7dd --- /dev/null +++ b/apps/desktop/src/lib/drag-ghost.ts @@ -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() + } + } +} diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index be5f2304819f..43e5e21978f8 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -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([...(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(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) { 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') {