Skip to content

Commit e951fa0

Browse files
committed
feat: Implement floating panel functionality for Notebook and Macro, enhancing user interface flexibility
Assisted-by: Claude Opus 4.7
1 parent 4e08bfa commit e951fa0

8 files changed

Lines changed: 620 additions & 220 deletions

File tree

‎src/App.tsx‎

Lines changed: 342 additions & 130 deletions
Large diffs are not rendered by default.

‎src/actions/registry.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,16 @@ export interface ViewActionCallbacks {
252252
/** True when the active panel has at least one object selected
253253
* (drives the enabled state of "View in a new window…"). */
254254
hasSelection: boolean;
255+
/** True when the Notebook panel is currently detached as a
256+
* floating overlay. */
257+
notebookFloating: boolean;
258+
/** Toggle the Notebook panel placement (tab ⇄ floating). */
259+
onToggleNotebookFloating: () => void;
260+
/** True when the Macro panel is currently detached as a floating
261+
* overlay. */
262+
macroFloating: boolean;
263+
/** Toggle the Macro panel placement (tab ⇄ floating). */
264+
onToggleMacroFloating: () => void;
255265
}
256266

257267
/** Wire View menu actions (UI preferences only). */
@@ -263,6 +273,8 @@ export function buildViewActions(cb: ViewActionCallbacks): ActionDescriptor[] {
263273
const checkPrefix = (on: boolean) => (on ? "\u2713 " : " ");
264274
const overlayPrefix = checkPrefix(cb.showResultsOverlay);
265275
const titlesPrefix = checkPrefix(cb.showGraphicalTitles);
276+
const notebookPrefix = checkPrefix(cb.notebookFloating);
277+
const macroPrefix = checkPrefix(cb.macroFloating);
266278
return [
267279
{
268280
id: "view.open_separate_view",
@@ -290,6 +302,21 @@ export function buildViewActions(cb: ViewActionCallbacks): ActionDescriptor[] {
290302
enabled: always,
291303
run: cb.onToggleGraphicalTitles,
292304
},
305+
{
306+
id: "view.notebook_floating",
307+
label: `${notebookPrefix}Detach Notebooks panel`,
308+
menuPath: `View/${notebookPrefix}Detach Notebooks panel`,
309+
beginGroup: true,
310+
enabled: always,
311+
run: cb.onToggleNotebookFloating,
312+
},
313+
{
314+
id: "view.macro_floating",
315+
label: `${macroPrefix}Detach Macros panel`,
316+
menuPath: `View/${macroPrefix}Detach Macros panel`,
317+
enabled: always,
318+
run: cb.onToggleMacroFloating,
319+
},
293320
];
294321
}
295322

‎src/components/CentralViewSwitcher.tsx‎

Lines changed: 66 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -5,77 +5,84 @@ import { getRootIconUrl } from "../assets/rootIcons";
55
export type CentralView = "plot" | "macro" | "notebook";
66

77
export interface CentralViewSwitcherProps {
8-
active: CentralView;
9-
onChange: (view: CentralView) => void;
10-
disabled?: boolean;
8+
active: CentralView;
9+
onChange: (view: CentralView) => void;
10+
disabled?: boolean;
11+
/** Map of views currently rendered as floating overlays. Detached
12+
* tabs render a small "↗" glyph and call ``onChange`` even when
13+
* clicked while active — the host uses this to focus / un-collapse
14+
* the overlay. */
15+
detached?: Partial<Record<CentralView, boolean>>;
1116
}
1217

1318
const PLOT_ICON = getRootIconUrl("visualization.svg");
1419
const MACRO_ICON = getRootIconUrl("script.svg");
1520
const NOTEBOOK_ICON = getRootIconUrl("notebook.svg");
1621

22+
interface TabConfig {
23+
view: CentralView;
24+
label: string;
25+
iconUrl: string | null;
26+
}
27+
28+
const TABS: TabConfig[] = [
29+
{ view: "plot", label: "Plot", iconUrl: PLOT_ICON },
30+
{ view: "macro", label: "Macros", iconUrl: MACRO_ICON },
31+
{ view: "notebook", label: "Notebooks", iconUrl: NOTEBOOK_ICON },
32+
];
33+
1734
/**
1835
* Three-tab switcher at the top of the central area that controls
1936
* which view occupies the central pane: the plot for the
2037
* currently-selected object, the macro editor, or the notebook
2138
* editor. Independent from the left-panel TreeKindSwitcher.
39+
*
40+
* The Notebook and Macro tabs may be *detached* — rendered as a
41+
* floating overlay instead of occupying the central area. Detached
42+
* tabs gain a "↗" glyph; clicking such a tab is forwarded to the
43+
* host so it can focus the corresponding overlay.
2244
*/
2345
export function CentralViewSwitcher({
24-
active,
25-
onChange,
26-
disabled,
46+
active,
47+
onChange,
48+
disabled,
49+
detached,
2750
}: CentralViewSwitcherProps): ReactElement {
28-
return (
29-
<div
30-
className="central-view-switcher"
31-
role="tablist"
32-
aria-label="Central view"
33-
>
34-
<button
35-
type="button"
36-
role="tab"
37-
aria-selected={active === "plot"}
38-
disabled={disabled}
39-
className={`central-view-switcher-tab${
40-
active === "plot" ? " active" : ""
41-
}`}
42-
onClick={() => onChange("plot")}
43-
>
44-
{PLOT_ICON && (
45-
<img src={PLOT_ICON} alt="" className="switcher-tab-icon" />
46-
)}
47-
Plot
48-
</button>
49-
<button
50-
type="button"
51-
role="tab"
52-
aria-selected={active === "macro"}
53-
disabled={disabled}
54-
className={`central-view-switcher-tab${
55-
active === "macro" ? " active" : ""
56-
}`}
57-
onClick={() => onChange("macro")}
58-
>
59-
{MACRO_ICON && (
60-
<img src={MACRO_ICON} alt="" className="switcher-tab-icon" />
61-
)}
62-
Macros
63-
</button>
64-
<button
65-
type="button"
66-
role="tab"
67-
aria-selected={active === "notebook"}
68-
disabled={disabled}
69-
className={`central-view-switcher-tab${
70-
active === "notebook" ? " active" : ""
71-
}`}
72-
onClick={() => onChange("notebook")}
73-
>
74-
{NOTEBOOK_ICON && (
75-
<img src={NOTEBOOK_ICON} alt="" className="switcher-tab-icon" />
76-
)}
77-
Notebooks
78-
</button>
79-
</div>
80-
);
51+
return (
52+
<div
53+
className="central-view-switcher"
54+
role="tablist"
55+
aria-label="Central view"
56+
>
57+
{TABS.map(({ view, label, iconUrl }) => {
58+
const isDetached = !!detached?.[view];
59+
const isActive = active === view;
60+
return (
61+
<button
62+
key={view}
63+
type="button"
64+
role="tab"
65+
aria-selected={isActive}
66+
aria-label={isDetached ? `${label} (detached)` : label}
67+
disabled={disabled}
68+
className={`central-view-switcher-tab${isActive ? " active" : ""}`}
69+
onClick={() => onChange(view)}
70+
>
71+
{iconUrl && (
72+
<img src={iconUrl} alt="" className="switcher-tab-icon" />
73+
)}
74+
{label}
75+
{isDetached && (
76+
<span
77+
className="central-view-switcher-tab-detached"
78+
aria-hidden="true"
79+
>
80+
↗
81+
</span>
82+
)}
83+
</button>
84+
);
85+
})}
86+
</div>
87+
);
8188
}

‎src/components/FloatingDock.tsx‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* FloatingDockStack — right-anchored overlay container that stacks its
3+
* children horizontally from right to left.
4+
*
5+
* Hosts every floating panel (AI Assistant, detached Notebook, detached
6+
* Macro, …). Using a single flex ``row-reverse`` container guarantees
7+
* that overlays never overlap and that their visual order is purely
8+
* determined by their JSX order — no manual ``right`` offset math.
9+
*/
10+
11+
import type { ReactNode } from "react";
12+
13+
export interface FloatingDockStackProps {
14+
children?: ReactNode;
15+
}
16+
17+
/**
18+
* Right-anchored absolute container; flex row-reverse so the first
19+
* child sits rightmost and additional children stack leftward. An
20+
* 8px gap separates them. ``pointer-events: none`` on the stack
21+
* itself + ``pointer-events: auto`` on each :class:`FloatingDockSlot`
22+
* means the gaps between overlays don't block clicks on the plot
23+
* beneath them.
24+
*/
25+
export function FloatingDockStack({
26+
children,
27+
}: FloatingDockStackProps): JSX.Element {
28+
return <div className="floating-dock-stack">{children}</div>;
29+
}
30+
31+
export interface FloatingDockSlotProps {
32+
/** Logical width hint applied as the slot's CSS width. */
33+
width?: number;
34+
/** Extra class appended to the slot wrapper (visual variants). */
35+
className?: string;
36+
children?: ReactNode;
37+
}
38+
39+
/**
40+
* Standard chrome (border, shadow, rounded corners) for a single
41+
* overlay panel hosted in a :class:`FloatingDockStack`. Apply this
42+
* around any panel that wants to look like the existing AI Assistant
43+
* overlay.
44+
*/
45+
export function FloatingDockSlot({
46+
width,
47+
className,
48+
children,
49+
}: FloatingDockSlotProps): JSX.Element {
50+
const cls = ["floating-dock-host", className].filter(Boolean).join(" ");
51+
return (
52+
<div className={cls} style={width ? { width } : undefined}>
53+
{children}
54+
</div>
55+
);
56+
}

‎src/components/MacroPanel.tsx‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,13 @@ interface Props {
129129
onCountChanged?: (count: number) => void;
130130
/** "light"|"dark" — pulled from the host theme. */
131131
theme: "light" | "dark";
132+
/** Current placement of this panel (``"tab"`` ⇒ central tab,
133+
* ``"floating"`` ⇒ right-side overlay). Optional; when omitted
134+
* the placement toggle button is hidden. */
135+
placement?: "tab" | "floating";
136+
/** Toggle the placement. When omitted, no toggle button is
137+
* rendered. */
138+
onTogglePlacement?: () => void;
132139
}
133140

134141
interface MacroState extends MacroMeta {
@@ -158,6 +165,8 @@ export const MacroPanel = forwardRef<MacroPanelHandle, Props>(
158165
onConvertToNotebook,
159166
onCountChanged,
160167
theme,
168+
placement,
169+
onTogglePlacement,
161170
}: Props,
162171
ref,
163172
) {
@@ -796,9 +805,8 @@ export const MacroPanel = forwardRef<MacroPanelHandle, Props>(
796805
return (
797806
<div
798807
key={m.id}
799-
className={`macro-recent-menu-item${
800-
alreadyOpen ? " macro-recent-menu-item-open" : ""
801-
}`}
808+
className={`macro-recent-menu-item${alreadyOpen ? " macro-recent-menu-item-open" : ""
809+
}`}
802810
>
803811
<button
804812
type="button"
@@ -862,6 +870,23 @@ export const MacroPanel = forwardRef<MacroPanelHandle, Props>(
862870
/>
863871
<span className="macro-toolbar-spacer" />
864872
<span className="macro-toolbar-status">{runStatusLabel}</span>
873+
{onTogglePlacement && (
874+
<button
875+
type="button"
876+
className="macro-btn panel-placement-toggle"
877+
onClick={onTogglePlacement}
878+
title={
879+
placement === "floating"
880+
? "Dock this panel as a central tab"
881+
: "Detach this panel as a floating overlay"
882+
}
883+
aria-label={
884+
placement === "floating" ? "Dock Macros" : "Detach Macros"
885+
}
886+
>
887+
{placement === "floating" ? "↙ Dock" : "↗ Detach"}
888+
</button>
889+
)}
865890
</div>
866891
<div className="macro-body">
867892
<div className="macro-editor-wrap" style={{ height: editorHeight }}>

‎src/components/notebook/NotebookPanel.tsx‎

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ interface NotebookPanelProps {
7979
* predicate) can react. Fires once on mount with the initial count.
8080
*/
8181
onCountChanged?: (count: number) => void;
82+
/** Current placement of this panel (``"tab"`` ⇒ central tab,
83+
* ``"floating"`` ⇒ right-side overlay). Optional; when omitted
84+
* the placement toggle button is hidden. */
85+
placement?: "tab" | "floating";
86+
/** Toggle the placement. When omitted, no toggle button is
87+
* rendered. */
88+
onTogglePlacement?: () => void;
8289
}
8390

8491
/**
@@ -137,6 +144,8 @@ export const NotebookPanel = forwardRef<
137144
onModelChanged,
138145
onConvertToMacro,
139146
onCountChanged,
147+
placement,
148+
onTogglePlacement,
140149
},
141150
ref,
142151
) {
@@ -267,8 +276,8 @@ export const NotebookPanel = forwardRef<
267276
const ordered =
268277
openIds.length > 0
269278
? openIds
270-
.map((id) => restoredNbs.find((n) => n.id === id))
271-
.filter((n): n is NotebookModel => Boolean(n))
279+
.map((id) => restoredNbs.find((n) => n.id === id))
280+
.filter((n): n is NotebookModel => Boolean(n))
272281
: restoredNbs;
273282

274283
if (ordered.length === 0) {
@@ -472,12 +481,12 @@ export const NotebookPanel = forwardRef<
472481
cells: nb.cells.map((c) =>
473482
c.id === id
474483
? {
475-
...c,
476-
type: c.type === "code" ? "markdown" : "code",
477-
outputs: [],
478-
execCount: null,
479-
status: "idle" as const,
480-
}
484+
...c,
485+
type: c.type === "code" ? "markdown" : "code",
486+
outputs: [],
487+
execCount: null,
488+
status: "idle" as const,
489+
}
481490
: c,
482491
),
483492
}));
@@ -1139,9 +1148,8 @@ export const NotebookPanel = forwardRef<
11391148
return (
11401149
<div
11411150
key={m.id}
1142-
className={`nb-open-menu-item${
1143-
alreadyOpen ? " nb-open-menu-item-open" : ""
1144-
}`}
1151+
className={`nb-open-menu-item${alreadyOpen ? " nb-open-menu-item-open" : ""
1152+
}`}
11451153
>
11461154
<button
11471155
type="button"
@@ -1194,6 +1202,23 @@ export const NotebookPanel = forwardRef<
11941202
)}
11951203
<span className="nb-toolbar-spacer" />
11961204
<span className="nb-toolbar-status">{kernelLabel}</span>
1205+
{onTogglePlacement && (
1206+
<button
1207+
type="button"
1208+
className="panel-placement-toggle"
1209+
onClick={onTogglePlacement}
1210+
title={
1211+
placement === "floating"
1212+
? "Dock this panel as a central tab"
1213+
: "Detach this panel as a floating overlay"
1214+
}
1215+
aria-label={
1216+
placement === "floating" ? "Dock Notebooks" : "Detach Notebooks"
1217+
}
1218+
>
1219+
{placement === "floating" ? "↙ Dock" : "↗ Detach"}
1220+
</button>
1221+
)}
11971222
</div>
11981223
<div className="nb-cells">
11991224
{activeNotebook ? (

0 commit comments

Comments
 (0)