mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 01:27:09 +01:00
fix(tasks): keep anchored opens compact and stabilize saved cards
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
063cfd4381
commit
e6166108a5
16 files changed
+347
-39
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,39 @@
|
||||
# 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.
|
||||
Focused UI tests: 507 pass. Focused server tests: 16 pass. Token and map checks pass.
|
||||
Final browser checks, full workspace typecheck/tests/build and hosted review are
|
||||
in progress. 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.
|
||||
|
||||
Local reproduction: http://127.0.0.1:3223/TAS/issues/TAS-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/` until final attachment.
|
||||
|
||||
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`
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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,26 @@ 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 and document metadata 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 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.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([
|
||||
|
||||
@@ -9353,11 +9353,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 =
|
||||
|
||||
@@ -4254,7 +4254,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,168 @@
|
||||
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 }) => {
|
||||
try {
|
||||
const saved = JSON.parse(await fs.readFile(fixturePath, "utf8"));
|
||||
if ((await request.get(`/api/issues/${saved.id}`)).ok()) {
|
||||
fixture = saved;
|
||||
const summary = await request.put(`/api/issues/${fixture.id}/documents/continuation-summary`, { data: { title: "Continuation summary", format: "markdown", body: "# Continuation summary\n\nSaved handoff evidence." } });
|
||||
expect(summary.ok()).toBeTruthy();
|
||||
return;
|
||||
}
|
||||
} catch { /* A fresh local instance needs its own synthetic task. */ }
|
||||
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("saved rich cards are present at first reveal and never insert 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, 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(); });
|
||||
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; 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);
|
||||
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("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(); }
|
||||
});
|
||||
@@ -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/${commentId}`, options),
|
||||
listFeedbackVotes: (id: string) =>
|
||||
api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
|
||||
getCostSummary: (id: string, options: { excludeRoot?: boolean } = {}) => {
|
||||
|
||||
@@ -368,14 +368,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 act(async () => { await new Promise(resolve => requestAnimationFrame(resolve)); });
|
||||
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) => {
|
||||
|
||||
@@ -2978,10 +2978,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 });
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -53,6 +53,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(),
|
||||
@@ -1830,6 +1831,32 @@ describe("IssueDetail", () => {
|
||||
expect(queryClient.getQueryData(["issues", "history-expanded", "PAP-1"])).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["#document-qa-evidence", "#document-plan", "#document-continuation-summary", "#work-product-one", "#attachment-one", "#unknown", "#comment-old-comment"])("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: "old-comment", 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.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);
|
||||
@@ -2377,7 +2404,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());
|
||||
@@ -2399,11 +2426,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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6519,6 +6546,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" })]);
|
||||
@@ -6555,6 +6583,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());
|
||||
|
||||
@@ -2625,7 +2625,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.
|
||||
@@ -2634,6 +2634,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
initialMetadataPending ||
|
||||
activityPending ||
|
||||
linkedRunsPending ||
|
||||
inputFacts.isLoading ||
|
||||
!runtimeSelectionKnown)
|
||||
}
|
||||
initialHistoryError={
|
||||
@@ -3324,12 +3325,19 @@ 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 });
|
||||
const planKey = [...queryKeys.issues.documents(core.id), "plan"];
|
||||
if (core.planDocument !== undefined && !queryClient.getQueryState(planKey)) {
|
||||
queryClient.setQueryData(planKey, core.planDocument);
|
||||
}
|
||||
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({
|
||||
@@ -3432,10 +3440,22 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
InfiniteData<IssueComment[], string | null>
|
||||
>(issueId ?? "pending"),
|
||||
});
|
||||
const comments = useMemo(
|
||||
const linkedCommentId = location.hash.startsWith("#comment-")
|
||||
? location.hash.slice("#comment-".length)
|
||||
: 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),
|
||||
});
|
||||
const comments = useMemo(
|
||||
() => mergeWindowComments(linkedComment.data ? [linkedComment.data] : [], windowComments),
|
||||
[linkedComment.data, windowComments],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
beginIssueDetailNavigation();
|
||||
@@ -3461,14 +3481,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(
|
||||
() =>
|
||||
@@ -4219,6 +4236,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) },
|
||||
@@ -4261,6 +4282,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) =>
|
||||
@@ -6279,7 +6304,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") {
|
||||
@@ -6626,13 +6656,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,
|
||||
@@ -8370,13 +8401,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();
|
||||
@@ -8384,7 +8416,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