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 ( + )} + {/* A file read on the server or a parse takes seconds: the box says so while it waits. */} + {(converting || state.parsing) && ( +