From 9db8a8be745e8403a9c89feff54da40999b843d4 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Wed, 28 Oct 2026 10:20:00 -0700 Subject: [PATCH 01/13] Add vertical tabs with a shared title band and tool rail Adds a vertical-tabs plugin that moves the workspace tab strip, navigation, and top-bar tools into a resizable side rail that can collapse to icons. Command-B collapses the rail and Command-Shift-B (or leader w v) switches between side and top tabs, with panes kept mounted across the switch. The workspace layout now owns a title band for vertical mode: every pane kind's top bar takes the rail header's height and the shared chrome color, and the content sheet curves beneath it. The sheet edge is drawn in the band's last row so native surfaces such as Ghostty terminals cannot cover it. Tab groups draw a continuous line aligned under the group label in vertical strips, and the pull request flyout opens beside the rail. --- program/cordis.json | 18 + .../plugins/hotkeys-vertical-tabs.client.ts | 32 ++ program/plugins/pull-requests.client.tsx | 8 +- program/plugins/pull-requests.css | 11 + program/plugins/vertical-tabs-api.ts | 24 + program/plugins/vertical-tabs.client.tsx | 244 +++++++++ program/plugins/vertical-tabs.css | 255 +++++++++ program/plugins/workspace-layout-api.ts | 33 ++ program/plugins/workspace-layout.client.tsx | 487 ++++++++++-------- program/plugins/workspace-layout.css | 84 ++- .../plugins/workspace-tab-groups.client.tsx | 21 +- program/plugins/workspace-tab-groups.css | 57 ++ tests/fixtures/vertical-tabs.cjs | 353 +++++++++++++ tests/vertical-tabs-browser.test.ts | 45 ++ 14 files changed, 1444 insertions(+), 228 deletions(-) create mode 100644 program/plugins/hotkeys-vertical-tabs.client.ts create mode 100644 program/plugins/vertical-tabs-api.ts create mode 100644 program/plugins/vertical-tabs.client.tsx create mode 100644 program/plugins/vertical-tabs.css create mode 100644 tests/fixtures/vertical-tabs.cjs create mode 100644 tests/vertical-tabs-browser.test.ts diff --git a/program/cordis.json b/program/cordis.json index 999ac04..c89609d 100644 --- a/program/cordis.json +++ b/program/cordis.json @@ -649,6 +649,24 @@ "enabled": true, "config": {} }, + { + "id": "vertical-tabs", + "name": "Vertical Tabs", + "description": "Adds a resizable side tab column that can collapse to icons, and switches between it and the top tabs.", + "client": "plugins/vertical-tabs.client.tsx", + "enabled": false, + "config": {}, + "children": [ + { + "id": "hotkeys-vertical-tabs", + "name": "Vertical Tabs Shortcuts", + "description": "Switches tab orientation with Command-Shift-B and collapses the side tab column with Command-B.", + "client": "plugins/hotkeys-vertical-tabs.client.ts", + "enabled": true, + "config": {} + } + ] + }, { "id": "seal", "name": "Seal", diff --git a/program/plugins/hotkeys-vertical-tabs.client.ts b/program/plugins/hotkeys-vertical-tabs.client.ts new file mode 100644 index 0000000..c17f5b3 --- /dev/null +++ b/program/plugins/hotkeys-vertical-tabs.client.ts @@ -0,0 +1,32 @@ +import type { Plugin } from 'cordis' +import './vertical-tabs-api.js' + +const hotkeysVerticalTabs: Plugin = (ctx) => { + ctx.clientHotkeys.registerAction(ctx, { + id: 'panel.vertical-tabs.toggle', + label: 'Toggle vertical tabs', + detail: 'Collapse the side tab column to icons, or expand it again.', + category: 'Panels', + binding: { kind: 'global', key: 'b', meta: true }, + enabled: () => ctx.clientVerticalTabs.snapshot().orientation === 'vertical', + run: () => ctx.clientVerticalTabs.toggleCollapsed(), + }) + + ctx.clientHotkeys.registerAction(ctx, { + id: 'workspace.tabs.orientation.toggle', + label: 'Switch tab orientation', + detail: 'Move workspace tabs between the top bar and the side column.', + category: 'Workspace', + binding: { + kind: 'leader', + prefix: [{ key: 'w', label: 'Workspaces' }], + key: 'v', + }, + aliases: [{ kind: 'global', key: 'b', meta: true, shift: true }], + run: () => ctx.clientVerticalTabs.toggleOrientation(), + }) +} + +hotkeysVerticalTabs.inject = ['clientHotkeys', 'clientVerticalTabs'] + +export default hotkeysVerticalTabs diff --git a/program/plugins/pull-requests.client.tsx b/program/plugins/pull-requests.client.tsx index 2317dac..3294df5 100644 --- a/program/plugins/pull-requests.client.tsx +++ b/program/plugins/pull-requests.client.tsx @@ -180,13 +180,13 @@ function useToggleMount(ui: ClientUiService): HTMLElement | null { const revision = useSyncExternalStore(ui.subscribe, ui.snapshot) const [mount, setMount] = useState(null) useLayoutEffect(() => { - const sync = (): boolean => { + const sync = (): void => { const target = document.querySelector(TOGGLE_MOUNT) setMount((current) => current === target ? current : target) - return target !== null } - if (sync()) return - const observer = new MutationObserver(() => { if (sync()) observer.disconnect() }) + sync() + // Keep observing: the toolbar can move, for example into a side tab rail. + const observer = new MutationObserver(sync) observer.observe(document.querySelector('.shell-kernel') ?? document.body, { childList: true, subtree: true }) return () => observer.disconnect() }, [revision]) diff --git a/program/plugins/pull-requests.css b/program/plugins/pull-requests.css index 199e53f..b70b874 100644 --- a/program/plugins/pull-requests.css +++ b/program/plugins/pull-requests.css @@ -26,6 +26,17 @@ transition: transform 160ms ease-out, opacity 140ms ease-out, visibility 0s linear 160ms; } .pr-dashboard-flyout.is-open { opacity: 1; visibility: visible; pointer-events: auto; transform: translateX(0); transition-delay: 0s; } +/* When a side tab rail hosts the toolbar, open beside its footer instead. */ +html[data-workspace-tools='rail'] .pr-dashboard-flyout { + top: auto; + right: auto; + bottom: var(--space-3); + left: calc(var(--workspace-tools-rail-end) + var(--space-2)); + max-height: min(calc(100dvh - var(--space-5)), calc(var(--control-button) * 21)); + max-width: calc(100vw - var(--workspace-tools-rail-end) - var(--space-5)); + transform: translateX(calc(-1 * var(--space-3))); +} +html[data-workspace-tools='rail'] .pr-dashboard-flyout.is-open { transform: translateX(0); } .pr-dashboard { display: flex; flex: 0 1 auto; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; container-type: inline-size; font-family: var(--sans); } .pr-dashboard-header { flex-shrink: 0; justify-content: space-between; gap: var(--space-2); padding: var(--space-3) var(--space-3) var(--space-2) var(--space-4); } .pr-dashboard-heading { display: flex; min-width: 0; align-items: center; gap: var(--space-2); white-space: nowrap; color: var(--text); font-size: var(--type-body); font-weight: var(--weight-medium); } diff --git a/program/plugins/vertical-tabs-api.ts b/program/plugins/vertical-tabs-api.ts new file mode 100644 index 0000000..60bc929 --- /dev/null +++ b/program/plugins/vertical-tabs-api.ts @@ -0,0 +1,24 @@ +import type { WorkspaceTabOrientation } from './workspace-layout-api.js' + +export interface VerticalTabPreferences { + /** Horizontal shows the compositor's top tabs; vertical moves them into the side rail. */ + orientation: WorkspaceTabOrientation + width: number + collapsed: boolean +} + +export interface ClientVerticalTabsService { + subscribe(listener: () => void): () => void + snapshot(): VerticalTabPreferences + setOrientation(orientation: WorkspaceTabOrientation): void + toggleOrientation(): void + setWidth(width: number): void + setCollapsed(collapsed: boolean): void + toggleCollapsed(): void +} + +declare module 'cordis' { + interface Context { + clientVerticalTabs: ClientVerticalTabsService + } +} diff --git a/program/plugins/vertical-tabs.client.tsx b/program/plugins/vertical-tabs.client.tsx new file mode 100644 index 0000000..8dded74 --- /dev/null +++ b/program/plugins/vertical-tabs.client.tsx @@ -0,0 +1,244 @@ +import { PanelLeftClose, PanelLeftOpen } from 'lucide-react' +import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react' +import { clientStyles, type BrowserPlugin } from '../../src/client/plugin-api.js' +import type { ClientVerticalTabsService, VerticalTabPreferences } from './vertical-tabs-api.js' +import { + WORKSPACE_TOOLS_RAIL_ATTRIBUTE, + type WorkspaceTabAddonRegistration, + type WorkspaceTabOrientation, + type WorkspaceTabRailProps, +} from './workspace-layout-api.js' +import styles from './vertical-tabs.css' + +const STORAGE_KEY = 'codex-cordis.vertical-tabs' +const DEFAULT_WIDTH = 232 +const MIN_WIDTH = 168 +const MAX_WIDTH = 400 +/** Below this workspace width the rail shows only tab icons. */ +const COMPACT_WORKSPACE_WIDTH = 720 + +export function clampRailWidth(width: number): number { + return Math.round(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width))) +} + +export function parseVerticalTabPreferences(value: unknown): VerticalTabPreferences { + const record = value && typeof value === 'object' ? value as Record : {} + return { + // Enabling the plugin opts into the side rail, so vertical is the default. + orientation: record.orientation === 'horizontal' ? 'horizontal' : 'vertical', + width: typeof record.width === 'number' && Number.isFinite(record.width) ? clampRailWidth(record.width) : DEFAULT_WIDTH, + collapsed: record.collapsed === true, + } +} + +function readPreferences(): VerticalTabPreferences { + try { + return parseVerticalTabPreferences(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null')) + } catch { + return parseVerticalTabPreferences(undefined) + } +} + +function writePreferences(preferences: VerticalTabPreferences): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)) + } catch { + // Storage can be unavailable; the rail still works for this session. + } +} + +/** Keeps the rail's width and collapsed state outside React so shortcuts can change them. */ +export class VerticalTabsController implements ClientVerticalTabsService { + private state = readPreferences() + private readonly listeners = new Set<() => void>() + private persistence = true + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + snapshot = (): VerticalTabPreferences => this.state + + setOrientation(orientation: WorkspaceTabOrientation): void { + this.update({ orientation }) + } + + toggleOrientation(): void { + this.update({ orientation: this.state.orientation === 'vertical' ? 'horizontal' : 'vertical' }) + } + + setWidth(width: number): void { + this.update({ width: clampRailWidth(width) }) + } + + setCollapsed(collapsed: boolean): void { + this.update({ collapsed }) + } + + toggleCollapsed(): void { + this.update({ collapsed: !this.state.collapsed }) + } + + /** Save once a drag finishes rather than on every pointer move. */ + holdPersistence(hold: boolean): void { + this.persistence = !hold + if (!hold) writePreferences(this.state) + } + + private update(patch: Partial): void { + const next = { ...this.state, ...patch } + if (next.orientation === this.state.orientation && next.width === this.state.width && next.collapsed === this.state.collapsed) return + this.state = next + if (this.persistence) writePreferences(next) + for (const listener of this.listeners) listener() + } +} + +function VerticalTabRail({ strip, navigation, tools, controller }: WorkspaceTabRailProps & { controller: VerticalTabsController }): ReactNode { + const rail = useRef(null) + const preferences = useSyncExternalStore(controller.subscribe, controller.snapshot) + const [compact, setCompact] = useState(false) + const [resizing, setResizing] = useState(false) + + // Follow the workspace's own width so a pinned sidebar or split window also compacts the rail. + useLayoutEffect(() => { + const container = rail.current?.parentElement + if (!container) return + const measure = (): void => setCompact(container.getBoundingClientRect().width < COMPACT_WORKSPACE_WIDTH) + measure() + const observer = new ResizeObserver(measure) + observer.observe(container) + return () => observer.disconnect() + }, []) + + // The width changes continuously while dragging, so it is a custom property rather than a class. + useLayoutEffect(() => { + rail.current?.style.setProperty('--vertical-tab-rail-width', preferences.width + 'px') + }, [preferences.width]) + + // Tool flyouts open beside the rail while it hosts the top bar's tools. + useEffect(() => { + const element = rail.current + if (!element) return + const root = document.documentElement + root.setAttribute(WORKSPACE_TOOLS_RAIL_ATTRIBUTE, 'rail') + const measure = (): void => root.style.setProperty('--workspace-tools-rail-end', Math.round(element.getBoundingClientRect().right) + 'px') + measure() + const observer = new ResizeObserver(measure) + observer.observe(element) + window.addEventListener('resize', measure) + return () => { + observer.disconnect() + window.removeEventListener('resize', measure) + root.removeAttribute(WORKSPACE_TOOLS_RAIL_ATTRIBUTE) + root.style.removeProperty('--workspace-tools-rail-end') + } + }, []) + + const collapsed = compact || preferences.collapsed + const beginResize = (event: PointerEvent): void => { + if (event.button !== 0) return + event.preventDefault() + const handle = event.currentTarget + const left = rail.current?.getBoundingClientRect().left ?? 0 + handle.setPointerCapture(event.pointerId) + setResizing(true) + controller.holdPersistence(true) + const move = (moveEvent: globalThis.PointerEvent): void => controller.setWidth(moveEvent.clientX - left) + const end = (): void => { + setResizing(false) + controller.holdPersistence(false) + handle.removeEventListener('pointermove', move) + handle.removeEventListener('pointerup', end) + handle.removeEventListener('pointercancel', end) + } + handle.addEventListener('pointermove', move) + handle.addEventListener('pointerup', end) + handle.addEventListener('pointercancel', end) + } + const resizeWithKeys = (event: KeyboardEvent): void => { + const step = event.shiftKey ? 48 : 16 + const width = event.key === 'ArrowLeft' ? preferences.width - step + : event.key === 'ArrowRight' ? preferences.width + step + : event.key === 'Home' ? MIN_WIDTH + : event.key === 'End' ? MAX_WIDTH + : undefined + if (width === undefined) return + event.preventDefault() + controller.setWidth(width) + } + + return ( + + ) +} + +const verticalTabs: BrowserPlugin = (ctx) => { + const controller = new VerticalTabsController() + ctx.provide('clientVerticalTabs', controller) + const Rail = (props: WorkspaceTabRailProps): ReactNode => + // The rail is registered only while vertical; the compositor restores the top + // bar when it is removed, and panes stay mounted either way. + ctx.effect(() => { + let registration: WorkspaceTabAddonRegistration | undefined + const sync = (): void => { + const vertical = controller.snapshot().orientation === 'vertical' + if (vertical && !registration) { + registration = ctx.clientWorkspaceLayout.registerTabRail(ctx, { id: 'vertical-tabs', renderer: Rail }) + } else if (!vertical && registration) { + void registration.dispose() + registration = undefined + } + } + sync() + const unsubscribe = controller.subscribe(sync) + return () => { + unsubscribe() + void registration?.dispose() + } + }, 'vertical-tabs.orientation') + ctx.clientUi.registerStyle(ctx, 'vertical-tabs', String(styles)) +} + +verticalTabs.inject = ['clientWorkspaceLayout', 'clientUi'] +verticalTabs.provide = 'clientVerticalTabs' +export default verticalTabs diff --git a/program/plugins/vertical-tabs.css b/program/plugins/vertical-tabs.css new file mode 100644 index 0000000..aad68ab --- /dev/null +++ b/program/plugins/vertical-tabs.css @@ -0,0 +1,255 @@ +/* Tabs on the side: a column of full-width rows beside the content sheet, + with an icon-only mode that keeps every tab one click away. */ +.vertical-tab-rail { + --vertical-tab-rail-width: 232px; + --vertical-tab-titlebar-height: var(--workspace-title-band-height); + /* Clears the native window buttons drawn at the top-left on macOS. */ + --vertical-tab-window-controls: 76px; + --vertical-tab-height: var(--control-default); + --vertical-tab-collapsed-width: 48px; + position: relative; + min-width: 0; + min-height: 0; + width: var(--vertical-tab-rail-width); + display: flex; + flex: 0 0 auto; + flex-direction: column; + background: var(--workspace-chrome); + transition: width 180ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.vertical-tab-rail.is-collapsed { width: var(--vertical-tab-collapsed-width); } +.vertical-tab-rail.is-resizing { transition: none; user-select: none; } + +/* Renaming needs room for the input, so a collapsed rail widens until it finishes. */ +.vertical-tab-rail.is-collapsed:has(.workspace-tab.is-renaming) { width: var(--vertical-tab-rail-width); } + +/* The rail's top row replaces the top bar: it is the window drag region, + leaves room for the macOS window buttons, and holds back and forward. */ +.vertical-tab-rail-header { + min-height: var(--vertical-tab-titlebar-height); + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-items: center; + gap: var(--space-1); + padding-inline: var(--space-2); + -webkit-app-region: drag; +} + +html[data-desktop-platform='darwin'] .vertical-tab-rail-header { + padding-inline-start: var(--vertical-tab-window-controls); +} + +.vertical-tab-rail-header > .workspace-shell-contribution { display: flex; align-items: center; } + +.vertical-tab-rail.is-collapsed .vertical-tab-rail-header, +html[data-desktop-platform='darwin'] .vertical-tab-rail.is-collapsed .vertical-tab-rail-header { + justify-content: center; + padding-inline: 0; +} + +.vertical-tab-rail.is-collapsed .workspace-history-controls { + flex-wrap: wrap; + justify-content: center; + margin: 0; +} + +/* Collapsed on macOS, the window buttons get their own row and set the rail's width. */ +html[data-desktop-platform='darwin'] .vertical-tab-rail.is-collapsed { width: var(--vertical-tab-window-controls); } +html[data-desktop-platform='darwin'] .vertical-tab-rail.is-collapsed .vertical-tab-rail-header { + padding-block-start: var(--vertical-tab-titlebar-height); +} + +/* The top bar's tools sit beneath the tabs, ending with the collapse control. */ +.vertical-tab-rail-footer { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--space-1); + padding: var(--space-2); +} + +.vertical-tab-rail-footer > .workspace-tabbar-tools { + min-width: 0; + flex: 1 1 auto; + flex-wrap: wrap; + gap: var(--space-1); +} + +.vertical-tab-rail-toggle { + flex: 0 0 auto; + margin-inline-start: auto; + color: var(--muted); + -webkit-app-region: no-drag; +} + +.vertical-tab-rail.is-collapsed .vertical-tab-rail-footer, +.vertical-tab-rail.is-collapsed .vertical-tab-rail-footer .workspace-tabbar-tools { + flex-direction: column; + align-items: center; +} + +.vertical-tab-rail.is-collapsed .vertical-tab-rail-footer { padding-inline: 0; } +.vertical-tab-rail.is-collapsed .vertical-tab-rail-toggle { margin-inline-start: 0; } +.vertical-tab-rail.is-collapsed .workspace-tabbar-tools button > span { display: none; } +.vertical-tab-rail.is-collapsed .workspace-tabbar-tools .alto-button { padding-inline: var(--space-2); } + +.vertical-tab-rail .workspace-tabs { + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + align-items: stretch; + --workspace-tab-group-gap: calc(var(--space-1) / 2); + gap: var(--workspace-tab-group-gap); + padding: var(--space-1) var(--space-2) var(--space-4); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior-x: auto; + overscroll-behavior-y: contain; + -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - var(--space-4)), transparent); + mask-image: linear-gradient(to bottom, #000 calc(100% - var(--space-4)), transparent); + -webkit-app-region: no-drag; +} + +.vertical-tab-rail.is-collapsed .workspace-tabs { padding-inline: var(--space-1); } + +.vertical-tab-rail .workspace-tab { + width: 100%; + min-width: 0; + max-width: none; + height: auto; + min-height: 0; + flex: 0 0 var(--vertical-tab-height); + gap: var(--space-2); + padding-inline: var(--space-2) 0; +} + +.vertical-tab-rail .workspace-tab > .workspace-tab-title { + min-width: 0; + flex: 1 1 auto; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.vertical-tab-rail .workspace-tab-name-input { margin-inline: 0 var(--space-1); } + +.workspace-tab-icon { + display: grid; + flex: 0 0 16px; + place-items: center; + color: var(--muted); + font-style: normal; +} + +.workspace-tab.is-active > .workspace-tab-icon, +.workspace-tab:hover > .workspace-tab-icon { color: var(--text); } + +/* Chat tabs show only their titles; collapsed, the title's first letter stands in. */ +.workspace-tab-monogram { + font-size: var(--type-label); + font-weight: var(--weight-medium); + line-height: 1; +} +.vertical-tab-rail:not(.is-collapsed) .workspace-tab:not(.is-renaming) > .workspace-tab-monogram, +.vertical-tab-rail .workspace-tab.is-renaming > .workspace-tab-monogram { display: none; } + +.vertical-tab-rail .workspace-tab-thread-status { + display: grid; + flex: 0 0 auto; + place-items: center; +} + +/* Group labels read as section headings for the tabs beneath them. */ +.vertical-tab-rail .workspace-tab-group-label { + font-size: var(--type-label); + font-weight: var(--weight-medium); +} + +.vertical-tab-rail .workspace-tab-group-label > span:first-child { + min-width: 0; + flex: 1 1 auto; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.vertical-tab-rail .workspace-tab-group-label .workspace-tab-group-count { margin-inline-start: 0; } + +/* The new-chat control becomes a labeled row at the end of the list. */ +.vertical-tab-rail .workspace-tab-add-control { width: 100%; } +.vertical-tab-rail .workspace-tab-add { + width: 100%; + height: var(--vertical-tab-height); + display: flex; + justify-content: flex-start; + gap: var(--space-2); + padding-inline: var(--space-2); + border-radius: var(--radius-sm, 12px); + font: inherit; + font-size: var(--type-label); +} +.vertical-tab-rail .workspace-tab-add::after { content: 'New chat'; } + +/* Icon-only rail: titles, counts, and close buttons give way to centered icons. */ +.vertical-tab-rail.is-collapsed .workspace-tab, +.vertical-tab-rail.is-collapsed .workspace-tab-add { + justify-content: center; + padding-inline: 0; +} +.vertical-tab-rail.is-collapsed .workspace-tab:not(.is-renaming) > .workspace-tab-title, +.vertical-tab-rail.is-collapsed .workspace-tab:not(.is-renaming) > .workspace-tab-close, +.vertical-tab-rail.is-collapsed .workspace-tab-group-label > span, +.vertical-tab-rail.is-collapsed .workspace-tab-ungroup-target, +.vertical-tab-rail.is-collapsed .workspace-tab-add::after { display: none; } + +.vertical-tab-rail.is-collapsed .workspace-tab:not(.is-renaming) > .workspace-tab-thread-status { + position: absolute; + inset-block-start: 2px; + inset-inline-end: 2px; +} + +.vertical-tab-rail.is-collapsed .workspace-tabs { --workspace-tab-group-line-inset: var(--space-1); } +.vertical-tab-rail.is-collapsed .workspace-tabs .workspace-tab-group-member { padding-inline-start: 0; } + +/* A collapsed group label shrinks to a short colored bar. */ +.vertical-tab-rail.is-collapsed .workspace-tab-group-label { + flex-basis: 14px; +} +.vertical-tab-rail.is-collapsed .workspace-tab-group-label::after { + content: ''; + inline-size: 20px; + block-size: 4px; + border-radius: var(--radius-pill); + background: var(--tab-group-color, var(--accent)); +} + +.vertical-tab-rail-resize { + position: absolute; + z-index: 3; + inset-block: 0; + inset-inline-end: -3px; + inline-size: 6px; + cursor: col-resize; + -webkit-app-region: no-drag; +} + +.vertical-tab-rail-resize:hover, +.vertical-tab-rail-resize:focus-visible, +.vertical-tab-rail.is-resizing .vertical-tab-rail-resize { + outline: none; + background: linear-gradient(to right, transparent 2px, color-mix(in srgb, var(--accent) 42%, transparent) 2px 4px, transparent 4px); +} + +/* A pinned conversation sidebar overlays the workspace's leading edge. */ +@media (min-width: 761px) { + html[data-sidebar-mode='pinned'] .workspace-body[data-tab-orientation='vertical'] { + padding-left: calc(var(--sidebar-pane-width) + (2 * var(--space-3))); + } + html[data-sidebar-mode='pinned'] .workspace-body[data-tab-orientation='vertical'] > .workspace-views { padding-inline-start: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .vertical-tab-rail { transition: none; } +} diff --git a/program/plugins/workspace-layout-api.ts b/program/plugins/workspace-layout-api.ts index c308109..e517c7f 100644 --- a/program/plugins/workspace-layout-api.ts +++ b/program/plugins/workspace-layout-api.ts @@ -63,7 +63,11 @@ export interface WorkspaceTabPresentation { onKeyDown?(event: KeyboardEvent): void } +export type WorkspaceTabOrientation = 'horizontal' | 'vertical' + export interface WorkspaceTabStripProps { + /** Vertical while a registered tab rail hosts the strip; omitted means horizontal. */ + orientation?: WorkspaceTabOrientation layout: WorkspaceLayoutState change: Dispatch> rootRef: RefObject @@ -82,6 +86,32 @@ export interface WorkspaceTabStrip { shortcutTargets?(layout: WorkspaceLayoutState): readonly WorkspaceView[] } +export interface WorkspaceTabRailProps { + /** The active tab strip, rendered vertically with its new-tab control. */ + strip: ReactNode + /** Back and forward controls followed by header-left contributions. */ + navigation: ReactNode + /** Header-right contributions and header-end surfaces from the top bar. */ + tools: ReactNode + layout: WorkspaceLayoutState +} + +/** + * Set on the document element while a rail hosts the top bar's tools, with + * `--workspace-tools-rail-end` giving the rail's trailing edge. Tool flyouts + * use it to open beside the rail instead of beneath the top bar. + */ +export const WORKSPACE_TOOLS_RAIL_ATTRIBUTE = 'data-workspace-tools' + +export interface WorkspaceTabRail { + id: string + /** + * Frames the strip in a column beside the workspace views. The top bar is + * omitted while a rail is registered, so the rail must render navigation and tools. + */ + renderer: ComponentType +} + export interface WorkspacePaneTarget { workspaceId: string paneId: string @@ -213,6 +243,9 @@ export interface ClientWorkspaceLayoutService { registerTabAction(owner: import('cordis').Context, action: WorkspaceTabAction): WorkspaceTabAddonRegistration /** Replaces only the tab bar contents; pane trees keep their existing owners and keys. */ registerTabStrip(owner: import('cordis').Context, strip: WorkspaceTabStrip): WorkspaceTabAddonRegistration + tabRail(): WorkspaceTabRail | undefined + /** Moves the tab strip into a side rail; pane trees stay mounted when it is added or removed. */ + registerTabRail(owner: import('cordis').Context, rail: WorkspaceTabRail): WorkspaceTabAddonRegistration registerPaneAddon( owner: import('cordis').Context, addon: WorkspacePaneAddon, diff --git a/program/plugins/workspace-layout.client.tsx b/program/plugins/workspace-layout.client.tsx index 3d31efe..eddcd39 100644 --- a/program/plugins/workspace-layout.client.tsx +++ b/program/plugins/workspace-layout.client.tsx @@ -8,6 +8,7 @@ import { Maximize2, MessageSquarePlus, Minimize2, + PanelsTopLeft, Pencil, Plus, Rows2, @@ -76,6 +77,7 @@ import type { WorkspaceTabAddon, WorkspaceTabAction, WorkspaceTabTarget, + WorkspaceTabRail, WorkspaceTabStrip, WorkspaceTabStripProps, } from './workspace-layout-api.js' @@ -257,6 +259,7 @@ export class WorkspaceLayoutRegistry implements ClientWorkspaceLayoutService { private readonly pageEntries = new Map() private activePageId: string | undefined private tabStripEntry: WorkspaceTabStrip | undefined + private tabRailEntry: WorkspaceTabRail | undefined private readonly listeners = new Set<() => void>() private controller: WorkspaceLayoutController | undefined private tabTargets: readonly WorkspaceTabTarget[] = [] @@ -481,6 +484,10 @@ export class WorkspaceLayoutRegistry implements ClientWorkspaceLayoutService { return this.tabStripEntry } + tabRail(): WorkspaceTabRail | undefined { + return this.tabRailEntry + } + pages(): readonly WorkspacePage[] { return [...this.pageEntries.values()] } @@ -537,6 +544,19 @@ export class WorkspaceLayoutRegistry implements ClientWorkspaceLayoutService { return { dispose: async () => dispose() } } + registerTabRail(owner: Context, rail: WorkspaceTabRail) { + const dispose = owner.effect(() => { + if (this.tabRailEntry) throw new Error(`workspace tab rail "${this.tabRailEntry.id}" is already registered`) + this.tabRailEntry = rail + this.emit() + return () => { + if (this.tabRailEntry === rail) this.tabRailEntry = undefined + this.emit() + } + }, `clientWorkspaceLayout.registerTabRail(${JSON.stringify(rail.id)})`) + return { dispose: async () => dispose() } + } + registerPaneAddon(owner: Context, addon: WorkspacePaneAddon) { const dispose = owner.effect(() => { if (this.addonEntries.has(addon.id)) throw new Error(`workspace pane add-on "${addon.id}" is already registered`) @@ -603,6 +623,7 @@ export class WorkspaceLayoutRegistry implements ClientWorkspaceLayoutService { this.tabActionEntries.clear() this.tabNameSources.clear() this.tabStripEntry = undefined + this.tabRailEntry = undefined this.paneKindEntries.clear() this.pageEntries.clear() this.activePageId = undefined @@ -2036,9 +2057,23 @@ export function dockWorkspaceTab( }) } -function DefaultWorkspaceTabStrip({ layout, change, renderTab, beginDrag, children, tabActions = [], overlays, rootRef }: WorkspaceTabStripProps): ReactNode { +/** Vertical tabs lead with the focused pane's kind, much as browser tabs show a favicon. */ +export function WorkspaceTabIcon({ view, kinds }: { view: WorkspaceView; kinds: readonly WorkspacePaneKind[] }): ReactNode { + const kind = workspacePane(view.root, view.focusedPaneId)?.kind ?? 'chat' + // Chats are the usual tab, so only other pane kinds carry an icon. A chat + // keeps its title's first letter for rails that show icons alone. + if (kind === 'chat') { + return + } + const Icon = kinds.find((entry) => entry.id === kind)?.icon ?? PanelsTopLeft + return +} + +function DefaultWorkspaceTabStrip({ orientation = 'horizontal', layout, change, renderTab, beginDrag, children, tabActions = [], overlays, rootRef }: WorkspaceTabStripProps): ReactNode { const [menu, setMenu] = useState<{ view: WorkspaceView; x: number; y: number; trigger: HTMLElement }>() - return
{ if (!event.dataTransfer.types.includes('application/x-alto-workspace-tab')) return event.preventDefault() @@ -2050,7 +2085,9 @@ function DefaultWorkspaceTabStrip({ layout, change, renderTab, beginDrag, childr event.preventDefault() const target = (event.target as Element).closest('[data-workspace-tab]') const bounds = target?.getBoundingClientRect() - const after = bounds && event.clientX >= bounds.left + bounds.width / 2 + const after = bounds && (orientation === 'vertical' + ? event.clientY >= bounds.top + bounds.height / 2 + : event.clientX >= bounds.left + bounds.width / 2) change((current) => { const source = current.views.find((view) => view.id === sourceId) if (!source || source.id === target?.dataset.workspaceTab) return current @@ -2179,6 +2216,7 @@ function WorkspaceLayout({ const paneKinds = registry.paneKinds() const tabAddons = registry.tabAddons() const TabStrip = registry.tabStrip()?.renderer ?? DefaultWorkspaceTabStrip + const TabRail = registry.tabRail()?.renderer useLayoutEffect(() => { if (!renamingViewId) return @@ -2980,6 +3018,193 @@ function WorkspaceLayout({
)) : null + const tabStrip = ( + { draggingViewId.current = id; showTabDropTarget(undefined) }} + renderTab={(view, index, presentation = {}) => { + const renaming = renamingViewId === view.id + const active = !activePage && view.id === layout.activeViewId + const threadIds = workspaceViewThreadIds(view) + return ( +
= 0 && index < 9 ? `workspace.tab.select.${index + 1}` : undefined} + tabIndex={view.id === layout.activeViewId ? 0 : -1} + aria-label={view.name} + aria-selected={active} + draggable={!renaming} + key={view.id} + onAuxClick={(event) => { + if (event.button === 1) closeView(view.id) + }} + onClick={() => { + if (renaming) return + registry.showPage() + setLayout((current) => activateWorkspaceTab(current, view.id)) + }} + onDoubleClick={(event) => { + event.stopPropagation() + beginRename(view) + }} + onKeyDown={(event) => { + presentation.onKeyDown?.(event) + if (event.defaultPrevented) return + if (event.key === 'F2') { + event.preventDefault() + beginRename(view) + return + } + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + registry.showPage() + setLayout((current) => activateWorkspaceTab(current, view.id)) + }} + onDragStart={(event) => { + draggingViewId.current = view.id + showTabDropTarget(undefined) + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('application/x-alto-workspace-tab', view.id) + event.dataTransfer.setData('text/plain', view.id) + }} + onDragEnd={() => { + draggingViewId.current = undefined + showTabDropTarget(undefined) + }} + > + {TabRail && } + {renaming ? ( + <> + setRenamingViewName(event.target.value)} + onPointerDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onBlur={() => void finishRename(true)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === 'Enter') { + event.preventDefault() + void finishRename(true) + } else if (event.key === 'Escape') { + event.preventDefault() + void finishRename(false) + } + }} + /> + {renameError && !} + + ) : ( + {view.name} + )} + {!renaming && tabAddons.map((addon) => { + const Addon = addon.renderer + return ( + + ) + })} + {layout.views.length > 1 && ( + + )} +
+ ) + }}> +
+ +
+
+ ) + const navigation = ( + <> + + {renderContributions('header-left')} + + ) + const tools = ( +
+ {renderContributions('header-right')} + {Plugins && } + {Settings && } +
+ {(globalState.ui?.surfaces ?? []) + .filter((surface) => surface.data?.slot === 'header-end') + .sort((a, b) => Number(a.data?.order ?? 0) - Number(b.data?.order ?? 0)) + .map((surface) => { + const Renderer = ui.renderer(surface) + return Renderer ? : null + })} +
+
+ ) const historyMouseButton = (event: ReactMouseEvent): void => { const direction = event.button === 3 ? -1 : event.button === 4 ? 1 : undefined if (!direction) return @@ -3042,188 +3267,16 @@ function WorkspaceLayout({ return view ? focusViewPane(view, paneId) : false }} /> -
-
- - {renderContributions('header-left')} - { draggingViewId.current = id; showTabDropTarget(undefined) }} - renderTab={(view, index, presentation = {}) => { - const renaming = renamingViewId === view.id - const active = !activePage && view.id === layout.activeViewId - const threadIds = workspaceViewThreadIds(view) - return ( -
= 0 && index < 9 ? `workspace.tab.select.${index + 1}` : undefined} - tabIndex={view.id === layout.activeViewId ? 0 : -1} - aria-label={view.name} - aria-selected={active} - draggable={!renaming} - key={view.id} - onAuxClick={(event) => { - if (event.button === 1) closeView(view.id) - }} - onClick={() => { - if (renaming) return - registry.showPage() - setLayout((current) => activateWorkspaceTab(current, view.id)) - }} - onDoubleClick={(event) => { - event.stopPropagation() - beginRename(view) - }} - onKeyDown={(event) => { - presentation.onKeyDown?.(event) - if (event.defaultPrevented) return - if (event.key === 'F2') { - event.preventDefault() - beginRename(view) - return - } - if (event.key !== 'Enter' && event.key !== ' ') return - event.preventDefault() - registry.showPage() - setLayout((current) => activateWorkspaceTab(current, view.id)) - }} - onDragStart={(event) => { - draggingViewId.current = view.id - showTabDropTarget(undefined) - event.dataTransfer.effectAllowed = 'move' - event.dataTransfer.setData('application/x-alto-workspace-tab', view.id) - event.dataTransfer.setData('text/plain', view.id) - }} - onDragEnd={() => { - draggingViewId.current = undefined - showTabDropTarget(undefined) - }} - > - {renaming ? ( - <> - setRenamingViewName(event.target.value)} - onPointerDown={(event) => event.stopPropagation()} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - onBlur={() => void finishRename(true)} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === 'Enter') { - event.preventDefault() - void finishRename(true) - } else if (event.key === 'Escape') { - event.preventDefault() - void finishRename(false) - } - }} - /> - {renameError && !} - - ) : ( - {view.name} - )} - {!renaming && tabAddons.map((addon) => { - const Addon = addon.renderer - return ( - - ) - })} - {layout.views.length > 1 && ( - - )} -
- ) - }}> -
- -
-
-
-
- {renderContributions('header-right')} - {Plugins && } - {Settings && } -
- {(globalState.ui?.surfaces ?? []) - .filter((surface) => surface.data?.slot === 'header-end') - .sort((a, b) => Number(a.data?.order ?? 0) - Number(b.data?.order ?? 0)) - .map((surface) => { - const Renderer = ui.renderer(surface) - return Renderer ? : null - })} + {/* A tab rail replaces the whole top bar and hosts its navigation and tools. */} + {!TabRail && ( +
+
+ {navigation} + {tabStrip}
-
-
+ {tools} + + )} {workspacePickerOpen && (
{newTabOptions.map((option) => ( @@ -3250,37 +3303,41 @@ function WorkspaceLayout({ /> )} {renderContributions('main')} -
{ - const next = event.relatedTarget - if (!(next instanceof Node) || !event.currentTarget.contains(next)) { - showTabDropTarget(undefined) - } - }} - onDrop={dropWorkspaceTabIntoPane} - > - {pages.map((page) => { - const Page = page.renderer - const visible = page.id === activePage?.id - return - })} - {/* Hidden workspaces stay mounted: their chat sessions and native panes own live state. */} - {layout.views.map((view) => { - const visible = !activePage && view.id === layout.activeViewId - return ( - - ) - })} + {/* The views keep one position whether or not a rail is present, so panes never remount. */} +
+ {TabRail && } +
{ + const next = event.relatedTarget + if (!(next instanceof Node) || !event.currentTarget.contains(next)) { + showTabDropTarget(undefined) + } + }} + onDrop={dropWorkspaceTabIntoPane} + > + {pages.map((page) => { + const Page = page.renderer + const visible = page.id === activePage?.id + return + })} + {/* Hidden workspaces stay mounted: their chat sessions and native panes own live state. */} + {layout.views.map((view) => { + const visible = !activePage && view.id === layout.activeViewId + return ( + + ) + })} +
) diff --git a/program/plugins/workspace-layout.css b/program/plugins/workspace-layout.css index 2d11cb0..13de526 100644 --- a/program/plugins/workspace-layout.css +++ b/program/plugins/workspace-layout.css @@ -3,8 +3,13 @@ internals that paint var(--canvas) need no changes. */ .workspace-layout-root { --workspace-pane-header-height: 35px; - --workspace-chrome: var(--canvas); + /* Height of the window's title band when a tab rail replaces the top bar. */ + --workspace-title-band-height: calc(var(--control-default) + var(--space-2)); + --workspace-sheet-radius: var(--radius-md, 18px); + /* The tab bar or rail, the title band, and gutters: a step darker than the sheet. */ + --workspace-chrome: color-mix(in srgb, var(--canvas) 97%, black); --workspace-sheet-border: var(--border); + --workspace-sheet-edge: 1px; position: relative; z-index: 2; min-width: 0; @@ -290,6 +295,83 @@ html[data-desktop-platform="darwin"] .workspace-tabbar { .workspace-tabbar-tools { flex: 0 0 auto; } .workspace-tabbar-tools .shell-control { box-shadow: none; } +html[data-alto-theme='dark'] .workspace-layout-root { + --workspace-chrome: color-mix(in srgb, var(--canvas) 55%, var(--bg)); +} + +/* A registered tab rail sits before the views in this row. */ +.workspace-body { + min-width: 0; + min-height: 0; + display: flex; + flex: 1 1 0; + overflow: hidden; +} + +/* A tab rail replaces the top bar, so the top row of pane title bars becomes + the window's title band: every pane kind's bar takes the band height and the + chrome color, and the content sheet begins beneath it, curving down beside + the rail. Pane kinds join by using .alto-pane-header for their top bar. */ +.workspace-body[data-tab-orientation='vertical'] { + --workspace-pane-header-height: var(--workspace-title-band-height); +} +.workspace-body[data-tab-orientation='vertical'] :is( + .workspace-chat-pane:not(.workspace-typed-pane) > .workspace-pane-header, + .alto-pane-header, + .workspace-typed-pane:not(:has(.alto-pane-header)) > .workspace-pane-header +):not(.workspace-split-vertical > .workspace-split-second *) { + border-bottom-color: transparent; + background: var(--workspace-chrome); +} +/* A pane kind without its own top bar gets a band from its pane controls. */ +.workspace-body[data-tab-orientation='vertical'] .workspace-typed-pane:not(:has(.alto-pane-header)) > .workspace-pane-header { + position: relative; + top: auto; + right: auto; + width: auto; + flex: 0 0 var(--workspace-pane-header-height); + padding-inline-start: var(--pane-chrome-gutter); + pointer-events: auto; +} +.workspace-body[data-tab-orientation='vertical'] .workspace-typed-pane:not(:has(.alto-pane-header)) > .workspace-pane-header .workspace-typed-pane-title { display: flex; } +.workspace-body[data-tab-orientation='vertical'] > .workspace-views > .workspace-view { + position: relative; + border-top: 0; +} +/* The sheet edge occupies the band's last row rather than the content's first. + Native surfaces such as terminals cover the content box, so a line drawn + there would be hidden beneath them. */ +.workspace-body[data-tab-orientation='vertical'] > .workspace-views > .workspace-view::before, +.workspace-body[data-tab-orientation='vertical'] > .workspace-views > .workspace-view::after { + position: absolute; + top: calc(var(--workspace-pane-header-height) - var(--workspace-sheet-edge)); + left: 0; + content: ''; + pointer-events: none; +} +.workspace-body[data-tab-orientation='vertical'] > .workspace-views > .workspace-view::before { + z-index: 20; + right: 0; + bottom: 0; + border-top: var(--workspace-sheet-edge) solid var(--workspace-sheet-border); + border-left: var(--workspace-sheet-edge) solid var(--workspace-sheet-border); + border-top-left-radius: var(--workspace-sheet-radius); +} +/* Fills the outside of the sheet's rounded corner with the chrome color. */ +.workspace-body[data-tab-orientation='vertical'] > .workspace-views > .workspace-view::after { + z-index: 19; + width: var(--workspace-sheet-radius); + height: var(--workspace-sheet-radius); + background: radial-gradient(circle at 100% 100%, transparent calc(var(--workspace-sheet-radius) - 0.5px), var(--workspace-chrome) var(--workspace-sheet-radius)); +} +/* Pages draw their own layout from the top edge, so they keep a straight divider. */ +.workspace-body[data-tab-orientation='vertical'] > .workspace-views > .workspace-view[role='region']::before { + top: 0; + border-top: 0; + border-top-left-radius: 0; +} +.workspace-body[data-tab-orientation='vertical'] > .workspace-views > .workspace-view[role='region']::after { display: none; } + .workspace-views, .workspace-view, .workspace-split, diff --git a/program/plugins/workspace-tab-groups.client.tsx b/program/plugins/workspace-tab-groups.client.tsx index 8cb5a67..fd49dd0 100644 --- a/program/plugins/workspace-tab-groups.client.tsx +++ b/program/plugins/workspace-tab-groups.client.tsx @@ -44,17 +44,17 @@ interface DropPreview extends WorkspaceTabDestination { edge: 'before' | 'after' | 'inside' } -function dropPreview(layout: WorkspaceLayoutState, source: WorkspaceTabDrag, strip: HTMLElement, target: EventTarget, x: number): DropPreview { +function dropPreview(layout: WorkspaceLayoutState, source: WorkspaceTabDrag, strip: HTMLElement, target: EventTarget, point: number, vertical: boolean): DropPreview { const elements = [...strip.querySelectorAll('[data-workspace-tab]:not([inert]), [data-workspace-group]')] const hit = (target as Element).closest('[data-workspace-tab]:not([inert]), [data-workspace-group]') - ?? elements.find((element) => element.getBoundingClientRect().left > x) + ?? elements.find((element) => (vertical ? element.getBoundingClientRect().top : element.getBoundingClientRect().left) > point) if (!hit) return { targetKind: 'end', edge: 'after' } const groupId = hit.dataset.workspaceGroup const tab = layout.views.find((view) => view.id === hit.dataset.workspaceTab) const members = groupId ? layout.views.filter((view) => view.groupId === groupId) : tab ? [tab] : [] if (!members.length) return { targetKind: 'end', edge: 'after' } const bounds = hit.getBoundingClientRect() - const fraction = (x - bounds.left) / bounds.width + const fraction = vertical ? (point - bounds.top) / bounds.height : (point - bounds.left) / bounds.width if (source.kind === 'tab' && groupId && fraction >= 0.2) { const next = layout.views[layout.views.indexOf(members.at(-1)!) + 1] return { targetKind: 'group', targetId: groupId, edge: 'inside', groupId, ...(next ? { beforeId: next.id } : {}) } @@ -77,7 +77,7 @@ function dropPreview(layout: WorkspaceLayoutState, source: WorkspaceTabDrag, str } } -export function WorkspaceTabGroups({ layout, change, rootRef, ui, beginDrag, renderTab, children, tabActions = [] }: WorkspaceTabStripProps & { ui: ClientUiService }): ReactNode { +export function WorkspaceTabGroups({ orientation = 'horizontal', layout, change, rootRef, ui, beginDrag, renderTab, children, tabActions = [] }: WorkspaceTabStripProps & { ui: ClientUiService }): ReactNode { const [menu, setMenu] = useState() const [name, setName] = useState('') const panel = useRef(null) @@ -113,11 +113,15 @@ export function WorkspaceTabGroups({ layout, change, rootRef, ui, beginDrag, ren if (!source) return event.preventDefault() event.dataTransfer.dropEffect = 'move' - const next = dropPreview(layout, source, event.currentTarget, event.target, event.clientX) + const vertical = orientation === 'vertical' + const next = dropPreview(layout, source, event.currentTarget, event.target, vertical ? event.clientY : event.clientX, vertical) setPreview((current) => current?.targetKind === next.targetKind && current?.targetId === next.targetId && current?.edge === next.edge && current?.beforeId === next.beforeId && current?.groupId === next.groupId ? current : next) const bounds = event.currentTarget.getBoundingClientRect() - if (event.clientX < bounds.left + 24) event.currentTarget.scrollLeft -= 20 + if (vertical) { + if (event.clientY < bounds.top + 24) event.currentTarget.scrollTop -= 20 + else if (event.clientY > bounds.bottom - 24) event.currentTarget.scrollTop += 20 + } else if (event.clientX < bounds.left + 24) event.currentTarget.scrollLeft -= 20 else if (event.clientX > bounds.right - 24) event.currentTarget.scrollLeft += 20 } const menuTab = menu?.kind === 'tab' ? layout.views.find((view) => view.id === menu.id) : undefined @@ -202,7 +206,7 @@ export function WorkspaceTabGroups({ layout, change, rootRef, ui, beginDrag, ren const shortcuts = tabGroupShortcutTargets(layout) const seen = new Set() - return
{ const source = (event.target as Element).closest('[data-workspace-tab]:not([inert]), [data-workspace-group]') if (!source) return @@ -226,7 +230,8 @@ export function WorkspaceTabGroups({ layout, change, rootRef, ui, beginDrag, ren const source = drag.current if (!source) return event.preventDefault() - const destination = dropPreview(layout, source, event.currentTarget, event.target, event.clientX) + const vertical = orientation === 'vertical' + const destination = dropPreview(layout, source, event.currentTarget, event.target, vertical ? event.clientY : event.clientX, vertical) change((current) => dropWorkspaceTabs(current, source, destination)) clearDrag() }}> diff --git a/program/plugins/workspace-tab-groups.css b/program/plugins/workspace-tab-groups.css index 0a9fa31..2081d84 100644 --- a/program/plugins/workspace-tab-groups.css +++ b/program/plugins/workspace-tab-groups.css @@ -131,10 +131,58 @@ html[data-alto-theme='dark'] [data-group-color] { transition-delay: 0s, 0s, 0s, 0s, 0s, 0s, 0s, var(--workspace-tab-group-duration); } +/* Vertical strips stack members under their label and mark them with a + colored line instead of a frame. Collapsing animates height rather than width. */ +.workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab-group-label, +.workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab-group-member, +.workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab-group-label[aria-expanded='true'] + .workspace-tab-group-member { + margin-inline: 0; +} +.workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab-group-label { color: var(--text); } +.workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab-group-label::before { display: none; } +/* The line sits under the label's first letter, and member titles start a + step further in, so the group reads as one indented column. */ +.workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab-group-member { + padding-inline-start: var(--space-4); + transition: flex-basis var(--workspace-tab-group-duration) var(--workspace-tab-group-easing), + margin-block var(--workspace-tab-group-duration) var(--workspace-tab-group-easing), + opacity 180ms ease, visibility 0s; +} +.workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab-group-member::before, +.workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab-group-label[aria-expanded='true'] + .workspace-tab-group-member::before, +.workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab-group-member.is-group-end::before { + /* Offsets start inside the tab's 1px border, so reach past it to meet the next slice. */ + inset-block: calc(-1px - 0.5 * var(--workspace-tab-group-gap)); + inset-inline: var(--workspace-tab-group-line-inset, var(--space-2)) auto; + inline-size: 2px; + border: 0; + border-radius: 0; + background: var(--tab-group-color); +} +.workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab-group-member.is-group-end::before { + inset-block-end: var(--space-1); + border-end-start-radius: var(--radius-pill); + border-end-end-radius: var(--radius-pill); +} +/* The line starts clear of the label's rounded corner, matching its end. */ +.workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab-group-label[aria-expanded='true'] + .workspace-tab-group-member::before { + inset-block-start: var(--space-1); + border-start-start-radius: var(--radius-pill); + border-start-end-radius: var(--radius-pill); +} +.workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab-group-member.is-group-collapsed { + flex-basis: 0px; + border-block-width: 0; + margin-inline: 0; + margin-block-end: calc(-1 * var(--workspace-tab-group-gap)); + transition-delay: 0s, 0s, 0s, var(--workspace-tab-group-duration); +} + @media (prefers-reduced-motion: reduce) { .workspace-tab-group-label, .workspace-tab-group-label::before, .workspace-tab-group-member, + .workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab-group-member, .workspace-tab-group-count, .workspace-tab-group-toggle > svg { transition: none; } } @@ -150,6 +198,15 @@ html[data-alto-theme='dark'] [data-group-color] { } .workspace-tabs [data-tab-drop='before']::after { inset-inline-start: 0; } .workspace-tabs [data-tab-drop='after']::after { inset-inline-end: 0; } +.workspace-tabs[aria-orientation='vertical'] [data-tab-drop='before']::after, +.workspace-tabs[aria-orientation='vertical'] [data-tab-drop='after']::after { + inset-block: auto; + inset-inline: var(--space-1); + inline-size: auto; + block-size: calc(var(--space-1) / 2); +} +.workspace-tabs[aria-orientation='vertical'] [data-tab-drop='before']::after { inset-block-start: 0; } +.workspace-tabs[aria-orientation='vertical'] [data-tab-drop='after']::after { inset-block-end: 0; } .workspace-tabs [data-tab-drop='inside'] { outline: 2px solid var(--tab-group-color, var(--accent)); outline-offset: -2px; } .workspace-tab-ungroup-target { flex: 0 0 auto; diff --git a/tests/fixtures/vertical-tabs.cjs b/tests/fixtures/vertical-tabs.cjs new file mode 100644 index 0000000..6824f61 --- /dev/null +++ b/tests/fixtures/vertical-tabs.cjs @@ -0,0 +1,353 @@ +const { app, BrowserWindow } = require('./background-electron.cjs') +const { build } = require('esbuild') +const assert = require('node:assert/strict') +const fs = require('node:fs/promises') +const http = require('node:http') +const path = require('node:path') + +async function check() { + await app.whenReady() + const bundle = await build({ + stdin: { resolveDir: process.cwd(), sourcefile: 'vertical-tabs-check.tsx', loader: 'tsx', contents: ` + import React,{useEffect} from 'react'; + import {createRoot} from 'react-dom/client'; + import {flushSync} from 'react-dom'; + import {SquareTerminal} from 'lucide-react'; + import plugin from './program/plugins/workspace-layout.client'; + import groupsPlugin from './program/plugins/workspace-tab-groups.client'; + import verticalTabsPlugin from './program/plugins/vertical-tabs.client'; + import verticalHotkeysPlugin from './program/plugins/hotkeys-vertical-tabs.client'; + import {HotkeysService} from './program/plugins/hotkeys.client'; + import {parseWorkspaceLayout} from './program/plugins/workspace-layout-state'; + let saved={version:2,activeViewId:'a',views:['Alto','Incident review with a long name','Design','Review','Shell'].map((name,i)=>{ + const id=String.fromCharCode(97+i);return {id,name,workspace:'/repo',focusedPaneId:'pane-'+id,root:{type:'pane',id:'pane-'+id,workspace:'/repo',kind:id==='e'?'terminal':'test'}}; + })}; + localStorage.setItem('codex-cordis.workspace-layout',JSON.stringify(saved)); + localStorage.removeItem('codex-cordis.vertical-tabs'); + const state={connected:true,session:{workspace:'/repo',permissionMode:'full'},projects:[],threads:[],skills:[],turn:{tag:'idle'},activities:[],history:{tag:'ready',entries:[]},harness:{codex:{status:'ready',models:[]},ui:{surfaces:[],contributions:[{id:'test-tool',slot:'header-right'}],regions:[]}}}; + const host={subscribe:()=>()=>{},snapshot:()=>({connectionEpoch:1}),call:async(method,payload)=>{ + if(method.endsWith('.read'))return structuredClone(saved); + if(method.endsWith('.write')){saved=parseWorkspaceLayout(payload);return null;} + }}; + let Surface,registry,root; + const session={snapshot:()=>state,subscribe:()=>()=>{}}; + const Placeholder=()=>null; + const Tool=()=>; + const ui={subscribe:()=>()=>{},snapshot:()=>0,component:()=>Placeholder,renderer:()=>undefined,contributionRenderer:()=>Tool, + overlays:{open:()=>{},close:()=>{}}, + registerSurface:(_owner,_id,component)=>{Surface=component}, + registerStyle:(owner,id,css)=>{owner.effect(()=>{const el=document.createElement('style');el.dataset.pluginStyle=id;el.textContent=css;document.head.append(el);return ()=>el.remove()})}}; + const ctx={provide:(name,value)=>{if(name==='clientVerticalTabs')registry.verticalTabs=value;else registry=value},clientUi:ui,clientHost:host,clientSession:session, + clientSessionRouter:{setActive:()=>{},clearActive:()=>{}},clientSessionFactory:{create:()=>{throw new Error('Unexpected chat creation')}}}; + plugin({...ctx,effect:fn=>fn()}); + const fiber=install=>{let effects=[];return { + enable:()=>flushSync(()=>install({...ctx,clientWorkspaceLayout:registry,effect:fn=>{const cleanup=fn();effects.push(cleanup);return cleanup}})), + disable:()=>flushSync(()=>{for(const cleanup of effects.reverse())cleanup();effects=[]}), + }}; + const hotkeys=new HotkeysService(ui,{leader:'Alt'}); + const groups=fiber(groupsPlugin),vertical=fiber(ctx=>{verticalTabsPlugin(ctx); + verticalHotkeysPlugin({...ctx,clientVerticalTabs:registry.verticalTabs,clientHotkeys:{registerAction:(owner,action)=>hotkeys.registerAction(owner,action)}})}); + window.enableGroups=groups.enable;window.disableGroups=groups.disable; + window.enableVertical=vertical.enable;window.disableVertical=vertical.disable; + groups.enable(); + window.mounts=0; + const Pane=({pane})=>{useEffect(()=>{window.mounts++},[]);const body=

Conversation in {pane.id}