Address UI split review and verify failures

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 16:48:58 -05:00
1 parent beefbe1b52
commit 26e1bf1db5
7 files changed
+137 -28

No files matched your search

+1 -1
View File
@@ -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,
+3 -1
View File
@@ -99,7 +99,9 @@ function boardRoutes() {
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
{import.meta.env.DEV ? (
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
) : null}
<Route path="routines" element={<Routines />} />
<Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
@@ -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(
<MemoryRouter>
<IssueChatThread
comments={issueChatLongThreadComments}
linkedRuns={issueChatLongThreadLinkedRuns}
timelineEvents={issueChatLongThreadEvents}
liveRuns={[]}
agentMap={issueChatLongThreadAgentMap}
currentUserId="user-board"
onAdd={async () => {}}
showComposer={false}
showJumpToLatest={false}
enableLiveTranscriptPolling={false}
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
/>
</MemoryRouter>,
);
});
const virtualRows = container.querySelectorAll<HTMLDivElement>(
'[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);
+51 -13
View File
@@ -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<React.Key, number>());
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);
},
};
}
+3
View File
@@ -968,6 +968,7 @@ describe("IssuesList", () => {
}),
);
const onLoadMoreIssues = vi.fn();
setDocumentScrollMetrics({ innerHeight: 2000, scrollY: 0, scrollHeight: 1000 });
const { root } = renderWithQueryClient(
<IssuesList
@@ -985,6 +986,8 @@ describe("IssuesList", () => {
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 });
+1 -5
View File
@@ -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);
+13 -8
View File
@@ -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,