Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<ContentCanvasProps> = ({
Expand All @@ -55,6 +57,7 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
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.
Expand Down Expand Up @@ -112,13 +115,23 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
}, [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(() => {
Expand Down Expand Up @@ -149,7 +162,7 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
const renderContent = () => {
// Show empty state when there are no visible tabs and no terminal keep-alive tabs.
if (!hasRenderableTabs) {
return <EmptyState onClose={disablePopOut ? undefined : collapsePanel} />;
return <EmptyState onClose={disablePopOut ? undefined : collapsePanel} grid9Hint={layout.splitMode === 'grid9'} />;
}

return (
Expand All @@ -165,6 +178,7 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
onTabCloseAllWithDirtyCheck={handleCloseAllWithDirtyCheck}
disablePopOut={disablePopOut}
terminalResizeSuspended={terminalResizeSuspended}
grid9Slot={grid9Slot}
/>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down Expand Up @@ -86,6 +86,20 @@ export const DropZone: React.FC<DropZoneProps> = ({
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]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]' } }],
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<EditorGroupProps, 'grid9Slot' | 'gridMerge' | 'gridRemove'>;

export interface EditorAreaProps {
workspacePath?: string;
isSceneActive?: boolean;
Expand All @@ -18,6 +30,9 @@ export interface EditorAreaProps {
onTabCloseAllWithDirtyCheck?: (groupId: EditorGroupId) => Promise<boolean>;
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<EditorAreaProps> = ({
Expand All @@ -29,9 +44,11 @@ export const EditorArea: React.FC<EditorAreaProps> = ({
onTabCloseAllWithDirtyCheck,
disablePopOut = false,
terminalResizeSuspended = false,
grid9Slot,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const topRowRef = useRef<HTMLDivElement>(null);
const grid9Ref = useRef<HTMLDivElement>(null);

// Fine-grained selectors: subscribe to each slice/action individually so
// unrelated store changes do not re-render the editor area.
Expand All @@ -54,9 +71,15 @@ export const EditorArea: React.FC<EditorAreaProps> = ({
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);
Expand Down Expand Up @@ -124,7 +147,25 @@ export const EditorArea: React.FC<EditorAreaProps> = ({
[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) => (
<EditorGroup
groupId={groupId}
group={group}
Expand All @@ -151,11 +192,119 @@ export const EditorArea: React.FC<EditorAreaProps> = ({
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(
<div
key={gid}
data-bf-component="canvas-editor-area"
data-bf-part="grid9Cell"
data-bf-group={gid}
data-bf-state={activeGroupId === gid ? 'active' : ''}
className="canvas-editor-area__grid9-cell"
style={{ gridColumn: cellTrack(c), gridRow: cellTrack(r) }}
>
{renderEditorGroup(gid, cell, {
grid9Slot: isPrimary ? primaryGrid9Slot : undefined,
gridMerge,
gridRemove,
})}
</div>
);

// Column resizer after this cell (except last column): a vertical divider
// that drags along clientX / container width.
if (c < cols - 1) {
nodes.push(
<SplitHandle
key={`${gid}-colh`}
direction="horizontal"
ratio={colRatios[c]}
onRatioChange={(nr) => 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(
<SplitHandle
key={`row-${r}`}
direction="vertical"
ratio={rowRatios[r]}
onRatioChange={(nr) => 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 (
<div
data-bf-component="canvas-editor-area"
data-bf-part="root"
data-bf-layout="grid9"
ref={grid9Ref}
className="canvas-editor-area is-grid9"
>
<div
className="canvas-editor-area__grid9-canvas"
style={{ gridTemplateColumns, gridTemplateRows }}
>
{nodes}
</div>
</div>
);
}

if (splitMode === 'none') {
return (
<div data-bf-component="canvas-editor-area" data-bf-part="root" data-bf-layout="none" ref={containerRef} className="canvas-editor-area">
Expand Down
Loading