fix(tasks): keep anchored opens compact and stabilize saved cards (#18092)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task pages must make the current instruction and recent conversation
readable quickly.
> - Compact startup avoids reading all earlier messages and run
activity.
> - Every URL fragment previously selected the full-history path, even
for documents.
> - Saved document cards also arrived after the conversation was
visible. PR statistics could add another row later.
> - This pull request keeps anchors compact and reveals the saved
conversation structure together.
> - The benefit is stable reading position without loading unrelated
history.

## Linked Issues or Issue Description

Related: #17593 introduced compact startup. #14667 covers earlier reveal
and scroll work. This fix retains the bounded history contract.

**What happened?**

Opening a task document link loaded all task history. Ordinary cold
opens could reveal messages before saved document cards and plans
arrived. Later PR metadata added a statistics row and changed card
height.

**Expected behavior**

Every URL fragment keeps compact startup. Document links open their
pane. Older-comment links fetch only the target comment. Saved cards
keep their geometry after first reveal.

**Steps to reproduce**

1. Create a task with many comments, a saved plan, documents and saved
PR work products.
2. Delay the enriched task response and open the task. Observe cards
inserted after messages appear.
3. Open a document fragment. Observe full comment, activity and run
reads.
4. Delay PR metadata refresh. Observe each card grow when diff counts
arrive.

## What Changed

- Remove fragment and tab checks from automatic history expansion.
Resolve validated comment UUIDs through the authorized single-comment
endpoint, encode their path components, retain fetched targets across
document navigation, and preserve cancellation/deletion cache updates.
- Include the saved plan, document metadata and local work-product
projection in the core task read. Skip recovery, workspace and
related-task enrichment. Do not refresh providers. Summary queries no
longer select other document bodies.
- Seed saved work-product and plan queries from core, including already
pending or errored reads, without replacing known lists or an
authoritative empty result. Coordinate the bounded recent reads, saved
cards, latest transcript and settled initial pane width before first
reveal. Keep the thread and composer mounted during refresh and explicit
expansion.
- Open continuation summaries in the document pane. Reserve PR
statistics geometry while provider metadata loads.
- Add a reusable synthetic local task, nine browser checks, frame
measurements and video. Extend the PostgreSQL core-route fixture with a
saved PR row and assert its identity, URL, state and stored metadata
survive the response. Update design guidance and capability maps.

## Verification

- Red: visible document/plan cards changed from 0 to 1 to 4; thread
height grew from 690 to 1,432 pixels. Green: all seven saved cards
appear before reveal, with constant thread height and zero per-card
movement in the final reviewed browser run. The final 1,492-pixel layout
includes the reserved PR statistics lines.
- Red: delayed PR diff counts changed all three cards from 62 to 82
pixels. Green: their heights stay at 82 pixels before and after metadata
refresh.
- Nine browser journeys pass: delayed enrichment,
document/comment/unknown/continuation anchors, document clicks and
explicit history expansion with a retained draft, missing-comment retry,
delayed PR statistics, and retained older-comment position after
document navigation.
- 583 focused UI tests passed on `c91fa107ba`. After the plan-cache
follow-up, all 212 affected page/cache/plan-hook tests and UI typecheck
pass, including 14 plan-hook cases. The original 16 focused PostgreSQL
tests passed for the server changes.
- Merge-review red: holding the duplicate stored work-products read
blocked the first reveal past 10 seconds despite core already containing
the saved cards. Both empty and populated core projections now reveal
while that request remains pending. Malformed-comment tests prove no
target request is made; API tests prove path encoding and abort-signal
forwarding.
- The populated core-route test now inserts a real saved PR work product
alongside the plan and documents, then checks the complete saved
projection through HTTP. All ten task cold-open PostgreSQL tests pass.
- Plan-cache review red: an existing pending or errored plan query
prevented the saved core plan from reaching the consumer. Four real-hook
cases failed before the data-presence fix and pass afterward, for both
present and absent plans. A separately fetched newer plan or known
absent plan remains authoritative.
- Further layout red: the initial properties pane reduced visible thread
width from 775 to 742 pixels after reveal. The reveal now waits for two
stable width frames; the browser check includes width, height, card
counts and per-card geometry.
- `pnpm -r typecheck` and `pnpm build` passed for the original change;
UI typecheck and build pass after review fixes. Token, module-boundary
and map checks pass. `pnpm test:run` was stopped after about 15 minutes
in the serialized server lane. No full local test-suite pass is claimed.
On the earlier head, hosted build/typecheck and all Vitest lanes passed;
one E2E shard failed because the agent-configuration clipboard helper
copied page text into its YAML parser. On `c91fa107ba`, Greptile was 5/5
and every E2E shard passed, including the earlier clipboard case. One
unchanged Discord chat-binding test in server shard 8 returned false
after 185 ms; its 16-test integration file passes locally without
changes. The CI cause remains unestablished. On `be436ce492`, both
server shard 8 and E2E shard 7 passed without changing those tests or
their product code. On final head `e357b3bced`, all CI jobs pass,
including full general and serialized tests, all eight E2E shards,
typecheck, build and the aggregate verification gate. Exact-head
Greptile is 5/5 with no findings. The overall status rollup contains 55
successes, two intentionally skipped Storybook checks and a neutral
Paperclip Review. That advisory review ended without a complete
assessment; all previously posted findings are resolved and it added no
current findings.
- Test startup exceeded an initial 5-second deadline once while
development assets and other checks were active. The final browser
checks allow 10 seconds. These runs establish geometry and bounded
requests; they do not establish a reliable latency improvement. An
initial 503 error fixture exercised global reconnect/backoff. The
target-comment failure test uses a terminal 404.
- Browser data is synthetic. Provider metadata is simulated for the
delayed-response check. No staging deployment or live provider execution
is claimed.

## Risks

- Core task reads now include local saved-card queries and the saved
plan body. Other document bodies and all historical run activity remain
outside that path.
- First reveal waits for the bounded current structure. The existing
15-second incomplete-history fallback and inline retry remain available.
- Continuation-summary links open in the document pane in the current
task interface. Classic tasks keep their existing surface.
- No migration or mutation permission change.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, code editing, shell tools
and browser testing. The exact serving model ID and context-window size
are not exposed in this session.

## 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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-29 09:13:01 -05:00
1 parent 2a6d4d1694
commit 7e27bc5007
19 files changed
+545 -63

No files matched your search

+6 -2
View File
@@ -68,12 +68,16 @@ message scroller and a normal flex footer for the composer. Navigation stays at
the layout viewport bottom, behind the keyboard.
Cold task opens reveal the instruction and recent conversation independently of
plans, related metadata, and historical run activity. Show the latest human
recovery, related metadata, and historical run activity. Saved document cards,
the saved plan, and the bounded latest turn arrive before first reveal so they
cannot insert rows into an already readable conversation. Show the latest human
follow-up, recent saved replies, and text or pending requests from the latest run.
Put older messages and diagnostic activity behind **More messages**. Fetch those
only when expanded; never download all history just to calculate a count.
Retain messages and runs observed while the task stays open, preserve expansion
on return within the session, and load linked older comments when requested.
on return within the session. No URL fragment expands history. Document links,
including continuation summaries, open their pane. Older comment links fetch
only the requested comment and preserve the recent conversation.
Background refreshes and history expansion keep the conversation and composer
mounted. A failed history request shows an inline retry without hiding readable
content. Full-history and classic views retain the bounded 15-second initial wait.
@@ -0,0 +1,62 @@
# Stable task startup and anchors
Deliver one reviewed PR. Keep cold task opens compact for every URL fragment.
Use a disposable local task with a long thread, a saved plan, document cards,
and saved PR cards, modeled on the reported staging page with synthetic text.
| Acceptance | Evidence |
| --- | --- |
| Documents, comments, artifacts and unknown anchors keep compact history | Route tests inspect recent reads and reject full-history reads |
| Older comment links still work | Fetch only the target comment; browser checks target and recent replies |
| Saved cards do not insert after first reveal | Delayed-response browser test records frames, card counts, geometry and video; demonstrate red then green |
| Fast reads remain independent of slow task enrichment | Stall full task detail while opening the local fixture |
| Refresh and More messages preserve mounted content | Browser checks reading position and composer identity |
| Reviewable handoff | Full required checks, token/maps checks, PR template, review and CI |
The task route owns expansion and targeted comment loading. The document service
owns the saved document projection. The thread owns its first coherent reveal.
No anchor may request all comments, full activity, or historical transcripts.
No deployment is included.
Evidence so far: the baseline document/plan cards changed from 0 to 1 to 4
visible cards after reveal, and thread height grew from 690 to 1,432 pixels.
The first candidate keeps all 7 saved cards present and height constant. A
second regression showed delayed PR counts changing card heights from 62 to
82 pixels; reserving that statistics line makes both states 82 pixels.
Final focused UI tests before merge review: 552 pass. Focused server tests: 16 pass. Token and map checks pass.
All nine browser checks, full workspace typecheck and build pass. The full local suite was stopped after about 15 minutes in the serialized server lane; it is not a full local pass. Hosted CI supplies whole-suite acceptance. Review found that document navigation dropped a separately fetched older comment. The fix retains target comments in the observed compact window without advancing its cursor. Its added unit and browser checks pass, including row position. Hosted acceptance is recorded in PR #18092. One early candidate browser run exceeded the initial 5-second dev
readiness deadline; later runs use 10 seconds. No reliable latency gain is claimed.
The initial 503 linked-comment fixture exercised the app's global reconnect and
30-second retry policy. The target-specific failure check uses a terminal 404.
Merge review added regressions for core work products arriving during a pending
stored-list request and malformed comment fragments. Core projections now seed
empty or populated work-product caches without replacing already known lists;
comment fragments must contain UUIDs and API path components are encoded.
The delayed stored-read browser case was red at its 10-second reveal deadline.
Frame evidence also found a 775-to-742-pixel width change after reveal while
the initial properties pane opened. Every first reveal now waits for two stable
width frames, while keeping the existing 15-second fallback. The browser fixture
checks width as well as card positions and heights. Final follow-up verification:
583 focused UI tests and all nine browser journeys pass, along with UI typecheck
and build. The layout capture held seven cards, width 742 and height 1,492 across
101 frames. Review follow-up evidence
and hosted acceptance are recorded in PR #18092.
The next review found the same pending/error distinction for the plan query.
The projection helper now checks for missing plan data rather than a missing
query. Four hook regressions first failed with pending or errored empty caches;
they pass after the fix. The existing-plan test now also preserves a known null.
All 212 affected page/cache/plan-hook tests and UI typecheck pass. CI on c91fa107ba
passed every browser shard, including the earlier YAML clipboard case, but one
unchanged Discord chat-binding test returned false in 185 ms. Its 16-test local
integration file passes unchanged; the CI cause remains unestablished. The next
head's hosted CI must pass before merging.
Local reproduction: http://127.0.0.1:3223/TASAAAAA/issues/TASAAAAA-1#document-investigation.
Synthetic task data only; no live provider execution. Restart with
`PAPERCLIP_HOME=/tmp/paperclip-stable-task-loading PAPERCLIP_INSTANCE_ID=task-loading PAPERCLIP_UI_DEV_MIDDLEWARE=true node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts run`.
Evidence is retained under `/tmp/task-loading-proof/`; the generated report is also attached to the disposable local instance. The canonical PR is https://github.com/paperclipai/paperclip/pull/18092.
State: branch `codex/stable-task-anchor-loading`; baseline `063cfd4381`.
Dependencies installed locally; the existing lockfile was stale and will not be committed.
+1 -1
View File
@@ -84,7 +84,7 @@ Gaps: Bounded native search and one-page CLI local match are not complete compan
### `read-task-and-summaries`
API: `present` — GET /api/issues/{id} (optional `view=core` omits related enrichment); GET /api/companies/{companyId}/issues/summaries?ids=... .
API: `present` — GET /api/issues/{id} (optional `view=core` includes saved document/work-product cards and omits recovery and related-task enrichment); GET /api/companies/{companyId}/issues/summaries?ids=... .
Agent access: `partial` — REST/CLI issue get; native get_task_context reads CURRENT task only; call_api for eligible arbitrary task/summaries. No dedicated summaries CLI/tool.
+1 -1
View File
@@ -46,7 +46,7 @@ Open `/issues` or a project’s Issues view; legacy `/tasks` links redirect.
### `task-detail`
Open `/issues/:issueId` from a row, mention, or direct link. Cold opens show the instruction and recent exchange first. **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. 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.
### `task-sharing`
+23 -3
View File
@@ -3,12 +3,13 @@ import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { activityLog, agents, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues } from "@paperclipai/db";
import { activityLog, agents, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues, issueWorkProducts } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
import { createHeartbeatRunState } from "../services/heartbeat/run-state.js";
import { activityService } from "../services/activity.js";
import { documentService } from "../services/documents.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("task cold-open reads", () => {
@@ -37,15 +38,34 @@ const support = await getEmbeddedPostgresTestSupport();
app.use(errorHandler);
return app;
}
it("returns the core instruction without related detail and keeps company checks", async () => {
it("returns the instruction and saved card projection without related detail and keeps company checks", async () => {
const response = await request(app()).get(`/api/issues/${issueId}?view=core`);
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body).toMatchObject({ id: issueId, description: "Read this first" });
expect(response.body).not.toHaveProperty("ancestors");
expect(response.body).not.toHaveProperty("workProducts");
expect(response.body).toMatchObject({ documentSummaries: [], planDocument: null, workProducts: [] });
const denied = await request(app(randomUUID())).get(`/api/issues/${issueId}?view=core`);
expect([403, 404]).toContain(denied.status);
});
it("includes saved plan content, document metadata and work products in the core read", async () => {
const documents = documentService(db);
await documents.upsertIssueDocument({ issueId, key: "plan", title: "Saved plan", format: "markdown", body: "# Stable plan" });
await documents.upsertIssueDocument({ issueId, key: "evidence", title: "Saved evidence", format: "markdown", body: "Long saved evidence".repeat(1000) });
const savedProduct = {
id: randomUUID(), companyId, issueId, type: "pull_request", provider: "github",
title: "Saved investigation fix", status: "merged",
url: "https://github.com/example/reproduction/pull/42",
metadata: { repository: "example/reproduction", number: 42, additions: 78, deletions: 2 },
};
await db.insert(issueWorkProducts).values(savedProduct);
const response = await request(app()).get(`/api/issues/${issueId}?view=core`);
expect(response.status).toBe(200);
expect(response.body.planDocument).toMatchObject({ key: "plan", body: "# Stable plan" });
expect(response.body.workProducts).toEqual([expect.objectContaining(savedProduct)]);
expect(response.body.documentSummaries).toEqual(expect.arrayContaining([expect.objectContaining({ key: "evidence", title: "Saved evidence" })]));
expect(response.body.documentSummaries.every((document: Record<string, unknown>) => !("body" in document))).toBe(true);
expect(response.body).not.toHaveProperty("ancestors");
});
it("returns a bounded recent exchange plus the latest human instruction", async () => {
const humanId = randomUUID();
await db.insert(issueComments).values([
+8 -3
View File
@@ -9426,11 +9426,16 @@ export function issueRoutes(
);
if (!issue) return;
if (!(await timing.time("authorization", () => assertIssueReadAllowed(req, res, issue, { allowBreakGlass: true })))) return;
// The cold-open read owns only the task row. Related objects and recovery
// projections retain their existing authorized detail path.
// Saved cards are structural conversation data. Include their local
// projection before first paint, independently of slow recovery, workspace
// and related-task enrichment. This never refreshes external providers.
if (req.query.view === "core") {
const [documentPayload, workProducts] = await Promise.all([
timing.time("documents", () => documentsSvc.getIssueDocumentPayload(issue)),
timing.time("work_products", () => workProductsSvc.listForIssue(issue.id)),
]);
res.setHeader("Server-Timing", timing.header());
res.json(issue);
res.json({ ...issue, ...documentPayload, workProducts });
return;
}
const inboxArchiveFieldsPromise =
+1 -1
View File
@@ -4261,7 +4261,7 @@ registry.registerPath({
path: "/api/issues/{id}",
tags: ["issues"],
summary: "Get an issue",
request: { params: z.object({ id: z.string() }), query: z.object({ view: z.literal("core").optional().describe("Core task fields without related enrichment.") }) },
request: { params: z.object({ id: z.string() }), query: z.object({ view: z.literal("core").optional().describe("Task fields with saved document and work-product cards, without recovery or related-task enrichment and without provider refresh.") }) },
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { and, asc, desc, eq } from "drizzle-orm";
import { and, asc, desc, eq, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { documentRevisions, documents, issueDocuments, issues } from "@paperclipai/db";
import { isSystemIssueDocumentKey, issueDocumentKeySchema } from "@paperclipai/shared";
@@ -125,7 +125,7 @@ export function documentService(db: Db) {
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, "plan")))
.then((rows) => rows[0] ?? null),
db
.select(issueDocumentSelect)
.select({ ...issueDocumentSelect, latestBody: sql<string>`''` })
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(eq(issueDocuments.issueId, issue.id))
+8
View File
@@ -52,6 +52,14 @@ include completed requests observed during the sample, even after content paint.
## Task layout stability
`compact-startup.spec.ts` keeps a synthetic local reproduction with 24 messages,
a plan, three documents and three saved PR cards. It holds the enriched task
response, records each visible frame's card count and geometry, and rejects
cards inserted after the conversation becomes readable. It also checks document,
older-comment and unknown anchors without full comments/activity/run reads.
Run it with the same loopback-only URL override below. The fixture URL is saved
in `test-results/compact-startup-fixture.json` for manual inspection and reuse.
`layout-stability.spec.ts` exercises the real task route, including Inbox
navigation, with 160 Markdown comments and a resolved interaction. It delays
initial comments, tests failures and retry, tracks a reading anchor across
@@ -0,0 +1,184 @@
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 });
}
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(); }
});
+8
View File
@@ -30,6 +30,14 @@ describe("issuesApi.list", () => {
mockApi.patch.mockResolvedValue({});
});
it("encodes a linked comment id as one path component and forwards cancellation", async () => {
const signal = new AbortController().signal;
await issuesApi.getComment("issue-1", "../../health?raw=true#fragment", { signal });
expect(mockApi.get).toHaveBeenCalledWith(
"/issues/issue-1/comments/..%2F..%2Fhealth%3Fraw%3Dtrue%23fragment", { signal },
);
});
it.each([null, "stopped-run"])("dispatches a stopped queue using its current revision (%s)", async (target) => {
mockApi.get.mockResolvedValueOnce({ queueId: "queue-1", targetRunId: null, revision: "revision-2" });
await issuesApi.interruptLatestQueuedComments("issue-1", target);
+2 -2
View File
@@ -651,8 +651,8 @@ export const issuesApi = {
`/issues/${id}/interactions/${interactionId}/verdicts`,
{ verdicts },
),
getComment: (id: string, commentId: string) =>
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`),
getComment: (id: string, commentId: string, options?: RequestOptions) =>
api.get<IssueComment>(`/issues/${id}/comments/${encodeURIComponent(commentId)}`, options),
listFeedbackVotes: (id: string) =>
api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
getCostSummary: (id: string, options: { excludeRoot?: boolean } = {}) => {
+25 -13
View File
@@ -168,6 +168,13 @@ function render(ui: ReactElement) {
);
}
async function settleThreadLayout() {
await vi.waitFor(async () => {
await act(async () => { await new Promise(resolve => requestAnimationFrame(resolve)); });
expect(container.querySelector('[data-testid="task-chat-thread"] > [aria-busy="false"]')).not.toBeNull();
});
}
describe.each(["legacy", "native"] as const)("historical %s system status", (runtimeMode) => {
const run = (id: string, status: string, minute: number) => ({
runId: id, status, runtimeMode, agentId: "agent", agentName: "Assistant",
@@ -353,9 +360,7 @@ it.each([true, false])("defers hidden content until history is ready and keeps i
render(<TaskChatThread {...props} initialHistoryPending={false} />);
const composer = container.querySelector('[data-testid="mock-editor"]');
expect(composer).not.toBeNull();
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
await settleThreadLayout();
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
expect(container.querySelector('.task-chat-history-pending')).toBeNull();
expect(container.querySelector('[data-testid="task-chat-composer-dock"]')?.hasAttribute("inert")).toBe(false);
@@ -368,14 +373,21 @@ it.each([true, false])("defers hidden content until history is ready and keeps i
expect(container.querySelector('.task-chat-history-pending')).toBeNull();
});
it.each([true, false])("keeps cold-open messages readable while history is pending (mobile=%s)", (mobile) => {
it.each([true, false])("coordinates compact cards before first reveal and retains content during refresh (mobile=%s)", async (mobile) => {
sidebarState.isMobile = mobile;
render(<TaskChatThread issueId="cold-issue" compactHistory initialHistoryPending
comments={createLongThreadComments()} historyControl={<button>More messages</button>} onAdd={async () => {}} />);
const props = { issueId: "cold-issue", compactHistory: true, comments: createLongThreadComments(), historyControl: <button>More messages</button>, onAdd: async () => {} };
render(<TaskChatThread {...props} initialHistoryPending />);
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).not.toBeNull();
render(<TaskChatThread {...props} initialHistoryPending={false} />);
await settleThreadLayout();
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull();
expect(container.textContent).toContain("Thread message 1");
expect(container.textContent).toContain("More messages");
expect(container.querySelector('[data-testid="mock-editor"]')).not.toBeNull();
const editor = container.querySelector('[data-testid="mock-editor"]');
expect(editor).not.toBeNull();
render(<TaskChatThread {...props} initialHistoryPending />);
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull();
expect(container.querySelector('[data-testid="mock-editor"]')).toBe(editor);
});
it.each([0, 32_000, 100_000])("offers earlier output only when a single legacy run exceeds the compact window (%s bytes)", (logBytes) => {
@@ -413,6 +425,7 @@ it.each([true, false])("keeps an upload attached when a conversation draft becom
const onAttachImage = vi.fn().mockReturnValue(new Promise(resolve => { resolveUpload = resolve; }));
const props = { comments: [], onAdd, onAttachImage, conversationMode: true, draftKey: "agent-chat:company:agent" };
render(<TaskChatThread {...props} issueId="" />);
await settleThreadLayout();
const composer = container.querySelector('[data-testid="mock-editor"]');
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!;
const file = new File(["notes"], "chat-notes.txt", { type: "text/plain" });
@@ -428,7 +441,7 @@ it.each([true, false])("keeps an upload attached when a conversation draft becom
const id = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
await act(async () => { resolveUpload({ id, contentPath: `/api/attachments/${id}/content`, originalFilename: file.name }); });
render(<TaskChatThread {...props} issueId="persisted-chat" initialHistoryPending={false} />);
await act(async () => { await new Promise(resolve => requestAnimationFrame(resolve)); });
await settleThreadLayout();
const chip = container.querySelector('[data-testid="task-chat-composer-attachments"] [data-slot="attachment"]');
expect(chip?.getAttribute("data-state")).toBe("done");
const send = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')!;
@@ -579,7 +592,7 @@ it("prepares comment-link history and its composer before reveal", async () => {
expect(row).not.toBeNull();
expect(row?.closest('[inert]')).not.toBeNull();
show(false);
await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); });
await settleThreadLayout();
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
expect(container.querySelector('[data-testid="mock-editor"]')).toBe(composer);
expect(container.querySelector('[data-thread-anchor="comment-1"]')).toBe(row);
@@ -642,13 +655,14 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti
nativeTranscriptState.hydratedRunIds = new Set();
});
it("reveals comments while a scheduled retry has no transcript to hydrate", () => {
it("reveals comments while a scheduled retry has no transcript to hydrate", async () => {
renderThread({
issueId: "issue-1",
comments: createLongThreadComments(),
onAdd: async () => {},
linkedRuns: [retryRun],
});
await settleThreadLayout();
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
expect(
@@ -720,9 +734,7 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti
ts: "2026-08-25T18:00:01.000Z",
}]);
render(<TaskChatThread {...props} />);
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
await settleThreadLayout();
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
expect(
+21 -10
View File
@@ -3037,10 +3037,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
: logsAreInitiallyHydrating;
});
// Hydrating a run inserts activity around its saved reply and can move the
// latest viewport substantially in the full-history view. Cold opens reveal
// the brief and recent messages independently; neither plans nor transcripts
// may hold them behind the loading screen.
const historyPending = !props.compactHistory && (initialHistoryPending || planLoading || transcriptHistoryPending);
// latest viewport substantially. Compact opens hydrate only their bounded
// recent window, but its saved cards and latest turn must arrive together so
// the first readable frame already has its final structure.
const historyPending = initialHistoryPending || planLoading || transcriptHistoryPending;
const scrollNavigation = useTaskChatScrollNavigation();
useEffect(() => {
recordIssueDetailReadiness({ plan: !planLoading, transcripts: !transcriptHistoryPending, history: !historyPending });
@@ -3055,10 +3055,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
: Boolean(logErrorsByRun?.has(run.id)),
);
const historyKey = conversationMode && draftKey ? draftKey : issueId;
const [revealedHistoryKey, setRevealedHistoryKey] = useState<string | null | undefined>(
() => (historyPending ? undefined : historyKey),
);
const [revealedHistoryKey, setRevealedHistoryKey] = useState<string | null | undefined>(undefined);
const historyRevealed = revealedHistoryKey === historyKey;
const historyShellRef = useRef<HTMLDivElement>(null);
const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ historyKey: typeof historyKey } | null>(null);
const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.historyKey === historyKey;
const prepareInitialContent = Boolean(scrollNavigation.hash) || !historyPending || historyRevealed || historyWaitExpired;
@@ -3073,12 +3072,23 @@ export function TaskChatThread(props: TaskChatThreadProps) {
}, INITIAL_HISTORY_REVEAL_TIMEOUT_MS);
return () => window.clearTimeout(timer);
}, [historyRevealed, historyKey]);
// Mount and measure the real thread while concealed, then reveal in one
// commit. A frame also lets ancestor navigation scroll restoration finish.
// Mount and measure the real thread while concealed. The properties pane
// can still be opening when data becomes ready; revealing during its width
// transition rewraps cards and races initial anchor/focus scrolling.
// Readiness is latched per issue: refetches never hide existing conversation.
useEffect(() => {
if (historyRevealed || historyPending) return;
const frame = requestAnimationFrame(() => setRevealedHistoryKey(historyKey));
let previousWidth: number | undefined;
let stableFrames = 0;
let frame: number;
const revealWhenSettled = () => {
const width = historyShellRef.current?.getBoundingClientRect().width;
stableFrames = width === previousWidth ? stableFrames + 1 : 0;
previousWidth = width;
if (stableFrames >= 2) setRevealedHistoryKey(historyKey);
else frame = requestAnimationFrame(revealWhenSettled);
};
frame = requestAnimationFrame(revealWhenSettled);
return () => cancelAnimationFrame(frame);
}, [historyPending, historyRevealed, historyKey]);
useEffect(() => {
@@ -3105,6 +3115,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
isMobile && !historyRevealed && "task-chat-history-pending",
)}
data-testid="task-chat-thread"
ref={historyShellRef}
>
<div
className={cn(
@@ -279,7 +279,11 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
<div className="min-w-0 flex-1">
<strong className={cn("block text-sm font-medium text-foreground", expandable ? detailsOpen ? "break-words" : "line-clamp-2 break-words" : "truncate")}>{workProduct.title}</strong>
{visibleMeta.length > 0 ? <p className="mt-1 truncate text-xs text-muted-foreground">{visibleMeta.join(" · ")}</p> : null}
{statsLabel ? <p className="mt-1 whitespace-nowrap text-xs text-muted-foreground">{statsLabel}</p> : null}
{statsLabel || workProduct.type === "pull_request" ? (
// Provider refresh can supply these counts after first paint. Keep
// the line's geometry while the saved PR card is already readable.
<p className="mt-1 min-h-4 whitespace-nowrap text-xs text-muted-foreground" aria-hidden={!statsLabel}>{statsLabel || "\u00a0"}</p>
) : null}
{!compact && summary && !linklessBranch ? <p className={cn("mt-1 text-xs text-muted-foreground", detailsOpen ? "whitespace-pre-wrap break-words" : "line-clamp-2")}>{summary}</p> : null}
{expandable && detailsOpen ? (
<div className="mt-2 flex flex-col gap-1 text-xs text-muted-foreground">
+29 -3
View File
@@ -52,6 +52,33 @@ it.each([null, plan])("uses the fetched task's plan without another loading roun
expect(host.textContent).toBe(value?.body ?? "No plan");
expect(issuesApi.getDocument).not.toHaveBeenCalled();
});
it.each([null, plan])("uses core plan data while a separate plan request remains pending: %s", async (value) => {
let release!: () => void;
vi.mocked(issuesApi.getDocument).mockReturnValue(new Promise<IssueDocument>((resolve, reject) => {
release = () => value ? resolve(value) : reject(new ApiError("Not found", 404, null));
}));
await mount();
expect(host.firstElementChild?.getAttribute("data-loading")).toBe("true");
try {
await act(async () => { await fetchDetail(value); });
await vi.waitFor(() => {
expect(host.firstElementChild?.getAttribute("data-loading")).toBe("false");
expect(host.textContent).toBe(value?.body ?? "No plan");
});
} finally {
await act(async () => { release(); });
}
});
it.each([null, plan])("recovers an errored plan query from the core projection: %s", async (value) => {
vi.mocked(issuesApi.getDocument).mockRejectedValue(new Error("Plan request unavailable"));
await mount();
await vi.waitFor(() => expect(host.firstElementChild?.getAttribute("data-error")).toBe("true"));
await act(async () => { await fetchDetail(value); });
await vi.waitFor(() => {
expect(host.firstElementChild?.getAttribute("data-error")).toBe("false");
expect(host.textContent).toBe(value?.body ?? "No plan");
});
});
it("revalidates document events after seeding an absent plan, including deletion", async () => {
await fetchDetail(null);
await mount();
@@ -79,13 +106,12 @@ it("fetches normally for list-only snapshots and responses without a plan projec
expect(issuesApi.getDocument).toHaveBeenCalledExactlyOnceWith(issue.id, "plan");
expect(host.firstElementChild?.getAttribute("data-loading")).toBe("true");
});
it("does not overwrite a separately fetched plan when task detail finishes later", async () => {
const newerPlan = { ...plan, body: "Newer revision", latestRevisionId: "revision-2" };
it.each([null, { ...plan, body: "Newer revision", latestRevisionId: "revision-2" }])("does not overwrite a separately fetched plan when task detail finishes later: %s", async (newerPlan) => {
client.setQueryData(planKey, newerPlan);
await fetchDetail(plan);
await mount();
expect(client.getQueryData(planKey)).toEqual(newerPlan);
expect(host.textContent).toBe("Newer revision");
expect(host.textContent).toBe(newerPlan?.body ?? "No plan");
});
it("does not mistake a refreshed list snapshot for a fresh plan after query eviction", async () => {
await fetchDetail(null);
+23 -4
View File
@@ -116,16 +116,35 @@ export function seedIssueDetailCache(
return merged;
}
/** Seed saved projections without replacing lists already read or refreshed. */
export function seedIssueDetailProjections(
queryClient: QueryClient,
issue: Issue,
issueRef?: string | null,
) {
const planKey = [...queryKeys.issues.documents(issue.id), "plan"];
if (issue.planDocument !== undefined && queryClient.getQueryData(planKey) === undefined) {
queryClient.setQueryData(planKey, issue.planDocument);
}
if (issue.workProducts !== undefined) {
for (const ref of collectIssueRefs(issueRef, issue)) {
const key = queryKeys.issues.workProducts(ref);
// The stored request can already be pending when core arrives. Its
// background refresh should not hold the first saved cards behind a gate.
if (queryClient.getQueryData(key) === undefined) {
queryClient.setQueryData(key, issue.workProducts);
}
}
}
}
export async function fetchIssueDetail(
queryClient: QueryClient,
issueRef: string,
options?: { signal?: AbortSignal },
): Promise<Issue> {
const issue = options ? await issuesApi.get(issueRef, options) : await issuesApi.get(issueRef);
const planKey = [...queryKeys.issues.documents(issue.id), "plan"];
if (issue.planDocument !== undefined && !queryClient.getQueryState(planKey)) {
queryClient.setQueryData(planKey, issue.planDocument);
}
seedIssueDetailProjections(queryClient, issue, issueRef);
return seedIssueDetailCache(queryClient, issue, { issueRef });
}
+78 -3
View File
@@ -60,6 +60,7 @@ vi.mock("../api/grokBot", () => ({ grokBotApi: mockGrokBotApi }));
const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(),
getCore: vi.fn(),
getComment: vi.fn(),
recentComments: vi.fn(),
list: vi.fn(),
listAll: vi.fn(),
@@ -1926,6 +1927,78 @@ describe("IssueDetail", () => {
expect(queryClient.getQueryData(["issues", "history-expanded", "PAP-1"])).toBe(true);
});
it.each(["empty", "saved"])("reveals core work products while the stored request is stalled (%s)", async (projection) => {
const workProducts = projection === "empty" ? [] : [createArtifactWorkProduct({
id: "saved-output", attachmentId: "00000000-0000-4000-8000-000000000001", contentType: "text/plain", originalFilename: "evidence.txt",
})];
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
mockIssuesApi.getCore.mockResolvedValue(createIssue({ workProducts }));
mockIssuesApi.get.mockReturnValue(createDeferred<Issue>().promise);
mockIssuesApi.listWorkProducts.mockReturnValue(createDeferred<[]>().promise);
await act(async () => renderIssueView());
await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
initialHistoryPending: false, workProducts,
}));
expect(mockIssuesApi.listWorkProducts).toHaveBeenCalled();
});
it.each(["..", "../../../../health", "invalid"])("ignores a malformed comment anchor %s", async (target) => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
mockLocation.hash = `#comment-${target}`;
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => renderIssueView());
await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
compactHistory: true, initialHistoryPending: false,
}));
expect(mockIssuesApi.getComment).not.toHaveBeenCalled();
expect(mockIssuesApi.listComments).not.toHaveBeenCalled();
});
it.each(["#document-qa-evidence", "#document-plan", "#document-continuation-summary", "#work-product-one", "#attachment-one", "#unknown", "#comment-6e391846-d4aa-410f-9d9a-6a60d74bce37"])("keeps the recent conversation for anchor %s", async (hash) => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
mockLocation.hash = hash;
mockIssuesApi.get.mockResolvedValue(createIssue());
const recent = createIssueComment({ id: "recent-comment", body: "Latest reply" });
const older = createIssueComment({ id: "6e391846-d4aa-410f-9d9a-6a60d74bce37", body: "Linked older reply" });
mockIssuesApi.getComment.mockResolvedValue(older);
mockIssuesApi.recentComments.mockResolvedValue({ comments: [recent], latestCommentId: recent.id, hasMore: true });
await act(async () => renderIssueView());
await waitForAssertion(() => {
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ compactHistory: true });
expect(mockIssuesApi.recentComments).toHaveBeenCalled();
});
expect(mockIssuesApi.listComments).not.toHaveBeenCalled();
expect(mockActivityApi.forIssue).not.toHaveBeenCalled();
expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled();
if (hash.startsWith("#comment-")) {
await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].comments).toEqual(expect.arrayContaining([
expect.objectContaining({ id: older.id }), expect.objectContaining({ id: recent.id }),
])));
expect(mockIssuesApi.getComment).toHaveBeenCalledWith("issue-1", older.id, expect.anything());
} else {
expect(mockIssuesApi.getComment).not.toHaveBeenCalled();
}
});
it("retains a linked older comment when opening a document without moving the preview cursor", async () => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
mockLocation.hash = "#comment-6e391846-d4aa-410f-9d9a-6a60d74bce37";
mockIssuesApi.get.mockResolvedValue(createIssue());
const recent = createIssueComment({ id: "recent-comment", body: "Latest reply" });
const older = createIssueComment({ id: "6e391846-d4aa-410f-9d9a-6a60d74bce37", body: "Read my saved evidence" });
mockIssuesApi.getComment.mockResolvedValue(older);
mockIssuesApi.recentComments.mockResolvedValue({ comments: [recent], latestCommentId: recent.id, hasMore: true });
await act(async () => renderIssueView());
const props = () => mockIssueChatThreadRender.mock.calls.at(-1)![0];
await waitForAssertion(() => expect(props().comments).toEqual(expect.arrayContaining([expect.objectContaining({ id: older.id })])));
mockLocation.hash = "#document-qa-evidence";
await act(async () => renderIssueView());
await waitForAssertion(() => expect(props().comments).toEqual(expect.arrayContaining([expect.objectContaining({ id: older.id }), expect.objectContaining({ id: recent.id })])));
expect(queryClient.getQueryData<IssueRecentComments>([...queryKeys.issues.comments("PAP-1"), "recent"])?.latestCommentId).toBe(recent.id);
expect(mockIssuesApi.listComments).not.toHaveBeenCalled();
expect(mockActivityApi.forIssue).not.toHaveBeenCalled();
});
it.each([false, true])("loads older history after sending from a cold view (reassign=%s)", async (reassign) => {
queryClient.setDefaultOptions({ queries: { retry: false, staleTime: 30_000 } });
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
@@ -2492,7 +2565,7 @@ describe("IssueDetail", () => {
});
});
it("routes plan to the Plan pane tab and leaves continuation-summary on its existing surface", async () => {
it("opens plan and continuation-summary in the pane without switching to full activity", async () => {
mockPanelState.panelVisible = false;
mockLocation.hash = "#document-plan";
mockIssuesApi.get.mockResolvedValue(createIssue());
@@ -2514,11 +2587,11 @@ describe("IssueDetail", () => {
await act(async () => {
renderIssueView();
});
expect(mockSetPanelVisible).not.toHaveBeenCalled();
expect(mockSetPanelVisible).toHaveBeenCalledWith(true);
await waitForAssertion(() => {
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.documentDeepLink).toBeNull();
expect(panel?.props?.documentDeepLink).toMatchObject({ tab: "document", documentKey: "continuation-summary" });
});
});
@@ -6634,6 +6707,7 @@ describe("IssueDetail", () => {
comments: [earlier, comment], latestCommentId: comment.id, hasMore: true,
});
queryClient.setQueryData(queryKeys.issues.comments(ref), { pages: [[comment, earlier]], pageParams: [null] });
queryClient.setQueryData([...queryKeys.issues.comments(ref), "linked", comment.id], comment);
}
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "in_progress", assigneeAgentId: "agent-1" }));
mockAgentsApi.list.mockResolvedValue([createAgent({ adapterType: "paperclip_runner" })]);
@@ -6670,6 +6744,7 @@ describe("IssueDetail", () => {
await waitForAssertion(() => {
for (const ref of refs) {
const recent = queryClient.getQueryData<IssueRecentComments>([...queryKeys.issues.comments(ref), "recent"]);
expect(queryClient.getQueryData([...queryKeys.issues.comments(ref), "linked", comment.id])).toEqual(action === "delete" ? deleted : null);
expect(recent?.comments).toEqual(after);
expect(recent?.latestCommentId).toBe(after.at(-1)!.id);
expect(queryClient.getQueryData<{ pages: IssueComment[][] }>(queryKeys.issues.comments(ref))?.pages.flat()).toEqual([...after].reverse());
+58 -14
View File
@@ -136,7 +136,7 @@ import {
shouldTrackIssueActiveRun,
} from "../lib/issueActiveRun";
import { prefetchIssueThread } from "../lib/issue-thread-queries";
import { getIssueDetailQueryOptions } from "../lib/issueDetailCache";
import { getIssueDetailQueryOptions, seedIssueDetailProjections } from "../lib/issueDetailCache";
import {
beginIssueDetailNavigation,
recordIssueDetailReadiness,
@@ -392,6 +392,7 @@ import {
type IssueTreeControlMode,
type WorkspaceFileRef,
workspaceFileRefSchema,
isUuidLike,
} from "@paperclipai/shared";
// Stable empty array for React Query `data` defaults. A literal `= []` default
@@ -2634,7 +2635,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
browsers={browsers}
onOpenBrowser={onOpenBrowser}
hasOlderComments={hasOlderComments}
initialHistoryPending={historyExpanded && !!issueId && (
initialHistoryPending={!!issueId && (
initialHistoryPending ||
commentsInitialLoading ||
// These responses add rows or change the composer takeover.
@@ -2643,6 +2644,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
initialMetadataPending ||
activityPending ||
linkedRunsPending ||
inputFacts.isLoading ||
!runtimeSelectionKnown)
}
initialHistoryError={
@@ -3334,12 +3336,16 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const coreIssueQuery = useQuery({
queryKey: [...queryKeys.issues.detail(issueId!), "core"],
queryFn: ({ signal }) => issuesApi.getCore(issueId!, { signal }),
queryFn: async ({ signal }) => {
const core = await issuesApi.getCore(issueId!, { signal });
seedIssueDetailProjections(queryClient, core, issueId);
return core;
},
enabled: !!issueId,
});
const { historyExpanded, expandHistory, recent: recentComments } = useIssueHistoryWindow(
issueId,
Boolean(conversation || classicTaskInterfaceEnabled || location.hash || detailTab !== "chat"),
Boolean(conversation || classicTaskInterfaceEnabled),
coreIssueQuery.data,
);
const issueQuery = useQuery({
@@ -3442,10 +3448,36 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
InfiniteData<IssueComment[], string | null>
>(issueId ?? "pending"),
});
const comments = useMemo(
const commentAnchor = location.hash.startsWith("#comment-")
? location.hash.slice("#comment-".length)
: null;
const linkedCommentId = commentAnchor && isUuidLike(commentAnchor) ? commentAnchor.trim() : null;
const windowComments = useMemo(
() => mergeWindowComments(recentComments.data?.comments ?? [], flattenIssueCommentPages(commentPages?.pages)),
[commentPages?.pages, recentComments.data?.comments],
);
const linkedComment = useQuery({
queryKey: [...queryKeys.issues.comments(issueId!), "linked", linkedCommentId],
queryFn: ({ signal }) => issuesApi.getComment(issue!.id, linkedCommentId!, { signal }),
enabled: !!issue && !!linkedCommentId && !historyExpanded && !windowComments.some(comment => comment.id === linkedCommentId),
});
useEffect(() => {
const target = linkedComment.data;
if (!target || !recentComments.data) return;
// A document click changes the hash, not the reader's conversation. Keep
// every fetched target in the observed window so that click cannot remove
// its source row. The preview cursor still belongs to the latest reply.
queryClient.setQueryData<IssueRecentComments>(
[...queryKeys.issues.comments(issueId!), "recent"],
current => !current || current.comments.some(comment => comment.id === target.id)
? current
: { ...current, comments: mergeWindowComments([target], current.comments) },
);
}, [issueId, linkedComment.data, recentComments.data, queryClient]);
const comments = useMemo(
() => mergeWindowComments(linkedComment.data ? [linkedComment.data] : [], windowComments),
[linkedComment.data, windowComments],
);
useLayoutEffect(() => {
beginIssueDetailNavigation();
@@ -3471,14 +3503,11 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
ISSUE_DETAIL_CONTENT_MEASURE,
);
}, [classicTaskInterfaceEnabled, commentsLoading, issue?.id]);
const linkedCommentId = location.hash.startsWith("#comment-")
? location.hash.slice("#comment-".length)
: null;
const linkedCommentPending = Boolean(
linkedCommentId &&
!comments.some((comment) => comment.id === linkedCommentId) &&
!commentsError &&
(commentsLoading || hasOlderComments),
!(historyExpanded ? commentsError : linkedComment.isError) &&
(historyExpanded ? commentsLoading || hasOlderComments : linkedComment.isPending),
);
const shouldPrefetchOlderComments = useMemo(
() =>
@@ -4264,6 +4293,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(ref) }),
));
for (const ref of issueCacheRefs) {
queryClient.setQueryData<IssueComment | null>(
[...queryKeys.issues.comments(ref), "linked", commentId],
current => current ? null : current,
);
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
queryKeys.issues.comments(ref),
(current) => current && { ...current, pages: removeIssueCommentFromPages(current.pages, commentId) },
@@ -4306,6 +4339,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(ref) }),
));
for (const ref of issueCacheRefs) {
queryClient.setQueryData<IssueComment | null>(
[...queryKeys.issues.comments(ref), "linked", comment.id],
current => current ? comment : current,
);
queryClient.setQueryData<
InfiniteData<IssueComment[], string | null> | undefined
>(queryKeys.issues.comments(ref), (current) =>
@@ -6342,7 +6379,12 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const lastMaximizeRequestKeyRef = useRef<string | null>(null);
const routeIssueDocumentDeepLink = useCallback(
(hash: string) => {
const route = resolveIssueDocumentDeepLink(hash);
const resolvedRoute = resolveIssueDocumentDeepLink(hash);
// A handoff is a saved document too. Opening it must not switch the
// conversation to full activity just because it has a legacy hash.
const route = resolvedRoute?.kind === "continuation-summary" && taskChatShellEnabled
? { kind: "properties-pane" as const, tab: "document" as const, documentKey: "continuation-summary", maximize: false }
: resolvedRoute;
if (!route) return false;
if (route.kind === "continuation-summary") {
@@ -6689,13 +6731,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
useEffect(() => {
if (
!shouldPrefetchOlderComments &&
!(linkedCommentPending && hasOlderComments && !commentsLoadingOlder)
!(historyExpanded && linkedCommentPending && hasOlderComments && !commentsLoadingOlder)
)
return;
void fetchOlderComments();
}, [
fetchOlderComments,
shouldPrefetchOlderComments,
historyExpanded,
linkedCommentPending,
hasOlderComments,
commentsLoadingOlder,
@@ -8446,13 +8489,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
workProductsLoading
}
initialHistoryError={
(historyExpanded ? commentsError : recentComments.isError) ||
(historyExpanded ? commentsError : recentComments.isError || linkedComment.isError) ||
interactionsError ||
attachmentsError ||
workProductsError
}
onRetryInitialHistory={() => {
void (historyExpanded ? refetchComments() : recentComments.refetch());
if (linkedCommentId && !historyExpanded) void linkedComment.refetch();
void refetchInteractions();
void refetchAttachments();
void refetchWorkProducts();
@@ -8460,7 +8504,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
interactions={interactions}
documents={issue.documentSummaries ?? []}
workProducts={workProducts ?? []}
workProducts={workProducts ?? issue.workProducts ?? []}
attachments={attachments ?? []}
hasOlderComments={hasOlderComments}
commentsLoadingOlder={commentsLoadingOlder}