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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/src/sections/agent-chat/BoxWorking.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<BoxWorking line='reading "meter.parquet" on the server' since={Date.now()} />);
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(<BoxWorking line="parsing your data" since={Date.now() - 7000} />);
expect(screen.getByTestId('box-working')).toHaveTextContent('# parsing your data … · 7 s');
});
});
107 changes: 107 additions & 0 deletions app/src/sections/agent-chat/BoxWorking.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box
aria-hidden
data-testid="box-working"
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
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',
}}
>
<Box
component="span"
sx={{
...labelSx,
Comment thread
MarkusNeusinger marked this conversation as resolved.
fontSize: fontSize.md,
color: 'var(--ink)',
bgcolor: 'var(--bg-elevated)',
border: '1px solid var(--rule)',
borderRadius: '4px',
px: 1.5,
py: 0.75,
// A long file or sheet name without spaces wraps instead of being clipped on a phone.
minWidth: 0,
maxWidth: '100%',
overflowWrap: 'anywhere',
}}
>
# {line} <Ellipsis />
{seconds >= 1 ? ` · ${seconds} s` : ''}
</Box>
<Box
sx={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '2px',
overflow: 'hidden',
'@media (prefers-reduced-motion: reduce)': { display: 'none' },
}}
>
<Box
sx={{
'@keyframes agentBoxWorking': {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(340%)' },
},
width: '30%',
height: '100%',
bgcolor: 'var(--ink-soft)',
animation: 'agentBoxWorking 1.6s ease-in-out infinite',
}}
/>
</Box>
</Box>
);
}
8 changes: 8 additions & 0 deletions app/src/sections/agent-chat/DataPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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();
});

Expand Down Expand Up @@ -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());
Expand All @@ -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 () => {
Expand Down
43 changes: 33 additions & 10 deletions app/src/sections/agent-chat/DataPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string | null>(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<ConvertedFile | null>(null);
// The name of the file the box's text came from; typing keeps it, a text paste or an empty box drops it.
Expand All @@ -798,6 +801,12 @@ export function DataPanel({
const boxRef = useRef<HTMLTextAreaElement>(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;
Expand Down Expand Up @@ -885,18 +894,21 @@ 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);
};

// 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);
};

Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand Down Expand Up @@ -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<HTMLTextAreaElement>) => {
setText(event.target.value);
Expand Down Expand Up @@ -1290,6 +1305,14 @@ export function DataPanel({
</Box>
</Box>
)}
{/* A file read on the server or a parse takes seconds: the box says so while it waits. */}
{(converting || state.parsing) && (
<BoxWorking
line={convertLine ?? 'parsing your data'}
since={converting ? convertStart.current : parseStart.current}
hidden={dragOver}
/>
)}
</Box>
<Box
sx={{
Expand Down
9 changes: 9 additions & 0 deletions changelog.d/agent-box-working.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Changed

- **The data box says what it waits for.** While a dropped Parquet, Excel,
OpenDocument, Feather or NumPy file is read on the server, and while
`.parse()` runs, the box shows `# reading "x.parquet" on the server … · 4 s`
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. (#12221)
Loading