diff --git a/DESIGN.md b/DESIGN.md index 1b740713f13..63e8b37c49f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -84,6 +84,8 @@ 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. +Standing agent chats open with the latest 20 contiguous messages and hydrate only their runs. Upward scrolling requests earlier pages; a fixed-height status slot says “Loading earlier messages…” while the page is pending. Preserve the logical row anchor and mounted composer through prepends and background refresh. Retry failed history inline without automatic retry loops. Answer receipts and created-task history use the same scrolling affordance. + Only the viewer's own human comments sit on the right in blue bubbles. Agents and other humans sit on the left on the page surface, with their own name and avatar above the message. Human identity comes from the company directory; diff --git a/maps/agent-access-map/agent-chat.md b/maps/agent-access-map/agent-chat.md index 64fe049f155..fe50d30deff 100644 --- a/maps/agent-access-map/agent-chat.md +++ b/maps/agent-access-map/agent-chat.md @@ -46,7 +46,9 @@ Review: `baseline`. API: `present` — representative operation IDs: -- `GET /api/issues/{id}/comments`. +- `GET /api/issues/{id}/recent-comments` (20 contiguous comments for a standing chat). +- `GET /api/issues/{id}/comments` (cursor pages for older history). +- `GET /api/issues/{id}/runs?runIds=...` (at most 100 requested IDs, constrained to the accessible issue and company). - `POST /api/issues/{id}/comments`. - `GET /api/issues/{id}/decision-receipts` (own standing-chat board read). - `GET /api/issues/{id}/chat-handoffs` (own standing-chat board read, paginated). diff --git a/maps/feature-map/agent-chat.md b/maps/feature-map/agent-chat.md index 506fec74016..916eadf31ff 100644 --- a/maps/feature-map/agent-chat.md +++ b/maps/feature-map/agent-chat.md @@ -27,6 +27,8 @@ Open `/chats`, choose an agent, or use `/chats/:agentRef` and its conversation s With Coordinator enabled, the composer exposes the shared model/thinking-level picker for supported agents. The agent stays fixed in this control; switching agents remains in the chat header. Choices save as conversation overrides on send, preserving the agent's configuration. A first message creates the conversation before saving settings and submitting through the ordered comments endpoint. +Standing chats open with the latest 20 contiguous messages. Scroll upward to fetch earlier messages, answer receipts, and created tasks. The loading indicator stays in the thread; history failures offer an inline Retry while preserving the draft and reading position. Opening a chat does not fetch its lifetime comment, activity, or run history. Already observed messages survive live refresh and Back within the query-cache session. + Coordinator answer receipts from this conversation jump to the answer bubble without leaving chat. Answer updates from an execution task link to that source task and its decision context. Compact document links read “Open [document title] in the sidebar”. ### `project-handoff` @@ -43,6 +45,8 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enab ### `agent-conversation` +Local browser recipe: [long-chat startup and scrolling](../../tests/perf/agent-chat/agent-chat-startup.spec.ts), with fixture and command details in the [performance README](../../tests/perf/agent-chat/README.md). It covers recent-only reveal, delayed and failed pages, Back, older comment links, and live refresh. Synthetic local data is not deployed acceptance evidence. + Automated: [chat landing](../../ui/src/pages/AgentChats.test.tsx) checks recent-chat routing; [conversation service](../../server/src/__tests__/agent-conversations.test.ts) covers persisted conversation behavior. Automated: [composer picker](../../ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx) covers fixed-agent model/effort selection on desktop and mobile; [composer](../../ui/src/components/task-chat/TaskChatComposer.test.tsx) covers Coordinator gating and settings submission without reassignment. [Task detail](../../ui/src/pages/IssueDetail.test.tsx) verifies canonical first/existing-chat IDs, settings-before-comment ordering, and no comment submission when settings fail to save. These tests mock the API and do not prove provider execution with the chosen model. diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index ada9938813b..6c4c07c36cd 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1779,6 +1779,7 @@ export interface IssueAttachment { /** Bounded cold-open window; the complete chronological feed stays paginated. */ export interface IssueRecentComments { latestCommentId: string | null; + oldestCommentId?: string | null; comments: IssueComment[]; hasMore: boolean; } diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index e8f7d67d258..0c507b0a157 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -333,6 +333,20 @@ describe("activity routes", () => { expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]); }); + it("validates bounded run IDs before using the authorized issue projection", async () => { + const runId = "10000000-0000-4000-8000-000000000001"; + mockIssueService.getByIdentifier.mockResolvedValue({ id: "issue-uuid-1", companyId: "company-1" }); + mockActivityService.runsForIssue.mockResolvedValue([{ runId }]); + const app = await createApp(); + const response = await requestApp(app, base => request(base).get(`/api/issues/PAP-1/runs?runIds=${runId}`)); + expect(response.status).toBe(200); + expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1", "user-1", undefined, [runId]); + for (const ids of ["", "not-a-uuid", Array.from({ length: 101 }, () => runId).join(",")]) { + const denied = await requestApp(app, base => request(base).get("/api/issues/PAP-1/runs").query({ runIds: ids })); + expect(denied.status).toBe(400); + } + }); + it("requires company access before creating activity events", async () => { const app = await createApp(); const res = await requestApp(app, (baseUrl) => request(baseUrl) diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 826fe78ec31..68f1c5eda6f 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -1090,6 +1090,7 @@ describe("agent issue mutation checkout ownership", () => { ]); expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" })); expect(mockIssueService.listComments).toHaveBeenCalledWith(issueId, { + companyId, afterCommentId: null, order: "desc", limit: null, diff --git a/server/src/__tests__/task-cold-open.test.ts b/server/src/__tests__/task-cold-open.test.ts index 15fc364a644..3d6aac70b08 100644 --- a/server/src/__tests__/task-cold-open.test.ts +++ b/server/src/__tests__/task-cold-open.test.ts @@ -3,7 +3,7 @@ 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, issueWorkProducts } from "@paperclipai/db"; +import { activityLog, agents, applyPendingMigrations, closeRegisteredClients, 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"; @@ -11,16 +11,25 @@ import { createHeartbeatRunState } from "../services/heartbeat/run-state.js"; import { activityService } from "../services/activity.js"; import { documentService } from "../services/documents.js"; -const support = await getEmbeddedPostgresTestSupport(); +const externalDatabase = process.env.PAPERCLIP_TASK_COLD_OPEN_DATABASE_URL; +if (externalDatabase) { + const target = new URL(externalDatabase); + if (!["localhost", "127.0.0.1", "[::1]"].includes(target.hostname) || !target.pathname.endsWith("_test")) + throw new Error("PAPERCLIP_TASK_COLD_OPEN_DATABASE_URL must name a disposable loopback database ending in _test"); +} +const support = externalDatabase ? { supported: true } : await getEmbeddedPostgresTestSupport(); (support.supported ? describe : describe.skip)("task cold-open reads", () => { - let fixture: Awaited>; + let fixture: Pick>, "connectionString" | "cleanup">; let db: ReturnType; const companyId = randomUUID(); const issueId = randomUUID(); const agentId = randomUUID(); const runId = randomUUID(); beforeAll(async () => { - fixture = await startEmbeddedPostgresTestDatabase("task-cold-open-"); + if (externalDatabase) { + await applyPendingMigrations(externalDatabase); + fixture = { connectionString: externalDatabase, cleanup: () => closeRegisteredClients(externalDatabase) }; + } else fixture = await startEmbeddedPostgresTestDatabase("task-cold-open-"); db = createDb(fixture.connectionString); await db.insert(companies).values({ id: companyId, name: "Cold open", issuePrefix: `T${companyId.slice(0, 6)}` }); await db.insert(agents).values({ id: agentId, companyId, name: "Writer", role: "engineer", adapterType: "codex_local" }); @@ -79,6 +88,39 @@ const support = await getEmbeddedPostgresTestSupport(); expect(response.body.latestCommentId).toBe(response.body.comments[1].id); expect([403, 404]).toContain((await request(app(randomUUID())).get(`/api/issues/${issueId}/recent-comments`)).status); }); + it("opens long chats with a contiguous recent window and pages every older message", async () => { + const chatId = randomUUID(); + await db.insert(issues).values({ id: chatId, companyId, title: "Persistent chat", conversationAgentId: agentId, conversationUserId: "local-board", assigneeAgentId: agentId, conversationState: "waiting" }); + const rows = Array.from({ length: 123 }, (_, i) => ({ id: randomUUID(), companyId, issueId: chatId, + authorType: "user" as const, authorUserId: "local-board", body: `Chat ${i}`, createdAt: new Date(1000 + Math.floor(i / 3)) })); + await db.insert(issueComments).values(rows); + const ordered = [...rows].sort((a, b) => +b.createdAt - +a.createdAt || b.id.localeCompare(a.id)); + const response = await request(app()).get(`/api/issues/${chatId}/recent-comments`); + expect(response.status).toBe(200); + expect(response.body.comments.map((row: { id: string }) => row.id)).toEqual(ordered.slice(0, 20).map(row => row.id)); + expect(response.body.oldestCommentId).toBe(ordered[19].id); + expect(response.body.hasMore).toBe(true); + const seen = response.body.comments.map((row: { id: string }) => row.id); + let cursor = response.body.oldestCommentId; + while (cursor) { + const page = await request(app()).get(`/api/issues/${chatId}/comments`).query({ order: "desc", limit: 50, after: cursor }); + expect(page.status).toBe(200); + seen.push(...page.body.map((row: { id: string }) => row.id)); + cursor = page.body.at(-1)?.id; + } + expect(seen).toEqual(ordered.map(row => row.id)); + expect(new Set(seen).size).toBe(123); + expect([403, 404]).toContain((await request(app(randomUUID())).get(`/api/issues/${chatId}/recent-comments`)).status); + }); + it("hydrates only requested runs linked to the company and issue", async () => { + const otherIssue = randomUUID(), otherRun = randomUUID(); + await db.insert(issues).values({ id: otherIssue, companyId, title: "Other task" }); + await db.insert(heartbeatRuns).values({ id: otherRun, companyId, agentId, issueId: otherIssue, status: "succeeded" }); + const activity = activityService(db); + expect((await activity.runsForIssue(companyId, issueId, null, undefined, [runId, otherRun])).map(run => run.runId)).toEqual([runId]); + expect(await activity.runsForIssue(randomUUID(), issueId, null, undefined, [runId])).toEqual([]); + expect(await activity.runsForIssue(companyId, issueId, null, undefined, [])).toEqual([]); + }); it.each(["user", null] as const)("preserves initial and latest human instructions without a description (authorType=%s)", async (authorType) => { const bareIssueId = randomUUID(); await db.insert(issues).values({ id: bareIssueId, companyId, title: "No description" }); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index fd46f3d1208..7a9b187dbab 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -373,6 +373,14 @@ export function activityRoutes(db: Db) { if (!issue) return; if (!(await assertIssueReadAllowed(req, res, issue))) return; const viewerUserId = req.actor.type === "board" ? getActorInfo(req).actorId : null; + if (req.query.runIds !== undefined) { + const parsed = z.array(z.string().uuid()).min(1).max(100).safeParse( + typeof req.query.runIds === "string" ? req.query.runIds.split(",") : null, + ); + if (!parsed.success) throw badRequest("runIds must contain 1 to 100 UUIDs"); + res.json(await svc.runsForIssue(issue.companyId, issue.id, viewerUserId, undefined, parsed.data)); + return; + } const result = await (req.query.view === "recent" ? svc.runsForIssue(issue.companyId, issue.id, viewerUserId, 2) : svc.runsForIssue(issue.companyId, issue.id, viewerUserId)); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index b168d12beaf..cf86556ce5e 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -16449,12 +16449,23 @@ export function issueRoutes( router.get("/issues/:id/recent-comments", async (req, res) => { const issue = await getAccessibleResource(req, res, getIssueById(req, req.params.id as string), "Issue not found"); if (!issue || !(await assertIssueReadAllowed(req, res, issue))) return; + if (issue.conversationAgentId) { + const recent = await svc.listComments(issue.id, { companyId: issue.companyId, order: "desc", limit: 21 }); + const comments = recent.slice(0, 20); + res.json(await runRedactions.redactForIssue(issue.companyId, issue.id, { + comments, + latestCommentId: comments[0]?.id ?? null, + oldestCommentId: comments.at(-1)?.id ?? null, + hasMore: recent.length > 20, + })); + return; + } const [recent, human, firstInstruction] = await Promise.all([ - svc.listComments(issue.id, { order: "desc", limit: 5 }), - svc.listComments(issue.id, { order: "desc", limit: 1, humanOnly: true }), + svc.listComments(issue.id, { companyId: issue.companyId, order: "desc", limit: 5 }), + svc.listComments(issue.id, { companyId: issue.companyId, order: "desc", limit: 1, humanOnly: true }), issue.description?.trim() ? Promise.resolve([]) - : svc.listComments(issue.id, { order: "asc", limit: 1, humanOnly: true }), + : svc.listComments(issue.id, { companyId: issue.companyId, order: "asc", limit: 1, humanOnly: true }), ]); const comments = [...new Map([...firstInstruction, ...human, ...recent.slice(0, 4)].map((comment) => [comment.id, comment])).values()]; res.json(await runRedactions.redactForIssue(issue.companyId, issue.id, { @@ -16494,7 +16505,8 @@ export function issueRoutes( limitRaw && Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), MAX_ISSUE_COMMENT_LIMIT) : null; - const comments = await svc.listComments(id, { + const comments = await svc.listComments(issue.id, { + companyId: issue.companyId, afterCommentId, order, limit, diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 7b10759ed26..ff110a52e18 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -4599,7 +4599,7 @@ registry.registerPath({ path: "/api/issues/{id}/recent-comments", tags: ["issues"], summary: "Read a bounded recent task exchange", - description: "Returns four recent comments, the latest human comment, and the first human instruction when the task has no description. Includes latestCommentId and hasMore. Uses the same authorization and redaction as full comment reads.", + description: "Returns four recent comments, the latest human comment, and the first human instruction when the task has no description. Agent chats instead return the latest 20 contiguous comments and oldestCommentId for backward cursor paging. Includes latestCommentId and hasMore. Uses the same authorization and redaction as full comment reads.", request: { params: z.object({ id: z.string() }) }, responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, }); @@ -6091,8 +6091,8 @@ registry.registerPath({ path: "/api/issues/{id}/runs", tags: ["activity"], summary: "List runs for an issue", - request: { params: z.object({ id: z.string() }), query: z.object({ view: z.literal("recent").optional().describe("At most two recent run summaries.") }) }, - responses: { 200: r.ok(), 401: r.unauthorized }, + request: { params: z.object({ id: z.string() }), query: z.object({ view: z.literal("recent").optional().describe("At most two recent run summaries."), runIds: z.string().optional().describe("Comma-separated list of 1 to 100 run UUIDs. Returns only those runs linked to this accessible issue.") }) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, }); registry.registerPath({ diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index b256bf358ff..bac15a5239f 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -428,7 +428,8 @@ export function activityService(db: Db) { return limit === undefined ? query : query.limit(limit); }, - runsForIssue: async (companyId: string, issueId: string, viewerUserId: string | null = null, limit?: number) => { + runsForIssue: async (companyId: string, issueId: string, viewerUserId: string | null = null, limit?: number, selectedRunIds?: string[]) => { + if (selectedRunIds && !selectedRunIds.length) return []; scheduleRunLivenessBackfill(companyId, issueId); const runsQuery = db .select({ @@ -479,6 +480,7 @@ export function activityService(db: Db) { .where( and( eq(heartbeatRuns.companyId, companyId), + ...(selectedRunIds ? [inArray(heartbeatRuns.id, selectedRunIds)] : []), or( eq(heartbeatRuns.issueId, issueId), sql`exists ( diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e066949c630..5fb92dc1ce3 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -12587,6 +12587,7 @@ export function issueService(db: Db) { order?: "asc" | "desc"; limit?: number | null; humanOnly?: boolean; + companyId?: string; }, ) => { const order = opts?.order === "asc" ? "asc" : "desc"; @@ -12597,6 +12598,7 @@ export function issueService(db: Db) { : null; const conditions = [eq(issueComments.issueId, issueId)]; + if (opts?.companyId) conditions.push(eq(issueComments.companyId, opts.companyId)); if (opts?.humanOnly) conditions.push(or( eq(issueComments.authorType, "user"), and(isNull(issueComments.authorType), isNotNull(issueComments.authorUserId)), diff --git a/tests/perf/agent-chat/README.md b/tests/perf/agent-chat/README.md new file mode 100644 index 00000000000..847d3982f34 --- /dev/null +++ b/tests/perf/agent-chat/README.md @@ -0,0 +1,39 @@ +# Long-running agent chats + +`agent-chat-startup.spec.ts` drives the real standing-chat route with a retained +600-message, 300-run synthetic conversation. Completed runs have valid native +assistant events; there are no model calls. It verifies recent-only startup, +one backward page per upward gesture, delayed/error recovery, an unobscured latest reply, retained rows and +drafts, Back, older links, one saved live message, and sending before the first upward scroll. The send check uses separate retained fixture files per browser project so queued board input cannot alter the main history dataset. The live-message check posts one reply with a temporary key for the fixture agent, then revokes it. It starts no agent execution. Delayed inline images verify both following the latest reply and retaining an unpinned reading anchor. Additional checks keep message paging available during a receipts outage and verify that successive pages request each newly revealed metadata id once and avoid rereading previously hydrated terminal streams. Native stream recovery still refreshes on retry, resume, mode change, or reconnect. The disposable board user dismisses the current optional announcement before the journeys. + +Use a disposable loopback instance with Agent Chat enabled. The first seed needs +its matching PostgreSQL URL, with a database name ending in `_test`: + +```sh +PAPERCLIP_ISSUE_PERF_BASE_URL=http://127.0.0.1:3221 \ +PAPERCLIP_CHAT_PERF_DATABASE_URL=postgres://paperclip:paperclip@127.0.0.1:55503/fast_chat_preview_test \ + pnpm exec playwright test --config tests/perf/agent-chat/playwright.config.ts +``` + +The recipe enables Streamlined UI and the disposable board user's Coordinator +preference. It creates isolated fixture records through the API and database; +never point it at a shared instance. The fixture is retained at +`test-results/agent-chat/fixture-600.json`. Override +`PAPERCLIP_CHAT_PERF_SEED_FILE` to reuse an existing matching fixture; retained +fixtures need no database connection for browser journeys. +`PAPERCLIP_CHAT_PERF_MESSAGES=6000` creates a larger fixture for the same assertions. +The rig blocks service workers so fixture responses are intercepted deterministically and build-update reloads cannot change the selected UI artifact. The ordinary local prototype keeps the normal service worker enabled. +The rig runs the same journeys on desktop and mobile. Use `--project=desktop` +for the smaller iteration loop, then run both projects before handoff. + +`PAPERCLIP_TASK_COLD_OPEN_DATABASE_URL` lets the focused server suite reuse a +separate disposable loopback database ending in `_test`; it applies migrations +and retains randomly scoped fixture records until that database is discarded. +The normal CI path still uses the embedded PostgreSQL helper. + +Measure startup latency separately from these assertions: use matched production +builds, the same retained fixture and server, alternate baseline/candidate order, +and stop this task's builds and suites during timing samples. Readiness is the +visible recent conversation at its intended position with an editable composer, +not the loading shell. Retain per-frame geometry and server timings alongside +sample medians/ranges; do not treat fewer transcript requests as latency proof. diff --git a/tests/perf/agent-chat/agent-chat-fixture.ts b/tests/perf/agent-chat/agent-chat-fixture.ts new file mode 100644 index 00000000000..e9dffd6fc6f --- /dev/null +++ b/tests/perf/agent-chat/agent-chat-fixture.ts @@ -0,0 +1,71 @@ +import fs from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { expect, type APIRequestContext } from "@playwright/test"; + +export interface ChatFixture { + company: { id: string; issuePrefix: string }; + agent: { id: string }; + issue: { id: string }; + comments: { id: string; body: string }[]; +} +export async function longChatFixture(request: APIRequestContext, suffix = "", options: { delayedImage?: boolean } = {}): Promise { + const count = Number(process.env.PAPERCLIP_CHAT_PERF_MESSAGES ?? 600); + if (!Number.isSafeInteger(count) || count < 100 || count > 10_000) throw new Error("Use 100–10000 synthetic messages"); + const baseFile = process.env.PAPERCLIP_CHAT_PERF_SEED_FILE ?? `test-results/agent-chat/fixture-${count}.json`; + const file = suffix ? `${baseFile}.${suffix}.json` : baseFile; + const saved: ChatFixture | null = await fs.readFile(file, "utf8").then(JSON.parse).catch(error => { + if (error.code !== "ENOENT") throw error; + return null; + }); + if (saved && (await request.get(`/api/issues/${saved.issue.id}`)).ok()) return saved; + const databaseUrl = process.env.PAPERCLIP_CHAT_PERF_DATABASE_URL; + if (!databaseUrl) throw new Error("Set PAPERCLIP_CHAT_PERF_DATABASE_URL for a disposable local PostgreSQL database, or retain a seed file"); + const target = new URL(databaseUrl); + if (!["localhost", "127.0.0.1", "[::1]"].includes(target.hostname) || !target.pathname.endsWith("_test")) + throw new Error("The chat fixture requires a disposable loopback database ending in _test"); + const { createDb, closeRegisteredClients, heartbeatRuns, heartbeatRunEvents, issueComments, activityLog } = await import("../../../packages/db/src/index"); + const companyResponse = await request.post("/api/companies", { data: { name: `Long chat ${count}` } }); + expect(companyResponse.ok(), await companyResponse.text()).toBeTruthy(); + const company = await companyResponse.json(); + const agentResponse = await request.post(`/api/companies/${company.id}/agents`, { data: { + name: "History Fixture", role: "engineer", status: "paused", adapterType: "process", adapterConfig: { command: "/usr/bin/true" }, + } }); + expect(agentResponse.ok(), await agentResponse.text()).toBeTruthy(); + const agent = await agentResponse.json(); + const chatResponse = await request.post(`/api/companies/${company.id}/chats/${agent.id}`, { data: {} }); + expect(chatResponse.ok(), await chatResponse.text()).toBeTruthy(); + const issue = await chatResponse.json(); + const db = createDb(databaseUrl); + const comments: (typeof issueComments.$inferInsert)[] = []; + const runs: (typeof heartbeatRuns.$inferInsert)[] = []; + const events: (typeof heartbeatRunEvents.$inferInsert)[] = []; + const activity: (typeof activityLog.$inferInsert)[] = []; + const base = Date.now() - 30 * 24 * 3600_000; + for (let index = 0; index < count; index++) { + const human = index % 2 === 0, id = randomUUID(), runId = human ? null : randomUUID(); + const createdAt = new Date(base + index * 60_000); + let body = `History message ${index}. ` + (human ? "Please investigate these findings." : "The investigation is complete.\n\n- Checked the saved evidence.\n- Verified the result.\n\n" + "Saved findings and verification details. ".repeat(index % 7 + 2)); + if (options.delayedImage && index === count - 10) body += "\n\nInline evidence: ![Delayed evidence](/__chat-history-image.svg)"; + if (runId) { + runs.push({ id: runId, companyId: company.id, agentId: agent.id, issueId: issue.id, runtimeMode: "native", status: "succeeded", + createdAt: new Date(+createdAt - 1000), startedAt: new Date(+createdAt - 900), finishedAt: createdAt, contextSnapshot: { issueId: issue.id, conversationSessionGeneration: issue.conversationSessionGeneration, wakeCommentId: comments.at(-1)?.id } }); + events.push({ companyId: company.id, agentId: agent.id, runId, seq: 1, eventType: "item.completed", createdAt, + payload: { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, eventType: "item.completed", runId, + emittedAt: createdAt.toISOString(), itemId: `answer-${runId}`, payload: { kind: "agentMessage", text: body } } } }); + } + comments.push({ id, companyId: company.id, issueId: issue.id, authorType: human ? "user" : "agent", authorUserId: human ? issue.conversationUserId : null, + authorAgentId: human ? null : agent.id, createdByRunId: runId, body, createdAt, updatedAt: createdAt }); + activity.push({ companyId: company.id, actorType: human ? "user" : "agent", actorId: human ? issue.conversationUserId : agent.id, + agentId: human ? null : agent.id, runId, entityType: "issue", entityId: issue.id, action: "issue.comment_added", details: { commentId: id }, createdAt }); + } + try { + for (let start = 0; start < runs.length; start += 100) await db.insert(heartbeatRuns).values(runs.slice(start, start + 100)); + for (let start = 0; start < comments.length; start += 100) await db.insert(issueComments).values(comments.slice(start, start + 100)); + for (let start = 0; start < events.length; start += 100) await db.insert(heartbeatRunEvents).values(events.slice(start, start + 100)); + for (let start = 0; start < activity.length; start += 100) await db.insert(activityLog).values(activity.slice(start, start + 100)); + } finally { await closeRegisteredClients(databaseUrl); } + const fixture = { company, agent, issue, comments: comments.map(({ id, body }) => ({ id: id!, body })) }; + await fs.mkdir("test-results/agent-chat", { recursive: true }); + await fs.writeFile(file, JSON.stringify(fixture)); + return fixture; +} diff --git a/tests/perf/agent-chat/agent-chat-startup.spec.ts b/tests/perf/agent-chat/agent-chat-startup.spec.ts new file mode 100644 index 00000000000..f78e1ff86a1 --- /dev/null +++ b/tests/perf/agent-chat/agent-chat-startup.spec.ts @@ -0,0 +1,309 @@ +import fs from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { expect, test, type Page } from "@playwright/test"; +import { longChatFixture, type ChatFixture } from "./agent-chat-fixture"; + +let fixture: ChatFixture; +let chatPath: string; +const scroller = (page: Page) => page.getByTestId("task-chat-scroller"); +const editor = (page: Page) => page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]').last(); +async function ready(page: Page) { + await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 20_000 }); + await expect(editor(page)).toBeVisible(); +} +async function anchor(page: Page) { + return scroller(page).evaluate(root => { + const rect = root.getBoundingClientRect(); + const row = [...root.querySelectorAll("[data-thread-anchor]")].find(row => row.getBoundingClientRect().height && row.getBoundingClientRect().bottom > rect.top && row.getBoundingClientRect().top < rect.bottom)!; + return { id: row.dataset.threadAnchor!, top: row.getBoundingClientRect().top - rect.top }; + }); +} +async function offset(page: Page, id: string) { + return scroller(page).evaluate((root, id) => { + const row = [...root.querySelectorAll("[data-thread-anchor]")].find(row => row.dataset.threadAnchor === id)!; + return row.getBoundingClientRect().top - root.getBoundingClientRect().top; + }, id); +} +async function scrollUp(page: Page, distance = 100_000) { + await scroller(page).hover(); + const wheel = page.evaluate(() => new Promise(resolve => { + window.addEventListener("wheel", () => requestAnimationFrame(() => requestAnimationFrame(() => resolve())), { once: true, capture: true }); + })); + await page.mouse.wheel(0, -distance); + await wheel; + await scroller(page).evaluate(root => new Promise(resolve => { + let previous = root.scrollTop, stable = 0; + const sample = () => { + const next = root.scrollTop; + stable = next === previous ? stable + 1 : 0; + previous = next; + if (stable >= 3) resolve(); else requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + })); +} +test.beforeAll(async ({ request }) => { + const experimental = await request.patch("/api/instance/settings/experimental", { data: { enableAgentChat: true, enableStreamlinedUi: true } }); + expect(experimental.ok()).toBeTruthy(); + fixture = await longChatFixture(request); + const preference = await request.put(`/api/companies/${fixture.company.id}/workspace-preferences/me`, { data: { coordinatorWorkspaceEnabled: true } }); + expect(preference.ok()).toBeTruthy(); + const announcementResponse = await request.get("/api/announcements/current"); + const announcement = await announcementResponse.json(); + if (announcement) { + const dismissed = await request.post(`/api/announcements/${announcement.id}/dismiss`, { data: { companyId: fixture.company.id } }); + expect(dismissed.ok()).toBeTruthy(); + } + chatPath = `/${fixture.company.issuePrefix}/chats/${fixture.agent.id}`; +}); + +test("cold long chat reveals one recent window without full-history reads or redraws", async ({ page, request }, info) => { + const snapshot = await request.get(`/api/issues/${fixture.issue.id}/recent-comments`); + const latestId = (await snapshot.json()).latestCommentId; + const reads: string[] = [], failures: string[] = []; + page.on("request", request => { if (request.url().includes(`/api/issues/${fixture.issue.id}/`)) reads.push(request.url()); }); + page.on("pageerror", error => failures.push(error.message)); + await page.addInitScript(() => { + const frames: { height: number; latest: number; rows: number }[] = []; + Object.assign(window, { chatFrames: frames }); + const sample = () => { + const root = document.querySelector('[data-testid="task-chat-thread"] > [aria-busy="false"] [data-testid="task-chat-scroller"]'); + if (root) { + const rows = root.querySelectorAll('[id^="comment-"]'); + frames.push({ height: root.scrollHeight, latest: rows[rows.length - 1]?.getBoundingClientRect().top ?? 0, rows: rows.length }); + } + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }); + await page.goto(chatPath); + await ready(page); + await expect(page.locator('[id^="comment-"]')).toHaveCount(20); + await expect(page.locator(`[id="comment-${latestId}"]`)).toBeVisible(); + const reply = await page.locator(`[id="comment-${latestId}"]`).boundingBox(); + const composer = await page.getByTestId("task-chat-composer-dock").boundingBox(); + expect(reply!.y + reply!.height).toBeLessThanOrEqual(composer!.y + 2); + await expect(page.getByRole("button", { name: /^(More messages|Load earlier (comments|decisions|tasks))$/ })).toHaveCount(0); + await page.waitForTimeout(500); + expect(reads.filter(url => /\/comments\?/.test(url))).toEqual([]); + expect(reads.filter(url => /\/(activity|runs)$/.test(url))).toEqual([]); + expect(failures).toEqual([]); + const frames = await page.evaluate(() => (window as unknown as { chatFrames: { height: number; latest: number; rows: number }[] }).chatFrames); + expect(new Set(frames.map(frame => frame.rows)).size).toBe(1); + expect(Math.max(...frames.map(frame => frame.height)) - Math.min(...frames.map(frame => frame.height))).toBeLessThanOrEqual(2); + expect(Math.max(...frames.map(frame => frame.latest)) - Math.min(...frames.map(frame => frame.latest))).toBeLessThanOrEqual(2); + await fs.writeFile(info.outputPath("first-reveal.json"), JSON.stringify({ reads, frames })); + await page.screenshot({ path: info.outputPath("recent-chat.png") }); +}); + +test("upward scrolling loads one delayed page and preserves the row, viewport and draft", async ({ page }, info) => { + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let pageReads = 0; + await page.route(`**/api/issues/${fixture.issue.id}/comments?**`, async route => { + if (new URL(route.request().url()).searchParams.get("order") === "desc") { pageReads++; await held; } + await route.continue(); + }); + await page.goto(chatPath); await ready(page); + await editor(page).fill("Keep this draft while I read earlier messages."); + await editor(page).evaluate(element => element.setAttribute("data-retained-editor", "yes")); + try { + await scrollUp(page); + await expect(page.getByTestId("chat-history-status")).toHaveText("Loading earlier messages…"); + expect(pageReads).toBe(1); + const saved = await anchor(page); + await scrollUp(page, 100); + expect(pageReads).toBe(1); + release(); + await expect(page.locator('[id^="comment-"]')).toHaveCount(70); + await expect(page.getByTestId("chat-history-status")).toHaveText("Scroll up for earlier messages"); + await expect.poll(() => offset(page, saved.id)).toBeCloseTo(saved.top, 0); + await page.waitForTimeout(300); + expect(Math.abs(await offset(page, saved.id) - saved.top)).toBeLessThanOrEqual(2); + await expect(editor(page)).toHaveAttribute("data-retained-editor", "yes"); + await expect(editor(page)).toContainText("Keep this draft"); + await page.screenshot({ path: info.outputPath("older-page.png") }); + } finally { release(); } +}); + +test("failed history stops automatic retries and leaves a working inline retry", async ({ page }) => { + let attempts = 0; + await page.route(`**/api/issues/${fixture.issue.id}/comments?**`, async route => { + if (++attempts === 1) await route.fulfill({ status: 500, json: { error: "Synthetic older-page failure" } }); + else await route.continue(); + }); + await page.goto(chatPath); await ready(page); + await editor(page).fill("A draft survives a history outage."); + await scrollUp(page); + await expect(page.getByTestId("chat-history-status")).toContainText("Earlier messages couldn’t load."); + const saved = await anchor(page); + await scrollUp(page, 100); + await page.waitForTimeout(300); + expect(attempts).toBe(1); + await expect(page.locator('[id^="comment-"]')).toHaveCount(20); + await page.getByTestId("chat-history-status").getByRole("button", { name: "Retry" }).click(); + await expect(page.locator('[id^="comment-"]')).toHaveCount(70); + expect(Math.abs(await offset(page, saved.id) - saved.top)).toBeLessThanOrEqual(2); + await expect(editor(page)).toContainText("A draft survives"); +}); + +test("Back restores paged history and the reading position", async ({ page }) => { + await page.goto(chatPath); await ready(page); + await scrollUp(page); + await expect(page.locator('[id^="comment-"]')).toHaveCount(70); + await scrollUp(page, 400); + const saved = await anchor(page); + const navigation = page.viewportSize()!.width < 768 + ? page.getByRole("navigation", { name: "Mobile navigation" }) + : page.getByRole("navigation", { name: "Workspace navigation" }); + await navigation.getByRole("link", { name: "Tasks", exact: true }).click(); + await expect(page).toHaveURL(/\/issues$/); + await page.goBack(); await ready(page); + await expect(page.locator('[id^="comment-"]')).toHaveCount(70); + expect(Math.abs(await offset(page, saved.id) - saved.top)).toBeLessThanOrEqual(2); +}); + +test("an older comment link keeps compact loading and a usable target", async ({ page }) => { + const fullReads: string[] = []; + page.on("request", request => { if (/\/(comments|activity|runs)$/.test(new URL(request.url()).pathname) && !new URL(request.url()).search) fullReads.push(request.url()); }); + await page.goto(`${chatPath}#comment-${fixture.comments[5].id}`); await ready(page); + await expect(page.locator(`[id="comment-${fixture.comments[5].id}"]`)).toBeVisible(); + await expect(page.locator('[id^="comment-"]')).toHaveCount(21); + expect(fullReads).toEqual([]); +}); + +test("live updates keep an unpinned reader and draft, and the saved message appears once", async ({ page, request }) => { + await page.goto(chatPath); await ready(page); + await editor(page).fill("An unsent draft stays mounted during live refresh."); + await editor(page).evaluate(element => element.setAttribute("data-retained-editor", "yes")); + await scrollUp(page, 400); + const saved = await anchor(page); + const keyResponse = await request.post(`/api/agents/${fixture.agent.id}/keys`, { data: { name: "Disposable chat history regression" } }); + expect(keyResponse.status()).toBe(201); + const key = await keyResponse.json(); + const recentResponse = await request.get(`/api/issues/${fixture.issue.id}/recent-comments`); + const recentRuns = new Set((await recentResponse.json()).comments.map((row: { createdByRunId: string | null }) => row.createdByRunId)); + let sourceRunId: string | undefined; + for (const source of fixture.comments.filter((_, index) => index % 2 === 1).slice(0, 25)) { + const sourceResponse = await request.get(`/api/issues/${fixture.issue.id}/comments/${source.id}`); + const runId = (await sourceResponse.json()).createdByRunId; + if (runId && !recentRuns.has(runId)) { sourceRunId = runId; break; } + } + expect(sourceRunId).toBeTruthy(); + const body = `Live saved message ${randomUUID()}`; + const response = await request.post(`/api/issues/${fixture.issue.id}/comments`, { + headers: { authorization: `Bearer ${key.token}`, "x-paperclip-run-id": sourceRunId! }, data: { body }, + }); + await request.delete(`/api/agents/${fixture.agent.id}/keys/${key.id}`); + expect(response.ok(), await response.text()).toBeTruthy(); + const message = await response.json(); + await expect(page.locator(`[id="comment-${message.id}"]`)).toBeAttached({ timeout: 15_000 }); + expect(Math.abs(await offset(page, saved.id) - saved.top)).toBeLessThanOrEqual(2); + await expect(editor(page)).toHaveAttribute("data-retained-editor", "yes"); + await expect(editor(page)).toContainText("An unsent draft stays mounted"); + const persisted = await request.get(`/api/issues/${fixture.issue.id}/comments?order=desc&limit=50`); + expect((await persisted.json()).filter((row: { body: string }) => row.body === body)).toHaveLength(1); + await page.getByRole("button", { name: "Scroll to latest", exact: true }).click(); + await expect(page.locator(`[id="comment-${message.id}"]`)).toBeVisible(); +}); + +test("sending before the first upward scroll leaves earlier history reachable", async ({ page, request }, info) => { + const own = await longChatFixture(request, `send-first-${info.project.name}`); + const snapshot = await request.get(`/api/issues/${own.issue.id}/recent-comments`); + const oldest = (await snapshot.json()).oldestCommentId; + await page.goto(`/${own.company.issuePrefix}/chats/${own.agent.id}`); await ready(page); + const body = `Saved before paging ${randomUUID()}`; + await editor(page).fill(body); + const posted = page.waitForResponse(response => response.request().method() === "POST" + && new URL(response.url()).pathname === `/api/issues/${own.issue.id}/comments`); + await page.getByTestId("task-chat-composer-send").click(); + const receipt = await posted; + expect(receipt.ok(), await receipt.text()).toBeTruthy(); + await expect(page.getByTestId("chat-history-status")).toHaveText("Scroll up for earlier messages"); + const older = page.waitForResponse(response => new URL(response.url()).pathname === `/api/issues/${own.issue.id}/comments` + && new URL(response.url()).searchParams.get("order") === "desc"); + await scrollUp(page); + const pageResponse = await older; + expect(new URL(pageResponse.url()).searchParams.get("after")).toBe(oldest); + await expect(page.locator(`[id="comment-${own.comments.at(-21)!.id}"]`)).toBeAttached(); +}); + +test("a receipts outage leaves message paging available with one independent retry", async ({ page }) => { + await page.route("**/decision-receipts?*", route => route.fulfill({ status: 500, contentType: "application/json", body: '{"error":"Receipt fixture unavailable"}' })); + await page.goto(chatPath); await ready(page); + await expect(page.getByText("Decision updates couldn’t load.")).toBeVisible(); + await scrollUp(page); + await expect(page.locator('[id^="comment-"]')).toHaveCount(70); + await expect(page.getByTestId("chat-history-status")).toHaveText("Scroll up for earlier messages"); + await expect(page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(1); +}); + +test("successive older pages hydrate each message and run id only once", async ({ page, request }) => { + const fixture = await longChatFixture(request, "metadata"); + await request.put(`/api/companies/${fixture.company.id}/workspace-preferences/me`, { data: { coordinatorWorkspaceEnabled: true } }); + const chatPath = `/${fixture.company.issuePrefix}/chats/${fixture.agent.id}`; + const nativeIds: string[] = []; + page.on("response", response => { + const match = new URL(response.url()).pathname.match(/^\/api\/heartbeat-runs\/([^/]+)\/events$/); + if (match && response.status() === 200) nativeIds.push(match[1]); + }); + const seen: Record = { commentIds: [], runIds: [] }; + page.on("request", request => { + const url = new URL(request.url()); + if (!url.pathname.startsWith(`/api/issues/${fixture.issue.id}/`)) return; + for (const field of ["commentIds", "runIds"]) seen[field].push(...(url.searchParams.get(field)?.split(",") ?? [])); + }); + await page.goto(chatPath); await ready(page); + await page.waitForLoadState("networkidle"); + const initialNative = new Set(nativeIds); + await scrollUp(page); await expect(page.locator('[id^="comment-"]')).toHaveCount(70); + await expect.poll(() => new Set(nativeIds.filter(id => !initialNative.has(id))).size).toBe(25); + await page.waitForLoadState("networkidle"); + const secondPageStart = Object.fromEntries(Object.entries(seen).map(([key, ids]) => [key, ids.length])); + const firstPageNative = new Set(nativeIds); + const firstPageReads = nativeIds.length; + await scrollUp(page); await expect(page.locator('[id^="comment-"]')).toHaveCount(120); + await expect.poll(() => seen.commentIds.length - secondPageStart.commentIds).toBe(50); + await expect.poll(() => new Set(nativeIds.filter(id => !firstPageNative.has(id))).size).toBe(25); + await page.waitForLoadState("networkidle"); + expect(nativeIds.slice(firstPageReads).filter(id => firstPageNative.has(id))).toEqual([]); + for (const [key, ids] of Object.entries(seen)) { + const secondPage = ids.slice(secondPageStart[key]); + const previous = new Set(ids.slice(0, secondPageStart[key])); + expect(new Set(secondPage).size).toBe(secondPage.length); + expect(secondPage.filter(id => previous.has(id))).toEqual([]); + } +}); + +for (const pinned of [true, false]) test(`a delayed image preserves ${pinned ? "latest following" : "the reading anchor"}`, async ({ page, request }, info) => { + const own = await longChatFixture(request, `image-v3-${pinned}-${info.project.name}`, { delayedImage: true }); + const preference = await request.put(`/api/companies/${own.company.id}/workspace-preferences/me`, { data: { coordinatorWorkspaceEnabled: true } }); + expect(preference.ok()).toBeTruthy(); + let release!: () => void, requested!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const pendingImage = new Promise(resolve => { requested = resolve; }); + await page.route("**/__chat-history-image.svg", async route => { + requested(); await held; + await route.fulfill({ contentType: "image/svg+xml", body: '' }); + }); + try { + await page.goto(`/${own.company.issuePrefix}/chats/${own.agent.id}`); await ready(page); + await scrollUp(page, 1200); + const images = await page.locator('img[alt="Delayed evidence"]').evaluateAll(nodes => nodes.map(node => ({ src: (node as HTMLImageElement).src, complete: (node as HTMLImageElement).complete, rect: node.getBoundingClientRect().toJSON() }))); + await fs.writeFile(info.outputPath("image-before-release.json"), JSON.stringify({ images })); + await pendingImage; + await page.getByRole("button", { name: "Scroll to latest", exact: true }).click(); + await expect.poll(() => scroller(page).evaluate(root => root.scrollHeight - root.scrollTop - root.clientHeight)).toBeLessThanOrEqual(2); + if (!pinned) await scrollUp(page, 200); + const saved = await anchor(page); + release(); + await expect(page.locator('img[alt="Delayed evidence"]')).toHaveJSProperty("complete", true); + await expect(page.locator('img[alt="Delayed evidence"]')).toHaveJSProperty("naturalWidth", 640); + await page.waitForTimeout(200); + if (pinned) { + const latest = await page.locator(`[id="comment-${own.comments.at(-1)!.id}"]`).boundingBox(); + const composer = await page.getByTestId("task-chat-composer-dock").boundingBox(); + expect(latest!.y + latest!.height).toBeLessThanOrEqual(composer!.y + 2); + } else expect(Math.abs(await offset(page, saved.id) - saved.top)).toBeLessThanOrEqual(2); + } finally { release(); } +}); diff --git a/tests/perf/agent-chat/playwright.config.ts b/tests/perf/agent-chat/playwright.config.ts new file mode 100644 index 00000000000..73582281dea --- /dev/null +++ b/tests/perf/agent-chat/playwright.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "@playwright/test"; +import base from "../issue-detail/playwright.config"; + +export default defineConfig({ + ...base, + testDir: ".", + testMatch: "agent-chat-startup.spec.ts", + timeout: 60_000, + workers: 1, + use: { ...base.use, serviceWorkers: "block" }, + projects: [ + { name: "desktop", use: { viewport: { width: 1440, height: 900 } } }, + { name: "mobile", use: { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true } }, + ], + outputDir: "../../../test-results/agent-chat/playwright", +}); diff --git a/tests/perf/issue-detail/README.md b/tests/perf/issue-detail/README.md index 1077987582c..f344845ad8a 100644 --- a/tests/perf/issue-detail/README.md +++ b/tests/perf/issue-detail/README.md @@ -120,3 +120,7 @@ per-frame positions, layout shifts, browser timing counters, and reading-anchor measurement. The live script rejects non-local URLs and uses real provider capacity. Review the video as well as the assertions: layout-shift scores alone do not capture programmatic scrolling or replacement flashes. + +## Long-running agent chats + +The separate [agent-chat performance rig](../agent-chat/README.md) covers recent-only startup and scrolling history with long-lived conversations. It is opt-in and does not run in this task-detail rig. diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts index c5dc760a577..31b1058ef4c 100644 --- a/ui/src/api/activity.ts +++ b/ui/src/api/activity.ts @@ -92,5 +92,11 @@ export const activityApi = { forIssue: (issueId: string) => api.get(`/issues/${issueId}/activity`), recentRunsForIssue: (issueId: string) => api.get(`/issues/${issueId}/runs?view=recent`), runsForIssue: (issueId: string) => api.get(`/issues/${issueId}/runs`), + runsByIdsForIssue: (issueId: string, runIds: string[]) => { + const ids = [...new Set(runIds)]; + return Promise.all(Array.from({ length: Math.ceil(ids.length / 100) }, (_, index) => + api.get(`/issues/${issueId}/runs?runIds=${ids.slice(index * 100, (index + 1) * 100).join(",")}`), + )).then(pages => pages.flat()); + }, issuesForRun: (runId: string) => api.get(`/heartbeat-runs/${runId}/issues`), }; diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 4990c447a1d..c834c8df0a1 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -20,7 +20,7 @@ import type { import { heartbeatsApi } from "@/api/heartbeats"; import { nativeRunEventsToTranscript } from "./transcript/native-run-events"; import { pendingConnectionIntentInteraction } from "@/fixtures/issueThreadInteractionFixtures"; -import { TaskChatScrollNavigation } from "./task-chat/scroll-navigation"; +import { TaskChatScrollNavigation, useTaskChatScrollNavigation } from "./task-chat/scroll-navigation"; import type { HeartbeatRunEvent } from "@paperclipai/shared"; const transcriptState = vi.hoisted(() => ({ @@ -390,6 +390,16 @@ it.each([true, false])("coordinates compact cards before first reveal and retain expect(container.querySelector('[data-testid="mock-editor"]')).toBe(editor); }); +it("keeps revealed history scrollable while metadata for another page is pending", async () => { + function Readiness() { return {String(useTaskChatScrollNavigation().ready)}; } + const props = { issueId: "paged-chat", compactHistory: true, comments: createLongThreadComments(), onAdd: async () => {}, threadHeader: }; + render(); + await settleThreadLayout(); + expect(container.querySelector('[data-testid="history-scroll-ready"]')?.textContent).toBe("true"); + render(); + expect(container.querySelector('[data-testid="history-scroll-ready"]')?.textContent).toBe("true"); +}); + it.each([0, 32_000, 100_000])("offers earlier output only when a single legacy run exceeds the compact window (%s bytes)", (logBytes) => { const onExpandHistory = vi.fn(); if (logBytes > 32_000) transcriptState.truncatedRunIds.add("legacy-run"); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index a2d7bc49cf7..e68b2fbcaa7 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -1,3 +1,4 @@ +import type { ScrollingHistory } from "@/components/task-chat/TaskMessageScroller"; import { TaskChatShimmerScope } from "@/components/task-chat/TaskChatShimmer"; import { useRunnerSubmission } from "@/hooks/useRunnerSubmission"; import { @@ -429,6 +430,7 @@ export type TaskChatThreadProps = ComponentProps & { conversationMode?: boolean; creationActivity?: ActivityEvent[]; compactHistory?: boolean; + scrollingHistory?: ScrollingHistory; historyControl?: ReactNode; onExpandHistory?: () => void; initialHistoryPending?: boolean; @@ -619,6 +621,18 @@ export function TaskChatThread(props: TaskChatThreadProps) { const decisionReceipts = useCoordinatorChatDecisionReceipts(issueId, conversationMode); const pendingQuestions = useCoordinatorChatPendingQuestions(issueId, conversationMode); const taskHandoffs = useCoordinatorChatTaskHandoffs(issueId, conversationMode === true); + const scrollingHistory: ScrollingHistory | undefined = props.scrollingHistory ? { + hasMore: props.scrollingHistory.hasMore || !!decisionReceipts.hasNextPage || !!taskHandoffs.hasNextPage, + loading: props.scrollingHistory.loading || decisionReceipts.isFetchingNextPage || taskHandoffs.isFetchingNextPage, + error: props.scrollingHistory.error, + load: () => Promise.all([ + props.scrollingHistory?.hasMore ? props.scrollingHistory.load() : undefined, + !decisionReceipts.isError && decisionReceipts.hasNextPage ? decisionReceipts.fetchNextPage() : undefined, + !taskHandoffs.isError && taskHandoffs.hasNextPage ? taskHandoffs.fetchNextPage() : undefined, + ]), + } : undefined; + const conversationWindowStart = props.scrollingHistory?.hasMore && comments.length + ? Math.min(...comments.map(comment => toMs(comment.createdAt))) : -Infinity; const liveHandoffIds = useLiveChatHandoffIds(`${currentUserId}:${issueId}`, taskHandoffs.handoffs, taskHandoffs.isSuccess); const createdProjectItems = useProjectCreatedItems(props.creationActivity ?? [], companyId); const createdSkillItems = useMemo(() => skillCreatedItems(props.creationActivity ?? []), [props.creationActivity]); @@ -1545,10 +1559,12 @@ export function TaskChatThread(props: TaskChatThreadProps) { entriesWithFailures.push({ id, ms: toMs(question.createdAt), order: 4, item: { id, kind: "pending_question", question } }); } for (const receipt of decisionReceipts.receipts) { + if (toMs(receipt.resolvedAt) < conversationWindowStart) continue; entriesWithFailures.push({ id: `decision-receipt:${receipt.id}`, ms: toMs(receipt.resolvedAt), order: 4, item: { id: `decision-receipt:${receipt.id}`, kind: "decision_receipt", receipt } }); } for (const handoff of standaloneChatHandoffs(taskHandoffs.handoffs, interactions ?? [])) { + if (toMs(handoff.createdAt) < conversationWindowStart) continue; entriesWithFailures.push({ id: handoff.id, ms: toMs(handoff.createdAt), order: 4, item: { id: handoff.id, kind: "task_handoff", handoff, animateEntry: liveHandoffIds.has(handoff.id) } }); } @@ -2237,6 +2253,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { }, [ orderedEntries, coordinatorEnabled, + conversationWindowStart, decisionReceipts.receipts, pendingQuestions.questions, taskHandoffs.handoffs, @@ -3045,7 +3062,12 @@ export function TaskChatThread(props: TaskChatThreadProps) { // 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 chatMetadataPending = Boolean(issueId && conversationMode && ( + (pendingQuestions.enabled && pendingQuestions.isPending) || + (decisionReceipts.enabled && decisionReceipts.isPending) || + (taskHandoffs.enabled && taskHandoffs.isPending) + )); + const historyPending = initialHistoryPending || planLoading || transcriptHistoryPending || chatMetadataPending; const scrollNavigation = useTaskChatScrollNavigation(); useEffect(() => { recordIssueDetailReadiness({ plan: !planLoading, transcripts: !transcriptHistoryPending, history: !historyPending }); @@ -3110,7 +3132,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { return ( - + @@ -3166,7 +3188,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { > {prepareInitialContent ? ( <> - {items.length === 0 && !tailRunId && !optimisticRunnerStartup ? ( + {items.length === 0 && !tailRunId && !optimisticRunnerStartup && !scrollingHistory?.hasMore ? (
@@ -3180,8 +3202,8 @@ export function TaskChatThread(props: TaskChatThreadProps) { data-testid="task-chat-thread-header" > {threadHeaderWithBlockers} - - + +
) : null}
@@ -3201,8 +3223,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { ) : ( {threadHeaderWithBlockers}{!issueBrief ? historyControl : null}} + header={<>{threadHeaderWithBlockers}{!issueBrief ? historyControl : null}} renderInteraction={renderInteraction} renderBrief={renderBrief} renderMessageActions={renderMessageActions} diff --git a/ui/src/components/coordinator/CoordinatorChatDecisionReceipts.tsx b/ui/src/components/coordinator/CoordinatorChatDecisionReceipts.tsx index e9ceb493bc2..49467cf21be 100644 --- a/ui/src/components/coordinator/CoordinatorChatDecisionReceipts.tsx +++ b/ui/src/components/coordinator/CoordinatorChatDecisionReceipts.tsx @@ -48,12 +48,12 @@ export function CoordinatorChatDecisionReceipt({ receipt }: { receipt: DecisionH ; } -export function CoordinatorChatReceiptStatus({ query }: { query: ReturnType }) { +export function CoordinatorChatReceiptStatus({ query, scroll = false }: { scroll?: boolean; query: ReturnType }) { if (!query.enabled) return null; if (query.isError) return
Decision updates couldn’t load.
; - if (query.hasNextPage) return ; return null; diff --git a/ui/src/components/coordinator/CoordinatorChatTaskHandoffs.tsx b/ui/src/components/coordinator/CoordinatorChatTaskHandoffs.tsx index 1ed10c89cc4..6428b0bc53c 100644 --- a/ui/src/components/coordinator/CoordinatorChatTaskHandoffs.tsx +++ b/ui/src/components/coordinator/CoordinatorChatTaskHandoffs.tsx @@ -61,12 +61,12 @@ export function CoordinatorChatTaskHandoff({ handoff, animateEntry = false }: { ; } -export function CoordinatorChatHandoffStatus({ query }: { query: ReturnType }) { +export function CoordinatorChatHandoffStatus({ query, scroll = false }: { scroll?: boolean; query: ReturnType }) { if (!query.enabled) return null; if (query.isError) return
Created tasks couldn’t load.
; - if (query.hasNextPage) return ; return null; diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index ea2b0a24d83..7c95a95f071 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -21,7 +21,7 @@ import { TaskChatUsageReadout } from "./TaskChatUsageReadout"; import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup"; import { TaskChatActivityPhase } from "./TaskChatActivityPhase"; import { TaskChatThinking } from "./TaskChatThinking"; -import { TaskMessageScroller } from "./TaskMessageScroller"; +import { TaskMessageScroller, type ScrollingHistory } from "./TaskMessageScroller"; import { TaskChatProtocolCard } from "./TaskChatProtocolCard"; import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow"; import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard"; @@ -33,6 +33,7 @@ import { CoordinatorChatPendingQuestion } from "@/components/coordinator/Coordin const EMPTY_ATTACHMENTS: IssueAttachment[] = []; interface TaskChatThreadViewProps { + scrollingHistory?: ScrollingHistory; items: TaskChatItem[]; /** * Content rendered above the first message INSIDE the scroll viewport (the @@ -303,6 +304,7 @@ export function taskChatItemSpacingClass( * TaskChatItem[] onto the primitives inside the auto-follow scroller. */ export function TaskChatThreadView({ + scrollingHistory, items, header, onApprovalDecision, @@ -433,7 +435,7 @@ export function TaskChatThreadView({ if (!scroll) return body; return ( - + {body} ); diff --git a/ui/src/components/task-chat/TaskMessageScroller.test.tsx b/ui/src/components/task-chat/TaskMessageScroller.test.tsx index 379fba4d6d9..fbc0ce1d9f2 100644 --- a/ui/src/components/task-chat/TaskMessageScroller.test.tsx +++ b/ui/src/components/task-chat/TaskMessageScroller.test.tsx @@ -12,14 +12,14 @@ const PILL_SELECTOR = 'button[aria-label="Scroll to latest"]'; * jsdom has no layout, so scroll geometry is faked per element: scrollHeight / * clientHeight are pinned and scrollTop is backed by a plain variable. */ -function fakeGeometry(el: HTMLElement, { scrollHeight = 1000, clientHeight = 400 } = {}) { +function fakeGeometry(el: HTMLElement, { scrollHeight = 1000, clientHeight = 400, clamp = false } = {}) { let scrollTop = 0; Object.defineProperty(el, "scrollHeight", { value: scrollHeight, configurable: true }); Object.defineProperty(el, "clientHeight", { value: clientHeight, configurable: true }); Object.defineProperty(el, "scrollTop", { get: () => scrollTop, set: (v: number) => { - scrollTop = v; + scrollTop = clamp ? Math.max(0, Math.min(v, el.scrollHeight - el.clientHeight)) : v; }, configurable: true, }); @@ -125,6 +125,118 @@ describe("TaskMessageScroller", () => { vi.unstubAllGlobals(); }); + it("loads one older page on upward scroll, keeps the status slot, and pauses after failure", async () => { + let release!: () => void; + const load = vi.fn(() => new Promise(resolve => { release = resolve; })); + const history = { hasMore: true, loading: false, error: false, load }; + const update = (tick: number) => flushSync(() => root.render( +
saved messages
, + )); + update(0); + fakeGeometry(scroller()); + update(1); + expect(load).not.toHaveBeenCalled(); + await scrollTo(scroller(), 400); + expect(load).not.toHaveBeenCalled(); + await scrollTo(scroller(), 150); + expect(load).toHaveBeenCalledTimes(1); + await scrollTo(scroller(), 100); + scroller().dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 })); + await flushEvents(); + expect(load).toHaveBeenCalledTimes(1); + const status = container.querySelector('[data-testid="chat-history-status"]'); + history.loading = true; + update(2); + expect(container.querySelector('[data-testid="chat-history-status"]')).toBe(status); + expect(status?.textContent).toBe("Loading earlier messages…"); + release(); + await flushEvents(); + history.loading = false; + history.error = true; + update(3); + await scrollTo(scroller(), 50); + expect(load).toHaveBeenCalledTimes(1); + expect(status?.textContent).toContain("Earlier messages couldn’t load."); + (status?.querySelector('button') as HTMLButtonElement).click(); + expect(load).toHaveBeenCalledTimes(2); + release(); + await flushEvents(); + history.error = false; + history.hasMore = false; + update(4); + await scrollTo(scroller(), 0); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("follows a late image resize in the message body after the history status", () => { + const observers: { targets: Set; resize: () => void }[] = []; + vi.stubGlobal("ResizeObserver", class { + targets = new Set(); + constructor(callback: ResizeObserverCallback) { observers.push({ targets: this.targets, resize: () => callback([], this as unknown as ResizeObserver) }); } + observe(target: Element) { this.targets.add(target); } + disconnect() {} + }); + const update = (tick: number) => flushSync(() => root.render( +
image content
, + )); + update(0); fakeGeometry(scroller(), { clamp: true }); update(1); + const body = container.querySelector('[data-testid="image-body"]')!; + Object.defineProperty(scroller(), "scrollHeight", { value: 1400, configurable: true }); + for (const observer of observers) if (observer.targets.has(body)) observer.resize(); + expect(scroller().scrollTop).toBe(1000); + }); + + it("retains upward intent when content reconciles before the scroll event", async () => { + const load = vi.fn(); + const update = (tick: number) => flushSync(() => root.render( +
saved messages
, + )); + update(0); fakeGeometry(scroller()); update(1); + await scrollTo(scroller(), 400); + scroller().dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 })); + scroller().scrollTop = 0; + update(2); + await dispatch(scroller(), new Event("scroll", { bubbles: true })); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("holds a short conversation when an upward wheel gesture loads history", async () => { + const load = vi.fn().mockResolvedValue(undefined); + const update = (tick: number) => flushSync(() => root.render( +
short conversation
, + )); + update(0); + fakeGeometry(scroller(), { scrollHeight: 200, clientHeight: 400, clamp: true }); + update(1); + await scrollTo(scroller(), 0); + scroller().dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 })); + await flushEvents(); + expect(load).toHaveBeenCalledTimes(1); + Object.defineProperty(scroller(), "scrollHeight", { value: 1200, configurable: true }); + update(2); + expect(scroller().scrollTop).toBe(0); + }); + + it("pages short conversations with touch and keyboard gestures", async () => { + const load = vi.fn().mockResolvedValue(undefined); + const update = (tick: number) => flushSync(() => root.render( +
short conversation
, + )); + update(0); fakeGeometry(scroller(), { scrollHeight: 200, clientHeight: 400, clamp: true }); update(1); + await scrollTo(scroller(), 0); + const touch = (type: string, y: number) => { + const event = new Event(type, { bubbles: true }); + Object.defineProperty(event, "touches", { value: [{ clientY: y }] }); + scroller().dispatchEvent(event); + }; + touch("touchstart", 100); touch("touchmove", 200); touch("touchmove", 250); + await flushEvents(); + expect(load).toHaveBeenCalledTimes(1); + scroller().dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Home" })); + await flushEvents(); + expect(load).toHaveBeenCalledTimes(2); + }); + it("does not measure historical rows on each streaming update while following the bottom", () => { let initialized = false; let rowMeasurements = 0; diff --git a/ui/src/components/task-chat/TaskMessageScroller.tsx b/ui/src/components/task-chat/TaskMessageScroller.tsx index 5c5501fe29d..a0f7c9e6db2 100644 --- a/ui/src/components/task-chat/TaskMessageScroller.tsx +++ b/ui/src/components/task-chat/TaskMessageScroller.tsx @@ -21,7 +21,15 @@ function motionDisabled(): boolean { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } +export interface ScrollingHistory { + hasMore: boolean; + loading: boolean; + error?: boolean; + load: () => unknown; +} + interface TaskMessageScrollerProps { + scrollingHistory?: ScrollingHistory; children: ReactNode; /** Value that changes whenever content that could grow the thread updates. */ contentKey: unknown; @@ -44,7 +52,7 @@ interface TaskMessageScrollerProps { * the glide cancels it and treats the user as unpinned. Content-driven follow * while pinned stays instant, so no reflow/jump happens during streaming. */ -export function TaskMessageScroller({ children, contentKey, className }: TaskMessageScrollerProps) { +export function TaskMessageScroller({ children, contentKey, className, scrollingHistory }: TaskMessageScrollerProps) { const streamlined = useStreamlinedTaskChatPresentation(); const navigation = useTaskChatScrollNavigation(); const initialPositionApplied = useRef(false); @@ -57,6 +65,26 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes const scrollbarIdleTimerRef = useRef(null); const scrollbarIdleDelayRef = useRef(null); const [pillPhase, setPillPhase] = useState("hidden"); + const previousScrollTop = useRef(0); + const touchStartY = useRef(null); + const requestingHistory = useRef(false); + const upwardIntent = useRef(false); + const requestEarlierHistory = useCallback(() => { + upwardIntent.current = false; + if (!initialPositionApplied.current || !navigation.ready || !scrollingHistory?.hasMore + || scrollingHistory.loading || scrollingHistory.error || requestingHistory.current) return; + requestingHistory.current = true; + const el = ref.current; + if (el?.clientHeight) { + const rect = el.getBoundingClientRect(); + pinnedRef.current = false; + anchorRef.current = readThreadScrollAnchor(el, rect.top, rect.bottom); + navigation.remember(el.scrollTop, anchorRef.current, false); + } + Promise.resolve().then(() => scrollingHistory.load()).catch(() => {}).finally(() => { + requestingHistory.current = false; + }); + }, [navigation.key, navigation.hash, navigation.ready, scrollingHistory]); const showScrollbarWhileScrolling = useCallback(() => { const el = ref.current; @@ -148,10 +176,15 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes // Compensation preserves the existing reading anchor. Recapture only when // the reader scrolls or navigation selects a different position. rememberAnchor(false); + previousScrollTop.current = el.scrollTop; }, [rememberAnchor, scrollToBottom, hidePill]); const handleScroll = useCallback(() => { if (!ref.current?.clientHeight) return; + const top = ref.current.scrollTop; + const scrollingUp = top < previousScrollTop.current; + previousScrollTop.current = top; + if ((scrollingUp || upwardIntent.current) && top <= 192 && !easingRef.current) requestEarlierHistory(); showScrollbarWhileScrolling(); // A growing composer shrinks this viewport. Some browsers dispatch the // resulting scroll event before ResizeObserver, so preserve the previous @@ -162,7 +195,7 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes hidePill(); return; } - const pinned = isPinned(); + const pinned = requestingHistory.current ? false : isPinned(); if (easingRef.current) { // Smooth re-follow in flight: intermediate scroll events must not // unpin/re-show the pill. Only act once we arrive within the threshold. @@ -185,6 +218,7 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes hidePill, showPill, showScrollbarWhileScrolling, + requestEarlierHistory, ]); const handleJumpToLatest = useCallback(() => { @@ -245,7 +279,7 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes observer.observe(el); // The viewport itself does not resize when an image or historical row // grows. Observe the content box too, before the browser paints it. - if (el.firstElementChild) observer.observe(el.firstElementChild); + for (const child of el.children) observer.observe(child); return () => observer.disconnect(); }, [followViewportResize, hidePill, reconcileContent]); @@ -273,6 +307,26 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
{ + if (["ArrowUp", "PageUp", "Home"].includes(event.key) && ref.current && ref.current.scrollTop <= 192) requestEarlierHistory(); + }} + onTouchStart={event => { touchStartY.current = event.touches.length === 1 ? event.touches[0]!.clientY : null; }} + onTouchMove={event => { + const start = touchStartY.current; + if (start !== null && event.touches.length === 1 && event.touches[0]!.clientY > start + 16 + && ref.current && ref.current.scrollTop <= 192) { + touchStartY.current = null; + requestEarlierHistory(); + } + }} + onTouchEnd={() => { touchStartY.current = null; }} + onWheel={event => { + upwardIntent.current = event.deltaY < 0; + if (event.deltaY < 0 && ref.current && ref.current.scrollTop <= 192) requestEarlierHistory(); + }} // Keep the viewport tied to the flex-sized wrapper vertically — // percentage heights don't reliably resolve against flex-determined // block heights, which let the thread overflow the page. In the @@ -288,7 +342,17 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes )} data-testid="task-chat-scroller" > + {scrollingHistory ? <> + {scrollingHistory && (scrollingHistory.hasMore || scrollingHistory.loading || scrollingHistory.error) ? ( +
+ {scrollingHistory.error ? <> + Earlier messages couldn’t load. + + : scrollingHistory.loading ? "Loading earlier messages…" : "Scroll up for earlier messages"} +
+ ) : null} {children} + : children}
{pillPhase !== "hidden" ? ( @@ -2644,6 +2667,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ initialMetadataPending || activityPending || linkedRunsPending || + (conversationMode && observedRunIds.length > 0 && commentRuns.isPending) || inputFacts.isLoading || !runtimeSelectionKnown) } @@ -2652,6 +2676,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ activityError || (!historyExpanded && inputFacts.isError) || linkedRunsError || + (conversationMode && commentRuns.isError) || liveRunsError || (activeRunQueryEnabled && activeRunError) } @@ -2660,6 +2685,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ void refetchActivity(); if (!historyExpanded && inputCommentIds.length) void inputFacts.refetch(); void refetchLinkedRuns(); + if (conversationMode && observedRunIds.length) void commentRuns.refetch(); void refetchLiveRuns(); if (activeRunQueryEnabled) void refetchActiveRun(); }} @@ -3343,13 +3369,15 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS }, enabled: !!issueId, }); - const { historyExpanded, expandHistory, recent: recentComments } = useIssueHistoryWindow( + const { historyExpanded, expandHistory, recent: recentComments, retainComment, acknowledgedComments } = useIssueHistoryWindow( issueId, // Classic settings can resolve before the personal workspace preference. // Do not persist full-history expansion for a surface we have not chosen. - Boolean(conversation || (taskInterfaceSettingsLoaded && classicTaskInterfaceEnabled)), + Boolean(!conversation && !coreIssueQuery.data?.conversationAgentId && taskInterfaceSettingsLoaded && classicTaskInterfaceEnabled), coreIssueQuery.data, + Boolean(conversation || coreIssueQuery.data?.conversationAgentId), ); + const paginatedConversation = Boolean(conversation || coreIssueQuery.data?.conversationAgentId); const issueQuery = useQuery({ ...getIssueDetailQueryOptions(queryClient, issueId!, { placeholderIssue: issueHeaderSeed @@ -3450,13 +3478,16 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS InfiniteData >(issueId ?? "pending"), }); + const earlierChatComments = useConversationHistoryPages(issueId, paginatedConversation, recentComments.data?.oldestCommentId, recentComments.data?.comments ?? []); 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], + () => mergeWindowComments(recentComments.data?.comments ?? [], paginatedConversation + ? earlierChatComments.data?.pages.flatMap(page => page.comments) ?? [] + : flattenIssueCommentPages(commentPages?.pages), acknowledgedComments), + [paginatedConversation, earlierChatComments.data?.pages, commentPages?.pages, recentComments.data?.comments, acknowledgedComments], ); const linkedComment = useQuery({ queryKey: [...queryKeys.issues.comments(issueId!), "linked", linkedCommentId], @@ -4303,6 +4334,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS queryKeys.issues.comments(ref), (current) => current && { ...current, pages: removeIssueCommentFromPages(current.pages, commentId) }, ); + queryClient.setQueryData(queryKeys.issues.acknowledgedComments(ref), current => + current?.filter(comment => comment.id !== commentId), + ); + queryClient.setQueryData>( + queryKeys.issues.earlierComments(ref), current => current && { + ...current, pages: current.pages.map(page => ({ ...page, comments: page.comments.filter(comment => comment.id !== commentId) })), + }, + ); queryClient.setQueryData( [...queryKeys.issues.comments(ref), "recent"], (current) => { @@ -4355,6 +4394,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS } : current, ); + queryClient.setQueryData(queryKeys.issues.acknowledgedComments(ref), current => + current?.map(existing => existing.id === comment.id ? comment : existing), + ); + queryClient.setQueryData>( + queryKeys.issues.earlierComments(ref), current => current && { + ...current, pages: current.pages.map(page => ({ ...page, comments: page.comments.map(existing => existing.id === comment.id ? comment : existing) })), + }, + ); queryClient.setQueryData( [...queryKeys.issues.comments(ref), "recent"], (current) => current && { @@ -5089,6 +5136,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS if (context?.optimisticCommentId) { commentRenderKeys.current.set(comment.id, context.optimisticCommentId); } + if (paginatedConversation) retainComment(comment); queryClient.setQueryData>( queryKeys.issues.comments(issueId ?? comment.issueId), (current) => @@ -5512,6 +5560,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS }); } if (comment) { + if (paginatedConversation) retainComment(comment); if (context?.optimisticCommentId) commentRenderKeys.current.set( comment.id, @@ -6696,10 +6745,13 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS const attachmentsInitialLoading = attachmentsLoading && attachments === undefined; - const loadOlderComments = useCallback(() => { - void fetchOlderComments(); - }, [fetchOlderComments]); + const loadOlderComments = useCallback(() => paginatedConversation + ? earlierChatComments.load() : fetchOlderComments(), [paginatedConversation, earlierChatComments.load, fetchOlderComments]); const refetchLatestComments = useCallback(async () => { + if (paginatedConversation) { + await recentComments.refetch(); + return; + } // Refetch page 0 first so comments that arrived after initial load are // visible, then load every remaining older page. The chat thread is // paginated and virtualized, so "latest" must be resolved against the @@ -6729,7 +6781,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS } window.requestAnimationFrame(() => resolve()); }); - }, [issueId, queryClient, refetchComments]); + }, [issueId, queryClient, refetchComments, paginatedConversation, recentComments.refetch]); useEffect(() => { if ( !shouldPrefetchOlderComments && @@ -8508,8 +8560,11 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS documents={issue.documentSummaries ?? []} workProducts={workProducts ?? issue.workProducts ?? []} attachments={attachments ?? []} - hasOlderComments={hasOlderComments} - commentsLoadingOlder={commentsLoadingOlder} + hasOlderComments={paginatedConversation && !historyExpanded + ? (earlierChatComments.data ? earlierChatComments.hasNextPage : recentComments.data?.hasMore) ?? false + : hasOlderComments} + commentsOlderError={paginatedConversation && earlierChatComments.isError} + commentsLoadingOlder={paginatedConversation ? earlierChatComments.isFetching : commentsLoadingOlder} onLoadOlderComments={loadOlderComments} onRefreshLatestComments={refetchLatestComments} composerRef={commentComposerRef}