fix(ui): settle task geometry and seed saved card reads

Validate linked comment fragments and encode their path component. Seed saved work-product caches from core while duplicate reads remain pending, and reveal only after the initial pane width settles.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-29 08:26:27 -05:00
1 parent e65b7b9a38
commit c91fa107ba
9 files changed
+121 -36

No files matched your search

@@ -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`.
@@ -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<void>(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;
+8
View File
@@ -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);
+1 -1
View File
@@ -652,7 +652,7 @@ export const issuesApi = {
{ verdicts },
),
getComment: (id: string, commentId: string, options?: RequestOptions) =>
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`, options),
api.get<IssueComment>(`/issues/${id}/comments/${encodeURIComponent(commentId)}`, options),
listFeedbackVotes: (id: string) =>
api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
getCostSummary: (id: string, options: { excludeRoot?: boolean } = {}) => {
+15 -10
View File
@@ -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(<TaskChatThread {...props} initialHistoryPending={false} />);
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(<TaskChatThread {...props} initialHistoryPending />);
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).not.toBeNull();
render(<TaskChatThread {...props} initialHistoryPending={false} />);
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(<TaskChatThread {...props} issueId="" />);
await settleThreadLayout();
const composer = container.querySelector('[data-testid="mock-editor"]');
const input = container.querySelector<HTMLInputElement>('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(<TaskChatThread {...props} issueId="persisted-chat" initialHistoryPending={false} />);
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<HTMLButtonElement>('[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(<TaskChatThread {...props} />);
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
await settleThreadLayout();
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
expect(
+17 -6
View File
@@ -2996,10 +2996,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
: Boolean(logErrorsByRun?.has(run.id)),
);
const historyKey = conversationMode && draftKey ? draftKey : issueId;
const [revealedHistoryKey, setRevealedHistoryKey] = useState<string | null | undefined>(
() => (historyPending ? undefined : historyKey),
);
const [revealedHistoryKey, setRevealedHistoryKey] = useState<string | null | undefined>(undefined);
const historyRevealed = revealedHistoryKey === historyKey;
const historyShellRef = useRef<HTMLDivElement>(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}
>
<div
className={cn(
+23 -4
View File
@@ -116,16 +116,35 @@ export function seedIssueDetailCache(
return merged;
}
/** Seed saved projections without replacing lists already read or refreshed. */
export function seedIssueDetailProjections(
queryClient: QueryClient,
issue: Issue,
issueRef?: string | null,
) {
const planKey = [...queryKeys.issues.documents(issue.id), "plan"];
if (issue.planDocument !== undefined && !queryClient.getQueryState(planKey)) {
queryClient.setQueryData(planKey, issue.planDocument);
}
if (issue.workProducts !== undefined) {
for (const ref of collectIssueRefs(issueRef, issue)) {
const key = queryKeys.issues.workProducts(ref);
// The stored request can already be pending when core arrives. Its
// background refresh should not hold the first saved cards behind a gate.
if (queryClient.getQueryData(key) === undefined) {
queryClient.setQueryData(key, issue.workProducts);
}
}
}
}
export async function fetchIssueDetail(
queryClient: QueryClient,
issueRef: string,
options?: { signal?: AbortSignal },
): Promise<Issue> {
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 });
}
+31 -4
View File
@@ -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<Issue>().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());
+5 -6
View File
@@ -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<IssueComment[], string | null>
>(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],