mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Hide continuation summary from normal documents
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a138baee52
commit
3e3643355a
15 files changed
+508
-14
No files matched your search
@@ -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<string>(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];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof createDb>;
|
||||
let svc!: ReturnType<typeof documentService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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",
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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`)
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = <T extends { key: string }>(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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -130,7 +130,10 @@ export const issuesApi = {
|
||||
),
|
||||
cancelComment: (id: string, commentId: string) =>
|
||||
api.delete<IssueComment>(`/issues/${id}/comments/${commentId}`),
|
||||
listDocuments: (id: string) => api.get<IssueDocument[]>(`/issues/${id}/documents`),
|
||||
listDocuments: (id: string, options?: { includeSystem?: boolean }) =>
|
||||
api.get<IssueDocument[]>(
|
||||
`/issues/${id}/documents${options?.includeSystem ? "?includeSystem=true" : ""}`,
|
||||
),
|
||||
getDocument: (id: string, key: string) => api.get<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
||||
upsertDocument: (id: string, key: string, data: UpsertIssueDocument) =>
|
||||
api.put<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`, data),
|
||||
|
||||
@@ -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 }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children, onClick, type = "button", ...props }: ComponentProps<"button">) => (
|
||||
<button type={type} onClick={onClick} {...props}>{children}</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(<IssueContinuationHandoff document={handoff} />);
|
||||
});
|
||||
|
||||
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(<IssueContinuationHandoff document={createHandoffDocument()} focusSignal={1} />);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
id={`document-${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`}
|
||||
className={cn(
|
||||
"mb-3 rounded-lg border border-border bg-accent/20 p-3 transition-colors duration-1000",
|
||||
highlighted && "border-primary/50 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
aria-label={expanded ? "Collapse continuation handoff" : "Expand continuation handoff"}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<History className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase text-muted-foreground">
|
||||
handoff
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Updated {relativeTime(document.updatedAt)}
|
||||
{document.latestRevisionNumber > 0 ? ` - revision ${document.latestRevisionNumber}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={copyBody} className="shrink-0">
|
||||
{copied ? <Check className="mr-1.5 h-3.5 w-3.5" /> : <Copy className="mr-1.5 h-3.5 w-3.5" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="mt-3 rounded-md border border-border bg-background/80 p-3">
|
||||
<MarkdownBody className="paperclip-edit-in-place-content text-sm leading-6" softBreaks={false}>
|
||||
{document.body}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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: "",
|
||||
|
||||
@@ -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<IssueDocument[] | undefined>(
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, import("../lib/company-members").CompanyUserProfile>;
|
||||
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<Awaited<ReturnType<typeof issuesApi.listApprovals>>>(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<Awaited<ReturnType<typeof issuesApi.getDocument>> | null>(
|
||||
issueId,
|
||||
),
|
||||
});
|
||||
const initialLoading =
|
||||
(activityLoading && activity === undefined)
|
||||
|| (linkedRunsLoading && linkedRuns === undefined);
|
||||
@@ -816,6 +836,7 @@ function IssueDetailActivityTab({
|
||||
hasLiveRuns={hasLiveRuns}
|
||||
/>
|
||||
</div>
|
||||
<IssueContinuationHandoff document={continuationHandoff} focusSignal={handoffFocusSignal} />
|
||||
{linkedApprovals && linkedApprovals.length > 0 && (
|
||||
<div className="mb-3 space-y-3">
|
||||
{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 });
|
||||
}}
|
||||
|
||||
Reference in new issue
Block a user