diff --git a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx index a3201762d8..c19582c7af 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx @@ -10,7 +10,7 @@ import { MissionControl } from './mission-control'; import { EmptyState } from './empty-state'; import { useCanvasStore } from './stores'; import { useTabLifecycle, useKeyboardShortcuts, usePanelTabCoordinator } from './hooks'; -import type { AnchorPosition } from './types'; +import type { AnchorPosition, EditorGroupState, Grid9Slot } from './types'; import { TAB_EVENTS } from './types'; import { selectActiveBtwSessionTab } from '@/flow_chat/services/btwSessionPane'; import { openMainSession } from '@/flow_chat/services/sessionActivation'; @@ -41,6 +41,8 @@ export interface ContentCanvasProps { onCollapsePanel?: () => void; /** Suspend terminal fit/PTY resize while the hosting panel is animating. */ terminalResizeSuspended?: boolean; + /** Optional grid9 slot info threaded to the TabBar (primary group only). */ + grid9Slot?: Grid9Slot; } export const ContentCanvas: React.FC = ({ @@ -55,6 +57,7 @@ export const ContentCanvas: React.FC = ({ onExpandPanel, onCollapsePanel, terminalResizeSuspended = false, + grid9Slot, }) => { // Store state — fine-grained selectors so unrelated store changes // (drag state, closed-tab history, ...) do not re-render the whole canvas. @@ -112,13 +115,23 @@ export const ContentCanvas: React.FC = ({ }, [activeBtwSessionData?.parentSessionId, activeBtwSessionData?.workspacePath, activeBtwSessionTab?.id, mode, workspacePath]); // Keep the editor area mounted for hidden terminal tabs. Closing a terminal - // tab backgrounds it without destroying the xterm instance. + // tab backgrounds it without destroying the xterm instance. Slot-aware: in + // grid9 mode tabs live in `layout.grid9Cells`, so count every renderable + // group (legacy + grid9 cells), not just the three hard-coded fields. + // These values are already subscribed through the mode-aware `useCanvasStore` + // selectors above, so the memo recomputes whenever the current mode's store + // changes — reading them here (instead of a fresh `store.getState()`) keeps the + // deps truthful and the result responsive. const hasRenderableTabs = useMemo(() => { - const groups = [primaryGroup, secondaryGroup, tertiaryGroup]; - return groups.some(group => - group.tabs.some(tab => !tab.isHidden || tab.content.type === 'terminal') - ); - }, [primaryGroup, secondaryGroup, tertiaryGroup]); + const groups: EditorGroupState[] = layout.splitMode === 'grid9' + ? Object.values(layout.grid9Cells).filter((g): g is EditorGroupState => !!g) + : [primaryGroup, secondaryGroup, tertiaryGroup]; + // Any group (legacy or grid9 cell) with visible tabs counts as renderable. + if (groups.some(group => group.tabs.some(tab => !tab.isHidden))) return true; + // Keep hidden terminal tabs mounted (keep-alive) so reopening a terminal + // reuses the xterm buffer instead of replaying history. + return groups.some(group => group.tabs.some(tab => tab.content.type === 'terminal')); + }, [layout, primaryGroup, secondaryGroup, tertiaryGroup]); // Handle anchor close const handleAnchorClose = useCallback(() => { @@ -149,7 +162,7 @@ export const ContentCanvas: React.FC = ({ const renderContent = () => { // Show empty state when there are no visible tabs and no terminal keep-alive tabs. if (!hasRenderableTabs) { - return ; + return ; } return ( @@ -165,6 +178,7 @@ export const ContentCanvas: React.FC = ({ onTabCloseAllWithDirtyCheck={handleCloseAllWithDirtyCheck} disablePopOut={disablePopOut} terminalResizeSuspended={terminalResizeSuspended} + grid9Slot={grid9Slot} /> diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/DropZone.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/DropZone.tsx index 6ce9dae2aa..9156ee1cce 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/DropZone.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/DropZone.tsx @@ -1,13 +1,13 @@ import React, { useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import type { DropPosition, EditorGroupId } from '../types'; +import type { DropPosition, EditorGroupId, SplitMode } from '../types'; import './DropZone.scss'; export interface DropZoneProps { groupId: EditorGroupId; isDragging: boolean; draggingFromGroupId: EditorGroupId | null; - splitMode: 'none' | 'horizontal' | 'vertical' | 'grid'; + splitMode: SplitMode; onDrop: (position: DropPosition) => void; children: React.ReactNode; } @@ -86,6 +86,20 @@ export const DropZone: React.FC = ({ return [{ position: 'center', label: t('canvas.dropCenter'), show: true }]; } + if (splitMode === 'grid9') { + // grid9 with independent rows/columns: every cell offers edge zones + // (left/right = grow columns, top/bottom = grow rows) plus a center + // placement. This lets the user build the grid in any order — rows + // first, columns first, or interleaved — up to GRID_MAX_DIM. + return [ + { position: 'left', label: t('canvas.dropLeft'), show: true }, + { position: 'right', label: t('canvas.dropRight'), show: true }, + { position: 'top', label: t('canvas.dropTop'), show: true }, + { position: 'bottom', label: t('canvas.dropBottom'), show: true }, + { position: 'center', label: t('canvas.dropCenter'), show: true }, + ]; + } + return []; }, [isDragging, splitMode, isFromSameGroup, isFromDifferentGroup, groupId, t]); diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.appearance.ts b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.appearance.ts index 9adb07be1f..0e5d84af7f 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.appearance.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.appearance.ts @@ -3,7 +3,8 @@ export const canvasEditorAreaAppearanceDescriptor: AppearanceSurfaceDescriptor = id: 'canvas-editor-area', parts: [ { id: 'root' }, { id: 'primary' }, { id: 'secondary' }, - { id: 'tertiary' }, { id: 'topRow' }, + { id: 'tertiary' }, { id: 'topRow' }, { id: 'grid9Cell' }, ], - facets: [{ id: 'layout', attribute: 'data-bf-layout', values: ['none', 'horizontal', 'vertical', 'grid'] }], + facets: [{ id: 'layout', attribute: 'data-bf-layout', values: ['none', 'horizontal', 'vertical', 'grid', 'grid9'] }], + states: [{ id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }], }; diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.scss b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.scss index 5287b2d200..303519b797 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.scss @@ -55,6 +55,61 @@ } } + &.is-grid9 { + width: 100%; + height: 100%; + overflow: hidden; + + .canvas-editor-area__grid9-canvas { + display: grid; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + + .canvas-editor-area__grid9-cell { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + + .canvas-editor-group { + min-width: 0; + min-height: 0; + } + + // Empty slot: dashed placeholder frame so users can see it is a valid + // drop target before starting a drag. + &:has(.canvas-editor-group__empty) { + background: var(--bf-appearance-token-color-bg-secondary); + border: 1px dashed var(--bf-appearance-token-border-base); + + .canvas-editor-group__empty-content span { + font-size: 12px; + color: var(--bf-appearance-token-color-text-muted); + } + } + } + + .canvas-split-handle { + min-width: 0; + min-height: 0; + + // grid9 uses fixed wide tracks for the resizers; make the handle fill + // its track so the grip/line are not clipped by the track size. + &--horizontal { + width: 100%; + height: 100%; + } + + &--vertical { + width: 100%; + height: 100%; + } + } + } + } + &__primary, &__secondary, &__tertiary { diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx index 4e3bddb3c1..2baaa0764d 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx @@ -1,14 +1,26 @@ import React, { useRef, useCallback } from 'react'; -import { EditorGroup } from './EditorGroup'; +import { EditorGroup, type EditorGroupProps } from './EditorGroup'; import { SplitHandle } from './SplitHandle'; import { useCanvasStore } from '../stores'; import type { EditorGroupId, + Grid9Slot, TabDragPayload, DropPosition, PanelContent, } from '../types'; +import { + EDITOR_GROUP_IDS, + GRID_MAX_DIM, + LAYOUT_CONFIG, + GRID9_RATIO_CONFIG, + createEditorGroupState, +} from '../types'; import './EditorArea.scss'; + +/** Grid9 cell-level grid props forwarded from EditorArea into each EditorGroup. */ +type Grid9CellProps = Pick; + export interface EditorAreaProps { workspacePath?: string; isSceneActive?: boolean; @@ -18,6 +30,9 @@ export interface EditorAreaProps { onTabCloseAllWithDirtyCheck?: (groupId: EditorGroupId) => Promise; disablePopOut?: boolean; terminalResizeSuspended?: boolean; + /** Optional grid9 slot info threaded from ContentCanvas → EditorArea → + * EditorGroup → TabBar (primary only). If absent EditorArea builds one. */ + grid9Slot?: Grid9Slot; } export const EditorArea: React.FC = ({ @@ -29,9 +44,11 @@ export const EditorArea: React.FC = ({ onTabCloseAllWithDirtyCheck, disablePopOut = false, terminalResizeSuspended = false, + grid9Slot, }) => { const containerRef = useRef(null); const topRowRef = useRef(null); + const grid9Ref = useRef(null); // Fine-grained selectors: subscribe to each slice/action individually so // unrelated store changes do not re-render the editor area. @@ -54,9 +71,15 @@ export const EditorArea: React.FC = ({ const setSplitRatio = useCanvasStore(state => state.setSplitRatio); const setSplitRatio2 = useCanvasStore(state => state.setSplitRatio2); const setActiveGroup = useCanvasStore(state => state.setActiveGroup); + const setSplitMode = useCanvasStore(state => state.setSplitMode); const updateTabContent = useCanvasStore(state => state.updateTabContent); const setTabDirty = useCanvasStore(state => state.setTabDirty); const setTabFileDeletedFromDisk = useCanvasStore(state => state.setTabFileDeletedFromDisk); + const setGrid9ColRatio = useCanvasStore(state => state.setGrid9ColRatio); + const setGrid9RowRatio = useCanvasStore(state => state.setGrid9RowRatio); + const applyGrid9Template = useCanvasStore(state => state.applyGrid9Template); + const mergeGrid9Cells = useCanvasStore(state => state.mergeGrid9Cells); + const removeGrid9Cell = useCanvasStore(state => state.removeGrid9Cell); const handleTabClick = useCallback((groupId: EditorGroupId) => (tabId: string) => { switchToTab(tabId, groupId); @@ -124,7 +147,25 @@ export const EditorArea: React.FC = ({ [setTabFileDeletedFromDisk] ); - const renderEditorGroup = (groupId: EditorGroupId, group: typeof primaryGroup) => ( + // Resident grid-template entry slot for the primary cell. Built here (not + // inside the grid9 branch) so the TabBar grid-template toggle button stays + // reachable in EVERY layout mode (none/h/v/grid) to enter grid9 — matches the + // upstream resident grid9Slot at EditorArea's primary render. The slot's + // active/toggle reflect the current splitMode (grid9 → exit, other → enter). + const primaryGrid9Slot: Grid9Slot = grid9Slot ?? { + active: layout.splitMode === 'grid9', + onToggle: () => setSplitMode(layout.splitMode === 'grid9' ? 'none' : 'grid9'), + label: 'gridTemplate.label', + templates: [ + { cols: 2, rows: 2, label: 'gridTemplate.four' }, + { cols: 3, rows: 2, label: 'gridTemplate.six' }, + { cols: 3, rows: 3, label: 'gridTemplate.nine' }, + { cols: 4, rows: 4, label: 'gridTemplate.sixteen' }, + ], + onApplyTemplate: (c, r) => applyGrid9Template(c, r), + }; + + const renderEditorGroup = (groupId: EditorGroupId, group: typeof primaryGroup, grid9Props?: Grid9CellProps) => ( = ({ onInteraction={onInteraction} disablePopOut={disablePopOut} terminalResizeSuspended={terminalResizeSuspended} + {...grid9Props} + grid9Slot={grid9Props?.grid9Slot ?? (groupId === 'primary' ? primaryGrid9Slot : undefined)} /> ); const { splitMode, splitRatio, splitRatio2 } = layout; + if (splitMode === 'grid9') { + // Dynamic cols×rows grid (1..GRID_MAX_DIM each) that fully tiles the panel. + // Only the activated rows/columns are rendered (no invisible outer frame), so + // the template truly fills the panel edge to edge. Ratios are stored as + // per-axis shares already normalized to sum to 1 (length === count). + const cellTrack = (i: number) => 2 * i + 1; + const handleTrack = (i: number) => 2 * i + 2; + const cols = layout.grid9ColsCount; + const rows = layout.grid9RowsCount; + const gap = LAYOUT_CONFIG.RESIZER_WIDTH; // 4px resizer-track gaps + const colRatios = Array.from({ length: cols }, (_, i) => layout.grid9ColRatios[i] ?? 1 / cols); + const rowRatios = Array.from({ length: rows }, (_, i) => layout.grid9RowRatios[i] ?? 1 / rows); + const gridTemplateColumns = colRatios.map(r => `${r}fr`).join(` ${gap}px `); + const gridTemplateRows = rowRatios.map(r => `${r}fr`).join(` ${gap}px `); + + const nodes: React.ReactNode[] = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const gid = EDITOR_GROUP_IDS[r * GRID_MAX_DIM + c]; + const cell = layout.grid9Cells[gid] ?? createEditorGroupState(); + const isPrimary = r === 0 && c === 0; + + // Merge this cell into a neighbour: prefer the left cell in the same row, + // otherwise the cell above. "Merge two small windows into one big window". + let gridMerge: (() => void) | undefined; + if (!isPrimary && cell.tabs.length > 0 && (c > 0 || r > 0)) { + const target = c > 0 + ? EDITOR_GROUP_IDS[r * GRID_MAX_DIM + (c - 1)] + : EDITOR_GROUP_IDS[(r - 1) * GRID_MAX_DIM + c]; + gridMerge = () => mergeGrid9Cells(gid, target); + } + const gridRemove = cell.tabs.length === 0 ? () => removeGrid9Cell(gid) : undefined; + + nodes.push( +
+ {renderEditorGroup(gid, cell, { + grid9Slot: isPrimary ? primaryGrid9Slot : undefined, + gridMerge, + gridRemove, + })} +
+ ); + + // Column resizer after this cell (except last column): a vertical divider + // that drags along clientX / container width. + if (c < cols - 1) { + nodes.push( + setGrid9ColRatio(c, nr)} + containerRef={grid9Ref} + minRatio={GRID9_RATIO_CONFIG.MIN} + maxRatio={GRID9_RATIO_CONFIG.MAX} + resetRatio={1 / cols} + style={{ gridColumn: handleTrack(c), gridRow: cellTrack(r) }} + /> + ); + } + } + // Row resizer after this row (except last row): a horizontal divider that + // drags along clientY / container height, spanning all columns. + if (r < rows - 1) { + nodes.push( + setGrid9RowRatio(r, nr)} + containerRef={grid9Ref} + minRatio={GRID9_RATIO_CONFIG.MIN} + maxRatio={GRID9_RATIO_CONFIG.MAX} + resetRatio={1 / rows} + style={{ gridColumn: '1 / -1', gridRow: handleTrack(r) }} + /> + ); + } + } + + return ( +
+
+ {nodes} +
+
+ ); + } + if (splitMode === 'none') { return (
diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.appearance.ts b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.appearance.ts index 3de1ed45e2..ea3842af29 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.appearance.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.appearance.ts @@ -2,6 +2,14 @@ import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; export const canvasEditorGroupAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'canvas-editor-group', parts: [{ id: 'root' }, { id: 'content' }, { id: 'tabContent' }, { id: 'empty' }, { id: 'emptyContent' }], - facets: [{ id: 'group', attribute: 'data-bf-group', values: ['primary', 'secondary', 'tertiary'] }], + facets: [{ + id: 'group', + attribute: 'data-bf-group', + values: [ + 'primary', 'secondary', 'tertiary', + 'slot4', 'slot5', 'slot6', 'slot7', 'slot8', 'slot9', 'slot10', + 'slot11', 'slot12', 'slot13', 'slot14', 'slot15', 'slot16', + ], + }], states: [{ id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }], }; diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx index 50989a9a92..a37f95b6e8 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx @@ -18,6 +18,7 @@ import { import type { EditorGroupId, EditorGroupState, + Grid9Slot, TabDragPayload, DropPosition, PanelContent, @@ -42,6 +43,13 @@ export interface EditorGroupProps { onDragEnd: () => void; onReorderTab: (tabId: string, newIndex: number) => void; onDrop: (position: DropPosition) => void; + /** Optional grid template toggle/menu info (primary group only), threaded to + * the TabBar. C3 types it through; C4 renders the menu. */ + grid9Slot?: Grid9Slot; + /** Merge this grid9 cell's tabs into a neighbour (free split/merge). */ + gridMerge?: () => void; + /** Remove this blank grid9 cell (shrink + re-tile remaining cells). */ + gridRemove?: () => void; onGroupFocus: () => void; onContentChange: (tabId: string, content: PanelContent) => void; onDirtyStateChange: (tabId: string, isDirty: boolean) => void; @@ -70,6 +78,9 @@ export const EditorGroup: React.FC = ({ onDragEnd, onReorderTab, onDrop, + grid9Slot, + gridMerge, + gridRemove, onGroupFocus, onContentChange, onDirtyStateChange, @@ -221,6 +232,9 @@ export const EditorGroup: React.FC = ({ onOpenMissionControl={onOpenMissionControl} onCloseAllTabs={onCloseAllTabs} onTabPopOut={disablePopOut ? undefined : handleTabPopOut} + grid9Slot={grid9Slot} + gridMerge={gridMerge} + gridRemove={gridRemove} /> void; /** Container ref */ containerRef: React.RefObject; + /** Extra inline styles (e.g. explicit CSS Grid placement) */ + style?: React.CSSProperties; + /** Upper bound for the ratio while dragging (defaults to LAYOUT_CONFIG.MAX_SPLIT_RATIO). */ + minRatio?: number; + /** Lower bound for the ratio while dragging (defaults to LAYOUT_CONFIG.MIN_SPLIT_RATIO). */ + maxRatio?: number; + /** Ratio to restore on double-click (defaults to LAYOUT_CONFIG.DEFAULT_SPLIT_RATIO). */ + resetRatio?: number; } export const SplitHandle: React.FC = ({ @@ -25,12 +33,26 @@ export const SplitHandle: React.FC = ({ ratio, onRatioChange, containerRef, + style, + minRatio, + maxRatio, + resetRatio, }) => { const { t } = useTranslation('components'); const [isDragging, setIsDragging] = useState(false); const startPosRef = useRef(0); const startRatioRef = useRef(ratio); + // Effective bounds for this handle. grid9 resizers pass explicit + // minRatio/maxRatio (from GRID9_RATIO_CONFIG); legacy splits fall back to the + // layout config so they keep today's behaviour. + const effectiveMin = minRatio ?? LAYOUT_CONFIG.MIN_SPLIT_RATIO; + const effectiveMax = maxRatio ?? LAYOUT_CONFIG.MAX_SPLIT_RATIO; + const clampToBounds = useCallback( + (r: number) => Math.max(effectiveMin, Math.min(effectiveMax, r)), + [effectiveMin, effectiveMax], + ); + // Handle mouse down const handleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); @@ -54,8 +76,8 @@ export const SplitHandle: React.FC = ({ const currentPos = direction === 'horizontal' ? e.clientX : e.clientY; const delta = currentPos - startPosRef.current; const deltaRatio = delta / containerSize; - - const newRatio = clampSplitRatio(startRatioRef.current + deltaRatio); + + const newRatio = clampToBounds(startRatioRef.current + deltaRatio); onRatioChange(newRatio); }; @@ -70,12 +92,13 @@ export const SplitHandle: React.FC = ({ document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; - }, [isDragging, direction, containerRef, onRatioChange]); + }, [isDragging, direction, containerRef, onRatioChange, clampToBounds]); - // Double-click to reset + // Double-click to reset. grid9 passes resetRatio = 1/N (N = axis count) so a + // double-click restores an even share rather than the legacy 0.5 default. const handleDoubleClick = useCallback(() => { - onRatioChange(LAYOUT_CONFIG.DEFAULT_SPLIT_RATIO); - }, [onRatioChange]); + onRatioChange(resetRatio ?? LAYOUT_CONFIG.DEFAULT_SPLIT_RATIO); + }, [onRatioChange, resetRatio]); // Handle keyboard adjustments const handleKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -84,21 +107,21 @@ export const SplitHandle: React.FC = ({ if (direction === 'horizontal') { if (e.key === 'ArrowLeft') { e.preventDefault(); - onRatioChange(clampSplitRatio(ratio - step)); + onRatioChange(clampToBounds(ratio - step)); } else if (e.key === 'ArrowRight') { e.preventDefault(); - onRatioChange(clampSplitRatio(ratio + step)); + onRatioChange(clampToBounds(ratio + step)); } } else { if (e.key === 'ArrowUp') { e.preventDefault(); - onRatioChange(clampSplitRatio(ratio - step)); + onRatioChange(clampToBounds(ratio - step)); } else if (e.key === 'ArrowDown') { e.preventDefault(); - onRatioChange(clampSplitRatio(ratio + step)); + onRatioChange(clampToBounds(ratio + step)); } } - }, [direction, ratio, onRatioChange]); + }, [direction, ratio, onRatioChange, clampToBounds]); return ( @@ -106,6 +129,7 @@ export const SplitHandle: React.FC = ({ className={`canvas-split-handle canvas-split-handle--${direction} ${ isDragging ? 'is-dragging' : '' }`} + style={style} onMouseDown={handleMouseDown} onDoubleClick={handleDoubleClick} onKeyDown={handleKeyDown} diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/index.ts b/src/web-ui/src/app/components/panels/content-canvas/editor-area/index.ts index f782b25fb8..989ff8b8bc 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/index.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/index.ts @@ -13,3 +13,6 @@ export type { SplitHandleProps } from './SplitHandle'; export { DropZone } from './DropZone'; export type { DropZoneProps } from './DropZone'; + +// Grid9 prop types shared with the TabBar (primary group template menu). +export type { Grid9Slot, Grid9Template } from '../types'; diff --git a/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.scss b/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.scss index cdf8ba71b2..7db8da2aa5 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.scss @@ -60,4 +60,11 @@ color: var(--bf-appearance-token-color-text-secondary); } } + + // Grid9-specific hint under the primary "no content" message + &__hint { + margin-top: 8px; + font-size: 13px; + color: var(--bf-appearance-token-color-text-muted); + } } diff --git a/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.tsx b/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.tsx index f33130ef8c..91ebe3b6e7 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.tsx @@ -11,9 +11,11 @@ import './EmptyState.scss'; export interface EmptyStateProps { onClose?: () => void; + /** When true (canvas empty in grid9 mode) show an additional grid9 hint line. */ + grid9Hint?: boolean; } -export const EmptyState: React.FC = ({ onClose }) => { +export const EmptyState: React.FC = ({ onClose, grid9Hint }) => { const { t } = useTranslation('components'); const handleClose = useCallback((e: React.MouseEvent) => { @@ -39,6 +41,7 @@ export const EmptyState: React.FC = ({ onClose }) => { {/* Message */}

{t('canvas.noContentOpen')}

+ {grid9Hint &&

{t('canvas.grid9EmptyHint')}

}
diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts b/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts index 0204954327..2b72119e26 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts @@ -12,13 +12,16 @@ import { dismissibleLayerManager } from '@/infrastructure/services/DismissibleLa import { useShortcut } from '@/infrastructure/hooks/useShortcut'; import { activeEditTargetService } from '@/tools/editor/services/ActiveEditTargetService'; import { useCanvasStore } from '../stores'; -import type { EditorGroupId } from '../types'; +import type { EditorGroupId, EditorGroupState } from '../types'; interface UseKeyboardShortcutsOptions { enabled?: boolean; handleCloseWithDirtyCheck?: (tabId: string, groupId: EditorGroupId) => Promise; } +/** Fallback for an un-materialised grid9 cell (avoids deref of undefined). */ +const EMPTY_GROUP: EditorGroupState = { tabs: [], activeTabId: null }; + export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) => { const { enabled = true, handleCloseWithDirtyCheck } = options; const hasCanvasDismissibleLayer = useHasDismissibleLayer('canvas'); @@ -32,14 +35,21 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) switchToTab, reopenClosedTab, setSplitMode, + enterGrid9, setAnchorPosition, toggleMaximize, toggleMissionControl, } = useCanvasStore(); + // Slot-aware active group resolver. In grid9 mode the active slot is a cell in + // `layout.grid9Cells`; the three legacy fields are dormant then. This is what + // makes Ctrl+W / Ctrl+1..9 target the correct grid9 cell (slot4..slot16). const getActiveGroup = useCallback(() => { + if (layout.splitMode === 'grid9') { + return layout.grid9Cells[activeGroupId] ?? EMPTY_GROUP; + } return activeGroupId === 'primary' ? primaryGroup : secondaryGroup; - }, [activeGroupId, primaryGroup, secondaryGroup]); + }, [activeGroupId, layout.splitMode, layout.grid9Cells, primaryGroup, secondaryGroup]); const getVisibleTabs = useCallback(() => { return getActiveGroup().tabs.filter((t) => !t.isHidden); @@ -79,6 +89,31 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) { enabled, description: 'keyboard.shortcuts.canvas.splitVertical' } ); + // grid9 grid: mod+Shift+9 — toggle grid9 mode. Entering routes to + // `enterGrid9` on the store (the grid9 cell model); exiting collects every + // cell's tabs back into the primary column via `setSplitMode('none')`. + // Registered in BOTH canvas and chat scopes so it fires whether focus is in + // the auxiliary canvas or the center chat pane. + const toggleGrid9 = useCallback(() => { + if (layout.splitMode === 'grid9') { + setSplitMode('none'); + return; + } + enterGrid9(3, 3); + }, [layout.splitMode, setSplitMode, enterGrid9]); + useShortcut( + 'canvas.splitGrid9', + { key: '9', ctrl: true, shift: true, scope: 'canvas' }, + toggleGrid9, + { enabled, description: 'keyboard.shortcuts.canvas.splitGrid9' } + ); + useShortcut( + 'canvas.splitGrid9.chat', + { key: '9', ctrl: true, shift: true, scope: 'chat' }, + toggleGrid9, + { enabled, description: 'keyboard.shortcuts.canvas.splitGrid9' } + ); + // Anchor zone: mod+` useShortcut( 'canvas.anchorZone', diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts b/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts index 7031d58987..365531e7d2 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts @@ -50,6 +50,8 @@ export const usePanelTabCoordinator = (options: UsePanelTabCoordinatorOptions = const { primaryGroup, secondaryGroup, + layout, + getAllRenderableGroups, } = useCanvasStore(); const { state, toggleRightPanel, updateRightPanelWidth } = useApp(); @@ -131,10 +133,21 @@ export const usePanelTabCoordinator = (options: UsePanelTabCoordinatorOptions = return; } - // Count visible tabs - const primaryVisible = primaryGroup.tabs.filter(t => !t.isHidden).length; - const secondaryVisible = secondaryGroup.tabs.filter(t => !t.isHidden).length; - const visibleCount = primaryVisible + secondaryVisible; + // Count visible tabs. In grid9 mode tabs live in `layout.grid9Cells` and the + // legacy primary/secondary groups are cleared when entering grid9, so count + // every renderable group there. Non-grid9 modes keep the original + // primary+secondary count for zero regression. + let visibleCount: number; + if (layout.splitMode === 'grid9') { + visibleCount = getAllRenderableGroups().reduce( + (sum, { group }) => sum + group.tabs.filter(t => !t.isHidden).length, + 0, + ); + } else { + const primaryVisible = primaryGroup.tabs.filter(t => !t.isHidden).length; + const secondaryVisible = secondaryGroup.tabs.filter(t => !t.isHidden).length; + visibleCount = primaryVisible + secondaryVisible; + } const isCollapsed = rightPanelCollapsedRef.current; @@ -147,8 +160,10 @@ export const usePanelTabCoordinator = (options: UsePanelTabCoordinatorOptions = expandPanel(); } }, [ + layout, primaryGroup.tabs, secondaryGroup.tabs, + getAllRenderableGroups, autoCollapseOnEmpty, autoExpandOnTabOpen, expandPanel, diff --git a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts index 5ab184f2a5..676596d6b1 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts @@ -24,6 +24,11 @@ import { createLayoutState, clampSplitRatio, clampAnchorSize, + clampGrid9Ratio, + GRID_MAX_DIM, + EDITOR_GROUP_IDS, + EDITOR_GROUP_COL, + EDITOR_GROUP_ROW, } from '../types'; import { normalizePath } from '@/shared/utils/pathUtils'; @@ -148,6 +153,29 @@ interface CanvasStoreActions { /** Get all tabs */ getAllTabs: () => CanvasTab[]; + + // ==================== Grid9 operations ==================== + + /** Enter grid9 mode, seeding grid9Cells from existing content and clamping counts */ + enterGrid9: (cols: number, rows: number) => void; + + /** Apply a preset grid9 template (rows×cols), resetting ratios and moving out-of-template tabs into primary */ + applyGrid9Template: (cols: number, rows: number) => void; + + /** Merge two grid9 cells: all tabs from `fromGroupId` move into `toGroupId`, source is emptied */ + mergeGrid9Cells: (fromGroupId: EditorGroupId, toGroupId: EditorGroupId) => void; + + /** Remove a blank grid9 cell: shrink the grid by one column/row and shift remaining cells */ + removeGrid9Cell: (groupId: EditorGroupId) => void; + + /** Set a grid9 column ratio, renormalising the axis to sum to 1 */ + setGrid9ColRatio: (col: number, ratio: number) => void; + + /** Set a grid9 row ratio, renormalising the axis to sum to 1 */ + setGrid9RowRatio: (row: number, ratio: number) => void; + + /** All groups (legacy + grid9 cells) that currently have renderable tabs */ + getAllRenderableGroups: () => { id: EditorGroupId; group: EditorGroupState }[]; } type CanvasStore = CanvasStoreState & CanvasStoreActions; @@ -168,6 +196,17 @@ const initialState: CanvasStoreState = { }; const getGroup = (draft: CanvasStoreState, groupId: EditorGroupId): EditorGroupState => { + // grid9 mode: the single grid9Cells map is the source of truth; the three + // legacy fields are dormant. Reading a slot that has no cell yet materialises + // an empty cell so callers can mutate it in place (write-through). + if (draft.layout.splitMode === 'grid9') { + let cell = draft.layout.grid9Cells[groupId]; + if (!cell) { + cell = createEditorGroupState(); + draft.layout.grid9Cells[groupId] = cell; + } + return cell; + } if (groupId === 'primary') return draft.primaryGroup; if (groupId === 'secondary') return draft.secondaryGroup; return draft.tertiaryGroup; @@ -185,6 +224,220 @@ const ensureValidActiveTab = (group: EditorGroupState) => { } }; +// ==================== Grid9 helpers ==================== + +/** Clamp a grid9 dimension (columns/rows) to 1..GRID_MAX_DIM. */ +const clampGrid9Dim = (n: number): number => Math.min(GRID_MAX_DIM, Math.max(1, Math.round(n))); + +/** + * Move content from the three legacy groups into the grid9Cells map when + * entering grid9 mode, then clear the legacy groups (which stay dormant while + * grid9 is active). Content is keyed by its canonical slot id. + */ +const seedGrid9CellsFromLegacy = (draft: CanvasStoreState) => { + const moveCell = (gid: EditorGroupId, legacy: EditorGroupState) => { + if (legacy.tabs.length === 0) return; + const cell = draft.layout.grid9Cells[gid] ?? createEditorGroupState(); + cell.tabs = [...cell.tabs, ...legacy.tabs]; + if (legacy.activeTabId && cell.tabs.some(t => t.id === legacy.activeTabId)) { + cell.activeTabId = legacy.activeTabId; + } else if (!cell.activeTabId && cell.tabs.length > 0) { + cell.activeTabId = cell.tabs[0].id; + } + draft.layout.grid9Cells[gid] = cell; + }; + moveCell('primary', draft.primaryGroup); + moveCell('secondary', draft.secondaryGroup); + moveCell('tertiary', draft.tertiaryGroup); + draft.primaryGroup = createEditorGroupState(); + draft.secondaryGroup = createEditorGroupState(); + draft.tertiaryGroup = createEditorGroupState(); +}; + +/** Reset grid9 ratios to equal shares for a cols×rows template (sum === 1). */ +const resetGrid9Ratios = (layout: LayoutState, cols: number, rows: number) => { + layout.grid9ColRatios = Array.from({ length: cols }, () => 1 / cols); + layout.grid9RowRatios = Array.from({ length: rows }, () => 1 / rows); +}; + +/** + * Grow a grid9 ratio array to `targetLen` entries. The new last share gets an + * equal share of the axis; the existing shares are scaled proportionally so + * their relative proportions are preserved and the axis still sums to 1. + */ +const growGrid9RatiosTo = (ratios: number[], targetLen: number): number[] => { + let result = [...ratios]; + while (result.length < targetLen) { + const weight = 1 / (result.length + 1); + const scale = 1 - weight; + result = result.map(r => r * scale); + result.push(weight); + } + return result; +}; + +/** Shrink a grid9 ratio array to `targetLen` by keeping the first entries and renormalising the sum to 1. */ +const shrinkGrid9Ratios = (ratios: number[], targetLen: number): number[] => { + if (targetLen <= 0) return [1]; + const kept = ratios.slice(0, targetLen); + const sum = kept.reduce((a, b) => a + b, 0); + if (sum === 0) return Array.from({ length: targetLen }, () => 1 / targetLen); + return kept.map(r => r / sum); +}; + +/** Remove a single ratio at `idx` and renormalise the remaining entries to sum to 1. */ +const removeGrid9RatioAt = (ratios: number[], idx: number): number[] => { + const kept = ratios.filter((_, i) => i !== idx); + if (kept.length === 0) return [1]; + const sum = kept.reduce((a, b) => a + b, 0); + if (sum === 0) return Array.from({ length: kept.length }, () => 1 / kept.length); + return kept.map(r => r / sum); +}; + +const cellHasVisibleTabs = (layout: LayoutState, gid: EditorGroupId): boolean => { + const cell = layout.grid9Cells[gid]; + return !!cell && cell.tabs.some(t => !t.isHidden); +}; + +const trailingRowHasTabs = (layout: LayoutState, row: number, colsCount: number): boolean => { + for (let c = 0; c < colsCount; c++) { + if (cellHasVisibleTabs(layout, EDITOR_GROUP_IDS[row * GRID_MAX_DIM + c])) return true; + } + return false; +}; + +const trailingColHasTabs = (layout: LayoutState, col: number, rowsCount: number): boolean => { + for (let r = 0; r < rowsCount; r++) { + if (cellHasVisibleTabs(layout, EDITOR_GROUP_IDS[r * GRID_MAX_DIM + col])) return true; + } + return false; +}; + +/** + * Shrink trailing empty rows (then trailing empty columns) of a grid9 layout, + * each down to a minimum of 1, renormalising the corresponding ratio array so + * the axis still sums to 1. Shared by closeTab / closeAllTabs / handleDrop / + * removeGrid9Cell. Rows are collapsed first so a row removed by column collapse + * cannot leave a phantom empty column. + */ +const collapseTrailingGrid9 = (layout: LayoutState) => { + let rows = layout.grid9RowsCount; + while (rows > 1 && !trailingRowHasTabs(layout, rows - 1, layout.grid9ColsCount)) { + rows -= 1; + } + let cols = layout.grid9ColsCount; + while (cols > 1 && !trailingColHasTabs(layout, cols - 1, rows)) { + cols -= 1; + } + if (rows !== layout.grid9RowsCount) { + layout.grid9RowsCount = rows; + layout.grid9RowRatios = shrinkGrid9Ratios(layout.grid9RowRatios, rows); + } + if (cols !== layout.grid9ColsCount) { + layout.grid9ColsCount = cols; + layout.grid9ColRatios = shrinkGrid9Ratios(layout.grid9ColRatios, cols); + } +}; + +/** + * Keep activeGroupId pointing at a live, in-template grid9 cell; fall back to + * the first non-empty in-template cell, then to primary. + */ +const fixGrid9Active = (draft: CanvasStoreState) => { + const layout = draft.layout; + const activeCell = layout.grid9Cells[draft.activeGroupId]; + const activeRow = EDITOR_GROUP_ROW[draft.activeGroupId]; + const activeCol = EDITOR_GROUP_COL[draft.activeGroupId]; + const activeEmpty = !activeCell || getVisibleCount(activeCell) === 0; + if (activeRow >= layout.grid9RowsCount || activeCol >= layout.grid9ColsCount || activeEmpty) { + const firstNonEmpty = EDITOR_GROUP_IDS.find(gid => { + const cell = layout.grid9Cells[gid]; + return ( + EDITOR_GROUP_ROW[gid] < layout.grid9RowsCount && + EDITOR_GROUP_COL[gid] < layout.grid9ColsCount && + !!cell && + getVisibleCount(cell) > 0 + ); + }); + draft.activeGroupId = firstNonEmpty ?? 'primary'; + } +}; + +/** + * grid9 close-tab lifecycle: remove the tab from its cell (or hide a terminal), + * collapse trailing empty rows/columns and fix the active slot. Never enters the + * legacy three-field degradation matrix. + */ +const grid9CloseCellTab = ( + draft: CanvasStoreState, + groupId: EditorGroupId, + tabId: string, + options?: { forceRemove?: boolean }, +) => { + const group = getGroup(draft, groupId); + const tabIndex = group.tabs.findIndex(t => t.id === tabId); + if (tabIndex === -1) return; + + const tab = group.tabs[tabIndex]; + const forceRemove = options?.forceRemove === true; + + // Terminal tabs without force remove: hide instead of deleting for reactivation. + if (tab.content.type === 'terminal' && !forceRemove) { + tab.isHidden = true; + if (group.activeTabId === tabId) { + const visibleTabs = group.tabs.filter(t => !t.isHidden); + group.activeTabId = visibleTabs[0]?.id || null; + } + return; + } + + if (!(tab.content.type === 'terminal' && forceRemove)) { + draft.closedTabs.unshift({ tab: { ...tab }, closedAt: Date.now(), groupId, index: tabIndex }); + if (draft.closedTabs.length > draft.maxClosedTabsHistory) { + draft.closedTabs.pop(); + } + } + + group.tabs.splice(tabIndex, 1); + ensureValidActiveTab(group); + collapseTrailingGrid9(draft.layout); + fixGrid9Active(draft); +}; + +/** + * grid9 close-all lifecycle: keep pinned tabs in each cell, collect surviving + * pinned tabs into the primary cell, clear the rest and collapse to a single + * column. Must never drop a pinned tab from any slot. + */ +const grid9CloseAllCells = (draft: CanvasStoreState) => { + const layout = draft.layout; + const collected: CanvasTab[] = []; + for (const gid of EDITOR_GROUP_IDS) { + const cell = layout.grid9Cells[gid]; + if (!cell) continue; + const pinned = cell.tabs.filter(t => t.state === 'pinned'); + if (pinned.length > 0) { + collected.push(...pinned); + } + delete layout.grid9Cells[gid]; + } + // Restore surviving pinned tabs into the legacy primary group (we drop to a + // single column), so none of them are lost and the canvas still renders them. + draft.primaryGroup = + collected.length > 0 + ? { tabs: collected, activeTabId: collected[0]?.id ?? null } + : createEditorGroupState(); + draft.secondaryGroup = createEditorGroupState(); + draft.tertiaryGroup = createEditorGroupState(); + draft.activeGroupId = 'primary'; + draft.layout.splitMode = 'none'; + layout.grid9Cells = {}; + layout.grid9ColsCount = 1; + layout.grid9RowsCount = 1; + layout.grid9ColRatios = [1]; + layout.grid9RowRatios = [1]; +}; + const keepPinnedTabsOnly = (group: EditorGroupState) => { group.tabs = group.tabs.filter(tab => tab.state === 'pinned'); ensureValidActiveTab(group); @@ -246,6 +499,13 @@ const createCanvasStoreHook = () => create()( closeTab: (tabId, groupId, options) => { set((draft) => { + // grid9: run the dedicated lifecycle and never enter the legacy + // three-field degradation matrix. + if (draft.layout.splitMode === 'grid9') { + grid9CloseCellTab(draft, groupId, tabId, options); + return; + } + const group = getGroup(draft, groupId); const tabIndex = group.tabs.findIndex(t => t.id === tabId); @@ -512,6 +772,20 @@ const createCanvasStoreHook = () => create()( closeAllTabs: (groupId) => { set((draft) => { + // grid9: run the dedicated lifecycle and never enter the legacy + // three-field degradation matrix. + if (draft.layout.splitMode === 'grid9') { + if (groupId) { + const group = getGroup(draft, groupId); + keepPinnedTabsOnly(group); + collapseTrailingGrid9(draft.layout); + fixGrid9Active(draft); + } else { + grid9CloseAllCells(draft); + } + return; + } + if (groupId) { const group = getGroup(draft, groupId); keepPinnedTabsOnly(group); @@ -795,6 +1069,27 @@ const createCanvasStoreHook = () => create()( set((draft) => { const record = draft.closedTabs.shift(); if (record) { + // grid9: if the recorded slot no longer exists (was removed/merged), + // restore into the primary cell instead of a dead slot. + if (draft.layout.splitMode === 'grid9') { + const gid = record.groupId; + const cellExists = !!draft.layout.grid9Cells[gid]; + const insideTemplate = + EDITOR_GROUP_ROW[gid] < draft.layout.grid9RowsCount && + EDITOR_GROUP_COL[gid] < draft.layout.grid9ColsCount; + const targetGid = cellExists && insideTemplate ? gid : 'primary'; + const cell = draft.layout.grid9Cells[targetGid] ?? createEditorGroupState(); + const insertIndex = Math.min(record.index, cell.tabs.length); + cell.tabs.splice(insertIndex, 0, { + ...record.tab, + lastAccessedAt: Date.now(), + }); + cell.activeTabId = record.tab.id; + draft.layout.grid9Cells[targetGid] = cell; + draft.activeGroupId = targetGid; + return; + } + const group = getGroup(draft, record.groupId); // Restore tab to its original position @@ -982,12 +1277,93 @@ const createCanvasStoreHook = () => create()( draft.activeGroupId = targetGroupId; } } else if (splitMode === 'grid') { - if (position === 'center') { + if (position === 'bottom' && toGroupId === 'tertiary') { + // Expand the 3-pane (left/right/bottom) into the grid: the dragged + // tab opens row 1 (rows grows to 2), keeping the existing 2 + // columns. Rows/columns stay independent. The new cell below + // tertiary is row1 col1 (slot6 in row-major), computed from the + // grid geometry so it stays correct if the geometry changes. + seedGrid9CellsFromLegacy(draft); + draft.layout.splitMode = 'grid9'; + draft.layout.grid9ColsCount = 2; + draft.layout.grid9RowsCount = 2; + resetGrid9Ratios(draft.layout, 2, 2); + const slotId = EDITOR_GROUP_IDS[1 * GRID_MAX_DIM + 1]; // slot6 + const slotGroup = getGroup(draft, slotId); + slotGroup.tabs = [tab]; + slotGroup.activeTabId = tab.id; + draft.activeGroupId = slotId; + } else if (position === 'center') { const targetGroup = getGroup(draft, toGroupId); targetGroup.tabs.unshift(tab); targetGroup.activeTabId = tab.id; draft.activeGroupId = toGroupId; } + } else if (splitMode === 'grid9') { + // grid9 with independent rows/columns (grid9ColsCount × + // grid9RowsCount, each 1..GRID_MAX_DIM). Edge drops grow the + // corresponding axis; the center drop places the tab into the + // target slot and grows the grid if that slot is outside it. + const targetRow = EDITOR_GROUP_ROW[toGroupId]; + const targetCol = EDITOR_GROUP_COL[toGroupId]; + if (position === 'left' || position === 'right') { + if (draft.layout.grid9ColsCount < GRID_MAX_DIM) { + draft.layout.grid9ColsCount += 1; + } + draft.layout.grid9ColRatios = growGrid9RatiosTo( + draft.layout.grid9ColRatios, + draft.layout.grid9ColsCount, + ); + const newCol = Math.min(draft.layout.grid9ColsCount - 1, GRID_MAX_DIM - 1); + const slotId = EDITOR_GROUP_IDS[targetRow * GRID_MAX_DIM + newCol]; + const slotGroup = getGroup(draft, slotId); + slotGroup.tabs.unshift(tab); + slotGroup.activeTabId = tab.id; + draft.activeGroupId = slotId; + } else if (position === 'top' || position === 'bottom') { + if (draft.layout.grid9RowsCount < GRID_MAX_DIM) { + draft.layout.grid9RowsCount += 1; + } + draft.layout.grid9RowRatios = growGrid9RatiosTo( + draft.layout.grid9RowRatios, + draft.layout.grid9RowsCount, + ); + const newRow = Math.min(draft.layout.grid9RowsCount - 1, GRID_MAX_DIM - 1); + const slotId = EDITOR_GROUP_IDS[newRow * GRID_MAX_DIM + targetCol]; + const slotGroup = getGroup(draft, slotId); + slotGroup.tabs.unshift(tab); + slotGroup.activeTabId = tab.id; + draft.activeGroupId = slotId; + } else { + // center: place into the target slot; grow the grid if the slot is + // outside the current rows/cols. + if (targetRow >= draft.layout.grid9RowsCount) { + draft.layout.grid9RowsCount = targetRow + 1; + } + if (targetCol >= draft.layout.grid9ColsCount) { + draft.layout.grid9ColsCount = targetCol + 1; + } + draft.layout.grid9ColRatios = growGrid9RatiosTo( + draft.layout.grid9ColRatios, + draft.layout.grid9ColsCount, + ); + draft.layout.grid9RowRatios = growGrid9RatiosTo( + draft.layout.grid9RowRatios, + draft.layout.grid9RowsCount, + ); + const targetGroup = getGroup(draft, toGroupId); + targetGroup.tabs.unshift(tab); + targetGroup.activeTabId = tab.id; + draft.activeGroupId = toGroupId; + } + } + + // grid9: no auto-merge/downgrade — just collapse trailing empty rows + // and columns and keep activeGroupId on a live cell. + if (draft.layout.splitMode === 'grid9') { + collapseTrailingGrid9(draft.layout); + fixGrid9Active(draft); + return; } // Auto-merge empty editor groups @@ -1065,7 +1441,26 @@ const createCanvasStoreHook = () => create()( setSplitMode: (mode) => { set((draft) => { - if (mode === 'none' && draft.layout.splitMode !== 'none') { + if (mode === 'grid9' && draft.layout.splitMode !== 'grid9') { + // Enter grid9: seed grid9Cells from the legacy content and clamp the + // active counts to 1x1 (the drop/template ops grow them from here). + seedGrid9CellsFromLegacy(draft); + draft.layout.grid9ColsCount = 1; + draft.layout.grid9RowsCount = 1; + draft.layout.grid9ColRatios = [1]; + draft.layout.grid9RowRatios = [1]; + } else if (mode === 'none' && draft.layout.splitMode === 'grid9') { + // Leave grid9: collect every grid9 cell's tabs into primary so no + // tab is lost, then drop back to a single column. + const allTabs: CanvasTab[] = []; + for (const gid of EDITOR_GROUP_IDS) { + const cell = draft.layout.grid9Cells[gid]; + if (cell) allTabs.push(...cell.tabs); + } + draft.primaryGroup.tabs = allTabs; + draft.primaryGroup.activeTabId = allTabs[0]?.id ?? null; + draft.layout.grid9Cells = {}; + } else if (mode === 'none' && draft.layout.splitMode !== 'none') { const allTabs = [ ...draft.primaryGroup.tabs, ...draft.secondaryGroup.tabs, @@ -1095,6 +1490,278 @@ const createCanvasStoreHook = () => create()( draft.layout.splitRatio2 = clampSplitRatio(ratio); }); }, + + // ==================== Grid9 templates & operations ==================== + + enterGrid9: (cols, rows) => { + set((draft) => { + const wasGrid9 = draft.layout.splitMode === 'grid9'; + if (!wasGrid9) { + seedGrid9CellsFromLegacy(draft); + } + const c = clampGrid9Dim(cols); + const r = clampGrid9Dim(rows); + draft.layout.splitMode = 'grid9'; + draft.layout.grid9ColsCount = c; + draft.layout.grid9RowsCount = r; + if (draft.layout.grid9ColRatios.length !== c) { + draft.layout.grid9ColRatios = growGrid9RatiosTo(draft.layout.grid9ColRatios, c); + } + if (draft.layout.grid9RowRatios.length !== r) { + draft.layout.grid9RowRatios = growGrid9RatiosTo(draft.layout.grid9RowRatios, r); + } + }); + }, + + applyGrid9Template: (cols, rows) => { + set((draft) => { + const wasGrid9 = draft.layout.splitMode === 'grid9'; + if (!wasGrid9) { + seedGrid9CellsFromLegacy(draft); + } + const c = clampGrid9Dim(cols); + const r = clampGrid9Dim(rows); + draft.layout.splitMode = 'grid9'; + draft.layout.grid9ColsCount = c; + draft.layout.grid9RowsCount = r; + // A template always tiles evenly: reset ratios to equal shares. + resetGrid9Ratios(draft.layout, c, r); + + // Move any tabs from slots outside the new template into the primary + // cell (never silently dropped). + const orphanedTabs: CanvasTab[] = []; + for (const gid of EDITOR_GROUP_IDS) { + const row = EDITOR_GROUP_ROW[gid]; + const col = EDITOR_GROUP_COL[gid]; + if (row >= r || col >= c) { + const slot = draft.layout.grid9Cells[gid]; + if (slot && slot.tabs.length > 0) { + if (slot.activeTabId && slot.tabs.some(t => t.id === slot.activeTabId)) { + draft.layout.grid9Cells['primary'] = { + ...(draft.layout.grid9Cells['primary'] ?? createEditorGroupState()), + activeTabId: slot.activeTabId, + }; + } + orphanedTabs.push(...slot.tabs); + } + delete draft.layout.grid9Cells[gid]; + } + } + if (orphanedTabs.length > 0) { + const primaryCell = draft.layout.grid9Cells['primary'] ?? createEditorGroupState(); + primaryCell.tabs = [...primaryCell.tabs, ...orphanedTabs]; + if (!primaryCell.activeTabId) { + primaryCell.activeTabId = primaryCell.tabs[0]?.id ?? null; + } + draft.layout.grid9Cells['primary'] = primaryCell; + } + + // Keep activeGroupId inside the new template. + const activeRow = EDITOR_GROUP_ROW[draft.activeGroupId]; + const activeCol = EDITOR_GROUP_COL[draft.activeGroupId]; + if ( + activeRow >= r || + activeCol >= c || + !draft.layout.grid9Cells[draft.activeGroupId] + ) { + draft.activeGroupId = 'primary'; + } + const primaryCell = draft.layout.grid9Cells['primary']; + if (primaryCell && primaryCell.tabs.length > 0 && !primaryCell.activeTabId) { + primaryCell.activeTabId = primaryCell.tabs[0].id; + } + }); + }, + + mergeGrid9Cells: (fromGroupId, toGroupId) => { + set((draft) => { + if (fromGroupId === toGroupId) return; + const source = draft.layout.grid9Cells[fromGroupId]; + if (!source || source.tabs.length === 0) return; + const target = draft.layout.grid9Cells[toGroupId] ?? createEditorGroupState(); + if (source.activeTabId && source.tabs.some(t => t.id === source.activeTabId)) { + target.activeTabId = source.activeTabId; + } + target.tabs = [...target.tabs, ...source.tabs]; + draft.layout.grid9Cells[toGroupId] = target; + delete draft.layout.grid9Cells[fromGroupId]; + draft.activeGroupId = toGroupId; + }); + }, + + removeGrid9Cell: (groupId) => { + set((draft) => { + const layout = draft.layout; + if (layout.splitMode !== 'grid9') return; + if (EDITOR_GROUP_IDS.indexOf(groupId) < 0) return; + const row = EDITOR_GROUP_ROW[groupId]; + const col = EDITOR_GROUP_COL[groupId]; + const cols = layout.grid9ColsCount; + const rows = layout.grid9RowsCount; + // A 1x1 grid cannot shrink any further. + if (cols <= 1 && rows <= 1) return; + + const moveTabs = (fromGid: EditorGroupId, toGid: EditorGroupId) => { + const from = layout.grid9Cells[fromGid]; + if (!from || from.tabs.length === 0) return; + const to = layout.grid9Cells[toGid] ?? createEditorGroupState(); + if (from.activeTabId && from.tabs.some(t => t.id === from.activeTabId)) { + to.activeTabId = from.activeTabId; + } + to.tabs = [...to.tabs, ...from.tabs]; + layout.grid9Cells[toGid] = to; + delete layout.grid9Cells[fromGid]; + }; + const resetCell = (gid: EditorGroupId) => { + delete layout.grid9Cells[gid]; + }; + + if (cols > 1) { + const mergeTargetCol = col > 0 ? col - 1 : 1; + for (let r = 0; r < rows; r++) { + moveTabs( + EDITOR_GROUP_IDS[r * GRID_MAX_DIM + col], + EDITOR_GROUP_IDS[r * GRID_MAX_DIM + mergeTargetCol], + ); + } + for (let r = 0; r < rows; r++) { + for (let c = col === 0 ? 0 : col; c < cols - 1; c++) { + moveTabs( + EDITOR_GROUP_IDS[r * GRID_MAX_DIM + c + 1], + EDITOR_GROUP_IDS[r * GRID_MAX_DIM + c], + ); + } + resetCell(EDITOR_GROUP_IDS[r * GRID_MAX_DIM + cols - 1]); + } + layout.grid9ColsCount = cols - 1; + layout.grid9ColRatios = removeGrid9RatioAt(layout.grid9ColRatios, col); + } else { + const mergeTargetRow = row > 0 ? row - 1 : 1; + for (let c = 0; c < cols; c++) { + moveTabs( + EDITOR_GROUP_IDS[row * GRID_MAX_DIM + c], + EDITOR_GROUP_IDS[mergeTargetRow * GRID_MAX_DIM + c], + ); + } + for (let c = 0; c < cols; c++) { + for (let r = row === 0 ? 0 : row; r < rows - 1; r++) { + moveTabs( + EDITOR_GROUP_IDS[(r + 1) * GRID_MAX_DIM + c], + EDITOR_GROUP_IDS[r * GRID_MAX_DIM + c], + ); + } + resetCell(EDITOR_GROUP_IDS[(rows - 1) * GRID_MAX_DIM + c]); + } + layout.grid9RowsCount = rows - 1; + layout.grid9RowRatios = removeGrid9RatioAt(layout.grid9RowRatios, row); + } + + // Clear any leftover cells outside the new template (their content was + // already relocated), then keep activeGroupId inside the template. + const newCols = layout.grid9ColsCount; + const newRows = layout.grid9RowsCount; + for (const gid of EDITOR_GROUP_IDS) { + if (EDITOR_GROUP_ROW[gid] >= newRows || EDITOR_GROUP_COL[gid] >= newCols) { + const cell = layout.grid9Cells[gid]; + if (cell && cell.tabs.length > 0) { + // Defensive: never drop tabs that ended up outside the template. + const primaryCell = layout.grid9Cells['primary'] ?? createEditorGroupState(); + primaryCell.tabs = [...primaryCell.tabs, ...cell.tabs]; + layout.grid9Cells['primary'] = primaryCell; + } + delete layout.grid9Cells[gid]; + } + } + + const activeRow = EDITOR_GROUP_ROW[draft.activeGroupId]; + const activeCol = EDITOR_GROUP_COL[draft.activeGroupId]; + const activeCell = layout.grid9Cells[draft.activeGroupId]; + if ( + activeRow >= newRows || + activeCol >= newCols || + !activeCell || + getVisibleCount(activeCell) === 0 + ) { + const firstNonEmpty = EDITOR_GROUP_IDS.find(gid => { + const cell = layout.grid9Cells[gid]; + return ( + EDITOR_GROUP_ROW[gid] < newRows && + EDITOR_GROUP_COL[gid] < newCols && + !!cell && + getVisibleCount(cell) > 0 + ); + }); + draft.activeGroupId = firstNonEmpty ?? 'primary'; + } + const primaryCell = layout.grid9Cells['primary']; + if (primaryCell && primaryCell.tabs.length > 0 && !primaryCell.activeTabId) { + primaryCell.activeTabId = primaryCell.tabs[0].id; + } + }); + }, + + setGrid9ColRatio: (col, ratio) => { + set((draft) => { + const ratios = draft.layout.grid9ColRatios; + if (col < 0 || col >= ratios.length) return; + const clamped = clampGrid9Ratio(ratio); + if (ratios.length === 1) { + ratios[0] = 1; + return; + } + const others = ratios.length - 1; + const sumOthers = ratios.reduce((acc, r, i) => (i === col ? acc : acc + r), 0); + const scale = sumOthers === 0 ? 1 / others : (1 - clamped) / sumOthers; + for (let i = 0; i < ratios.length; i++) { + if (i === col) { + ratios[i] = clamped; + } else { + ratios[i] *= scale; + } + } + }); + }, + + setGrid9RowRatio: (row, ratio) => { + set((draft) => { + const ratios = draft.layout.grid9RowRatios; + if (row < 0 || row >= ratios.length) return; + const clamped = clampGrid9Ratio(ratio); + if (ratios.length === 1) { + ratios[0] = 1; + return; + } + const others = ratios.length - 1; + const sumOthers = ratios.reduce((acc, r, i) => (i === row ? acc : acc + r), 0); + const scale = sumOthers === 0 ? 1 / others : (1 - clamped) / sumOthers; + for (let i = 0; i < ratios.length; i++) { + if (i === row) { + ratios[i] = clamped; + } else { + ratios[i] *= scale; + } + } + }); + }, + + getAllRenderableGroups: () => { + const state = get(); + const groups: { id: EditorGroupId; group: EditorGroupState }[] = []; + const pushIfRenderable = (id: EditorGroupId, group: EditorGroupState) => { + if (group && getVisibleTabs(group).length > 0) groups.push({ id, group }); + }; + if (state.layout.splitMode === 'grid9') { + for (const gid of EDITOR_GROUP_IDS) { + const cell = state.layout.grid9Cells[gid]; + if (cell) pushIfRenderable(gid, cell); + } + } else { + pushIfRenderable('primary', state.primaryGroup); + pushIfRenderable('secondary', state.secondaryGroup); + pushIfRenderable('tertiary', state.tertiaryGroup); + } + return groups; + }, setAnchorPosition: (position) => { set((draft) => { @@ -1321,21 +1988,32 @@ export function useCanvasStore(selector?: (state: CanvasStore) => T): T | Can // ==================== Selector Hooks ==================== /** - * Get tabs for a specific editor group. + * Get tabs for a specific editor group. In grid9 mode the slot lives in the + * single grid9Cells map; otherwise the legacy three-field layout is used. */ export const useGroupTabs = (groupId: EditorGroupId) => { - return useCanvasStore((state) => - groupId === 'primary' ? state.primaryGroup.tabs : state.secondaryGroup.tabs - ); + return useCanvasStore((state) => { + if (state.layout.splitMode === 'grid9') { + return state.layout.grid9Cells[groupId]?.tabs ?? []; + } + if (groupId === 'primary') return state.primaryGroup.tabs; + if (groupId === 'secondary') return state.secondaryGroup.tabs; + return state.tertiaryGroup.tabs; + }); }; /** - * Get active tab ID for a specific editor group. + * Get active tab ID for a specific editor group (grid9 slot-aware). */ export const useActiveTabId = (groupId: EditorGroupId) => { - return useCanvasStore((state) => - groupId === 'primary' ? state.primaryGroup.activeTabId : state.secondaryGroup.activeTabId - ); + return useCanvasStore((state) => { + if (state.layout.splitMode === 'grid9') { + return state.layout.grid9Cells[groupId]?.activeTabId ?? null; + } + if (groupId === 'primary') return state.primaryGroup.activeTabId; + if (groupId === 'secondary') return state.secondaryGroup.activeTabId; + return state.tertiaryGroup.activeTabId; + }); }; /** diff --git a/src/web-ui/src/app/components/panels/content-canvas/stores/grid9Drop.test.ts b/src/web-ui/src/app/components/panels/content-canvas/stores/grid9Drop.test.ts new file mode 100644 index 0000000000..6907fe4ff7 --- /dev/null +++ b/src/web-ui/src/app/components/panels/content-canvas/stores/grid9Drop.test.ts @@ -0,0 +1,237 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; +import type { EditorGroupId } from '@/app/components/panels/content-canvas/types'; + +function tabsIn(groupId: string): { title: string; id: string }[] { + const state = useAgentCanvasStore.getState(); + if (state.layout.splitMode === 'grid9') { + return (state.layout.grid9Cells[groupId as EditorGroupId]?.tabs ?? []) as { title: string; id: string }[]; + } + if (groupId === 'primary') return state.primaryGroup.tabs as { title: string; id: string }[]; + if (groupId === 'secondary') return state.secondaryGroup.tabs as { title: string; id: string }[]; + return state.tertiaryGroup.tabs as { title: string; id: string }[]; +} + +function findTab(groupId: string, title: string) { + return tabsIn(groupId).find(t => t.title === title); +} + +const md = (title: string) => ({ type: 'markdown-viewer', title, data: {} }); + +describe('grid9 drag-drop: independent rows/columns', () => { + beforeEach(() => { + useAgentCanvasStore.getState().reset(); + }); + + it('moves a tab from primary to slot6 when dropped in grid9 mode (center)', () => { + const store = useAgentCanvasStore.getState(); + store.addTab(md('A'), 'active', 'primary'); + store.addTab(md('B'), 'active', 'primary'); + store.setSplitMode('grid9'); + const tabB = findTab('primary', 'B'); + expect(tabB).toBeDefined(); + + useAgentCanvasStore.getState().handleDrop(tabB!.id, 'primary', 'slot6', 'center'); + const after = useAgentCanvasStore.getState(); + expect(tabsIn('slot6').some(t => t.title === 'B')).toBe(true); + expect(tabsIn('primary').some(t => t.title === 'B')).toBe(false); + // center drop into slot6 (row1 col1 in 4x4 row-major) grows rows to 2 and cols to 2. + expect(after.layout.grid9RowsCount).toBe(2); + expect(after.layout.grid9ColsCount).toBe(2); + }); + + it('keeps grid9 mode when closing a tab', () => { + const store = useAgentCanvasStore.getState(); + store.addTab(md('A'), 'active', 'primary'); + store.setSplitMode('grid9'); + const tabA = findTab('primary', 'A')!; + useAgentCanvasStore.getState().closeTab(tabA.id, 'primary'); + expect(useAgentCanvasStore.getState().layout.splitMode).toBe('grid9'); + }); + + it('none-mode center drop does NOT jump to grid9 (single-column mode preserved)', () => { + const store = useAgentCanvasStore.getState(); + store.addTab(md('A'), 'active', 'primary'); + store.addTab(md('B'), 'active', 'primary'); + expect(useAgentCanvasStore.getState().layout.splitMode).toBe('none'); + + const tabB = findTab('primary', 'B')!; + useAgentCanvasStore.getState().handleDrop(tabB.id, 'primary', 'primary', 'center'); + + expect(useAgentCanvasStore.getState().layout.splitMode).toBe('none'); + }); + + it('drag-natural upgrade: edge drop in none mode still enters horizontal split', () => { + const store = useAgentCanvasStore.getState(); + store.addTab(md('A'), 'active', 'primary'); + store.addTab(md('B'), 'active', 'primary'); + const tabB = findTab('primary', 'B')!; + useAgentCanvasStore.getState().handleDrop(tabB.id, 'primary', 'primary', 'right'); + + const after = useAgentCanvasStore.getState(); + expect(after.layout.splitMode).toBe('horizontal'); + expect(after.secondaryGroup.tabs.some(t => t.title === 'B')).toBe(true); + }); + + it('rows-first: bottom edge drops grow rows independently (1→2→3 rows)', () => { + const store = useAgentCanvasStore.getState(); + store.addTab(md('A'), 'active', 'primary'); + store.setSplitMode('grid9'); + expect(useAgentCanvasStore.getState().layout.grid9ColsCount).toBe(1); + expect(useAgentCanvasStore.getState().layout.grid9RowsCount).toBe(1); + + const a = findTab('primary', 'A')!; + useAgentCanvasStore.getState().handleDrop(a.id, 'primary', 'primary', 'bottom'); + expect(useAgentCanvasStore.getState().layout.grid9RowsCount).toBe(2); + expect(useAgentCanvasStore.getState().layout.grid9ColsCount).toBe(1); + + const a2 = tabsIn(useAgentCanvasStore.getState().activeGroupId).find(t => t.title === 'A')!; + useAgentCanvasStore.getState().handleDrop(a2.id, useAgentCanvasStore.getState().activeGroupId, useAgentCanvasStore.getState().activeGroupId, 'bottom'); + expect(useAgentCanvasStore.getState().layout.grid9RowsCount).toBe(3); + expect(useAgentCanvasStore.getState().layout.grid9ColsCount).toBe(1); + }); + + it('columns-first: right edge drops grow columns independently (1→2→3 cols)', () => { + const store = useAgentCanvasStore.getState(); + store.addTab(md('A'), 'active', 'primary'); + store.setSplitMode('grid9'); + + const a = findTab('primary', 'A')!; + useAgentCanvasStore.getState().handleDrop(a.id, 'primary', 'primary', 'right'); + expect(useAgentCanvasStore.getState().layout.grid9ColsCount).toBe(2); + expect(useAgentCanvasStore.getState().layout.grid9RowsCount).toBe(1); + + const a2 = tabsIn(useAgentCanvasStore.getState().activeGroupId).find(t => t.title === 'A')!; + useAgentCanvasStore.getState().handleDrop(a2.id, useAgentCanvasStore.getState().activeGroupId, useAgentCanvasStore.getState().activeGroupId, 'right'); + expect(useAgentCanvasStore.getState().layout.grid9ColsCount).toBe(3); + expect(useAgentCanvasStore.getState().layout.grid9RowsCount).toBe(1); + }); + + it('grows columns to 4 in grid9 mode (4x4)', () => { + const store = useAgentCanvasStore.getState(); + store.addTab(md('A'), 'active', 'primary'); + store.setSplitMode('grid9'); + + let tab = findTab('primary', 'A')!; + for (let expected = 2; expected <= 4; expected++) { + useAgentCanvasStore.getState().handleDrop(tab.id, useAgentCanvasStore.getState().activeGroupId, useAgentCanvasStore.getState().activeGroupId, 'right'); + expect(useAgentCanvasStore.getState().layout.grid9ColsCount).toBe(expected); + expect(useAgentCanvasStore.getState().layout.grid9RowsCount).toBe(1); + tab = tabsIn(useAgentCanvasStore.getState().activeGroupId).find(t => t.title === 'A')!; + } + // 4 is the max: another right drop keeps 4 columns. + useAgentCanvasStore.getState().handleDrop(tab.id, useAgentCanvasStore.getState().activeGroupId, useAgentCanvasStore.getState().activeGroupId, 'right'); + expect(useAgentCanvasStore.getState().layout.grid9ColsCount).toBe(4); + }); + + it('grows rows to 4 in grid9 mode (4x4)', () => { + const store = useAgentCanvasStore.getState(); + store.addTab(md('A'), 'active', 'primary'); + store.setSplitMode('grid9'); + + let tab = findTab('primary', 'A')!; + for (let expected = 2; expected <= 4; expected++) { + useAgentCanvasStore.getState().handleDrop(tab.id, useAgentCanvasStore.getState().activeGroupId, useAgentCanvasStore.getState().activeGroupId, 'bottom'); + expect(useAgentCanvasStore.getState().layout.grid9RowsCount).toBe(expected); + expect(useAgentCanvasStore.getState().layout.grid9ColsCount).toBe(1); + tab = tabsIn(useAgentCanvasStore.getState().activeGroupId).find(t => t.title === 'A')!; + } + useAgentCanvasStore.getState().handleDrop(tab.id, useAgentCanvasStore.getState().activeGroupId, useAgentCanvasStore.getState().activeGroupId, 'bottom'); + expect(useAgentCanvasStore.getState().layout.grid9RowsCount).toBe(4); + }); + + it('center drop into a row3/col3 slot grows the grid to 4x4', () => { + const store = useAgentCanvasStore.getState(); + store.addTab(md('A'), 'active', 'primary'); + store.setSplitMode('grid9'); + // slot16 = row 3, col 3 (4x4 row-major). + const a = findTab('primary', 'A')!; + useAgentCanvasStore.getState().handleDrop(a.id, 'primary', 'slot16', 'center'); + const s = useAgentCanvasStore.getState(); + expect(s.layout.grid9ColsCount).toBe(4); + expect(s.layout.grid9RowsCount).toBe(4); + expect(tabsIn('slot16').some(t => t.title === 'A')).toBe(true); + }); + + it('rows-then-columns: bottom then right builds a 2x2 grid in any order', () => { + const store = useAgentCanvasStore.getState(); + store.addTab(md('A'), 'active', 'primary'); + store.setSplitMode('grid9'); + + const a = findTab('primary', 'A')!; + useAgentCanvasStore.getState().handleDrop(a.id, 'primary', 'primary', 'bottom'); + expect(useAgentCanvasStore.getState().layout.grid9RowsCount).toBe(2); + expect(useAgentCanvasStore.getState().layout.grid9ColsCount).toBe(1); + + const a2 = tabsIn(useAgentCanvasStore.getState().activeGroupId).find(t => t.title === 'A')!; + useAgentCanvasStore.getState().handleDrop(a2.id, useAgentCanvasStore.getState().activeGroupId, useAgentCanvasStore.getState().activeGroupId, 'right'); + const after = useAgentCanvasStore.getState(); + expect(after.layout.grid9ColsCount).toBe(2); + expect(after.layout.grid9RowsCount).toBe(2); + }); + + it('columns-then-rows: right then bottom also builds a 2x2 grid', () => { + const store = useAgentCanvasStore.getState(); + store.addTab(md('A'), 'active', 'primary'); + store.setSplitMode('grid9'); + + const a = findTab('primary', 'A')!; + useAgentCanvasStore.getState().handleDrop(a.id, 'primary', 'primary', 'right'); + expect(useAgentCanvasStore.getState().layout.grid9ColsCount).toBe(2); + expect(useAgentCanvasStore.getState().layout.grid9RowsCount).toBe(1); + + const a2 = tabsIn(useAgentCanvasStore.getState().activeGroupId).find(t => t.title === 'A')!; + useAgentCanvasStore.getState().handleDrop(a2.id, useAgentCanvasStore.getState().activeGroupId, useAgentCanvasStore.getState().activeGroupId, 'bottom'); + const after = useAgentCanvasStore.getState(); + expect(after.layout.grid9ColsCount).toBe(2); + expect(after.layout.grid9RowsCount).toBe(2); + }); + + it('closing the last tab in a trailing row shrinks the row count', () => { + const store = useAgentCanvasStore.getState(); + store.addTab(md('A'), 'active', 'primary'); + store.setSplitMode('grid9'); + + const a = findTab('primary', 'A')!; + useAgentCanvasStore.getState().handleDrop(a.id, 'primary', 'primary', 'bottom'); + expect(useAgentCanvasStore.getState().layout.grid9RowsCount).toBe(2); + expect(tabsIn('slot5').some(t => t.title === 'A')).toBe(true); + + const tab5 = findTab('slot5', 'A')!; + useAgentCanvasStore.getState().closeTab(tab5.id, 'slot5'); + expect(useAgentCanvasStore.getState().layout.grid9RowsCount).toBe(1); + }); + + it('grid(3-pane) expands to grid9 by dropping below the bottom pane', () => { + const store = useAgentCanvasStore.getState(); + store.addTab(md('A'), 'active', 'primary'); + store.addTab(md('B'), 'active', 'primary'); + // Reach 2-pane: none → horizontal (right). + const tabB = findTab('primary', 'B')!; + useAgentCanvasStore.getState().handleDrop(tabB.id, 'primary', 'primary', 'right'); + expect(useAgentCanvasStore.getState().layout.splitMode).toBe('horizontal'); + + // Reach 3-pane: a fresh tab dropped to the bottom grows the grid. + store.addTab(md('C'), 'active', 'primary'); + const tabC = findTab('primary', 'C')!; + useAgentCanvasStore.getState().handleDrop(tabC.id, 'primary', 'tertiary', 'bottom'); + expect(useAgentCanvasStore.getState().layout.splitMode).toBe('grid'); + expect(tabsIn('tertiary').some(t => t.title === 'C')).toBe(true); + + // Expand into grid9 by dropping below tertiary → rows=2, cols=2. + const tabC2 = findTab('tertiary', 'C')!; + useAgentCanvasStore.getState().handleDrop(tabC2.id, 'tertiary', 'tertiary', 'bottom'); + const after = useAgentCanvasStore.getState(); + expect(after.layout.splitMode).toBe('grid9'); + expect(after.layout.grid9ColsCount).toBe(2); + expect(after.layout.grid9RowsCount).toBe(2); + // slot6 = row1 col1 in 4x4 row-major — the cell directly below tertiary + // (row0 col2), which is what the grid→grid9 upgrade path means by + // "dropping below the bottom pane". + expect(tabsIn('slot6').some(t => t.title === 'C')).toBe(true); + }); +}); diff --git a/src/web-ui/src/app/components/panels/content-canvas/stores/grid9Ops.test.ts b/src/web-ui/src/app/components/panels/content-canvas/stores/grid9Ops.test.ts new file mode 100644 index 0000000000..0d005e025e --- /dev/null +++ b/src/web-ui/src/app/components/panels/content-canvas/stores/grid9Ops.test.ts @@ -0,0 +1,373 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; +import type { EditorGroupId } from '@/app/components/panels/content-canvas/types'; + +/** + * In grid9 mode tabs live in `layout.grid9Cells[gid]`; in none/h/v/grid mode + * they live in the three legacy groups. This helper reads whichever is active. + */ +function tabsIn(groupId: string): { title: string; id: string }[] { + const state = useAgentCanvasStore.getState(); + if (state.layout.splitMode === 'grid9') { + return (state.layout.grid9Cells[groupId as EditorGroupId]?.tabs ?? []) as { title: string; id: string }[]; + } + if (groupId === 'primary') return state.primaryGroup.tabs as { title: string; id: string }[]; + if (groupId === 'secondary') return state.secondaryGroup.tabs as { title: string; id: string }[]; + return state.tertiaryGroup.tabs as { title: string; id: string }[]; +} + +function findTab(groupId: string, title: string) { + return tabsIn(groupId).find(t => t.title === title); +} + +function addTab(title: string, groupId: string) { + useAgentCanvasStore.getState().addTab({ type: 'markdown-viewer', title, data: {} }, 'active', groupId as EditorGroupId); +} + +const sum = (arr: number[]) => arr.reduce((a, b) => a + b, 0); + +describe('grid9 templates', () => { + beforeEach(() => { + useAgentCanvasStore.getState().reset(); + }); + + it('applyGrid9Template 2x2 sets cols/rows and splitMode', () => { + useAgentCanvasStore.getState().applyGrid9Template(2, 2); + const s = useAgentCanvasStore.getState(); + expect(s.layout.splitMode).toBe('grid9'); + expect(s.layout.grid9ColsCount).toBe(2); + expect(s.layout.grid9RowsCount).toBe(2); + }); + + it('applyGrid9Template clamps to 1..GRID_MAX_DIM', () => { + useAgentCanvasStore.getState().applyGrid9Template(9, 0); + const s = useAgentCanvasStore.getState(); + expect(s.layout.grid9ColsCount).toBe(4); + expect(s.layout.grid9RowsCount).toBe(1); + }); + + it('applyGrid9Template supports 4x4 and clamps beyond to 4', () => { + useAgentCanvasStore.getState().applyGrid9Template(4, 4); + const s = useAgentCanvasStore.getState(); + expect(s.layout.splitMode).toBe('grid9'); + expect(s.layout.grid9ColsCount).toBe(4); + expect(s.layout.grid9RowsCount).toBe(4); + useAgentCanvasStore.getState().applyGrid9Template(7, 9); + const s2 = useAgentCanvasStore.getState(); + expect(s2.layout.grid9ColsCount).toBe(4); + expect(s2.layout.grid9RowsCount).toBe(4); + }); + + it('4x4 template keeps tabs in a slot inside the template', () => { + useAgentCanvasStore.getState().applyGrid9Template(4, 4); + addTab('A', 'slot15'); // row3 col3 — inside a 4x4 template + expect(tabsIn('slot15').some(t => t.title === 'A')).toBe(true); + expect(useAgentCanvasStore.getState().layout.grid9ColsCount).toBe(4); + expect(useAgentCanvasStore.getState().layout.grid9RowsCount).toBe(4); + }); + + it('applyGrid9Template moves tabs outside the template into primary (no silent drop)', () => { + useAgentCanvasStore.getState().applyGrid9Template(3, 3); + addTab('A', 'primary'); + addTab('B', 'secondary'); + addTab('C', 'tertiary'); // row0 col2 — outside a 2x2 template + useAgentCanvasStore.getState().applyGrid9Template(2, 2); + const s = useAgentCanvasStore.getState(); + expect(tabsIn('primary').some(t => t.title === 'C')).toBe(true); + expect(tabsIn('tertiary').length).toBe(0); + expect(s.layout.grid9ColsCount).toBe(2); + expect(s.layout.grid9RowsCount).toBe(2); + }); + + it('applyGrid9Template resets ratios to equal shares (axis sums to 1)', () => { + useAgentCanvasStore.getState().applyGrid9Template(2, 2); + useAgentCanvasStore.getState().setGrid9ColRatio(0, 0.6); + useAgentCanvasStore.getState().applyGrid9Template(3, 3); + const s = useAgentCanvasStore.getState(); + expect(s.layout.grid9ColRatios).toHaveLength(3); + expect(s.layout.grid9RowRatios).toHaveLength(3); + expect(sum(s.layout.grid9ColRatios)).toBeCloseTo(1); + expect(sum(s.layout.grid9RowRatios)).toBeCloseTo(1); + expect(s.layout.grid9ColRatios[0]).toBeCloseTo(1 / 3); + expect(s.layout.grid9ColRatios[1]).toBeCloseTo(1 / 3); + expect(s.layout.grid9RowRatios[0]).toBeCloseTo(1 / 3); + }); + + it('setGrid9ColRatio renormalizes the column axis to sum to 1 and leaves rows untouched', () => { + useAgentCanvasStore.getState().applyGrid9Template(3, 2); + useAgentCanvasStore.getState().setGrid9ColRatio(0, 0.6); + const s = useAgentCanvasStore.getState(); + expect(s.layout.grid9ColRatios[0]).toBeCloseTo(0.6); + expect(sum(s.layout.grid9ColRatios)).toBeCloseTo(1); + // Rows stay at equal shares (2 rows -> 0.5 each). + expect(s.layout.grid9RowRatios).toHaveLength(2); + expect(sum(s.layout.grid9RowRatios)).toBeCloseTo(1); + expect(s.layout.grid9RowRatios[0]).toBeCloseTo(0.5); + }); + + it('growing the other axis (adding a row) does NOT change column ratios', () => { + useAgentCanvasStore.getState().applyGrid9Template(2, 1); + addTab('A', 'primary'); // col0 row0 + addTab('B', 'secondary'); // col1 row0 — keeps the trailing column non-empty + useAgentCanvasStore.getState().setGrid9ColRatio(0, 0.7); + const store = useAgentCanvasStore.getState(); + // Grow a row below primary; the boundary grid must stay 2 columns wide so no + // trailing-column collapse fires and the column ratios are preserved exactly. + store.handleDrop(findTab('primary', 'A')!.id, 'primary', 'primary', 'bottom'); + const s = useAgentCanvasStore.getState(); + expect(s.layout.grid9RowsCount).toBe(2); + expect(s.layout.grid9ColsCount).toBe(2); + expect(s.layout.grid9ColRatios[0]).toBeCloseTo(0.7); + expect(sum(s.layout.grid9ColRatios)).toBeCloseTo(1); + }); + + it('growing the column axis preserves the relative proportions of existing ratios', () => { + useAgentCanvasStore.getState().applyGrid9Template(2, 1); + addTab('A', 'primary'); + useAgentCanvasStore.getState().setGrid9ColRatio(0, 0.7); // col1 -> 0.3 + const store = useAgentCanvasStore.getState(); + store.handleDrop(findTab('primary', 'A')!.id, 'primary', 'primary', 'right'); + const s = useAgentCanvasStore.getState(); + expect(s.layout.grid9ColRatios).toHaveLength(3); + expect(sum(s.layout.grid9ColRatios)).toBeCloseTo(1); + // 0.7 : 0.3 relative proportion preserved after appending the new share. + expect(s.layout.grid9ColRatios[0] / s.layout.grid9ColRatios[1]).toBeCloseTo(0.7 / 0.3); + }); + + it('applyGrid9Template resets activeGroupId to primary when it points outside', () => { + useAgentCanvasStore.getState().applyGrid9Template(3, 3); + addTab('A', 'slot7'); // row1 col2 in 4x4 row-major — outside a 2x2 template + useAgentCanvasStore.getState().setActiveGroup('slot7'); + useAgentCanvasStore.getState().applyGrid9Template(2, 2); + expect(useAgentCanvasStore.getState().activeGroupId).toBe('primary'); + }); + + it('applyGrid9Template keeps activeGroupId inside the template', () => { + useAgentCanvasStore.getState().applyGrid9Template(3, 3); + addTab('A', 'secondary'); + useAgentCanvasStore.getState().setActiveGroup('secondary'); + useAgentCanvasStore.getState().applyGrid9Template(2, 2); + expect(useAgentCanvasStore.getState().activeGroupId).toBe('secondary'); + }); +}); + +describe('grid -> grid9 upgrade (existing boundary)', () => { + beforeEach(() => { + useAgentCanvasStore.getState().reset(); + }); + + it('keeps pre-existing tertiary tabs in tertiary (outside 2x2 template, preserved not dropped)', () => { + const store = useAgentCanvasStore.getState(); + store.setSplitMode('grid'); + store.addTab({ type: 'markdown-viewer', title: 'T', data: {} }, 'active', 'tertiary'); + store.addTab({ type: 'markdown-viewer', title: 'D', data: {} }, 'active', 'primary'); + const dragged = tabsIn('primary').find(t => t.title === 'D')!; + // Drag D onto the bottom edge of tertiary: the grid→grid9 upgrade path + // lands D in slot6 (row1 col1), the cell below tertiary. + store.handleDrop(dragged.id, 'primary', 'tertiary', 'bottom'); + const s = useAgentCanvasStore.getState(); + expect(s.layout.splitMode).toBe('grid9'); + expect(s.layout.grid9ColsCount).toBe(2); + expect(s.layout.grid9RowsCount).toBe(2); + expect(tabsIn('slot6').some(t => t.title === 'D')).toBe(true); + // Pre-existing tertiary tab T is never silently dropped (row0 col2 is + // outside the 2x2 template so it is preserved but not rendered). + expect(tabsIn('tertiary').some(t => t.title === 'T')).toBe(true); + }); +}); + +describe('mergeGrid9Cells', () => { + beforeEach(() => { + useAgentCanvasStore.getState().reset(); + }); + + it('merges tabs from secondary into primary and empties secondary', () => { + useAgentCanvasStore.getState().applyGrid9Template(2, 2); + addTab('A', 'primary'); + addTab('B', 'secondary'); + useAgentCanvasStore.getState().mergeGrid9Cells('secondary', 'primary'); + const s = useAgentCanvasStore.getState(); + expect(tabsIn('primary').some(t => t.title === 'B')).toBe(true); + expect(tabsIn('secondary').length).toBe(0); + expect(s.activeGroupId).toBe('primary'); + }); + + it('no-op when source is empty or same group', () => { + useAgentCanvasStore.getState().applyGrid9Template(2, 2); + addTab('A', 'primary'); + const before = tabsIn('primary').length; + useAgentCanvasStore.getState().mergeGrid9Cells('secondary', 'primary'); + expect(tabsIn('primary').length).toBe(before); + useAgentCanvasStore.getState().mergeGrid9Cells('primary', 'primary'); + expect(tabsIn('primary').length).toBe(before); + }); + + it('merges active tab id from source into target', () => { + useAgentCanvasStore.getState().applyGrid9Template(2, 2); + addTab('A', 'primary'); + addTab('B', 'secondary'); + const tabB = findTab('secondary', 'B'); + useAgentCanvasStore.getState().switchToTab(tabB!.id, 'secondary'); + useAgentCanvasStore.getState().mergeGrid9Cells('secondary', 'primary'); + const s = useAgentCanvasStore.getState(); + expect(tabsIn('primary').some(t => t.title === 'B')).toBe(true); + expect(s.layout.grid9Cells['primary']?.activeTabId).toBe(tabB!.id); + }); +}); + +describe('removeGrid9Cell', () => { + beforeEach(() => { + useAgentCanvasStore.getState().reset(); + }); + + it('removing a blank middle column shifts columns left and keeps tabs', () => { + useAgentCanvasStore.getState().applyGrid9Template(3, 2); + addTab('A', 'primary'); + addTab('B', 'tertiary'); // row0 col2 + useAgentCanvasStore.getState().removeGrid9Cell('secondary'); // row0 col1 + const s = useAgentCanvasStore.getState(); + expect(s.layout.grid9ColsCount).toBe(2); + expect(s.layout.grid9RowsCount).toBe(2); + expect(tabsIn('secondary').some(t => t.title === 'B')).toBe(true); + expect(tabsIn('tertiary').length).toBe(0); + expect(tabsIn('primary').some(t => t.title === 'A')).toBe(true); + }); + + it('removing a blank column renormalizes the column ratios to sum to 1', () => { + useAgentCanvasStore.getState().applyGrid9Template(3, 2); + addTab('A', 'primary'); + addTab('B', 'tertiary'); + useAgentCanvasStore.getState().setGrid9ColRatio(2, 0.6); + useAgentCanvasStore.getState().removeGrid9Cell('secondary'); + const s = useAgentCanvasStore.getState(); + expect(s.layout.grid9ColsCount).toBe(2); + expect(s.layout.grid9ColRatios).toHaveLength(2); + expect(sum(s.layout.grid9ColRatios)).toBeCloseTo(1); + }); + + it('removing the first column shifts everything left without losing tabs', () => { + useAgentCanvasStore.getState().applyGrid9Template(2, 2); + addTab('A', 'primary'); + addTab('B', 'secondary'); + useAgentCanvasStore.getState().removeGrid9Cell('primary'); + const s = useAgentCanvasStore.getState(); + expect(s.layout.grid9ColsCount).toBe(1); + expect(s.layout.grid9RowsCount).toBe(2); + expect(tabsIn('primary').some(t => t.title === 'A')).toBe(true); + expect(tabsIn('primary').some(t => t.title === 'B')).toBe(true); + expect(s.activeGroupId).toBe('primary'); + }); + + it('removing a blank row shifts rows up', () => { + useAgentCanvasStore.getState().applyGrid9Template(1, 3); + addTab('A', 'primary'); + addTab('B', 'slot9'); // row2 col0 in 4x4 row-major + useAgentCanvasStore.getState().removeGrid9Cell('slot5'); // row1 col0 + const s = useAgentCanvasStore.getState(); + expect(s.layout.grid9ColsCount).toBe(1); + expect(s.layout.grid9RowsCount).toBe(2); + expect(tabsIn('slot5').some(t => t.title === 'B')).toBe(true); + expect(tabsIn('slot9').length).toBe(0); + }); + + it('removing a blank middle column on a 4x4 grid shifts columns and keeps tabs', () => { + useAgentCanvasStore.getState().applyGrid9Template(4, 4); + addTab('A', 'primary'); + addTab('B', 'tertiary'); // row0 col2 + useAgentCanvasStore.getState().removeGrid9Cell('secondary'); // row0 col1 + const s = useAgentCanvasStore.getState(); + expect(s.layout.grid9ColsCount).toBe(3); + expect(s.layout.grid9RowsCount).toBe(4); + expect(tabsIn('secondary').some(t => t.title === 'B')).toBe(true); + expect(tabsIn('tertiary').length).toBe(0); + expect(tabsIn('primary').some(t => t.title === 'A')).toBe(true); + }); + + it('removing a blank row on a 4-row grid shifts rows up', () => { + useAgentCanvasStore.getState().applyGrid9Template(1, 4); + addTab('A', 'primary'); + addTab('B', 'slot13'); // row3 col0 in 4x4 row-major + useAgentCanvasStore.getState().removeGrid9Cell('slot5'); // row1 col0 + const s = useAgentCanvasStore.getState(); + expect(s.layout.grid9ColsCount).toBe(1); + expect(s.layout.grid9RowsCount).toBe(3); + expect(tabsIn('slot9').some(t => t.title === 'B')).toBe(true); + expect(tabsIn('slot13').length).toBe(0); + }); + + it('does nothing on a 1x1 grid', () => { + useAgentCanvasStore.getState().applyGrid9Template(1, 1); + addTab('A', 'primary'); + useAgentCanvasStore.getState().removeGrid9Cell('primary'); + const s = useAgentCanvasStore.getState(); + expect(s.layout.grid9ColsCount).toBe(1); + expect(s.layout.grid9RowsCount).toBe(1); + expect(tabsIn('primary').some(t => t.title === 'A')).toBe(true); + }); + + it('fixes activeGroupId when the active cell is removed', () => { + useAgentCanvasStore.getState().applyGrid9Template(2, 2); + addTab('A', 'secondary'); + useAgentCanvasStore.getState().setActiveGroup('secondary'); + useAgentCanvasStore.getState().removeGrid9Cell('secondary'); + expect(useAgentCanvasStore.getState().activeGroupId).toBe('primary'); + }); +}); + +describe('enterGrid9', () => { + beforeEach(() => { + useAgentCanvasStore.getState().reset(); + }); + + it('enters grid9 with clamped counts and preserves primary content', () => { + useAgentCanvasStore.getState().addTab({ type: 'markdown-viewer', title: 'A', data: {} }, 'active', 'primary'); + useAgentCanvasStore.getState().enterGrid9(2, 2); + const s = useAgentCanvasStore.getState(); + expect(s.layout.splitMode).toBe('grid9'); + expect(s.layout.grid9ColsCount).toBe(2); + expect(s.layout.grid9RowsCount).toBe(2); + expect(tabsIn('primary').some(t => t.title === 'A')).toBe(true); + }); +}); + +describe('closeAllTabs (no-arg) clears all grid9 slots', () => { + beforeEach(() => { + useAgentCanvasStore.getState().reset(); + }); + + it('empties every group slot (primary..slot16) while keeping pinned tabs', () => { + useAgentCanvasStore.getState().applyGrid9Template(4, 4); + const seed: string[] = [ + 'primary', 'secondary', 'tertiary', + 'slot4', 'slot5', 'slot6', 'slot7', 'slot8', 'slot9', + 'slot10', 'slot11', 'slot12', 'slot13', 'slot14', 'slot15', 'slot16', + ]; + seed.forEach((gid, i) => addTab(`tab-${i}`, gid)); + seed.forEach(gid => expect(tabsIn(gid).length).toBe(1)); + + useAgentCanvasStore.getState().closeAllTabs(); + + seed.forEach(gid => expect(tabsIn(gid).length).toBe(0)); + }); + + it('keeps pinned tabs from every group, not only slots 4-9', () => { + useAgentCanvasStore.getState().applyGrid9Template(4, 4); + useAgentCanvasStore.getState().addTab({ type: 'markdown-viewer', title: 'P10', data: {} }, 'pinned', 'slot10'); + useAgentCanvasStore.getState().addTab({ type: 'markdown-viewer', title: 'U10', data: {} }, 'preview', 'slot10'); + useAgentCanvasStore.getState().addTab({ type: 'markdown-viewer', title: 'P16', data: {} }, 'pinned', 'slot16'); + useAgentCanvasStore.getState().addTab({ type: 'markdown-viewer', title: 'U16', data: {} }, 'preview', 'slot16'); + + useAgentCanvasStore.getState().closeAllTabs(); + + expect(tabsIn('slot10').some(t => t.title === 'U10')).toBe(false); + expect(tabsIn('slot16').some(t => t.title === 'U16')).toBe(false); + expect(tabsIn('primary').some(t => t.title === 'P10')).toBe(true); + expect(tabsIn('primary').some(t => t.title === 'P16')).toBe(true); + expect(useAgentCanvasStore.getState().layout.splitMode).toBe('none'); + expect(useAgentCanvasStore.getState().activeGroupId).toBe('primary'); + }); +}); diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.appearance.ts b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.appearance.ts index 7f90f3decc..ce481214fd 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.appearance.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.appearance.ts @@ -8,7 +8,22 @@ export const canvasTabBarAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'dropIndicator', propertyProfile: 'overlay', visualRole: 'divider' }, { id: 'actions', visualRole: 'toolbar' }, { id: 'action', propertyProfile: 'control', visualRole: 'control' }, + { id: 'gridTemplate', propertyProfile: 'control', visualRole: 'control' }, + { id: 'gridTemplateMenu', propertyProfile: 'overlay', visualRole: 'popup' }, + { id: 'gridTemplateItem', propertyProfile: 'control', visualRole: 'control' }, + { id: 'gridTemplateExit', propertyProfile: 'control', visualRole: 'control' }, ], - facets: [{ id: 'group', attribute: 'data-bf-group', values: ['primary', 'secondary', 'tertiary'] }], + facets: [{ + id: 'group', + attribute: 'data-bf-group', + // The `group` facet enumerates every editor-group slot this tab bar can render. + // Beyond the legacy primary/secondary/tertiary, grid9 exposes slot4..slot16 + // (16 editor groups total) so the surface keeps complete group-facet coverage (P2-4). + values: [ + 'primary', 'secondary', 'tertiary', + 'slot4', 'slot5', 'slot6', 'slot7', 'slot8', 'slot9', 'slot10', + 'slot11', 'slot12', 'slot13', 'slot14', 'slot15', 'slot16', + ], + }], states: [{ id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }], }; diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.scss b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.scss index 722e657726..9adecfb15f 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.scss @@ -65,6 +65,53 @@ background: var(--bf-appearance-token-glass-red-hover); color: var(--bf-appearance-token-color-error); } + + // Grid-template toggle (primary grid9 cell): accent-tinted when the grid is active + &.canvas-tab-bar__grid9-btn.is-active { + color: var(--bf-appearance-token-color-accent-500); + background: var(--bf-appearance-token-color-accent-100); + } + } +} + +// Grid-template drop-down (pure-CSS relative positioning under the trigger) +.canvas-tab-bar__grid9-wrap { + position: relative; + display: inline-flex; +} + +.canvas-tab-bar__grid9-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 60; + min-width: 132px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--bf-appearance-token-color-bg-elevated); + border: 1px solid var(--bf-appearance-token-border-base); + border-radius: 8px; + box-shadow: 0 6px 20px var(--bf-appearance-token-color-overlay-black-12); +} + +.canvas-tab-bar__grid9-menu-item { + display: flex; + align-items: center; + width: 100%; + padding: 6px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--bf-appearance-token-color-text-primary); + font-size: 12px; + text-align: left; + cursor: pointer; + + &:hover { + background: var(--bf-appearance-token-element-bg-hover); + color: var(--bf-appearance-token-color-accent-500); } } diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx index 359cb5ad89..f769c9ccce 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx @@ -4,18 +4,24 @@ */ import React, { useState, useRef, useEffect, useCallback, useMemo, useLayoutEffect } from 'react'; -import { X } from 'lucide-react'; +import { X, Table2, Combine, Trash2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Tooltip } from '@/component-library'; import { Tab } from './Tab'; import { TabOverflowMenu } from './TabOverflowMenu'; -import type { CanvasTab, EditorGroupId, TabDragPayload } from '../types'; +import type { CanvasTab, EditorGroupId, Grid9Slot, TabDragPayload } from '../types'; import { createLogger } from '@/shared/utils/logger'; import './TabBar.scss'; const log = createLogger('TabBar'); const TAB_REORDER_DURATION_MS = 160; const TAB_REORDER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; +// Estimated width of the grid-template toggle button (icon-only; the dropdown +// carries the template labels) in the actions area. Only present for the primary +// grid9 cell; accounted for in visible-tab fitting so tabs are not clipped. +// P2-1: the truth-source upstream does NOT count this width; we count it here +// to avoid the entry button clipping the action area or tabs behind it. +const GRID9_ACTION_WIDTH = 32; export interface TabBarProps { /** Tab list */ @@ -48,6 +54,13 @@ export interface TabBarProps { onCloseAllTabs?: () => Promise | void; /** Pop out tab as independent scene */ onTabPopOut?: (tabId: string) => void; + /** Optional grid template toggle info (primary group only). Threaded here in + * C3 (types only); C4 renders the menu. */ + grid9Slot?: Grid9Slot; + /** Merge this grid9 cell's tabs into a neighbour (free split/merge). */ + gridMerge?: () => void; + /** Remove this blank grid9 cell (shrink + re-tile remaining cells). */ + gridRemove?: () => void; } /** @@ -99,22 +112,34 @@ export const TabBar: React.FC = ({ onOpenMissionControl, onCloseAllTabs, onTabPopOut, + grid9Slot, + gridMerge, + gridRemove, }) => { const { t } = useTranslation('components'); const [visibleTabsCount, setVisibleTabsCount] = useState(tabs.length); const [dragOverIndex, setDragOverIndex] = useState(null); // Track initial layout measurement completion const [layoutReady, setLayoutReady] = useState(false); + // Grid-template menu (primary grid9 cell only) + const [grid9MenuOpen, setGrid9MenuOpen] = useState(false); const containerRef = useRef(null); const tabsListRef = useRef(null); const actionsRef = useRef(null); + const grid9WrapperRef = useRef(null); + const grid9MenuRef = useRef(null); const tabWrapperRefs = useRef>(new Map()); const pendingReorderRectsRef = useRef | null>(null); const reorderAnimationsRef = useRef>(new Map()); // Cache actual tab widths (keyed by tab.id + title since title affects width) const tabWidthCacheRef = useRef>(new Map()); + // Whether the grid-template toggle button should render (primary grid9 cell only). + // Kept as a stable boolean (not the slot object) so visible-tab fitting does not + // re-run on every render when the caller rebuilds the slot object. + const hasGrid9Slot = grid9Slot != null; + // Filter out hidden tabs const visibleTabs = useMemo(() => tabs.filter(t => !t.isHidden), [tabs]); @@ -164,8 +189,8 @@ export const TabBar: React.FC = ({ const totalTabsWidth = allTabWidths.reduce((sum, w) => sum + w, 0); // Base actions width (excluding overflow button) - // Close-all button: 28px + gap - const baseActionsWidth = (onCloseAllTabs ? 28 : 0) + 4; + // Close-all button: 28px + gap. Grid9 template button (primary only) adds width. + const baseActionsWidth = (onCloseAllTabs ? 28 : 0) + 4 + (hasGrid9Slot ? GRID9_ACTION_WIDTH : 0); // Overflow button width (~50px with badge, 28px with only mission control) const overflowBtnWidth = onOpenMissionControl ? 50 : 28; // Gap before actions area @@ -199,7 +224,7 @@ export const TabBar: React.FC = ({ const finalCount = Math.max(1, Math.min(count, visibleTabs.length)); setVisibleTabsCount(finalCount); setLayoutReady(true); - }, [visibleTabs, getTabWidth, getTabCacheKey, onCloseAllTabs, onOpenMissionControl]); + }, [visibleTabs, getTabWidth, getTabCacheKey, onCloseAllTabs, onOpenMissionControl, hasGrid9Slot]); // Reset to render all tabs when list changes (re-measure) useEffect(() => { @@ -367,6 +392,60 @@ export const TabBar: React.FC = ({ } }, [onTabClose, visibleTabs]); + const handleGrid9Toggle = useCallback(() => { + // Grid active → clicking the button exits the grid (M1: previously templates + // always existed so the click only opened the menu and the grid could never + // be turned off from this button). + if (grid9Slot?.active) { + setGrid9MenuOpen(false); + grid9Slot.onToggle(); + return; + } + // Inactive → open the template dropdown (four/six/nine/sixteen-cell). + if (!grid9Slot?.templates?.length) { + grid9Slot?.onToggle(); + return; + } + setGrid9MenuOpen(prev => !prev); + }, [grid9Slot]); + + const handleGrid9ApplyTemplate = useCallback((cols: number, rows: number) => { + grid9Slot?.onApplyTemplate?.(cols, rows); + setGrid9MenuOpen(false); + }, [grid9Slot]); + + const handleGrid9Exit = useCallback(() => { + grid9Slot?.onToggle(); + setGrid9MenuOpen(false); + }, [grid9Slot]); + + // Close the grid-template dropdown on outside click. + useEffect(() => { + if (!grid9MenuOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + if ( + grid9MenuRef.current && + !grid9MenuRef.current.contains(target) && + grid9WrapperRef.current && + !grid9WrapperRef.current.contains(target) + ) { + setGrid9MenuOpen(false); + } + }; + + // Delay listener to avoid swallowing the click that opened the menu. + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + }, 0); + + return () => { + clearTimeout(timer); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [grid9MenuOpen]); + return (
= ({ {/* Actions area */}
+ {/* Grid-template toggle + dropdown (primary grid9 cell only): reuses the + existing action button/tooltip and opens a pure-CSS relative dropdown. */} + {grid9Slot && ( +
+ + + + {grid9MenuOpen && grid9Slot.templates && ( +
e.stopPropagation()} + > + {grid9Slot.templates.map((tpl) => ( + + ))} + {grid9Slot.active && ( + + )} +
+ )} +
+ )} + + {/* Merge this grid9 cell's tabs into a neighbour (free split/merge). */} + {gridMerge && ( + + + + )} + + {/* Remove this blank grid9 cell (shrink + re-tile remaining cells). */} + {gridRemove && ( + + + + )} + {/* Overflow menu (all groups; mission control only in primary) */} {visibleTabs.length > 0 && layoutReady && ( = { + primary: 0, + secondary: 1, + tertiary: 2, + slot4: 3, + slot5: 0, + slot6: 1, + slot7: 2, + slot8: 3, + slot9: 0, + slot10: 1, + slot11: 2, + slot12: 3, + slot13: 0, + slot14: 1, + slot15: 2, + slot16: 3, +}; + +/** Row index (0..3) of each group in the 4x4 grid. */ +export const EDITOR_GROUP_ROW: Record = { + primary: 0, + secondary: 0, + tertiary: 0, + slot4: 0, + slot5: 1, + slot6: 1, + slot7: 1, + slot8: 1, + slot9: 2, + slot10: 2, + slot11: 2, + slot12: 2, + slot13: 3, + slot14: 3, + slot15: 3, + slot16: 3, +}; export interface LayoutState { splitMode: SplitMode; @@ -26,6 +111,20 @@ export interface LayoutState { splitRatio: number; /** Secondary split ratio: grid-top left/right or grid-bottom left/right */ splitRatio2: number; + /** + * grid9 cells: active grid pane states keyed by editor group id. Populated only + * in grid9 mode; the three legacy fields stay the single source of truth for + * none/h/v/grid and are left untouched while the layout is grid9. + */ + grid9Cells: Partial>; + /** Activated column count in grid9 mode (1..GRID_MAX_DIM). */ + grid9ColsCount: number; + /** Activated row count in grid9 mode (1..GRID_MAX_DIM). */ + grid9RowsCount: number; + /** Column ratios (relative shares summing to 1); length === grid9ColsCount. */ + grid9ColRatios: number[]; + /** Row ratios (relative shares summing to 1); length === grid9RowsCount. */ + grid9RowRatios: number[]; anchorPosition: AnchorPosition; anchorSize: number; isMaximized: boolean; @@ -84,6 +183,11 @@ export const createLayoutState = (): LayoutState => ({ splitMode: 'none', splitRatio: LAYOUT_CONFIG.DEFAULT_SPLIT_RATIO, splitRatio2: LAYOUT_CONFIG.DEFAULT_SPLIT_RATIO, + grid9Cells: {}, + grid9ColsCount: 1, + grid9RowsCount: 1, + grid9ColRatios: [1], + grid9RowRatios: [1], anchorPosition: 'hidden', anchorSize: LAYOUT_CONFIG.DEFAULT_ANCHOR_SIZE, isMaximized: false, @@ -117,3 +221,56 @@ export const clampAnchorSize = (size: number): number => { Math.min(LAYOUT_CONFIG.MAX_ANCHOR_SIZE, size) ); }; + +/** + * Grid9 column/row ratio bounds. + * + * Equal bounds for split ratios and grid9 ratios (MIN 0.2 / MAX 0.8) so a + * dragged split never reports a ratio the store later clamps to a different + * window. grid9 stores per-axis shares normalized to 1.0 at write time. + */ +export const GRID9_RATIO_CONFIG = { + MIN: 0.2, + MAX: 0.8, +} as const; + +/** + * Clamp a single grid9 column/row ratio. Ratios are relative shares of the + * container along that axis; two adjacent resizers can both reach the max. + */ +export const clampGrid9Ratio = (ratio: number): number => { + return Math.max( + GRID9_RATIO_CONFIG.MIN, + Math.min(GRID9_RATIO_CONFIG.MAX, ratio) + ); +}; + +/** + * A grid-template preset: a cols×rows arrangement plus a display label. + * The label is an i18n key resolved by the consumer (the TabBar menu owns + * the actual copy; this type only carries the geometry + a label handle). + */ +export interface Grid9Template { + cols: number; + rows: number; + label: string; +} + +/** + * grid9 slot info threaded down to the TabBar (primary group only) so it can + * render a grid-template toggle/menu. The object is built by EditorArea and + * passed through ContentCanvas → EditorArea → EditorGroup → TabBar. C3 only + * threads the type; C4 renders the menu. + */ +export interface Grid9Slot { + /** Whether grid9 mode is currently active. */ + active: boolean; + /** Toggle grid9 mode on/off. */ + onToggle: () => void; + /** Renderable label for the template toggle button (i18n key). */ + label: string; + /** Preset templates shown in the dropdown. */ + templates?: Grid9Template[]; + /** Apply a preset template (cols × rows). */ + onApplyTemplate?: (cols: number, rows: number) => void; +} diff --git a/src/web-ui/src/infrastructure/services/grid9Reachability.test.ts b/src/web-ui/src/infrastructure/services/grid9Reachability.test.ts new file mode 100644 index 0000000000..0fa03f2a3e --- /dev/null +++ b/src/web-ui/src/infrastructure/services/grid9Reachability.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { shortcutManager } from '@/infrastructure/services/ShortcutManager'; + +describe('grid9 chat-scope reachability', () => { + beforeEach(() => { + shortcutManager.clear(); + shortcutManager.setEnabled(true); + shortcutManager.loadUserOverrides({}); + document.body.innerHTML = ''; + }); + + afterEach(() => { + shortcutManager.clear(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('fires canvas.splitGrid9.chat when focus is in chat scope', () => { + const cb = vi.fn(); + shortcutManager.register('canvas.splitGrid9.chat', { key: '9', ctrl: true, shift: true, scope: 'chat' }, cb); + const target = document.createElement('div'); + target.setAttribute('data-shortcut-scope', 'chat'); + document.body.appendChild(target); + target.dispatchEvent(new KeyboardEvent('keydown', { + key: '9', code: 'Digit9', ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true, + })); + expect(cb).toHaveBeenCalled(); + document.body.removeChild(target); + }); + + it('does NOT fire when focus is in canvas scope and only chat registered', () => { + const cb = vi.fn(); + shortcutManager.register('canvas.splitGrid9.chat', { key: '9', ctrl: true, shift: true, scope: 'chat' }, cb); + const target = document.createElement('div'); + target.setAttribute('data-shortcut-scope', 'canvas'); + document.body.appendChild(target); + target.dispatchEvent(new KeyboardEvent('keydown', { + key: '9', code: 'Digit9', ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true, + })); + expect(cb).not.toHaveBeenCalled(); + document.body.removeChild(target); + }); + + it('checkConflicts reports zero conflicts for Ctrl+Shift+9 in canvas scope', () => { + const conflicts = shortcutManager.checkConflicts({ key: '9', ctrl: true, shift: true, scope: 'canvas' }); + expect(conflicts).toHaveLength(0); + }); + + it('checkConflicts reports zero conflicts for Ctrl+Shift+9 in chat scope', () => { + const conflicts = shortcutManager.checkConflicts({ key: '9', ctrl: true, shift: true, scope: 'chat' }); + expect(conflicts).toHaveLength(0); + }); +}); diff --git a/src/web-ui/src/locales/en-US/components.json b/src/web-ui/src/locales/en-US/components.json index cf6432d42e..42b46fe24a 100644 --- a/src/web-ui/src/locales/en-US/components.json +++ b/src/web-ui/src/locales/en-US/components.json @@ -346,6 +346,9 @@ "popOut": "Pop out as scene", "closeOthers": "Close Others", "closeAll": "Close All", + "exitGrid": "Exit Grid", + "mergeCell": "Merge into this window", + "removeCell": "Remove this cell", "closeRight": "Close Tabs to the Right", "pin": "Pin", "unpin": "Unpin Tab", @@ -550,6 +553,7 @@ }, "canvas": { "noContentOpen": "No content open", + "grid9EmptyHint": "Your grid is empty. Adjust the grid template from the tab bar to open a layout.", "filesCount": "{{count}} files", "searchPlaceholder": "Search file name or path...", "noPreview": "No preview available", @@ -579,6 +583,15 @@ "dropBottom": "Bottom", "dropCenter": "Drop" }, + "grid9": "Grid Layout", + "gridTemplate": { + "label": "Grid Template", + "four": "Grid of 4", + "six": "Grid of 6", + "nine": "Grid of 9", + "sixteen": "Grid of 16", + "exit": "Exit Grid" + }, "flexiblePanel": { "empty": { "title": "No Content", diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json index 07190c7d76..9816e4e592 100644 --- a/src/web-ui/src/locales/en-US/settings.json +++ b/src/web-ui/src/locales/en-US/settings.json @@ -221,6 +221,7 @@ "missionControl": "Mission Control", "splitHorizontal": "Horizontal Split", "splitVertical": "Vertical Split", + "splitGrid9": "Grid Layout (3x3)", "anchorZone": "Toggle Anchor Zone", "maximize": "Maximize Editor", "closePreview": "Close Preview" diff --git a/src/web-ui/src/locales/zh-CN/components.json b/src/web-ui/src/locales/zh-CN/components.json index 2405c33e24..1b1bee3a85 100644 --- a/src/web-ui/src/locales/zh-CN/components.json +++ b/src/web-ui/src/locales/zh-CN/components.json @@ -346,6 +346,9 @@ "popOut": "弹出为独立场景", "closeOthers": "关闭其他", "closeAll": "全部关闭", + "exitGrid": "退出宫格布局", + "mergeCell": "合并到此窗口", + "removeCell": "删除此宫格", "closeRight": "关闭右侧标签", "pin": "固定", "unpin": "取消固定", @@ -550,6 +553,7 @@ }, "canvas": { "noContentOpen": "暂未打开任何内容", + "grid9EmptyHint": "当前宫格为空,可通过标签栏的模板菜单调整布局。", "filesCount": "{{count}} 个文件", "searchPlaceholder": "搜索文件名或路径...", "noPreview": "暂无预览内容", @@ -579,6 +583,15 @@ "dropBottom": "下", "dropCenter": "放置" }, + "grid9": "宫格布局", + "gridTemplate": { + "label": "宫格模板", + "four": "四宫格", + "six": "六宫格", + "nine": "九宫格", + "sixteen": "十六宫格", + "exit": "退出宫格" + }, "flexiblePanel": { "empty": { "title": "暂无内容", diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json index 803074badd..d1ecea590f 100644 --- a/src/web-ui/src/locales/zh-CN/settings.json +++ b/src/web-ui/src/locales/zh-CN/settings.json @@ -249,6 +249,7 @@ "missionControl": "Mission Control", "splitHorizontal": "水平分屏", "splitVertical": "垂直分屏", + "splitGrid9": "九宫格布局 (3x3)", "anchorZone": "切换锚点区", "maximize": "最大化编辑器", "closePreview": "关闭预览" diff --git a/src/web-ui/src/locales/zh-TW/components.json b/src/web-ui/src/locales/zh-TW/components.json index 3ea9bdd5fb..e8b3272eff 100644 --- a/src/web-ui/src/locales/zh-TW/components.json +++ b/src/web-ui/src/locales/zh-TW/components.json @@ -346,6 +346,9 @@ "popOut": "彈出為獨立場景", "closeOthers": "關閉其他", "closeAll": "全部關閉", + "exitGrid": "退出宮格佈局", + "mergeCell": "合併到此視窗", + "removeCell": "刪除此宮格", "closeRight": "關閉右側標籤", "pin": "固定", "unpin": "取消固定", @@ -550,6 +553,7 @@ }, "canvas": { "noContentOpen": "暫未開啟任何內容", + "grid9EmptyHint": "目前宮格為空,可透過標籤列的模板選單調整佈局。", "filesCount": "{{count}} 個檔案", "searchPlaceholder": "搜尋檔案名稱或路徑...", "noPreview": "暫無預覽內容", @@ -579,6 +583,15 @@ "dropBottom": "下", "dropCenter": "放置" }, + "grid9": "宮格佈局", + "gridTemplate": { + "label": "宮格模板", + "four": "四宮格", + "six": "六宮格", + "nine": "九宮格", + "sixteen": "十六宮格", + "exit": "退出宮格" + }, "flexiblePanel": { "empty": { "title": "暫無內容", diff --git a/src/web-ui/src/locales/zh-TW/settings.json b/src/web-ui/src/locales/zh-TW/settings.json index 2a1ba87f85..01abd5311e 100644 --- a/src/web-ui/src/locales/zh-TW/settings.json +++ b/src/web-ui/src/locales/zh-TW/settings.json @@ -249,6 +249,7 @@ "missionControl": "Mission Control", "splitHorizontal": "水平分屏", "splitVertical": "垂直分屏", + "splitGrid9": "九宮格佈局 (3x3)", "anchorZone": "切換錨點區", "maximize": "最大化編輯器", "closePreview": "關閉預覽"