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
2 changes: 2 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
flex: 1;
min-width: 0;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}

.shell-main-content {
Expand Down
18 changes: 16 additions & 2 deletions apps/web/src/pages/agents-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from "@corbits/react-ui";
import type { BadgeTone, ViewMode } from "@corbits/react-ui";
import { Bot, Copy, Workflow } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";

Expand Down Expand Up @@ -59,6 +59,16 @@ const INSTANCE_CAP = 4;
* visible text anywhere on this surface. */
function CopyAddressButton({ address }: { readonly address: string }) {
const [copied, setCopied] = useState(false);
// The "Copied" confirmation clears itself after a timeout. Track that
// timer so unmounting the button (switching tabs, leaving the page) can
// cancel it — otherwise the callback fires setState on an unmounted
// component, the classic leak.
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (resetTimer.current !== null) clearTimeout(resetTimer.current);
};
}, []);
return (
<Button
type="button"
Expand All @@ -69,7 +79,11 @@ function CopyAddressButton({ address }: { readonly address: string }) {
onClick={() => {
void navigator.clipboard.writeText(address).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
if (resetTimer.current !== null) clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(() => {
resetTimer.current = null;
setCopied(false);
}, 1500);
});
}}
>
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useNavigate } from "../navigation";
import type { SessionUser } from "../session";
import { canvasColumnAllowed, contextualPanelVisible } from "./breakpoints";
import { useShellFocusRescue } from "./focus-rescue";
import { useScrollReset } from "./use-scroll-reset";
import {
initialCanvasColumnState,
resolveCanvasVisibility,
Expand Down Expand Up @@ -37,7 +38,10 @@ export function AppShell({
const canvasAllowed = canvasColumnAllowed(layoutMode);
const canvasOpen = resolveCanvasVisibility(canvasState, canvasAllowed);
const frameRef = useRef<HTMLDivElement>(null);
const mainRef = useRef<HTMLDivElement>(null);
useShellFocusRescue(layoutMode, frameRef);
// Route changes must not inherit the previous page's scroll position.
useScrollReset(mainRef, path);

return (
<div className="shell-frame" ref={frameRef}>
Expand All @@ -56,7 +60,7 @@ export function AppShell({
canvasAllowed={canvasAllowed}
/>
)}
<div className="shell-main">
<div className="shell-main" ref={mainRef}>
<div className="shell-main-content">{children}</div>
</div>
{canvasAllowed && <CanvasColumn open={canvasOpen} />}
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/shell/use-scroll-reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Scroll position is per-route state, not global: landing on a new page
// should start at the top instead of inheriting however far the previous
// page was scrolled. This resets the main pane's own scroll container to
// the top whenever the route changes. Extracted as its own hook so the
// behaviour is unit-testable without mounting the whole shell.

import { useEffect, type RefObject } from "react";

/** Scrolls `ref` back to the top whenever `dep` changes. No-op while the
* ref is unattached. */
export function useScrollReset<T extends Element>(
ref: RefObject<T | null>,
dep: unknown,
): void {
useEffect(() => {
if (ref.current !== null) ref.current.scrollTop = 0;
// `ref` is a stable identity; `dep` is what actually triggers a reset.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dep]);
}
59 changes: 59 additions & 0 deletions apps/web/test/use-scroll-reset.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test";
import { act, createElement, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import type { RefObject } from "react";

import { useScrollReset } from "../src/shell/use-scroll-reset";

function mount(initialPath: string) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let setPath: (path: string) => void = () => {};
let scrollEl: HTMLDivElement | null = null;

function Host() {
const [path, updatePath] = useState(initialPath);
setPath = updatePath;
const ref = useRef<HTMLDivElement>(null);
useScrollReset(ref as RefObject<HTMLDivElement | null>, path);
return createElement(
"div",
{
ref: (node: HTMLDivElement | null) => {
scrollEl = node;
(ref as { current: HTMLDivElement | null }).current = node;
},
style: { overflow: "auto", height: "40px" },
},
createElement("div", { style: { height: "400px" } }),
);
}

act(() => {
root.render(createElement(Host));
});

return {
setScrollTop: (value: number) => {
if (scrollEl !== null) scrollEl.scrollTop = value;
},
setPath: (path: string) =>
act(() => {
setPath(path);
}),
getScrollTop: () => scrollEl?.scrollTop ?? -1,
unmount: () => root.unmount(),
};
}

describe("useScrollReset", () => {
test("resets scrollTop when the route dependency changes", () => {
const harness = mount("/library");
harness.setScrollTop(240);
expect(harness.getScrollTop()).toBe(240);
harness.setPath("/agents");
expect(harness.getScrollTop()).toBe(0);
harness.unmount();
});
});
Loading