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,