diff --git a/agents/anyplot/prompts/root.md b/agents/anyplot/prompts/root.md index 53521a2d56..3dd67e9356 100644 --- a/agents/anyplot/prompts/root.md +++ b/agents/anyplot/prompts/root.md @@ -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 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. diff --git a/app/src/pages/AgentChatPage.feedback.test.tsx b/app/src/pages/AgentChatPage.feedback.test.tsx index c400bd0cb5..2cd35468dd 100644 --- a/app/src/pages/AgentChatPage.feedback.test.tsx +++ b/app/src/pages/AgentChatPage.feedback.test.tsx @@ -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/); diff --git a/app/src/pages/AgentChatPage.public.test.tsx b/app/src/pages/AgentChatPage.public.test.tsx index b6fa5f87f9..7f7d23106d 100644 --- a/app/src/pages/AgentChatPage.public.test.tsx +++ b/app/src/pages/AgentChatPage.public.test.tsx @@ -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. */ @@ -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'; @@ -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' @@ -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'); diff --git a/app/src/pages/AgentChatPage.save.test.tsx b/app/src/pages/AgentChatPage.save.test.tsx index 1d965e2bb0..29976f748d 100644 --- a/app/src/pages/AgentChatPage.save.test.tsx +++ b/app/src/pages/AgentChatPage.save.test.tsx @@ -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'; @@ -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 = Promise.resolve()) { bundleSearch = []; + bundleAnswer = () => json(200, { transcript: [], versions: [] }); vi.stubGlobal( 'fetch', vi.fn(async (input: string, init: RequestInit = {}) => { @@ -55,7 +58,7 @@ function stubFetch(openStatus = 200, opened: Promise = 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 }); @@ -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']) @@ -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, @@ -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(); @@ -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(); }); }); diff --git a/app/src/pages/AgentChatPage.test.tsx b/app/src/pages/AgentChatPage.test.tsx index 47b92d2ef9..05136d8476 100644 --- a/app/src/pages/AgentChatPage.test.tsx +++ b/app/src/pages/AgentChatPage.test.tsx @@ -498,7 +498,14 @@ describe('AgentChatPage gating', () => { it('explains the missing parameters instead of calling the BFF', () => { flags.isDev = true; renderAt('/debug/agent?spec=scatter-basic&library=excel'); - expect(screen.getByText(/open this page from a plot/)).toBeInTheDocument(); + // The plot page's entry since 2026-11-02 is `.use_my_data()`. + expect(screen.getByText(/open it from a plot/)).toHaveTextContent( + "open it from a plot's .use_my_data(), or start with scatter-basic in matplotlib." + ); + expect(screen.getByRole('link', { name: 'scatter-basic in matplotlib' })).toHaveAttribute( + 'href', + '/debug/agent?spec=scatter-basic&library=matplotlib&language=python' + ); expect(agentCalls()).toHaveLength(0); }); @@ -532,9 +539,9 @@ describe('AgentChatPage gating', () => { renderAt(PAGE); expect(await screen.findByLabelText(/# your data/)).toBeInTheDocument(); expect(currentStep()).toEqual(['1 data']); - expect( - screen.getByText(/check the preview and the bindings, confirm, then/) - ).toBeInTheDocument(); + // The steps are behind the data card's ⓘ, not in a paragraph on the page (2026-11-02). + expect(screen.queryByText(/check the preview and the bindings/)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'How the data card works' })).toBeInTheDocument(); fireEvent.change(screen.getByLabelText(/# your data/), { target: { value: 'hours,score\n1.5,52\n2.0,58\n' }, @@ -617,7 +624,7 @@ describe('AgentChatPage gating', () => { expect(thread.firstElementChild).toHaveTextContent('.summarize_data()'); expect(screen.getByLabelText('Message')).toHaveAttribute( 'placeholder', - 'ask about your data, or for a fix: "sum per month"' + 'ask for example data, a curve, or a fix to your data' ); // A correction in words, before the confirmation: the card shows it in place. @@ -664,15 +671,17 @@ describe('AgentChatPage gating', () => { localStorage.setItem(ADMIN_HINT_KEY, '1'); renderAt(PAGE); const textbox = await screen.findByLabelText(/# your data/); - // Without data, both boxes name the other sources: example data, and a curve the assistant computes. - expect(textbox).toHaveAccessibleDescription( - 'or type "example data" here, or ask for a curve in the chat: y = sin(x), x from -pi to pi' + // Without data, both boxes name the other sources in their placeholders: example data, and a + // curve the assistant computes; the how-to paragraph under the card is gone (2026-11-02). + expect(textbox).toHaveAttribute( + 'placeholder', + expect.stringContaining('or ask for a curve: y = sin(x), x from -pi to pi') ); expect(screen.getByLabelText('Message')).toHaveAttribute( 'placeholder', - 'ask for example data or a curve: "y = sin(x), x from -pi to pi"' + 'ask for example data, a curve, or a fix to your data' ); - expect(screen.getByText(/or ask here for example data or a curve/)).toBeInTheDocument(); + expect(screen.queryByText(/paste your data above/)).not.toBeInTheDocument(); fireEvent.change(textbox, { target: { value: FORMULA } }); fireEvent.click(screen.getByRole('button', { name: '.parse()' })); @@ -751,7 +760,7 @@ describe('AgentChatPage gating', () => { expect(quietCreate()).toBeEnabled(); expect(screen.getByLabelText('Message')).toHaveAttribute( 'placeholder', - 'ask about your data, or for a fix: "sum per month"' + 'ask for example data, a curve, or a fix to your data' ); // The loss is named where it happened, right after the change, and next to `.confirm()`. expect(lastInThread()).toHaveTextContent('# bindings changed · confirm the data again'); @@ -846,7 +855,7 @@ describe('AgentChatPage gating', () => { expect(screen.getByRole('article', { name: 'Plot version 1' })).toBeInTheDocument(); expect(screen.getByLabelText('Message')).toHaveAttribute( 'placeholder', - 'ask about this plot, or ask for a change' + 'ask for a change, e.g. log scale on y' ); expect(screen.getByRole('button', { name: '.send()' })).toBeDisabled(); expect(screen.queryByRole('button', { name: 'Create plot' })).not.toBeInTheDocument(); @@ -929,7 +938,7 @@ describe('AgentChatPage gating', () => { expect(quietCreate()).toBeEnabled(); expect(screen.getByLabelText('Message')).toHaveAttribute( 'placeholder', - 'ask about your data, or for a fix: "sum per month"' + 'ask for example data, a curve, or a fix to your data' ); // Discarded, the stored data is the confirmed data again. @@ -1501,7 +1510,7 @@ describe('AgentChatPage .plot_all()', () => { expect(box).toBeEnabled(); expect(box).toHaveAttribute( 'placeholder', - 'ask for example data or a curve: "y = sin(x), x from -pi to pi"' + 'ask for example data, a curve, or a fix to your data' ); expect(box).not.toHaveAccessibleDescription(); @@ -1517,7 +1526,7 @@ describe('AgentChatPage .plot_all()', () => { expect(screen.getByLabelText('Message')).toBeEnabled(); expect(screen.getByLabelText('Message')).toHaveAttribute( 'placeholder', - 'ask about your data, or for a fix: "sum per month"' + 'ask for example data, a curve, or a fix to your data' ); sendFromComposer('what is the mean score?'); expect(await screen.findByText('The mean score is 55.')).toBeInTheDocument(); diff --git a/app/src/pages/AgentChatPage.tsx b/app/src/pages/AgentChatPage.tsx index ddcdc3d25e..c37e58a872 100644 --- a/app/src/pages/AgentChatPage.tsx +++ b/app/src/pages/AgentChatPage.tsx @@ -27,7 +27,9 @@ * per library: the libraries grouped by language, `all` for the admin) and, * next to it, the session language chip (`LanguageChip`): the language of the * replies and of the text in the plot, English until the first typed message - * or a choice sets another. The page talks only to the BFF under + * or a choice sets another. On the admin lane, the "⋯" menu (`SessionMenu`) at the + * header's end holds `.save()` with its `# include data.csv` box (out of the header row + * since 2026-11-02, owner: less text); the public lane has neither. The page talks only to the BFF under * `/debug/agent/*` (`src/lib/agent.ts`, `src/hooks/useAgentSession.ts`). * * A library switch keeps the data, the bindings, their confirmation, the @@ -90,9 +92,11 @@ * and shows the same notice. * The public lane: the API's `GET /agent/lane` (`useAgentLaneOpen`); closed, * the page says so. It talks to `/agent/*` with `PUBLIC_LANE` in place of the - * admin token, shows the experimental notice above the first input, offers - * no `all`, and leaves the data boxes of feedback and `.save()` unticked - * (`sections/agent-chat/lane.ts`). + * admin token, shows the experimental notice above the first input (one line, the + * details behind its ⓘ button: `ExperimentalNotice`), offers no `all` and no + * `.save()` (owner decision of 2026-11-02: a visitor cannot resume a saved session, + * and the feedback form covers "something went wrong"), and leaves the feedback + * form's data box unticked (`sections/agent-chat/lane.ts`). * * Design: docs/concepts/agent-network.md ("Frontend", "Request flow"). */ @@ -132,6 +136,7 @@ import { paths, specPath } from 'src/routes/paths'; import { ChatFeedback } from 'src/sections/agent-chat/ChatFeedback'; import { ChatThread } from 'src/sections/agent-chat/ChatThread'; import { DATA_SUMMARY_ID, DataPanel } from 'src/sections/agent-chat/DataPanel'; +import { ExperimentalNotice } from 'src/sections/agent-chat/ExperimentalNotice'; import { FanOut } from 'src/sections/agent-chat/FanOut'; import { DEFAULT_USER_MAX_RUNS } from 'src/sections/agent-chat/fanoutRules'; import { AgentLaneContext } from 'src/sections/agent-chat/lane'; @@ -146,7 +151,7 @@ import { import { ProgressTimeline } from 'src/sections/agent-chat/ProgressTimeline'; import { ReloadHint } from 'src/sections/agent-chat/ReloadHint'; import { Remembered } from 'src/sections/agent-chat/Remembered'; -import { SaveButton } from 'src/sections/agent-chat/SaveButton'; +import { SessionMenu } from 'src/sections/agent-chat/SessionMenu'; import { StepHeader } from 'src/sections/agent-chat/StepHeader'; import { inStep2, showsTimeline } from 'src/sections/agent-chat/steps'; import { @@ -165,8 +170,13 @@ import { readAdminHint, readAdminToken, setAdminHint } from 'src/utils/adminAuth const SPEC_PATTERN = /^[a-z0-9-]{1,100}$/; const OPEN_SOURCES = new Set(['plot_page', 'landing']); -/** The chat column's width: a thread to read, and a plot that fits the screen with its composer. */ -const COLUMN_MAX_WIDTH = 960; +/** + * The chat column's width: it grows with the window up to this (960 until 2026-11-02; owner: + * use the room of a wide screen), so the data card with its grid, the plot cards and the code + * take the full width, while the thread's text keeps a readable line (`CHAT_TEXT_MAX` in + * `styles.ts`). Below about 1100 px the window, not this cap, sets the width, as before. + */ +const COLUMN_MAX_WIDTH = 1360; /** * What `.start_over()` does, unlike `.send()` beside it (the owner could not tell the two apart), * and the quiet `.create_plot()` beside the offer after a switch: the catalogue code again, with the @@ -225,48 +235,10 @@ function AdminRequired() { ); } -/** - * The public lane's notice above the first input: that this is an AI and which one, that it - * can be wrong, what not to paste, what is stored, and the limits (also the EU AI Act's - * Art. 50 duty to say that a person talks to an AI). The owner reads every user-facing text: - * keep it in the PR's "Texts to read". - */ -export const EXPERIMENTAL_NOTICE = - 'an AI does the work here: Claude by Anthropic, through Google Cloud Vertex AI in the EU, reads what you paste and writes the plot and its code. results can be wrong, and nobody checks them by hand. do not paste data you may not share. nothing is stored unless you send feedback. daily limits apply.'; - /** The chat's Highcharts note, shown while the session plots in Highcharts (the legal page says the same). */ export const HIGHCHARTS_NOTE = 'highcharts is free for non-commercial use; a commercial use needs a licence from Highsoft.'; -function ExperimentalNotice() { - return ( - - # experimental · {EXPERIMENTAL_NOTICE}{' '} - {/* A new tab: leaving the page would end the session. */} - - how the assistant works - - - ); -} - /** The public lane's page while the lane is closed (`GET /agent/lane` answered no). */ function LaneClosed() { return ( @@ -283,7 +255,8 @@ function LaneClosed() { /** * The public chat at `/agent`: the same page on the public lane (`PUBLIC_LANE`), open to * everyone while the API's public lane is (`useAgentLaneOpen`), labelled experimental, with - * the admin-only parts left out (the fan-out's `all`) and the data boxes unticked. + * the admin-only parts left out (the fan-out's `all`, `.save()` and its "⋯" menu) and the + * feedback form's data box unticked. */ export function PublicAgentChatPage() { return ; @@ -361,8 +334,7 @@ export function AgentChatPage({ lane = 'admin' }: { lane?: AgentLane } = {}) { if (!valid) { return ( - open this page from a plot's .adapt() button: it needs a spec and a - library, such as{' '} + open it from a plot's .use_my_data(), or start with{' '} chatPath(specId, lib, LIB_TO_LANG[lib] ?? 'python') : undefined} // Not during a run (the server refuses a switch then) or while the fan-out's panel // is open: its sessions run on this one's data. @@ -857,9 +829,11 @@ function AgentChat({ onSet={session.setLanguage} /> )} - {/* `.save()` downloads the session; without one (ineligible, error) there is nothing to save. */} - {!reopen && ( - @@ -954,22 +928,6 @@ function AgentChat({ onDataViewShown={() => session.reportOutcome('opened_data_view', 0)} /> - {state.items.length === 0 && !state.run && ( - - paste your data above, or ask here for example data or a curve: "y = sin(x), x - from{' '} - - -pi - {' '} - to pi", and the assistant makes the table. it sums your data up here, and you can - ask it to fix the data in plain words, such as "read Anteil as numbers" or - "sum per month". check the preview and the bindings, confirm, then{' '} - .create_plot(), with a note first if the plot should do something - differently. afterwards, ask for changes to the plot: "log scale on y", - "dark version", "label the outliers". - - )} - { const onSend = vi.fn(async () => undefined); render(); await user.click(screen.getByRole('button', { name: 'Give feedback on this chat' })); + await user.click(screen.getByRole('button', { name: 'Bug' })); const box = screen.getByRole('checkbox', { name: 'Include data.csv in the feedback on this chat', @@ -47,7 +48,6 @@ describe('ChatFeedback', () => { expect(box).not.toBeChecked(); expect(box).toBeDisabled(); expect(screen.getByText('# no data yet')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Bug' })); await user.click(screen.getByRole('button', { name: 'Send feedback' })); expect(onSend).toHaveBeenCalledWith(0, { reaction: 'bug', message: '', includeData: false }); diff --git a/app/src/sections/agent-chat/ChatThread.test.tsx b/app/src/sections/agent-chat/ChatThread.test.tsx index f74d6f204b..26005172d0 100644 --- a/app/src/sections/agent-chat/ChatThread.test.tsx +++ b/app/src/sections/agent-chat/ChatThread.test.tsx @@ -9,6 +9,7 @@ import { import { ChatThread } from 'src/sections/agent-chat/ChatThread'; import { localClock } from 'src/sections/agent-chat/messages'; import { pendingReply } from 'src/sections/agent-chat/steps'; +import { CHAT_TEXT_MAX } from 'src/sections/agent-chat/styles'; import { render, screen } from 'src/test-utils'; const notReady = (reason: string): AgentSessionState => ({ @@ -47,6 +48,32 @@ function renderThread(state: AgentSessionState) { } describe('ChatThread', () => { + it("keeps the thread's text to a readable line in the wide column", () => { + renderThread({ + ...initialAgentState('matplotlib'), + phase: 'ready', + sessionId: 'S1', + items: [ + { id: 1, kind: 'user', text: 'make the markers bigger' }, + { id: 2, kind: 'assistant', text: 'Done: the markers are larger now.' }, + { id: 3, kind: 'notice', text: 'bindings changed', tone: 'warning' }, + ], + }); + // A reply and a notice are no wider than about 80 characters (2026-11-02: the column grows + // up to 1360 px); the user's turn keeps its right alignment. jsdom resolves `ch` to pixels + // of each element's own font, so the check is that a cap is there, in `ch` or in pixels. + expect(CHAT_TEXT_MAX).toBe('80ch'); + for (const element of [ + screen.getByText('Done: the markers are larger now.'), + screen.getByTestId('confirm-lost'), + ]) { + expect(getComputedStyle(element).maxWidth).toMatch(/^\d+(\.\d+)?(px|ch)$/); + } + expect(getComputedStyle(screen.getByText('make the markers bigger')).alignSelf).toBe( + 'flex-end' + ); + }); + it('reads a plot refused for unconfirmed data as a step to take', () => { renderThread(notReady('data_unconfirmed')); expect(screen.getByText('plot · not ready · data_unconfirmed')).toBeInTheDocument(); diff --git a/app/src/sections/agent-chat/ChatThread.tsx b/app/src/sections/agent-chat/ChatThread.tsx index 8daa2d07d6..5ee61cc4e6 100644 --- a/app/src/sections/agent-chat/ChatThread.tsx +++ b/app/src/sections/agent-chat/ChatThread.tsx @@ -24,7 +24,12 @@ import { ERROR_TEXT, localClock, PLOT_FAILED_TEXT } from 'src/sections/agent-cha import { ReloadHint } from 'src/sections/agent-chat/ReloadHint'; import { type CardOutcome, ResultCard } from 'src/sections/agent-chat/ResultCard'; import { type PendingReply, pendingReply } from 'src/sections/agent-chat/steps'; -import { bodyTextSx, labelSx } from 'src/sections/agent-chat/styles'; +import { + bodyTextSx, + CHAT_TEXT_MAX, + labelSx, + readableSx as textSx, +} from 'src/sections/agent-chat/styles'; import { colors, typography } from 'src/theme'; /** The in-progress line's words; the pulsing `…` follows them. */ @@ -47,10 +52,15 @@ interface ChatThreadProps { onSendFeedback?: (version: number, input: FeedbackInput) => Promise; } +/** + * The user's turns, on the right; like every text item of the thread (`textSx`), no wider than + * a readable line since the column grows up to 1360 px (2026-11-02). The result cards take the + * full width. + */ const promptSx = { ...bodyTextSx, alignSelf: 'flex-end', - maxWidth: { xs: '100%', sm: '85%' }, + maxWidth: { xs: '100%', sm: `min(85%, ${CHAT_TEXT_MAX})` }, bgcolor: 'var(--bg-elevated)', border: '1px solid var(--rule)', borderRadius: 2, @@ -95,11 +105,16 @@ function Item({ ); case 'assistant': return ( - {item.text} + + {item.text} + ); case 'refusal': return ( - + refusal · {item.code} {/* The service's own text says 00:00 UTC: the page adds the user's own time. */} @@ -110,7 +125,7 @@ function Item({ ); case 'error': return ( - + error · {item.code} {item.ref ? ` · ref ${item.ref}` : ''} @@ -128,6 +143,7 @@ function Item({ data-testid="confirm-lost" sx={{ ...labelSx, + ...textSx, color: 'var(--ink)', borderLeft: `3px solid ${colors.warning}`, pl: 1.5, @@ -136,11 +152,11 @@ function Item({ # {item.text} ) : ( - # {item.text} + # {item.text} ); case 'plot_failed': return ( - + plot · {item.result.status === 'not_ready' ? 'not ready' : 'failed'} {item.result.reason ? ` · ${item.result.reason}` : ''} @@ -193,6 +209,7 @@ export function ChatThread(props: ChatThreadProps) { data-testid="pending-reply" sx={{ ...bodyTextSx, + ...textSx, color: 'var(--ink-soft)', borderLeft: '2px solid var(--rule)', pl: 1.5, diff --git a/app/src/sections/agent-chat/DataPanel.test.tsx b/app/src/sections/agent-chat/DataPanel.test.tsx index e4e60a600f..cc4e2b40f4 100644 --- a/app/src/sections/agent-chat/DataPanel.test.tsx +++ b/app/src/sections/agent-chat/DataPanel.test.tsx @@ -8,6 +8,7 @@ import { import { AgentApiError, type ConvertAnswer, type RoleSpec } from 'src/lib/agent'; import { bindingRows } from 'src/sections/agent-chat/bindings'; import { DataPanel, DataViewUnavailable } from 'src/sections/agent-chat/DataPanel'; +import { DATA_HOWTO, DATA_PLACEHOLDER } from 'src/sections/agent-chat/messages'; import { fireEvent, render, screen, userEvent, waitFor, within } from 'src/test-utils'; /** `scrollIntoView` as a spy: jsdom has none. */ @@ -936,15 +937,34 @@ describe('DataPanel formulas', () => { ...overrides, }); - it('names the chat as the other source of data until there is some', () => { - const { rerender } = renderPanel(empty()); - const hint = - 'or type "example data" here, or ask for a curve in the chat: y = sin(x), x from -pi to pi'; - expect(screen.getByLabelText(/# your data/)).toHaveAccessibleDescription(hint); + it('names example data and a curve in the placeholder, under a one-line label', () => { + renderPanel(empty()); + const box = screen.getByRole('textbox', { name: '# your data' }); + expect(box).toHaveAttribute('placeholder', DATA_PLACEHOLDER); + expect(DATA_PLACEHOLDER.split('\n')).toEqual([ + 'paste a table or drop a file (csv, excel, json …)', + 'or type: example data', + 'or ask for a curve: y = sin(x), x from -pi to pi', + ]); + // The how-to is behind the ⓘ, never a line of its own. + expect(box).not.toHaveAccessibleDescription(); + expect(screen.queryByText(/Parquet/)).not.toBeInTheDocument(); + }); - rerender(stateWith({ x: 'month', y1: 'shoes' })); - expect(screen.queryByText(hint)).not.toBeInTheDocument(); - expect(screen.getByLabelText(/# your data/)).not.toHaveAccessibleDescription(); + it('opens the how-to from the ⓘ beside the label, and closes it with Escape', async () => { + const user = userEvent.setup(); + renderPanel(empty()); + const info = screen.getByRole('button', { name: 'How the data card works' }); + expect(info).toHaveAttribute('aria-haspopup', 'dialog'); + + await user.click(info); + const dialog = screen.getByRole('dialog', { name: 'How the data card works' }); + for (const line of DATA_HOWTO) expect(dialog).toHaveTextContent(line); + expect(within(dialog).getByTestId('data-howto').children).toHaveLength(DATA_HOWTO.length); + + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(info).toHaveFocus(); }); it('offers .send() for a refused formula, which empties the box', async () => { diff --git a/app/src/sections/agent-chat/DataPanel.tsx b/app/src/sections/agent-chat/DataPanel.tsx index 8111b0ff53..11a06da00a 100644 --- a/app/src/sections/agent-chat/DataPanel.tsx +++ b/app/src/sections/agent-chat/DataPanel.tsx @@ -152,9 +152,12 @@ import { type FileReading, readFile, } from 'src/sections/agent-chat/fileText'; +import { InfoPopover } from 'src/sections/agent-chat/InfoPopover'; import { lazyOrFallback } from 'src/sections/agent-chat/lazyOrFallback'; import { CONFIRM_LOST_TEXT, + DATA_HOWTO, + DATA_PLACEHOLDER, describeConvertFailure, describeConvertWarnings, describeDataFailure, @@ -1254,24 +1257,20 @@ export function DataPanel({ // bindings, and in step 1 the confirmation at its foot. const cardContent = ( <> - - # your data — paste or drop CSV, TSV, JSON, plain text, Excel, ODS, Parquet, Feather or - NumPy - - {/* Until there is data, the other sources: example data the assistant makes up, a curve it computes. */} - {!dataset && ( - - or type "example data" here, or ask for a curve in the chat: y = sin(x), x from{' '} - - -pi - {' '} - to pi + {/* One short label (since 2026-11-02, owner: less text); the how-to behind its ⓘ, the + other sources (example data, a curve) in the box's placeholder. The button sits beside + the label, not in it, so it never joins the box's accessible name. */} + + + # your data - )} + + {/* The drop zone: the box itself. A file dragged over it tints it with the brand colour, or with the warning colour while a step runs and the drop would be refused. */} ) => { @@ -1314,6 +1312,8 @@ export function DataPanel({ lineHeight: 1.5, whiteSpace: 'pre', overflowX: 'auto', + // A table keeps its lines; the placeholder's lines wrap at phone width. + '&::placeholder': { whiteSpace: 'pre-wrap' }, ...(dragOver && { borderColor: dropReady ? colors.primary : colors.warning, borderStyle: 'dashed', diff --git a/app/src/sections/agent-chat/ExperimentalNotice.tsx b/app/src/sections/agent-chat/ExperimentalNotice.tsx new file mode 100644 index 0000000000..5566e0050f --- /dev/null +++ b/app/src/sections/agent-chat/ExperimentalNotice.tsx @@ -0,0 +1,62 @@ +/** + * The public lane's notice above the first input, one line since 2026-11-02 (owner: less + * text, the details behind a button rather than removed). The line keeps in view, without a + * click, that this is an experimental AI (the EU AI Act's Art. 50 duty to say that a person + * talks to an AI), that its results can be wrong, and that nothing is stored without feedback. + * The ⓘ button at its end (`InfoPopover`) opens the rest: which AI and where it runs, what not + * to paste, the daily limits, and the legal page's section on the assistant. The link opens a + * new tab: leaving the page would end the session. The texts live in `lane.ts`, with the + * public lane's consent line. + */ + +import Box from '@mui/material/Box'; + +import { paths } from 'src/routes/paths'; +import { InfoPopover } from 'src/sections/agent-chat/InfoPopover'; +import { EXPERIMENTAL_DETAILS, EXPERIMENTAL_LINE } from 'src/sections/agent-chat/lane'; +import { labelSx } from 'src/sections/agent-chat/styles'; +import { colors, proseLinkStyle } from 'src/theme'; + +export function ExperimentalNotice() { + // The last word and the button keep together, so the ⓘ never wraps onto a line of its own. + const words = EXPERIMENTAL_LINE.split(' '); + const last = words.pop(); + + return ( + + # {words.join(' ')}{' '} + + {last} + + how the assistant works + , + ]} + /> + + + ); +} diff --git a/app/src/sections/agent-chat/InfoPopover.tsx b/app/src/sections/agent-chat/InfoPopover.tsx new file mode 100644 index 0000000000..c6f8917370 --- /dev/null +++ b/app/src/sections/agent-chat/InfoPopover.tsx @@ -0,0 +1,99 @@ +/** + * The ⓘ button at the end of a one-line header of the chat, and the popover it opens with the + * details that would otherwise crowd the line (owner, 2026-11-02: less text, the details behind + * buttons rather than removed). The experimental notice (`ExperimentalNotice`) and the data + * card's `# your data` header (`DataPanel`) use it. + * + * A popover rather than a tooltip: it opens on a tap as on a click, it can hold a link, and it + * keeps the keyboard focus inside until Escape or a click outside closes it, which returns the + * focus to the button. + */ + +import { type ReactNode, useId, useState } from 'react'; + +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Popover from '@mui/material/Popover'; + +import { labelSx } from 'src/sections/agent-chat/styles'; +import { colors } from 'src/theme'; + +export interface InfoPopoverProps { + /** The button's and the popover's accessible name, in words: "About the assistant". */ + label: string; + /** The popover's first line, a `#` comment. */ + heading: string; + /** The popover's lines, one list item each. */ + lines: readonly ReactNode[]; + /** The list's test id. */ + testId?: string; +} + +export function InfoPopover({ label, heading, lines, testId }: InfoPopoverProps) { + const [anchor, setAnchor] = useState(null); + const popoverId = useId(); + const open = anchor !== null; + + return ( + <> + ) => setAnchor(event.currentTarget)} + sx={{ + // 32 px to tap; the negative margin keeps it out of the line height, so the line it + // ends reads as text. + p: 0.75, + my: -0.75, + ml: 0.25, + color: 'var(--ink-soft)', + '&:hover': { color: colors.primary, bgcolor: 'var(--bg-elevated)' }, + '&:focus-visible': { outline: `2px solid ${colors.primary}`, outlineOffset: 1 }, + }} + > + + + setAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + transformOrigin={{ vertical: 'top', horizontal: 'left' }} + slotProps={{ + paper: { + role: 'dialog', + 'aria-label': label, + sx: { width: { xs: 'calc(100vw - 32px)', sm: 420 }, p: 1.5, mt: 0.5 }, + }, + }} + > + {heading} + + {lines.map((line, index) => ( +
  • {line}
  • + ))} +
    +
    + + ); +} diff --git a/app/src/sections/agent-chat/ResultCard.test.tsx b/app/src/sections/agent-chat/ResultCard.test.tsx index 26e7a5fac4..14960d335d 100644 --- a/app/src/sections/agent-chat/ResultCard.test.tsx +++ b/app/src/sections/agent-chat/ResultCard.test.tsx @@ -74,18 +74,117 @@ afterEach(() => { }); describe('ResultCard', () => { - it('shows the rendered image, the changes and the residual notes', () => { + it('shows the rendered image, the residual notes and the run in one status line', async () => { renderCard(); expect(screen.getByAltText('Adapted plot, version 2, light theme')).toHaveAttribute( 'src', 'blob:light' ); - expect(screen.getByText('Marker size raised')).toBeInTheDocument(); expect(screen.getByText('VQ-03 light: markers overlap the label')).toBeInTheDocument(); expect(screen.getByTestId('result-status')).toHaveTextContent('needs attention'); // Two attempts are the first try and one repair round, named as the progress timeline names them. - expect(screen.getByText('second try')).toBeInTheDocument(); + const summary = screen.getByRole('button', { name: 'Run details: second try · 1 change' }); + expect(summary).toHaveAttribute('aria-expanded', 'false'); + // Folded: what changed is one click away. + expect(screen.queryByText('Marker size raised')).not.toBeInTheDocument(); expect(screen.getByTestId('result-feedback-slot')).toBeInTheDocument(); + + await userEvent.click(summary); + expect(summary).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('# what changed')).toBeInTheDocument(); + expect(screen.getByText('Marker size raised')).toBeInTheDocument(); + await userEvent.click(summary); + expect(screen.queryByText('Marker size raised')).not.toBeInTheDocument(); + }); + + it('opens the rounds of the run from the status line', async () => { + const version = makeVersion(); + version.result = { + ...version.result, + status: 'ok', + changes: [], + residual_defects: [], + rounds: [ + { attempt: 1, outcome: 'review', findings: 2, shipped: false }, + { attempt: 2, outcome: 'ok', findings: 0, shipped: true }, + ], + } as PlotVersion['result']; + renderCard({ version }); + expect(screen.queryByRole('table', { name: 'Rounds of version 2' })).toBeNull(); + await userEvent.click(screen.getByRole('button', { name: /^Run details: second try$/ })); + expect(screen.getByText('# rounds')).toBeInTheDocument(); + expect(screen.getByRole('table', { name: 'Rounds of version 2' })).toBeInTheDocument(); + expect(screen.queryByText('# what changed')).not.toBeInTheDocument(); + }); + + it('shows a plain status line when the run has neither rounds nor changes', () => { + const version = makeVersion(); + version.result = { ...version.result, attempts: 1, changes: [] }; + renderCard({ version }); + expect(screen.getByTestId('result-summary')).toHaveTextContent('first try'); + expect(screen.queryByRole('button', { name: /^Run details/ })).not.toBeInTheDocument(); + }); + + it('folds the code behind .show_code(), with .copy() and the downloads in view', async () => { + renderCard(); + expect(screen.queryByTestId('code-highlighter')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy code' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Download plot.py' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Download data.csv' })).toBeInTheDocument(); + + const show = screen.getByRole('button', { name: 'Show code' }); + expect(show).toHaveTextContent('.show_code()'); + expect(show).toHaveAttribute('aria-expanded', 'false'); + await userEvent.click(show); + expect(await screen.findByTestId('code-highlighter')).toHaveTextContent('import pandas as pd'); + const hide = screen.getByRole('button', { name: 'Hide code' }); + expect(hide).toHaveTextContent('.hide_code()'); + expect(hide).toHaveAttribute('aria-expanded', 'true'); + await userEvent.click(hide); + expect(screen.queryByTestId('code-highlighter')).not.toBeInTheDocument(); + }); + + it('says why .copy() waits when the code could not be loaded, also while folded', () => { + renderCard({ version: makeVersion({ code: { state: 'failed' } }) }); + expect(screen.getByRole('alert')).toHaveTextContent('could not load plot.py'); + expect(screen.getByRole('button', { name: 'Copy code' })).toBeDisabled(); + }); + + it("keeps the card's own choices across a collapse of the whole card", async () => { + renderCard(); + await userEvent.click(screen.getByRole('button', { name: 'Show code' })); + await userEvent.click(screen.getByRole('button', { name: /^Run details/ })); + + await userEvent.click(screen.getByRole('button', { name: /^Version 2,/ })); + expect(screen.queryByTestId('code-highlighter')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /^Version 2,/ })); + + expect(await screen.findByTestId('code-highlighter')).toBeInTheDocument(); + expect(screen.getByText('Marker size raised')).toBeInTheDocument(); + }); + + it("starts a newer version's card folded, whatever the card before it showed", async () => { + const props: ResultCardProps = { + version: makeVersion(), + specId: 'scatter-basic', + language: 'python', + latest: true, + busy: false, + onRequestTheme: vi.fn(), + onFetchArtifact: vi.fn(), + onTrack: vi.fn(), + }; + const { rerender } = render(); + await userEvent.click(screen.getByRole('button', { name: 'Show code' })); + expect(await screen.findByTestId('code-highlighter')).toBeInTheDocument(); + + rerender(); + expect(screen.getByAltText('Adapted plot, version 3, light theme')).toBeInTheDocument(); + expect(screen.queryByTestId('code-highlighter')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Run details/ })).toHaveAttribute( + 'aria-expanded', + 'false' + ); }); it('sizes the image box to the loaded PNG, footer strip included', () => { @@ -182,11 +281,13 @@ describe('ResultCard', () => { }); version.result = { ...version.result, artifacts: ['plot-light.png', 'plot.R', 'data.csv'] }; renderCard({ version, language: 'python' }); + await userEvent.click(screen.getByRole('button', { name: 'Show code' })); expect(await screen.findByTestId('code-highlighter')).toHaveAttribute('data-language', 'r'); }); it('keeps the page language for a library the app does not know', async () => { renderCard({ version: makeVersion({ library: 'unknown-lib' }), language: 'python' }); + await userEvent.click(screen.getByRole('button', { name: 'Show code' })); expect(await screen.findByTestId('code-highlighter')).toHaveAttribute( 'data-language', 'python' diff --git a/app/src/sections/agent-chat/ResultCard.tsx b/app/src/sections/agent-chat/ResultCard.tsx index 6e99797a3b..4ff88d16a2 100644 --- a/app/src/sections/agent-chat/ResultCard.tsx +++ b/app/src/sections/agent-chat/ResultCard.tsx @@ -5,6 +5,15 @@ * request or a question goes through the thread's one composer below it, * which the assistant reads either way (since 2026-10-12). * + * - Folded by default since 2026-11-02 (owner: less text, the details behind + * a toggle rather than removed): the image, its toolbar and the theme switch + * show; the run's rounds and its "what changed" list sit behind one status + * line (`second try · 5 changes ⓘ`), and the code behind `.show_code()`, + * while `.copy()` and the downloads stay in view. Each card keeps its own + * choice while the session lives, also across a collapse of the whole card; + * a new version's card starts folded. The residual notes stay in view: they + * are what still needs the user's eye. + * * - The image is the blob URL of the theme the run rendered. The light and * dark switch shows the other theme, rendering it first through the theme * toggle route when the version does not have it yet (no model call). @@ -37,12 +46,12 @@ * The card is self-contained, so the public phase can reuse it unchanged. */ -import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useId, useRef, useState } from 'react'; -import CheckIcon from '@mui/icons-material/Check'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import DownloadIcon from '@mui/icons-material/Download'; import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined'; +import InfoIcon from '@mui/icons-material/Info'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import Box from '@mui/material/Box'; @@ -70,7 +79,13 @@ import { InteractivePreview } from 'src/sections/agent-chat/InteractivePreview'; import { IMAGE_FAILURE } from 'src/sections/agent-chat/messages'; import { ResultFeedback } from 'src/sections/agent-chat/ResultFeedback'; import { RoundGrid } from 'src/sections/agent-chat/RoundGrid'; -import { actionButtonSx, bodyTextSx, labelSx, smallText } from 'src/sections/agent-chat/styles'; +import { + actionButtonSx, + bodyTextSx, + labelSx, + readableSx, + smallText, +} from 'src/sections/agent-chat/styles'; import { gridRows, triesText } from 'src/sections/agent-chat/timeline'; import { colors, fontSize, overlayButtonSx, typography } from 'src/theme'; @@ -100,6 +115,8 @@ const STATUS_LABEL: Record = { needs_attention: 'needs attention', }; +const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; + export interface ResultCardProps { version: PlotVersion; specId: string; @@ -159,6 +176,12 @@ export function ResultCard({ const previewReported = useRef(false); const toastTimer = useRef>(null); const [wasLatest, setWasLatest] = useState(latest); + // The run's details (its rounds and what changed) and the code, folded until asked for. The + // card's own state, outside the part a collapse unmounts, so the choice outlives a collapse. + const [detailsOpen, setDetailsOpen] = useState(false); + const [codeOpen, setCodeOpen] = useState(false); + const detailsId = useId(); + const codeId = useId(); useEffect( () => () => { @@ -369,6 +392,11 @@ export function ResultCard({ // Named the way the progress grid names its rounds, and which round shipped when an earlier one won. const attempts = triesText(result); const rounds = gridRows(result.rounds ?? [], [], result.status); + // The status line: the tries and how many changes the run made, and the toggle of both lists. + const summary = result.changes.length + ? `${attempts} · ${plural(result.changes.length, 'change')}` + : attempts; + const hasDetails = rounds.length > 0 || result.changes.length > 0; const header = ( {STATUS_LABEL[result.status] ?? result.status} - - {attempts} - {expanded ? '.collapse()' : '.expand()'} @@ -640,68 +665,124 @@ export function ResultCard({ )}
    - {/* Theme switch: the run rendered one theme; the other renders on demand. */} + {/* The theme switch and, at the row's end, the run's status line. */} - - theme + {/* Theme switch: the run rendered one theme; the other renders on demand. */} + + + theme + + {(['light', 'dark'] as const).map(theme => { + const active = theme === shownTheme; + const state = version.images[theme]?.state; + return ( + handleTheme(theme)} + sx={{ + ...actionButtonSx, + color: active ? 'var(--ink)' : 'var(--ink-soft)', + bgcolor: active ? 'var(--bg-elevated)' : 'transparent', + border: '1px solid', + borderColor: active ? 'var(--ink-muted)' : 'var(--rule)', + }} + > + {theme} + {state === 'loading' && theme !== shownTheme ? ' …' : ''} + + ); + })} + {!interactive && + readyImage?.status === 'needs_attention' && + shownTheme !== version.theme && ( + + padded onto the canvas + + )} - {(['light', 'dark'] as const).map(theme => { - const active = theme === shownTheme; - const state = version.images[theme]?.state; - return ( - handleTheme(theme)} - sx={{ - ...actionButtonSx, - color: active ? 'var(--ink)' : 'var(--ink-soft)', - bgcolor: active ? 'var(--bg-elevated)' : 'transparent', - border: '1px solid', - borderColor: active ? 'var(--ink-muted)' : 'var(--rule)', - }} - > - {theme} - {state === 'loading' && theme !== shownTheme ? ' …' : ''} - - ); - })} - {!interactive && - readyImage?.status === 'needs_attention' && - shownTheme !== version.theme && ( - - padded onto the canvas - - )} + {/* The run in one line: its tries and the count of its changes. The ⓘ toggle opens + the rounds and the "what changed" list; a run with neither has a plain line. */} + {hasDetails ? ( + setDetailsOpen(open => !open)} + aria-expanded={detailsOpen} + aria-controls={detailsOpen ? detailsId : undefined} + // The visible line, with what it opens in front for a screen reader. + aria-label={`Run details: ${summary}`} + sx={{ + ...actionButtonSx, + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + ml: 'auto', + mr: -0.75, + px: 0.75, + whiteSpace: 'normal', + textAlign: 'left', + }} + > + {summary} + {detailsOpen ? ( + + ) : ( + + )} + + ) : ( + + {summary} + + )} - {/* How each round of the run ended, as the progress grid showed it while it ran. */} - {rounds.length > 0 && ( - - # rounds - - - )} + {detailsOpen && hasDetails && ( + + {/* How each round of the run ended, as the progress grid showed it while it ran. */} + {rounds.length > 0 && ( + + # rounds + + + )} - {result.changes.length > 0 && ( - - # what changed - - {result.changes.map((change, index) => ( -
  • {change}
  • - ))} -
    + {result.changes.length > 0 && ( + + # what changed + + {result.changes.map((change, index) => ( +
  • {change}
  • + ))} +
    +
    + )}
    )} @@ -714,7 +795,7 @@ export function ResultCard({ }} > # notes — still worth a look - + {result.residual_defects.map((note, index) => (
  • {note}
  • ))} @@ -722,15 +803,14 @@ export function ResultCard({
    )} - {/* The adapted code: copy it, or download it with its data. */} - + {/* The adapted code: copy it, or download it with its data; the code itself on request. */} + @@ -739,6 +819,29 @@ export function ResultCard({ : `# ${codeName} runs next to data.csv`} + setCodeOpen(open => !open)} + aria-expanded={codeOpen} + aria-controls={codeOpen ? codeId : undefined} + aria-label={codeOpen ? 'Hide code' : 'Show code'} + sx={actionButtonSx} + > + {codeOpen ? '.hide_code()' : '.show_code()'} + + + version.code.state === 'ready' && copyToClipboard(version.code.text) + } + disabled={version.code.state !== 'ready'} + aria-label="Copy code" + sx={actionButtonSx} + > + {copied ? '.copied' : '.copy()'} + {fileError && ( - + {fileError} )} - - {version.code.state === 'ready' ? ( - <> - - - version.code.state === 'ready' && copyToClipboard(version.code.text) - } - aria-label="Copy code" - size="small" - sx={{ - position: 'absolute', - top: 10, - right: 10, - zIndex: 1, - bgcolor: 'var(--bg-elevated)', - border: '1px solid var(--code-border)', - '&:hover': { bgcolor: 'var(--bg-surface)' }, - }} - > - {copied ? ( - - ) : ( - - )} - - + {/* In view also while the code is folded: it says why `.copy()` waits. */} + {version.code.state === 'failed' && ( + + could not load {codeName} + + )} + {/* The highlighter's chunk loads only once the code is asked for. */} + {codeOpen && version.code.state !== 'failed' && ( + + {version.code.state === 'ready' ? ( - - ) : version.code.state === 'failed' ? ( - - could not load {codeName} - - ) : ( - - )} - + ) : ( + + )} + + )} {/* Quick feedback: this version as a case for tuning the assistant. */} diff --git a/app/src/sections/agent-chat/ResultFeedback.test.tsx b/app/src/sections/agent-chat/ResultFeedback.test.tsx index ecf731a8a1..2325caef46 100644 --- a/app/src/sections/agent-chat/ResultFeedback.test.tsx +++ b/app/src/sections/agent-chat/ResultFeedback.test.tsx @@ -29,7 +29,7 @@ const dataBox = (version: number) => const sentLine = () => screen.getByTestId('result-feedback-sent'); describe('ResultFeedback', () => { - it('offers the four reactions and sends only once one is picked', async () => { + it('shows only the four reactions at first, and the form once one is picked', async () => { const user = userEvent.setup(); const onSend = vi.fn(async () => undefined); render(); @@ -39,9 +39,17 @@ describe('ResultFeedback', () => { expect(screen.getByRole('button', { name })).toHaveAttribute('aria-pressed', 'false'); } expect(group.querySelectorAll('button')).toHaveLength(4); - expect(sendButton()).toBeDisabled(); + // Folded: no message box, no data box, nothing to send yet. + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Send feedback' })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Bug' })); + // The picked reaction keeps the focus; the form unfolds after it. + expect(screen.getByRole('button', { name: 'Bug' })).toHaveFocus(); + expect(screen.getByRole('textbox', { name: 'Feedback message' })).toBeInTheDocument(); + expect(dataBox(3)).toBeChecked(); + expect(sendButton()).toBeEnabled(); expect(screen.getByRole('button', { name: 'Bug' })).toHaveAttribute('aria-pressed', 'true'); await user.click(screen.getByRole('button', { name: 'Thumbs down' })); expect(screen.getByRole('button', { name: 'Bug' })).toHaveAttribute('aria-pressed', 'false'); @@ -55,9 +63,25 @@ describe('ResultFeedback', () => { }); }); + it('folds a fresh form back with .cancel(), with no reaction picked', async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Idea' })); + await user.click(screen.getByRole('button', { name: 'Cancel the feedback on version 3' })); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Idea' })).toHaveAttribute('aria-pressed', 'false'); + // `.cancel()` went with the form: the focus is on the first reaction, not on the page. + expect(screen.getByRole('button', { name: 'Thumbs up' })).toHaveFocus(); + expect(onSend).not.toHaveBeenCalled(); + }); + it('keeps the text to 500 characters and counts them', async () => { const user = userEvent.setup(); render(()} />); + await user.click(screen.getByRole('button', { name: 'Thumbs up' })); const box = screen.getByRole('textbox', { name: 'Feedback message' }); expect(box).toHaveAttribute('maxLength', '500'); @@ -71,11 +95,11 @@ describe('ResultFeedback', () => { const user = userEvent.setup(); const onSend = vi.fn(async () => undefined); render(); + await user.click(screen.getByRole('button', { name: 'Thumbs up' })); const box = dataBox(2); expect(box).toBeChecked(); await user.click(box); - await user.click(screen.getByRole('button', { name: 'Thumbs up' })); await user.click(sendButton()); expect(onSend).toHaveBeenLastCalledWith(2, { reaction: 'thumbs_up', @@ -251,13 +275,15 @@ describe('ResultFeedback', () => { const onSend = vi.fn(async () => undefined); renderPublic(onSend); + // The consent line unfolds with the form, so it is in view before `.send()` is. + expect(screen.queryByText(new RegExp(FEEDBACK_CONSENT.slice(0, 40)))).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Thumbs up' })); expect(dataBox(2)).not.toBeChecked(); expect(screen.getByText(new RegExp(FEEDBACK_CONSENT.slice(0, 40)))).toBeInTheDocument(); const legal = screen.getByRole('link', { name: 'legal' }); expect(legal).toHaveAttribute('href', '/legal#assistant'); expect(legal).toHaveAttribute('target', '_blank'); - await user.click(screen.getByRole('button', { name: 'Thumbs up' })); await user.click(sendButton()); expect(onSend).toHaveBeenCalledWith(2, expect.objectContaining({ includeData: false })); }); @@ -267,14 +293,16 @@ describe('ResultFeedback', () => { const onSend = vi.fn(async () => undefined); renderPublic(onSend); - await user.click(dataBox(2)); await user.click(screen.getByRole('button', { name: 'Bug' })); + await user.click(dataBox(2)); await user.click(sendButton()); expect(onSend).toHaveBeenCalledWith(2, expect.objectContaining({ includeData: true })); }); - it('keeps the admin default and shows no consent line without the public lane', () => { + it('keeps the admin default and shows no consent line without the public lane', async () => { + const user = userEvent.setup(); render(()} />); + await user.click(screen.getByRole('button', { name: 'Idea' })); expect(dataBox(2)).toBeChecked(); expect(screen.queryByRole('link', { name: 'legal' })).not.toBeInTheDocument(); }); diff --git a/app/src/sections/agent-chat/ResultFeedback.tsx b/app/src/sections/agent-chat/ResultFeedback.tsx index 6095a7cc86..bef1af6922 100644 --- a/app/src/sections/agent-chat/ResultFeedback.tsx +++ b/app/src/sections/agent-chat/ResultFeedback.tsx @@ -8,6 +8,11 @@ * form says what sending stores (`lane.ts`). `.send()` has the BFF store the * version's case in the private case bucket. * + * - Compact since 2026-11-02 (owner: less text): at first only the label and + * the reactions show; a click on one unfolds the message box, the data box, + * the consent line and `.send()`, so the consent is in view before anything + * can be sent. `.cancel()` folds a fresh form back to the reactions, with + * none picked, and the focus goes to the first of them. * - One case per version: once stored, the control shows `sent · v` with * the reaction, and `.edit()` opens the form again with what was sent; the * next `.send()` stores a new case that replaces it. The record lives on the @@ -157,6 +162,13 @@ export function ResultFeedback({ const handleCancel = () => { setFailure(null); + if (!sent) { + // A fresh form folds back to the reactions, which stay mounted: the focus moves to the + // first of them before `.cancel()` goes. + rootRef.current?.querySelector('[role="group"] button')?.focus(); + setReaction(null); + return; + } pendingFocus.current = 'edit'; setEditing(false); }; @@ -297,109 +309,121 @@ export function ResultFeedback({ })} - ) => setMessage(event.target.value)} - sx={{ ...nativeControlSx, width: '100%', boxSizing: 'border-box', resize: 'vertical' }} - /> - - + {/* Folded until a reaction is picked: the rest of the form, the consent line with it. */} + {reaction && ( + <> ) => - setIncludeData(event.target.checked) + component="textarea" + ref={messageRef} + aria-label="Feedback message" + placeholder="optional: what worked, what did not" + value={message} + maxLength={MAX_FEEDBACK_CHARS} + rows={2} + disabled={sending} + onChange={(event: React.ChangeEvent) => + setMessage(event.target.value) } - sx={{ m: 0, width: 14, height: 14, accentColor: colors.primary, cursor: 'inherit' }} + sx={{ ...nativeControlSx, width: '100%', boxSizing: 'border-box', resize: 'vertical' }} /> - {dataAvailable ? '# include data.csv' : '# no data yet'} - - {/* Right-aligned, also on a line of its own at phone width. */} - - - {message.length}/{MAX_FEEDBACK_CHARS} - - {sent && ( - - .cancel() - - )} - {/* As wide as the longest label, so the row does not jump while sending. */} - {sending ? '.send() …' : '.send()'} + ) => + setIncludeData(event.target.checked) + } + sx={{ m: 0, width: 14, height: 14, accentColor: colors.primary, cursor: 'inherit' }} + /> + {dataAvailable ? '# include data.csv' : '# no data yet'} + + {/* Right-aligned, also on a line of its own at phone width. */} + + + {message.length}/{MAX_FEEDBACK_CHARS} + + + .cancel() + + + {/* As wide as the longest label, so the row does not jump while sending. */} + + {sending ? '.send() …' : '.send()'} + + - - - {lane === 'public' && ( - - # {FEEDBACK_CONSENT} {/* A new tab: leaving the page would end the session. */} - - legal - - + {lane === 'public' && ( + + # {FEEDBACK_CONSENT} {/* A new tab: leaving the page would end the session. */} + + legal + + + )} + )} {failureLine} diff --git a/app/src/sections/agent-chat/SaveButton.test.tsx b/app/src/sections/agent-chat/SaveButton.test.tsx index d4ede0682f..83f0afff8e 100644 --- a/app/src/sections/agent-chat/SaveButton.test.tsx +++ b/app/src/sections/agent-chat/SaveButton.test.tsx @@ -43,6 +43,23 @@ describe('SaveButton', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); + it('says when the download started, so the menu can close, and never after a failure', async () => { + const user = userEvent.setup(); + const onSaved = vi.fn(); + const onSave = vi + .fn<(includeData: boolean) => Promise>() + .mockRejectedValueOnce(new AgentApiError(413, 'too_large', 'r-big')) + .mockResolvedValueOnce(undefined); + render(); + + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(await screen.findByRole('alert')).toHaveTextContent('too large for one file'); + expect(onSaved).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + }); + it('shows the save in progress, keeps the focus and starts no second one', async () => { const user = userEvent.setup(); const gate = deferred(); diff --git a/app/src/sections/agent-chat/SaveButton.tsx b/app/src/sections/agent-chat/SaveButton.tsx index 0a2750a569..cf8d0a091c 100644 --- a/app/src/sections/agent-chat/SaveButton.tsx +++ b/app/src/sections/agent-chat/SaveButton.tsx @@ -1,10 +1,11 @@ /** - * `.save()` in the chat header: downloads the session as one JSON file, the - * feedback case the agents service assembles (the conversation, every version's + * `.save()` in the chat header's "⋯" menu (`SessionMenu`, since 2026-11-02; in the header + * row before), on the admin lane only since then: downloads the session as one JSON file, + * the feedback case the agents service assembles (the conversation, every version's * code, plan, result and images, the data profile, the bindings and the config - * stamp), to keep or to hand on when something went wrong. `# include data.csv` - * adds each version's data file; it is on by default for an admin, who saves - * their own test data, and off on the public lane (`lane.ts`, + * stamp), the owner's debugging bundle. `# include data.csv` adds each version's data + * file; it is on by default for an admin, who saves their own test data. Rendered on + * the public lane, which the page no longer does, it would start off (`lane.ts`, * docs/concepts/agent-network.md, "Feedback capture"). * * The button holds its own saving flag and failure line: a save only reads the @@ -47,9 +48,11 @@ export interface SaveButtonProps { disabled: boolean; /** Fetch and download the bundle; rejects with an `AgentApiError` on failure. */ onSave: (includeData: boolean) => Promise; + /** The download started: the menu that holds the button closes. */ + onSaved?: () => void; } -export function SaveButton({ disabled, onSave }: SaveButtonProps) { +export function SaveButton({ disabled, onSave, onSaved }: SaveButtonProps) { // The file goes to the user's own computer; on the public lane it still holds the data only // when they tick the box, as the feedback form does. const lane = useAgentLane(); @@ -77,6 +80,7 @@ export function SaveButton({ disabled, onSave }: SaveButtonProps) { setFailure(null); try { await onSave(includeData); + if (mounted.current) onSaved?.(); } catch (err) { if (mounted.current) { setFailure( @@ -91,11 +95,16 @@ export function SaveButton({ disabled, onSave }: SaveButtonProps) { }; return ( - - + + - {/* As wide as the longest label, so the header does not jump while saving. */} + {/* As wide as the longest label, so the menu does not jump while saving. */} { + it('holds .save() behind the "⋯" button, folded until it is opened', async () => { + const user = userEvent.setup(); + render( undefined)} />); + + const button = screen.getByRole('button', { name: 'More actions' }); + expect(button).toHaveAttribute('aria-haspopup', 'dialog'); + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(); + + await user.click(button); + expect(screen.getByRole('dialog', { name: 'Save the session' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); + expect(screen.getByRole('checkbox', { name: '# include data.csv' })).toBeChecked(); + }); + + it('closes on Escape and gives the focus back to its button', async () => { + const user = userEvent.setup(); + render( undefined)} />); + const button = screen.getByRole('button', { name: 'More actions' }); + await user.click(button); + expect(screen.getByRole('dialog', { name: 'Save the session' })).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: 'Save the session' })).not.toBeInTheDocument() + ); + expect(button).toHaveFocus(); + }); + + it('keeps a failure line while open and drops it once the menu closes', async () => { + const user = userEvent.setup(); + const onSave = vi + .fn<(includeData: boolean) => Promise>() + .mockRejectedValue(new AgentApiError(413, 'too_large', 'r-big')); + render(); + await user.click(screen.getByRole('button', { name: 'More actions' })); + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(await screen.findByRole('alert')).toHaveTextContent('too large for one file'); + + await user.keyboard('{Escape}'); + await user.click(await screen.findByRole('button', { name: 'More actions' })); + expect(screen.getByRole('dialog', { name: 'Save the session' })).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + // The data box kept its state across the closing. + expect(screen.getByRole('checkbox', { name: '# include data.csv' })).toBeChecked(); + }); + + it('closes when something else starts, and waits until it ends', async () => { + const user = userEvent.setup(); + const onSave = vi.fn(async () => undefined); + const { rerender } = render(); + await user.click(screen.getByRole('button', { name: 'More actions' })); + expect(screen.getByRole('dialog', { name: 'Save the session' })).toBeInTheDocument(); + + rerender(); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: 'Save the session' })).not.toBeInTheDocument() + ); + expect(screen.getByTestId('session-menu')).toBeDisabled(); + }); +}); diff --git a/app/src/sections/agent-chat/SessionMenu.tsx b/app/src/sections/agent-chat/SessionMenu.tsx new file mode 100644 index 0000000000..b537210160 --- /dev/null +++ b/app/src/sections/agent-chat/SessionMenu.tsx @@ -0,0 +1,88 @@ +/** + * The "⋯" menu at the end of the chat header: `.save()` with its `# include data.csv` box + * (`SaveButton`), out of the header row since 2026-11-02 (owner: less text). In a menu of its + * own rather than in the feedback area, because the two do different things: `.save()` downloads + * the session to the user's own computer and stores nothing, while feedback stores a case on the + * server for the operator to read; one box for both would blur what goes where. The admin + * lane's only (owner decision of 2026-11-02): the page leaves it out on the public lane, where + * a visitor cannot resume a saved session and the feedback form covers "something went wrong". + * + * A popover, as the language chip's: it opens on a tap as on a click and returns the focus to + * the button when it closes. It stays mounted while closed, so the data box keeps the user's + * choice from one save to the next. A save that started its download closes it; a failure keeps + * it open with its line, which goes when the menu closes, so a later opening never shows a stale + * one. Like the chip, the button waits until the session is open and while anything else is in + * flight, which also closes an open menu. + */ + +import { useId, useState } from 'react'; + +import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; +import Box from '@mui/material/Box'; +import Popover from '@mui/material/Popover'; + +import { SaveButton, type SaveButtonProps } from 'src/sections/agent-chat/SaveButton'; +import { actionButtonSx, labelSx } from 'src/sections/agent-chat/styles'; + +export type SessionMenuProps = SaveButtonProps; + +export function SessionMenu({ disabled, onSave }: SessionMenuProps) { + const [anchor, setAnchor] = useState(null); + // Something else started (a turn, a parse, a render): the menu closes, as the language chip's does. + const [wasDisabled, setWasDisabled] = useState(disabled); + if (disabled !== wasDisabled) { + setWasDisabled(disabled); + if (disabled) setAnchor(null); + } + const popoverId = useId(); + const open = anchor !== null; + + return ( + + ) => setAnchor(event.currentTarget)} + disabled={disabled} + aria-label="More actions" + aria-haspopup="dialog" + aria-expanded={open} + aria-controls={open ? popoverId : undefined} + title="more · .save() the session as a json file" + sx={{ + ...actionButtonSx, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: 32, + px: 0.75, + border: '1px solid var(--rule)', + color: 'var(--ink)', + }} + > + + + setAnchor(null)} + keepMounted + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + slotProps={{ + paper: { + role: 'dialog', + 'aria-label': 'Save the session', + sx: { width: { xs: 'calc(100vw - 32px)', sm: 340 }, p: 1.5, mt: 0.5 }, + }, + }} + > + # save the session as a json file + {/* Closed counts as disabled for the button: its failure line from an earlier attempt goes. */} + setAnchor(null)} /> + + + ); +} diff --git a/app/src/sections/agent-chat/lane.ts b/app/src/sections/agent-chat/lane.ts index 52a229da82..79dba40a61 100644 --- a/app/src/sections/agent-chat/lane.ts +++ b/app/src/sections/agent-chat/lane.ts @@ -4,9 +4,11 @@ * provides it (`AgentChatPage`); without a provider it is the admin lane, so a * component rendered on its own behaves as it always has. * - * What differs on the public lane: the feedback and `.save()` boxes start - * unticked, and the feedback form says what sending stores (the owner's privacy - * rule: explicit consent at the moment of sending). + * What differs on the public lane: the feedback form's data box starts + * unticked, the form says what sending stores (the owner's privacy rule: + * explicit consent at the moment of sending), the experimental notice says that + * an AI does the work, and there is no `.save()` (since 2026-11-02; the page + * leaves the "⋯" menu out). */ import { createContext, useContext } from 'react'; @@ -19,6 +21,19 @@ export function useAgentLane(): AgentLane { return useContext(AgentLaneContext); } +/** + * The public lane's notice above the first input (`ExperimentalNotice`): one line, always in + * view, and the lines its ⓘ button opens, above the link to the legal page. The owner reads + * every user-facing text: keep them in the PR's "Texts to read". + */ +export const EXPERIMENTAL_LINE = + 'experimental AI · results can be wrong · stored only if you send feedback'; +export const EXPERIMENTAL_DETAILS = [ + 'Claude by Anthropic, through Google Cloud Vertex AI in the EU', + "don't paste data you may not share", + 'daily limits apply', +] as const; + /** * The consent line under the public feedback form, next to `# include data.csv`. * The owner reads every user-facing text: keep it in the PR's "Texts to read". diff --git a/app/src/sections/agent-chat/messages.ts b/app/src/sections/agent-chat/messages.ts index 653eca9df3..3d9995adbd 100644 --- a/app/src/sections/agent-chat/messages.ts +++ b/app/src/sections/agent-chat/messages.ts @@ -163,6 +163,24 @@ export const LANGUAGE_PASTED_TEXT = (language: string) => export const LEAVE_TEXT = 'leave the chat? the session ends here, and its data and plots cannot be restored'; +/** + * The how-to behind the data card's `# your data ⓘ` (since 2026-11-02, a label and a paragraph + * under the card before; owner: far too long, and interesting only once): the formats, the + * steps, a fix in plain words and changes afterwards. Example data and a curve are named by the + * two boxes' placeholders. The owner reads every user-facing text: keep them in the PR's "Texts + * to read". + */ +export const DATA_HOWTO = [ + 'formats: CSV, TSV, JSON, plain text, Excel, ODS, Parquet, Feather or NumPy', + 'paste or drop → check the preview and the bindings → .confirm() → .create_plot()', + 'the assistant fixes your data in plain words: "sum per month"', + 'afterwards, ask for changes: "log scale on y", "label the outliers"', +] as const; + +/** The data box's placeholder: a table or a file, example data, or a curve. */ +export const DATA_PLACEHOLDER = + 'paste a table or drop a file (csv, excel, json …)\nor type: example data\nor ask for a curve: y = sin(x), x from -pi to pi'; + /** Failures only the language chip meets; any other code reads as the page's turn errors do. */ export const LANGUAGE_FAILURE: Record = { invalid: 'the server refused this language name', diff --git a/app/src/sections/agent-chat/styles.ts b/app/src/sections/agent-chat/styles.ts index 7d2f21a89c..d8a60be948 100644 --- a/app/src/sections/agent-chat/styles.ts +++ b/app/src/sections/agent-chat/styles.ts @@ -126,6 +126,14 @@ export const visuallyHiddenSx = { border: 0, } as const; +/** + * A readable line, at most about 80 characters: the thread's text and a result card's text + * lists keep it since the chat column grows up to 1360 px (2026-11-02), while the data card, + * the plot cards and the code take the full width. + */ +export const CHAT_TEXT_MAX = '80ch'; +export const readableSx = { maxWidth: CHAT_TEXT_MAX } as const; + /** Small mono label above a control or a list. */ export const labelSx = { fontFamily: typography.mono, diff --git a/changelog.d/agent-chat-declutter.md b/changelog.d/agent-chat-declutter.md new file mode 100644 index 0000000000..b91f628d45 --- /dev/null +++ b/changelog.d/agent-chat-declutter.md @@ -0,0 +1,20 @@ +### Changed + +- **The agent chat shows less text, with the details behind buttons.** The + public experimental notice is one line, `# experimental AI · results can be + wrong · stored only if you send feedback`, and its ⓘ button opens the rest + (which AI and where, what not to paste, the daily limits, the legal page). A + result card shows its image, toolbar and theme switch, while the rounds and + the change list sit behind one status line and the code behind + `.show_code()`, with `.copy()` and the downloads in view; each card keeps its + choice for the session, and a new version starts folded. The quick feedback + shows only the reactions until one is picked. On the admin lane `.save()` + and its data box moved from the header row into a "⋯" menu, not into the + feedback area, because a save downloads the session and stores nothing; the + public lane has neither, since a visitor cannot resume a saved session and + the feedback form covers what went wrong. The data card's + header is `# your data ⓘ`, its how-to behind the button, and the long hint + and the how-to paragraph under the card gave way to short placeholders. + The chat column grows with the window up to 1360 px, so the data card, + the plot images and the code use a wide screen, while the chat text keeps + a readable line of about 80 characters. (#12540) diff --git a/docs/concepts/agent-network.md b/docs/concepts/agent-network.md index c72437b0d8..8972378310 100644 --- a/docs/concepts/agent-network.md +++ b/docs/concepts/agent-network.md @@ -488,7 +488,7 @@ Every agent result can be reported with one click and a short text, and the repo Built since 2026-10-12, the capture half. Every result card carries the control (`app/src/sections/agent-chat/ResultFeedback.tsx`): the four reactions, an optional text of at most 500 characters, `# include data.csv` (ticked by default), and `.send()`, which shows `sent · v` afterwards and takes an edit that stores a new case; sending never waits for a turn and never starts one. The BFF route `POST /debug/agent/sessions/{sid}/feedback` (`api/routers/agent.py`, `api/agent_cases.py`) fetches the version's bundle from anyplot-agents and writes the case to `cases//` in the bucket that `AGENT_CASES_BUCKET` names (`anyplot-agent-cases` by default): the PNG of each rendered theme, `plot.py`, `data.csv` only with the box ticked, and `manifest.json` last, with every field of the planned table row below plus `case_uri` and the bundle without its PNGs and data ([Feedback case](../reference/api.md#feedback-case)). The case id is the UTC time plus eight random hex digits, so ids sort by time; no upload replaces an object. An edit sends the earlier case's id as an optional `replaces`, which the manifest keeps, so the triage can tell which case an edit replaces; without it, the newest case of a session hash and version is the one that counts. The replaced case stays in the bucket, its `data.csv` too, until the lifecycle rule deletes it. The bundle carries no `model_version`, token or cost totals yet, so those manifest fields are `null`. `agents/evals/import_case.py` turns a saved bundle (`.save()`) or a case directory copied from the bucket into an eval case in the git-ignored `agents/evals/.cases/`, which the harness reads as promoted cases (`agents/README.md`, "Import a feedback case"); its expected outcome follows the triage rule below, thumbs up `accepted`, thumbs down `rejected`, and `--expected` for bug and idea. The bundle stores no change request and no bindings per version, so the tool matches a version's request by position only when the transcript's plot turns and the versions agree in number, and refuses otherwise until you pass the request yourself. The triage half is built since 2026-10-12 as well: the `agent_feedback` table and its migration, the "Agent cases" section in `DebugPage`, and `agents/evals/sync_cases.py`, which pulls the promoted cases from the bucket. Not built yet: the delete-my-case route. - **UI.** On the result card and in the chat page's floating-action slot: thumbs up, thumbs down, bug and idea (the existing reaction set), an optional text of at most 500 characters, and a checkbox "include my data file" (on by default for admins in phase 1; off by default and behind an explicit consent notice for real users later). Sending is optional and never blocks the flow. One submission per result version; a later edit replaces it. -- **Route.** `POST /debug/agent/sessions/{sid}/feedback {reaction, message?, include_data, result_version}` on the BFF, behind `require_admin` in phase 1; the public phase reuses the feedback router's per-IP-hash rate limit and honeypot. The BFF calls `GET /v1/sessions/{sid}/bundle` on anyplot-agents, which assembles the case on the server so that anyplot-agents stays credential-free: the transcript of user and root messages (never tool outputs or thoughts), the catalogue snapshot id, the working and run code of every version, the adapt plans and defect lines, the plot results, the dataset profile and bindings, the PNG of every rendered theme as the user saw it (with the footer strip) with its gate outcome, and a config stamp (`AGENT_PROVIDER`, `AGENT_MODEL`, `model_version` from `usage_metadata`, the judge model, prompt file hashes, validator and normaliser version, ADK version, token and cost totals, latency per step). `data.csv` is included only when `include_data` is set. Besides the feedback route, the chat page's `.save()` in the header downloads that bundle through the BFF's `GET /debug/agent/sessions/{sid}/bundle` as one JSON file (`anyplot-session-.json`, with `data.csv` while its `# include data.csv` box is ticked, the phase-1 default), so a session can be kept and handed on by hand. +- **Route.** `POST /debug/agent/sessions/{sid}/feedback {reaction, message?, include_data, result_version}` on the BFF, behind `require_admin` in phase 1; the public phase reuses the feedback router's per-IP-hash rate limit and honeypot. The BFF calls `GET /v1/sessions/{sid}/bundle` on anyplot-agents, which assembles the case on the server so that anyplot-agents stays credential-free: the transcript of user and root messages (never tool outputs or thoughts), the catalogue snapshot id, the working and run code of every version, the adapt plans and defect lines, the plot results, the dataset profile and bindings, the PNG of every rendered theme as the user saw it (with the footer strip) with its gate outcome, and a config stamp (`AGENT_PROVIDER`, `AGENT_MODEL`, `model_version` from `usage_metadata`, the judge model, prompt file hashes, validator and normaliser version, ADK version, token and cost totals, latency per step). `data.csv` is included only when `include_data` is set. Besides the feedback route, the admin chat page's `.save()` in the header's "⋯" menu (not on the public lane since 2026-11-02) downloads that bundle through the BFF's `GET /debug/agent/sessions/{sid}/bundle` as one JSON file (`anyplot-session-.json`, with `data.csv` while its `# include data.csv` box is ticked, the phase-1 default), so a session can be kept and handed on by hand. - **Storage**, written by the API, which already has database and GCS access: a row in a new table `agent_feedback` (Alembic migration) with `id, created_at, user_hash, session_hash, spec_id, library_id, language, reaction, message, include_data, status (new|in_progress|done|wont_solve), case_uri, model, model_version, prompt_hash, pipeline_status, attempts, tokens, cost_estimate`; the bundle as `cases//{manifest.json, plot-light.png, plot-dark.png, plot.py[, data.csv]}` in a new private EU bucket `anyplot-agent-cases` (public-access prevention, uniform access, lifecycle delete after 180 days by default; never `anyplot-images`). The attribution log stays content-free; the case bundle is the only place content is stored, and only on explicit submission. - **Triage.** A new "Agent cases" section in `DebugPage` lists cases with filters by reaction, status, spec, library and model version, opens a case (transcript, code diff against the catalogue original, the image of every rendered theme, defect lines, config stamp), updates the status, and offers **Promote to eval case**, which asks for an explicit expected outcome (`accepted` or `rejected`; the form preselects `accepted` for thumbs up and `rejected` for thumbs down, and makes the owner choose for bug and idea reactions, because those do not map to a fixture outcome) and copies the case (dataset, bindings, spec and library, the chosen outcome) to the `evals/cases//` prefix of the same private bucket. The harness syncs that prefix into a gitignored `agents/evals/.cases/` directory at run time. Promoted cases are never committed: they contain users' data, and anything under `agents/evals/fixtures/` ships under the repository's MIT licence. Only synthetic fixtures (the spike-X perturbation datasets) are committed. This is the bridge from real sessions to the regression harness. - **Feedback without a plot.** Since 2026-10-12 a session that made no plot can be reported too: the owner's session that asked for a feature column from a sine table never reached a result card, so he had to paste its JSON by hand. Below the composer, while the thread has a turn and the session has no version, `# no plot yet? tell us what went wrong` opens the same control (`ChatFeedback.tsx`) as `# feedback on this chat`; it sends `result_version` 0, the chat itself. The bundle route answers `version=0` with the conversation, the profile, the bindings and no version, and with `include_data` the session's current data as the top-level `data_csv` (`null` before any data, which the BFF answers with `422 no_data`; the control then shows `# no data yet` and cannot tick the box). The case holds `data.csv` (when asked for) and `manifest.json`, no image and no `plot.py`; its row carries `result_version` 0 and no pipeline status, and the same `replaces` chain links an edit. The triage labels it `chat, no plot`, shows no renders, and refuses its promotion (`422 no_plot`): the plot harness has nothing to replay, and `import_case` refuses it, because such a case belongs in the prep or scope eval fixtures. @@ -534,8 +534,8 @@ Built on 2026-10-10: the harness, the two fixture generators and the 146 fixture - **BFF** in `api/routers/agent.py`: `APIRouter(prefix="/debug/agent", dependencies=[Depends(require_admin)])`. A small refactor `require_admin_identity()` returns `AdminIdentity(email|None, via)` and `require_admin` wraps it unchanged. `user_id = "adm_" + HMAC(key, email or "token")[:16]`. POST requests need `Content-Type: application/json`, `X-Anyplot-Client: agent-chat/1` and an allowed Origin. ID tokens come from `google.oauth2.id_token.fetch_id_token` (skipped for localhost). The messages route is an async generator declared with `response_class=EventSourceResponse` (which is what gives FastAPI's automatic 15 s pings; Cloudflare returns 524 after 125 s), reads upstream with httpx `aiter_lines()`, assembles complete events, re-validates each event type against the `anyplot/1` allowlist, and yields `ServerSentEvent(event=..., raw_data=...)`; on upstream failure it emits `error{code:"upstream"}`. Routes mirror `/v1` plus `GET eligibility`, `POST sessions/{sid}/feedback`, and the triage routes `GET /debug/agent/cases`, `GET /debug/agent/cases/{id}` and `PATCH /debug/agent/cases/{id}`. The deploy smoke test expects 401 on `/debug/agent/status`. - **Public lane** in `api/routers/agent_public.py` (since 2026-11-02, shipped dark): `APIRouter(prefix="/agent")` behind `AGENT_PUBLIC_ENABLED`, which calls the admin lane's route functions with its own context. The identity is `require_public_identity`, the one seam a bot check (Turnstile) plugs into: the page's tab id from `X-Anyplot-Anon` plus the client's network from `client_ip` (Cloudflare's `CF-Connecting-IP`; IPv4 address or IPv6 /64), both forwarded upstream for the agents service to salt (`X-Anyplot-Anon`, `X-Anyplot-Network`), with `X-Anyplot-Tier: public`. The session id travels as `X-Anyplot-Session`, never in a URL, because Cloud Run keeps request URLs with the client address for 30 days. Admin-only and absent: the fan-out's `dataset/copy` and `source: fanout`, `GET /budget` and the `/cases*` triage; a test maps every admin route onto a public one or that list. `GET /agent/lane` answers `{"open": true}` without an identity or an upstream call, so the frontend learns at runtime whether to show the entry points: one switch opens and closes everything. A per-instance stream cap (`AGENT_PUBLIC_MAX_STREAMS`, 20) and a per-network hourly feedback cap (`AGENT_PUBLIC_FEEDBACK_PER_HOUR`, 20) are the lane's own; the per-user and per-IP limits live in the agents service. The route table is in [the API reference](../reference/api.md#public-lane). - **SSE protocol `anyplot/1`**, translated from ADK events and never forwarded raw: `ready{v, run_id}`, sent when the turn starts, at once; `status{step:"queued", position, waiting}` mid-turn while its `plot_pipeline` or `run_data_code` call waits in the run queue, at once, on every change and every 15 s unchanged (`position` 1 runs next, `waiting` counts every queued entry, this one included); `status{step, attempt}` for the pipeline's steps; `message{text}` (root final text only), `plot{PlotResult}`, `refusal{code, text}`, `error{code, ref}` with codes `capacity` (also for a run that waited the queue's maximum), `run_limit` (since 2026-10-21, a sandbox run past the user's `AGENT_USER_MAX_RUNS` across sessions), `deadline`, `guard_unavailable`, `upstream` and `internal`, and `done{llm_calls, tokens, bindings?, data_confirmed?, dataset?, language?}` (the last four are optional: after a run `bindings` and `data_confirmed` carry the session's bindings and whether the data is confirmed, so the page syncs its dropdowns and its step after a turn in which the root's `set_bindings` changed them, `dataset` carries the new data version after a turn in which the root's `prepare_data` or `generate_data` changed the data, and `language` carries the session language as `{language, source}`, `source` one of `default`, `pasted`, `detected` and `chosen`, so the page syncs its chip (the dataset route's answer carries the same field after a paste); a turn refused before the queue, such as the budget refusal, and a session gone meanwhile omit them). The translator tolerates the ADK 2.x `node_info` and `output` fields. A user already over the daily budget gets `ready`, `refusal{code:"budget"}` and `done` without waiting in the queue. Because queued time does not count toward the agents service's deadline, the BFF restarts its own turn budget (`AGENT_REQUEST_TIMEOUT_S`) on every queued status, with the queue's 15 s heartbeat on top because the run may start that long before its first event, and on the first event after the wait. Nothing moves a turn past `AGENT_TURN_MAX_S` (1,190 s), which stays below anyplot-api's `--timeout=1200`: a turn still queued when its run could no longer finish inside that cap ends with `error{code:"capacity"}`, and closing the upstream stream takes it out of the queue before it spends a token. -- **Frontend.** A lazy `app/src/pages/AgentChatPage.tsx` at `debug/agent`, laid out as one chat column (since 2026-10-11, "chat first"). The data card (`app/src/sections/agent-chat/DataPanel.tsx`) is the first turn of the thread: a textarea with a 1 MB counter (200 KB until 2026-10-14), preview table, binding dropdowns, `.confirm()` at its foot, and the `# changed` block with `.undo()` after the assistant changed the data or computed it from a formula, without `.undo()` when the formula made the session's first data; a formula pasted into the textarea gets the server's `422 formula`, and the card offers `.send()`, which sends the text to the chat as a turn. The assistant's summary of a stored parse is the next turn, and each data change updates the card in place. After `.confirm()` the card collapses into a header pinned under the site's sticky masthead (`✓ data fits · N rows · M columns · version V`), whose `.show()` opens the full card again without leaving step 2; any binding change, undo, data request or new paste drops the confirmation on the server, and the page returns to step 1 with the card open. The header then stays, as `! confirm again · N rows · M columns · version V` with the phrase in bold on a warning-tinted surface, and its `.show()` leads back to the card from anywhere in the thread, for example after a data request sent from below the plot. The thread gets a warning notice that names what dropped the confirmation (`bindings changed`, `the data changed`, `not confirmed on the server`), and the card's step line repeats it next to `.confirm()`; the first binding setup after a paste, where nothing was confirmed yet, gets none. The step line follows the data the card shows: a data request or undo after the loss names the changed data, and a new paste, which nobody has confirmed yet, names no loss. A table read from free text that waits for `.use()` or `.discard()` holds the page in step 1, even when a turn reports the server's confirmation of the stored data. While the header shows, the page's `scroll-padding-top` keeps keyboard focus and scrolled-to content below it. Below the thread sit a progress grid (since 2026-10-12, the owner's variant A: one row per round, `first try`, `second try`, ... (`repair 1` until live round 9 of 2026-10-16, which read as no second round; a running repair round says what it repairs in words on the right, `repairing text size, layout`, from the rubric ids, `Round.defects`, that the round before passed on), the same four steps in the same columns so each step lines up under the round above, and how the round ended on the right, such as `✓ ok`, `✗ review: 3 findings`, `✗ 2 measured`, `✗ code check failed` or, for the shipped round of a `needs_attention` result, `~ 1 finding open` (the round marked `shipped`, which is not always the last: a repair that fails its check leaves the earlier render shipped); the step a round stopped at shows a red ✗ and the steps after it a hollow dot; the facts come from `PlotResult.rounds` and the `outcome` and `findings` that the `repairing` status carries for the round before it; the result card keeps the same grid under `# rounds` and names the shipped round the same way (`second try`), or, when an earlier round's render won, which one (`first try shipped of 2`); on a phone the round's label and outcome take a line above its steps), a Stop button (`AbortController` plus the cancel route) and the composer. While a turn without pipeline steps runs (the summary, a question, a data request), the thread shows an in-progress line in the assistant's slot (`summarizing your data …`, `working on your message …`) until the first reply or pipeline step; the progress grid shows only for a plot run, a queue position or pipeline steps, so the two never say the same thing. While anything is in flight, the bindings block says that the bindings and `.confirm()` wait, and a running parse says so under `.parse()`. Once a dataset exists, the composer always shows `.create_plot()`: before the confirmation as the quiet button next to `.send()`, whose click calls nothing and shows a notice under the composer that leads to `.confirm()` (`# confirm the data first · …`, with `.show()`, which scrolls to the button and focuses it; for a table read from text that still waits, it leads to `.use()`). The notice reads the data card at every render, so a table read from text that arrives or is discarded after the click changes its text and the target of its `.show()`. In step 2 it is the filled action: with an empty box it starts the first plot as it is, with text the text goes as the user's turn, so the assistant makes the first plot with that change. Once the library has a plot, the composer refines with `.send()` (a question or a change on the latest version) and keeps a quiet `.start_over()` beside it (since 2026-10-12, `.create_plot()` before: the owner could not tell the two apart), described as "new from the catalogue plot, without your changes": a fresh plot of the same data from the catalogue code. A wide table (since 2026-10-12, `agents/anyplot/data/shape.py`): when the spec wants one required label role and exactly one required numeric role, no numeric series family, and the table holds 2 to 20 numeric columns and no text or boolean column (date columns are kept as keys), the dataset answers carry `suggestion`, the `pivot_longer` into two columns named in the session language (`Weekday`, `Month` or `Year` when every melted header is a weekday, month or year of one language, else `Category`, and `Value`; German `Wochentag`, `Monat`, `Jahr`, `Kategorie`, `Wert`; `shape.LONG_NAMES` for en, de, fr, es, it, pt, nl and pl, English otherwise; English for every session until live round 9 of 2026-10-16, where `Weekday` and `Value` became a German heatmap's axis and colorbar titles; the language route and every turn's `done` return the recomputed offer, so the card always names what `.to_long()` applies; a name that an input column already has gives way to the role's name, then to a numbered one, so names never decide the offer; `.to_long()` binds the two new columns to the roles they were made for, `LongSuggestion.bindings`, so `Category` never takes a spec's `category` role by its name), since live test round 8 of 2026-10-16, where the role names `y` and `value` became the plot's title "Wert nach Wochentag und Stunde"; the data card shows it as `# wide table` with `.to_long()`, which `POST /v1/sessions/{sid}/dataset/to_long` applies without a model call as the next version with the new columns bound to their roles, the user's bindings on the kept columns carried over and the defaults for the rest (undo as usual), and the root's profile tool carries it as `to_long` so the summary names it in one sentence. Since live test round 7 of 2026-10-16 a wide matrix gets it too: a spec whose required roles are two label roles of a grid or grouped chart (`shape.MATRIX_ROLES`: a heatmap's `x` and `y`, a grouped box's `category` and `subcategory`, a stacked bar's `component`; never a flow's `source` and `target`, a hierarchy's `name` and `parent` or a party's `color`) and one numeric role, and a table of one row-label column with a distinct label in every row (the one text column, else the one date column, else a first column of whole numbers that is a year or every hour of a range) plus at least three numeric columns (`Stunde;Mo;...;So`) is offered the `pivot_longer` of the numeric columns into the second label role, the row labels kept as the key for the first. Years and parts of a date are keys in both rules, never melted (an unnamed 1-to-12 column counts as a month only right after a year column that repeats), and so is a first column that numbers the rows (`id`, `Nr`, `Stunde` 0 to 3), which keeps the category of `id,a,b,c` on `id`. The numeric columns the offer melts (`bindings.label_siblings`) are never bound by default to a role that takes labels, also when `carry_bindings` refills the open roles after an undo: the role stays open, so the bindings are incomplete until `.to_long()` (the default had bound the heatmap's `y` to `Mo` and `value` to `Di`, and a confirmed run drew a meaningless matrix after 170 s). Bound so by hand, the binding is valid with a warning, shown under the dropdown (the dataset answer's `suggestion.siblings`) and in the session block, where the root offers `.to_long()` before any plot; blocking `.confirm()` on it is an open owner decision. Since 2026-10-12 the chat header carries a language chip next to the library menu (the library pills until 2026-11-02): `lang: en`, `lang: de · from data` after a paste set it from the notes around the table or the column names (since 2026-10-14; one `#` notice), `lang: de · detected` after the first typed message set it (the thread then shows one `#` line that names the chip), or `lang: de · chosen`. A click opens a popover with the ten common languages and a field for any other language in Latin letters; a choice calls the language route, and a `done` with `language` updates the chip, never moving it from `chosen` back down to `detected` or `default`. Keyboard focus follows the steps: after the user's own `.confirm()` it moves to `.create_plot()` before the first plot and to the header's `.show()` after it, a turn that reports the confirmation again leaves it where it is, and a key press on a composer button that a run replaces with Stop moves it into the message box, so a second press never stops the run. The **result card** (`app/src/sections/agent-chat/ResultCard.tsx`) reuses the plot page's overlay actions: the rendered plot shown inline as a blob URL, in a box that takes the loaded PNG's own aspect because the footer strip makes it taller than 16:9, with a light and dark switch that renders the other theme on demand through the theme toggle route, **Copy image** (Clipboard API `navigator.clipboard.write([new ClipboardItem({"image/png": blob})])`, falling back to download where unsupported), **Download PNG** for every rendered theme and **Open full size**; the adapted code in `CodeHighlighter` with **Copy code** (`useCopyCode`, one click), **Download plot.py** and **Download data.csv** (the pair runs unchanged); the change list and residual notes; and the quick-feedback control. The card has no composer of its own (since 2026-10-12): a change request or a question about the plot goes through the thread's one composer below it, and the assistant decides whether it is a change or an answer. Earlier versions stay reachable in the thread, each with its own image and code. The same card is reused unchanged when the feature goes public. `app/src/lib/sse.ts` parses the stream over `fetchWithAuth(...).body.getReader()`. The entry is the text action `.use_my_data() →` to the right of the library carousel in `app/src/sections/spec-detail/LibraryPills.tsx`, the mirror of `← .compare()` on its left, with no "experimental" of its own, which the chat's notice says once open (until 2026-11-02 it was the `.adapt()` button, a fourth overlay button on the image in `SpecDetailView.tsx`); it is wired through `onUseWithMyData` from `SpecPage.tsx` and renders only when `useAgentEligibility` (`app/src/hooks/useAgentEligibility.ts`) names a lane for the pair: the admin lane when `CONFIG.features.agentChat && (CONFIG.isDev || adminHint)`, else the public lane while `CONFIG.features.agentChat` holds and `GET /agent/lane` says it is open, in both cases only when that lane's eligibility route answers `eligible`. `adminHint` is a localStorage flag (`anyplot.adminHint`, `app/src/utils/adminAuth.ts`) that `DebugPage` sets after `/debug/status` succeeds and clears on a refusal. For an admin the entry does a full navigation to `/debug/agent` so Cloudflare Access can intercept; a public visitor goes through the router to `/agent`, and public pages never probe `/api/debug/*`. As built on 2026-10-10: the dataset response lists the spec's roles (`{name, kinds, required, variadic, description}`), so every role gets a dropdown, a variadic family one per member (`y1`, `y2`, ...) plus an empty one for the next, kept contiguous on the client; a refused binding set answers with the check's lines, which the panel lists; the `plot` event carries the stored `version`, which addresses the artifact and theme toggle routes (see [Agent chat](../reference/api.md#agent-chat)); the page starts nothing while a turn, a theme render, a parse or a binding change is in flight, since the server would refuse it with `409 run_active`; Stop calls the cancel route and keeps the stream open until the server's `done` (at most 30 s), so a result stored just before the stop still arrives; the session is also deleted on `pagehide` with `keepalive`, and since nothing restores it, the page asks before it goes while the session holds data, a table read from text, a plot or work in flight (since 2026-11-01, after a reload dropped the 15 plots of a `.plot_all()` unasked): a reload, a closed tab or a typed address gets the browser's `beforeunload` dialog, and a link inside the site or the back button gets the router's blocker with a `confirm`, while the page's own query edits (a library, `&all=1`) and the reload links after `session_expired` or `unreachable` pass (`app/src/hooks/useLeaveWarning.ts`); a request without a readable answer (in production usually an expired Cloudflare Access session) reloads the page once, then offers a reload link; the queue position shows as a small counter and as a `queued · 2 of 3` line above the progress grid; the entry adds `source=plot_page` to the URL, which the page reads for `agent_open` and then drops. `app/scripts/agent-bff-mock.mjs` serves the documented BFF routes and a scripted stream locally (for `scatter-basic` and the series family of `line-multi`), so the page can be driven in a browser without the API, the agents service or a model. -- **Public page** (since 2026-11-02, launch package): `/agent?spec=&library=&language=` is the same `AgentChatPage` on the public lane (`PublicAgentChatPage`), an ordinary route that opens while `GET /agent/lane` says the lane is open (`useAgentLaneOpen`, one request per page load) and says the assistant is closed otherwise. Its requests go to `/agent/*` through `src/lib/agent.ts` with the credential `PUBLIC_LANE` in place of the admin token: no cookie, the tab's random id from sessionStorage as `X-Anyplot-Anon`, the session id moved from the path into `X-Anyplot-Session` (`publicRoute`). Above the first input it shows the experimental notice (an AI, which one and where, can be wrong and is not checked by hand, what not to paste, nothing stored without feedback, daily limits; the EU AI Act's Art. 50 notice), with a link to the legal page's `#assistant`; while the session plots in Highcharts, both lanes show the licence note. It offers no `all`, and the `# include data.csv` boxes of the feedback form and `.save()` start unticked, the feedback form with a consent line that says what sending stores (`sections/agent-chat/lane.ts`). The entry points follow the same switch: the plot page's `.use_my_data() →` beside the library carousel (accessible name "Use with my data"; until 2026-11-02 an `.adapt()` button on the image) shows for everyone while the lane is open and the pair is eligible, an admin still going to `/debug/agent`, and while the lane is open the landing page carries one line, `# new: pick any plot, then .use_my_data() →`, that leads to the catalogue (`/plots`, `nav_click{source: hero_use_my_data}`), so the visitor picks the plot first; on launch day it opened the chat on scatter-basic in matplotlib (`agent_open{source: landing}`). +- **Frontend.** A lazy `app/src/pages/AgentChatPage.tsx` at `debug/agent`, laid out as one chat column (since 2026-10-11, "chat first"), which grows with the window up to 1360 px (960 px until 2026-11-02): the data card, the plot cards and the code take the full width, while the thread's replies, turns and notices and a card's text lists keep a readable line of at most about 80 characters (`CHAT_TEXT_MAX`). The data card (`app/src/sections/agent-chat/DataPanel.tsx`) is the first turn of the thread: a textarea with a 1 MB counter (200 KB until 2026-10-14), preview table, binding dropdowns, `.confirm()` at its foot, and the `# changed` block with `.undo()` after the assistant changed the data or computed it from a formula, without `.undo()` when the formula made the session's first data; a formula pasted into the textarea gets the server's `422 formula`, and the card offers `.send()`, which sends the text to the chat as a turn. The assistant's summary of a stored parse is the next turn, and each data change updates the card in place. After `.confirm()` the card collapses into a header pinned under the site's sticky masthead (`✓ data fits · N rows · M columns · version V`), whose `.show()` opens the full card again without leaving step 2; any binding change, undo, data request or new paste drops the confirmation on the server, and the page returns to step 1 with the card open. The header then stays, as `! confirm again · N rows · M columns · version V` with the phrase in bold on a warning-tinted surface, and its `.show()` leads back to the card from anywhere in the thread, for example after a data request sent from below the plot. The thread gets a warning notice that names what dropped the confirmation (`bindings changed`, `the data changed`, `not confirmed on the server`), and the card's step line repeats it next to `.confirm()`; the first binding setup after a paste, where nothing was confirmed yet, gets none. The step line follows the data the card shows: a data request or undo after the loss names the changed data, and a new paste, which nobody has confirmed yet, names no loss. A table read from free text that waits for `.use()` or `.discard()` holds the page in step 1, even when a turn reports the server's confirmation of the stored data. While the header shows, the page's `scroll-padding-top` keeps keyboard focus and scrolled-to content below it. Below the thread sit a progress grid (since 2026-10-12, the owner's variant A: one row per round, `first try`, `second try`, ... (`repair 1` until live round 9 of 2026-10-16, which read as no second round; a running repair round says what it repairs in words on the right, `repairing text size, layout`, from the rubric ids, `Round.defects`, that the round before passed on), the same four steps in the same columns so each step lines up under the round above, and how the round ended on the right, such as `✓ ok`, `✗ review: 3 findings`, `✗ 2 measured`, `✗ code check failed` or, for the shipped round of a `needs_attention` result, `~ 1 finding open` (the round marked `shipped`, which is not always the last: a repair that fails its check leaves the earlier render shipped); the step a round stopped at shows a red ✗ and the steps after it a hollow dot; the facts come from `PlotResult.rounds` and the `outcome` and `findings` that the `repairing` status carries for the round before it; the result card keeps the same grid under `# rounds`, behind its status line since 2026-11-02, and names the shipped round the same way (`second try`), or, when an earlier round's render won, which one (`first try shipped of 2`); on a phone the round's label and outcome take a line above its steps), a Stop button (`AbortController` plus the cancel route) and the composer. While a turn without pipeline steps runs (the summary, a question, a data request), the thread shows an in-progress line in the assistant's slot (`summarizing your data …`, `working on your message …`) until the first reply or pipeline step; the progress grid shows only for a plot run, a queue position or pipeline steps, so the two never say the same thing. While anything is in flight, the bindings block says that the bindings and `.confirm()` wait, and a running parse says so under `.parse()`. Once a dataset exists, the composer always shows `.create_plot()`: before the confirmation as the quiet button next to `.send()`, whose click calls nothing and shows a notice under the composer that leads to `.confirm()` (`# confirm the data first · …`, with `.show()`, which scrolls to the button and focuses it; for a table read from text that still waits, it leads to `.use()`). The notice reads the data card at every render, so a table read from text that arrives or is discarded after the click changes its text and the target of its `.show()`. In step 2 it is the filled action: with an empty box it starts the first plot as it is, with text the text goes as the user's turn, so the assistant makes the first plot with that change. Once the library has a plot, the composer refines with `.send()` (a question or a change on the latest version) and keeps a quiet `.start_over()` beside it (since 2026-10-12, `.create_plot()` before: the owner could not tell the two apart), described as "new from the catalogue plot, without your changes": a fresh plot of the same data from the catalogue code. A wide table (since 2026-10-12, `agents/anyplot/data/shape.py`): when the spec wants one required label role and exactly one required numeric role, no numeric series family, and the table holds 2 to 20 numeric columns and no text or boolean column (date columns are kept as keys), the dataset answers carry `suggestion`, the `pivot_longer` into two columns named in the session language (`Weekday`, `Month` or `Year` when every melted header is a weekday, month or year of one language, else `Category`, and `Value`; German `Wochentag`, `Monat`, `Jahr`, `Kategorie`, `Wert`; `shape.LONG_NAMES` for en, de, fr, es, it, pt, nl and pl, English otherwise; English for every session until live round 9 of 2026-10-16, where `Weekday` and `Value` became a German heatmap's axis and colorbar titles; the language route and every turn's `done` return the recomputed offer, so the card always names what `.to_long()` applies; a name that an input column already has gives way to the role's name, then to a numbered one, so names never decide the offer; `.to_long()` binds the two new columns to the roles they were made for, `LongSuggestion.bindings`, so `Category` never takes a spec's `category` role by its name), since live test round 8 of 2026-10-16, where the role names `y` and `value` became the plot's title "Wert nach Wochentag und Stunde"; the data card shows it as `# wide table` with `.to_long()`, which `POST /v1/sessions/{sid}/dataset/to_long` applies without a model call as the next version with the new columns bound to their roles, the user's bindings on the kept columns carried over and the defaults for the rest (undo as usual), and the root's profile tool carries it as `to_long` so the summary names it in one sentence. Since live test round 7 of 2026-10-16 a wide matrix gets it too: a spec whose required roles are two label roles of a grid or grouped chart (`shape.MATRIX_ROLES`: a heatmap's `x` and `y`, a grouped box's `category` and `subcategory`, a stacked bar's `component`; never a flow's `source` and `target`, a hierarchy's `name` and `parent` or a party's `color`) and one numeric role, and a table of one row-label column with a distinct label in every row (the one text column, else the one date column, else a first column of whole numbers that is a year or every hour of a range) plus at least three numeric columns (`Stunde;Mo;...;So`) is offered the `pivot_longer` of the numeric columns into the second label role, the row labels kept as the key for the first. Years and parts of a date are keys in both rules, never melted (an unnamed 1-to-12 column counts as a month only right after a year column that repeats), and so is a first column that numbers the rows (`id`, `Nr`, `Stunde` 0 to 3), which keeps the category of `id,a,b,c` on `id`. The numeric columns the offer melts (`bindings.label_siblings`) are never bound by default to a role that takes labels, also when `carry_bindings` refills the open roles after an undo: the role stays open, so the bindings are incomplete until `.to_long()` (the default had bound the heatmap's `y` to `Mo` and `value` to `Di`, and a confirmed run drew a meaningless matrix after 170 s). Bound so by hand, the binding is valid with a warning, shown under the dropdown (the dataset answer's `suggestion.siblings`) and in the session block, where the root offers `.to_long()` before any plot; blocking `.confirm()` on it is an open owner decision. Since 2026-10-12 the chat header carries a language chip next to the library menu (the library pills until 2026-11-02): `lang: en`, `lang: de · from data` after a paste set it from the notes around the table or the column names (since 2026-10-14; one `#` notice), `lang: de · detected` after the first typed message set it (the thread then shows one `#` line that names the chip), or `lang: de · chosen`. A click opens a popover with the ten common languages and a field for any other language in Latin letters; a choice calls the language route, and a `done` with `language` updates the chip, never moving it from `chosen` back down to `detected` or `default`. Keyboard focus follows the steps: after the user's own `.confirm()` it moves to `.create_plot()` before the first plot and to the header's `.show()` after it, a turn that reports the confirmation again leaves it where it is, and a key press on a composer button that a run replaces with Stop moves it into the message box, so a second press never stops the run. The **result card** (`app/src/sections/agent-chat/ResultCard.tsx`) reuses the plot page's overlay actions: the rendered plot shown inline as a blob URL, in a box that takes the loaded PNG's own aspect because the footer strip makes it taller than 16:9, with a light and dark switch that renders the other theme on demand through the theme toggle route, **Copy image** (Clipboard API `navigator.clipboard.write([new ClipboardItem({"image/png": blob})])`, falling back to download where unsupported), **Download PNG** for every rendered theme and **Open full size**; the adapted code in `CodeHighlighter` with **Copy code** (`useCopyCode`, one click), **Download plot.py** and **Download data.csv** (the pair runs unchanged); the change list and residual notes; and the quick-feedback control. Since 2026-11-02 (owner: less text, the details behind toggles rather than removed) the card shows its details folded: one status line at the end of the theme row (`second try · 5 changes ⓘ`) opens the rounds grid and the change list, `.show_code()` and `.hide_code()` open and close the code while `.copy()` and the downloads stay in view, each card keeps its own choice for the session (also across a collapse of the whole card, `.expand()` and `.collapse()`), and a new version's card starts folded; the residual notes stay in view. The quick feedback shows only the four reactions until one is picked, which unfolds the message box, the data box, the public lane's consent line and `.send()`. On the admin lane the header's `.save()` and its data box moved into a "⋯" menu at the header's end (`SessionMenu.tsx`), not into the feedback area: a save downloads the session and stores nothing, while feedback stores a case. The public lane has no `.save()` and no menu (owner decision of 2026-11-02): a visitor cannot resume a saved session, and the feedback form covers "something went wrong". The data card's label is `# your data` with an ⓘ (`InfoPopover.tsx`, shared with the public notice) that opens the how-to (`DATA_HOWTO`: the formats, the steps to `.create_plot()`, a fix in plain words, changes afterwards); the data box's placeholder names a table or a file, `example data` and a curve, the composer's names example data, a curve or a fix in step 1 and a change after a plot, and the how-to paragraph under the card is gone. The card has no composer of its own (since 2026-10-12): a change request or a question about the plot goes through the thread's one composer below it, and the assistant decides whether it is a change or an answer. Earlier versions stay reachable in the thread, each with its own image and code. The same card is reused unchanged when the feature goes public. `app/src/lib/sse.ts` parses the stream over `fetchWithAuth(...).body.getReader()`. The entry is the text action `.use_my_data() →` to the right of the library carousel in `app/src/sections/spec-detail/LibraryPills.tsx`, the mirror of `← .compare()` on its left, with no "experimental" of its own, which the chat's notice says once open (until 2026-11-02 it was the `.adapt()` button, a fourth overlay button on the image in `SpecDetailView.tsx`); it is wired through `onUseWithMyData` from `SpecPage.tsx` and renders only when `useAgentEligibility` (`app/src/hooks/useAgentEligibility.ts`) names a lane for the pair: the admin lane when `CONFIG.features.agentChat && (CONFIG.isDev || adminHint)`, else the public lane while `CONFIG.features.agentChat` holds and `GET /agent/lane` says it is open, in both cases only when that lane's eligibility route answers `eligible`. `adminHint` is a localStorage flag (`anyplot.adminHint`, `app/src/utils/adminAuth.ts`) that `DebugPage` sets after `/debug/status` succeeds and clears on a refusal. For an admin the entry does a full navigation to `/debug/agent` so Cloudflare Access can intercept; a public visitor goes through the router to `/agent`, and public pages never probe `/api/debug/*`. As built on 2026-10-10: the dataset response lists the spec's roles (`{name, kinds, required, variadic, description}`), so every role gets a dropdown, a variadic family one per member (`y1`, `y2`, ...) plus an empty one for the next, kept contiguous on the client; a refused binding set answers with the check's lines, which the panel lists; the `plot` event carries the stored `version`, which addresses the artifact and theme toggle routes (see [Agent chat](../reference/api.md#agent-chat)); the page starts nothing while a turn, a theme render, a parse or a binding change is in flight, since the server would refuse it with `409 run_active`; Stop calls the cancel route and keeps the stream open until the server's `done` (at most 30 s), so a result stored just before the stop still arrives; the session is also deleted on `pagehide` with `keepalive`, and since nothing restores it, the page asks before it goes while the session holds data, a table read from text, a plot or work in flight (since 2026-11-01, after a reload dropped the 15 plots of a `.plot_all()` unasked): a reload, a closed tab or a typed address gets the browser's `beforeunload` dialog, and a link inside the site or the back button gets the router's blocker with a `confirm`, while the page's own query edits (a library, `&all=1`) and the reload links after `session_expired` or `unreachable` pass (`app/src/hooks/useLeaveWarning.ts`); a request without a readable answer (in production usually an expired Cloudflare Access session) reloads the page once, then offers a reload link; the queue position shows as a small counter and as a `queued · 2 of 3` line above the progress grid; the entry adds `source=plot_page` to the URL, which the page reads for `agent_open` and then drops. `app/scripts/agent-bff-mock.mjs` serves the documented BFF routes and a scripted stream locally (for `scatter-basic` and the series family of `line-multi`), so the page can be driven in a browser without the API, the agents service or a model. +- **Public page** (since 2026-11-02, launch package): `/agent?spec=&library=&language=` is the same `AgentChatPage` on the public lane (`PublicAgentChatPage`), an ordinary route that opens while `GET /agent/lane` says the lane is open (`useAgentLaneOpen`, one request per page load) and says the assistant is closed otherwise. Its requests go to `/agent/*` through `src/lib/agent.ts` with the credential `PUBLIC_LANE` in place of the admin token: no cookie, the tab's random id from sessionStorage as `X-Anyplot-Anon`, the session id moved from the path into `X-Anyplot-Session` (`publicRoute`). Above the first input it shows the experimental notice, the EU AI Act's Art. 50 notice, as one line since 2026-11-02 (`# experimental AI · results can be wrong · stored only if you send feedback`), whose ⓘ button (`About the assistant`) opens a popover with the rest: which AI and where (Claude by Anthropic, through Google Cloud Vertex AI in the EU), what not to paste, the daily limits, and a link to the legal page's `#assistant` (`sections/agent-chat/ExperimentalNotice.tsx`); while the session plots in Highcharts, both lanes show the licence note. It offers no `all` and no `.save()` (since 2026-11-02), and the feedback form's `# include data.csv` box starts unticked, with a consent line that says what sending stores (`sections/agent-chat/lane.ts`). The entry points follow the same switch: the plot page's `.use_my_data() →` beside the library carousel (accessible name "Use with my data"; until 2026-11-02 an `.adapt()` button on the image) shows for everyone while the lane is open and the pair is eligible, an admin still going to `/debug/agent`, and while the lane is open the landing page carries one line, `# new: pick any plot, then .use_my_data() →`, that leads to the catalogue (`/plots`, `nav_click{source: hero_use_my_data}`), so the visitor picks the plot first; on launch day it opened the chat on scatter-basic in matplotlib (`agent_open{source: landing}`). - **Analytics** (enum properties only, documented in [Plausible](../reference/plausible.md#agent-chat)): the pageviews `/debug/agent` and `/agent` (the public page), `agent_open{library,source,spec}`, `agent_data_parsed{status,size_bucket}`, `agent_data_confirmed{size_bucket}`, `agent_data_prepared{changes}`, `agent_data_undo{version}`, `agent_plot_rendered{library,status,repaired,spec}`, `agent_guardrail_block{reason}`, `agent_session_saved{include_data}`, `agent_result_feedback{reaction,include_data}`, and `copy_code{page:'agent_chat',method:'agent'}`, all built; the public page fires the same events. The session language fires no event: decided while the chat was admin-only, when the chip's numbers would have been tiny, and kept for the experimental launch. ## Repository layout diff --git a/docs/reference/api.md b/docs/reference/api.md index 89b3caecc5..0b120a54de 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -579,7 +579,7 @@ All paths start with `/agent`. A session route takes the session id as | `POST /session/messages?theme=` | The chat turn, behind the per-instance stream cap | | `POST /session/cancel`, `POST /session/outcome` | Stop, and the outcome beacon | | `POST /session/versions/{version}/render`, `GET /session/artifacts/{name}?v=&theme=` | The theme toggle and the result files | -| `GET /session/bundle?include_data=&version=` | `.save()`: your session as a file; anyplot keeps no copy | +| `GET /session/bundle?include_data=&version=` | Your session as a file; anyplot keeps no copy. The public page offers no `.save()` since 2026-11-02, so no page calls it; the admin page's `.save()` uses the admin route | | `POST /session/feedback` | The feedback case, behind the per-network hourly cap. The case's `user_hash` is a salted hash of your tab id, never the id itself | The admin lane's fan-out copy (`POST /sessions/{sid}/dataset/copy`), the day diff --git a/docs/reference/plausible.md b/docs/reference/plausible.md index e89571a586..4528a5028c 100644 --- a/docs/reference/plausible.md +++ b/docs/reference/plausible.md @@ -137,8 +137,10 @@ dataset size: the size travels as a bucket. The events exist only in builds with `VITE_ENABLE_AGENT_CHAT=true`, and like every event they fire only on `anyplot.ai`. Since 2026-11-02 the public chat at `/agent` fires the same events with the same properties; the pageview tells the two lanes apart. On -the public lane the `# include data.csv` boxes start unticked, so -`include_data` reads `no` unless the user ticked the box. +the public lane the feedback form's `# include data.csv` box starts unticked, +so `include_data` reads `no` unless the user ticked the box, and since +2026-11-02 it offers no `.save()`, so `agent_session_saved` comes only from the +admin lane. | Event Name | Properties | Where | Description | |------------|-----------|-------|-------------| @@ -150,7 +152,7 @@ the public lane the `# include data.csv` boxes start unticked, so | `agent_data_file` | `kind`, `encoding`, `outcome` | DataPanel.tsx | A file reached the data box: dropped on it, chosen with `.open()` or pasted as a file (since 2026-10-15). A text file is read in the browser; a spreadsheet, Parquet, Feather, Arrow or NumPy file goes to the server, which converts it in the renderer's sandbox (since the binary-file step of 2026-10-15). `.parse()` afterwards reports `agent_data_parsed` as for a paste. `kind` ∈ `text`, `spreadsheet`, `parquet`, `arrow`, `numpy`, `hdf5`, `pickle`, `pdf`, `archive`, `binary` (by the file's first bytes in the browser; `binary` for anything else that is not text), `unknown` (refused before it was read), and for a file the server converted the format it found: `xlsx`, `xlsm`, `xlsb`, `xls`, `ods`, `parquet`, `feather`, `arrow`, `npy`, `npz`; `encoding` ∈ `utf-8`, `utf-8-bom`, `utf-16le`, `utf-16be`, `windows-1252`, `none` (a binary file); `outcome` ∈ `filled` (into an empty box), `replaced` (the box had text and the user chose `.replace()`), `kept` (the user chose `.keep()`), `sheet` (another sheet or `.npz` array chosen in the sheet picker and read again), `refused` (a format never read, an empty file, or a file the server refused: encrypted, damaged, over a cap), `failed` (the server could not convert it now: rate limit, no render slot, renderer unavailable, network), `too_large` (over 4 MB as a text file or 5 MB as a binary one, or over 1 MB as text), `busy` (a step was running, or another file was still being read), `unreadable` (the browser could not read it). The file's name and its sheet names are never sent | | `agent_plot_rendered` | `library`, `status`, `repaired`, `spec` | useAgentSession.ts | A turn's `plot` event arrived. `status` ∈ `ok`, `needs_attention`, `failed`, `not_ready`; `repaired` is `yes` when the run needed its repair round (two attempts), else `no` | | `agent_guardrail_block` | `reason` | useAgentSession.ts | A guardrail stopped a request. `reason` ∈ `out_of_scope`, `needs_data` (the request needs data the service would have to fetch), `budget` (the user's own daily token budget), `service_paused` (since 2026-11-02: the whole service spent its day budget, from a turn's refusal or the dataset route's `429`), `unsupported_content`, `rate_limited` and `daily_limit` (the user's message limits per minute and per day, and since 2026-10-14 the paste limits, which the data panel reports from the dataset route's `429`), `session_closed` (the session closed after three scope refusals), `other_library` (a request for this plot in another library or language, answered with a pointer to the catalogue), `locked_out` (the user is paused for the day after three attack verdicts; these are the stream's fixed refusals), `data_refused` (the pre-filter or the dataset judge refused the pasted data: text addressed to an AI, or the judge's verdict; personal data is never a reason), `guard_unavailable` (the scope check could not answer and failed closed), `other` (a refusal code the page does not know yet) | -| `agent_session_saved` | `include_data` | useAgentSession.ts | The user saved the session as a JSON file with `.save()` in the chat header, and the download started. `include_data` is `yes` when the `# include data.csv` box was ticked (the admin's default; unticked on the public lane), so the file holds each version's data file, else `no`. A failed save fires nothing | +| `agent_session_saved` | `include_data` | useAgentSession.ts | The admin saved the session as a JSON file with `.save()` in the chat header's "⋯" menu (admin lane only since 2026-11-02), and the download started. `include_data` is `yes` when the `# include data.csv` box was ticked (the admin's default), so the file holds each version's data file, else `no`. A failed save fires nothing | | `agent_result_feedback` | `reaction`, `include_data`, `subject` | useAgentSession.ts | The user sent quick feedback with `.send()`, on a result version from its result card or on the chat itself from the control below the composer, and the BFF stored it as a feedback case. `reaction` ∈ `thumbs_up`, `thumbs_down`, `bug`, `idea`, the site feedback's set; `include_data` is `yes` when the `# include data.csv` box was ticked (the admin's default; unticked on the public lane), so the case holds the data file, else `no`; `subject` is `result` for a version and `chat` for the chat (since 2026-10-12, for a session that made no plot). An edit that replaces a sent case fires again. A failed send fires nothing; the text never travels | `copy_code` on a result card carries `method: agent` and `page: agent_chat`