mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
perf(chat): load recent messages and page history on scroll (#18358)
## Thinking Path > - Paperclip lets people manage work done by agents. > - A standing agent chat can hold months of conversation. > - The chat route forced full-history mode, bypassing compact task loading. > - A cold visit fetched lifetime run summaries and hydrated historical transcripts. > - This PR starts with 20 recent messages and loads earlier pages on upward scroll. > - It preserves the reader's position, current questions, and draft while history grows. ## Linked Issues or Issue Description **What happened?** Standing agent chats forced full-history loading. A long conversation fetched full activity and run history before it became readable. Earlier comments also used an explicit load control. **Expected behavior** Open a small recent conversation quickly. Load older messages when the reader scrolls upward. Show an inline loading state. Keep the visible message and composer mounted through paging, live updates, and Back navigation. **Steps to reproduce** 1. Open a standing agent chat with hundreds of messages and completed runs. 2. Use a fresh browser query cache. 3. Observe full activity and run reads plus transcript requests for historical runs. 4. Compare the recent window and upward paging after this change. Related work: #18312 and #18323 restored compact task and collection loading. #14848 added reopening of visited agent chats. A GitHub search found no open PR for this chat-history optimization. ## What Changed - Return 20 contiguous recent comments for standing chats, with a stable oldest cursor. Keep the existing compact task exchange contract. Use the existing company/issue/time index for cursor reads. - Keep chats compact even if their task view previously expanded history. Retain observed messages through live catch-up and cache-backed return visits. - Keep fetched history pages, acknowledged sends, and the observed recent cursor separate. Reconcile live replies without losing history after a send or deleted cursor. - Hydrate metadata only for newly revealed message/run IDs; preserve prior batches while paging. Slow-send recovery refreshes the recent window. Receipts failures have an independent retry. - Hydrate run summaries only for loaded messages and the latest turn. Validate batches of at most 100 run IDs and keep the existing issue/company access rules. - Reuse hydrated terminal native streams when only history membership changes. Preserve retry, resume, mode changes, reconnect recovery, and interrupted recovery reads. - Load older messages, decision receipts, and task handoffs through upward scrolling. Support wheel, touch, and keyboard input. Keep pending questions visible. - Reserve a status slot while loading. Preserve a logical message anchor and the mounted composer. Pause failed page reads until an inline Retry. - Add database, route, controller, and scroll regressions, plus an opt-in production-build browser rig for long chats on desktop and mobile. Update design and feature/access documentation. ## Verification - The real PostgreSQL cold-open suite passes all 12 tests, including contiguous pages with equal timestamps, complete history, company denial, and scoped run IDs. - Activity route and OpenAPI suites pass 53 tests. The route run allowed local cold-import overhead; CI retains the repository's normal timeouts. - Three new paging regressions fail against the unchanged source. The candidate's focused paging/window suite passes 26 tests. Broader UI verification is recorded below when complete. - Desktop and mobile browser acceptance covers recent-only startup, per-frame reveal geometry, delayed and failed older pages, stable reading anchors and drafts, Back, older comment links, and one persisted live reply. - Full workspace typecheck and build passed locally. The final UI source also passes its typecheck and production build. - Affected UI suites pass: 212 controller/history/paging tests, 352 thread/scroller tests, 26 native-stream tests, plus metadata batching and live-cache hydration coverage. The issue mutation/access route suite passes 179 tests. - All 22 final desktop/mobile browser journeys pass, including delayed image growth. The isolated second-page request proof also passes six repeated desktop/mobile runs. A separate 6,000-message, 3,000-run fixture passes cold-open and delayed-paging checks on both screen sizes, still opening 20 messages and loading one 50-message page. - Final matched production timings use baseline `aa58fa1073` (`index-BaRiMbxd.js`) and candidate UI source `f9a26b9783` (`index-DPWTbsFF.js`, identical application source at final head `bf71ffd5af`). Chromium 151.0.7922.34, 1440×900, 4× CPU throttle, no added network latency, browser HTTP cache disabled, fresh application cache for each cold visit. Both variants block service workers and use the same warm local API server and immutable 600-message/300-run fixture. Only document HTML is intercepted for both variants; API requests are real. The common API revision is `109981f8c1`; subsequent commits change only UI and tests. | Usable conversation | Baseline median (range) | Candidate median (range) | | --- | --- | --- | | Cold direct URL | 6.57 s (5.69–7.45) | 3.07 s (2.50–3.28) | | Warm in-app return | 3.76 s (3.54–4.76) | 0.67 s (0.58–1.46) | | Back return | 2.53 s (2.12–4.20) | 0.51 s (0.47–1.18) | Five pairs alternate order. Every paired difference favors the candidate for all three journeys. Readiness requires a revealed, fully visible latest reply above the composer and an editable composer. Return timing starts after the Tasks route renders. Cold API requests fall from 953–954 to 71; API wire bytes fall from 517–559 KB to about 49 KB. Separate CPU profiles and request waterfalls confirm the removal of lifetime event hydration and are excluded from the five timing pairs. Both variants have the same expected missing optional plan document (404) and no browser exceptions. Control recent-window API reads have medians of 37 ms / 32 ms, with shared-host outliers up to 376 ms. The 6,000-message recent-window SQL uses `issue_comments_company_issue_created_at_idx` (22 index rows read for a 21-row probe). Geometry assertions retain a 2-pixel tolerance for the reading anchor and first-reveal layout. These are local synthetic measurements, not deployed staging latency. Earlier timing probes are excluded because they did not isolate service workers or composer clearance. Final head `bf71ffd5afb5684ba9105a3127f617e7adf5139c` has 55 successful checks, three optional skips and one neutral result, with no failures or pending checks. Greptile is 5/5 with no open review findings. ### Failure record Early local attempts exposed an unconditional scroller wrapper that changed the old task layout, and automatic retries on chat page failures. Both were corrected. The original task scroller structure stays in place. Metadata request assertions wait for initial connection recovery to settle, then verify only the new page’s reads; retry/resume/reconnect revalidation remains intentional. The initial review caught a send-before-scroll cache defect; its controller regression failed before the fix. Later rapid-paging checks caught scroll readiness being gated by metadata after the first reveal; readiness now stays enabled once revealed. Live replies use the separate acknowledged-message cache, then catch up from the server cursor. Visual review exposed a pagination wrapper bypassing existing composer clearance. The body is again a direct scroller child; cold-open browser checks require the full latest reply above the composer. A resize-observer regression failed before the fix and now passes. Controlled delayed inline images prove both pinned following and a stable reading anchor on desktop/mobile. The first performance fixture lacked valid native event envelopes and caused missing-log fallbacks. It is excluded from latency claims. The final fixture uses saved native assistant events and conversation session generations. The live test uses a temporary fixture-agent key, attributes the reply to an existing synthetic run, and revokes the key. It starts no model or agent process. Local database attempts failed during a Docker shutdown. The retained disposable database was restored and its suite passed. Early fixture errors included a missing conversation identity field and session generation. Mobile test setup also selected an offscreen sidebar link and reused queued human input. Optional announcements could intercept input; the fixture now dismisses them before the journeys. The scroll observer now waits for the delivered wheel event and settled animation frames, including when the viewport is already at the top. A concurrently running live test briefly changed another probe’s row count; the final acceptance and timing runs were sequential on separate fixtures. The final rig uses the visible mobile navigation and a saved agent reply. Anchor observation waits for the user's scroll to settle before measuring the effect of live updates. The full local `pnpm test:run` attempt hit repeated embedded PostgreSQL initialization failures and failures in untouched native-session executor suites; it was stopped after the repeated environment failures. The targeted real-PostgreSQL suite passed, and final-head CI is required before handoff. The first CI run also caught an exact mock expectation missing the added company scope; the expectation is updated and its 179-test suite passes. The final-head first CI attempt also stalled an assigned child task in the existing plan-approval scenario, then hit a heartbeat-run row lock timeout during fixture cleanup. The failed shard and aggregate check passed on an unchanged rerun of the same commit. Scenario assertions and timeouts were unchanged. The failure and rerun are retained in [CI run 41116120430](https://github.com/paperclipai/paperclip/actions/runs/41116120430). ## Risks - Historical transcript diagnostics no longer load on a cold chat open. Saved messages and conversation events remain available; diagnostic run views keep their full records. - Cursor paging must not omit comments with equal timestamps. Database tests cover timestamp/UUID ordering and the complete set. - Prepended or late content can move the reader. Browser checks cover retained row identity, viewport offsets, mounted drafts, and Back. Previously loaded pages stay in the query cache for its existing lifetime. - Local synthetic evidence does not establish deployed staging latency. This PR does not merge or deploy the change. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, tool use, and code execution. The exact backend deployment ID and context window were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (affected checks; limits are recorded above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9e1735ec17
commit
6dcf4160dd
38 files changed
+1228
-60
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let fixture: Pick<Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>, "connectionString" | "cleanup">;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
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" });
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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.
|
||||
@@ -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<ChatFixture> {
|
||||
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: ";
|
||||
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;
|
||||
}
|
||||
@@ -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<HTMLElement>("[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<HTMLElement>("[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<void>(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<void>(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<void>(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<string, string[]> = { 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<void>(resolve => { release = resolve; });
|
||||
const pendingImage = new Promise<void>(resolve => { requested = resolve; });
|
||||
await page.route("**/__chat-history-image.svg", async route => {
|
||||
requested(); await held;
|
||||
await route.fulfill({ contentType: "image/svg+xml", body: '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480"><rect width="640" height="480" fill="gray"/></svg>' });
|
||||
});
|
||||
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(); }
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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.
|
||||
@@ -92,5 +92,11 @@ export const activityApi = {
|
||||
forIssue: (issueId: string) => api.get<ActivityEvent[]>(`/issues/${issueId}/activity`),
|
||||
recentRunsForIssue: (issueId: string) => api.get<RunForIssue[]>(`/issues/${issueId}/runs?view=recent`),
|
||||
runsForIssue: (issueId: string) => api.get<RunForIssue[]>(`/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<RunForIssue[]>(`/issues/${issueId}/runs?runIds=${ids.slice(index * 100, (index + 1) * 100).join(",")}`),
|
||||
)).then(pages => pages.flat());
|
||||
},
|
||||
issuesForRun: (runId: string) => api.get<IssueForRun[]>(`/heartbeat-runs/${runId}/issues`),
|
||||
};
|
||||
@@ -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 <span data-testid="history-scroll-ready">{String(useTaskChatScrollNavigation().ready)}</span>; }
|
||||
const props = { issueId: "paged-chat", compactHistory: true, comments: createLongThreadComments(), onAdd: async () => {}, threadHeader: <Readiness /> };
|
||||
render(<TaskChatThread {...props} />);
|
||||
await settleThreadLayout();
|
||||
expect(container.querySelector('[data-testid="history-scroll-ready"]')?.textContent).toBe("true");
|
||||
render(<TaskChatThread {...props} initialHistoryPending />);
|
||||
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");
|
||||
|
||||
@@ -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<typeof IssueChatThread> & {
|
||||
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 (
|
||||
<TaskChatRequestHistoryContext.Provider value={issueId ?? "unknown"}>
|
||||
<TaskChatExpansionState.Provider value={expansionState.current}>
|
||||
<TaskChatScrollReady.Provider value={!historyPending || historyWaitExpired}>
|
||||
<TaskChatScrollReady.Provider value={historyRevealed || !historyPending || historyWaitExpired}>
|
||||
<TaskChatPresentationProvider
|
||||
mode={streamlinedUiEnabled ? "streamlined" : "production"}
|
||||
>
|
||||
@@ -3166,7 +3188,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
>
|
||||
{prepareInitialContent ? (
|
||||
<>
|
||||
{items.length === 0 && !tailRunId && !optimisticRunnerStartup ? (
|
||||
{items.length === 0 && !tailRunId && !optimisticRunnerStartup && !scrollingHistory?.hasMore ? (
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
@@ -3180,8 +3202,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
data-testid="task-chat-thread-header"
|
||||
>
|
||||
{threadHeaderWithBlockers}
|
||||
<CoordinatorChatQuestionStatus query={pendingQuestions} /><CoordinatorChatReceiptStatus query={decisionReceipts} />
|
||||
<CoordinatorChatHandoffStatus query={taskHandoffs} />
|
||||
<CoordinatorChatQuestionStatus query={pendingQuestions} /><CoordinatorChatReceiptStatus query={decisionReceipts} scroll={!!scrollingHistory} />
|
||||
<CoordinatorChatHandoffStatus query={taskHandoffs} scroll={!!scrollingHistory} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
|
||||
@@ -3201,8 +3223,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
) : (
|
||||
<TaskChatThreadView
|
||||
items={threadItems}
|
||||
scrollingHistory={scrollingHistory}
|
||||
attachments={attachments}
|
||||
header={<>{threadHeaderWithBlockers}{!issueBrief ? historyControl : null}<CoordinatorChatQuestionStatus query={pendingQuestions} /><CoordinatorChatReceiptStatus query={decisionReceipts} /><CoordinatorChatHandoffStatus query={taskHandoffs} /></>}
|
||||
header={<>{threadHeaderWithBlockers}{!issueBrief ? historyControl : null}<CoordinatorChatQuestionStatus query={pendingQuestions} /><CoordinatorChatReceiptStatus query={decisionReceipts} scroll={!!scrollingHistory} /><CoordinatorChatHandoffStatus query={taskHandoffs} scroll={!!scrollingHistory} /></>}
|
||||
renderInteraction={renderInteraction}
|
||||
renderBrief={renderBrief}
|
||||
renderMessageActions={renderMessageActions}
|
||||
|
||||
@@ -48,12 +48,12 @@ export function CoordinatorChatDecisionReceipt({ receipt }: { receipt: DecisionH
|
||||
</Link>;
|
||||
}
|
||||
|
||||
export function CoordinatorChatReceiptStatus({ query }: { query: ReturnType<typeof useCoordinatorChatDecisionReceipts> }) {
|
||||
export function CoordinatorChatReceiptStatus({ query, scroll = false }: { scroll?: boolean; query: ReturnType<typeof useCoordinatorChatDecisionReceipts> }) {
|
||||
if (!query.enabled) return null;
|
||||
if (query.isError) return <div role="status" className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Decision updates couldn’t load.</span><Button size="sm" variant="ghost" onClick={() => void query.refetch()}>Retry</Button>
|
||||
</div>;
|
||||
if (query.hasNextPage) return <Button size="sm" variant="ghost" disabled={query.isFetchingNextPage} onClick={() => void query.fetchNextPage()}>
|
||||
if (!scroll && query.hasNextPage) return <Button size="sm" variant="ghost" disabled={query.isFetchingNextPage} onClick={() => void query.fetchNextPage()}>
|
||||
{query.isFetchingNextPage ? "Loading earlier decisions…" : "Load earlier decisions"}
|
||||
</Button>;
|
||||
return null;
|
||||
|
||||
@@ -61,12 +61,12 @@ export function CoordinatorChatTaskHandoff({ handoff, animateEntry = false }: {
|
||||
</CoordinatorTaskMotion>;
|
||||
}
|
||||
|
||||
export function CoordinatorChatHandoffStatus({ query }: { query: ReturnType<typeof useCoordinatorChatTaskHandoffs> }) {
|
||||
export function CoordinatorChatHandoffStatus({ query, scroll = false }: { scroll?: boolean; query: ReturnType<typeof useCoordinatorChatTaskHandoffs> }) {
|
||||
if (!query.enabled) return null;
|
||||
if (query.isError) return <div role="status" className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Created tasks couldn’t load.</span><Button size="sm" variant="ghost" onClick={() => void query.refetch()}>Retry</Button>
|
||||
</div>;
|
||||
if (query.hasNextPage) return <Button size="sm" variant="ghost" disabled={query.isFetchingNextPage} onClick={() => void query.fetchNextPage()}>
|
||||
if (!scroll && query.hasNextPage) return <Button size="sm" variant="ghost" disabled={query.isFetchingNextPage} onClick={() => void query.fetchNextPage()}>
|
||||
{query.isFetchingNextPage ? "Loading earlier tasks…" : "Load earlier tasks"}
|
||||
</Button>;
|
||||
return null;
|
||||
|
||||
@@ -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 (
|
||||
<TaskMessageScroller contentKey={contentKey ?? taskChatContentKey(items)}>
|
||||
<TaskMessageScroller contentKey={contentKey ?? taskChatContentKey(items)} scrollingHistory={scrollingHistory}>
|
||||
{body}
|
||||
</TaskMessageScroller>
|
||||
);
|
||||
|
||||
@@ -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<void>(resolve => { release = resolve; }));
|
||||
const history = { hasMore: true, loading: false, error: false, load };
|
||||
const update = (tick: number) => flushSync(() => root.render(
|
||||
<TaskMessageScroller contentKey={tick} scrollingHistory={{ ...history }}><div>saved messages</div></TaskMessageScroller>,
|
||||
));
|
||||
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<Element>; resize: () => void }[] = [];
|
||||
vi.stubGlobal("ResizeObserver", class {
|
||||
targets = new Set<Element>();
|
||||
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(
|
||||
<TaskMessageScroller contentKey={tick} scrollingHistory={{ hasMore: true, loading: false, load: vi.fn() }}><div data-testid="image-body">image content</div></TaskMessageScroller>,
|
||||
));
|
||||
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(
|
||||
<TaskMessageScroller contentKey={tick} scrollingHistory={{ hasMore: true, loading: false, load }}><div>saved messages</div></TaskMessageScroller>,
|
||||
));
|
||||
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(
|
||||
<TaskMessageScroller contentKey={tick} scrollingHistory={{ hasMore: true, loading: false, load }}><div>short conversation</div></TaskMessageScroller>,
|
||||
));
|
||||
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(
|
||||
<TaskMessageScroller contentKey={tick} scrollingHistory={{ hasMore: true, loading: false, load }}><div>short conversation</div></TaskMessageScroller>,
|
||||
));
|
||||
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;
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const scrollbarIdleDelayRef = useRef<number | null>(null);
|
||||
const [pillPhase, setPillPhase] = useState<PillPhase>("hidden");
|
||||
const previousScrollTop = useRef(0);
|
||||
const touchStartY = useRef<number | null>(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
|
||||
<div
|
||||
ref={ref}
|
||||
onScroll={handleScroll}
|
||||
tabIndex={scrollingHistory ? 0 : undefined}
|
||||
role={scrollingHistory ? "region" : undefined}
|
||||
aria-label={scrollingHistory ? "Conversation messages" : undefined}
|
||||
onKeyDown={event => {
|
||||
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) ? (
|
||||
<div role="status" className="flex h-8 items-center justify-center gap-2 text-xs text-muted-foreground" data-testid="chat-history-status">
|
||||
{scrollingHistory.error ? <>
|
||||
<span>Earlier messages couldn’t load.</span>
|
||||
<button type="button" className="underline underline-offset-4" onClick={() => scrollingHistory.load()}>Retry</button>
|
||||
</> : scrollingHistory.loading ? "Loading earlier messages…" : "Scroll up for earlier messages"}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</> : children}
|
||||
</div>
|
||||
{pillPhase !== "hidden" ? (
|
||||
<button
|
||||
|
||||
@@ -118,8 +118,8 @@ describe("native history readiness and stable projection", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let latest: ReturnType<typeof useNativeRunTranscripts>;
|
||||
function StateProbe({ runs = [{ id: "one", status: "running", runtimeMode: "native" as const }] }) {
|
||||
latest = useNativeRunTranscripts(runs);
|
||||
function StateProbe({ runs = [{ id: "one", status: "running", runtimeMode: "native" as const }], enabled = true }) {
|
||||
latest = useNativeRunTranscripts(runs, { enabled });
|
||||
return null;
|
||||
}
|
||||
beforeEach(() => {
|
||||
@@ -150,6 +150,38 @@ describe("native history readiness and stable projection", () => {
|
||||
expect(latest.errorsByRun.size).toBe(0);
|
||||
});
|
||||
|
||||
it("loads newly revealed terminal runs without rereading settled siblings, and still refreshes on retry", async () => {
|
||||
eventsMock.mockResolvedValue([]);
|
||||
const one = { id: "one", status: "succeeded", runtimeMode: "native" as const };
|
||||
const two = { ...one, id: "two" };
|
||||
await act(async () => { root.render(<StateProbe runs={[one]} />); });
|
||||
await act(async () => { root.render(<StateProbe runs={[one, two]} />); });
|
||||
expect(eventsMock.mock.calls.map(([id]) => id)).toEqual(["one", "two"]);
|
||||
await act(async () => { latest.retry(); });
|
||||
expect(eventsMock.mock.calls.map(([id]) => id)).toEqual(["one", "two", "one", "two"]);
|
||||
});
|
||||
|
||||
it("restarts an interrupted explicit refresh when history membership changes", async () => {
|
||||
eventsMock.mockResolvedValueOnce([]).mockImplementationOnce(() => new Promise(() => {})).mockResolvedValue([]);
|
||||
const one = { id: "one", status: "succeeded", runtimeMode: "native" as const };
|
||||
await act(async () => { root.render(<StateProbe runs={[one]} />); });
|
||||
await act(async () => { latest.retry(); });
|
||||
const interruptedSignal = eventsMock.mock.calls[1][3].signal;
|
||||
await act(async () => { root.render(<StateProbe runs={[one, { ...one, id: "two" }]} />); });
|
||||
expect(interruptedSignal.aborted).toBe(true);
|
||||
expect(eventsMock.mock.calls.map(([id]) => id)).toEqual(["one", "one", "one", "two"]);
|
||||
});
|
||||
|
||||
it("revalidates settled history after a disabled view resumes", async () => {
|
||||
eventsMock.mockResolvedValue([]);
|
||||
const runs = [{ id: "one", status: "succeeded", runtimeMode: "native" as const }];
|
||||
await act(async () => { root.render(<StateProbe runs={runs} />); });
|
||||
await act(async () => { root.render(<StateProbe runs={runs} enabled={false} />); });
|
||||
expect(eventsMock).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { root.render(<StateProbe runs={runs} />); });
|
||||
expect(eventsMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not rebuild unchanged history on empty polls", async () => {
|
||||
eventsMock.mockResolvedValueOnce([{ seq: 1, payload: {}, eventType: "log" }]).mockResolvedValue([]);
|
||||
await act(async () => { root.render(<StateProbe />); });
|
||||
@@ -219,6 +251,8 @@ describe("native history readiness and stable projection", () => {
|
||||
expect(latest.hydratedRunIds.has("slow")).toBe(false);
|
||||
expect(latest.transcriptByRun.has("one")).toBe(true);
|
||||
await act(async () => { root.render(<StateProbe runs={[one]} />); });
|
||||
expect(eventsMock.mock.calls.filter(([id]) => id === "one").map(([, cursor]) => cursor)).toEqual([0]);
|
||||
await act(async () => { latest.retry(); });
|
||||
expect(eventsMock.mock.calls.filter(([id]) => id === "one").map(([, cursor]) => cursor)).toEqual([0, 7]);
|
||||
expect(latest.isInitialHydrating).toBe(false);
|
||||
});
|
||||
|
||||
@@ -59,14 +59,16 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource
|
||||
};
|
||||
const gapPendingRef = useRef(new Set<string>());
|
||||
const historyReadsRef = useRef(new Map<string, AbortSignal>());
|
||||
const interruptedReadsRef = useRef(new Set<string>());
|
||||
const refreshByRunRef = useRef(new Map<string, (force?: boolean) => void>());
|
||||
const previousCompanyRef = useRef(options.companyId);
|
||||
const refreshContextRef = useRef<readonly unknown[]>([]);
|
||||
useEffect(() => {
|
||||
if (previousCompanyRef.current === options.companyId) return;
|
||||
previousCompanyRef.current = options.companyId;
|
||||
setEventsByRun(new Map()); setErrorsByRun(new Map()); setHydratedRunIds(new Set()); setHydratedStatusByRun(new Map());
|
||||
projectionCacheRef.current.clear(); cursorByRunRef.current.clear(); conversationOnlyByRunRef.current.clear();
|
||||
sequencesRef.current.clear(); gapPendingRef.current.clear();
|
||||
sequencesRef.current.clear(); gapPendingRef.current.clear(); interruptedReadsRef.current.clear();
|
||||
}, [options.companyId]);
|
||||
const stream = useTranscriptLiveStream(nativeRuns.filter(run => isLive(run.status)).map(run => run.id), options, event => {
|
||||
const runId = typeof event.payload.runId === "string" ? event.payload.runId : null;
|
||||
@@ -89,10 +91,14 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const context = [retryGeneration, visible, options.enabled, options.companyId, stream.healthy, stream.epoch];
|
||||
const refreshExisting = context.some((value, index) => value !== refreshContextRef.current[index]);
|
||||
refreshContextRef.current = context;
|
||||
if (!visible || options.enabled === false) return;
|
||||
const changedModes = new Set<string>();
|
||||
for (const run of nativeRuns) {
|
||||
const mode = Boolean(run.conversationOnly);
|
||||
if (conversationOnlyByRunRef.current.get(run.id) !== mode) { cursorByRunRef.current.delete(run.id); sequencesRef.current.delete(run.id); }
|
||||
if (conversationOnlyByRunRef.current.get(run.id) !== mode) { changedModes.add(run.id); cursorByRunRef.current.delete(run.id); sequencesRef.current.delete(run.id); }
|
||||
conversationOnlyByRunRef.current.set(run.id, mode);
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -100,6 +106,7 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource
|
||||
const timers = new Map<string, number>();
|
||||
const pendingRefresh = new Set<string>();
|
||||
const retainedIds = new Set(nativeRuns.map((run) => run.id));
|
||||
for (const id of interruptedReadsRef.current) if (!retainedIds.has(id)) interruptedReadsRef.current.delete(id);
|
||||
const retainMap = <T,>(previous: Map<string, T>) => {
|
||||
const next = new Map([...previous].filter(([id]) => retainedIds.has(id)));
|
||||
return next.size === previous.size ? previous : next;
|
||||
@@ -161,6 +168,7 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource
|
||||
next.delete(run.id);
|
||||
return next;
|
||||
});
|
||||
interruptedReadsRef.current.delete(run.id);
|
||||
setHydratedStatusByRun((previous) => {
|
||||
if (previous.get(run.id) === run.status) return previous;
|
||||
return new Map(previous).set(run.id, run.status);
|
||||
@@ -196,13 +204,17 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource
|
||||
if (force) pendingRefresh.add(run.id);
|
||||
void refreshRun(run);
|
||||
});
|
||||
void refreshRun(run);
|
||||
if (refreshExisting || changedModes.has(run.id) || interruptedReadsRef.current.has(run.id) || isLive(run.status)
|
||||
|| hydratedStatusByRun.get(run.id) !== run.status || errorsByRun.has(run.id) || sequence(run.id).needsCatchup) void refreshRun(run);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
refreshByRunRef.current.clear();
|
||||
controller.abort();
|
||||
for (const [id, signal] of historyReadsRef.current) if (signal === controller.signal) historyReadsRef.current.delete(id);
|
||||
for (const [id, signal] of historyReadsRef.current) if (signal === controller.signal) {
|
||||
interruptedReadsRef.current.add(id);
|
||||
historyReadsRef.current.delete(id);
|
||||
}
|
||||
for (const timer of timers.values()) window.clearTimeout(timer);
|
||||
};
|
||||
}, [nativeRuns, retryGeneration, visible, options.enabled, options.companyId, stream.healthy, stream.epoch]);
|
||||
|
||||
@@ -1446,6 +1446,28 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
});
|
||||
|
||||
describe("LiveUpdatesProvider visible issue comment hydration", () => {
|
||||
it("keeps a live chat reply separate from fetched pages and the observed cursor", async () => {
|
||||
const client = new QueryClient();
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
client.setQueryData(queryKeys.issues.detail("PAP-759"), {
|
||||
id: "issue-1", identifier: "PAP-759", conversationAgentId: "agent-1",
|
||||
});
|
||||
const recentKey = [...queryKeys.issues.comments("PAP-759"), "recent"];
|
||||
client.setQueryData(recentKey, { comments: [], latestCommentId: "observed", hasMore: true });
|
||||
const comment = { id: "new-reply", issueId: "issue-1", body: "A saved live reply" };
|
||||
getCommentMock.mockResolvedValueOnce(comment);
|
||||
await __liveUpdatesTestUtils.hydrateVisibleIssueComment(client, "/PAP/issues/PAP-759", {
|
||||
entityType: "issue", entityId: "issue-1", action: "issue.comment_added", details: { commentId: comment.id },
|
||||
}, { isForegrounded: true });
|
||||
for (const ref of ["PAP-759", "issue-1"]) {
|
||||
expect(client.getQueryData(queryKeys.issues.acknowledgedComments(ref))).toEqual([comment]);
|
||||
expect(client.getQueryData(queryKeys.issues.comments(ref))).toBeUndefined();
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: [...queryKeys.issues.comments(ref), "recent"] });
|
||||
}
|
||||
expect(client.getQueryData(recentKey)).toMatchObject({ latestCommentId: "observed", hasMore: true });
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it("hydrates the visible issue comments cache with only the new comment", async () => {
|
||||
getCommentMock.mockResolvedValueOnce({
|
||||
id: "comment-2",
|
||||
|
||||
@@ -289,6 +289,7 @@ interface VisibleRouteOptions {
|
||||
}
|
||||
|
||||
interface VisibleIssueRouteContext {
|
||||
conversation: boolean;
|
||||
routeIssueRef: string;
|
||||
issueRefs: Set<string>;
|
||||
assigneeAgentId: string | null;
|
||||
@@ -473,6 +474,7 @@ function resolveVisibleIssueRouteContext(
|
||||
}
|
||||
}
|
||||
return {
|
||||
conversation: segments[0] === "chats" || Boolean(issue?.conversationAgentId),
|
||||
routeIssueRef: issueRef,
|
||||
issueRefs,
|
||||
assigneeAgentId: issue?.assigneeAgentId ?? null,
|
||||
@@ -909,6 +911,15 @@ async function hydrateVisibleIssueComment(
|
||||
context.routeIssueRef,
|
||||
commentId,
|
||||
);
|
||||
if (context.conversation) {
|
||||
for (const ref of context.issueRefs) {
|
||||
queryClient.setQueryData<IssueComment[]>(queryKeys.issues.acknowledgedComments(ref), current =>
|
||||
[...new Map([...(current ?? []), comment].map(row => [row.id, row])).values()],
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: [...queryKeys.issues.comments(ref), "recent"] });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
queryClient.setQueryData<
|
||||
InfiniteData<IssueComment[], string | null> | undefined
|
||||
>(queryKeys.issues.comments(context.routeIssueRef), (current) => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { IssueComment } from "@paperclipai/shared";
|
||||
import { useConversationHistoryPages } from "./useConversationHistoryPages";
|
||||
const api = vi.hoisted(() => ({ listComments: vi.fn() }));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: api }));
|
||||
|
||||
it("recovers deleted cursors in both the initial window and a fetched page", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const comment = (n: number) => ({ id: `comment-${n}`, createdAt: new Date(n * 1000) } as IssueComment);
|
||||
const recent = [1, 100, 101, 102].map(comment);
|
||||
api.listComments.mockImplementation(async (_ref, options) => {
|
||||
const cursor = Number(options.after?.split("-")[1] ?? 103);
|
||||
if ([100, 50].includes(cursor)) return [];
|
||||
return Array.from({ length: cursor }, (_, index) => comment(cursor - index - 1)).filter(row => !["comment-100", "comment-50"].includes(row.id)).slice(0, 50);
|
||||
});
|
||||
let current: ReturnType<typeof useConversationHistoryPages>;
|
||||
function Probe() { current = useConversationHistoryPages("chat", true, "comment-100", recent); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
|
||||
expect(api.listComments).not.toHaveBeenCalled();
|
||||
await act(async () => { current!.load(); });
|
||||
await vi.waitFor(() => expect(current!.isSuccess).toBe(true));
|
||||
expect(current!.data?.pages[0].comments.at(-1)?.id).toBe("comment-49");
|
||||
expect(current!.hasNextPage).toBe(true);
|
||||
api.listComments.mockImplementation(async (_ref, options) => {
|
||||
const cursor = Number(options.after?.split("-")[1] ?? 103);
|
||||
if (cursor === 49) return [];
|
||||
return Array.from({ length: cursor }, (_, index) => comment(cursor - index - 1)).filter(row => !["comment-100", "comment-50", "comment-49"].includes(row.id)).slice(0, 50);
|
||||
});
|
||||
await act(async () => { await current!.load(); });
|
||||
await vi.waitFor(() => expect(current!.data?.pages).toHaveLength(2));
|
||||
expect(current!.data?.pages[1].comments.at(-1)?.id).toBe("comment-0");
|
||||
expect(current!.hasNextPage).toBe(false);
|
||||
} finally { await act(async () => root.unmount()); client.clear(); }
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useInfiniteQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
|
||||
import type { IssueComment } from "@paperclipai/shared";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
export interface ConversationHistoryPage {
|
||||
comments: IssueComment[];
|
||||
nextCursor: string | null;
|
||||
fallbackCursors?: string[];
|
||||
}
|
||||
|
||||
export function useConversationHistoryPages(issueRef: string | undefined, active: boolean, oldestCommentId: string | null | undefined, recentComments: IssueComment[]) {
|
||||
const client = useQueryClient();
|
||||
const [requestedRef, setRequestedRef] = useState<string | null>(null);
|
||||
const key = queryKeys.issues.earlierComments(issueRef ?? "pending");
|
||||
const cached = client.getQueryData<InfiniteData<ConversationHistoryPage, string | null>>(key);
|
||||
const started = requestedRef === issueRef || Boolean(cached);
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: key,
|
||||
enabled: active && !!issueRef && started,
|
||||
retry: false,
|
||||
initialPageParam: oldestCommentId ?? null,
|
||||
queryFn: async ({ pageParam, signal }): Promise<ConversationHistoryPage> => {
|
||||
const read = (cursor: string | null) => issuesApi.listComments(issueRef!, {
|
||||
order: "desc", limit: 50, ...(cursor ? { after: cursor } : {}),
|
||||
}, { signal });
|
||||
let comments = await read(pageParam);
|
||||
if (!comments.length && pageParam) {
|
||||
const previous = client.getQueryData<InfiniteData<ConversationHistoryPage, string | null>>(key);
|
||||
const saved = previous?.pages.find(page => page.nextCursor === pageParam)?.fallbackCursors;
|
||||
const boundary = recentComments.findIndex(comment => comment.id === pageParam);
|
||||
const candidates = saved ?? (boundary < 0 ? [] : recentComments.slice(boundary + 1).map(comment => comment.id));
|
||||
const anchors = [...new Set([candidates.find(id => id !== pageParam), candidates.at(-1), null])];
|
||||
for (const anchor of anchors) {
|
||||
if (anchor === undefined || anchor === pageParam) continue;
|
||||
comments = await read(anchor);
|
||||
if (comments.length) break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
comments, nextCursor: comments.length === 50 ? comments.at(-1)!.id : null,
|
||||
fallbackCursors: comments.slice(0, -1).reverse().map(comment => comment.id),
|
||||
};
|
||||
},
|
||||
getNextPageParam: page => page.nextCursor ?? undefined,
|
||||
});
|
||||
const load = useCallback(() => {
|
||||
if (!active || !issueRef) return;
|
||||
if (!started) { setRequestedRef(issueRef); return; }
|
||||
return query.fetchNextPage({ cancelRefetch: false });
|
||||
}, [active, issueRef, started, query.fetchNextPage]);
|
||||
return { ...query, load };
|
||||
}
|
||||
@@ -51,6 +51,7 @@ it.each([true, false])("catches up after canceled input and a reply burst (earli
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
|
||||
for (let i = 0; i < 20 && !current!.recent.isSuccess; i++) await wait();
|
||||
expect(current!.recent.isSuccess).toBe(true);
|
||||
await act(async () => current!.retainComment(comment(61)));
|
||||
api.recentComments.mockResolvedValue({ comments: [58, 59, 60, 61].map(comment), latestCommentId: "comment-61", hasMore: true });
|
||||
api.listComments.mockImplementation(async (_issue, options) => {
|
||||
if (options.after === "comment-1") return []; // Canceled queued input.
|
||||
@@ -70,3 +71,42 @@ it.each([true, false])("catches up after canceled input and a reply burst (earli
|
||||
client.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a continuous chat compact even after its task view expanded history", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(["issues", "history-expanded", "chat"], true);
|
||||
const root = createRoot(document.createElement("div"));
|
||||
api.recentComments.mockResolvedValue({ comments: [], latestCommentId: null, oldestCommentId: null, hasMore: false });
|
||||
let current: ReturnType<typeof useIssueHistoryWindow>;
|
||||
function Probe() { current = useIssueHistoryWindow("chat", false, undefined, true); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
|
||||
await vi.waitFor(() => expect(current!.recent.isSuccess).toBe(true));
|
||||
expect(current!.historyExpanded).toBe(false);
|
||||
} finally { await act(async () => root.unmount()); client.clear(); }
|
||||
});
|
||||
|
||||
it("retains an acknowledged reply without skipping unseen messages in catch-up", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const comment = (n: number) => ({ id: `comment-${n}`, issueId: "chat", createdAt: new Date(n * 1000), body: `Message ${n}` } as IssueComment);
|
||||
api.recentComments.mockResolvedValue({ comments: [comment(0)], latestCommentId: "comment-0", oldestCommentId: "comment-0", hasMore: true });
|
||||
api.listComments.mockReset().mockResolvedValueOnce(Array.from({ length: 50 }, (_, i) => comment(i + 1))).mockResolvedValueOnce(Array.from({ length: 10 }, (_, i) => comment(i + 51)));
|
||||
let current: ReturnType<typeof useIssueHistoryWindow>;
|
||||
function Probe() { current = useIssueHistoryWindow("chat", false, undefined, true); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
|
||||
await vi.waitFor(() => expect(current!.recent.isSuccess).toBe(true));
|
||||
await act(async () => current!.retainComment(comment(60)));
|
||||
await vi.waitFor(() => expect(current!.acknowledgedComments.map(row => row.id)).toEqual(["comment-60"]));
|
||||
expect(current!.recent.data?.comments.map(row => row.id)).toEqual(["comment-0"]);
|
||||
expect(current!.recent.data?.latestCommentId).toBe("comment-0");
|
||||
api.recentComments.mockResolvedValue({ comments: [comment(60)], latestCommentId: "comment-60", oldestCommentId: "comment-60", hasMore: true });
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.issues.comments("chat") }); });
|
||||
await vi.waitFor(() => expect(current!.recent.data?.comments).toHaveLength(61));
|
||||
expect(current!.recent.data?.oldestCommentId).toBe("comment-0");
|
||||
expect(current!.recent.data?.latestCommentId).toBe("comment-60");
|
||||
expect(api.listComments).toHaveBeenCalledTimes(2);
|
||||
await vi.waitFor(() => expect(current!.acknowledgedComments).toHaveLength(0));
|
||||
} finally { await act(async () => root.unmount()); client.clear(); }
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export function useIssueHistoryWindow(
|
||||
issueRef: string | undefined,
|
||||
showFullHistory: boolean,
|
||||
issue?: Pick<Issue, "id" | "identifier">,
|
||||
continuous = false,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const expansionKey = ["issues", "history-expanded", issueRef] as const;
|
||||
@@ -24,7 +25,7 @@ export function useIssueHistoryWindow(
|
||||
initialData: false,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
const historyExpanded = showFullHistory || expanded;
|
||||
const historyExpanded = !continuous && (showFullHistory || expanded);
|
||||
const expandHistory = useCallback(() => {
|
||||
for (const ref of new Set([issueRef, issue?.id, issue?.identifier])) {
|
||||
if (ref) queryClient.setQueryData(["issues", "history-expanded", ref], true);
|
||||
@@ -32,8 +33,8 @@ export function useIssueHistoryWindow(
|
||||
}, [issueRef, issue?.id, issue?.identifier, queryClient]);
|
||||
useEffect(() => {
|
||||
// Keep identifier and UUID navigation on the same expansion preference.
|
||||
if (showFullHistory || expanded) expandHistory();
|
||||
}, [showFullHistory, expanded, expandHistory]);
|
||||
if (!continuous && (showFullHistory || expanded)) expandHistory();
|
||||
}, [continuous, showFullHistory, expanded, expandHistory]);
|
||||
const recentKey = [...queryKeys.issues.comments(issueRef ?? ""), "recent"] as const;
|
||||
const recent = useQuery({
|
||||
queryKey: recentKey,
|
||||
@@ -76,12 +77,40 @@ export function useIssueHistoryWindow(
|
||||
return {
|
||||
...snapshot,
|
||||
hasMore: previous.hasMore,
|
||||
oldestCommentId: previous.oldestCommentId,
|
||||
latestCommentId: incoming.at(-1)?.id ?? snapshot.latestCommentId,
|
||||
comments: mergeWindowComments(previous.comments, incoming, snapshot.comments),
|
||||
};
|
||||
},
|
||||
});
|
||||
const { data: acknowledgedComments = [] } = useQuery<IssueComment[]>({
|
||||
queryKey: queryKeys.issues.acknowledgedComments(issueRef ?? ""),
|
||||
queryFn: () => [],
|
||||
enabled: false,
|
||||
initialData: [],
|
||||
});
|
||||
const retainComment = useCallback((comment: IssueComment) => {
|
||||
for (const ref of new Set([issueRef, issue?.id, issue?.identifier, comment.issueId])) {
|
||||
if (!ref) continue;
|
||||
queryClient.setQueryData<IssueComment[]>(queryKeys.issues.acknowledgedComments(ref), current =>
|
||||
mergeWindowComments(current ?? [], [comment]),
|
||||
);
|
||||
}
|
||||
}, [issueRef, issue?.id, issue?.identifier, queryClient]);
|
||||
useEffect(() => {
|
||||
if (!recent.data) return;
|
||||
const observed = new Set(recent.data.comments.map(comment => comment.id));
|
||||
for (const ref of new Set([issueRef, issue?.id, issue?.identifier])) {
|
||||
if (!ref) continue;
|
||||
queryClient.setQueryData<IssueComment[]>(queryKeys.issues.acknowledgedComments(ref), current => {
|
||||
if (!current?.some(comment => observed.has(comment.id))) return current;
|
||||
return current.filter(comment => !observed.has(comment.id));
|
||||
});
|
||||
}
|
||||
}, [recent.data, issueRef, issue?.id, issue?.identifier, queryClient]);
|
||||
return {
|
||||
acknowledgedComments,
|
||||
retainComment,
|
||||
historyExpanded,
|
||||
expandHistory,
|
||||
recent,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { useIssueIdBatches } from "./useIssueIdBatches";
|
||||
|
||||
it("requests metadata only for newly revealed ids and refreshes existing batches on invalidation", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const fetch = vi.fn(async (ids: string[]) => ids.map(id => ({ id })));
|
||||
let ids = Array.from({ length: 20 }, (_, index) => String(index));
|
||||
let current: ReturnType<typeof useIssueIdBatches<{ id: string }>>;
|
||||
function Probe() { current = useIssueIdBatches(["metadata", "chat"], ids, fetch, true); return null; }
|
||||
const render = () => act(async () => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
|
||||
try {
|
||||
await render();
|
||||
await vi.waitFor(() => expect(current!.data).toHaveLength(20));
|
||||
ids = Array.from({ length: 170 }, (_, index) => String(index));
|
||||
await render();
|
||||
await vi.waitFor(() => expect(current!.data).toHaveLength(170));
|
||||
expect(fetch.mock.calls.flatMap(([batch]) => batch)).toHaveLength(170);
|
||||
expect(fetch.mock.calls.every(([batch]) => batch.length <= 100)).toBe(true);
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: ["metadata", "chat"] }); });
|
||||
expect(fetch.mock.calls.flatMap(([batch]) => batch)).toHaveLength(340);
|
||||
} finally { await act(async () => root.unmount()); client.clear(); }
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function useIssueIdBatches<T>(key: readonly unknown[], ids: string[], fetchBatch: (ids: string[]) => Promise<T[]>, enabled: boolean) {
|
||||
const client = useQueryClient();
|
||||
const serializedKey = JSON.stringify(key);
|
||||
const base = useMemo(() => JSON.parse(serializedKey) as unknown[], [serializedKey]);
|
||||
const { data: batches = [] } = useQuery<string[][]>({
|
||||
queryKey: [...base, "batch-ids"], queryFn: () => [], enabled: false, initialData: [],
|
||||
});
|
||||
const known = new Set(batches.flat());
|
||||
const missing = ids.filter(id => !known.has(id));
|
||||
const missingKey = JSON.stringify(missing);
|
||||
useEffect(() => {
|
||||
if (!enabled || missingKey === "[]") return;
|
||||
client.setQueryData<string[][]>([...base, "batch-ids"], current => {
|
||||
const existing = new Set(current?.flat() ?? []);
|
||||
const incoming = (JSON.parse(missingKey) as string[]).filter(id => !existing.has(id));
|
||||
if (!incoming.length) return current;
|
||||
const appended: string[][] = [];
|
||||
for (let index = 0; index < incoming.length; index += 100) appended.push(incoming.slice(index, index + 100));
|
||||
return [...current ?? [], ...appended];
|
||||
});
|
||||
}, [client, base, missingKey, enabled]);
|
||||
const queries = useQueries({ queries: batches.map(batch => ({
|
||||
queryKey: [...base, "batch", batch], queryFn: () => fetchBatch(batch), enabled,
|
||||
})) });
|
||||
return {
|
||||
data: queries.flatMap(query => query.data ?? []),
|
||||
isPending: enabled && (missing.length > 0 || queries.some(query => query.isPending)),
|
||||
isLoading: enabled && (missing.length > 0 || queries.some(query => query.isLoading)),
|
||||
isError: enabled && queries.some(query => query.isError),
|
||||
refetch: () => Promise.all(queries.map(query => query.refetch())),
|
||||
};
|
||||
}
|
||||
@@ -374,6 +374,8 @@ export const queryKeys = {
|
||||
["issues", companyId, "summaries", userId ?? "__local_board__", sortedIds] as const,
|
||||
detail: (id: string) => ["issues", "detail", id] as const,
|
||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||
acknowledgedComments: (issueId: string) => ["issues", "comments", issueId, "acknowledged"] as const,
|
||||
earlierComments: (issueId: string) => ["issues", "comments", issueId, "earlier"] as const,
|
||||
commentsList: (issueId: string) =>
|
||||
["issues", "comments", issueId, "list"] as const,
|
||||
queuedComments: (issueId: string) =>
|
||||
|
||||
@@ -103,6 +103,7 @@ const mockActivityApi = vi.hoisted(() => ({
|
||||
recentForIssue: vi.fn(),
|
||||
inputFactsForIssue: vi.fn(),
|
||||
runsForIssue: vi.fn(),
|
||||
runsByIdsForIssue: vi.fn(),
|
||||
recentRunsForIssue: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -1350,6 +1351,7 @@ describe("IssueDetail", () => {
|
||||
mockIssuesApi.getCore.mockImplementation((id) => mockIssuesApi.get(id));
|
||||
mockIssuesApi.recentComments.mockResolvedValue({ comments: [], latestCommentId: null, hasMore: false });
|
||||
mockActivityApi.recentRunsForIssue.mockResolvedValue([]);
|
||||
mockActivityApi.runsByIdsForIssue.mockResolvedValue([]);
|
||||
mockActivityApi.recentForIssue.mockResolvedValue([]);
|
||||
mockActivityApi.inputFactsForIssue.mockResolvedValue([]);
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
@@ -1920,6 +1922,59 @@ describe("IssueDetail", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("opens chats compactly and pages backward without expanding all history", async () => {
|
||||
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
|
||||
const chat = createIssue({ conversationAgentId: "agent-1", conversationUserId: "user-1" });
|
||||
const latest = createIssueComment({ id: "10000000-0000-4000-8000-000000000020", body: "Recent chat reply" });
|
||||
mockIssuesApi.getCore.mockResolvedValue(chat);
|
||||
mockIssuesApi.get.mockResolvedValue(chat);
|
||||
mockIssuesApi.recentComments.mockResolvedValue({ comments: [latest], latestCommentId: latest.id, oldestCommentId: latest.id, hasMore: true });
|
||||
mockIssuesApi.listComments.mockResolvedValue([]);
|
||||
await act(async () => renderIssueView());
|
||||
await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
compactHistory: true, historyControl: null, scrollingHistory: { hasMore: true, loading: false },
|
||||
}));
|
||||
expect(mockIssuesApi.listComments).not.toHaveBeenCalled();
|
||||
expect(mockActivityApi.forIssue).not.toHaveBeenCalled();
|
||||
expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled();
|
||||
await act(async () => { await mockIssueChatThreadRender.mock.calls.at(-1)?.[0].onRefreshLatestComments(); });
|
||||
expect(mockIssuesApi.listComments).not.toHaveBeenCalled();
|
||||
await act(async () => { await mockIssueChatThreadRender.mock.calls.at(-1)?.[0].scrollingHistory.load(); });
|
||||
await waitForAssertion(() => expect(mockIssuesApi.listComments.mock.calls.some(([ref, options]) => ref === "PAP-1" && options.order === "desc" && options.after === latest.id && options.limit === 50)).toBe(true));
|
||||
expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled();
|
||||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].compactHistory).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps chat paging after sending (earlier pages loaded: %s)", async loaded => {
|
||||
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
|
||||
const chat = createIssue({ conversationAgentId: "agent-1", conversationUserId: "user-1" });
|
||||
const latest = createIssueComment({ id: "10000000-0000-4000-8000-000000000020" });
|
||||
const sent = createIssueComment({ id: "10000000-0000-4000-8000-000000000021", body: "Send before scrolling" });
|
||||
const rows = Array.from({ length: 100 }, (_, index) => createIssueComment({
|
||||
id: `20000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
|
||||
body: `Earlier ${index}`, createdAt: new Date(100_000 - index * 1000),
|
||||
}));
|
||||
mockIssuesApi.getCore.mockResolvedValue(chat); mockIssuesApi.get.mockResolvedValue(chat);
|
||||
mockIssuesApi.recentComments.mockResolvedValue({ comments: [latest], latestCommentId: latest.id, oldestCommentId: latest.id, hasMore: true });
|
||||
mockIssuesApi.addComment.mockResolvedValue(sent);
|
||||
mockIssuesApi.listComments.mockImplementation(async (_ref, options) => options.order === "asc" ? [sent]
|
||||
: options.after === rows[49].id ? rows.slice(50) : rows.slice(0, 50));
|
||||
await act(async () => renderIssueView());
|
||||
const props = () => mockIssueChatThreadRender.mock.calls.at(-1)![0];
|
||||
await waitForAssertion(() => expect(props().scrollingHistory?.hasMore).toBe(true));
|
||||
if (loaded) {
|
||||
await act(async () => { await props().scrollingHistory.load(); });
|
||||
await waitForAssertion(() => expect(props().comments.some((comment: IssueComment) => comment.id === rows[49].id)).toBe(true));
|
||||
}
|
||||
mockIssuesApi.recentComments.mockResolvedValue({ comments: [sent, latest], latestCommentId: sent.id, oldestCommentId: latest.id, hasMore: true });
|
||||
await act(async () => { await props().onAdd(sent.body); });
|
||||
await waitForAssertion(() => expect(props().scrollingHistory?.hasMore).toBe(true));
|
||||
await act(async () => { await props().scrollingHistory.load(); });
|
||||
await waitForAssertion(() => expect(mockIssuesApi.listComments.mock.calls.some(([ref, options]) => ref === "PAP-1"
|
||||
&& options.order === "desc" && options.limit === 50 && options.after === (loaded ? rows[49].id : latest.id))).toBe(true));
|
||||
expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reveals a cold task and recent exchange while full detail and older history are stalled", async () => {
|
||||
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
|
||||
const stalled = createDeferred<Issue>();
|
||||
@@ -6727,6 +6782,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.earlierComments(ref), { pages: [{ comments: [comment, earlier], nextCursor: earlier.id }], pageParams: [comment.id] });
|
||||
queryClient.setQueryData([...queryKeys.issues.comments(ref), "linked", comment.id], comment);
|
||||
}
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "in_progress", assigneeAgentId: "agent-1" }));
|
||||
@@ -6761,6 +6817,11 @@ describe("IssueDetail", () => {
|
||||
finishStaleRead({ comments: [earlier, comment], latestCommentId: comment.id, hasMore: true });
|
||||
await staleRead;
|
||||
});
|
||||
for (const ref of refs) {
|
||||
const history = queryClient.getQueryData<{ pages: { comments: IssueComment[]; nextCursor: string }[] }>(queryKeys.issues.earlierComments(ref))!;
|
||||
expect(history.pages[0].nextCursor).toBe(earlier.id);
|
||||
expect(history.pages[0].comments.find(row => row.id === comment.id)).toEqual(action === "delete" ? deleted : undefined);
|
||||
}
|
||||
await waitForAssertion(() => {
|
||||
for (const ref of refs) {
|
||||
const recent = queryClient.getQueryData<IssueRecentComments>([...queryKeys.issues.comments(ref), "recent"]);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useIssueIdBatches } from "@/hooks/useIssueIdBatches";
|
||||
import { useConversationHistoryPages, type ConversationHistoryPage } from "@/hooks/useConversationHistoryPages";
|
||||
import { taskChatHashAnchor } from "@/components/task-chat/scroll-navigation";
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { CoordinatorAgentSwitcher } from "@/components/CoordinatorAgentSwitcher";
|
||||
@@ -1282,6 +1284,7 @@ type IssueDetailChatTabProps = {
|
||||
attachments: IssueAttachment[];
|
||||
hasOlderComments: boolean;
|
||||
commentsLoadingOlder: boolean;
|
||||
commentsOlderError?: boolean;
|
||||
onLoadOlderComments: () => void;
|
||||
onRefreshLatestComments: () => Promise<unknown> | void;
|
||||
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
|
||||
@@ -1438,6 +1441,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
attachments,
|
||||
hasOlderComments,
|
||||
commentsLoadingOlder,
|
||||
commentsOlderError = false,
|
||||
onLoadOlderComments,
|
||||
onRefreshLatestComments,
|
||||
onWorkModeChange,
|
||||
@@ -1530,11 +1534,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
: undefined,
|
||||
});
|
||||
const inputCommentIds = comments.filter((comment) => !("clientStatus" in comment) || !comment.clientStatus).map((comment) => comment.id).sort();
|
||||
const inputFacts = useQuery({
|
||||
queryKey: [...queryKeys.issues.activity(issueQueryRef), "comment-inputs", inputCommentIds],
|
||||
queryFn: () => activityApi.inputFactsForIssue(issueQueryRef, inputCommentIds),
|
||||
enabled: !!issueId && !historyExpanded && inputCommentIds.length > 0,
|
||||
});
|
||||
const inputFacts = useIssueIdBatches(
|
||||
[...queryKeys.issues.activity(issueQueryRef), "comment-inputs"], inputCommentIds,
|
||||
ids => activityApi.inputFactsForIssue(issueQueryRef, ids), !!issueId && !historyExpanded,
|
||||
);
|
||||
const {
|
||||
data: liveRuns,
|
||||
isFetched: liveRunsFetched,
|
||||
@@ -1658,7 +1661,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
)) {
|
||||
// After a reconnect, several turns may have completed beyond the small
|
||||
// window. Reconcile watched runs once against authoritative summaries.
|
||||
recent = await activityApi.runsForIssue(issueQueryRef);
|
||||
recent = conversationMode
|
||||
? await activityApi.runsByIdsForIssue(issueQueryRef, [...new Set([...previous.map(run => run.runId), ...recent.map(run => run.runId)])])
|
||||
: await activityApi.runsForIssue(issueQueryRef);
|
||||
reconciledFullHistory = true;
|
||||
const existingRunIds = new Set(recent.map((run) => run.runId));
|
||||
previous = previous.filter((run) => existingRunIds.has(run.runId));
|
||||
@@ -1679,7 +1684,18 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
hasLiveRuns || issueStatus === "in_progress" ? 1000 : false,
|
||||
placeholderData: historyExpanded ? queryClient.getQueryData<RecentIssueRunWindow>([...queryKeys.issues.runs(issueQueryRef), "recent-window"])?.runs : undefined,
|
||||
});
|
||||
const linkedRuns = Array.isArray(linkedRunHistory) ? linkedRunHistory : linkedRunHistory?.runs;
|
||||
const observedRunIds = useMemo(() => [...new Set([
|
||||
...comments.flatMap(comment => [comment.createdByRunId, comment.derivedCreatedByRunId]),
|
||||
...(inputFacts.data ?? []).flatMap(event => [event.runId, event.details?.targetRunId]),
|
||||
].filter((id): id is string => typeof id === "string" && isUuidLike(id)))].sort(), [comments, inputFacts.data]);
|
||||
const commentRuns = useIssueIdBatches(
|
||||
[...queryKeys.issues.runs(issueQueryRef), "comment-window"], observedRunIds,
|
||||
ids => activityApi.runsByIdsForIssue(issueQueryRef, ids), !!issueId && !!conversationMode && !historyExpanded,
|
||||
);
|
||||
const linkedRuns = useMemo(() => [...new Map([
|
||||
...(Array.isArray(linkedRunHistory) ? linkedRunHistory : linkedRunHistory?.runs ?? []),
|
||||
...(conversationMode && !historyExpanded ? commentRuns.data ?? [] : []),
|
||||
].map(run => [run.runId, run])).values()], [linkedRunHistory, conversationMode, historyExpanded, commentRuns.data]);
|
||||
const hasEarlierRuns = !Array.isArray(linkedRunHistory) && linkedRunHistory?.hasEarlierRuns === true;
|
||||
const resolvedActivity = useMemo(
|
||||
() => [...new Map([...(activity ?? []), ...(inputFacts.data ?? [])].map((event) => [event.id, event])).values()],
|
||||
@@ -2576,7 +2592,13 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
[resolvedActivity],
|
||||
);
|
||||
|
||||
const loadOlderButton = hasOlderComments ? (
|
||||
const scrollingHistory = conversationMode ? {
|
||||
hasMore: !!hasOlderComments,
|
||||
loading: commentsLoadingOlder,
|
||||
error: commentsOlderError,
|
||||
load: onLoadOlderComments,
|
||||
} : undefined;
|
||||
const loadOlderButton = !conversationMode && hasOlderComments ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -2619,8 +2641,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
<ThreadComponent
|
||||
{...(!classicTaskInterfaceEnabled ? {
|
||||
compactHistory: !historyExpanded,
|
||||
onExpandHistory,
|
||||
historyControl: !historyExpanded && (hasEarlierMessages || hasEarlierRuns) ? (
|
||||
onExpandHistory: conversationMode ? undefined : onExpandHistory,
|
||||
scrollingHistory,
|
||||
historyControl: conversationMode ? null : !historyExpanded && (hasEarlierMessages || hasEarlierRuns) ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onExpandHistory}>
|
||||
More messages
|
||||
</Button>
|
||||
@@ -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<IssueComment[], string | null>
|
||||
>(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<IssueComment[]>(queryKeys.issues.acknowledgedComments(ref), current =>
|
||||
current?.filter(comment => comment.id !== commentId),
|
||||
);
|
||||
queryClient.setQueryData<InfiniteData<ConversationHistoryPage, string | null>>(
|
||||
queryKeys.issues.earlierComments(ref), current => current && {
|
||||
...current, pages: current.pages.map(page => ({ ...page, comments: page.comments.filter(comment => comment.id !== commentId) })),
|
||||
},
|
||||
);
|
||||
queryClient.setQueryData<IssueRecentComments>(
|
||||
[...queryKeys.issues.comments(ref), "recent"],
|
||||
(current) => {
|
||||
@@ -4355,6 +4394,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
}
|
||||
: current,
|
||||
);
|
||||
queryClient.setQueryData<IssueComment[]>(queryKeys.issues.acknowledgedComments(ref), current =>
|
||||
current?.map(existing => existing.id === comment.id ? comment : existing),
|
||||
);
|
||||
queryClient.setQueryData<InfiniteData<ConversationHistoryPage, string | null>>(
|
||||
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<IssueRecentComments>(
|
||||
[...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<InfiniteData<IssueComment[], string | null>>(
|
||||
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}
|
||||
|
||||
Reference in new issue
Block a user