Hide continuation summary from normal documents

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-19 09:17:58 -05:00
1 parent a138baee52
commit 3e3643355a
15 files changed
+508 -14

No files matched your search

+10
View File
@@ -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];
+4
View File
@@ -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: [
+5 -2
View File
@@ -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);
});
+12 -5
View File
@@ -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;
+4 -1
View File
@@ -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: "",
+3 -1
View File
@@ -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();
+1
View File
@@ -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,
+32
View File
@@ -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 });
}}