mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
perf: reveal task content sooner and parallelize issue reads (#14727)
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - A task page must show saved replies quickly so a person can read the work. > - The title could appear while the conversation waited for unrelated metadata and transcripts. > - Thread requests also waited for enriched task details, while the server read several independent fields in sequence. > - This change shows saved content as soon as it is ready, starts thread reads earlier, and runs independent server reads together. > - Native event history takes priority over legacy log fallback, and mentioned tasks load on intent. > - Content-free timing spans make the remaining server delays visible without recording task content. ## Linked Issues or Issue Description **What happened?** Task titles and properties appeared quickly, but saved conversation content stayed hidden for several more seconds while metadata and run transcripts loaded. **Expected behavior** Saved replies and the task description should be readable without waiting for supporting history. Returning to a cached task should show content within a frame or two. **Steps to reproduce** 1. Open a task with saved comments and completed runs. 2. Delay the task activity and runs responses by five seconds in the browser. 3. Observe whether saved content remains hidden until those responses finish. 4. Navigate away and return to the task to check cached navigation. **Paperclip version or commit** The change was developed from `1b48e73e0` and rebased onto `44736c9c7`. **Deployment mode** Built from source, tested in an authenticated staging deployment and with local response replay. Related: #14667 overlaps the transcript reveal behavior and adds separate retry UX. This PR also changes navigation prefetch, parent metadata gates, native log fallback, server read scheduling, and timing spans. #12647 proposes a separate SQL predicate optimization in the runs service. #13597 and #13095 are earlier loading fixes. ## What Changed - Start activity and runs when the task page mounts, alongside task details and comments, using the route reference for shared query keys. Hover/focus prefetch does not start full history reads. - Reveal saved comments and descriptions while metadata and transcripts load. Preserve strict waits for linked-comment navigation and tasks with only runtime content. - For settled native runs, fetch legacy logs only when event history is empty or fails. Preserve live-log subscriptions for queued and running native runs. Fetch mentioned-task details on hover or focus. - Run independent issue-detail enrichment and run metadata reads in parallel while preserving recovery dependencies. - Add `Server-Timing` phases and opt-in OpenTelemetry spans, plus regression tests and observability documentation. ## Verification - **313 tests passed** across the initial seven focused component, cache, timing, and scroll suites. After review fixes, **313 tests passed** across five task-page, cache, prefetch, and live-transcript suites (`pnpm exec vitest run` with `--maxWorkers=1`; these sets overlap). - `pnpm -r typecheck` and `pnpm build` passed locally before the final UI-only review fixes. UI typecheck/build and `pnpm check:token-gates` passed after those fixes. The final commit also passes full typecheck and build in CI. - The full local Vitest run was attempted. Several unrelated embedded PostgreSQL fixtures failed to start, and parallel test workers hit timeouts. Focused reruns passed. A later local full-suite rerun was stopped after the complete CI suite passed; it is not claimed as a local full-suite pass. - Final commit `d1e147145`: **54 checks passed, 2 skipped**, including all server/workspace test shards, all eight browser E2E shards, full build/typecheck, Runner checks, release registry, and canary clean-install verification. [CI run](https://github.com/paperclipai/paperclip/actions/runs/36734642034). Greptile **5/5** after two reviews; both findings fixed and all review threads resolved. No merge conflicts. - Live browser tests on an existing task with two saved replies: median full reload to visible content fell from **1.92 s** (3 samples) to **1.40 s** (5 samples). Cached return fell from **421 ms** (1 sample) to **29 ms** (3 samples). These are observed samples, not a performance guarantee. - With activity and runs delayed by five seconds, saved content appeared in **1.38 s** on desktop and **1.33 s** on mobile. The inspected comment did not move when metadata arrived. Verified history expansion, task properties, pending-input navigation, dashboard return, and mobile layout. - A separate local replay with fixed responses reduced visible-content time from **5.12 s** to **2.15 s**. This isolates frontend behavior and is not a live-server benchmark. ## Risks Progressive history can change the thread after first paint. Existing anchor behavior is retained and covered by tests and delayed-response browser checks. Query aliases must stay aligned for invalidation. Parallel reads can increase short bursts of database work; dependent recovery operations remain ordered. Full reloads still depend on network and task-detail latency. No schema or authorization change. OpenTelemetry remains disabled without an operator endpoint. The added spans use a closed set of phase names and carry no task IDs, task content, or exception text. I checked `ROADMAP.md`; this is a performance fix within the existing task page. ## Model Used OpenAI GPT-6 in Codex. The runtime does not expose a more specific model ID or context-window size. The agent used reasoning, code editing, terminal tools, and Chrome performance profiling. ## 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 - [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
a36cbffa9e
commit
0e5830887b
13 files changed
+421
-125
No files matched your search
+29
-1
@@ -22,7 +22,8 @@ alternative peer dependencies — install exactly **one**, matching
|
||||
`OTEL_EXPORTER_OTLP_PROTOCOL`.
|
||||
|
||||
When `OTEL_EXPORTER_OTLP_ENDPOINT` is unset, none of the `@opentelemetry/*` SDK
|
||||
packages are imported and there is zero runtime overhead.
|
||||
packages are imported and there is no SDK or exporter overhead. Local request
|
||||
timings still use the performance clock.
|
||||
|
||||
`server/package.json` declares each optional package at the exact version the
|
||||
server tests against; install that exact version. Our Dependabot cannot bump
|
||||
@@ -34,6 +35,33 @@ maintains as a dependency; once you install the packages below, they become
|
||||
normal dependencies of **your own** project, and your own Dependabot updates
|
||||
them.
|
||||
|
||||
## Task detail loading
|
||||
|
||||
`GET /api/issues/:id` returns `Server-Timing` with `paperclip_issue` for the
|
||||
whole handler and `issue_<phase>` for its reads. The total includes the final
|
||||
execution-blocker read after recovery revalidation. Concurrent phases overlap;
|
||||
do not sum them to obtain request duration.
|
||||
|
||||
With the existing operator-configured OTLP endpoint, the same reads produce
|
||||
`issue.read.<phase>` child spans through the `paperclip.issue-read` tracer.
|
||||
There are no custom attributes, identifiers, content, or exception messages.
|
||||
The closed phase names in `server/src/services/issue-read-timing.ts` are:
|
||||
`lookup`, `authorization`, `project_goal`, `ancestors`, `mentions`, `documents`,
|
||||
`relations`, `blockers`, `review`, `references`, `handoff`, `retry`, `recovery`,
|
||||
`cases`, `inbox`, `channel`, `workspace`, `work_products`, `execution_blocker`,
|
||||
`relation_recovery`, `revalidate_recovery`, and `mentioned_projects`.
|
||||
Without an OTLP endpoint, spans remain no-ops. This adds no first-party
|
||||
Telemetry events or run-log events.
|
||||
|
||||
The browser's `issue-detail:navigate→content-paint` User Timing measure ends
|
||||
after the redesigned conversation is revealed and painted, rather than when
|
||||
the comments request completes while the conversation is still hidden. Durable
|
||||
messages can paint before run history and plan enrichment. Runtime-only threads
|
||||
wait for initial output; late history retains the existing scroll-anchor behavior.
|
||||
Use Chrome's request waterfall and this mark together to distinguish API wait,
|
||||
request dependencies, and rendering time. A hard-load trace also includes auth,
|
||||
company selection, and JavaScript startup before the task navigation mark.
|
||||
|
||||
## Enabling tracing
|
||||
|
||||
### 1. Install the OTel peer dependencies
|
||||
|
||||
+40
-43
@@ -1,3 +1,4 @@
|
||||
import { createIssueReadTiming } from "../services/issue-read-timing.js";
|
||||
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
|
||||
import { retryNativeWorkspaceExport } from "../services/native-runtime/native-workspace-export-retry.js";
|
||||
import { queuedInteractionId, readQueuedInteractionResponse, hasQueuedInteractionResponse } from "../services/queued-interaction-response.js";
|
||||
@@ -8734,19 +8735,19 @@ export function issueRoutes(
|
||||
});
|
||||
|
||||
router.get("/issues/:id", async (req, res) => {
|
||||
const requestStartedAt = performance.now();
|
||||
const timing = createIssueReadTiming();
|
||||
const id = req.params.id as string;
|
||||
const issue = await getAccessibleResource(
|
||||
req,
|
||||
res,
|
||||
getIssueById(req, id),
|
||||
timing.time("lookup", () => getIssueById(req, id)),
|
||||
"Issue not found",
|
||||
);
|
||||
if (!issue) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
||||
if (!(await timing.time("authorization", () => assertIssueReadAllowed(req, res, issue)))) return;
|
||||
const inboxArchiveFieldsPromise =
|
||||
req.actor.type === "board" && req.actor.userId
|
||||
? svc.getActiveInboxArchiveFields(issue, req.actor.userId)
|
||||
? timing.time("inbox", () => svc.getActiveInboxArchiveFields(issue, req.actor.userId!))
|
||||
: Promise.resolve({});
|
||||
const [
|
||||
{ project, goal },
|
||||
@@ -8763,54 +8764,50 @@ export function issueRoutes(
|
||||
linkedCases,
|
||||
inboxArchiveFields,
|
||||
externalChannelBinding,
|
||||
currentExecutionWorkspace,
|
||||
workProducts,
|
||||
] = await Promise.all([
|
||||
resolveIssueProjectAndGoal(issue),
|
||||
svc.getAncestors(issue.id),
|
||||
svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }),
|
||||
documentsSvc.getIssueDocumentPayload(issue),
|
||||
svc.getRelationSummaries(issue.id),
|
||||
svc
|
||||
timing.time("project_goal", () => resolveIssueProjectAndGoal(issue)),
|
||||
timing.time("ancestors", () => svc.getAncestors(issue.id)),
|
||||
timing.time("mentions", () => svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false })),
|
||||
timing.time("documents", () => documentsSvc.getIssueDocumentPayload(issue)),
|
||||
timing.time("relations", () => svc.getRelationSummaries(issue.id)),
|
||||
timing.time("blockers", () => svc
|
||||
.listBlockerAttention(issue.companyId, [issue])
|
||||
.then((map) => map.get(issue.id) ?? null),
|
||||
svc
|
||||
.then((map) => map.get(issue.id) ?? null)),
|
||||
timing.time("review", () => svc
|
||||
.listReviewAttention(issue.companyId, [issue])
|
||||
.then((map) => map.get(issue.id) ?? null),
|
||||
issueReferencesSvc.listIssueReferenceSummary(issue.id),
|
||||
listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]),
|
||||
svc.getCurrentScheduledRetry(issue.id),
|
||||
recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id),
|
||||
listIssueLinkedCases(db, issue.companyId, issue.id),
|
||||
.then((map) => map.get(issue.id) ?? null)),
|
||||
timing.time("references", () => issueReferencesSvc.listIssueReferenceSummary(issue.id)),
|
||||
timing.time("handoff", () => listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id])),
|
||||
timing.time("retry", () => svc.getCurrentScheduledRetry(issue.id)),
|
||||
timing.time("recovery", () => recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id)),
|
||||
timing.time("cases", () => listIssueLinkedCases(db, issue.companyId, issue.id)),
|
||||
inboxArchiveFieldsPromise,
|
||||
getExternalChannelBindingSummary(db, issue.companyId, issue.id),
|
||||
timing.time("channel", () => getExternalChannelBindingSummary(db, issue.companyId, issue.id)),
|
||||
timing.time("workspace", () => issue.executionWorkspaceId
|
||||
? executionWorkspacesSvc.getById(issue.executionWorkspaceId)
|
||||
: Promise.resolve(null)),
|
||||
timing.time("work_products", () => workProductsSvc.listForIssue(issue.id)),
|
||||
]);
|
||||
const recoveryActionsByRelationIssue = await relationRecoveryActionMap(
|
||||
recoveryActionsSvc,
|
||||
issue.companyId,
|
||||
relations,
|
||||
);
|
||||
const relationsWithRecoveryActions = withRecoveryActionsOnRelationSummaries(
|
||||
relations,
|
||||
recoveryActionsByRelationIssue,
|
||||
);
|
||||
const revalidatedActiveRecoveryAction =
|
||||
await revalidateActiveSourceRecoveryForRead({
|
||||
const [recoveryActionsByRelationIssue, revalidatedActiveRecoveryAction, mentionedProjects] = await Promise.all([
|
||||
timing.time("relation_recovery", () => relationRecoveryActionMap(recoveryActionsSvc, issue.companyId, relations)),
|
||||
timing.time("revalidate_recovery", () => revalidateActiveSourceRecoveryForRead({
|
||||
issue,
|
||||
trigger: "read_projection",
|
||||
actor: getActorInfo(req),
|
||||
activeRecoveryAction,
|
||||
});
|
||||
const mentionedProjects =
|
||||
mentionedProjectIds.length > 0
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
: [];
|
||||
const currentExecutionWorkspace = issue.executionWorkspaceId
|
||||
? await executionWorkspacesSvc.getById(issue.executionWorkspaceId)
|
||||
: null;
|
||||
const workProducts = await workProductsSvc.listForIssue(issue.id);
|
||||
res.setHeader(
|
||||
"Server-Timing",
|
||||
`paperclip_issue;dur=${(performance.now() - requestStartedAt).toFixed(1)}`,
|
||||
})),
|
||||
timing.time("mentioned_projects", () => mentionedProjectIds.length > 0
|
||||
? projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
: Promise.resolve([])),
|
||||
]);
|
||||
const relationsWithRecoveryActions = withRecoveryActionsOnRelationSummaries(
|
||||
relations, recoveryActionsByRelationIssue,
|
||||
);
|
||||
// Recovery revalidation may change the blocker; read it afterwards.
|
||||
const executionBlocker = await timing.time("execution_blocker", () => getExecutionBlocker(db, issue.companyId, issue.id));
|
||||
res.setHeader("Server-Timing", timing.header());
|
||||
res.json({
|
||||
...issue,
|
||||
...inboxArchiveFields,
|
||||
@@ -8819,7 +8816,7 @@ export function issueRoutes(
|
||||
...(blockerAttention ? { blockerAttention } : {}),
|
||||
...(reviewAttention ? { reviewAttention } : {}),
|
||||
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
|
||||
executionBlocker: await getExecutionBlocker(db, issue.companyId, issue.id),
|
||||
executionBlocker,
|
||||
scheduledRetry,
|
||||
activeRecoveryAction: revalidatedActiveRecoveryAction,
|
||||
blockedBy: relationsWithRecoveryActions.blockedBy,
|
||||
|
||||
@@ -450,7 +450,7 @@ export function activityService(db: Db) {
|
||||
const runIds = runs.map((run) => run.runId);
|
||||
if (runIds.length === 0) return runs;
|
||||
|
||||
const exhaustionRows = await db
|
||||
const exhaustionRowsQuery = db
|
||||
.select({
|
||||
runId: heartbeatRunEvents.runId,
|
||||
message: heartbeatRunEvents.message,
|
||||
@@ -465,13 +465,7 @@ export function activityService(db: Db) {
|
||||
)
|
||||
.orderBy(asc(heartbeatRunEvents.runId), desc(heartbeatRunEvents.id));
|
||||
|
||||
const retryExhaustedReasonByRunId = new Map<string, string>();
|
||||
for (const row of exhaustionRows) {
|
||||
if (!row.message || retryExhaustedReasonByRunId.has(row.runId)) continue;
|
||||
retryExhaustedReasonByRunId.set(row.runId, row.message);
|
||||
}
|
||||
|
||||
const leaseRows = await db
|
||||
const leaseRowsQuery = db
|
||||
.select({
|
||||
lease: environmentLeases,
|
||||
environment: {
|
||||
@@ -490,6 +484,28 @@ export function activityService(db: Db) {
|
||||
)
|
||||
.orderBy(desc(environmentLeases.lastUsedAt), desc(environmentLeases.createdAt));
|
||||
|
||||
// Only stored, current plan revisions can support a saved-plan link.
|
||||
// Do not trust an adapter's claim that it wrote a document.
|
||||
const savedPlanQuery = runs.some((run) => hasWorkspaceRestoreFailure(run.resultJson))
|
||||
? db.select({ revisionId: documentRevisions.id, runId: documentRevisions.createdByRunId })
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, and(eq(documents.id, issueDocuments.documentId), eq(documents.companyId, companyId)))
|
||||
.innerJoin(documentRevisions, and(eq(documentRevisions.id, documents.latestRevisionId), eq(documentRevisions.documentId, documents.id), eq(documentRevisions.companyId, companyId)))
|
||||
.where(and(eq(issueDocuments.companyId, companyId), eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, "plan")))
|
||||
.limit(1)
|
||||
: Promise.resolve([]);
|
||||
const [exhaustionRows, leaseRows, executionByRunId, [savedPlan]] = await Promise.all([
|
||||
exhaustionRowsQuery,
|
||||
leaseRowsQuery,
|
||||
executionProjectionsForRuns(db, companyId, runIds),
|
||||
savedPlanQuery,
|
||||
]);
|
||||
const retryExhaustedReasonByRunId = new Map<string, string>();
|
||||
for (const row of exhaustionRows) {
|
||||
if (!row.message || retryExhaustedReasonByRunId.has(row.runId)) continue;
|
||||
retryExhaustedReasonByRunId.set(row.runId, row.message);
|
||||
}
|
||||
|
||||
const leaseByRunId = new Map<string, (typeof leaseRows)[number]>();
|
||||
for (const row of leaseRows) {
|
||||
if (row.lease.heartbeatRunId && !leaseByRunId.has(row.lease.heartbeatRunId)) {
|
||||
@@ -497,17 +513,6 @@ export function activityService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
const executionByRunId = await executionProjectionsForRuns(db, companyId, runIds);
|
||||
// Only stored, current plan revisions can support a saved-plan link.
|
||||
// Do not trust an adapter's claim that it wrote a document.
|
||||
const [savedPlan] = runs.some((run) => hasWorkspaceRestoreFailure(run.resultJson))
|
||||
? await db.select({ revisionId: documentRevisions.id, runId: documentRevisions.createdByRunId })
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, and(eq(documents.id, issueDocuments.documentId), eq(documents.companyId, companyId)))
|
||||
.innerJoin(documentRevisions, and(eq(documentRevisions.id, documents.latestRevisionId), eq(documentRevisions.documentId, documents.id), eq(documentRevisions.companyId, companyId)))
|
||||
.where(and(eq(issueDocuments.companyId, companyId), eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, "plan")))
|
||||
.limit(1)
|
||||
: [];
|
||||
return runs.map((run) => {
|
||||
const leaseRow = leaseByRunId.get(run.runId);
|
||||
const leaseMetadata = leaseRow?.lease.metadata ?? null;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createIssueReadTiming } from "./issue-read-timing.js";
|
||||
|
||||
const span = vi.hoisted(() => ({ setStatus: vi.fn(), end: vi.fn() }));
|
||||
const startActiveSpan = vi.hoisted(() => vi.fn((_name, callback) => callback(span)));
|
||||
vi.mock("@opentelemetry/api", () => ({
|
||||
SpanStatusCode: { ERROR: 2 },
|
||||
trace: { getTracer: () => ({ startActiveSpan }) },
|
||||
}));
|
||||
|
||||
describe("issue read timing", () => {
|
||||
it("records parallel phases and the full request without recording result content", async () => {
|
||||
const timing = createIssueReadTiming();
|
||||
let resolve!: (value: string) => void;
|
||||
const lookup = timing.time("lookup", () => new Promise<string>((done) => { resolve = done; }));
|
||||
expect(await timing.time("documents", async () => "private content")).toBe("private content");
|
||||
resolve("private identifier");
|
||||
await lookup;
|
||||
expect(timing.header()).toMatch(/^paperclip_issue;dur=\d+\.\d, issue_documents;dur=\d+\.\d, issue_lookup;dur=\d+\.\d$/);
|
||||
expect(startActiveSpan).toHaveBeenCalledWith("issue.read.lookup", expect.any(Function));
|
||||
expect(timing.header()).not.toContain("private");
|
||||
});
|
||||
|
||||
it("ends failed spans and preserves the original error without exporting its text", async () => {
|
||||
vi.clearAllMocks();
|
||||
const timing = createIssueReadTiming();
|
||||
const error = new Error("private database detail");
|
||||
await expect(timing.time("workspace", async () => { throw error; })).rejects.toBe(error);
|
||||
expect(span.setStatus).toHaveBeenCalledExactlyOnceWith({ code: 2 });
|
||||
expect(span.end).toHaveBeenCalledOnce();
|
||||
expect(timing.header()).toContain("issue_workspace;dur=");
|
||||
expect(timing.header()).not.toContain(error.message);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
|
||||
// Closed, content-free dimensions. OTel remains a no-op without an operator
|
||||
// endpoint; Server-Timing also makes the same phases inspectable in DevTools.
|
||||
type IssueReadPhase = "lookup" | "authorization" | "project_goal" | "ancestors"
|
||||
| "mentions" | "documents" | "relations" | "blockers" | "review"
|
||||
| "references" | "handoff" | "retry" | "recovery" | "cases" | "inbox"
|
||||
| "channel" | "workspace" | "work_products" | "execution_blocker"
|
||||
| "relation_recovery" | "revalidate_recovery" | "mentioned_projects";
|
||||
|
||||
export function createIssueReadTiming() {
|
||||
const start = performance.now();
|
||||
const durations = new Map<IssueReadPhase, number>();
|
||||
return {
|
||||
time<T>(phase: IssueReadPhase, read: () => Promise<T>): Promise<T> {
|
||||
return trace.getTracer("paperclip.issue-read").startActiveSpan(`issue.read.${phase}`, async (span) => {
|
||||
const phaseStart = performance.now();
|
||||
try {
|
||||
return await read();
|
||||
} catch (error) {
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
throw error;
|
||||
} finally {
|
||||
durations.set(phase, performance.now() - phaseStart);
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
},
|
||||
header() {
|
||||
return [
|
||||
`paperclip_issue;dur=${(performance.now() - start).toFixed(1)}`,
|
||||
...[...durations].map(([phase, duration]) => `issue_${phase};dur=${duration.toFixed(1)}`),
|
||||
].join(", ");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, type ComponentProps } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
|
||||
const getIssue = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../api/issues", () => ({ issuesApi: { get: getIssue } }));
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, ...props }: ComponentProps<"a"> & { to: string }) => <a href={to} {...props} />,
|
||||
useCaseHref: () => () => "",
|
||||
}));
|
||||
vi.mock("../context/CompanyContext", () => ({ useOptionalCompany: () => null }));
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it.each(["pointerover", "focusin"])("fetches mentioned task details only on %s", async (event) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
getIssue.mockResolvedValue({ id: "task-72", identifier: "PAP-72", title: "Mentioned task", status: "done" });
|
||||
try {
|
||||
await act(async () => root.render(
|
||||
<QueryClientProvider client={client}><ThemeProvider>
|
||||
<MarkdownBody>{"See PAP-72 and PAP-73 for background."}</MarkdownBody>
|
||||
</ThemeProvider></QueryClientProvider>,
|
||||
));
|
||||
expect(getIssue).not.toHaveBeenCalled();
|
||||
const link = container.querySelector<HTMLAnchorElement>('a[href="/issues/PAP-72"]')!;
|
||||
expect(link).not.toBeNull();
|
||||
await act(async () => {
|
||||
link.dispatchEvent(new MouseEvent(event, { bubbles: true }));
|
||||
});
|
||||
expect(getIssue).toHaveBeenCalledExactlyOnceWith("PAP-72");
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
client.clear();
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isValidElement, memo, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, ExternalLink, WrapText } from "lucide-react";
|
||||
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -10,6 +10,7 @@ import { useTheme } from "../context/ThemeContext";
|
||||
import { useOptionalCompany } from "../context/CompanyContext";
|
||||
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { getCachedIssueDetail } from "../lib/issueDetailCache";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference";
|
||||
import { remarkLinkCaseReferences } from "../lib/case-reference";
|
||||
@@ -112,8 +113,14 @@ function MarkdownIssueLink({
|
||||
issuePathId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [engaged, setEngaged] = useState(false);
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.issues.detail(issuePathId),
|
||||
// A transcript can mention dozens of tasks. Their full detail projections
|
||||
// are hover information, not prerequisites for reading this conversation.
|
||||
enabled: engaged,
|
||||
placeholderData: getCachedIssueDetail(queryClient, issuePathId),
|
||||
queryFn: () => issuesApi.get(issuePathId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
@@ -127,6 +134,8 @@ function MarkdownIssueLink({
|
||||
<Link
|
||||
to={`/issues/${identifier}`}
|
||||
data-mention-kind="issue"
|
||||
onPointerEnter={() => setEngaged(true)}
|
||||
onFocus={() => setEngaged(true)}
|
||||
// Boxless inline mention: the unified status glyph + a regular-weight
|
||||
// underlined link, optically centered with the body text.
|
||||
className={cn("paperclip-markdown-issue-ref", "font-normal underline")}
|
||||
|
||||
@@ -247,7 +247,7 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti
|
||||
});
|
||||
|
||||
it.each(["running", "succeeded"])(
|
||||
"waits for a %s run to hydrate even when a scheduled retry is present",
|
||||
"reveals durable comments before a %s run finishes hydrating",
|
||||
async (status) => {
|
||||
const props = {
|
||||
issueId: "issue-1",
|
||||
@@ -264,10 +264,11 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti
|
||||
],
|
||||
};
|
||||
render(<TaskChatThread {...props} />);
|
||||
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
|
||||
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-history-loading"]'),
|
||||
).not.toBeNull();
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("Thread message 1");
|
||||
|
||||
transcriptState.hydratedRunIds = new Set(["started-run"]);
|
||||
nativeTranscriptState.hydratedRunIds = new Set(["started-run"]);
|
||||
@@ -603,7 +604,53 @@ describe("TaskChatThread draft pass-through", () => {
|
||||
});
|
||||
|
||||
describe("TaskChatThread runtime transcript selection", () => {
|
||||
it.each(["queued", "running"])("retains the live log transport for a %s native run with event history", (status) => {
|
||||
nativeTranscriptState.hydratedRunIds = new Set(["native-run"]);
|
||||
nativeTranscriptState.transcriptByRun.set("native-run", [
|
||||
{ kind: "assistant", text: "Native progress", ts: "2026-08-25T18:00:01.000Z", channel: "progress" },
|
||||
]);
|
||||
const props = {
|
||||
issueId: "native-live", comments: [], onAdd: async () => {},
|
||||
linkedRuns: [{ runId: "native-run", runtimeMode: "native" as const,
|
||||
status, agentId: "agent-1", adapterType: "paperclip_runner",
|
||||
createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z" }],
|
||||
};
|
||||
render(<TaskChatThread {...props} />);
|
||||
expect(transcriptHookRuns.legacy.at(-1)).toMatchObject([{ id: "native-run", status }]);
|
||||
|
||||
render(<TaskChatThread {...props} linkedRuns={[{ ...props.linkedRuns[0], status: "succeeded" }]} />);
|
||||
expect(transcriptHookRuns.legacy.at(-1)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not download fallback logs until native events are empty or fail", () => {
|
||||
const props = {
|
||||
issueId: "native-history", comments: [], onAdd: async () => {},
|
||||
linkedRuns: [{ runId: "native-run", runtimeMode: "native" as const,
|
||||
status: "succeeded", agentId: "agent-1", adapterType: "paperclip_runner",
|
||||
createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z" }],
|
||||
};
|
||||
nativeTranscriptState.hydratedRunIds = new Set();
|
||||
render(<TaskChatThread {...props} />);
|
||||
expect(transcriptHookRuns.legacy.at(-1)).toEqual([]);
|
||||
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
|
||||
|
||||
nativeTranscriptState.hydratedRunIds = new Set(["native-run"]);
|
||||
nativeTranscriptState.transcriptByRun.set("native-run", [{ kind: "assistant", text: "Native response", ts: "2026-08-25T18:00:01.000Z", channel: "final" }]);
|
||||
render(<TaskChatThread {...props} />);
|
||||
expect(transcriptHookRuns.legacy.at(-1)).toEqual([]);
|
||||
|
||||
nativeTranscriptState.transcriptByRun.clear();
|
||||
render(<TaskChatThread {...props} />);
|
||||
expect(transcriptHookRuns.legacy.at(-1)).toMatchObject([{ id: "native-run" }]);
|
||||
|
||||
nativeTranscriptState.hydratedRunIds = new Set();
|
||||
nativeTranscriptState.errorsByRun.set("native-run", { message: "Unavailable" });
|
||||
render(<TaskChatThread {...props} />);
|
||||
expect(transcriptHookRuns.legacy.at(-1)).toMatchObject([{ id: "native-run" }]);
|
||||
});
|
||||
|
||||
it("selects persisted runtime facts while retaining the log parser as native fallback", () => {
|
||||
nativeTranscriptState.hydratedRunIds = new Set(["native-run"]);
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
ISSUE_DETAIL_CONTENT_PAINT_MARK,
|
||||
ISSUE_DETAIL_CONTENT_MEASURE,
|
||||
scheduleIssueDetailPaintMeasure,
|
||||
} from "@/lib/issue-detail-performance";
|
||||
import { hasWorkspaceRestoreFailure } from "@paperclipai/shared";
|
||||
import { workspaceRestoreMarkerDetail } from "@/lib/workspace-restore-marker";
|
||||
import type { ActivityEvent, TaskBrowser } from "@paperclipai/shared";
|
||||
@@ -883,19 +888,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
() => runs.filter((run) => run.runtimeMode === "native"),
|
||||
[runs],
|
||||
);
|
||||
const {
|
||||
transcriptByRun: logTranscriptByRun,
|
||||
isInitialHydrating: logsAreInitiallyHydrating,
|
||||
hydratedRunIds: hydratedLogRunIds,
|
||||
errorsByRun: logErrorsByRun,
|
||||
retry: retryLogs,
|
||||
} = useLiveRunTranscripts({
|
||||
// Native events are authoritative, but the persisted/live log remains a
|
||||
// compatibility source when an upgraded server has no event history or
|
||||
// the native event endpoint is temporarily unavailable.
|
||||
runs,
|
||||
companyId,
|
||||
});
|
||||
const {
|
||||
transcriptByRun: nativeTranscriptByRun,
|
||||
errorsByRun: nativeTranscriptErrorsByRun,
|
||||
@@ -903,6 +895,22 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
hydratedRunIds: hydratedNativeRunIds,
|
||||
retry: retryNativeEvents,
|
||||
} = useNativeRunTranscripts(nativeRuns);
|
||||
const logRuns = useMemo(() => runs.filter((run) =>
|
||||
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.
|
||||
run.status === "running" || run.status === "queued" ||
|
||||
nativeTranscriptErrorsByRun.has(run.id) ||
|
||||
(hydratedNativeRunIds?.has(run.id) &&
|
||||
(nativeTranscriptByRun.get(run.id)?.length ?? 0) === 0)
|
||||
), [runs, nativeTranscriptErrorsByRun, hydratedNativeRunIds, nativeTranscriptByRun]);
|
||||
const {
|
||||
transcriptByRun: logTranscriptByRun,
|
||||
isInitialHydrating: logsAreInitiallyHydrating,
|
||||
hydratedRunIds: hydratedLogRunIds,
|
||||
errorsByRun: logErrorsByRun,
|
||||
retry: retryLogs,
|
||||
} = useLiveRunTranscripts({ runs: logRuns, companyId });
|
||||
const fallbackByRunRef = useRef(
|
||||
new Map<string, NonNullable<ReturnType<typeof logTranscriptByRun.get>>>(),
|
||||
);
|
||||
@@ -2760,28 +2768,31 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
initialCommentWindow.current.oldestAt
|
||||
);
|
||||
});
|
||||
const historyPending =
|
||||
initialHistoryPending ||
|
||||
planLoading ||
|
||||
initialRuns.some((run) => {
|
||||
// A scheduled retry has not started and has no log to hydrate yet.
|
||||
if (run.status === "scheduled_retry") return false;
|
||||
if (
|
||||
run.runtimeMode === "native" &&
|
||||
(hydratedNativeRunIds
|
||||
? !hydratedNativeRunIds.has(run.id)
|
||||
: nativeEventsAreInitiallyHydrating)
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
run.runtimeMode === "native" &&
|
||||
(nativeTranscriptByRun.get(run.id)?.length ?? 0) > 0
|
||||
)
|
||||
return false;
|
||||
return run.status !== "queued" && hydratedLogRunIds
|
||||
? !hydratedLogRunIds.has(run.id)
|
||||
: logsAreInitiallyHydrating;
|
||||
});
|
||||
const transcriptHistoryPending = initialRuns.some((run) => {
|
||||
// A scheduled retry has not started and has no log to hydrate yet.
|
||||
if (run.status === "scheduled_retry") return false;
|
||||
if (
|
||||
run.runtimeMode === "native" &&
|
||||
(hydratedNativeRunIds
|
||||
? !hydratedNativeRunIds.has(run.id)
|
||||
: nativeEventsAreInitiallyHydrating)
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
run.runtimeMode === "native" &&
|
||||
(nativeTranscriptByRun.get(run.id)?.length ?? 0) > 0
|
||||
)
|
||||
return false;
|
||||
return run.status !== "queued" && hydratedLogRunIds
|
||||
? !hydratedLogRunIds.has(run.id)
|
||||
: logsAreInitiallyHydrating;
|
||||
});
|
||||
// Durable messages are useful immediately. Tool history can fill in around
|
||||
// their stable anchors without concealing already-loaded replies. A thread
|
||||
// with only runtime output still waits for that output before showing empty.
|
||||
const historyPending = initialHistoryPending || (
|
||||
comments.length === 0 && !issueBrief?.description && (planLoading || transcriptHistoryPending)
|
||||
);
|
||||
const historyError =
|
||||
initialHistoryError ||
|
||||
planError ||
|
||||
@@ -2803,6 +2814,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const frame = requestAnimationFrame(() => setRevealedIssue(issueId));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [historyPending, historyRevealed, issueId]);
|
||||
useEffect(() => {
|
||||
if (!historyRevealed || !issueId) return;
|
||||
scheduleIssueDetailPaintMeasure(ISSUE_DETAIL_CONTENT_PAINT_MARK, ISSUE_DETAIL_CONTENT_MEASURE);
|
||||
}, [historyRevealed, issueId]);
|
||||
const retryHistory = () => {
|
||||
onRetryInitialHistory?.();
|
||||
retryLogs?.();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { activityApi } from "@/api/activity";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
/** These requests do not depend on the enriched issue detail response. Start
|
||||
* them on navigation, using the same route ref the thread will observe. */
|
||||
export function prefetchIssueThread(queryClient: QueryClient, issueRef: string) {
|
||||
return Promise.all([
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.issues.activity(issueRef),
|
||||
queryFn: () => activityApi.forIssue(issueRef),
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.issues.runs(issueRef),
|
||||
queryFn: () => activityApi.runsForIssue(issueRef),
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query";
|
||||
import type { Issue, IssueComment } from "@paperclipai/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { activityApi } from "@/api/activity";
|
||||
import { prefetchIssueComments, prefetchIssueDetailForNavigation } from "./issueDetailCache";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
@@ -12,6 +13,13 @@ vi.mock("@/api/issues", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/activity", () => ({
|
||||
activityApi: {
|
||||
forIssue: vi.fn(),
|
||||
runsForIssue: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function createComment(overrides: Partial<IssueComment> = {}): IssueComment {
|
||||
return {
|
||||
id: "comment-1",
|
||||
@@ -96,5 +104,9 @@ describe("prefetchIssueComments", () => {
|
||||
queryKeys.issues.comments(issue.identifier!),
|
||||
);
|
||||
expect(cachedComments?.pages).toEqual([firstPage]);
|
||||
// Hover/focus uses this helper too. Full history starts only once the task
|
||||
// page mounts, so moving across links does not fan out expensive reads.
|
||||
expect(activityApi.forIssue).not.toHaveBeenCalled();
|
||||
expect(activityApi.runsForIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1562,6 +1562,34 @@ describe("IssueDetail", () => {
|
||||
expect(windowOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["comments", "description", "empty"])("reveals %s without waiting for supporting history unless the thread is empty", async (content) => {
|
||||
const history = createDeferred<[]>();
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
description: content === "description" ? "Saved task description" : null,
|
||||
}));
|
||||
mockIssuesApi.listComments.mockResolvedValue(content === "comments" ? [createIssueComment()] : []);
|
||||
mockActivityApi.forIssue.mockReturnValue(history.promise);
|
||||
mockActivityApi.runsForIssue.mockReturnValue(history.promise);
|
||||
mockHeartbeatsApi.liveRunsForIssue.mockReturnValue(history.promise);
|
||||
mockIssuesApi.listInteractions.mockReturnValue(history.promise);
|
||||
mockIssuesApi.listAttachments.mockReturnValue(history.promise);
|
||||
mockIssuesApi.listWorkProducts.mockReturnValue(history.promise);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<QueryClientProvider client={queryClient}><IssueDetail /></QueryClientProvider>);
|
||||
});
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
initialHistoryPending: content === "empty",
|
||||
});
|
||||
});
|
||||
// Resolving metadata fills the same thread rather than replacing its content.
|
||||
history.resolve([]);
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ initialHistoryPending: false });
|
||||
});
|
||||
});
|
||||
|
||||
it("loads from the pending state into issue detail without changing hook order", async () => {
|
||||
const issueRequest = createDeferred<Issue>();
|
||||
mockIssuesApi.get.mockReturnValueOnce(issueRequest.promise);
|
||||
@@ -1574,6 +1602,11 @@ describe("IssueDetail", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// The task response may need slow workspace/recovery enrichment. The
|
||||
// thread requests must already be in flight while its skeleton is showing.
|
||||
expect(mockActivityApi.forIssue).toHaveBeenCalledWith("PAP-1");
|
||||
expect(mockActivityApi.runsForIssue).toHaveBeenCalledWith("PAP-1");
|
||||
|
||||
issueRequest.resolve(createIssue());
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
@@ -112,6 +112,7 @@ import {
|
||||
resolveIssueActiveRun,
|
||||
shouldTrackIssueActiveRun,
|
||||
} from "../lib/issueActiveRun";
|
||||
import { prefetchIssueThread } from "../lib/issue-thread-queries";
|
||||
import { getIssueDetailQueryOptions } from "../lib/issueDetailCache";
|
||||
import {
|
||||
beginIssueDetailNavigation,
|
||||
@@ -1181,6 +1182,7 @@ function InboxMobileToolbar({
|
||||
}
|
||||
|
||||
type IssueDetailChatTabProps = {
|
||||
queryIssueId?: string;
|
||||
browsers?: import("@paperclipai/shared").TaskBrowser[];
|
||||
onOpenBrowser?: (browserId: string) => void;
|
||||
onOpenSkill?: (skillId: string, name: string) => void;
|
||||
@@ -1220,6 +1222,7 @@ type IssueDetailChatTabProps = {
|
||||
comments: IssueDetailComment[];
|
||||
commentsInitialLoading?: boolean;
|
||||
initialHistoryPending?: boolean;
|
||||
initialMetadataPending?: boolean;
|
||||
initialHistoryError?: boolean;
|
||||
onRetryInitialHistory?: () => void;
|
||||
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
|
||||
@@ -1334,6 +1337,7 @@ type IssueDetailChatTabProps = {
|
||||
};
|
||||
|
||||
const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
queryIssueId,
|
||||
browsers,
|
||||
onOpenBrowser,
|
||||
onOpenSkill,
|
||||
@@ -1364,6 +1368,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
comments,
|
||||
commentsInitialLoading = false,
|
||||
initialHistoryPending = false,
|
||||
initialMetadataPending = false,
|
||||
initialHistoryError = false,
|
||||
onRetryInitialHistory,
|
||||
locallyQueuedCommentRunIds,
|
||||
@@ -1440,6 +1445,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
? IssueChatThread
|
||||
: TaskChatThread;
|
||||
const queryClient = useQueryClient();
|
||||
const issueQueryRef = queryIssueId ?? issueId;
|
||||
const scrollLocation = useLocation();
|
||||
const scrollNavigationType = useNavigationType();
|
||||
const { pushToast } = useToastActions();
|
||||
@@ -1449,10 +1455,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
isError: activityError,
|
||||
refetch: refetchActivity,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.issues.activity(issueId),
|
||||
queryFn: () => activityApi.forIssue(issueId),
|
||||
queryKey: queryKeys.issues.activity(issueQueryRef),
|
||||
queryFn: () => activityApi.forIssue(issueQueryRef),
|
||||
enabled: !!issueId,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<ActivityEvent[]>(issueId),
|
||||
placeholderData: keepPreviousDataForSameQueryTail<ActivityEvent[]>(issueQueryRef),
|
||||
});
|
||||
const {
|
||||
data: liveRuns,
|
||||
@@ -1460,12 +1466,12 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
isError: liveRunsError,
|
||||
refetch: refetchLiveRuns,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.issues.liveRuns(issueId),
|
||||
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
|
||||
queryKey: queryKeys.issues.liveRuns(issueQueryRef),
|
||||
queryFn: () => heartbeatsApi.liveRunsForIssue(issueQueryRef),
|
||||
enabled: !!issueId,
|
||||
refetchInterval: 1000,
|
||||
placeholderData:
|
||||
keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
|
||||
keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueQueryRef),
|
||||
});
|
||||
const resolvedLiveRuns = liveRuns ?? [];
|
||||
const liveRunCount = resolvedLiveRuns.length;
|
||||
@@ -1477,12 +1483,12 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
isError: activeRunError,
|
||||
refetch: refetchActiveRun,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.issues.activeRun(issueId),
|
||||
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
|
||||
queryKey: queryKeys.issues.activeRun(issueQueryRef),
|
||||
queryFn: () => heartbeatsApi.activeRunForIssue(issueQueryRef),
|
||||
enabled: activeRunQueryEnabled,
|
||||
refetchInterval: liveRunCount > 0 ? false : 1000,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(
|
||||
issueId,
|
||||
issueQueryRef,
|
||||
),
|
||||
});
|
||||
const resolvedActiveRun = useMemo(
|
||||
@@ -1547,12 +1553,12 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
isError: linkedRunsError,
|
||||
refetch: refetchLinkedRuns,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.issues.runs(issueId),
|
||||
queryFn: () => activityApi.runsForIssue(issueId),
|
||||
queryKey: queryKeys.issues.runs(issueQueryRef),
|
||||
queryFn: () => activityApi.runsForIssue(issueQueryRef),
|
||||
enabled: !!issueId,
|
||||
refetchInterval:
|
||||
hasLiveRuns || issueStatus === "in_progress" ? 1000 : false,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
|
||||
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueQueryRef),
|
||||
});
|
||||
const resolvedActivity = activity ?? [];
|
||||
const resolvedLinkedRuns = linkedRuns ?? [];
|
||||
@@ -1577,13 +1583,13 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.runs(issueId),
|
||||
queryKey: queryKeys.issues.runs(issueQueryRef),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.liveRuns(issueId),
|
||||
queryKey: queryKeys.issues.liveRuns(issueQueryRef),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.activeRun(issueId),
|
||||
queryKey: queryKeys.issues.activeRun(issueQueryRef),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -2171,7 +2177,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
queryKey: queryKeys.issues.comments(issueId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.activity(issueId),
|
||||
queryKey: queryKeys.issues.activity(issueQueryRef),
|
||||
}),
|
||||
]);
|
||||
storeQueuedCommentQueue(nextQueue);
|
||||
@@ -2193,6 +2199,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
effectiveQueuedCommentQueue?.queueId,
|
||||
effectiveQueuedCommentQueue?.targetRunId,
|
||||
issueId,
|
||||
issueQueryRef,
|
||||
queryClient,
|
||||
refreshQueueAfterConflict,
|
||||
storeQueuedCommentQueue,
|
||||
@@ -2333,9 +2340,15 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
initialHistoryPending={!!issueId && (
|
||||
initialHistoryPending ||
|
||||
commentsInitialLoading ||
|
||||
activityPending ||
|
||||
linkedRunsPending ||
|
||||
!runtimeSelectionKnown)
|
||||
// Saved replies and the description can render while supporting
|
||||
// history loads. Only an otherwise empty thread needs that data
|
||||
// before deciding whether it has anything to show.
|
||||
((classicTaskInterfaceEnabled || (commentsForThread.length === 0 && !issueBrief?.description)) && (
|
||||
initialMetadataPending ||
|
||||
activityPending ||
|
||||
linkedRunsPending ||
|
||||
!runtimeSelectionKnown
|
||||
)))
|
||||
}
|
||||
initialHistoryError={
|
||||
initialHistoryError ||
|
||||
@@ -3004,6 +3017,9 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
}),
|
||||
enabled: !!issueId,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (issueId) void prefetchIssueThread(queryClient, issueId);
|
||||
}, [issueId, queryClient]);
|
||||
const issue = queriedIssue ?? conversation?.issue ?? draftIssue;
|
||||
const resolveWritableIssueId = async () => {
|
||||
if (!conversation) return issueId!;
|
||||
@@ -3096,12 +3112,12 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
}, [issue?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issue || commentsLoading) return;
|
||||
if (!classicTaskInterfaceEnabled || !issue || commentsLoading) return;
|
||||
scheduleIssueDetailPaintMeasure(
|
||||
ISSUE_DETAIL_CONTENT_PAINT_MARK,
|
||||
ISSUE_DETAIL_CONTENT_MEASURE,
|
||||
);
|
||||
}, [commentsLoading, issue?.id]);
|
||||
}, [classicTaskInterfaceEnabled, commentsLoading, issue?.id]);
|
||||
const linkedCommentId = location.hash.startsWith("#comment-")
|
||||
? location.hash.slice("#comment-".length)
|
||||
: null;
|
||||
@@ -7785,6 +7801,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
onRetry: (actionId) => retryDispositionRecovery.mutateAsync(actionId).then(() => undefined),
|
||||
}}>
|
||||
<IssueDetailChatTab
|
||||
queryIssueId={issueId ?? issue.id}
|
||||
onOpenSkill={handleOpenSkill}
|
||||
browsers={browserQuery.data}
|
||||
onOpenBrowser={(id) => { setOpenBrowserId(id); if (isMobile) setMobilePropsOpen(true); else openTaskSidePanel(); }}
|
||||
@@ -7865,8 +7882,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
legacyRecoverySourceIssue={legacyRecoverySourceIssue}
|
||||
comments={threadComments}
|
||||
commentsInitialLoading={commentsLoading}
|
||||
initialHistoryPending={
|
||||
linkedCommentPending ||
|
||||
initialHistoryPending={linkedCommentPending}
|
||||
initialMetadataPending={
|
||||
interactionsLoading ||
|
||||
attachmentsLoading ||
|
||||
workProductsLoading
|
||||
|
||||
Reference in new issue
Block a user