diff --git a/src/ui/activity.test.tsx b/src/ui/activity.test.tsx
index 86f0ebd8..b0024bf3 100644
--- a/src/ui/activity.test.tsx
+++ b/src/ui/activity.test.tsx
@@ -97,6 +97,21 @@ describe("Activity", () => {
mounted.unmount();
});
+ test("a focused step sliding out of the latest ten hands focus to the control", () => {
+ const steps = Array.from({ length: 10 }, (_, index) => call(index));
+ const mounted = mount();
+ const oldest = mounted.container.querySelector("li[data-step] button");
+ if (!(oldest instanceof HTMLButtonElement))
+ throw new Error("expected a control in the oldest step");
+ act(() => oldest.focus());
+ mounted.render(
+ ,
+ );
+ expect(shownSteps(mounted.container)).not.toContain("files__read_0");
+ expect(document.activeElement?.textContent).toBe("Show 1 earlier step");
+ mounted.unmount();
+ });
+
test("ten or fewer steps need no control", () => {
const steps = Array.from({ length: 10 }, (_, index) => call(index));
const mounted = mount();
diff --git a/src/ui/activity.tsx b/src/ui/activity.tsx
index 00a703ef..dbf19b87 100644
--- a/src/ui/activity.tsx
+++ b/src/ui/activity.tsx
@@ -253,7 +253,8 @@ function stepKey(step: ActivityStep, index: number): string {
/**
* The run's steps, while open: the latest ten, with a control for the rest.
* A finished thought with no text is left out, as `Reasoning` shows nothing
- * for it.
+ * for it. When a new step pushes the step holding focus behind the control,
+ * focus moves to the control.
*/
export function ActivityContent({
renderStep,
@@ -300,7 +301,10 @@ function ActivitySteps({
}) {
const [showAll, setShowAll] = useState(false);
const listRef = useRef(null);
+ const showEarlierRef = useRef(null);
const revealed = useRef(false);
+ // The key of the step holding focus, while focus is in the list.
+ const focusedStep = useRef(null);
const hidden = showAll ? 0 : Math.max(0, steps.length - SHOWN_STEPS);
// The control disappears once pressed, so focus moves to the first step
@@ -311,6 +315,21 @@ function ActivitySteps({
listRef.current?.querySelector("li[data-step]")?.focus();
}, [showAll]);
+ // A new step slides the oldest shown one behind the control. If focus
+ // went with it, it moves to the control, which now counts that step.
+ useLayoutEffect(() => {
+ const key = focusedStep.current;
+ if (key === null) return;
+ const active = document.activeElement;
+ if (active !== null && active !== document.body) return;
+ const behind = steps
+ .slice(0, hidden)
+ .some(({ step, index }) => stepKey(step, index) === key);
+ if (!behind) return;
+ focusedStep.current = null;
+ showEarlierRef.current?.focus();
+ });
+
return (
{
+ focusedStep.current =
+ event.target
+ .closest("li[data-step]")
+ ?.getAttribute("data-step-key") ?? null;
+ }}
+ onBlur={(event) => {
+ // A step going away with focus in it does not reach here: React
+ // ignores blur while it commits, so the effect still sees the key.
+ const next = event.relatedTarget;
+ if (next === null || !event.currentTarget.contains(next))
+ focusedStep.current = null;
+ }}
>
{hidden > 0 ? (