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..2dc9ddfd5a1
--- /dev/null
+++ b/app/src/sections/agent-chat/BoxWorking.test.tsx
@@ -0,0 +1,24 @@
+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');
+ });
+
+ 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
new file mode 100644
index 00000000000..40d3d8a5945
--- /dev/null
+++ b/app/src/sections/agent-chat/BoxWorking.tsx
@@ -0,0 +1,107 @@
+/**
+ * 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';
+
+/**
+ * `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 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 (
+
+
+ # {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..85e4954e632 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.getByLabelText(/# your data/)).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..59c97ec439d 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.
@@ -798,6 +801,12 @@ 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);
+ // When the box's current conversion and the panel's last parse began, set where they start (every
+ // parse starts here: `.parse()`, the table picker, `.use()`), so the overlay's count survives its remount.
+ const convertStart = useRef(0);
+ const parseStart = 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;
@@ -885,11 +894,13 @@ export function DataPanel({
const handleUse = () => {
if (!transcription) return;
setText(transcription.text);
+ parseStart.current = Date.now();
onConfirmTranscription();
};
// `.parse()`: the file's name rides along only when the text came from a file.
const handleParse = () => {
+ parseStart.current = Date.now();
if (source) onParse(text, source);
else onParse(text);
};
@@ -897,6 +908,7 @@ export function DataPanel({
// The table picker: the stored paste's text again, another of its tables.
const handleTable = (table: number) => {
if (!paste || !ready || busy || converting || table === choice?.table) return;
+ parseStart.current = Date.now();
onParse(paste.text, paste.source, table);
};
@@ -952,13 +964,13 @@ 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`;
+ convertSeq.current = seq;
+ convertStart.current = Date.now();
+ setConvertLine(line);
+ setFileNotice({ tone: 'ok', text: `${line} …` });
let answer: ConvertAnswer;
try {
answer = await onConvert(file.base64, sheet);
@@ -981,7 +993,9 @@ export function DataPanel({
trackFile(file.browserKind, null, failure.code.startsWith('file_') ? 'refused' : 'failed');
return;
} finally {
- if (seq === readSeq.current) setConverting(false);
+ // 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(
@@ -1224,6 +1238,7 @@ export function DataPanel({
spellCheck={false}
aria-describedby={dataset ? undefined : 'agent-data-hint'}
placeholder={'month,revenue\n2026-01,120\n2026-02,135'}
+ aria-busy={converting || state.parsing || undefined}
ref={boxRef}
onChange={(event: React.ChangeEvent) => {
setText(event.target.value);
@@ -1290,6 +1305,14 @@ export function DataPanel({
)}
+ {/* A file read on the server or a parse takes seconds: the box says so while it waits. */}
+ {(converting || state.parsing) && (
+
+ )}