mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 01:27:09 +01:00
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:
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
|
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
|
second regression showed delayed PR counts changing card heights from 62 to
|
||||||
82 pixels; reserving that statistics line makes both states 82 pixels.
|
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
|
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.
|
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
|
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.
|
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.
|
Local reproduction: http://127.0.0.1:3223/TASAAAAA/issues/TASAAAAA-1#document-investigation.
|
||||||
Synthetic task data only; no live provider execution. Restart with
|
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`.
|
`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 });
|
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(() => { window.__PAPERCLIP_ISSUE_DETAIL_PROFILE__ = true; });
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const frames: unknown[] = [];
|
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"]');
|
const scroller = thread?.querySelector('[data-testid="task-chat-scroller"]');
|
||||||
if (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"]')];
|
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 };
|
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;
|
let release!: () => void;
|
||||||
const held = new Promise<void>(resolve => { release = resolve; });
|
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\/[^/?]+$/, async route => { await held; await route.continue(); });
|
||||||
|
await page.route(/\/api\/issues\/[^/?]+\/work-products$/, async route => { await held; await route.continue(); });
|
||||||
try {
|
try {
|
||||||
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
|
||||||
await ready(page);
|
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-resource-document")).toHaveCount(3);
|
||||||
await expect(page.getByTestId("task-chat-plan-preview")).toHaveCount(1);
|
await expect(page.getByTestId("task-chat-plan-preview")).toHaveCount(1);
|
||||||
await page.waitForTimeout(500);
|
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 fs.writeFile(info.outputPath("layout.json"), JSON.stringify(frames, null, 2));
|
||||||
await page.screenshot({ path: info.outputPath("settled.png") });
|
await page.screenshot({ path: info.outputPath("settled.png") });
|
||||||
expect(frames.length).toBeGreaterThan(5);
|
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(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.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 (let card = 0; card < frames[0].cards; card++) {
|
||||||
for (const dimension of ["top", "height"] as const) {
|
for (const dimension of ["top", "height"] as const) {
|
||||||
const values = frames.map(frame => frame.boxes[card][dimension]);
|
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.
|
// 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();
|
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.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);
|
await ready(page);
|
||||||
const row = page.locator(`[id="comment-${fixture.oldestCommentId}"]`);
|
const row = page.locator(`[id="comment-${fixture.oldestCommentId}"]`);
|
||||||
const before = (await row.boundingBox())!.y;
|
const before = (await row.boundingBox())!.y;
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ describe("issuesApi.list", () => {
|
|||||||
mockApi.patch.mockResolvedValue({});
|
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) => {
|
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" });
|
mockApi.get.mockResolvedValueOnce({ queueId: "queue-1", targetRunId: null, revision: "revision-2" });
|
||||||
await issuesApi.interruptLatestQueuedComments("issue-1", target);
|
await issuesApi.interruptLatestQueuedComments("issue-1", target);
|
||||||
|
|||||||
@@ -652,7 +652,7 @@ export const issuesApi = {
|
|||||||
{ verdicts },
|
{ verdicts },
|
||||||
),
|
),
|
||||||
getComment: (id: string, commentId: string, options?: RequestOptions) =>
|
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) =>
|
listFeedbackVotes: (id: string) =>
|
||||||
api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
|
api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
|
||||||
getCostSummary: (id: string, options: { excludeRoot?: boolean } = {}) => {
|
getCostSummary: (id: string, options: { excludeRoot?: boolean } = {}) => {
|
||||||
|
|||||||
@@ -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) => {
|
describe.each(["legacy", "native"] as const)("historical %s system status", (runtimeMode) => {
|
||||||
const run = (id: string, status: string, minute: number) => ({
|
const run = (id: string, status: string, minute: number) => ({
|
||||||
runId: id, status, runtimeMode, agentId: "agent", agentName: "Assistant",
|
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} />);
|
render(<TaskChatThread {...props} initialHistoryPending={false} />);
|
||||||
const composer = container.querySelector('[data-testid="mock-editor"]');
|
const composer = container.querySelector('[data-testid="mock-editor"]');
|
||||||
expect(composer).not.toBeNull();
|
expect(composer).not.toBeNull();
|
||||||
await act(async () => {
|
await settleThreadLayout();
|
||||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
||||||
});
|
|
||||||
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
|
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
|
||||||
expect(container.querySelector('.task-chat-history-pending')).toBeNull();
|
expect(container.querySelector('.task-chat-history-pending')).toBeNull();
|
||||||
expect(container.querySelector('[data-testid="task-chat-composer-dock"]')?.hasAttribute("inert")).toBe(false);
|
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 />);
|
render(<TaskChatThread {...props} initialHistoryPending />);
|
||||||
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).not.toBeNull();
|
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).not.toBeNull();
|
||||||
render(<TaskChatThread {...props} initialHistoryPending={false} />);
|
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.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull();
|
||||||
expect(container.textContent).toContain("Thread message 1");
|
expect(container.textContent).toContain("Thread message 1");
|
||||||
expect(container.textContent).toContain("More messages");
|
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 onAttachImage = vi.fn().mockReturnValue(new Promise(resolve => { resolveUpload = resolve; }));
|
||||||
const props = { comments: [], onAdd, onAttachImage, conversationMode: true, draftKey: "agent-chat:company:agent" };
|
const props = { comments: [], onAdd, onAttachImage, conversationMode: true, draftKey: "agent-chat:company:agent" };
|
||||||
render(<TaskChatThread {...props} issueId="" />);
|
render(<TaskChatThread {...props} issueId="" />);
|
||||||
|
await settleThreadLayout();
|
||||||
const composer = container.querySelector('[data-testid="mock-editor"]');
|
const composer = container.querySelector('[data-testid="mock-editor"]');
|
||||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!;
|
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!;
|
||||||
const file = new File(["notes"], "chat-notes.txt", { type: "text/plain" });
|
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";
|
const id = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
||||||
await act(async () => { resolveUpload({ id, contentPath: `/api/attachments/${id}/content`, originalFilename: file.name }); });
|
await act(async () => { resolveUpload({ id, contentPath: `/api/attachments/${id}/content`, originalFilename: file.name }); });
|
||||||
render(<TaskChatThread {...props} issueId="persisted-chat" initialHistoryPending={false} />);
|
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"]');
|
const chip = container.querySelector('[data-testid="task-chat-composer-attachments"] [data-slot="attachment"]');
|
||||||
expect(chip?.getAttribute("data-state")).toBe("done");
|
expect(chip?.getAttribute("data-state")).toBe("done");
|
||||||
const send = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')!;
|
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).not.toBeNull();
|
||||||
expect(row?.closest('[inert]')).not.toBeNull();
|
expect(row?.closest('[inert]')).not.toBeNull();
|
||||||
show(false);
|
show(false);
|
||||||
await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); });
|
await settleThreadLayout();
|
||||||
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
|
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
|
||||||
expect(container.querySelector('[data-testid="mock-editor"]')).toBe(composer);
|
expect(container.querySelector('[data-testid="mock-editor"]')).toBe(composer);
|
||||||
expect(container.querySelector('[data-thread-anchor="comment-1"]')).toBe(row);
|
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();
|
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({
|
renderThread({
|
||||||
issueId: "issue-1",
|
issueId: "issue-1",
|
||||||
comments: createLongThreadComments(),
|
comments: createLongThreadComments(),
|
||||||
onAdd: async () => {},
|
onAdd: async () => {},
|
||||||
linkedRuns: [retryRun],
|
linkedRuns: [retryRun],
|
||||||
});
|
});
|
||||||
|
await settleThreadLayout();
|
||||||
|
|
||||||
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
|
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
|
||||||
expect(
|
expect(
|
||||||
@@ -727,9 +734,7 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti
|
|||||||
ts: "2026-08-25T18:00:01.000Z",
|
ts: "2026-08-25T18:00:01.000Z",
|
||||||
}]);
|
}]);
|
||||||
render(<TaskChatThread {...props} />);
|
render(<TaskChatThread {...props} />);
|
||||||
await act(async () => {
|
await settleThreadLayout();
|
||||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
|
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -2996,10 +2996,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||||||
: Boolean(logErrorsByRun?.has(run.id)),
|
: Boolean(logErrorsByRun?.has(run.id)),
|
||||||
);
|
);
|
||||||
const historyKey = conversationMode && draftKey ? draftKey : issueId;
|
const historyKey = conversationMode && draftKey ? draftKey : issueId;
|
||||||
const [revealedHistoryKey, setRevealedHistoryKey] = useState<string | null | undefined>(
|
const [revealedHistoryKey, setRevealedHistoryKey] = useState<string | null | undefined>(undefined);
|
||||||
() => (historyPending ? undefined : historyKey),
|
|
||||||
);
|
|
||||||
const historyRevealed = revealedHistoryKey === historyKey;
|
const historyRevealed = revealedHistoryKey === historyKey;
|
||||||
|
const historyShellRef = useRef<HTMLDivElement>(null);
|
||||||
const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ historyKey: typeof historyKey } | null>(null);
|
const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ historyKey: typeof historyKey } | null>(null);
|
||||||
const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.historyKey === historyKey;
|
const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.historyKey === historyKey;
|
||||||
const prepareInitialContent = Boolean(scrollNavigation.hash) || !historyPending || historyRevealed || historyWaitExpired;
|
const prepareInitialContent = Boolean(scrollNavigation.hash) || !historyPending || historyRevealed || historyWaitExpired;
|
||||||
@@ -3014,12 +3013,23 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||||||
}, INITIAL_HISTORY_REVEAL_TIMEOUT_MS);
|
}, INITIAL_HISTORY_REVEAL_TIMEOUT_MS);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [historyRevealed, historyKey]);
|
}, [historyRevealed, historyKey]);
|
||||||
// Mount and measure the real thread while concealed, then reveal in one
|
// Mount and measure the real thread while concealed. The properties pane
|
||||||
// commit. A frame also lets ancestor navigation scroll restoration finish.
|
// 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.
|
// Readiness is latched per issue: refetches never hide existing conversation.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (historyRevealed || historyPending) return;
|
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);
|
return () => cancelAnimationFrame(frame);
|
||||||
}, [historyPending, historyRevealed, historyKey]);
|
}, [historyPending, historyRevealed, historyKey]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3046,6 +3056,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||||||
isMobile && !historyRevealed && "task-chat-history-pending",
|
isMobile && !historyRevealed && "task-chat-history-pending",
|
||||||
)}
|
)}
|
||||||
data-testid="task-chat-thread"
|
data-testid="task-chat-thread"
|
||||||
|
ref={historyShellRef}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -116,16 +116,35 @@ export function seedIssueDetailCache(
|
|||||||
return merged;
|
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(
|
export async function fetchIssueDetail(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
issueRef: string,
|
issueRef: string,
|
||||||
options?: { signal?: AbortSignal },
|
options?: { signal?: AbortSignal },
|
||||||
): Promise<Issue> {
|
): Promise<Issue> {
|
||||||
const issue = options ? await issuesApi.get(issueRef, options) : await issuesApi.get(issueRef);
|
const issue = options ? await issuesApi.get(issueRef, options) : await issuesApi.get(issueRef);
|
||||||
const planKey = [...queryKeys.issues.documents(issue.id), "plan"];
|
seedIssueDetailProjections(queryClient, issue, issueRef);
|
||||||
if (issue.planDocument !== undefined && !queryClient.getQueryState(planKey)) {
|
|
||||||
queryClient.setQueryData(planKey, issue.planDocument);
|
|
||||||
}
|
|
||||||
return seedIssueDetailCache(queryClient, issue, { issueRef });
|
return seedIssueDetailCache(queryClient, issue, { issueRef });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1831,12 +1831,39 @@ describe("IssueDetail", () => {
|
|||||||
expect(queryClient.getQueryData(["issues", "history-expanded", "PAP-1"])).toBe(true);
|
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);
|
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
|
||||||
mockLocation.hash = hash;
|
mockLocation.hash = hash;
|
||||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||||
const recent = createIssueComment({ id: "recent-comment", body: "Latest reply" });
|
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.getComment.mockResolvedValue(older);
|
||||||
mockIssuesApi.recentComments.mockResolvedValue({ comments: [recent], latestCommentId: recent.id, hasMore: true });
|
mockIssuesApi.recentComments.mockResolvedValue({ comments: [recent], latestCommentId: recent.id, hasMore: true });
|
||||||
await act(async () => renderIssueView());
|
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 () => {
|
it("retains a linked older comment when opening a document without moving the preview cursor", async () => {
|
||||||
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
|
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());
|
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||||
const recent = createIssueComment({ id: "recent-comment", body: "Latest reply" });
|
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.getComment.mockResolvedValue(older);
|
||||||
mockIssuesApi.recentComments.mockResolvedValue({ comments: [recent], latestCommentId: recent.id, hasMore: true });
|
mockIssuesApi.recentComments.mockResolvedValue({ comments: [recent], latestCommentId: recent.id, hasMore: true });
|
||||||
await act(async () => renderIssueView());
|
await act(async () => renderIssueView());
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ import {
|
|||||||
shouldTrackIssueActiveRun,
|
shouldTrackIssueActiveRun,
|
||||||
} from "../lib/issueActiveRun";
|
} from "../lib/issueActiveRun";
|
||||||
import { prefetchIssueThread } from "../lib/issue-thread-queries";
|
import { prefetchIssueThread } from "../lib/issue-thread-queries";
|
||||||
import { getIssueDetailQueryOptions } from "../lib/issueDetailCache";
|
import { getIssueDetailQueryOptions, seedIssueDetailProjections } from "../lib/issueDetailCache";
|
||||||
import {
|
import {
|
||||||
beginIssueDetailNavigation,
|
beginIssueDetailNavigation,
|
||||||
recordIssueDetailReadiness,
|
recordIssueDetailReadiness,
|
||||||
@@ -388,6 +388,7 @@ import {
|
|||||||
type IssueTreeControlMode,
|
type IssueTreeControlMode,
|
||||||
type WorkspaceFileRef,
|
type WorkspaceFileRef,
|
||||||
workspaceFileRefSchema,
|
workspaceFileRefSchema,
|
||||||
|
isUuidLike,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
|
||||||
// Stable empty array for React Query `data` defaults. A literal `= []` default
|
// 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"],
|
queryKey: [...queryKeys.issues.detail(issueId!), "core"],
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
const core = await issuesApi.getCore(issueId!, { signal });
|
const core = await issuesApi.getCore(issueId!, { signal });
|
||||||
const planKey = [...queryKeys.issues.documents(core.id), "plan"];
|
seedIssueDetailProjections(queryClient, core, issueId);
|
||||||
if (core.planDocument !== undefined && !queryClient.getQueryState(planKey)) {
|
|
||||||
queryClient.setQueryData(planKey, core.planDocument);
|
|
||||||
}
|
|
||||||
return core;
|
return core;
|
||||||
},
|
},
|
||||||
enabled: !!issueId,
|
enabled: !!issueId,
|
||||||
@@ -3440,9 +3438,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
|||||||
InfiniteData<IssueComment[], string | null>
|
InfiniteData<IssueComment[], string | null>
|
||||||
>(issueId ?? "pending"),
|
>(issueId ?? "pending"),
|
||||||
});
|
});
|
||||||
const linkedCommentId = location.hash.startsWith("#comment-")
|
const commentAnchor = location.hash.startsWith("#comment-")
|
||||||
? location.hash.slice("#comment-".length)
|
? location.hash.slice("#comment-".length)
|
||||||
: null;
|
: null;
|
||||||
|
const linkedCommentId = commentAnchor && isUuidLike(commentAnchor) ? commentAnchor.trim() : null;
|
||||||
const windowComments = useMemo(
|
const windowComments = useMemo(
|
||||||
() => mergeWindowComments(recentComments.data?.comments ?? [], flattenIssueCommentPages(commentPages?.pages)),
|
() => mergeWindowComments(recentComments.data?.comments ?? [], flattenIssueCommentPages(commentPages?.pages)),
|
||||||
[commentPages?.pages, recentComments.data?.comments],
|
[commentPages?.pages, recentComments.data?.comments],
|
||||||
|
|||||||
Reference in new issue
Block a user