From 3c3e29589886203921a46fc291505c4294ab4b36 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sat, 10 Oct 2026 18:35:47 +0900 Subject: [PATCH 01/10] Add opt-in Assistant panel shell --- .../src/pages/AssistantWorkspace.css | 86 ++++++++++++ .../src/pages/AssistantWorkspace.spec.tsx | 75 +++++++++++ .../src/pages/AssistantWorkspace.tsx | 127 ++++++++++++++++++ .../assistant-workspace/lib/usePanelResize.ts | 102 ++++++++++++++ .../projects/zeppelin-react/webpack.config.js | 3 +- .../projects/zeppelin-sdk/src/assistant-ui.ts | 11 ++ .../assistant-host.component.spec.ts | 48 +++++++ .../assistant/assistant-host.component.ts | 119 ++++++++++++++++ .../assistant/assistant-slots.spec.ts | 39 ++++++ .../notebook/assistant/assistant-slots.ts | 58 ++++++++ .../notebook/notebook.component.html | 13 +- .../workspace/notebook/notebook.component.ts | 8 +- .../workspace/notebook/notebook.module.ts | 4 + .../notebook/sidebar/sidebar.component.html | 1 + .../sidebar/sidebar.component.spec.ts | 35 +++++ .../notebook/sidebar/sidebar.component.ts | 29 +++- .../services/react-feature.service.spec.ts | 25 ++++ .../src/app/services/react-feature.service.ts | 7 +- 18 files changed, 783 insertions(+), 7 deletions(-) create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.css create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.spec.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/lib/usePanelResize.ts create mode 100644 zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-host.component.spec.ts create mode 100644 zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-host.component.ts create mode 100644 zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-slots.spec.ts create mode 100644 zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-slots.ts create mode 100644 zeppelin-web-angular/src/app/pages/workspace/notebook/sidebar/sidebar.component.spec.ts create mode 100644 zeppelin-web-angular/src/app/services/react-feature.service.spec.ts diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.css b/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.css new file mode 100644 index 00000000000..b88d5ad3dae --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.css @@ -0,0 +1,86 @@ +/* + * 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. + */ + +.assistant-shell-nav { + width: 40px; + height: 40px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font-weight: 600; +} + +.assistant-shell-nav:hover, +.assistant-shell-nav[aria-pressed='true'] { + color: #3071a9; +} + +.assistant-shell-panel { + position: sticky; + top: 0; + box-sizing: border-box; + width: var(--assistant-panel-width); + height: calc(100dvh - var(--assistant-panel-top, 0px)); + display: flex; + flex-direction: column; + border-right: 1px solid; + box-shadow: 4px 0 2px 0 rgba(0, 0, 0, 0.06); +} + +.assistant-shell-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border-bottom: 1px solid; +} + +.assistant-shell-header h2 { + margin: 0; + font-size: 14px; +} + +.assistant-shell-header button { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 20px; +} + +.assistant-shell-empty { + padding: 16px; +} + +.assistant-shell-resize { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 5px; + cursor: col-resize; + touch-action: none; +} + +@media (max-width: 640px) { + .assistant-shell-panel { + width: min(var(--assistant-panel-width), 100vw); + margin-right: calc(-1 * min(var(--assistant-panel-width), 100vw)); + margin-left: -40px; + } + + .assistant-shell-resize { + 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..6b1eb6a7686 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.spec.tsx @@ -0,0 +1,75 @@ +/* + * 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 { describe, expect, it, vi } from 'vitest'; +import type { AssistantShellProps } from '@zeppelin/sdk'; +import { mount } from './AssistantWorkspace'; + +describe('AssistantWorkspace remote', () => { + it('portals the navigation and panel, coordinates close, and shares the panel 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.textContent).toContain('No conversation yet.'); + expect(onPanelVisibilityChange).toHaveBeenLastCalledWith(true); + expect(panel.style.getPropertyValue('--assistant-panel-width')).toBe('370px'); + + await act(async () => { + panel + .querySelector('[role="separator"]') + ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + }); + expect(onPanelWidthChange).toHaveBeenCalledWith(386); + + 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(''); + }); +}); 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..3ecd439e7a9 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.tsx @@ -0,0 +1,127 @@ +/* + * 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 { theme } from 'antd'; +import type { AssistantShellProps } from '@zeppelin/sdk'; +import { ReactErrorBoundary } from '@/components'; +import { ZeppelinThemeProvider } from '@/theme'; +import { usePanelResize } from './assistant-workspace/lib/usePanelResize'; +import './AssistantWorkspace.css'; + +export type AssistantWorkspaceProps = AssistantShellProps; + +export const AssistantWorkspace = ({ + noteId, + slots, + panelWidth, + onPanelWidthChange, + onPanelVisibilityChange, + subscribePanelClose +}: AssistantWorkspaceProps) => { + const { token } = theme.useToken(); + 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..788da31f570 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/assistant-workspace/lib/usePanelResize.ts @@ -0,0 +1,102 @@ +/* + * 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-panel-width', `${width}px`); + +/** + * The panel's width, shared with the notebook sidebar 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 [resizing, setResizing] = useState(false); + 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-panel-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; + setResizing(false); + commit(latest); + }; + + const handleProps = { + role: 'separator', + '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 }; + setResizing(true); + }, + 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 { width, resizing, 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..5582af23d95 --- /dev/null +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-host.component.spec.ts @@ -0,0 +1,48 @@ +/* + * 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 { expect, it, vi } from 'vitest'; +import { AssistantHostComponent } from './assistant-host.component'; +import { AssistantSlots } from './assistant-slots'; + +it('updates remote slots and width, then retires the panel when disabled', async () => { + const slots = new AssistantSlots(); + const host = new AssistantHostComponent(slots, { markForCheck: vi.fn() } as unknown as ChangeDetectorRef); + host.enabled = true; + host.noteId = 'note-a'; + host.panelWidth = 370; + host.ngOnChanges({ enabled: new SimpleChange(undefined, true, true) }); + host.ngOnInit(); + + 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(); +}); 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..41c939037d2 --- /dev/null +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/assistant/assistant-host.component.ts @@ -0,0 +1,119 @@ +/* + * 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 { Subject, takeUntil } from 'rxjs'; + +import type { AssistantShellProps } from '@zeppelin/sdk'; +import { AssistantSlots } from './assistant-slots'; + +@Component({ + selector: 'zeppelin-assistant-host', + template: ` + @if (enabled) { + @if (failed) { +

Unable to load the assistant.

+ } @else if (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 + ) {} + + 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 => { + 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 @@ > + , navigation.element )} @@ -88,22 +90,24 @@ export const AssistantWorkspace = ({ , panel.element 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 index d31cdbadb1a..de7133cac56 100644 --- 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 @@ -17,10 +17,10 @@ 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-panel-width', `${width}px`); + element?.style.setProperty('--assistant-sidebar-width', `${width}px`); /** - * The panel's width, shared with the notebook sidebar through the host, and the props of its resize handle. A drag + * 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. */ @@ -38,7 +38,7 @@ export const usePanelResize = ( useLayoutEffect(() => { setWidthOn(element, width); return () => { - element?.style.removeProperty('--assistant-panel-width'); + element?.style.removeProperty('--assistant-sidebar-width'); }; }, [element, width]); From aae52ac9645391822819db0d8d5a69a5d9b2de0f Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sun, 11 Oct 2026 10:08:46 +0900 Subject: [PATCH 04/10] [ZEPPELIN-6743] Track Assistant host in page E2E coverage The opt-in Assistant host handles panel visibility and error states. Include it in the page coverage registry so the reporter tracks its currently untested browser flow. Confidence: high Scope-risk: none Not-tested: Full browser E2E and GitHub CI --- zeppelin-web-angular/e2e/utils.ts | 1 + 1 file changed, 1 insertion(+) 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', From 6ffe6ae7d7a1cbb7ee5b4fa5521049f6e038cea8 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sun, 11 Oct 2026 10:43:30 +0900 Subject: [PATCH 05/10] [ZEPPELIN-6743] Verify Assistant sidebar open and close Cover the flagged notebook shell through its visible sidebar controls. Create and remove a disposable notebook for each test run. Confidence: high Scope-risk: none Not-tested: full E2E suite and runtime fault injection --- .../e2e/models/notebook-assistant-page.ts | 27 +++++++++ .../assistant/assistant-shell.spec.ts | 60 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 zeppelin-web-angular/e2e/models/notebook-assistant-page.ts create mode 100644 zeppelin-web-angular/e2e/tests/notebook/assistant/assistant-shell.spec.ts 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); + } + }); +}); From c839b4666ddd681b6b07dcb2728c4a522618ff23 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 16 Oct 2026 22:46:03 +0900 Subject: [PATCH 06/10] [ZEPPELIN-6743] Re-export the Assistant workspace and document its flag --- zeppelin-web-angular/projects/zeppelin-react/README.md | 2 +- zeppelin-web-angular/projects/zeppelin-react/src/main.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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'; From bffbc568ae186194c107c4b2e9eaa8ebc19dbd72 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 16 Oct 2026 22:46:57 +0900 Subject: [PATCH 07/10] [ZEPPELIN-6743] Report Assistant remote failures where users see them --- .../assistant-host.component.spec.ts | 40 ++++++++++++++++++- .../assistant/assistant-host.component.ts | 17 ++++---- 2 files changed, 47 insertions(+), 10 deletions(-) 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 index 5582af23d95..8538ce94d78 100644 --- 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 @@ -11,18 +11,29 @@ */ 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'; -it('updates remote slots and width, then retires the panel when disabled', async () => { +const createHost = () => { const slots = new AssistantSlots(); - const host = new AssistantHostComponent(slots, { markForCheck: vi.fn() } as unknown as ChangeDetectorRef); + 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' }); @@ -46,3 +57,28 @@ it('updates remote slots and width, then retires the panel when disabled', async 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 index 41c939037d2..2ffbdfe0c2c 100644 --- 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 @@ -21,6 +21,7 @@ import { Output, SimpleChanges } from '@angular/core'; +import { NzMessageService } from 'ng-zorro-antd/message'; import { Subject, takeUntil } from 'rxjs'; import type { AssistantShellProps } from '@zeppelin/sdk'; @@ -29,12 +30,8 @@ import { AssistantSlots } from './assistant-slots'; @Component({ selector: 'zeppelin-assistant-host', template: ` - @if (enabled) { - @if (failed) { -

Unable to load the assistant.

- } @else if (assistantProps) { -
- } + @if (enabled && !failed && assistantProps) { +
} `, standalone: false @@ -52,7 +49,8 @@ export class AssistantHostComponent implements OnInit, OnChanges, OnDestroy { constructor( private readonly slots: AssistantSlots, - private readonly cdr: ChangeDetectorRef + private readonly cdr: ChangeDetectorRef, + private readonly nzMessageService: NzMessageService ) {} ngOnInit(): void { @@ -98,7 +96,10 @@ export class AssistantHostComponent implements OnInit, OnChanges, OnDestroy { this.panelWidthChange.emit(width); }; - private readonly onError = (_error: unknown): void => { + 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(); }; From 90b4b5e51501dd91e1a77761ec849439d34e9c69 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 16 Oct 2026 22:48:30 +0900 Subject: [PATCH 08/10] [ZEPPELIN-6743] Theme the Assistant toggle and label its resize handle --- .../zeppelin-react/src/pages/AssistantWorkspace.css | 6 +++--- .../zeppelin-react/src/pages/AssistantWorkspace.tsx | 6 +++++- .../src/pages/assistant-workspace/lib/usePanelResize.ts | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.css b/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.css index 1de2ccb7183..67b2ed889db 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.css +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.css @@ -27,10 +27,10 @@ place-items: center; width: 20px; height: 16px; - border: 1px solid rgba(48, 71, 91, 0.24); + border: 1px solid var(--assistant-nav-icon-border); border-radius: 3px; - background: #fff; - color: #203447; + background: var(--assistant-nav-icon-bg); + color: var(--assistant-nav-icon-color); font: 700 10px/1 Arial, sans-serif; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.tsx index e13f7a90080..29a548c0ea2 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/pages/AssistantWorkspace.tsx @@ -68,10 +68,14 @@ export const AssistantWorkspace = ({ createPortal( , @@ -92,27 +88,20 @@ export const AssistantWorkspace = ({ panel && createPortal( , panel.element )}