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 b575500210b..8f9eec4e465 100644 --- a/doc/plans/2026-10-28-stable-task-anchor-loading.md +++ b/doc/plans/2026-10-28-stable-task-anchor-loading.md @@ -23,9 +23,8 @@ 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. -Focused UI tests: 507 pass. Focused server tests: 16 pass. Token and map checks pass. -Final browser checks, full workspace typecheck/tests/build and hosted review are -in progress. One early candidate browser run exceeded the initial 5-second dev +Focused UI tests: 551 pass before the added retention regression. Focused server tests: 16 pass. Token and map checks pass. +Eight initial browser checks, full workspace typecheck and build pass. Full local tests and hosted CI remain in progress. 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. @@ -33,7 +32,7 @@ The initial 503 linked-comment fixture exercised the app's global reconnect and Local reproduction: http://127.0.0.1:3223/TAS/issues/TAS-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`. -Evidence is retained under `/tmp/task-loading-proof/` until final attachment. +Evidence is retained under `/tmp/task-loading-proof/`; the generated report is also attached to the disposable local instance. The canonical PR is https://github.com/paperclipai/paperclip/pull/18092. State: branch `codex/stable-task-anchor-loading`; baseline `063cfd4381`. Dependencies installed locally; the existing lockfile was stale and will not be committed. diff --git a/tests/perf/issue-detail/compact-startup.spec.ts b/tests/perf/issue-detail/compact-startup.spec.ts index ea0a13ea64e..b0adef1926d 100644 --- a/tests/perf/issue-detail/compact-startup.spec.ts +++ b/tests/perf/issue-detail/compact-startup.spec.ts @@ -132,6 +132,21 @@ test("opening documents and older comments keeps the mounted composer and compac await expect(editor).toHaveAttribute("data-mount-proof", "retained"); }); +test("opening a document from an older linked comment retains that row and its position", async ({ page, request }) => { + // 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 ready(page); + const row = page.locator(`[id="comment-${fixture.oldestCommentId}"]`); + const before = (await row.boundingBox())!.y; + await row.getByRole("link", { name: "Inspect saved evidence", exact: true }).click(); + await expect(page.locator('[id="side-panel-content-document:investigation"]')).toBeVisible(); + await expect(row).toBeVisible(); + await expect.poll(async () => Math.abs((await row.boundingBox())!.y - before)).toBeLessThanOrEqual(2); + await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible(); +}); + test("a failed linked comment keeps recent messages readable and retries without expanding history", async ({ page }) => { const targetPath = `**/api/issues/${fixture.id}/comments/${fixture.oldestCommentId}`; await page.route(targetPath, route => route.fulfill({ status: 404, contentType: "application/json", body: '{"error":"Comment not found"}' })); diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 0b6bec7919c..25994345d00 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1857,6 +1857,25 @@ 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"; + mockIssuesApi.get.mockResolvedValue(createIssue()); + const recent = createIssueComment({ id: "recent-comment", body: "Latest reply" }); + const older = createIssueComment({ id: "old-comment", body: "Read my saved evidence" }); + mockIssuesApi.getComment.mockResolvedValue(older); + mockIssuesApi.recentComments.mockResolvedValue({ comments: [recent], latestCommentId: recent.id, hasMore: true }); + await act(async () => renderIssueView()); + const props = () => mockIssueChatThreadRender.mock.calls.at(-1)![0]; + await waitForAssertion(() => expect(props().comments).toEqual(expect.arrayContaining([expect.objectContaining({ id: older.id })]))); + mockLocation.hash = "#document-qa-evidence"; + await act(async () => renderIssueView()); + await waitForAssertion(() => expect(props().comments).toEqual(expect.arrayContaining([expect.objectContaining({ id: older.id }), expect.objectContaining({ id: recent.id })]))); + expect(queryClient.getQueryData([...queryKeys.issues.comments("PAP-1"), "recent"])?.latestCommentId).toBe(recent.id); + expect(mockIssuesApi.listComments).not.toHaveBeenCalled(); + expect(mockActivityApi.forIssue).not.toHaveBeenCalled(); + }); + it.each([false, true])("loads older history after sending from a cold view (reassign=%s)", async (reassign) => { queryClient.setDefaultOptions({ queries: { retry: false, staleTime: 30_000 } }); queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index b13952680e8..6db10588817 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -3452,6 +3452,19 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS queryFn: ({ signal }) => issuesApi.getComment(issue!.id, linkedCommentId!, { signal }), enabled: !!issue && !!linkedCommentId && !historyExpanded && !windowComments.some(comment => comment.id === linkedCommentId), }); + useEffect(() => { + const target = linkedComment.data; + if (!target || !recentComments.data) return; + // A document click changes the hash, not the reader's conversation. Keep + // every fetched target in the observed window so that click cannot remove + // its source row. The preview cursor still belongs to the latest reply. + queryClient.setQueryData( + [...queryKeys.issues.comments(issueId!), "recent"], + current => !current || current.comments.some(comment => comment.id === target.id) + ? current + : { ...current, comments: mergeWindowComments([target], current.comments) }, + ); + }, [issueId, linkedComment.data, recentComments.data, queryClient]); const comments = useMemo( () => mergeWindowComments(linkedComment.data ? [linkedComment.data] : [], windowComments), [linkedComment.data, windowComments],