From a49f1bb1fd85ecf8acb376f9e4a6ba4574cf2e7f Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 29 Oct 2026 18:09:20 +0545 Subject: [PATCH] fix(chat): keep focus and counts honest in message parts and activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses findings 1, 10 and 11 of #142. - 1, MessageParts: focus leaving for the page or outside now also clears the deferred decision watch, so a later `parts` update that records the decision no longer pulls focus back to the run's trigger. The window losing focus to another tab or window keeps the watch, so a decision recorded meanwhile still hands focus to the run. - 10, ToolGroup: the live "done" count leaves out failed calls, so active, waiting, done and failed no longer overlap in the `running` or `held` header (e.g. "Running 3 at once · 1 done · 1 failed"). - 11, ActivityContent: when a new step slides the step holding focus behind the latest-ten window, focus moves to the "Show N earlier steps" control instead of falling to the page. --- src/ui/activity.test.tsx | 15 +++++++++++++ src/ui/activity.tsx | 36 +++++++++++++++++++++++++++++- src/ui/message-parts.test.tsx | 36 ++++++++++++++++++++++++++++++ src/ui/message-parts.tsx | 11 ++++++---- src/ui/tool-group.tsx | 11 +++++++--- src/ui/tool.test.tsx | 41 +++++++++++++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 8 deletions(-) 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 ? (