mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
Merge landed routines and current master into merge confirmation work
Preserve all 15 qualified confirmation files and exactly the 23 incoming master UI, documentation, and test paths. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
bc22051c56
23 files changed
+698
-75
No files matched your search
+3
-1
@@ -71,7 +71,9 @@ tests do not establish a completed live pass or unattended CI qualification.
|
||||
The deployment-targeted `question-answer-resume` journey reuses the classic
|
||||
two-question scenario and native continuation oracle on an attached target.
|
||||
It checks the actual board answers, one to three bound work runs, an agent-authored
|
||||
task document and browser readback after reload. It accepts provider questions and
|
||||
task document and browser readback after reload. A typed reference may use a text
|
||||
field or the native card's explicit written-answer control; a predefined option
|
||||
cannot replace the exact typed answer. It accepts provider questions and
|
||||
durable questions proven by an applied semantic receipt or an immutable completed
|
||||
native API mutation receipt. It does not qualify named-tool selection. Its provider owns target setup,
|
||||
usage and cleanup. Registration and local calibration do not establish a fresh
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -40,7 +40,12 @@ never start a server or depend on a tenant filesystem.
|
||||
|
||||
The question-resumption journey uses the same natural-language scenario, browser
|
||||
answer helper and behavioral grader as the classic `question-resume` suite. Native
|
||||
provider questions can stay in one run; durable native questions create one exactly bound
|
||||
reference cards may expose a text field or an explicit canonical written-answer
|
||||
control on one single-select question. The browser must type the complete reference
|
||||
and submit no predefined option. An absent or disabled custom-answer capability is
|
||||
not an equivalent form. The user still answers exactly two distinct native questions;
|
||||
the document must retain the actual choice and typed reference after reload.
|
||||
Provider questions can stay in one run; durable native questions create one exactly bound
|
||||
continuation each. Creation must have an applied semantic receipt or a completed native
|
||||
API mutation receipt with the original card, exact scope, form and idempotency key.
|
||||
An API receipt must retain its creation identity unchanged after the answer. A form or
|
||||
|
||||
@@ -1771,7 +1771,13 @@ remain unchanged after the answer. Both durable paths require one new response
|
||||
wake bound to that interaction and source run. Missing, pending, foreign or
|
||||
mismatched receipts fail; form shape and run counts cannot replace them. Only a verified provider
|
||||
choice may carry the adapter's optional Other field. A second substantive question
|
||||
is still rejected; the subsequent reference question must be text-only. Both real
|
||||
is still rejected. The subsequent reference must use a text field or one native
|
||||
provider single-select question with an explicit canonical `customAnswer.enabled=true`.
|
||||
The browser selects its written-answer control and submits the exact typed reference
|
||||
with no predefined option. Missing or disabled custom answers, inferred legacy
|
||||
capabilities, extra questions and substituted choices fail. This behavioral form
|
||||
equivalence does not change the text-only necessary-question or named-tool
|
||||
documentation qualifications. Both real
|
||||
board answers must remain bound to unchanged forms, and one saved document must
|
||||
include both answers. Existing lifecycle, ownership, premature-output, terminal
|
||||
state and no-extra-task checks apply. Three checkpoints are required even when
|
||||
@@ -1785,6 +1791,12 @@ Other field stopped the driver before any answer remains a FAIL. A new behavior
|
||||
pass does not establish semantic-tool selection, documentation placement, default
|
||||
hiring behavior or general reliability.
|
||||
|
||||
The original scheduled attempt that stopped at a closed reference card remains a
|
||||
FAIL, including its separate cleanup failure. Supporting a server-bound written
|
||||
answer does not regrade that card, infer a missing provider flag, or establish a
|
||||
fresh Cloud pass. The general OpenCode omitted-default normalization fix requires
|
||||
its own reviewed runtime artifact; browser/grader calibration cannot prove it served.
|
||||
|
||||
Continuation screenshots wait for the correct task heading and fully revealed
|
||||
conversation before capture. A loading screen or wrong task fails capture.
|
||||
Browser-only regressions exercise delayed rendering without provider calls:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { gradeNecessaryQuestion } from "./necessary-question.js";
|
||||
import { necessaryQuestionContract } from "./continuation-cases.js";
|
||||
import { validResumeQuestionForm } from "./question-resume-scoring.js";
|
||||
import { validResumeQuestionForm, validResumeReferenceForm } from "./question-resume-scoring.js";
|
||||
import { answerResumeQuestionThroughUi } from "./question-resume-ui.js";
|
||||
import { gradeLifecycleBaseline, type LifecycleCheckpoint } from "./lifecycle-baseline.js";
|
||||
import { lifecycleLiveCase, lifecycleLiveContinuation, gradeLifecycleNarrative } from "./lifecycle-live-cases.js";
|
||||
@@ -220,12 +220,15 @@ export async function runContinuationFlow(input: {
|
||||
if (scenario.id === "necessary-free-text") {
|
||||
expect(validResumeQuestionForm(checkpoints[0]!, questions[0], false), "a bound text-only question").toBe(true);
|
||||
} else if (scenario.id === "question-answer-resume") {
|
||||
expect(validResumeQuestionForm(checkpoints.at(-1)!, questions[0], !!choice), "a verified native question path and the requested form").toBe(true);
|
||||
expect(choice ? validResumeQuestionForm(checkpoints.at(-1)!, questions[0], true)
|
||||
: validResumeReferenceForm(checkpoints.at(-1)!, questions[0]), "a verified native question path and the requested form").toBe(true);
|
||||
} else if (scenario.id === "provider-question-bridge") {
|
||||
expect(isSingleClaudeQuestion(set.questions), "one choice question with only the optional provider Other field").toBe(true);
|
||||
} else expect(set.questions, "ask only the requested next question").toHaveLength(1);
|
||||
const before = new Set(runs.map((r) => r.id));
|
||||
await answerResumeQuestionThroughUi(page, questions[0], choice ? { choice } : { text: scenario.answer });
|
||||
await answerResumeQuestionThroughUi(page, questions[0], choice ? { choice } : {
|
||||
text: scenario.answer, allowProviderCustomAnswer: scenario.id === "question-answer-resume",
|
||||
});
|
||||
await settle(before, false, questions[0].id);
|
||||
}
|
||||
async function reply(body: string) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineConfig } from "@playwright/test";
|
||||
/** Browser-only harness regressions: no Paperclip instance or provider credentials. */
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: ["clarification-readback.spec.ts", "project-context-readback.spec.ts", "skill-studio-flow.spec.ts", "native-task-reply.spec.ts", "screenshot-readiness.spec.ts", "service-worker-reload.spec.ts", "lost-send.spec.ts", "chat-restart.spec.ts", "settings-toggle.spec.ts", "browser-bootstrap-diagnostics.spec.ts", "service-worker-module-loading.spec.ts", "browser-bootstrap-recovery.spec.ts", "blocker-input.spec.ts", "native-blocker-visible.spec.ts", "copilot-permission-card.spec.ts", "kimi-permission-card.spec.ts", "github-review-check.spec.ts", "antigravity-journey-browser.spec.ts", "task-search-filter.spec.ts"],
|
||||
testMatch: ["question-resume-ui.spec.ts", "clarification-readback.spec.ts", "project-context-readback.spec.ts", "skill-studio-flow.spec.ts", "native-task-reply.spec.ts", "screenshot-readiness.spec.ts", "service-worker-reload.spec.ts", "lost-send.spec.ts", "chat-restart.spec.ts", "settings-toggle.spec.ts", "browser-bootstrap-diagnostics.spec.ts", "service-worker-module-loading.spec.ts", "browser-bootstrap-recovery.spec.ts", "blocker-input.spec.ts", "native-blocker-visible.spec.ts", "copilot-permission-card.spec.ts", "kimi-permission-card.spec.ts", "github-review-check.spec.ts", "antigravity-journey-browser.spec.ts", "task-search-filter.spec.ts"],
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 10_000,
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { JourneyContext } from "./types.js";
|
||||
type Row = Record<string, any>;
|
||||
const context = { company: { id: "company" }, user: { id: "board" }, environmentId: "daytona",
|
||||
agent: { id: "agent", provider: "opencode", model: "model", runtimeMode: "native" } } as JourneyContext;
|
||||
function recording(paths: Array<"provider" | "semantic" | "api">) {
|
||||
function recording(paths: Array<"provider" | "semantic" | "api">, writtenReference = false) {
|
||||
const runs: Row[] = [], cards: Row[] = [], checkpoints: any[] = [];
|
||||
const createRun = (previous?: Row) => ({ id: `run-${runs.length}`, status: "running", runtimeMode: "native", companyId: "company", issueId: "task", agentId: "agent",
|
||||
contextSnapshot: { paperclipEnvironment: { id: "daytona" }, ...(previous ? { issueId: "task", sourceRunId: previous.sourceRunId,
|
||||
@@ -41,7 +41,9 @@ function recording(paths: Array<"provider" | "semantic" | "api">) {
|
||||
runs.push(createRun());
|
||||
for (const [index, path] of paths.entries()) {
|
||||
const run = runs.at(-1)!;
|
||||
const field = index === 0 ? { id: "choice", answerMode: "single_select", options: [{ id: "am", label: "Morning" }, { id: "pm", label: "Afternoon" }] } : { id: "reference", answerMode: "text" };
|
||||
const field = index === 0 ? { id: "choice", answerMode: "single_select", options: [{ id: "am", label: "Morning" }, { id: "pm", label: "Afternoon" }] }
|
||||
: writtenReference ? { id: "reference", answerMode: "single_select", options: [{ id: "none", label: "No reference" }], customAnswer: { enabled: true } }
|
||||
: { id: "reference", answerMode: "text" };
|
||||
const request = `request-${index}`, semantic = path !== "provider";
|
||||
const card: Row = { id: `question-${index}`, kind: "ask_user_questions", status: "pending", companyId: "company", issueId: "task", createdByAgentId: "agent", sourceRunId: run.id,
|
||||
idempotencyKey: semantic ? `semantic-${index}` : `paperclip-runner-question:${run.id}:${request}`, continuationPolicy: semantic ? "wake_assignee" : "none", createdByUserId: null,
|
||||
@@ -65,6 +67,11 @@ function recording(paths: Array<"provider" | "semantic" | "api">) {
|
||||
const failures = (f: ReturnType<typeof recording>) => gradeClarificationEvidence(context, "task", "AMBERnonce", f.checkpoints, f.readback).filter(c => !c.passed);
|
||||
|
||||
describe("attached clarification evidence", () => {
|
||||
it("accepts an explicit provider written reference with both exact board answers and saved native work", () => {
|
||||
const f = recording(["provider", "provider"], true), point = f.checkpoints[1];
|
||||
expect(clarificationQuestionFailure(point, point.interactions[1], false)).toBeUndefined();
|
||||
expect(failures(f)).toEqual([]);
|
||||
});
|
||||
it.each([true, false])("rejects an incomplete API receipt instead of inferring native question evidence (choice=%s)", choice => {
|
||||
const f = recording(["semantic", "semantic"]), point = f.checkpoints[choice ? 0 : 1];
|
||||
const question = point.interactions.find((i: Row) => i.status === "pending"), source = point.runs.at(-1);
|
||||
@@ -87,8 +94,8 @@ describe("attached clarification evidence", () => {
|
||||
expect(failures(recording([...paths]))).toEqual([]);
|
||||
});
|
||||
it.each(["foreign-user", "foreign-agent-document", "foreign-task-document", "lost-revision", "wrong-visible-answer", "changed-readback-body", "premature-document", "extra-child", "foreign-model", "foreign-environment", "missing-native-input", "duplicate-question", "wrong-answer"])("rejects a plausible false pass: %s", kind => {
|
||||
for (const path of ["semantic", "api"] as const) {
|
||||
const f = recording([path, path]), first = f.checkpoints[0], final = f.checkpoints[2];
|
||||
for (const path of ["semantic", "api", "provider"] as const) {
|
||||
const f = recording([path, path], path === "provider"), first = f.checkpoints[0], final = f.checkpoints[2];
|
||||
if (kind === "foreign-user") final.interactions[1].resolvedByUserId = "other-board";
|
||||
if (kind === "foreign-agent-document") final.documents[0].updatedByAgentId = "other-agent";
|
||||
if (kind === "foreign-task-document") final.documents[0].issueId = "other-task";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { gradeQuestionResume, questionPath, validResumeQuestionForm } from "../question-resume-scoring.js";
|
||||
import { gradeQuestionResume, questionPath, validResumeQuestionForm, validResumeReferenceForm } from "../question-resume-scoring.js";
|
||||
import type { ContinuationCheckpoint } from "../continuation-scoring.js";
|
||||
import { validateAgentTaskState } from "./agent-flows.js";
|
||||
import type { JourneyContext } from "./types.js";
|
||||
@@ -19,7 +19,8 @@ export function clarificationQuestionFailure(point: ContinuationCheckpoint, ques
|
||||
});
|
||||
return apiCreated ? `clarification_${stage}_api_fallback` : `clarification_${stage}_native_path_invalid`;
|
||||
}
|
||||
return validResumeQuestionForm(point, question, choice) ? undefined : `clarification_${stage}_form_invalid`;
|
||||
return (choice ? validResumeQuestionForm(point, question, true) : validResumeReferenceForm(point, question))
|
||||
? undefined : `clarification_${stage}_form_invalid`;
|
||||
}
|
||||
|
||||
export function gradeClarificationEvidence(context: JourneyContext, issueId: string, marker: string,
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function runClarificationJourney(context: JourneyContext, session:
|
||||
await session.own({ kind: "issue", name: title, id: created.issueId, journeyId: "question-answer-resume" });
|
||||
issue = await context.api.get<Row>(`/api/issues/${created.issueId}`);
|
||||
let state = await settle();
|
||||
for (const [index, input] of [{ choice: "Afternoon" }, { text: scenario.answer }].entries()) {
|
||||
for (const [index, input] of [{ choice: "Afternoon" }, { text: scenario.answer, allowProviderCustomAnswer: true }].entries()) {
|
||||
const point = await snapshot(index === 0 ? "initial" : "answered", state);
|
||||
const question = state.interactions.find(i => i.kind === "ask_user_questions" && i.status === "pending")!;
|
||||
const questionFailure = clarificationQuestionFailure(point, question, index === 0);
|
||||
|
||||
@@ -67,6 +67,15 @@ export function validResumeQuestionForm(checkpoint: ContinuationCheckpoint, i: R
|
||||
(fields.length === 1 || (path === "provider" && isSingleClaudeQuestion(fields)));
|
||||
}
|
||||
|
||||
export function validResumeReferenceForm(checkpoint: ContinuationCheckpoint, i: Row): boolean {
|
||||
if (validResumeQuestionForm(checkpoint, i, false)) return true;
|
||||
const fields = resumeQuestionFields(i), field = fields[0], options: Row[] = Array.isArray(field?.options) ? field.options : [];
|
||||
return questionPath(checkpoint, i) === "provider" && fields.length === 1 && nonempty(field.id)
|
||||
&& field.answerMode === "single_select" && field.customAnswer?.enabled === true
|
||||
&& options.length > 0 && options.every(option => nonempty(option.id) && nonempty(option.label))
|
||||
&& new Set(options.map(option => option.id)).size === options.length;
|
||||
}
|
||||
|
||||
export function sameQuestion(a: Row, b?: Row) {
|
||||
return !!b && ["id", "kind", "sourceRunId", "issueId", "companyId", "createdByAgentId", "idempotencyKey", "continuationPolicy"]
|
||||
.every(k => a[k] === b[k]) && isDeepStrictEqual(a.payload, b.payload);
|
||||
@@ -110,8 +119,8 @@ export function gradeQuestionResume(checkpoints: ContinuationCheckpoint[], marke
|
||||
options.every(o => nonempty(o.id)) && new Set(options.map(o => o.id)).size === options.length &&
|
||||
options.some(o => /^morning\b/i.test(String(o.label).trim())) && !!afternoon &&
|
||||
questions(middle).length === 2 && questions(middle).filter(i => i.status === "pending").length === 1 &&
|
||||
nonempty(second?.id) && second?.id !== first?.id && validResumeQuestionForm(middle, second, false),
|
||||
detail: "One choice plus only a verified provider Other companion, then a distinct text-only question.",
|
||||
nonempty(second?.id) && second?.id !== first?.id && validResumeReferenceForm(middle, second),
|
||||
detail: "One choice plus only a verified provider Other companion, then a distinct typed reference with an explicit provider written-answer control or a text field.",
|
||||
}];
|
||||
for (const [before, after, i, field, answer] of [
|
||||
[initial, middle, first, choice, "choice"], [middle, final, second, text, "text"],
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import path from "node:path";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "../../ui/node_modules/vite/dist/node/index.js";
|
||||
import { answerResumeQuestionThroughUi } from "./question-resume-ui.js";
|
||||
import { chatQuestionPresentation } from "./chat-flow.js";
|
||||
|
||||
let server: ViteDevServer;
|
||||
let origin: string;
|
||||
const uiRoot = path.resolve(import.meta.dirname, "../../ui");
|
||||
const probe = path.join(uiRoot, "src/__question_resume_probe.tsx");
|
||||
|
||||
test.beforeAll(async () => {
|
||||
server = await createServer({
|
||||
root: uiRoot, configFile: false,
|
||||
resolve: { alias: { "@": path.join(uiRoot, "src") } },
|
||||
oxc: { jsx: { runtime: "automatic" } },
|
||||
server: { host: "127.0.0.1", port: 0 },
|
||||
plugins: [{
|
||||
name: "question-resume-controls",
|
||||
resolveId(id: string) { if (id === "/src/__question_resume_probe.tsx") return probe; },
|
||||
load(id: string) {
|
||||
if (id !== probe) return;
|
||||
return `import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QuestionForm } from "/src/components/task-chat/QuestionForm.tsx";
|
||||
const root = createRoot(document.getElementById("root"));
|
||||
const client = new QueryClient();
|
||||
window.showQuestion = questionSet => root.render(<QueryClientProvider client={client}>
|
||||
<QuestionForm id="reference" questionSet={questionSet} onSubmit={response => { window.submitted = response; }} />
|
||||
</QueryClientProvider>);`;
|
||||
},
|
||||
configureServer(vite: ViteDevServer) {
|
||||
vite.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
|
||||
if (req.url !== "/") return next();
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end('<div id="root"></div><script type="module" src="/src/__question_resume_probe.tsx"></script>');
|
||||
});
|
||||
},
|
||||
}],
|
||||
});
|
||||
await server.listen();
|
||||
const address = server.httpServer!.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing test server address");
|
||||
origin = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
test.afterAll(async () => { await server?.close(); });
|
||||
|
||||
for (const mode of ["text", "single_select"] as const) {
|
||||
test(`submits an exact typed reference through the real ${mode} QuestionForm control`, async ({ page }) => {
|
||||
const field = { id: "reference", prompt: "What reference should I include?", required: true, answerMode: mode,
|
||||
...(mode === "single_select" ? { options: [{ id: "none", label: "No reference" }], customAnswer: { enabled: true, label: "Write reference" } } : {}) };
|
||||
const card = { payload: { questionSet: { schema: "paperclip.question_set.v1", questions: [field] } } };
|
||||
await page.goto(origin);
|
||||
await page.waitForFunction(() => typeof (window as any).showQuestion === "function");
|
||||
await page.evaluate(set => (window as any).showQuestion(set), card.payload.questionSet);
|
||||
if (mode === "single_select") await page.getByRole("radio", { name: "No reference", exact: true }).click();
|
||||
await answerResumeQuestionThroughUi(page, card, { text: "Use AMBERwritten.", allowProviderCustomAnswer: true });
|
||||
expect(await page.evaluate(() => (window as any).submitted)).toEqual({
|
||||
schema: "paperclip.question_response.v1", answers: { reference: mode === "text"
|
||||
? { text: "Use AMBERwritten." } : { selectedOptionIds: [], customText: "Use AMBERwritten." } },
|
||||
});
|
||||
if (mode === "single_select") await expect(page.getByRole("radio", { name: "No reference", exact: true })).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
}
|
||||
|
||||
for (const invalid of ["missing", "disabled", "legacy", "strict"] as const) {
|
||||
test(`does not infer a written-answer capability from ${invalid} metadata`, async ({ page }) => {
|
||||
const field = { id: "reference", prompt: "Reference?", answerMode: "single_select", options: [{ id: "none", label: "No reference" }],
|
||||
...(invalid === "missing" ? {} : { customAnswer: { enabled: invalid !== "disabled" } }) };
|
||||
const card = { payload: invalid === "legacy" ? { questions: [{ ...field, options: [{ id: "none", label: "No reference", freeText: true }] }] }
|
||||
: { questionSet: { schema: "paperclip.question_set.v1", questions: [field] } } };
|
||||
await page.goto(origin);
|
||||
await page.waitForFunction(() => typeof (window as any).showQuestion === "function");
|
||||
const set = invalid === "legacy" ? chatQuestionPresentation(card.payload as any) : card.payload.questionSet;
|
||||
await page.evaluate(set => (window as any).showQuestion(set), set);
|
||||
await expect(page.getByRole("radio", { name: "No reference", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("radio", { name: "Other", exact: true })).toHaveCount(invalid === "strict" ? 1 : 0);
|
||||
await expect(answerResumeQuestionThroughUi(page, card, { text: "Use AMBERwritten.", ...(invalid === "strict" ? {} : { allowProviderCustomAnswer: true }) })).rejects.toThrow();
|
||||
expect(await page.evaluate(() => (window as any).submitted)).toBeUndefined();
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { chatQuestionPresentation } from "./chat-flow.js";
|
||||
|
||||
type Row = Record<string, any>;
|
||||
|
||||
export async function answerResumeQuestionThroughUi(page: Page, interaction: Row, input: { choice: string } | { text: string }) {
|
||||
export async function answerResumeQuestionThroughUi(page: Page, interaction: Row, input: { choice: string } | { text: string; allowProviderCustomAnswer?: boolean }) {
|
||||
const set = chatQuestionPresentation(interaction.payload);
|
||||
if ("choice" in input) {
|
||||
expect(set.questions[0]?.answerMode, "choices must use radio controls").toBe("single_select");
|
||||
@@ -12,8 +12,14 @@ export async function answerResumeQuestionThroughUi(page: Page, interaction: Row
|
||||
const choice = input.choice.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
await page.getByRole("radio", { name: new RegExp(`^${choice}\\b`, "i") }).last().click();
|
||||
} else {
|
||||
expect(set.questions[0]?.answerMode, "open answers must render a text field, not a choice question").toBe("text");
|
||||
await page.getByTestId("question-text-answer-composer").last()
|
||||
const field = set.questions[0];
|
||||
const custom = field?.answerMode !== "text";
|
||||
if (custom) {
|
||||
expect(input.allowProviderCustomAnswer && interaction.payload.questionSet && field?.answerMode === "single_select"
|
||||
&& field.customAnswer?.enabled === true, "the canonical provider form must explicitly permit a written reference").toBeTruthy();
|
||||
await page.getByRole("radio", { name: field!.customAnswer?.label ?? "Other", exact: true }).last().click();
|
||||
}
|
||||
await page.getByTestId(custom ? "question-other-answer-composer" : "question-text-answer-composer").last()
|
||||
.locator('[contenteditable="true"],textarea').first().fill(input.text);
|
||||
}
|
||||
for (let index = 1; index < set.questions.length; index += 1) {
|
||||
|
||||
@@ -60,8 +60,55 @@ function recording(paths: QuestionPath[] = ["provider", "semantic"]) {
|
||||
}
|
||||
const failed = (r: any[]) => gradeQuestionResume(r, "AMBERnonce").filter(c => !c.passed).map(c => c.id);
|
||||
const question = (r: any[], n = 0) => r[n].interactions[n];
|
||||
function writtenReference(r: any[]) {
|
||||
for (const point of r.slice(1)) Object.assign(point.interactions[1].payload.questionSet.questions[0], {
|
||||
answerMode: "single_select", options: [{ id: "none", label: "No reference" }], customAnswer: { enabled: true },
|
||||
});
|
||||
return r;
|
||||
}
|
||||
|
||||
describe("native question/resume path qualification", () => {
|
||||
it("accepts an explicit provider written answer without selecting a predefined option", () => {
|
||||
const r = writtenReference(recording(["provider", "provider"]));
|
||||
expect(failed(r)).toEqual([]);
|
||||
expect(validResumeQuestionForm(r[1], question(r, 1), false)).toBe(false);
|
||||
});
|
||||
it("rejects an inferred legacy freeText reference without a canonical question set", () => {
|
||||
const r = writtenReference(recording(["provider", "provider"]));
|
||||
for (const point of r.slice(1)) {
|
||||
const payload = point.interactions[1].payload;
|
||||
payload.questions = [{ id: "reference", prompt: "What reference should I include?", selectionMode: "single",
|
||||
options: [{ id: "write", label: "Write reference", freeText: true }] }];
|
||||
delete payload.questionSet;
|
||||
}
|
||||
expect(failed(r)).toContain("question-resume.forms");
|
||||
});
|
||||
it.each(["missing-custom", "disabled-custom", "missing-options", "duplicate-options", "missing-option-id", "multi-select", "extra-question",
|
||||
"wrong-request", "foreign-source", "foreign-agent", "foreign-company", "semantic", "api"])("rejects an unverified written-reference form: %s", kind => {
|
||||
const r = writtenReference(recording(["provider", kind === "semantic" || kind === "api" ? kind : "provider"]));
|
||||
const card = question(r, 1), field = card.payload.questionSet.questions[0];
|
||||
if (kind === "missing-custom") delete field.customAnswer;
|
||||
if (kind === "disabled-custom") field.customAnswer.enabled = false;
|
||||
if (kind === "missing-options") field.options = [];
|
||||
if (kind === "duplicate-options") field.options.push(structuredClone(field.options[0]));
|
||||
if (kind === "missing-option-id") delete field.options[0].id;
|
||||
if (kind === "multi-select") field.answerMode = "multi_select";
|
||||
if (kind === "extra-question") card.payload.questionSet.questions.push({ id: "extra", answerMode: "text" });
|
||||
if (kind === "wrong-request") card.payload.runtimeRequestId = "other";
|
||||
if (kind === "foreign-source") card.sourceRunId = "other";
|
||||
if (kind === "foreign-agent") card.createdByAgentId = "other";
|
||||
if (kind === "foreign-company") card.companyId = "other";
|
||||
expect(failed(r)).toContain("question-resume.forms");
|
||||
});
|
||||
it.each(["predefined-option", "wrong-text", "missing-text", "foreign-user", "changed-form"])("rejects a corrupt written-reference answer: %s", kind => {
|
||||
const r = writtenReference(recording(["provider", "provider"])), card = r[2].interactions[1], answer = card.result.answers[0];
|
||||
if (kind === "predefined-option") answer.optionIds = ["none"];
|
||||
if (kind === "wrong-text") answer.otherText = "Use OLD.";
|
||||
if (kind === "missing-text") delete answer.otherText;
|
||||
if (kind === "foreign-user") { card.resolvedByUserId = null; card.resolvedByAgentId = "agent"; }
|
||||
if (kind === "changed-form") card.payload.questionSet.questions[0].customAnswer.label = "Changed";
|
||||
expect(failed(r)).toContain("question-resume.text.answer");
|
||||
});
|
||||
it("accepts a native API creation receipt with the retained server response shape", () => {
|
||||
const point = JSON.parse(readFileSync(new URL("./fixtures/native-api-question.json", import.meta.url), "utf8"));
|
||||
expect(questionPath(point, point.interactions[0])).toBe("api");
|
||||
|
||||
@@ -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