diff --git a/tests/perf/issue-detail/README.md b/tests/perf/issue-detail/README.md index 1bfe138ef9d..c93b59180cc 100644 --- a/tests/perf/issue-detail/README.md +++ b/tests/perf/issue-detail/README.md @@ -69,6 +69,13 @@ Videos and per-frame `layout.json` measurements are written under each test's output directory. The two-pixel assertion measures a logical row's viewport offset; total scroll offset legitimately changes when history is prepended. +Ordinary task opens defer the concealed history and composer until their initial +data is ready. The skeleton and scroll/composer containers retain their geometry. +Comment-link navigation prepares content eagerly so the target can be positioned +before reveal. Revealed content stays mounted during refresh. The draft scenario +holds a background task read, checks the same editor remains mounted, reloads the +saved draft, sends one reply, and independently checks its persisted comment. + To run against an existing **disposable local test-drive instance**: ```sh diff --git a/tests/perf/issue-detail/layout-stability.spec.ts b/tests/perf/issue-detail/layout-stability.spec.ts index f54083fcf19..d377a0415c2 100644 --- a/tests/perf/issue-detail/layout-stability.spec.ts +++ b/tests/perf/issue-detail/layout-stability.spec.ts @@ -8,6 +8,7 @@ test.use({ video: "on", trace: "retain-on-failure", viewport: { width: 1440, hei let prefix: string; let issue: { id: string; identifier: string }; let other: { id: string; identifier: string }; +let draftIssue: { id: string; identifier: string }; let oldestCommentId: string; test.beforeAll(async ({ request }) => { @@ -22,6 +23,7 @@ test.beforeAll(async ({ request }) => { }; issue = await createIssue("Stable conversation acceptance"); other = await createIssue("Rapid navigation destination"); + draftIssue = await createIssue("Draft stability acceptance"); for (let i = 0; i < 160; i++) { const response = await request.post(`/api/issues/${issue.id}/comments`, { data: { body: `## Historical message ${i}\n\n${"Long Markdown with **emphasis**, detail, and enough text to wrap across several lines. ".repeat(8)}\n\n\`\`\`js\nconsole.log(${i});\n\`\`\`\n\n| State | Result |\n|---|---|\n| Complete | Stable |${i === 159 ? `\n\n[Read the oldest message](#comment-${oldestCommentId})` : ""}` }, @@ -151,6 +153,52 @@ test("explicit comment links resolve older pages before revealing at the target" }).toBeLessThanOrEqual(2); }); +test("a draft survives background task refresh, reload, and one persisted reply", async ({ page, request }) => { + await page.goto(`/${prefix}/issues/${draftIssue.identifier}`); + await ready(page); + const editor = page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]'); + const draft = "This draft survives task refresh and is submitted exactly once."; + await editor.fill(draft); + await editor.evaluate((el) => { el.setAttribute("data-draft-mount-proof", "retained"); }); + await expect.poll(() => page.evaluate((id) => localStorage.getItem(`paperclip:issue-comment-draft:${id}`), draftIssue.id)).toBe(draft); + + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let refreshing = false; + await page.route(/\/api\/issues\/[^/?]+$/, async (route) => { + if (route.request().method() === "GET") { + refreshing = true; + await held; + } + await route.continue(); + }); + const title = "Draft stability after background update"; + const updated = await request.patch(`/api/issues/${draftIssue.id}`, { data: { title } }); + expect(updated.ok(), await updated.text()).toBeTruthy(); + try { + await expect.poll(() => refreshing).toBe(true); + await expect(page.getByTestId("task-chat-history-loading")).toHaveCount(0); + await expect(editor).toHaveAttribute("data-draft-mount-proof", "retained"); + await expect(editor).toHaveText(draft); + } finally { + release(); + } + await expect(page.getByText(title, { exact: true }).first()).toBeVisible(); + await expect(editor).toHaveAttribute("data-draft-mount-proof", "retained"); + await page.reload(); + await ready(page); + await expect(editor).toHaveText(draft); + await page.getByTestId("task-chat-composer-send").click(); + await expect.poll(async () => { + const comments = await (await request.get(`/api/issues/${draftIssue.id}/comments`)).json(); + return comments.filter((comment: { body: string }) => comment.body === draft).length; + }).toBe(1); + await page.reload(); + await ready(page); + await expect(editor).toHaveText(""); + await expect(page.getByText(draft, { exact: true })).toHaveCount(1); +}); + test("failed initial comments expose retry, then recover without an indefinite skeleton", async ({ page }) => { await page.route("**/api/issues/*/comments?*", (route) => route.fulfill({ status: 503, contentType: "application/json", body: '{"error":"Temporarily unavailable"}' })); await page.goto(`/${prefix}/issues/${issue.identifier}`); diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 59bf338dadc..12f9dc11194 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -17,6 +17,7 @@ import type { import { heartbeatsApi } from "@/api/heartbeats"; import { nativeRunEventsToTranscript } from "./transcript/native-run-events"; import { pendingConnectionIntentInteraction } from "@/fixtures/issueThreadInteractionFixtures"; +import { TaskChatScrollNavigation } from "./task-chat/scroll-navigation"; import type { HeartbeatRunEvent } from "@paperclipai/shared"; const transcriptState = vi.hoisted(() => ({ @@ -312,20 +313,21 @@ it.each([true, false])("interleaves browser sessions with their requests and pre expect(anchors()).toEqual(expected); }); -it("coordinates first reveal while keeping the composer and visible history mounted through refresh", async () => { - sidebarState.isMobile = true; +it.each([true, false])("defers hidden content until history is ready and keeps it mounted through refresh (mobile=%s)", async (mobile) => { + sidebarState.isMobile = mobile; const props = { issueId: "coordinated-issue", comments: [], onAdd: async () => {}, }; render(); - const composer = container.querySelector('[data-testid="mock-editor"]'); - expect(composer).not.toBeNull(); + expect(container.querySelector('[data-testid="mock-editor"]')).toBeNull(); expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); - expect(container.querySelector('.task-chat-history-pending')).not.toBeNull(); + expect(Boolean(container.querySelector('.task-chat-history-pending'))).toBe(mobile); expect(container.querySelector('[data-testid="task-chat-composer-dock"]')?.hasAttribute("inert")).toBe(true); render(); + const composer = container.querySelector('[data-testid="mock-editor"]'); + expect(composer).not.toBeNull(); await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); @@ -341,6 +343,30 @@ it("coordinates first reveal while keeping the composer and visible history moun expect(container.querySelector('.task-chat-history-pending')).toBeNull(); }); +it("prepares comment-link history and its composer before reveal", async () => { + const props = { + issueId: "linked-issue", + comments: createLongThreadComments(), + onAdd: async () => {}, + }; + const show = (pending: boolean) => render( + + + , + ); + show(true); + const composer = container.querySelector('[data-testid="mock-editor"]'); + const row = container.querySelector('[data-thread-anchor="comment-1"]'); + expect(composer).not.toBeNull(); + expect(row).not.toBeNull(); + expect(row?.closest('[inert]')).not.toBeNull(); + show(false); + await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); + 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); +}); + it.each([true, false])("reveals saved conversation when initial history stalls (mobile=%s)", async (mobile) => { vi.useFakeTimers(); try { @@ -356,10 +382,12 @@ it.each([true, false])("reveals saved conversation when initial history stalls ( onAdd: async () => {}, }; render(); - const composer = container.querySelector('[data-testid="mock-editor"]'); + expect(container.querySelector('[data-testid="mock-editor"]')).toBeNull(); await act(async () => { await vi.advanceTimersByTimeAsync(14_999); }); expect(container.querySelector('[data-testid="task-chat-history-loading"]')).not.toBeNull(); await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + const composer = container.querySelector('[data-testid="mock-editor"]'); + expect(composer).not.toBeNull(); expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull(); expect(container.querySelector('[data-testid="task-chat-history-content"]')?.hasAttribute("inert")).toBe(false); expect(container.querySelector('[data-testid="task-chat-composer-dock"]')?.hasAttribute("inert")).toBe(false); @@ -461,8 +489,8 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti expect( container.querySelector('[data-testid="task-chat-history-loading"]'), ).not.toBeNull(); - expect(container.textContent).toContain("Thread message 1"); - expect(container.querySelector('[data-thread-anchor="comment-1"]')?.closest('[inert]')).not.toBeNull(); + expect(container.textContent).not.toContain("Thread message 1"); + expect(container.querySelector('[data-thread-anchor="comment-1"]')).toBeNull(); transcriptState.hydratedRunIds = new Set(["started-run"]); nativeTranscriptState.hydratedRunIds = new Set(["started-run"]); transcriptState.transcriptByRun.set("started-run", [{ diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 284743610bd..f0468ecd8bd 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -12,7 +12,7 @@ import { useProjectCreatedItems } from "@/hooks/useProjectCreatedItems"; import { skillCreatedItems } from "@/components/task-chat/skill-created-items"; import { requiresExecutionReconciliation } from "@paperclipai/shared"; import { TaskChatExpansionState } from "@/components/task-chat/expansion-state"; -import { TaskChatScrollReady } from "@/components/task-chat/scroll-navigation"; +import { TaskChatScrollReady, useTaskChatScrollNavigation } from "@/components/task-chat/scroll-navigation"; import { useCallback, useEffect, @@ -2911,6 +2911,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { // history and plan before revealing; older runs outside that window do not // delay it, and the latch below keeps subsequent refreshes visible. const historyPending = initialHistoryPending || planLoading || transcriptHistoryPending; + const scrollNavigation = useTaskChatScrollNavigation(); useEffect(() => { recordIssueDetailReadiness({ plan: !planLoading, transcripts: !transcriptHistoryPending, history: !historyPending }); }, [issueId, planLoading, transcriptHistoryPending, historyPending]); @@ -2929,6 +2930,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { const historyRevealed = revealedIssue === issueId; const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ issueId: typeof issueId } | null>(null); const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.issueId === issueId; + const prepareInitialContent = Boolean(scrollNavigation.hash) || !historyPending || historyRevealed || historyWaitExpired; // Supporting requests can stall without rejecting. Bound the first reveal // independently of their pending states so saved conversation and the // composer stay accessible, with an explicit incomplete-history notice. @@ -3021,145 +3023,149 @@ export function TaskChatThread(props: TaskChatThreadProps) { )} inert={!historyRevealed} > - {items.length === 0 && !tailRunId ? ( -
- {threadHeaderWithBlockers ? ( + {prepareInitialContent ? ( + <> + {items.length === 0 && !tailRunId ? (
- {threadHeaderWithBlockers} + {threadHeaderWithBlockers ? ( +
+ {threadHeaderWithBlockers} +
+ ) : null} +
+ {emptyMessage} +
+ {bottomBlockerLinks ? ( +
+ {bottomBlockerLinks} +
+ ) : null}
- ) : null} -
- {emptyMessage} -
- {bottomBlockerLinks ? ( -
- {bottomBlockerLinks} -
- ) : null} -
- ) : ( - run.execution?.phase === "recovery_needed", - ) - ? onTryAgainNoLiveExecutionPath - : undefined - } - tryAgainNoLiveExecutionPathPending={ - tryAgainNoLiveExecutionPathPending - } - onRetryFailedRun={retryFailedRunHandler} - retryFailedRunId={retryFailedRunId} - onOpenSkill={onOpenSkill} - onOpenBrowser={onOpenBrowser} - tail={ - tailRunId || - optimisticRunnerStartup || - bottomBlockerLinks ? ( - <> - {tailRunId || optimisticRunnerStartup ? ( -
- {paperclipRunnerTail || - optimisticRunnerStartup ? ( - 0 - } - onRuntimeRequestDecision={ - handleRuntimeRequestDecision - } - /> - ) : ( - <> - - - - )} -
- ) : null} - {bottomBlockerLinks} - - ) : null - } - contentKey={`${autoFollowContentKey}:${historyRevealed}`} - className={isMobile ? undefined : "pt-3"} - scroll={!isMobile} - /> - )} + ) : ( + run.execution?.phase === "recovery_needed", + ) + ? onTryAgainNoLiveExecutionPath + : undefined + } + tryAgainNoLiveExecutionPathPending={ + tryAgainNoLiveExecutionPathPending + } + onRetryFailedRun={retryFailedRunHandler} + retryFailedRunId={retryFailedRunId} + onOpenSkill={onOpenSkill} + onOpenBrowser={onOpenBrowser} + tail={ + tailRunId || + optimisticRunnerStartup || + bottomBlockerLinks ? ( + <> + {tailRunId || optimisticRunnerStartup ? ( +
+ {paperclipRunnerTail || + optimisticRunnerStartup ? ( + 0 + } + onRuntimeRequestDecision={ + handleRuntimeRequestDecision + } + /> + ) : ( + <> + + + + )} +
+ ) : null} + {bottomBlockerLinks} + + ) : null + } + contentKey={`${autoFollowContentKey}:${historyRevealed}`} + className={isMobile ? undefined : "pt-3"} + scroll={!isMobile} + /> + )} + + ) : null} {assignedAgentForNotice?.status === "paused" ? ( @@ -3173,110 +3179,114 @@ export function TaskChatThread(props: TaskChatThreadProps) { ) : null} {showComposer ? ( - {composerAccessory} - {tailTurnStatus ? ( - + {prepareInitialContent ? ( + <> + {composerAccessory} + {tailTurnStatus ? ( + + ) : null} + +
+ {!composerPause ? ( + { + if (!onReorderQueuedComments) + throw new Error("Queue reordering is unavailable."); + await onReorderQueuedComments( + orderedCommentIds, + revision, + ); + }} + onSteer={async (commentId, revision) => { + if (!onSteerQueuedComment) + throw new Error("Steering is unavailable."); + await onSteerQueuedComment(commentId, revision); + }} + onInterrupt={ + onInterruptQueued && queuedMessageQueue?.queueId + ? async () => { + await onInterruptQueued( + queuedMessageQueue.targetRunId, + ); + } + : undefined + } + onDiscard={async (commentId, revision) => { + if (commentId.startsWith("optimistic-")) { + if (!onCancelQueued) + throw new Error("Discard is unavailable."); + onCancelQueued(commentId); + return; + } + if (!onDiscardQueuedComment) + throw new Error("Discard is unavailable."); + await onDiscardQueuedComment(commentId, revision); + if (queuedEdit?.commentId === commentId) + setQueuedEdit(null); + }} + /> + ) : null} +
+ + comment.authorUserId === currentUserId && comment.clientRequestId && + !("clientStatus" in comment && comment.clientStatus) + ).map((comment) => comment.clientRequestId!))} + onReviewConversation={onReviewConversation} + onStop={liveRun ? onCancelRun : undefined} + stopPending={stopPending} + stopScope={stopScope} + workMode={issueWorkMode} + onWorkModeChange={onWorkModeChange} + disabled={Boolean(runtimeComposerDisabledReason)} + disabledReason={runtimeComposerDisabledReason} + onAttachImage={onAttachImage} + onImageUpload={imageUploadHandler} + mentions={mentions} + enableReassign={enableReassign} + conversationMode={conversationMode} + reassignOptions={reassignOptions} + agentMap={agentMap} + modelAgents={agentMap} + userProfileMap={userProfileMap} + currentAssigneeValue={currentAssigneeValue} + suggestedAssigneeValue={composerSuggestedAssignee} + companyId={companyId} + assigneeAdapterOverrides={assigneeAdapterOverrides} + onPendingAssigneeChange={setPendingComposerAssignee} + issueStatus={issueStatus} + mobile={isMobile} + draftKey={draftKey} + queuedEdit={queuedEdit} + onSaveQueuedEdit={saveQueuedEdit} + onCancelQueuedEdit={() => setQueuedEdit(null)} + pause={composerPause} + takeover={composerTakeover} + runnerGoalCapability={runnerGoal.data?.capability ?? null} + onRunnerGoalCommand={runnerGoal.executeComposerCommand} + onRunnerGoalReassign={reassignForRunnerGoal} + pendingTakeover={ + pendingReminderInputs.length > 0 + ? { + count: pendingReminderInputs.length, + label: `${pendingReminderInputs.length} pending input${pendingReminderInputs.length === 1 ? "" : "s"}`, + onOpen: openPendingTakeover, + } + : null + } + /> +
+
+ {footer} + ) : null} - -
- {!composerPause ? ( - { - if (!onReorderQueuedComments) - throw new Error("Queue reordering is unavailable."); - await onReorderQueuedComments( - orderedCommentIds, - revision, - ); - }} - onSteer={async (commentId, revision) => { - if (!onSteerQueuedComment) - throw new Error("Steering is unavailable."); - await onSteerQueuedComment(commentId, revision); - }} - onInterrupt={ - onInterruptQueued && queuedMessageQueue?.queueId - ? async () => { - await onInterruptQueued( - queuedMessageQueue.targetRunId, - ); - } - : undefined - } - onDiscard={async (commentId, revision) => { - if (commentId.startsWith("optimistic-")) { - if (!onCancelQueued) - throw new Error("Discard is unavailable."); - onCancelQueued(commentId); - return; - } - if (!onDiscardQueuedComment) - throw new Error("Discard is unavailable."); - await onDiscardQueuedComment(commentId, revision); - if (queuedEdit?.commentId === commentId) - setQueuedEdit(null); - }} - /> - ) : null} -
- - comment.authorUserId === currentUserId && comment.clientRequestId && - !("clientStatus" in comment && comment.clientStatus) - ).map((comment) => comment.clientRequestId!))} - onReviewConversation={onReviewConversation} - onStop={liveRun ? onCancelRun : undefined} - stopPending={stopPending} - stopScope={stopScope} - workMode={issueWorkMode} - onWorkModeChange={onWorkModeChange} - disabled={Boolean(runtimeComposerDisabledReason)} - disabledReason={runtimeComposerDisabledReason} - onAttachImage={onAttachImage} - onImageUpload={imageUploadHandler} - mentions={mentions} - enableReassign={enableReassign} - conversationMode={conversationMode} - reassignOptions={reassignOptions} - agentMap={agentMap} - modelAgents={agentMap} - userProfileMap={userProfileMap} - currentAssigneeValue={currentAssigneeValue} - suggestedAssigneeValue={composerSuggestedAssignee} - companyId={companyId} - assigneeAdapterOverrides={assigneeAdapterOverrides} - onPendingAssigneeChange={setPendingComposerAssignee} - issueStatus={issueStatus} - mobile={isMobile} - draftKey={draftKey} - queuedEdit={queuedEdit} - onSaveQueuedEdit={saveQueuedEdit} - onCancelQueuedEdit={() => setQueuedEdit(null)} - pause={composerPause} - takeover={composerTakeover} - runnerGoalCapability={runnerGoal.data?.capability ?? null} - onRunnerGoalCommand={runnerGoal.executeComposerCommand} - onRunnerGoalReassign={reassignForRunnerGoal} - pendingTakeover={ - pendingReminderInputs.length > 0 - ? { - count: pendingReminderInputs.length, - label: `${pendingReminderInputs.length} pending input${pendingReminderInputs.length === 1 ? "" : "s"}`, - onOpen: openPendingTakeover, - } - : null - } - /> -
-
- {footer}
) : null}