mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
perf(tasks): defer completed transcripts on compact opens (#18461)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task conversations must show the saved instruction and recent replies quickly. > - Compact task reads already limit the saved message window. > - The conversation still waits for completed run transcripts even when a saved reply contains the result. > - Removing that wait alone can insert activity after the reader starts reading. > - This pull request defers that output until the reader opens history and keeps required output in the initial reveal. > - The benefit is a useful conversation sooner, with a stable first layout and a working composer. ## Linked Issues or Issue Description Refs #17593, #18092, #18312, #18358, #18422. **What happened?** Opening a task with a long saved investigation can wait for native events or legacy logs before showing its existing replies. A slow transcript request can hold the conversation behind the loading screen until the 15-second fallback. **Expected behavior** Show the bounded saved conversation without reading completed output that already has a usable saved reply. Keep that output available through the existing history controls. **Steps to reproduce** 1. Create a task with many completed runs and long saved agent replies. 2. Delay its run events and logs. 3. Open the compact task directly. Before this change, the transcript read blocks the saved replies and composer. ## What Changed - Defer completed successful run transcripts on compact task opens when a usable saved reply is present. Load that output through the existing history controls. - Keep required output on the initial path: live and failed runs, yielded runs, pending interactions, placeholder/progress-only replies, explicit anchors, chats, and full history. Retain output already selected during this visit when its run completes. - Keep saved plans visible when their owning run output is deferred or still loading. - Keep the same composer and draft through history expansion. Offer **More run output** when a single saved run has deferred output. - Position same-task comment links after their target arrives. Wait only for a missing target, with a 15-second limit. Unrelated stalled metadata cannot block an available target after the reveal deadline. - Preserve the reading anchor when a queued scroll event arrives during a properties-pane width transition. - Add a 200-reply/200-run PostgreSQL browser fixture with a saved plan, covering both task views on desktop and mobile. Correct stale browser setup for current routes and the shared mobile scroller. - Remove a random company-prefix collision in the isolated privacy test fixture that failed during CI. ## Verification - Final revision `fba3f2fcbb`: 386 focused thread/scroller unit cases and all 32 production-browser journeys passed. The browser cases cover both task views on desktop/mobile, saved plans, delayed transcripts, history, comment links, Back, drafts, retry, avatar continuity, media and pane resizing. The 25 privacy fixture cases also passed against PostgreSQL. - Workspace typecheck and full build passed with Node 24. A broader local UI run passed 10,220 cases across 849 files before the final navigation/plan review fixes; the changed components were then rechecked in the 386-case focused run. - Five alternating pairs compared master `12b0cc6304` with this revision, using 200 long saved replies and 200 completed native runs. Both production UI builds used the same loopback API/database, Chromium 151.0.7922.34, 1440 × 900, fresh browser contexts, service workers blocked, no CPU throttle, and a controlled 3-second events/log delay. Builds and other suites were stopped during measurement. No page errors occurred. | Useful conversation + editable composer | Baseline median (range) | Fixed median (range) | | --- | --- | --- | | Cold direct open | 3.570s (3.538–3.616s) | 1.056s (1.022–1.141s) | | Task-list navigation | 3.373s (3.342–3.394s) | 0.899s (0.885–0.915s) | | Back | 22ms (22–25ms) | 25ms (25–28ms) | - Median paired savings: 2.519s cold, 2.470s from the list. Completed-output requests: two per baseline sample, zero per fixed sample. Recent-comments API control medians were 13.6ms for both, with ranges 12.1–18.7ms and 11.6–50.8ms. No Back improvement is claimed; an earlier revision's run also included one 1-second Back outlier, retained in the local evidence. - The full local default test command did **not** pass: it stopped on the existing chat test line-selection error. After restoring embedded PostgreSQL library symlinks omitted by an install with scripts disabled, Node 24 passed 672 cases in the first two chat shards, then failed to select the third. This is separate from the passing focused suites and remote CI. - Earlier CI failures were addressed: the privacy prefix collision is fixed; the delegated task-completion reply case passed locally after merging master's reply-recovery fix (#18465). Browser fixture corrections retain the original behavior assertions. Red tests reproduced the loading, navigation, resize and saved-plan regressions before their fixes. - Map, module-boundary, token-gate and added-line token checks passed. The full-tree token scan still finds pre-existing active-username fixtures; this PR adds zero forbidden token lines. - Greptile: **5/5**, final revision, all four review threads resolved. The final-revision PR CI workflow passed, including build, typecheck, all server/chat/workspace test shards, all eight E2E shards, and every Runner lane. GitHub reports 55 successful checks and three skipped checks. The separate **Paperclip Review** check remains queued with **Waiting for the assigned agent**; it has not produced an assessment and has not been bypassed. The branch has no merge conflicts. ## Risks - The default compact task omits completed transcript details when the saved result is sufficient. The reader must open history to inspect them. - Required structural data remains inside the initial reveal boundary. Slow server responses for that data remain visible latency. - Link positioning falls back after 15 seconds when its target never arrives. The conversation remains readable. - The measurements use a controlled local delay. They do not establish a deployed staging speedup. - No API, schema, authorization, or migration changes. ## Model Used OpenAI Codex, GPT-6, with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass (focused suites pass; the full default wrapper has the existing selection error documented above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (code CI passed; the separate assigned-agent Paperclip Review remains queued) - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
dce383c733
commit
91fdf02e95
11 files changed
+504
-56
No files matched your search
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@ import { canActorReadWorkspaceOperation } from "../services/heartbeat-run-privac
|
||||
describe("private task production review", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
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" })));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void>(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<void>(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<HTMLElement>("[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<HTMLElement>("[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<void>((resolve) => { release = resolve; });
|
||||
|
||||
@@ -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<void>(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(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>(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(); }
|
||||
});
|
||||
@@ -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 <span data-testid="target-scroll-ready">{String(useTaskChatScrollNavigation().ready)}</span>; }
|
||||
const props = { issueId: "linked-task", compactHistory: true, comments: createLongThreadComments(), onAdd: async () => {}, threadHeader: <Readiness /> };
|
||||
const show = (hash: string, pending: boolean) => render(
|
||||
<TaskChatScrollNavigation.Provider value={{ key: hash || "initial", hash, restore: false }}>
|
||||
<TaskChatThread {...props} initialHistoryPending={pending} />
|
||||
</TaskChatScrollNavigation.Provider>,
|
||||
);
|
||||
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 <span data-testid="target-scroll-ready">{String(useTaskChatScrollNavigation().ready)}</span>; }
|
||||
render(<TaskChatScrollNavigation.Provider value={{ key: "available-target", hash: "#comment-comment-1", restore: false }}>
|
||||
<TaskChatThread issueId="available-target" comments={createLongThreadComments()} onAdd={async () => {}} initialHistoryPending threadHeader={<Readiness />} />
|
||||
</TaskChatScrollNavigation.Provider>);
|
||||
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 <span data-testid="target-scroll-ready">{String(useTaskChatScrollNavigation().ready)}</span>; }
|
||||
const show = (hash: string, pending: boolean) => render(<TaskChatScrollNavigation.Provider value={{ key: hash || "initial", hash, restore: false }}>
|
||||
<TaskChatThread {...props} initialHistoryPending={pending} threadHeader={<Readiness />} />
|
||||
</TaskChatScrollNavigation.Provider>);
|
||||
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(<TaskChatThread {...props} initialHistoryPending />);
|
||||
expect(transcriptHookRuns.native.at(-1)).toEqual([]);
|
||||
expect(transcriptHookRuns.legacy.at(-1)).toEqual([]);
|
||||
render(<TaskChatThread {...props} initialHistoryPending={false} />);
|
||||
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(<TaskChatThread {...props} compactHistory={false} />);
|
||||
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(<TaskChatThread {...props} />);
|
||||
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(<TaskChatThread {...props} />);
|
||||
render(<TaskChatThread {...props} linkedRuns={[{ ...props.linkedRuns[0], status: "succeeded" }]}
|
||||
comments={[...props.comments, { ...props.comments[3], authorType: "agent", authorAgentId: "agent-1", runId: "live-run", body: "Just saved" }]} />);
|
||||
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(<CoordinatorPresentation.Provider value={coordinator}>
|
||||
<TaskChatThread {...props} compactHistory={compactHistory} />
|
||||
</CoordinatorPresentation.Provider>);
|
||||
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"),
|
||||
|
||||
@@ -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<string>() });
|
||||
if (requestedTranscripts.current.issueId !== issueId) {
|
||||
requestedTranscripts.current = { issueId, ids: new Set<string>() };
|
||||
}
|
||||
const deferredTranscriptRunIds = useMemo(() => {
|
||||
const deferred = new Set<string>();
|
||||
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<TaskChatPlanDocumentItem | null>(() => {
|
||||
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)))
|
||||
? <Button type="button" variant="ghost" size="sm" onClick={props.onExpandHistory}>More run output</Button>
|
||||
: 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<HTMLDivElement>(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<string | null>(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 (
|
||||
<TaskChatRequestHistoryContext.Provider value={issueId ?? "unknown"}>
|
||||
<TaskChatExpansionState.Provider value={expansionState.current}>
|
||||
<TaskChatScrollReady.Provider value={historyRevealed || !historyPending || historyWaitExpired}>
|
||||
<TaskChatScrollReady.Provider value={(historyRevealed || !historyPending || historyWaitExpired) && !scrollTargetPending}>
|
||||
<TaskChatPresentationProvider
|
||||
mode={streamlinedUiEnabled ? "streamlined" : "production"}
|
||||
>
|
||||
|
||||
@@ -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(<TaskMessageScroller contentKey="width-transition">
|
||||
<div ref={node => {
|
||||
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);
|
||||
}}>
|
||||
<div data-thread-anchor="reading-row" ref={node => {
|
||||
if (node) node.getBoundingClientRect = () => ({ top: rowTop - scroller().scrollTop, bottom: rowTop + 100 - scroller().scrollTop, height: 100 } as DOMRect);
|
||||
}}>Saved reply</div>
|
||||
</div>
|
||||
</TaskMessageScroller>));
|
||||
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(
|
||||
<TaskChatScrollNavigation.Provider value={{ key: `late-target-${restore}-${readerMoved}`, hash: "#late-target", restore }}>
|
||||
<TaskMessageScroller contentKey={targetReady}>
|
||||
<div ref={node => {
|
||||
if (node && !initialized) { fakeGeometry(node.parentElement!, { clamp: true }); initialized = true; }
|
||||
}}>
|
||||
Saved conversation
|
||||
{targetReady ? <div id="late-target" data-thread-anchor="late-target" ref={node => {
|
||||
if (node) node.getBoundingClientRect = () => ({ top: 100 - scroller().scrollTop, bottom: 200 - scroller().scrollTop, height: 100 } as DOMRect);
|
||||
}}>Linked comment</div> : null}
|
||||
</div>
|
||||
</TaskMessageScroller>
|
||||
</TaskChatScrollNavigation.Provider>,
|
||||
));
|
||||
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(
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const anchorRef = useRef<ThreadScrollAnchor | null>(null);
|
||||
const pinnedRef = useRef(true);
|
||||
const easingRef = useRef(false);
|
||||
const clientHeightRef = useRef<number | null>(null);
|
||||
const clientWidthRef = useRef<number | null>(null);
|
||||
const scrollbarIdleTimerRef = useRef<number | null>(null);
|
||||
const scrollbarIdleDelayRef = useRef<number | null>(null);
|
||||
const [pillPhase, setPillPhase] = useState<PillPhase>("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
|
||||
<div
|
||||
ref={ref}
|
||||
onScroll={handleScroll}
|
||||
onPointerDown={() => { 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();
|
||||
}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user