This is an automated email from the ASF dual-hosted git repository.

tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new 76a7153885 [ZEPPELIN-6743] Mount an opt-in AI Assistant shell in the 
notebook sidebar
76a7153885 is described below

commit 76a7153885a652ddd1c945fbcd08f97bf4528cd8
Author: YONGJAE LEE (이용재) <[email protected]>
AuthorDate: Sun Oct 18 16:07:00 2026 +0900

    [ZEPPELIN-6743] Mount an opt-in AI Assistant shell in the notebook sidebar
    
    ### What is this PR for?
    
    Add an opt-in AI Assistant button and empty panel shell to the notebook 
sidebar. With `?reactAssistant=true`, the Angular notebook mounts the React 
shell, coordinates it with the file tree and table of contents, and shares the 
sidebar width for resizing. The panel closes when the active note changes and 
reports the failure and removes the button if the React remote fails to load.
    
    This establishes the notebook mount point for later conversation UI work. 
Conversation components, server transport, and message handling are outside 
this PR.
    
    #5558 and #5561 have merged. This PR is now based on master.
    
    ### What type of PR is it?
    
    Feature
    
    ### Todos
    
    * [x] Merge #5558 and #5561 before this PR
    * [x] Rebase onto master and retarget this PR to master before merging
    * [x] Confirm CI results
    
    ### What is the Jira issue?
    
    ZEPPELIN-6743
    
    ### How should this be tested?
    
    Run from `zeppelin-web-angular/`:
    
    ```bash
    npm run build-project:sdk
    npm run typecheck:react
    npm --prefix projects/zeppelin-react test -- 
src/pages/AssistantWorkspace.spec.tsx
    npm run test:shell -- src/app/pages/workspace/notebook/assistant 
src/app/pages/workspace/notebook/sidebar/sidebar.component.spec.ts 
src/app/services/react-feature.service.spec.ts
    npm run lint
    npm run build:react
    ```
    
    In an authenticated notebook, open `?reactAssistant=true` and check 
opening, closing, resizing, switching sidebar sections, and switching notes. 
Confirm that the button is absent without the flag and in view-only and 
revision views.
    
    ### Screenshots (if appropriate)
    
    N/A
    
    ### Questions:
    
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    
    
    
    Closes #5565 from voidmatcha/voidmatcha/assistant-sidebar-shell.
    
    Signed-off-by: ChanHo Lee <[email protected]>
---
 .../models/notebook-assistant-page.ts}             |  23 +--
 .../notebook/assistant/assistant-shell.spec.ts     |  60 +++++++
 zeppelin-web-angular/e2e/utils.ts                  |   1 +
 .../projects/zeppelin-react/README.md              |   2 +-
 .../projects/zeppelin-react/src/main.ts            |   1 +
 .../src/pages/AssistantWorkspace.css.ts            | 113 +++++++++++++
 .../src/pages/AssistantWorkspace.spec.tsx          | 183 +++++++++++++++++++++
 .../src/pages/AssistantWorkspace.tsx               | 129 +++++++++++++++
 .../assistant-workspace/lib/usePanelResize.ts      | 100 +++++++++++
 .../projects/zeppelin-react/webpack.config.js      |   3 +-
 .../projects/zeppelin-sdk/src/assistant-ui.ts      |  11 ++
 .../assistant/assistant-host.component.spec.ts     |  84 ++++++++++
 .../notebook/assistant/assistant-host.component.ts | 120 ++++++++++++++
 .../notebook/assistant/assistant-slots.spec.ts     |  39 +++++
 .../notebook/assistant/assistant-slots.ts          |  58 +++++++
 .../workspace/notebook/notebook.component.html     |  13 +-
 .../pages/workspace/notebook/notebook.component.ts |   8 +-
 .../pages/workspace/notebook/notebook.module.ts    |   4 +
 .../notebook/sidebar/sidebar.component.html        |   1 +
 .../notebook/sidebar/sidebar.component.spec.ts     |  35 ++++
 .../notebook/sidebar/sidebar.component.ts          |  29 +++-
 .../src/app/services/react-feature.service.spec.ts |  25 +++
 .../src/app/services/react-feature.service.ts      |   7 +-
 23 files changed, 1030 insertions(+), 19 deletions(-)

diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts 
b/zeppelin-web-angular/e2e/models/notebook-assistant-page.ts
similarity index 51%
copy from zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts
copy to zeppelin-web-angular/e2e/models/notebook-assistant-page.ts
index 913ebc6239..0403b0b185 100644
--- a/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts
+++ b/zeppelin-web-angular/e2e/models/notebook-assistant-page.ts
@@ -10,17 +10,18 @@
  * limitations under the License.
  */
 
-/** The notebook sidebar's width: its default and its resize range. The 
assistant panel shares it. */
-export const NOTEBOOK_SIDEBAR_WIDTH = { initial: 370, min: 280, max: 800 } as 
const;
+import { Locator, Page } from '@playwright/test';
+import { BasePage } from './base-page';
 
-/** A notebook paragraph as the remote sees it: enough to label and link it. */
-export interface AssistantParagraphRef {
-  id: string;
-  title?: string;
-}
+export class NotebookAssistantPage extends BasePage {
+  readonly toggleButton: Locator;
+  readonly panel: Locator;
+  readonly closeButton: Locator;
 
-/** Notebook DOM targets for the React navigation button and panel. */
-export interface AssistantSlot {
-  element: HTMLElement;
-  kind: 'navigation' | 'panel';
+  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 0000000000..41b953acf0
--- /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 8e72643f26..3255297cc6 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 1a81621ec7..68319e196b 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 ba6a218ee0..2e4b426bcc 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 0000000000..82a711ab27
--- /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 0000000000..acb2525860
--- /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<typeof mount>;
+
+    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<HTMLElement>('[role="separator"]')
+        ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', 
bubbles: true }));
+    });
+    expect(onPanelWidthChange).toHaveBeenCalledWith(386);
+
+    onPanelWidthChange.mockClear();
+    const resizeHandle = 
panel.querySelector<HTMLElement>('[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<void>) | 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<typeof mount>;
+    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 0000000000..2afad5a225
--- /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<HTMLButtonElement>(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(
+          <button
+            ref={button}
+            type="button"
+            className={`${scope} ${styles.nav}`}
+            style={themeStyles}
+            aria-label="Toggle AI Assistant"
+            aria-pressed={visible}
+            onClick={() => setVisible(open => !open)}
+          >
+            <span className={styles.navIcon} aria-hidden="true">
+              AI
+            </span>
+          </button>,
+          navigation.element
+        )}
+      {visible &&
+        panel &&
+        createPortal(
+          <aside
+            className={`${scope} ${styles.panel}`}
+            aria-label="AI Assistant workspace"
+            style={themeStyles}
+            onKeyDown={event => {
+              if (event.key === 'Escape') close();
+            }}
+          >
+            <header className={styles.header}>
+              <h2 className={styles.title}>AI Assistant</h2>
+              <button type="button" className={styles.close} aria-label="Close 
AI Assistant" onClick={close}>
+                <CloseOutlined aria-hidden="true" />
+              </button>
+            </header>
+            <div className={styles.resize} {...handleProps} />
+          </aside>,
+          panel.element
+        )}
+    </>
+  );
+};
+
+export const mount = (element: HTMLElement, initialProps: 
AssistantWorkspaceProps) => {
+  const root: Root = createRoot(element);
+  const renderWith = (props: AssistantWorkspaceProps) => {
+    root.render(
+      <ReactErrorBoundary onError={props.onError}>
+        <ZeppelinThemeProvider>
+          <AssistantWorkspace {...props} />
+        </ZeppelinThemeProvider>
+      </ReactErrorBoundary>
+    );
+  };
+
+  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 0000000000..a21ec451df
--- /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<HTMLElement>) => {
+      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<HTMLElement>) => {
+      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<HTMLElement>) => {
+      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 0b39ca2a98..9c2c4dba60 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 913ebc6239..2139ce57a1 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 0000000000..8538ce94d7
--- /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 0000000000..2ffbdfe0c2
--- /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) {
+      <div zeppelin-react-mount="./AssistantWorkspace" 
[reactProps]="assistantProps"></div>
+    }
+  `,
+  standalone: false
+})
+export class AssistantHostComponent implements OnInit, OnChanges, OnDestroy {
+  @Input() enabled = false;
+  @Input() noteId!: string;
+  @Input() panelWidth!: number;
+  @Output() readonly panelWidthChange = new EventEmitter<number>();
+  assistantProps: AssistantShellProps | null = null;
+  failed = false;
+
+  private readonly destroy$ = new Subject<void>();
+  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 0000000000..76a4201758
--- /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 0000000000..2610588514
--- /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<AssistantSlot[]>([]);
+  readonly panelOpen = new BehaviorSubject(false);
+  readonly panelCloseRequests = new Subject<void>();
+
+  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<HTMLElement>,
+    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 f17bf0cdf6..f966cee552 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 @@
       <div
         class="sidebar-area"
         nz-resizable
-        [nzMaxWidth]="!isSidebarOpen ? 40 : 800"
-        [nzMinWidth]="!isSidebarOpen ? 40 : 280"
+        [nzMaxWidth]="!isSidebarOpen ? 40 : sidebarWidthRange.max"
+        [nzMinWidth]="!isSidebarOpen ? 40 : sidebarWidthRange.min"
         (nzResize)="onResizeSidebar($event)"
         [style.min-width.px]="!isSidebarOpen ? 40 : sidebarWidth"
       >
@@ -45,6 +45,7 @@
           <nz-resize-handle nzDirection="right"><div 
class="sidebar-resize"></div></nz-resize-handle>
         }
       </div>
+      <div zeppelin-assistant-slot="panel"></div>
       <div class="notebook-area">
         @if (activatedExtension !== 'hide' && permissions) {
           <div class="extension-area">
@@ -114,5 +115,13 @@
         </div>
       </div>
     </div>
+    @if (!viewOnly && !revisionView) {
+      <zeppelin-assistant-host
+        [noteId]="note.id"
+        [enabled]="useReactAssistant"
+        [panelWidth]="sidebarWidth"
+        (panelWidthChange)="sidebarWidth = $event"
+      ></zeppelin-assistant-host>
+    }
   </div>
 }
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 cf05e7c0a2..21d761b99c 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<typeof setTimeout> | 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 28c769a333..8dbfc6ea58 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 d77084ec30..cd5cfebe91 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 @@
     >
       <i nz-icon nzType="folder" nzTheme="outline"></i>
     </button>
+    <span zeppelin-assistant-slot="navigation"></span>
   </div>
   <button
     class="sidebar-button sidebar-close"
diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/sidebar/sidebar.component.spec.ts
 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/sidebar/sidebar.component.spec.ts
new file mode 100644
index 0000000000..1c23a6c751
--- /dev/null
+++ 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/sidebar/sidebar.component.spec.ts
@@ -0,0 +1,35 @@
+/*
+ * 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 { describe, expect, it, vi } from 'vitest';
+import type { ChangeDetectorRef } from '@angular/core';
+import { AssistantSlots } from '../assistant/assistant-slots';
+import { NotebookSidebarComponent } from './sidebar.component';
+
+describe('NotebookSidebarComponent assistant coordination', () => {
+  it('closes the file tree when the Assistant opens and closes the Assistant 
when the TOC opens', () => {
+    const slots = new AssistantSlots();
+    const sidebar = new NotebookSidebarComponent({ markForCheck: vi.fn() } as 
unknown as ChangeDetectorRef, slots);
+    const open = vi.spyOn(sidebar.isSidebarOpenChange, 'emit');
+    const closeAssistant = vi.spyOn(slots.panelCloseRequests, 'next');
+    sidebar.ngOnInit();
+
+    sidebar.setOrToggleSidebarState(sidebar.SidebarState.FILE_TREE);
+    slots.setPanelOpen(true);
+    expect(sidebar.sidebarState).toBe(sidebar.SidebarState.CLOSED);
+    sidebar.setOrToggleSidebarState(sidebar.SidebarState.TOC);
+    expect(closeAssistant).toHaveBeenCalledOnce();
+    expect(open.mock.calls).toEqual([[true], [false], [true]]);
+
+    sidebar.ngOnDestroy();
+  });
+});
diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/sidebar/sidebar.component.ts
 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/sidebar/sidebar.component.ts
index b05eee58f6..a188d589dd 100644
--- 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/sidebar/sidebar.component.ts
+++ 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/sidebar/sidebar.component.ts
@@ -10,9 +10,12 @@
  * limitations under the License.
  */
 
-import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, 
Output } from '@angular/core';
+import { Subject } from 'rxjs';
+import { filter, takeUntil } from 'rxjs/operators';
 
 import { Note } from '@zeppelin/sdk';
+import { AssistantSlots } from '../assistant/assistant-slots';
 
 enum SidebarState {
   CLOSED = 'CLOSED',
@@ -26,12 +29,33 @@ enum SidebarState {
   styleUrls: ['./sidebar.component.less'],
   standalone: false
 })
-export class NotebookSidebarComponent {
+export class NotebookSidebarComponent implements OnInit, OnDestroy {
   @Input() note!: Exclude<Note['note'], undefined>;
   @Output() readonly isSidebarOpenChange = new EventEmitter<boolean>();
   @Output() readonly scrollToParagraph = new EventEmitter<string>();
   sidebarState = SidebarState.CLOSED;
   SidebarState = SidebarState;
+  private readonly destroy$ = new Subject<void>();
+
+  constructor(
+    private readonly cdr: ChangeDetectorRef,
+    private readonly assistantSlots: AssistantSlots
+  ) {}
+
+  ngOnInit(): void {
+    this.assistantSlots.panelOpen.pipe(filter(Boolean), 
takeUntil(this.destroy$)).subscribe(() => {
+      if (this.sidebarState !== SidebarState.CLOSED) {
+        this.sidebarState = SidebarState.CLOSED;
+        this.isSidebarOpenChange.emit(false);
+        this.cdr.markForCheck();
+      }
+    });
+  }
+
+  ngOnDestroy(): void {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
 
   setOrToggleSidebarState(sidebarState: SidebarState) {
     if (this.sidebarState === sidebarState) {
@@ -42,6 +66,7 @@ export class NotebookSidebarComponent {
     if (this.sidebarState === SidebarState.CLOSED) {
       this.isSidebarOpenChange.emit(false);
     } else {
+      this.assistantSlots.requestPanelClose();
       this.isSidebarOpenChange.emit(true);
     }
   }
diff --git 
a/zeppelin-web-angular/src/app/services/react-feature.service.spec.ts 
b/zeppelin-web-angular/src/app/services/react-feature.service.spec.ts
new file mode 100644
index 0000000000..239ec2d4c5
--- /dev/null
+++ b/zeppelin-web-angular/src/app/services/react-feature.service.spec.ts
@@ -0,0 +1,25 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+import { ReactFeatureService } from './react-feature.service';
+
+describe('Assistant React feature flag', () => {
+  const service = new ReactFeatureService();
+
+  it('is disabled by default and enabled only by the Assistant query 
parameter', () => {
+    expect(service.isEnabled('assistantPanel')).toBe(false);
+    expect(service.isEnabled('assistantPanel', new Map([['reactFooter', 
'true']]))).toBe(false);
+    expect(service.isEnabled('assistantPanel', new Map([['reactAssistant', 
'true']]))).toBe(true);
+    expect(service.isEnabled('assistantPanel', new Map([['reactAssistant', 
'false']]))).toBe(false);
+  });
+});
diff --git a/zeppelin-web-angular/src/app/services/react-feature.service.ts 
b/zeppelin-web-angular/src/app/services/react-feature.service.ts
index 9559fbb927..239a49cee1 100644
--- a/zeppelin-web-angular/src/app/services/react-feature.service.ts
+++ b/zeppelin-web-angular/src/app/services/react-feature.service.ts
@@ -13,7 +13,8 @@
 import { Injectable } from '@angular/core';
 import { parseBooleanFlag } from './query-flag.util';
 
-export type ReactSurface = 'publishedParagraph' | 'paragraphFooter' | 
'configurationTable' | 'notebookRepoList';
+export type ReactSurface =
+  'publishedParagraph' | 'paragraphFooter' | 'configurationTable' | 
'notebookRepoList' | 'assistantPanel';
 
 interface ReactSurfaceConfig {
   queryParam: string;
@@ -36,6 +37,10 @@ const SURFACES: Record<ReactSurface, ReactSurfaceConfig> = {
   notebookRepoList: {
     queryParam: 'reactNotebookRepos',
     defaultEnabled: false
+  },
+  assistantPanel: {
+    queryParam: 'reactAssistant',
+    defaultEnabled: false
   }
 };
 

Reply via email to