From 5cd66e5eecc8222b43d50da6430ea5399352a16b Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Thu, 9 Jul 2026 13:57:58 -0500 Subject: [PATCH 1/5] feat(terminal): native theming and chrome-free pane rebuild Round 1 of the Terminal Command Center UI epic: makes the Terminal feature feel native to PageSpace instead of xterm's raw green-tinted default look, and rebuilds pane chrome to match PurePoint's real chrome-free design. - Extract useMonacoTheme's CSS-var-to-hex resolution pipeline into a shared apps/web/src/lib/theme/css-color-resolution.ts util (no behavior change). - Add useXtermTheme, reactive to next-themes' resolvedTheme, with a curated muted ANSI palette per light/dark mode. - Wire theme + typography into XtermTerminal; live theme updates via terminal.options.theme without remounting the PTY connection. - Rebuild TerminalPanes chrome-free (no per-pane header, ever): top accent-bar focus indicator, hover-revealed Split Right/Split Down/Close controls, design tokens instead of hardcoded black/white and red/green states. - Add a real vertical split axis: workspace-reducer now models a horizontal row of columns, each an independent vertical stack of panes, via splitRight/splitDown. Rendered as nested ResizablePanelGroups over the existing resizable primitives. - Add a chrome-free ResizableHandle variant so the seam stays faintly visible at rest (chrome-free panes have no other cue), without touching the global default used elsewhere in the app. Tests: reducer/store tests rewritten for the columns structure (28 tests, given/should style). typecheck + build green across all 16 workspace packages. --- .../page-views/terminal/XtermTerminal.tsx | 35 ++- .../terminal/workspace/TerminalPanes.tsx | 119 +++++--- apps/web/src/components/ui/resizable.tsx | 15 +- apps/web/src/hooks/useMonacoTheme.ts | 207 +------------ apps/web/src/hooks/useXtermTheme.ts | 96 +++++++ .../web/src/lib/theme/css-color-resolution.ts | 216 ++++++++++++++ .../useTerminalWorkspaceStore.test.ts | 63 ++-- .../__tests__/workspace-reducer.test.ts | 272 +++++++++++++++--- .../useTerminalWorkspaceStore.ts | 25 +- .../terminal-workspace/workspace-reducer.ts | 93 ++++-- 10 files changed, 798 insertions(+), 343 deletions(-) create mode 100644 apps/web/src/hooks/useXtermTheme.ts create mode 100644 apps/web/src/lib/theme/css-color-resolution.ts diff --git a/apps/web/src/components/layout/middle-content/page-views/terminal/XtermTerminal.tsx b/apps/web/src/components/layout/middle-content/page-views/terminal/XtermTerminal.tsx index f45c2671ff..2e5040a8c8 100644 --- a/apps/web/src/components/layout/middle-content/page-views/terminal/XtermTerminal.tsx +++ b/apps/web/src/components/layout/middle-content/page-views/terminal/XtermTerminal.tsx @@ -2,7 +2,12 @@ import { useEffect, useRef } from 'react'; import type { Socket } from 'socket.io-client'; +import type { Terminal as XtermTerminalInstance } from '@xterm/xterm'; import { useEditingStore } from '@/stores/useEditingStore'; +import { useXtermTheme } from '@/hooks/useXtermTheme'; +import { getCssVar } from '@/lib/theme/css-color-resolution'; + +const FALLBACK_FONT_FAMILY = "ui-monospace, SFMono-Regular, Menlo, Monaco, 'Courier New', monospace"; export interface AgentTerminalConnectPayload { terminalId: string; @@ -23,6 +28,21 @@ interface XtermTerminalProps { export default function XtermTerminal({ socket, sessionId, connectPayload, onReady, onError }: XtermTerminalProps) { const containerRef = useRef(null); + const terminalRef = useRef(null); + const theme = useXtermTheme(); + // Read at creation time only — the connect effect below is intentionally + // NOT keyed on `theme` (see its own comment), so later theme changes are + // pushed live via the effect further down instead of through this ref. + const themeRef = useRef(theme); + themeRef.current = theme; + + // Live theme updates without tearing down the [socket, sessionId]-keyed + // connection effect — xterm supports assigning `options.theme` directly. + useEffect(() => { + if (terminalRef.current) { + terminalRef.current.options.theme = theme; + } + }, [theme]); useEffect(() => { if (!containerRef.current) return; @@ -46,7 +66,17 @@ export default function XtermTerminal({ socket, sessionId, connectPayload, onRea if (cancelled || !containerRef.current) return; - const terminal = new Terminal({ cursorBlink: true }); + const fontFamily = getCssVar('--font-mono') || FALLBACK_FONT_FAMILY; + const terminal = new Terminal({ + cursorBlink: true, + theme: themeRef.current, + fontFamily, + fontSize: 13, + cursorStyle: 'bar', + letterSpacing: 0, + lineHeight: 1.35, + }); + terminalRef.current = terminal; const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); terminal.open(containerRef.current); @@ -101,6 +131,9 @@ export default function XtermTerminal({ socket, sessionId, connectPayload, onRea // timeout) correctly reflects "this one pane closed". socket.emit('agent-terminal:disconnect', { connectionId }); terminal.dispose(); + if (terminalRef.current === terminal) { + terminalRef.current = null; + } useEditingStore.getState().endEditing(sessionId); }; diff --git a/apps/web/src/components/layout/middle-content/page-views/terminal/workspace/TerminalPanes.tsx b/apps/web/src/components/layout/middle-content/page-views/terminal/workspace/TerminalPanes.tsx index 50021bdb3f..d3dcfb4a41 100644 --- a/apps/web/src/components/layout/middle-content/page-views/terminal/workspace/TerminalPanes.tsx +++ b/apps/web/src/components/layout/middle-content/page-views/terminal/workspace/TerminalPanes.tsx @@ -3,7 +3,7 @@ import { Fragment, useCallback, useState } from 'react'; import dynamic from 'next/dynamic'; import type { Socket } from 'socket.io-client'; -import { SquareSplitHorizontal, X } from 'lucide-react'; +import { SquareSplitHorizontal, SquareSplitVertical, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; import { useTerminalWorkspaceStore, selectWorkspace, type OpenTerminalScope, type TerminalPaneState } from '@/stores/terminal-workspace/useTerminalWorkspaceStore'; @@ -18,17 +18,14 @@ interface TerminalPanesProps { socket: Socket | null | undefined; } -function scopeLabel(scope: OpenTerminalScope): string { - return [scope.projectName, scope.branchName, scope.name].filter(Boolean).join('/'); -} - function paneSessionId(terminalId: string, scope: OpenTerminalScope): string { return `agent-terminal:${terminalId}:${scope.projectName ?? ''}:${scope.branchName ?? ''}:${scope.name}`; } export default function TerminalPanes({ terminalId, socket }: TerminalPanesProps) { const workspace = useTerminalWorkspaceStore(selectWorkspace(terminalId)); - const split = useTerminalWorkspaceStore((state) => state.split); + const splitRight = useTerminalWorkspaceStore((state) => state.splitRight); + const splitDown = useTerminalWorkspaceStore((state) => state.splitDown); const closePane = useTerminalWorkspaceStore((state) => state.closePane); const selectPane = useTerminalWorkspaceStore((state) => state.selectPane); @@ -36,35 +33,36 @@ export default function TerminalPanes({ terminalId, socket }: TerminalPanesProps // mounting TerminalWorkspace's ensureWorkspace effect committing. if (!workspace) return null; - const { panes, activePaneId } = workspace; + const { columns, activePaneId } = workspace; + const canClose = columns.reduce((sum, column) => sum + column.panes.length, 0) > 1; return ( -
-
- -
- - {panes.map((pane, i) => ( - - {i > 0 && } - - 1} - onSelect={() => selectPane(terminalId, pane.id)} - onClose={() => closePane(terminalId, pane.id)} - /> +
+ + {columns.map((column, columnIndex) => ( + + {columnIndex > 0 && } + + + {column.panes.map((pane, paneIndex) => ( + + {paneIndex > 0 && } + + selectPane(terminalId, pane.id)} + onSplitRight={() => splitRight(terminalId, pane.id)} + onSplitDown={() => splitDown(terminalId, pane.id)} + onClose={() => closePane(terminalId, pane.id)} + /> + + + ))} + ))} @@ -73,6 +71,11 @@ export default function TerminalPanes({ terminalId, socket }: TerminalPanesProps ); } +/** + * Chrome-free by design (no per-pane header, ever) — session identity lives + * in the Navigator sidebar instead. A top accent bar shows focus and + * hover-revealed controls handle splitting/closing without permanent chrome. + */ function TerminalPane({ socket, terminalId, @@ -80,6 +83,8 @@ function TerminalPane({ isActive, canClose, onSelect, + onSplitRight, + onSplitDown, onClose, }: { socket: Socket | null | undefined; @@ -88,19 +93,40 @@ function TerminalPane({ isActive: boolean; canClose: boolean; onSelect(): void; + onSplitRight(): void; + onSplitDown(): void; onClose(): void; }) { const sessionId = pane.scope ? paneSessionId(terminalId, pane.scope) : null; return ( -
-
- - {pane.scope ? scopeLabel(pane.scope) : 'No terminal open — click to make this pane active, then pick a terminal in the navigator'} - +
+
+
+ + {canClose && ( @@ -162,13 +189,13 @@ function TerminalPaneStream({ /> )} {!connected && ( -
+
{error ? ( - {error} + {error} ) : (
-
- Connecting… +
+ Connecting…
)}
diff --git a/apps/web/src/components/ui/resizable.tsx b/apps/web/src/components/ui/resizable.tsx index d43a5a0c56..cda07d69a4 100644 --- a/apps/web/src/components/ui/resizable.tsx +++ b/apps/web/src/components/ui/resizable.tsx @@ -25,11 +25,13 @@ function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) { function ResizableHandle({ className, + variant = "default", ...props -}: ResizablePrimitive.SeparatorProps) { +}: ResizablePrimitive.SeparatorProps & { variant?: "default" | "chrome-free" }) { return ( -
+ {/* chrome-free panes (e.g. Terminal) strip every other seam cue, so + their handle must stay faintly visible at rest — everywhere else + the resting opacity-0 default is correct because sibling + borders/cards already show the seam. */} +
) } diff --git a/apps/web/src/hooks/useMonacoTheme.ts b/apps/web/src/hooks/useMonacoTheme.ts index 2109a40ca0..35e5de921c 100644 --- a/apps/web/src/hooks/useMonacoTheme.ts +++ b/apps/web/src/hooks/useMonacoTheme.ts @@ -3,212 +3,7 @@ import { useTheme } from 'next-themes'; import { useEffect, useState } from 'react'; import type { Monaco } from '@monaco-editor/react'; - -let colorResolveContext: CanvasRenderingContext2D | null | undefined; - -function getCssVar(name: string): string { - return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -function byteToHex(value: number): string { - return clamp(Math.round(value), 0, 255).toString(16).padStart(2, '0'); -} - -function normalizeHexColor(value: string): string | null { - const normalized = value.trim().toLowerCase(); - - if (/^#[0-9a-f]{6}$/.test(normalized) || /^#[0-9a-f]{8}$/.test(normalized)) { - return normalized; - } - - if (/^#[0-9a-f]{3}$/.test(normalized)) { - const r = normalized[1]; - const g = normalized[2]; - const b = normalized[3]; - return `#${r}${r}${g}${g}${b}${b}`; - } - - if (/^#[0-9a-f]{4}$/.test(normalized)) { - const r = normalized[1]; - const g = normalized[2]; - const b = normalized[3]; - const a = normalized[4]; - return `#${r}${r}${g}${g}${b}${b}${a}${a}`; - } - - return null; -} - -function parseRgbChannel(value: string): number | null { - const channel = value.trim(); - if (channel.endsWith('%')) { - const percent = Number.parseFloat(channel.slice(0, -1)); - if (!Number.isFinite(percent)) return null; - return clamp((percent / 100) * 255, 0, 255); - } - - const numeric = Number.parseFloat(channel); - if (!Number.isFinite(numeric)) return null; - return clamp(numeric, 0, 255); -} - -function parseAlphaChannel(value: string): number | null { - const alpha = value.trim(); - if (alpha.endsWith('%')) { - const percent = Number.parseFloat(alpha.slice(0, -1)); - if (!Number.isFinite(percent)) return null; - return clamp(percent / 100, 0, 1); - } - - const numeric = Number.parseFloat(alpha); - if (!Number.isFinite(numeric)) return null; - return clamp(numeric, 0, 1); -} - -function parseRgbLikeColorToHex(value: string): string | null { - const input = value.trim(); - const match = input.match(/^rgba?\((.*)\)$/i); - if (!match) return null; - - const content = match[1].trim(); - let rgbParts: string[] = []; - let alphaPart: string | undefined; - - if (content.includes(',')) { - const parts = content.split(',').map((part) => part.trim()); - if (parts.length < 3) return null; - rgbParts = parts.slice(0, 3); - alphaPart = parts[3]; - } else { - const slashParts = content.split('/'); - const rgbTokens = slashParts[0]?.trim().split(/\s+/) ?? []; - if (rgbTokens.length < 3) return null; - rgbParts = rgbTokens.slice(0, 3); - alphaPart = slashParts[1]?.trim(); - } - - const r = parseRgbChannel(rgbParts[0] ?? ''); - const g = parseRgbChannel(rgbParts[1] ?? ''); - const b = parseRgbChannel(rgbParts[2] ?? ''); - if (r === null || g === null || b === null) return null; - - const a = alphaPart ? parseAlphaChannel(alphaPart) : 1; - if (a === null) return null; - - const alphaHex = a < 1 ? byteToHex(a * 255) : ''; - return `#${byteToHex(r)}${byteToHex(g)}${byteToHex(b)}${alphaHex}`; -} - -function getColorResolveContext(): CanvasRenderingContext2D | null { - if (colorResolveContext !== undefined) { - return colorResolveContext; - } - - const canvas = document.createElement('canvas'); - canvas.width = 1; - canvas.height = 1; - colorResolveContext = canvas.getContext('2d', { willReadFrequently: true }); - return colorResolveContext; -} - -function parseCssColorToHex(value: string): string | null { - const input = value.trim(); - if (!input || typeof window === 'undefined') { - return null; - } - - if (!('CSS' in window) || typeof window.CSS.supports !== 'function') { - return null; - } - - if (!window.CSS.supports('color', input)) { - return null; - } - - const context = getColorResolveContext(); - if (!context) { - return null; - } - - context.clearRect(0, 0, 1, 1); - context.fillStyle = 'rgba(0, 0, 0, 0)'; - context.fillStyle = input; - context.fillRect(0, 0, 1, 1); - - const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data; - const alphaHex = a < 255 ? byteToHex(a) : ''; - return `#${byteToHex(r)}${byteToHex(g)}${byteToHex(b)}${alphaHex}`; -} - -function getComputedColorValue(cssValue: string): string | null { - const probe = document.createElement('span'); - probe.style.color = ''; - probe.style.color = cssValue; - - if (!probe.style.color) { - return null; - } - - probe.style.display = 'none'; - const parent = document.body ?? document.documentElement; - parent.appendChild(probe); - const computed = getComputedStyle(probe).color.trim(); - probe.remove(); - - return computed || null; -} - -function resolveColor(cssValue: string, fallback: string): string { - const fallbackHex = normalizeHexColor(fallback) ?? '#000000'; - - if (!cssValue) { - return fallbackHex; - } - - const directHex = normalizeHexColor(cssValue); - if (directHex) { - return directHex; - } - - const directRgbHex = parseRgbLikeColorToHex(cssValue); - if (directRgbHex) { - return directRgbHex; - } - - const cssHex = parseCssColorToHex(cssValue); - if (cssHex) { - return cssHex; - } - - const computed = getComputedColorValue(cssValue); - if (computed) { - const computedHex = - normalizeHexColor(computed) ?? - parseRgbLikeColorToHex(computed) ?? - parseCssColorToHex(computed); - if (computedHex) { - return computedHex; - } - } - - return fallbackHex; -} - -function withAlpha(color: string, alpha: number, fallback: string): string { - const resolved = resolveColor(color, fallback); - const normalized = normalizeHexColor(resolved) ?? normalizeHexColor(fallback) ?? '#000000'; - - const r = Number.parseInt(normalized.slice(1, 3), 16); - const g = Number.parseInt(normalized.slice(3, 5), 16); - const b = Number.parseInt(normalized.slice(5, 7), 16); - const a = byteToHex(clamp(alpha, 0, 1) * 255); - - return `#${byteToHex(r)}${byteToHex(g)}${byteToHex(b)}${a}`; -} +import { getCssVar, resolveColor, withAlpha } from '@/lib/theme/css-color-resolution'; export function useMonacoTheme(monaco: Monaco | null): string { const { resolvedTheme } = useTheme(); diff --git a/apps/web/src/hooks/useXtermTheme.ts b/apps/web/src/hooks/useXtermTheme.ts new file mode 100644 index 0000000000..91ea821431 --- /dev/null +++ b/apps/web/src/hooks/useXtermTheme.ts @@ -0,0 +1,96 @@ +'use client'; + +import { useTheme } from 'next-themes'; +import { useEffect, useState } from 'react'; +import type { ITheme } from '@xterm/xterm'; +import { getCssVar, resolveColor, withAlpha } from '@/lib/theme/css-color-resolution'; + +/** + * Curated, low-contrast ANSI palettes — no ANSI-color design tokens exist in + * globals.css, so these are muted constants (not raw CSS-var lookups) tuned + * to read as intentionally quiet rather than a raw default xterm palette. + */ +const MUTED_ANSI_DARK = { + black: '#3b3f46', + red: '#c97b7b', + green: '#8fae83', + yellow: '#c9b17a', + blue: '#7b93b8', + magenta: '#a688a8', + cyan: '#7fa8a3', + white: '#c7c7c7', + brightBlack: '#5c6370', + brightRed: '#e0a0a0', + brightGreen: '#a8c99a', + brightYellow: '#e0c896', + brightBlue: '#9bb4d6', + brightMagenta: '#c2a3c2', + brightCyan: '#9ecac4', + brightWhite: '#e8e8e8', +} as const; + +const MUTED_ANSI_LIGHT = { + black: '#5c5f66', + red: '#a85d5d', + green: '#5f7a56', + yellow: '#93793f', + blue: '#4f668c', + magenta: '#805f80', + cyan: '#4f7d78', + white: '#6b6b6b', + brightBlack: '#8a8d94', + brightRed: '#bf7373', + brightGreen: '#79966d', + brightYellow: '#ab9058', + brightBlue: '#6785ab', + brightMagenta: '#987298', + brightCyan: '#6d9a94', + brightWhite: '#3a3a3a', +} as const; + +const FALLBACK_DARK = { background: '#222222', foreground: '#f0f0f0', primary: '#5b8cff' }; +const FALLBACK_LIGHT = { background: '#ffffff', foreground: '#1f1f1f', primary: '#3f6dff' }; + +function buildFallbackTheme(isDark: boolean): ITheme { + const fallback = isDark ? FALLBACK_DARK : FALLBACK_LIGHT; + const ansi = isDark ? MUTED_ANSI_DARK : MUTED_ANSI_LIGHT; + return { + background: fallback.background, + foreground: fallback.foreground, + cursor: fallback.primary, + cursorAccent: fallback.background, + selectionBackground: `${fallback.primary}40`, + ...ansi, + }; +} + +function resolveTheme(isDark: boolean): ITheme { + const fallback = isDark ? FALLBACK_DARK : FALLBACK_LIGHT; + const ansi = isDark ? MUTED_ANSI_DARK : MUTED_ANSI_LIGHT; + + const background = resolveColor(getCssVar('--background'), fallback.background); + const foreground = resolveColor(getCssVar('--foreground'), fallback.foreground); + const primary = resolveColor(getCssVar('--primary'), fallback.primary); + + return { + background, + foreground, + cursor: primary, + cursorAccent: background, + selectionBackground: withAlpha(primary, isDark ? 0.3 : 0.2, fallback.primary), + ...ansi, + }; +} + +/** Reactive to next-themes' resolvedTheme, same pattern as useMonacoTheme. */ +export function useXtermTheme(): ITheme { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === 'dark'; + const [theme, setTheme] = useState(() => buildFallbackTheme(isDark)); + + useEffect(() => { + setTheme(resolveTheme(isDark)); + }, [isDark]); + + return theme; +} diff --git a/apps/web/src/lib/theme/css-color-resolution.ts b/apps/web/src/lib/theme/css-color-resolution.ts new file mode 100644 index 0000000000..5d4bf42ba6 --- /dev/null +++ b/apps/web/src/lib/theme/css-color-resolution.ts @@ -0,0 +1,216 @@ +/** + * CSS custom-property color resolution. + * + * Design tokens are defined as CSS custom properties (oklch, rgb, etc.) — + * consumers that need a concrete hex string (Monaco theme colors, xterm + * ITheme) resolve them through this pipeline: try a fast direct parse first, + * then fall back to asking the browser (CSS.supports + canvas fill, then a + * hidden-element computed-style probe) for anything the fast paths can't + * parse themselves (oklch, color-mix, named colors, etc.). + */ + +let colorResolveContext: CanvasRenderingContext2D | null | undefined; + +export function getCssVar(name: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function byteToHex(value: number): string { + return clamp(Math.round(value), 0, 255).toString(16).padStart(2, '0'); +} + +function normalizeHexColor(value: string): string | null { + const normalized = value.trim().toLowerCase(); + + if (/^#[0-9a-f]{6}$/.test(normalized) || /^#[0-9a-f]{8}$/.test(normalized)) { + return normalized; + } + + if (/^#[0-9a-f]{3}$/.test(normalized)) { + const r = normalized[1]; + const g = normalized[2]; + const b = normalized[3]; + return `#${r}${r}${g}${g}${b}${b}`; + } + + if (/^#[0-9a-f]{4}$/.test(normalized)) { + const r = normalized[1]; + const g = normalized[2]; + const b = normalized[3]; + const a = normalized[4]; + return `#${r}${r}${g}${g}${b}${b}${a}${a}`; + } + + return null; +} + +function parseRgbChannel(value: string): number | null { + const channel = value.trim(); + if (channel.endsWith('%')) { + const percent = Number.parseFloat(channel.slice(0, -1)); + if (!Number.isFinite(percent)) return null; + return clamp((percent / 100) * 255, 0, 255); + } + + const numeric = Number.parseFloat(channel); + if (!Number.isFinite(numeric)) return null; + return clamp(numeric, 0, 255); +} + +function parseAlphaChannel(value: string): number | null { + const alpha = value.trim(); + if (alpha.endsWith('%')) { + const percent = Number.parseFloat(alpha.slice(0, -1)); + if (!Number.isFinite(percent)) return null; + return clamp(percent / 100, 0, 1); + } + + const numeric = Number.parseFloat(alpha); + if (!Number.isFinite(numeric)) return null; + return clamp(numeric, 0, 1); +} + +function parseRgbLikeColorToHex(value: string): string | null { + const input = value.trim(); + const match = input.match(/^rgba?\((.*)\)$/i); + if (!match) return null; + + const content = match[1].trim(); + let rgbParts: string[] = []; + let alphaPart: string | undefined; + + if (content.includes(',')) { + const parts = content.split(',').map((part) => part.trim()); + if (parts.length < 3) return null; + rgbParts = parts.slice(0, 3); + alphaPart = parts[3]; + } else { + const slashParts = content.split('/'); + const rgbTokens = slashParts[0]?.trim().split(/\s+/) ?? []; + if (rgbTokens.length < 3) return null; + rgbParts = rgbTokens.slice(0, 3); + alphaPart = slashParts[1]?.trim(); + } + + const r = parseRgbChannel(rgbParts[0] ?? ''); + const g = parseRgbChannel(rgbParts[1] ?? ''); + const b = parseRgbChannel(rgbParts[2] ?? ''); + if (r === null || g === null || b === null) return null; + + const a = alphaPart ? parseAlphaChannel(alphaPart) : 1; + if (a === null) return null; + + const alphaHex = a < 1 ? byteToHex(a * 255) : ''; + return `#${byteToHex(r)}${byteToHex(g)}${byteToHex(b)}${alphaHex}`; +} + +function getColorResolveContext(): CanvasRenderingContext2D | null { + if (colorResolveContext !== undefined) { + return colorResolveContext; + } + + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + colorResolveContext = canvas.getContext('2d', { willReadFrequently: true }); + return colorResolveContext; +} + +function parseCssColorToHex(value: string): string | null { + const input = value.trim(); + if (!input || typeof window === 'undefined') { + return null; + } + + if (!('CSS' in window) || typeof window.CSS.supports !== 'function') { + return null; + } + + if (!window.CSS.supports('color', input)) { + return null; + } + + const context = getColorResolveContext(); + if (!context) { + return null; + } + + context.clearRect(0, 0, 1, 1); + context.fillStyle = 'rgba(0, 0, 0, 0)'; + context.fillStyle = input; + context.fillRect(0, 0, 1, 1); + + const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data; + const alphaHex = a < 255 ? byteToHex(a) : ''; + return `#${byteToHex(r)}${byteToHex(g)}${byteToHex(b)}${alphaHex}`; +} + +function getComputedColorValue(cssValue: string): string | null { + const probe = document.createElement('span'); + probe.style.color = ''; + probe.style.color = cssValue; + + if (!probe.style.color) { + return null; + } + + probe.style.display = 'none'; + const parent = document.body ?? document.documentElement; + parent.appendChild(probe); + const computed = getComputedStyle(probe).color.trim(); + probe.remove(); + + return computed || null; +} + +export function resolveColor(cssValue: string, fallback: string): string { + const fallbackHex = normalizeHexColor(fallback) ?? '#000000'; + + if (!cssValue) { + return fallbackHex; + } + + const directHex = normalizeHexColor(cssValue); + if (directHex) { + return directHex; + } + + const directRgbHex = parseRgbLikeColorToHex(cssValue); + if (directRgbHex) { + return directRgbHex; + } + + const cssHex = parseCssColorToHex(cssValue); + if (cssHex) { + return cssHex; + } + + const computed = getComputedColorValue(cssValue); + if (computed) { + const computedHex = + normalizeHexColor(computed) ?? + parseRgbLikeColorToHex(computed) ?? + parseCssColorToHex(computed); + if (computedHex) { + return computedHex; + } + } + + return fallbackHex; +} + +export function withAlpha(color: string, alpha: number, fallback: string): string { + const resolved = resolveColor(color, fallback); + const normalized = normalizeHexColor(resolved) ?? normalizeHexColor(fallback) ?? '#000000'; + + const r = Number.parseInt(normalized.slice(1, 3), 16); + const g = Number.parseInt(normalized.slice(3, 5), 16); + const b = Number.parseInt(normalized.slice(5, 7), 16); + const a = byteToHex(clamp(alpha, 0, 1) * 255); + + return `#${byteToHex(r)}${byteToHex(g)}${byteToHex(b)}${a}`; +} diff --git a/apps/web/src/stores/terminal-workspace/__tests__/useTerminalWorkspaceStore.test.ts b/apps/web/src/stores/terminal-workspace/__tests__/useTerminalWorkspaceStore.test.ts index a681cdc20a..20bdf4a90e 100644 --- a/apps/web/src/stores/terminal-workspace/__tests__/useTerminalWorkspaceStore.test.ts +++ b/apps/web/src/stores/terminal-workspace/__tests__/useTerminalWorkspaceStore.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useTerminalWorkspaceStore, selectWorkspace } from '../useTerminalWorkspaceStore'; +import { useTerminalWorkspaceStore, selectWorkspace, type WorkspaceState } from '../useTerminalWorkspaceStore'; const reset = () => useTerminalWorkspaceStore.setState({ workspaces: {} }); +function allPanes(workspace: WorkspaceState | undefined) { + return workspace?.columns.flatMap((column) => column.panes) ?? []; +} + describe('useTerminalWorkspaceStore', () => { beforeEach(() => { reset(); @@ -32,7 +36,7 @@ describe('useTerminalWorkspaceStore', () => { expect({ given: 'ensureWorkspace is called for a new terminalId', should: 'create a single-pane workspace', - actual: workspace?.panes.length, + actual: allPanes(workspace).length, expected: 1, }).toEqual({ given: 'ensureWorkspace is called for a new terminalId', @@ -93,7 +97,7 @@ describe('useTerminalWorkspaceStore', () => { expect({ given: 'two ensured workspaces', should: 'keep them independent', - actual: { w1Scope: workspace1?.panes[0].scope, w2Scope: workspace2?.panes[0].scope }, + actual: { w1Scope: allPanes(workspace1)[0].scope, w2Scope: allPanes(workspace2)[0].scope }, expected: { w1Scope: { name: 'only-in-1' }, w2Scope: null }, }).toEqual({ given: 'two ensured workspaces', @@ -112,7 +116,7 @@ describe('useTerminalWorkspaceStore', () => { expect({ given: 'openTerminal', should: 'write the scope into the active pane', - actual: workspace?.panes.find((p) => p.id === workspace.activePaneId)?.scope, + actual: allPanes(workspace).find((p) => p.id === workspace?.activePaneId)?.scope, expected: { name: 'my-terminal' }, }).toEqual({ given: 'openTerminal', @@ -122,22 +126,43 @@ describe('useTerminalWorkspaceStore', () => { }); }); - it('given split, should add a second pane and activate it', () => { + it('given splitRight, should add a second column and activate its pane', () => { useTerminalWorkspaceStore.getState().ensureWorkspace('terminal-1'); - useTerminalWorkspaceStore.getState().split('terminal-1'); + const before = selectWorkspace('terminal-1')(useTerminalWorkspaceStore.getState()); + useTerminalWorkspaceStore.getState().splitRight('terminal-1', before!.activePaneId); const workspace = selectWorkspace('terminal-1')(useTerminalWorkspaceStore.getState()); expect({ - given: 'split', - should: 'add a second pane and activate it', - actual: { paneCount: workspace?.panes.length, activeIsNewPane: workspace?.activePaneId !== workspace?.panes[0].id }, - expected: { paneCount: 2, activeIsNewPane: true }, + given: 'splitRight', + should: 'add a second column and activate its pane', + actual: { columnCount: workspace?.columns.length, activeIsNewPane: workspace?.activePaneId !== before?.activePaneId }, + expected: { columnCount: 2, activeIsNewPane: true }, }).toEqual({ - given: 'split', - should: 'add a second pane and activate it', - actual: { paneCount: 2, activeIsNewPane: true }, - expected: { paneCount: 2, activeIsNewPane: true }, + given: 'splitRight', + should: 'add a second column and activate its pane', + actual: { columnCount: 2, activeIsNewPane: true }, + expected: { columnCount: 2, activeIsNewPane: true }, + }); + }); + + it('given splitDown, should stack a new pane in the same column', () => { + useTerminalWorkspaceStore.getState().ensureWorkspace('terminal-1'); + const before = selectWorkspace('terminal-1')(useTerminalWorkspaceStore.getState()); + useTerminalWorkspaceStore.getState().splitDown('terminal-1', before!.activePaneId); + + const workspace = selectWorkspace('terminal-1')(useTerminalWorkspaceStore.getState()); + + expect({ + given: 'splitDown', + should: 'stack a new pane in the same column', + actual: { columnCount: workspace?.columns.length, paneCountInColumn: workspace?.columns[0]?.panes.length }, + expected: { columnCount: 1, paneCountInColumn: 2 }, + }).toEqual({ + given: 'splitDown', + should: 'stack a new pane in the same column', + actual: { columnCount: 1, paneCountInColumn: 2 }, + expected: { columnCount: 1, paneCountInColumn: 2 }, }); }); @@ -145,7 +170,7 @@ describe('useTerminalWorkspaceStore', () => { useTerminalWorkspaceStore.getState().ensureWorkspace('terminal-1'); const before = selectWorkspace('terminal-1')(useTerminalWorkspaceStore.getState()); - useTerminalWorkspaceStore.getState().closePane('terminal-1', before!.panes[0].id); + useTerminalWorkspaceStore.getState().closePane('terminal-1', allPanes(before)[0].id); const after = selectWorkspace('terminal-1')(useTerminalWorkspaceStore.getState()); expect({ @@ -163,10 +188,11 @@ describe('useTerminalWorkspaceStore', () => { it('given selectPane, should activate the chosen pane', () => { useTerminalWorkspaceStore.getState().ensureWorkspace('terminal-1'); - useTerminalWorkspaceStore.getState().split('terminal-1'); + const before = selectWorkspace('terminal-1')(useTerminalWorkspaceStore.getState()); + useTerminalWorkspaceStore.getState().splitRight('terminal-1', before!.activePaneId); const workspace = selectWorkspace('terminal-1')(useTerminalWorkspaceStore.getState()); - const firstPaneId = workspace!.panes[0].id; + const firstPaneId = allPanes(workspace)[0].id; useTerminalWorkspaceStore.getState().selectPane('terminal-1', firstPaneId); const after = selectWorkspace('terminal-1')(useTerminalWorkspaceStore.getState()); @@ -186,7 +212,8 @@ describe('useTerminalWorkspaceStore', () => { it('given actions on a terminalId that was never ensured, should be a no-op', () => { useTerminalWorkspaceStore.getState().openTerminal('never-ensured', { name: 'ghost' }); - useTerminalWorkspaceStore.getState().split('never-ensured'); + useTerminalWorkspaceStore.getState().splitRight('never-ensured', 'anything'); + useTerminalWorkspaceStore.getState().splitDown('never-ensured', 'anything'); useTerminalWorkspaceStore.getState().closePane('never-ensured', 'anything'); useTerminalWorkspaceStore.getState().selectPane('never-ensured', 'anything'); diff --git a/apps/web/src/stores/terminal-workspace/__tests__/workspace-reducer.test.ts b/apps/web/src/stores/terminal-workspace/__tests__/workspace-reducer.test.ts index 6cba3e0e4e..505ea7a2ec 100644 --- a/apps/web/src/stores/terminal-workspace/__tests__/workspace-reducer.test.ts +++ b/apps/web/src/stores/terminal-workspace/__tests__/workspace-reducer.test.ts @@ -2,28 +2,29 @@ import { describe, it, expect } from 'vitest'; import { initialWorkspace, openTerminal, - split, + splitRight, + splitDown, closePane, selectPane, type WorkspaceState, } from '../workspace-reducer'; describe('initialWorkspace', () => { - it('given a first pane id, should create a single active pane with no scope', () => { + it('given a first pane id, should create a single column with a single active pane and no scope', () => { const actual = initialWorkspace('pane-1'); const expected: WorkspaceState = { - panes: [{ id: 'pane-1', scope: null }], + columns: [{ id: 'pane-1', panes: [{ id: 'pane-1', scope: null }] }], activePaneId: 'pane-1', }; expect({ given: 'a first pane id', - should: 'create a single active pane with no scope', + should: 'create a single column with a single active pane and no scope', actual, expected, }).toEqual({ given: 'a first pane id', - should: 'create a single active pane with no scope', + should: 'create a single column with a single active pane and no scope', actual: expected, expected, }); @@ -31,11 +32,11 @@ describe('initialWorkspace', () => { }); describe('openTerminal', () => { - it('given two panes, should write the scope into only the active pane', () => { + it('given two panes across columns, should write the scope into only the active pane', () => { const state: WorkspaceState = { - panes: [ - { id: 'pane-1', scope: null }, - { id: 'pane-2', scope: null }, + columns: [ + { id: 'col-1', panes: [{ id: 'pane-1', scope: null }] }, + { id: 'col-2', panes: [{ id: 'pane-2', scope: null }] }, ], activePaneId: 'pane-2', }; @@ -43,20 +44,20 @@ describe('openTerminal', () => { const actual = openTerminal(state, scope); const expected: WorkspaceState = { - panes: [ - { id: 'pane-1', scope: null }, - { id: 'pane-2', scope }, + columns: [ + { id: 'col-1', panes: [{ id: 'pane-1', scope: null }] }, + { id: 'col-2', panes: [{ id: 'pane-2', scope }] }, ], activePaneId: 'pane-2', }; expect({ - given: 'two panes', + given: 'two panes across columns', should: 'write the scope into only the active pane', actual, expected, }).toEqual({ - given: 'two panes', + given: 'two panes across columns', should: 'write the scope into only the active pane', actual: expected, expected, @@ -83,51 +84,150 @@ describe('openTerminal', () => { }); }); -describe('split', () => { - it('given a single pane, should append a new empty pane and activate it', () => { +describe('splitRight', () => { + it('given a single column, should insert a new column after it and activate its pane', () => { const state = initialWorkspace('pane-1'); - const actual = split(state, 'pane-2'); + const actual = splitRight(state, 'pane-1', 'col-2', 'pane-2'); const expected: WorkspaceState = { - panes: [ - { id: 'pane-1', scope: null }, - { id: 'pane-2', scope: null }, + columns: [ + { id: 'pane-1', panes: [{ id: 'pane-1', scope: null }] }, + { id: 'col-2', panes: [{ id: 'pane-2', scope: null }] }, ], activePaneId: 'pane-2', }; expect({ - given: 'a single pane', - should: 'append a new empty pane and activate it', + given: 'a single column', + should: 'insert a new column after it and activate its pane', actual, expected, }).toEqual({ - given: 'a single pane', - should: 'append a new empty pane and activate it', + given: 'a single column', + should: 'insert a new column after it and activate its pane', actual: expected, expected, }); }); - it('given a pane with a scope, splitting should leave the existing pane untouched', () => { + it('given a pane with a scope, splitting right should leave the existing pane untouched', () => { const scope = { name: 'my-terminal' }; const state: WorkspaceState = { - panes: [{ id: 'pane-1', scope }], + columns: [{ id: 'col-1', panes: [{ id: 'pane-1', scope }] }], activePaneId: 'pane-1', }; - const result = split(state, 'pane-2'); + const result = splitRight(state, 'pane-1', 'col-2', 'pane-2'); expect({ given: 'a pane with a scope', should: 'leave the existing pane untouched', - actual: result.panes[0], - expected: { id: 'pane-1', scope }, + actual: result.columns[0], + expected: { id: 'col-1', panes: [{ id: 'pane-1', scope }] }, }).toEqual({ given: 'a pane with a scope', should: 'leave the existing pane untouched', - actual: { id: 'pane-1', scope }, - expected: { id: 'pane-1', scope }, + actual: { id: 'col-1', panes: [{ id: 'pane-1', scope }] }, + expected: { id: 'col-1', panes: [{ id: 'pane-1', scope }] }, + }); + }); + + it('given splitting the middle column of three, should insert immediately after it', () => { + const state: WorkspaceState = { + columns: [ + { id: 'col-1', panes: [{ id: 'pane-1', scope: null }] }, + { id: 'col-2', panes: [{ id: 'pane-2', scope: null }] }, + { id: 'col-3', panes: [{ id: 'pane-3', scope: null }] }, + ], + activePaneId: 'pane-1', + }; + + const actual = splitRight(state, 'pane-2', 'col-new', 'pane-new'); + + expect({ + given: 'splitting the middle column of three', + should: 'insert immediately after it', + actual: actual.columns.map((c) => c.id), + expected: ['col-1', 'col-2', 'col-new', 'col-3'], + }).toEqual({ + given: 'splitting the middle column of three', + should: 'insert immediately after it', + actual: ['col-1', 'col-2', 'col-new', 'col-3'], + expected: ['col-1', 'col-2', 'col-new', 'col-3'], + }); + }); +}); + +describe('splitDown', () => { + it('given a single-pane column, should stack a new pane within that same column', () => { + const state = initialWorkspace('pane-1'); + + const actual = splitDown(state, 'pane-1', 'pane-2'); + const expected: WorkspaceState = { + columns: [ + { + id: 'pane-1', + panes: [ + { id: 'pane-1', scope: null }, + { id: 'pane-2', scope: null }, + ], + }, + ], + activePaneId: 'pane-2', + }; + + expect({ + given: 'a single-pane column', + should: 'stack a new pane within that same column', + actual, + expected, + }).toEqual({ + given: 'a single-pane column', + should: 'stack a new pane within that same column', + actual: expected, + expected, + }); + }); + + it('given a pane whose id does not exist, should be a no-op', () => { + const state = initialWorkspace('pane-1'); + + const actual = splitDown(state, 'does-not-exist', 'pane-2'); + + expect({ + given: 'a pane whose id does not exist', + should: 'be a no-op', + actual, + expected: state, + }).toEqual({ + given: 'a pane whose id does not exist', + should: 'be a no-op', + actual: state, + expected: state, + }); + }); + + it('given splitting down in one column of two, should leave the other column untouched', () => { + const state: WorkspaceState = { + columns: [ + { id: 'col-1', panes: [{ id: 'pane-1', scope: null }] }, + { id: 'col-2', panes: [{ id: 'pane-2', scope: null }] }, + ], + activePaneId: 'pane-1', + }; + + const actual = splitDown(state, 'pane-1', 'pane-1b'); + + expect({ + given: 'splitting down in one column of two', + should: 'leave the other column untouched', + actual: actual.columns[1], + expected: { id: 'col-2', panes: [{ id: 'pane-2', scope: null }] }, + }).toEqual({ + given: 'splitting down in one column of two', + should: 'leave the other column untouched', + actual: { id: 'col-2', panes: [{ id: 'pane-2', scope: null }] }, + expected: { id: 'col-2', panes: [{ id: 'pane-2', scope: null }] }, }); }); }); @@ -151,21 +251,82 @@ describe('closePane', () => { }); }); + it('given closing the last pane in a column, should remove the column', () => { + const state: WorkspaceState = { + columns: [ + { id: 'col-1', panes: [{ id: 'pane-1', scope: null }] }, + { id: 'col-2', panes: [{ id: 'pane-2', scope: null }] }, + ], + activePaneId: 'pane-1', + }; + + const actual = closePane(state, 'pane-2'); + const expected: WorkspaceState = { + columns: [{ id: 'col-1', panes: [{ id: 'pane-1', scope: null }] }], + activePaneId: 'pane-1', + }; + + expect({ + given: 'closing the last pane in a column', + should: 'remove the column', + actual, + expected, + }).toEqual({ + given: 'closing the last pane in a column', + should: 'remove the column', + actual: expected, + expected, + }); + }); + + it('given closing one of two stacked panes in a column, should keep the column with the remaining pane', () => { + const state: WorkspaceState = { + columns: [ + { + id: 'col-1', + panes: [ + { id: 'pane-1', scope: null }, + { id: 'pane-2', scope: null }, + ], + }, + ], + activePaneId: 'pane-1', + }; + + const actual = closePane(state, 'pane-2'); + const expected: WorkspaceState = { + columns: [{ id: 'col-1', panes: [{ id: 'pane-1', scope: null }] }], + activePaneId: 'pane-1', + }; + + expect({ + given: 'closing one of two stacked panes in a column', + should: 'keep the column with the remaining pane', + actual, + expected, + }).toEqual({ + given: 'closing one of two stacked panes in a column', + should: 'keep the column with the remaining pane', + actual: expected, + expected, + }); + }); + it('given the active pane is closed, should re-target active to the first remaining pane', () => { const state: WorkspaceState = { - panes: [ - { id: 'pane-1', scope: null }, - { id: 'pane-2', scope: null }, - { id: 'pane-3', scope: null }, + columns: [ + { id: 'col-1', panes: [{ id: 'pane-1', scope: null }] }, + { id: 'col-2', panes: [{ id: 'pane-2', scope: null }] }, + { id: 'col-3', panes: [{ id: 'pane-3', scope: null }] }, ], activePaneId: 'pane-2', }; const actual = closePane(state, 'pane-2'); const expected: WorkspaceState = { - panes: [ - { id: 'pane-1', scope: null }, - { id: 'pane-3', scope: null }, + columns: [ + { id: 'col-1', panes: [{ id: 'pane-1', scope: null }] }, + { id: 'col-3', panes: [{ id: 'pane-3', scope: null }] }, ], activePaneId: 'pane-1', }; @@ -185,16 +346,16 @@ describe('closePane', () => { it('given a non-active pane is closed, should leave the active pane untouched', () => { const state: WorkspaceState = { - panes: [ - { id: 'pane-1', scope: null }, - { id: 'pane-2', scope: null }, + columns: [ + { id: 'col-1', panes: [{ id: 'pane-1', scope: null }] }, + { id: 'col-2', panes: [{ id: 'pane-2', scope: null }] }, ], activePaneId: 'pane-2', }; const actual = closePane(state, 'pane-1'); const expected: WorkspaceState = { - panes: [{ id: 'pane-2', scope: null }], + columns: [{ id: 'col-2', panes: [{ id: 'pane-2', scope: null }] }], activePaneId: 'pane-2', }; @@ -210,14 +371,35 @@ describe('closePane', () => { expected, }); }); + + it('given closing the only pane in the only column, should no-op like today', () => { + const state: WorkspaceState = { + columns: [{ id: 'col-1', panes: [{ id: 'pane-1', scope: { name: 'my-terminal' } }] }], + activePaneId: 'pane-1', + }; + + const actual = closePane(state, 'pane-1'); + + expect({ + given: 'closing the only pane in the only column', + should: 'no-op like today', + actual, + expected: state, + }).toEqual({ + given: 'closing the only pane in the only column', + should: 'no-op like today', + actual: state, + expected: state, + }); + }); }); describe('selectPane', () => { it('given a valid pane id, should activate it', () => { const state: WorkspaceState = { - panes: [ - { id: 'pane-1', scope: null }, - { id: 'pane-2', scope: null }, + columns: [ + { id: 'col-1', panes: [{ id: 'pane-1', scope: null }] }, + { id: 'col-2', panes: [{ id: 'pane-2', scope: null }] }, ], activePaneId: 'pane-1', }; diff --git a/apps/web/src/stores/terminal-workspace/useTerminalWorkspaceStore.ts b/apps/web/src/stores/terminal-workspace/useTerminalWorkspaceStore.ts index 636d81a9e9..79976b5cba 100644 --- a/apps/web/src/stores/terminal-workspace/useTerminalWorkspaceStore.ts +++ b/apps/web/src/stores/terminal-workspace/useTerminalWorkspaceStore.ts @@ -9,7 +9,7 @@ * * This is the single source of truth shared, by composition, between the * Navigator (right sidebar) and TerminalPanes (middle content) — they no - * longer need a common parent to share `panes`/`activePaneId` local state. + * longer need a common parent to share `columns`/`activePaneId` local state. * * Navigation cleanup (dispose on unmount) is performed by the mounting * component via `useEffect`, not from inside the store — same pattern as @@ -20,22 +20,25 @@ import { create } from 'zustand'; import { initialWorkspace, openTerminal as openTerminalTransition, - split as splitTransition, + splitRight as splitRightTransition, + splitDown as splitDownTransition, closePane as closePaneTransition, selectPane as selectPaneTransition, type OpenTerminalScope, + type TerminalColumnState, type TerminalPaneState, type WorkspaceState, } from './workspace-reducer'; -export type { OpenTerminalScope, TerminalPaneState, WorkspaceState }; +export type { OpenTerminalScope, TerminalColumnState, TerminalPaneState, WorkspaceState }; interface TerminalWorkspaceStoreState { workspaces: Record; ensureWorkspace: (terminalId: string) => void; disposeWorkspace: (terminalId: string) => void; openTerminal: (terminalId: string, scope: OpenTerminalScope) => void; - split: (terminalId: string) => void; + splitRight: (terminalId: string, fromPaneId: string) => void; + splitDown: (terminalId: string, fromPaneId: string) => void; closePane: (terminalId: string, paneId: string) => void; selectPane: (terminalId: string, paneId: string) => void; } @@ -81,9 +84,17 @@ export const useTerminalWorkspaceStore = create((se set((state) => applyTransition(state, terminalId, (workspace) => openTerminalTransition(workspace, scope))); }, - split: (terminalId) => { - const newId = crypto.randomUUID(); - set((state) => applyTransition(state, terminalId, (workspace) => splitTransition(workspace, newId))); + splitRight: (terminalId, fromPaneId) => { + const newColumnId = crypto.randomUUID(); + const newPaneId = crypto.randomUUID(); + set((state) => + applyTransition(state, terminalId, (workspace) => splitRightTransition(workspace, fromPaneId, newColumnId, newPaneId)) + ); + }, + + splitDown: (terminalId, fromPaneId) => { + const newPaneId = crypto.randomUUID(); + set((state) => applyTransition(state, terminalId, (workspace) => splitDownTransition(workspace, fromPaneId, newPaneId))); }, closePane: (terminalId, paneId) => { diff --git a/apps/web/src/stores/terminal-workspace/workspace-reducer.ts b/apps/web/src/stores/terminal-workspace/workspace-reducer.ts index 71d283019f..c685fd4fbc 100644 --- a/apps/web/src/stores/terminal-workspace/workspace-reducer.ts +++ b/apps/web/src/stores/terminal-workspace/workspace-reducer.ts @@ -5,6 +5,10 @@ * the Terminal page's Navigator (right sidebar) and TerminalPanes (middle * content). IDs are passed in rather than generated here, so every * transition is deterministic and independently testable. + * + * Layout is a two-level structure — a horizontal row of columns, each an + * independent vertical stack of panes — deliberately not a full recursive + * split tree. splitRight adds a column; splitDown stacks within one. */ /** Identifies which terminal to open in a pane — neither `projectName` nor @@ -21,50 +25,103 @@ export interface TerminalPaneState { scope: OpenTerminalScope | null; } -export interface WorkspaceState { +export interface TerminalColumnState { + id: string; panes: TerminalPaneState[]; +} + +export interface WorkspaceState { + columns: TerminalColumnState[]; activePaneId: string; } export function initialWorkspace(firstId: string): WorkspaceState { return { - panes: [{ id: firstId, scope: null }], + columns: [{ id: firstId, panes: [{ id: firstId, scope: null }] }], activePaneId: firstId, }; } +function findPaneLocation( + state: WorkspaceState, + paneId: string +): { columnIndex: number; paneIndex: number } | null { + for (let columnIndex = 0; columnIndex < state.columns.length; columnIndex++) { + const paneIndex = state.columns[columnIndex].panes.findIndex((pane) => pane.id === paneId); + if (paneIndex !== -1) return { columnIndex, paneIndex }; + } + return null; +} + /** Opens a terminal into the ACTIVE pane — an explicit target rather than * guessing from array position, which would silently overwrite the wrong * pane whenever a second pane existed. */ export function openTerminal(state: WorkspaceState, scope: OpenTerminalScope): WorkspaceState { return { ...state, - panes: state.panes.map((pane) => - pane.id === state.activePaneId ? { ...pane, scope } : pane - ), + columns: state.columns.map((column) => ({ + ...column, + panes: column.panes.map((pane) => (pane.id === state.activePaneId ? { ...pane, scope } : pane)), + })), }; } -export function split(state: WorkspaceState, newId: string): WorkspaceState { - return { - panes: [...state.panes, { id: newId, scope: null }], - activePaneId: newId, - }; +/** Splits `fromPaneId` rightward — a new column, with one new pane, inserted + * immediately after `fromPaneId`'s column. */ +export function splitRight( + state: WorkspaceState, + fromPaneId: string, + newColumnId: string, + newPaneId: string +): WorkspaceState { + const location = findPaneLocation(state, fromPaneId); + const insertAt = location ? location.columnIndex + 1 : state.columns.length; + + const columns = [...state.columns]; + columns.splice(insertAt, 0, { id: newColumnId, panes: [{ id: newPaneId, scope: null }] }); + + return { columns, activePaneId: newPaneId }; } -/** Removing the last remaining pane is a no-op — a workspace never has zero - * panes. Removing the active pane re-targets active to the first remaining - * pane. */ +/** Splits `fromPaneId` downward — a new pane appended to `fromPaneId`'s + * existing column. */ +export function splitDown(state: WorkspaceState, fromPaneId: string, newPaneId: string): WorkspaceState { + const location = findPaneLocation(state, fromPaneId); + if (!location) return state; + + const columns = state.columns.map((column, columnIndex) => + columnIndex === location.columnIndex + ? { ...column, panes: [...column.panes, { id: newPaneId, scope: null }] } + : column + ); + + return { columns, activePaneId: newPaneId }; +} + +/** Removing the very last remaining pane is a no-op — a workspace never has + * zero panes. Closing the last pane in a column removes the column too; + * closing the active pane re-targets active to the first remaining pane. */ export function closePane(state: WorkspaceState, id: string): WorkspaceState { - if (state.panes.length <= 1) return state; + const location = findPaneLocation(state, id); + if (!location) return state; + + const totalPanes = state.columns.reduce((sum, column) => sum + column.panes.length, 0); + if (totalPanes <= 1) return state; + + const columns = state.columns + .map((column, columnIndex) => + columnIndex === location.columnIndex + ? { ...column, panes: column.panes.filter((pane) => pane.id !== id) } + : column + ) + .filter((column) => column.panes.length > 0); - const panes = state.panes.filter((pane) => pane.id !== id); - const activePaneId = state.activePaneId === id ? panes[0].id : state.activePaneId; + const activePaneId = state.activePaneId === id ? columns[0].panes[0].id : state.activePaneId; - return { panes, activePaneId }; + return { columns, activePaneId }; } export function selectPane(state: WorkspaceState, id: string): WorkspaceState { - if (!state.panes.some((pane) => pane.id === id)) return state; + if (!findPaneLocation(state, id)) return state; return { ...state, activePaneId: id }; } From 7b912b63f5dcca576e58cc1f388ee33359e5e43d Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Thu, 9 Jul 2026 14:23:10 -0500 Subject: [PATCH 2/5] fix(terminal): address self-review findings on split axis and handle geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proactive high-effort self-review (no external reviewer available yet — CodeRabbit hit its rate limit) surfaced 3 confirmed defects, all fixed: - resizable.tsx: the handle's accent-line div was hardcoded to vertical geometry (absolute + translateX centering) regardless of orientation. This PR's splitDown is the first orientation="vertical" panel group in the app, and the new chrome-free variant makes the line permanently visible — so the bug, latent before, now renders a floating vertical dash instead of a horizontal seam between stacked panes. Fixed by sizing the line (h-full w-px vs h-px w-full via an aria-orientation-aware variant) and letting the parent's existing flex centering position it, instead of fighting it with absolute/translate. - workspace-reducer.ts: splitRight silently appended an orphan column when fromPaneId didn't resolve, unlike every sibling transition (splitDown/closePane/selectPane), which no-op on an unresolved pane id. Now consistent; added a matching no-op test. - useXtermTheme.ts / useMonacoTheme.ts: the light/dark fallback swatches (background/foreground/primary hex) were duplicated verbatim between both hooks. Extracted to THEME_FALLBACK in css-color-resolution.ts so Monaco and xterm can't silently drift apart on a future rebrand. Also documented (not reverted) the header-removal tradeoff flagged by the review: TerminalPanes drops the per-pane header per explicit, PurePoint-verified spec ("never shown, in any state"), but the Navigator sidebar doesn't yet show which open terminal maps to which visible pane with 2+ panes open. That's Navigator/sidebar work, out of scope for this theming-foundation round — added a comment recording it as a known, intentional gap rather than an oversight. typecheck/build green across all 16 workspace packages; 518 apps/web tests passing (29 in terminal-workspace, +1 new no-op case). --- .../terminal/workspace/TerminalPanes.tsx | 9 ++++++--- apps/web/src/components/ui/resizable.tsx | 9 +++++++-- apps/web/src/hooks/useMonacoTheme.ts | 10 +++------- apps/web/src/hooks/useXtermTheme.ts | 9 +++------ apps/web/src/lib/theme/css-color-resolution.ts | 11 +++++++++++ .../__tests__/workspace-reducer.test.ts | 18 ++++++++++++++++++ .../terminal-workspace/workspace-reducer.ts | 8 +++++--- 7 files changed, 53 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/layout/middle-content/page-views/terminal/workspace/TerminalPanes.tsx b/apps/web/src/components/layout/middle-content/page-views/terminal/workspace/TerminalPanes.tsx index d3dcfb4a41..fb81d92c46 100644 --- a/apps/web/src/components/layout/middle-content/page-views/terminal/workspace/TerminalPanes.tsx +++ b/apps/web/src/components/layout/middle-content/page-views/terminal/workspace/TerminalPanes.tsx @@ -72,9 +72,12 @@ export default function TerminalPanes({ terminalId, socket }: TerminalPanesProps } /** - * Chrome-free by design (no per-pane header, ever) — session identity lives - * in the Navigator sidebar instead. A top accent bar shows focus and - * hover-revealed controls handle splitting/closing without permanent chrome. + * Chrome-free by design (no per-pane header, ever, verified against + * PurePoint's real chrome-free pane design) — a top accent bar shows focus + * and hover-revealed controls handle splitting/closing without permanent + * chrome. Known tradeoff: with 2+ panes open the Navigator sidebar does not + * yet indicate which open terminal is showing in which pane — that's + * sidebar/Navigator work, out of scope for this theming-foundation round. */ function TerminalPane({ socket, diff --git a/apps/web/src/components/ui/resizable.tsx b/apps/web/src/components/ui/resizable.tsx index cda07d69a4..8835d5303c 100644 --- a/apps/web/src/components/ui/resizable.tsx +++ b/apps/web/src/components/ui/resizable.tsx @@ -45,10 +45,15 @@ function ResizableHandle({ {/* chrome-free panes (e.g. Terminal) strip every other seam cue, so their handle must stay faintly visible at rest — everywhere else the resting opacity-0 default is correct because sibling - borders/cards already show the seam. */} + borders/cards already show the seam. + Sized (not absolute-positioned) so the parent's flex centering + handles both orientations — a fixed absolute/translate line was + hardcoded vertical and broke for orientation="vertical" groups. */}
diff --git a/apps/web/src/hooks/useMonacoTheme.ts b/apps/web/src/hooks/useMonacoTheme.ts index 35e5de921c..df31172f34 100644 --- a/apps/web/src/hooks/useMonacoTheme.ts +++ b/apps/web/src/hooks/useMonacoTheme.ts @@ -3,7 +3,7 @@ import { useTheme } from 'next-themes'; import { useEffect, useState } from 'react'; import type { Monaco } from '@monaco-editor/react'; -import { getCssVar, resolveColor, withAlpha } from '@/lib/theme/css-color-resolution'; +import { getCssVar, resolveColor, withAlpha, THEME_FALLBACK } from '@/lib/theme/css-color-resolution'; export function useMonacoTheme(monaco: Monaco | null): string { const { resolvedTheme } = useTheme(); @@ -20,22 +20,18 @@ export function useMonacoTheme(monaco: Monaco | null): string { const fallbackPalette = isDark ? { - background: '#222222', - foreground: '#f0f0f0', + ...THEME_FALLBACK.dark, border: '#373737', muted: '#353535', mutedForeground: '#737373', - primary: '#5b8cff', card: '#2b2b2b', input: '#373737', } : { - background: '#ffffff', - foreground: '#1f1f1f', + ...THEME_FALLBACK.light, border: '#d9d9d9', muted: '#f3f3f3', mutedForeground: '#7a7a7a', - primary: '#3f6dff', card: '#ffffff', input: '#f8f8f8', }; diff --git a/apps/web/src/hooks/useXtermTheme.ts b/apps/web/src/hooks/useXtermTheme.ts index 91ea821431..c1fe7a8ccf 100644 --- a/apps/web/src/hooks/useXtermTheme.ts +++ b/apps/web/src/hooks/useXtermTheme.ts @@ -3,7 +3,7 @@ import { useTheme } from 'next-themes'; import { useEffect, useState } from 'react'; import type { ITheme } from '@xterm/xterm'; -import { getCssVar, resolveColor, withAlpha } from '@/lib/theme/css-color-resolution'; +import { getCssVar, resolveColor, withAlpha, THEME_FALLBACK } from '@/lib/theme/css-color-resolution'; /** * Curated, low-contrast ANSI palettes — no ANSI-color design tokens exist in @@ -48,11 +48,8 @@ const MUTED_ANSI_LIGHT = { brightWhite: '#3a3a3a', } as const; -const FALLBACK_DARK = { background: '#222222', foreground: '#f0f0f0', primary: '#5b8cff' }; -const FALLBACK_LIGHT = { background: '#ffffff', foreground: '#1f1f1f', primary: '#3f6dff' }; - function buildFallbackTheme(isDark: boolean): ITheme { - const fallback = isDark ? FALLBACK_DARK : FALLBACK_LIGHT; + const fallback = isDark ? THEME_FALLBACK.dark : THEME_FALLBACK.light; const ansi = isDark ? MUTED_ANSI_DARK : MUTED_ANSI_LIGHT; return { background: fallback.background, @@ -65,7 +62,7 @@ function buildFallbackTheme(isDark: boolean): ITheme { } function resolveTheme(isDark: boolean): ITheme { - const fallback = isDark ? FALLBACK_DARK : FALLBACK_LIGHT; + const fallback = isDark ? THEME_FALLBACK.dark : THEME_FALLBACK.light; const ansi = isDark ? MUTED_ANSI_DARK : MUTED_ANSI_LIGHT; const background = resolveColor(getCssVar('--background'), fallback.background); diff --git a/apps/web/src/lib/theme/css-color-resolution.ts b/apps/web/src/lib/theme/css-color-resolution.ts index 5d4bf42ba6..28c5b86cc2 100644 --- a/apps/web/src/lib/theme/css-color-resolution.ts +++ b/apps/web/src/lib/theme/css-color-resolution.ts @@ -9,6 +9,17 @@ * parse themselves (oklch, color-mix, named colors, etc.). */ +/** + * Shared light/dark fallback swatches — used when a CSS custom property is + * missing or fails to resolve (e.g. before the stylesheet loads). Kept in + * one place so Monaco and xterm's themes can't silently drift apart on a + * future rebrand. + */ +export const THEME_FALLBACK = { + dark: { background: '#222222', foreground: '#f0f0f0', primary: '#5b8cff' }, + light: { background: '#ffffff', foreground: '#1f1f1f', primary: '#3f6dff' }, +} as const; + let colorResolveContext: CanvasRenderingContext2D | null | undefined; export function getCssVar(name: string): string { diff --git a/apps/web/src/stores/terminal-workspace/__tests__/workspace-reducer.test.ts b/apps/web/src/stores/terminal-workspace/__tests__/workspace-reducer.test.ts index 505ea7a2ec..fe348cd516 100644 --- a/apps/web/src/stores/terminal-workspace/__tests__/workspace-reducer.test.ts +++ b/apps/web/src/stores/terminal-workspace/__tests__/workspace-reducer.test.ts @@ -132,6 +132,24 @@ describe('splitRight', () => { }); }); + it('given a pane whose id does not exist, should be a no-op', () => { + const state = initialWorkspace('pane-1'); + + const actual = splitRight(state, 'does-not-exist', 'col-2', 'pane-2'); + + expect({ + given: 'a pane whose id does not exist', + should: 'be a no-op', + actual, + expected: state, + }).toEqual({ + given: 'a pane whose id does not exist', + should: 'be a no-op', + actual: state, + expected: state, + }); + }); + it('given splitting the middle column of three, should insert immediately after it', () => { const state: WorkspaceState = { columns: [ diff --git a/apps/web/src/stores/terminal-workspace/workspace-reducer.ts b/apps/web/src/stores/terminal-workspace/workspace-reducer.ts index c685fd4fbc..91e6bdbe85 100644 --- a/apps/web/src/stores/terminal-workspace/workspace-reducer.ts +++ b/apps/web/src/stores/terminal-workspace/workspace-reducer.ts @@ -67,7 +67,9 @@ export function openTerminal(state: WorkspaceState, scope: OpenTerminalScope): W } /** Splits `fromPaneId` rightward — a new column, with one new pane, inserted - * immediately after `fromPaneId`'s column. */ + * immediately after `fromPaneId`'s column. A `fromPaneId` that no longer + * resolves (e.g. a stale click racing a close) is a no-op, same as every + * other transition here. */ export function splitRight( state: WorkspaceState, fromPaneId: string, @@ -75,10 +77,10 @@ export function splitRight( newPaneId: string ): WorkspaceState { const location = findPaneLocation(state, fromPaneId); - const insertAt = location ? location.columnIndex + 1 : state.columns.length; + if (!location) return state; const columns = [...state.columns]; - columns.splice(insertAt, 0, { id: newColumnId, panes: [{ id: newPaneId, scope: null }] }); + columns.splice(location.columnIndex + 1, 0, { id: newColumnId, panes: [{ id: newPaneId, scope: null }] }); return { columns, activePaneId: newPaneId }; } From 60d01422e05eb2b128f2f321555b5e25a1658ee4 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Thu, 9 Jul 2026 14:24:25 -0500 Subject: [PATCH 3/5] fix(terminal): make hover pane controls visible on keyboard focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Codex review feedback on PR #1965: the Split Right/Split Down/Close toolbar was opacity-0 until group-hover, but the buttons stayed in the tab order — keyboard users tabbing through would land on invisible controls with no visual focus indicator. Add group-focus-within/pane:opacity-100 alongside the existing group-hover/pane:opacity-100, matching the same pattern already used in sidebar.tsx, MessageHoverToolbar.tsx, and NotificationItem.tsx. --- .../page-views/terminal/workspace/TerminalPanes.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/layout/middle-content/page-views/terminal/workspace/TerminalPanes.tsx b/apps/web/src/components/layout/middle-content/page-views/terminal/workspace/TerminalPanes.tsx index fb81d92c46..7f5abf09fd 100644 --- a/apps/web/src/components/layout/middle-content/page-views/terminal/workspace/TerminalPanes.tsx +++ b/apps/web/src/components/layout/middle-content/page-views/terminal/workspace/TerminalPanes.tsx @@ -105,7 +105,7 @@ function TerminalPane({ return (
-
+