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:
DottaandPaperclip authored and GitHub committed 2026-09-30 10:26:28 -05:00
1 parent a36cbffa9e
commit 0e5830887b
13 files changed
+421 -125

No files matched your search

+29 -1
View File
@@ -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
View File
@@ -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,
+24 -19
View File
@@ -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);
});
});
+36
View File
@@ -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();
}
});
+10 -1
View File
@@ -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")}
+50 -3
View File
@@ -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={[]}
+50 -35
View File
@@ -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?.();
+20
View File
@@ -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,
}),
]);
}
+12
View File
@@ -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();
});
});
+33
View File
@@ -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();
+40 -23
View File
@@ -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