Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/client/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,9 @@
font-weight: 500;
color: #5b6c4e;
}
.document-chat-panel .live-chat {
width: 100%;
}
.document-chat-panel .chat-persona {
padding: 18px;
}
Expand Down
199 changes: 199 additions & 0 deletions tests/page-chat-layout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { readFileSync } from 'node:fs';
import { renderToStaticMarkup } from 'react-dom/server';
import { chromium } from 'playwright';
import { expect, it, vi } from 'vitest';
import { Chat } from '../src/client/Chat';
import { PageReviewCard } from '../src/client/PageReviewCard';

vi.mock('@copilotkit/react-core/v2', () => ({
useAgent: () => ({
isReady: true,
agent: {
messages: [
{ id: 'user', role: 'user', content: 'Please review these notes.' },
{
id: 'assistant',
role: 'assistant',
content: 'I prepared a review draft with the key points.',
toolCalls: [
{
id: 'review',
type: 'function',
function: { name: 'review_space_page', arguments: '{}' },
},
],
},
],
},
}),
useCopilotKit: () => ({ copilotkit: {} }),
useHumanInTheLoop: () => {},
useRenderTool: () => {},
CopilotChatToolCallsView: () => (
<PageReviewCard
args={{
title: 'Project notes',
content: 'A summary of the project plan.',
spaceId: 'space',
}}
status="executing"
threadId="thread"
toolCallId="review"
onSaved={() => {}}
/>
),
}));
vi.mock('../src/client/api', () => ({ api: vi.fn() }));
vi.mock('../src/client/useVoice', () => ({
useVoice: () => ({ status: 'idle' }),
}));
vi.mock('../src/client/CallView', () => ({ CallView: () => null }));

async function launchChatBrowser() {
try {
return await chromium.launch({
executablePath: process.env.CHROMIUM_EXECUTABLE_PATH,
});
} catch (error) {
if (
process.env.CHROMIUM_EXECUTABLE_PATH ||
!(error instanceof Error) ||
!error.message.includes("Executable doesn't exist at")
) {
throw error;
}
// Hosted CI images include Chrome even when Playwright's cache is empty.
return chromium.launch({ channel: 'chrome' });
}
}

it('falls back to Chrome only when bundled Chromium is missing', async () => {
vi.stubEnv('CHROMIUM_EXECUTABLE_PATH', undefined);
const browser = {} as Awaited<ReturnType<typeof chromium.launch>>;
const launch = vi
.spyOn(chromium, 'launch')
.mockRejectedValueOnce(
new Error("Executable doesn't exist at /cache/chromium"),
)
.mockResolvedValueOnce(browser);
try {
expect(await launchChatBrowser()).toBe(browser);
expect(launch.mock.calls).toEqual([
[{ executablePath: undefined }],
[{ channel: 'chrome' }],
]);
} finally {
launch.mockRestore();
vi.unstubAllEnvs();
}
});

it('does not hide other browser launch failures', async () => {
vi.stubEnv('CHROMIUM_EXECUTABLE_PATH', undefined);
const launch = vi
.spyOn(chromium, 'launch')
.mockRejectedValue(new Error('browser sandbox failed'));
try {
await expect(launchChatBrowser()).rejects.toThrow('browser sandbox failed');
expect(launch).toHaveBeenCalledTimes(1);
} finally {
launch.mockRestore();
vi.unstubAllEnvs();
}
});

it('does not replace an explicitly configured executable', async () => {
vi.stubEnv('CHROMIUM_EXECUTABLE_PATH', '/custom/chrome');
const launch = vi
.spyOn(chromium, 'launch')
.mockRejectedValue(new Error("Executable doesn't exist at /custom/chrome"));
try {
await expect(launchChatBrowser()).rejects.toThrow(
"Executable doesn't exist at /custom/chrome",
);
expect(launch.mock.calls).toEqual([[{ executablePath: '/custom/chrome' }]]);
} finally {
launch.mockRestore();
vi.unstubAllEnvs();
}
});

it('keeps page chat inside its sidebar across desktop and stacked widths', async () => {
const browser = await launchChatBrowser();
try {
const page = await browser.newPage();
const css = ['style.css', 'editor.css']
.map((file) =>
readFileSync(new URL(`../src/client/${file}`, import.meta.url), 'utf8'),
)
.join('\n');
const html = renderToStaticMarkup(
<section
className="document-session chat-visible"
style={{ height: 900 }}
>
<div className="document-column">Project notes</div>
<div className="document-assistant open">
<aside className="document-chat-panel">
<div className="document-chat-heading">
<span>Page conversation</span>
<button>Close</button>
</div>
<Chat
thread={{
id: 'thread',
dotId: 'dot',
ownerId: 'owner',
title: 'Page chat',
createdAt: 0,
}}
dot={{
id: 'dot',
name: 'Research specialist',
spaceId: 'space',
spaceIds: ['space'],
instructions: 'Review notes',
researchAllowed: false,
memoryAllowed: false,
appsAllowed: false,
composioToolkits: [],
createdAt: 0,
}}
onConsumed={() => {}}
voiceReady={false}
calls={[]}
paused={false}
onSaved={() => {}}
onSchedule={() => {}}
/>
</aside>
</div>
</section>,
);
await page.setContent(`<style>${css}</style>${html}`);
for (const width of [1320, 1150, 901, 900, 700, 390]) {
await page.setViewportSize({ width, height: 900 });
const bounds = await page
.locator('.document-assistant')
.evaluate((panel) => {
const chat = panel.querySelector('.live-chat')!;
return {
panelWidth: panel.clientWidth,
panelScroll: panel.scrollWidth,
chatRight: chat.getBoundingClientRect().right,
panelRight: panel.getBoundingClientRect().right,
};
});
expect(
bounds.panelScroll,
`panel overflow at ${width}px`,
).toBeLessThanOrEqual(bounds.panelWidth + 1);
expect(
bounds.chatRight,
`chat extends outside panel at ${width}px`,
).toBeLessThanOrEqual(bounds.panelRight + 1);
}
} finally {
await browser.close();
}
}, 30000);
Loading