Skip to content
Merged
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
4 changes: 2 additions & 2 deletions agents/anyplot/prompts/root.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,10 @@ When the user answers a question or gives a detail about the data or the plot ("
These are all the controls the page has. Name only these, by these names, when you tell the user what to press:

- The data panel: the text box for the pasted data and `.parse()`, which reads it (the server detects the delimiter, including tables aligned with spaces, the table among notes, units and number formats; the user chooses none of them); `.use()` and `.discard()` for a table read from text; one dropdown per role of the spec, which binds a column to it; `.to_long()`, when it is offered; `.undo()`, after a change to the data; `.confirm()` at its foot; `.show()` and `.hide()`, which open and close the card, its notes and the code that made the data; `.send()` under a refused formula or code, which moves it to the chat.
- The page header: the library menu (`lib: ...`), which switches the library and keeps the data and the user's decisions, as your `switch_plot` does; the language chip (`lang: ...`), which shows the session language and sets it together with the plot language; `.save()`, which downloads the session.
- The page header: the library menu (`lib: ...`), which switches the library and keeps the data and the user's decisions, as your `switch_plot` does; the language chip (`lang: ...`), which shows the session language and sets it together with the plot language.
- The composer: the message box with `.send()`, `.create_plot()` (`.start_over()` once this library has a plot), and `.stop()` while a run is in progress; before the first plot, `.feedback()` below it. After a switch, "plot in <library> with your N decisions" makes the plot in the new library with the user's decisions and the theme they asked for before; `.create_plot()` or `.start_over()` starts again from the catalogue plot and keeps the decisions too.
- The remembered list above the composer: the plot memory's decisions, each with `.forget()`, which removes it, and `.forget_all()`, which removes all of them for a blank start; nothing else clears them.
- Each result card: the light and dark switch, `.copy_png()`, `.download()` (the PNG), `.open()`, for a Chart.js, D3, ECharts, Highcharts or MUI X plot `.interactive()` (the interactive page in the card, in the theme on screen) and `.preview()` (the image again), `.expand()` and `.collapse()` for the code, `.copy()`, `.download('plot.py')` (`.download('plot.R')` for a ggplot2 plot, `.download('plot.jl')` for a Makie plot, `.download('plot.js')` for a Chart.js, D3, ECharts or Highcharts plot, `.download('plot.tsx')` for a MUI X plot), `.download('plot.html')` for a Chart.js, D3, ECharts, Highcharts or MUI X plot (the interactive page in the theme on screen), `.download('data.csv')`, and the feedback reactions `.like()`, `.dislike()`, `.idea()` and `.bug()` with `.send()`.
- Each result card: the light and dark switch, `.copy_png()`, `.download()` (the PNG), `.open()`, for a Chart.js, D3, ECharts, Highcharts or MUI X plot `.interactive()` (the interactive page in the card, in the theme on screen) and `.preview()` (the image again), the status line next to the light and dark switch (the tries and the number of changes), which opens the rounds and the list of what changed, `.show_code()` and `.hide_code()` for the code, `.expand()` and `.collapse()` for the whole card, `.copy()`, `.download('plot.py')` (`.download('plot.R')` for a ggplot2 plot, `.download('plot.jl')` for a Makie plot, `.download('plot.js')` for a Chart.js, D3, ECharts or Highcharts plot, `.download('plot.tsx')` for a MUI X plot), `.download('plot.html')` for a Chart.js, D3, ECharts, Highcharts or MUI X plot (the interactive page in the theme on screen), `.download('data.csv')`, and the feedback reactions `.like()`, `.dislike()`, `.idea()` and `.bug()`, a click on one of which opens the message box with `.send()` and `.cancel()`.

There is no setting for a delimiter, a separator, an encoding, a header row, a decimal mark or the lines to skip, and no upload or import dialog. When the data cannot be read or fixed with your tools (the table came in as one text column, or the notes and the table run together), say so in one sentence and suggest that the user pastes only the table, from its header line to its last row, into the text box and presses `.parse()` again.

Expand Down
2 changes: 1 addition & 1 deletion app/src/pages/AgentChatPage.feedback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,11 @@ describe('AgentChatPage quick feedback', () => {

await user.click(await screen.findByRole('button', { name: 'Give feedback on this chat' }));
const form = screen.getByRole('form', { name: 'Feedback on this chat' });
await user.click(within(form).getByRole('button', { name: 'Idea' }));
// No data pasted: the box cannot be ticked.
expect(
within(form).getByRole('checkbox', { name: 'Include data.csv in the feedback on this chat' })
).toBeDisabled();
await user.click(within(form).getByRole('button', { name: 'Idea' }));
await user.click(within(form).getByRole('button', { name: 'Send feedback' }));

await screen.findByText(/sent · this chat/);
Expand Down
50 changes: 35 additions & 15 deletions app/src/pages/AgentChatPage.public.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* The public chat at `/agent`: the same page on the public lane. It opens only while
* the API's public lane is open, talks to `/agent/*` anonymously (the tab id and the
* session id as headers, no cookie, no admin token), says it is experimental above
* the first input, offers no `all`, and starts the data boxes unticked. The page's
* the first input, offers no `all` and no `.save()`, and starts the feedback form's data
* box unticked. The page's
* flows are in AgentChatPage.test.tsx; this file stubs only what the lane changes.
*/

Expand All @@ -15,6 +16,7 @@ import { ThemeProvider } from '@mui/material/styles';

import { resetAgentLaneProbe } from 'src/hooks/useAgentLaneOpen';
import { HIGHCHARTS_NOTE, PublicAgentChatPage } from 'src/pages/AgentChatPage';
import { EXPERIMENTAL_DETAILS, EXPERIMENTAL_LINE } from 'src/sections/agent-chat/lane';
import { theme } from 'src/theme';
import { ADMIN_HINT_KEY } from 'src/utils/adminAuth';

Expand Down Expand Up @@ -96,7 +98,7 @@ describe('PublicAgentChatPage', () => {
// Even a browser with the admin hint stays on the public lane at /agent.
localStorage.setItem(ADMIN_HINT_KEY, '1');
renderPage();
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled());
await waitFor(() => expect(screen.getByTestId('language-chip')).toBeEnabled());

const open = calls.find(
call => call.url.pathname.endsWith('/agent/sessions') && call.init.method === 'POST'
Expand All @@ -110,29 +112,47 @@ describe('PublicAgentChatPage', () => {
expect(analytics.trackPageview).toHaveBeenCalledWith('/agent');
});

it('says it is experimental above the first input, with the link to the legal page', async () => {
it('says it is an experimental AI in one line, with the details behind its button', async () => {
const user = userEvent.setup();
renderPage();
const notice = await screen.findByTestId('experimental-notice');
expect(notice).toHaveTextContent(/# experimental/);
expect(notice).toHaveTextContent(
// In view without a click: an experimental AI (AI Act Art. 50), can be wrong, what is stored.
expect(notice).toHaveTextContent(`# ${EXPERIMENTAL_LINE}`);
expect(notice).toHaveTextContent(/experimental AI/);
expect(screen.queryByTestId('experimental-details')).not.toBeInTheDocument();
// Above the data card's paste box: the notice comes first in the document.
const box = await screen.findByRole('textbox', { name: /data/i });
expect(notice.compareDocumentPosition(box) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();

const about = within(notice).getByRole('button', { name: 'About the assistant' });
expect(about).toHaveAttribute('aria-expanded', 'false');
await user.click(about);
const dialog = await screen.findByRole('dialog', { name: 'About the assistant' });
expect(about).toHaveAttribute('aria-expanded', 'true');
for (const line of EXPERIMENTAL_DETAILS) expect(dialog).toHaveTextContent(line);
expect(dialog).toHaveTextContent(
/Claude by Anthropic, through Google Cloud Vertex AI in the EU/
);
expect(notice).toHaveTextContent(/nobody checks them by hand/);
expect(notice).toHaveTextContent(/nothing is stored unless you send feedback/);
expect(notice).toHaveTextContent(/daily limits apply/);
const link = within(notice).getByRole('link', { name: 'how the assistant works' });
const link = within(dialog).getByRole('link', { name: 'how the assistant works' });
expect(link).toHaveAttribute('href', '/legal#assistant');
expect(link).toHaveAttribute('target', '_blank');
// Above the data card's paste box: the notice comes first in the document.
const box = await screen.findByRole('textbox', { name: /data/i });
expect(notice.compareDocumentPosition(box) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();

// Escape closes it and gives the focus back to the button.
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(about).toHaveFocus();
});

it('offers no "all" and starts the .save() box unticked', async () => {
it('offers no "all", no .save() and no "⋯" menu', async () => {
const user = userEvent.setup();
renderPage();
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled());
expect(screen.getByRole('checkbox', { name: '# include data.csv' })).not.toBeChecked();
await waitFor(() => expect(screen.getByTestId('language-chip')).toBeEnabled());
// Owner decision of 2026-11-02: a visitor cannot resume a saved session, and the feedback
// form covers "something went wrong"; the menu would hold nothing else.
expect(screen.queryByRole('button', { name: 'More actions' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
expect(screen.queryByRole('checkbox', { name: '# include data.csv' })).not.toBeInTheDocument();
expect(calls.some(call => call.url.pathname.endsWith('/bundle'))).toBe(false);

await user.click(screen.getByRole('button', { name: /^Library: matplotlib/ }));
const dialog = await screen.findByRole('dialog');
Expand Down
70 changes: 55 additions & 15 deletions app/src/pages/AgentChatPage.save.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* The chat page's `.save()` in the header: shown with a session, hidden without
* one, and wired to the bundle download. The flows of the page are in
* The chat page's `.save()` in the header's "⋯" menu: shown with a session, hidden
* without one, and wired to the bundle download. The flows of the page are in
* AgentChatPage.test.tsx; this file stubs only the calls the header needs.
*/

import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
Expand Down Expand Up @@ -35,10 +35,13 @@ const json = (status: number, body: unknown) =>
const PAGE = '/debug/agent?spec=scatter-basic&library=matplotlib&language=python';

let bundleSearch: string[];
/** The bundle route's answer; the default is a small bundle. */
let bundleAnswer: () => Response;

/** `opened` holds the session's opening until it resolves. */
function stubFetch(openStatus = 200, opened: Promise<void> = Promise.resolve()) {
bundleSearch = [];
bundleAnswer = () => json(200, { transcript: [], versions: [] });
vi.stubGlobal(
'fetch',
vi.fn(async (input: string, init: RequestInit = {}) => {
Expand All @@ -55,7 +58,7 @@ function stubFetch(openStatus = 200, opened: Promise<void> = Promise.resolve())
}
if (url.pathname.endsWith('/sessions/S1/bundle')) {
bundleSearch.push(url.search);
return json(200, { transcript: [], versions: [] });
return bundleAnswer();
}
if (url.pathname.endsWith('/debug/agent/status')) {
return json(200, { libraries: ['matplotlib', 'seaborn'], enabled: true });
Expand Down Expand Up @@ -99,20 +102,33 @@ afterEach(() => {
vi.restoreAllMocks();
});

const menuButton = () => screen.getByRole('button', { name: 'More actions' });
/** The same button while the menu is open, when the popover hides the page from the a11y tree. */
const menuTrigger = () => screen.getByTestId('session-menu');

describe('AgentChatPage .save()', () => {
it('saves the open session with and without the data file', async () => {
it('saves the open session from the "⋯" menu, with and without the data file', async () => {
const user = userEvent.setup();
stubFetch();
renderPage();
// Disabled while the session opens; the page renders the header anew once it is open.
expect(await screen.findByRole('button', { name: 'Save' })).toBeDisabled();
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled());
const save = screen.getByRole('button', { name: 'Save' });
// Out of the header row: nothing of `.save()` shows until the menu opens.
await waitFor(() => expect(menuButton()).toBeEnabled());
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
expect(screen.queryByRole('checkbox', { name: '# include data.csv' })).not.toBeInTheDocument();

await user.click(save);
await user.click(menuButton());
expect(menuTrigger()).toHaveAttribute('aria-expanded', 'true');
const menu = screen.getByRole('dialog', { name: 'Save the session' });
expect(menu).toHaveTextContent('# save the session as a json file');
await user.click(within(menu).getByRole('button', { name: 'Save' }));
await waitFor(() => expect(downloads).toEqual(['anyplot-session-S1.json']));
// The download started: the menu closes and gives the focus back to its button.
await waitFor(() => expect(menuButton()).toHaveAttribute('aria-expanded', 'false'));
await waitFor(() => expect(menuButton()).toHaveFocus());

await user.click(menuButton());
await user.click(screen.getByRole('checkbox', { name: '# include data.csv' }));
await user.click(save);
await user.click(screen.getByRole('button', { name: 'Save' }));

await waitFor(() =>
expect(bundleSearch).toEqual(['?include_data=true', '?include_data=false'])
Expand All @@ -124,9 +140,32 @@ describe('AgentChatPage .save()', () => {
include_data: 'no',
});
expect(screen.queryByRole('alert')).not.toBeInTheDocument();

// The box keeps the choice from one save to the next.
await user.click(menuButton());
expect(screen.getByRole('checkbox', { name: '# include data.csv' })).not.toBeChecked();
});

it('locks the data box while the session opens, so the box shows what a save sends', async () => {
it('keeps the menu open with the failure line when a save fails', async () => {
const user = userEvent.setup();
stubFetch();
bundleAnswer = () => json(413, { detail: 'too_large', ref: 'r-big' });
renderPage();
await waitFor(() => expect(menuButton()).toBeEnabled());

await user.click(menuButton());
await user.click(screen.getByRole('button', { name: 'Save' }));

const menu = screen.getByRole('dialog', { name: 'Save the session' });
expect(await within(menu).findByRole('alert')).toHaveTextContent(
'the session is over 32 MB, too large for one file · ref r-big'
);
expect(menuTrigger()).toHaveAttribute('aria-expanded', 'true');
expect(downloads).toEqual([]);
});

it('holds the menu back while the session opens, so the box shows what a save sends', async () => {
const user = userEvent.setup();
let open!: () => void;
stubFetch(
200,
Expand All @@ -137,11 +176,11 @@ describe('AgentChatPage .save()', () => {
renderPage();
// The page renders the header anew once the session is open; a choice made
// while it opens would be lost there.
expect(await screen.findByRole('checkbox', { name: '# include data.csv' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
expect(await screen.findByRole('button', { name: 'More actions' })).toBeDisabled();

open();
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled());
await waitFor(() => expect(menuButton()).toBeEnabled());
await user.click(menuButton());
const box = screen.getByRole('checkbox', { name: '# include data.csv' });
expect(box).toBeEnabled();
expect(box).toBeChecked();
Expand All @@ -151,6 +190,7 @@ describe('AgentChatPage .save()', () => {
stubFetch(422);
renderPage();
expect(await screen.findByText('not eligible · ref r1')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'More actions' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
});
});
Loading
Loading