diff --git a/doc/observability.md b/doc/observability.md index f859149d14..6af36505f4 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -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_` 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.` 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 diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 005b4c6f79..087928d900 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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, diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index 2a6bbc5e34..5d2b3ebdba 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -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(); - 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(); + for (const row of exhaustionRows) { + if (!row.message || retryExhaustedReasonByRunId.has(row.runId)) continue; + retryExhaustedReasonByRunId.set(row.runId, row.message); + } + const leaseByRunId = new Map(); 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; diff --git a/server/src/services/issue-read-timing.test.ts b/server/src/services/issue-read-timing.test.ts new file mode 100644 index 0000000000..24c08cb587 --- /dev/null +++ b/server/src/services/issue-read-timing.test.ts @@ -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((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); + }); +}); diff --git a/server/src/services/issue-read-timing.ts b/server/src/services/issue-read-timing.ts new file mode 100644 index 0000000000..332bf3d97f --- /dev/null +++ b/server/src/services/issue-read-timing.ts @@ -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(); + return { + time(phase: IssueReadPhase, read: () => Promise): Promise { + 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(", "); + }, + }; +} diff --git a/ui/src/components/MarkdownBody.performance.test.tsx b/ui/src/components/MarkdownBody.performance.test.tsx new file mode 100644 index 0000000000..6ef1b338f0 --- /dev/null +++ b/ui/src/components/MarkdownBody.performance.test.tsx @@ -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 }) => , + 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( + + {"See PAP-72 and PAP-73 for background."} + , + )); + expect(getIssue).not.toHaveBeenCalled(); + const link = container.querySelector('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(); + } +}); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 24b9772854..d78f7644c2 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -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({ 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")} diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 4e457820dc..2adadd5a2a 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -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(); - 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(); + expect(transcriptHookRuns.legacy.at(-1)).toMatchObject([{ id: "native-run", status }]); + + render(); + 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(); + 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(); + expect(transcriptHookRuns.legacy.at(-1)).toEqual([]); + + nativeTranscriptState.transcriptByRun.clear(); + render(); + expect(transcriptHookRuns.legacy.at(-1)).toMatchObject([{ id: "native-run" }]); + + nativeTranscriptState.hydratedRunIds = new Set(); + nativeTranscriptState.errorsByRun.set("native-run", { message: "Unavailable" }); + render(); + 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( 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>>(), ); @@ -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?.(); diff --git a/ui/src/lib/issue-thread-queries.ts b/ui/src/lib/issue-thread-queries.ts new file mode 100644 index 0000000000..4191335df4 --- /dev/null +++ b/ui/src/lib/issue-thread-queries.ts @@ -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, + }), + ]); +} diff --git a/ui/src/lib/prefetchIssueComments.test.ts b/ui/src/lib/prefetchIssueComments.test.ts index 4aeebd558c..3588b75f10 100644 --- a/ui/src/lib/prefetchIssueComments.test.ts +++ b/ui/src/lib/prefetchIssueComments.test.ts @@ -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 { 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(); }); }); diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 70b4d86ad5..b785be4437 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -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(); + }); + 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(); 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(); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 848ef5dfcb..672a89738c 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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; @@ -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(issueId), + placeholderData: keepPreviousDataForSameQueryTail(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(issueId), + keepPreviousDataForSameQueryTail(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( - 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(issueId), + placeholderData: keepPreviousDataForSameQueryTail(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), }}> { 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