From 0f8b1b9e14ab6d388da25f305df63c00830855bb Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:51:41 +0200 Subject: [PATCH 1/4] feat(agent-chat): the data box shows what it waits for A server-side file read (Parquet, Excel, ODS, Feather, NumPy) and a .parse() take seconds, but the box only greyed its buttons and changed a quiet line under them, so the page looked stuck. BoxWorking draws the step over the box with a pulsing ellipsis, a seconds counter and a thin bar (plain indicator, style guide 10.1; no motion under reduced motion). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- .../sections/agent-chat/BoxWorking.test.tsx | 19 ++++ app/src/sections/agent-chat/BoxWorking.tsx | 87 +++++++++++++++++++ .../sections/agent-chat/DataPanel.test.tsx | 8 ++ app/src/sections/agent-chat/DataPanel.tsx | 26 +++--- changelog.d/agent-box-working.md | 9 ++ 5 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 app/src/sections/agent-chat/BoxWorking.test.tsx create mode 100644 app/src/sections/agent-chat/BoxWorking.tsx create mode 100644 changelog.d/agent-box-working.md diff --git a/app/src/sections/agent-chat/BoxWorking.test.tsx b/app/src/sections/agent-chat/BoxWorking.test.tsx new file mode 100644 index 00000000000..94f20fc73c2 --- /dev/null +++ b/app/src/sections/agent-chat/BoxWorking.test.tsx @@ -0,0 +1,19 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BoxWorking } from 'src/sections/agent-chat/BoxWorking'; +import { act, render, screen } from 'src/test-utils'; + +describe('BoxWorking', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('names the step at once and counts the seconds from the first one on', () => { + render(); + const line = screen.getByTestId('box-working'); + expect(line).toHaveTextContent(/^# reading "meter\.parquet" on the server …$/); + expect(line).toHaveAttribute('aria-hidden', 'true'); + + act(() => vi.advanceTimersByTime(3000)); + expect(line).toHaveTextContent('# reading "meter.parquet" on the server … · 3 s'); + }); +}); diff --git a/app/src/sections/agent-chat/BoxWorking.tsx b/app/src/sections/agent-chat/BoxWorking.tsx new file mode 100644 index 00000000000..85bf43c5e9c --- /dev/null +++ b/app/src/sections/agent-chat/BoxWorking.tsx @@ -0,0 +1,87 @@ +/** + * What the data box waits for, drawn over it: a file read on the server or a parse. + * Both take seconds while the box itself does not change, so the line names the step, + * pulses its `…` and counts the seconds from the second one on, and a thin bar runs + * along the box's foot: a plain indicator, no spinner (docs/reference/style-guide.md + * §10.1). Hidden from screen readers, which hear the live line under the box; without + * motion under `prefers-reduced-motion`, where the counter still moves. + */ + +import { useEffect, useState } from 'react'; + +import Box from '@mui/material/Box'; + +import { Ellipsis } from 'src/sections/agent-chat/Ellipsis'; +import { labelSx } from 'src/sections/agent-chat/styles'; +import { fontSize } from 'src/theme'; + +export function BoxWorking({ line }: { line: string }) { + const [seconds, setSeconds] = useState(0); + useEffect(() => { + const started = Date.now(); + const timer = window.setInterval( + () => setSeconds(Math.floor((Date.now() - started) / 1000)), + 1000 + ); + return () => window.clearInterval(timer); + }, []); + + return ( + + + # {line} + {seconds >= 1 ? ` · ${seconds} s` : ''} + + + + + + ); +} diff --git a/app/src/sections/agent-chat/DataPanel.test.tsx b/app/src/sections/agent-chat/DataPanel.test.tsx index 3740b8bcdfa..479277d2cdf 100644 --- a/app/src/sections/agent-chat/DataPanel.test.tsx +++ b/app/src/sections/agent-chat/DataPanel.test.tsx @@ -538,6 +538,8 @@ describe('DataPanel working states', () => { rerender(stateWith({ x: 'month', y1: 'shoes' }, { parsing: true })); expect(screen.getByTestId('parse-hint')).toHaveTextContent('# parsing your data …'); + expect(screen.getByTestId('box-working')).toHaveTextContent('# parsing your data …'); + expect(screen.getByTestId('data-drop-zone')).toHaveAttribute('aria-busy', 'true'); const parse = screen.getByRole('button', { name: '.parse() …' }); expect(parse).toBeDisabled(); expect(parse).toHaveAccessibleDescription('# parsing your data'); @@ -554,6 +556,7 @@ describe('DataPanel working states', () => { parsing: true, }); expect(screen.getByTestId('parse-hint')).toHaveTextContent('# parsing your data …'); + expect(screen.getByTestId('box-working')).toBeInTheDocument(); expect(screen.queryByTestId('bindings-lock')).not.toBeInTheDocument(); }); @@ -1597,6 +1600,10 @@ describe('DataPanel file drop', () => { '# reading "Bericht.xlsx" on the server …' ) ); + // The box itself says what it waits for, not only the line under it. + expect(screen.getByTestId('box-working')).toHaveTextContent( + '# reading "Bericht.xlsx" on the server …' + ); expect(screen.getByRole('button', { name: 'Open a data file' })).toBeDisabled(); drop(csvFile()); @@ -1607,6 +1614,7 @@ describe('DataPanel file drop', () => { finish(answer()); await waitFor(() => expect(box()).toHaveValue('Report 2024\n\nmonth,revenue\nJan,120\n')); + expect(screen.queryByTestId('box-working')).not.toBeInTheDocument(); }); it('answers a pickle by its format, without the server', async () => { diff --git a/app/src/sections/agent-chat/DataPanel.tsx b/app/src/sections/agent-chat/DataPanel.tsx index bc9f10fa571..855995cfe10 100644 --- a/app/src/sections/agent-chat/DataPanel.tsx +++ b/app/src/sections/agent-chat/DataPanel.tsx @@ -130,6 +130,7 @@ import { siblingHint, signHint, } from 'src/sections/agent-chat/bindings'; +import { BoxWorking } from 'src/sections/agent-chat/BoxWorking'; import { Ellipsis } from 'src/sections/agent-chat/Ellipsis'; import { countLines, @@ -779,8 +780,10 @@ export function DataPanel({ onConvert, }: DataPanelProps) { const [text, setText] = useState(''); - // A binary file on its way to the server and back; the box, `.open()` and `.parse()` wait for it. - const [converting, setConverting] = useState(false); + // A binary file on its way to the server and back, as the line the box shows meanwhile + // (`reading "x.parquet" on the server`); the box, `.open()` and `.parse()` wait for it. + const [convertLine, setConvertLine] = useState(null); + const converting = convertLine !== null; // The converted file whose text is in the box, for the sheet picker; a text paste or an empty box drops it. const [converted, setConverted] = useState(null); // The name of the file the box's text came from; typing keeps it, a text paste or an empty box drops it. @@ -952,13 +955,11 @@ export function DataPanel({ trackFile(file.browserKind, null, 'refused'); return; } - setConverting(true); - setFileNotice({ - tone: 'ok', - text: sheet - ? `reading the sheet ${sheetLabel(sheet)} of "${file.name}" on the server …` - : `reading "${file.name}" on the server …`, - }); + const line = sheet + ? `reading the sheet ${sheetLabel(sheet)} of "${file.name}" on the server` + : `reading "${file.name}" on the server`; + setConvertLine(line); + setFileNotice({ tone: 'ok', text: `${line} …` }); let answer: ConvertAnswer; try { answer = await onConvert(file.base64, sheet); @@ -981,7 +982,7 @@ export function DataPanel({ trackFile(file.browserKind, null, failure.code.startsWith('file_') ? 'refused' : 'failed'); return; } finally { - if (seq === readSeq.current) setConverting(false); + if (seq === readSeq.current) setConvertLine(null); } if (seq !== readSeq.current) return; offerFile( @@ -1210,6 +1211,7 @@ export function DataPanel({ )} + {/* A file read on the server or a parse takes seconds: the box says so while it waits. */} + {!dragOver && (converting || state.parsing) && ( + + )} Date: Wed, 21 Oct 2026 00:52:59 +0200 Subject: [PATCH 2/4] chore: reference PR #12221 in the changelog fragment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- changelog.d/agent-box-working.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/agent-box-working.md b/changelog.d/agent-box-working.md index d032f095886..ea01494d379 100644 --- a/changelog.d/agent-box-working.md +++ b/changelog.d/agent-box-working.md @@ -6,4 +6,4 @@ or `# parsing your data … · 3 s` over itself: a pulsing `…`, a seconds counter and a thin bar along its foot. Before, only the buttons greyed out and a quiet line under them changed, so the page looked stuck for the several seconds a - sandbox read takes. + sandbox read takes. (#12221) From 72e9a6991799b0760de02710efda402842557331 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:58:29 +0200 Subject: [PATCH 3/4] fix(agent-chat): box overlay dims the text, survives a drag, never sticks Gemini 4 Argon review of #12221: - the overlay dims the text underneath, so the line stands out on the box's own surface in both themes; - a drag over the box hides the overlay instead of unmounting it, so the seconds counter keeps running; - only the conversion that set the line clears it (convertSeq), so a later read that converted nothing cannot leave the box waiting; - aria-busy sits on the textarea, not on its wrapper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- app/src/sections/agent-chat/BoxWorking.tsx | 7 ++++++- app/src/sections/agent-chat/DataPanel.test.tsx | 2 +- app/src/sections/agent-chat/DataPanel.tsx | 17 +++++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/app/src/sections/agent-chat/BoxWorking.tsx b/app/src/sections/agent-chat/BoxWorking.tsx index 85bf43c5e9c..eed70e11566 100644 --- a/app/src/sections/agent-chat/BoxWorking.tsx +++ b/app/src/sections/agent-chat/BoxWorking.tsx @@ -15,7 +15,8 @@ import { Ellipsis } from 'src/sections/agent-chat/Ellipsis'; import { labelSx } from 'src/sections/agent-chat/styles'; import { fontSize } from 'src/theme'; -export function BoxWorking({ line }: { line: string }) { +/** `hidden`: a drag over the box shows its own hint; the counter keeps running underneath. */ +export function BoxWorking({ line, hidden = false }: { line: string; hidden?: boolean }) { const [seconds, setSeconds] = useState(0); useEffect(() => { const started = Date.now(); @@ -40,6 +41,10 @@ export function BoxWorking({ line }: { line: string }) { textAlign: 'center', pointerEvents: 'none', overflow: 'hidden', + visibility: hidden ? 'hidden' : 'visible', + // Dims the text underneath, so the line stands out on the box's own surface in both themes. + bgcolor: 'color-mix(in srgb, var(--bg-elevated) 55%, transparent)', + borderRadius: '4px', }} > { expect(screen.getByTestId('parse-hint')).toHaveTextContent('# parsing your data …'); expect(screen.getByTestId('box-working')).toHaveTextContent('# parsing your data …'); - expect(screen.getByTestId('data-drop-zone')).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByLabelText(/# your data/)).toHaveAttribute('aria-busy', 'true'); const parse = screen.getByRole('button', { name: '.parse() …' }); expect(parse).toBeDisabled(); expect(parse).toHaveAccessibleDescription('# parsing your data'); diff --git a/app/src/sections/agent-chat/DataPanel.tsx b/app/src/sections/agent-chat/DataPanel.tsx index 855995cfe10..4a3ec094b97 100644 --- a/app/src/sections/agent-chat/DataPanel.tsx +++ b/app/src/sections/agent-chat/DataPanel.tsx @@ -801,6 +801,8 @@ export function DataPanel({ const boxRef = useRef(null); // Counts file reads, so the result of a superseded one is dropped. const readSeq = useRef(0); + // The read whose server conversion `convertLine` shows. + const convertSeq = useRef(0); // The box's text as of the last render, for a file read that ends after an edit. const textRef = useRef(text); textRef.current = text; @@ -958,6 +960,7 @@ export function DataPanel({ const line = sheet ? `reading the sheet ${sheetLabel(sheet)} of "${file.name}" on the server` : `reading "${file.name}" on the server`; + convertSeq.current = seq; setConvertLine(line); setFileNotice({ tone: 'ok', text: `${line} …` }); let answer: ConvertAnswer; @@ -982,7 +985,9 @@ export function DataPanel({ trackFile(file.browserKind, null, failure.code.startsWith('file_') ? 'refused' : 'failed'); return; } finally { - if (seq === readSeq.current) setConvertLine(null); + // Only the conversion that set the line clears it; a later read that converted + // nothing (refused, or a text file) must not leave the box waiting forever. + if (seq === convertSeq.current) setConvertLine(null); } if (seq !== readSeq.current) return; offerFile( @@ -1211,7 +1216,6 @@ export function DataPanel({ ) => { setText(event.target.value); @@ -1293,8 +1298,12 @@ export function DataPanel({ )} {/* A file read on the server or a parse takes seconds: the box says so while it waits. */} - {!dragOver && (converting || state.parsing) && ( - + {(converting || state.parsing) && ( + Date: Wed, 21 Oct 2026 01:08:59 +0200 Subject: [PATCH 4/4] fix(agent-chat): the box count runs from the step's start, long names wrap Copilot review of #12221: - the panel keeps when the conversion and the parse began (set where they start), so collapsing and reopening the card no longer restarts the overlay's seconds count; - the overlay's label wraps a long file or sheet name instead of being clipped on a narrow screen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- .../sections/agent-chat/BoxWorking.test.tsx | 7 ++++- app/src/sections/agent-chat/BoxWorking.tsx | 31 ++++++++++++++----- app/src/sections/agent-chat/DataPanel.tsx | 10 +++++- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/app/src/sections/agent-chat/BoxWorking.test.tsx b/app/src/sections/agent-chat/BoxWorking.test.tsx index 94f20fc73c2..2dc9ddfd5a1 100644 --- a/app/src/sections/agent-chat/BoxWorking.test.tsx +++ b/app/src/sections/agent-chat/BoxWorking.test.tsx @@ -8,7 +8,7 @@ describe('BoxWorking', () => { afterEach(() => vi.useRealTimers()); it('names the step at once and counts the seconds from the first one on', () => { - render(); + render(); const line = screen.getByTestId('box-working'); expect(line).toHaveTextContent(/^# reading "meter\.parquet" on the server …$/); expect(line).toHaveAttribute('aria-hidden', 'true'); @@ -16,4 +16,9 @@ describe('BoxWorking', () => { act(() => vi.advanceTimersByTime(3000)); expect(line).toHaveTextContent('# reading "meter.parquet" on the server … · 3 s'); }); + + it('counts from when the step began, not from when it mounted', () => { + render(); + expect(screen.getByTestId('box-working')).toHaveTextContent('# parsing your data … · 7 s'); + }); }); diff --git a/app/src/sections/agent-chat/BoxWorking.tsx b/app/src/sections/agent-chat/BoxWorking.tsx index eed70e11566..40d3d8a5945 100644 --- a/app/src/sections/agent-chat/BoxWorking.tsx +++ b/app/src/sections/agent-chat/BoxWorking.tsx @@ -15,17 +15,28 @@ import { Ellipsis } from 'src/sections/agent-chat/Ellipsis'; import { labelSx } from 'src/sections/agent-chat/styles'; import { fontSize } from 'src/theme'; -/** `hidden`: a drag over the box shows its own hint; the counter keeps running underneath. */ -export function BoxWorking({ line, hidden = false }: { line: string; hidden?: boolean }) { - const [seconds, setSeconds] = useState(0); +/** + * `since`: when the step began (`Date.now()`), kept by the caller so a remount (a collapsed + * and reopened card) does not restart the count. `hidden`: a drag over the box shows its own + * hint; the count runs on underneath. + */ +export function BoxWorking({ + line, + since, + hidden = false, +}: { + line: string; + since: number; + hidden?: boolean; +}) { + const [mounted] = useState(() => Date.now()); + const [now, setNow] = useState(mounted); useEffect(() => { - const started = Date.now(); - const timer = window.setInterval( - () => setSeconds(Math.floor((Date.now() - started) / 1000)), - 1000 - ); + const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, []); + // A step the caller did not time (0) counts from the mount. + const seconds = Math.max(0, Math.floor((now - (since > 0 ? since : mounted)) / 1000)); return (