diff --git a/doc/plans/2026-10-28-stable-task-anchor-loading.md b/doc/plans/2026-10-28-stable-task-anchor-loading.md index 2ea9901e466..24ab235a4a0 100644 --- a/doc/plans/2026-10-28-stable-task-anchor-loading.md +++ b/doc/plans/2026-10-28-stable-task-anchor-loading.md @@ -23,12 +23,26 @@ visible cards after reveal, and thread height grew from 690 to 1,432 pixels. The first candidate keeps all 7 saved cards present and height constant. A second regression showed delayed PR counts changing card heights from 62 to 82 pixels; reserving that statistics line makes both states 82 pixels. -Final focused UI tests: 552 pass. Focused server tests: 16 pass. Token and map checks pass. +Final focused UI tests before merge review: 552 pass. Focused server tests: 16 pass. Token and map checks pass. All nine browser checks, full workspace typecheck and build pass. The full local suite was stopped after about 15 minutes in the serialized server lane; it is not a full local pass. Hosted CI supplies whole-suite acceptance. Review found that document navigation dropped a separately fetched older comment. The fix retains target comments in the observed compact window without advancing its cursor. Its added unit and browser checks pass, including row position. Hosted acceptance is recorded in PR #18092. One early candidate browser run exceeded the initial 5-second dev readiness deadline; later runs use 10 seconds. No reliable latency gain is claimed. The initial 503 linked-comment fixture exercised the app's global reconnect and 30-second retry policy. The target-specific failure check uses a terminal 404. +Merge review added regressions for core work products arriving during a pending +stored-list request and malformed comment fragments. Core projections now seed +empty or populated work-product caches without replacing already known lists; +comment fragments must contain UUIDs and API path components are encoded. +The delayed stored-read browser case was red at its 10-second reveal deadline. +Frame evidence also found a 775-to-742-pixel width change after reveal while +the initial properties pane opened. Every first reveal now waits for two stable +width frames, while keeping the existing 15-second fallback. The browser fixture +checks width as well as card positions and heights. Final follow-up verification: +583 focused UI tests and all nine browser journeys pass, along with UI typecheck +and build. The layout capture held seven cards, width 742 and height 1,492 across +101 frames. Review follow-up evidence +and hosted acceptance are recorded in PR #18092. + Local reproduction: http://127.0.0.1:3223/TASAAAAA/issues/TASAAAAA-1#document-investigation. Synthetic task data only; no live provider execution. Restart with `PAPERCLIP_HOME=/tmp/paperclip-stable-task-loading PAPERCLIP_INSTANCE_ID=task-loading PAPERCLIP_UI_DEV_MIDDLEWARE=true node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts run`. diff --git a/tests/perf/issue-detail/compact-startup.spec.ts b/tests/perf/issue-detail/compact-startup.spec.ts index d9b2c34fee8..cd2f083e841 100644 --- a/tests/perf/issue-detail/compact-startup.spec.ts +++ b/tests/perf/issue-detail/compact-startup.spec.ts @@ -52,7 +52,7 @@ async function ready(page: Page) { await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 10_000 }); } -test("saved rich cards are present at first reveal and never insert during slow enrichment", async ({ page }, info) => { +test("core rich cards reveal before duplicate reads and stay stable during slow enrichment", async ({ page }, info) => { await page.addInitScript(() => { window.__PAPERCLIP_ISSUE_DETAIL_PROFILE__ = true; }); await page.addInitScript(() => { const frames: unknown[] = []; @@ -62,7 +62,7 @@ test("saved rich cards are present at first reveal and never insert during slow const scroller = thread?.querySelector('[data-testid="task-chat-scroller"]'); if (scroller) { const cards = [...scroller.querySelectorAll('[data-testid="task-chat-resource-document"], [data-testid="task-chat-plan-preview"], [data-testid="task-chat-rich-work-product-pull_request"], [data-testid="task-chat-resource-deliverable"]')]; - frames.push({ time: performance.now(), height: scroller.scrollHeight, cards: cards.length, boxes: cards.map(card => { + frames.push({ time: performance.now(), height: scroller.scrollHeight, width: scroller.clientWidth, cards: cards.length, boxes: cards.map(card => { const box = card.getBoundingClientRect(); return { top: box.top, height: box.height }; }) }); } @@ -73,6 +73,7 @@ test("saved rich cards are present at first reveal and never insert during slow let release!: () => void; const held = new Promise(resolve => { release = resolve; }); await page.route(/\/api\/issues\/[^/?]+$/, async route => { await held; await route.continue(); }); + await page.route(/\/api\/issues\/[^/?]+\/work-products$/, async route => { await held; await route.continue(); }); try { await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`); await ready(page); @@ -81,12 +82,13 @@ test("saved rich cards are present at first reveal and never insert during slow await expect(page.getByTestId("task-chat-resource-document")).toHaveCount(3); await expect(page.getByTestId("task-chat-plan-preview")).toHaveCount(1); await page.waitForTimeout(500); - const frames = await page.evaluate(() => (window as unknown as { compactStartupFrames: { height: number; cards: number; boxes: { top: number; height: number }[] }[] }).compactStartupFrames); + const frames = await page.evaluate(() => (window as unknown as { compactStartupFrames: { height: number; width: number; cards: number; boxes: { top: number; height: number }[] }[] }).compactStartupFrames); await fs.writeFile(info.outputPath("layout.json"), JSON.stringify(frames, null, 2)); await page.screenshot({ path: info.outputPath("settled.png") }); expect(frames.length).toBeGreaterThan(5); expect(new Set(frames.map(frame => frame.cards)).size, "Cards must arrive before the conversation becomes readable").toBe(1); expect(Math.max(...frames.map(frame => frame.height)) - Math.min(...frames.map(frame => frame.height)), "Initial content must not grow after reveal").toBeLessThanOrEqual(2); + expect(Math.max(...frames.map(frame => frame.width)) - Math.min(...frames.map(frame => frame.width)), "Initial pane geometry must settle before reveal").toBeLessThanOrEqual(2); for (let card = 0; card < frames[0].cards; card++) { for (const dimension of ["top", "height"] as const) { const values = frames.map(frame => frame.boxes[card][dimension]); @@ -135,7 +137,7 @@ test("opening a document from an older linked comment retains that row and its p // Give the real saved target a document link without inventing a browser row. const target = await (await request.get(`/api/issues/${fixture.id}/comments/${fixture.oldestCommentId}`)).json(); await page.route(`**/api/issues/${fixture.id}/comments/${fixture.oldestCommentId}`, route => route.fulfill({ json: { ...target, body: `${target.body}\n\n[Inspect saved evidence](#document-investigation)` } })); - await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}#comment-${fixture.oldestCommentId}`); + await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}#comment-${fixture.oldestCommentId}`, { waitUntil: "domcontentloaded" }); await ready(page); const row = page.locator(`[id="comment-${fixture.oldestCommentId}"]`); const before = (await row.boundingBox())!.y; diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 310a0e7d6aa..26a2ddcd212 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -30,6 +30,14 @@ describe("issuesApi.list", () => { mockApi.patch.mockResolvedValue({}); }); + it("encodes a linked comment id as one path component and forwards cancellation", async () => { + const signal = new AbortController().signal; + await issuesApi.getComment("issue-1", "../../health?raw=true#fragment", { signal }); + expect(mockApi.get).toHaveBeenCalledWith( + "/issues/issue-1/comments/..%2F..%2Fhealth%3Fraw%3Dtrue%23fragment", { signal }, + ); + }); + it.each([null, "stopped-run"])("dispatches a stopped queue using its current revision (%s)", async (target) => { mockApi.get.mockResolvedValueOnce({ queueId: "queue-1", targetRunId: null, revision: "revision-2" }); await issuesApi.interruptLatestQueuedComments("issue-1", target); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index fa432603359..b3767e75079 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -652,7 +652,7 @@ export const issuesApi = { { verdicts }, ), getComment: (id: string, commentId: string, options?: RequestOptions) => - api.get(`/issues/${id}/comments/${commentId}`, options), + api.get(`/issues/${id}/comments/${encodeURIComponent(commentId)}`, options), listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), getCostSummary: (id: string, options: { excludeRoot?: boolean } = {}) => { diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 7be2ab4896f..44ae1ad8933 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -168,6 +168,13 @@ function render(ui: ReactElement) { ); } +async function settleThreadLayout() { + await vi.waitFor(async () => { + await act(async () => { await new Promise(resolve => requestAnimationFrame(resolve)); }); + expect(container.querySelector('[data-testid="task-chat-thread"] > [aria-busy="false"]')).not.toBeNull(); + }); +} + describe.each(["legacy", "native"] as const)("historical %s system status", (runtimeMode) => { const run = (id: string, status: string, minute: number) => ({ runId: id, status, runtimeMode, agentId: "agent", agentName: "Assistant", @@ -353,9 +360,7 @@ it.each([true, false])("defers hidden content until history is ready and keeps i render(); const composer = container.querySelector('[data-testid="mock-editor"]'); expect(composer).not.toBeNull(); - await act(async () => { - await new Promise((resolve) => requestAnimationFrame(resolve)); - }); + await settleThreadLayout(); expect(container.querySelector('[aria-busy="false"]')).not.toBeNull(); expect(container.querySelector('.task-chat-history-pending')).toBeNull(); expect(container.querySelector('[data-testid="task-chat-composer-dock"]')?.hasAttribute("inert")).toBe(false); @@ -374,7 +379,7 @@ it.each([true, false])("coordinates compact cards before first reveal and retain render(); expect(container.querySelector('[data-testid="task-chat-history-loading"]')).not.toBeNull(); render(); - await act(async () => { await new Promise(resolve => requestAnimationFrame(resolve)); }); + await settleThreadLayout(); expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull(); expect(container.textContent).toContain("Thread message 1"); expect(container.textContent).toContain("More messages"); @@ -420,6 +425,7 @@ it.each([true, false])("keeps an upload attached when a conversation draft becom const onAttachImage = vi.fn().mockReturnValue(new Promise(resolve => { resolveUpload = resolve; })); const props = { comments: [], onAdd, onAttachImage, conversationMode: true, draftKey: "agent-chat:company:agent" }; render(); + await settleThreadLayout(); const composer = container.querySelector('[data-testid="mock-editor"]'); const input = container.querySelector('input[type="file"]')!; const file = new File(["notes"], "chat-notes.txt", { type: "text/plain" }); @@ -435,7 +441,7 @@ it.each([true, false])("keeps an upload attached when a conversation draft becom const id = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3"; await act(async () => { resolveUpload({ id, contentPath: `/api/attachments/${id}/content`, originalFilename: file.name }); }); render(); - await act(async () => { await new Promise(resolve => requestAnimationFrame(resolve)); }); + await settleThreadLayout(); const chip = container.querySelector('[data-testid="task-chat-composer-attachments"] [data-slot="attachment"]'); expect(chip?.getAttribute("data-state")).toBe("done"); const send = container.querySelector('[data-testid="task-chat-composer-send"]')!; @@ -586,7 +592,7 @@ it("prepares comment-link history and its composer before reveal", async () => { expect(row).not.toBeNull(); expect(row?.closest('[inert]')).not.toBeNull(); show(false); - await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); + await settleThreadLayout(); expect(container.querySelector('[aria-busy="false"]')).not.toBeNull(); expect(container.querySelector('[data-testid="mock-editor"]')).toBe(composer); expect(container.querySelector('[data-thread-anchor="comment-1"]')).toBe(row); @@ -649,13 +655,14 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti nativeTranscriptState.hydratedRunIds = new Set(); }); - it("reveals comments while a scheduled retry has no transcript to hydrate", () => { + it("reveals comments while a scheduled retry has no transcript to hydrate", async () => { renderThread({ issueId: "issue-1", comments: createLongThreadComments(), onAdd: async () => {}, linkedRuns: [retryRun], }); + await settleThreadLayout(); expect(container.querySelector('[aria-busy="false"]')).not.toBeNull(); expect( @@ -727,9 +734,7 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti ts: "2026-08-25T18:00:01.000Z", }]); render(); - await act(async () => { - await new Promise((resolve) => requestAnimationFrame(resolve)); - }); + await settleThreadLayout(); expect(container.querySelector('[aria-busy="false"]')).not.toBeNull(); expect( diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 287af7e439d..877b2c2c36c 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -2996,10 +2996,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { : Boolean(logErrorsByRun?.has(run.id)), ); const historyKey = conversationMode && draftKey ? draftKey : issueId; - const [revealedHistoryKey, setRevealedHistoryKey] = useState( - () => (historyPending ? undefined : historyKey), - ); + const [revealedHistoryKey, setRevealedHistoryKey] = useState(undefined); const historyRevealed = revealedHistoryKey === historyKey; + const historyShellRef = useRef(null); const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ historyKey: typeof historyKey } | null>(null); const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.historyKey === historyKey; const prepareInitialContent = Boolean(scrollNavigation.hash) || !historyPending || historyRevealed || historyWaitExpired; @@ -3014,12 +3013,23 @@ export function TaskChatThread(props: TaskChatThreadProps) { }, INITIAL_HISTORY_REVEAL_TIMEOUT_MS); return () => window.clearTimeout(timer); }, [historyRevealed, historyKey]); - // Mount and measure the real thread while concealed, then reveal in one - // commit. A frame also lets ancestor navigation scroll restoration finish. + // Mount and measure the real thread while concealed. The properties pane + // can still be opening when data becomes ready; revealing during its width + // transition rewraps cards and races initial anchor/focus scrolling. // Readiness is latched per issue: refetches never hide existing conversation. useEffect(() => { if (historyRevealed || historyPending) return; - const frame = requestAnimationFrame(() => setRevealedHistoryKey(historyKey)); + let previousWidth: number | undefined; + let stableFrames = 0; + let frame: number; + const revealWhenSettled = () => { + const width = historyShellRef.current?.getBoundingClientRect().width; + stableFrames = width === previousWidth ? stableFrames + 1 : 0; + previousWidth = width; + if (stableFrames >= 2) setRevealedHistoryKey(historyKey); + else frame = requestAnimationFrame(revealWhenSettled); + }; + frame = requestAnimationFrame(revealWhenSettled); return () => cancelAnimationFrame(frame); }, [historyPending, historyRevealed, historyKey]); useEffect(() => { @@ -3046,6 +3056,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { isMobile && !historyRevealed && "task-chat-history-pending", )} data-testid="task-chat-thread" + ref={historyShellRef} >
{ const issue = options ? await issuesApi.get(issueRef, options) : await issuesApi.get(issueRef); - const planKey = [...queryKeys.issues.documents(issue.id), "plan"]; - if (issue.planDocument !== undefined && !queryClient.getQueryState(planKey)) { - queryClient.setQueryData(planKey, issue.planDocument); - } + seedIssueDetailProjections(queryClient, issue, issueRef); return seedIssueDetailCache(queryClient, issue, { issueRef }); } diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 25994345d00..f47185b1931 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1831,12 +1831,39 @@ describe("IssueDetail", () => { expect(queryClient.getQueryData(["issues", "history-expanded", "PAP-1"])).toBe(true); }); - it.each(["#document-qa-evidence", "#document-plan", "#document-continuation-summary", "#work-product-one", "#attachment-one", "#unknown", "#comment-old-comment"])("keeps the recent conversation for anchor %s", async (hash) => { + it.each(["empty", "saved"])("reveals core work products while the stored request is stalled (%s)", async (projection) => { + const workProducts = projection === "empty" ? [] : [createArtifactWorkProduct({ + id: "saved-output", attachmentId: "00000000-0000-4000-8000-000000000001", contentType: "text/plain", originalFilename: "evidence.txt", + })]; + queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false); + mockIssuesApi.getCore.mockResolvedValue(createIssue({ workProducts })); + mockIssuesApi.get.mockReturnValue(createDeferred().promise); + mockIssuesApi.listWorkProducts.mockReturnValue(createDeferred<[]>().promise); + await act(async () => renderIssueView()); + await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ + initialHistoryPending: false, workProducts, + })); + expect(mockIssuesApi.listWorkProducts).toHaveBeenCalled(); + }); + + it.each(["..", "../../../../health", "invalid"])("ignores a malformed comment anchor %s", async (target) => { + queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false); + mockLocation.hash = `#comment-${target}`; + mockIssuesApi.get.mockResolvedValue(createIssue()); + await act(async () => renderIssueView()); + await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ + compactHistory: true, initialHistoryPending: false, + })); + expect(mockIssuesApi.getComment).not.toHaveBeenCalled(); + expect(mockIssuesApi.listComments).not.toHaveBeenCalled(); + }); + + it.each(["#document-qa-evidence", "#document-plan", "#document-continuation-summary", "#work-product-one", "#attachment-one", "#unknown", "#comment-6e391846-d4aa-410f-9d9a-6a60d74bce37"])("keeps the recent conversation for anchor %s", async (hash) => { queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false); mockLocation.hash = hash; mockIssuesApi.get.mockResolvedValue(createIssue()); const recent = createIssueComment({ id: "recent-comment", body: "Latest reply" }); - const older = createIssueComment({ id: "old-comment", body: "Linked older reply" }); + const older = createIssueComment({ id: "6e391846-d4aa-410f-9d9a-6a60d74bce37", body: "Linked older reply" }); mockIssuesApi.getComment.mockResolvedValue(older); mockIssuesApi.recentComments.mockResolvedValue({ comments: [recent], latestCommentId: recent.id, hasMore: true }); await act(async () => renderIssueView()); @@ -1859,10 +1886,10 @@ describe("IssueDetail", () => { it("retains a linked older comment when opening a document without moving the preview cursor", async () => { queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false); - mockLocation.hash = "#comment-old-comment"; + mockLocation.hash = "#comment-6e391846-d4aa-410f-9d9a-6a60d74bce37"; mockIssuesApi.get.mockResolvedValue(createIssue()); const recent = createIssueComment({ id: "recent-comment", body: "Latest reply" }); - const older = createIssueComment({ id: "old-comment", body: "Read my saved evidence" }); + const older = createIssueComment({ id: "6e391846-d4aa-410f-9d9a-6a60d74bce37", body: "Read my saved evidence" }); mockIssuesApi.getComment.mockResolvedValue(older); mockIssuesApi.recentComments.mockResolvedValue({ comments: [recent], latestCommentId: recent.id, hasMore: true }); await act(async () => renderIssueView()); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 6db10588817..ab03a9efe9e 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -132,7 +132,7 @@ import { shouldTrackIssueActiveRun, } from "../lib/issueActiveRun"; import { prefetchIssueThread } from "../lib/issue-thread-queries"; -import { getIssueDetailQueryOptions } from "../lib/issueDetailCache"; +import { getIssueDetailQueryOptions, seedIssueDetailProjections } from "../lib/issueDetailCache"; import { beginIssueDetailNavigation, recordIssueDetailReadiness, @@ -388,6 +388,7 @@ import { type IssueTreeControlMode, type WorkspaceFileRef, workspaceFileRefSchema, + isUuidLike, } from "@paperclipai/shared"; // Stable empty array for React Query `data` defaults. A literal `= []` default @@ -3327,10 +3328,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS queryKey: [...queryKeys.issues.detail(issueId!), "core"], queryFn: async ({ signal }) => { const core = await issuesApi.getCore(issueId!, { signal }); - const planKey = [...queryKeys.issues.documents(core.id), "plan"]; - if (core.planDocument !== undefined && !queryClient.getQueryState(planKey)) { - queryClient.setQueryData(planKey, core.planDocument); - } + seedIssueDetailProjections(queryClient, core, issueId); return core; }, enabled: !!issueId, @@ -3440,9 +3438,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS InfiniteData >(issueId ?? "pending"), }); - const linkedCommentId = location.hash.startsWith("#comment-") + const commentAnchor = location.hash.startsWith("#comment-") ? location.hash.slice("#comment-".length) : null; + const linkedCommentId = commentAnchor && isUuidLike(commentAnchor) ? commentAnchor.trim() : null; const windowComments = useMemo( () => mergeWindowComments(recentComments.data?.comments ?? [], flattenIssueCommentPages(commentPages?.pages)), [commentPages?.pages, recentComments.data?.comments],