diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index bed951913fd..5402b118b6f 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -910,7 +910,6 @@ export function issueRoutes(
const parsedOffset = rawOffset !== undefined && /^\d+$/.test(rawOffset)
? Number.parseInt(rawOffset, 10)
: null;
- const offset = parsedOffset ?? 0;
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
@@ -936,6 +935,7 @@ export function issueRoutes(
res.status(400).json({ error: "offset must be a non-negative integer" });
return;
}
+ const offset = parsedOffset ?? 0;
const result = await svc.list(companyId, {
status: req.query.status as string | undefined,
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 008799bef70..ec82de9dbd8 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -99,7 +99,9 @@ function boardRoutes() {
} />
} />
} />
- } />
+ {import.meta.env.DEV ? (
+ } />
+ ) : null}
} />
} />
} />
diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx
index f3901a2e42a..ff89602a454 100644
--- a/ui/src/components/IssueChatThread.test.tsx
+++ b/ui/src/components/IssueChatThread.test.tsx
@@ -399,6 +399,71 @@ describe("IssueChatThread", () => {
});
});
+ it("measures tall virtual rows before positioning following rows", async () => {
+ const root = createRoot(container);
+ const requestAnimationFrameMock = vi
+ .spyOn(window, "requestAnimationFrame")
+ .mockImplementation((callback) => {
+ callback(0);
+ return 0;
+ });
+
+ act(() => {
+ root.render(
+
+ {}}
+ showComposer={false}
+ showJumpToLatest={false}
+ enableLiveTranscriptPolling={false}
+ transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
+ hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
+ />
+ ,
+ );
+ });
+
+ const virtualRows = container.querySelectorAll(
+ '[data-testid="issue-chat-thread-virtual-row"]',
+ );
+ expect(virtualRows.length).toBeGreaterThan(1);
+
+ Object.defineProperty(virtualRows[0], "getBoundingClientRect", {
+ configurable: true,
+ value: () => ({
+ x: 0,
+ y: 0,
+ width: 700,
+ height: 800,
+ top: 0,
+ right: 700,
+ bottom: 800,
+ left: 0,
+ toJSON: () => ({}),
+ }),
+ });
+
+ await act(async () => {
+ virtualRows[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ const nextTransform = virtualRows[1].style.transform;
+ const translateY = Number(nextTransform.match(/translateY\(([-\d.]+)px\)/)?.[1] ?? "0");
+ expect(translateY).toBeGreaterThanOrEqual(800);
+
+ act(() => {
+ root.unmount();
+ });
+ requestAnimationFrameMock.mockRestore();
+ });
+
it("scrolls loaded hash targets through the virtualized message index", () => {
const root = createRoot(container);
const targetComment = issueChatLongThreadComments.at(-1);
diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx
index 584851a57a6..2abd38ff0ac 100644
--- a/ui/src/components/IssueChatThread.tsx
+++ b/ui/src/components/IssueChatThread.tsx
@@ -2167,6 +2167,7 @@ type SimpleVirtualItem = {
index: number;
key: React.Key;
start: number;
+ size: number;
};
function useIssueThreadVirtualizer({
@@ -2186,9 +2187,21 @@ function useIssueThreadVirtualizer({
getItemKey: (index: number) => React.Key;
mode: VirtualizedScrollMode;
}) {
+ const measuredSizeByKeyRef = useRef(new Map());
const [, rerender] = useState(0);
- const itemSpan = estimateSize() + gap;
- const totalSize = Math.max(0, count * itemSpan - gap);
+ const estimatedSize = estimateSize();
+
+ const itemStarts: number[] = [];
+ const itemSizes: number[] = [];
+ let nextStart = scrollMargin;
+ for (let index = 0; index < count; index += 1) {
+ const key = getItemKey(index);
+ const size = measuredSizeByKeyRef.current.get(key) ?? estimatedSize;
+ itemStarts.push(nextStart);
+ itemSizes.push(size);
+ nextStart += size + gap;
+ }
+ const totalSize = Math.max(0, nextStart - scrollMargin - gap);
const viewportHeight = () => (mode.kind === "window" ? window.innerHeight : mode.element.clientHeight);
const scrollOffset = () => (mode.kind === "window" ? window.scrollY : mode.element.scrollTop);
@@ -2211,15 +2224,29 @@ function useIssueThreadVirtualizer({
};
}, [mode]);
- const rawStart = Math.max(0, scrollOffset() - scrollMargin);
- const startIndex = Math.max(0, Math.floor(rawStart / itemSpan) - overscan);
- const endIndex = Math.min(
- count - 1,
- Math.ceil((rawStart + viewportHeight()) / itemSpan) + overscan,
- );
+ const rawStart = Math.max(scrollMargin, scrollOffset());
+ const rawEnd = rawStart + viewportHeight();
+ let visibleStartIndex = 0;
+ while (
+ visibleStartIndex < count - 1
+ && itemStarts[visibleStartIndex] + itemSizes[visibleStartIndex] < rawStart
+ ) {
+ visibleStartIndex += 1;
+ }
+ let visibleEndIndex = visibleStartIndex;
+ while (visibleEndIndex < count - 1 && itemStarts[visibleEndIndex] <= rawEnd) {
+ visibleEndIndex += 1;
+ }
+ const startIndex = Math.max(0, visibleStartIndex - overscan);
+ const endIndex = Math.min(count - 1, visibleEndIndex + overscan);
const virtualItems: SimpleVirtualItem[] = [];
for (let index = startIndex; index <= endIndex; index += 1) {
- virtualItems.push({ index, key: getItemKey(index), start: index * itemSpan + scrollMargin });
+ virtualItems.push({
+ index,
+ key: getItemKey(index),
+ start: itemStarts[index] ?? scrollMargin,
+ size: itemSizes[index] ?? estimatedSize,
+ });
}
const scrollToIndex = (
@@ -2228,11 +2255,11 @@ function useIssueThreadVirtualizer({
) => {
const clampedIndex = Math.max(0, Math.min(index, count - 1));
const targetMax = maxScrollOffset();
- let top = clampedIndex * itemSpan + scrollMargin;
+ let top = itemStarts[clampedIndex] ?? scrollMargin;
if (options?.align === "center") {
- top = top - viewportHeight() / 2 + itemSpan / 2;
+ top = top - viewportHeight() / 2 + (itemSizes[clampedIndex] ?? estimatedSize) / 2;
} else if (options?.align === "end") {
- top = count > 1 ? targetMax * (clampedIndex / (count - 1)) : targetMax;
+ top = top + (itemSizes[clampedIndex] ?? estimatedSize) - viewportHeight();
}
top = Math.max(0, Math.min(top, targetMax));
if (mode.kind === "window") {
@@ -2248,7 +2275,18 @@ function useIssueThreadVirtualizer({
getTotalSize: () => totalSize,
scrollToIndex,
measure: () => undefined,
- measureElement: (_element?: HTMLElement | null) => undefined,
+ measureElement: (element?: HTMLElement | null) => {
+ if (!element) return;
+ const index = Number(element.dataset.index);
+ if (!Number.isInteger(index) || index < 0 || index >= count) return;
+ const measuredSize = element.getBoundingClientRect().height || element.offsetHeight;
+ if (!Number.isFinite(measuredSize) || measuredSize <= 0) return;
+ const key = getItemKey(index);
+ const previousSize = measuredSizeByKeyRef.current.get(key) ?? estimatedSize;
+ if (Math.abs(previousSize - measuredSize) < 1) return;
+ measuredSizeByKeyRef.current.set(key, measuredSize);
+ rerender((value) => value + 1);
+ },
};
}
diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx
index 2ac60c388fe..a45658d2452 100644
--- a/ui/src/components/IssuesList.test.tsx
+++ b/ui/src/components/IssuesList.test.tsx
@@ -968,6 +968,7 @@ describe("IssuesList", () => {
}),
);
const onLoadMoreIssues = vi.fn();
+ setDocumentScrollMetrics({ innerHeight: 2000, scrollY: 0, scrollHeight: 1000 });
const { root } = renderWithQueryClient(
{
await waitForAssertion(() => {
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100);
});
+ await flush();
+ expect(onLoadMoreIssues).not.toHaveBeenCalled();
act(() => {
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 1500, scrollHeight: 2000 });
diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx
index ea8e4f14dcb..96995c467f1 100644
--- a/ui/src/components/IssuesList.tsx
+++ b/ui/src/components/IssuesList.tsx
@@ -79,10 +79,7 @@ function findIssuesScrollContainer(element: HTMLElement | null): HTMLElement | n
let current = element.parentElement;
while (current && current !== document.body && current !== document.documentElement) {
const overflowY = window.getComputedStyle(current).overflowY;
- if (
- (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay")
- && current.scrollHeight > current.clientHeight
- ) {
+ if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") {
return current;
}
current = current.parentElement;
@@ -1054,7 +1051,6 @@ export function IssuesList({
scrollTarget.addEventListener("scroll", checkScrollPosition, { passive: true });
window.addEventListener("resize", checkScrollPosition);
- checkScrollPosition();
return () => {
scrollTarget.removeEventListener("scroll", checkScrollPosition);
diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx
index 2be9a6f0a2d..c8be8cedce0 100644
--- a/ui/src/pages/Issues.tsx
+++ b/ui/src/pages/Issues.tsx
@@ -63,16 +63,25 @@ export function Issues() {
const queryClient = useQueryClient();
const fetchNextPageInFlightRef = useRef(false);
- const initialSearch = searchParams.get("q") ?? "";
- const [syncedSearch, setSyncedSearch] = useState(initialSearch);
+ const urlSearch = searchParams.get("q") ?? "";
+ const [searchOverride, setSearchOverride] = useState<{ search: string; locationSearch: string } | null>(null);
+ const syncedSearch = useMemo(() => {
+ if (typeof window !== "undefined" && searchOverride?.locationSearch === window.location.search) {
+ return searchOverride.search;
+ }
+ return urlSearch;
+ }, [searchOverride, urlSearch, location.search]);
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
const initialWorkspaces = searchParams.getAll("workspace").filter((workspaceId) => workspaceId.length > 0);
const workspaceIdFilter = initialWorkspaces.length === 1 ? initialWorkspaces[0] : undefined;
const handleSearchChange = useCallback((search: string) => {
- setSyncedSearch(search);
const nextUrl = buildIssuesSearchUrl(window.location.href, search);
- if (!nextUrl) return;
+ if (!nextUrl) {
+ setSearchOverride(null);
+ return;
+ }
window.history.replaceState(window.history.state, "", nextUrl);
+ setSearchOverride({ search, locationSearch: window.location.search });
}, []);
const { data: agents } = useQuery({
@@ -112,10 +121,6 @@ export function Issues() {
const issuePageSize = workspaceIdFilter ? WORKSPACE_FILTER_ISSUE_LIMIT : ISSUES_PAGE_SIZE;
- useEffect(() => {
- setSyncedSearch(initialSearch);
- }, [initialSearch]);
-
const {
data: issuePages,
isLoading,