Skip to content

Commit 9811bc0

Browse files
committed
Merge Assistant Storybook and notebook sidebar shell bases
Combine the open Storybook and empty notebook shell prerequisites so component contributors can verify their work in the actual sidebar. The source PR branches remain unchanged. Confidence: medium Scope-risk: Combined frontend build may expose integration failures. Not-tested: Live notebook and full frontend E2E at commit creation.
2 parents 90f2626 + 654d828 commit 9811bc0

25 files changed

Lines changed: 1135 additions & 7 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
* http://www.apache.org/licenses/LICENSE-2.0
6+
* Unless required by applicable law or agreed to in writing, software
7+
* distributed under the License is distributed on an "AS IS" BASIS,
8+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
* See the License for the specific language governing permissions and
10+
* limitations under the License.
11+
*/
12+
13+
import { Locator, Page } from '@playwright/test';
14+
import { BasePage } from './base-page';
15+
16+
export class NotebookAssistantPage extends BasePage {
17+
readonly toggleButton: Locator;
18+
readonly panel: Locator;
19+
readonly closeButton: Locator;
20+
21+
constructor(page: Page) {
22+
super(page);
23+
this.toggleButton = page.getByRole('button', { name: 'Toggle AI Assistant' });
24+
this.panel = page.getByRole('complementary', { name: 'AI Assistant workspace' });
25+
this.closeButton = page.getByRole('button', { name: 'Close AI Assistant' });
26+
}
27+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
* http://www.apache.org/licenses/LICENSE-2.0
6+
* Unless required by applicable law or agreed to in writing, software
7+
* distributed under the License is distributed on an "AS IS" BASIS,
8+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
* See the License for the specific language governing permissions and
10+
* limitations under the License.
11+
*/
12+
13+
import { expect, test } from '@playwright/test';
14+
import { NotebookAssistantPage } from '../../../models/notebook-assistant-page';
15+
import { addPageAnnotationBeforeEach, createTestNotebook, PAGES, waitForZeppelinReady } from '../../../utils';
16+
17+
test.describe('Notebook AI Assistant shell', () => {
18+
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_ASSISTANT);
19+
20+
let assistant: NotebookAssistantPage;
21+
22+
test.beforeEach(async ({ page }) => {
23+
assistant = new NotebookAssistantPage(page);
24+
});
25+
26+
test('opens and closes from the notebook sidebar', async ({ page }) => {
27+
await page.goto('/#/');
28+
await waitForZeppelinReady(page);
29+
const { noteId } = await createTestNotebook(page);
30+
try {
31+
await test.step('Given a notebook with the Assistant enabled', async () => {
32+
await page.goto(`/#/notebook/${noteId}?reactAssistant=true`);
33+
await waitForZeppelinReady(page);
34+
await expect(assistant.toggleButton).toBeVisible();
35+
await expect(assistant.panel).toBeHidden();
36+
});
37+
38+
await test.step('When I open the Assistant', async () => {
39+
await assistant.toggleButton.click();
40+
});
41+
42+
await test.step('Then the panel appears', async () => {
43+
await expect(assistant.toggleButton).toHaveAttribute('aria-pressed', 'true');
44+
await expect(assistant.panel).toBeVisible();
45+
});
46+
47+
await test.step('When I close the Assistant', async () => {
48+
await assistant.closeButton.click();
49+
});
50+
51+
await test.step('Then the notebook returns to its closed state', async () => {
52+
await expect(assistant.panel).toBeHidden();
53+
await expect(assistant.toggleButton).toHaveAttribute('aria-pressed', 'false');
54+
});
55+
} finally {
56+
const response = await page.request.delete(`/api/notebook/${noteId}`);
57+
expect(response.ok(), 'Delete the disposable notebook').toBe(true);
58+
}
59+
});
60+
});

