diff --git a/zeppelin-web-angular/e2e/models/notebook-assistant-page.ts b/zeppelin-web-angular/e2e/models/notebook-assistant-page.ts new file mode 100644 index 00000000000..0403b0b1854 --- /dev/null +++ b/zeppelin-web-angular/e2e/models/notebook-assistant-page.ts @@ -0,0 +1,27 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Locator, Page } from '@playwright/test'; +import { BasePage } from './base-page'; + +export class NotebookAssistantPage extends BasePage { + readonly toggleButton: Locator; + readonly panel: Locator; + readonly closeButton: Locator; + + constructor(page: Page) { + super(page); + this.toggleButton = page.getByRole('button', { name: 'Toggle AI Assistant' }); + this.panel = page.getByRole('complementary', { name: 'AI Assistant workspace' }); + this.closeButton = page.getByRole('button', { name: 'Close AI Assistant' }); + } +} diff --git a/zeppelin-web-angular/e2e/tests/notebook/assistant/assistant-shell.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/assistant/assistant-shell.spec.ts new file mode 100644 index 00000000000..41b953acf07 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/notebook/assistant/assistant-shell.spec.ts @@ -0,0 +1,60 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, test } from '@playwright/test'; +import { NotebookAssistantPage } from '../../../models/notebook-assistant-page'; +import { addPageAnnotationBeforeEach, createTestNotebook, PAGES, waitForZeppelinReady } from '../../../utils'; + +test.describe('Notebook AI Assistant shell', () => { + addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_ASSISTANT); + + let assistant: NotebookAssistantPage; + + test.beforeEach(async ({ page }) => { + assistant = new NotebookAssistantPage(page); + }); + + test('opens and closes from the notebook sidebar', async ({ page }) => { + await page.goto('/#/'); + await waitForZeppelinReady(page); + const { noteId } = await createTestNotebook(page); + try { + await test.step('Given a notebook with the Assistant enabled', async () => { + await page.goto(`/#/notebook/${noteId}?reactAssistant=true`); + await waitForZeppelinReady(page); + await expect(assistant.toggleButton).toBeVisible(); + await expect(assistant.panel).toBeHidden(); + }); + + await test.step('When I open the Assistant', async () => { + await assistant.toggleButton.click(); + }); + + await test.step('Then the panel appears', async () => { + await expect(assistant.toggleButton).toHaveAttribute('aria-pressed', 'true'); + await expect(assistant.panel).toBeVisible(); + }); + + await test.step('When I close the Assistant', async () => { + await assistant.closeButton.click(); + }); + + await test.step('Then the notebook returns to its closed state', async () => { + await expect(assistant.panel).toBeHidden(); + await expect(assistant.toggleButton).toHaveAttribute('aria-pressed', 'false'); + }); + } finally { + const response = await page.request.delete(`/api/notebook/${noteId}`); + expect(response.ok(), 'Delete the disposable notebook').toBe(true); + } + }); +}); diff --git a/zeppelin-web-angular/e2e/utils.ts b/zeppelin-web-angular/e2e/utils.ts index 8e72643f26e..3255297cc61 100644 --- a/zeppelin-web-angular/e2e/utils.ts +++ b/zeppelin-web-angular/e2e/utils.ts @@ -52,6 +52,7 @@ export const PAGES = { NOTEBOOK: 'src/app/pages/workspace/notebook/notebook.component', NOTEBOOK_ACTION_BAR: 'src/app/pages/workspace/notebook/action-bar/action-bar.component', NOTEBOOK_ADD_PARAGRAPH: 'src/app/pages/workspace/notebook/add-paragraph/add-paragraph.component', + NOTEBOOK_ASSISTANT: 'src/app/pages/workspace/notebook/assistant/assistant-host.component', NOTEBOOK_CORE_READ: 'src/app/pages/workspace/notebook/core-read/notebook-core-read.component', NOTEBOOK_INTERPRETER_BINDING: 'src/app/pages/workspace/notebook/interpreter-binding/interpreter-binding.component', NOTEBOOK_NOTE_FORM: 'src/app/pages/workspace/notebook/note-form-block/note-form-block.component', diff --git a/zeppelin-web-angular/projects/zeppelin-react/README.md b/zeppelin-web-angular/projects/zeppelin-react/README.md index 1a81621ec73..68319e196b1 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/README.md +++ b/zeppelin-web-angular/projects/zeppelin-react/README.md @@ -86,7 +86,7 @@ Each React surface is behind a URL query flag, resolved by `ReactFeatureService` | `?react=false` | disabled | | flag absent | disabled | -Append `?react=true` to any published paragraph URL, `?reactFooter=true` to a notebook URL, `?reactConfiguration=true` to the configuration URL, or `?reactNotebookRepos=true` to the notebook repository URL to activate React mode. +Append `?react=true` to any published paragraph URL, `?reactFooter=true` or `?reactAssistant=true` to a notebook URL, `?reactConfiguration=true` to the configuration URL, or `?reactNotebookRepos=true` to the notebook repository URL to activate React mode. ## Setup diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts b/zeppelin-web-angular/projects/zeppelin-react/src/main.ts index ba6a218ee08..2e4b426bcc3 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts +++ b/zeppelin-web-angular/projects/zeppelin-react/src/main.ts @@ -10,6 +10,7 @@ * limitations under the License. */ +export { AssistantWorkspace, mount as mountAssistantWorkspace } from './pages/AssistantWorkspace'; export { ConfigurationTable, mount as mountConfigurationTable } from './pages/ConfigurationTable'; export { NotebookRepoList, mount as mountNotebookRepoList } from './pages/NotebookRepoList'; export { PublishedParagraph, mount } from './pages/PublishedParagraph'; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.css.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.css.ts new file mode 100644 index 00000000000..82a711ab271 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.css.ts @@ -0,0 +1,113 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { globalStyle, style } from '@vanilla-extract/css'; +import { vars } from '@/shared/ui/assistant-theme/AssistantScope.css'; + +export const nav = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 40, + height: 32, + padding: 0, + border: 0, + background: 'transparent', + cursor: 'pointer' +}); + +export const navIcon = style({ + display: 'inline-grid', + placeItems: 'center', + width: 20, + height: 16, + border: `1px solid ${vars.borderStrong}`, + borderRadius: 3, + background: vars.bg, + color: vars.text, + font: '700 10px/1 Arial, sans-serif' +}); + +globalStyle(`${nav}[aria-pressed='true'] ${navIcon}`, { borderColor: vars.accent }); + +export const panel = style({ + position: 'sticky', + zIndex: 25, + top: 0, + width: 'calc(var(--assistant-sidebar-width) - 40px)', + height: 'calc(100dvh - var(--assistant-panel-top, 0px))', + display: 'flex', + flexDirection: 'column', + minHeight: 0, + overflow: 'hidden', + borderRight: `1px solid ${vars.border}`, + background: vars.bg, + boxShadow: '4px 0 2px 0 rgba(0, 0, 0, 0.06)', + paddingTop: vars.space2, + '@media': { + '(max-width: 640px)': { + width: 'min(var(--assistant-sidebar-width), 100vw)', + marginRight: 'calc(-1 * min(var(--assistant-sidebar-width), 100vw))', + marginLeft: -40 + } + } +}); + +export const header = style({ + display: 'flex', + alignItems: 'center', + // The scope's border-box includes the padding and border, which the 36px content height excluded. + minHeight: 45, + padding: `${vars.space1} ${vars.space2} ${vars.space1} ${vars.space3}`, + borderBottom: `1px solid ${vars.border}` +}); + +export const title = style({ + margin: 0, + fontSize: 'inherit', + fontWeight: 600 +}); + +export const close = style({ + position: 'absolute', + zIndex: 1, + top: 3, + right: 3, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 40, + height: 50, + padding: 0, + border: 0, + background: 'transparent', + color: vars.textSecondary, + cursor: 'pointer', + fontSize: 20, + transition: `color ${vars.durationFast} ${vars.easeStandard}`, + selectors: { + '&:hover': { color: vars.accentTextHover } + } +}); + +export const resize = style({ + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + width: 5, + cursor: 'col-resize', + touchAction: 'none', + '@media': { + '(max-width: 640px)': { display: 'none' } + } +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.spec.tsx new file mode 100644 index 00000000000..acb25258601 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.spec.tsx @@ -0,0 +1,183 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { act } from 'react'; +import { fireEvent, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { NOTEBOOK_SIDEBAR_WIDTH, type AssistantShellProps } from '@zeppelin/sdk'; +import { mount } from './AssistantWorkspace'; + +describe('AssistantWorkspace remote', () => { + it('portals the navigation and panel, coordinates close, and shares the sidebar width', async () => { + const root = document.createElement('div'); + const navigation = document.createElement('div'); + const panel = document.createElement('div'); + const onPanelVisibilityChange = vi.fn(); + const onPanelWidthChange = vi.fn(); + let requestClose = () => {}; + const props: AssistantShellProps = { + noteId: 'note-a', + slots: [ + { element: navigation, kind: 'navigation' }, + { element: panel, kind: 'panel' } + ], + panelWidth: 370, + onPanelVisibilityChange, + onPanelWidthChange, + subscribePanelClose: listener => { + requestClose = listener; + return () => { + requestClose = () => {}; + }; + } + }; + let handle: ReturnType; + + await act(async () => { + handle = mount(root, props); + }); + expect(navigation.querySelector('button')).not.toBeNull(); + expect(panel.textContent).toBe(''); + + await act(async () => navigation.querySelector('button')?.click()); + expect(panel.querySelector('aside')).not.toBeNull(); + expect(onPanelVisibilityChange).toHaveBeenLastCalledWith(true); + expect(panel.style.getPropertyValue('--assistant-sidebar-width')).toBe('370px'); + + await act(async () => { + panel + .querySelector('[role="separator"]') + ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + }); + expect(onPanelWidthChange).toHaveBeenCalledWith(386); + + onPanelWidthChange.mockClear(); + const resizeHandle = panel.querySelector('[role="separator"]')!; + await act(async () => fireEvent.pointerDown(resizeHandle, { button: 0, buttons: 1, clientX: 100 })); + await act(async () => fireEvent.pointerMove(resizeHandle, { buttons: 1, clientX: 140 })); + expect(panel.style.getPropertyValue('--assistant-sidebar-width')).toBe('426px'); + expect(resizeHandle.getAttribute('aria-valuenow')).toBe('426'); + expect(onPanelWidthChange).not.toHaveBeenCalled(); + await act(async () => fireEvent.pointerUp(resizeHandle, { button: 0, clientX: 140 })); + expect(onPanelWidthChange).toHaveBeenCalledExactlyOnceWith(426); + + onPanelWidthChange.mockClear(); + await act(async () => fireEvent.pointerDown(resizeHandle, { button: 0, buttons: 1, clientX: 100 })); + await act(async () => fireEvent.pointerMove(resizeHandle, { buttons: 1, clientX: 120 })); + await act(async () => fireEvent.pointerMove(resizeHandle, { buttons: 0, clientX: 120 })); + expect(onPanelWidthChange).toHaveBeenCalledExactlyOnceWith(446); + + await act(async () => requestClose()); + expect(panel.textContent).toBe(''); + expect(onPanelVisibilityChange).toHaveBeenLastCalledWith(false); + + await act(async () => navigation.querySelector('button')?.click()); + await act(async () => handle.update({ ...props, noteId: 'note-b' })); + expect(panel.textContent).toBe(''); + + await act(async () => { + handle.unmount(); + }); + expect(navigation.innerHTML).toBe(''); + }); +}); + +describe('AssistantWorkspace', () => { + let cleanup: (() => Promise) | undefined; + + afterEach(async () => { + await cleanup?.(); + cleanup = undefined; + }); + + const setup = async () => { + const navigation = document.createElement('div'); + const panel = document.createElement('div'); + document.body.append(navigation, panel); + const onPanelVisibilityChange = vi.fn(); + const onPanelWidthChange = vi.fn(); + let handle: ReturnType; + await act(async () => { + handle = mount(document.createElement('div'), { + noteId: 'note-a', + slots: [ + { element: navigation, kind: 'navigation' }, + { element: panel, kind: 'panel' } + ], + panelWidth: 370, + onPanelVisibilityChange, + onPanelWidthChange, + subscribePanelClose: () => () => {} + }); + }); + const unmount = async () => { + await act(async () => handle.unmount()); + navigation.remove(); + panel.remove(); + }; + cleanup = unmount; + const toggle = () => screen.getByRole('button', { name: 'Toggle AI Assistant' }); + const open = async () => act(async () => fireEvent.click(toggle())); + return { toggle, open, unmount, onPanelVisibilityChange, onPanelWidthChange }; + }; + + it('marks the toggle as pressed while the panel is open', async () => { + const { toggle, open } = await setup(); + expect(toggle().getAttribute('aria-pressed')).toBe('false'); + + await open(); + expect(toggle().getAttribute('aria-pressed')).toBe('true'); + expect(screen.getByRole('complementary', { name: 'AI Assistant workspace' })).toBeTruthy(); + }); + + it('closes on Escape and returns focus to the toggle', async () => { + const { toggle, open, onPanelVisibilityChange } = await setup(); + await open(); + + await act(async () => fireEvent.keyDown(screen.getByRole('complementary'), { key: 'Escape' })); + expect(screen.queryByRole('complementary')).toBeNull(); + expect(document.activeElement).toBe(toggle()); + expect(onPanelVisibilityChange).toHaveBeenLastCalledWith(false); + }); + + it('returns focus to the toggle when the close button is used', async () => { + const { toggle, open } = await setup(); + await open(); + + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Close AI Assistant' }))); + expect(screen.queryByRole('complementary')).toBeNull(); + expect(document.activeElement).toBe(toggle()); + }); + + it('keeps keyboard resizing within the sidebar limits', async () => { + const { open, onPanelWidthChange } = await setup(); + await open(); + const handle = screen.getByRole('separator', { name: 'Resize AI Assistant panel' }); + + await act(async () => fireEvent.keyDown(handle, { key: 'End' })); + expect(onPanelWidthChange).toHaveBeenLastCalledWith(NOTEBOOK_SIDEBAR_WIDTH.max); + await act(async () => fireEvent.keyDown(handle, { key: 'ArrowRight' })); + expect(handle.getAttribute('aria-valuenow')).toBe(String(NOTEBOOK_SIDEBAR_WIDTH.max)); + + await act(async () => fireEvent.keyDown(handle, { key: 'Home' })); + expect(onPanelWidthChange).toHaveBeenLastCalledWith(NOTEBOOK_SIDEBAR_WIDTH.min); + }); + + it('reports the panel as closed when the remote unmounts while open', async () => { + const { open, unmount, onPanelVisibilityChange } = await setup(); + await open(); + cleanup = undefined; + + await unmount(); + expect(onPanelVisibilityChange).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.tsx new file mode 100644 index 00000000000..2afad5a2256 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.tsx @@ -0,0 +1,129 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { createRoot, Root } from 'react-dom/client'; +import { CloseOutlined } from '@ant-design/icons'; +import { theme } from 'antd'; +import type { AssistantShellProps } from '@zeppelin/sdk'; +import { ReactErrorBoundary } from '@/components'; +import { ZeppelinThemeProvider } from '@/theme'; +import { assistantThemeStyles } from '@/shared/ui/assistant-theme'; +import { scope } from '@/shared/ui/assistant-theme/AssistantScope.css'; +import { usePanelResize } from './assistant-workspace/lib/usePanelResize'; +import * as styles from './AssistantWorkspace.css'; + +export type AssistantWorkspaceProps = AssistantShellProps; + +export const AssistantWorkspace = ({ + noteId, + slots, + panelWidth, + onPanelWidthChange, + onPanelVisibilityChange, + subscribePanelClose +}: AssistantWorkspaceProps) => { + const { token } = theme.useToken(); + const themeStyles = assistantThemeStyles(token); + const [visible, setVisible] = useState(false); + const button = useRef(null); + const navigation = slots.find(slot => slot.kind === 'navigation'); + const panel = slots.find(slot => slot.kind === 'panel'); + const { handleProps } = usePanelResize(panel?.element, panelWidth, onPanelWidthChange); + + useEffect(() => onPanelVisibilityChange(visible), [onPanelVisibilityChange, visible]); + useEffect(() => () => onPanelVisibilityChange(false), [onPanelVisibilityChange]); + useEffect(() => subscribePanelClose(() => setVisible(false)), [subscribePanelClose]); + useEffect(() => setVisible(false), [noteId]); + useLayoutEffect(() => { + const element = panel?.element; + if (!element || !visible) return; + const measure = () => + element.style.setProperty('--assistant-panel-top', `${Math.max(0, element.getBoundingClientRect().top)}px`); + measure(); + window.addEventListener('scroll', measure, true); + window.addEventListener('resize', measure); + return () => { + window.removeEventListener('scroll', measure, true); + window.removeEventListener('resize', measure); + element.style.removeProperty('--assistant-panel-top'); + }; + }, [panel?.element, visible]); + + const close = () => { + setVisible(false); + button.current?.focus(); + }; + + return ( + <> + {navigation && + createPortal( + , + navigation.element + )} + {visible && + panel && + createPortal( + , + panel.element + )} + + ); +}; + +export const mount = (element: HTMLElement, initialProps: AssistantWorkspaceProps) => { + const root: Root = createRoot(element); + const renderWith = (props: AssistantWorkspaceProps) => { + root.render( + + + + + + ); + }; + + renderWith(initialProps); + return { + update: renderWith, + unmount: () => root.unmount() + }; +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/lib/usePanelResize.ts b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/lib/usePanelResize.ts new file mode 100644 index 00000000000..a21ec451dfd --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/lib/usePanelResize.ts @@ -0,0 +1,100 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react'; +import { NOTEBOOK_SIDEBAR_WIDTH } from '@zeppelin/sdk'; + +const KEY_STEP_PX = 16; +const clamp = (width: number) => + Math.min(NOTEBOOK_SIDEBAR_WIDTH.max, Math.max(NOTEBOOK_SIDEBAR_WIDTH.min, Math.round(width))); +const setWidthOn = (element: HTMLElement | undefined, width: number) => + element?.style.setProperty('--assistant-sidebar-width', `${width}px`); + +/** + * The sidebar's total width, shared with the panel through the host, and the props of its resize handle. A drag + * writes the width straight to `element` so the panel does not re-render per pointer move, and reports it once at the + * end; arrow keys step it. The host's width wins whenever it changes. + */ +export const usePanelResize = ( + element: HTMLElement | undefined, + hostWidth: number | undefined, + onChange?: (width: number) => void +) => { + const [width, setWidth] = useState(() => clamp(hostWidth ?? NOTEBOOK_SIDEBAR_WIDTH.initial)); + const drag = useRef<{ startX: number; startWidth: number; latest: number } | null>(null); + + useEffect(() => { + if (hostWidth !== undefined) setWidth(clamp(hostWidth)); + }, [hostWidth]); + useLayoutEffect(() => { + setWidthOn(element, width); + return () => { + element?.style.removeProperty('--assistant-sidebar-width'); + }; + }, [element, width]); + + const commit = (next: number) => { + setWidth(next); + if (next !== width) onChange?.(next); + }; + const end = () => { + if (!drag.current) return; + const { latest } = drag.current; + drag.current = null; + commit(latest); + }; + + const handleProps = { + role: 'separator', + 'aria-label': 'Resize AI Assistant panel', + 'aria-orientation': 'vertical', + 'aria-valuenow': width, + 'aria-valuetext': `${width} pixels wide`, + 'aria-valuemin': NOTEBOOK_SIDEBAR_WIDTH.min, + 'aria-valuemax': NOTEBOOK_SIDEBAR_WIDTH.max, + tabIndex: 0, + onPointerDown: (event: PointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + // Captured, so the move and up events keep coming here even when the pointer leaves the strip. + event.currentTarget.setPointerCapture?.(event.pointerId); + drag.current = { startX: event.clientX, startWidth: width, latest: width }; + }, + onPointerMove: (event: PointerEvent) => { + if (!drag.current) return; + // Released where the strip could not see it (no pointer capture): the drag is over. + if (event.buttons === 0) { + end(); + return; + } + drag.current.latest = clamp(drag.current.startWidth + event.clientX - drag.current.startX); + setWidthOn(element, drag.current.latest); + event.currentTarget.setAttribute('aria-valuenow', String(drag.current.latest)); + event.currentTarget.setAttribute('aria-valuetext', `${drag.current.latest} pixels wide`); + }, + onPointerUp: end, + onPointerCancel: end, + onKeyDown: (event: KeyboardEvent) => { + const target = { + ArrowLeft: width - KEY_STEP_PX, + ArrowRight: width + KEY_STEP_PX, + Home: NOTEBOOK_SIDEBAR_WIDTH.min, + End: NOTEBOOK_SIDEBAR_WIDTH.max + }[event.key]; + if (target === undefined) return; + event.preventDefault(); + commit(clamp(target)); + } + } as const; + + return { handleProps }; +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js b/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js index 0b39ca2a98f..9c2c4dba609 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js +++ b/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js @@ -82,7 +82,8 @@ module.exports = (_env, argv) => { './PublishedParagraph': './src/pages/PublishedParagraph', './ParagraphFooter': './src/components/paragraph/ParagraphFooter', './ConfigurationTable': './src/pages/ConfigurationTable', - './NotebookRepoList': './src/pages/NotebookRepoList' + './NotebookRepoList': './src/pages/NotebookRepoList', + './AssistantWorkspace': './src/pages/AssistantWorkspace' } }), new HtmlWebpackPlugin({ diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts index 913ebc62398..2139ce57a12 100644 --- a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts @@ -24,3 +24,14 @@ export interface AssistantSlot { element: HTMLElement; kind: 'navigation' | 'panel'; } + +/** The layout contract; transport and conversation props are added with notebook integration. */ +export type AssistantShellProps = { + noteId: string; + slots: AssistantSlot[]; + panelWidth: number; + onPanelWidthChange: (width: number) => void; + onPanelVisibilityChange: (visible: boolean) => void; + subscribePanelClose: (listener: () => void) => () => void; + onError?: (error: unknown) => void; +}; diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-host.component.spec.ts b/zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-host.component.spec.ts new file mode 100644 index 00000000000..8538ce94d78 --- /dev/null +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-host.component.spec.ts @@ -0,0 +1,84 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SimpleChange, type ChangeDetectorRef } from '@angular/core'; +import type { NzMessageService } from 'ng-zorro-antd/message'; +import { expect, it, vi } from 'vitest'; +import { AssistantHostComponent } from './assistant-host.component'; +import { AssistantSlots } from './assistant-slots'; + +const createHost = () => { + const slots = new AssistantSlots(); + const message = { error: vi.fn() }; + const host = new AssistantHostComponent( + slots, + { markForCheck: vi.fn() } as unknown as ChangeDetectorRef, + message as unknown as NzMessageService + ); + host.enabled = true; + host.noteId = 'note-a'; + host.panelWidth = 370; + host.ngOnChanges({ enabled: new SimpleChange(undefined, true, true) }); + host.ngOnInit(); + return { host, slots, message }; +}; + +it('updates remote slots and width, then retires the panel when disabled', async () => { + const { host, slots } = createHost(); + + const navigation = document.createElement('span'); + slots.set({ element: navigation, kind: 'navigation' }); + await Promise.resolve(); + expect(host.assistantProps?.slots).toEqual([{ element: navigation, kind: 'navigation' }]); + + host.panelWidth = 400; + host.ngOnChanges({ panelWidth: new SimpleChange(370, 400, false) }); + expect(host.assistantProps?.panelWidth).toBe(400); + host.assistantProps?.onPanelVisibilityChange(true); + expect(slots.panelOpen.value).toBe(true); + + const close = vi.spyOn(slots.panelCloseRequests, 'next'); + host.noteId = 'note-b'; + host.ngOnChanges({ noteId: new SimpleChange('note-a', 'note-b', false) }); + expect(close).toHaveBeenCalledOnce(); + + host.enabled = false; + host.ngOnChanges({ enabled: new SimpleChange(true, false, false) }); + expect(slots.panelOpen.value).toBe(false); + expect(host.assistantProps).toBeNull(); + host.ngOnDestroy(); +}); + +it('closes the panel when the notebook is destroyed', () => { + const { host, slots } = createHost(); + host.assistantProps?.onPanelVisibilityChange(true); + + host.ngOnDestroy(); + expect(slots.panelOpen.value).toBe(false); +}); + +it('logs a remote failure, tells the user, and remounts on the next note', () => { + const { host, message } = createHost(); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const error = new Error('remote unavailable'); + + host.assistantProps?.onError?.(error); + expect(consoleError).toHaveBeenCalledWith('React assistant error', error); + expect(message.error).toHaveBeenCalledWith('Unable to load the AI Assistant.'); + expect(host.failed).toBe(true); + + host.noteId = 'note-b'; + host.ngOnChanges({ noteId: new SimpleChange('note-a', 'note-b', false) }); + expect(host.failed).toBe(false); + consoleError.mockRestore(); + host.ngOnDestroy(); +}); diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-host.component.ts b/zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-host.component.ts new file mode 100644 index 00000000000..2ffbdfe0c2c --- /dev/null +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-host.component.ts @@ -0,0 +1,120 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges +} from '@angular/core'; +import { NzMessageService } from 'ng-zorro-antd/message'; +import { Subject, takeUntil } from 'rxjs'; + +import type { AssistantShellProps } from '@zeppelin/sdk'; +import { AssistantSlots } from './assistant-slots'; + +@Component({ + selector: 'zeppelin-assistant-host', + template: ` + @if (enabled && !failed && assistantProps) { +
+ } + `, + standalone: false +}) +export class AssistantHostComponent implements OnInit, OnChanges, OnDestroy { + @Input() enabled = false; + @Input() noteId!: string; + @Input() panelWidth!: number; + @Output() readonly panelWidthChange = new EventEmitter(); + assistantProps: AssistantShellProps | null = null; + failed = false; + + private readonly destroy$ = new Subject(); + private destroyed = false; + + constructor( + private readonly slots: AssistantSlots, + private readonly cdr: ChangeDetectorRef, + private readonly nzMessageService: NzMessageService + ) {} + + ngOnInit(): void { + this.slots.slots.pipe(takeUntil(this.destroy$)).subscribe(() => { + queueMicrotask(() => { + if (this.destroyed) return; + this.refreshProps(); + }); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.noteId && !changes.noteId.firstChange && changes.noteId.previousValue !== this.noteId) { + this.slots.requestPanelClose(); + this.failed = false; + } + if (!this.enabled) { + this.slots.setPanelOpen(false); + this.assistantProps = null; + return; + } + if (changes.enabled) this.failed = false; + this.refreshProps(); + } + + ngOnDestroy(): void { + this.destroyed = true; + this.slots.setPanelOpen(false); + this.destroy$.next(); + this.destroy$.complete(); + } + + private readonly onPanelVisibilityChange = (visible: boolean): void => { + this.slots.setPanelOpen(visible); + }; + + private readonly subscribePanelClose = (listener: () => void): (() => void) => { + const subscription = this.slots.panelCloseRequests.subscribe(listener); + return () => subscription.unsubscribe(); + }; + + private readonly onPanelWidthChange = (width: number): void => { + this.panelWidthChange.emit(width); + }; + + private readonly onError = (error: unknown): void => { + console.error('React assistant error', error); + // The shell renders into the sidebar slots, so the host has no place of its own for a fallback. + this.nzMessageService.error('Unable to load the AI Assistant.'); + this.failed = true; + this.cdr.markForCheck(); + }; + + private refreshProps(): void { + if (!this.enabled || !this.noteId) return; + this.assistantProps = { + noteId: this.noteId, + slots: this.slots.slots.value, + panelWidth: this.panelWidth, + onPanelWidthChange: this.onPanelWidthChange, + onPanelVisibilityChange: this.onPanelVisibilityChange, + subscribePanelClose: this.subscribePanelClose, + onError: this.onError + }; + this.cdr.markForCheck(); + } +} diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-slots.spec.ts b/zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-slots.spec.ts new file mode 100644 index 00000000000..76a42017581 --- /dev/null +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-slots.spec.ts @@ -0,0 +1,39 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ElementRef } from '@angular/core'; +import { expect, it, vi } from 'vitest'; +import { AssistantSlotDirective, AssistantSlots } from './assistant-slots'; + +it('registers and removes the React portal targets', () => { + const slots = new AssistantSlots(); + const element = document.createElement('span'); + const directive = new AssistantSlotDirective(new ElementRef(element), slots); + directive.assistantSlot = 'navigation'; + directive.ngOnChanges(); + expect(slots.slots.value).toEqual([{ element, kind: 'navigation' }]); + directive.assistantSlot = 'panel'; + directive.ngOnChanges(); + expect(slots.slots.value).toEqual([{ element, kind: 'panel' }]); + directive.ngOnDestroy(); + expect(slots.slots.value).toEqual([]); +}); + +it('requests panel close only while the panel is open', () => { + const slots = new AssistantSlots(); + const close = vi.spyOn(slots.panelCloseRequests, 'next'); + slots.requestPanelClose(); + expect(close).not.toHaveBeenCalled(); + slots.setPanelOpen(true); + slots.requestPanelClose(); + expect(close).toHaveBeenCalledOnce(); +}); diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-slots.ts b/zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-slots.ts new file mode 100644 index 00000000000..26105885145 --- /dev/null +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-slots.ts @@ -0,0 +1,58 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Directive, ElementRef, Injectable, Input, OnChanges, OnDestroy } from '@angular/core'; +import { BehaviorSubject, Subject } from 'rxjs'; + +import type { AssistantSlot } from '@zeppelin/sdk'; + +@Injectable() +export class AssistantSlots { + readonly slots = new BehaviorSubject([]); + readonly panelOpen = new BehaviorSubject(false); + readonly panelCloseRequests = new Subject(); + + setPanelOpen(open: boolean): void { + if (this.panelOpen.value !== open) this.panelOpen.next(open); + } + + requestPanelClose(): void { + if (this.panelOpen.value) this.panelCloseRequests.next(); + } + + set(slot: AssistantSlot): void { + const others = this.slots.value.filter(item => item.element !== slot.element); + this.slots.next([...others, slot]); + } + + remove(element: HTMLElement): void { + this.slots.next(this.slots.value.filter(item => item.element !== element)); + } +} + +@Directive({ selector: '[zeppelin-assistant-slot]', standalone: false }) +export class AssistantSlotDirective implements OnChanges, OnDestroy { + @Input('zeppelin-assistant-slot') assistantSlot!: AssistantSlot['kind']; + + constructor( + private host: ElementRef, + private registry: AssistantSlots + ) {} + + ngOnChanges(): void { + this.registry.set({ element: this.host.nativeElement, kind: this.assistantSlot }); + } + + ngOnDestroy(): void { + this.registry.remove(this.host.nativeElement); + } +} diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.html b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.html index f17bf0cdf6f..f966cee5526 100644 --- a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.html +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.html @@ -31,8 +31,8 @@ +
@if (activatedExtension !== 'hide' && permissions) {
@@ -114,5 +115,13 @@
+ @if (!viewOnly && !revisionView) { + + } } diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts index cf05e7c0a2a..21d761b99cf 100644 --- a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts @@ -34,6 +34,7 @@ import { DynamicFormParams, InterpreterBindingItem, MessageReceiveDataTypeMap, + NOTEBOOK_SIDEBAR_WIDTH, Note, OP, RevisionListItem, @@ -52,9 +53,11 @@ import { import { scrollIntoViewIfNeeded } from '@zeppelin/utility'; import { NotebookParagraphComponent } from './paragraph/paragraph.component'; +import { AssistantSlots } from './assistant/assistant-slots'; @Component({ selector: 'zeppelin-notebook', + providers: [AssistantSlots], templateUrl: './notebook.component.html', styleUrls: ['./notebook.component.less'], changeDetection: ChangeDetectionStrategy.OnPush, @@ -79,10 +82,12 @@ export class NotebookComponent extends MessageListenersManager implements OnInit saveTimer: ReturnType | null = null; interpreterBindings: InterpreterBindingItem[] = []; activatedExtension: 'interpreter' | 'permissions' | 'revisions' | 'hide' = 'hide'; - sidebarWidth = 370; + sidebarWidth: number = NOTEBOOK_SIDEBAR_WIDTH.initial; + readonly sidebarWidthRange = NOTEBOOK_SIDEBAR_WIDTH; sidebarAnimationFrame = -1; isSidebarOpen = false; useReactFooter = false; + useReactAssistant = false; @MessageListener(OP.NOTE) getNote(data: MessageReceiveDataTypeMap[OP.NOTE]) { @@ -473,6 +478,7 @@ export class NotebookComponent extends MessageListenersManager implements OnInit .pipe(startWith(this.activatedRoute.snapshot.queryParamMap), takeUntil(this.destroy$)) .subscribe(data => { this.useReactFooter = this.reactFeature.isEnabled('paragraphFooter', data); + this.useReactAssistant = this.reactFeature.isEnabled('assistantPanel', data); this.cdr.markForCheck(); }); this.activatedRoute.params.pipe(takeUntil(this.destroy$), distinctUntilKeyChanged('noteId')).subscribe(() => { diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.module.ts b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.module.ts index 28c769a3339..8dbfc6ea584 100644 --- a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.module.ts +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.module.ts @@ -58,9 +58,13 @@ import { NotebookRoutingModule } from './notebook-routing.module'; import { NotebookComponent } from './notebook.component'; import { NotebookShareModule } from './share/share.module'; import { NotebookSidebarComponent } from './sidebar/sidebar.component'; +import { AssistantHostComponent } from './assistant/assistant-host.component'; +import { AssistantSlotDirective } from './assistant/assistant-slots'; @NgModule({ declarations: [ + AssistantHostComponent, + AssistantSlotDirective, NotebookComponent, NotebookCoreReadComponent, NotebookActionBarComponent, diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/sidebar/sidebar.component.html b/zeppelin-web-angular/src/app/pages/workspace/notebook/sidebar/sidebar.component.html index d77084ec305..cd5cfebe917 100644 --- a/zeppelin-web-angular/src/app/pages/workspace/notebook/sidebar/sidebar.component.html +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/sidebar/sidebar.component.html @@ -28,6 +28,7 @@ > +