@principal-ai/subsystems-react 0.56.1 → 0.56.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,130 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import type { ComponentProps } from 'react';
3
+ import { Window } from 'happy-dom';
4
+ import { cleanup, fireEvent, render } from '@testing-library/react/pure';
5
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
6
+ import { FileDrawer } from './FileDrawer';
7
+
8
+ const dom = new Window();
9
+ const globals = globalThis as unknown as Record<string, unknown>;
10
+ globals.window = dom;
11
+ globals.document = dom.document;
12
+ globals.navigator = dom.navigator;
13
+ globals.HTMLElement = dom.HTMLElement;
14
+ globals.Node = dom.Node;
15
+ globals.Element = dom.Element;
16
+ globals.Event = dom.Event;
17
+ globals.MouseEvent = dom.MouseEvent;
18
+ globals.KeyboardEvent = dom.KeyboardEvent;
19
+ globals.localStorage = dom.localStorage;
20
+ globals.sessionStorage = dom.sessionStorage;
21
+ globals.matchMedia = dom.matchMedia.bind(dom);
22
+ globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
23
+ globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
24
+
25
+ afterEach(cleanup);
26
+
27
+ function drawer(props: Partial<ComponentProps<typeof FileDrawer>> = {}) {
28
+ return (
29
+ <ThemeProvider theme={defaultEditorTheme}>
30
+ <FileDrawer title="Trail code" onClose={() => {}} {...props}>
31
+ <input aria-label="Code state" defaultValue="selected step" />
32
+ </FileDrawer>
33
+ </ThemeProvider>
34
+ );
35
+ }
36
+
37
+ function parts(container: HTMLElement) {
38
+ return {
39
+ spacer: container.children[0] as HTMLElement,
40
+ panel: container.children[1] as HTMLElement,
41
+ };
42
+ }
43
+
44
+ describe('FileDrawer docking', () => {
45
+ test('keeps the default bottom drawer and reserves height', () => {
46
+ const { container } = render(drawer());
47
+ const { spacer, panel } = parts(container);
48
+ expect(spacer.style.height).toBe('45%');
49
+ expect(spacer.style.width).toBe('');
50
+ expect(panel.style.height).toBe('45%');
51
+ expect(panel.style.left).toBe('0px');
52
+ expect(panel.style.right).toBe('0px');
53
+ expect(panel.style.top).toBe('');
54
+ expect(panel.style.borderTopStyle).toBe('solid');
55
+ });
56
+
57
+ test('docks on the left and reserves width before the canvas', () => {
58
+ const { container } = render(drawer({ placement: 'left' }));
59
+ const { spacer, panel } = parts(container);
60
+ expect(spacer.style.width).toBe('45%');
61
+ expect(spacer.style.height).toBe('');
62
+ expect(spacer.style.order).toBe('-1');
63
+ expect(panel.style.width).toBe('45%');
64
+ expect(panel.style.height).toBe('100%');
65
+ expect(panel.style.left).toBe('0px');
66
+ expect(panel.style.right).toBe('');
67
+ expect(panel.style.top).toBe('0px');
68
+ expect(panel.style.borderRightStyle).toBe('solid');
69
+ expect(panel.style.borderTopStyle).toBe('none');
70
+ });
71
+
72
+ test('preserves mounted content when switching dock axes', () => {
73
+ const { container, getByLabelText, rerender } = render(drawer());
74
+ const input = getByLabelText('Code state') as HTMLInputElement;
75
+ fireEvent.change(input, { target: { value: 'step 3' } });
76
+ rerender(drawer({ placement: 'left' }));
77
+ expect(getByLabelText('Code state')).toBe(input);
78
+ expect(input.value).toBe('step 3');
79
+ expect(parts(container).spacer.style.height).toBe('');
80
+ rerender(drawer());
81
+ expect(getByLabelText('Code state')).toBe(input);
82
+ expect(parts(container).spacer.style.width).toBe('');
83
+ expect(parts(container).panel.style.width).toBe('');
84
+ });
85
+
86
+ test('maximizes and restores along the left dock axis', () => {
87
+ const { container, getByLabelText } = render(drawer({ placement: 'left' }));
88
+ fireEvent.click(getByLabelText('Maximize code view'));
89
+ expect(parts(container).panel.style.width).toBe('100%');
90
+ expect(parts(container).spacer.style.width).toBe('0px');
91
+ fireEvent.click(getByLabelText('Minimize code view'));
92
+ expect(parts(container).panel.style.width).toBe('45%');
93
+ expect(parts(container).spacer.style.width).toBe('45%');
94
+ });
95
+
96
+ test('fills the graph area for full-file reading', () => {
97
+ const { container } = render(drawer({ placement: 'left', fillHeight: true }));
98
+ expect(parts(container).panel.style.width).toBe('100%');
99
+ expect(parts(container).spacer.style.width).toBe('0px');
100
+ });
101
+
102
+ test('closed and hidden left drawers reserve no width', () => {
103
+ const { container, rerender } = render(drawer({ placement: 'left', title: null }));
104
+ expect(parts(container).panel.style.width).toBe('0px');
105
+ expect(parts(container).spacer.style.width).toBe('0px');
106
+ rerender(drawer({ placement: 'left', hidden: true }));
107
+ expect(parts(container).panel.style.width).toBe('0px');
108
+ expect(parts(container).spacer.style.width).toBe('0px');
109
+ });
110
+
111
+ test('closing resets maximize state before reopening', () => {
112
+ const { container, getByLabelText, rerender } = render(drawer({ placement: 'left' }));
113
+ fireEvent.click(getByLabelText('Maximize code view'));
114
+ rerender(drawer({ placement: 'left', title: null }));
115
+ rerender(drawer({ placement: 'left' }));
116
+ expect(parts(container).panel.style.width).toBe('45%');
117
+ expect(getByLabelText('Maximize code view')).not.toBeNull();
118
+ });
119
+
120
+ test('Escape closes a left drawer unless a full-file overlay handles it', () => {
121
+ let closes = 0;
122
+ const onClose = () => { closes++; };
123
+ const { rerender } = render(drawer({ placement: 'left', onClose }));
124
+ fireEvent.keyDown(window, { key: 'Escape' });
125
+ expect(closes).toBe(1);
126
+ rerender(drawer({ placement: 'left', onClose, suppressEscape: true }));
127
+ fireEvent.keyDown(window, { key: 'Escape' });
128
+ expect(closes).toBe(1);
129
+ });
130
+ });
@@ -1,6 +1,6 @@
1
1
  /**
2
- * FileDrawer — bottom panel that slides up from the bottom of the graph area
3
- * to show file / trail code. Opened by sidebar file-tree clicks,
2
+ * FileDrawer — bottom or left panel in the graph area
3
+ * showing file / trail code. Opened by sidebar file-tree clicks,
4
4
  * declaration links, and trail step focus; content is injected as
5
5
  * children by the graph component.
6
6
  */
