mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
perf(tasks): show recent conversation before loading historical activity
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2e47c42fad
commit
9f8a6b399c
30 files changed
+608
-65
No files matched your search
@@ -63,13 +63,16 @@ reversals and Safari's edge bounce. It glides out and eases back in with shared
|
||||
motion tokens; the task composer follows the same motion. Keep page padding
|
||||
stable while the navigation moves, and honor reduced-motion preferences.
|
||||
|
||||
Task conversations reveal their initial comments, interaction cards, plan, and
|
||||
relevant run history together after positioning the latest message. Keep the
|
||||
mobile loading surface at a stable viewport height while that history loads;
|
||||
concealed content must not stretch the document. Background refreshes keep an
|
||||
already revealed conversation and composer mounted and visible.
|
||||
Bound the initial wait to 15 seconds. If a request stalls, reveal the available
|
||||
conversation and composer with a notice that some history is still loading.
|
||||
Cold task opens reveal the instruction and recent conversation independently of
|
||||
plans, related metadata, and historical run activity. Show the latest human
|
||||
follow-up, recent saved replies, and text or pending requests from the latest run.
|
||||
Put older messages and diagnostic activity behind **More messages**. Fetch those
|
||||
only when expanded; never download all history just to calculate a count.
|
||||
Retain messages and runs observed while the task stays open, preserve expansion
|
||||
on return within the session, and load linked older comments when requested.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -1896,6 +1896,13 @@ Required UX behaviors:
|
||||
- conflict toasts on atomic checkout failure
|
||||
- no silent background failures; every failed run visible in UI
|
||||
|
||||
Cold opens of ordinary tasks show the instruction and recent conversation before
|
||||
related metadata and historical run activity. Earlier messages and diagnostics
|
||||
load through **More messages**. Messages observed while the task stays open remain
|
||||
visible; deep links still load their target. Compact reads retain the full reads'
|
||||
authorization and redaction rules. Agent Chat and the classic task interface keep
|
||||
their existing history behavior.
|
||||
|
||||
Regular task chats and Agent Chat keep requests as compact, reopenable entries
|
||||
at their original position in the feed. Dismissing any composer request clears
|
||||
its form and pending reminder, including completion reviews, confirmations,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Task cold-open performance
|
||||
|
||||
Branch: `codex/task-cold-open`. Owner: task detail UI and existing issue/run read services.
|
||||
Endpoint: tested, reviewable PR; deployment and staging acceptance are separate.
|
||||
|
||||
Cold opens show the instruction and recent exchange without hydrating historical
|
||||
run diagnostics. Full issue enrichment stays on its existing authorized route.
|
||||
The core view skips enrichment. Recent comments retain the latest human input
|
||||
and four recent replies, plus the first human instruction when no description
|
||||
exists. Recent activity preserves queue/steering metadata. Recent run summaries
|
||||
select the latest run; conversation event reads exclude tools and traces.
|
||||
|
||||
The browser retains messages and runs observed while open. New native runs use
|
||||
the normal event path. Expansion fetches full history, preserves existing rows,
|
||||
and remains expanded on return in the session. Deep links and classic/Agent Chat
|
||||
retain their full-history behavior. No database migration or permissions change.
|
||||
|
||||
| Case | Evidence |
|
||||
| --- | --- |
|
||||
| Cold task | Page tests hold enrichment/history pending while recent messages render; desktop/mobile thread tests keep content and composer visible. |
|
||||
| Long history | Disposable app with 80 comments and six large run histories; cold network uses recent comments, recent runs, and one conversation-events request. Expansion reveals older comments. |
|
||||
| Active page | Burst catch-up tests retain 60 messages across cursor pages; new-run tests retain previously visible runs. |
|
||||
| Native history | Real PostgreSQL test filters tool payloads, preserves request lifecycles and message deltas, and pages by sequence. Expansion test resets cursor without duplicates. |
|
||||
| Access | Core and preview route tests reject a foreign authenticated tenant. |
|
||||
| Compatibility | Existing task UI, full event reads, and full activity/run reads remain covered. |
|
||||
|
||||
Measurements on the disposable local fixture: core read 50 ms; recent comments
|
||||
24 ms; recent run summaries 17 ms. Latest native conversation events: 839 JSON
|
||||
characters / 8 ms versus full latest-run events: 1,099,005 / 86 ms. These are local
|
||||
HTTP samples, not staging latency promises. Browser navigation showed the initial
|
||||
content paint at 1.34 seconds, with development assets already cached.
|
||||
|
||||
Verification: affected UI/API tests, real PostgreSQL tests, workspace typecheck,
|
||||
production build, token gates, and map checks. The full suite was attempted but
|
||||
this host exhausts macOS PostgreSQL semaphores. Targeted database tests were run
|
||||
against a separate disposable database on an existing local server; the temporary
|
||||
test setup override was restored before typecheck/build.
|
||||
|
||||
Related open PRs: #14667 changes the reveal gate and scroll anchors; #16229 adds
|
||||
general history pagination. This change adds a core read and a folded cold-open
|
||||
projection, while preserving the default full API contracts. Coordinate those
|
||||
shared files if either PR lands first.
|
||||
@@ -210,7 +210,7 @@ API: `present` — representative operation IDs:
|
||||
|
||||
- `GET /api/heartbeat-runs/{runId}`.
|
||||
- `GET /api/heartbeat-runs/{runId}/log`.
|
||||
- `GET /api/heartbeat-runs/{runId}/events`.
|
||||
- `GET /api/heartbeat-runs/{runId}/events` (optional `view=conversation` returns assistant messages and request lifecycles through the same access/redaction checks; `afterSeq` remains a sequence cursor).
|
||||
- `GET /api/heartbeat-runs/{runId}/workspace-operations`.
|
||||
- `GET /api/heartbeat-runs/{runId}/issues`.
|
||||
- `GET /api/companies/{companyId}/heartbeat-runs`.
|
||||
|
||||
@@ -84,7 +84,7 @@ Gaps: Bounded native search and one-page CLI local match are not complete compan
|
||||
|
||||
### `read-task-and-summaries`
|
||||
|
||||
API: `present` — GET /api/issues/{id}; GET /api/companies/{companyId}/issues/summaries?ids=... .
|
||||
API: `present` — GET /api/issues/{id} (optional `view=core` omits related enrichment); GET /api/companies/{companyId}/issues/summaries?ids=... .
|
||||
|
||||
Agent access: `partial` — REST/CLI issue get; native get_task_context reads CURRENT task only; call_api for eligible arbitrary task/summaries. No dedicated summaries CLI/tool.
|
||||
|
||||
@@ -254,7 +254,7 @@ Read, append/retry, author-delete or queued-cancel messages, attach allowed file
|
||||
|
||||
### `read-history-comment`
|
||||
|
||||
API: `present` — GET /api/issues/{id}/comments and /comments/{commentId}.
|
||||
API: `present` — GET /api/issues/{id}/comments and /comments/{commentId}; GET /api/issues/{id}/recent-comments returns four latest comments plus the latest human comment, a cursor, and `hasMore`. The compact read also includes the first human instruction when there is no description. Cold task views use `GET /api/issues/{id}/activity?view=recent` (50 rows) and `/runs?view=recent` (two summaries) without changing the default full-read contracts. It uses the same issue authorization and run/trust redaction.
|
||||
|
||||
Agent access: `available` — REST/call_api, CLI issue comments/comment:get; native get_task_history CURRENT task only, declared limit 1..200 but production clamps to 1..100 (default 50).
|
||||
|
||||
@@ -264,7 +264,7 @@ Evidence: `source-only` — reviewed at `3bbc2104b65caf9f7763938a01260a5673e12f7
|
||||
|
||||
Verification: `not-run` — Read multipage history and exact comment, reload same content; reject inaccessible task, foreign/mismatched comment and inspect trust redaction. Request native limit 200 and confirm at most 100 comments.
|
||||
|
||||
Gaps: Native bounded history is not an arbitrary-task/paginated API substitute.
|
||||
Gaps: Native bounded history is not an arbitrary-task/paginated API substitute. The new UI preview route has no dedicated native tool or CLI command. Existing full comment reads remain available.
|
||||
|
||||
### `append-retry-comment`
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ Open `/issues` or a project’s Issues view; legacy `/tasks` links redirect.
|
||||
|
||||
### `task-detail`
|
||||
|
||||
Open `/issues/:issueId` from a row, mention, or direct link.
|
||||
Open `/issues/:issueId` from a row, mention, or direct link. Cold opens show the instruction and recent exchange first. **More messages** loads earlier messages and run activity; incoming messages remain visible while the task stays open.
|
||||
|
||||
### `task-sharing`
|
||||
|
||||
@@ -87,7 +87,9 @@ Manual: Filter by status and assignee, change grouping/sort, open a task, and re
|
||||
|
||||
### `task-detail`
|
||||
|
||||
Automated: [task detail](../../ui/src/pages/IssueDetail.test.tsx) covers component behavior, while [issue service](../../server/src/__tests__/issues-service.test.ts) covers server contracts.
|
||||
Manual: Open a long task with slow historical requests. Read the instruction and recent reply before those requests finish. Expand **More messages**, verify scroll position and no duplicate replies, return to the task, and follow an older comment link. Leave a task open across new messages and runs and confirm none disappear.
|
||||
|
||||
Automated: [cold-open reads](../../server/src/__tests__/task-cold-open.test.ts) cover bounded reads, event filtering and company denial. [Task detail](../../ui/src/pages/IssueDetail.test.tsx) covers component behavior, while [issue service](../../server/src/__tests__/issues-service.test.ts) covers server contracts.
|
||||
|
||||
Failure markers have no dashed divider rules. An adjacent run error and
|
||||
no-live-execution-path notice show only the last error, with its details and
|
||||
|
||||
@@ -1131,6 +1131,7 @@ export type {
|
||||
IssueExecutionStagePrincipal,
|
||||
IssueExecutionDecision,
|
||||
IssueComment,
|
||||
IssueRecentComments,
|
||||
IssueQueuedCommentEntry,
|
||||
IssueQueuedCommentProtocol,
|
||||
IssueQueuedCommentQueue,
|
||||
|
||||
@@ -732,6 +732,7 @@ export type {
|
||||
IssueReviewRequest,
|
||||
IssueExecutionDecision,
|
||||
IssueComment,
|
||||
IssueRecentComments,
|
||||
IssueQueuedCommentEntry,
|
||||
IssueQueuedCommentProtocol,
|
||||
IssueQueuedCommentQueue,
|
||||
|
||||
@@ -1720,3 +1720,10 @@ export interface IssueAttachment {
|
||||
openPath?: string;
|
||||
downloadPath?: string;
|
||||
}
|
||||
|
||||
/** Bounded cold-open window; the complete chronological feed stays paginated. */
|
||||
export interface IssueRecentComments {
|
||||
latestCommentId: string | null;
|
||||
comments: IssueComment[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { activityLog, agents, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { createHeartbeatRunState } from "../services/heartbeat/run-state.js";
|
||||
import { activityService } from "../services/activity.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("task cold-open reads", () => {
|
||||
let fixture: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
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-");
|
||||
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" });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Original instruction", description: "Read this first", identifier: `T${companyId.slice(0, 6)}-1`, issueNumber: 1 });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, runtimeMode: "native", status: "succeeded" });
|
||||
}, 30_000);
|
||||
afterAll(async () => { await fixture?.cleanup(); });
|
||||
function app(allowedCompany = companyId) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = { type: "board", userId: "local-board", source: allowedCompany === companyId ? "local_implicit" : "cloud_tenant", companyIds: [allowedCompany], isInstanceAdmin: allowedCompany === companyId };
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes(db, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
it("returns the core instruction without related detail and keeps company checks", async () => {
|
||||
const response = await request(app()).get(`/api/issues/${issueId}?view=core`);
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(response.body).toMatchObject({ id: issueId, description: "Read this first" });
|
||||
expect(response.body).not.toHaveProperty("ancestors");
|
||||
expect(response.body).not.toHaveProperty("workProducts");
|
||||
const denied = await request(app(randomUUID())).get(`/api/issues/${issueId}?view=core`);
|
||||
expect([403, 404]).toContain(denied.status);
|
||||
});
|
||||
it("returns a bounded recent exchange plus the latest human instruction", async () => {
|
||||
const humanId = randomUUID();
|
||||
await db.insert(issueComments).values([
|
||||
{ id: humanId, companyId, issueId, body: "Human direction", authorType: "user", authorUserId: "local-board", createdAt: new Date(0) },
|
||||
...Array.from({ length: 20 }, (_, i) => ({ companyId, issueId, authorType: "agent" as const, authorAgentId: agentId, body: `Reply ${i}`, createdAt: new Date(1000 + i) })),
|
||||
]);
|
||||
const response = await request(app()).get(`/api/issues/${issueId}/recent-comments`);
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(response.body.hasMore).toBe(true);
|
||||
expect(response.body.comments.map((c: { body: string }) => c.body)).toEqual(["Human direction", "Reply 19", "Reply 18", "Reply 17", "Reply 16"]);
|
||||
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("preserves the initial human instruction when the task has no description", async () => {
|
||||
const bareIssueId = randomUUID();
|
||||
await db.insert(issues).values({ id: bareIssueId, companyId, title: "No description" });
|
||||
await db.insert(issueComments).values([
|
||||
{ companyId, issueId: bareIssueId, body: "Human direction", authorType: "user", authorUserId: "local-board", createdAt: new Date(0) },
|
||||
{ companyId, issueId: bareIssueId, body: "Newest direction", authorType: "user", authorUserId: "local-board", createdAt: new Date(50_000) },
|
||||
...Array.from({ length: 6 }, (_, i) => ({ companyId, issueId: bareIssueId, body: `New reply ${i}`, authorType: "agent" as const, authorAgentId: agentId, createdAt: new Date(60_000 + i) })),
|
||||
]);
|
||||
const response = await request(app()).get(`/api/issues/${bareIssueId}/recent-comments`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.comments.map((c: { body: string }) => c.body)).toEqual([
|
||||
"Human direction", "Newest direction", "New reply 5", "New reply 4", "New reply 3", "New reply 2",
|
||||
]);
|
||||
});
|
||||
it("pages conversation events without tools, including identity-less deltas and requests", async () => {
|
||||
const event = (seq: number, eventType: string, payload: Record<string, unknown>, itemId?: string) => ({
|
||||
companyId, runId, agentId, seq, eventType, payload: { prpEvent: { schema: "paperclip.prp.event.v1", eventType, itemId, payload } },
|
||||
});
|
||||
await db.insert(heartbeatRunEvents).values([
|
||||
event(1, "item.started", { kind: "toolCall", arguments: "large tool arguments" }, "tool"),
|
||||
event(2, "item.completed", { kind: "toolCall", output: "x".repeat(100_000) }, "tool"),
|
||||
event(3, "item.started", { kind: "agentMessage" }, "message"),
|
||||
event(4, "item.delta", { text: "Current progress" }, "message"),
|
||||
event(5, "runtime_request.opened", { requestId: "request", prompt: "Which option?" }),
|
||||
event(6, "runtime_request.resolved", { requestId: "request" }),
|
||||
event(7, "item.completed", { kind: "assistant_message", text: "Final answer" }),
|
||||
event(8, "turn.completed", {}),
|
||||
]);
|
||||
const service = createHeartbeatRunState(db);
|
||||
const first = await service.listEvents(runId, 0, 2, true);
|
||||
expect(first.map(e => e.seq)).toEqual([3, 4]);
|
||||
const rest = await service.listEvents(runId, 4, 1000, true);
|
||||
expect(rest.map(e => e.seq)).toEqual([5, 6, 7, 8]);
|
||||
expect(JSON.stringify([...first, ...rest])).not.toContain("large tool arguments");
|
||||
expect(await service.listEvents(runId)).toHaveLength(8);
|
||||
await db.insert(heartbeatRuns).values({ companyId, agentId, issueId, status: "succeeded", createdAt: new Date(Date.now() + 1000) });
|
||||
const activity = activityService(db);
|
||||
await db.insert(activityLog).values(Array.from({ length: 55 }, () => ({
|
||||
companyId, actorId: "test", action: "issue.updated", entityType: "issue", entityId: issueId,
|
||||
})));
|
||||
expect(await activity.forIssue(issueId, 50)).toHaveLength(50);
|
||||
expect(await activity.forIssue(issueId)).toHaveLength(55);
|
||||
expect(await activity.runsForIssue(companyId, issueId, null, 1)).toHaveLength(1);
|
||||
expect(await activity.runsForIssue(companyId, issueId)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -355,7 +355,7 @@ export function activityRoutes(db: Db) {
|
||||
const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found");
|
||||
if (!issue) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
||||
const result = await svc.forIssue(issue.id);
|
||||
const result = await (req.query.view === "recent" ? svc.forIssue(issue.id, 50) : svc.forIssue(issue.id));
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
@@ -364,8 +364,10 @@ export function activityRoutes(db: Db) {
|
||||
const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found");
|
||||
if (!issue) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
||||
const result = await svc.runsForIssue(issue.companyId, issue.id,
|
||||
req.actor.type === "board" ? getActorInfo(req).actorId : null);
|
||||
const viewerUserId = req.actor.type === "board" ? getActorInfo(req).actorId : null;
|
||||
const result = await (req.query.view === "recent"
|
||||
? svc.runsForIssue(issue.companyId, issue.id, viewerUserId, 2)
|
||||
: svc.runsForIssue(issue.companyId, issue.id, viewerUserId));
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
|
||||
@@ -7558,7 +7558,11 @@ export function agentRoutes(
|
||||
|
||||
const afterSeq = Number(req.query.afterSeq ?? 0);
|
||||
const limit = Number(req.query.limit ?? 200);
|
||||
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
|
||||
const cursor = Number.isFinite(afterSeq) ? afterSeq : 0;
|
||||
const pageLimit = Number.isFinite(limit) ? limit : 200;
|
||||
const events = await (req.query.view === "conversation"
|
||||
? heartbeat.listEvents(runId, cursor, pageLimit, true)
|
||||
: heartbeat.listEvents(runId, cursor, pageLimit));
|
||||
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
|
||||
const redactedEvents = events.map((event) =>
|
||||
redactCurrentUserValue({
|
||||
|
||||
@@ -9316,6 +9316,13 @@ export function issueRoutes(
|
||||
);
|
||||
if (!issue) return;
|
||||
if (!(await timing.time("authorization", () => assertIssueReadAllowed(req, res, issue, { allowBreakGlass: true })))) return;
|
||||
// The cold-open read owns only the task row. Related objects and recovery
|
||||
// projections retain their existing authorized detail path.
|
||||
if (req.query.view === "core") {
|
||||
res.setHeader("Server-Timing", timing.header());
|
||||
res.json(issue);
|
||||
return;
|
||||
}
|
||||
const inboxArchiveFieldsPromise =
|
||||
req.actor.type === "board" && req.actor.userId
|
||||
? timing.time("inbox", () => svc.getActiveInboxArchiveFields(issue, req.actor.userId!))
|
||||
@@ -16273,6 +16280,24 @@ export function issueRoutes(
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
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;
|
||||
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 }),
|
||||
issue.description?.trim()
|
||||
? Promise.resolve([])
|
||||
: svc.listComments(issue.id, { 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, {
|
||||
comments,
|
||||
latestCommentId: recent[0]?.id ?? null,
|
||||
hasMore: recent.length > 4,
|
||||
}));
|
||||
});
|
||||
|
||||
router.get("/issues/:id/comments", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await getAccessibleResource(
|
||||
|
||||
@@ -4317,7 +4317,7 @@ registry.registerPath({
|
||||
path: "/api/issues/{id}",
|
||||
tags: ["issues"],
|
||||
summary: "Get an issue",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
request: { params: z.object({ id: z.string() }), query: z.object({ view: z.literal("core").optional().describe("Core task fields without related enrichment.") }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
|
||||
});
|
||||
|
||||
@@ -4624,6 +4624,16 @@ registry.registerPath({
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
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.",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/issues/{id}/comments",
|
||||
@@ -6080,7 +6090,7 @@ registry.registerPath({
|
||||
path: "/api/issues/{id}/activity",
|
||||
tags: ["activity"],
|
||||
summary: "List activity for an issue",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
request: { params: z.object({ id: z.string() }), query: z.object({ view: z.literal("recent").optional().describe("At most 50 recent activity rows.") }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
|
||||
@@ -6089,7 +6099,7 @@ registry.registerPath({
|
||||
path: "/api/issues/{id}/runs",
|
||||
tags: ["activity"],
|
||||
summary: "List runs for an issue",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -7821,7 +7831,11 @@ registry.registerPath({
|
||||
path: "/api/heartbeat-runs/{runId}/events",
|
||||
tags: ["runs"],
|
||||
summary: "Get events for a heartbeat run",
|
||||
request: { params: z.object({ runId: heartbeatRunIdParamSchema }) },
|
||||
request: { params: z.object({ runId: heartbeatRunIdParamSchema }), query: z.object({
|
||||
view: z.literal("conversation").optional().describe("Assistant text and request lifecycle events, excluding historical tool activity."),
|
||||
afterSeq: z.coerce.number().int().min(0).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(1000).optional(),
|
||||
}) },
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
|
||||
});
|
||||
|
||||
|
||||
@@ -396,8 +396,8 @@ export function activityService(db: Db) {
|
||||
.then((rows) => rows.map((r) => r.activityLog));
|
||||
},
|
||||
|
||||
forIssue: (issueId: string) =>
|
||||
db
|
||||
forIssue: (issueId: string, limit?: number) => {
|
||||
const query = db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(
|
||||
@@ -407,11 +407,13 @@ export function activityService(db: Db) {
|
||||
sql`${activityLog.companyId} = (select company_id from issues where id = ${issueId})`),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(activityLog.createdAt)),
|
||||
.orderBy(desc(activityLog.createdAt), desc(activityLog.id));
|
||||
return limit === undefined ? query : query.limit(limit);
|
||||
},
|
||||
|
||||
runsForIssue: async (companyId: string, issueId: string, viewerUserId: string | null = null) => {
|
||||
runsForIssue: async (companyId: string, issueId: string, viewerUserId: string | null = null, limit?: number) => {
|
||||
scheduleRunLivenessBackfill(companyId, issueId);
|
||||
const runs = await db
|
||||
const runsQuery = db
|
||||
.select({
|
||||
runId: heartbeatRuns.id,
|
||||
runtimeMode: heartbeatRuns.runtimeMode,
|
||||
@@ -473,7 +475,8 @@ export function activityService(db: Db) {
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt));
|
||||
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id));
|
||||
const runs = await (limit === undefined ? runsQuery : runsQuery.limit(limit));
|
||||
|
||||
if (runs.length === 0) return runs;
|
||||
const runIds = runs.map((run) => run.runId);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { heartbeatRunEvents } from "@paperclipai/db";
|
||||
|
||||
/**
|
||||
* Canonical PRP conversation data, excluding historical tool activity.
|
||||
* Keep request lifecycles so a cold open cannot hide a question or approval.
|
||||
* Item identity may occur on started/completed rather than each text delta.
|
||||
*/
|
||||
export function conversationEventCondition(runId: string) {
|
||||
return sql`(
|
||||
${heartbeatRunEvents.eventType} like 'runtime_request.%'
|
||||
or ${heartbeatRunEvents.eventType} in ('run.result.accepted', 'run.terminal', 'turn.completed', 'turn.failed', 'turn.cancelled', 'turn.interrupted')
|
||||
or (
|
||||
${heartbeatRunEvents.eventType} in ('item.started', 'item.delta', 'item.completed')
|
||||
and (lower(replace(coalesce(${heartbeatRunEvents.payload} #>> '{prpEvent,payload,kind}', ${heartbeatRunEvents.payload} #>> '{prpEvent,payload,item,kind}', ${heartbeatRunEvents.payload} #>> '{prpEvent,payload,item,type}', ''), '_', '')) in ('agentmessage', 'assistantmessage')
|
||||
or coalesce(
|
||||
${heartbeatRunEvents.payload} #>> '{prpEvent,itemId}',
|
||||
${heartbeatRunEvents.payload} #>> '{prpEvent,payload,itemId}',
|
||||
${heartbeatRunEvents.payload} #>> '{prpEvent,payload,item,id}',
|
||||
${heartbeatRunEvents.payload} #>> '{prpEvent,payload,id}'
|
||||
) in (
|
||||
select coalesce(e.payload #>> '{prpEvent,itemId}', e.payload #>> '{prpEvent,payload,itemId}', e.payload #>> '{prpEvent,payload,item,id}', e.payload #>> '{prpEvent,payload,id}')
|
||||
from heartbeat_run_events e
|
||||
where e.run_id = ${runId}
|
||||
and e.event_type in ('item.started', 'item.delta', 'item.completed')
|
||||
and lower(replace(coalesce(e.payload #>> '{prpEvent,payload,kind}', e.payload #>> '{prpEvent,payload,item,kind}', e.payload #>> '{prpEvent,payload,item,type}', ''), '_', '')) in ('agentmessage', 'assistantmessage')
|
||||
))
|
||||
)
|
||||
)`;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { conversationEventCondition } from "./conversation-events.js";
|
||||
import { deriveTaskKey } from "./run-preparation.js";
|
||||
import { retryIdempotentDatabaseOperation } from "../../database-retry.js";
|
||||
import { isConversation } from "../agent-conversations.js";
|
||||
@@ -1962,7 +1963,7 @@ export function createHeartbeatRunState(db: Db) {
|
||||
};
|
||||
},
|
||||
|
||||
listEvents: (runId: string, afterSeq = 0, limit = 200) =>
|
||||
listEvents: (runId: string, afterSeq = 0, limit = 200, conversationOnly = false) =>
|
||||
db
|
||||
.select()
|
||||
.from(heartbeatRunEvents)
|
||||
@@ -1970,6 +1971,7 @@ export function createHeartbeatRunState(db: Db) {
|
||||
and(
|
||||
eq(heartbeatRunEvents.runId, runId),
|
||||
gt(heartbeatRunEvents.seq, afterSeq),
|
||||
conversationOnly ? conversationEventCondition(runId) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(heartbeatRunEvents.seq))
|
||||
|
||||
@@ -12342,6 +12342,7 @@ export function issueService(db: Db) {
|
||||
afterCommentId?: string | null;
|
||||
order?: "asc" | "desc";
|
||||
limit?: number | null;
|
||||
humanOnly?: boolean;
|
||||
},
|
||||
) => {
|
||||
const order = opts?.order === "asc" ? "asc" : "desc";
|
||||
@@ -12352,6 +12353,7 @@ export function issueService(db: Db) {
|
||||
: null;
|
||||
|
||||
const conditions = [eq(issueComments.issueId, issueId)];
|
||||
if (opts?.humanOnly) conditions.push(eq(issueComments.authorType, "user"));
|
||||
if (afterCommentId) {
|
||||
// Guard: reject non-UUID cursors before hitting the DB to avoid Postgres type errors.
|
||||
if (!isUuidLike(afterCommentId)) return [];
|
||||
|
||||
@@ -80,7 +80,9 @@ export const activityApi = {
|
||||
const qs = params.toString();
|
||||
return api.get<ActivityEvent[]>(`/companies/${companyId}/activity${qs ? `?${qs}` : ""}`, options);
|
||||
},
|
||||
recentForIssue: (issueId: string) => api.get<ActivityEvent[]>(`/issues/${issueId}/activity?view=recent`),
|
||||
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`),
|
||||
issuesForRun: (runId: string) => api.get<IssueForRun[]>(`/heartbeat-runs/${runId}/issues`),
|
||||
};
|
||||
@@ -138,6 +138,11 @@ export const heartbeatsApi = {
|
||||
);
|
||||
},
|
||||
get: (runId: string) => api.get<HeartbeatRun>(`/heartbeat-runs/${runId}`),
|
||||
conversationEvents: (runId: string, afterSeq = 0, limit = 200, options?: RequestOptions) =>
|
||||
api.get<HeartbeatRunEvent[]>(
|
||||
`/heartbeat-runs/${runId}/events?view=conversation&afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
|
||||
options,
|
||||
),
|
||||
events: (runId: string, afterSeq = 0, limit = 200, options?: RequestOptions) =>
|
||||
api.get<HeartbeatRunEvent[]>(
|
||||
`/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
IssueAttachment,
|
||||
IssueCostSummary,
|
||||
IssueComment,
|
||||
IssueRecentComments,
|
||||
IssueQueuedCommentQueue,
|
||||
IssueDocument,
|
||||
IssueLabel,
|
||||
@@ -247,6 +248,8 @@ export const issuesApi = {
|
||||
createLabel: (companyId: string, data: { name: string; color: string }) =>
|
||||
api.post<IssueLabel>(`/companies/${companyId}/labels`, data),
|
||||
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
|
||||
getCore: (id: string, options?: RequestOptions) => api.get<Issue>(`/issues/${id}?view=core`, options),
|
||||
recentComments: (id: string, options?: RequestOptions) => api.get<IssueRecentComments>(`/issues/${id}/recent-comments`, options),
|
||||
get: (id: string, options?: RequestOptions) =>
|
||||
options
|
||||
? api.get<Issue>(`/issues/${id}`, options)
|
||||
@@ -394,6 +397,7 @@ export const issuesApi = {
|
||||
order?: "asc" | "desc";
|
||||
limit?: number;
|
||||
},
|
||||
options?: RequestOptions,
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.after) params.set("after", filters.after);
|
||||
@@ -402,6 +406,7 @@ export const issuesApi = {
|
||||
const qs = params.toString();
|
||||
return api.get<IssueComment[]>(
|
||||
`/issues/${id}/comments${qs ? `?${qs}` : ""}`,
|
||||
options,
|
||||
);
|
||||
},
|
||||
getQueuedComments: (id: string) =>
|
||||
|
||||
@@ -361,6 +361,16 @@ it.each([true, false])("defers hidden content until history is ready and keeps i
|
||||
expect(container.querySelector('.task-chat-history-pending')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([true, false])("keeps cold-open messages readable while history is pending (mobile=%s)", (mobile) => {
|
||||
sidebarState.isMobile = mobile;
|
||||
render(<TaskChatThread issueId="cold-issue" compactHistory initialHistoryPending
|
||||
comments={createLongThreadComments()} historyControl={<button>More messages</button>} onAdd={async () => {}} />);
|
||||
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull();
|
||||
expect(container.textContent).toContain("Thread message 1");
|
||||
expect(container.textContent).toContain("More messages");
|
||||
expect(container.querySelector('[data-testid="mock-editor"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("defers initial history for a saved conversation with its own draft key", () => {
|
||||
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueId="saved-chat"
|
||||
conversationMode draftKey="agent-chat:company:saved-agent" initialHistoryPending />);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
IssueAssigneePausedNotice,
|
||||
@@ -422,6 +423,8 @@ function resolvedWithoutUserFacingResponse(value: unknown): boolean {
|
||||
export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread> & {
|
||||
conversationMode?: boolean;
|
||||
creationActivity?: ActivityEvent[];
|
||||
compactHistory?: boolean;
|
||||
historyControl?: ReactNode;
|
||||
initialHistoryPending?: boolean;
|
||||
initialHistoryError?: boolean;
|
||||
onRetryInitialHistory?: () => void;
|
||||
@@ -898,6 +901,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
for (const r of linkedRuns ?? []) {
|
||||
map.set(r.runId, {
|
||||
id: r.runId,
|
||||
createdAt: r.createdAt,
|
||||
status: r.status,
|
||||
adapterType: r.adapterType ?? "",
|
||||
runtimeMode: r.runtimeMode,
|
||||
@@ -908,6 +912,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
for (const r of liveRuns ?? []) {
|
||||
map.set(r.id, {
|
||||
id: r.id,
|
||||
createdAt: r.createdAt,
|
||||
status: r.status,
|
||||
adapterType: r.adapterType,
|
||||
runtimeMode: r.runtimeMode,
|
||||
@@ -919,6 +924,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
if (activeRun) {
|
||||
map.set(activeRun.id, {
|
||||
id: activeRun.id,
|
||||
createdAt: activeRun.createdAt,
|
||||
status: activeRun.status,
|
||||
adapterType: activeRun.adapterType,
|
||||
runtimeMode: activeRun.runtimeMode,
|
||||
@@ -929,9 +935,13 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
return [...map.values()];
|
||||
}, [linkedRuns, liveRuns, activeRun]);
|
||||
|
||||
const openedAt = useMemo(() => Date.now(), [issueId]);
|
||||
const nativeRuns = useMemo(
|
||||
() => runs.filter((run) => run.runtimeMode === "native"),
|
||||
[runs],
|
||||
() => runs.filter((run) => run.runtimeMode === "native").map((run) => ({
|
||||
...run,
|
||||
conversationOnly: Boolean(props.compactHistory && (!run.createdAt || new Date(run.createdAt).getTime() <= openedAt)),
|
||||
})),
|
||||
[runs, props.compactHistory, openedAt],
|
||||
);
|
||||
const {
|
||||
transcriptByRun: nativeTranscriptByRun,
|
||||
@@ -942,6 +952,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
retry: retryNativeEvents,
|
||||
} = useNativeRunTranscripts(nativeRuns);
|
||||
const logRuns = useMemo(() => runs.filter((run) =>
|
||||
(!nativeRuns.some((nativeRun) => nativeRun.id === run.id && nativeRun.conversationOnly) ||
|
||||
nativeTranscriptErrorsByRun.has(run.id) ||
|
||||
(hydratedNativeRunIds.has(run.id) && (nativeTranscriptByRun.get(run.id)?.length ?? 0) === 0)) && (
|
||||
run.runtimeMode !== "native" ||
|
||||
// Active runs still need the websocket log stream. Only settled native
|
||||
// history can skip the legacy transport when event history is available.
|
||||
@@ -949,14 +962,14 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
nativeTranscriptErrorsByRun.has(run.id) ||
|
||||
(hydratedNativeRunIds?.has(run.id) &&
|
||||
(nativeTranscriptByRun.get(run.id)?.length ?? 0) === 0)
|
||||
), [runs, nativeTranscriptErrorsByRun, hydratedNativeRunIds, nativeTranscriptByRun]);
|
||||
)), [runs, nativeRuns, nativeTranscriptErrorsByRun, hydratedNativeRunIds, nativeTranscriptByRun]);
|
||||
const {
|
||||
transcriptByRun: logTranscriptByRun,
|
||||
isInitialHydrating: logsAreInitiallyHydrating,
|
||||
hydratedRunIds: hydratedLogRunIds,
|
||||
errorsByRun: logErrorsByRun,
|
||||
retry: retryLogs,
|
||||
} = useLiveRunTranscripts({ runs: logRuns, companyId });
|
||||
} = useLiveRunTranscripts({ runs: logRuns, companyId, ...(props.compactHistory ? { logReadLimitBytes: 32_000 } : {}) });
|
||||
const fallbackByRunRef = useRef(
|
||||
new Map<string, NonNullable<ReturnType<typeof logTranscriptByRun.get>>>(),
|
||||
);
|
||||
@@ -2813,8 +2826,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
);
|
||||
|
||||
const renderBrief = useCallback(
|
||||
() => issueBrief ? <TaskChatDescriptionBubble brief={issueBrief} /> : null,
|
||||
[issueBrief],
|
||||
() => issueBrief ? <><TaskChatDescriptionBubble brief={issueBrief} />{props.historyControl}</> : null,
|
||||
[issueBrief, props.historyControl],
|
||||
);
|
||||
|
||||
const assignedAgentForNotice = useMemo(() => {
|
||||
@@ -2968,10 +2981,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
: logsAreInitiallyHydrating;
|
||||
});
|
||||
// Hydrating a run inserts activity around its saved reply and can move the
|
||||
// latest viewport substantially. Wait for the initial comment window's run
|
||||
// history and plan before revealing; older runs outside that window do not
|
||||
// delay it, and the latch below keeps subsequent refreshes visible.
|
||||
const historyPending = initialHistoryPending || planLoading || transcriptHistoryPending;
|
||||
// latest viewport substantially in the full-history view. Cold opens reveal
|
||||
// the brief and recent messages independently; neither plans nor transcripts
|
||||
// may hold them behind the loading screen.
|
||||
const historyPending = !props.compactHistory && (initialHistoryPending || planLoading || transcriptHistoryPending);
|
||||
const scrollNavigation = useTaskChatScrollNavigation();
|
||||
useEffect(() => {
|
||||
recordIssueDetailReadiness({ plan: !planLoading, transcripts: !transcriptHistoryPending, history: !historyPending });
|
||||
@@ -3124,7 +3137,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
<TaskChatThreadView
|
||||
items={items}
|
||||
attachments={attachments}
|
||||
header={threadHeaderWithBlockers}
|
||||
header={<>{threadHeaderWithBlockers}{!issueBrief ? props.historyControl : null}</>}
|
||||
renderInteraction={renderInteraction}
|
||||
renderBrief={renderBrief}
|
||||
renderMessageActions={renderMessageActions}
|
||||
|
||||
@@ -46,6 +46,7 @@ const TASK_VIEW_MAX_BYTES_PER_RUN = 2_000_000;
|
||||
const RUN_ABSENCE_PRUNE_GRACE_MS = 20_000;
|
||||
|
||||
export interface RunTranscriptSource {
|
||||
createdAt?: string | Date;
|
||||
id: string;
|
||||
status: string;
|
||||
adapterType: string;
|
||||
|
||||
@@ -7,9 +7,10 @@ import { useNativeRunTranscripts } from "./useNativeRunTranscripts";
|
||||
import { TRANSCRIPT_REQUEST_TIMEOUT_MS } from "./read-transcript-request";
|
||||
|
||||
const eventsMock = vi.hoisted(() => vi.fn());
|
||||
const conversationEventsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/heartbeats", () => ({
|
||||
heartbeatsApi: { events: eventsMock },
|
||||
heartbeatsApi: { events: eventsMock, conversationEvents: conversationEventsMock },
|
||||
}));
|
||||
|
||||
function Probe() {
|
||||
@@ -49,6 +50,29 @@ describe("useNativeRunTranscripts", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("expands from sequence zero without duplicating the preview or withholding new runs", async () => {
|
||||
let state: ReturnType<typeof useNativeRunTranscripts>;
|
||||
const event = (seq: number, text: string) => ({
|
||||
id: `event-${seq}`, runId: "old", companyId: "company", seq, eventType: "item.completed", createdAt: new Date().toISOString(),
|
||||
payload: { prpEvent: { schema: "paperclip.prp.event.v1", eventType: "item.completed", runId: "old", schemaVersion: 1, emittedAt: new Date().toISOString(), itemId: `item-${seq}`, payload: { kind: "agentMessage", text } } },
|
||||
});
|
||||
conversationEventsMock.mockResolvedValue([event(3, "Latest answer")]);
|
||||
eventsMock.mockImplementation((id) => Promise.resolve(id === "old" ? [event(1, "Earlier answer"), event(3, "Latest answer")] : []));
|
||||
function Window({ compact }: { compact: boolean }) {
|
||||
state = useNativeRunTranscripts([
|
||||
{ id: "old", runtimeMode: "native", status: "succeeded", conversationOnly: compact },
|
||||
{ id: "new", runtimeMode: "native", status: "succeeded" },
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
await act(async () => { root.render(<Window compact />); });
|
||||
expect(conversationEventsMock).toHaveBeenCalledWith("old", 0, 1000, expect.any(Object));
|
||||
expect(eventsMock).toHaveBeenCalledWith("new", 0, 1000, expect.any(Object));
|
||||
await act(async () => { root.render(<Window compact={false} />); });
|
||||
expect(eventsMock).toHaveBeenCalledWith("old", 0, 1000, expect.any(Object));
|
||||
expect(state!.transcriptByRun.get("old")?.filter((entry) => entry.kind === "assistant").map((entry) => entry.text)).toEqual(["Earlier answer", "Latest answer"]);
|
||||
});
|
||||
|
||||
it("exposes event transport failures and retries terminal runs until recovery", async () => {
|
||||
eventsMock
|
||||
.mockRejectedValueOnce(new Error("event endpoint unavailable"))
|
||||
|
||||
@@ -11,6 +11,7 @@ const EVENT_POLL_INTERVAL_MS = 2_000;
|
||||
export interface NativeRunTranscriptSource {
|
||||
id: string;
|
||||
status: string;
|
||||
conversationOnly?: boolean;
|
||||
runtimeMode?: "legacy" | "native";
|
||||
}
|
||||
|
||||
@@ -26,7 +27,7 @@ function isLive(status: string): boolean {
|
||||
export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource[]) {
|
||||
const nativeRunsKey = runs
|
||||
.filter((run) => run.runtimeMode === "native")
|
||||
.map((run) => `${run.id}:${run.status}`)
|
||||
.map((run) => `${run.id}:${run.status}:${Boolean(run.conversationOnly)}`)
|
||||
.sort()
|
||||
.join(",");
|
||||
const nativeRuns = useMemo(
|
||||
@@ -43,8 +44,14 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource
|
||||
const retry = useCallback(() => setRetryGeneration((value) => value + 1), []);
|
||||
const projectionCacheRef = useRef(new Map<string, { events: HeartbeatRunEvent[]; transcript: TranscriptEntry[] }>());
|
||||
const cursorByRunRef = useRef(new Map<string, number>());
|
||||
const conversationOnlyByRunRef = useRef(new Map<string, boolean>());
|
||||
|
||||
useEffect(() => {
|
||||
for (const run of nativeRuns) {
|
||||
const mode = Boolean(run.conversationOnly);
|
||||
if (conversationOnlyByRunRef.current.get(run.id) !== mode) cursorByRunRef.current.delete(run.id);
|
||||
conversationOnlyByRunRef.current.set(run.id, mode);
|
||||
}
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
const timers = new Set<number>();
|
||||
@@ -71,7 +78,7 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource
|
||||
const incoming: HeartbeatRunEvent[] = [];
|
||||
for (;;) {
|
||||
const page = await readTranscriptRequest(
|
||||
(signal) => heartbeatsApi.events(run.id, cursor, EVENT_PAGE_SIZE, { signal }),
|
||||
(signal) => (run.conversationOnly ? heartbeatsApi.conversationEvents : heartbeatsApi.events)(run.id, cursor, EVENT_PAGE_SIZE, { signal }),
|
||||
controller.signal,
|
||||
);
|
||||
if (cancelled) return;
|
||||
@@ -89,7 +96,7 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource
|
||||
cursorByRunRef.current.set(run.id, cursor);
|
||||
if (incoming.length > 0) setEventsByRun((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(run.id, [...(previous.get(run.id) ?? []), ...incoming]);
|
||||
next.set(run.id, [...new Map([...(previous.get(run.id) ?? []), ...incoming].map((event) => [event.seq, event])).values()].sort((a, b) => a.seq - b.seq));
|
||||
return next;
|
||||
});
|
||||
setErrorsByRun((previous) => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// @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 { useIssueHistoryWindow } from "./useIssueHistoryWindow";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
const api = vi.hoisted(() => ({ recentComments: vi.fn(), listComments: vi.fn() }));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: api }));
|
||||
|
||||
it.each([true, false])("retains a burst larger than the preview window (initially empty: %s)", async (empty) => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
const comment = (n: number) => ({ id: `comment-${n}`, createdAt: new Date(n * 1000), body: `Message ${n}` } as IssueComment);
|
||||
let current: ReturnType<typeof useIssueHistoryWindow>;
|
||||
function Probe() { current = useIssueHistoryWindow("one", false); return <span>{current.recent.data?.comments.length}</span>; }
|
||||
api.recentComments.mockResolvedValue({ comments: empty ? [] : [comment(0)], latestCommentId: empty ? null : "comment-0", hasMore: !empty });
|
||||
api.listComments.mockReset();
|
||||
const wait = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
||||
try {
|
||||
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);
|
||||
api.recentComments.mockResolvedValue({ comments: [comment(58),comment(59),comment(60)], latestCommentId: "comment-60", hasMore: true });
|
||||
api.listComments.mockResolvedValueOnce(Array.from({length:50},(_,i)=>comment(i+1))).mockResolvedValueOnce(Array.from({length:10},(_,i)=>comment(i+51)));
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.issues.comments("one") }); });
|
||||
for (let i = 0; i < 20 && current!.recent.data?.latestCommentId !== "comment-60"; i++) await wait();
|
||||
expect(current!.recent.error).toBeNull();
|
||||
expect(current!.recent.data?.comments).toHaveLength(empty ? 60 : 61);
|
||||
expect(current!.recent.data?.hasMore).toBe(!empty);
|
||||
expect(api.listComments).toHaveBeenCalledTimes(2);
|
||||
expect(new Set(current!.recent.data?.comments.map(c=>c.id)).size).toBe(empty ? 60 : 61);
|
||||
} finally { await act(async () => root.unmount()); client.clear(); }
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Issue, IssueComment, IssueRecentComments } from "@paperclipai/shared";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
export function mergeWindowComments(...windows: readonly IssueComment[][]): IssueComment[] {
|
||||
return [...new Map(windows.flat().map((comment) => [comment.id, comment])).values()]
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** Freeze the cold-open window, then retain every message observed while open. */
|
||||
export function useIssueHistoryWindow(
|
||||
issueRef: string | undefined,
|
||||
showFullHistory: boolean,
|
||||
issue?: Pick<Issue, "id" | "identifier">,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const expansionKey = ["issues", "history-expanded", issueRef] as const;
|
||||
const { data: expanded = false } = useQuery({
|
||||
queryKey: expansionKey,
|
||||
queryFn: () => false,
|
||||
enabled: false,
|
||||
initialData: false,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
const historyExpanded = 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);
|
||||
}
|
||||
}, [issueRef, issue?.id, issue?.identifier, queryClient]);
|
||||
useEffect(() => {
|
||||
// Keep identifier and UUID navigation on the same expansion preference.
|
||||
if (showFullHistory || expanded) expandHistory();
|
||||
}, [showFullHistory, expanded, expandHistory]);
|
||||
const recentKey = [...queryKeys.issues.comments(issueRef ?? ""), "recent"] as const;
|
||||
const recent = useQuery({
|
||||
queryKey: recentKey,
|
||||
enabled: Boolean(issueRef) && !historyExpanded,
|
||||
queryFn: async ({ signal }): Promise<IssueRecentComments> => {
|
||||
const previous = queryClient.getQueryData<IssueRecentComments>(recentKey);
|
||||
const snapshot = await issuesApi.recentComments(issueRef!, { signal });
|
||||
if (!previous) return snapshot;
|
||||
// A burst can contain more than the cold-open window. Catch up from the
|
||||
// saved cursor instead of dropping messages between two invalidations.
|
||||
let cursor = previous.latestCommentId;
|
||||
const incoming: IssueComment[] = [];
|
||||
while (cursor !== snapshot.latestCommentId) {
|
||||
const page = await issuesApi.listComments(issueRef!, {
|
||||
order: "asc",
|
||||
...(cursor ? { after: cursor } : {}),
|
||||
limit: 50,
|
||||
}, { signal });
|
||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (!page.length) break;
|
||||
incoming.push(...page);
|
||||
const next = page.at(-1)!.id;
|
||||
if (next === cursor) break;
|
||||
cursor = next;
|
||||
if (page.length < 50 || page.some((comment) => comment.id === snapshot.latestCommentId)) break;
|
||||
}
|
||||
return {
|
||||
...snapshot,
|
||||
hasMore: previous.hasMore,
|
||||
latestCommentId: incoming.at(-1)?.id ?? snapshot.latestCommentId,
|
||||
comments: mergeWindowComments(previous.comments, incoming, snapshot.comments),
|
||||
};
|
||||
},
|
||||
});
|
||||
return {
|
||||
historyExpanded,
|
||||
expandHistory,
|
||||
recent,
|
||||
};
|
||||
}
|
||||
@@ -51,6 +51,8 @@ vi.mock("../api/grokBot", () => ({ grokBotApi: mockGrokBotApi }));
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
getCore: vi.fn(),
|
||||
recentComments: vi.fn(),
|
||||
list: vi.fn(),
|
||||
listAll: vi.fn(),
|
||||
listAcceptedPlanDecompositions: vi.fn(),
|
||||
@@ -88,7 +90,9 @@ const mockIssuesApi = vi.hoisted(() => ({
|
||||
|
||||
const mockActivityApi = vi.hoisted(() => ({
|
||||
forIssue: vi.fn(),
|
||||
recentForIssue: vi.fn(),
|
||||
runsForIssue: vi.fn(),
|
||||
recentRunsForIssue: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatsApi = vi.hoisted(() => ({
|
||||
@@ -1329,6 +1333,11 @@ describe("IssueDetail", () => {
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], true);
|
||||
mockIssuesApi.getCore.mockImplementation((id) => mockIssuesApi.get(id));
|
||||
mockIssuesApi.recentComments.mockResolvedValue({ comments: [], latestCommentId: null, hasMore: false });
|
||||
mockActivityApi.recentRunsForIssue.mockResolvedValue([]);
|
||||
mockActivityApi.recentForIssue.mockResolvedValue([]);
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
@@ -1671,6 +1680,51 @@ describe("IssueDetail", () => {
|
||||
expect(windowOpen).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>();
|
||||
mockIssuesApi.getCore.mockResolvedValue(createIssue({ description: "Original instruction" }));
|
||||
mockIssuesApi.get.mockReturnValue(stalled.promise);
|
||||
mockIssuesApi.recentComments.mockResolvedValue({ comments: [createIssueComment({ body: "Latest answer" })], latestCommentId: "comment-1", hasMore: true });
|
||||
await act(async () => { renderIssueView(); });
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Issue detail smoke");
|
||||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
compactHistory: true, initialHistoryPending: false,
|
||||
comments: [expect.objectContaining({ body: "Latest answer" })],
|
||||
});
|
||||
});
|
||||
expect(mockIssuesApi.listComments).not.toHaveBeenCalled();
|
||||
expect(mockActivityApi.forIssue).not.toHaveBeenCalled();
|
||||
expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled();
|
||||
const disclosure = mockIssueChatThreadRender.mock.calls.at(-1)?.[0].historyControl;
|
||||
await act(async () => { disclosure.props.onClick(); });
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssuesApi.listComments).toHaveBeenCalled();
|
||||
expect(mockActivityApi.runsForIssue).toHaveBeenCalled();
|
||||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].compactHistory).toBe(false);
|
||||
});
|
||||
expect(queryClient.getQueryData(["issues", "history-expanded", "PAP-1"])).toBe(true);
|
||||
});
|
||||
|
||||
it("retains every recent run observed during an open task", async () => {
|
||||
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
const run = (id: string) => ({ runId: id, agentId: "agent-1", status: "succeeded", runtimeMode: "native", createdAt: new Date().toISOString() });
|
||||
mockActivityApi.recentRunsForIssue.mockResolvedValue([run("latest"), run("older")]);
|
||||
await act(async () => { renderIssueView(); });
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].linkedRuns.map((r: { runId: string }) => r.runId)).toEqual(["latest"]);
|
||||
});
|
||||
for (const id of ["next", "newest"]) {
|
||||
mockActivityApi.recentRunsForIssue.mockResolvedValue([run(id)]);
|
||||
await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs("PAP-1") }); });
|
||||
}
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].linkedRuns.map((r: { runId: string }) => r.runId)).toEqual(["latest", "next", "newest"]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["comments", "description", "empty"])("coordinates %s with the initial cards and supporting history", async (content) => {
|
||||
const history = createDeferred<[]>();
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
@@ -6115,7 +6169,8 @@ describe("IssueDetail", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a discarded queued comment out of the thread when the queue becomes empty", async () => {
|
||||
it.each([true, false])("keeps a discarded queued comment out of the thread (expanded=%s)", async (expanded) => {
|
||||
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], expanded);
|
||||
const queue = createQueuedCommentQueue();
|
||||
const emptyQueue = createQueuedCommentQueue({
|
||||
queueId: null,
|
||||
@@ -6134,6 +6189,7 @@ describe("IssueDetail", () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
createAgent({ adapterType: "paperclip_runner" }),
|
||||
]);
|
||||
mockIssuesApi.recentComments.mockResolvedValue({ comments: [queue.entries[0].comment], latestCommentId: "queued-comment-1", hasMore: false });
|
||||
// Keep returning the pre-discard page to exercise the local projection
|
||||
// across the queueId -> null transition, as can happen during refetch.
|
||||
mockIssuesApi.listComments.mockResolvedValue([queue.entries[0].comment]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mergeWindowComments, useIssueHistoryWindow } from "../hooks/useIssueHistoryWindow";
|
||||
import { useTaskRetry } from "../hooks/useTaskRetry";
|
||||
import { grokBotApi } from "@/api/grokBot";
|
||||
import { WorkspaceBaseRefRecoveryProvider } from "../components/WorkspaceBaseRefRecovery";
|
||||
@@ -1242,6 +1243,9 @@ type IssueDetailChatTabProps = {
|
||||
} | null;
|
||||
comments: IssueDetailComment[];
|
||||
commentsInitialLoading?: boolean;
|
||||
historyExpanded?: boolean;
|
||||
onExpandHistory?: () => void;
|
||||
hasEarlierMessages?: boolean;
|
||||
initialHistoryPending?: boolean;
|
||||
initialMetadataPending?: boolean;
|
||||
initialHistoryError?: boolean;
|
||||
@@ -1394,6 +1398,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
legacyRecoverySourceIssue,
|
||||
comments,
|
||||
commentsInitialLoading = false,
|
||||
historyExpanded = true,
|
||||
onExpandHistory,
|
||||
hasEarlierMessages = false,
|
||||
initialHistoryPending = false,
|
||||
initialMetadataPending = false,
|
||||
initialHistoryError = false,
|
||||
@@ -1484,10 +1491,17 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
isError: activityError,
|
||||
refetch: refetchActivity,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.issues.activity(issueQueryRef),
|
||||
queryFn: () => activityApi.forIssue(issueQueryRef),
|
||||
queryKey: historyExpanded ? queryKeys.issues.activity(issueQueryRef) : [...queryKeys.issues.activity(issueQueryRef), "recent"],
|
||||
queryFn: async () => {
|
||||
if (historyExpanded) return activityApi.forIssue(issueQueryRef);
|
||||
const recent = await activityApi.recentForIssue(issueQueryRef);
|
||||
const previous = queryClient.getQueryData<ActivityEvent[]>([...queryKeys.issues.activity(issueQueryRef), "recent"]) ?? [];
|
||||
return [...new Map([...previous, ...recent].map((event) => [event.id, event])).values()];
|
||||
},
|
||||
enabled: !!issueId,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<ActivityEvent[]>(issueQueryRef),
|
||||
placeholderData: historyExpanded
|
||||
? queryClient.getQueryData<ActivityEvent[]>([...queryKeys.issues.activity(issueQueryRef), "recent"])
|
||||
: undefined,
|
||||
});
|
||||
const {
|
||||
data: liveRuns,
|
||||
@@ -1591,18 +1605,29 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
setClassicQueuedDeliveryError(null);
|
||||
}, [issueId]);
|
||||
const hasLiveRuns = liveRunCount > 0 || !!resolvedActiveRun;
|
||||
const linkedRunsKey = historyExpanded
|
||||
? queryKeys.issues.runs(issueQueryRef)
|
||||
: [...queryKeys.issues.runs(issueQueryRef), "recent"];
|
||||
const {
|
||||
data: linkedRuns,
|
||||
isPending: linkedRunsPending,
|
||||
isError: linkedRunsError,
|
||||
refetch: refetchLinkedRuns,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.issues.runs(issueQueryRef),
|
||||
queryFn: () => activityApi.runsForIssue(issueQueryRef),
|
||||
queryKey: linkedRunsKey,
|
||||
queryFn: async () => {
|
||||
if (historyExpanded) return activityApi.runsForIssue(issueQueryRef);
|
||||
const recent = await activityApi.recentRunsForIssue(issueQueryRef);
|
||||
const previous = queryClient.getQueryData<RunForIssue[]>(linkedRunsKey) ?? [];
|
||||
// The second row refreshes a just-finished predecessor. Start with only
|
||||
// the latest run, then keep every run observed while this task is open.
|
||||
const visible = new Set([...previous.map((run) => run.runId), ...recent.slice(0, 1).map((run) => run.runId)]);
|
||||
return [...new Map([...previous, ...recent].filter((run) => visible.has(run.runId)).map((run) => [run.runId, run])).values()];
|
||||
},
|
||||
enabled: !!issueId,
|
||||
refetchInterval:
|
||||
hasLiveRuns || issueStatus === "in_progress" ? 1000 : false,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueQueryRef),
|
||||
placeholderData: historyExpanded ? queryClient.getQueryData<RunForIssue[]>([...queryKeys.issues.runs(issueQueryRef), "recent"]) : undefined,
|
||||
});
|
||||
const resolvedActivity = activity ?? [];
|
||||
const resolvedLinkedRuns = linkedRuns ?? [];
|
||||
@@ -2517,6 +2542,14 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
>
|
||||
<EmailThreadProvider companyId={companyId} issueId={issueId}>
|
||||
<ThreadComponent
|
||||
{...(!classicTaskInterfaceEnabled ? {
|
||||
compactHistory: !historyExpanded,
|
||||
historyControl: !historyExpanded && (hasEarlierMessages || (linkedRuns?.length ?? 0) > 0) ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onExpandHistory}>
|
||||
More messages
|
||||
</Button>
|
||||
) : null,
|
||||
} : {})}
|
||||
executionBlocked={executionBlocked}
|
||||
interruption={interruption}
|
||||
retryPreparing={retryPreparing}
|
||||
@@ -2526,7 +2559,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
browsers={browsers}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
hasOlderComments={hasOlderComments}
|
||||
initialHistoryPending={!!issueId && (
|
||||
initialHistoryPending={historyExpanded && !!issueId && (
|
||||
initialHistoryPending ||
|
||||
commentsInitialLoading ||
|
||||
// These responses add rows or change the composer takeover.
|
||||
@@ -3223,11 +3256,22 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
[location.state, resolvedIssueDetailState],
|
||||
);
|
||||
|
||||
const coreIssueQuery = useQuery({
|
||||
queryKey: ["issues", "core", issueId],
|
||||
queryFn: ({ signal }) => issuesApi.getCore(issueId!, { signal }),
|
||||
enabled: !!issueId,
|
||||
});
|
||||
const { historyExpanded, expandHistory, recent: recentComments } = useIssueHistoryWindow(
|
||||
issueId,
|
||||
Boolean(conversation || classicTaskInterfaceEnabled || location.hash || detailTab !== "chat"),
|
||||
coreIssueQuery.data,
|
||||
);
|
||||
const {
|
||||
data: queriedIssue,
|
||||
isLoading,
|
||||
isPlaceholderData,
|
||||
error,
|
||||
refetch: refetchIssueDetail,
|
||||
} = useQuery({
|
||||
...getIssueDetailQueryOptions(queryClient, issueId!, {
|
||||
placeholderIssue: issueHeaderSeed
|
||||
@@ -3237,12 +3281,15 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
enabled: !!issueId,
|
||||
enabled: !!issueId && (coreIssueQuery.isFetched || !!queryClient.getQueryData(queryKeys.issues.detail(issueId))),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (issueId) void prefetchIssueThread(queryClient, issueId);
|
||||
}, [issueId, queryClient]);
|
||||
const issue = queriedIssue ?? conversation?.issue ?? draftIssue;
|
||||
if (issueId && historyExpanded) void prefetchIssueThread(queryClient, issueId);
|
||||
}, [issueId, historyExpanded, queryClient]);
|
||||
const accessError = [error, coreIssueQuery.error].find((value) =>
|
||||
value instanceof ApiError && [401, 403, 404].includes(value.status),
|
||||
);
|
||||
const issue = accessError ? undefined : queriedIssue ?? coreIssueQuery.data ?? conversation?.issue ?? draftIssue;
|
||||
const taskRetry = useTaskRetry(issue ?? undefined);
|
||||
const resolveWritableIssueId = async () => {
|
||||
if (!conversation) return issueId!;
|
||||
@@ -3258,7 +3305,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
// or upload against the previous task while the requested task is loading.
|
||||
const loadedIssue =
|
||||
!isPlaceholderData &&
|
||||
!error &&
|
||||
(!error || !!coreIssueQuery.data) &&
|
||||
issue &&
|
||||
issueId &&
|
||||
(issue.id.toLowerCase() === issueId.toLowerCase() ||
|
||||
@@ -3304,7 +3351,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
limit: ISSUE_COMMENT_PAGE_SIZE,
|
||||
...(pageParam ? { after: pageParam } : {}),
|
||||
}),
|
||||
enabled: !!issueId,
|
||||
enabled: !!issueId && historyExpanded,
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (lastPage) =>
|
||||
getNextIssueCommentPageParam(lastPage, ISSUE_COMMENT_PAGE_SIZE),
|
||||
@@ -3313,8 +3360,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
>(issueId ?? "pending"),
|
||||
});
|
||||
const comments = useMemo(
|
||||
() => flattenIssueCommentPages(commentPages?.pages),
|
||||
[commentPages?.pages],
|
||||
() => mergeWindowComments(recentComments.data?.comments ?? [], flattenIssueCommentPages(commentPages?.pages)),
|
||||
[commentPages?.pages, recentComments.data?.comments],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -3352,7 +3399,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
);
|
||||
const shouldPrefetchOlderComments = useMemo(
|
||||
() =>
|
||||
shouldAutoloadOlderIssueComments({
|
||||
historyExpanded && shouldAutoloadOlderIssueComments({
|
||||
activeDetailTab: detailTab,
|
||||
hasOlderComments: hasOlderComments ?? false,
|
||||
loadedCommentCount: comments.length,
|
||||
@@ -3364,6 +3411,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
comments.length,
|
||||
commentsLoading,
|
||||
commentsLoadingOlder,
|
||||
historyExpanded,
|
||||
detailTab,
|
||||
hasOlderComments,
|
||||
],
|
||||
@@ -7044,9 +7092,9 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
);
|
||||
}, [activePauseHold, issue]);
|
||||
|
||||
if (isLoading)
|
||||
if (!issue && !accessError && (isLoading || coreIssueQuery.isPending))
|
||||
return <IssueDetailLoadingState headerSeed={issueHeaderSeed} />;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!issue && (error || coreIssueQuery.error)) return <p className="text-sm text-destructive">{(error || coreIssueQuery.error)?.message}</p>;
|
||||
if (!issue) return null;
|
||||
// Do not expose a file chooser on the outgoing UUID/company/interface
|
||||
// branch: its input can be detached before the chosen file is returned.
|
||||
@@ -7793,6 +7841,12 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
: "max-w-3xl space-y-6"
|
||||
}
|
||||
>
|
||||
{error && coreIssueQuery.data ? (
|
||||
<div role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
Some task details could not be loaded.
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => void refetchIssueDetail()}>Retry</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Parent chain breadcrumb (redesign: rendered inside the thread viewport) */}
|
||||
{taskChatShellEnabled ? null : ancestorsNav}
|
||||
{taskChatShellEnabled ? null : createdFromNote}
|
||||
@@ -8229,7 +8283,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
|
||||
legacyRecoverySourceIssue={legacyRecoverySourceIssue}
|
||||
comments={threadComments}
|
||||
commentsInitialLoading={commentsLoading}
|
||||
commentsInitialLoading={historyExpanded ? commentsLoading : recentComments.isLoading}
|
||||
historyExpanded={historyExpanded}
|
||||
onExpandHistory={expandHistory}
|
||||
hasEarlierMessages={recentComments.data?.hasMore ?? false}
|
||||
initialHistoryPending={linkedCommentPending}
|
||||
initialMetadataPending={
|
||||
interactionsLoading ||
|
||||
@@ -8237,13 +8294,13 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
workProductsLoading
|
||||
}
|
||||
initialHistoryError={
|
||||
commentsError ||
|
||||
(historyExpanded ? commentsError : recentComments.isError) ||
|
||||
interactionsError ||
|
||||
attachmentsError ||
|
||||
workProductsError
|
||||
}
|
||||
onRetryInitialHistory={() => {
|
||||
void refetchComments();
|
||||
void (historyExpanded ? refetchComments() : recentComments.refetch());
|
||||
void refetchInteractions();
|
||||
void refetchAttachments();
|
||||
void refetchWorkProducts();
|
||||
|
||||
Reference in new issue
Block a user