mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Tasks combine saved messages, documents, run output, monitors, and
decisions.
> - Compact history makes long tasks load quickly.
> - The loading view still showed the real task header before the thread
scrolled to recent messages.
> - Different loading frames also changed the viewport, composer bounds,
and header typography.
> - This pull request uses one loading frame and reveals the prepared
conversation once.
> - The benefit is a stable first view without a material loading-speed
penalty.
## Linked Issues or Issue Description
**What happened?**
A cold task open could show a generic skeleton, then the real title and
monitor above placeholder messages, then jump to recent content. The
loading view changed width on desktop and height on mobile. Its title
line height differed from the real title.
**Expected behavior**
Keep the loading viewport and composer in place. Prepare the initial
reading position out of view. Reveal the recent conversation once its
data, fonts, scroll position, and composer bounds settle. Cap the font
wait at three seconds if the font host does not answer.
**Steps to reproduce**
1. Open a task with a long title, long saved replies, document cards, a
monitor, and a pending decision.
2. Reload it with CPU and network throttling.
3. Watch the header appear above placeholders, then move offscreen when
the recent conversation appears.
**Paperclip version or commit**
e122478368. The synthetic baseline recorded 10 animation frames with a
real header visible before the conversation was ready.
**Deployment mode**
Built from source. Local production previews used the same disposable
PostgreSQL fixture and API.
Related work: #17593, #16878, #18092, and #18461 preserve compact
startup and saved-card behavior. Older open PR #14667 addressed
transcript reveal and reading anchors; this change targets the remaining
loading-frame transition on current master.
## What Changed
- Prepare task data while the personal layout preference loads. Keep the
temporary page hidden until the correct layout is known. This removes a
12 px mobile header change.
- Share one matching loading frame across task metadata and history
preparation. Use the real composer surface and dock geometry for its
placeholder.
- Keep the real title and monitor concealed until the viewport, scroll
position, composer, and fonts settle. Bound a stalled font request to
three seconds.
- Reserve the final chat viewport and breadcrumb identifier position.
Mount task properties after the personal preference resolves.
- Match the loading title typography to the selected task interface,
including Classic.
- Add normal-CI browser coverage for rich tasks, Classic startup and
typography, mobile layout selection, and stalled fonts. Preserve the
outage recovery assertions.
- Add Design Guide examples and an optional retained fixture with 200
long messages, documents, a PR card, a monitor, and a pending
confirmation. Disable both interval and on-demand agent wakes in this
fixture.
## Verification
- Final UI source: `8398af3493`. The later commits change tests or merge
master. The recorded production build is `99867e2dc3`; its UI source is
identical to the final PR head.
- All 10,263 UI tests pass: `pnpm --filter @paperclipai/ui exec vitest
run`. Workspace typecheck and the full production build pass. Token
gates, module boundaries, map validation, and diff checks pass.
- Task browser checks pass 40 cases. Shared chat passes 22 cases. The
normal-CI loading and outage recovery suite passes all 13 cases on the
final head.
- The eight rich loading cases cover direct and list entry, delayed
fonts, and delayed personal preferences on desktop and mobile. At 4× CPU
slowdown and 80 ms latency, each records zero real-header frames before
reveal. Loading-frame, composer, and initial reading-position movement
are 0 px. Title typography matches.
- The normal browser suite also verifies that unresolved mobile layouts
stay hidden, the selected mobile viewport stays fixed, and a seeded
Classic loading title matches the resolved title after an uncached
reload.
- Cold speed: five fresh contexts per variant and viewport, alternating
before/after order. Same local API and synthetic fixture; 4× CPU
slowdown, 80 ms latency, 1.18 MB/s download. No interception,
recordings, builds, or test suites during measurement. Desktop median:
3578 ms before, 3572 ms after. Mobile median: 3500 ms before, 3476 ms
after. Sample ranges overlap.
- The unmodified baseline records 10 real-header frames before
readiness. The final eight-case matrix records zero. Local recordings
and failed prototype evidence are retained outside the repository. No
private staging content is published in this PR.
- CI initially counted hidden zero-size elements as visible frames. Its
saved data confirms the `(0, 0, 0, 0)` entries. Both browser samplers
now ignore those entries and retain all visible geometry checks. The
corrected local suite passes.
- The default `pnpm test:run` command stopped in the unchanged server
chat integration shard with `No test found ... in lines ...` and 1817
skipped tests. This is not a local full-suite pass. One unchanged
AgentActionButtons test also failed during a concurrent run; all 37
cases pass in isolation, and the full 10,263-test UI rerun passes
without source changes to that component.
- Final-head [CI
passes](https://github.com/paperclipai/paperclip/actions/runs/41320240728)
at `d1ef4a955f`. No checks are failed or pending. Greptile and Ponytail
Paperclip Review both report 5/5 for that exact head. All review threads
are resolved.
- The fix is not deployed to staging.
## Risks
- First reveal waits for stable geometry and fonts, with a three-second
font cap. A font that arrives after this degraded-mode cap can still
change text metrics. The existing history deadline still exposes usable
content if another dependency stalls.
- The loading frame cannot predict arbitrary conversation content. The
real content remains concealed until its initial position is ready.
- The shared thread also serves agent chat. Its browser startup and
scroll journeys are included in final verification.
## Model Used
OpenAI GPT-6 (Codex), with reasoning, tool use, code execution, and
browser inspection. The host did not expose a more specific model ID or
the context-window size.
## 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
- [x] I have run tests locally and they pass
- [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
- [x] All Paperclip CI gates are green
- [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>
308 lines
19 KiB
TypeScript
308 lines
19 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
test.setTimeout(60_000);
|
|
test.use({ video: "on", trace: "retain-on-failure", serviceWorkers: "block", viewport: { width: 1440, height: 900 } });
|
|
|
|
let fixture: { id: string; identifier: string; prefix: string; oldestCommentId: string };
|
|
const fixturePath = path.resolve("test-results/compact-startup-fixture.json");
|
|
test.beforeAll(async ({ request }) => {
|
|
const saved = await fs.readFile(fixturePath, "utf8").then(text => JSON.parse(text)).catch(() => null);
|
|
if (saved) {
|
|
const existing = await request.get(`/api/issues/${saved.id}`);
|
|
if (existing.ok()) { fixture = saved; return; }
|
|
// A different disposable instance needs a new fixture. Server failures
|
|
// must fail the test rather than silently creating another company.
|
|
expect(existing.status(), await existing.text()).toBe(404);
|
|
}
|
|
const company = await (await request.post("/api/companies", { data: { name: "Task loading reproduction" } })).json();
|
|
const response = await request.post(`/api/companies/${company.id}/issues`, { data: {
|
|
title: "Review the latest investigation", status: "backlog", description: "Investigate the latest reported pattern and keep the saved evidence together.",
|
|
} });
|
|
expect(response.ok(), await response.text()).toBeTruthy();
|
|
const issue = await response.json();
|
|
let oldestCommentId = "";
|
|
for (let index = 0; index < 24; index++) {
|
|
const comment = await request.post(`/api/issues/${issue.id}/comments`, { data: { body: index === 23
|
|
? "The saved update is ready. [Read the investigation](#document-investigation). The recent reply must stay in the same place while cards load."
|
|
: `Historical investigation message ${index}. ${"Saved findings and verification details. ".repeat(5)}` } });
|
|
expect(comment.ok()).toBeTruthy();
|
|
if (!index) oldestCommentId = (await comment.json()).id;
|
|
}
|
|
for (const [key, title] of [["plan", "Investigation and verification plan"], ["investigation", "Saved investigation"], ["verification", "Verification evidence"], ["handoff", "Engineering handoff"], ["continuation-summary", "Continuation summary"]]) {
|
|
const document = await request.put(`/api/issues/${issue.id}/documents/${key}`, { data: {
|
|
title, format: "markdown", body: `# ${title}\n\nInspect the original report.\n\nVerify the saved evidence.\n\nKeep the reader's position stable.`,
|
|
} });
|
|
expect(document.ok(), await document.text()).toBeTruthy();
|
|
}
|
|
for (let index = 0; index < 3; index++) {
|
|
const product = await request.post(`/api/issues/${issue.id}/work-products`, { data: {
|
|
type: "pull_request", provider: "github", title: `Investigation fix ${index + 1}`, status: "merged",
|
|
url: `https://github.com/example/reproduction/pull/${index + 1}`, metadata: { repository: "example/reproduction", number: index + 1 },
|
|
} });
|
|
expect(product.ok(), await product.text()).toBeTruthy();
|
|
}
|
|
fixture = { id: issue.id, identifier: issue.identifier, prefix: company.issuePrefix, oldestCommentId };
|
|
await fs.mkdir(path.dirname(fixturePath), { recursive: true });
|
|
await fs.writeFile(fixturePath, JSON.stringify(fixture, null, 2));
|
|
});
|
|
|
|
async function ready(page: Page) {
|
|
await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
|
|
async function coordinatorPreference(page: Page, waitForPreference = Promise.resolve()) {
|
|
await page.route("**/api/instance/settings/experimental", async route => {
|
|
const response = await route.fetch();
|
|
await route.fulfill({ json: { ...await response.json(), enableClassicTaskInterface: true } });
|
|
});
|
|
await page.route("**/api/workspace-preferences/me", async route => {
|
|
await waitForPreference;
|
|
const response = await route.fetch();
|
|
await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: true } });
|
|
});
|
|
}
|
|
|
|
test("a delayed Coordinator preference cannot expand a cold task through Classic settings", async ({ page }) => {
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
await coordinatorPreference(page, held);
|
|
const historyRequests: string[] = [];
|
|
page.on("request", request => {
|
|
const url = new URL(request.url());
|
|
if (/\/api\/issues\/[^/]+\/(comments|activity|runs)$/.test(url.pathname) &&
|
|
!["recent", "comment-inputs"].includes(url.searchParams.get("view") ?? "")) historyRequests.push(url.pathname + url.search);
|
|
});
|
|
try {
|
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
|
|
await expect(page.getByTestId("shell-configuration-loading")).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "Review the latest investigation", exact: true })).not.toBeVisible();
|
|
expect(historyRequests).toEqual([]);
|
|
release();
|
|
await ready(page);
|
|
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
|
|
expect(historyRequests).toEqual([]);
|
|
await page.getByRole("button", { name: "More messages", exact: true }).click();
|
|
await expect(page.getByText("Historical investigation message 0.", { exact: false }).first()).toBeAttached();
|
|
expect(historyRequests.length).toBeGreaterThan(0);
|
|
} finally { release(); }
|
|
});
|
|
|
|
test("Coordinator task rows use index reads and open a compact conversation", async ({ page }) => {
|
|
await coordinatorPreference(page);
|
|
const listReads: string[] = [];
|
|
page.on("request", request => {
|
|
const url = new URL(request.url());
|
|
if (/\/api\/companies\/[^/]+\/issues$/.test(url.pathname)) listReads.push(url.searchParams.get("view") ?? "full");
|
|
});
|
|
await page.goto(`/${fixture.prefix}/issues`);
|
|
const row = page.locator("[data-inbox-issue-link]", { hasText: "Review the latest investigation" }).first();
|
|
await expect(row).toBeVisible();
|
|
expect(listReads).toContain("index");
|
|
expect(listReads).not.toContain("full");
|
|
await row.click();
|
|
await ready(page);
|
|
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
|
|
});
|
|
|
|
test("core rich cards reveal before duplicate reads and stay stable during slow enrichment", async ({ page }, info) => {
|
|
await page.addInitScript(() => { window.__PAPERCLIP_ISSUE_DETAIL_PROFILE__ = true; });
|
|
await page.addInitScript(() => {
|
|
const frames: unknown[] = [];
|
|
Object.assign(window, { compactStartupFrames: 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) {
|
|
const cards = [...scroller.querySelectorAll('[data-testid="task-chat-resource-document"], [data-testid="task-chat-plan-preview"], [data-testid="task-chat-rich-work-product-pull_request"], [data-testid="task-chat-resource-deliverable"]')];
|
|
frames.push({ time: performance.now(), height: scroller.scrollHeight, width: scroller.clientWidth, cards: cards.length, boxes: cards.map(card => {
|
|
const box = card.getBoundingClientRect(); return { top: box.top, height: box.height };
|
|
}) });
|
|
}
|
|
requestAnimationFrame(sample);
|
|
};
|
|
requestAnimationFrame(sample);
|
|
});
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
await page.route(/\/api\/issues\/[^/?]+$/, async route => { await held; await route.continue(); });
|
|
await page.route(/\/api\/issues\/[^/?]+\/work-products$/, async route => { await held; await route.continue(); });
|
|
try {
|
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
|
|
await ready(page);
|
|
await page.waitForTimeout(250);
|
|
release();
|
|
await expect(page.getByTestId("task-chat-resource-document")).toHaveCount(3);
|
|
await expect(page.getByTestId("task-chat-plan-preview")).toHaveCount(1);
|
|
await page.waitForTimeout(500);
|
|
const frames = await page.evaluate(() => (window as unknown as { compactStartupFrames: { height: number; width: number; cards: number; boxes: { top: number; height: number }[] }[] }).compactStartupFrames);
|
|
await fs.writeFile(info.outputPath("layout.json"), JSON.stringify(frames, null, 2));
|
|
await page.screenshot({ path: info.outputPath("settled.png") });
|
|
expect(frames.length).toBeGreaterThan(5);
|
|
expect(new Set(frames.map(frame => frame.cards)).size, "Cards must arrive before the conversation becomes readable").toBe(1);
|
|
expect(Math.max(...frames.map(frame => frame.height)) - Math.min(...frames.map(frame => frame.height)), "Initial content must not grow after reveal").toBeLessThanOrEqual(2);
|
|
expect(Math.max(...frames.map(frame => frame.width)) - Math.min(...frames.map(frame => frame.width)), "Initial pane geometry must settle before reveal").toBeLessThanOrEqual(2);
|
|
for (let card = 0; card < frames[0].cards; card++) {
|
|
for (const dimension of ["top", "height"] as const) {
|
|
const values = frames.map(frame => frame.boxes[card][dimension]);
|
|
expect(Math.max(...values) - Math.min(...values), `Card ${card} ${dimension} stays stable after reveal`).toBeLessThanOrEqual(2);
|
|
}
|
|
}
|
|
} finally {
|
|
await fs.writeFile(info.outputPath("readiness.json"), JSON.stringify(await page.evaluate(() => performance.getEntriesByType("mark").map(entry => ({ ...entry.toJSON(), detail: (entry as PerformanceMark).detail }))), null, 2));
|
|
release();
|
|
}
|
|
});
|
|
|
|
for (const anchor of ["document", "comment", "unknown", "continuation"]) {
|
|
test(`${anchor} anchors keep compact loading and resolve their target`, async ({ page }) => {
|
|
const requests: string[] = [];
|
|
page.on("request", request => { if (request.url().includes("/api/issues/")) requests.push(request.url()); });
|
|
const hash = anchor === "comment" ? `#comment-${fixture.oldestCommentId}` : anchor === "document" ? "#document-investigation" : anchor === "continuation" ? "#document-continuation-summary" : "#unknown";
|
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}${hash}`);
|
|
await ready(page);
|
|
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
|
|
await expect(page.getByText("The saved update is ready.", { exact: false }).first()).toBeAttached();
|
|
if (anchor === "comment") await expect(page.locator(`[id="comment-${fixture.oldestCommentId}"]`)).toBeVisible();
|
|
if (anchor === "document") await expect(page.locator('[id="side-panel-content-document:investigation"]')).toBeVisible();
|
|
if (anchor === "continuation") await expect(page.locator('[id="side-panel-content-document:continuation-summary"]')).toBeVisible();
|
|
expect(requests.some(url => /\/comments\?/.test(url))).toBe(false);
|
|
expect(requests.some(url => /\/(activity|runs)(?:\?|$)/.test(url) && !url.includes("view=recent") && !url.includes("comment-inputs"))).toBe(false);
|
|
});
|
|
}
|
|
|
|
test("opening documents and older comments keeps the mounted composer and compact window", async ({ page }) => {
|
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
|
|
await ready(page);
|
|
const editor = page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]');
|
|
await editor.fill("A draft stays here while I inspect saved evidence.");
|
|
await editor.evaluate(element => { element.setAttribute("data-mount-proof", "retained"); });
|
|
await page.getByRole("link", { name: "Read the investigation", exact: true }).click();
|
|
await expect(page.locator('[id="side-panel-content-document:investigation"]')).toBeVisible();
|
|
await expect(editor).toHaveAttribute("data-mount-proof", "retained");
|
|
await expect(editor).toHaveText("A draft stays here while I inspect saved evidence.");
|
|
await page.getByRole("button", { name: "More messages", exact: true }).click();
|
|
await expect(page.getByText("Historical investigation message 0.", { exact: false }).first()).toBeAttached();
|
|
await expect(editor).toHaveAttribute("data-mount-proof", "retained");
|
|
});
|
|
|
|
test("opening a document from an older linked comment retains that row and its position", async ({ page, request }) => {
|
|
// Give the real saved target a document link without inventing a browser row.
|
|
const target = await (await request.get(`/api/issues/${fixture.id}/comments/${fixture.oldestCommentId}`)).json();
|
|
await page.route(`**/api/issues/${fixture.id}/comments/${fixture.oldestCommentId}`, route => route.fulfill({ json: { ...target, body: `${target.body}\n\n[Inspect saved evidence](#document-investigation)` } }));
|
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}#comment-${fixture.oldestCommentId}`, { waitUntil: "domcontentloaded" });
|
|
await ready(page);
|
|
const row = page.locator(`[id="comment-${fixture.oldestCommentId}"]`);
|
|
const before = (await row.boundingBox())!.y;
|
|
await row.getByRole("link", { name: "Inspect saved evidence", exact: true }).click();
|
|
await expect(page.locator('[id="side-panel-content-document:investigation"]')).toBeVisible();
|
|
await expect(row).toBeVisible();
|
|
await expect.poll(async () => Math.abs((await row.boundingBox())!.y - before)).toBeLessThanOrEqual(2);
|
|
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
|
|
});
|
|
|
|
test("a failed linked comment keeps recent messages readable and retries without expanding history", async ({ page }) => {
|
|
const targetPath = `**/api/issues/${fixture.id}/comments/${fixture.oldestCommentId}`;
|
|
await page.route(targetPath, route => route.fulfill({ status: 404, contentType: "application/json", body: '{"error":"Comment not found"}' }));
|
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}#comment-${fixture.oldestCommentId}`);
|
|
await ready(page);
|
|
await expect(page.getByText("The saved update is ready.", { exact: false }).first()).toBeAttached();
|
|
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
|
|
await page.unroute(targetPath);
|
|
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
|
await expect(page.locator(`[id="comment-${fixture.oldestCommentId}"]`)).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
|
|
});
|
|
|
|
test("late provider metadata keeps saved PR card geometry stable", async ({ page }) => {
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
await page.route("**/api/issues/*/work-products?refreshPullRequests=true", async route => {
|
|
const response = await route.fetch();
|
|
const products = await response.json();
|
|
await held;
|
|
await route.fulfill({ response, json: products.map((product: { metadata: object }) => ({ ...product, metadata: { ...product.metadata, additions: 78, deletions: 2, filesChanged: 5 } })) });
|
|
});
|
|
try {
|
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
|
|
await ready(page);
|
|
const cards = page.getByTestId("task-chat-rich-work-product-pull_request");
|
|
await expect(cards).toHaveCount(3);
|
|
const before = await cards.evaluateAll(elements => elements.map(element => element.getBoundingClientRect().height));
|
|
release();
|
|
await expect(cards.first()).toContainText("+78");
|
|
const after = await cards.evaluateAll(elements => elements.map(element => element.getBoundingClientRect().height));
|
|
expect(after).toEqual(before);
|
|
} finally { release(); }
|
|
});
|
|
|
|
test("the selected responder stays mounted through comment acknowledgement and legacy startup", async ({ page, request }, info) => {
|
|
const source = await (await request.get(`/api/issues/${fixture.id}`)).json();
|
|
const agentResponse = await request.post(`/api/companies/${source.companyId}/agents`, { data: {
|
|
name: "Startup responder", role: "engineer", adapterType: "process",
|
|
adapterConfig: { command: process.execPath, args: ["-e", "setTimeout(() => console.log('Fixture finished.'), 12000)"] },
|
|
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } },
|
|
} });
|
|
expect(agentResponse.ok(), await agentResponse.text()).toBeTruthy();
|
|
const agent = await agentResponse.json();
|
|
const issueResponse = await request.post(`/api/companies/${source.companyId}/issues`, { data: {
|
|
title: `Keep the selected responder visible ${Date.now()}`, status: "in_review", assigneeUserId: "local-board",
|
|
description: "This disposable process fixture verifies the send transition without a model provider.",
|
|
} });
|
|
expect(issueResponse.ok(), await issueResponse.text()).toBeTruthy();
|
|
const issue = await issueResponse.json();
|
|
// Repeated runs must not adopt the previous fixture through same-title dedup.
|
|
expect(issue).toMatchObject({ status: "in_review", assigneeUserId: "local-board", assigneeAgentId: null });
|
|
await coordinatorPreference(page);
|
|
await page.addInitScript(() => {
|
|
const frames: { visible: boolean; same: boolean }[] = [];
|
|
let first: Element | null = null;
|
|
Object.assign(window, { responderFrames: frames });
|
|
const sample = () => {
|
|
const identity = document.querySelector('[data-testid="task-chat-live-transcript"] [data-testid="task-chat-agent-identity"]');
|
|
first ??= identity;
|
|
if (first) frames.push({ visible: !!identity && identity.getBoundingClientRect().height > 0, same: first === identity });
|
|
requestAnimationFrame(sample);
|
|
};
|
|
requestAnimationFrame(sample);
|
|
});
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
let holdRuntime = false;
|
|
await page.route(/\/api\/issues\/[^/]+\/(active-run|live-runs|runs)(\?|$)/, async route => {
|
|
if (holdRuntime && route.request().method() === "GET") await held;
|
|
await route.continue();
|
|
});
|
|
try {
|
|
await page.goto(`/${fixture.prefix}/issues/${issue.identifier}`);
|
|
await ready(page);
|
|
await page.getByTestId("task-chat-composer-assignee").click();
|
|
await page.locator(`[data-assignee-identity="agent:${agent.id}"]`).click();
|
|
await page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]').fill("Keep your identity visible through startup.");
|
|
holdRuntime = true;
|
|
const saved = page.waitForResponse(response => response.request().method() === "PATCH" &&
|
|
[issue.id, issue.identifier].some(ref => new URL(response.url()).pathname === `/api/issues/${ref}`));
|
|
await page.getByTestId("task-chat-composer-send").click();
|
|
expect((await saved).ok()).toBeTruthy();
|
|
const identity = page.getByTestId("task-chat-live-transcript").getByTestId("task-chat-agent-identity");
|
|
await expect(identity).toHaveText(agent.name);
|
|
await page.screenshot({ path: info.outputPath("startup.png") });
|
|
release();
|
|
await expect(page.getByTestId("task-chat-live-run-pill")).toBeVisible();
|
|
await expect(identity).toHaveText(agent.name);
|
|
await page.screenshot({ path: info.outputPath("running.png") });
|
|
const frames = await page.evaluate(() => (window as unknown as { responderFrames: { visible: boolean; same: boolean }[] }).responderFrames);
|
|
await fs.writeFile(info.outputPath("avatar-frames.json"), JSON.stringify(frames, null, 2));
|
|
expect(frames.length).toBeGreaterThan(2);
|
|
expect(frames.every(frame => frame.visible && frame.same), "The responder must remain visible in the same mounted identity").toBe(true);
|
|
} finally {
|
|
release();
|
|
const active = await request.get(`/api/issues/${issue.id}/active-run`);
|
|
if (active.ok()) {
|
|
const run = await active.json();
|
|
if (run?.id) await request.post(`/api/heartbeat-runs/${run.id}/cancel`);
|
|
}
|
|
}
|
|
});
|