mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
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:
1 parent
2a6d4d1694
commit
7e27bc5007
19 files changed
+545
-63
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } = {}) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user