diff --git a/DESIGN.md b/DESIGN.md index 9403a681136..963d5f12124 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/doc/plans/2026-10-28-stable-task-anchor-loading.md b/doc/plans/2026-10-28-stable-task-anchor-loading.md new file mode 100644 index 00000000000..b575500210b --- /dev/null +++ b/doc/plans/2026-10-28-stable-task-anchor-loading.md @@ -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. diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md index ce801ef5f2e..34916f952a9 100644 --- a/maps/agent-access-map/tasks.md +++ b/maps/agent-access-map/tasks.md @@ -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. diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md index 37e39a58ce3..3e196076d38 100644 --- a/maps/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -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` diff --git a/server/src/__tests__/task-cold-open.test.ts b/server/src/__tests__/task-cold-open.test.ts index 6df6495caa4..6cf02531151 100644 --- a/server/src/__tests__/task-cold-open.test.ts +++ b/server/src/__tests__/task-cold-open.test.ts @@ -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) => !("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([ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 245f2314dd6..702c6f8a305 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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 = diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index eea69ddc69e..77e43760447 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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 }, }); diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index aa6490fc9cb..2adefbd0795 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -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`''` }) .from(issueDocuments) .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) .where(eq(issueDocuments.issueId, issue.id)) diff --git a/tests/perf/issue-detail/README.md b/tests/perf/issue-detail/README.md index c93b59180cc..0b95c660338 100644 --- a/tests/perf/issue-detail/README.md +++ b/tests/perf/issue-detail/README.md @@ -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 diff --git a/tests/perf/issue-detail/compact-startup.spec.ts b/tests/perf/issue-detail/compact-startup.spec.ts new file mode 100644 index 00000000000..ea0a13ea64e --- /dev/null +++ b/tests/perf/issue-detail/compact-startup.spec.ts @@ -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(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(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(); } +}); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 5a68b1abe7e..fa432603359 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -651,8 +651,8 @@ export const issuesApi = { `/issues/${id}/interactions/${interactionId}/verdicts`, { verdicts }, ), - getComment: (id: string, commentId: string) => - api.get(`/issues/${id}/comments/${commentId}`), + getComment: (id: string, commentId: string, options?: RequestOptions) => + api.get(`/issues/${id}/comments/${commentId}`, options), listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), getCostSummary: (id: string, options: { excludeRoot?: boolean } = {}) => { diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index b4da55dad3c..7be2ab4896f 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -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(More messages} onAdd={async () => {}} />); + const props = { issueId: "cold-issue", compactHistory: true, comments: createLongThreadComments(), historyControl: , onAdd: async () => {} }; + render(); + expect(container.querySelector('[data-testid="task-chat-history-loading"]')).not.toBeNull(); + render(); + 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(); + 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) => { diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index f7705ab746e..287af7e439d 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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 }); diff --git a/ui/src/components/task-chat/RichWorkProductCard.tsx b/ui/src/components/task-chat/RichWorkProductCard.tsx index 4b5dedf7226..bb3efe73e57 100644 --- a/ui/src/components/task-chat/RichWorkProductCard.tsx +++ b/ui/src/components/task-chat/RichWorkProductCard.tsx @@ -279,7 +279,11 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
{workProduct.title} {visibleMeta.length > 0 ?

{visibleMeta.join(" · ")}

: null} - {statsLabel ?

{statsLabel}

: 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. +

{statsLabel || "\u00a0"}

+ ) : null} {!compact && summary && !linklessBranch ?

{summary}

: null} {expandable && detailsOpen ? (
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index fa780e53f31..0b6bec7919c 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -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 } | 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([...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()); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index db09bcb3077..b13952680e8 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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 >(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( + [...queryKeys.issues.comments(ref), "linked", commentId], + current => current ? null : current, + ); queryClient.setQueryData>( 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( + [...queryKeys.issues.comments(ref), "linked", comment.id], + current => current ? comment : current, + ); queryClient.setQueryData< InfiniteData | undefined >(queryKeys.issues.comments(ref), (current) => @@ -6279,7 +6304,12 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS const lastMaximizeRequestKeyRef = useRef(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}