diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md index b9c16a8ed13..28c794fd07c 100644 --- a/maps/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -59,7 +59,7 @@ Filters → **Routine tasks** selects tasks created by routine runs. The ### `task-detail` -Open `/issues/:issueId` from a row, mention, or direct link. Cold opens show the instruction, recent exchange and saved cards together. No URL fragment expands history; older-comment anchors fetch only their target, and document anchors open the pane. **More messages** appears only when earlier comments or runs are omitted and loads that history. Fresh tasks with no omitted history have no disclosure. A single legacy run with a truncated log offers **More run output** instead. **More task actions → Show task activity** loads full activity even for a single native run; incoming messages remain visible while the task stays open. +Open `/issues/:issueId` from a row, mention, or direct link. Cold opens show the instruction, recent exchange and saved cards together. Completed runs with saved replies defer their transcripts until the reader opens history. Live runs, failures, yielded runs and runs without a saved reply still hydrate; an already shown transcript stays available after completion. No URL fragment expands history; older-comment anchors fetch only their target, and document anchors open the pane. **More messages** appears only when earlier comments or runs are omitted and loads that history. Fresh tasks with no omitted history have no disclosure. A single run with deferred output or a truncated legacy log offers **More run output** instead. **More task actions → Show task activity** loads full activity even for a single native run; incoming messages remain visible while the task stays open. ### `task-sharing` @@ -113,6 +113,8 @@ Manual: Open a long task with slow historical requests. Read the instruction and Automated: [cold-open reads](../../server/src/__tests__/task-cold-open.test.ts) cover bounded reads, event filtering and company denial. [Task detail](../../ui/src/pages/IssueDetail.test.tsx) covers component behavior, while [issue service](../../server/src/__tests__/issues-service.test.ts) covers server contracts. +The opt-in [saved-reply browser case](../../tests/perf/issue-detail/saved-reply-startup.spec.ts) uses 200 completed native runs and long saved replies in a disposable PostgreSQL instance. It holds transcript reads, checks desktop/mobile first-reveal geometry, then opens history while retaining the editor and draft. See its [setup](../../tests/perf/issue-detail/README.md). + Failure markers have no dashed divider rules. An adjacent run error and no-live-execution-path notice show only the last error, with its details and retry action, including a permitted legacy run retry carried onto a later diff --git a/server/src/__tests__/privacy-production-review.test.ts b/server/src/__tests__/privacy-production-review.test.ts index 1e0b37396ca..dd01aebef67 100644 --- a/server/src/__tests__/privacy-production-review.test.ts +++ b/server/src/__tests__/privacy-production-review.test.ts @@ -16,6 +16,7 @@ import { canActorReadWorkspaceOperation } from "../services/heartbeat-run-privac describe("private task production review", () => { let tempDb: Awaited>; let db: ReturnType; + let fixtureCompanyNumber = 0; beforeAll(async () => { process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; tempDb = await startEmbeddedPostgresTestDatabase("paperclip-private-review-"); @@ -26,7 +27,7 @@ describe("private task production review", () => { afterAll(async () => { await tempDb?.cleanup(); }); async function fixture() { - const [company] = await db.insert(companies).values({ name: randomUUID(), issuePrefix: `T${randomUUID().slice(0, 5)}` }).returning(); + const [company] = await db.insert(companies).values({ name: randomUUID(), issuePrefix: `T${++fixtureCompanyNumber}` }).returning(); const owner = randomUUID(), outsider = randomUUID(); await db.insert(authUsers).values([owner, outsider].map(id => ({ id, name: id, email: `${id}@example.test`, createdAt: new Date(), updatedAt: new Date() }))); await db.insert(companyMemberships).values([owner, outsider].map(principalId => ({ companyId: company.id, principalType: "user", principalId, status: "active", membershipRole: "operator" }))); diff --git a/tests/perf/issue-detail/README.md b/tests/perf/issue-detail/README.md index f344845ad8a..ce6a5a25199 100644 --- a/tests/perf/issue-detail/README.md +++ b/tests/perf/issue-detail/README.md @@ -75,17 +75,23 @@ Back navigation, and covers mobile reduced motion and explicit comment links. The eight scenarios also exercise same-task hash navigation without remounting on desktop and mobile, and stalled native/log reads that expose Retry after the 15-second request deadline. -`plan-startup.spec.ts` also opens tasks with and without a saved plan while -blocking the separate plan endpoint. Both must reveal the saved conversation -from the task response without issuing that redundant read. -It also checks task-list navigation, where a list-only snapshot needs one normal -plan fetch and must still show a saved plan when present. +`plan-startup.spec.ts` opens tasks directly and from the task list, with and +without a saved plan, while holding separate plan reads. The core projection +must reveal the saved conversation and plan before those reads finish. Separate +plan reads are recorded as diagnostics. The hook tests cover fallback reads for +list-only snapshots and responses without a plan projection. 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. +Compact tasks reuse saved replies from completed runs. Their transcript reads +start when the reader opens older history through **More messages** or **More run +output**. Saved Plans from completed runs stay visible while their output is deferred or pending. +Live runs, failures, yielded runs, explicit anchors, and runs without a +saved reply still load their output. A transcript already shown during this visit +stays mounted when the run completes. 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 @@ -105,6 +111,20 @@ isolated instance as before. Live provider walkthroughs additionally require a configured native Paperclip runner and Codex authentication; deterministic browser fixtures do not substitute for watching an actual provider run. +`saved-reply-startup.spec.ts` adds a PostgreSQL fixture with 200 long saved agent +replies, 200 completed native runs, and a Plan linked to the latest run. It holds +transcript requests, checks both task presentations on desktop and mobile, and measures +reply positions on each frame. Opening older history must start the transcript +reads and preserve the editor and draft. This case is skipped unless +`PAPERCLIP_ISSUE_PERF_DATABASE_URL` points to a disposable loopback PostgreSQL +database whose name ends in `_test`. Use the same database as the local server: + +```sh +PAPERCLIP_ISSUE_PERF_BASE_URL=http://127.0.0.1:3102 \ +PAPERCLIP_ISSUE_PERF_DATABASE_URL=postgres://paperclip:paperclip@127.0.0.1:5432/task_loading_test \ + pnpm exec playwright test --config tests/perf/issue-detail/playwright.config.ts saved-reply-startup.spec.ts +``` + For the live walkthrough, first assign a disposable task to a native Paperclip runner configured with the Codex provider. Its scratch project should contain a small `sum.mjs` fixture. The script sends a paced job, scrolls up, disconnects diff --git a/tests/perf/issue-detail/layout-stability.spec.ts b/tests/perf/issue-detail/layout-stability.spec.ts index d377a0415c2..1f3521d74b5 100644 --- a/tests/perf/issue-detail/layout-stability.spec.ts +++ b/tests/perf/issue-detail/layout-stability.spec.ts @@ -99,6 +99,7 @@ test("reading anchor survives late media, composer resizing, older history and b await page.goto(`/${prefix}/issues/${issue.identifier}`); await ready(page); const scroller = page.getByTestId("task-chat-scroller"); + await page.getByRole("button", { name: "More messages", exact: true }).click(); await expect(page.getByText("Historical message 10", { exact: true })).toBeAttached(); await scroller.evaluate((el) => { el.scrollTop = el.scrollHeight / 2; }); await page.waitForTimeout(100); @@ -153,6 +154,45 @@ test("explicit comment links resolve older pages before revealing at the target" }).toBeLessThanOrEqual(2); }); +test("a loaded comment target positions after the reveal deadline when live-run metadata stalls", async ({ page }) => { + let release!: () => void; + let metadataReads = 0; + const held = new Promise(resolve => { release = resolve; }); + await page.route("**/api/issues/*/live-runs", async route => { metadataReads++; await held; await route.continue().catch(() => {}); }); + try { + await page.goto(`/${prefix}/issues/${issue.identifier}#comment-${oldestCommentId}`); + await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 20_000 }); + expect(metadataReads).toBeGreaterThan(0); + await expect(page.getByText("Some task history is still loading.", { exact: true })).toBeVisible(); + const target = page.locator(`[id="comment-${oldestCommentId}"]`); + await expect(target).toBeVisible(); + await expect.poll(() => target.evaluate(el => Math.abs(el.getBoundingClientRect().top - document.querySelector('[data-testid="task-chat-scroller"]')!.getBoundingClientRect().top))) + .toBeLessThanOrEqual(2); + await expect(page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]')).toBeVisible(); + } finally { release(); } +}); + +test("a late linked comment positions after the reveal deadline without losing the draft", async ({ page }) => { + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + await page.route(`**/api/issues/*/comments/${oldestCommentId}`, async route => { await held; await route.continue().catch(() => {}); }); + try { + await page.goto(`/${prefix}/issues/${issue.identifier}#comment-${oldestCommentId}`); + await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 20_000 }); + const target = page.locator(`[id="comment-${oldestCommentId}"]`); + await expect(target).toHaveCount(0); + const editor = page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]'); + await editor.fill("Keep this draft while the linked comment loads."); + const originalEditor = await editor.elementHandle(); + release(); + await expect(target).toBeVisible(); + await expect.poll(() => target.evaluate(el => Math.abs(el.getBoundingClientRect().top - document.querySelector('[data-testid="task-chat-scroller"]')!.getBoundingClientRect().top))) + .toBeLessThanOrEqual(2); + await expect(editor).toHaveText("Keep this draft while the linked comment loads."); + expect(await originalEditor!.evaluate(element => element.isConnected)).toBe(true); + } finally { release(); } +}); + 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); @@ -200,10 +240,10 @@ test("a draft survives background task refresh, reload, and one persisted reply" }); 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.route("**/api/issues/*/recent-comments", (route) => route.fulfill({ status: 500, contentType: "application/json", body: '{"error":"Comment read failed"}' })); await page.goto(`/${prefix}/issues/${issue.identifier}`); await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible({ timeout: 30_000 }); - await page.unroute("**/api/issues/*/comments?*"); + await page.unroute("**/api/issues/*/recent-comments"); await page.getByRole("button", { name: "Retry", exact: true }).click(); await ready(page); await expect(page.getByText("Historical message 159", { exact: true })).toBeAttached(); @@ -215,13 +255,15 @@ test("mobile reduced-motion cold open and rapid task switching", async ({ page } await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto(`/${prefix}/issues/${issue.identifier}`); await ready(page); - await expect.poll(() => page.evaluate(() => document.documentElement.scrollHeight - window.scrollY - window.innerHeight)).toBeLessThan(2); - await page.evaluate(() => window.scrollTo(0, 1200)); + const scroller = page.getByTestId("task-chat-scroller"); + const distanceToLatest = () => scroller.evaluate(el => el.scrollHeight - el.scrollTop - el.clientHeight); + await expect.poll(distanceToLatest).toBeLessThan(2); + await scroller.evaluate(el => { el.scrollTop = 1200; }); await page.waitForTimeout(100); - const saved = await page.evaluate(() => window.scrollY); + const saved = await scroller.evaluate(el => el.scrollTop); await page.reload(); await ready(page); - await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(1200); + await expect.poll(distanceToLatest).toBeLessThan(2); // A fresh navigation must show the new task, never retain the previous feed. await page.goto(`/${prefix}/issues/${other.identifier}`); await ready(page); @@ -234,34 +276,34 @@ for (const mobile of [false, true]) { if (mobile) await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`/${prefix}/issues/${issue.identifier}`); await ready(page); - await page.getByText("Historical message 10", { exact: true }).waitFor({ state: "attached" }); + await page.getByText("Historical message 159", { exact: true }).waitFor({ state: "attached" }); await expect(page.locator(`[id="comment-${oldestCommentId}"]`)).toHaveCount(0); const originalThread = await page.getByTestId("task-chat-thread").elementHandle(); - const before = await page.evaluate((mobile) => { - const root = document.querySelector(mobile ? '[data-testid="task-chat-thread"]' : '[data-testid="task-chat-scroller"]')!; - const top = mobile ? 0 : root.getBoundingClientRect().top; + const before = await page.evaluate(() => { + const root = document.querySelector('[data-testid="task-chat-scroller"]')!; + const top = root.getBoundingClientRect().top; const row = [...root.querySelectorAll("[data-thread-anchor]")].find((el) => el.getBoundingClientRect().bottom > top)!; return { id: row.dataset.threadAnchor!, offset: row.getBoundingClientRect().top - top }; - }, mobile); + }); await page.getByRole("link", { name: "Read the oldest message", exact: true }).click(); const target = page.locator(`[id="comment-${oldestCommentId}"]`); await expect(target).toBeAttached(); - const targetOffset = () => target.evaluate((el, mobile) => el.getBoundingClientRect().top - (mobile ? 0 : document.querySelector('[data-testid="task-chat-scroller"]')!.getBoundingClientRect().top), mobile); + const targetOffset = () => target.evaluate(el => el.getBoundingClientRect().top - document.querySelector('[data-testid="task-chat-scroller"]')!.getBoundingClientRect().top); await expect.poll(async () => Math.abs(await targetOffset())).toBeLessThanOrEqual(2); expect(await originalThread!.evaluate((el) => el === document.querySelector('[data-testid="task-chat-thread"]'))).toBe(true); await page.goBack(); - await expect.poll(async () => Math.abs(await page.evaluate(({ mobile, before }) => { - const root = document.querySelector(mobile ? '[data-testid="task-chat-thread"]' : '[data-testid="task-chat-scroller"]')!; + await expect.poll(async () => Math.abs(await page.evaluate(before => { + const root = document.querySelector('[data-testid="task-chat-scroller"]')!; const row = [...root.querySelectorAll("[data-thread-anchor]")].find((el) => el.dataset.threadAnchor === before.id)!; - return row.getBoundingClientRect().top - (mobile ? 0 : root.getBoundingClientRect().top) - before.offset; - }, { mobile, before }))).toBeLessThanOrEqual(2); + return row.getBoundingClientRect().top - root.getBoundingClientRect().top - before.offset; + }, before))).toBeLessThanOrEqual(2); }); } test("stalled native and log history reveal loaded content with Retry after the request deadline", async ({ page }) => { const runId = "20000000-0000-4000-8000-000000000001"; const at = new Date().toISOString(); - await page.route("**/api/issues/*/runs", (route) => route.fulfill({ json: [{ runId, runtimeMode: "native", status: "succeeded", agentId: "20000000-0000-4000-8000-000000000002", adapterType: "paperclip_runner", createdAt: at, startedAt: at, finishedAt: at }] })); + await page.route(/\/api\/issues\/[^/]+\/runs(?:\?|$)/, (route) => route.fulfill({ json: [{ runId, runtimeMode: "native", status: "succeeded", agentId: "20000000-0000-4000-8000-000000000002", adapterType: "paperclip_runner", createdAt: at, startedAt: at, finishedAt: at }] })); let stalled = true; let release!: () => void; const held = new Promise((resolve) => { release = resolve; }); diff --git a/tests/perf/issue-detail/plan-startup.spec.ts b/tests/perf/issue-detail/plan-startup.spec.ts index faa514308b1..e2b779f0a5b 100644 --- a/tests/perf/issue-detail/plan-startup.spec.ts +++ b/tests/perf/issue-detail/plan-startup.spec.ts @@ -5,7 +5,7 @@ test.setTimeout(60_000); for (const hasPlan of [false, true]) { for (const entry of ["direct", "list"]) { - test(`task reuses a full plan projection and fetches one for list-only snapshots (plan=${hasPlan}, entry=${entry})`, async ({ page, request }) => { + test(`task reveals its core plan projection while separate plan reads are stalled (plan=${hasPlan}, entry=${entry})`, async ({ page, request }, info) => { const companyResponse = await request.post("/api/companies", { data: { name: `Plan startup ${Date.now()}` } }); expect(companyResponse.ok()).toBe(true); const company = await companyResponse.json(); @@ -19,22 +19,26 @@ for (const hasPlan of [false, true]) { expect(planResponse.ok()).toBe(true); } let planRequests = 0; + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); await page.route("**/api/issues/*/documents/plan", async (route) => { planRequests += 1; - if (entry === "direct") await route.abort(); - else await route.continue(); + await held; + await route.continue().catch(() => {}); }); - if (entry === "list") { - await page.goto(`/${company.issuePrefix}/issues`); - await page.locator("[data-inbox-issue-link]", { hasText: "Read a saved conversation" }).first().click(); - } else { - await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`); - } - const thread = page.getByTestId("task-chat-thread"); - await expect(thread.locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 10_000 }); - await expect(thread.getByText("This saved reply must be readable without a redundant plan request.", { exact: true })).toBeVisible(); - if (hasPlan) await expect(thread.getByTestId("task-chat-plan-preview")).toBeVisible(); - expect(planRequests).toBe(entry === "direct" ? 0 : 1); + try { + if (entry === "list") { + await page.goto(`/${company.issuePrefix}/issues`); + await page.locator("[data-inbox-issue-link]", { hasText: "Read a saved conversation" }).first().click(); + } else { + await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`); + } + const thread = page.getByTestId("task-chat-thread"); + await expect(thread.locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 10_000 }); + await expect(thread.getByText("This saved reply must be readable without a redundant plan request.", { exact: true })).toBeVisible(); + if (hasPlan) await expect(thread.getByTestId("task-chat-plan-preview")).toBeVisible(); + await info.attach("plan-reads.json", { body: JSON.stringify({ planRequests }), contentType: "application/json" }); + } finally { release(); } }); } } diff --git a/tests/perf/issue-detail/saved-reply-startup.spec.ts b/tests/perf/issue-detail/saved-reply-startup.spec.ts new file mode 100644 index 00000000000..48cdee108b0 --- /dev/null +++ b/tests/perf/issue-detail/saved-reply-startup.spec.ts @@ -0,0 +1,126 @@ +import fs from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { expect, test } from "@playwright/test"; + +test.use({ serviceWorkers: "block" }); +test.setTimeout(60_000); +test.skip(!process.env.PAPERCLIP_ISSUE_PERF_DATABASE_URL, "The native saved-reply fixture requires a disposable PostgreSQL database"); + +let fixture: { prefix: string; identifier: string; runId: string; lastCommentId: string }; + +test.beforeAll(async ({ request }) => { + const databaseUrl = process.env.PAPERCLIP_ISSUE_PERF_DATABASE_URL; + if (!databaseUrl) throw new Error("Set PAPERCLIP_ISSUE_PERF_DATABASE_URL for a disposable local database"); + const target = new URL(databaseUrl); + if (!["localhost", "127.0.0.1", "[::1]"].includes(target.hostname) || !target.pathname.endsWith("_test")) { + throw new Error("The saved-reply fixture requires a disposable loopback database ending in _test"); + } + const { createDb, closeRegisteredClients, heartbeatRuns, heartbeatRunEvents, issueComments, activityLog } = await import("../../../packages/db/src/index"); + const companyResponse = await request.post("/api/companies", { data: { name: `Saved reply startup ${Date.now()}` } }); + expect(companyResponse.ok(), await companyResponse.text()).toBeTruthy(); + const company = await companyResponse.json(); + const agentResponse = await request.post(`/api/companies/${company.id}/agents`, { data: { + name: "Saved investigation author", role: "engineer", status: "paused", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" }, + } }); + expect(agentResponse.ok(), await agentResponse.text()).toBeTruthy(); + const agent = await agentResponse.json(); + const issueResponse = await request.post(`/api/companies/${company.id}/issues`, { data: { + title: "Read the saved investigation", status: "backlog", description: "Review the latest findings and load older investigation history when needed.", + } }); + expect(issueResponse.ok(), await issueResponse.text()).toBeTruthy(); + const issue = await issueResponse.json(); + const planResponse = await request.put(`/api/issues/${issue.id}/documents/plan`, { data: { + title: "Saved investigation plan", format: "markdown", body: "# Saved investigation plan\n\nKeep the saved evidence available before loading its completed run output.", + } }); + expect(planResponse.ok(), await planResponse.text()).toBeTruthy(); + const plan = await planResponse.json(); + const db = createDb(databaseUrl); + const comments: (typeof issueComments.$inferInsert)[] = []; + const runs: (typeof heartbeatRuns.$inferInsert)[] = []; + const activity: (typeof activityLog.$inferInsert)[] = []; + const base = Date.now() - 30 * 24 * 3600_000; + for (let index = 0; index < 200; index++) { + const runId = randomUUID(), id = randomUUID(), createdAt = index === 199 + ? new Date(new Date(plan.updatedAt).getTime() + 50) + : new Date(base + index * 60_000); + const startedAt = index === 199 ? new Date(new Date(plan.updatedAt).getTime() - 1000) : new Date(+createdAt - 900); + runs.push({ id: runId, companyId: company.id, agentId: agent.id, issueId: issue.id, runtimeMode: "native", status: "succeeded", + createdAt: new Date(+startedAt - 100), startedAt, finishedAt: createdAt, contextSnapshot: { issueId: issue.id } }); + comments.push({ id, companyId: company.id, issueId: issue.id, authorType: "agent", authorAgentId: agent.id, + createdByRunId: runId, body: `## Saved investigation ${index}\n\n${"Verified the saved evidence and checked the result. ".repeat(60)}`, createdAt, updatedAt: createdAt }); + activity.push({ companyId: company.id, actorType: "agent", actorId: agent.id, agentId: agent.id, runId, + entityType: "issue", entityId: issue.id, action: "issue.comment_added", details: { commentId: id }, createdAt }); + } + const last = comments.at(-1)!, runId = runs.at(-1)!.id!; + try { + for (let offset = 0; offset < runs.length; offset += 100) await db.insert(heartbeatRuns).values(runs.slice(offset, offset + 100)); + for (let offset = 0; offset < comments.length; offset += 100) await db.insert(issueComments).values(comments.slice(offset, offset + 100)); + for (let offset = 0; offset < activity.length; offset += 100) await db.insert(activityLog).values(activity.slice(offset, offset + 100)); + await db.insert(heartbeatRunEvents).values({ companyId: company.id, agentId: agent.id, runId, seq: 1, eventType: "item.completed", createdAt: last.createdAt, + payload: { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, eventType: "item.completed", runId, + emittedAt: last.createdAt!.toISOString(), itemId: `answer-${runId}`, payload: { kind: "agentMessage", text: last.body } } } }); + } finally { await closeRegisteredClients(databaseUrl); } + fixture = { prefix: company.issuePrefix, identifier: issue.identifier, runId, lastCommentId: last.id! }; +}); + +for (const coordinator of [false, true]) for (const mobile of [false, true]) test(`saved task replies reveal before historical output and keep their geometry (Coordinator=${coordinator}, mobile=${mobile})`, async ({ page }, info) => { + await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 900 }); + await page.route("**/api/workspace-preferences/me", async route => { + const response = await route.fetch(); + await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: coordinator } }); + }); + await page.addInitScript(() => { + const frames: { height: number; latest: number }[] = []; + Object.assign(window, { savedReplyFrames: frames }); + const sample = () => { + const thread = document.querySelector('[data-testid="task-chat-thread"] > [aria-busy="false"]'); + const scroller = thread?.querySelector('[data-testid="task-chat-scroller"]'); + if (scroller) frames.push({ height: scroller.scrollHeight, latest: [...scroller.querySelectorAll('[id^="comment-"]')].at(-1)?.getBoundingClientRect().top ?? 0 }); + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }); + const reads: string[] = []; + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + await page.route(`**/api/heartbeat-runs/*/{events,log}?*`, async route => { + reads.push(route.request().url()); + await held; + await route.continue(); + }); + try { + const started = performance.now(); + await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`); + await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 8_000 }); + const readyMs = performance.now() - started; + const reply = page.locator(`[id="comment-${fixture.lastCommentId}"]`); + await expect(reply).toContainText("Saved investigation 199"); + await expect(page.getByTestId("task-chat-plan-preview")).toHaveCount(1); + await expect(page.getByTestId("task-chat-plan-preview")).toContainText("Saved investigation plan"); + expect(reads).toEqual([]); + await page.waitForTimeout(600); + const frames = await page.evaluate(() => (window as unknown as { savedReplyFrames: { height: number; latest: number }[] }).savedReplyFrames); + expect(frames.length).toBeGreaterThan(5); + for (const dimension of ["height", "latest"] as const) { + const values = frames.map(frame => frame[dimension]); + expect(Math.max(...values) - Math.min(...values)).toBeLessThanOrEqual(2); + } + const editor = page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]'); + await editor.fill("Keep this draft while historical output is unavailable."); + const originalEditor = await editor.elementHandle(); + const before = await reply.boundingBox(); + const height = () => page.getByTestId("task-chat-scroller").evaluate(element => element.scrollHeight); + const beforeHeight = await height(); + await page.waitForTimeout(600); + expect(await height()).toBe(beforeHeight); + expect(Math.abs((await reply.boundingBox())!.y - before!.y)).toBeLessThanOrEqual(2); + await page.getByRole("button", { name: "More messages", exact: true }).evaluate(element => (element as HTMLButtonElement).click()); + await expect.poll(() => reads.some(url => url.includes(fixture.runId))).toBe(true); + await expect(editor).toHaveText("Keep this draft while historical output is unavailable."); + expect(await originalEditor!.evaluate(element => element.isConnected)).toBe(true); + release(); + await expect(page.getByRole("heading", { name: "Saved investigation 195", exact: true })).toBeAttached(); + await expect(editor).toHaveText("Keep this draft while historical output is unavailable."); + await fs.writeFile(info.outputPath("readiness.json"), JSON.stringify({ readyMs, reads, frames }, null, 2)); + } finally { release(); } +}); diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index c834c8df0a1..e298551b3cf 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -21,6 +21,7 @@ import { heartbeatsApi } from "@/api/heartbeats"; import { nativeRunEventsToTranscript } from "./transcript/native-run-events"; import { pendingConnectionIntentInteraction } from "@/fixtures/issueThreadInteractionFixtures"; import { TaskChatScrollNavigation, useTaskChatScrollNavigation } from "./task-chat/scroll-navigation"; +import { CoordinatorPresentation } from "./coordinator/CoordinatorPresentation"; import type { HeartbeatRunEvent } from "@paperclipai/shared"; const transcriptState = vi.hoisted(() => ({ @@ -400,6 +401,56 @@ it("keeps revealed history scrollable while metadata for another page is pending expect(container.querySelector('[data-testid="history-scroll-ready"]')?.textContent).toBe("true"); }); +it("waits for a same-task comment target before applying its scroll position without hiding the thread", async () => { + function Readiness() { return {String(useTaskChatScrollNavigation().ready)}; } + const props = { issueId: "linked-task", compactHistory: true, comments: createLongThreadComments(), onAdd: async () => {}, threadHeader: }; + const show = (hash: string, pending: boolean) => render( + + + , + ); + show("", false); + await settleThreadLayout(); + const editor = container.querySelector('[data-testid="mock-editor"]'); + show("#comment-older", true); + expect(container.querySelector('[data-testid="target-scroll-ready"]')?.textContent).toBe("false"); + expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull(); + expect(container.querySelector('[data-testid="mock-editor"]')).toBe(editor); + show("#comment-older", false); + expect(container.querySelector('[data-testid="target-scroll-ready"]')?.textContent).toBe("true"); +}); + +it("positions an available comment after the reveal deadline even if unrelated metadata is stalled", async () => { + vi.useFakeTimers(); + try { + function Readiness() { return {String(useTaskChatScrollNavigation().ready)}; } + render( + {}} initialHistoryPending threadHeader={} /> + ); + await act(async () => { await vi.advanceTimersByTimeAsync(15_000); }); + expect(container.querySelector('[data-testid="target-scroll-ready"]')?.textContent).toBe("true"); + expect(container.querySelector('[data-testid="mock-editor"]')).not.toBeNull(); + } finally { vi.useRealTimers(); } +}); + +it("bounds the scroll wait for a same-task target that never arrives", async () => { + const props = { issueId: "stalled-target", comments: createLongThreadComments(), onAdd: async () => {} }; + function Readiness() { return {String(useTaskChatScrollNavigation().ready)}; } + const show = (hash: string, pending: boolean) => render( + } /> + ); + show("", false); + await settleThreadLayout(); + vi.useFakeTimers(); + try { + show("#comment-unavailable", true); + expect(container.querySelector('[data-testid="target-scroll-ready"]')?.textContent).toBe("false"); + await act(async () => { await vi.advanceTimersByTimeAsync(15_000); }); + expect(container.querySelector('[data-testid="target-scroll-ready"]')?.textContent).toBe("true"); + expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull(); + } finally { vi.useRealTimers(); } +}); + it.each([0, 32_000, 100_000])("offers earlier output only when a single legacy run exceeds the compact window (%s bytes)", (logBytes) => { const onExpandHistory = vi.fn(); if (logBytes > 32_000) transcriptState.truncatedRunIds.add("legacy-run"); @@ -703,6 +754,57 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti nativeTranscriptState.hydratedRunIds = new Set(); }); + it("opens saved compact replies without fetching their completed run and loads output on expansion", async () => { + const props = { + issueId: "saved-compact-task", compactHistory: true, onAdd: async () => {}, onExpandHistory: vi.fn(), + linkedRuns: [{ ...retryRun, runId: "saved-run", status: "succeeded", startedAt: retryRun.createdAt }], + comments: [{ ...createLongThreadComments()[3], id: "saved-answer", authorType: "agent" as const, + authorAgentId: "agent-1", body: "Saved investigation result", runId: "saved-run" }], + }; + render(); + expect(transcriptHookRuns.native.at(-1)).toEqual([]); + expect(transcriptHookRuns.legacy.at(-1)).toEqual([]); + render(); + await settleThreadLayout(); + expect(container.textContent).toContain("Saved investigation result"); + expect(transcriptHookRuns.native.at(-1)).toEqual([]); + expect(transcriptHookRuns.legacy.at(-1)).toEqual([]); + const editor = container.querySelector('[data-testid="mock-editor"]'); + act(() => editorCallbacks.change("Keep this draft")); + const output = [...container.querySelectorAll("button")].find(button => button.textContent === "More run output"); + expect(output).toBeDefined(); + act(() => output!.click()); + expect(props.onExpandHistory).toHaveBeenCalledOnce(); + render(); + expect((runtimeMode === "native" ? transcriptHookRuns.native : transcriptHookRuns.legacy).at(-1)).toMatchObject([{ id: "saved-run" }]); + expect(container.querySelector('[data-testid="mock-editor"]')).toBe(editor); + expect(editor?.textContent).toBe("Keep this draft"); + }); + + it.each(["running", "failed", "no-reply", "placeholder", "chat", "yielded", "no-presentation", "progress"])("hydrates compact output when it is required (%s)", (scenario) => { + const props = { + issueId: "required-output", compactHistory: true, onAdd: async () => {}, onExpandHistory: vi.fn(), + conversationMode: scenario === "chat", + linkedRuns: [{ ...retryRun, runId: "saved-run", status: ["running", "failed"].includes(scenario) ? scenario : "succeeded", startedAt: retryRun.createdAt, + resultJson: scenario === "yielded" ? { nativeResult: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded" } } + : scenario === "no-presentation" ? { presentationDecision: { schema: "paperclip.run_presentation_decision.v1", commentId: null } } + : scenario === "progress" ? { semanticToolReceipts: { progress: { operationId: "report_progress", result: { commentId: "saved-answer" } } } } : {} }], + comments: scenario === "no-reply" ? [] : [{ ...createLongThreadComments()[3], id: "saved-answer", authorType: "agent" as const, + authorAgentId: "agent-1", body: scenario === "placeholder" ? "Run completed. Agent did not post a summary comment this run (transcript withheld — see run log)." : "Saved investigation result", runId: "saved-run" }], + }; + render(); + expect((runtimeMode === "native" ? transcriptHookRuns.native : transcriptHookRuns.legacy).at(-1)).toMatchObject([{ id: "saved-run" }]); + }); + + it("retains a live transcript after the saved reply arrives", () => { + const props = { issueId: "live-to-saved", compactHistory: true, onAdd: async () => {}, onExpandHistory: vi.fn(), + linkedRuns: [{ ...retryRun, runId: "live-run", status: "running", startedAt: retryRun.createdAt }], comments: createLongThreadComments() }; + render(); + render(); + expect((runtimeMode === "native" ? transcriptHookRuns.native : transcriptHookRuns.legacy).at(-1)).toMatchObject([{ id: "live-run" }]); + }); + it("reveals comments while a scheduled retry has no transcript to hydrate", async () => { renderThread({ issueId: "issue-1", @@ -1555,6 +1657,33 @@ describe("TaskChatThread runtime transcript selection", () => { expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull(); }); + it.each(["timestamp", "resolved-review"].flatMap(owner => [false, true].map(coordinator => ({ owner, coordinator }))))("keeps a saved Plan visible while its completed output is deferred ($owner, Coordinator=$coordinator)", async ({ owner, coordinator }) => { + const runId = "saved-plan-run"; + planState.data = planDocument({ updatedAt: new Date("2026-08-25T18:00:02.000Z") }); + nativeTranscriptState.hydratedRunIds = new Set(); + nativeTranscriptState.terminalHydratedRunIds = new Set(); + const props = { + issueId: "issue-1", compactHistory: true, onAdd: async () => {}, onExpandHistory: vi.fn(), + comments: [{ ...createLongThreadComments()[3], id: "saved-plan-reply", authorType: "agent" as const, + authorAgentId: "agent-1", body: "The saved Plan is ready.", runId }], + interactions: owner === "resolved-review" ? [planReviewInteraction("accepted", "revision-3", runId)] : [], + linkedRuns: [{ runId, runtimeMode: "native" as const, status: "succeeded", agentId: "agent-1", adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z", finishedAt: "2026-08-25T18:00:03.000Z" }], + }; + const show = (compactHistory: boolean) => render( + + ); + show(true); + await settleThreadLayout(); + expect(transcriptHookRuns.native.at(-1)).toEqual([]); + expect(container.querySelectorAll('[data-testid="task-chat-plan-preview"]')).toHaveLength(1); + expect(container.textContent).toContain("Preview the Plan"); + const editor = container.querySelector('[data-testid="mock-editor"]'); + show(false); + expect(container.querySelectorAll('[data-testid="task-chat-plan-preview"]')).toHaveLength(1); + expect(container.querySelector('[data-testid="mock-editor"]')).toBe(editor); + }); + it("projects the saved Plan inline at its native write_document boundary", () => { planState.data = planDocument({ updatedAt: new Date("2026-08-25T18:00:02.000Z"), diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index e89adcb5a54..42738c1cd56 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -20,7 +20,7 @@ import { CoordinatorChatReceiptStatus, useCoordinatorChatDecisionReceipts } from import { CoordinatorChatQuestionStatus, useCoordinatorChatPendingQuestions } from "@/components/coordinator/CoordinatorChatPendingQuestions"; import { requiresExecutionReconciliation } from "@paperclipai/shared"; import { TaskChatExpansionState } from "@/components/task-chat/expansion-state"; -import { TaskChatScrollReady, useTaskChatScrollNavigation } from "@/components/task-chat/scroll-navigation"; +import { TaskChatScrollReady, taskChatHashAnchor, useTaskChatScrollNavigation } from "@/components/task-chat/scroll-navigation"; import { useCallback, useEffect, @@ -971,12 +971,45 @@ export function TaskChatThread(props: TaskChatThreadProps) { }, [linkedRuns, liveRuns, activeRun]); const openedAt = useMemo(() => Date.now(), [issueId]); + const scrollNavigation = useTaskChatScrollNavigation(); + const requestedTranscripts = useRef({ issueId, ids: new Set() }); + if (requestedTranscripts.current.issueId !== issueId) { + requestedTranscripts.current = { issueId, ids: new Set() }; + } + const deferredTranscriptRunIds = useMemo(() => { + const deferred = new Set(); + if (!props.compactHistory || conversationMode || !props.onExpandHistory || scrollNavigation.hash) return deferred; + for (const run of runs) { + if (requestedTranscripts.current.ids.has(run.id) || run.status !== "succeeded" || + !run.createdAt || toMs(run.createdAt) > openedAt) continue; + const meta = linkedRunMetaById.get(run.id); + if (acceptedSemanticResultDisposition(meta?.resultJson) === "yielded" || + interactions?.some(interaction => interaction.sourceRunId === run.id && interaction.status === "pending")) continue; + const presentationCommentId = presentationDecisionCommentId(meta?.resultJson); + const progressCommentIds = semanticProgressCommentIds(meta?.resultJson); + if (projectedComments.some(comment => + comment.authorType === "agent" && !comment.deletedAt && comment.body.trim() && + comment.body !== LEGACY_WITHHELD_RUN_COMMENT && !progressCommentIds.has(comment.id) && + (comment.runId ?? comment.createdByRunId ?? comment.derivedCreatedByRunId) === run.id && + (presentationCommentId === undefined || presentationCommentId === comment.id), + )) deferred.add(run.id); + } + return deferred; + }, [issueId, runs, projectedComments, linkedRunMetaById, interactions, props.compactHistory, conversationMode, props.onExpandHistory, scrollNavigation.hash, openedAt]); + const transcriptRuns = useMemo(() => runs.filter(run => + !deferredTranscriptRunIds.has(run.id) && + !(props.compactHistory && !conversationMode && props.onExpandHistory && !scrollNavigation.hash && + initialHistoryPending && isTerminalRunStatus(run.status) && !requestedTranscripts.current.ids.has(run.id)), + ), [issueId, runs, deferredTranscriptRunIds, props.compactHistory, conversationMode, props.onExpandHistory, scrollNavigation.hash, initialHistoryPending]); + useLayoutEffect(() => { + for (const run of transcriptRuns) requestedTranscripts.current.ids.add(run.id); + }, [transcriptRuns]); const nativeRuns = useMemo( - () => runs.filter((run) => run.runtimeMode === "native").map((run) => ({ + () => transcriptRuns.filter((run) => run.runtimeMode === "native").map((run) => ({ ...run, conversationOnly: Boolean(props.compactHistory && (!run.createdAt || new Date(run.createdAt).getTime() <= openedAt)), })), - [runs, props.compactHistory, openedAt], + [transcriptRuns, props.compactHistory, openedAt], ); const { transcriptByRun: nativeTranscriptByRun, @@ -986,7 +1019,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { terminalHydratedRunIds, retry: retryNativeEvents, } = useNativeRunTranscripts(nativeRuns, { companyId }); - const logRuns = useMemo(() => runs.filter((run) => + const logRuns = useMemo(() => transcriptRuns.filter((run) => (!nativeRuns.some((nativeRun) => nativeRun.id === run.id && nativeRun.conversationOnly) || nativeTranscriptErrorsByRun.has(run.id) || (hydratedNativeRunIds.has(run.id) && (nativeTranscriptByRun.get(run.id)?.length ?? 0) === 0)) && ( @@ -997,7 +1030,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { nativeTranscriptErrorsByRun.has(run.id) || (hydratedNativeRunIds?.has(run.id) && (nativeTranscriptByRun.get(run.id)?.length ?? 0) === 0) - )), [runs, nativeRuns, nativeTranscriptErrorsByRun, hydratedNativeRunIds, nativeTranscriptByRun]); + )), [transcriptRuns, nativeRuns, nativeTranscriptErrorsByRun, hydratedNativeRunIds, nativeTranscriptByRun]); const { transcriptByRun: logTranscriptByRun, isInitialHydrating: logsAreInitiallyHydrating, @@ -1168,7 +1201,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { // for stable embedding, so their Plans remain chronological thread entries. // Prefer a native review request's explicit run binding; while that request // is still being created, infer the native owner from the document timestamp. - const planDocumentSourceRunId = useMemo(() => { + const planDocumentOwnerRunId = useMemo(() => { // Coordinator keeps saved plans in document chronology. A run may write // several documents (or use shell tools with no semantic write boundary); // attaching the plan to its last write can put it after the resulting work. @@ -1231,6 +1264,12 @@ export function TaskChatThread(props: TaskChatThreadProps) { return matchingRuns[0]?.id ?? null; }, [activeRun, coordinatorEnabled, interactions, linkedRuns, liveRuns, planDocument]); + const planDocumentSourceRunId = planDocumentOwnerRunId && + !deferredTranscriptRunIds.has(planDocumentOwnerRunId) && + ((transcriptByRun.get(planDocumentOwnerRunId)?.length ?? 0) > 0 || + !runs.some(run => run.id === planDocumentOwnerRunId && isTerminalRunStatus(run.status))) + ? planDocumentOwnerRunId : null; + const planTurnItem = useMemo(() => { if (!planDocument || !planDocumentSourceRunId) return null; return { @@ -2913,7 +2952,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { const historyControl = props.historyControl ?? ( props.compactHistory && props.onExpandHistory - && logRuns.some((run) => truncatedRunIds?.has(run.id)) + && (deferredTranscriptRunIds.size > 0 || logRuns.some((run) => truncatedRunIds?.has(run.id))) ? : null ); @@ -3039,7 +3078,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { comment.derivedCreatedByRunId, ]), ); - const initialRuns = runs.filter((run) => { + const initialRuns = transcriptRuns.filter((run) => { if ( !initialCommentWindow.current || !isTerminalRunStatus(run.status) || @@ -3082,7 +3121,6 @@ export function TaskChatThread(props: TaskChatThreadProps) { (taskHandoffs.enabled && taskHandoffs.isPending) )); const historyPending = initialHistoryPending || planLoading || transcriptHistoryPending || chatMetadataPending; - const scrollNavigation = useTaskChatScrollNavigation(); useEffect(() => { recordIssueDetailReadiness({ plan: !planLoading, transcripts: !transcriptHistoryPending, history: !historyPending }); }, [issueId, planLoading, transcriptHistoryPending, historyPending]); @@ -3101,6 +3139,17 @@ export function TaskChatThread(props: TaskChatThreadProps) { const historyShellRef = useRef(null); const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ historyKey: typeof historyKey } | null>(null); const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.historyKey === historyKey; + const scrollTarget = taskChatHashAnchor(scrollNavigation.hash ?? ""); + const scrollCommentId = scrollTarget?.startsWith("comment-") ? scrollTarget.slice("comment-".length) : null; + const scrollWaitKey = scrollCommentId ? JSON.stringify([issueId, scrollNavigation.key, scrollNavigation.hash]) : ""; + const [expiredScrollWaitKey, setExpiredScrollWaitKey] = useState(null); + const scrollTargetPending = Boolean(scrollCommentId && initialHistoryPending && + !comments.some(comment => comment.id === scrollCommentId) && expiredScrollWaitKey !== scrollWaitKey); + useEffect(() => { + if (!scrollTargetPending) return; + const timer = window.setTimeout(() => setExpiredScrollWaitKey(scrollWaitKey), INITIAL_HISTORY_REVEAL_TIMEOUT_MS); + return () => window.clearTimeout(timer); + }, [scrollTargetPending, scrollWaitKey]); 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 @@ -3146,7 +3195,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { return ( - + diff --git a/ui/src/components/task-chat/TaskMessageScroller.test.tsx b/ui/src/components/task-chat/TaskMessageScroller.test.tsx index fbc0ce1d9f2..24bd3e99097 100644 --- a/ui/src/components/task-chat/TaskMessageScroller.test.tsx +++ b/ui/src/components/task-chat/TaskMessageScroller.test.tsx @@ -186,6 +186,38 @@ describe("TaskMessageScroller", () => { expect(scroller().scrollTop).toBe(1000); }); + it("retains the reading anchor when a resize scroll event precedes the next width reflow", async () => { + const resize: (() => void)[] = []; + vi.stubGlobal("ResizeObserver", class { + constructor(callback: ResizeObserverCallback) { resize.push(() => callback([], this as unknown as ResizeObserver)); } + observe() {} + disconnect() {} + }); + let width = 400, rowTop = 100; + flushSync(() => root.render( +
{ + if (!node) return; + const viewport = node.parentElement!; + fakeGeometry(viewport, { clamp: true }); + Object.defineProperty(viewport, "clientWidth", { get: () => width, configurable: true }); + viewport.getBoundingClientRect = () => ({ top: 0, bottom: 400 } as DOMRect); + }}> +
{ + if (node) node.getBoundingClientRect = () => ({ top: rowTop - scroller().scrollTop, bottom: rowTop + 100 - scroller().scrollTop, height: 100 } as DOMRect); + }}>Saved reply
+
+
)); + await scrollTo(scroller(), 150); + width = 500; rowTop = 80; + for (const callback of resize) callback(); + expect(scroller().scrollTop).toBe(130); + width = 600; rowTop = 60; + await dispatch(scroller(), new Event("scroll", { bubbles: true })); + for (const callback of resize) callback(); + expect(rowTop - scroller().scrollTop).toBe(-50); + expect(pill()).not.toBeNull(); + }); + it("retains upward intent when content reconciles before the scroll event", async () => { const load = vi.fn(); const update = (tick: number) => flushSync(() => root.render( @@ -300,6 +332,31 @@ describe("TaskMessageScroller", () => { expect(viewport.scrollTop).toBe(500); }); + it.each([[false, "none"], [false, "wheel"], [false, "pointer"], [true, "none"], [true, "wheel"], [true, "pointer"]] as const)("positions a late hash target unless the reader moved on (restore=%s, readerMoved=%s)", async (restore, readerMoved) => { + let initialized = false; + const show = (targetReady: boolean) => flushSync(() => root.render( + + +
{ + if (node && !initialized) { fakeGeometry(node.parentElement!, { clamp: true }); initialized = true; } + }}> + Saved conversation + {targetReady ?
{ + if (node) node.getBoundingClientRect = () => ({ top: 100 - scroller().scrollTop, bottom: 200 - scroller().scrollTop, height: 100 } as DOMRect); + }}>Linked comment
: null} +
+
+
, + )); + show(false); + if (readerMoved !== "none") { + await scrollTo(scroller(), 300); + await dispatch(scroller(), readerMoved === "wheel" ? new WheelEvent("wheel", { bubbles: true, deltaY: -100 }) : new Event("pointerdown", { bubbles: true })); + } + show(true); + expect(scroller().scrollTop).toBe(readerMoved === "none" ? 100 : 300); + }); + it("does not replace a saved reading position with a hidden route's geometry", async () => { function mount(restore: boolean) { flushSync(() => root.render( diff --git a/ui/src/components/task-chat/TaskMessageScroller.tsx b/ui/src/components/task-chat/TaskMessageScroller.tsx index a0f7c9e6db2..dba3d3863fa 100644 --- a/ui/src/components/task-chat/TaskMessageScroller.tsx +++ b/ui/src/components/task-chat/TaskMessageScroller.tsx @@ -4,7 +4,7 @@ import { cn } from "@/lib/utils"; import { useStreamlinedTaskChatPresentation } from "./presentation-mode"; import { ArrowDown } from "lucide-react"; import { parseCssTimeMs } from "./motion-tokens"; -import { useTaskChatScrollNavigation } from "./scroll-navigation"; +import { taskChatHashAnchor, useTaskChatScrollNavigation } from "./scroll-navigation"; const PIN_THRESHOLD_PX = 48; @@ -57,11 +57,13 @@ export function TaskMessageScroller({ children, contentKey, className, scrolling const navigation = useTaskChatScrollNavigation(); const initialPositionApplied = useRef(false); const appliedNavigation = useRef({ key: navigation.key, hash: navigation.hash }); + const pendingHashTarget = useRef(taskChatHashAnchor(navigation.hash ?? "")); const ref = useRef(null); const anchorRef = useRef(null); const pinnedRef = useRef(true); const easingRef = useRef(false); const clientHeightRef = useRef(null); + const clientWidthRef = useRef(null); const scrollbarIdleTimerRef = useRef(null); const scrollbarIdleDelayRef = useRef(null); const [pillPhase, setPillPhase] = useState("hidden"); @@ -130,8 +132,15 @@ export function TaskMessageScroller({ children, contentKey, className, scrolling const el = ref.current; if (!el) return false; const previousClientHeight = clientHeightRef.current; + const previousClientWidth = clientWidthRef.current; const nextClientHeight = el.clientHeight; clientHeightRef.current = nextClientHeight; + clientWidthRef.current = el.clientWidth; + if (previousClientWidth != null && previousClientWidth !== el.clientWidth && !pinnedRef.current) { + const delta = threadScrollAnchorDelta(el, anchorRef.current, el.getBoundingClientRect().top); + if (delta) el.scrollTop += delta; + return true; + } if ( previousClientHeight == null || previousClientHeight <= 0 || @@ -192,7 +201,7 @@ export function TaskMessageScroller({ children, contentKey, className, scrolling // scroll away from the bottom. if (followViewportResize()) { rememberAnchor(false); - hidePill(); + if (pinnedRef.current) hidePill(); return; } const pinned = requestingHistory.current ? false : isPinned(); @@ -222,6 +231,7 @@ export function TaskMessageScroller({ children, contentKey, className, scrolling ]); const handleJumpToLatest = useCallback(() => { + pendingHashTarget.current = null; const el = ref.current; if (!el) return; if (isPinned()) { @@ -271,9 +281,10 @@ export function TaskMessageScroller({ children, contentKey, className, scrolling const el = ref.current; if (!el) return; clientHeightRef.current = el.clientHeight; + clientWidthRef.current = el.clientWidth; if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { - if (followViewportResize()) hidePill(); + if (followViewportResize() && pinnedRef.current) hidePill(); reconcileContent(); }); observer.observe(el); @@ -289,13 +300,17 @@ export function TaskMessageScroller({ children, contentKey, className, scrolling if (appliedNavigation.current.key !== navigation.key || appliedNavigation.current.hash !== navigation.hash) { appliedNavigation.current = { key: navigation.key, hash: navigation.hash }; initialPositionApplied.current = false; + pendingHashTarget.current = taskChatHashAnchor(navigation.hash ?? ""); } - if (el && el.clientHeight > 0 && navigation.ready && !initialPositionApplied.current) { - const top = navigation.initialPosition(el, el.getBoundingClientRect().top, el.scrollTop); + const target = pendingHashTarget.current ? document.getElementById(pendingHashTarget.current) : null; + if (el && el.clientHeight > 0 && navigation.ready && + (!initialPositionApplied.current || (target && el.contains(target)))) { + const top = navigation.initialPosition(el, el.getBoundingClientRect().top, el.scrollTop, { restore: !initialPositionApplied.current }); if (top !== null) { el.scrollTop = top; pinnedRef.current = isPinned(); rememberAnchor(); + pendingHashTarget.current = null; } initialPositionApplied.current = true; } @@ -307,13 +322,15 @@ export function TaskMessageScroller({ children, contentKey, className, scrolling
{ pendingHashTarget.current = null; }} tabIndex={scrollingHistory ? 0 : undefined} role={scrollingHistory ? "region" : undefined} aria-label={scrollingHistory ? "Conversation messages" : undefined} onKeyDown={event => { + if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"].includes(event.key)) pendingHashTarget.current = null; if (["ArrowUp", "PageUp", "Home"].includes(event.key) && ref.current && ref.current.scrollTop <= 192) requestEarlierHistory(); }} - onTouchStart={event => { touchStartY.current = event.touches.length === 1 ? event.touches[0]!.clientY : null; }} + onTouchStart={event => { pendingHashTarget.current = null; touchStartY.current = event.touches.length === 1 ? event.touches[0]!.clientY : null; }} onTouchMove={event => { const start = touchStartY.current; if (start !== null && event.touches.length === 1 && event.touches[0]!.clientY > start + 16 @@ -324,6 +341,7 @@ export function TaskMessageScroller({ children, contentKey, className, scrolling }} onTouchEnd={() => { touchStartY.current = null; }} onWheel={event => { + pendingHashTarget.current = null; upwardIntent.current = event.deltaY < 0; if (event.deltaY < 0 && ref.current && ref.current.scrollTop <= 192) requestEarlierHistory(); }} diff --git a/ui/src/components/task-chat/scroll-navigation.tsx b/ui/src/components/task-chat/scroll-navigation.tsx index b87c5f51289..f2fd7df25c3 100644 --- a/ui/src/components/task-chat/scroll-navigation.tsx +++ b/ui/src/components/task-chat/scroll-navigation.tsx @@ -40,9 +40,9 @@ export function useTaskChatScrollNavigation() { key: navigation?.key, hash: navigation?.hash, ready, - initialPosition(root: Element, viewportTop: number, scrollTop: number): number | null { + initialPosition(root: Element, viewportTop: number, scrollTop: number, options?: { restore?: boolean }): number | null { if (!navigation) return null; - if (navigation.restore && positionKey && positions.has(positionKey)) { + if (options?.restore !== false && navigation.restore && positionKey && positions.has(positionKey)) { const saved = positions.get(positionKey)!; // A saved pixel offset goes stale when rotation changes text wrapping. // Bottom-follow needs no row measurements, even across navigation.