diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index bab8dee0202..f40f0c830db 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -141,6 +141,16 @@ export type IssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number]; export const ISSUE_RELATION_TYPES = ["blocks"] as const; export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number]; +export const ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY = "continuation-summary" as const; +export const SYSTEM_ISSUE_DOCUMENT_KEYS = [ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY] as const; +export type SystemIssueDocumentKey = (typeof SYSTEM_ISSUE_DOCUMENT_KEYS)[number]; + +const SYSTEM_ISSUE_DOCUMENT_KEY_SET = new Set(SYSTEM_ISSUE_DOCUMENT_KEYS); + +export function isSystemIssueDocumentKey(key: string): key is SystemIssueDocumentKey { + return SYSTEM_ISSUE_DOCUMENT_KEY_SET.has(key); +} + export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const; export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 78a5fc40112..f4f84ab6589 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,6 +16,9 @@ export { ISSUE_PRIORITIES, ISSUE_ORIGIN_KINDS, ISSUE_RELATION_TYPES, + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + SYSTEM_ISSUE_DOCUMENT_KEYS, + isSystemIssueDocumentKey, ISSUE_EXECUTION_POLICY_MODES, ISSUE_EXECUTION_STAGE_TYPES, ISSUE_EXECUTION_STATE_STATUSES, @@ -94,6 +97,7 @@ export { type IssuePriority, type IssueOriginKind, type IssueRelationType, + type SystemIssueDocumentKey, type IssueExecutionPolicyMode, type IssueExecutionStageType, type IssueExecutionStateStatus, diff --git a/server/src/__tests__/documents-service.test.ts b/server/src/__tests__/documents-service.test.ts new file mode 100644 index 00000000000..12658845719 --- /dev/null +++ b/server/src/__tests__/documents-service.test.ts @@ -0,0 +1,115 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + companies, + createDb, + documentRevisions, + documents, + issueDocuments, + issues, +} from "@paperclipai/db"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { documentService } from "../services/documents.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres document service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("documentService system issue documents", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-documents-service-"); + db = createDb(tempDb.connectionString); + svc = documentService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(documentRevisions); + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(issues); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function createIssueWithDocuments() { + const companyId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + identifier: "PAP-1600", + title: "System document filtering", + description: "Validate document filtering", + status: "in_progress", + priority: "medium", + }); + + await svc.upsertIssueDocument({ + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "# Plan", + }); + await svc.upsertIssueDocument({ + issueId, + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + title: "Continuation Summary", + format: "markdown", + body: "# Handoff", + }); + + return { issueId }; + } + + it("filters continuation summaries from default document lists and issue payload summaries", async () => { + const { issueId } = await createIssueWithDocuments(); + + const defaultDocuments = await svc.listIssueDocuments(issueId); + expect(defaultDocuments.map((doc) => doc.key)).toEqual(["plan"]); + + const payload = await svc.getIssueDocumentPayload({ id: issueId, description: null }); + expect(payload.planDocument?.key).toBe("plan"); + expect(payload.documentSummaries.map((doc) => doc.key)).toEqual(["plan"]); + }); + + it("keeps system documents available for includeSystem and direct fetch callers", async () => { + const { issueId } = await createIssueWithDocuments(); + + const debugDocuments = await svc.listIssueDocuments(issueId, { includeSystem: true }); + expect(debugDocuments.map((doc) => doc.key)).toEqual([ + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + "plan", + ]); + + const directHandoff = await svc.getIssueDocumentByKey(issueId, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY); + expect(directHandoff).toEqual(expect.objectContaining({ + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + body: "# Handoff", + })); + }); +}); diff --git a/server/src/__tests__/issue-document-restore-routes.test.ts b/server/src/__tests__/issue-document-restore-routes.test.ts index 048cc974fb8..a0caa578b3a 100644 --- a/server/src/__tests__/issue-document-restore-routes.test.ts +++ b/server/src/__tests__/issue-document-restore-routes.test.ts @@ -10,6 +10,7 @@ const mockIssueService = vi.hoisted(() => ({ })); const mockDocumentsService = vi.hoisted(() => ({ + listIssueDocuments: vi.fn(), listIssueDocumentRevisions: vi.fn(), restoreIssueDocumentRevision: vi.fn(), })); @@ -114,6 +115,25 @@ describe("issue document revision routes", () => { title: "Document revisions", status: "in_progress", }); + mockDocumentsService.listIssueDocuments.mockResolvedValue([ + { + id: "document-1", + companyId, + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "# Plan", + latestRevisionId: "revision-2", + latestRevisionNumber: 2, + createdByAgentId: null, + createdByUserId: "board-user", + updatedByAgentId: null, + updatedByUserId: "board-user", + createdAt: new Date("2026-03-26T12:00:00.000Z"), + updatedAt: new Date("2026-03-26T12:10:00.000Z"), + }, + ]); mockDocumentsService.listIssueDocumentRevisions.mockResolvedValue([ { id: "revision-2", @@ -169,6 +189,20 @@ describe("issue document revision routes", () => { ]); }); + it("filters system documents by default on the document list route", async () => { + const res = await request(await createApp()).get(`/api/issues/${issueId}/documents`); + + expect(res.status).toBe(200); + expect(mockDocumentsService.listIssueDocuments).toHaveBeenCalledWith(issueId, { includeSystem: false }); + expect(res.body).toEqual([expect.objectContaining({ key: "plan" })]); + }); + + it("passes includeSystem=true through for debug document listing", async () => { + await request(await createApp()).get(`/api/issues/${issueId}/documents?includeSystem=true`); + + expect(mockDocumentsService.listIssueDocuments).toHaveBeenCalledWith(issueId, { includeSystem: true }); + }); + it("restores a revision through the append-only route and logs the action", async () => { const res = await request(await createApp()) .post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`) diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index 63acabfe309..d20f5916ea8 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -22,6 +22,11 @@ const mockGoalService = vi.hoisted(() => ({ getDefaultCompanyGoal: vi.fn(), })); +const mockDocumentsService = vi.hoisted(() => ({ + getIssueDocumentPayload: vi.fn(), + getIssueDocumentByKey: vi.fn(), +})); + vi.mock("../services/index.js", () => ({ accessService: () => ({ canUser: vi.fn(), @@ -30,10 +35,7 @@ vi.mock("../services/index.js", () => ({ agentService: () => ({ getById: vi.fn(), }), - documentService: () => ({ - getIssueDocumentPayload: vi.fn(async () => ({})), - getIssueDocumentByKey: vi.fn(async () => null), - }), + documentService: () => mockDocumentsService, executionWorkspaceService: () => ({ getById: vi.fn(), }), @@ -140,6 +142,8 @@ describe("issue goal context routes", () => { }); mockIssueService.getComment.mockResolvedValue(null); mockIssueService.listAttachments.mockResolvedValue([]); + mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({}); + mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null); mockProjectService.getById.mockResolvedValue({ id: legacyProjectLinkedIssue.projectId, companyId: "company-1", @@ -215,6 +219,31 @@ describe("issue goal context routes", () => { expect(res.body.attachments).toEqual([]); }); + it("preserves direct continuation summary lookup in GET /issues/:id/heartbeat-context", async () => { + mockDocumentsService.getIssueDocumentByKey.mockResolvedValue({ + key: "continuation-summary", + title: "Continuation Summary", + body: "# Handoff", + latestRevisionId: "revision-1", + latestRevisionNumber: 1, + updatedAt: new Date("2026-04-19T12:00:00.000Z"), + }); + + const res = await request(await createApp()).get( + "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", + ); + + expect(res.status).toBe(200); + expect(mockDocumentsService.getIssueDocumentByKey).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + "continuation-summary", + ); + expect(res.body.continuationSummary).toEqual(expect.objectContaining({ + key: "continuation-summary", + body: "# Handoff", + })); + }); + it("surfaces blocker summaries on GET /issues/:id/heartbeat-context", async () => { mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 07981ee729b..5c5067cbb6c 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -18,6 +18,7 @@ import { upsertIssueFeedbackVoteSchema, linkIssueApprovalSchema, issueDocumentKeySchema, + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, restoreIssueDocumentRevisionSchema, updateIssueWorkProductSchema, upsertIssueDocumentSchema, @@ -778,7 +779,7 @@ export function issueRoutes( wakeCommentId ? svc.getComment(wakeCommentId) : null, svc.getRelationSummaries(issue.id), svc.listAttachments(issue.id), - documentsSvc.getIssueDocumentByKey(issue.id, "continuation-summary"), + documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY), ]); res.json({ @@ -868,7 +869,9 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); - const docs = await documentsSvc.listIssueDocuments(issue.id); + const docs = await documentsSvc.listIssueDocuments(issue.id, { + includeSystem: req.query.includeSystem === "true", + }); res.json(docs); }); diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index 1a56af27d1c..5c5ee74761a 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -1,7 +1,7 @@ import { and, asc, desc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { documentRevisions, documents, issueDocuments, issues } from "@paperclipai/db"; -import { issueDocumentKeySchema } from "@paperclipai/shared"; +import { isSystemIssueDocumentKey, issueDocumentKeySchema } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; function normalizeDocumentKey(key: string) { @@ -83,8 +83,14 @@ const issueDocumentSelect = { }; export function documentService(db: Db) { + const filterSystemDocuments = (rows: T[], includeSystem: boolean) => + includeSystem ? rows : rows.filter((row) => !isSystemIssueDocumentKey(row.key)); + return { - getIssueDocumentPayload: async (issue: { id: string; description: string | null }) => { + getIssueDocumentPayload: async ( + issue: { id: string; description: string | null }, + options: { includeSystem?: boolean } = {}, + ) => { const [planDocument, documentSummaries] = await Promise.all([ db .select(issueDocumentSelect) @@ -104,7 +110,8 @@ export function documentService(db: Db) { return { planDocument: planDocument ? mapIssueDocumentRow(planDocument, true) : null, - documentSummaries: documentSummaries.map((row) => mapIssueDocumentRow(row, false)), + documentSummaries: filterSystemDocuments(documentSummaries, options.includeSystem ?? false) + .map((row) => mapIssueDocumentRow(row, false)), legacyPlanDocument: legacyPlanBody ? { key: "plan" as const, @@ -115,14 +122,14 @@ export function documentService(db: Db) { }; }, - listIssueDocuments: async (issueId: string) => { + listIssueDocuments: async (issueId: string, options: { includeSystem?: boolean } = {}) => { const rows = await db .select(issueDocumentSelect) .from(issueDocuments) .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) .where(eq(issueDocuments.issueId, issueId)) .orderBy(asc(issueDocuments.key), desc(documents.updatedAt)); - return rows.map((row) => mapIssueDocumentRow(row, true)); + return filterSystemDocuments(rows, options.includeSystem ?? false).map((row) => mapIssueDocumentRow(row, true)); }, getIssueDocumentByKey: async (issueId: string, rawKey: string) => { diff --git a/server/src/services/issue-continuation-summary.ts b/server/src/services/issue-continuation-summary.ts index 9f962d7fa22..422e3a5b15e 100644 --- a/server/src/services/issue-continuation-summary.ts +++ b/server/src/services/issue-continuation-summary.ts @@ -1,9 +1,10 @@ import { and, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { documents, issueDocuments, issues } from "@paperclipai/db"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; import { documentService } from "./documents.js"; -export const ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY = "continuation-summary"; +export { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY }; export const ISSUE_CONTINUATION_SUMMARY_TITLE = "Continuation Summary"; export const ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS = 8_000; const SUMMARY_SECTION_MAX_CHARS = 1_200; diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index e0d8ec05d0d..7fbaab0e35c 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -130,7 +130,10 @@ export const issuesApi = { ), cancelComment: (id: string, commentId: string) => api.delete(`/issues/${id}/comments/${commentId}`), - listDocuments: (id: string) => api.get(`/issues/${id}/documents`), + listDocuments: (id: string, options?: { includeSystem?: boolean }) => + api.get( + `/issues/${id}/documents${options?.includeSystem ? "?includeSystem=true" : ""}`, + ), getDocument: (id: string, key: string) => api.get(`/issues/${id}/documents/${encodeURIComponent(key)}`), upsertDocument: (id: string, key: string, data: UpsertIssueDocument) => api.put(`/issues/${id}/documents/${encodeURIComponent(key)}`, data), diff --git a/ui/src/components/IssueContinuationHandoff.test.tsx b/ui/src/components/IssueContinuationHandoff.test.tsx new file mode 100644 index 00000000000..80e7a3d5160 --- /dev/null +++ b/ui/src/components/IssueContinuationHandoff.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ComponentProps } from "react"; +import { createRoot } from "react-dom/client"; +import type { IssueDocument } from "@paperclipai/shared"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IssueContinuationHandoff } from "./IssueContinuationHandoff"; + +vi.mock("./MarkdownBody", () => ({ + MarkdownBody: ({ children, className }: { children: string; className?: string }) => ( +
{children}
+ ), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, onClick, type = "button", ...props }: ComponentProps<"button">) => ( + + ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createHandoffDocument(): IssueDocument { + return { + id: "document-handoff", + companyId: "company-1", + issueId: "issue-1", + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + title: "Continuation Summary", + format: "markdown", + body: "# Handoff\n\nResume from the activity tab.", + latestRevisionId: "revision-1", + latestRevisionNumber: 1, + createdByAgentId: "agent-1", + createdByUserId: null, + updatedByAgentId: "agent-1", + updatedByUserId: null, + createdAt: new Date("2026-04-19T12:00:00.000Z"), + updatedAt: new Date("2026-04-19T12:05:00.000Z"), + }; +} + +describe("IssueContinuationHandoff", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: vi.fn(async () => undefined) }, + }); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders compact metadata by default with copy access", async () => { + const root = createRoot(container); + const handoff = createHandoffDocument(); + + await act(async () => { + root.render(); + }); + + expect(container.textContent).toContain("Continuation Summary"); + expect(container.textContent).toContain("handoff"); + expect(container.textContent).not.toContain("Resume from the activity tab."); + + const copyButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Copy")); + expect(copyButton).toBeTruthy(); + + await act(async () => { + copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(handoff.body); + expect(container.textContent).toContain("Copied"); + + await act(async () => { + root.unmount(); + }); + }); + + it("expands and anchors the handoff body when focused from a document deep link", async () => { + const root = createRoot(container); + const scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + + await act(async () => { + root.render(); + }); + + expect(container.querySelector(`#document-${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`)).toBeTruthy(); + expect(container.textContent).toContain("Resume from the activity tab."); + expect(scrollIntoView).toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/IssueContinuationHandoff.tsx b/ui/src/components/IssueContinuationHandoff.tsx new file mode 100644 index 00000000000..a1830a78c14 --- /dev/null +++ b/ui/src/components/IssueContinuationHandoff.tsx @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { IssueDocument } from "@paperclipai/shared"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { cn, relativeTime } from "../lib/utils"; +import { MarkdownBody } from "./MarkdownBody"; +import { Check, ChevronDown, ChevronRight, Copy, History } from "lucide-react"; + +type IssueContinuationHandoffProps = { + document: IssueDocument | null | undefined; + focusSignal?: number; +}; + +export function IssueContinuationHandoff({ + document, + focusSignal = 0, +}: IssueContinuationHandoffProps) { + const [expanded, setExpanded] = useState(false); + const [copied, setCopied] = useState(false); + const [highlighted, setHighlighted] = useState(false); + const rootRef = useRef(null); + const copiedTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (copiedTimerRef.current) { + clearTimeout(copiedTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (!document || focusSignal <= 0) return; + setExpanded(true); + setHighlighted(true); + rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + const timer = setTimeout(() => setHighlighted(false), 3000); + return () => clearTimeout(timer); + }, [document, focusSignal]); + + const copyBody = useCallback(async () => { + if (!document) return; + await navigator.clipboard?.writeText(document.body); + setCopied(true); + if (copiedTimerRef.current) { + clearTimeout(copiedTimerRef.current); + } + copiedTimerRef.current = setTimeout(() => setCopied(false), 1500); + }, [document]); + + if (!document) return null; + + const title = document.title?.trim() || "Continuation handoff"; + + return ( +
+
+ + +
+
+ {title} + + handoff + +
+
+ Updated {relativeTime(document.updatedAt)} + {document.latestRevisionNumber > 0 ? ` - revision ${document.latestRevisionNumber}` : ""} +
+
+ +
+ {expanded ? ( +
+ + {document.body} + +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index 9f04df9f433..5b6c4bb107e 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -5,6 +5,7 @@ import type { ComponentProps } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { DocumentRevision, Issue, IssueDocument } from "@paperclipai/shared"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssueDocumentsSection } from "./IssueDocumentsSection"; import { queryKeys } from "../lib/queryKeys"; @@ -260,6 +261,50 @@ describe("IssueDocumentsSection", () => { container.remove(); }); + it("keeps system handoff documents out of the normal document surface", async () => { + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + mockIssuesApi.listDocuments.mockResolvedValue([ + createIssueDocument({ key: "plan", body: "# Plan" }), + createIssueDocument({ + id: "document-handoff", + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + title: "Continuation Summary", + body: "# Handoff", + }), + ]); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + expect(container.textContent).toContain("# Plan"); + expect(container.textContent).not.toContain("# Handoff"); + expect(container.querySelector(`#document-${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`)).toBeNull(); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); + it("shows the restored document body immediately after a revision restore", async () => { const blankLatestDocument = createIssueDocument({ body: "", diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 0acbca9a6a7..0bb66002634 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -8,6 +8,7 @@ import type { Issue, IssueDocument, } from "@paperclipai/shared"; +import { isSystemIssueDocumentKey } from "@paperclipai/shared"; import { useLocation } from "@/lib/router"; import { ApiError } from "../api/client"; import { issuesApi } from "../api/issues"; @@ -204,6 +205,7 @@ export function IssueDocumentsSection({ }, [issue.id, queryClient]); const syncDocumentCaches = useCallback((document: IssueDocument) => { + if (isSystemIssueDocumentKey(document.key)) return; queryClient.setQueryData( queryKeys.issues.documents(issue.id), (current) => { @@ -273,7 +275,7 @@ export function IssueDocumentsSection({ }); const sortedDocuments = useMemo(() => { - return [...(documents ?? [])].sort((a, b) => { + return (documents ?? []).filter((doc) => !isSystemIssueDocumentKey(doc.key)).sort((a, b) => { if (a.key === "plan" && b.key !== "plan") return -1; if (a.key !== "plan" && b.key === "plan") return 1; return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 57c4abb5225..1d83563cdd7 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -48,6 +48,7 @@ export const queryKeys = { feedbackVotes: (issueId: string) => ["issues", "feedback-votes", issueId] as const, attachments: (issueId: string) => ["issues", "attachments", issueId] as const, documents: (issueId: string) => ["issues", "documents", issueId] as const, + document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const, documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const, activity: (issueId: string) => ["issues", "activity", issueId] as const, runs: (issueId: string) => ["issues", "runs", issueId] as const, diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 6648375c15b..dd00e43f8e8 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEve import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router"; import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query"; +import { ApiError } from "../api/client"; import { issuesApi } from "../api/issues"; import { approvalsApi } from "../api/approvals"; import { activityApi, type RunForIssue } from "../api/activity"; @@ -59,6 +60,7 @@ import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils" import { ApprovalCard } from "../components/ApprovalCard"; import { InlineEditor } from "../components/InlineEditor"; import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread"; +import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssuesList } from "../components/IssuesList"; import { IssueProperties } from "../components/IssueProperties"; @@ -104,6 +106,7 @@ import { import { getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, type ActivityEvent, type Agent, type FeedbackVote, @@ -731,6 +734,7 @@ type IssueDetailActivityTabProps = { userProfileMap: Map; pendingApprovalAction: { approvalId: string; action: "approve" | "reject" } | null; onApprovalAction: (approvalId: string, action: "approve" | "reject") => void; + handoffFocusSignal?: number; }; function IssueDetailActivityTab({ @@ -743,6 +747,7 @@ function IssueDetailActivityTab({ userProfileMap, pendingApprovalAction, onApprovalAction, + handoffFocusSignal = 0, }: IssueDetailActivityTabProps) { const { data: activity, isLoading: activityLoading } = useQuery({ queryKey: queryKeys.issues.activity(issueId), @@ -759,6 +764,21 @@ function IssueDetailActivityTab({ queryFn: () => issuesApi.listApprovals(issueId), placeholderData: keepPreviousDataForSameQueryTail>>(issueId), }); + const { data: continuationHandoff } = useQuery({ + queryKey: queryKeys.issues.document(issueId, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY), + queryFn: async () => { + try { + return await issuesApi.getDocument(issueId, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY); + } catch (error) { + if (error instanceof ApiError && error.status === 404) return null; + throw error; + } + }, + retry: false, + placeholderData: keepPreviousDataForSameQueryTail> | null>( + issueId, + ), + }); const initialLoading = (activityLoading && activity === undefined) || (linkedRunsLoading && linkedRuns === undefined); @@ -816,6 +836,7 @@ function IssueDetailActivityTab({ hasLiveRuns={hasLiveRuns} /> + {linkedApprovals && linkedApprovals.length > 0 && (
{linkedApprovals.map((approval) => ( @@ -893,6 +914,7 @@ export function IssueDetail() { const [copied, setCopied] = useState(false); const [mobilePropsOpen, setMobilePropsOpen] = useState(false); const [detailTab, setDetailTab] = useState("chat"); + const [handoffFocusSignal, setHandoffFocusSignal] = useState(0); const [pendingApprovalAction, setPendingApprovalAction] = useState<{ approvalId: string; action: "approve" | "reject"; @@ -1976,6 +1998,15 @@ export function IssueDetail() { }; }, [keyboardShortcutsEnabled, navigate, sourceBreadcrumb.href]); + useEffect(() => { + const hash = location.hash; + if (!hash.startsWith("#document-")) return; + const documentKey = decodeURIComponent(hash.slice("#document-".length)); + if (documentKey !== ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY) return; + setDetailTab("activity"); + setHandoffFocusSignal((current) => current + 1); + }, [location.hash]); + useEffect(() => { if (pendingCommentComposerFocusKey === 0) return; if (detailTab !== "chat") return; @@ -2684,6 +2715,7 @@ export function IssueDetail() { currentUserId={currentUserId} userProfileMap={userProfileMap} pendingApprovalAction={pendingApprovalAction} + handoffFocusSignal={handoffFocusSignal} onApprovalAction={(approvalId, action) => { approvalDecision.mutate({ approvalId, action }); }}