@@ -10,18 +10,15 @@ import type { ReactNode } from "react";
10
10
  import { useTheme } from "@principal-ade/industry-theme";
11
11
  import { Minimize2, Maximize2, X } from "lucide-react";
12
12
 
13
- /** Matches `transition: height …` below — callers that fitView after open
14
- * should wait at least this long so the canvas has its reduced height. */
13
+ /** Matches the drawer's size transition; wait this long before fitting the canvas. */
15
14
  export const FILE_DRAWER_HEIGHT_MS = 200;
16
15
 
17
- /** Bottom panel that slides up from the bottom of the graph area.
18
- * Bottom-anchored with an animated height; an in-flow spacer reserves its
19
- * footprint so the canvas shrinks while open (nothing covered). Stays mounted
20
- * so open/close/maximize animate. The header's maximize button (or a
21
- * double-click) grows it over the whole graph column for whole-file reading. */
16
+ /** An in-flow spacer reserves the panel's footprint so it never covers the
17
+ * canvas. Stays mounted across open/close, maximize, and docking changes. */
22
18
  export function FileDrawer({
23
19
  title,
24
20
  onClose,
21
+ placement = "bottom",
25
22
  fillHeight = false,
26
23
  suppressEscape = false,
27
24
  hidden = false,
@@ -32,7 +29,9 @@ export function FileDrawer({
32
29
  /** Drawer chrome title; `null` closes the drawer. */
33
30
  title: string | null;
34
31
  onClose: () => void;
35
- /** When true and open, fill the graph column instead of ~45% height. */
32
+ /** Left docking requires a row container; bottom docking requires a column. */
33
+ placement?: "bottom" | "left";
34
+ /** When true and open, fill the graph area instead of ~45% of its dock axis. */
36
35
  fillHeight?: boolean;
37
36
  /** Skip Escape handling (e.g. while a full-file overlay is on top). */
38
37
  suppressEscape?: boolean;
@@ -54,18 +53,17 @@ export function FileDrawer({
54
53
  const showProgress = progress != null && progress.total > 1;
55
54
  const [closeHover, setCloseHover] = useState(false);
56
55
  const [maxHover, setMaxHover] = useState(false);
57
- // Full-height mode: the maximize button or double-clicking the header.
58
- // The panel is always bottom-anchored with an animated height, and a spacer
59
- // in normal flow reserves its footprint, so maximizing grows it upward over
60
- // the still-mounted graph instead of snapping to full.
56
+ // Maximize grows over the still-mounted graph along the current dock axis.
61
57
  const [maximized, setMaximized] = useState(false);
62
- // A closed drawer reopens at the default height, not maximized.
58
+ // A closed drawer reopens at the default size, not maximized.
63
59
  useEffect(() => {
64
60
  if (!open) setMaximized(false);
65
61
  }, [open]);
66
- const fullHeight = open && (maximized || fillHeight);
62
+ const fullSize = open && (maximized || fillHeight);
63
+ const sideDocked = placement === "left";
64
+ const sizeAxis = sideDocked ? "width" : "height";
67
65
  // Space the panel takes out of the canvas's flow (0 when it overlays fully).
68
- const reservedHeight = fullHeight ? 0 : open ? "45%" : 0;
66
+ const reservedSize = fullSize ? 0 : open ? "45%" : 0;
69
67
 
70
68
  useEffect(() => {
71
69
  if (!open || suppressEscape) return;
@@ -78,36 +76,37 @@ export function FileDrawer({
78
76
 
79
77
  return (
80
78
  <>
81
- {/* Reserves the drawer's footprint in the column so the canvas shrinks
82
- while the drawer is open. Animates with the panel, so the boundary
83
- between graph and code slides smoothly. */}
79
+ {/* Reserve the same size along the parent's matching flex axis. */}
84
80
  <div
85
81
  aria-hidden="true"
86
82
  style={{
87
83
  flexShrink: 0,
88
- height: reservedHeight,
89
- transition: `height ${FILE_DRAWER_HEIGHT_MS}ms ease`,
84
+ order: sideDocked ? -1 : undefined,
85
+ [sizeAxis]: reservedSize,
86
+ transition: `${sizeAxis} ${FILE_DRAWER_HEIGHT_MS}ms ease`,
90
87
  }}
91
88
  />
92
- {/* The panel — bottom-anchored and grown via `height`, so maximize/fill
93
- reads as dragging up over the diagram. */}
94
89
  <div
95
90
  style={{
96
91
  position: "absolute",
97
92
  left: 0,
98
- right: 0,
93
+ top: sideDocked ? 0 : undefined,
94
+ right: sideDocked ? undefined : 0,
99
95
  bottom: 0,
100
96
  // Above the edge-label overlay (zIndex 5), React Flow, and overlays
101
97
  // (zIndex 6–8); 30 lifts it over the whole graph area when maximized.
102
98
  zIndex: maximized ? 30 : 6,
103
- height: open ? (fullHeight ? "100%" : "45%") : 0,
99
+ width: sideDocked ? (open ? (fullSize ? "100%" : "45%") : 0) : undefined,
100
+ height: sideDocked ? "100%" : open ? (fullSize ? "100%" : "45%") : 0,
101
+ minWidth: 0,
104
102
  minHeight: 0,
105
103
  overflow: "hidden",
106
104
  display: "flex",
107
105
  flexDirection: "column",
108
106
  background: theme.colors.background,
109
- borderTop: open ? `1px solid ${theme.colors.border}` : "none",
110
- transition: `height ${FILE_DRAWER_HEIGHT_MS}ms ease`,
107
+ borderTop: open && !sideDocked ? `1px solid ${theme.colors.border}` : "none",
108
+ borderRight: open && sideDocked ? `1px solid ${theme.colors.border}` : "none",
109
+ transition: `${sizeAxis} ${FILE_DRAWER_HEIGHT_MS}ms ease`,
111
110
  }}
112
111
  >
113
112
  <div
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test';
2
- import { createElement } from 'react';
2
+ import { createElement, useState } from 'react';
3
3
  import { Window } from 'happy-dom';
4
4
  import { cleanup, fireEvent, render } from '@testing-library/react/pure';
5
5
  import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
@@ -125,6 +125,59 @@ describe('GraphSidebar chrome', () => {
125
125
  expect(sidebarColumn(container).style.width).toBe('400px');
126
126
  });
127
127
 
128
+ test('multiple moves track displacement from drag start without accumulating', () => {
129
+ const { container, getByLabelText } = renderSidebar({});
130
+ const handle = getByLabelText('Resize sidebar');
131
+ fireEvent.mouseDown(handle, { clientX: 100 });
132
+ fireEvent.mouseMove(document, { clientX: 120 });
133
+ expect(sidebarColumn(container).style.width).toBe('340px');
134
+ fireEvent.mouseMove(document, { clientX: 140 });
135
+ expect(sidebarColumn(container).style.width).toBe('360px');
136
+ fireEvent.mouseMove(document, { clientX: 110 });
137
+ expect(sidebarColumn(container).style.width).toBe('330px');
138
+ fireEvent.mouseUp(document);
139
+
140
+ // Each new gesture starts from the width the previous gesture left.
141
+ fireEvent.mouseDown(handle, { clientX: 110 });
142
+ fireEvent.mouseMove(document, { clientX: 130 });
143
+ fireEvent.mouseUp(document);
144
+ expect(sidebarColumn(container).style.width).toBe('350px');
145
+ });
146
+
147
+ test('clamps at both limits and follows the pointer back from them', () => {
148
+ const { container, getByLabelText } = renderSidebar({});
149
+ fireEvent.mouseDown(getByLabelText('Resize sidebar'), { clientX: 100 });
150
+ fireEvent.mouseMove(document, { clientX: 500 });
151
+ expect(sidebarColumn(container).style.width).toBe('600px');
152
+ fireEvent.mouseMove(document, { clientX: 150 });
153
+ expect(sidebarColumn(container).style.width).toBe('370px');
154
+ fireEvent.mouseMove(document, { clientX: -100 });
155
+ expect(sidebarColumn(container).style.width).toBe('240px');
156
+ fireEvent.mouseMove(document, { clientX: 100 });
157
+ expect(sidebarColumn(container).style.width).toBe('320px');
158
+ fireEvent.mouseUp(document);
159
+ });
160
+
161
+ test('controlled host updates do not change the drag baseline', () => {
162
+ function ControlledSidebar() {
163
+ const [width, setWidth] = useState(400);
164
+ return createElement(
165
+ ThemeProvider,
166
+ { theme: defaultEditorTheme },
167
+ createElement(GraphSidebar, { width, onWidthChange: setWidth }),
168
+ );
169
+ }
170
+ const { container, getByLabelText } = render(createElement(ControlledSidebar));
171
+ fireEvent.mouseDown(getByLabelText('Resize sidebar'), { clientX: 100 });
172
+ fireEvent.mouseMove(document, { clientX: 140 });
173
+ expect(sidebarColumn(container).style.width).toBe('440px');
174
+ fireEvent.mouseMove(document, { clientX: 160 });
175
+ expect(sidebarColumn(container).style.width).toBe('460px');
176
+ fireEvent.mouseMove(document, { clientX: 100 });
177
+ expect(sidebarColumn(container).style.width).toBe('400px');
178
+ fireEvent.mouseUp(document);
179
+ });
180
+
128
181
  test('a drag ends by persisting the width under persistKey', () => {
129
182
  const { getByLabelText } = renderSidebar({
130
183
  persistKey: 'gs-test',
@@ -132,6 +185,8 @@ describe('GraphSidebar chrome', () => {
132
185
  });
133
186
  const handle = getByLabelText('Resize sidebar');
134
187
  fireEvent.mouseDown(handle, { clientX: 100 });
188
+ fireEvent.mouseMove(document, { clientX: 140 });
189
+ fireEvent.mouseMove(document, { clientX: 180 });
135
190
  fireEvent.mouseMove(document, { clientX: 200 });
136
191
  fireEvent.mouseUp(document);
137
192
  expect(window.localStorage.getItem('principal.subsystems.sidebarWidth.gs-test')).toBe('420');
@@ -130,6 +130,7 @@ export function GraphSidebar({
130
130
  // persisting only then — not on every mousemove.
131
131
  const [dragging, setDragging] = useState(false);
132
132
  const dragStartX = useRef(0);
133
+ const dragStartWidth = useRef(0);
133
134
  // Mirror of the current width for the mouseup handler (a closure over the
134
135
  // drag-start render would persist the stale start width).
135
136
  const currentWidthRef = useRef(effectiveWidth);
@@ -139,6 +140,7 @@ export function GraphSidebar({
139
140
  (e: ReactMouseEvent) => {
140
141
  e.preventDefault();
141
142
  dragStartX.current = e.clientX;
143
+ dragStartWidth.current = effectiveWidth;
142
144
  currentWidthRef.current = effectiveWidth;
143
145
  setDragging(true);
144
146
  onDraggingChange?.(true);
@@ -150,7 +152,7 @@ export function GraphSidebar({
150
152
  if (!dragging) return;
151
153
  const onMove = (e: MouseEvent) => {
152
154
  const next = Math.min(
153
- Math.max(currentWidthRef.current + (e.clientX - dragStartX.current), minWidth),
155
+ Math.max(dragStartWidth.current + (e.clientX - dragStartX.current), minWidth),
154
156
  maxWidth,
155
157
  );
156
158
  if (controlled) {
@@ -199,7 +199,7 @@ export interface SubsystemComponentGraphProps {
199
199
  * sidebar's bottom half offers a Files/Trails toggle:
200
200
  * the flows panel lists each trail's steps (`symbol` or `file:line`);
201
201
  * clicking a flow row toggles its steps; clicking a step focuses that
202
- * step's edge and (when `renderTrailViewer` is set) opens the bottom
202
+ * step's edge and (when `renderTrailViewer` is set) opens the code
203
203
  * drawer on that flow's snippets. Opened flows stay on the canvas
204
204
  * (unselected ones dimmed); everything else is hidden.
205
205
  */
@@ -450,8 +450,9 @@ export interface SubsystemComponentGraphProps {
450
450
  renderFileViewer?: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
451
451
  /**
452
452
  * Host-injected multi-snippet viewer for a focused trail. When set,
453
- * clicking a flow step opens the bottom drawer with this content and
454
- * updates it as the focused step changes.
453
+ * clicking a flow step opens the code drawer with this content and
454
+ * updates it as the focused step changes. Docks left for vertical layouts,
455
+ * below the canvas for horizontal layouts.
455
456
  */
456
457
  renderTrailViewer?: (ctx: TrailViewerContext) => ReactNode;
457
458
  /**
@@ -928,10 +929,10 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
928
929
  const showDesc = !!description && descriptionVisible;
929
930
  // Sidebar width lives in the shared GraphWithSidebar chrome — draggable and
930
931
  // persisted under `principal.subsystems.sidebarWidth.<persistKey>`. The graph
931
- // only bounds how wide the panel may get so the canvas keeps room to breathe;
932
+ // caps it at half the view width so the canvas keeps room to breathe;
932
933
  // a width saved under the old view-state key still seeds the panel once.
933
934
  const sidebarMaxWidth = useMemo(
934
- () => Math.max(Math.min((_measured?.w ?? 680) * 0.5, 600), 240),
935
+ () => Math.max((_measured?.w ?? 680) * 0.5, 240),
935
936
  [_measured?.w],
936
937
  );
937
938
  // Persist the trail working set per model. Each write merges into the
@@ -2080,7 +2081,8 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2080
2081
  const drawerFillHeight =
2081
2082
  drawerTarget?.kind === 'file' && drawerTarget.startLine == null;
2082
2083
  const fileOverlayOpen = fileOverlay != null;
2083
- const collapseCanvas = drawerFillHeight || fileOverlayOpen;
2084
+ const collapseCanvas =
2085
+ drawerFillHeight || (fileOverlayOpen && committedDirection !== 'vertical');
2084
2086
 
2085
2087
  // Drop the full-file overlay when the underlying trail drawer changes
2086
2088
  // or closes — open-file keeps the same drawerTarget so the snippets stay mounted.
@@ -3041,6 +3043,57 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
3041
3043
  })}
3042
3044
  </>
3043
3045
  ) : null;
3046
+ const fileOverlayView = fileOverlay && (
3047
+ <div
3048
+ style={{
3049
+ position: 'absolute',
3050
+ inset: 0,
3051
+ zIndex: 8,
3052
+ display: 'flex',
3053
+ flexDirection: 'column',
3054
+ background: theme.colors.background,
3055
+ borderLeft: `1px solid ${theme.colors.border}`,
3056
+ }}
3057
+ >
3058
+ <div
3059
+ style={{
3060
+ display: 'flex',
3061
+ alignItems: 'center',
3062
+ gap: 8,
3063
+ padding: '6px 10px',
3064
+ borderBottom: `1px solid ${theme.colors.border}`,
3065
+ flexShrink: 0,
3066
+ }}
3067
+ >
3068
+ <span
3069
+ title={fileOverlay.file}
3070
+ style={{
3071
+ flex: 1,
3072
+ minWidth: 0,
3073
+ overflow: 'hidden',
3074
+ textOverflow: 'ellipsis',
3075
+ whiteSpace: 'nowrap',
3076
+ fontFamily: theme.fonts.monospace,
3077
+ fontSize: theme.fontSizes[0],
3078
+ color: theme.colors.textMuted ?? theme.colors.textSecondary,
3079
+ }}
3080
+ >
3081
+ {fileOverlay.file}
3082
+ </span>
3083
+ <FileOverlayCloseButton onClose={closeFileOverlay} />
3084
+ </div>
3085
+ <div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
3086
+ <FileDrawerContent
3087
+ render={renderDrawerContent}
3088
+ file={fileOverlay.file}
3089
+ startLine={fileOverlay.startLine}
3090
+ endLine={fileOverlay.endLine}
3091
+ fullFile
3092
+ />
3093
+ </div>
3094
+ </div>
3095
+ );
3096
+
3044
3097
  return (
3045
3098
  <GraphWithSidebar
3046
3099
  hidden={!sidebarVisible}
@@ -3063,7 +3116,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
3063
3116
  onResizeEnd={() => requestAnimationFrame(() => fitView())}
3064
3117
  >
3065
3118
  {/* Graph area */}
3066
- <div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
3119
+ <div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: committedDirection === 'vertical' ? 'row' : 'column', minHeight: 0, minWidth: 0 }}>
3067
3120
  {/* Canvas box — the edge-label overlay and legend anchor here, so
3068
3121
  overlays shift with the canvas, not the labels. Collapses when the
3069
3122
  file drawer fills the column (whole-file reading). */}
@@ -3073,6 +3126,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
3073
3126
  position: 'relative',
3074
3127
  flex: collapseCanvas ? 0 : 1,
3075
3128
  minHeight: 0,
3129
+ minWidth: 0,
3076
3130
  height: collapseCanvas ? 0 : undefined,
3077
3131
  overflow: 'hidden',
3078
3132
  display: 'flex',
@@ -3537,10 +3591,13 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
3537
3591
  />
3538
3592
  </div>
3539
3593
  )}
3594
+ {/* Vertical full-file reading replaces only the graph, not either left column. */}
3595
+ {committedDirection === 'vertical' && fileOverlayView}
3540
3596
  </div>
3541
3597
  <FileDrawer
3542
3598
  title={drawerTitle}
3543
3599
  onClose={closeDrawer}
3600
+ placement={committedDirection === 'vertical' ? 'left' : 'bottom'}
3544
3601
  fillHeight={drawerFillHeight}
3545
3602
  suppressEscape={fileOverlayOpen}
3546
3603
  hidden={hideDrawer}
@@ -3579,56 +3636,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
3579
3636
  />
3580
3637
  ) : null}
3581
3638
  </FileDrawer>
3582
- {fileOverlay && (
3583
- <div
3584
- style={{
3585
- position: 'absolute',
3586
- inset: 0,
3587
- zIndex: 8,
3588
- display: 'flex',
3589
- flexDirection: 'column',
3590
- background: theme.colors.background,
3591
- borderLeft: `1px solid ${theme.colors.border}`,
3592
- }}
3593
- >
3594
- <div
3595
- style={{
3596
- display: 'flex',
3597
- alignItems: 'center',
3598
- gap: 8,
3599
- padding: '6px 10px',
3600
- borderBottom: `1px solid ${theme.colors.border}`,
3601
- flexShrink: 0,
3602
- }}
3603
- >
3604
- <span
3605
- title={fileOverlay.file}
3606
- style={{
3607
- flex: 1,
3608
- minWidth: 0,
3609
- overflow: 'hidden',
3610
- textOverflow: 'ellipsis',
3611
- whiteSpace: 'nowrap',
3612
- fontFamily: theme.fonts.monospace,
3613
- fontSize: theme.fontSizes[0],
3614
- color: theme.colors.textMuted ?? theme.colors.textSecondary,
3615
- }}
3616
- >
3617
- {fileOverlay.file}
3618
- </span>
3619
- <FileOverlayCloseButton onClose={closeFileOverlay} />
3620
- </div>
3621
- <div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
3622
- <FileDrawerContent
3623
- render={renderDrawerContent}
3624
- file={fileOverlay.file}
3625
- startLine={fileOverlay.startLine}
3626
- endLine={fileOverlay.endLine}
3627
- fullFile
3628
- />
3629
- </div>
3630
- </div>
3631
- )}
3639
+ {committedDirection !== 'vertical' && fileOverlayView}
3632
3640
  {/* Startup cover — hides measurement, layout swap, and camera settle.
3633
3641
  Kept away during a direction flip: the flip is meant to be watched,
3634
3642
  and the cover would swallow the node slide. */}