mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
Address UI split review and verify failures
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
beefbe1b52
commit
26e1bf1db5
7 files changed
+137
-28
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user