From 99ff67eb038431265800113a360e32a1e4ed952f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:28:21 -0400 Subject: [PATCH 01/28] =?UTF-8?q?feat(desktop):=20plugin=20SDK=20surface?= =?UTF-8?q?=20=E2=80=94=20rest=20door,=20socket,=20react-query,=20UI=20kit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/sdk/index.ts | 228 ++++++++++++++++++++++++++++++++ apps/desktop/src/sdk/runtime.ts | 54 ++++++++ 2 files changed, 282 insertions(+) create mode 100644 apps/desktop/src/sdk/index.ts create mode 100644 apps/desktop/src/sdk/runtime.ts diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts new file mode 100644 index 000000000000..c82ce4a05384 --- /dev/null +++ b/apps/desktop/src/sdk/index.ts @@ -0,0 +1,228 @@ +/** + * @hermes/plugin-sdk — THE plugin language. The vscode-module model: plugin + * authors import exactly one module and get everything — they never touch + * `@/…` internals (lint-fenced) and never need codebase access. + * + * Two delivery modes, one surface: + * - bundled (`src/plugins//`): the import resolves here via alias; + * - runtime-fetched (plugin host, next phase): the loader injects this same + * object as `window.__HERMES_PLUGIN_SDK__` and maps the import to it, so a + * published plugin builds against the types with the SDK marked external. + * + * Capability tiers (WoW-style): + * - `host.state.*` — READONLY app state (nanostore atoms; `.get()` or + * subscribe; `useValue` in React). + * - `host.*` actions — curated, safe verbs (toast, haptic). + * - `host.request` — the gateway JSON-RPC door; the plugin's real power, + * and the future seam for per-plugin capability grants. + * - `ui.*` — the design language, so plugin UI looks native by default. + */ + +import { atom, type ReadableAtom } from 'nanostores' + +import { $narrowViewport } from '@/components/pane-shell/tree/store' +import { onGatewayEvent } from '@/contrib/events' +import { getLogs, getStatus } from '@/hermes' +import { $gateway } from '@/store/gateway' +import { notify, notifyError } from '@/store/notifications' +import { $activeGatewayProfile } from '@/store/profile' +import { $activeSessionId, $currentCwd, $currentModel, $gatewayState } from '@/store/session' +import { runGatewayRestart } from '@/store/system-actions' + +// -- state: readonly views over the app's live atoms ------------------------- + +const readonlyAtom = (atomLike: ReadableAtom): ReadableAtom => atomLike + +/** Window geometry + the app's responsive posture, one readonly rect. */ +export interface ViewportRect { + width: number + height: number + /** Below the app's sidebar-collapse breakpoint (rails become overlays). */ + narrow: boolean +} + +const readViewport = (): ViewportRect => ({ + width: typeof window === 'undefined' ? 0 : window.innerWidth, + height: typeof window === 'undefined' ? 0 : window.innerHeight, + narrow: $narrowViewport.get() +}) + +const $viewport = atom(readViewport()) + +if (typeof window !== 'undefined') { + const refresh = () => $viewport.set(readViewport()) + window.addEventListener('resize', refresh) + $narrowViewport.listen(refresh) +} + +export const host = { + state: { + /** Runtime id of the active chat session (null on a fresh draft). */ + activeSessionId: readonlyAtom($activeSessionId), + /** Active workspace cwd ('' when detached). */ + cwd: readonlyAtom($currentCwd), + /** Gateway socket state: 'idle' | 'connecting' | 'open' | …. */ + gateway: readonlyAtom($gatewayState), + /** Current main model slug. */ + model: readonlyAtom($currentModel), + /** Profile the live gateway is routed to. */ + profile: readonlyAtom($activeGatewayProfile), + /** Window geometry ({ width, height, narrow }). */ + viewport: readonlyAtom($viewport) + }, + + /** Toast into the app's notification stack. */ + notify, + notifyError, + + // NOTE: every host door is async-safe — wrapped so a sync throw from an + // internal helper (e.g. no desktop bridge in a plain browser) becomes a + // rejection a plugin's .catch() sees, never an error-boundary crash. + + /** Tail an app log file (`agent` / `errors` / `gateway` / `gui` / …). */ + logs: async (...args: Parameters) => getLogs(...args), + + /** Navigate the app router (hash routes, e.g. '/command-center?section=system'). */ + navigate: (path: string) => { + window.location.hash = path.startsWith('#') ? path : `#${path}` + }, + + /** HEAR the gateway stream (message deltas, session lifecycle, tool + * activity, …) by event type — `'*'` for everything. Returns a disposer. + * Listeners are isolated; a throw can't affect app dispatch. */ + onEvent: onGatewayEvent, + + /** Restart the backend gateway (progress surfaces in the core statusbar). */ + restartGateway: async () => runGatewayRestart(), + + /** One-shot system status snapshot (platforms, versions, …). */ + status: async () => getStatus(), + + /** Gateway JSON-RPC — sessions, config, skills, cron, kanban, everything + * the app itself uses. Lazy: resolves the LIVE socket per call. */ + request: async (method: string, params: Record = {}): Promise => { + const gateway = $gateway.get() + + if (!gateway) { + throw new Error('Hermes gateway unavailable') + } + + return gateway.request(method, params) + } +} + +// -- react bridge ------------------------------------------------------------- + +// Every contribution surface, plugin-reachable: register keybinds, palette +// commands, routes, themes, panes, composer extensions, and bar items with +// the same area ids + payload types core uses. +export { COMPOSER_AREAS, type ComposerAttachmentProvider, type ComposerMiddleware } from '@/app/chat/composer/contrib' + +// -- ui: the design language -------------------------------------------------- + +export { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib' +export { type RouteContribution, ROUTES_AREA, SIDEBAR_NAV_AREA, type SidebarNavContribution } from '@/app/routes' +export type { StatusbarItem } from '@/app/shell/statusbar-controls' + +export type { TitlebarTool } from '@/app/shell/titlebar-controls' +export { StatusDot, type StatusTone } from '@/components/status-dot' +export { Badge } from '@/components/ui/badge' +export { Button } from '@/components/ui/button' +export { Checkbox } from '@/components/ui/checkbox' +export { Codicon } from '@/components/ui/codicon' +export { ConfirmDialog } from '@/components/ui/confirm-dialog' +export { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +export { CopyButton } from '@/components/ui/copy-button' +export { DecodeText } from '@/components/ui/decode-text' +export { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +export { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +export { EmptyState } from '@/components/ui/empty-state' +export { ErrorState } from '@/components/ui/error-state' +export { GlyphSpinner } from '@/components/ui/glyph-spinner' +export { Input } from '@/components/ui/input' +export { Kbd, KbdGroup } from '@/components/ui/kbd' +/** The app's canonical loader (animated curves; `lemniscate-bloom` for long + * page loads) — the same one every core page uses. */ +export { Loader, type LoaderType } from '@/components/ui/loader' +export { LogView } from '@/components/ui/log-view' +export { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +export { ScrollArea } from '@/components/ui/scroll-area' +export { SearchField } from '@/components/ui/search-field' +export { SegmentedControl } from '@/components/ui/segmented-control' +export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +export { Separator } from '@/components/ui/separator' +export { Skeleton } from '@/components/ui/skeleton' +export { Switch } from '@/components/ui/switch' +export { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +export { Textarea } from '@/components/ui/textarea' +export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +export type { GatewayEventListener } from '@/contrib/events' +export type { HermesPlugin, PluginContext, PluginContribution, PluginRestOptions, PluginStorage } from '@/contrib/plugin' + +// -- contracts ---------------------------------------------------------------- + +/** Mount-scoped contribution: while the rendering component is mounted, its + * children render in the target area's slot; unmount disposes it. Use for + * page-owned chrome (a page's titlebar control leaves with the page) — + * `ctx.register` stays the door for permanent contributions. Namespace the + * id with your plugin slug (`kanban:board-switcher`). */ +export { Contribute, type ContributeProps } from '@/contrib/react/contribute' +export type { Contribution } from '@/contrib/types' +/** Localized copy — plugins reuse the app's strings (and stay translatable). */ +export { useI18n } from '@/i18n' +export { triggerHaptic as haptic } from '@/lib/haptics' +/** The app's lucide icon set (RefreshCw, LayoutDashboard, Activity, …). */ +export * as icons from '@/lib/icons' +export { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' +/** The app's deterministic identity color for a name (profiles, assignees, + * authors) + its translucent tag fill — so plugin-rendered identities read + * the same hue as everywhere else. */ +export { profileColor, profileColorSoft } from '@/lib/profile-color' +/** The shared client itself, for invalidation OUTSIDE React (e.g. a + * `ctx.socket` frame invalidating a query). Inside components keep using + * `useQueryClient`. */ +export { queryClient } from '@/lib/query-client' + +export const PANES_AREA = 'panes' +export const STATUSBAR_AREAS = { left: 'statusBar.left', right: 'statusBar.right' } as const +export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left', right: 'titleBar.right' } as const + +/** The app's own gateway-readiness evaluation (setup.status + + * setup.runtime_check, reconciled) — pass `host.request`. Don't hand-roll + * readiness from raw RPC shapes. */ +export { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' +/** Canonical time formatting — every timestamp/age string in the app comes + * from these (localized `Intl` under the hood). Don't hand-roll "Xm ago". */ +export { coarseElapsed, fmtDateTime, fmtDayTime, relativeTime } from '@/lib/time' +export { cn } from '@/lib/utils' +export { THEMES_AREA } from '@/themes/user-themes' +export type { RpcEvent, StatusResponse } from '@/types/hermes' +/** Subscribe a component to a `host.state` atom. */ +export { useStore as useValue } from '@nanostores/react' +/** The app's data-fetching layer. Plugins share the ONE QueryClient mounted at + * the app root, so their queries cache, dedupe, poll (`refetchInterval`), and + * invalidate exactly like core screens — no hand-rolled atoms or polls. */ +export { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +/** Plugin-local reactive state (share between a trigger and its panel, poll + * loops, cross-component signals) — the same primitive `host.state` uses. */ +export { atom, computed } from 'nanostores' diff --git a/apps/desktop/src/sdk/runtime.ts b/apps/desktop/src/sdk/runtime.ts new file mode 100644 index 000000000000..449b9060b05d --- /dev/null +++ b/apps/desktop/src/sdk/runtime.ts @@ -0,0 +1,54 @@ +/** + * Runtime SDK injection — the other half of the vscode-module model. Bundled + * plugins resolve `@hermes/plugin-sdk` through the vite alias; RUNTIME-loaded + * plugins (disk / fetched) import the same specifier and get the same object: + * the loader rewrites bare specifiers to shim modules that re-export the + * live namespaces installed here. React ships as the app's singletons — + * a second React instance would break hooks. + */ + +import * as React from 'react' +import * as jsxDevRuntime from 'react/jsx-dev-runtime' +import * as jsxRuntime from 'react/jsx-runtime' + +import * as sdk from './index' + +const GLOBALS = { + __HERMES_PLUGIN_SDK__: sdk, + __HERMES_REACT__: React, + __HERMES_REACT_JSX__: jsxRuntime, + __HERMES_REACT_JSX_DEV__: jsxDevRuntime +} as const + +export function installPluginSdk(): void { + Object.assign(globalThis, GLOBALS) +} + +/** Build a shim ESM blob that re-exports a global namespace's live members. + * Export names come from the namespace itself, so the list can't drift. */ +function shimUrl(globalKey: keyof typeof GLOBALS): string { + const names = Object.keys(GLOBALS[globalKey]).filter(name => name !== 'default' && /^[A-Za-z_$][\w$]*$/.test(name)) + + const source = + `const m = globalThis.${globalKey};\n` + + `export default m.default ?? m;\n` + + // Guard the destructuring: `export const { } = m` is a syntax error, so + // only emit it when the namespace actually has named exports. + (names.length ? `export const { ${names.join(', ')} } = m;\n` : '') + + return URL.createObjectURL(new Blob([source], { type: 'text/javascript' })) +} + +let cached: Record | null = null + +/** Specifier -> shim URL map for the runtime loader (longest keys first). */ +export function sdkImportMap(): Record { + cached ??= { + '@hermes/plugin-sdk': shimUrl('__HERMES_PLUGIN_SDK__'), + 'react/jsx-dev-runtime': shimUrl('__HERMES_REACT_JSX_DEV__'), + 'react/jsx-runtime': shimUrl('__HERMES_REACT_JSX__'), + react: shimUrl('__HERMES_REACT__') + } + + return cached +} From aefb36299a6f6ff5eb3586ca92c9395c6debbddb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:28:21 -0400 Subject: [PATCH 02/28] =?UTF-8?q?feat(desktop):=20contribution=20registry?= =?UTF-8?q?=20=E2=80=94=20namespaced=20areas,=20keybinds,=20palette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/contrib/events.ts | 45 +++ apps/desktop/src/contrib/index.ts | 6 + apps/desktop/src/contrib/plugin.ts | 111 ++++++ apps/desktop/src/contrib/plugins-store.ts | 124 +++++++ apps/desktop/src/contrib/plugins.ts | 74 ++++ apps/desktop/src/contrib/react/boundary.tsx | 59 ++++ apps/desktop/src/contrib/react/contribute.tsx | 51 +++ apps/desktop/src/contrib/react/slot.tsx | 26 ++ .../src/contrib/react/use-contributions.ts | 14 + apps/desktop/src/contrib/registry.ts | 162 +++++++++ apps/desktop/src/contrib/runtime-loader.ts | 332 ++++++++++++++++++ apps/desktop/src/contrib/types.ts | 43 +++ 12 files changed, 1047 insertions(+) create mode 100644 apps/desktop/src/contrib/events.ts create mode 100644 apps/desktop/src/contrib/index.ts create mode 100644 apps/desktop/src/contrib/plugin.ts create mode 100644 apps/desktop/src/contrib/plugins-store.ts create mode 100644 apps/desktop/src/contrib/plugins.ts create mode 100644 apps/desktop/src/contrib/react/boundary.tsx create mode 100644 apps/desktop/src/contrib/react/contribute.tsx create mode 100644 apps/desktop/src/contrib/react/slot.tsx create mode 100644 apps/desktop/src/contrib/react/use-contributions.ts create mode 100644 apps/desktop/src/contrib/registry.ts create mode 100644 apps/desktop/src/contrib/runtime-loader.ts create mode 100644 apps/desktop/src/contrib/types.ts diff --git a/apps/desktop/src/contrib/events.ts b/apps/desktop/src/contrib/events.ts new file mode 100644 index 000000000000..e76ddff7ff95 --- /dev/null +++ b/apps/desktop/src/contrib/events.ts @@ -0,0 +1,45 @@ +/** + * The plugin-facing gateway event tap. The wiring fans every inbound gateway + * event through here BEFORE its own dispatch; plugins subscribe by type (or + * `'*'`) via `host.onEvent`. Listeners are isolated — a throwing plugin can + * never break the app's event handling — and emit is zero-cost when nobody + * listens. + */ + +import type { RpcEvent } from '@/types/hermes' + +export type GatewayEventListener = (event: RpcEvent) => void + +const listeners = new Map>() + +/** Subscribe to gateway events by `type` (`'*'` = everything). Returns a disposer. */ +export function onGatewayEvent(type: string, listener: GatewayEventListener): () => void { + const set = listeners.get(type) ?? new Set() + set.add(listener) + listeners.set(type, set) + + return () => { + set.delete(listener) + + if (set.size === 0) { + listeners.delete(type) + } + } +} + +/** Fan an event to subscribers (wiring-side; call before app dispatch). */ +export function emitGatewayEvent(event: RpcEvent): void { + if (listeners.size === 0) { + return + } + + for (const type of [event.type, '*']) { + for (const listener of listeners.get(type) ?? []) { + try { + listener(event) + } catch (error) { + console.error('[plugins] gateway event listener failed', error) + } + } + } +} diff --git a/apps/desktop/src/contrib/index.ts b/apps/desktop/src/contrib/index.ts new file mode 100644 index 000000000000..a5faa2f232b5 --- /dev/null +++ b/apps/desktop/src/contrib/index.ts @@ -0,0 +1,6 @@ +export { Slot } from './react/slot' +export type { SlotProps } from './react/slot' + +export { useContributions } from './react/use-contributions' +export { registry } from './registry' +export type { Contribution, ContributionSource } from './types' diff --git a/apps/desktop/src/contrib/plugin.ts b/apps/desktop/src/contrib/plugin.ts new file mode 100644 index 000000000000..c8a34d59df78 --- /dev/null +++ b/apps/desktop/src/contrib/plugin.ts @@ -0,0 +1,111 @@ +/** + * The plugin authoring contract. A plugin is a file that default-exports a + * `HermesPlugin`; it never touches the registry directly — it receives a + * scoped `PluginContext` whose `register` auto-tags provenance + * (`source: 'plugin:'`) and namespaces the contribution id + * (`:`), so authors write plain contributions and collisions + * between plugins are impossible. + * + * Bundled plugins live in `src/plugins//plugin.tsx` and are discovered + * by `discoverBundledPlugins()` (contrib/plugins.ts) — no import, no registry + * edit. Runtime-fetched third-party plugins will drive the SAME contract + * through the plugin host loader (next phase); this is that seam. + */ + +import { pluginRest, type PluginRestOptions, pluginSocket } from '@/hermes' +import { readKey, writeKey } from '@/lib/storage' + +import { registry } from './registry' +import type { Contribution } from './types' + +export type { PluginRestOptions } from '@/hermes' + +/** A contribution as a plugin author writes it — provenance + id scoping are + * the host's job, so those fields are off-limits here. */ +export type PluginContribution = Omit & { id: string } + +/** Namespaced JSON persistence (the VS Code `globalState` analog). Keys live + * under `hermes.plugin..` — plugins can't read or clobber each other. */ +export interface PluginStorage { + get(key: string, fallback: T): T + set(key: string, value: unknown): void + remove(key: string): void +} + +export interface PluginContext { + /** The resolved plugin source tag, e.g. `'plugin:cost-meter'`. */ + readonly source: string + /** Register one contribution (id namespaced, source stamped). */ + register: (c: PluginContribution) => () => void + /** Register several at once; the returned disposer removes all of them. */ + registerMany: (cs: PluginContribution[]) => () => void + /** REST to this plugin's own backend namespace (`/api/plugins/`); `path` + * is relative ('/board'). The sanctioned door for a plugin that ships a + * `plugin_api.py` — profile-aware, namespace-scoped by construction. Use + * `host.request` for gateway JSON-RPC. */ + rest: (path: string, opts?: PluginRestOptions) => Promise + /** Live twin of `rest`: a WebSocket to this plugin's own namespace + * ('/events'), JSON frames to `onMessage`, auto-reconnect, disposer + * returned. Resolves to a no-op on OAuth remotes — treat it as an + * accelerator over your polling, never a replacement. */ + socket: (path: string, onMessage: (data: unknown) => void) => () => void + /** Plugin-scoped persistence. */ + storage: PluginStorage +} + +export interface HermesPlugin { + /** Stable slug — becomes the `plugin:` source and the id namespace. */ + id: string + /** Human name for settings / about UI. */ + name?: string + /** Registers on load when the user hasn't chosen (default true). Set false + * for opt-in plugins: they inventory in Settings ▸ Plugins, off until the + * user flips the switch. */ + defaultEnabled?: boolean + /** Called once at load; wire contributions through `ctx`. */ + register: (ctx: PluginContext) => void +} + +function createPluginStorage(pluginId: string): PluginStorage { + const scoped = (key: string) => `hermes.plugin.${pluginId}.${key}` + + return { + get(key, fallback) { + const raw = readKey(scoped(key)) + + if (raw === null) { + return fallback + } + + try { + return JSON.parse(raw) + } catch { + return fallback + } + }, + set: (key, value) => writeKey(scoped(key), JSON.stringify(value)), + remove: key => writeKey(scoped(key), null) + } +} + +/** Build the scoped context handed to a plugin's `register`. `onDispose` + * receives every registration's disposer (the loader's unload/reload hook). */ +export function createPluginContext(pluginId: string, onDispose?: (dispose: () => void) => void): PluginContext { + const source = `plugin:${pluginId}` + const scope = (c: PluginContribution): Contribution => ({ ...c, id: `${pluginId}:${c.id}`, source }) + + const track = (dispose: () => void) => { + onDispose?.(dispose) + + return dispose + } + + return { + source, + register: c => track(registry.register(scope(c))), + registerMany: cs => track(registry.registerMany(cs.map(scope))), + rest: (path: string, opts?: PluginRestOptions) => pluginRest(pluginId, path, opts), + socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)), + storage: createPluginStorage(pluginId) + } +} diff --git a/apps/desktop/src/contrib/plugins-store.ts b/apps/desktop/src/contrib/plugins-store.ts new file mode 100644 index 000000000000..9e90a4cc0be6 --- /dev/null +++ b/apps/desktop/src/contrib/plugins-store.ts @@ -0,0 +1,124 @@ +/** + * PLUGIN INVENTORY — the reactive record of every desktop plugin the app + * knows about (bundled `src/plugins/*`, the in-repo runtime example, the + * `/desktop-plugins/*` disk door — incl. agent-written ones), + * plus the persisted DISABLED set. The settings "Plugins" page renders this; + * the loaders publish into it and consult the disabled set before + * registering. Enable/disable is live: each record carries the loader's own + * activate/deactivate handles, so toggling never needs an app reload. + */ + +import { atom } from 'nanostores' + +export type PluginKind = 'bundled' | 'disk' | 'runtime' +export type PluginStatus = 'disabled' | 'error' | 'loaded' + +export interface PluginRecord { + id: string + name: string + kind: PluginKind + status: PluginStatus + /** Load/registration failure message (status 'error'). */ + error?: string + /** Absolute plugin.js path (disk plugins) — powers "Reveal in Finder". */ + file?: string +} + +// Explicit user enable/disable choices, id -> boolean. ABSENCE means "no +// choice" — the plugin falls back to its own `defaultEnabled`. This is what +// lets an opt-in plugin ship off-by-default: absence ≠ enabled anymore. +const DECISIONS_KEY = 'hermes.desktop.pluginDecisions.v2' +const LEGACY_DISABLED_KEY = 'hermes.desktop.disabledPlugins.v1' + +function loadDecisions(): Record { + try { + const raw = window.localStorage.getItem(DECISIONS_KEY) + + if (raw) { + return JSON.parse(raw) as Record + } + + // Migrate the v1 disabled-set: each disabled id becomes an explicit `false`. + const legacy = window.localStorage.getItem(LEGACY_DISABLED_KEY) + + if (legacy) { + return Object.fromEntries((JSON.parse(legacy) as string[]).map(id => [id, false])) + } + } catch { + // Nonfatal — fall through to no choices. + } + + return {} +} + +export const $pluginDecisions = atom>(loadDecisions()) + +/** Whether a plugin should register: the user's explicit choice if any, else + * the plugin's own default (true for ordinary plugins, false for opt-in). */ +export function pluginActive(id: string, defaultEnabled = true): boolean { + const decisions = $pluginDecisions.get() + + return id in decisions ? decisions[id] : defaultEnabled +} + +function saveDecisions(next: Record) { + $pluginDecisions.set(next) + + try { + window.localStorage.setItem(DECISIONS_KEY, JSON.stringify(next)) + } catch { + // Nonfatal. + } +} + +export const $pluginRecords = atom>({}) + +/** Loader-owned lifecycle controls for a plugin (activate/deactivate). */ +interface PluginHandle { + activate: () => Promise | void + deactivate: () => void +} + +/** Loader-owned lifecycle handles, keyed by plugin id. */ +const handles = new Map() + +/** Publish/refresh a plugin's record + its activate/deactivate handles. */ +export function publishPlugin(record: PluginRecord, handle?: PluginHandle): void { + $pluginRecords.set({ ...$pluginRecords.get(), [record.id]: record }) + + if (handle) { + handles.set(record.id, handle) + } +} + +export function patchPlugin(id: string, patch: Partial): void { + const current = $pluginRecords.get()[id] + + if (current) { + $pluginRecords.set({ ...$pluginRecords.get(), [id]: { ...current, ...patch } }) + } +} + +export function dropPlugin(id: string): void { + const { [id]: _dropped, ...rest } = $pluginRecords.get() + $pluginRecords.set(rest) + handles.delete(id) +} + +/** Live toggle: deactivate + remember, or forget + reactivate. */ +export async function setPluginEnabled(id: string, enabled: boolean): Promise { + saveDecisions({ ...$pluginDecisions.get(), [id]: enabled }) + + const handle = handles.get(id) + + if (!handle) { + return + } + + if (enabled) { + await handle.activate() + } else { + handle.deactivate() + patchPlugin(id, { status: 'disabled' }) + } +} diff --git a/apps/desktop/src/contrib/plugins.ts b/apps/desktop/src/contrib/plugins.ts new file mode 100644 index 000000000000..d1cab8a32fba --- /dev/null +++ b/apps/desktop/src/contrib/plugins.ts @@ -0,0 +1,74 @@ +/** + * Plugin discovery — both delivery modes: + * + * - BUNDLED: every `src/plugins//plugin.{ts,tsx}` default-exporting a + * `HermesPlugin` registers automatically (vite glob — drop a folder in). + * None ship in-tree today; reference/demo plugins live in the companion + * `hermes-example-plugins` repo. + * - RUNTIME: the on-disk door (`/desktop-plugins//plugin.js`) + * — the agent's/user's door, watched + hot-reloaded by the runtime loader. + */ + +import { createPluginContext, type HermesPlugin } from './plugin' +import { pluginActive, publishPlugin } from './plugins-store' +import { watchRuntimePlugins } from './runtime-loader' + +const modules = import.meta.glob<{ default: HermesPlugin }>('../plugins/*/plugin.{ts,tsx}', { eager: true }) + +// One-shot init guard. Contributions themselves register by id (re-registering +// is idempotent), but the disk-door watcher setup below (watchRuntimePlugins) +// must NOT run twice — so discovery is guarded to a single pass, not re-run on +// HMR. +let loaded = false + +export function discoverBundledPlugins(): void { + if (loaded) { + return + } + + loaded = true + + for (const [path, mod] of Object.entries(modules)) { + const plugin = mod.default + + if (!plugin?.id || typeof plugin.register !== 'function') { + console.warn(`[plugins] ${path} has no valid default HermesPlugin export — skipped`) + + continue + } + + // Same inventory + live-toggle contract as runtime plugins: each bundled + // plugin publishes a record with activate/deactivate handles, and a + // persisted disable survives boots by skipping registration here. + const record = { id: plugin.id, name: plugin.name ?? plugin.id, kind: 'bundled' as const } + let disposers: (() => void)[] = [] + + const activate = () => { + disposers.forEach(dispose => dispose()) + disposers = [] + + try { + plugin.register(createPluginContext(plugin.id, dispose => disposers.push(dispose))) + publishPlugin({ ...record, status: 'loaded' }) + } catch (error) { + console.error(`[plugins] ${plugin.id} failed to register`, error) + publishPlugin({ ...record, status: 'error', error: error instanceof Error ? error.message : String(error) }) + } + } + + const deactivate = () => { + disposers.forEach(dispose => dispose()) + disposers = [] + } + + publishPlugin({ ...record, status: 'disabled' }, { activate, deactivate }) + + if (pluginActive(plugin.id, plugin.defaultEnabled ?? true)) { + activate() + } + } + + // The SELF-MAINTAINING disk door (fs-watched hot reloads, slow folder + // reconciliation) — the runtime loader pipeline's real, shipping consumer. + watchRuntimePlugins() +} diff --git a/apps/desktop/src/contrib/react/boundary.tsx b/apps/desktop/src/contrib/react/boundary.tsx new file mode 100644 index 000000000000..570ba6c8f4c3 --- /dev/null +++ b/apps/desktop/src/contrib/react/boundary.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from 'react' + +import { ErrorBoundary } from '@/components/error-boundary' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { ErrorState } from '@/components/ui/error-state' +import { Tip } from '@/components/ui/tooltip' + +interface ContribBoundaryProps { + children: ReactNode + /** Contribution key, shown in the fallback + console tag. */ + id: string + /** `chip` = inline bar item (tiny fallback); `pane` = zone body. */ + variant?: 'chip' | 'pane' +} + +/** + * The blast wall between a contribution's `render()` and the app. Plugin + * code throwing during render (bad import, undefined component, logic bug) + * degrades to a small inline error in ITS slot — the surrounding bar/zone, + * other plugins, and the app keep working. Every surface that mounts + * contribution renders wraps them in this. + * + * The pane fallback uses the app's canonical `ErrorState` (same icon/title/body + * as the React boundary and dialog errors) so a crashed contribution reads like + * every other failure, not a raw stack dump. + */ +export function ContribBoundary({ children, id, variant = 'pane' }: ContribBoundaryProps) { + return ( + + variant === 'chip' ? ( + + + + ) : ( +
+ + + +
+ ) + } + label={`contrib:${id}`} + > + {children} +
+ ) +} diff --git a/apps/desktop/src/contrib/react/contribute.tsx b/apps/desktop/src/contrib/react/contribute.tsx new file mode 100644 index 000000000000..4526e3cdbfed --- /dev/null +++ b/apps/desktop/src/contrib/react/contribute.tsx @@ -0,0 +1,51 @@ +/** + * Mount-scoped contribution — the "reverse portal" companion to `Slot`. + * + * `ctx.register` is for PERMANENT contributions (registered at plugin load, + * alive for the plugin's lifetime). `` is for chrome that belongs + * to a mounted surface: while the owning component is mounted, `children` + * render inside the target area's Slot; on unmount the contribution disposes + * itself. No route sniffing, no `when()` polling — React's lifecycle IS the + * visibility contract (a page's titlebar control leaves with the page). + * + * Children stay LIVE: they're projected through a nanostore the registered + * render subscribes to, so caller state flows into the slot on every render + * without re-registering (which would churn every other slot). + */ + +import { useStore } from '@nanostores/react' +import { atom, type WritableAtom } from 'nanostores' +import { type ReactNode, useEffect, useRef } from 'react' + +import { registry } from '../registry' + +function ProjectedNode({ $node }: { $node: WritableAtom }) { + return <>{useStore($node)} +} + +export interface ContributeProps { + /** Target area id (e.g. `titleBar.center`). */ + area: string + /** Stable contribution id — namespace it (`kanban:board-switcher`). */ + id: string + order?: number + children: ReactNode +} + +export function Contribute({ area, children, id, order }: ContributeProps) { + const $node = useRef>(null!) + + $node.current ??= atom(null) + + // Push the latest children into the projection after every render. + useEffect(() => { + $node.current.set(children) + }) + + useEffect( + () => registry.register({ area, id, order, render: () => }), + [area, id, order] + ) + + return null +} diff --git a/apps/desktop/src/contrib/react/slot.tsx b/apps/desktop/src/contrib/react/slot.tsx new file mode 100644 index 000000000000..802cc0bf817b --- /dev/null +++ b/apps/desktop/src/contrib/react/slot.tsx @@ -0,0 +1,26 @@ +import { ContribBoundary } from './boundary' +import { useContributions } from './use-contributions' + +export interface SlotProps { + /** Area id whose contributions render inline, in order. */ + area: string +} + +/** Renders a bar area: ordered inline items `[...core, ...plugin]`. */ +export function Slot({ area }: SlotProps) { + const items = useContributions(area) + + if (items.length === 0) { + return null + } + + return ( + <> + {items.map(c => ( + + {c.render?.()} + + ))} + + ) +} diff --git a/apps/desktop/src/contrib/react/use-contributions.ts b/apps/desktop/src/contrib/react/use-contributions.ts new file mode 100644 index 000000000000..a7486ec1cb8f --- /dev/null +++ b/apps/desktop/src/contrib/react/use-contributions.ts @@ -0,0 +1,14 @@ +import { useCallback, useSyncExternalStore } from 'react' + +import { registry } from '../registry' +import type { Contribution } from '../types' + +/** Subscribe to the resolved contributions for an area. The subscription is + * scoped to `area`, so a slot re-renders only when ITS area mutates — a + * statusbar registration never re-renders a titlebar (or panes) slot. */ +export function useContributions(area: string): readonly Contribution[] { + const subscribe = useCallback((onChange: () => void) => registry.subscribeArea(area, onChange), [area]) + const getSnapshot = useCallback(() => registry.getArea(area), [area]) + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +} diff --git a/apps/desktop/src/contrib/registry.ts b/apps/desktop/src/contrib/registry.ts new file mode 100644 index 000000000000..1a8f1f2e1c6e --- /dev/null +++ b/apps/desktop/src/contrib/registry.ts @@ -0,0 +1,162 @@ +import { atom } from 'nanostores' + +import type { Contribution } from './types' + +/** Bumped on every registry mutation — the reactive hook for non-React + * consumers (nanostores `computed`s, memo deps) that read `registry.getArea` + * imperatively. React trees should keep using `useContributions`. */ +export const $registryVersion = atom(0) + +type Listener = () => void + +const EMPTY: readonly Contribution[] = Object.freeze([]) + +/** + * The one registry every area reads from. Keyed by namespaced area id so the + * same primitive resolves at any depth of the recursive Area scene graph + * (`statusBar.right`, `rightColumn.panel`, `capabilities.detail.actions`, ...). + * + * Snapshots are cached per area and only invalidated on mutation so the value + * is referentially stable for `useSyncExternalStore` (no render loops). + * + * Invalidation is AREA-SCOPED: mutating one area only clears that area's + * snapshot and only notifies subscribers of that area, so registering a + * statusbar item can never re-render a titlebar slot (or any other unrelated + * area). `registerMany`/`removeMany` collapse a batch into one notification + * per touched area. A global listener channel (`subscribe`) still fires on + * every mutation for engines that react to any change (pane adoption). + * + * Note: cache invalidation is mutation-driven, so a purely dynamic `when()` + * that flips without a register/remove won't re-resolve on its own -- fine for + * the current surfaces (no dynamic `when` is in use); revisit if we need + * reactive `when`. + */ +class ContributionRegistry { + private byArea = new Map() + private snapshot = new Map() + private areaListeners = new Map>() + private globalListeners = new Set() + + /** Register one contribution. Returns a disposer that removes it. */ + register = (c: Contribution): (() => void) => this.registerMany([c]) + + /** Register several at once. Returns a disposer that removes all of them. + * A batch touches each affected area exactly once — no per-item churn. */ + registerMany = (cs: Contribution[]): (() => void) => { + cs.forEach(c => this.put(c)) + this.invalidate(cs.map(c => c.area)) + + return () => this.removeMany(cs.map(c => ({ area: c.area, id: c.id }))) + } + + /** Resolved, sorted, filtered entries for an area. Stable ref until mutated. */ + getArea = (area: string): readonly Contribution[] => { + const cached = this.snapshot.get(area) + + if (cached) { + return cached + } + + const raw = this.byArea.get(area) + + const resolved: readonly Contribution[] = + !raw || raw.length === 0 + ? EMPTY + : raw + .filter(c => c.enabled !== false && (c.when ? c.when() : true)) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + + this.snapshot.set(area, resolved) + + return resolved + } + + /** Subscribe to ANY registry mutation (engines that react to every change, + * e.g. pane adoption). React trees should prefer `subscribeArea`. */ + subscribe = (fn: Listener): (() => void) => { + this.globalListeners.add(fn) + + return () => { + this.globalListeners.delete(fn) + } + } + + /** Subscribe to mutations of ONE area only — the leaf-level channel behind + * `useContributions`, so a slot re-renders solely for its own area. */ + subscribeArea = (area: string, fn: Listener): (() => void) => { + const set = this.areaListeners.get(area) ?? new Set() + set.add(fn) + this.areaListeners.set(area, set) + + return () => { + set.delete(fn) + + if (set.size === 0) { + this.areaListeners.delete(area) + } + } + } + + private removeMany(entries: { area: string; id: string }[]) { + const changed: string[] = [] + + for (const { area, id } of entries) { + if (this.take(area, id)) { + changed.push(area) + } + } + + if (changed.length) { + this.invalidate(changed) + } + } + + /** Insert/replace an entry without notifying (batchable). */ + private put(c: Contribution) { + const list = this.byArea.get(c.area) ?? [] + this.byArea.set(c.area, [...list.filter(e => e.id !== c.id), c]) + } + + /** Remove an entry without notifying (batchable). Returns whether it existed. */ + private take(area: string, id: string): boolean { + const list = this.byArea.get(area) + + if (!list) { + return false + } + + const next = list.filter(e => e.id !== id) + + if (next.length === list.length) { + return false + } + + if (next.length) { + this.byArea.set(area, next) + } else { + this.byArea.delete(area) + } + + return true + } + + private invalidate(areas: readonly string[]) { + const unique = new Set(areas) + + for (const area of unique) { + this.snapshot.delete(area) + + const listeners = this.areaListeners.get(area) + + if (listeners) { + listeners.forEach(l => l()) + } + } + + // Global listeners fire once per batch, regardless of how many areas moved. + this.globalListeners.forEach(l => l()) + $registryVersion.set($registryVersion.get() + 1) + } +} + +export const registry = new ContributionRegistry() diff --git a/apps/desktop/src/contrib/runtime-loader.ts b/apps/desktop/src/contrib/runtime-loader.ts new file mode 100644 index 000000000000..bf1fe2ca9dc4 --- /dev/null +++ b/apps/desktop/src/contrib/runtime-loader.ts @@ -0,0 +1,332 @@ +/** + * Runtime plugin loader — plugins as CODE, not registry edits, loaded after + * build time. The pipeline every non-bundled plugin takes: + * + * source (plain ESM js) -> [integrity check] -> bare-specifier rewrite + * (`@hermes/plugin-sdk` / `react*` -> live shim blobs, see sdk/runtime.ts) + * -> blob `import()` -> validate default HermesPlugin -> register(ctx) + * + * Loading the same plugin id again disposes the previous registrations first + * (agent rewrites a plugin file -> clean reload). Failures toast + log; a + * broken plugin can never take the app down. + * + * Sources today: the in-repo runtime example (`?raw`, proves the pipeline) + * and `/desktop-plugins//plugin.js` on disk — the door the + * agent writes through. + * + * SECURITY — this is NOT a capability boundary. A loaded plugin is evaluated + * as ESM in the renderer realm with FULL app authority: the React singleton, + * the whole SDK (`host.request` gateway RPC, `ctx.rest`, storage, `navigate`). + * The isolation here is *error* isolation only (ContribBoundary, isolated + * listeners) — a plugin can't crash the app, but it can do anything the app + * can. That's acceptable for local sources (disk files can already run code), + * and `integrity` only proves the bytes match a hash — it does NOT sandbox. + * A remote source (https + allowlist) must NOT reuse this pipeline as-is: + * it needs a real boundary (iframe/worker + CSP + capability gating) before + * it can land. The `{ integrity }` option is the transport seam, not the + * trust seam. + */ + +import { getStatus } from '@/hermes' +import { installPluginSdk, sdkImportMap } from '@/sdk/runtime' +import { notifyError } from '@/store/notifications' + +import { createPluginContext, type HermesPlugin } from './plugin' +import { dropPlugin, pluginActive, type PluginKind, publishPlugin } from './plugins-store' + +interface LoadOptions { + /** Absolute plugin.js path (disk plugins) — recorded for reveal/inventory. */ + file?: string + /** `sha256-` — verified against the source before evaluation. */ + integrity?: string + /** Inventory bucket; the disk door is the default runtime source. */ + kind?: PluginKind +} + +/** Live runtime plugins: id -> disposers (unload/reload support). */ +const loaded = new Map void)[]>() + +// Matches the specifier of a static `from '…'`, a side-effect `import '…'`, or +// a dynamic `import('…')` — anchored to import/export syntax so a bare string +// literal or comment (e.g. `notify('react')`) is never touched. +const importSpecifierRe = () => /(from\s*|import\s*\(\s*|import\s+)(['"])([^'"]+)\2/g + +/** Rewrite ONLY mapped import specifiers (@hermes/plugin-sdk, react*) to their + * live shim blob URLs — never occurrences inside strings/comments. */ +function rewriteSpecifiers(source: string): string { + const map = sdkImportMap() + + return source.replace(importSpecifierRe(), (whole, pre, quote, spec) => + map[spec] ? `${pre}${quote}${map[spec]}${quote}` : whole + ) +} + +/** Bare import specifiers the loader can't resolve (not relative/URL, not in + * the SDK map). Surfaced up-front so they don't fail as a cryptic native + * "Failed to resolve module specifier" from the blob import. */ +function unsupportedImports(source: string): string[] { + const map = sdkImportMap() + const bare = new Set() + + for (const m of source.matchAll(importSpecifierRe())) { + const spec = m[3] + + // Skip relative/absolute (./ ../ /) and any URL scheme (blob: http(s):). + if (spec && !/^[./]/.test(spec) && !/^[a-z][a-z0-9+.-]*:/i.test(spec) && !map[spec]) { + bare.add(spec) + } + } + + return [...bare] +} + +async function verifyIntegrity(source: string, integrity: string): Promise { + const [algo, expected] = integrity.split('-', 2) + + if (algo !== 'sha256' || !expected) { + return false + } + + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source)) + // Standard SRI base64 (`sha256-`) — a base64url-encoded hash won't match. + const actual = btoa(String.fromCharCode(...new Uint8Array(digest))) + + return actual === expected +} + +export function unloadRuntimePlugin(id: string): void { + loaded.get(id)?.forEach(dispose => dispose()) + loaded.delete(id) +} + +/** Evaluate + register one runtime plugin. Returns its id, or null on failure. */ +export async function loadRuntimePlugin(source: string, origin: string, options: LoadOptions = {}): Promise { + installPluginSdk() + + try { + if (options.integrity && !(await verifyIntegrity(source, options.integrity))) { + throw new Error(`integrity check failed for ${origin}`) + } + + const unsupported = unsupportedImports(source) + + if (unsupported.length > 0) { + throw new Error( + `unsupported import${unsupported.length > 1 ? 's' : ''}: ${unsupported.join(', ')} — ` + + `runtime plugins may only import @hermes/plugin-sdk and react` + ) + } + + const url = URL.createObjectURL(new Blob([rewriteSpecifiers(source)], { type: 'text/javascript' })) + + let mod: { default?: HermesPlugin } + + try { + mod = await import(/* @vite-ignore */ url) + } finally { + URL.revokeObjectURL(url) + } + + const plugin = mod.default + + if (!plugin?.id || typeof plugin.register !== 'function') { + throw new Error(`${origin} has no valid default HermesPlugin export`) + } + + const record = { + id: plugin.id, + name: plugin.name ?? plugin.id, + kind: options.kind ?? 'disk', + file: options.file + } + + const activate = () => { + // Reload = dispose the previous incarnation, then register fresh. + unloadRuntimePlugin(plugin.id) + const disposers: (() => void)[] = [] + plugin.register(createPluginContext(plugin.id, dispose => disposers.push(dispose))) + loaded.set(plugin.id, disposers) + publishPlugin({ ...record, status: 'loaded' }) + } + + publishPlugin({ ...record, status: 'disabled' }, { activate, deactivate: () => unloadRuntimePlugin(plugin.id) }) + + // A disabled plugin still inventories (settings shows it, toggle + // reactivates via the handle above) — it just never registers. + if (pluginActive(plugin.id, plugin.defaultEnabled ?? true)) { + activate() + } + + return plugin.id + } catch (error) { + console.error(`[plugins] runtime load failed (${origin})`, error) + notifyError(error, `Plugin "${origin}" failed to load`) + publishPlugin({ + id: origin, + name: origin, + kind: options.kind ?? 'disk', + file: options.file, + status: 'error', + error: error instanceof Error ? error.message : String(error) + }) + + return null + } +} + +// --------------------------------------------------------------------------- +// The on-disk plugin door: `/desktop-plugins//plugin.js` +// (agent- or user-written). SELF-MAINTAINING — no reload ceremony: +// - each plugin.js is fs-watched (the preview watcher IPC, debounced in +// main): saving the file hot-reloads the plugin in place; +// - a slow visible-tab poll of the directory picks up new folders (load + +// watch) and removed ones (unload + unwatch). +// Panes land via placement adoption and STAY where the user drags them — +// the tree treats not-yet-loaded pane ids as hidden, so boot and reload are +// collapse -> appear, never a placeholder flash. +// --------------------------------------------------------------------------- + +const DISK_POLL_MS = 5_000 + +interface DiskPlugin { + file: string + /** Loaded plugin id (null while broken — kept so a fixing save reloads). */ + id: null | string + watchId: null | string +} + +const disk = new Map() +let watching = false +let scanning = false + +async function loadDiskPlugin(name: string, file: string): Promise { + const desktop = window.hermesDesktop! + const entry = disk.get(name) + const prevId = entry?.id + + try { + const { text } = await desktop.readFileText(file) + const id = await loadRuntimePlugin(text, name, { file }) + + // A hot-edit that changes `plugin.id`: loadRuntimePlugin only disposes the + // NEW id, so unload the previous incarnation here or its contributions + + // inventory row orphan. + if (id && prevId && prevId !== id) { + unloadRuntimePlugin(prevId) + dropPlugin(prevId) + } + + if (entry) { + entry.id = id ?? entry.id + } + + // A fixing save under a different plugin id — drop the folder-named + // error record so the inventory shows one row, not a ghost. + if (id && id !== name) { + dropPlugin(name) + } + } catch { + // File vanished mid-read — the next scan reconciles. + } +} + +async function scanDiskPlugins(): Promise { + const desktop = window.hermesDesktop + + // Re-entrancy guard: the 5s poll must not overlap a slow in-flight scan + // (reads/loads can exceed the interval). + if (!desktop || scanning) { + return + } + + scanning = true + + try { + const { hermes_home } = await getStatus() + const { entries } = await desktop.readDir(`${hermes_home}/desktop-plugins`) + const seen = new Set() + + for (const dir of entries.filter(e => e.isDirectory)) { + seen.add(dir.name) + + if (disk.has(dir.name)) { + continue + } + + const file = `${dir.path}/plugin.js` + + try { + await desktop.readFileText(file) + } catch { + continue // No plugin.js (yet) — not a plugin folder. + } + + const record: DiskPlugin = { file, id: null, watchId: null } + disk.set(dir.name, record) + await loadDiskPlugin(dir.name, file) + + try { + record.watchId = (await desktop.watchPreviewFile(file)).id + } catch { + // Unwatchable — the poll still reconciles new folders; edits need a + // manual "Reload desktop plugins". + } + } + + // Folder deleted -> plugin gone, cleanly (inventory row included). + for (const [name, record] of disk) { + if (seen.has(name)) { + continue + } + + if (record.id) { + unloadRuntimePlugin(record.id) + dropPlugin(record.id) + } + + dropPlugin(name) + + if (record.watchId) { + void desktop.stopPreviewFileWatch(record.watchId) + } + + disk.delete(name) + } + } catch { + // No desktop-plugins dir (or no gateway yet) — nothing to reconcile. + } finally { + scanning = false + } +} + +/** Manual rescan (the ⌘K "Reload desktop plugins" fallback). */ +export const discoverRuntimePlugins = scanDiskPlugins + +/** Start the self-maintaining disk door: initial scan, per-file hot reload, + * slow folder reconciliation while the window is visible. Idempotent. */ +export function watchRuntimePlugins(): void { + const desktop = window.hermesDesktop + + if (watching || !desktop) { + return + } + + watching = true + + desktop.onPreviewFileChanged(({ id }) => { + for (const [name, record] of disk) { + if (record.watchId === id) { + void loadDiskPlugin(name, record.file) + + return + } + } + }) + + void scanDiskPlugins() + window.setInterval(() => { + if (document.visibilityState === 'visible') { + void scanDiskPlugins() + } + }, DISK_POLL_MS) +} diff --git a/apps/desktop/src/contrib/types.ts b/apps/desktop/src/contrib/types.ts new file mode 100644 index 000000000000..c46ee3e9929e --- /dev/null +++ b/apps/desktop/src/contrib/types.ts @@ -0,0 +1,43 @@ +import type { ReactNode } from 'react' + +/** + * Where a contribution came from. `'core'` is the app's own default UI; + * anything else is a plugin/extension id (e.g. `'plugin:kanban'`). This is the + * provenance tag that drives precedence and, later, the trust/capability gate + * (WoW-style taint: plugin-sourced contributions can be blocked from privileged + * actions unless granted). + */ +export type ContributionSource = 'core' | (string & {}) + +/** + * The single, uniform primitive every surface consumes. A bar renders these as + * inline items via ``; a dock renders them as stacked/tabbed panes via + * ``. Same shape either way -- the host decides how to present them. + */ +export interface Contribution { + /** Stable id, unique within its area. Re-registering the same id replaces it. */ + id: string + /** Namespaced area id this contribution targets, e.g. `'secondarySidebar'`. */ + area: string + /** Provenance; defaults to `'core'` when omitted. */ + source?: ContributionSource + /** Human label (pane tab / header). Optional for bar items. */ + title?: string + /** Ascending sort key within the area; ties keep insertion order. */ + order?: number + /** Dynamic visibility predicate. Omit for always-on. + * NOTE: evaluated when the area's snapshot is (re)built — i.e. on a + * register/remove in that area, NOT reactively. A `when()` that flips on + * external state won't re-resolve on its own; trigger a registry mutation + * (or don't rely on it flipping without one). */ + when?: () => boolean + /** Soft disable without unregistering. `false` hides it. */ + enabled?: boolean + /** Renders the contribution's content (UI contributions). */ + render?: () => ReactNode + /** + * Declarative payload for data contributions (Family B): layout presets, + * themes, commands — anything consumed by an engine rather than rendered. + */ + data?: unknown +} From 7ed71709602c2955954bbd91329cdbe1b566cd99 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:28:21 -0400 Subject: [PATCH 03/28] feat(desktop): plugin manager, runtime loader, and plugins settings --- apps/desktop/src/app/settings/index.tsx | 13 +- .../src/app/settings/plugins-settings.tsx | 124 ++++++++++++++++++ apps/desktop/src/app/settings/types.ts | 1 + apps/desktop/src/plugins/README.md | 14 ++ 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/app/settings/plugins-settings.tsx create mode 100644 apps/desktop/src/plugins/README.md diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 9c7c4dd0b4e1..6f6662056cd4 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -6,7 +6,7 @@ import { Tip } from '@/components/ui/tooltip' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { Archive, Bell, Download, Globe, Info, KeyRound, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons' +import { Archive, Bell, Download, Globe, Info, KeyRound, Package, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons' import { notifyError } from '@/store/notifications' import { useRouteEnumParam } from '../hooks/use-route-enum-param' @@ -22,6 +22,7 @@ import { SECTIONS } from './constants' import { GatewaySettings } from './gateway-settings' import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings' import { NotificationsSettings } from './notifications-settings' +import { PluginsSettings } from './plugins-settings' import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings' import { SessionsSettings } from './sessions-settings' import type { SettingsPageProps, SettingsView as SettingsViewId } from './types' @@ -32,6 +33,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ 'gateway', 'keys', 'notifications', + 'plugins', 'sessions', 'about' ] @@ -186,6 +188,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set label: t.settings.nav.apiKeys, onSelect: () => setActiveView('keys') }, + { + active: activeView === 'plugins', + icon: Package, + id: 'plugins', + label: t.settings.nav.plugins, + onSelect: () => setActiveView('plugins') + }, { active: activeView === 'sessions', icon: Archive, @@ -259,6 +268,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set ) : activeView === 'notifications' ? ( + ) : activeView === 'plugins' ? ( + ) : ( )} diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx new file mode 100644 index 000000000000..17bc213940e6 --- /dev/null +++ b/apps/desktop/src/app/settings/plugins-settings.tsx @@ -0,0 +1,124 @@ +import { useStore } from '@nanostores/react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Switch } from '@/components/ui/switch' +import { Tip } from '@/components/ui/tooltip' +import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store' +import { discoverRuntimePlugins } from '@/contrib/runtime-loader' +import { getStatus } from '@/hermes' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { Package } from '@/lib/icons' +import { notifyError } from '@/store/notifications' + +import { EmptyState, ListRow, Pill, SectionHeading, SettingsContent } from './primitives' + +const KIND_ORDER: Record = { disk: 0, runtime: 1, bundled: 2 } + +function reveal(file: string) { + void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined) +} + +async function revealPluginsDir() { + try { + const { hermes_home } = await getStatus() + // openDir (not reveal): the door often doesn't exist on first use, and + // showItemInFolder on a missing path silently no-ops (esp. Windows). + const result = await window.hermesDesktop?.openDir?.(`${hermes_home}/desktop-plugins`) + + if (result && !result.ok) { + notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder') + } + } catch (err) { + notifyError(err, 'Could not resolve the plugins folder') + } +} + +function PluginRow({ record }: { record: PluginRecord }) { + const { t } = useI18n() + const p = t.settings.plugins + + return ( + + {record.file && ( + + + + )} + { + triggerHaptic('selection') + void setPluginEnabled(record.id, on) + }} + /> + + } + description={ + record.status === 'error' ? ( + {record.error} + ) : ( + record.file ?? record.id + ) + } + title={ + + {record.name} + {p.kinds[record.kind]} + {record.status === 'error' && {p.failed}} + + } + /> + ) +} + +export function PluginsSettings() { + const { t } = useI18n() + const p = t.settings.plugins + const records = useStore($pluginRecords) + + const rows = Object.values(records).sort( + (a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name) + ) + + return ( + + +

{p.blurb}

+ +
+ + +
+ + {rows.length === 0 ? ( + + ) : ( +
+ {rows.map(record => ( + + ))} +
+ )} +
+ ) +} diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index 1b6509ef1a72..f3637c37a001 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -9,6 +9,7 @@ export type SettingsView = | 'gateway' | 'keys' | 'notifications' + | 'plugins' | 'providers' | 'sessions' | `config:${string}` diff --git a/apps/desktop/src/plugins/README.md b/apps/desktop/src/plugins/README.md new file mode 100644 index 000000000000..adcad66e2307 --- /dev/null +++ b/apps/desktop/src/plugins/README.md @@ -0,0 +1,14 @@ +# Bundled plugins + +Drop a `/plugin.{ts,tsx}` here that default-exports a `HermesPlugin` and +it registers automatically at boot (vite glob in `../contrib/plugins.ts`), with +the same inventory + live enable/disable contract as runtime plugins. + +None ship in-tree today — reference/demo plugins (the counter example, the +gateway-pill 1:1 rebuild, the runtime-loader hello world) live in the companion +[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins) +repo so the shipped app stays uncluttered. + +User- and agent-authored plugins load at runtime from +`$HERMES_HOME/desktop-plugins//plugin.js` (the disk door) — see the +`hermes-desktop-plugins` skill. From c388daa665d22cffe6e90206e1441aac9325b413 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:28:21 -0400 Subject: [PATCH 04/28] feat(desktop): layout-tree model + store + workspace geometry --- .../src/components/pane-shell/geometry.ts | 270 +++++ .../src/components/pane-shell/tree/model.ts | 613 ++++++++++ .../src/components/pane-shell/tree/store.ts | 1049 +++++++++++++++++ 3 files changed, 1932 insertions(+) create mode 100644 apps/desktop/src/components/pane-shell/geometry.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/model.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/store.ts diff --git a/apps/desktop/src/components/pane-shell/geometry.ts b/apps/desktop/src/components/pane-shell/geometry.ts new file mode 100644 index 000000000000..bfc05d35c0f0 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/geometry.ts @@ -0,0 +1,270 @@ +/** + * Pane geometry — AABB INTERSECTION → WINDOW-CONTROL AWARENESS. The native + * window controls (macOS traffic lights top-left / Windows-WSLg overlay + * top-right) are a rectangle in viewport pixels. Any region whose rect + * intersects it must reserve that space and expose a drag strip. One + * `intersect()` call replaces per-layout inset special cases. + * + * Also publishes the WORKSPACE zone's edges as CSS vars (see + * publishWorkspaceGeometry) — chrome that aligns to the main pane (titlebar + * title et al) consumes `var(--workspace-left/right)` in plain CSS instead of + * threading rects through React. + */ + +import { type RefObject, useLayoutEffect, useState, useSyncExternalStore } from 'react' + +import { $layoutTree } from '@/components/pane-shell/tree/store' +import { $connection } from '@/store/session' + +// --------------------------------------------------------------------------- +// Rects +// --------------------------------------------------------------------------- + +export interface Rect { + x: number + y: number + width: number + height: number +} + +/** AABB intersection. Returns null when the rects don't overlap. */ +export function intersect(a: Rect, b: Rect): Rect | null { + const x = Math.max(a.x, b.x) + const y = Math.max(a.y, b.y) + const right = Math.min(a.x + a.width, b.x + b.width) + const bottom = Math.min(a.y + a.height, b.y + b.height) + + if (right <= x || bottom <= y) { + return null + } + + return { x, y, width: right - x, height: bottom - y } +} + +// --------------------------------------------------------------------------- +// Native window controls rect +// --------------------------------------------------------------------------- + +/** Height of the band the native controls live in. */ +const CONTROLS_BAND_HEIGHT = 34 +/** Width of the macOS traffic-light cluster measured from the buttons' x. */ +const MACOS_LIGHTS_WIDTH = 58 +const MACOS_FALLBACK_BUTTON_X = 24 + +interface ConnectionLike { + windowButtonPosition?: { x: number; y: number } | null + nativeOverlayWidth?: number | null + isFullscreen?: boolean | null +} + +/** + * The native window-control rectangle in viewport pixels, or null when there + * is nothing to dodge (fullscreen, plain browser, secondary windows with + * hidden controls). + */ +export function windowControlsRect(connection: ConnectionLike | null, viewportWidth: number): Rect | null { + const inElectron = typeof window !== 'undefined' && 'hermesDesktop' in window + + if (!inElectron) { + return null + } + + if (connection?.isFullscreen) { + return null + } + + // Windows / WSLg: native overlay on the top-right. + const overlayWidth = connection?.nativeOverlayWidth ?? 0 + + if (overlayWidth > 0) { + return { x: viewportWidth - overlayWidth, y: 0, width: overlayWidth, height: CONTROLS_BAND_HEIGHT } + } + + // macOS: traffic lights on the top-left. windowButtonPosition === null means + // the platform has no left-side controls at all (Windows/Linux w/o overlay). + const pos = connection?.windowButtonPosition + + if (pos === null) { + return null + } + + const isMac = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform) + + if (!pos && !isMac) { + return null + } + + const x = pos?.x ?? MACOS_FALLBACK_BUTTON_X + + return { x: 0, y: 0, width: x + MACOS_LIGHTS_WIDTH, height: CONTROLS_BAND_HEIGHT } +} + +// --------------------------------------------------------------------------- +// Live hook +// --------------------------------------------------------------------------- + +let cachedRect: Rect | null = null +let cachedKey = '' + +function rectKey(r: Rect | null) { + return r ? `${r.x},${r.y},${r.width},${r.height}` : '' +} + +function readControlsRect(): Rect | null { + const next = windowControlsRect($connection.get(), typeof window === 'undefined' ? 0 : window.innerWidth) + const key = rectKey(next) + + // Referentially stable snapshot for useSyncExternalStore. + if (key !== cachedKey) { + cachedKey = key + cachedRect = next + } + + return cachedRect +} + +function subscribeControlsRect(cb: () => void) { + const unsubConnection = $connection.subscribe(() => cb()) + window.addEventListener('resize', cb) + + return () => { + unsubConnection() + window.removeEventListener('resize', cb) + } +} + +/** Reactive native window-controls rect (connection + viewport aware). */ +export function useWindowControlsRect(): Rect | null { + return useSyncExternalStore(subscribeControlsRect, readControlsRect, () => null) +} + +// --------------------------------------------------------------------------- +// Per-element overlap +// --------------------------------------------------------------------------- + +function sameRect(a: Rect | null, b: Rect | null) { + if (a === b) {return true} + + if (!a || !b) {return false} + + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height +} + +// --------------------------------------------------------------------------- +// Workspace-edge CSS vars +// --------------------------------------------------------------------------- + +/** + * Publish the workspace zone's viewport edges as root CSS vars: + * --workspace-left : px from the viewport's left to the main zone + * --workspace-right : px from the main zone's right to the viewport's right + * + * Measured once per relevant change (zone resize via ResizeObserver, layout + * mutations via $layoutTree, window resize) and consumed in plain CSS — the + * measured-var idiom (--composer-measured-height), not per-component JS + * geometry. Call once from the tree root; returns the disposer. + */ +export function publishWorkspaceGeometry(): () => void { + const root = document.documentElement + let el: HTMLElement | null = null + let lastLeft = NaN + let lastRight = NaN + + const ro = new ResizeObserver(() => measure()) + + const measure = () => { + const next = document.querySelector('[data-session-anchor="workspace"]') + + if (next !== el) { + if (el) { + ro.unobserve(el) + } + + el = next + + if (el) { + ro.observe(el) + } + } + + if (!el) { + return + } + + const r = el.getBoundingClientRect() + const left = Math.round(r.left) + const right = Math.round(window.innerWidth - r.right) + + // Skip unchanged writes: a sash drag fires the RO every frame, and each + // :root custom-property set dirties style for everything that reads them. + if (left !== lastLeft) { + lastLeft = left + root.style.setProperty('--workspace-left', `${left}px`) + } + + if (right !== lastRight) { + lastRight = right + root.style.setProperty('--workspace-right', `${right}px`) + } + } + + // Tree mutations move zones without resizing them (⌘\ flip) — re-measure a + // frame later, after the DOM committed. RO covers width changes (sash drags, + // side collapses); window resize covers the rest. + const unsubTree = $layoutTree.listen(() => requestAnimationFrame(measure)) + window.addEventListener('resize', measure) + measure() + + return () => { + unsubTree() + window.removeEventListener('resize', measure) + ro.disconnect() + root.style.removeProperty('--workspace-left') + root.style.removeProperty('--workspace-right') + } +} + +/** + * Intersects an element's live viewport rect with the native window-controls + * rect. Returns the overlap in ELEMENT-LOCAL coordinates (null when clear), so + * the consumer can reserve the space and paint a drag strip without knowing + * anything about platform, fullscreen state, or layout position. + */ +export function useWindowControlsOverlap(ref: RefObject, enabled = true): Rect | null { + const controls = useWindowControlsRect() + const [overlap, setOverlap] = useState(null) + + useLayoutEffect(() => { + const el = ref.current + + if (!enabled || !controls || !el) { + setOverlap(null) + + return + } + + const update = () => { + const r = el.getBoundingClientRect() + const hit = intersect(controls, { x: r.x, y: r.y, width: r.width, height: r.height }) + const local = hit ? { x: hit.x - r.x, y: hit.y - r.y, width: hit.width, height: hit.height } : null + + setOverlap(prev => (sameRect(prev, local) ? prev : local)) + } + + update() + + // Size changes fire the observer; cross-window moves fire `resize`. A pane + // shifted only by a sibling's resize re-measures on its own grid reflow + // (its track width changes), so this covers the shell's real cases. + const ro = new ResizeObserver(update) + ro.observe(el) + window.addEventListener('resize', update) + + return () => { + ro.disconnect() + window.removeEventListener('resize', update) + } + }, [controls, enabled, ref]) + + return overlap +} diff --git a/apps/desktop/src/components/pane-shell/tree/model.ts b/apps/desktop/src/components/pane-shell/tree/model.ts new file mode 100644 index 000000000000..8e037e927185 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/model.ts @@ -0,0 +1,613 @@ +/** + * Layout tree model — the Dockview-style structure that replaces the + * rails/bands grammar. Two node kinds: + * + * - `split`: children laid out along an orientation with fractional weights. + * - `group`: a stack of panes (tabs) with one active; may be minimized to + * its header strip (DetailPane semantics). + * + * Everything the old grammar special-cased is just tree shape here: a "top row + * spanning the right rail" is a column split; "a cell inside a column" is a + * stacked group; spans fall out of tree position. All operations are pure and + * return new trees; `normalize` keeps the structure canonical (no empty + * groups, no single-child or same-orientation nested splits). + */ + +export type Orientation = 'row' | 'column' + +export interface SplitNode { + type: 'split' + id: string + orientation: Orientation + children: LayoutNode[] + /** Parallel to children; relative flex weights. */ + weights: number[] +} + +export interface GroupNode { + type: 'group' + id: string + /** Pane ids stacked in this group (rendered as tabs when > 1). */ + panes: string[] + /** The visible pane. */ + active: string + /** Collapsed to header strip (chevron restores). */ + minimized?: boolean + /** + * Header hidden entirely (double-click the header to hide, double-click the + * zone's top edge to bring it back). Minimize always shows the header — + * a minimized group IS its header. + */ + headerHidden?: boolean +} + +export type LayoutNode = SplitNode | GroupNode + +/** Where a dragged pane lands relative to a target group. */ +export type DropPosition = 'center' | 'left' | 'right' | 'top' | 'bottom' + +export type RootEdge = 'left' | 'right' | 'top' | 'bottom' + +let seq = 0 +export const nodeId = (kind: string) => `${kind}-${Date.now().toString(36)}-${(seq++).toString(36)}` + +export const group = (panes: string[], options?: Partial>): GroupNode => ({ + type: 'group', + id: options?.id ?? nodeId('g'), + panes, + active: options?.active ?? panes[0] ?? '', + minimized: options?.minimized, + headerHidden: options?.headerHidden +}) + +export const split = ( + orientation: Orientation, + children: LayoutNode[], + weights?: number[], + id?: string +): SplitNode => ({ + type: 'split', + id: id ?? nodeId('s'), + orientation, + children, + weights: weights ?? children.map(() => 1) +}) + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +export function findGroup(node: LayoutNode, groupId: string): GroupNode | null { + if (node.type === 'group') { + return node.id === groupId ? node : null + } + + for (const child of node.children) { + const hit = findGroup(child, groupId) + + if (hit) { + return hit + } + } + + return null +} + +export function findGroupOfPane(node: LayoutNode, paneId: string): GroupNode | null { + if (node.type === 'group') { + return node.panes.includes(paneId) ? node : null + } + + for (const child of node.children) { + const hit = findGroupOfPane(child, paneId) + + if (hit) { + return hit + } + } + + return null +} + +export function allPaneIds(node: LayoutNode): string[] { + return node.type === 'group' ? [...node.panes] : node.children.flatMap(allPaneIds) +} + +// --------------------------------------------------------------------------- +// Structural edits (pure) +// --------------------------------------------------------------------------- + +/** + * Canonical form: unwrap single-child splits, flatten same-orientation + * nesting (weights scaled into the parent's slot), and PRUNE EMPTY GROUPS — + * dragging the last pane out of a zone closes the zone and its siblings + * absorb the space (VS Code semantics). Keeping empties as "stable regions" + * (the original FancyZones rule) let invisible residue accumulate into + * corrupt-feeling structure (`row([]|[])` eating half a slot); authored + * empty zones still exist inside the zone editor's own grid model, and an + * editor-applied tree keeps them until the first structural op. + */ +export function normalize(node: LayoutNode): LayoutNode | null { + if (node.type === 'group') { + if (node.panes.length === 0) { + return null + } + + const active = node.panes.includes(node.active) ? node.active : node.panes[0] + // A zone down to one pane clears a redundant HIDDEN override (the lone-pane + // default is already headerless) but KEEPS an explicit SHOWN override — + // once a zone has ever had a tab bar, closing back to one tab leaves it + // shown (sticky bar; the off switch is "Hide tab bar"). `false` survives. + const headerHidden = node.panes.length <= 1 && node.headerHidden !== false ? undefined : node.headerHidden + + if (active === node.active && headerHidden === node.headerHidden) { + return node + } + + return { ...node, active, headerHidden } + } + + const children: LayoutNode[] = [] + const weights: number[] = [] + + node.children.forEach((child, i) => { + const kept = normalize(child) + + if (!kept) { + return + } + + if (kept.type === 'split' && kept.orientation === node.orientation) { + // Flatten: distribute this slot's weight across the flattened children + // proportionally to their internal weights. + const total = kept.weights.reduce((a, b) => a + b, 0) || 1 + kept.children.forEach((grandchild, j) => { + children.push(grandchild) + weights.push((node.weights[i] ?? 1) * ((kept.weights[j] ?? 1) / total)) + }) + + return + } + + children.push(kept) + weights.push(node.weights[i] ?? 1) + }) + + if (children.length === 0) { + return null + } + + if (children.length === 1) { + return children[0] + } + + return { ...node, children, weights } +} + +/** Remove a pane wherever it lives. Closing the ACTIVE tab activates its + * previous neighbor (the next one when it was first) — browser-tab feel, + * never a jump to the strip's start. */ +export function removePane(node: LayoutNode, paneId: string): LayoutNode | null { + const walk = (n: LayoutNode): LayoutNode => { + if (n.type === 'group') { + const at = n.panes.indexOf(paneId) + + if (at === -1) { + return n + } + + const panes = n.panes.filter(p => p !== paneId) + + return { ...n, panes, active: n.active === paneId ? panes[Math.max(0, at - 1)] : n.active } + } + + return { ...n, children: n.children.map(walk) } + } + + return normalize(walk(node)) +} + +/** + * Insert `paneId` at `target` group: `center` joins the stack (as a tab); + * an edge splits the group in that direction. If the neighboring split + * already runs in that orientation the new group is spliced in beside the + * target instead of nesting (normalize would flatten it anyway). + */ +export function insertAtGroup( + node: LayoutNode, + targetGroupId: string, + paneId: string, + pos: DropPosition, + /** Center drops only: stack BEFORE this pane id (`null`/omitted = append) — + * the tab-strip insertion divider's slot. */ + before?: null | string +): LayoutNode | null { + const walk = (n: LayoutNode): LayoutNode => { + if (n.type === 'group') { + if (n.id !== targetGroupId) { + return n + } + + if (pos === 'center') { + const at = before ? n.panes.indexOf(before) : -1 + const panes = at >= 0 ? [...n.panes.slice(0, at), paneId, ...n.panes.slice(at)] : [...n.panes, paneId] + + // Gaining a pane pins the header EXPLICITLY shown (not just cleared): + // 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 } + } + + const orientation: Orientation = pos === 'left' || pos === 'right' ? 'row' : 'column' + const leading = pos === 'left' || pos === 'top' + const added = group([paneId]) + const children = leading ? [added, n] : [n, added] + + return split(orientation, children, [1, 1]) + } + + return { ...n, children: n.children.map(walk) } + } + + return normalize(walk(node)) +} + +/** + * The tree's VISIBLE shape: pane stacks + split orientations, with empty + * groups skipped (editor-session trees may still hold them) and single-child + * runs unwrapped. Two trees with equal signatures are indistinguishable on + * screen regardless of node ids. + */ +function shapeSignature(node: LayoutNode): string { + if (node.type === 'group') { + return node.panes.length > 0 ? `[${node.panes.join(',')}]` : '' + } + + const children = node.children.map(shapeSignature).filter(Boolean) + + if (children.length === 0) { + return '' + } + + return children.length === 1 ? children[0] : `${node.orientation}(${children.join('|')})` +} + +/** + * Move = remove + insert. If the target group vanished during removal (the + * pane was its only occupant), the move is a no-op. A move whose result + * LOOKS identical to the current layout is also a no-op — e.g. a "split + * bottom" drop onto the zone the pane already sits alone below would only + * rebuild the same arrangement under a fresh zone id. + */ +export function movePane( + root: LayoutNode, + paneId: string, + target: { groupId: string; pos: DropPosition; before?: null | string } +): LayoutNode { + const from = findGroupOfPane(root, paneId) + + // No-op guards: dropping a pane onto its own single-pane group. + if (from && from.id === target.groupId && from.panes.length === 1) { + return root + } + + const without = removePane(root, paneId) + + if (!without) { + // The pane was the only thing in the tree. + return root + } + + if (!findGroup(without, target.groupId)) { + return root + } + + const next = insertAtGroup(without, target.groupId, paneId, target.pos, target.before) ?? root + + return shapeSignature(next) === shapeSignature(root) ? root : next +} + +/** Group ids of every leaf under a node, in tree order. */ +export function groupLeafIds(node: LayoutNode): string[] { + return node.type === 'group' ? [node.id] : node.children.flatMap(groupLeafIds) +} + +function pathToGroup(node: LayoutNode, groupId: string): LayoutNode[] | null { + if (node.type === 'group') { + return node.id === groupId ? [node] : null + } + + for (const child of node.children) { + const sub = pathToGroup(child, groupId) + + if (sub) { + return [node, ...sub] + } + } + + return null +} + +const OPPOSITE_EDGE: Record = { bottom: 'top', left: 'right', right: 'left', top: 'bottom' } + +/** The viable group touching `edge` of this subtree. Along the edge's axis + * children are scanned edge-first — a non-viable zone is display:none, so the + * next sibling IS the visual edge; across it, every child touches the edge. */ +function edgeGroup(node: LayoutNode, edge: RootEdge, viable: (g: GroupNode) => boolean): GroupNode | null { + if (node.type === 'group') { + return viable(node) ? node : null + } + + const along = (node.orientation === 'row') === (edge === 'left' || edge === 'right') + const children = along && (edge === 'right' || edge === 'bottom') ? [...node.children].reverse() : node.children + + for (const child of children) { + const hit = edgeGroup(child, edge, viable) + + if (hit) { + return hit + } + } + + return null +} + +/** + * The viable zone VISUALLY adjacent to `groupId` on `side` (the target of the + * zone menu's "Move left/right/up/down"). Walks up to the nearest ancestor + * split running along that axis with a sibling on that side, then descends to + * the sibling's closest viable leaf; subtrees whose every zone fails `viable` + * (all panes hidden) are skipped, matching their collapsed rendering. + */ +export function adjacentGroup( + root: LayoutNode, + groupId: string, + side: RootEdge, + viable: (g: GroupNode) => boolean +): GroupNode | null { + const path = pathToGroup(root, groupId) + + if (!path) { + return null + } + + const orientation: Orientation = side === 'left' || side === 'right' ? 'row' : 'column' + const forward = side === 'right' || side === 'bottom' + + for (let i = path.length - 2; i >= 0; i--) { + const parent = path[i] + + if (parent.type !== 'split' || parent.orientation !== orientation) { + continue + } + + const index = parent.children.indexOf(path[i + 1]) + + 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) + + if (hit) { + return hit + } + } + } + + return null +} + +function sameSet(ids: string[], set: Set): boolean { + return ids.length === set.size && ids.every(id => set.has(id)) +} + +/** The node whose complete leaf set equals `set` (a rectangular region in a + * guillotine tree is always exactly one subtree), or null. */ +function findCover(node: LayoutNode, set: Set): LayoutNode | null { + if (sameSet(groupLeafIds(node), set)) { + return node + } + + if (node.type === 'split') { + for (const child of node.children) { + const hit = findCover(child, set) + + if (hit) { + return hit + } + } + } + + return null +} + +/** + * FancyZones span: merge the highlighted zones into ONE group holding + * `paneId`, absorbing any panes that lived in those zones as tabs. Only works + * when the highlighted set forms a rectangular subtree (it always does for a + * combined zone range on a guillotine tree); returns null otherwise so the + * caller can fall back to a single-zone drop. + */ +export function mergeZonesWithPane(root: LayoutNode, groupIds: string[], paneId: string): LayoutNode | null { + const set = new Set(groupIds) + + if (set.size <= 1 || !findCover(root, set)) { + return null + } + + // Panes from the merged zones (tree order), minus the dragged one. + const panesInSet: string[] = [] + + const collect = (n: LayoutNode) => { + if (n.type === 'group') { + if (set.has(n.id)) { + panesInSet.push(...n.panes.filter(p => p !== paneId)) + } + } else { + n.children.forEach(collect) + } + } + + collect(root) + + // If the dragged pane lives OUTSIDE the merged set, pull it from its origin + // first (leaving that origin an empty zone). Inside the set it's absorbed. + const origin = findGroupOfPane(root, paneId) + let working = root + + if (origin && !set.has(origin.id)) { + working = removePane(root, paneId) ?? root + } + + const merged = group([paneId, ...panesInSet]) + + const replace = (n: LayoutNode): LayoutNode => { + if (sameSet(groupLeafIds(n), set)) { + return merged + } + + return n.type === 'split' ? { ...n, children: n.children.map(replace) } : n + } + + return normalize(replace(working)) +} + +// --------------------------------------------------------------------------- +// Attribute edits +// --------------------------------------------------------------------------- + +function mapGroups(node: LayoutNode, fn: (g: GroupNode) => GroupNode): LayoutNode { + return node.type === 'group' ? fn(node) : { ...node, children: node.children.map(c => mapGroups(c, fn)) } +} + +export function setActivePane(root: LayoutNode, groupId: string, paneId: string): LayoutNode { + return mapGroups(root, g => (g.id === groupId && g.panes.includes(paneId) ? { ...g, active: paneId } : g)) +} + +/** Reorder a pane within its group's tab stack (browser-tab drag semantics). */ +export function reorderPaneInGroup(root: LayoutNode, groupId: string, paneId: string, toIndex: number): LayoutNode { + return mapGroups(root, g => { + if (g.id !== groupId || !g.panes.includes(paneId)) { + return g + } + + const without = g.panes.filter(p => p !== paneId) + const index = Math.max(0, Math.min(without.length, toIndex)) + const panes = [...without.slice(0, index), paneId, ...without.slice(index)] + + return { ...g, panes } + }) +} + +export function setGroupMinimized(root: LayoutNode, groupId: string, minimized: boolean): LayoutNode { + return mapGroups(root, g => (g.id === groupId ? { ...g, minimized } : g)) +} + +export function setGroupHeaderHidden(root: LayoutNode, groupId: string, headerHidden: boolean): LayoutNode { + return mapGroups(root, g => (g.id === groupId ? { ...g, headerHidden } : g)) +} + +function replaceNode(node: LayoutNode, id: string, make: (g: GroupNode) => LayoutNode): LayoutNode { + if (node.type === 'group') { + return node.id === id ? make(node) : node + } + + return { ...node, children: node.children.map(c => replaceNode(c, id, make)) } +} + +/** + * Split a zone: `movePaneId` (one of SEVERAL panes in the group) moves into + * the new zone on `side` — VS Code "split right", split and move in one + * gesture. A lone pane can't split away from itself: no-op (normalize prunes + * the empty zone the split would have minted). + */ +export function splitGroupZone(root: LayoutNode, groupId: string, side: RootEdge, movePaneId: string): LayoutNode { + const orientation: Orientation = side === 'left' || side === 'right' ? 'row' : 'column' + const before = side === 'left' || side === 'top' + + return ( + normalize( + replaceNode(root, groupId, g => { + if (g.panes.length < 2 || !g.panes.includes(movePaneId)) { + return g + } + + const added = group([movePaneId]) + const remaining = { ...g, panes: g.panes.filter(p => p !== movePaneId) } + + return split(orientation, before ? [added, remaining] : [remaining, added], [1, 1]) + }) + ) ?? root + ) +} + +/** Mirror the layout HORIZONTALLY (the titlebar flip toggle / ⌘\): reverse + * every ROW split's child order at EVERY depth, so left↔right flips + * everywhere. A right rail lands on the left with its OWN internal order + * mirrored too — so preview stays directly beside the file tree instead of + * jumping to the far edge (a shallow root-only reverse left nested rails in + * place). COLUMN splits keep their top↔bottom order (the terminal stays at + * the bottom). Its own involution: flipping twice is the identity. */ +export function mirrorTreeHorizontal(root: LayoutNode): LayoutNode { + if (root.type === 'group') { + return root + } + + const children = root.children.map(mirrorTreeHorizontal) + + return root.orientation === 'row' + ? { ...root, children: children.reverse(), weights: [...root.weights].reverse() } + : { ...root, children } +} + +export function setSplitWeights(root: LayoutNode, splitId: string, weights: number[]): LayoutNode { + if (root.type === 'split') { + if (root.id === splitId) { + return { ...root, weights } + } + + return { ...root, children: root.children.map(c => setSplitWeights(c, splitId, weights)) } + } + + return root +} + +// --------------------------------------------------------------------------- +// Validation (persisted trees are untrusted) +// --------------------------------------------------------------------------- + +export function isLayoutNode(value: unknown): value is LayoutNode { + if (!value || typeof value !== 'object') { + return false + } + + const n = value as Record + + if (n.type === 'group') { + return ( + typeof n.id === 'string' && + Array.isArray(n.panes) && + n.panes.every(p => typeof p === 'string') && + typeof n.active === 'string' + ) + } + + if (n.type === 'split') { + return ( + typeof n.id === 'string' && + (n.orientation === 'row' || n.orientation === 'column') && + Array.isArray(n.children) && + n.children.length > 0 && + n.children.every(isLayoutNode) && + Array.isArray(n.weights) && + n.weights.length === n.children.length && + n.weights.every(w => typeof w === 'number' && Number.isFinite(w) && w > 0) + ) + } + + return false +} diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts new file mode 100644 index 000000000000..fcde7f596b05 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -0,0 +1,1049 @@ +/** + * Layout tree store: one persisted tree replaces paneStates side/band + * overrides. The DEFAULT tree is declared by the app root (like config); + * the persisted tree is the user's customization; reset returns to default. + */ + +import { atom, computed } from 'nanostores' + +import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '@/app/layout-constants' +import { setPluginEnabled } from '@/contrib/plugins-store' +import { registry } from '@/contrib/registry' +import { translateNow } from '@/i18n' +import { readJson, readKey, writeJson, writeKey } from '@/lib/storage' +import { notify } from '@/store/notifications' +import { clearAllPaneSizeOverrides } from '@/store/panes' + +import { + allPaneIds, + type DropPosition, + findGroup, + findGroupOfPane, + groupLeafIds, + insertAtGroup, + isLayoutNode, + type LayoutNode, + mergeZonesWithPane as mergeZonesWithPaneOp, + mirrorTreeHorizontal, + movePane as movePaneOp, + normalize, + removePane, + reorderPaneInGroup as reorderPaneInGroupOp, + type RootEdge, + setActivePane as setActivePaneOp, + setGroupHeaderHidden as setGroupHeaderHiddenOp, + setGroupMinimized, + setSplitWeights as setSplitWeightsOp, + splitGroupZone as splitGroupZoneOp +} from './model' +import { rootChildSide } from './renderer/track-model' + +// v2: v1 trees were saved against placeholder panes with index-order zone +// assignment (chat could land in a corner cell). Retire them wholesale. +const STORAGE_KEY = 'hermes.desktop.layoutTree.v2' + +writeKey('hermes.desktop.layoutTree.v1', null) + +let defaultTree: LayoutNode | null = null + +function loadPersisted(): LayoutNode | null { + const parsed = readJson(STORAGE_KEY) + + // Canonicalize on load: strips stale attributes older code persisted + // (e.g. explicit headerHidden on lone-pane zones) and re-flattens. + return isLayoutNode(parsed) ? normalize(parsed) : null +} + +function persist(tree: LayoutNode | null) { + writeJson(STORAGE_KEY, tree) +} + +/** The live tree (null until a default is declared). */ +export const $layoutTree = atom(loadPersisted()) + +/** + * Which layout preset the current tree came from; `'custom'` after the user + * rearranges anything. Drives the picker's active highlight. + */ +export const $activePresetId = atom(readKey('hermes.desktop.layoutPreset.active') ?? 'default') + +export function markActivePreset(id: string) { + $activePresetId.set(id) + writeKey('hermes.desktop.layoutPreset.active', id) +} + +/** Pane id being dragged (tree drag session), null when idle. Also set to the + * SESSION_TILE_DRAG sentinel while a sidebar session is dragged over the tree, + * so the SAME zone overlay lights up (see session-tile-drop-bridge). */ +export const $treeDragging = atom(null) + +/** Sentinel `$treeDragging` value for a session (not a pane) drag — the zone + * overlay renders its normal targets, scoped to session-hosting zones. */ +export const SESSION_TILE_DRAG = '__session-tile-drag__' + +/** + * Panes hidden by app chrome toggles (titlebar sidebar / right-sidebar + * buttons). The tree KEEPS the zone and its mounted content; a zone whose + * every pane is hidden collapses to nothing until a toggle brings it back. + * Not persisted here — each binding's store owns persistence. + */ +export const $hiddenTreePanes = atom>(new Set()) + +/** Add/remove `item` in a readonly set, returning a fresh set — or null when + * membership already matches `present` (so callers can early-out on a no-op). */ +function toggledSet(set: ReadonlySet, item: T, present: boolean): Set | null { + if (set.has(item) === present) { + return null + } + + const next = new Set(set) + + if (present) { + next.add(item) + } else { + next.delete(item) + } + + return next +} + +export function setTreePaneHidden(paneId: string, hidden: boolean) { + const next = toggledSet($hiddenTreePanes.get(), paneId, hidden) + + if (!next) { + return + } + + $hiddenTreePanes.set(next) + + // Unhiding is an intent to SEE the pane — front it in its group. + if (!hidden) { + revealTreePane(paneId) + } +} + +/** + * CLOSE — the tab context menu's "Close". Two routes: + * - a registered closer (core panes whose visibility an app store owns: + * review/terminal/preview/sessions) closes through that store, so the + * titlebar/statusbar toggles stay truthful; + * - everything else (plugin panes, unbound core panes) is DISMISSED: removed + * from the tree and remembered so adoption doesn't re-add it. Reveal + * intent (a preview target, ⌘G) or a layout reset un-dismisses. + */ +const DISMISSED_KEY = 'hermes.desktop.dismissedPanes.v1' + +function loadDismissed(): ReadonlySet { + return new Set(readJson(DISMISSED_KEY) ?? []) +} + +export const $dismissedPanes = atom>(loadDismissed()) + +function saveDismissed(next: ReadonlySet) { + $dismissedPanes.set(next) + writeJson(DISMISSED_KEY, next.size === 0 ? null : [...next]) +} + +function setDismissed(paneId: string, dismissed: boolean) { + const next = toggledSet($dismissedPanes.get(), paneId, dismissed) + + if (next) { + saveDismissed(next) + } +} + +const paneClosers: Record void> = {} +const paneOpeners: Record void> = {} + +/** Route a pane's Close through the app store that owns its visibility. */ +export function registerPaneCloser(paneId: string, close: () => void) { + paneClosers[paneId] = close +} + +/** + * Route a pane's "show it" intent through the app store that owns its + * visibility — the mirror of `registerPaneCloser`, so a preset can reveal a + * toggle-gated pane (e.g. the terminal, whose visibility ⌃`/`$terminalTakeover` + * owns) while the toggle stays truthful. Only panes that opt in via + * `data.revealOnPreset` are opened on preset apply. + */ +export function registerPaneOpener(paneId: string, open: () => void) { + paneOpeners[paneId] = open +} + +const resetHandlers = new Set<() => void>() + +/** Run during a layout reset, BEFORE generic adoption — lets an owner + * pre-place its panes into the fresh default tree (session tiles collapse + * into main as tabs) so adoption sees them already placed and never scatters + * them to their old edges. */ +export function registerLayoutResetHandler(fn: () => void): () => void { + resetHandlers.add(fn) + + return () => { + resetHandlers.delete(fn) + } +} + +/** The zone the user last interacted with (clicked / focused into) — the ⌘W + * target when nothing is DOM-focused (activeElement is often `body` after a + * click lands on a non-focusable surface). Tracked by trackActiveTreeGroup. */ +export const $activeTreeGroup = atom(null) + +/** Record the interacted zone (pointerdown / focusin). Idempotent. */ +export function noteActiveTreeGroup(groupId: null | string) { + if (groupId !== $activeTreeGroup.get()) { + $activeTreeGroup.set(groupId) + } +} + +/** Install the active-zone tracker (call once from the tree root). Records the + * `[data-tree-group]` under each pointerdown / focusin so ⌘W knows which + * zone's tab to close even when nothing is DOM-focused. */ +export function trackActiveTreeGroup(): () => void { + const track = (event: Event) => { + const el = event.target instanceof HTMLElement ? event.target : null + const groupId = el?.closest('[data-tree-group]')?.dataset.treeGroup + + if (groupId) { + noteActiveTreeGroup(groupId) + } + } + + window.addEventListener('pointerdown', track, true) + window.addEventListener('focusin', track, true) + + return () => { + window.removeEventListener('pointerdown', track, true) + window.removeEventListener('focusin', track, true) + } +} + +const isUncloseablePane = (paneId: string): boolean => + 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 + * close, so ⌘W stays a no-op — it never closes the window. */ +export function closeWorkspaceTab(): boolean { + const tree = $layoutTree.get() + const active = tree ? findGroupOfPane(tree, 'workspace')?.active : null + + if (!active || isUncloseablePane(active)) { + return false + } + + closeTreePane(active) + + return true +} + +/** Closeable siblings of `paneId` within its group, split by position — powers + * the tab menu's Close-others / Close-to-the-right verbs (and their enablement). */ +function closeableTreeSiblings(paneId: string): { others: string[]; right: string[] } { + const tree = $layoutTree.get() + const panes = (tree ? findGroupOfPane(tree, paneId) : null)?.panes ?? [] + const idx = panes.indexOf(paneId) + + return { + others: panes.filter(id => id !== paneId && !isUncloseablePane(id)), + right: panes.filter((id, i) => i > idx && !isUncloseablePane(id)) + } +} + +/** Closeable-tab counts for a tab's menu enablement (`all` includes self). */ +export function treeTabCloseTargets(paneId: string): { all: number; others: number; right: number } { + const { others, right } = closeableTreeSiblings(paneId) + + return { all: others.length + (isUncloseablePane(paneId) ? 0 : 1), others: others.length, right: right.length } +} + +export function closeOtherTreeTabs(paneId: string): void { + closeableTreeSiblings(paneId).others.forEach(closeTreePane) +} + +export function closeTreeTabsToRight(paneId: string): void { + closeableTreeSiblings(paneId).right.forEach(closeTreePane) +} + +/** Close every closeable tab in `paneId`'s group (the uncloseable workspace stays). */ +export function closeAllTreeTabs(paneId: string): void { + const tree = $layoutTree.get() + const panes = (tree ? findGroupOfPane(tree, paneId) : null)?.panes ?? [] + + panes.filter(id => !isUncloseablePane(id)).forEach(closeTreePane) +} + +/** Pane ids in the tree under a `${prefix}:` namespace — lets a mirror prune + * panes the SHARED (cross-profile) tree persisted for tiles that no longer + * back the current profile (a profile switch reloads with the other profile's + * tile panes still stacked in). */ +export function treePanesWithPrefix(prefix: string): string[] { + const tree = $layoutTree.get() + + return tree ? allPaneIds(tree).filter(id => id.startsWith(prefix)) : [] +} + +/** ⌘1…⌘9: activate the Nth tab of the FOCUSED zone (the interaction tracker's + * group), but only when it's a real tab strip (≥2 panes). Returns false so the + * caller falls back to its default (profile switch) — the number keys mean + * "switch tab" only while a multi-tab zone holds focus. */ +export function activateTreeTabSlot(slot: number): boolean { + const groupId = $activeTreeGroup.get() + const tree = $layoutTree.get() + const panes = (groupId && tree ? findGroup(tree, groupId)?.panes : null) ?? [] + + if (panes.length < 2 || slot < 1 || slot > panes.length) { + return false + } + + activateTreePane(groupId!, panes[slot - 1]) + + return true +} + +/** ⌃Tab / ⌃⇧Tab: cycle the FOCUSED zone's tabs (wrapping) — but only a + * session/main strip with ≥2 tabs. Returns false so the caller falls back to + * the recent-session switcher when the focus isn't a chat tab strip. */ +export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean { + const groupId = $activeTreeGroup.get() + const tree = $layoutTree.get() + const group = groupId && tree ? findGroup(tree, groupId) : null + const panes = group?.panes ?? [] + + if (panes.length < 2 || !panes.some(id => id === 'workspace' || id.startsWith('session-tile:'))) { + return false + } + + const idx = Math.max(0, panes.indexOf(group!.active ?? '')) + activateTreePane(group!.id, panes[(idx + direction + panes.length) % panes.length]) + + return true +} + +/** Remove a pane from the tree WITHOUT a dismissal record — for surfaces + * whose lifecycle an owner store drives (session tiles): the owner removes + * the contribution too, and a later re-open must re-adopt cleanly. */ +export function removeTreePane(paneId: string) { + const tree = $layoutTree.get() + + if (tree) { + commit(removePane(tree, paneId)) + } +} + +/** Which root-row side a pane currently lives in, or null when it's nested + * with main (dragged into the middle) — where a side collapse can't hide it. + * Lets side-bound closers (files/sessions) fall back to dismissal. */ +export function paneRootSide(paneId: string): null | TreeSide { + const tree = $layoutTree.get() + + if (tree?.type !== 'split' || tree.orientation !== 'row') { + return null + } + + const panes = registry.getArea('panes') + const child = tree.children.find(c => allPaneIds(c).includes(paneId)) + + return child ? rootChildSide(child, id => panes.find(p => p.id === id)) : null +} + +/** The closer-less Close: dismiss the pane (removed + remembered; reveal + * intent or a layout reset un-dismisses). */ +export function dismissTreePane(paneId: string) { + const tree = $layoutTree.get() + + if (tree) { + setDismissed(paneId, true) + commit(removePane(tree, paneId)) + } +} + +export function closeTreePane(paneId: string) { + const closer = paneClosers[paneId] + + if (closer) { + closer() + + return + } + + // A plugin's pane: Close = DISABLE the plugin — the same switch as + // Settings → Plugins, so recovery is discoverable and symmetric. The + // contribution unregisters but the pane id STAYS in the tree, so + // re-enabling restores it exactly where it was. (Dismissal + removal + // would strand the pane with no way back short of a layout reset.) + const source = registry.getArea('panes').find(c => c.id === paneId)?.source + + if (source?.startsWith('plugin:')) { + const pluginId = source.slice('plugin:'.length) + void setPluginEnabled(pluginId, false) + notify({ + kind: 'info', + title: translateNow('zones.pluginDisabled', pluginId), + message: translateNow('zones.pluginDisabledBody') + }) + + return + } + + dismissTreePane(paneId) +} + +/** + * POSITIONAL side collapse — the titlebar's left/right sidebar toggles (and + * ⌘B / ⌘J). Everything on that side of the MAIN zone in the root row hides + * together, whatever panes live there (this is what makes the buttons agree + * with a rearranged layout; the flip derivation works the same way). An AND + * on top of per-pane visibility: zone shown ⇔ side open ∧ some pane shown. + */ +export type TreeSide = 'left' | 'right' + +export const $collapsedTreeSides = atom>(new Set()) + +// Side visibility is DERIVED from an app store (the binding owns persistence +// + button state); reveals flow back through its setter so they never +// disagree with the flag. +const sideOpeners: Partial void>> = {} + +export function setTreeSideCollapsed(side: TreeSide, collapsed: boolean) { + const next = toggledSet($collapsedTreeSides.get(), side, collapsed) + + if (next) { + $collapsedTreeSides.set(next) + } + + // Opening a side is an intent to SEE it — heal any pane of that side that a + // stale dismissal record removed from the tree, so ⌘B/⌘J can never press on + // nothing. Closing chrome panes is NEVER permanent (main parity). + if (!collapsed) { + restoreDismissedSidePanes(side) + } +} + +/** + * Does the layout have a collapsible root side of `side`? ⌘J's normal target is + * the right sidebar; a layout without one (e.g. a terminal-on-bottom preset) + * lets callers fall back to the terminal so ⌘J is never a dead key. Semantic — + * reuses `rootChildSide`, so it tracks a ⌘\ flip / drag like the toggles do. + */ +export function layoutHasRootSide(side: TreeSide): boolean { + const tree = $layoutTree.get() + + if (tree?.type !== 'split' || tree.orientation !== 'row') { + return false + } + + const panes = registry.getArea('panes') + + return tree.children.some(child => rootChildSide(child, id => panes.find(p => p.id === id)) === side) +} + +/** + * Un-dismiss + re-adopt every registered pane whose placement maps to `side` + * (the same semantic mapping as `rootChildSide`: 'left' panes ⇔ ⌘B, everything + * else non-main ⇔ ⌘J). Dismissal records for core chrome panes only exist as + * legacy state (they all register closers now), but they must not strand the + * pane where only a layout reset can recover it. + */ +function restoreDismissedSidePanes(side: TreeSide) { + const dismissed = $dismissedPanes.get() + + if (dismissed.size === 0) { + return + } + + let changed = false + + for (const pane of registry.getArea('panes')) { + if (!dismissed.has(pane.id)) { + continue + } + + const placement = (pane.data as { placement?: string } | undefined)?.placement + const paneSide = placement === 'left' ? 'left' : placement === 'main' ? null : 'right' + + if (paneSide === side) { + setDismissed(pane.id, false) + changed = true + } + } + + if (changed) { + adoptContributedPanes() + } +} + +/** Bind a side's visibility to an app store (mirror of bindPaneVisibility). */ +export function bindTreeSideVisibility( + side: TreeSide, + $open: { get(): boolean; listen(fn: (open: boolean) => void): void }, + setOpen: (open: boolean) => void +) { + sideOpeners[side] = setOpen + setTreeSideCollapsed(side, !$open.get()) + $open.listen(open => setTreeSideCollapsed(side, !open)) +} + +/** The chrome toggle owning `paneId`'s root-row column — SEMANTIC, matching + * the renderer's `rootChildSide`: ⌘B ⇔ the sessions column (left-placement + * panes) wherever it sits, ⌘J ⇔ the other side columns. Null for the main + * column (never side-collapsed). */ +export function treeSideOfPane(paneId: string): TreeSide | null { + const tree = $layoutTree.get() + + if (!tree || tree.type !== 'split' || tree.orientation !== 'row') { + return null + } + + const child = tree.children.find(node => allPaneIds(node).includes(paneId)) + + if (!child) { + return null + } + + const placementOf = (id: string) => + (registry.getArea('panes').find(c => c.id === id)?.data as { placement?: string } | undefined)?.placement + + const placements = allPaneIds(child).map(placementOf) + + if (placements.includes('main')) { + return null + } + + return placements.includes('left') ? 'left' : 'right' +} + +/** + * App intent "show pane X" (a preview target landed, ⌘G opened review, …): + * open its side, unhide it, and bring it to the front of its group. + */ +export function revealTreePane(paneId: string) { + // Reveal beats a Close: un-dismiss and let adoption put the pane back. + if ($dismissedPanes.get().has(paneId)) { + setDismissed(paneId, false) + adoptContributedPanes() + } + + const side = treeSideOfPane(paneId) + + if (side && $collapsedTreeSides.get().has(side)) { + const open = sideOpeners[side] + + // Through the bound store when there is one, so the toggle stays truthful. + if (open) { + open(true) + } else { + setTreeSideCollapsed(side, false) + } + } + + const hiddenNow = $hiddenTreePanes.get() + + if (hiddenNow.has(paneId)) { + setTreePaneHidden(paneId, false) + + return + } + + const tree = $layoutTree.get() + const group = tree ? findGroupOfPane(tree, paneId) : null + + if (tree && group && group.active !== paneId) { + commit(setActivePaneOp(tree, group.id, paneId)) + } +} + +/** + * Narrow viewport (the app's sidebar-collapse breakpoint): panes whose + * contribution declares `collapsible: true` leave the grid and become + * edge overlays (see NarrowOverlays in renderer.tsx). + */ +// Optional-chained + `typeof window` guarded like every other matchMedia call +// site: this module is imported by non-DOM code paths (session actions) whose +// test env has no `window`/`matchMedia` — an unguarded call throws at load. +const narrowQuery = typeof window !== 'undefined' ? window.matchMedia?.(SIDEBAR_COLLAPSE_MEDIA_QUERY) : undefined + +export const $narrowViewport = atom(Boolean(narrowQuery?.matches)) + +narrowQuery?.addEventListener('change', event => $narrowViewport.set(event.matches)) + +/** The titlebar flip toggle (⌘\): mirror the whole layout left↔right. */ +export function mirrorLayoutTree() { + const tree = $layoutTree.get() + + if (tree) { + commit(mirrorTreeHorizontal(tree)) + } +} + +export interface DropHint { + kind: 'group' + /** The zone a drop will land in (ClosestCenter among `groupIds`). */ + groupId?: string + /** Full highlighted set (multi-zone when Shift extends the range). */ + groupIds?: string[] + pos?: DropPosition + /** Hovering the target's TAB STRIP: the drop stacks at a specific slot — + * before this pane id, or at the end (`before: null`). The strip renders + * the insertion divider; the zone sheet stands down. */ + stack?: { before: null | string } +} + +/** Live drop target under the pointer while dragging. */ +export const $dropHint = atom(null) + +/** + * Derived session-drag booleans for HEAVY subscribers (the chat surfaces). + * `$dropHint` churns on every pointer-crossing during ANY drag; a chat surface + * subscribing to it raw re-renders its whole thread per hint change. These + * computeds collapse the churn to booleans that only notify on actual flips — + * and stay `false` throughout pane/tab drags, which chat never cares about. + */ +export const $sessionTileDragging = computed($treeDragging, dragging => dragging === SESSION_TILE_DRAG) + +/** True while a session drag aims at a zone EDGE (a tile split) or a tab + * strip (a stack) — the moments the chat surfaces' "link to chat" overlay + * must stand down. */ +export const $sessionTileEdgeHover = computed( + [$treeDragging, $dropHint], + (dragging, hint) => + dragging === SESSION_TILE_DRAG && ((hint?.pos !== undefined && hint.pos !== 'center') || hint?.stack !== undefined) +) + +/** + * Adopt panes present in `source` but missing from `target`: each joins the + * group its source siblings map to in the target (first group as a last + * resort). Layout changes never lose panes. + */ +function adoptMissingPanes(target: LayoutNode, source: LayoutNode): LayoutNode { + const have = new Set(allPaneIds(target)) + let next = target + + for (const paneId of allPaneIds(source)) { + if (have.has(paneId)) { + continue + } + + const sibling = findGroupOfPane(source, paneId)?.panes.find(p => have.has(p)) + const targetId = (sibling ? findGroupOfPane(next, sibling)?.id : undefined) ?? groupLeafIds(next)[0] + + if (targetId) { + next = insertAtGroup(next, targetId, paneId, 'center') ?? next + have.add(paneId) + } + } + + return next +} + +/** + * Declare the app's default tree. Adopted immediately when the user has no + * persisted customization; a persisted tree from an older default adopts any + * panes it's missing. + */ +export function declareDefaultTree(tree: LayoutNode) { + defaultTree = tree + const current = $layoutTree.get() + + if (!current) { + $layoutTree.set(tree) + + return + } + + const next = adoptMissingPanes(current, tree) + + if (next !== current) { + commit(next) + } +} + +/** + * LIVE pane adoption — a `panes` contribution that isn't in the tree yet + * (a plugin registered after boot, incl. runtime-loaded ones) joins the + * tree via the SAME primitive a human drag/drop commits with + * (`insertAtGroup`: anchor group + side). The pane's data supplies the + * gesture: + * + * - `dock: { pane, pos }` — "drop me on that edge of that pane". Any pane, + * any side, exactly what the drop chips do. + * - otherwise the semantic `placement` role infers the anchor: stack with + * a settled pane of the same placement, main zone as last resort. + * + * Happens once per pane lifetime (the committed tree remembers it across + * boots), so user rearrangement wins from then on and plugin reloads keep + * the pane where the user left it. + */ +interface PaneDockHint { + pane: string + pos: DropPosition + /** Center docks: stack BEFORE this pane id (the strip divider's slot). */ + before?: null | string +} + +function adoptContributedPanes(): void { + const tree = $layoutTree.get() + + if (!tree) { + return + } + + const panes = registry.getArea('panes') + + const dataOf = (paneId: string) => + panes.find(c => c.id === paneId)?.data as { placement?: string; dock?: PaneDockHint } | undefined + + const placementOf = (paneId: string) => dataOf(paneId)?.placement + const mainId = panes.find(c => placementOf(c.id) === 'main')?.id + const inTree = new Set(allPaneIds(tree)) + + // Plugin panes are never dismissed anymore (Close disables the plugin + // instead) — drop stale entries so panes stranded by the old behavior + // re-adopt on their own. + for (const pane of panes) { + if (pane.source?.startsWith('plugin:') && $dismissedPanes.get().has(pane.id)) { + setDismissed(pane.id, false) + } + } + + const dismissed = $dismissedPanes.get() + const missing = panes.filter(c => !inTree.has(c.id) && !dismissed.has(c.id)) + + if (missing.length === 0) { + return + } + + let next = tree + + for (const pane of missing) { + const dock = dataOf(pane.id)?.dock + const placement = placementOf(pane.id) ?? 'right' + + const anchor = + (dock && allPaneIds(next).includes(dock.pane) ? dock.pane : undefined) ?? + allPaneIds(next).find(id => id !== pane.id && placementOf(id) === placement) ?? + mainId + + const target = findGroupOfPane(next, anchor ?? '')?.id + + if (target) { + next = insertAtGroup(next, target, pane.id, dock?.pos ?? 'center', dock?.before) ?? 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; + // the next structural op returns lone panes to the auto-hide default.) + const landed = findGroupOfPane(next, pane.id) + + if (landed) { + next = setGroupHeaderHiddenOp(next, landed.id, false) + } + } + } + + if (next !== tree) { + commit(next) + } +} + +/** Adopt now + on every registry change (call once from the app root). */ +export function watchContributedPanes(): void { + adoptContributedPanes() + registry.subscribe(adoptContributedPanes) +} + +function commit(next: LayoutNode | null) { + if (!next) { + return + } + + $layoutTree.set(next) + persist(next) +} + +// --------------------------------------------------------------------------- +// USER-PLACED panes — "their spot wins". A pane the user has explicitly +// dragged (zone move / span / zone-menu split) keeps that placement; auto- +// docking (dockPaneBeside) only steers panes the user hasn't touched. +// Presets and resets hand placement back to the app. +// --------------------------------------------------------------------------- + +const USER_PLACED_KEY = 'hermes.desktop.userPlacedPanes.v1' + +export const $userPlacedPanes = atom>(new Set(readJson(USER_PLACED_KEY) ?? [])) + +function saveUserPlaced(next: ReadonlySet) { + $userPlacedPanes.set(next) + writeJson(USER_PLACED_KEY, next.size === 0 ? null : [...next]) +} + +function markPaneUserPlaced(paneId: string) { + const next = toggledSet($userPlacedPanes.get(), paneId, true) + + if (next) { + saveUserPlaced(next) + } +} + +/** + * Dock `paneId` directly beside `anchorPaneId` — the "preview opens NEXT TO + * the file tree" contract, position-aware: wherever the anchor lives (default + * rail, flipped via ⌘\, dragged into a stack, tabbed into main), the pane + * lands adjacent to it. Side rule: an anchor sitting right of the main zone + * gets the pane on its LEFT (the rail slides open toward the chat — main + * parity); an anchor left of main, stacked with it, or anywhere else gets it + * on the RIGHT. Skipped when the USER has placed the pane themselves, or the + * anchor isn't visible. Idempotent — a pane already beside its anchor is a + * shape no-op. + */ +export function dockPaneBeside(paneId: string, anchorPaneId: string) { + const tree = $layoutTree.get() + + if (!tree || $userPlacedPanes.get().has(paneId)) { + return + } + + const panes = registry.getArea('panes') + const anchor = findGroupOfPane(tree, anchorPaneId) + + // Anchor must be a live, shown pane — never dock beside a hidden file tree. + if (!anchor || $hiddenTreePanes.get().has(anchorPaneId) || !panes.some(c => c.id === anchorPaneId)) { + return + } + + // The uncloseable main workspace (session tiles are placement:'main' too, + // but closeable, so the uncloseable flag disambiguates). + const mainId = panes.find(c => { + const data = c.data as { placement?: string; uncloseable?: boolean } | undefined + + return data?.placement === 'main' && data.uncloseable + })?.id + + const order = allPaneIds(tree) + + const anchorRightOfMain = + !!mainId && !anchor.panes.includes(mainId) && order.indexOf(anchorPaneId) > order.indexOf(mainId) + + const pos: DropPosition = anchorRightOfMain ? 'left' : 'right' + + // A dismissed pane re-enters HERE (beside the anchor), not via adoption's + // placement fallback — clear the record so the two never disagree. + if ($dismissedPanes.get().has(paneId)) { + setDismissed(paneId, false) + } + + const next = findGroupOfPane(tree, paneId) + ? movePaneOp(tree, paneId, { groupId: anchor.id, pos }) + : insertAtGroup(tree, anchor.id, paneId, pos) + + if (next && next !== tree) { + commit(next) + } +} + +export function moveTreePane(paneId: string, target: { groupId: string; pos: DropPosition; before?: null | string }) { + const tree = $layoutTree.get() + + if (!tree) { + return + } + + const next = movePaneOp(tree, paneId, target) + + // movePane returns the SAME root for no-op drops ("stays here") — only a + // real move customizes the preset or pins the pane as user-placed. + if (next !== tree) { + commit(next) + markActivePreset('custom') + markPaneUserPlaced(paneId) + } +} + +/** + * Replace the whole tree (preset application). Panes living in the CURRENT + * tree that the preset doesn't know about (e.g. plugin panes vs a bundled + * preset) are adopted into the group their current siblings land in, so + * applying a preset never loses a pane. + */ +export function applyTree(tree: LayoutNode, presetId: string) { + const previous = $layoutTree.get() + + // A preset defines the layout's SIZES too — stale drag overrides from the + // previous arrangement would distort it. Same for user-placed pins: picking + // a layout hands pane placement back to the app (auto-docking resumes). + clearAllPaneSizeOverrides() + saveUserPlaced(new Set()) + commit(previous ? adoptMissingPanes(tree, previous) : tree) + markActivePreset(presetId) + + // Picking a named layout is an intent to SEE its panes. Toggle-gated panes + // (the terminal, whose visibility a store owns) would otherwise stay + // collapsed after the tree changes — so reveal the ones that opt in through + // their owning store, keeping the ⌃`/toggle state truthful. Iterate the + // preset's DECLARED panes (not the adopted result): logs is auto-adopted + // hidden into every tree, so only a preset that explicitly places it (Quad) + // should turn it on. + const panes = registry.getArea('panes') + + for (const paneId of allPaneIds(tree)) { + const data = panes.find(c => c.id === paneId)?.data as { revealOnPreset?: boolean } | undefined + + if (data?.revealOnPreset) { + paneOpeners[paneId]?.() + } + } +} + +/** + * Shift-drag span: merge the highlighted zones into one holding `paneId`. Falls + * back to a single-zone move at `fallbackGroupId` when the set can't merge + * (non-rectangular selection). + */ +export function mergeTreeZones(groupIds: string[], paneId: string, fallbackGroupId: string | null) { + const tree = $layoutTree.get() + + if (!tree) { + return + } + + const merged = mergeZonesWithPaneOp(tree, groupIds, paneId) + + if (merged) { + commit(merged) + markActivePreset('custom') + markPaneUserPlaced(paneId) + } else if (fallbackGroupId) { + moveTreePane(paneId, { groupId: fallbackGroupId, pos: 'center' }) + } +} + +export function activateTreePane(groupId: string, paneId: string) { + const tree = $layoutTree.get() + + if (tree) { + commit(setActivePaneOp(tree, groupId, paneId)) + } +} + +export function reorderTreePane(groupId: string, paneId: string, toIndex: number) { + const tree = $layoutTree.get() + + if (tree) { + commit(reorderPaneInGroupOp(tree, groupId, paneId, toIndex)) + markActivePreset('custom') + } +} + +/** Split a zone on `side`, moving `movePaneId` out of its stack into the new + * zone (VS Code split-and-move — the zone menu's Split actions). */ +export function splitTreeZone(groupId: string, side: RootEdge, movePaneId: string) { + const tree = $layoutTree.get() + + if (tree) { + commit(splitGroupZoneOp(tree, groupId, side, movePaneId)) + markActivePreset('custom') + markPaneUserPlaced(movePaneId) + } +} + +export function toggleTreeGroupMinimized(groupId: string, minimized: boolean) { + const tree = $layoutTree.get() + + if (tree) { + commit(setGroupMinimized(tree, groupId, minimized)) + } +} + +/** Hide/show a zone's header entirely (double-click gesture). */ +export function setTreeGroupHeaderHidden(groupId: string, headerHidden: boolean) { + const tree = $layoutTree.get() + + if (tree) { + commit(setGroupHeaderHiddenOp(tree, groupId, headerHidden)) + } +} + +export function setTreeSplitWeights(splitId: string, weights: number[]) { + const tree = $layoutTree.get() + + if (tree) { + // Weight drags are high-frequency: update live, persist on the trailing edge. + $layoutTree.set(setSplitWeightsOp(tree, splitId, weights)) + } +} + +function findSplitWeights(node: LayoutNode, splitId: string): number[] | null { + if (node.type !== 'split') { + return null + } + + if (node.id === splitId) { + return node.weights + } + + for (const child of node.children) { + const hit = findSplitWeights(child, splitId) + + if (hit) { + return hit + } + } + + return null +} + +/** + * The weights a layout preset declares for `splitId` — the ACTIVE preset + * first, then any other preset that knows the id. (Rearranging panes marks + * the active preset 'custom' but zone STRUCTURE — and so split ids — comes + * from whichever preset was applied, so the original baseline stays + * findable.) Null when no preset has a matching-shape split. + */ +export function presetSplitWeights(splitId: string, length: number): number[] | null { + const activeId = $activePresetId.get() + const presets = [...registry.getArea('layouts')].sort((a, b) => Number(b.id === activeId) - Number(a.id === activeId)) + + for (const preset of presets) { + const weights = preset.data && isLayoutNode(preset.data) ? findSplitWeights(preset.data, splitId) : null + + if (weights && weights.length === length) { + return [...weights] + } + } + + return null +} + +export function persistTree() { + persist($layoutTree.get()) +} + +export function resetLayoutTree() { + persist(null) + clearAllPaneSizeOverrides() + // Reset restores EVERYTHING — closed panes included — and hands pane + // placement back to the app (user-placed pins cleared). + saveDismissed(new Set()) + saveUserPlaced(new Set()) + $layoutTree.set(defaultTree) + markActivePreset('default') + // Owners PRE-PLACE their panes into the fresh default (session tiles stack + // into main as tabs) FIRST, so generic adoption sees them already in-tree + // and never scatters them to their old edges. + resetHandlers.forEach(fn => fn()) + // Everything still missing (plugin panes) adopts by placement. + adoptContributedPanes() +} + +// Dev hook for automation. +if (import.meta.env.DEV && typeof window !== 'undefined') { + ;(window as unknown as Record).__HERMES_LAYOUT_TREE__ = { + close: closeTreePane, + dismissed: () => $dismissedPanes.get(), + get: () => $layoutTree.get(), + move: moveTreePane, + registry, + reset: resetLayoutTree, + reveal: revealTreePane + } +} From 63a9bde77b22983c5d489aba9653381df0c7b99f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:28:21 -0400 Subject: [PATCH 05/28] =?UTF-8?q?feat(desktop):=20layout-tree=20renderer?= =?UTF-8?q?=20=E2=80=94=20splits,=20zones,=20pointer=20drag-session,=20tab?= =?UTF-8?q?=20strip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/pane-shell/context.ts | 19 - .../src/components/pane-shell/edit-mode.tsx | 59 ++ .../src/components/pane-shell/index.ts | 11 +- .../components/pane-shell/pane-shell.test.tsx | 333 --------- .../src/components/pane-shell/pane-shell.tsx | 598 ---------------- .../components/pane-shell/tree/grid-model.ts | 621 +++++++++++++++++ .../pane-shell/tree/grid-to-tree.ts | 198 ++++++ .../src/components/pane-shell/tree/presets.ts | 111 +++ .../pane-shell/tree/renderer/drag-session.ts | 588 ++++++++++++++++ .../pane-shell/tree/renderer/edit-bar.tsx | 113 +++ .../pane-shell/tree/renderer/index.tsx | 81 +++ .../tree/renderer/layout-picker.tsx | 203 ++++++ .../tree/renderer/narrow-overlays.tsx | 146 ++++ .../pane-shell/tree/renderer/track-model.ts | 260 +++++++ .../pane-shell/tree/renderer/tree-group.tsx | 656 ++++++++++++++++++ .../pane-shell/tree/renderer/tree-node.tsx | 28 + .../pane-shell/tree/renderer/tree-split.tsx | 518 ++++++++++++++ .../pane-shell/tree/zone-editor.tsx | 507 ++++++++++++++ .../pane-shell/tree/zones-engine.ts | 367 ++++++++++ apps/desktop/src/components/ui/pane-tab.tsx | 138 ++++ 20 files changed, 4601 insertions(+), 954 deletions(-) delete mode 100644 apps/desktop/src/components/pane-shell/context.ts create mode 100644 apps/desktop/src/components/pane-shell/edit-mode.tsx delete mode 100644 apps/desktop/src/components/pane-shell/pane-shell.test.tsx delete mode 100644 apps/desktop/src/components/pane-shell/pane-shell.tsx create mode 100644 apps/desktop/src/components/pane-shell/tree/grid-model.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/grid-to-tree.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/presets.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/edit-bar.tsx create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/index.tsx create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/layout-picker.tsx create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx create mode 100644 apps/desktop/src/components/pane-shell/tree/zone-editor.tsx create mode 100644 apps/desktop/src/components/pane-shell/tree/zones-engine.ts create mode 100644 apps/desktop/src/components/ui/pane-tab.tsx diff --git a/apps/desktop/src/components/pane-shell/context.ts b/apps/desktop/src/components/pane-shell/context.ts deleted file mode 100644 index b5bb3a907ace..000000000000 --- a/apps/desktop/src/components/pane-shell/context.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createContext } from 'react' - -export interface PaneSlot { - side: 'left' | 'right' - open: boolean - /** Resolved CSS `grid-column` value (e.g. "3 / 4", or a full-side span for a bottom-row pane). */ - gridColumn: string - /** Resolved CSS `grid-row` value ("1 / -1" full-height, "1 / 2" above a bottom row, "2 / 3" the row itself). */ - gridRow: string - /** True when this pane lays out as a horizontal row beneath its rail instead of a vertical column. */ - bottomRow: boolean -} - -export interface PaneShellContextValue { - paneById: Map - mainColumn: number -} - -export const PaneShellContext = createContext(null) diff --git a/apps/desktop/src/components/pane-shell/edit-mode.tsx b/apps/desktop/src/components/pane-shell/edit-mode.tsx new file mode 100644 index 000000000000..1e7bba6a64a7 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/edit-mode.tsx @@ -0,0 +1,59 @@ +/** + * Layout edit mode — the shared toggle for the tree renderer's FancyZones-style + * rearrangement (see tree/renderer.tsx). The toggle hotkey is a `keybinds` + * contribution (`layout.editMode`, default ⌘⇧\ — the sibling of ⌘\ = flip + * panes), so it's rebindable and collision-checked like every other action. + * This hook only owns Escape-to-exit. + */ + +import { atom } from 'nanostores' +import { useEffect } from 'react' + +import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers' + +export const $layoutEditMode = atom(false) + +export function toggleLayoutEditMode() { + $layoutEditMode.set(!$layoutEditMode.get()) +} + +/** Escape exits edit mode. Registered once by the layout root. */ +export function useLayoutEditHotkey(enabled: boolean) { + useEffect(() => { + if (!enabled || typeof window === 'undefined') { + return + } + + // Own an Escape layer only WHILE edit mode is on, so it doesn't outrank the + // narrow-pane reveal the rest of the time. + let releaseLayer: (() => void) | null = null + + const unsub = $layoutEditMode.subscribe(on => { + if (on && !releaseLayer) { + releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.layoutEdit) + } else if (!on && releaseLayer) { + releaseLayer() + releaseLayer = null + } + }) + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape' || e.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)) { + return + } + + if ($layoutEditMode.get()) { + e.preventDefault() + $layoutEditMode.set(false) + } + } + + window.addEventListener('keydown', onKeyDown) + + return () => { + window.removeEventListener('keydown', onKeyDown) + unsub() + releaseLayer?.() + } + }, [enabled]) +} diff --git a/apps/desktop/src/components/pane-shell/index.ts b/apps/desktop/src/components/pane-shell/index.ts index 1874b4bf0051..d29fc10cb4d8 100644 --- a/apps/desktop/src/components/pane-shell/index.ts +++ b/apps/desktop/src/components/pane-shell/index.ts @@ -1,4 +1,7 @@ -export type { PaneShellContextValue, PaneSlot } from './context' -export { PaneShellContext } from './context' -export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell' -export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell' +/** + * Cross-surface event: toggle-reveal a collapsed pane. Dispatched by the + * keybinds (⌘B / ⌘G / titlebar toggles on narrow viewports) with the pane id + * in `detail`; the layout tree's narrow overlays (tree/renderer.tsx) listen + * and slide the pane over the grid. + */ +export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal' diff --git a/apps/desktop/src/components/pane-shell/pane-shell.test.tsx b/apps/desktop/src/components/pane-shell/pane-shell.test.tsx deleted file mode 100644 index 0517b08c8ace..000000000000 --- a/apps/desktop/src/components/pane-shell/pane-shell.test.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import { cleanup, fireEvent, render } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { $paneStates, setPaneOpen, setPaneWidthOverride } from '@/store/panes' - -import { Pane, PaneMain, PaneShell } from './pane-shell' - -function gridContainer(rendered: ReturnType): HTMLElement { - const root = rendered.container.firstElementChild - - if (!(root instanceof HTMLElement)) { - throw new Error('PaneShell did not render a root element') - } - - return root -} - -function getColumnTemplate(container: HTMLElement): string[] { - return (container.style.gridTemplateColumns ?? '').split(/\s+/).filter(Boolean) -} - -function mockWidth(element: HTMLElement, width: number) { - Object.defineProperty(element, 'getBoundingClientRect', { - configurable: true, - value: () => ({ - bottom: 0, - height: 0, - left: 0, - right: width, - top: 0, - width, - x: 0, - y: 0, - toJSON: () => ({}) - }) - }) -} - -describe('PaneShell composition', () => { - beforeEach(() => { - $paneStates.set({}) - window.localStorage.clear() - }) - - afterEach(() => { - cleanup() - $paneStates.set({}) - window.localStorage.clear() - }) - - it('builds a 2-column grid for one left pane + main', () => { - const rendered = render( - - - files - - main - - ) - - const tracks = getColumnTemplate(gridContainer(rendered)) - - expect(tracks).toEqual(['240px', 'minmax(0,1fr)']) - }) - - it('orders panes left-to-right by side, preserving source order within a side', () => { - const rendered = render( - - - files - - - sessions - - main - - preview - - - inspector - - - ) - - const tracks = getColumnTemplate(gridContainer(rendered)) - - expect(tracks).toEqual(['240px', '200px', 'minmax(0,1fr)', '320px', '280px']) - }) - - it('collapses a closed pane to 0px', () => { - const rendered = render( - - - files - - main - - ) - - const tracks = getColumnTemplate(gridContainer(rendered)) - - expect(tracks).toEqual(['0px', 'minmax(0,1fr)']) - }) - - it('reads open state from the panes store', () => { - setPaneOpen('files', false) - - const rendered = render( - - - files - - main - - ) - - expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)']) - }) - - it('disabled forces the track to 0px even when the store says open', () => { - setPaneOpen('files', true) - - const rendered = render( - - - files - - main - - ) - - expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)']) - }) - - it('disabled does NOT mutate the store-persisted open state', () => { - setPaneOpen('files', true) - - render( - - - files - - main - - ) - - expect($paneStates.get().files?.open).toBe(true) - }) - - it('uses widthOverride from the store when set', () => { - setPaneOpen('files', true) - setPaneWidthOverride('files', 320) - - const rendered = render( - - - files - - main - - ) - - expect(getColumnTemplate(gridContainer(rendered))).toEqual(['320px', 'minmax(0,1fr)']) - }) - - it('preserves CSS-string widths verbatim (clamp, var, etc.)', () => { - const rendered = render( - - - inspector - - main - - ) - - const template = gridContainer(rendered).style.gridTemplateColumns - - expect(template).toContain('clamp(13.5rem,21vw,20rem)') - }) - - it('coerces numeric widths to px', () => { - const rendered = render( - - - files - - main - - ) - - expect(getColumnTemplate(gridContainer(rendered))).toEqual(['224px', 'minmax(0,1fr)']) - }) - - it('emits per-pane width as a CSS variable', () => { - const rendered = render( - - - files - - main - - ) - - const root = gridContainer(rendered) - - expect(root.style.getPropertyValue('--pane-files-width').trim()).toBe('240px') - }) - - it('places a Pane in the correct grid column via inline style', () => { - const rendered = render( - - - files - - - main - - - preview - - - ) - - const filesCell = rendered.getByTestId('files-content').parentElement! - const mainCell = rendered.getByTestId('main-content').parentElement! - const previewCell = rendered.getByTestId('preview-content').parentElement! - - expect(filesCell.style.gridColumn).toBe('1 / 2') - expect(mainCell.style.gridColumn).toBe('2 / 3') - expect(previewCell.style.gridColumn).toBe('3 / 4') - }) - - it('marks closed panes aria-hidden', () => { - const rendered = render( - - - files - - main - - ) - - const cell = rendered.getByTestId('files-content').parentElement! - - expect(cell.getAttribute('aria-hidden')).toBe('true') - expect(cell.getAttribute('data-pane-open')).toBe('false') - }) - - it('passes through arbitrary non-Pane children for self-placement', () => { - const rendered = render( - - - files - - main -
- overlay -
-
- ) - - expect(rendered.getByTestId('floating-overlay')).toBeDefined() - }) - - it('shows a resize handle only when resizable', () => { - const rendered = render( - - - files - - - preview - - main - - ) - - expect(rendered.queryByLabelText('Resize files')).toBeNull() - expect(rendered.getByLabelText('Resize preview')).toBeDefined() - }) - - it('dragging a left-pane separator stores a wider width override', () => { - const rendered = render( - - - files - - main - - ) - - const paneCell = rendered.getByTestId('files-content').parentElement - - if (!(paneCell instanceof HTMLElement)) { - throw new Error('Expected pane cell element') - } - - mockWidth(paneCell, 240) - const separator = rendered.getByLabelText('Resize files') - - fireEvent.pointerDown(separator, { clientX: 240, pointerId: 1 }) - fireEvent.pointerMove(window, { clientX: 300 }) - fireEvent.pointerUp(window, { clientX: 300 }) - - expect($paneStates.get().files?.widthOverride).toBe(300) - }) - - it('dragging a right-pane separator clamps to max width', () => { - const rendered = render( - - main - - preview - - - ) - - const paneCell = rendered.getByTestId('preview-content').parentElement - - if (!(paneCell instanceof HTMLElement)) { - throw new Error('Expected pane cell element') - } - - mockWidth(paneCell, 320) - const separator = rendered.getByLabelText('Resize preview') - - fireEvent.pointerDown(separator, { clientX: 900, pointerId: 1 }) - fireEvent.pointerMove(window, { clientX: 760 }) - fireEvent.pointerUp(window, { clientX: 760 }) - - expect($paneStates.get().preview?.widthOverride).toBe(340) - }) -}) diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx deleted file mode 100644 index 31a3f43c873d..000000000000 --- a/apps/desktop/src/components/pane-shell/pane-shell.tsx +++ /dev/null @@ -1,598 +0,0 @@ -import { useStore } from '@nanostores/react' -import { - Children, - type CSSProperties, - isValidElement, - type ReactElement, - type ReactNode, - type PointerEvent as ReactPointerEvent, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState -} from 'react' - -import { cn } from '@/lib/utils' -import { $paneStates, ensurePaneRegistered, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes' - -import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context' - -type PaneSide = 'left' | 'right' -type WidthValue = string | number - -interface PaneRoleMarker { - __paneShellRole?: 'pane' | 'main' -} - -export interface PaneProps { - children?: ReactNode - className?: string - defaultOpen?: boolean - /** Paints a persistent hairline on the resize edge (not just the hover sash) so the pane boundary is always visible. */ - divider?: boolean - /** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */ - disabled?: boolean - /** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */ - forceCollapsed?: boolean - /** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */ - hoverReveal?: boolean - /** - * Lay the pane out as a horizontal row beneath its rail (spanning every column on - * its `side`) instead of as a vertical column. The pane then resizes on the Y axis. - * Used to drop the terminal under a crowded rail rather than squeezing another column in. - */ - bottomRow?: boolean - /** Default height of a `bottomRow` pane. */ - height?: WidthValue - /** Min/max height clamps for a `bottomRow` pane's vertical resize. */ - maxHeight?: WidthValue - minHeight?: WidthValue - /** Width of the collapsed-overlay panel. Defaults to the docked width (or its resize override); set this to render a narrower overlay than the docked pane (e.g. min width on mobile). */ - overlayWidth?: WidthValue - /** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */ - onOverlayActiveChange?: (overlayActive: boolean) => void - id: string - maxWidth?: WidthValue - minWidth?: WidthValue - resizable?: boolean - side: PaneSide - width?: WidthValue -} - -export interface PaneMainProps { - children?: ReactNode - className?: string -} - -export interface PaneShellProps { - children?: ReactNode - className?: string - style?: CSSProperties -} - -interface CollectedPane { - bottomRow: boolean - defaultOpen: boolean - disabled: boolean - forceCollapsed: boolean - height: string - id: string - resizable: boolean - side: PaneSide - width: string -} - -const DEFAULT_WIDTH = '16rem' -const DEFAULT_HEIGHT = '18rem' -const DEFAULT_RESIZE_MIN_WIDTH = 160 -const DEFAULT_RESIZE_MIN_HEIGHT = 120 - -// Resize-sash geometry per axis: `x` is a vertical bar on the inner edge of a -// column; `y` is a horizontal bar on the top edge of a bottom row. -const SASH = { - x: { - orientation: 'vertical', - bar: 'bottom-0 top-0 w-1 cursor-col-resize', - line: 'inset-y-0 left-1/2 w-px -translate-x-1/2', - hover: 'inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2' - }, - y: { - orientation: 'horizontal', - bar: 'inset-x-0 top-0 h-1 -translate-y-1/2 cursor-row-resize', - line: 'inset-x-0 top-1/2 h-px -translate-y-1/2', - hover: 'inset-x-0 top-1/2 h-(--vscode-sash-hover-size,0.25rem) -translate-y-1/2' - } -} as const - -// Hover-reveal slide. The enter delay is a pure-CSS hover-intent gate: a fast -// pass-by doesn't dwell on the trigger long enough for the delay to elapse. -const HOVER_REVEAL_SLIDE_MS = 220 -const HOVER_REVEAL_ENTER_DELAY_MS = 130 -const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)' -// Offset shadow lifting the revealed panel off the content (same both sides; -// the mirror axis is offset-x, which is 0). Same color on light + dark. -const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012' -// Edge trigger strip, inset past the OS window-resize grab area AND the -// adjacent pane's scrollbar (0.5rem, .scrollbar-dt) — the strip overlays the -// neighboring scroller's edge, so any overlap makes the scrollbar reveal the -// pane on hover and swallow its clicks (#44140). -const HOVER_REVEAL_TRIGGER_WIDTH = 14 -const HOVER_REVEAL_EDGE_GUTTER = 'calc(0.5rem + 2px)' - -// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal -// from the keyboard, since its store-open toggle is a no-op while collapsed. -export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal' - -const widthToCss = (value: WidthValue | undefined, fallback: string) => - value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value - -const remPx = () => - typeof window === 'undefined' - ? 16 - : Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16 - -const viewportPx = () => (typeof window === 'undefined' ? 1280 : window.innerWidth) -const viewportHeightPx = () => (typeof window === 'undefined' ? 800 : window.innerHeight) - -// Resolves PaneProps min/max (number | "Npx" | "Nrem" | "Nvw" | "Nvh" | "N%") to -// pixels for drag clamping. vw/% resolve against window width, vh against height. -function widthToPx(value: WidthValue | undefined) { - if (typeof value === 'number') { - return Number.isFinite(value) ? value : undefined - } - - const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|vh|%)?$/) - - if (!match) { - return undefined - } - - const n = Number.parseFloat(match[1]) - - switch (match[2]) { - case 'rem': - return n * remPx() - - case 'vh': - return (n * viewportHeightPx()) / 100 - - case 'vw': - - case '%': - return (n * viewportPx()) / 100 - - default: - return n - } -} - -function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement { - return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role -} - -function collectPanes(children: ReactNode) { - const left: CollectedPane[] = [] - const right: CollectedPane[] = [] - let mainCount = 0 - - Children.forEach(children, child => { - if (isRole(child, 'main')) { - mainCount++ - - return - } - - if (!isRole(child, 'pane')) { - return - } - - const props = child.props as PaneProps - - const entry: CollectedPane = { - bottomRow: props.bottomRow ?? false, - defaultOpen: props.defaultOpen ?? true, - disabled: props.disabled ?? false, - forceCollapsed: props.forceCollapsed ?? false, - height: widthToCss(props.height, DEFAULT_HEIGHT), - id: props.id, - resizable: props.resizable ?? false, - side: props.side, - width: widthToCss(props.width, DEFAULT_WIDTH) - } - - ;(props.side === 'left' ? left : right).push(entry) - }) - - return { left, mainCount, right } -} - -type PaneStoreState = Record - -function paneIsOpen(pane: CollectedPane, states: PaneStoreState) { - const stateOpen = states[pane.id]?.open ?? pane.defaultOpen - - return !pane.disabled && !pane.forceCollapsed && stateOpen -} - -function trackForPane(pane: CollectedPane, states: PaneStoreState) { - const open = paneIsOpen(pane, states) - - if (!open) { - return { open: false, track: '0px' } - } - - const override = pane.resizable ? states[pane.id]?.widthOverride : undefined - - return { open: true, track: override !== undefined ? `${override}px` : pane.width } -} - -function heightTrackForPane(pane: CollectedPane, states: PaneStoreState) { - const override = pane.resizable ? states[pane.id]?.heightOverride : undefined - - return override !== undefined ? `${override}px` : pane.height -} - -export function PaneShell({ children, className, style }: PaneShellProps) { - const paneStates = useStore($paneStates) - const { left, mainCount, right } = useMemo(() => collectPanes(children), [children]) - - if (import.meta.env.DEV && mainCount > 1) { - console.warn('[PaneShell] expected at most one , got', mainCount) - } - - const ctxValue = useMemo(() => { - const paneById = new Map() - const tracks: string[] = [] - const cssVars: Record = {} - let column = 1 - - // A bottom-row pane drops out of its rail's column flow and instead spans - // every column on its side as a new row below them. The first open one wins - // and decides which rail gets split into two rows. - const leftCols = left.filter(pane => !pane.bottomRow) - const rightCols = right.filter(pane => !pane.bottomRow) - const bottomRowPanes = [...left, ...right].filter(pane => pane.bottomRow) - const activeBottomRow = bottomRowPanes.find(pane => paneIsOpen(pane, paneStates)) ?? null - const bottomRailSide = activeBottomRow?.side ?? null - - // Open column panes on the bottom row's side shrink to the top row; everything - // else (main, the other rail, closed / hover-reveal panes) stays full height. - const addColumn = (pane: CollectedPane, paneSide: PaneSide) => { - const { open, track } = trackForPane(pane, paneStates) - tracks.push(track) - cssVars[`--pane-${pane.id}-width`] = track - const gridRow = open && paneSide === bottomRailSide ? '1 / 2' : '1 / -1' - paneById.set(pane.id, { - open, - side: paneSide, - gridColumn: `${column} / ${column + 1}`, - gridRow, - bottomRow: false - }) - column++ - } - - for (const pane of leftCols) { - addColumn(pane, 'left') - } - - tracks.push('minmax(0,1fr)') - const mainColumn = column++ - - for (const pane of rightCols) { - addColumn(pane, 'right') - } - - // Place every bottom-row pane: span its rail's columns on the second row. - for (const pane of bottomRowPanes) { - const gridColumn = pane.side === 'left' ? `1 / ${mainColumn}` : `${mainColumn + 1} / -1` - paneById.set(pane.id, { - open: pane === activeBottomRow, - side: pane.side, - gridColumn, - gridRow: '2 / 3', - bottomRow: true - }) - } - - // Always emit explicit rows so `grid-row: 1 / -1` (full-height) resolves - // against a known last line. With a bottom row active there are two tracks; - // otherwise a single 1fr track behaves exactly like the old single-row grid. - const gridTemplateRows = activeBottomRow - ? `minmax(0,1fr) ${heightTrackForPane(activeBottomRow, paneStates)}` - : 'minmax(0,1fr)' - - return { - cssVars, - gridTemplate: tracks.join(' '), - gridTemplateRows, - mainColumn, - paneById - } satisfies PaneShellContextValue & { - cssVars: Record - gridTemplate: string - gridTemplateRows: string - } - }, [left, paneStates, right]) - - const composedStyle = useMemo( - () => ({ - ...ctxValue.cssVars, - ...style, - gridTemplateColumns: ctxValue.gridTemplate, - gridTemplateRows: ctxValue.gridTemplateRows - }), - [ctxValue.cssVars, ctxValue.gridTemplate, ctxValue.gridTemplateRows, style] - ) - - return ( - -
- {children} -
-
- ) -} - -export function Pane({ - children, - className, - defaultOpen = true, - divider = false, - disabled = false, - hoverReveal = false, - maxHeight, - minHeight, - overlayWidth: overlayWidthProp, - id, - maxWidth, - minWidth, - onOverlayActiveChange, - resizable = false, - width -}: PaneProps) { - const ctx = useContext(PaneShellContext) - const paneStates = useStore($paneStates) - const registered = useRef(false) - const paneRef = useRef(null) - // Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS. - const [forced, setForced] = useState(false) - - const slot = ctx?.paneById.get(id) - const open = Boolean(slot?.open && !disabled) - const side = slot?.side ?? 'left' - // Collapsed + hoverReveal: float the pane contents over the main column on - // hover/focus instead of hiding them. Honors any persisted resize width. - const overlayActive = !open && hoverReveal && !disabled - const override = resizable ? paneStates[id]?.widthOverride : undefined - - // Overlay width: an explicit `overlayWidth` (e.g. min width on mobile) wins, - // else the persisted resize override, else the docked width. - const overlayWidth = - overlayWidthProp !== undefined - ? widthToCss(overlayWidthProp, DEFAULT_WIDTH) - : override !== undefined - ? `${override}px` - : widthToCss(width, DEFAULT_WIDTH) - - useEffect(() => { - if (registered.current) { - return - } - - registered.current = true - ensurePaneRegistered(id, { open: defaultOpen }) - }, [defaultOpen, id]) - - // Keyboard toggle pins/unpins the reveal while collapsed; clear when no longer - // a collapsed overlay (reopened / widened). - useEffect(() => { - if (typeof window === 'undefined' || !overlayActive) { - setForced(false) - - return - } - - const onToggle = (e: Event) => { - if ((e as CustomEvent<{ id: string }>).detail?.id === id) { - setForced(v => !v) - } - } - - window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) - - return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) - }, [id, overlayActive]) - - // Keep contents mounted while collapsed so reveal is a pure CSS transform. - useEffect(() => { - onOverlayActiveChange?.(overlayActive) - }, [onOverlayActiveChange, overlayActive]) - - const isBottomRow = Boolean(slot?.bottomRow) - const axis = isBottomRow ? 'y' : 'x' - const sash = SASH[axis] - const canResize = open && resizable - const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH - const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY - const loH = widthToPx(minHeight) ?? DEFAULT_RESIZE_MIN_HEIGHT - const hiH = widthToPx(maxHeight) ?? Number.POSITIVE_INFINITY - - // One pointer-drag for both axes. Columns grow toward the main column (left - // rail → right, right rail → left); the bottom row grows up from its top edge. - const startResize = useCallback( - (event: ReactPointerEvent, axis: 'x' | 'y') => { - const rect = paneRef.current?.getBoundingClientRect() - const base = (axis === 'x' ? rect?.width : rect?.height) ?? 0 - - if (!canResize || base <= 0) { - return - } - - event.preventDefault() - - const handle = event.currentTarget - const { pointerId } = event - const start = axis === 'x' ? event.clientX : event.clientY - const dir = axis === 'x' ? (side === 'left' ? 1 : -1) : -1 - const [min, max] = axis === 'x' ? [lo, hi] : [loH, hiH] - const apply = axis === 'x' ? setPaneWidthOverride : setPaneHeightOverride - const restoreCursor = document.body.style.cursor - const restoreSelect = document.body.style.userSelect - - handle.setPointerCapture?.(pointerId) - document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize' - document.body.style.userSelect = 'none' - - const onMove = (e: PointerEvent) => { - const next = base + ((axis === 'x' ? e.clientX : e.clientY) - start) * dir - apply(id, Math.round(Math.min(max, Math.max(min, next)))) - } - - const cleanup = () => { - document.body.style.cursor = restoreCursor - document.body.style.userSelect = restoreSelect - handle.releasePointerCapture?.(pointerId) - window.removeEventListener('pointermove', onMove, true) - window.removeEventListener('pointerup', cleanup, true) - window.removeEventListener('pointercancel', cleanup, true) - window.removeEventListener('blur', cleanup) - } - - window.addEventListener('pointermove', onMove, true) - window.addEventListener('pointerup', cleanup, true) - window.addEventListener('pointercancel', cleanup, true) - window.addEventListener('blur', cleanup) - }, - [canResize, hi, hiH, id, lo, loH, side] - ) - - if (!ctx) { - if (import.meta.env.DEV) { - console.warn(`[Pane:${id}] must be rendered inside `) - } - - return null - } - - if (!slot) { - return null - } - - // Collapsed hover-reveal track: a 0px, pointer-transparent grid cell holding a - // thin edge trigger + the floating panel (both absolute, escaping the zero - // box). group-hover (or data-forced from the keyboard) drives the slide; the - // enter-delay is the hover-intent gate. No JS pointer math. - if (overlayActive) { - const edge = side === 'left' ? 'left' : 'right' - const offscreen = side === 'left' ? '-translate-x-[calc(100%+1rem)]' : 'translate-x-[calc(100%+1rem)]' - - return ( -
- - ) - } - - return ( -
- {canResize && ( -
startResize(e, axis)} - role="separator" - tabIndex={0} - > - {divider && } - -
- )} - {children} -
- ) -} - -;(Pane as unknown as PaneRoleMarker).__paneShellRole = 'pane' - -export function PaneMain({ children, className }: PaneMainProps) { - const ctx = useContext(PaneShellContext) - - if (!ctx) { - if (import.meta.env.DEV) { - console.warn('[PaneMain] must be rendered inside ') - } - - return null - } - - return ( -
- {children} -
- ) -} - -;(PaneMain as unknown as PaneRoleMarker).__paneShellRole = 'main' diff --git a/apps/desktop/src/components/pane-shell/tree/grid-model.ts b/apps/desktop/src/components/pane-shell/tree/grid-model.ts new file mode 100644 index 000000000000..327b210d5580 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/grid-model.ts @@ -0,0 +1,621 @@ +/** + * FancyZones grid model — a faithful port of PowerToys' + * `FancyZonesEditor/Models/GridLayoutModel.cs` + `FancyZonesEditor/GridData.cs` + * (microsoft/PowerToys, MIT). Function/field names, algorithms, and invariants + * follow the C# sources so behavior matches the original editor: + * + * - A layout is rows x columns with percent tracks summing to MULTIPLIER + * (10000) and a cellChildMap assigning each cell to a zone; a zone spanning + * multiple cells appears as the same index in adjacent cells. + * - Zones are rectangles in the 0..10000 coordinate space (prefix sums). + * - Resizers are the shared edges between zones, derived from cellChildMap + * discontinuities; dragging one moves every zone touching that edge. + * - Merging computes the rectangular CLOSURE of the selection (extending it + * until no zone is partially cut) — the signature FancyZones merge feel. + */ + +// The sum of row/column percents should be equal to this number. +export const MULTIPLIER = 10000 + +/** Minimum zone extent in model units (editor ergonomics; C# uses 1). */ +export const MIN_ZONE_SIZE = 500 + +export interface GridLayout { + rows: number + columns: number + rowPercents: number[] + columnPercents: number[] + /** cellChildMap[row][col] = zone index; spans = same index in adjacent cells. */ + cellChildMap: number[][] +} + +export interface GridZone { + index: number + left: number + top: number + right: number + bottom: number +} + +export interface GridResizer { + orientation: 'horizontal' | 'vertical' + /** All zones to the left/up, in order. */ + negativeSideIndices: number[] + /** All zones to the right/down, in order. */ + positiveSideIndices: number[] +} + +// --------------------------------------------------------------------------- +// GridData helpers (verbatim) +// --------------------------------------------------------------------------- + +/** result[k] is the sum of the first k elements of the given list. */ +export function prefixSum(list: number[]): number[] { + const result: number[] = [0] + let sum = 0 + + for (const value of list) { + sum += value + result.push(sum) + } + + return result +} + +/** Opposite of prefixSum: differences of consecutive elements. */ +function adjacentDifference(list: number[]): number[] { + if (list.length <= 1) { + return [] + } + + const result: number[] = [] + + for (let i = 0; i < list.length - 1; i++) { + result.push(list[i + 1] - list[i]) + } + + return result +} + +/** Contiguous-segment unique (order-preserving), as in GridData.Unique. */ +function unique(list: number[]): number[] { + const result: number[] = [] + + if (list.length === 0) { + return result + } + + let last = list[0] + result.push(last) + + for (let i = 1; i < list.length; i++) { + if (list[i] !== last) { + last = list[i] + result.push(last) + } + } + + return result +} + +// --------------------------------------------------------------------------- +// Model -> zones / resizers (GridData.ModelToZones / ModelToResizers) +// --------------------------------------------------------------------------- + +export function modelToZones(model: GridLayout): GridZone[] | null { + const { rows, columns: cols, cellChildMap } = model + + let zoneCount = 0 + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + zoneCount = Math.max(zoneCount, cellChildMap[row][col]) + } + } + + zoneCount++ + + if (zoneCount > rows * cols) { + return null + } + + const indexCount = new Array(zoneCount).fill(0) + const indexRowLow = new Array(zoneCount).fill(Number.MAX_SAFE_INTEGER) + const indexRowHigh = new Array(zoneCount).fill(0) + const indexColLow = new Array(zoneCount).fill(Number.MAX_SAFE_INTEGER) + const indexColHigh = new Array(zoneCount).fill(0) + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const index = cellChildMap[row][col] + indexCount[index]++ + indexRowLow[index] = Math.min(indexRowLow[index], row) + indexColLow[index] = Math.min(indexColLow[index], col) + indexRowHigh[index] = Math.max(indexRowHigh[index], row) + indexColHigh[index] = Math.max(indexColHigh[index], col) + } + } + + for (let index = 0; index < zoneCount; index++) { + if (indexCount[index] === 0) { + return null + } + + // Each zone must occupy a full rectangle of cells. + if (indexCount[index] !== (indexRowHigh[index] - indexRowLow[index] + 1) * (indexColHigh[index] - indexColLow[index] + 1)) { + return null + } + } + + if ( + model.rowPercents.length !== rows || + model.columnPercents.length !== cols || + model.rowPercents.some(x => x < 1) || + model.columnPercents.some(x => x < 1) + ) { + return null + } + + const rowPrefixSum = prefixSum(model.rowPercents) + const colPrefixSum = prefixSum(model.columnPercents) + + if (rowPrefixSum[rows] !== MULTIPLIER || colPrefixSum[cols] !== MULTIPLIER) { + return null + } + + const zones: GridZone[] = [] + + for (let index = 0; index < zoneCount; index++) { + zones.push({ + index, + left: colPrefixSum[indexColLow[index]], + right: colPrefixSum[indexColHigh[index] + 1], + top: rowPrefixSum[indexRowLow[index]], + bottom: rowPrefixSum[indexRowHigh[index] + 1] + }) + } + + return zones +} + +export function modelToResizers(model: GridLayout): GridResizer[] { + const grid = model.cellChildMap + const { rows, columns: cols } = model + const resizers: GridResizer[] = [] + + // Horizontal + for (let row = 1; row < rows; row++) { + for (let startCol = 0; startCol < cols; ) { + if (grid[row - 1][startCol] !== grid[row][startCol]) { + let endCol = startCol + + while (endCol + 1 < cols && grid[row - 1][endCol + 1] !== grid[row][endCol + 1]) { + endCol++ + } + + const positive: number[] = [] + const negative: number[] = [] + + for (let col = startCol; col <= endCol; col++) { + negative.push(grid[row - 1][col]) + positive.push(grid[row][col]) + } + + resizers.push({ + orientation: 'horizontal', + positiveSideIndices: unique(positive), + negativeSideIndices: unique(negative) + }) + + startCol = endCol + 1 + } else { + startCol++ + } + } + } + + // Vertical + for (let col = 1; col < cols; col++) { + for (let startRow = 0; startRow < rows; ) { + if (grid[startRow][col - 1] !== grid[startRow][col]) { + let endRow = startRow + + while (endRow + 1 < rows && grid[endRow + 1][col - 1] !== grid[endRow + 1][col]) { + endRow++ + } + + const positive: number[] = [] + const negative: number[] = [] + + for (let row = startRow; row <= endRow; row++) { + negative.push(grid[row][col - 1]) + positive.push(grid[row][col]) + } + + resizers.push({ + orientation: 'vertical', + positiveSideIndices: unique(positive), + negativeSideIndices: unique(negative) + }) + + startRow = endRow + 1 + } else { + startRow++ + } + } + } + + return resizers +} + +// --------------------------------------------------------------------------- +// Zones -> model (GridData.ZonesToModel) +// --------------------------------------------------------------------------- + +export function zonesToModel(zones: GridZone[]): GridLayout { + const xCoords = [...new Set(zones.flatMap(z => [z.left, z.right]))].sort((a, b) => a - b) + const yCoords = [...new Set(zones.flatMap(z => [z.top, z.bottom]))].sort((a, b) => a - b) + + const model: GridLayout = { + rows: yCoords.length - 1, + columns: xCoords.length - 1, + rowPercents: adjacentDifference(yCoords), + columnPercents: adjacentDifference(xCoords), + cellChildMap: Array.from({ length: yCoords.length - 1 }, () => new Array(xCoords.length - 1).fill(0)) + } + + for (let index = 0; index < zones.length; index++) { + const zone = zones[index] + const startRow = yCoords.indexOf(zone.top) + const endRow = yCoords.indexOf(zone.bottom) + const startCol = xCoords.indexOf(zone.left) + const endCol = xCoords.indexOf(zone.right) + + for (let row = startRow; row < endRow; row++) { + for (let col = startCol; col < endCol; col++) { + model.cellChildMap[row][col] = index + } + } + } + + return model +} + +// --------------------------------------------------------------------------- +// Closure + merge (GridData.ComputeClosure / DoMerge) +// --------------------------------------------------------------------------- + +function computeClosure(zones: GridZone[], indices: number[]): { indices: number[]; zone: GridZone } { + let left = Number.MAX_SAFE_INTEGER + let right = Number.MIN_SAFE_INTEGER + let top = Number.MAX_SAFE_INTEGER + let bottom = Number.MIN_SAFE_INTEGER + + if (indices.length === 0) { + return { indices: [], zone: { index: -1, left, right, top, bottom } } + } + + const extend = (zone: GridZone) => { + left = Math.min(left, zone.left) + right = Math.max(right, zone.right) + top = Math.min(top, zone.top) + bottom = Math.max(bottom, zone.bottom) + } + + for (const index of indices) { + extend(zones[index]) + } + + let possiblyBroken = true + + while (possiblyBroken) { + possiblyBroken = false + + for (const zone of zones) { + const area = (zone.bottom - zone.top) * (zone.right - zone.left) + + const cutLeft = Math.max(left, zone.left) + const cutRight = Math.min(right, zone.right) + const cutTop = Math.max(top, zone.top) + const cutBottom = Math.min(bottom, zone.bottom) + + const newArea = Math.max(0, cutBottom - cutTop) * Math.max(0, cutRight - cutLeft) + + if (newArea !== 0 && newArea !== area) { + // Bad intersection found, extend. + extend(zone) + possiblyBroken = true + } + } + } + + const resultIndices = zones + .filter(zone => left <= zone.left && zone.right <= right && top <= zone.top && zone.bottom <= bottom) + .map(zone => zone.index) + + return { indices: resultIndices, zone: { index: -1, left, right, top, bottom } } +} + +export function mergeClosureIndices(model: GridLayout, indices: number[]): number[] { + const zones = modelToZones(model) + + return zones ? computeClosure(zones, indices).indices : [] +} + +export function doMerge(model: GridLayout, indices: number[]): GridLayout { + if (indices.length === 0) { + return model + } + + const zones = modelToZones(model) + + if (!zones) { + return model + } + + const lowestIndex = Math.min(...indices) + const closure = computeClosure(zones, indices) + const closureIndices = new Set(closure.indices) + + const remaining = zones.filter(zone => !closureIndices.has(zone.index)) + remaining.splice(lowestIndex, 0, closure.zone) + + return zonesToModel(remaining) +} + +// --------------------------------------------------------------------------- +// Split (GridData.CanSplit / Split) +// --------------------------------------------------------------------------- + +export function canSplit( + model: GridLayout, + zoneIndex: number, + position: number, + orientation: 'horizontal' | 'vertical' +): boolean { + const zones = modelToZones(model) + + if (!zones || !zones[zoneIndex]) { + return false + } + + const zone = zones[zoneIndex] + + if (orientation === 'horizontal') { + return zone.top + MIN_ZONE_SIZE <= position && position <= zone.bottom - MIN_ZONE_SIZE + } + + return zone.left + MIN_ZONE_SIZE <= position && position <= zone.right - MIN_ZONE_SIZE +} + +export function splitZone( + model: GridLayout, + zoneIndex: number, + position: number, + orientation: 'horizontal' | 'vertical' +): GridLayout { + if (!canSplit(model, zoneIndex, position, orientation)) { + return model + } + + const zones = modelToZones(model)! + const zone = zones[zoneIndex] + const zone1 = { ...zone } + const zone2 = { ...zone } + + zones.splice(zoneIndex, 1) + + if (orientation === 'horizontal') { + zone1.bottom = position + zone2.top = position + } else { + zone1.right = position + zone2.left = position + } + + zones.splice(zoneIndex, 0, zone1) + zones.splice(zoneIndex + 1, 0, zone2) + + return zonesToModel(zones) +} + +// --------------------------------------------------------------------------- +// Resizer drag (GridData.CanDrag / Drag) +// --------------------------------------------------------------------------- + +export function canDrag(model: GridLayout, resizerIndex: number, delta: number): boolean { + const zones = modelToZones(model) + const resizers = modelToResizers(model) + const resizer = resizers[resizerIndex] + + if (!zones || !resizer) { + return false + } + + const getSize = (zoneIndex: number) => { + const zone = zones[zoneIndex] + + return resizer.orientation === 'vertical' ? zone.right - zone.left : zone.bottom - zone.top + } + + for (const zoneIndex of resizer.positiveSideIndices) { + if (getSize(zoneIndex) - delta < MIN_ZONE_SIZE) { + return false + } + } + + for (const zoneIndex of resizer.negativeSideIndices) { + if (getSize(zoneIndex) + delta < MIN_ZONE_SIZE) { + return false + } + } + + return true +} + +export function dragResizer(model: GridLayout, resizerIndex: number, delta: number): GridLayout { + if (!canDrag(model, resizerIndex, delta)) { + return model + } + + const zones = modelToZones(model)! + const resizer = modelToResizers(model)[resizerIndex] + + for (const zoneIndex of resizer.positiveSideIndices) { + const zone = zones[zoneIndex] + + if (resizer.orientation === 'horizontal') { + zone.top += delta + } else { + zone.left += delta + } + } + + for (const zoneIndex of resizer.negativeSideIndices) { + const zone = zones[zoneIndex] + + if (resizer.orientation === 'horizontal') { + zone.bottom += delta + } else { + zone.right += delta + } + } + + return zonesToModel(zones) +} + +// --------------------------------------------------------------------------- +// Templates + validation (GridLayoutModel) +// --------------------------------------------------------------------------- + +/** Even track sizes that sum EXACTLY to MULTIPLIER (GridLayoutModel.InitRows note). */ +function evenPercents(count: number): number[] { + const out: number[] = [] + + for (let i = 0; i < count; i++) { + out.push(Math.floor((MULTIPLIER * (i + 1)) / count) - Math.floor((MULTIPLIER * i) / count)) + } + + return out +} + +export function initColumns(count: number): GridLayout { + return { + rows: 1, + columns: count, + rowPercents: [MULTIPLIER], + columnPercents: evenPercents(count), + cellChildMap: [Array.from({ length: count }, (_, i) => i)] + } +} + +export function initRows(count: number): GridLayout { + return { + rows: count, + columns: 1, + rowPercents: evenPercents(count), + columnPercents: [MULTIPLIER], + cellChildMap: Array.from({ length: count }, (_, i) => [i]) + } +} + +export function initGrid(zoneCount: number): GridLayout { + let rows = 1 + + while (Math.floor(zoneCount / rows) >= rows) { + rows++ + } + + rows-- + let cols = Math.floor(zoneCount / rows) + + if (zoneCount % rows !== 0) { + cols++ + } + + const model: GridLayout = { + rows, + columns: cols, + rowPercents: evenPercents(rows), + columnPercents: evenPercents(cols), + cellChildMap: Array.from({ length: rows }, () => new Array(cols).fill(0)) + } + + let index = 0 + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + model.cellChildMap[row][col] = index++ + + if (index === zoneCount) { + index-- + } + } + } + + return model +} + +/** + * The "Priority Grid" template (GridLayoutModel._priorityData, decoded from + * its byte format: percents are `hi * 256 + lo`). Falls back to initGrid for + * counts beyond the table, as in the original. + */ +export function initPriorityGrid(zoneCount: number): GridLayout { + if (zoneCount === 2) { + return { rows: 1, columns: 2, rowPercents: [MULTIPLIER], columnPercents: [6667, 3333], cellChildMap: [[0, 1]] } + } + + if (zoneCount === 3) { + return { + rows: 1, + columns: 3, + rowPercents: [MULTIPLIER], + columnPercents: [2500, 5000, 2500], + cellChildMap: [[0, 1, 2]] + } + } + + return initGrid(zoneCount) +} + +/** GridLayoutModel.IsModelValid, extended with the rectangular-span check. */ +export function isGridValid(model: unknown): model is GridLayout { + if (!model || typeof model !== 'object') { + return false + } + + const m = model as GridLayout + + if (typeof m.rows !== 'number' || typeof m.columns !== 'number' || m.rows <= 0 || m.columns <= 0) { + return false + } + + if ( + !Array.isArray(m.rowPercents) || + !Array.isArray(m.columnPercents) || + m.rowPercents.length !== m.rows || + m.columnPercents.length !== m.columns || + m.rowPercents.some(x => typeof x !== 'number' || x < 1) || + m.columnPercents.some(x => typeof x !== 'number' || x < 1) + ) { + return false + } + + if ( + !Array.isArray(m.cellChildMap) || + m.cellChildMap.length !== m.rows || + m.cellChildMap.some(r => !Array.isArray(r) || r.length !== m.columns || r.some(c => typeof c !== 'number')) + ) { + return false + } + + const rowPrefix = prefixSum(m.rowPercents) + const colPrefix = prefixSum(m.columnPercents) + + if (rowPrefix[m.rows] !== MULTIPLIER || colPrefix[m.columns] !== MULTIPLIER) { + return false + } + + return modelToZones(m) !== null +} diff --git a/apps/desktop/src/components/pane-shell/tree/grid-to-tree.ts b/apps/desktop/src/components/pane-shell/tree/grid-to-tree.ts new file mode 100644 index 000000000000..e69318e8053a --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/grid-to-tree.ts @@ -0,0 +1,198 @@ +/** + * Grid -> tree bridge. A FancyZones grid whose zones can be produced by + * recursive guillotine cuts (every FancyZones template, and almost every + * practical layout) converts exactly to our runtime `LayoutNode` tree: + * full-length cut lines become splits with weights from the cut positions. + * Non-guillotine arrangements (interlocking pinwheels) return null and the + * editor disables Save with an explanation. + */ + +import type { GridLayout, GridZone } from './grid-model' +import { modelToZones } from './grid-model' +import { group, type LayoutNode, normalize, split } from './model' + +function cutCandidates(zones: GridZone[], axis: 'x' | 'y'): number[] { + const coords = new Set(zones.flatMap(z => (axis === 'x' ? [z.left, z.right] : [z.top, z.bottom]))) + + // Interior lines only. + return [...coords].sort((a, b) => a - b).slice(1, -1) +} + +function isValidCut(zones: GridZone[], axis: 'x' | 'y', at: number): boolean { + return zones.every(zone => (axis === 'x' ? zone.right <= at || zone.left >= at : zone.bottom <= at || zone.top >= at)) +} + +/** + * Recursively cut the zone set. Collects ALL valid cuts on the chosen axis at + * once so "three columns" becomes one flat 3-child split, not nested pairs. + */ +function cut(zones: GridZone[], assignPane: (zoneIndex: number) => string[]): LayoutNode | null { + if (zones.length === 1) { + // Zones without panes become EMPTY groups — editor-authored drop targets + // that live until the first structural op prunes them (normalize). + return group(assignPane(zones[0].index)) + } + + for (const axis of ['x', 'y'] as const) { + const cuts = cutCandidates(zones, axis).filter(at => isValidCut(zones, axis, at)) + + if (cuts.length === 0) { + continue + } + + const start = Math.min(...zones.map(z => (axis === 'x' ? z.left : z.top))) + const end = Math.max(...zones.map(z => (axis === 'x' ? z.right : z.bottom))) + const lines = [start, ...cuts, end] + const children: LayoutNode[] = [] + const weights: number[] = [] + + for (let i = 0; i < lines.length - 1; i++) { + const lo = lines[i] + const hi = lines[i + 1] + + const slice = zones.filter(zone => + axis === 'x' ? zone.left >= lo && zone.right <= hi : zone.top >= lo && zone.bottom <= hi + ) + + if (slice.length === 0) { + continue + } + + const child = cut(slice, assignPane) + + if (child) { + children.push(child) + weights.push(hi - lo) + } + } + + if (children.length === 0) { + return null + } + + if (children.length === 1) { + return children[0] + } + + return split(axis === 'x' ? 'row' : 'column', children, weights) + } + + // No full-length cut exists on either axis: non-guillotine (pinwheel). + return null +} + +export type PanePlacementHint = 'main' | 'left' | 'right' | 'top' | 'bottom' + +export interface PlacedPane { + id: string + placement?: PanePlacementHint +} + +const CENTER = 5000 // MULTIPLIER / 2 + +interface ZoneGeo { + index: number + area: number + cx: number + cy: number +} + +/** + * Semantic zone assignment: panes claim zones by ROLE, matched on geometry — + * `main` takes the largest zone, `left`/`right`/`top`/`bottom` take zones whose + * centroid actually sits on that side (with a small size tiebreak). A hinted + * pane with no acceptable zone left STACKS with its role-mates (or main) + * instead of squatting in some random cell; unhinted panes fill what remains + * biggest-first; extra zones stay empty. + */ +function assignZones(zones: GridZone[], panes: PlacedPane[]): Map { + const geo: ZoneGeo[] = zones.map(z => ({ + index: z.index, + area: (z.right - z.left) * (z.bottom - z.top), + cx: (z.left + z.right) / 2, + cy: (z.top + z.bottom) / 2 + })) + + const remaining = new Map(geo.map(g => [g.index, g])) + const assignments = new Map() + const zoneForRole = new Map() + + // Score = fit for the role; accept = the zone genuinely sits on that side. + const roles: Record boolean; score: (g: ZoneGeo) => number }> = { + main: { accept: () => true, score: g => g.area }, + left: { accept: g => g.cx < CENTER, score: g => CENTER - g.cx + g.area / 1e8 }, + right: { accept: g => g.cx > CENTER, score: g => g.cx - CENTER + g.area / 1e8 }, + top: { accept: g => g.cy < CENTER, score: g => CENTER - g.cy + g.area / 1e8 }, + bottom: { accept: g => g.cy > CENTER, score: g => g.cy - CENTER + g.area / 1e8 } + } + + const claim = (pane: PlacedPane, role: PanePlacementHint | '_') => { + const spec = role === '_' ? roles.main : roles[role] + let best: ZoneGeo | null = null + + for (const g of remaining.values()) { + if (spec.accept(g) && (!best || spec.score(g) > spec.score(best))) { + best = g + } + } + + if (best) { + remaining.delete(best.index) + assignments.set(best.index, [pane.id]) + zoneForRole.set(role, best.index) + + if (role === 'main' || !zoneForRole.has('main')) { + zoneForRole.set('main', zoneForRole.get('main') ?? best.index) + } + + return + } + + // No acceptable zone left: stack with role-mates, else with main, else last. + const home = zoneForRole.get(role) ?? zoneForRole.get('main') ?? [...assignments.keys()].pop() + + if (home !== undefined) { + assignments.get(home)?.push(pane.id) + } + } + + // Placement priority: main anchors first, then sided panes, then the rest. + const rank = (p: PlacedPane) => (p.placement === 'main' ? 0 : p.placement ? 1 : 2) + + for (const pane of [...panes].sort((a, b) => rank(a) - rank(b))) { + claim(pane, pane.placement ?? '_') + } + + return assignments +} + +/** + * Convert a grid to a tree. Panes carry placement hints (from their + * contribution's `data.placement`) and land in geometrically fitting zones; + * zones beyond the pane count stay as EMPTY zones. + */ +export function gridToTree(gridModel: GridLayout, panes: PlacedPane[]): LayoutNode | null { + const zones = modelToZones(gridModel) + + if (!zones || zones.length === 0 || panes.length === 0) { + return null + } + + const assignments = assignZones(zones, panes) + const result = cut(zones, zoneIndex => assignments.get(zoneIndex) ?? []) + + return result ? normalize(result) : null +} + +/** True when the grid is expressible as a tree (guillotine-cuttable). */ +export function gridIsTreeExpressible(gridModel: GridLayout): boolean { + const zones = modelToZones(gridModel) + + if (!zones) { + return false + } + + const probe = cut(zones, () => ['probe']) + + return probe !== null +} diff --git a/apps/desktop/src/components/pane-shell/tree/presets.ts b/apps/desktop/src/components/pane-shell/tree/presets.ts new file mode 100644 index 000000000000..c0eb27f874d4 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/presets.ts @@ -0,0 +1,111 @@ +/** + * Layout presets — the FancyZones treatment. + * + * A preset is a CONTRIBUTION (`area: 'layouts'`, `data: LayoutNode`): the app + * registers its bundled presets as `source: 'core'`, plugins register theirs + * exactly the same way, and user-saved presets round-trip through localStorage + * and re-register as `source: 'user'`. The picker (renderer.tsx) reads one + * uniform list via `useContributions('layouts')`. + */ + +import { registry } from '@/contrib/registry' +import { readJson, writeJson, writeKey } from '@/lib/storage' + +import { isLayoutNode, type LayoutNode } from './model' +import { $layoutTree, applyTree, markActivePreset } from './store' + +export const LAYOUTS_AREA = 'layouts' + +// v2: v1 presets predate semantic placement (see store.ts) — retire them. +const USER_KEY = 'hermes.desktop.layoutPresets.v2' + +writeKey('hermes.desktop.layoutPresets.v1', null) + +interface StoredPreset { + name: string + tree: LayoutNode +} + +const userDisposers = new Map void>() + +function loadUserPresets(): Record { + const parsed = readJson>(USER_KEY) ?? {} + const out: Record = {} + + for (const [id, preset] of Object.entries(parsed)) { + if (preset && typeof preset.name === 'string' && isLayoutNode(preset.tree)) { + out[id] = preset + } + } + + return out +} + +function persistUserPresets(presets: Record) { + writeJson(USER_KEY, presets) +} + +function registerUserPreset(id: string, preset: StoredPreset) { + userDisposers.get(id)?.() + userDisposers.set( + id, + registry.register({ id, area: LAYOUTS_AREA, source: 'user', title: preset.name, data: preset.tree }) + ) +} + +// Register persisted user presets at module load. +const userPresets = loadUserPresets() + +for (const [id, preset] of Object.entries(userPresets)) { + registerUserPreset(id, preset) +} + +/** Save any tree as a named user preset (and make it active). */ +export function saveLayoutPresetTree(name: string, tree: LayoutNode): string | null { + const trimmed = name.trim() + + if (!tree || !trimmed) { + return null + } + + const id = `user-${ + trimmed + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || Date.now().toString(36) + }` + + userPresets[id] = { name: trimmed, tree } + persistUserPresets(userPresets) + registerUserPreset(id, userPresets[id]) + markActivePreset(id) + + return id +} + +/** Save the CURRENT tree as a named user preset (and make it active). */ +export function saveCurrentLayoutAs(name: string) { + const tree = $layoutTree.get() + + if (tree) { + saveLayoutPresetTree(name, tree) + } +} + +export function deleteUserPreset(id: string) { + if (!(id in userPresets)) { + return + } + + delete userPresets[id] + persistUserPresets(userPresets) + userDisposers.get(id)?.() + userDisposers.delete(id) +} + +export const isUserPreset = (id: string) => id in userPresets + +/** Apply a preset's tree (deep-cloned so live edits never mutate the preset). */ +export function applyLayoutPreset(id: string, tree: LayoutNode) { + applyTree(structuredClone(tree), id) +} 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 new file mode 100644 index 000000000000..d41f5b2e8b3c --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts @@ -0,0 +1,588 @@ +/** + * THE in-app drag primitive. One pointer-capture session (`startDragSession`) + * owns the machinery every in-app drag shares — threshold, rAF-coalesced + * moves, cursor/user-select chrome, ghost chip, Esc-as-top-escape-layer, + * hint publishing, teardown — and a per-kind RESOLVER supplies the semantics: + * what the pointer is over (`resolveMove` → DropHint) and what a release + * does (`onCommit`). Pane/tab drags (below) are the first resolver; the + * sidebar session drag (app/chat/session-drag.ts) is the second. Native + * HTML5 DnD is reserved for true OS boundaries (Finder file drops) — in-app + * drags never ride it, so no snap-back animation, no hostile-library + * armor, and Esc aborts synchronously. + * + * Pane drags use the FancyZones engine (zones-engine.ts, ported verbatim): + * sensitivity-radius hit testing, HighlightedZones state machine, Shift = + * select-many (combined zone range), ClosestCenter primary on drop. The + * LAYOUT STAYS FIXED and every zone lights up as a whole-region drop target; + * NOTHING moves until release (tab reorder included — the strip shows an + * insertion divider, not a live shuffle). Over a zone's TAB STRIP the drop + * stacks at the divider's slot; elsewhere the radial position picks + * center/edge. + * + * PERFORMANCE CONTRACT: the layout never restructures mid-drag, so every + * rect a resolver needs is snapshotted once at drag start (zones AND tab + * strips) and each pointermove is pure math against those caches — no + * elementsFromPoint, no getBoundingClientRect, no style writes unless a + * value actually changed. Moves are coalesced to one hit-test per animation + * frame, with the pending move flushed synchronously on release so the drop + * commits at the exact final position. + */ + +import type { PointerEvent as ReactPointerEvent } from 'react' + +import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers' +import { reorderCommitHaptic, reorderStepHaptic } from '@/lib/reorder' + +import type { DropPosition } from '../model' +import { $dropHint, $treeDragging, type DropHint, mergeTreeZones, moveTreePane, reorderTreePane } from '../store' +import { type EngineZone, HighlightedZones, primaryZone, type ZoneRect } from '../zones-engine' + +const DRAG_THRESHOLD_PX = 4 + +/** Normalized radius of the elliptical CENTER region (stack/link). Outside it + * the drop targets the dominant-axis edge — the boundary curves with the + * zone's aspect ratio instead of snapping at a rigid pixel band, and corners + * ease into their nearest edge along the quadrant diagonals. */ +const CENTER_RADIUS = 0.62 + +export function snapshotZones(): EngineZone[] { + return [...document.querySelectorAll('[data-tree-group]')].map(el => { + const r = el.getBoundingClientRect() + + return { id: el.dataset.treeGroup!, rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom } } + }) +} + +/** Radial drop position within `rect`: inside the center ellipse = the center + * action (stack/link); outside, the dominant axis picks the edge (VS Code + * dock-preview geometry). `centerRadius` sizes the ellipse — larger = more + * center, slimmer curved edge bands. */ +export function radialPosition( + rect: { left: number; top: number; right: number; bottom: number }, + x: number, + y: number, + centerRadius = CENTER_RADIUS +): DropPosition { + // Zone-centered coordinates, ±1 at the edge midpoints. + const dx = ((x - rect.left) / Math.max(1, rect.right - rect.left)) * 2 - 1 + const dy = ((y - rect.top) / Math.max(1, rect.bottom - rect.top)) * 2 - 1 + + if (Math.hypot(dx, dy) < centerRadius) { + return 'center' + } + + 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). */ +export function subZonePosition(zones: EngineZone[], groupId: string, x: number, y: number): DropPosition { + const rect = zones.find(zone => zone.id === groupId)?.rect + + return rect ? radialPosition(rect, x, y) : 'center' +} + +/** One tab's insertion geometry: its pane id + horizontal midpoint. */ +interface StripSlot { + id: string + mid: number +} + +const stripSlots = (strip: HTMLElement): StripSlot[] => + [...strip.querySelectorAll('[data-tree-tab]')].map(tab => { + const r = tab.getBoundingClientRect() + + return { id: tab.dataset.treeTab ?? '', mid: r.left + r.width / 2 } + }) + +/** Insertion slot from the pointer x against the OTHER tabs' midpoints: + * stack BEFORE the returned pane id (`null` = append). */ +export function slotBefore(slots: StripSlot[], x: number, excludePaneId = ''): { before: null | string } { + for (const slot of slots) { + if (slot.id === excludePaneId) { + continue + } + + if (x < slot.mid) { + return { before: slot.id } + } + } + + return { before: null } +} + +/** Drag-start snapshot of one zone's tab strip. Strips never overlap and the + * layout never restructures mid-drag, so rect containment replaces a + * per-move elementsFromPoint hit test. A drop on a strip STACKS at the + * divider's slot — the strip is where tabs live, so it wins over the radial + * top-edge band that would otherwise read as "split top". */ +export interface StripSnapshot { + groupId: string + rect: ZoneRect + slots: StripSlot[] +} + +export function snapshotStrips(): StripSnapshot[] { + return [...document.querySelectorAll('[data-zone-tabstrip]')].map(el => { + const r = el.getBoundingClientRect() + + return { + groupId: el.dataset.zoneTabstrip!, + rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom }, + slots: stripSlots(el) + } + }) +} + +export const rectContains = (rect: ZoneRect, x: number, y: number, pad = 0) => + x >= rect.left - pad && x <= rect.right + pad && y >= rect.top - pad && y <= rect.bottom + pad + +const sameHint = (a: DropHint | null, b: DropHint | null) => + a?.groupId === b?.groupId && + a?.pos === b?.pos && + a?.stack?.before === b?.stack?.before && + (a?.stack === undefined) === (b?.stack === undefined) && + (a?.groupIds?.length ?? 0) === (b?.groupIds?.length ?? 0) && + (a?.groupIds ?? []).every((id, i) => b?.groupIds?.[i] === id) + +/** Double-tap detection for drag handles. Pane handles preventDefault + * pointerdown, which suppresses native `dblclick` — so rapid same-handle + * taps are detected here instead. */ +const DOUBLE_TAP_MS = 400 +let lastTap: { key: string; time: number } | null = null + +export interface DoubleTapContext { + /** Two sub-threshold releases with the same key within DOUBLE_TAP_MS. */ + key: string + onDoubleTap: () => void +} + +// --------------------------------------------------------------------------- +// The generic drag session (machinery) — resolvers plug in below / elsewhere. +// --------------------------------------------------------------------------- + +export interface DragSessionSpec { + /** Movement crossed the drag threshold: snapshot geometry, set the drag + * store(s), dim/mark the source. Runs once. */ + onEngage(x: number, y: number): void + /** Per-frame target resolution — pure math against drag-start snapshots. + * Returns the hint to publish; `null` = deny area (no-drop cursor, a + * release commits nothing). */ + resolveMove(x: number, y: number, shift: boolean): DropHint | null + /** Release over the final published hint (already flushed to the exact + * release position). Only called for engaged drags. */ + onCommit(hint: DropHint | null): void + /** Teardown for both commit and abort — undo whatever onEngage marked. */ + onEnd?(): void + /** Sub-threshold release = a click on the handle. */ + 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). */ + 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 + * the same task burst as pointerup; an Esc abort's click arrives with the + * eventual release, so the trap disarms right after it. */ +function suppressDragClick(committed: boolean) { + const swallow = (ev: MouseEvent) => { + ev.preventDefault() + ev.stopPropagation() + } + + window.addEventListener('click', swallow, { capture: true, once: true }) + + const disarm = () => window.setTimeout(() => window.removeEventListener('click', swallow, true), 0) + + if (committed) { + disarm() + } else { + window.addEventListener('pointerup', disarm, { capture: true, once: true }) + window.addEventListener('pointercancel', disarm, { capture: true, once: true }) + } +} + +/** + * Begin a drag session from a handle's pointerdown. A sub-threshold release + * is a click (`onTap` / `double.onDoubleTap`); past the threshold the spec's + * resolver owns targeting and the machinery owns everything else. Esc aborts + * instantly: the session registers as the TOP escape layer, tears down + * synchronously, and nothing commits. + */ +export function startDragSession(e: ReactPointerEvent, spec: DragSessionSpec) { + if (e.button !== 0) { + return + } + + const handle = e.currentTarget + const { pointerId } = e + const sx = e.clientX + const sy = e.clientY + const restoreCursor = document.body.style.cursor + const restoreSelect = document.body.style.userSelect + let engaged = false + let releaseEscapeLayer: (() => void) | null = null + let ghost: HTMLElement | 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. + let pending: { x: number; y: number; shift: boolean } | null = null + let raf = 0 + + // Cursor writes are per-frame; only touch the style when the value changes. + const setCursor = (value: string) => { + if (cursor !== value) { + cursor = value + document.body.style.cursor = value + } + } + + const publishHint = (next: DropHint | null) => { + if (!sameHint($dropHint.get(), next)) { + if (next?.stack !== undefined && $dropHint.get()?.stack?.before !== next.stack.before) { + reorderStepHaptic() + } + + $dropHint.set(next) + } + } + + const engage = (x: number, y: number) => { + engaged = true + + // Capture only once ENGAGED: pre-threshold pointer events must stay + // untouched so a plain click on the handle (and its children — a row + // body's own onClick) keeps working. Window-level listeners track the + // gesture either way. + try { + handle.setPointerCapture?.(pointerId) + } catch { + // Synthetic events (automation) have no active pointer. + } + + setCursor('grabbing') + document.body.style.userSelect = 'none' + // While dragging, Esc belongs to the drag ALONE — lower layers (edit + // mode, overlays) must not also fire on the same press. + releaseEscapeLayer = pushEscapeLayer(ESCAPE_PRIORITY.drag) + + if (spec.ghost) { + ghost = createGhost(spec.ghost.label) + } + + spec.onEngage(x, y) + } + + const processMove = (x: number, y: number, shift: boolean) => { + if (!engaged) { + if (Math.hypot(x - sx, y - sy) < DRAG_THRESHOLD_PX) { + return + } + + engage(x, y) + } + + if (ghost) { + ghost.style.transform = `translate3d(${x + 14}px, ${y + 12}px, 0)` + } + + const hint = spec.resolveMove(x, y, shift) + + // Over a deny area (no target — titlebar / statusbar / gutters / + // off-window) the release cancels; the cursor says so up front. + setCursor(hint ? 'grabbing' : 'no-drop') + publishHint(hint) + } + + const flushMove = () => { + raf = 0 + + if (pending) { + const { shift, x, y } = pending + pending = null + processMove(x, y, shift) + } + } + + const onMove = (ev: PointerEvent) => { + pending = { shift: ev.shiftKey, x: ev.clientX, y: ev.clientY } + raf ||= requestAnimationFrame(flushMove) + } + + const finish = (commit: boolean) => { + if (raf) { + cancelAnimationFrame(raf) + raf = 0 + } + + // The drop must land at the FINAL pointer position, not the last painted + // frame's — flush the pending move before reading the hint. An abort + // (Esc / pointercancel) skips it: everything is discarded anyway. + if (commit) { + flushMove() + } + + document.body.style.cursor = restoreCursor + document.body.style.userSelect = restoreSelect + ghost?.remove() + ghost = null + releaseEscapeLayer?.() + releaseEscapeLayer = null + + try { + handle.releasePointerCapture?.(pointerId) + } catch { + // Mirror of the capture guard. + } + + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', onUp, true) + window.removeEventListener('pointercancel', onCancel, true) + window.removeEventListener('keydown', onKey, true) + + if (engaged) { + suppressDragClick(commit) + + if (commit) { + spec.onCommit($dropHint.get()) + } + } else if (commit) { + const now = Date.now() + + if (spec.double && lastTap?.key === spec.double.key && now - lastTap.time < DOUBLE_TAP_MS) { + lastTap = null + spec.double.onDoubleTap() + } else { + lastTap = spec.double ? { key: spec.double.key, time: now } : null + spec.onTap?.() + } + } + + spec.onEnd?.() + $dropHint.set(null) + $treeDragging.set(null) + } + + const onUp = () => finish(true) + const onCancel = () => finish(false) + + // Esc aborts the drag — the target selection vanishes and nothing moves, + // the universal "never mind" for an in-flight drag. Capture-phase + stop so + // it doesn't also close a pane/overlay behind the drag (the escape layer + // covers contract-following handlers; the stop covers the rest). + const onKey = (ev: KeyboardEvent) => { + if (ev.key === 'Escape') { + ev.preventDefault() + ev.stopPropagation() + finish(false) + } + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', onUp, true) + window.addEventListener('pointercancel', onCancel, true) + window.addEventListener('keydown', onKey, true) +} + +// --------------------------------------------------------------------------- +// Pane drag — the tree's resolver over the generic session. +// --------------------------------------------------------------------------- + +interface ReorderContext { + groupId: string + /** The tab-strip element; tabs carry `data-tree-tab={paneId}`. */ + strip: HTMLElement +} + +/** How far (px) the pointer may stray from the strip before a tab drag stops + * being a reorder and becomes a zone move (browser-tab tear-off feel). */ +const TEAR_OFF_SLACK_PX = 18 + +/** + * Begin a pane drag from any handle. A sub-threshold release is a click + * (`onTap`, used to activate tabs; rapid repeat fires `double.onDoubleTap` + * instead). With a `reorder` context (tab drags), movement inside the strip + * targets an insertion slot — the strip renders a divider at it, NOTHING + * moves until release (placement-on-release, like every other drop); tearing + * away from the strip converts the drag into a zone move. Zone mode: zones + * 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. + */ +export function startPaneDrag( + paneId: string, + e: ReactPointerEvent, + onTap?: () => void, + reorder?: ReorderContext, + double?: DoubleTapContext +) { + if (e.button !== 0) { + return + } + + e.preventDefault() + e.stopPropagation() + + const highlighted = new HighlightedZones() + let zones: EngineZone[] = [] + let strips: StripSnapshot[] = [] + let mode: 'reorder' | 'zone' | null = null + let dimmed: HTMLElement | null = null + + const markSource = () => { + // The dragged tab dims for the drag's life — the divider says where it + // GOES, the dim says what MOVES. No live shuffle (placement-on-release). + dimmed ??= reorder?.strip.querySelector(`[data-tree-tab="${CSS.escape(paneId)}"]`) ?? null + dimmed?.style.setProperty('opacity', '0.45') + } + + const enterZoneMode = () => { + mode = 'zone' + // The layout never restructures mid-drag, so zone/strip rects are stable. + zones = snapshotZones() + strips = snapshotStrips() + $treeDragging.set(paneId) + markSource() + } + + // The reorder strip's geometry, snapshotted on first use (same fixed-layout + // guarantee as the zone snapshots). + let reorderSnap: { rect: ZoneRect; slots: StripSlot[] } | null = null + + const reorderStrip = () => { + if (!reorderSnap) { + const r = reorder!.strip.getBoundingClientRect() + reorderSnap = { + rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom }, + slots: stripSlots(reorder!.strip) + } + } + + return reorderSnap + } + + const withinStrip = (x: number, y: number) => + Boolean(reorder) && rectContains(reorderStrip().rect, x, y, TEAR_OFF_SLACK_PX) + + startDragSession(e, { + double, + onTap, + + onEngage(x, y) { + if (reorder && withinStrip(x, y)) { + mode = 'reorder' + markSource() + } else { + enterZoneMode() + } + }, + + resolveMove(x, y, shift) { + if (mode === 'reorder') { + if (withinStrip(x, y)) { + return { + kind: 'group', + groupId: reorder!.groupId, + groupIds: [reorder!.groupId], + pos: 'center', + stack: slotBefore(reorderStrip().slots, x, paneId) + } + } + + // Tear-off: the tab leaves the strip and becomes a zone move. + enterZoneMode() + } + + // The hint updates on highlight-set changes AND on sub-zone position + // changes (center/edge regions within the same primary zone). + const point = { x, y } + highlighted.update(zones, point, shift) + let groupIds = [...highlighted.zones()] + + // Spanning multiple zones is EXPLICIT (Shift). Without it, the seam- + // proximity capture (sensitivity radius grabs both neighbors near a + // shared edge) collapses to the primary zone — otherwise a drop near a + // seam silently merges zones the user never asked to merge. + if (!shift && groupIds.length > 1) { + const collapsed = primaryZone(zones, groupIds, point) + groupIds = collapsed ? [collapsed] : [] + } + + const groupId = groupIds.length > 0 ? (primaryZone(zones, groupIds, point) ?? undefined) : undefined + + // Over the target's TAB STRIP the drop stacks at the divider's slot; + // sub-positions only make sense for a single-zone drop (a Shift-span + // always merges, pos ignored). + const strip = + groupIds.length === 1 && groupId ? strips.find(s => s.groupId === groupId && rectContains(s.rect, x, y)) : null + + const stack = strip ? slotBefore(strip.slots, x, paneId) : undefined + + const pos: DropPosition = stack + ? 'center' + : groupIds.length === 1 && groupId + ? subZonePosition(zones, groupId, x, y) + : 'center' + + return groupIds.length > 0 ? { kind: 'group', groupId, groupIds, pos, stack } : null + }, + + onCommit(hint) { + if (mode === 'reorder' && reorder && hint?.stack !== undefined) { + // Slot -> index among the OTHER tabs (reorderPaneInGroup inserts there). + const others = [...reorder.strip.querySelectorAll('[data-tree-tab]')] + .map(el => el.dataset.treeTab) + .filter((id): id is string => Boolean(id) && id !== paneId) + + const toIndex = hint.stack.before ? others.indexOf(hint.stack.before) : others.length + + if (toIndex >= 0) { + reorderTreePane(reorder.groupId, paneId, toIndex) + reorderCommitHaptic() + } + } + + if (mode === 'zone') { + // Drop what the hint SHOWS — the overlay and the commit share one + // truth (the raw highlight set can hold both seam neighbors; the hint + // already collapsed that to the primary unless Shift made the span + // explicit). + const targets = hint?.groupIds ?? [] + + if (targets.length > 1) { + // Shift-span: merge the highlighted zones, dropping the pane across them. + mergeTreeZones([...targets], paneId, hint?.groupId ?? null) + } else if (hint?.groupId) { + // strip = stack at the divider slot; center = join the stack; + // an edge = split the zone and land there. + moveTreePane(paneId, { groupId: hint.groupId, pos: hint.pos ?? 'center', before: hint.stack?.before }) + } + } + }, + + onEnd() { + dimmed?.style.removeProperty('opacity') + highlighted.reset() + } + }) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/edit-bar.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/edit-bar.tsx new file mode 100644 index 000000000000..56e5c66e22e8 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/edit-bar.tsx @@ -0,0 +1,113 @@ +/** + * Edit-mode palette — the floating, draggable "Layouts" card shown while + * layout edit mode is on. It hosts the layout picker and the reset/done + * actions; its header doubles as the drag handle. Position survives edit-mode + * toggles within a session. + */ + +import { useStore } from '@nanostores/react' +import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { useI18n } from '@/i18n' +import { formatCombo } from '@/lib/keybinds/combo' +import { $bindings, bindingsFor } from '@/store/keybinds' + +import { $layoutEditMode } from '../../edit-mode' +import { resetLayoutTree } from '../store' + +import { LayoutPicker } from './layout-picker' + +// Palette position survives edit-mode toggles within a session; null = centered. +let lastPalettePos: { x: number; y: number } | null = null + +export function TreeEditBar() { + const { t } = useI18n() + const editMode = useStore($layoutEditMode) + const bindings = useStore($bindings) + const [pos, setPos] = useState(lastPalettePos) + const cardRef = useRef(null) + // The toggle is a `keybinds` contribution (`layout.editMode`) — show the + // user's LIVE binding, not a hardcoded chord. + const toggleCombo = bindingsFor('layout.editMode', bindings)[0] + + const startMove = useCallback((e: ReactPointerEvent) => { + if (e.button !== 0) { + return + } + + const card = cardRef.current + const parent = card?.parentElement + + if (!card || !parent) { + return + } + + e.preventDefault() + + const cardRect = card.getBoundingClientRect() + const dx = e.clientX - cardRect.x + const dy = e.clientY - cardRect.y + + const onMove = (ev: PointerEvent) => { + const parentRect = parent.getBoundingClientRect() + + const next = { + x: Math.max(4, Math.min(parentRect.width - cardRect.width - 4, ev.clientX - parentRect.x - dx)), + y: Math.max(4, Math.min(parentRect.height - 40, ev.clientY - parentRect.y - dy)) + } + + lastPalettePos = next + setPos(next) + } + + const onUp = () => { + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', onUp, true) + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', onUp, true) + }, []) + + if (!editMode) { + return null + } + + return ( +
+ {/* Header doubles as the drag handle (Panel-style title + actions). */} +
+
+

{t.zones.editTitle}

+

+ {t.zones.editHint}{' '} + {toggleCombo && ( + + {formatCombo(toggleCombo)} + + )} +

+
+
e.stopPropagation()}> + + +
+
+
+ +
+
+ ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx new file mode 100644 index 000000000000..04a0f9dca187 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx @@ -0,0 +1,81 @@ +/** + * Layout tree renderer (root). + * + * - `split` -> flex row/column; 1px seams between siblings double as resize + * sashes (the seam IS the boundary — junction-owned, never doubled). See + * tree-split.tsx. + * - `group` -> a ZONE: header strip (tabs when stacked, minimize chevron) + + * the active pane's content, resolved from the contribution registry + * (`area: 'panes'`). Empty zones exist only in editor-authored trees. See + * tree-group.tsx. + * + * Dragging is FancyZones-style (drag-session.ts): the LAYOUT STAYS FIXED and + * every zone lights up as a whole-region drop target; dropping moves the pane + * into that zone (joining its tab stack). Structure changes (splitting/merging/ + * resizing zones) belong to the zone editor, not the drag. + * + * This file owns only the composition: the recursive tree, the narrow-viewport + * overlays, the edit palette, and the zone editor. The pieces live in sibling + * modules — track-model (sizing), drag-session (drag), tree-split / tree-group + * (nodes), layout-picker + edit-bar (edit mode), narrow-overlays. + */ + +import { useStore } from '@nanostores/react' +import { type ReactNode, useEffect } from 'react' + +import { useLayoutEditHotkey } from '../../edit-mode' +import { publishWorkspaceGeometry } from '../../geometry' +import { $layoutTree, trackActiveTreeGroup } from '../store' +import { ZoneEditor } from '../zone-editor' + +import { TreeEditBar } from './edit-bar' +import { NarrowOverlays } from './narrow-overlays' +import { TreeNode } from './tree-node' + +export function LayoutTreeRoot({ children }: { children?: ReactNode }) { + const tree = useStore($layoutTree) + + useLayoutEditHotkey(true) + // Track the interacted zone so ⌘W closes the right tab even when nothing is + // DOM-focused. + useEffect(trackActiveTreeGroup, []) + // Publish --workspace-left/right so chrome (titlebar title) aligns to the + // main pane's geometry in plain CSS. + useEffect(publishWorkspaceGeometry, []) + + if (!tree) { + return null + } + + return ( +
+ {/* ZonesOverlay::GetAnimationAlpha ramp: clamp(t / 200ms, 0.001, 1). */} + + {/* THE SEAM INVARIANT: boundaries are drawn by the tree (one sash + hairline per seam) — content mounted in a zone must not paint its + own edge chrome. App components (asides, the shadcn sidebar) carry + edge borders + inset highlights for the OLD shell's geometry; this + neutralizes all of them at the zone boundary, for every current and + future pane, instead of per-pane class surgery. */} + + + + + + {children} +
+ ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/layout-picker.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/layout-picker.tsx new file mode 100644 index 000000000000..95b303501f51 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/layout-picker.tsx @@ -0,0 +1,203 @@ +/** + * Layout picker — the preset card grid inside the edit palette. Thumbnails + * are a miniature render of each preset's layout tree; clicking a card + * applies it, and "Save current arrangement" captures the live tree as a + * user preset. The "New grid layout" button opens the zone editor. + */ + +import { useStore } from '@nanostores/react' +import { type ReactNode, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Input } from '@/components/ui/input' +import { useContributions } from '@/contrib/react/use-contributions' +import type { Contribution } from '@/contrib/types' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' + +import type { LayoutNode } from '../model' +import { isLayoutNode } from '../model' +import { applyLayoutPreset, deleteUserPreset, isUserPreset, LAYOUTS_AREA, saveCurrentLayoutAs } from '../presets' +import { $activePresetId } from '../store' +import { $zoneEditorOpen } from '../zone-editor' + +/** Miniature render of a layout tree — the preset card thumbnail. */ +function TreeThumbnail({ node }: { node: LayoutNode }) { + if (node.type === 'group') { + return ( + // currentColor-derived fill: light zones on dark themes, dark zones on + // light — legible everywhere without leaning on the accent. +
+ ) + } + + return ( +
+ {node.children.map((child, i) => ( +
+ +
+ ))} +
+ ) +} + +/** Small-caps section heading — the app's SidebarPanelLabel voice. */ +function PickerSectionLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function PresetCard({ preset }: { preset: Contribution }) { + const { t } = useI18n() + const activeId = useStore($activePresetId) + const tree = isLayoutNode(preset.data) ? preset.data : null + + if (!tree) { + return null + } + + const active = preset.id === activeId + + return ( +
+ + {isUserPreset(preset.id) && ( + + )} +
+ ) +} + +export function LayoutPicker() { + const { t } = useI18n() + const presets = useContributions(LAYOUTS_AREA) + const [name, setName] = useState('') + const [saving, setSaving] = useState(false) + + const templates = presets.filter(p => !isUserPreset(p.id) && isLayoutNode(p.data)) + const custom = presets.filter(p => isUserPreset(p.id) && isLayoutNode(p.data)) + + const commitSave = () => { + if (!name.trim()) { + return + } + + saveCurrentLayoutAs(name) + setName('') + setSaving(false) + } + + return ( +
+
+ {t.zones.templates} +
+ {templates.map(preset => ( + + ))} +
+
+ +
+ {t.zones.custom} + {custom.length > 0 && ( +
+ {custom.map(preset => ( + + ))} +
+ )} + +
+ + {/* Save-current lives behind a reveal so the raw input doesn't clash + with the card grid until it's actually needed. */} + {saving ? ( +
{ + e.preventDefault() + commitSave() + }} + > + setName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Escape') { + setSaving(false) + setName('') + } + }} + placeholder={t.zones.nameLayoutPlaceholder} + value={name} + /> + + +
+ ) : ( + + )} +
+ ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx new file mode 100644 index 000000000000..3d75388d77e9 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx @@ -0,0 +1,146 @@ +/** + * Narrow-viewport edge overlays — the tree's take on the app's hover-reveal + * collapse. Collapsible panes leave the grid below the sidebar-collapse + * breakpoint; an edge strip (hover) or PANE_TOGGLE_REVEAL_EVENT (⌘B / ⌘G / + * titlebar toggles route here on narrow) slides the pane OVER the layout + * instead of squeezing it. Event reveals pin; hover reveals follow the mouse. + */ + +import { useStore } from '@nanostores/react' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { ContribBoundary } from '@/contrib/react/boundary' +import { useContributions } from '@/contrib/react/use-contributions' +import type { Contribution } from '@/contrib/types' +import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers' +import { cn } from '@/lib/utils' + +import { PANE_TOGGLE_REVEAL_EVENT } from '../..' +import { allPaneIds } from '../model' +import { $hiddenTreePanes, $layoutTree, $narrowViewport } from '../store' + +import { paneChrome } from './track-model' + +export function NarrowOverlays() { + const narrow = useStore($narrowViewport) + const tree = useStore($layoutTree) + const panes = useContributions('panes') + const hiddenPanes = useStore($hiddenTreePanes) + const [reveal, setReveal] = useState<{ id: string; pinned: boolean } | null>(null) + + // Own an Escape layer only while something is revealed, so Escape closes the + // overlay only when it's the top layer (never under a dialog / edit mode). + const revealActive = reveal !== null + useEffect(() => (revealActive ? pushEscapeLayer(ESCAPE_PRIORITY.narrowOverlay) : undefined), [revealActive]) + + const inTree = useMemo(() => new Set(tree ? allPaneIds(tree) : []), [tree]) + + const collapsibles = useMemo( + () => panes.filter(p => paneChrome(p).collapsible && inTree.has(p.id) && !hiddenPanes.has(p.id)), + [panes, inTree, hiddenPanes] + ) + + const collapsiblesRef = useRef(collapsibles) + collapsiblesRef.current = collapsibles + + // ⌘B / ⌘G's narrow branch dispatches the app's toggle-reveal event with the + // REAL pane id — accept those via each contribution's revealAliases. + useEffect(() => { + if (!narrow) { + setReveal(null) + + return + } + + const onToggle = (event: Event) => { + const detail = (event as CustomEvent<{ id?: string; mode?: 'close' | 'open' | 'toggle' }>).detail + const id = detail?.id + + if (!id) { + return + } + + const match = collapsiblesRef.current.find(p => p.id === id || paneChrome(p).revealAliases?.includes(id)) + + if (!match) { + return + } + + // `open`/`close` are explicit intents (programmatic reveal, titlebar show); + // `toggle` (default) is the ⌘B/⌘G flip. + const mode = detail?.mode ?? 'toggle' + setReveal(current => { + if (mode === 'open') { + return { id: match.id, pinned: true } + } + + if (mode === 'close') { + return current?.id === match.id ? null : current + } + + return current?.id === match.id && current.pinned ? null : { id: match.id, pinned: true } + }) + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)) { + return + } + + event.preventDefault() + setReveal(null) + } + + window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) + window.addEventListener('keydown', onKeyDown) + + return () => { + window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) + window.removeEventListener('keydown', onKeyDown) + } + }, [narrow]) + + if (!narrow || collapsibles.length === 0) { + return null + } + + const sideOf = (c: Contribution) => (paneChrome(c).placement === 'left' ? 'left' : 'right') + const revealed = reveal ? collapsibles.find(p => p.id === reveal.id) : undefined + const sides = [...new Set(collapsibles.map(sideOf))] + + return ( + <> + {/* Hover-intent strips on each edge that has a collapsed pane. */} + {sides.map(side => ( +
{ + const first = collapsibles.find(p => sideOf(p) === side) + + if (first) { + setReveal(current => (current?.pinned ? current : { id: first.id, pinned: false })) + } + }} + /> + ))} + + {revealed && ( +
setReveal(current => (current?.pinned ? current : null))} + // Match the pane's docked width (sessions ~237px, files its rail + // width) instead of a fat fixed 20rem — capped for tiny screens. + style={{ width: `min(${(revealed.data as { width?: string } | undefined)?.width ?? '18rem'}, 85vw)` }} + > + {revealed.render?.()} +
+ )} + + ) +} 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 new file mode 100644 index 000000000000..d6e9122082fc --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts @@ -0,0 +1,260 @@ +/** + * The TRACK MODEL — how a layout node resolves its size along a split axis. + * + * A node is a FIXED track when it resolves to a CSS length (sidebars keep + * their declared size) and a FLEX track when it doesn't (weight-shared + * leftover). Everything here is pure geometry over the layout tree + the + * live pane contributions; the React split renderer reads it per render. + */ + +import type * as React from 'react' + +import type { Contribution } from '@/contrib/types' + +import type { GroupNode, LayoutNode } from '../model' +import { allPaneIds } from '../model' + +export const MIN_PANE_PX = 80 + +/** Optional CSS sizing a pane contributes (`data.width` / `data.minWidth`…). + * Applied to the pane's GROUP along the axis of the split that contains it — + * the same semantics as the app's `Pane width/minWidth/maxWidth` props: + * a `width`/`height` makes the zone a FIXED track (sidebar-style — it keeps + * its size and the weighted zones absorb the rest); without one the zone + * shares leftover space by weight. */ +export interface PaneSizing { + width?: string + height?: string + minWidth?: string + maxWidth?: string + minHeight?: string + maxHeight?: string +} + +/** Chrome behavior flags a pane contributes. Read via `paneChrome`. */ +interface PaneChrome { + /** Leaves the grid on narrow viewports; revealed as an edge overlay. */ + collapsible?: boolean + /** Extra ids accepted from PANE_TOGGLE_REVEAL_EVENT (the real app's pane + * ids, e.g. `chat-sidebar` for `sessions`). */ + revealAliases?: string[] + placement?: string + /** No Close in the tab menu — the one surface the app can't lose (the + * main workspace). Session tiles share `placement: 'main'` but close. */ + uncloseable?: boolean + /** Wrap this pane's TAB (e.g. in a domain context menu — a session tile's + * 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 + /** 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. */ + headerVeto?: boolean +} + +export const paneChrome = (c: Contribution | undefined) => (c?.data ?? {}) as PaneChrome + +/** Resolve a computed style length ("237px" / "none" / "auto") to px. */ +export function computedPx(value: string, fallback: number): number { + const n = Number.parseFloat(value) + + return Number.isFinite(n) ? n : fallback +} + +/** Resolve an AUTHORED CSS length ("237px", "38vh", "clamp(18rem,36vw,32rem)") + * to px by measuring a probe inside `container` — handles every unit and + * math function the browser does. */ +export function resolveCssPx(container: HTMLElement, css: number | string, horizontal: boolean): number | null { + if (typeof css === 'number') { + return css + } + + const probe = document.createElement('div') + probe.style.position = 'absolute' + probe.style.visibility = 'hidden' + probe.style.pointerEvents = 'none' + + if (horizontal) { + probe.style.width = css + } else { + probe.style.height = css + } + + container.appendChild(probe) + const rect = probe.getBoundingClientRect() + probe.remove() + const px = horizontal ? rect.width : rect.height + + return Number.isFinite(px) && px > 0 ? px : null +} + +/** Everything fixed-track resolution needs about the current view state. */ +export interface TrackContext { + paneFor: (id: string) => Contribution | undefined + paneGone: (id: string) => boolean + overrides: Record +} + +/** A group's panes that are actually on screen (not hidden / narrow-collapsed + * / unregistered). The one place the "shown" filter lives. */ +export const shownPaneIds = (group: GroupNode, ctx: TrackContext): string[] => + group.panes.filter(id => !ctx.paneGone(id)) + +/** max() of the defined CSS lengths (deduped); undefined when none — the + * largest-tenant basis a fixed stack and its clamps both size from. */ +export const cssMax = (values: (string | null | undefined)[]): string | undefined => { + const unique = [...new Set(values.filter((v): v is string => Boolean(v)))] + + return unique.length === 0 ? undefined : unique.length === 1 ? unique[0] : `max(${unique.join(', ')})` +} + +/** + * THE TRACK MODEL. A node's size along `axis` is FIXED when it resolves to a + * CSS length, and FLEX (weight-shared leftover) when null: + * + * - zone: the max() of its shown panes' declared `width`/`height` (a live px + * override from a sash drag wins) — sidebars keep their size, main flexes, + * and the zone never resizes when tabs switch or a drop fronts a pane. + * - split ALONG the axis: the sum of its visible children — fixed only when + * every child is (one flex child makes the run flex). + * - split ACROSS the axis: the max of its visible fixed children (flex + * children just stretch to the container); flex only when none are fixed. + * + * This is how "two right sidebars over a terminal row" sizes itself from its + * content (237px, or 474px when review is visible) instead of taking a + * fraction of the window. + */ +/** A minimized zone IS its strip: the vertical rail (row) / header (column) + * are both 28px thick. */ +export const MINIMIZED_TRACK = '1.75rem' + +export function fixedTrackSize(node: LayoutNode, axis: 'row' | 'column', ctx: TrackContext): string | null { + if (node.type === 'group') { + // Ancestor splits must size a minimized zone as its strip, not as its + // panes' declared widths — otherwise the outer track keeps reserving the + // full sidebar width and the collapsed rail floats in a dead column. + if (node.minimized) { + return MINIMIZED_TRACK + } + + const overrideKey = axis === 'row' ? 'widthOverride' : 'heightOverride' + + const declared = (id: string) => { + const sizing = (ctx.paneFor(id)?.data ?? {}) as PaneSizing + const css = (axis === 'row' ? sizing.width : sizing.height) ?? null + const override = ctx.overrides[id]?.[overrideKey] + + // An override only refines a pane that DECLARES a size along this axis + // (sash drags write overrides to fixed zones only). One without a + // declaration is stale data from another surface — honoring it would + // turn a flex-at-heart zone (main!) into a fixed track and hand the + // whole leftover to the run's absorber. + if (css !== null && override !== undefined) { + return `${override}px` + } + + return css + } + + // Which zones are FIXED tracks: + // - a MAIN-bearing zone (workspace/tile stacked in) is flex-at-heart — + // mixing a sidebar pane into it (files fronted in the Focus mono-stack) + // must NOT snap the whole zone to sidebar width; + // - any other zone stays fixed as long as SOME tenant declares a size — + // dropping a size-less pane (the terminal has height but no width) + // into the 237px files sidebar must not balloon it to a flex track. + const ids = shownPaneIds(node, ctx) + const sizes = ids.map(declared) + const declaredSizes = sizes.filter((size): size is string => size !== null) + + if (declaredSizes.length === 0) { + return null + } + + if (sizes.length !== declaredSizes.length && ids.some(id => paneChrome(ctx.paneFor(id)).placement === 'main')) { + return null + } + + // A STACK sizes to its LARGEST tenant (CSS max()), never the active tab: + // dropping a pane into a zone — the drop fronts it — or switching tabs + // must not resize the container (dropping sessions into a wider fixed + // zone used to snap the whole zone down to sidebar width). + return cssMax(declaredSizes) ?? null + } + + const visible = node.children.filter(child => !subtreeGone(child, ctx)) + const sizes = visible.map(child => fixedTrackSize(child, axis, ctx)) + + if (node.orientation === axis) { + if (sizes.length === 0 || sizes.some(size => size === null)) { + return null + } + + return sizes.length === 1 ? sizes[0] : `calc(${sizes.join(' + ')})` + } + + // Across the axis a flex child just stretches; the fixed ones set the size. + return cssMax(sizes) ?? null +} + +/** True when every pane in the subtree is hidden/narrow-collapsed. */ +export function subtreeGone(node: LayoutNode, ctx: TrackContext): boolean { + const ids = allPaneIds(node) + + return ids.length > 0 && ids.every(ctx.paneGone) +} + +/** + * Which chrome toggle owns a root-row child — SEMANTIC, not positional: + * ⌘B is the sessions/nav column (any `placement: 'left'` pane) wherever a + * flip or drag puts it; ⌘J is every other side column. `null` = contains + * the main zone, never side-collapsed. This is what keeps the titlebar + * toggles and reveals 100% main-compatible through ⌘\ flips. + */ +export function rootChildSide( + child: LayoutNode, + paneFor: (id: string) => Contribution | undefined +): 'left' | 'right' | null { + const placements = allPaneIds(child).map(id => paneChrome(paneFor(id)).placement) + + if (placements.includes('main')) { + return null + } + + return placements.includes('left') ? 'left' : 'right' +} + +/** + * The FIXED zone that owns `edge` of this subtree along `axis` — the zone a + * sash on that boundary actually resizes (dragging the seam between main and + * a nested right section resizes the section's edge sidebar, VS Code-style). + */ +export function edgeFixedZone( + node: LayoutNode, + edge: 'start' | 'end', + axis: 'row' | 'column', + ctx: TrackContext +): GroupNode | null { + if (node.type === 'group') { + return fixedTrackSize(node, axis, ctx) !== null ? node : null + } + + const visible = node.children.filter(child => !subtreeGone(child, ctx)) + + if (node.orientation === axis) { + const child = edge === 'start' ? visible[0] : visible[visible.length - 1] + + return child ? edgeFixedZone(child, edge, axis, ctx) : null + } + + // Cross-axis: every child touches the edge — the first fixed one owns it. + for (const child of visible) { + const zone = edgeFixedZone(child, edge, axis, ctx) + + if (zone) { + return zone + } + } + + return null +} 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 new file mode 100644 index 000000000000..22dd8f74bdd1 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -0,0 +1,656 @@ +/** + * Group node renderer — a ZONE: header strip (tabs when stacked, minimize + * chevron) + the active pane's content, resolved from the contribution + * registry (`area: 'panes'`). Empty zones exist only in editor-authored + * trees (drop targets until the first structural op prunes them). + * + * Dragging is FancyZones-style (drag-session.ts): the layout stays fixed and + * every zone lights up as a whole-region drop target. Right-click opens the + * contextual zone menu (split/move + header/minimize toggles). + */ + +import { useStore } from '@nanostores/react' +import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react' + +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 { ContribBoundary } from '@/contrib/react/boundary' +import { useContributions } from '@/contrib/react/use-contributions' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' + +import { $layoutEditMode } from '../../edit-mode' +import { useWindowControlsOverlap } from '../../geometry' +import type { DropPosition, GroupNode, RootEdge } from '../model' +import { adjacentGroup } from '../model' +import { + $dropHint, + $hiddenTreePanes, + $layoutTree, + $narrowViewport, + $treeDragging, + activateTreePane, + closeTreePane, + moveTreePane, + SESSION_TILE_DRAG, + setTreeGroupHeaderHidden, + splitTreeZone, + toggleTreeGroupMinimized +} from '../store' + +import { type DoubleTapContext, startPaneDrag } from './drag-session' +import { paneChrome } from './track-model' + +/** A directional action in the zone menu (computed per group state). */ +interface ZoneMenuDirection { + side: RootEdge + label: string + run: () => void +} + +const DIRECTION_ORDER: readonly RootEdge[] = ['right', 'bottom', 'left', 'top'] +const DIRECTION_ARROW: Record = { bottom: '↓', left: '←', right: '→', top: '↑' } + +/** Right-click zone menu: directional actions + header toggle + minimize. + * The directions are CONTEXTUAL (computed by TreeGroup): a stacked group + * offers "Split " (carve a new zone with the clicked pane — VS Code + * split-and-move in one gesture); a single-pane group offers "Move " + * into the zone actually sitting on that side — directions with no visible + * neighbor aren't offered, so no action ever appears to do nothing. */ +function ZoneMenu({ + children, + closable, + minimizable = true, + directions, + headerHidden, + minimized, + nodeId +}: { + children: ReactNode + /** The pane the menu closes (the right-clicked chip / the active pane); + * undefined = not closable (the main zone). */ + closable?: () => string | undefined + /** False for the zone hosting the uncloseable workspace — collapsing the + * MAIN pane strands the app behind a strip. */ + minimizable?: boolean + directions: ZoneMenuDirection[] + headerHidden?: boolean + minimized?: boolean + nodeId: string +}) { + const { t } = useI18n() + + return ( + + {children} + + {directions.map(direction => ( + + {direction.label} + + ))} + setTreeGroupHeaderHidden(nodeId, !headerHidden)}> + {headerHidden ? t.zones.showHeader : t.zones.hideHeader} + + {minimizable && ( + toggleTreeGroupMinimized(nodeId, !minimized)}> + {minimized ? t.zones.restore : t.zones.minimize} + + )} + {/* Resolved at render: the menu mounts on open, after the right-click + set menuPane — so an uncloseable target hides the item instead + of offering a dead action. */} + {closable?.() !== undefined && ( + { + const paneId = closable?.() + + if (paneId) { + closeTreePane(paneId) + } + }} + > + {t.common.close} + + )} + + + ) +} + +export function TreeGroup({ + node, + parentAxis, + railSide = 'left' +}: { + node: GroupNode + parentAxis?: 'column' | 'row' + railSide?: 'left' | 'right' +}) { + const { t } = useI18n() + const ref = useRef(null) + const stripRef = useRef(null) + // The chip under the last right-click — the pane the zone menu's Split + // actions carry into the new zone (header background = the active pane). + // STATE, not a ref: the menu items (incl. Close's visibility) are JSX + // evaluated during THIS component's render — a ref write on right-click + // doesn't re-render, so the menu showed the PREVIOUS target's items (Close + // missing on an inactive tile tab whose zone-active was the uncloseable + // workspace). + const [menuPane, setMenuPane] = useState(undefined) + const panes = useContributions('panes') + // Coarse drag flag only (set once at drag start/end). The per-frame drop + // HINT lives in ZoneDropOverlay so a moving pointer re-renders the tiny + // overlay, not every zone's header/body (and not the menuDirections walk). + const dragging = useStore($treeDragging) + const editMode = useStore($layoutEditMode) + const wcOverlap = useWindowControlsOverlap(ref, true) + + const hiddenPanes = useStore($hiddenTreePanes) + const narrow = useStore($narrowViewport) + + const paneFor = (id: string) => panes.find(p => p.id === id) + + // Unregistered (plugin not loaded), chrome-toggled-off, and narrow-collapsed + // panes drop out of the header; the active pane falls back to the first + // shown one (render-side — the tree keeps `active`). + // Edit mode forces toggle-hidden panes visible so they can be rearranged + // (mirrors tree-split's paneGone) — restores itself on exit. + const paneShown = (id: string) => + Boolean(paneFor(id)) && (editMode || !hiddenPanes.has(id)) && !(narrow && paneChrome(paneFor(id)).collapsible) + + const shown = node.panes.filter(paneShown) + const activeId = shown.includes(node.active) ? node.active : (shown[0] ?? node.active) + const active = paneFor(activeId) + const isEmpty = node.panes.length === 0 + + // 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)) + + return !chrome.uncloseable && chrome.placement === 'main' + }) + + // 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)) + + // 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 + // empty column, so the minimized form is a narrow vertical rail instead + // (tabs reading top-to-bottom). In a column (stacked zones) the horizontal + // header IS the collapsed form, exactly as before. + const verticalCollapse = Boolean(node.minimized) && parentAxis === 'row' && !isEmpty + const headerVisible = !isEmpty && !verticalCollapse && (Boolean(node.minimized) || !headerHidden) + + // Drag handles preventDefault pointerdown (no native dblclick), so the + // header + chips share a synthesized double-tap: restore if collapsed + // (undoing the first tap's minimize toggle) and hide the chrome. + const hideHeaderDoubleTap: DoubleTapContext = { + key: `hide-header-${node.id}`, + onDoubleTap: () => { + toggleTreeGroupMinimized(node.id, false) + setTreeGroupHeaderHidden(node.id, true) + } + } + + const dirWord: Record = { + bottom: t.zones.dirDown, + left: t.zones.dirLeft, + right: t.zones.dirRight, + top: t.zones.dirUp + } + + // Zone-menu directions, contextual to this group's state: + // - stacked panes -> "Split ": carve a new zone on that side with the + // right-clicked chip's pane in it (split + move, one gesture); + // - a single pane -> "Move ": join the zone visually adjacent on that + // side (splitting here would only make an invisible empty zone). Sides + // with no visible neighbor are omitted entirely. + const tree = useStore($layoutTree) + + const menuDirections: ZoneMenuDirection[] = + shown.length > 1 + ? DIRECTION_ORDER.map(side => ({ + side, + label: `${t.zones.split(dirWord[side])} ${DIRECTION_ARROW[side]}`, + run: () => splitTreeZone(node.id, side, menuPane ?? activeId) + })) + : DIRECTION_ORDER.flatMap(side => { + const neighbor = tree ? adjacentGroup(tree, node.id, side, g => g.panes.some(paneShown)) : null + + if (!neighbor || neighbor.id === node.id) { + return [] + } + + return [ + { + side, + label: `${t.zones.move(dirWord[side])} ${DIRECTION_ARROW[side]}`, + run: () => moveTreePane(activeId, { groupId: neighbor.id, pos: 'center' }) + } + ] + }) + + // Close targets the right-clicked chip (falling back to the active pane); + // only panes that declare `uncloseable` (the main workspace) are exempt. + const closable = () => { + const paneId = menuPane ?? activeId + + return paneChrome(paneFor(paneId)).uncloseable ? undefined : paneId + } + + // The zone hosting the uncloseable workspace never minimizes — collapsing + // MAIN strands the whole app behind a strip. + const minimizable = !shown.some(id => paneChrome(paneFor(id)).uncloseable) + + // Same menu on the header strip and the edit veil — one prop bag. + const zoneMenu = { + closable, + directions: menuDirections, + headerHidden, + minimizable, + minimized: node.minimized, + nodeId: node.id + } + + // NO body double-click toggle: virtualized content (the thread) recreates + // its nodes between clicks, so the gesture was hopelessly unreliable. The + // bar's lifecycle is explicit instead — gaining a tab sticky-shows it + // (insertAtGroup pins headerHidden false), the main tab's context menu + // hides it, and full-page views veto it via paneChrome.headerVeto. + + return ( +
+ {wcOverlap && ( + + ) +} + +// --------------------------------------------------------------------------- +// Tab-strip insertion caret +// --------------------------------------------------------------------------- + +/** + * The insertion divider for a stack drop: a 2px vertical line at the slot the + * dragged tab will land in (before `stack.before`, or after the last tab). + * Absolute over the strip — pure overlay, zero layout shift. #000 on light, + * #FFF on dark. Split out so per-pointermove `$dropHint` churn re-renders + * only this node (same isolation contract as ZoneDropOverlay). + */ +function StripDropCaret({ groupId, stripRef }: { groupId: string; stripRef: RefObject }) { + const hint = useStore($dropHint) + const strip = stripRef.current + const stack = hint?.groupId === groupId ? hint.stack : undefined + + if (stack === undefined || !strip) { + return null + } + + // Slot x: the before-tab's left edge, or the last tab's right edge. + const tabs = [...strip.querySelectorAll('[data-tree-tab]')] + const target = stack.before ? tabs.find(el => el.dataset.treeTab === stack.before) : tabs.at(-1) + + if (!target) { + return null + } + + const stripRect = strip.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const x = (stack.before ? targetRect.left : targetRect.right) - stripRect.left + + // A short centered tick (~60% of the tab), not a full-height wall — reads + // as an insertion point between labels, browser-tab style. + return ( + + ) +} + +// --------------------------------------------------------------------------- +// FancyZones drop overlay +// --------------------------------------------------------------------------- + +/** Overlay entry fade. FancyZones ships 200ms (FADE_IN_DURATION_MILLIS in + * zones-engine); on a drag that starts under the cursor that ramp reads as + * lag, so the sheets snap in far faster — same softening, instant feel. */ +const OVERLAY_FADE_MS = 80 + +/** Sheet inset from the zone edge (px). */ +const REGION_PAD = 6 + +/** The sheet's box per drop position — longhand insets so CSS transitions can + * interpolate the px↔% change: the target GLIDES between the full zone and + * the hovered half instead of snapping (VS Code dock preview). */ +const REGION: Record = { + bottom: { bottom: REGION_PAD, left: REGION_PAD, right: REGION_PAD, top: '50%' }, + center: { bottom: REGION_PAD, left: REGION_PAD, right: REGION_PAD, top: REGION_PAD }, + left: { bottom: REGION_PAD, left: REGION_PAD, right: '50%', top: REGION_PAD }, + right: { bottom: REGION_PAD, left: '50%', right: REGION_PAD, top: REGION_PAD }, + 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. + */ +function ZoneDropOverlay({ isEmpty, node }: { isEmpty: boolean; node: GroupNode }) { + const { t } = useI18n() + const dragging = useStore($treeDragging) + const hint = useStore($dropHint) + + if (dragging === null) { + return null + } + + // A session drag (sidebar row) reuses this exact overlay — over ANY zone + // now (stack into its tabs / split its edges); only a CHAT zone's center is + // a link-to-chat (the composer overlay owns that visual). + const sessionDrag = dragging === SESSION_TILE_DRAG + const chatZone = node.panes.some(p => p === 'workspace' || p.startsWith('session-tile:')) + + const isDragSource = node.panes.includes(dragging) + + // The source zone, when it holds only the dragged pane, has nothing to drop. + if (isDragSource && node.panes.length === 1) { + return null + } + + const primary = hint?.groupId === node.id + + // Hovering the target's TAB STRIP: the insertion caret (StripDropCaret) + // owns the affordance — the zone sheet stands down so the two never stack. + if (primary && hint?.stack !== undefined) { + return null + } + + const active = hint?.groupIds?.includes(node.id) ?? false + const multi = (hint?.groupIds?.length ?? 0) > 1 + // 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. + 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/renderer/tree-node.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx new file mode 100644 index 000000000000..76680473eebc --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx @@ -0,0 +1,28 @@ +import type { LayoutNode } from '../model' + +import { TreeGroup } from './tree-group' +import { TreeSplit } from './tree-split' + +/** Dispatch a layout node to its renderer — the split/group recursion point. + * `root` marks the tree's top split (side collapse applies only there). + * `parentAxis` is the containing split's orientation — a group collapses + * ALONG that axis, so it picks the minimized form (row → vertical rail, + * column → horizontal header). `railSide` is which half of that row the + * child sits in — the rail's divider stroke faces the content side. */ +export function TreeNode({ + node, + parentAxis, + railSide, + root +}: { + node: LayoutNode + parentAxis?: 'column' | 'row' + railSide?: 'left' | 'right' + root?: boolean +}) { + return node.type === 'split' ? ( + + ) : ( + + ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx new file mode 100644 index 000000000000..843018c01a3f --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx @@ -0,0 +1,518 @@ +/** + * Split node renderer — a flex row/column whose 1px seams double as resize + * sashes (the seam IS the boundary — junction-owned, never doubled). Sizing + * is the TRACK MODEL (track-model.ts): fixed tracks keep their declared size, + * flex tracks share the leftover by weight, and an all-fixed run lets its + * last track absorb the slack (VS Code style). + */ + +import { useStore } from '@nanostores/react' +import { type PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, useSyncExternalStore } from 'react' + +import { useContributions } from '@/contrib/react/use-contributions' +import { cn } from '@/lib/utils' +import { $paneStates, type PaneStateSnapshot, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes' + +import { $layoutEditMode } from '../../edit-mode' +import type { LayoutNode, SplitNode } from '../model' +import { allPaneIds } from '../model' +import { + $collapsedTreeSides, + $hiddenTreePanes, + $narrowViewport, + persistTree, + presetSplitWeights, + setTreeSplitWeights +} from '../store' + +import { + computedPx, + cssMax, + edgeFixedZone, + fixedTrackSize, + MIN_PANE_PX, + paneChrome, + type PaneSizing, + resolveCssPx, + rootChildSide, + shownPaneIds, + subtreeGone, + type TrackContext +} from './track-model' +import { TreeNode } from './tree-node' + +/** + * The size overrides for a fixed set of panes, referentially stable until one + * of THEM changes. Sash drags churn `$paneStates` every frame; subscribing the + * whole map would re-render every split — this narrows each split to its own + * subtree via a signature-gated snapshot. + */ +function useSubtreeOverrides(paneIds: readonly string[]): TrackContext['overrides'] { + const key = paneIds.join(',') + const cache = useRef<{ sig: string; value: Record }>({ sig: '\0', value: {} }) + + const snapshot = useCallback(() => { + const all = $paneStates.get() + const sig = paneIds.map(id => `${id}:${all[id]?.widthOverride ?? ''}:${all[id]?.heightOverride ?? ''}`).join('|') + + if (cache.current.sig !== sig) { + cache.current = { sig, value: Object.fromEntries(paneIds.flatMap(id => (all[id] ? [[id, all[id]]] : []))) } + } + + return cache.current.value + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]) + + return useSyncExternalStore(cb => $paneStates.listen(cb), snapshot, snapshot) +} + +export function TreeSplit({ node, root }: { node: SplitNode; root?: boolean }) { + const containerRef = useRef(null) + const panes = useContributions('panes') + const hiddenPanes = useStore($hiddenTreePanes) + const narrow = useStore($narrowViewport) + // Scoped to THIS subtree's panes: a sash drag writes size overrides on every + // pointermove, but only the splits whose subtree actually resized should + // re-render — not every split in the tree. + const overrides = useSubtreeOverrides(useMemo(() => allPaneIds(node), [node])) + const editMode = useStore($layoutEditMode) + const collapsedSides = useStore($collapsedTreeSides) + const horizontal = node.orientation === 'row' + const axis = node.orientation + + // A pane leaves the grid when its contribution isn't registered (yet) — a + // runtime plugin's pane collapses until the plugin loads, then appears; no + // placeholder flash — when a chrome toggle hides it, or when the viewport + // is narrow and the pane is collapsible (edge overlay instead). + const paneFor = (id: string) => panes.find(p => p.id === id) + + // Layout-edit mode forces toggle-hidden panes (terminal off, review/preview + // closed) visible so they're rearrangeable — only truly-absent (unregistered) + // or narrow-collapsed panes stay gone. Restores itself on exit (render-only). + const paneGone = (id: string) => + !paneFor(id) || (!editMode && hiddenPanes.has(id)) || (narrow && Boolean(paneChrome(paneFor(id)).collapsible)) + + const trackCtx: TrackContext = { paneFor, paneGone, overrides } + + // Chrome-toggle collapse: a subtree whose every pane is gone renders + // display:none (content stays MOUNTED — toggling back is instant), and its + // siblings absorb the space. Narrow-collapse UNMOUNTS instead, so the edge + // overlay owns the single live instance of the pane's content. + // EMPTY zones only exist in editor-authored trees (normalize prunes them on + // every structural op) — they take space in edit mode as drop targets. + const isEmptyZone = (child: LayoutNode) => child.type === 'group' && child.panes.length === 0 + const isCollapsed = (child: LayoutNode) => subtreeGone(child, trackCtx) || (isEmptyZone(child) && !editMode) + + // Min/max clamps come from a direct GROUP child's panes (the same clamps + // the app's Pane props express) — but ONLY when they can speak for the + // zone: a fixed track (pure sidebar stack) or a single-pane zone. A sidebar + // pane fronted in a mixed flex stack must not cap it. A fixed STACK + // aggregates its panes' clamps (largest-tenant semantics, mirroring the + // max() track basis) — the active tab's caps must never resize the zone. + const sizingFor = (child: LayoutNode, track: string | null): PaneSizing | null => { + if (child.type !== 'group' || child.panes.length === 0) { + return null + } + + const shownIds = shownPaneIds(child, trackCtx) + + if (track === null && shownIds.length !== 1) { + return null + } + + if (shownIds.length <= 1) { + return (paneFor(shownIds[0])?.data as PaneSizing | undefined) ?? null + } + + // Fixed STACK: floors take the largest declared min; caps stay unbounded + // unless EVERY pane declares one (a single uncapped tenant uncaps the + // zone). Same largest-tenant basis as the track size — never per-tab. + const all = shownIds.map(id => (paneFor(id)?.data ?? {}) as PaneSizing) + + const cap = (pick: (s: PaneSizing) => string | undefined) => + all.every(pick) ? cssMax(all.map(pick)) : undefined + + return { + minWidth: cssMax(all.map(s => s.minWidth)), + maxWidth: cap(s => s.maxWidth), + minHeight: cssMax(all.map(s => s.minHeight)), + maxHeight: cap(s => s.maxHeight) + } + } + + // Sashes pair each visible child with its nearest visible PREVIOUS sibling + // (`aIndex`/`bIndex`), not blindly `i-1`/`i` — a collapsed zone in between + // (e.g. the closed preview pane parked between main and the right rail) + // must not swallow the seam its visible neighbors share. + const startSash = useCallback( + (aIndex: number, bIndex: number, e: ReactPointerEvent) => { + const container = containerRef.current + + if (!container || e.button !== 0) { + return + } + + e.preventDefault() + + const handle = e.currentTarget + const { pointerId } = e + const rect = container.getBoundingClientRect() + const totalPx = horizontal ? rect.width : rect.height + const totalWeight = node.weights.reduce((a, b) => a + b, 0) || 1 + const pxPerWeight = totalPx / totalWeight + const start = horizontal ? e.clientX : e.clientY + const restoreCursor = document.body.style.cursor + const restoreSelect = document.body.style.userSelect + + // Each side of the seam resolves to a RESIZE TARGET: a fixed zone (the + // sash writes its px override — sidebar semantics) or the flex run + // (the sash writes weights). Sizes/clamps read from the live DOM of + // whichever element actually owns the boundary. + const sizeOf = (el: HTMLElement) => { + const r = el.getBoundingClientRect() + + return horizontal ? r.width : r.height + } + + const sideFor = (child: LayoutNode, wrapper: HTMLElement, edge: 'start' | 'end') => { + const fixed = fixedTrackSize(child, axis, trackCtx) !== null + const zone = fixed ? edgeFixedZone(child, edge, axis, trackCtx) : null + const zoneEl = zone ? container.querySelector(`[data-tree-group="${zone.id}"]`) : null + // Clamps live on the zone's split-child WRAPPER (where we render them). + const el = zoneEl?.parentElement ?? wrapper + const cs = window.getComputedStyle(el) + + return { + // EVERY shown pane of the zone: the zone's track is the max() of its + // panes' sizes, so the sash writes the same px to all of them — + // writing only the active pane would leave the zone pinned at a + // larger sibling's width. + paneIds: zone ? shownPaneIds(zone, trackCtx) : [], + fixed: Boolean(zone), + size: sizeOf(zoneEl ?? wrapper), + min: Math.max(MIN_PANE_PX, computedPx(horizontal ? cs.minWidth : cs.minHeight, 0)), + max: computedPx(horizontal ? cs.maxWidth : cs.maxHeight, Number.POSITIVE_INFINITY) + } + } + + const kidA = container.children[aIndex] as HTMLElement | undefined + const kidB = container.children[bIndex] as HTMLElement | undefined + + if (!kidA || !kidB) { + return + } + + const a = sideFor(node.children[aIndex], kidA, 'end') + const b = sideFor(node.children[bIndex], kidB, 'start') + const a0px = a.fixed ? a.size : sizeOf(kidA) + const b0px = b.fixed ? b.size : sizeOf(kidB) + const lo = Math.max(a.min - a0px, b0px - b.max) + const hi = Math.min(a.max - a0px, b0px - b.min) + + const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride + + try { + handle.setPointerCapture?.(pointerId) + } catch { + // Synthetic events. + } + + document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize' + document.body.style.userSelect = 'none' + + const onMove = (ev: PointerEvent) => { + const shiftPx = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start)) + + if (a.fixed) { + a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx))) + } + + if (b.fixed) { + b.paneIds.forEach(id => setOverride(id, Math.round(b0px - shiftPx))) + } + + if (!a.fixed && !b.fixed) { + const weights = [...node.weights] + // Convert the CLAMPED pixel sizes back to weights so the persisted + // weights always agree with what's on screen. + weights[aIndex] = (a0px + shiftPx) / pxPerWeight + weights[bIndex] = (b0px - shiftPx) / pxPerWeight + setTreeSplitWeights(node.id, weights) + } + } + + const cleanup = () => { + document.body.style.cursor = restoreCursor + document.body.style.userSelect = restoreSelect + + try { + handle.releasePointerCapture?.(pointerId) + } catch { + // Mirror. + } + + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', cleanup, true) + window.removeEventListener('pointercancel', cleanup, true) + persistTree() + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', cleanup, true) + window.addEventListener('pointercancel', cleanup, true) + }, + // trackCtx is derived state rebuilt per render; the drag captures it once. + // eslint-disable-next-line react-hooks/exhaustive-deps + [axis, editMode, horizontal, node.children, node.id, node.weights, hiddenPanes, narrow, overrides, panes] + ) + + // Double-click a sash: every neighbor returns to its DEFAULT size. + // - fixed zones (sidebar stacks): clear the drag override -> the declared + // width (237px etc.) comes back; + // - flex zones fronted by a size-declaring pane (a sidebar in a mixed + // stack): pin the weight so the zone lands EXACTLY on that size; + // - everything else: the preset's weights for this split (rearranging + // panes keeps the applied preset's split ids), else even distribution. + const resetBoundary = useCallback( + (aIndex: number, bIndex: number) => { + const container = containerRef.current + + if (!container) { + return + } + + const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride + + for (const [child, edge] of [ + [node.children[aIndex], 'end'], + [node.children[bIndex], 'start'] + ] as const) { + const zone = edgeFixedZone(child, edge, axis, trackCtx) + + for (const paneId of zone ? shownPaneIds(zone, trackCtx) : []) { + setOverride(paneId, undefined) + } + } + + const preset = presetSplitWeights(node.id, node.weights.length) + const weights = preset ?? [...node.weights] + + const rect = container.getBoundingClientRect() + const totalPx = horizontal ? rect.width : rect.height + let pinned = false + + for (const i of [aIndex, bIndex]) { + const child = node.children[i] + + // Fixed tracks size themselves from the declared width (override + // cleared above) — weights only matter for FLEX zones. + if (child.type !== 'group' || fixedTrackSize(child, axis, trackCtx) !== null) { + continue + } + + // The zone's natural default = the largest size any of its panes + // declares along this axis (a sessions+terminal stack is still a + // 237px sidebar at heart, whichever chip is fronted). + let px: number | null = null + + for (const paneId of shownPaneIds(child, trackCtx)) { + const sizing = (paneFor(paneId)?.data ?? {}) as PaneSizing + const css = horizontal ? sizing.width : sizing.height + const resolved = css ? resolveCssPx(container, css, horizontal) : null + + if (resolved !== null) { + px = Math.max(px ?? 0, resolved) + } + } + + if (px === null || px <= 0 || px >= totalPx) { + continue + } + + const others = weights.reduce((sum, w, j) => (j === i ? sum : sum + w), 0) + + if (others > 0) { + weights[i] = (px * others) / (totalPx - px) + pinned = true + } + } + + setTreeSplitWeights(node.id, !preset && !pinned ? weights.map(() => 1) : weights) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [axis, editMode, horizontal, node.children, node.id, node.weights, hiddenPanes, narrow, overrides, panes] + ) + + // A run of ONLY fixed tracks can't fill the container (grow-0 all around + // leaves dead space — e.g. terminal + logs split into two 38vh zones with + // the rail above them collapsed). The LAST visible track absorbs the + // leftover, VS Code style. + const isMinimized = (child: LayoutNode) => child.type === 'group' && Boolean(child.minimized) + + // SEMANTIC side collapse (titlebar toggles / ⌘B / ⌘J): at the ROOT row, + // ⌘B owns the sessions column and ⌘J the other side columns — by pane + // placement, NOT position, so a ⌘\ flip moves the columns without + // rewiring the toggles (main parity). In edit mode sides stay visible. + const semanticSides = root && horizontal && collapsedSides.size > 0 && !editMode + + const sideGone = (i: number) => { + if (!semanticSides) { + return false + } + + const side = rootChildSide(node.children[i], paneFor) + + return side !== null && collapsedSides.has(side) + } + + // One pass per child: collapse/minimize state, resolved fixed track, clamps, + // and narrow-unmount flag. fixedTrackSize + subtreeGone each re-walk the + // subtree, so resolve them ONCE here instead of per read below. + const tracks = node.children.map((child, i) => { + const minimized = isMinimized(child) + const collapsed = isCollapsed(child) || sideGone(i) + const track = minimized || collapsed ? null : fixedTrackSize(child, axis, trackCtx) + const sizing = minimized || collapsed ? null : sizingFor(child, track) + // Narrow-collapse UNMOUNTS (the edge overlay owns the live instance) — but + // only for panes the breakpoint collapsed, not ones a chrome toggle hid. + const narrowCollapsed = narrow && collapsed && allPaneIds(child).some(id => !hiddenPanes.has(id)) + + return { child, collapsed, minimized, narrowCollapsed, sizing, track } + }) + + const growable = tracks.map((_, i) => i).filter(i => !tracks[i].collapsed && !tracks[i].minimized) + const allFixed = growable.length > 0 && growable.every(i => tracks[i].track !== null) + const absorberIndex = allFixed ? growable[growable.length - 1] : -1 + + // Weights are RATIOS, but CSS flex-grow is absolute: a run whose grows sum + // below 1 fills only that fraction of the leftover (normalize's flatten + // scales weights into the parent slot — a dock-split nested into an + // existing column can leave grow 0.5, i.e. dead space). Renormalize the + // flex run so its grows always sum to 1. + const flexTotal = growable.reduce((sum, i) => sum + (tracks[i].track === null ? node.weights[i] : 0), 0) + const grow = (i: number) => node.weights[i] / (flexTotal || 1) + + // The seam partner for a visible child: the nearest VISIBLE previous + // sibling. Collapsed zones (a hidden pane parked mid-row) are skipped, so + // their visible neighbors keep a shared, draggable boundary. + const seamPartner = (i: number): number => { + for (let j = i - 1; j >= 0; j--) { + if (!tracks[j].collapsed) { + return j + } + } + + return -1 + } + + // Which half of this row a visible child sits in — a minimized zone's rail + // hugs the app edge it collapsed toward, so its divider stroke must face + // the content side (left rail → stroke right, right rail → stroke left). + const visibleOrder = tracks.map((t, j) => (t.collapsed ? -1 : j)).filter(j => j >= 0) + + const railSideFor = (i: number): 'left' | 'right' => { + const pos = visibleOrder.indexOf(i) + + return pos >= 0 && (pos + 0.5) / visibleOrder.length > 0.5 ? 'right' : 'left' + } + + return ( +
+ {tracks.map(({ child, collapsed, minimized, narrowCollapsed, sizing, track }, i) => { + const partner = collapsed ? -1 : seamPartner(i) + + return ( +
+ {partner >= 0 && ( + resetBoundary(partner, i)} + onPointerDown={e => startSash(partner, i, e)} + /> + )} + {!narrowCollapsed && ( + + )} +
+ ) + })} +
+ ) +} + +function Sash({ + disabled, + horizontal, + onDoubleClick, + onPointerDown +}: { + disabled?: boolean + horizontal: boolean + onDoubleClick?: () => void + onPointerDown: (e: ReactPointerEvent) => void +}) { + return ( +
+ {/* Persistent hairline: same token as PaneShell's divider sash + (--ui-stroke-secondary) so every seam — vertical or horizontal — + reads identically. */} + + {!disabled && ( + + )} +
+ ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/zone-editor.tsx b/apps/desktop/src/components/pane-shell/tree/zone-editor.tsx new file mode 100644 index 000000000000..36d4f3cbb789 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/zone-editor.tsx @@ -0,0 +1,507 @@ +/** + * Zone editor — the FancyZones grid editor experience, ported from PowerToys' + * `GridEditor.xaml.cs` interactions onto the verbatim model port (grid-model.ts): + * + * - A full-screen canvas of numbered translucent zones. + * - A splitter line follows the cursor inside the hovered zone; CLICK splits + * at that position. Hold SHIFT to flip the line horizontal (row split). + * - DRAG across zones to rubber-band select; the selection expands to its + * rectangular closure (ComputeClosure) and a Merge button appears. + * - Shared zone edges are draggable resizer thumbs (multi-zone edges move + * together, min-size clamped) — GridData.Drag semantics. + * - Templates: Columns / Rows / Grid / Priority with a zone-count stepper. + * + * Saving converts the grid to our runtime layout tree (guillotine cuts) and + * registers it as a user preset; non-guillotine arrangements (pinwheels) + * disable Save with an explanation. + */ + +import { useStore } from '@nanostores/react' +import { atom } from 'nanostores' +import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { registry } from '@/contrib/registry' +import { useI18n } from '@/i18n' +import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers' +import { cn } from '@/lib/utils' + +import { + canSplit, + doMerge, + dragResizer, + type GridLayout, + type GridResizer, + type GridZone, + initColumns, + initGrid, + initPriorityGrid, + initRows, + mergeClosureIndices, + modelToResizers, + modelToZones, + MULTIPLIER, + splitZone +} from './grid-model' +import { gridIsTreeExpressible, gridToTree, type PanePlacementHint } from './grid-to-tree' +import { allPaneIds } from './model' +import { applyLayoutPreset, saveLayoutPresetTree } from './presets' +import { $layoutTree } from './store' + +export const $zoneEditorOpen = atom(false) + +const SPLIT_SNAP = 50 // model units (0.5%) + +const pct = (v: number) => `${(v / MULTIPLIER) * 100}%` + +interface SplitPreview { + zoneIndex: number + orientation: 'horizontal' | 'vertical' + position: number +} + +interface SelectBox { + x0: number + y0: number + x1: number + y1: number +} + +/** Resizer screen geometry derived from its zones (model units). */ +function resizerGeometry(resizer: GridResizer, zones: GridZone[]) { + const all = [...resizer.negativeSideIndices, ...resizer.positiveSideIndices].map(i => zones[i]) + + if (resizer.orientation === 'horizontal') { + return { + at: zones[resizer.positiveSideIndices[0]].top, + from: Math.min(...all.map(z => z.left)), + to: Math.max(...all.map(z => z.right)) + } + } + + return { + at: zones[resizer.positiveSideIndices[0]].left, + from: Math.min(...all.map(z => z.top)), + to: Math.max(...all.map(z => z.bottom)) + } +} + +export function ZoneEditor() { + const { t } = useI18n() + const open = useStore($zoneEditorOpen) + const [model, setModel] = useState(() => initPriorityGrid(3)) + const [templateCount, setTemplateCount] = useState(3) + const [shift, setShift] = useState(false) + const [splitPreview, setSplitPreview] = useState(null) + const [selectBox, setSelectBox] = useState(null) + const [selection, setSelection] = useState([]) + const [mergeAt, setMergeAt] = useState<{ x: number; y: number } | null>(null) + const [name, setName] = useState('') + const canvasRef = useRef(null) + + const zones = modelToZones(model) ?? [] + const resizers = modelToResizers(model) + const treeExpressible = gridIsTreeExpressible(model) + + // Shift flips the splitter orientation (FancyZones behavior); Esc closes. + useEffect(() => { + if (!open) { + return + } + + const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.zoneEditor) + + const down = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setShift(true) + } + + // Skip when a nested field (the name Input) already handled Escape, or a + // higher layer owns it. + if (e.key === 'Escape' && !e.defaultPrevented && isTopEscapeLayer(ESCAPE_PRIORITY.zoneEditor)) { + e.preventDefault() + $zoneEditorOpen.set(false) + } + } + + const up = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setShift(false) + } + } + + window.addEventListener('keydown', down) + window.addEventListener('keyup', up) + + return () => { + window.removeEventListener('keydown', down) + window.removeEventListener('keyup', up) + releaseLayer() + } + }, [open]) + + const toModelPoint = useCallback((clientX: number, clientY: number) => { + const rect = canvasRef.current!.getBoundingClientRect() + + return { + x: Math.round(((clientX - rect.x) / rect.width) * MULTIPLIER), + y: Math.round(((clientY - rect.y) / rect.height) * MULTIPLIER) + } + }, []) + + if (!open) { + return null + } + + const zoneAt = (x: number, y: number) => + zones.find(z => x >= z.left && x < z.right && y >= z.top && y < z.bottom) ?? null + + const updateSplitPreview = (clientX: number, clientY: number) => { + const p = toModelPoint(clientX, clientY) + const zone = zoneAt(p.x, p.y) + + if (!zone) { + setSplitPreview(null) + + return + } + + const orientation = shift ? 'horizontal' : 'vertical' + const raw = orientation === 'horizontal' ? p.y : p.x + const position = Math.round(raw / SPLIT_SNAP) * SPLIT_SNAP + + setSplitPreview( + canSplit(model, zone.index, position, orientation) ? { zoneIndex: zone.index, orientation, position } : null + ) + } + + const onCanvasPointerDown = (e: ReactPointerEvent) => { + if (e.button !== 0 || (e.target as HTMLElement).dataset.resizer !== undefined) { + return + } + + e.preventDefault() + setMergeAt(null) + setSelection([]) + + const start = toModelPoint(e.clientX, e.clientY) + let dragged = false + + const onMove = (ev: PointerEvent) => { + const cur = toModelPoint(ev.clientX, ev.clientY) + + if (!dragged && Math.hypot(cur.x - start.x, cur.y - start.y) < 60) { + return + } + + dragged = true + + const box = { + x0: Math.min(start.x, cur.x), + y0: Math.min(start.y, cur.y), + x1: Math.max(start.x, cur.x), + y1: Math.max(start.y, cur.y) + } + + setSelectBox(box) + + // Zones intersecting the rubber band, expanded to rectangular closure. + const picked = zones + .filter(z => z.left < box.x1 && z.right > box.x0 && z.top < box.y1 && z.bottom > box.y0) + .map(z => z.index) + + setSelection(mergeClosureIndices(model, picked)) + } + + const onUp = (ev: PointerEvent) => { + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', onUp, true) + setSelectBox(null) + + if (!dragged) { + // Plain click: split at the previewed line. + if (splitPreview) { + setModel(m => splitZone(m, splitPreview.zoneIndex, splitPreview.position, splitPreview.orientation)) + setSplitPreview(null) + } + + setSelection([]) + + return + } + + const rect = canvasRef.current!.getBoundingClientRect() + setMergeAt({ x: ev.clientX - rect.x, y: ev.clientY - rect.y }) + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', onUp, true) + } + + const startResizerDrag = (index: number, e: ReactPointerEvent) => { + e.preventDefault() + e.stopPropagation() + + const rect = canvasRef.current!.getBoundingClientRect() + const resizer = resizers[index] + const horizontal = resizer.orientation === 'horizontal' + const start = horizontal ? e.clientY : e.clientX + let applied = 0 + + const onMove = (ev: PointerEvent) => { + const px = (horizontal ? ev.clientY : ev.clientX) - start + const total = Math.round((px / (horizontal ? rect.height : rect.width)) * MULTIPLIER) + const step = total - applied + + if (step !== 0) { + setModel(m => { + const next = dragResizer(m, index, step) + + if (next !== m) { + applied = total + } + + return next + }) + } + } + + const onUp = () => { + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', onUp, true) + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', onUp, true) + } + + const merge = () => { + setModel(m => doMerge(m, selection)) + setSelection([]) + setMergeAt(null) + } + + const save = () => { + const paneIds = allPaneIds($layoutTree.get() ?? { type: 'group', id: 'tmp', panes: [], active: '' }) + // Placement hints ride on the pane contributions (`data.placement`), so + // zones are assigned by ROLE (main/left/right/bottom), not index order. + const contributions = registry.getArea('panes') + + const placed = paneIds.map(id => ({ + id, + placement: (contributions.find(c => c.id === id)?.data as { placement?: PanePlacementHint } | undefined) + ?.placement + })) + + const tree = gridToTree(model, placed) + + if (!tree) { + return + } + + const id = saveLayoutPresetTree(name || t.zones.customZoneName(zones.length), tree) + + if (id) { + applyLayoutPreset(id, tree) + } + + $zoneEditorOpen.set(false) + } + + const templates = [ + { label: t.zones.templateColumns, make: initColumns }, + { label: t.zones.templateRows, make: initRows }, + { label: t.zones.templateGrid, make: initGrid }, + { label: t.zones.templatePriority, make: initPriorityGrid } + ] + + return ( +
+ {/* Toolbar — Panel-style title + hint, template chooser on the right. */} +
+
+

{t.zones.zoneEditorTitle}

+

+ {t.zones.editorHintPre} + + ⇧ + + {t.zones.editorHintPost} +

+
+
+ {templates.map(t => ( + + ))} + setTemplateCount(Math.max(1, Math.min(8, Number(e.target.value) || 1)))} + type="number" + value={templateCount} + /> +
+
+ + {/* Canvas */} +
setSplitPreview(null)} + onPointerMove={e => updateSplitPreview(e.clientX, e.clientY)} + ref={canvasRef} + > + {zones.map(zone => { + const selected = selection.includes(zone.index) + + return ( +
+ {/* Quiet zone tag — the app's small-caps label voice, not a + billboard number. */} + + {t.zones.zoneTag(zone.index + 1)} + +
+ ) + })} + + {/* Splitter preview line following the cursor. */} + {splitPreview && + (() => { + const zone = zones[splitPreview.zoneIndex] + + if (!zone) { + return null + } + + const horizontal = splitPreview.orientation === 'horizontal' + + return ( +
+ ) + })()} + + {/* Rubber-band selection box. */} + {selectBox && ( +
+ )} + + {/* Resizer thumbs on shared edges. */} + {resizers.map((resizer, i) => { + const geo = resizerGeometry(resizer, zones) + const horizontal = resizer.orientation === 'horizontal' + + return ( +
startResizerDrag(i, e)} + style={ + horizontal + ? { top: pct(geo.at), left: pct(geo.from), width: pct(geo.to - geo.from) } + : { left: pct(geo.at), top: pct(geo.from), height: pct(geo.to - geo.from) } + } + > + +
+ ) + })} + + {/* Merge affordance at drag-release point. */} + {mergeAt && selection.length > 1 && ( +
+ +
+ )} +
+ + {/* Footer: save / cancel. */} +
+ setName(e.target.value)} + // Escape while typing clears the field and yields — it must not bubble + // up to close the whole editor and lose the name. + onKeyDown={e => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + setName('') + e.currentTarget.blur() + } + }} + placeholder={t.zones.layoutNamePlaceholder(t.zones.customZoneName(zones.length))} + value={name} + /> + + + {!treeExpressible && {t.zones.notExpressible}} + {t.zones.zoneCount(zones.length)} +
+
+ ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/zones-engine.ts b/apps/desktop/src/components/pane-shell/tree/zones-engine.ts new file mode 100644 index 000000000000..6be4e6b34f54 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/zones-engine.ts @@ -0,0 +1,367 @@ +/** + * FancyZones runtime engine — verbatim port of the drag/highlight internals + * from PowerToys `FancyZonesLib` (microsoft/PowerToys, MIT): + * + * - `Layout.cpp` -> `zonesFromPoint` (sensitivity-radius capture, the + * "captured but not strictly captured" rejection, the overlap resolution + * algorithms incl. ClosestCenter with OVERLAPPING_CENTERS_SENSITIVITY), + * `getCombinedZoneRange`, `getCombinedZonesRect`. + * - `HighlightedZones.cpp` -> the `HighlightedZones` update state machine + * (initial-zone latch + combined range while "select many" is held). + * - `ZonesOverlay.cpp` -> animation timing: FadeInDurationMillis = 200, + * FlashZonesDurationMillis = 700, alpha = clamp(t/200, 0.001, 1), and the + * fill-alpha rule (highlightOpacity applies to BOTH inactive + highlight). + * - `Colors.cpp` -> ZoneColors resolution (system-theme mode maps accent to + * border+highlight and background to primary; number color auto-contrasts). + */ + +// --------------------------------------------------------------------------- +// Constants (ZonesOverlay.cpp / Layout.cpp / FancyZones defaults) +// --------------------------------------------------------------------------- + +export const FADE_IN_DURATION_MILLIS = 200 +export const FLASH_ZONES_DURATION_MILLIS = 700 +/** LayoutDefaultSettings::DefaultSensitivityRadius. */ +export const DEFAULT_SENSITIVITY_RADIUS = 20 +/** ZoneSelectionAlgorithms::OVERLAPPING_CENTERS_SENSITIVITY. */ +const OVERLAPPING_CENTERS_SENSITIVITY = 75 + +export type OverlappingZonesAlgorithm = 'Smallest' | 'Largest' | 'Positional' | 'ClosestCenter' + +export interface ZoneRect { + left: number + top: number + right: number + bottom: number +} + +export interface EngineZone { + id: string + rect: ZoneRect +} + +interface Point { + x: number + y: number +} + +const zoneArea = (z: EngineZone) => Math.max(0, z.rect.right - z.rect.left) * Math.max(0, z.rect.bottom - z.rect.top) + +// --------------------------------------------------------------------------- +// ZonesOverlay::GetAnimationAlpha (verbatim ramp) +// --------------------------------------------------------------------------- + +export function getAnimationAlpha(startedAtMs: number, nowMs: number, autoHide: boolean): number { + const millis = nowMs - startedAtMs + + if (autoHide && millis > FLASH_ZONES_DURATION_MILLIS) { + return 0 + } + + // Return a positive value to avoid hiding. + return Math.min(Math.max(millis / FADE_IN_DURATION_MILLIS, 0.001), 1) +} + +// --------------------------------------------------------------------------- +// Colors (Colors.cpp + FancyZones settings defaults) +// --------------------------------------------------------------------------- + +export interface ZoneColors { + primaryColor: string + borderColor: string + highlightColor: string + numberColor: string + /** 0..100, applied as fill alpha to BOTH primary and highlight fills. */ + highlightOpacity: number +} + +/** FancyZonesSettings defaults (custom-color mode). */ +export const FANCYZONES_DEFAULT_COLORS: ZoneColors = { + primaryColor: '#F5FCFF', + borderColor: '#FFFFFF', + highlightColor: '#008CFF', + numberColor: '#000000', + highlightOpacity: 50 +} + +/** + * Colors::GetZoneColors systemTheme branch, mapped to our design tokens: + * accent -> border + highlight, surface -> primary, number auto-contrast + * (CSS light-dark handles what the C++ does with the black-background check). + */ +export function systemThemeZoneColors(): ZoneColors { + return { + primaryColor: 'var(--ui-bg-editor)', + borderColor: 'var(--ui-accent)', + highlightColor: 'var(--ui-accent)', + numberColor: 'var(--ui-text-primary)', + highlightOpacity: 50 + } +} + +// --------------------------------------------------------------------------- +// ZoneSelectionAlgorithms (Layout.cpp, verbatim) +// --------------------------------------------------------------------------- + +function zoneSelectPriority( + zones: Map, + capturedZones: string[], + compare: (a: EngineZone, b: EngineZone) => boolean +): string[] { + let chosen = 0 + + for (let i = 1; i < capturedZones.length; i++) { + if (compare(zones.get(capturedZones[i])!, zones.get(capturedZones[chosen])!)) { + chosen = i + } + } + + return [capturedZones[chosen]] +} + +function zoneSelectSubregion( + zones: Map, + capturedZones: string[], + pt: Point, + sensitivityRadius: number +): string[] { + const expand = (rect: ZoneRect): ZoneRect => ({ + top: rect.top - sensitivityRadius / 2, + bottom: rect.bottom + sensitivityRadius / 2, + left: rect.left - sensitivityRadius / 2, + right: rect.right + sensitivityRadius / 2 + }) + + // Compute the overlapped rectangle. + let overlap = expand(zones.get(capturedZones[0])!.rect) + + for (let i = 1; i < capturedZones.length; i++) { + const current = expand(zones.get(capturedZones[i])!.rect) + overlap = { + top: Math.max(overlap.top, current.top), + left: Math.max(overlap.left, current.left), + bottom: Math.min(overlap.bottom, current.bottom), + right: Math.min(overlap.right, current.right) + } + } + + // Avoid division by zero. + const width = Math.max(overlap.right - overlap.left, 1) + const height = Math.max(overlap.bottom - overlap.top, 1) + + const verticalSplit = height > width + let zoneIndex: number + + if (verticalSplit) { + zoneIndex = Math.floor(((pt.y - overlap.top) * capturedZones.length) / height) + } else { + zoneIndex = Math.floor(((pt.x - overlap.left) * capturedZones.length) / width) + } + + zoneIndex = Math.min(Math.max(zoneIndex, 0), capturedZones.length - 1) + + return [capturedZones[zoneIndex]] +} + +function zoneSelectClosestCenter(zones: Map, capturedZones: string[], pt: Point): string[] { + const getCenter = (zone: EngineZone): Point => ({ + x: (zone.rect.right + zone.rect.left) / 2, + y: (zone.rect.top + zone.rect.bottom) / 2 + }) + + const pointDifference = (a: Point, b: Point) => (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) + const distanceFromCenter = (zone: EngineZone) => pointDifference(getCenter(zone), pt) + + const closerToCenter = (zone1: EngineZone, zone2: EngineZone) => { + if (pointDifference(getCenter(zone1), getCenter(zone2)) > OVERLAPPING_CENTERS_SENSITIVITY) { + return distanceFromCenter(zone1) < distanceFromCenter(zone2) + } + + return zoneArea(zone1) < zoneArea(zone2) + } + + return zoneSelectPriority(zones, capturedZones, closerToCenter) +} + +// --------------------------------------------------------------------------- +// Layout::ZonesFromPoint (verbatim) +// --------------------------------------------------------------------------- + +export function zonesFromPoint( + zoneList: EngineZone[], + pt: Point, + sensitivityRadius = DEFAULT_SENSITIVITY_RADIUS, + overlappingAlgorithm: OverlappingZonesAlgorithm = 'Smallest' +): string[] { + const zones = new Map(zoneList.map(z => [z.id, z])) + const capturedZones: string[] = [] + const strictlyCapturedZones: string[] = [] + + for (const zone of zoneList) { + const zoneRect = zone.rect + + if ( + zoneRect.left - sensitivityRadius <= pt.x && + pt.x <= zoneRect.right + sensitivityRadius && + zoneRect.top - sensitivityRadius <= pt.y && + pt.y <= zoneRect.bottom + sensitivityRadius + ) { + capturedZones.push(zone.id) + } + + if (zoneRect.left <= pt.x && pt.x < zoneRect.right && zoneRect.top <= pt.y && pt.y < zoneRect.bottom) { + strictlyCapturedZones.push(zone.id) + } + } + + // If only one zone is captured, but it's not strictly captured + // don't consider it as captured. + if (capturedZones.length === 1 && strictlyCapturedZones.length === 0) { + return [] + } + + // If captured zones do not overlap, return all of them. + // Otherwise, return one of them based on the chosen selection algorithm. + let overlap = false + + outer: for (let i = 0; i < capturedZones.length; i++) { + for (let j = i + 1; j < capturedZones.length; j++) { + const rectI = zones.get(capturedZones[i])!.rect + const rectJ = zones.get(capturedZones[j])!.rect + + if ( + Math.max(rectI.top, rectJ.top) + sensitivityRadius < Math.min(rectI.bottom, rectJ.bottom) && + Math.max(rectI.left, rectJ.left) + sensitivityRadius < Math.min(rectI.right, rectJ.right) + ) { + overlap = true + + break outer + } + } + } + + if (overlap) { + switch (overlappingAlgorithm) { + case 'Smallest': + return zoneSelectPriority(zones, capturedZones, (a, b) => zoneArea(a) < zoneArea(b)) + + case 'Largest': + return zoneSelectPriority(zones, capturedZones, (a, b) => zoneArea(a) > zoneArea(b)) + + case 'Positional': + return zoneSelectSubregion(zones, capturedZones, pt, sensitivityRadius) + + case 'ClosestCenter': + return zoneSelectClosestCenter(zones, capturedZones, pt) + } + } + + return capturedZones +} + +/** The single zone a drop lands in: ClosestCenter among the highlighted set. */ +export function primaryZone(zoneList: EngineZone[], highlighted: string[], pt: Point): string | null { + if (highlighted.length === 0) { + return null + } + + if (highlighted.length === 1) { + return highlighted[0] + } + + const zones = new Map(zoneList.map(z => [z.id, z])) + + return zoneSelectClosestCenter(zones, highlighted, pt)[0] ?? highlighted[0] +} + +// --------------------------------------------------------------------------- +// Layout::GetCombinedZoneRange (verbatim) +// --------------------------------------------------------------------------- + +export function getCombinedZoneRange(zoneList: EngineZone[], initialZones: string[], finalZones: string[]): string[] { + const zones = new Map(zoneList.map(z => [z.id, z])) + const combinedZones = [...new Set([...initialZones, ...finalZones])] + + let boundingRect: ZoneRect | null = null + + for (const zoneId of combinedZones) { + const zone = zones.get(zoneId) + + if (zone) { + const rect = zone.rect + + if (!boundingRect) { + boundingRect = { ...rect } + } else { + boundingRect.left = Math.min(boundingRect.left, rect.left) + boundingRect.top = Math.min(boundingRect.top, rect.top) + boundingRect.right = Math.max(boundingRect.right, rect.right) + boundingRect.bottom = Math.max(boundingRect.bottom, rect.bottom) + } + } + } + + const result: string[] = [] + + if (boundingRect) { + for (const zone of zoneList) { + const rect = zone.rect + + if ( + boundingRect.left <= rect.left && + rect.right <= boundingRect.right && + boundingRect.top <= rect.top && + rect.bottom <= boundingRect.bottom + ) { + result.push(zone.id) + } + } + } + + return result +} + +// --------------------------------------------------------------------------- +// HighlightedZones (HighlightedZones.cpp, verbatim state machine) +// --------------------------------------------------------------------------- + +export class HighlightedZones { + private highlightZone: string[] = [] + private initialHighlightZone: string[] = [] + + zones(): readonly string[] { + return this.highlightZone + } + + empty(): boolean { + return this.highlightZone.length === 0 + } + + /** Returns true when the highlight set changed. */ + update(zoneList: EngineZone[], point: Point, selectManyZones: boolean): boolean { + let highlightZone = zonesFromPoint(zoneList, point) + + if (selectManyZones) { + if (this.initialHighlightZone.length === 0) { + // First time. + this.initialHighlightZone = highlightZone + } else { + highlightZone = getCombinedZoneRange(zoneList, this.initialHighlightZone, highlightZone) + } + } else { + this.initialHighlightZone = [] + } + + const updated = + highlightZone.length !== this.highlightZone.length || highlightZone.some((z, i) => z !== this.highlightZone[i]) + + this.highlightZone = highlightZone + + return updated + } + + reset(): void { + this.highlightZone = [] + this.initialHighlightZone = [] + } +} diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx new file mode 100644 index 000000000000..92f4201c6964 --- /dev/null +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -0,0 +1,138 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +/** Inset bottom stroke for a horizontal tab strip — titlebar color, cut by the active tab. */ +export const PANE_TAB_STRIP_LINE = 'shadow-[inset_0_-1px_0_var(--ui-stroke-tertiary)]' + +/** Inset stroke for a vertical tab rail — content-facing edge. */ +export const PANE_TAB_STRIP_LINE_LEFT = 'shadow-[inset_1px_0_0_var(--ui-stroke-tertiary)]' +export const PANE_TAB_STRIP_LINE_RIGHT = 'shadow-[inset_-1px_0_0_var(--ui-stroke-tertiary)]' + +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_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))]' + +// Inactive = gutter. Hover = 4% translucent wash (VS Code/GitHub alpha hover), +// not an opaque recolor — and never touch borders. +const TAB_IDLE = + 'text-(--ui-text-tertiary) [--tab-bg:var(--pane-tab-strip-bg,var(--theme-card-seed))] hover:shadow-[inset_0_0_0_100vmax_color-mix(in_srgb,var(--ui-base)_4%,transparent)] hover:text-(--ui-text-secondary)' + +interface PaneTabProps extends React.ComponentProps<'div'> { + active?: boolean + dirty?: boolean + /** Middle-click close (no hover X — too easy to hit on small tabs). */ + onClose?: () => void + /** Vertical rail form (collapsed sidebar zones). */ + vertical?: boolean + /** Content-facing edge of a vertical rail — the strip line the active tab cuts. */ + side?: 'left' | 'right' +} + +/** + * Editor tab shell — preview rail + zone headers + collapsed vertical rails. + * + * Strip sets `--pane-tab-active-bg` (content surface) and `--pane-tab-strip-bg` + * (gutter; prefer `--theme-card-seed` = VS Code `tab.inactiveBackground`). + * Active merges into content; inactive sits flush in the gutter. + */ +export const PaneTab = React.forwardRef(function PaneTab( + { + active = false, + dirty = false, + onClose, + onAuxClick, + onMouseDown, + vertical = false, + side = 'left', + children, + className, + ...props + }, + ref +) { + // Content-facing edge: horizontal cuts the bottom strip line; vertical cuts + // the side that faces the editor (left rail → right edge, right rail → left). + const edge = vertical ? (side === 'right' ? 'border-l' : 'border-r') : 'border-b' + + return ( +
{ + // Middle-click closes (browser/IDE). Swallow mousedown so Chromium + // doesn't autoscroll. + if (onClose && event.button === 1) { + event.preventDefault() + onClose() + } + + onAuxClick?.(event) + }} + onMouseDown={event => { + if (onClose && event.button === 1) { + event.preventDefault() + } + + onMouseDown?.(event) + }} + ref={ref} + {...props} + > + {children} + {dirty && ( + + + + )} +
+ ) +}) + +interface PaneTabLabelProps extends React.ComponentProps<'button'> { + /** `button` when the label is the activation target (preview rail); + * default `span` defers to the shell (zone drag/activate). */ + as?: 'button' | 'span' +} + +/** Truncating label inside a `PaneTab`. `className` merges into the text span + * (e.g. `normal-case tracking-normal` for filenames). */ +export const PaneTabLabel = React.forwardRef(function PaneTabLabel( + { as = 'span', className, children, ...props }, + ref +) { + const Comp = as as React.ElementType + + return ( + + + {children} + + + ) +}) From aae35c5ee4049f509820f4ade1e37dda042a16ce Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:28:21 -0400 Subject: [PATCH 06/28] =?UTF-8?q?feat(desktop):=20shared=20UI=20=E2=80=94?= =?UTF-8?q?=20per-session=20prompt=20overlays,=20gateway=20overlays,=20tab?= =?UTF-8?q?=20primitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assistant-ui/clarify-tool.test.tsx | 2 +- .../components/assistant-ui/clarify-tool.tsx | 9 +- .../components/assistant-ui/tool/approval.tsx | 29 +++-- .../components/gateway-connecting-overlay.tsx | 82 +++---------- .../src/components/onboarding/index.tsx | 2 + .../src/components/prompt-overlays.test.tsx | 4 +- .../src/components/prompt-overlays.tsx | 28 +++-- apps/desktop/src/components/ui/badge.tsx | 12 +- .../src/components/ui/context-menu.tsx | 32 +++-- .../desktop/src/components/ui/decode-text.tsx | 113 ++++++++++++++++++ .../src/components/ui/drop-affordance.tsx | 29 +++++ .../src/components/ui/title-menu-trigger.tsx | 33 +++++ 12 files changed, 271 insertions(+), 104 deletions(-) create mode 100644 apps/desktop/src/components/ui/decode-text.tsx create mode 100644 apps/desktop/src/components/ui/drop-affordance.tsx create mode 100644 apps/desktop/src/components/ui/title-menu-trigger.tsx diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx index a508b8471c51..07c615bdf6fc 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx @@ -1,5 +1,5 @@ -import { cleanup, render, screen } from '@testing-library/react' import type { ToolCallMessagePartProps } from '@assistant-ui/react' +import { cleanup, render, screen } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index 967c9aac2e4c..72b6fbbdfa81 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -13,6 +13,7 @@ import { useState } from 'react' +import { useSessionView } from '@/app/chat/session-view' import { ToolFallback } from '@/components/assistant-ui/tool/fallback' import { Button } from '@/components/ui/button' import { Kbd } from '@/components/ui/kbd' @@ -21,7 +22,7 @@ import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons' import { cn } from '@/lib/utils' -import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify' +import { clearClarifyRequest, sessionClarifyRequest } from '@/store/clarify' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' @@ -184,7 +185,11 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) { function ClarifyToolPending({ args }: ToolCallMessagePartProps) { const { t } = useI18n() const copy = t.assistant.clarify - const request = useStore($clarifyRequest) + // The tool row is in whichever session's transcript rendered it — read THAT + // session's clarify (primary or tile), not the globally-active one. + const sessionId = useStore(useSessionView().$runtimeId) + const $request = useMemo(() => sessionClarifyRequest(sessionId), [sessionId]) + const request = useStore($request) const gateway = useStore($gateway) const fromArgs = useMemo(() => readClarifyArgs(args), [args]) diff --git a/apps/desktop/src/components/assistant-ui/tool/approval.tsx b/apps/desktop/src/components/assistant-ui/tool/approval.tsx index 9d0d69b06a1d..a909e486fd86 100644 --- a/apps/desktop/src/components/assistant-ui/tool/approval.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/approval.tsx @@ -1,8 +1,9 @@ 'use client' import { useStore } from '@nanostores/react' -import { type FC, useCallback, useEffect, useState } from 'react' +import { type FC, useCallback, useEffect, useMemo, useState } from 'react' +import { useSessionView } from '@/app/chat/session-view' import { Button } from '@/components/ui/button' import { Dialog, @@ -20,11 +21,11 @@ import { cn } from '@/lib/utils' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { - $approvalInlineVisible, - $approvalRequest, type ApprovalRequest, clearApprovalRequest, - registerApprovalInlineAnchor + registerApprovalInlineAnchor, + sessionApprovalInlineVisible, + sessionApprovalRequest } from '@/store/prompts' import type { ToolPart } from './fallback-model' @@ -48,7 +49,11 @@ export const APPROVAL_TOOLS = new Set(['terminal', 'execute_code']) type ApprovalChoice = 'once' | 'session' | 'always' | 'deny' export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => { - const request = useStore($approvalRequest) + // The tool row lives in whichever session's transcript rendered it — read + // THAT session's approval (works for the primary and every tile). + const sessionId = useStore(useSessionView().$runtimeId) + const $request = useMemo(() => sessionApprovalRequest(sessionId), [sessionId]) + const request = useStore($request) if (!request || !APPROVAL_TOOLS.has(part.toolName)) { return null @@ -58,15 +63,18 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => { } const InlineApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => { - useEffect(() => registerApprovalInlineAnchor(), []) + useEffect(() => registerApprovalInlineAnchor(request.sessionId), [request.sessionId]) return } export const PendingApprovalFallback: FC = () => { const { t } = useI18n() - const request = useStore($approvalRequest) - const inlineVisible = useStore($approvalInlineVisible) + const sessionId = useStore(useSessionView().$runtimeId) + const $request = useMemo(() => sessionApprovalRequest(sessionId), [sessionId]) + const $inlineVisible = useMemo(() => sessionApprovalInlineVisible(sessionId), [sessionId]) + const request = useStore($request) + const inlineVisible = useStore($inlineVisible) if (!request || inlineVisible) { return null @@ -119,8 +127,9 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline' const respond = useCallback( async (choice: ApprovalChoice) => { // Another bar (or the keyboard path) may have already resolved this - // approval; the atom is the single source of truth, so bail if it's gone. - if (busy || !$approvalRequest.get()) { + // approval; the map is the single source of truth, so bail if this + // session's request is gone. + if (busy || !sessionApprovalRequest(request.sessionId).get()) { return } diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx index aa2065147502..be4b5d82843f 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -1,20 +1,15 @@ import { useStore } from '@nanostores/react' import { useEffect, useRef, useState } from 'react' +import { DecodeText } from '@/components/ui/decode-text' import { cn } from '@/lib/utils' import { $desktopBoot } from '@/store/boot' import { $gatewaySwitching } from '@/store/gateway-switch' import { $gatewayState } from '@/store/session' -// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at -// the render level means no timer logic (even a stale HMR one) can ever -// scramble "CONN". -const PREFIX = 'CONN' -const TAIL = 'ECTING' -// Even-weight mono ascii so cycling glyphs don't jump width (matches the -// nousnet-web download-button decode effect). -const SCRAMBLE_CHARS = '/\\|-_=+<>~:*' -const TICK_MS = 45 +// Decode mechanics live in the shared primitive +// (components/ui/decode-text.tsx). "CONN" stays legible via prefix={4}. +const TEXT = 'CONNECTING' // Exit choreography (ms): text fades down + out, hold, then the overlay fades. const TEXT_OUT_MS = 360 @@ -40,18 +35,11 @@ function forcedPreview(): boolean { } } -function scrambledTail(resolvedCount: number): string { - return Array.from(TAIL, (ch, i) => - i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0] - ).join('') -} - export function GatewayConnectingOverlay() { const gatewayState = useStore($gatewayState) const boot = useStore($desktopBoot) const gatewaySwitching = useStore($gatewaySwitching) const [previewing] = useState(forcedPreview) - const [tail, setTail] = useState(TAIL) const [phase, setPhase] = useState('live') // Once cold boot has completed once, never resurrect the fullscreen overlay // — soft gateway switches keep the shell and reskeleton the sidebar instead. @@ -67,12 +55,14 @@ export function GatewayConnectingOverlay() { // the chat then — users should still be able to type drafts, open settings, // and recover instead of staring at a modal CONNECTING screen. const initialBootActive = boot.visible || boot.running || boot.progress < 100 + const connecting = !coldBootDoneRef.current && !gatewaySwitching && gatewayState !== 'open' && !boot.error && initialBootActive + // Latches once we've actually shown the overlay, so the brief frame where // gatewayState flips to "open" (connecting -> false) before the exit phase // kicks in doesn't unmount us and cause a flash. @@ -82,36 +72,6 @@ export function GatewayConnectingOverlay() { shownRef.current = true } - // Decode loop — only while live (freeze the resolved word during the exit). - useEffect(() => { - if (phase !== 'live' || (!previewing && !connecting)) { - return - } - - let resolved = 0 - let hold = 0 - - const id = window.setInterval(() => { - if (resolved >= TAIL.length) { - hold += 1 - - if (hold > 16) { - resolved = 0 - hold = 0 - } - - setTail(TAIL) - - return - } - - resolved += 0.5 - setTail(scrambledTail(Math.floor(resolved))) - }, TICK_MS) - - return () => window.clearInterval(id) - }, [phase, previewing, connecting]) - // Kick off the exit when connected: real connect, or a faked timer in preview. useEffect(() => { if (phase !== 'live') { @@ -119,16 +79,12 @@ export function GatewayConnectingOverlay() { } if (previewing) { - const id = window.setTimeout(() => { - setTail(TAIL) - setPhase('text-out') - }, PREVIEW_CONNECT_MS) + const id = window.setTimeout(() => setPhase('text-out'), PREVIEW_CONNECT_MS) return () => window.clearTimeout(id) } if (gatewayState === 'open' && shownRef.current) { - setTail(TAIL) setPhase('text-out') } }, [phase, previewing, gatewayState]) @@ -149,10 +105,7 @@ export function GatewayConnectingOverlay() { // Preview replays so we can keep watching the transition. if (phase === 'gone' && previewing) { - const id = window.setTimeout(() => { - setTail(TAIL) - setPhase('live') - }, PREVIEW_REPLAY_MS) + const id = window.setTimeout(() => setPhase('live'), PREVIEW_REPLAY_MS) return () => window.clearTimeout(id) } @@ -183,21 +136,16 @@ export function GatewayConnectingOverlay() { overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100' )} > - - - {PREFIX} - {tail} - + cursor + prefix={4} + text={TEXT} + />
) } diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index df547b91a655..c7541b924d29 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -410,10 +410,12 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { // Which key-form option to preselect when we flip to 'apikey' mode. The // OpenRouter row selects its key; the generic link lands on the first option. const [apiKeyInitialEnv, setApiKeyInitialEnv] = useState(undefined) + const openKeyForm = (envKey?: string) => { setApiKeyInitialEnv(envKey) setOnboardingMode('apikey') } + const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers]) const hasOauth = ordered.length > 0 const apiKeyOptions = useApiKeyCatalog() diff --git a/apps/desktop/src/components/prompt-overlays.test.tsx b/apps/desktop/src/components/prompt-overlays.test.tsx index 7b8cb2148f5e..dd9cdb7ec0b4 100644 --- a/apps/desktop/src/components/prompt-overlays.test.tsx +++ b/apps/desktop/src/components/prompt-overlays.test.tsx @@ -12,10 +12,10 @@ import { PromptOverlays } from './prompt-overlays' vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() })) -function renderPrompts() { +function renderPrompts(sessionId: string | null = 's1') { render( - + ) } diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx index cf56d62e85ad..c64656e88ef6 100644 --- a/apps/desktop/src/components/prompt-overlays.tsx +++ b/apps/desktop/src/components/prompt-overlays.tsx @@ -1,7 +1,7 @@ 'use client' import { useStore } from '@nanostores/react' -import { type FormEvent, useCallback, useEffect, useState } from 'react' +import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react' import { PendingApprovalFallback } from '@/components/assistant-ui/tool/approval' import { Button } from '@/components/ui/button' @@ -20,7 +20,12 @@ import { triggerHaptic } from '@/lib/haptics' import { KeyRound, Loader2, Lock } from '@/lib/icons' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' -import { $secretRequest, $sudoRequest, clearSecretRequest, clearSudoRequest } from '@/store/prompts' +import { + clearSecretRequest, + clearSudoRequest, + sessionSecretRequest, + sessionSudoRequest +} from '@/store/prompts' // Renders the modal mid-turn prompts the gateway raises and waits on: sudo // password and skill secret capture. Dangerous-command / execute_code approval @@ -35,10 +40,11 @@ import { $secretRequest, $sudoRequest, clearSecretRequest, clearSudoRequest } fr // fire a second `*.respond` alongside onOpenChange (double-send) or block the // backdrop-dismiss path. -function SudoDialog() { +function SudoDialog({ sessionId }: { sessionId: string | null }) { const { t } = useI18n() const copy = t.prompts - const request = useStore($sudoRequest) + const $request = useMemo(() => sessionSudoRequest(sessionId), [sessionId]) + const request = useStore($request) const gateway = useStore($gateway) const [password, setPassword] = useState('') const [submitting, setSubmitting] = useState(false) @@ -137,10 +143,11 @@ function SudoDialog() { ) } -function SecretDialog() { +function SecretDialog({ sessionId }: { sessionId: string | null }) { const { t } = useI18n() const copy = t.prompts - const request = useStore($secretRequest) + const $request = useMemo(() => sessionSecretRequest(sessionId), [sessionId]) + const request = useStore($request) const gateway = useStore($gateway) const [value, setValue] = useState('') const [submitting, setSubmitting] = useState(false) @@ -237,12 +244,15 @@ function SecretDialog() { ) } -export function PromptOverlays() { +/** Mid-turn prompt surfaces for ONE session. Mounted by both the primary chat + * and each tile with its own session id, so a background/tiled session's + * blocking prompt renders instead of silently stalling. */ +export function PromptOverlays({ sessionId }: { sessionId: string | null }) { return ( <> - - + + ) } diff --git a/apps/desktop/src/components/ui/badge.tsx b/apps/desktop/src/components/ui/badge.tsx index 6f286c3fde7d..c4e46e52361d 100644 --- a/apps/desktop/src/components/ui/badge.tsx +++ b/apps/desktop/src/components/ui/badge.tsx @@ -7,7 +7,7 @@ import { cn } from '@/lib/utils' // Small status/metadata tag. App radius (not a full pill); tones map to the // shared accent/muted/destructive surfaces so badges read consistently. const badgeVariants = cva( - 'inline-flex w-fit shrink-0 items-center gap-1 rounded-[3px] px-1.5 py-0.5 text-[0.65rem] font-medium leading-none whitespace-nowrap [&_svg]:size-3 [&_svg]:pointer-events-none', + 'inline-flex w-fit shrink-0 items-center gap-1 rounded-[3px] font-medium leading-none whitespace-nowrap [&_svg]:pointer-events-none', { variants: { variant: { @@ -16,9 +16,13 @@ const badgeVariants = cva( warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300', destructive: 'bg-destructive/10 text-destructive', outline: 'border border-(--ui-stroke-secondary) text-muted-foreground' + }, + size: { + default: 'px-1.5 py-0.5 text-[0.65rem] [&_svg]:size-3', + xs: 'px-1 py-px text-[0.6rem] [&_svg]:size-2.5' } }, - defaultVariants: { variant: 'default' } + defaultVariants: { variant: 'default', size: 'default' } } ) @@ -26,10 +30,10 @@ export interface BadgeProps extends React.ComponentProps<'span'>, VariantProps + return } export { badgeVariants } diff --git a/apps/desktop/src/components/ui/context-menu.tsx b/apps/desktop/src/components/ui/context-menu.tsx index 0849efdd53c6..1652d68b9f70 100644 --- a/apps/desktop/src/components/ui/context-menu.tsx +++ b/apps/desktop/src/components/ui/context-menu.tsx @@ -113,16 +113,30 @@ function ContextMenuSubTrigger({ ) } -function ContextMenuSubContent({ className, ...props }: React.ComponentProps) { +function ContextMenuSubContent({ + className, + collisionPadding = 8, + ...props +}: React.ComponentProps) { return ( - + // Portal the submenu out of the parent Content so it escapes that Content's + // `overflow` clip — without this a submenu opening from a scrollable / + // overflow-hidden menu gets visually cut off at the parent's edges. Radix + // Popper still anchors it to the SubTrigger, so portaling is safe. Mirrors + // DropdownMenuSubContent. + + + ) } diff --git a/apps/desktop/src/components/ui/decode-text.tsx b/apps/desktop/src/components/ui/decode-text.tsx new file mode 100644 index 000000000000..e5bc470ab076 --- /dev/null +++ b/apps/desktop/src/components/ui/decode-text.tsx @@ -0,0 +1,113 @@ +import { type ComponentProps, useEffect, useState } from 'react' + +import { cn } from '@/lib/utils' + +/** + * DecodeText — the "CONNECTING" scramble-decode effect as a reusable + * primitive (extracted from gateway-connecting-overlay.tsx; same mechanics): + * + * - Even-weight mono ascii charset so cycling glyphs never jump width + * (matches the nousnet-web download-button decode effect). + * - Decode resolves half a character per 45ms tick; when fully resolved it + * holds for 16 ticks, then (in loop mode) replays. + * - The first `prefix` characters NEVER scramble — split at render level so + * no timer logic (even a stale HMR one) can garble them. + * - Optional blinking dither-cursor square. + * + * Typography (mono, small, uppercase, wide tracking) is baked in; color comes + * from the caller via className/text color so the same primitive works on the + * boot overlay (--theme-primary) and quiet surfaces (--ui-text-quaternary). + */ + +export const DECODE_SCRAMBLE_CHARS = '/\\|-_=+<>~:*' +const TICK_MS = 45 +const HOLD_TICKS = 16 + +function scrambled(tail: string, resolvedCount: number): string { + return Array.from(tail, (ch, i) => + ch === ' ' || i < resolvedCount ? ch : DECODE_SCRAMBLE_CHARS[(Math.random() * DECODE_SCRAMBLE_CHARS.length) | 0] + ).join('') +} + +export interface DecodeTextProps extends Omit, 'prefix'> { + text: string + /** Leading character count that stays legible at all times. */ + prefix?: number + /** Run the decode. When false, renders the plain resolved text (used to + * freeze the word during exit choreography). */ + active?: boolean + /** Replay after the hold, or resolve once and stop. */ + loop?: boolean + /** Blinking dither-cursor square after the text. */ + cursor?: boolean +} + +export function DecodeText({ + active = true, + className, + cursor = false, + loop = true, + prefix = 0, + text, + ...props +}: DecodeTextProps) { + const staticPrefix = text.slice(0, prefix) + const tailText = text.slice(prefix) + const [tail, setTail] = useState(tailText) + + useEffect(() => { + if (!active) { + setTail(tailText) + + return + } + + let resolved = 0 + let hold = 0 + + const id = window.setInterval(() => { + if (resolved >= tailText.length) { + hold += 1 + + if (hold > HOLD_TICKS) { + if (loop) { + resolved = 0 + hold = 0 + } else { + window.clearInterval(id) + } + } + + setTail(tailText) + + return + } + + resolved += 0.5 + setTail(scrambled(tailText, Math.floor(resolved))) + }, TICK_MS) + + return () => window.clearInterval(id) + }, [active, loop, tailText]) + + return ( + + {cursor && } + {staticPrefix} + {tail} + {cursor && ( + + ) +} diff --git a/apps/desktop/src/components/ui/drop-affordance.tsx b/apps/desktop/src/components/ui/drop-affordance.tsx new file mode 100644 index 000000000000..937233f86a34 --- /dev/null +++ b/apps/desktop/src/components/ui/drop-affordance.tsx @@ -0,0 +1,29 @@ +/** + * 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. + */ + +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/title-menu-trigger.tsx b/apps/desktop/src/components/ui/title-menu-trigger.tsx new file mode 100644 index 000000000000..9705b9b61f2f --- /dev/null +++ b/apps/desktop/src/components/ui/title-menu-trigger.tsx @@ -0,0 +1,33 @@ +import type * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +/** + * Compact "Label ▾" chrome trigger. Domain-agnostic — drop in as the child of + * `DropdownMenuTrigger asChild` (or any asChild menu trigger). Sessions, + * projects, filters, etc. own their menus; this only owns the trigger look. + */ +export function TitleMenuTrigger({ + children, + className, + ...props +}: Omit, 'children' | 'size' | 'variant'> & { + children: React.ReactNode +}) { + return ( + + ) +} From f1379bd6c26d7b0c5638c2c0932a675b0a1d2dce Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:28:21 -0400 Subject: [PATCH 07/28] feat(desktop): routes, nav, and command palette as contributions --- .../src/app/command-palette/contrib.ts | 28 +++++++ .../desktop/src/app/command-palette/index.tsx | 24 +++++- apps/desktop/src/app/index.tsx | 7 +- apps/desktop/src/app/routes.ts | 78 ++++++++++++++++++- 4 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/app/command-palette/contrib.ts diff --git a/apps/desktop/src/app/command-palette/contrib.ts b/apps/desktop/src/app/command-palette/contrib.ts new file mode 100644 index 000000000000..bd3e669cbe33 --- /dev/null +++ b/apps/desktop/src/app/command-palette/contrib.ts @@ -0,0 +1,28 @@ +/** + * Command-palette contribution surface — `palette` data contributions become + * rows in the ⌘K root list, same schema as every other area. Contributions + * with an `action` id render that action's live keybind as their hotkey hint. + */ + +import { useContributions } from '@/contrib/react/use-contributions' +import type { IconComponent } from '@/lib/icons' + +export const PALETTE_AREA = 'palette' + +/** Payload of a `palette` data contribution. */ +export interface PaletteContribution { + id: string + label: string + /** Keybind action id — its live combo renders as the hotkey hint. */ + action?: string + icon?: IconComponent + keywords?: string[] + run: () => void +} + +/** Contributed palette rows, with stable render keys. */ +export function usePaletteContributions(): Array { + return useContributions(PALETTE_AREA) + .map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as PaletteContribution) })) + .filter(item => Boolean(item.label && item.run)) +} diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index be89ebb4e128..f7a6e0139403 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -80,6 +80,7 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants' import { fieldCopyForSchemaKey } from '../settings/field-copy' import { prettyName } from '../settings/helpers' +import { usePaletteContributions } from './contrib' import { MarketplaceThemePage } from './marketplace-theme-page' import { PetInlineToggle, PetPalettePage } from './pet-palette-page' @@ -368,6 +369,8 @@ export function CommandPalette() { [t.settings.fieldLabels] ) + const contributedItems = usePaletteContributions() + const baseGroups = useMemo(() => { const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}` const cc = t.commandCenter @@ -559,9 +562,26 @@ export function CommandPalette() { run: go(settingsTab(entry.tab)) })) ] - } + }, + // Registry-contributed rows (core features + plugins) — one group, + // omitted while nothing contributes. + ...(contributedItems.length > 0 + ? [ + { + heading: cc.commands, + items: contributedItems.map(item => ({ + action: item.action, + icon: item.icon ?? Zap, + id: item.key, + keywords: item.keywords, + label: item.label, + run: item.run + })) + } + ] + : []) ] - }, [go, settingsSectionLabel, t, worktrees]) + }, [contributedItems, go, settingsSectionLabel, t, worktrees]) // The long, granular lists (settings fields, API keys, MCP servers, archived // chats) only surface once the user types — otherwise they'd bury the diff --git a/apps/desktop/src/app/index.tsx b/apps/desktop/src/app/index.tsx index ad8f79afebf6..a1dc8140b5ad 100644 --- a/apps/desktop/src/app/index.tsx +++ b/apps/desktop/src/app/index.tsx @@ -1 +1,6 @@ -export { DesktopController as default } from './desktop-controller' +// The app root is the contribution-driven shell: panes, titlebar/statusbar +// items, keybinds, palette commands, routes, and themes all register through +// the contribution registry (src/contrib) — core surfaces use the same calls +// plugins do. Everything lives under ./contrib: the wiring (gateway boot, +// sessions, streams) + pane surfaces, and the pane/layout registration. +export { ContribController as default } from './contrib' diff --git a/apps/desktop/src/app/routes.ts b/apps/desktop/src/app/routes.ts index 66ab264e474b..8db7ee7c3900 100644 --- a/apps/desktop/src/app/routes.ts +++ b/apps/desktop/src/app/routes.ts @@ -1,3 +1,8 @@ +import { atom } from 'nanostores' +import type { ReactNode } from 'react' + +import { registry } from '@/contrib/registry' + export const SESSION_ROUTE_PREFIX = '/' export const NEW_CHAT_ROUTE = '/' export const SETTINGS_ROUTE = '/settings' @@ -16,6 +21,11 @@ export type AppView = | 'chat' | 'command-center' | 'cron' + // A contributed (plugin) full page at its own route — NOT chat. Without this + // distinction contributed paths fell through appViewForPath's 'chat' default, + // so the sidebar kept a session highlighted and the titlebar kept the + // session-title dropdown while a plugin page was showing. + | 'extension' | 'messaging' | 'profiles' | 'settings' @@ -56,6 +66,52 @@ export const APP_ROUTES = [ const APP_VIEW_BY_PATH = new Map(APP_ROUTES.map(route => [route.path, route.view])) const RESERVED_PATHS: ReadonlySet = new Set(APP_ROUTES.map(route => route.path)) +// ── Contributed routes — the `routes` registry area ───────────────────────── +// A contribution mounts a FULL PAGE in the workspace pane at `data.path` +// (`render` on the contribution itself, like every other area). Contributed +// paths are reserved exactly like APP_ROUTES so the session-id parser never +// mistakes them for a session route. Navigate with `host.navigate(path)`. + +export const ROUTES_AREA = 'routes' + +/** Payload of a `routes` contribution's `data`. */ +export interface RouteContribution { + /** Absolute path, e.g. `/kanban`. One segment; no params. */ + path: string +} + +export function contributedRoutes(): Array<{ key: string; path: string; title?: string; render: () => ReactNode }> { + return registry + .getArea(ROUTES_AREA) + .map(c => ({ + key: `${c.source ?? 'core'}:${c.id}`, + path: (c.data as RouteContribution | undefined)?.path ?? '', + title: c.title, + render: c.render! + })) + .filter(route => Boolean(route.path.startsWith('/') && route.render) && !RESERVED_PATHS.has(route.path)) +} + +function isContributedPath(pathname: string): boolean { + return contributedRoutes().some(route => route.path === pathname) +} + +// ── Contributed sidebar nav — the `sidebar.nav` registry area ──────────────── +// A DATA contribution adds a row to the sidebar's top nav (below Artifacts). +// Pair with a ROUTES_AREA page: the row navigates to `path` and lights up +// while the app is there. + +export const SIDEBAR_NAV_AREA = 'sidebar.nav' + +/** Payload of a `sidebar.nav` data contribution. */ +export interface SidebarNavContribution { + /** Codicon name, e.g. `'project'`. */ + codicon: string + label: string + /** Route to navigate to (usually a contributed page's path). */ + path: string +} + // Views that render as a full-screen modal card (OverlayView) over the shell. // While one is open the app's titlebar control clusters must hide so they don't // bleed over the overlay (they sit at a higher z-index than the overlay card). @@ -77,7 +133,7 @@ export function isNewChatRoute(pathname: string): boolean { } export function routeSessionId(pathname: string): string | null { - if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname)) { + if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname) || isContributedPath(pathname)) { return null } @@ -95,5 +151,25 @@ export function appViewForPath(pathname: string): AppView { return 'chat' } + if (isContributedPath(pathname)) { + return 'extension' + } + return APP_VIEW_BY_PATH.get(pathname) ?? 'chat' } + +/** True while the workspace pane shows a FULL PAGE (skills/messaging/ + * artifacts/plugin routes) instead of the chat. Published by the wiring + * (which owns the router location); the workspace pane contribution mirrors + * it as `headerVeto` so the zone tab bar stands down on pages. Overlays + * (settings/…) don't count — the chat stays beneath them. */ +export const $workspaceIsPage = atom(false) + +export function syncWorkspaceIsPage(pathname: string): void { + const view = appViewForPath(pathname) + const isPage = view !== 'chat' && !isOverlayView(view) + + if (isPage !== $workspaceIsPage.get()) { + $workspaceIsPage.set(isPage) + } +} From e6fea77d14a93c8a1bbafc08a129d96911206512 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:28:21 -0400 Subject: [PATCH 08/28] =?UTF-8?q?feat(desktop):=20multi-session=20tiles=20?= =?UTF-8?q?=E2=80=94=20per-profile=20state,=20tile=20pane,=20pane=20mirror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/app/chat/pane-mirror.ts | 104 +++++ apps/desktop/src/app/chat/session-tile.tsx | 420 +++++++++++++++++++++ apps/desktop/src/store/session-states.ts | 339 +++++++++++++++++ 3 files changed, 863 insertions(+) create mode 100644 apps/desktop/src/app/chat/pane-mirror.ts create mode 100644 apps/desktop/src/app/chat/session-tile.tsx create mode 100644 apps/desktop/src/store/session-states.ts diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts new file mode 100644 index 000000000000..2190fd72bf01 --- /dev/null +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -0,0 +1,104 @@ +/** + * Mirror a reactive list of "tiles" into layout-tree pane contributions: + * register a pane per tile, refresh its title in place, and dispose panes whose + * tile is gone. This is the shared bookkeeping — a keyed registry, a wanted-set + * diff, a one-time pane closer — behind BOTH session tiles and route (page) + * tiles; each supplies only what differs (key, title, render, close, edge). + */ + +import type { ReadableAtom } from 'nanostores' +import type { ReactElement, ReactNode } from 'react' + +import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store' +import { registry } from '@/contrib/registry' +import type { TileDock } from '@/store/session-states' + +export interface PaneMirror { + /** Reactive source list. */ + source: ReadableAtom + /** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */ + also?: ReadableAtom[] + /** Stable key + pane-id seed for a tile. */ + key: (tile: T) => string + /** Pane-id namespace — the id is `${prefix}:${key}`. */ + prefix: string + /** Dock on adoption (default right; `center` = stack into anchor's zone). */ + dir?: (tile: T) => TileDock | undefined + /** Pane to dock against (default `workspace`) — a drop's target zone. */ + anchor?: (tile: T) => string | undefined + /** Center docks: the strip slot (stack before this pane id). */ + before?: (tile: T) => null | string | undefined + minWidth: string + title: (key: string) => string + render: (key: string) => ReactNode + /** Wrap the tile's TAB (domain context menu — session verbs). */ + tabWrap?: (key: string, tab: ReactElement) => ReactNode + /** Wired as the pane's closer (tab Close). */ + close: (key: string) => void +} + +/** Build a `watch*` fn: syncs once, then re-syncs on every source/also change. + * Module-level state lives in the returned closure, so call it once per app. */ +export function paneMirror(cfg: PaneMirror): () => void { + const registered = new Map void; title: string }>() + const paneId = (key: string) => `${cfg.prefix}:${key}` + + const sync = () => { + const tiles = cfg.source.get() + const wanted = new Set(tiles.map(cfg.key)) + + for (const tile of tiles) { + const key = cfg.key(tile) + const title = cfg.title(key) + const current = registered.get(key) + + // register() replaces same-id in place — safe for live title refreshes. + if (current && current.title === title) { + continue + } + + const dispose = registry.register({ + id: paneId(key), + area: 'panes', + title, + data: { + dock: { before: cfg.before?.(tile), pane: cfg.anchor?.(tile) ?? 'workspace', pos: cfg.dir?.(tile) ?? 'right' }, + minWidth: cfg.minWidth, + placement: 'main', + tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined + }, + render: () => cfg.render(key) + }) + + registered.set(key, { dispose, title }) + + if (!current) { + registerPaneCloser(paneId(key), () => cfg.close(key)) + } + } + + for (const [key, entry] of registered) { + if (!wanted.has(key)) { + entry.dispose() + registered.delete(key) + removeTreePane(paneId(key)) + } + } + + // Prune tree panes the SHARED tree persisted for a tile we never registered + // this session and that isn't wanted now — a profile switch reloads with the + // other profile's tile panes still stacked in. (`registered` is empty after a + // reload, so the loop above can't catch these.) + for (const id of treePanesWithPrefix(`${cfg.prefix}:`)) { + if (!wanted.has(id.slice(cfg.prefix.length + 1))) { + removeTreePane(id) + } + } + } + + return () => { + sync() + cfg.source.listen(sync) + cfg.also?.forEach(atom => atom.listen(sync)) + } +} diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx new file mode 100644 index 000000000000..f0085597e45d --- /dev/null +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -0,0 +1,420 @@ +/** + * SESSION TILES — a stored session rendered as a layout-tree pane BESIDE the + * main thread (multi-session tiling). A tile IS the real chat surface: the + * same ChatView/ChatBar/Thread tree the primary session renders, mounted + * under a tile `SessionView` (its session's slice of `$sessionStates`) and a + * tile `ComposerScope` (own attachment chips, own focus-bus key). Actions + * (submit/slash/steer/edit/reload/restore/stop) come from + * `useSessionTileActions`, all writing through the wiring cache. + * + * Lifecycle: `openSessionTile(storedId)` -> `watchSessionTiles` registers a + * pane contribution docked right of the main zone -> tree adoption lands it + * -> the pane mounts and asks the delegate for a live runtime id. Closing + * the pane (tab Close) removes the tile + its zone; tiles persist across + * restarts and re-resume on boot. + */ + +import { useStore } from '@nanostores/react' +import { atom, computed } from 'nanostores' +import { useEffect, useMemo, useRef } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils' +import { formatRefValue } from '@/components/assistant-ui/directive-text' +import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status' +import { findGroupOfPane } from '@/components/pane-shell/tree/model' +import { $layoutTree, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store' +import { Button } from '@/components/ui/button' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' +import { transcribeAudio } from '@/hermes' +import { useI18n } from '@/i18n' +import type { ChatMessage } from '@/lib/chat-messages' +import { sessionTitle } from '@/lib/chat-runtime' +import { createComposerAttachmentScope } from '@/store/composer' +import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout' +import { sessionAwaitingInput } from '@/store/prompts' +import { + $gatewayState, + $selectedStoredSessionId, + $sessions, + sessionMatchesStoredId, + sessionPinId +} from '@/store/session' +import { + $sessionStates, + $sessionTiles, + closeSessionTile, + discardSessionTile, + patchSessionTile, + type SessionTile, + sessionTileDelegate +} from '@/store/session-states' + +import { type ComposerScope, ComposerScopeProvider } from './composer/scope' +import { useComposerActions } from './hooks/use-composer-actions' +import { paneMirror } from './pane-mirror' +import { useSessionTileActions } from './session-tile-actions' +import { type SessionView, SessionViewProvider } from './session-view' +import { SessionContextMenu } from './sidebar/session-actions-menu' +import { lastVisibleMessageIsUser } from './thread-loading' + +import { ChatView } from '.' + +const NO_MESSAGES: ChatMessage[] = [] + +/** The tile's SessionView: the same atom shape the primary chat renders + * from, computed from this session's slice of `$sessionStates`. */ +function buildTileView(storedSessionId: string): SessionView { + const $runtimeId = computed( + $sessionTiles, + tiles => tiles.find(t => t.storedSessionId === storedSessionId)?.runtimeId ?? null + ) + + const $state = computed([$runtimeId, $sessionStates], (runtimeId, states) => + runtimeId ? states[runtimeId] : undefined + ) + + const $messages = computed($state, state => state?.messages ?? NO_MESSAGES) + + return { + kind: 'tile', + $awaitingResponse: computed($state, state => Boolean(state?.awaitingResponse)), + $busy: computed($state, state => Boolean(state?.busy)), + $cwd: computed($state, state => state?.cwd ?? ''), + $lastVisibleIsUser: computed($messages, lastVisibleMessageIsUser), + $messages, + $messagesEmpty: computed($messages, messages => messages.length === 0), + $model: computed($state, state => state?.model ?? ''), + $provider: computed($state, state => state?.provider ?? ''), + $runtimeId, + // Constant for the tile's lifetime — a plain atom, not a computed. + $storedId: atom(storedSessionId) + } +} + +function TileChat({ + runtimeId, + storedSessionId, + view +}: { + runtimeId: string + storedSessionId: string + view: SessionView +}) { + const { gatewayRef, requestGateway } = useGatewayRequest() + const cwd = useStore(view.$cwd) + + // One attachment set + focus key per tile, stable for the tile's lifetime. + const attachments = useRef(createComposerAttachmentScope()).current + + const scope = useMemo( + () => ({ + $awaitingInput: sessionAwaitingInput(runtimeId), + attachments, + popoutAllowed: false, + readMessages: () => view.$messages.get(), + target: `tile:${storedSessionId}` + }), + [attachments, runtimeId, storedSessionId, view.$messages] + ) + + const actions = useSessionTileActions({ runtimeId, scope, storedSessionId }) + + // The same attach/pick/paste/drop pipeline the primary composer uses, + // pointed at this tile's chips + session. + const composer = useComposerActions({ + activeSessionId: runtimeId, + currentCwd: cwd, + requestGateway, + scope: { add: attachments.add, remove: attachments.remove, target: scope.target } + }) + + return ( + + + composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} + onAttachDroppedItems={composer.attachDroppedItems} + onAttachImageBlob={composer.attachImageBlob} + onBranchInNewChat={() => undefined} + onCancel={actions.cancelRun} + onDeleteSelectedSession={() => undefined} + onDismissError={actions.dismissError} + onEdit={actions.editMessage} + onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)} + onPickFiles={() => void composer.pickContextPaths('file')} + onPickFolders={() => void composer.pickContextPaths('folder')} + onPickImages={() => void composer.pickImages()} + onReload={actions.reloadFromMessage} + onRemoveAttachment={id => void composer.removeAttachment(id)} + onRestoreToMessage={actions.restoreToMessage} + onRetryResume={() => patchSessionTile(storedSessionId, { error: undefined })} + onSteer={actions.steerPrompt} + onSubmit={actions.submitText} + onThreadMessagesChange={actions.handleThreadMessagesChange} + onToggleSelectedPin={() => undefined} + onTranscribeAudio={async audio => (await transcribeAudio(await blobToDataUrl(audio), audio.type)).transcript} + /> + + + ) +} + +export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) { + const tiles = useStore($sessionTiles) + const tile = tiles.find(t => t.storedSessionId === storedSessionId) + const runtimeId = tile?.runtimeId ?? null + const gatewayOpen = useStore($gatewayState) === 'open' + const resumingRef = useRef(false) + const view = useMemo(() => buildTileView(storedSessionId), [storedSessionId]) + + // Same gating as the primary's route resume (use-route-resume): never fire + // session.resume before the gateway is OPEN. Persisted tiles mount at boot + // while it's still connecting — an ungated resume rejected there and + // latched every restored tile into the error card. + useEffect(() => { + if (!gatewayOpen || runtimeId || tile?.error || resumingRef.current) { + return + } + + const delegate = sessionTileDelegate() + + if (!delegate) { + return + } + + resumingRef.current = true + + delegate + .resumeTile(storedSessionId) + .then(id => patchSessionTile(storedSessionId, { error: undefined, runtimeId: id })) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + + // A gone session (404 / "Session not found") is terminal — a stale or + // cross-profile persisted tile. Discard it instead of latching an error + // that re-retries on every reconnect (the "Session not found" spam). + if (/session not found|\b404\b/i.test(message)) { + discardSessionTile(storedSessionId) + } else { + patchSessionTile(storedSessionId, { error: message }) + } + }) + .finally(() => { + resumingRef.current = false + }) + }, [gatewayOpen, runtimeId, storedSessionId, tile?.error]) + + // The gateway (re)opening invalidates any latched error — it likely came + // from a not-yet-open gateway or the previous connection. Clearing it + // retriggers the resume effect: one bounded auto-retry per (re)connect, + // mirroring the primary path's became-open resync. + useEffect(() => { + if (gatewayOpen && tile?.error) { + patchSessionTile(storedSessionId, { error: undefined }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gatewayOpen, storedSessionId]) + + if (tile?.error) { + return ( +
+
+
Couldn't open this session
+
{tile.error}
+ +
+
+ ) + } + + if (!runtimeId) { + // The SAME session loader the primary thread shows (Thread's + // loading === 'session' branch) — one loading language everywhere. + return ( +
+ +
+ ) + } + + return +} + +// --------------------------------------------------------------------------- +// Tile -> pane contribution sync (call once from the app root). +// --------------------------------------------------------------------------- + +function tileTitle(storedSessionId: string): string { + const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)) + + return stored ? sessionTitle(stored) : 'Session' +} + +// --------------------------------------------------------------------------- +// Close confirmation — a BUSY tab (streaming, or blocked on clarify/approval +// input) doesn't close silently. +// --------------------------------------------------------------------------- + +/** Stored id awaiting close confirmation (null = no dialog). */ +const $confirmCloseTile = atom(null) + +/** The tile closer, gated: a quiet session closes immediately; a busy or + * input-blocked one asks first. One state read — the tile's runtime slice. */ +export function requestCloseSessionTile(storedSessionId: string): void { + const runtimeId = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId)?.runtimeId + const state = runtimeId ? $sessionStates.get()[runtimeId] : undefined + + if (state?.busy || state?.awaitingResponse || state?.needsInput) { + $confirmCloseTile.set(storedSessionId) + } else { + closeSessionTile(storedSessionId) + } +} + +/** Mounted once at the shell root: the "Close running tab?" confirmation. */ +export function SessionTileCloseConfirm() { + const { t } = useI18n() + const storedSessionId = useStore($confirmCloseTile) + + return ( + $confirmCloseTile.set(null)} + onConfirm={() => { + if (storedSessionId) { + closeSessionTile(storedSessionId) + } + }} + open={storedSessionId !== null} + title={t.zones.closeRunningTitle} + /> + ) +} + +/** Layout reset → every session tile collapses into the MAIN zone as a tab + * after the workspace (the primary session stays the first tab), the "smart" + * reset: N scattered tiles become one tab bar over the chat instead of + * re-docking to their old edges. + * + * Runs BEFORE generic adoption (see registerLayoutResetHandler) — the tiles + * aren't in the fresh tree yet, so each `moveTreePane` ADDS the tile into the + * workspace group as a tab (append). The main group id is re-read each pass + * because appending returns a new tree. */ +export function stackSessionTilesIntoMain(): void { + for (const tile of $sessionTiles.get()) { + const tree = $layoutTree.get() + const mainGroup = tree ? findGroupOfPane(tree, 'workspace')?.id : null + + if (mainGroup) { + moveTreePane(`session-tile:${tile.storedSessionId}`, { groupId: mainGroup, pos: 'center' }) + } + } +} + +/** A session TAB's context menu: the full session verb set (pin, copy id, new + * window, branch, rename, archive, delete) — the SAME menu a sidebar row + * gets, targeted through the tile delegate (whose verbs are generic over + * stored ids, primary included). The wrapper stops the contextmenu from also + * opening the zone strip's menu. Shared by tile tabs AND the main tab. */ +export function SessionTabMenu({ + children, + onClose, + onHideTabBar, + storedSessionId, + tabPaneId +}: { + children: React.ReactElement + /** Close this tab (tiles; the main tab passes nothing). */ + onClose?: () => void + /** Hide the zone's tab bar (main tab only — the sticky bar's off switch). */ + onHideTabBar?: () => void + storedSessionId: string + /** Layout-tree pane id — powers the Close-others/right/all verbs. */ + tabPaneId: string +}) { + const sessions = useStore($sessions) + const pinnedSessionIds = useStore($pinnedSessionIds) + const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId)) + const pinId = stored ? sessionPinId(stored) : storedSessionId + const pinned = pinnedSessionIds.includes(pinId) + + return ( + event.stopPropagation()}> + void sessionTileDelegate()?.archiveSession(storedSessionId)} + onBranch={() => void sessionTileDelegate()?.branchSession(storedSessionId)} + onClose={onClose} + onDelete={() => void sessionTileDelegate()?.deleteSession(storedSessionId)} + onHideTabBar={onHideTabBar} + onPin={() => (pinned ? unpinSession(pinId) : pinSession(pinId))} + pinned={pinned} + profile={stored?.profile} + sessionId={storedSessionId} + surface="tab" + tabPaneId={tabPaneId} + title={tileTitle(storedSessionId)} + > + {children} + + + ) +} + +/** The MAIN tab's menu: the same session verbs targeting the primary's loaded + * session, plus the bar's off switch (the bar sticky-shows once a tab is + * ever gained; this is the explicit way back). A fresh draft has no session — + * no menu. */ +export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) { + const selected = useStore($selectedStoredSessionId) + + const hideTabBar = () => { + const tree = $layoutTree.get() + const group = tree ? findGroupOfPane(tree, 'workspace') : null + + if (group) { + setTreeGroupHeaderHidden(group.id, true) + } + } + + if (!selected) { + return children + } + + return ( + + {children} + + ) +} + +/** Keep pane contributions mirroring `$sessionTiles` (+ titles from + * `$sessions`). Tiles dock against main on the chosen edge, flex width. */ +export const watchSessionTiles = paneMirror({ + source: $sessionTiles, + also: [$sessions], + key: t => t.storedSessionId, + prefix: 'session-tile', + dir: t => t.dir, + anchor: t => t.anchor, + before: t => t.before, + minWidth: '20rem', + title: tileTitle, + render: storedSessionId => , + tabWrap: (storedSessionId, tab) => ( + requestCloseSessionTile(storedSessionId)} + storedSessionId={storedSessionId} + tabPaneId={`session-tile:${storedSessionId}`} + > + {tab} + + ), + close: requestCloseSessionTile +}) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts new file mode 100644 index 000000000000..be5f2304819f --- /dev/null +++ b/apps/desktop/src/store/session-states.ts @@ -0,0 +1,339 @@ +/** + * MULTI-SESSION VIEW STATE — the reactive face of the per-runtime session + * cache (`sessionStateByRuntimeIdRef` in use-session-state-cache). + * + * The cache already ingests EVERY session's gateway events; only the view + * was single-session ($messages + the active-id gate). This store mirrors + * the cache per runtime id so any number of surfaces (session tiles, future + * pane windows) can each subscribe to one session's state without touching + * the main chat's `$messages` pipeline — same pattern as `useSessionSlice` + * over `$todosBySession`, applied to whole `ClientSessionState`s. + * + * TILES are the first consumer: sessions opened side-by-side with the main + * thread, each in its own layout-tree pane. `$sessionTiles` holds the + * stored-session ids (persisted — tiles survive restarts); the wiring layer + * owns resume/submit (it has the gateway + cache internals) and registers + * itself here as the delegate so tile UI stays dependency-light. + */ + +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 { readJson, writeJson } from '@/lib/storage' + +import { $activeGatewayProfile, normalizeProfileKey } from './profile' +import { $activeSessionId, $selectedStoredSessionId } from './session' + +// --------------------------------------------------------------------------- +// Reactive per-runtime session state (view mirror of the wiring cache). +// --------------------------------------------------------------------------- + +export const $sessionStates = atom>({}) + +/** Publish one session's state (immutable per-key — slices stay stable). */ +export function publishSessionState(runtimeId: string, state: ClientSessionState) { + $sessionStates.set({ ...$sessionStates.get(), [runtimeId]: state }) +} + +export function dropSessionState(runtimeId: string) { + const current = $sessionStates.get() + + if (!(runtimeId in current)) { + return + } + + const { [runtimeId]: _dropped, ...rest } = current + $sessionStates.set(rest) +} + + +// --------------------------------------------------------------------------- +// Session tiles. +// --------------------------------------------------------------------------- + +/** Edge a tile docks against main when it first joins the tree. Shared by + * session tiles and route (page) tiles. */ +export type SplitDir = 'bottom' | 'left' | 'right' | 'top' + +/** Where a tile lands on adoption: an edge split, or `center` = stack into + * the anchor's zone as a tab (a drop on the zone's tab strip). */ +export type TileDock = 'center' | SplitDir + +export interface SessionTile { + /** Stored session id — the durable identity (runtime ids are ephemeral). */ + storedSessionId: string + /** Dock against `anchor` on adoption (default right; center = stack). */ + dir?: TileDock + /** Pane to dock against (a drop's target zone) — default the workspace. + * In-memory only: after first adoption the tree remembers placement. */ + anchor?: string + /** Center docks: stack BEFORE this pane id (`null`/omitted = append) — + * the strip divider's slot. In-memory, like `anchor`. */ + before?: null | string + /** Live runtime id once the tile's resume has bound one. */ + runtimeId?: string + /** Resume failed terminally (shown in the tile; retryable). */ + error?: string +} + +// Tiles are persisted PER PROFILE: a session belongs to one profile, and the +// single live gateway is scoped to one profile at a time, so a tile only makes +// sense while its profile is active. Switching profiles swaps the visible set +// (and drops runtime bindings so each tile re-resumes against the now-current +// gateway — which also settles the "tile resumes against the wrong backend" and +// "stale runtime after respawn" bugs by construction). +const TILES_KEY = 'hermes.desktop.sessionTiles.v2' +const LEGACY_TILES_KEY = 'hermes.desktop.sessionTiles.v1' + +type StoredTile = Pick + +const toStored = (t: SessionTile): StoredTile => ({ dir: t.dir, storedSessionId: t.storedSessionId }) + +function parseTileList(value: unknown): StoredTile[] { + return Array.isArray(value) + ? value + .filter((t): t is SessionTile => Boolean(t && typeof (t as SessionTile).storedSessionId === 'string')) + .map(toStored) + : [] +} + +function loadTilesByProfile(): Record { + const byProfile: Record = {} + const parsed = readJson(TILES_KEY) + + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + for (const [profile, list] of Object.entries(parsed as Record)) { + const tiles = parseTileList(list) + + if (tiles.length > 0) { + byProfile[normalizeProfileKey(profile)] = tiles + } + } + } + + // Migrate a v1 flat list into the default profile, then retire the key. + const legacy = parseTileList(readJson(LEGACY_TILES_KEY)) + + if (legacy.length > 0) { + const key = normalizeProfileKey('default') + byProfile[key] = [...(byProfile[key] ?? []), ...legacy] + } + + writeJson(LEGACY_TILES_KEY, null) + + return byProfile +} + +const tilesByProfile = loadTilesByProfile() +// Keyed by the GATEWAY profile: the rail's profile switch is a soft swap +// ($activeGatewayProfile moves, no reload) — $activeProfile mirrors the +// window's primary backend and never changes on a rail switch, so keying on +// it left the previous profile's tiles registered (phantom "Session" tabs). +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()] ?? [])]) + +function persistTiles() { + writeJson(TILES_KEY, Object.keys(tilesByProfile).length === 0 ? null : tilesByProfile) +} + +function saveTiles(tiles: SessionTile[]) { + $sessionTiles.set(tiles) + const stored = tiles.map(toStored) + + if (stored.length > 0) { + tilesByProfile[profileKey()] = stored + } else { + delete tilesByProfile[profileKey()] + } + + persistTiles() +} + +// 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()] ?? [])]) +}) + +export function patchSessionTile(storedSessionId: string, patch: Partial) { + saveTiles($sessionTiles.get().map(t => (t.storedSessionId === storedSessionId ? { ...t, ...patch } : t))) +} + +/** Drop live runtime bindings so every tile re-resumes — used on gateway + * reconnect, where a respawned backend re-mints (recycles) runtime ids. */ +export function resetTileRuntimeBindings() { + const tiles = $sessionTiles.get() + + if (tiles.some(t => t.runtimeId)) { + $sessionTiles.set(tiles.map(toStored)) + } +} + +// --------------------------------------------------------------------------- +// Delegate — the wiring layer (which owns the gateway + session cache) plugs +// its actions in; tile UI calls through here. Same inversion as the tree +// store's pane closers. +// --------------------------------------------------------------------------- + +export interface SessionTileDelegate { + /** Archive a stored session (the sidebar's archive, incl. tile cleanup). */ + archiveSession(storedSessionId: string): Promise + /** Branch a stored session into a new chat (the sidebar's branch). */ + branchSession(storedSessionId: string): Promise + /** Delete a stored session (the sidebar's delete, incl. tile cleanup). */ + deleteSession(storedSessionId: string): Promise + /** Run a slash command against a tile's session (app-level effects — e.g. + * branch/handoff — act on the main surface, as they should). */ + executeSlash(rawCommand: string, sessionId: string): Promise + /** Interrupt a tile's running turn. */ + interruptSession(runtimeId: string): Promise + /** Bind a live runtime id for a stored session (resume without touching + * the main view). Returns the runtime id, or throws. */ + resumeTile(storedSessionId: string): Promise + /** Submit a prompt to a tile's live session. */ + submitToSession(runtimeId: string, text: string): Promise + /** THE session-state write path — routes through the wiring cache so the + * cache, the primary view (when active), and every tile mirror agree. */ + updateSession(runtimeId: string, updater: (state: ClientSessionState) => ClientSessionState): ClientSessionState +} + +let delegate: SessionTileDelegate | null = null + +export function setSessionTileDelegate(next: SessionTileDelegate) { + delegate = next +} + +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) { + const tiles = $sessionTiles.get() + + if (storedSessionId === $selectedStoredSessionId.get()) { + return + } + + if (!tiles.some(t => t.storedSessionId === storedSessionId)) { + saveTiles([...tiles, { anchor, before, dir, storedSessionId }]) + } +} + +// Closed-tab stack for ⌘⇧T reopen (in-memory) — keyed PER PROFILE like the +// tiles themselves, so ⌘⇧T after a profile switch never resurrects the other +// profile's session. The tile's placement is remembered so it returns in place. +const closedTilesByProfile: Record = {} +const closedStack = (): SessionTile[] => (closedTilesByProfile[profileKey()] ??= []) + +export function closeSessionTile(storedSessionId: string) { + const tile = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId) + + if (tile) { + closedStack().push({ anchor: tile.anchor, before: tile.before, dir: tile.dir, storedSessionId }) + } + + saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId)) +} + +/** Drop a DEAD tile — a persisted tile whose session no longer exists on the + * backend (resume 404s). Unlike close, it leaves no ⌘⇧T undo (resurrecting it + * would just 404 again) and evicts any cached state. This is what clears the + * "Session not found" resume spam from stale/cross-profile persisted tiles. */ +export function discardSessionTile(storedSessionId: string) { + const runtimeId = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId)?.runtimeId + + if (runtimeId) { + dropSessionState(runtimeId) + } + + saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId)) +} + +/** ⌘⇧T — reopen the most recently closed tab where it was. Skips ids that are + * live again (reopened, or now the primary). */ +export function reopenLastClosedTile(): void { + const stack = closedStack() + + for (let tile = stack.pop(); tile; tile = stack.pop()) { + const { storedSessionId } = tile + + if (storedSessionId === $selectedStoredSessionId.get()) { + continue + } + + if (!$sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) { + openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before) + + return + } + } +} + +// --------------------------------------------------------------------------- +// The FOCUSED session — one derivation, not another hand-maintained +// "$activeSession" sibling. The layout's interaction tracker ($activeTreeGroup: +// last click/focus, the same source ⌘W uses) resolves to a zone; its active +// pane names the session: a `session-tile:` pane IS that session, +// anything else falls back to the route-driven primary. Chrome that should +// follow the user between tiles (titlebar session title, statusbar context / +// timer / model) reads these instead of the primary-only atoms. +// --------------------------------------------------------------------------- + +const TILE_PANE_PREFIX = 'session-tile:' + +/** Stored id of the focused session (the interacted zone's tile, else the + * primary's selection). Null on a fresh draft. */ +export const $focusedStoredSessionId = computed( + [$activeTreeGroup, $layoutTree, $selectedStoredSessionId], + (groupId, tree, selected) => { + const active = groupId && tree ? findGroup(tree, groupId)?.active : undefined + + return active?.startsWith(TILE_PANE_PREFIX) ? active.slice(TILE_PANE_PREFIX.length) : selected + } +) + +/** Live runtime id of the focused session (a tile's bound runtime, else the + * primary's active session). */ +export const $focusedRuntimeId = computed( + [$focusedStoredSessionId, $selectedStoredSessionId, $activeSessionId, $sessionTiles], + (focused, selected, primaryRuntime, tiles) => { + if (focused && focused !== selected) { + return tiles.find(t => t.storedSessionId === focused)?.runtimeId ?? null + } + + return primaryRuntime + } +) + +/** The focused session's state slice (undefined while unresolved/unbound). */ +export const $focusedSessionState = computed([$focusedRuntimeId, $sessionStates], (runtimeId, states) => + runtimeId ? states[runtimeId] : undefined +) + +// 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)) + +// Dev hook for automation (mirrors __HERMES_LAYOUT_TREE__). +if (import.meta.env.DEV && typeof window !== 'undefined') { + ;(window as unknown as Record).__HERMES_SESSION_TILES__ = { + close: closeSessionTile, + open: openSessionTile, + patch: patchSessionTile, + publish: publishSessionState, + states: () => $sessionStates.get(), + tiles: () => $sessionTiles.get() + } +} From ac4f596ca2beec467ffcf6d722ec946081caf662 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:28:32 -0400 Subject: [PATCH 09/28] feat(desktop): pointer session drag/drop + row/tab menus with close others/right/all --- apps/desktop/src/app/chat/session-drag.ts | 162 ++++++++++++ apps/desktop/src/app/chat/sidebar/index.tsx | 130 +++++++--- .../src/app/chat/sidebar/profile-switcher.tsx | 21 +- .../chat/sidebar/session-actions-menu.test.ts | 3 + .../app/chat/sidebar/session-actions-menu.tsx | 245 ++++++++++++++---- .../src/app/chat/sidebar/session-row.tsx | 74 ++++-- .../src/app/chat/sidebar/split-submenu.tsx | 92 +++++++ 7 files changed, 610 insertions(+), 117 deletions(-) create mode 100644 apps/desktop/src/app/chat/session-drag.ts create mode 100644 apps/desktop/src/app/chat/sidebar/split-submenu.tsx diff --git a/apps/desktop/src/app/chat/session-drag.ts b/apps/desktop/src/app/chat/session-drag.ts new file mode 100644 index 000000000000..98cc8805d128 --- /dev/null +++ b/apps/desktop/src/app/chat/session-drag.ts @@ -0,0 +1,162 @@ +/** + * Sidebar session drag — the session RESOLVER over the shared pointer drag + * session (pane-shell drag-session.ts). Same machinery as a pane drag + * (threshold, rAF moves, snapshots, Esc-as-top-layer with synchronous + * teardown), session-specific targeting: + * + * - a chat zone's TAB STRIP → stack: open the session as a tab at the + * divider's slot (the strip caret shows it); + * - a chat zone's EDGE band → split: open the session as a tile docked on + * that edge (the zone sheet morphs to the half); + * - a chat zone's CENTER / the composer → link: insert an `@session` chip + * into that surface's composer (ChatDropOverlay owns the visual); + * - anything else (sidebar, terminal, gutters) → deny. + * + * Zones that don't host a chat surface are NOT targets — the overlay never + * lights them, so a release there must not commit either (one truth). + * + * This replaced the native-HTML5 drag + SessionTileDropBridge: riding the + * native DnD layer meant macOS's cancel snap-back animation, a `dragend` + * held hostage until that animation finished, an Esc the page never even + * saw, and window-level armor against react-dnd/dnd-kit. A pointer session + * has none of those failure modes. Native DnD remains only at the true OS + * boundary (Finder file drops). Known trade: a session can no longer be + * dragged into a separate BrowserWindow (native DnD was the only transport + * that crossed windows). + */ + +import type { PointerEvent as ReactPointerEvent } from 'react' + +import { findGroup } from '@/components/pane-shell/tree/model' +import { + rectContains, + slotBefore, + snapshotStrips, + snapshotZones, + startDragSession, + type StripSnapshot, + subZonePosition +} from '@/components/pane-shell/tree/renderer/drag-session' +import { $layoutTree, $treeDragging, type DropHint, revealTreePane, SESSION_TILE_DRAG } from '@/components/pane-shell/tree/store' +import type { EngineZone, ZoneRect } from '@/components/pane-shell/tree/zones-engine' +import { openSessionTile, type TileDock } from '@/store/session-states' + +import { requestComposerInsertRefs } from './composer/focus' +import { type SessionDragPayload, sessionInlineRef } from './composer/inline-refs' + +/** A chat surface's drag-start geometry: the anchor pane id it advertises + * (`data-session-anchor`) and the composer a link drop routes to + * (`data-composer-target`). */ +interface SurfaceSnapshot { + anchor: string + composerTarget: string + rect: ZoneRect +} + +const snapRect = (el: HTMLElement): ZoneRect => { + const r = el.getBoundingClientRect() + + return { left: r.left, top: r.top, right: r.right, bottom: r.bottom } +} + +function snapshotSurfaces(): SurfaceSnapshot[] { + return [...document.querySelectorAll('[data-session-anchor]')].map(el => ({ + anchor: el.dataset.sessionAnchor || 'workspace', + composerTarget: el.dataset.composerTarget || 'main', + rect: snapRect(el) + })) +} + +/** A session may land in a zone only if it hosts a chat surface — never the + * sidebar/terminal zones. Returns the pane a stack anchors to. */ +function chatZonePane(groupId: string): null | string { + const tree = $layoutTree.get() + const panes = tree ? (findGroup(tree, groupId)?.panes ?? []) : [] + + return panes.find(p => p === 'workspace' || p.startsWith('session-tile:')) ?? null +} + +/** + * Begin dragging a sidebar session row. Sub-threshold releases stay ordinary + * clicks (resume / pin / open-in-window all live on the row's own handlers); + * past the threshold the row is a drag source and the release commits a + * stack, a split, or a composer link — Esc aborts instantly. + */ +export function startSessionDrag(payload: SessionDragPayload, e: ReactPointerEvent) { + let zones: EngineZone[] = [] + let strips: StripSnapshot[] = [] + let surfaces: SurfaceSnapshot[] = [] + let composers: ZoneRect[] = [] + let zoneHost = new Map() + + // Commit intent, updated per resolved move (the machinery flushes the final + // move before commit, so these always match the released-at position). + let split: { anchor: string; before?: null | string; pos: TileDock } | null = null + let link: null | string = null + + startDragSession(e, { + ghost: { label: payload.title || `chat ${payload.id.slice(0, 8)}` }, + + onEngage() { + zones = snapshotZones() + strips = snapshotStrips() + surfaces = snapshotSurfaces() + composers = [...document.querySelectorAll('[data-slot="composer-root"]')].map(snapRect) + zoneHost = new Map(zones.map(zone => [zone.id, chatZonePane(zone.id)])) + // The same sentinel the zone overlay + chat surfaces key off — the + // whole drop language (sheets, pills, caret, link overlay) lights up. + $treeDragging.set(SESSION_TILE_DRAG) + }, + + resolveMove(x, y): DropHint | null { + const zone = zones.find(z => rectContains(z.rect, x, y)) + const host = zone ? zoneHost.get(zone.id) : null + + if (!zone || !host) { + split = null + link = null + + return null + } + + // The zone's TAB STRIP stacks the session at the divider's slot. + const strip = strips.find(s => s.groupId === zone.id && rectContains(s.rect, x, y)) + + if (strip) { + const stack = slotBefore(strip.slots, x) + split = { anchor: host, before: stack.before, pos: 'center' } + link = null + + return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos: 'center', stack } + } + + // The composer (and everything in it) is always the link/attach drop; + // elsewhere the shared radial targeting decides center vs edge. + const pos = composers.some(rect => rectContains(rect, x, y)) ? 'center' : subZonePosition(zones, zone.id, x, y) + const surface = surfaces.find(s => rectContains(s.rect, x, y)) + + if (pos === 'center') { + split = null + link = surface?.composerTarget ?? 'main' + } else { + split = { anchor: surface?.anchor ?? 'workspace', pos } + link = null + } + + return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos } + }, + + onCommit() { + if (split) { + openSessionTile(payload.id, split.pos, split.anchor, split.before) + // A tile for this session may already exist (openSessionTile is + // idempotent — e.g. persisted from an earlier run): a drop must never + // feel dead, so front/unhide/un-dismiss it either way. + revealTreePane(`session-tile:${payload.id}`) + } else if (link) { + // The "link to chat" drop: an @session chip in that surface's composer. + requestComposerInsertRefs([sessionInlineRef(payload)], { target: link }) + } + } + }) +} diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 416483dde427..b73434a77768 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -3,10 +3,12 @@ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable' import { useStore } from '@nanostores/react' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useLocation } from 'react-router-dom' import { PlatformAvatar } from '@/app/messaging/platform-icon' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { KbdGroup } from '@/components/ui/kbd' import { SearchField } from '@/components/ui/search-field' @@ -19,6 +21,7 @@ import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' +import { useContributions } from '@/contrib/react/use-contributions' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { useI18n } from '@/i18n' import { comboTokens } from '@/lib/keybinds/combo' @@ -34,8 +37,6 @@ import { $sidebarAgentsGrouped, $sidebarCronOpen, $sidebarMessagingOpenIds, - $sidebarOpen, - $sidebarOverlayMounted, $sidebarPinsOpen, $sidebarProjectOrderIds, $sidebarRecentsOpen, @@ -78,6 +79,7 @@ import { refreshWorktrees, scanAndRecordRepos } from '@/store/projects' +import { openRouteTile } from '@/store/route-tiles' import { $cronSessions, $currentCwd, @@ -94,8 +96,16 @@ import { sessionPinId, setCurrentCwd } from '@/store/session' +import type { SplitDir } from '@/store/session-states' -import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes' +import { + type AppView, + ARTIFACTS_ROUTE, + MESSAGING_ROUTE, + SIDEBAR_NAV_AREA, + type SidebarNavContribution, + SKILLS_ROUTE +} from '../../routes' import type { SidebarNavItem } from '../../types' import { countLabel } from './chrome' @@ -121,6 +131,7 @@ import { } from './projects' import { SidebarBlankState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states' import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section' +import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu' // Non-session groups (messaging platforms) stay compact: show a few rows up // front, reveal more in larger steps on demand. Keeps a busy platform from @@ -209,6 +220,8 @@ interface ChatSidebarProps extends React.ComponentProps { onArchiveSession: (sessionId: string) => void onBranchSession: (sessionId: string) => void onNewSessionInWorkspace: (path: null | string) => void + /** Create a brand-new session and open it as a tile on `dir`. */ + onNewSessionSplit: (dir: SplitDir) => void onManageCronJob: (jobId: string) => void onTriggerCronJob: (jobId: string) => void } @@ -224,15 +237,40 @@ export function ChatSidebar({ onArchiveSession, onBranchSession, onNewSessionInWorkspace, + onNewSessionSplit, onManageCronJob, onTriggerCronJob }: ChatSidebarProps) { const { t } = useI18n() const s = t.sidebar - const sidebarOpen = useStore($sidebarOpen) - // Collapsed-but-overlay-mounted → render the full sidebar, not just the nav rail. - const overlayMounted = useStore($sidebarOverlayMounted) - const contentVisible = sidebarOpen || overlayMounted + const { pathname } = useLocation() + // Contributed nav rows (plugins pairing a page with a sidebar entry) render + // below the built-ins with the same chrome; active = at their route. + const navContributions = useContributions(SIDEBAR_NAV_AREA) + + const contributedNav = useMemo( + () => + navContributions.flatMap(c => { + const data = c.data as Partial | undefined + + if (!data?.path?.startsWith('/') || !data.label) { + return [] + } + + const codicon = data.codicon || 'plug' + + return [ + { + id: c.id, + label: data.label, + icon: (props: { className?: string }) => , + route: data.path + } + ] + }), + [navContributions] + ) + const panesFlipped = useStore($panesFlipped) const agentsGrouped = useStore($sidebarAgentsGrouped) const pinnedSessionIds = useStore($pinnedSessionIds) @@ -1031,15 +1069,12 @@ export function ChatSidebar({ return (