‎zeppelin-web-angular/e2e/utils.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const PAGES = {
5252
NOTEBOOK: 'src/app/pages/workspace/notebook/notebook.component',
5353
NOTEBOOK_ACTION_BAR: 'src/app/pages/workspace/notebook/action-bar/action-bar.component',
5454
NOTEBOOK_ADD_PARAGRAPH: 'src/app/pages/workspace/notebook/add-paragraph/add-paragraph.component',
55+
NOTEBOOK_ASSISTANT: 'src/app/pages/workspace/notebook/assistant/assistant-host.component',
5556
NOTEBOOK_INTERPRETER_BINDING: 'src/app/pages/workspace/notebook/interpreter-binding/interpreter-binding.component',
5657
NOTEBOOK_NOTE_FORM: 'src/app/pages/workspace/notebook/note-form-block/note-form-block.component',
5758
NOTEBOOK_PERMISSIONS: 'src/app/pages/workspace/notebook/permissions/permissions.component',
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
* http://www.apache.org/licenses/LICENSE-2.0
6+
* Unless required by applicable law or agreed to in writing, software
7+
* distributed under the License is distributed on an "AS IS" BASIS,
8+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
* See the License for the specific language governing permissions and
10+
* limitations under the License.
11+
*/
12+
13+
export interface AssistantConversation {
14+
id: string;
15+
title?: string;
16+
/** Account that created the conversation, shown as "Started by …". */
17+
ownerId?: string;
18+
/** Whether the requesting user can send messages to the conversation, as the server decides. */
19+
canSendMessage?: boolean;
20+
/** Last activity (ISO time), shown in the conversation list. */
21+
updatedAt?: string;
22+
}
23+
export interface AssistantMessage {
24+
id: string;
25+
role: 'user' | 'assistant';
26+
content: string;
27+
/** From history: the tools the server called before this answer. Live runs report them as events instead. */
28+
toolCalls?: AssistantToolCallRef[];
29+
}
30+
export interface AssistantToolCallRef {
31+
id: string;
32+
name: string;
33+
}
34+
/** One page of a conversation's history, oldest first. */
35+
export interface AssistantMessagePage {
36+
messages: AssistantMessage[];
37+
/** Pass as `before` to load the page before this one; null when this page starts the conversation. */
38+
earlierCursor: string | null;
39+
}
40+
export interface AssistantRunBody {
41+
prompt: string;
42+
}
43+
export type AssistantRunEvent =
44+
| { type: 'run.started'; runId?: string }
45+
| { type: 'run.completed'; runId?: string }
46+
| { type: 'run.failed'; runId?: string; message: string }
47+
| { type: 'message.delta'; messageId: string; delta: string }
48+
| { type: 'message.done'; messageId: string; content: string }
49+
| { type: 'tool_call.started'; toolCallId: string; name: string }
50+
// The server does not repeat the tool name on completion.
51+
| { type: 'tool_call.done'; toolCallId: string; name?: string };
52+
53+
export type AssistantRunState = 'idle' | 'running' | 'disconnected';
54+
55+
export interface AssistantTransport {
56+
/** Conversations of the note the transport was created for. */
57+
listConversations(): Promise<AssistantConversation[]>;
58+
createConversation(body: { title?: string }): Promise<AssistantConversation>;
59+
deleteConversation(conversationId: string): Promise<void>;
60+
/** The latest page of history, or the page before `before` (an `earlierCursor`). */
61+
getMessages(conversationId: string, before?: string): Promise<AssistantMessagePage>;
62+
/** Observe local run state, including a lost connection until history is reloaded. */
63+
subscribeRunState?(conversationId: string, listener: (state: AssistantRunState) => void): () => void;
64+
openRun(conversationId: string, body: AssistantRunBody, signal: AbortSignal): AsyncIterable<AssistantRunEvent>;
65+
}
66+
67+
export class AssistantStreamError extends Error {
68+
constructor() {
69+
super('The assistant stopped responding before the reply finished. Retry the request.');
70+
this.name = 'AssistantStreamError';
71+
}
72+
}
73+
74+
/** The notebook WebSocket closed during a run; its events went with it, so only a reload shows where it got to. */
75+
export class AssistantConnectionError extends Error {
76+
constructor() {
77+
super('The connection to the server was lost. Retry to reload the conversation and see where the answer got to.');
78+
this.name = 'AssistantConnectionError';
79+
}
80+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
* http://www.apache.org/licenses/LICENSE-2.0
6+
* Unless required by applicable law or agreed to in writing, software
7+
* distributed under the License is distributed on an "AS IS" BASIS,
8+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
* See the License for the specific language governing permissions and
10+
* limitations under the License.
11+
*/
12+
13+
import { describe, expect, it } from 'vitest';
14+
15+
import type { AssistantMessage } from './assistantContract';
16+
import { joinPages } from './messageHistory';
17+
18+
describe('joinPages', () => {
19+
it('joins a reply split across pages under the later id and retains both action logs', () => {
20+
const earlier: AssistantMessage[] = [
21+
{ id: 'u1', role: 'user', content: 'Read this notebook' },
22+
{ id: 'a1', role: 'assistant', content: 'Looking.', toolCalls: [{ id: 'c1', name: 'read' }] }
23+
];
24+
const later: AssistantMessage[] = [
25+
{ id: 'a2', role: 'assistant', content: 'Done.', toolCalls: [{ id: 'c2', name: 'summarize' }] }
26+
];
27+
expect(joinPages(earlier, later)).toEqual([
28+
earlier[0],
29+
{
30+
id: 'a2',
31+
role: 'assistant',
32+
content: 'Looking.\n\nDone.',
33+
toolCalls: [
34+
{ id: 'c1', name: 'read' },
35+
{ id: 'c2', name: 'summarize' }
36+
]
37+
}
38+
]);
39+
expect(earlier[1].content).toBe('Looking.');
40+
expect(later[0].content).toBe('Done.');
41+
});
42+
43+
it('retains a tool-only reply across an empty intermediate page and deduplicates repeated calls', () => {
44+
const call = { id: 'c1', name: 'read' };
45+
const earlier: AssistantMessage[] = [{ id: 'a1', role: 'assistant', content: '', toolCalls: [call] }];
46+
const later: AssistantMessage[] = [{ id: 'a2', role: 'assistant', content: 'Done.', toolCalls: [call] }];
47+
expect(joinPages(joinPages(earlier, []), later)).toEqual([
48+
{ id: 'a2', role: 'assistant', content: 'Done.', toolCalls: [call] }
49+
]);
50+
});
51+
52+
it('keeps separate turns apart and handles empty history', () => {
53+
const earlier: AssistantMessage[] = [{ id: 'a1', role: 'assistant', content: 'First answer' }];
54+
const later: AssistantMessage[] = [
55+
{ id: 'u2', role: 'user', content: 'Another question' },
56+
{ id: 'a2', role: 'assistant', content: 'Another answer' }
57+
];
58+
expect(joinPages(earlier, later)).toEqual([...earlier, ...later]);
59+
expect(joinPages([], later)).toEqual(later);
60+
expect(joinPages(earlier, [])).toEqual(earlier);
61+
expect(joinPages([], [])).toEqual([]);
62+
});
63+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
* http://www.apache.org/licenses/LICENSE-2.0
6+
* Unless required by applicable law or agreed to in writing, software
7+
* distributed under the License is distributed on an "AS IS" BASIS,
8+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
* See the License for the specific language governing permissions and
10+
* limitations under the License.
11+
*/
12+
13+
import type { AssistantMessage } from './assistantContract';
14+
15+
export const mergeAnswers = (earlier: AssistantMessage, later: AssistantMessage): AssistantMessage => {
16+
const toolCalls = Array.from(
17+
new Map([...(earlier.toolCalls ?? []), ...(later.toolCalls ?? [])].map(call => [call.id, call])).values()
18+
);
19+
return {
20+
...later,
21+
content: [earlier.content, later.content].filter(Boolean).join('\n\n'),
22+
...(toolCalls.length ? { toolCalls } : {})
23+
};
24+
};
25+
26+
/**
27+
* Puts an earlier page in front of the loaded history. A turn split across the page boundary
28+
* becomes one bubble again, under the later id.
29+
*/
30+
export const joinPages = (earlier: AssistantMessage[], later: AssistantMessage[]): AssistantMessage[] => {
31+
const last = earlier[earlier.length - 1];
32+
const first = later[0];
33+
if (last?.role === 'assistant' && first?.role === 'assistant') {
34+
return [...earlier.slice(0, -1), mergeAnswers(last, first), ...later.slice(1)];
35+
}
36+
return [...earlier, ...later];
37+
};
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
* http://www.apache.org/licenses/LICENSE-2.0
6+
* Unless required by applicable law or agreed to in writing, software
7+
* distributed under the License is distributed on an "AS IS" BASIS,
8+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
* See the License for the specific language governing permissions and
10+
* limitations under the License.
11+
*/
12+
13+
.assistant-shell-nav {
14+
display: inline-flex;
15+
align-items: center;
16+
justify-content: center;
17+
width: 40px;
18+
height: 32px;
19+
padding: 0;
20+
border: 0;
21+
background: transparent;
22+
cursor: pointer;
23+
}
24+
25+
.assistant-shell-nav-icon {
26+
display: inline-grid;
27+
place-items: center;
28+
width: 20px;
29+
height: 16px;
30+
border: 1px solid rgba(48, 71, 91, 0.24);
31+
border-radius: 3px;
32+
background: #fff;
33+
color: #203447;
34+
font:
35+
700 10px/1 Arial,
36+
sans-serif;
37+
}
38+
39+
.assistant-shell-nav[aria-pressed='true'] .assistant-shell-nav-icon {
40+
border-color: var(--assistant-nav-active-color);
41+
}
42+
43+
.assistant-shell-panel {
44+
position: sticky;
45+
z-index: 25;
46+
top: 0;
47+
box-sizing: border-box;
48+
width: calc(var(--assistant-sidebar-width) - 40px);
49+
height: calc(100dvh - var(--assistant-panel-top, 0px));
50+
display: flex;
51+
flex-direction: column;
52+
min-height: 0;
53+
overflow: hidden;
54+
border-right: 1px solid;
55+
box-shadow: 4px 0 2px 0 rgba(0, 0, 0, 0.06);
56+
padding-top: 8px;
57+
}
58+
59+
.assistant-shell-header {
60+
display: flex;
61+
align-items: center;
62+
min-height: 36px;
63+
padding: 4px 8px 4px 12px;
64+
border-bottom: 1px solid;
65+
}
66+
67+
.assistant-shell-header h2 {
68+
margin: 0;
69+
font-size: inherit;
70+
font-weight: 600;
71+
}
72+
73+
.assistant-shell-close {
74+
position: absolute;
75+
z-index: 1;
76+
top: 3px;
77+
right: 3px;
78+
display: inline-flex;
79+
align-items: center;
80+
justify-content: center;
81+
width: 40px;
82+
height: 50px;
83+
padding: 0;
84+
border: 0;
85+
background: transparent;
86+
color: var(--assistant-close-color);
87+
cursor: pointer;
88+
font-size: 20px;
89+
transition: color 0.2s ease;
90+
}
91+
92+
.assistant-shell-close:hover {
93+
color: #3071a9;
94+
}
95+
96+
.assistant-shell-resize {
97+
position: absolute;
98+
top: 0;
99+
right: 0;
100+
bottom: 0;
101+
width: 5px;
102+
cursor: col-resize;
103+
touch-action: none;
104+
}
105+
106+
@media (max-width: 640px) {
107+
.assistant-shell-panel {
108+
width: min(var(--assistant-sidebar-width), 100vw);
109+
margin-right: calc(-1 * min(var(--assistant-sidebar-width), 100vw));
110+
margin-left: -40px;
111+
}
112+
113+
.assistant-shell-resize {
114+
display: none;
115+
}
116+
}

0 commit comments

Comments
 (0)