feat(control-plane): add annotation and workspace controls

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-17 14:21:24 +00:00
1 parent 1b89a8e3b8
commit 5794bc90f7
41 files changed
+3488 -435

No files matched your search

@@ -0,0 +1,41 @@
ALTER TABLE "workspace_operations" ADD COLUMN IF NOT EXISTS "issue_id" uuid;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'workspace_operations_issue_id_issues_id_fk'
) THEN
ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
WITH "run_issue_candidates" AS (
SELECT
"heartbeat_runs"."id" AS "run_id",
"heartbeat_runs"."company_id" AS "company_id",
CASE
WHEN "heartbeat_runs"."context_snapshot" ? 'issueId'
AND "heartbeat_runs"."context_snapshot"->>'issueId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
THEN ("heartbeat_runs"."context_snapshot"->>'issueId')::uuid
ELSE NULL
END AS "issue_id"
FROM "heartbeat_runs"
),
"run_issue_attribution" AS (
SELECT
"run_issue_candidates"."run_id",
"issues"."id" AS "issue_id"
FROM "run_issue_candidates"
INNER JOIN "issues"
ON "issues"."id" = "run_issue_candidates"."issue_id"
AND "issues"."company_id" = "run_issue_candidates"."company_id"
WHERE "run_issue_candidates"."issue_id" IS NOT NULL
)
UPDATE "workspace_operations"
SET
"issue_id" = "run_issue_attribution"."issue_id",
"updated_at" = now()
FROM "run_issue_attribution"
WHERE "workspace_operations"."heartbeat_run_id" = "run_issue_attribution"."run_id"
AND "workspace_operations"."issue_id" IS NULL;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "workspace_operations_company_workspace_issue_started_idx" ON "workspace_operations" USING btree ("company_id","execution_workspace_id","issue_id","started_at");
@@ -0,0 +1,154 @@
CREATE TABLE IF NOT EXISTS "routine_documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"routine_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"key" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'routine_documents_company_id_companies_id_fk'
) THEN
ALTER TABLE "routine_documents" ADD CONSTRAINT "routine_documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'routine_documents_routine_id_routines_id_fk'
) THEN
ALTER TABLE "routine_documents" ADD CONSTRAINT "routine_documents_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'routine_documents_document_id_documents_id_fk'
) THEN
ALTER TABLE "routine_documents" ADD CONSTRAINT "routine_documents_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "routine_documents_company_routine_key_uq" ON "routine_documents" USING btree ("company_id","routine_id","key");
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "routine_documents_document_uq" ON "routine_documents" USING btree ("document_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "routine_documents_company_routine_updated_idx" ON "routine_documents" USING btree ("company_id","routine_id","updated_at");
--> statement-breakpoint
ALTER TABLE "document_annotation_threads" ADD COLUMN IF NOT EXISTS "routine_id" uuid;
--> statement-breakpoint
ALTER TABLE "document_annotation_comments" ADD COLUMN IF NOT EXISTS "routine_id" uuid;
--> statement-breakpoint
ALTER TABLE "document_annotation_threads" ALTER COLUMN "issue_id" DROP NOT NULL;
--> statement-breakpoint
ALTER TABLE "document_annotation_comments" ALTER COLUMN "issue_id" DROP NOT NULL;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'document_annotation_threads_routine_id_routines_id_fk'
) THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'document_annotation_comments_routine_id_routines_id_fk'
) THEN
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_routine_status_idx" ON "document_annotation_threads" USING btree ("company_id","routine_id","status");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_routine_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","routine_id","created_at");
--> statement-breakpoint
DO $$
DECLARE
routine_row RECORD;
created_document_id uuid;
created_revision_id uuid;
BEGIN
FOR routine_row IN
SELECT *
FROM "routines"
WHERE NOT EXISTS (
SELECT 1
FROM "routine_documents"
WHERE "routine_documents"."routine_id" = "routines"."id"
AND "routine_documents"."key" = 'description'
)
LOOP
INSERT INTO "documents" (
"company_id",
"title",
"format",
"latest_body",
"latest_revision_number",
"created_by_agent_id",
"created_by_user_id",
"updated_by_agent_id",
"updated_by_user_id",
"created_at",
"updated_at"
)
VALUES (
routine_row."company_id",
'routine description',
'markdown',
coalesce(routine_row."description", ''),
1,
routine_row."created_by_agent_id",
routine_row."created_by_user_id",
routine_row."updated_by_agent_id",
routine_row."updated_by_user_id",
coalesce(routine_row."created_at", now()),
coalesce(routine_row."updated_at", now())
)
RETURNING "id" INTO created_document_id;
INSERT INTO "document_revisions" (
"company_id",
"document_id",
"revision_number",
"title",
"format",
"body",
"change_summary",
"created_by_agent_id",
"created_by_user_id",
"created_at"
)
VALUES (
routine_row."company_id",
created_document_id,
1,
'routine description',
'markdown',
coalesce(routine_row."description", ''),
'Backfilled routine description',
routine_row."created_by_agent_id",
routine_row."created_by_user_id",
coalesce(routine_row."created_at", now())
)
RETURNING "id" INTO created_revision_id;
UPDATE "documents"
SET "latest_revision_id" = created_revision_id
WHERE "id" = created_document_id;
INSERT INTO "routine_documents" ("company_id", "routine_id", "document_id", "key", "created_at", "updated_at")
VALUES (
routine_row."company_id",
routine_row."id",
created_document_id,
'description',
coalesce(routine_row."created_at", now()),
coalesce(routine_row."updated_at", now())
)
ON CONFLICT DO NOTHING;
END LOOP;
END $$;
+15 -1
View File
@@ -715,6 +715,20 @@
"when": 1781490000000,
"tag": "0101_plugin_company_id_tenant_isolation",
"breakpoints": true
},
{
"idx": 102,
"version": "7",
"when": 1781490000001,
"tag": "0102_workspace_operations_issue_id",
"breakpoints": true
},
{
"idx": 103,
"version": "7",
"when": 1781490000002,
"tag": "0103_routine_description_annotations",
"breakpoints": true
}
]
}
}
@@ -7,6 +7,7 @@ import { documents } from "./documents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issueComments } from "./issue_comments.js";
import { issues } from "./issues.js";
import { routines } from "./routines.js";
export const documentAnnotationComments = pgTable(
"document_annotation_comments",
@@ -14,7 +15,8 @@ export const documentAnnotationComments = pgTable(
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
threadId: uuid("thread_id").notNull().references(() => documentAnnotationThreads.id, { onDelete: "cascade" }),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "cascade" }),
routineId: uuid("routine_id").references(() => routines.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
body: text("body").notNull(),
authorType: text("author_type").$type<IssueCommentAuthorType>().notNull(),
@@ -36,6 +38,11 @@ export const documentAnnotationComments = pgTable(
table.issueId,
table.createdAt,
),
companyRoutineCreatedAtIdx: index("document_annotation_comments_company_routine_created_at_idx").on(
table.companyId,
table.routineId,
table.createdAt,
),
companyDocumentCreatedAtIdx: index("document_annotation_comments_company_document_created_at_idx").on(
table.companyId,
table.documentId,
@@ -10,13 +10,15 @@ import { companies } from "./companies.js";
import { documentRevisions } from "./document_revisions.js";
import { documents } from "./documents.js";
import { issues } from "./issues.js";
import { routines } from "./routines.js";
export const documentAnnotationThreads = pgTable(
"document_annotation_threads",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "cascade" }),
routineId: uuid("routine_id").references(() => routines.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
documentKey: text("document_key").notNull(),
status: text("status").$type<DocumentAnnotationThreadStatus>().notNull().default("open"),
@@ -56,6 +58,11 @@ export const documentAnnotationThreads = pgTable(
table.issueId,
table.status,
),
companyRoutineStatusIdx: index("document_annotation_threads_company_routine_status_idx").on(
table.companyId,
table.routineId,
table.status,
),
companyCurrentRevisionOpenIdx: index("document_annotation_threads_company_current_revision_open_idx").on(
table.companyId,
table.documentId,
+1
View File
@@ -56,6 +56,7 @@ export { issueAttachments } from "./issue_attachments.js";
export { documents } from "./documents.js";
export { documentRevisions } from "./document_revisions.js";
export { issueDocuments } from "./issue_documents.js";
export { routineDocuments } from "./routine_documents.js";
export { documentAnnotationThreads } from "./document_annotation_threads.js";
export { documentAnnotationComments } from "./document_annotation_comments.js";
export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_snapshots.js";
@@ -0,0 +1,30 @@
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { documents } from "./documents.js";
import { routines } from "./routines.js";
export const routineDocuments = pgTable(
"routine_documents",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
routineId: uuid("routine_id").notNull().references(() => routines.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
key: text("key").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyRoutineKeyUq: uniqueIndex("routine_documents_company_routine_key_uq").on(
table.companyId,
table.routineId,
table.key,
),
documentUq: uniqueIndex("routine_documents_document_uq").on(table.documentId),
companyRoutineUpdatedIdx: index("routine_documents_company_routine_updated_idx").on(
table.companyId,
table.routineId,
table.updatedAt,
),
}),
);
@@ -12,6 +12,7 @@ import {
import { companies } from "./companies.js";
import { executionWorkspaces } from "./execution_workspaces.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issues } from "./issues.js";
export const workspaceOperations = pgTable(
"workspace_operations",
@@ -24,6 +25,9 @@ export const workspaceOperations = pgTable(
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, {
onDelete: "set null",
}),
issueId: uuid("issue_id").references(() => issues.id, {
onDelete: "set null",
}),
phase: text("phase").notNull(),
command: text("command"),
cwd: text("cwd"),
@@ -53,5 +57,11 @@ export const workspaceOperations = pgTable(
table.executionWorkspaceId,
table.startedAt,
),
companyWorkspaceIssueStartedIdx: index("workspace_operations_company_workspace_issue_started_idx").on(
table.companyId,
table.executionWorkspaceId,
table.issueId,
table.startedAt,
),
}),
);
+1
View File
@@ -739,6 +739,7 @@ export type {
Routine,
RoutineEnvConfig,
RoutineManagedByPlugin,
RoutineDescriptionDocument,
RoutineVariable,
RoutineVariableDefaultValue,
RoutineRevisionSnapshotRoutineV1,
@@ -55,7 +55,8 @@ export interface DocumentAnnotationAnchorSnapshot {
export interface DocumentAnnotationThread {
id: string;
companyId: string;
issueId: string;
issueId: string | null;
routineId?: string | null;
documentId: string;
documentKey: string;
status: DocumentAnnotationThreadStatus;
@@ -86,7 +87,8 @@ export interface DocumentAnnotationComment {
id: string;
companyId: string;
threadId: string;
issueId: string;
issueId: string | null;
routineId?: string | null;
documentId: string;
body: string;
authorType: IssueCommentAuthorType;
+1
View File
@@ -418,6 +418,7 @@ export type {
Routine,
RoutineEnvConfig,
RoutineManagedByPlugin,
RoutineDescriptionDocument,
RoutineVariable,
RoutineVariableDefaultValue,
RoutineRevisionSnapshotRoutineV1,
+19
View File
@@ -10,6 +10,24 @@ import type {
} from "../constants.js";
import type { EnvBinding } from "./secrets.js";
export interface RoutineDescriptionDocument {
id: string;
companyId: string;
routineId: string;
key: "description";
title: string | null;
format: "markdown";
body: string;
latestRevisionId: string | null;
latestRevisionNumber: number;
createdByAgentId: string | null;
createdByUserId: string | null;
updatedByAgentId: string | null;
updatedByUserId: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface RoutineProjectSummary {
id: string;
name: string;
@@ -195,6 +213,7 @@ export interface RoutineDetail extends Routine {
project: RoutineProjectSummary | null;
assignee: RoutineAgentSummary | null;
parentIssue: RoutineIssueSummary | null;
descriptionDocument?: RoutineDescriptionDocument | null;
triggers: RoutineTrigger[];
recentRuns: RoutineRunSummary[];
activeIssue: RoutineIssueSummary | null;
@@ -12,6 +12,7 @@ export interface WorkspaceOperation {
companyId: string;
executionWorkspaceId: string | null;
heartbeatRunId: string | null;
issueId: string | null;
phase: WorkspaceOperationPhase;
command: string | null;
cwd: string | null;
@@ -298,6 +298,10 @@ describe("document annotation routes", () => {
expect(mockIssueReferenceService.syncAnnotationComment).toHaveBeenCalledWith(annotationComment.id);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "issue.document_annotation_thread_created",
details: expect.objectContaining({
key: "plan",
documentKey: "plan",
}),
}));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
@@ -324,6 +328,10 @@ describe("document annotation routes", () => {
expect(mockIssueReferenceService.syncAnnotationComment).toHaveBeenCalledWith(annotationComment.id);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "issue.document_annotation_comment_added",
details: expect.objectContaining({
key: "plan",
documentKey: "plan",
}),
}));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
@@ -334,6 +342,10 @@ describe("document annotation routes", () => {
expect(resolved.body.status).toBe("resolved");
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "issue.document_annotation_thread_resolved",
details: expect.objectContaining({
key: "plan",
documentKey: "plan",
}),
}));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
@@ -1345,13 +1345,23 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
describe("workspace_finalize accept gate", () => {
async function seedAcceptGateFixture() {
type AcceptGateInteractionKind = "request_confirmation" | "request_checkbox_confirmation";
async function seedAcceptGateFixture(options?: {
kind?: AcceptGateInteractionKind;
sourceRunId?: string | null;
}) {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const issueId = randomUUID();
const goalId = randomUUID();
const agentId = randomUUID();
const sourceRunId =
options?.sourceRunId === null ? null : options?.sourceRunId ?? randomUUID();
const foreignRunId = randomUUID();
const kind = options?.kind ?? "request_confirmation";
await db.insert(companies).values({
id: companyId,
@@ -1375,6 +1385,40 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
visibility: "default",
isPrimary: true,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values([
...(sourceRunId
? [
{
id: sourceRunId,
companyId,
agentId,
invocationSource: "manual",
status: "succeeded",
startedAt: new Date("2026-05-23T21:55:00.000Z"),
finishedAt: new Date("2026-05-23T22:05:00.000Z"),
},
]
: []),
{
id: foreignRunId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
startedAt: new Date("2026-05-23T22:10:00.000Z"),
},
]);
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
@@ -1404,107 +1448,63 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
executionWorkspaceId,
});
const payload = kind === "request_checkbox_confirmation"
? {
version: 1 as const,
prompt: "Which files should be accepted?",
options: [
{ id: "file-a", label: "a.txt" },
{ id: "file-b", label: "b.txt" },
],
minSelected: 0,
maxSelected: 2,
}
: {
version: 1 as const,
prompt: "Mark this issue done?",
};
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
kind,
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Mark this issue done?",
},
sourceRunId,
payload,
}, {
userId: "local-board",
});
return { companyId, projectId, executionWorkspaceId, issueId, goalId, interactionId: created.id };
return {
companyId,
projectId,
executionWorkspaceId,
issueId,
goalId,
interactionId: created.id,
sourceRunId,
foreignRunId,
};
}
it("refuses accept when the issue's latest workspace operation is not a successful workspace_finalize", async () => {
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
// A run touched the workspace (prepare) but never recorded workspace_finalize.
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
await expect(
interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId, projectId: null },
interactionId,
{},
{ userId: "local-board" },
),
).rejects.toMatchObject({
status: 409,
details: { executionWorkspaceId },
});
const row = await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, interactionId))
.then((rows) => rows[0]);
expect(row?.status).toBe("pending");
});
it("refuses accept when the latest workspace operation is a failed workspace_finalize", async () => {
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
it("allows request_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => {
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } =
await seedAcceptGateFixture();
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
phase: "worktree_prepare",
heartbeatRunId: sourceRunId,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
phase: "workspace_finalize",
status: "failed",
startedAt: new Date("2026-05-23T22:05:00.000Z"),
});
await expect(
interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId, projectId: null },
interactionId,
{},
{ userId: "local-board" },
),
).rejects.toMatchObject({
status: 409,
details: { executionWorkspaceId },
});
const row = await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, interactionId))
.then((rows) => rows[0]);
expect(row?.status).toBe("pending");
});
it("allows accept once a successful workspace_finalize lands as the latest operation", async () => {
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
phase: "workspace_finalize",
status: "failed",
startedAt: new Date("2026-05-23T22:05:00.000Z"),
});
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
phase: "workspace_finalize",
heartbeatRunId: foreignRunId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:10:00.000Z"),
});
@@ -1518,6 +1518,208 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
expect(accepted.interaction).toMatchObject({
id: interactionId,
kind: "request_confirmation",
status: "accepted",
});
});
it("refuses request_confirmation accept until the source run records a successful workspace_finalize", async () => {
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } =
await seedAcceptGateFixture();
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
heartbeatRunId: sourceRunId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
await expect(
interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId, projectId: null },
interactionId,
{},
{ userId: "local-board" },
),
).rejects.toMatchObject({
status: 409,
message: expect.stringContaining(
"the run that created this interaction has not finished syncing its workspace",
),
details: { executionWorkspaceId, sourceRunId },
});
const row = await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, interactionId))
.then((rows) => rows[0]);
expect(row?.status).toBe("pending");
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
heartbeatRunId: sourceRunId,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date("2026-05-23T22:05:00.000Z"),
});
const accepted = await interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId, projectId: null },
interactionId,
{},
{ userId: "local-board" },
);
expect(accepted.interaction).toMatchObject({
id: interactionId,
kind: "request_confirmation",
status: "accepted",
});
});
it("allows request_confirmation accept when sourceRunId is null", async () => {
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, foreignRunId } =
await seedAcceptGateFixture({ sourceRunId: null });
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
heartbeatRunId: foreignRunId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:10:00.000Z"),
});
const accepted = await interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId, projectId: null },
interactionId,
{},
{ userId: "local-board" },
);
expect(accepted.interaction).toMatchObject({
id: interactionId,
kind: "request_confirmation",
status: "accepted",
});
});
it("allows request_checkbox_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => {
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } =
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation" });
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
heartbeatRunId: sourceRunId,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
heartbeatRunId: foreignRunId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:10:00.000Z"),
});
const accepted = await interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId, projectId: null },
interactionId,
{ selectedOptionIds: ["file-b"] },
{ userId: "local-board" },
);
expect(accepted.interaction).toMatchObject({
id: interactionId,
kind: "request_checkbox_confirmation",
status: "accepted",
result: {
selectedOptionIds: ["file-b"],
},
});
});
it("refuses request_checkbox_confirmation accept until the source run records a successful workspace_finalize", async () => {
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } =
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation" });
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
heartbeatRunId: sourceRunId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
await expect(
interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId, projectId: null },
interactionId,
{ selectedOptionIds: ["file-a"] },
{ userId: "local-board" },
),
).rejects.toMatchObject({
status: 409,
message: expect.stringContaining(
"the run that created this interaction has not finished syncing its workspace",
),
details: { executionWorkspaceId, sourceRunId },
});
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
heartbeatRunId: sourceRunId,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date("2026-05-23T22:10:00.000Z"),
});
const accepted = await interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId, projectId: null },
interactionId,
{ selectedOptionIds: ["file-a"] },
{ userId: "local-board" },
);
expect(accepted.interaction).toMatchObject({
id: interactionId,
kind: "request_checkbox_confirmation",
status: "accepted",
});
});
it("allows request_checkbox_confirmation accept when sourceRunId is null", async () => {
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, foreignRunId } =
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation", sourceRunId: null });
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
heartbeatRunId: foreignRunId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:10:00.000Z"),
});
const accepted = await interactionsSvc.acceptInteraction(
{ id: issueId, companyId, goalId, projectId: null },
interactionId,
{ selectedOptionIds: ["file-a"] },
{ userId: "local-board" },
);
expect(accepted.interaction).toMatchObject({
id: interactionId,
kind: "request_checkbox_confirmation",
status: "accepted",
});
});
@@ -1528,11 +1730,12 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
// workspace_finalize gate (PAPA-440) must not apply here. Without this
// carve-out the board cannot triage suggested tasks on an issue whose
// latest workspace op is still worktree_prepare.
const { companyId, executionWorkspaceId, issueId, goalId } = await seedAcceptGateFixture();
const { companyId, executionWorkspaceId, issueId, goalId, foreignRunId } = await seedAcceptGateFixture();
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
heartbeatRunId: foreignRunId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-28T22:00:00.000Z"),
+163 -69
View File
@@ -2931,6 +2931,102 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
await tempDb?.cleanup();
});
async function seedSharedWorkspaceDependency() {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const blockerId = randomUUID();
const dependentId = randomUUID();
const foreignIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "QA",
role: "qa",
status: "active",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Shared workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Shared workspace",
sourceType: "local_path",
visibility: "default",
isPrimary: true,
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Shared exec workspace",
status: "active",
providerType: "git_worktree",
});
await db.insert(issues).values([
{
id: blockerId,
companyId,
projectId,
title: "Predecessor",
status: "done",
priority: "medium",
executionWorkspaceId,
},
{
id: dependentId,
companyId,
projectId,
title: "Dependent",
status: "blocked",
priority: "medium",
assigneeAgentId,
},
{
id: foreignIssueId,
companyId,
projectId,
title: "Foreign in-flight issue",
status: "in_progress",
priority: "medium",
executionWorkspaceId,
},
]);
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
return {
companyId,
assigneeAgentId,
projectId,
projectWorkspaceId,
executionWorkspaceId,
blockerId,
dependentId,
foreignIssueId,
};
}
it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
@@ -3088,86 +3184,82 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
]);
});
it("gates dependents on the workspace-finalize barrier when a done blocker's execution workspace has not synced back", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
it("treats done blockers on a shared workspace as ready while a foreign issue is in-flight", async () => {
const {
companyId,
executionWorkspaceId,
blockerId,
dependentId,
foreignIssueId,
} = await seedSharedWorkspaceDependency();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: assigneeAgentId,
await db.insert(workspaceOperations).values({
companyId,
name: "QA",
role: "qa",
status: "active",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Shared workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Shared workspace",
sourceType: "local_path",
visibility: "default",
isPrimary: true,
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Shared exec workspace",
status: "active",
providerType: "git_worktree",
executionWorkspaceId,
issueId: foreignIssueId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
const blockerId = randomUUID();
const dependentId = randomUUID();
await db.insert(issues).values([
{
id: blockerId,
companyId,
projectId,
title: "Predecessor",
status: "done",
priority: "medium",
executionWorkspaceId,
},
{
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
expect.objectContaining({
id: dependentId,
companyId,
projectId,
title: "Dependent",
status: "blocked",
priority: "medium",
assigneeAgentId,
},
blockerIssueIds: [blockerId],
}),
]);
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
isDependencyReady: true,
pendingFinalizeBlockerIssueIds: [],
unresolvedBlockerIssueIds: [],
});
});
// A run touched the workspace (prepare phase) but has not yet recorded
it("ignores unattributed pre-backfill workspace operations when checking blocker readiness", async () => {
const {
companyId,
executionWorkspaceId,
blockerId,
dependentId,
} = await seedSharedWorkspaceDependency();
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: null,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
expect.objectContaining({
id: dependentId,
blockerIssueIds: [blockerId],
}),
]);
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
isDependencyReady: true,
pendingFinalizeBlockerIssueIds: [],
unresolvedBlockerIssueIds: [],
});
});
it("gates dependents on the blocker's own workspace-finalize barrier until sync-back succeeds", async () => {
const {
companyId,
executionWorkspaceId,
blockerId,
dependentId,
assigneeAgentId,
} = await seedSharedWorkspaceDependency();
// The blocker touched its workspace but has not yet recorded
// workspace_finalize — the dependent must NOT wake.
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: blockerId,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
@@ -3184,6 +3276,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: blockerId,
phase: "workspace_finalize",
status: "failed",
startedAt: new Date("2026-05-23T22:05:00.000Z"),
@@ -3195,6 +3288,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: blockerId,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date("2026-05-23T22:10:00.000Z"),
@@ -0,0 +1,302 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const routineId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
const otherCompanyId = "33333333-3333-4333-8333-333333333333";
const agentId = "77777777-7777-4777-8777-777777777777";
const firstRevisionId = "44444444-4444-4444-8444-444444444444";
const secondRevisionId = "99999999-9999-4999-8999-999999999999";
const mockRoutineService = vi.hoisted(() => ({
get: vi.fn(),
getDetail: vi.fn(),
getDescriptionDocument: vi.fn(),
update: vi.fn(),
}));
const mockAnnotationService = vi.hoisted(() => ({
listThreadsForRoutineDocument: vi.fn(),
getThreadForRoutineDocument: vi.fn(),
createRoutineThread: vi.fn(),
addRoutineComment: vi.fn(),
updateRoutineThread: vi.fn(),
remapOpenThreadsForRoutineDocument: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
const routine = {
id: routineId,
companyId,
title: "Daily summary",
description: "Alpha selected text omega",
status: "active",
assigneeAgentId: agentId,
latestRevisionId: firstRevisionId,
latestRevisionNumber: 1,
};
const descriptionDocument = {
id: "document-1",
companyId,
routineId,
key: "description",
title: "Routine instructions",
format: "markdown",
body: "Alpha selected text omega",
latestRevisionId: firstRevisionId,
latestRevisionNumber: 1,
createdByAgentId: null,
createdByUserId: "board-user",
updatedByAgentId: null,
updatedByUserId: "board-user",
createdAt: new Date("2026-06-16T12:00:00.000Z"),
updatedAt: new Date("2026-06-16T12:00:00.000Z"),
};
const updatedDescriptionDocument = {
...descriptionDocument,
body: "Alpha updated selected text omega",
latestRevisionId: secondRevisionId,
latestRevisionNumber: 2,
updatedAt: new Date("2026-06-16T12:02:00.000Z"),
};
const selector = {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
};
const annotationThread = {
id: "55555555-5555-4555-8555-555555555555",
companyId,
issueId: null,
routineId,
documentId: descriptionDocument.id,
documentKey: "description",
status: "open",
anchorState: "active",
anchorConfidence: "exact",
originalRevisionId: firstRevisionId,
originalRevisionNumber: 1,
currentRevisionId: firstRevisionId,
currentRevisionNumber: 1,
selectedText: "selected text",
prefixText: "Alpha ",
suffixText: " omega",
normalizedStart: 6,
normalizedEnd: 19,
markdownStart: 6,
markdownEnd: 19,
anchorSelector: selector,
createdByAgentId: null,
createdByUserId: "board-user",
resolvedByAgentId: null,
resolvedByUserId: null,
resolvedAt: null,
createdAt: new Date("2026-06-16T12:01:00.000Z"),
updatedAt: new Date("2026-06-16T12:01:00.000Z"),
};
const annotationComment = {
id: "66666666-6666-4666-8666-666666666666",
companyId,
threadId: annotationThread.id,
issueId: null,
routineId,
documentId: descriptionDocument.id,
body: "Please tighten this",
authorType: "user",
authorAgentId: null,
authorUserId: "board-user",
createdByRunId: null,
createdAt: new Date("2026-06-16T12:01:00.000Z"),
updatedAt: new Date("2026-06-16T12:01:00.000Z"),
};
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => true),
}),
documentAnnotationService: () => mockAnnotationService,
logActivity: mockLogActivity,
routineService: () => mockRoutineService,
}));
}
async function createApp(actor: "board" | "agent" = "board", actorCompanyId = companyId) {
const [{ routineRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/routines.js")>("../routes/routines.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor === "agent"
? {
type: "agent",
agentId,
companyId: actorCompanyId,
runId: "88888888-8888-4888-8888-888888888888",
}
: {
type: "board",
userId: "board-user",
companyIds: [actorCompanyId],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", routineRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("routine description annotation routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/routines.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
mockRoutineService.get.mockResolvedValue(routine);
mockRoutineService.getDescriptionDocument.mockResolvedValue(updatedDescriptionDocument);
mockRoutineService.update.mockResolvedValue({
...routine,
description: "Alpha updated selected text omega",
latestRevisionId: secondRevisionId,
latestRevisionNumber: 2,
});
mockAnnotationService.listThreadsForRoutineDocument.mockImplementation(async (
_routineId: string,
_key: string,
options?: { includeComments?: boolean },
) => (
options?.includeComments
? [{ ...annotationThread, comments: [annotationComment] }]
: [annotationThread]
));
mockAnnotationService.getThreadForRoutineDocument.mockResolvedValue({
...annotationThread,
comments: [annotationComment],
});
mockAnnotationService.createRoutineThread.mockResolvedValue({
...annotationThread,
comments: [annotationComment],
});
mockAnnotationService.addRoutineComment.mockResolvedValue(annotationComment);
mockAnnotationService.updateRoutineThread.mockResolvedValue({ ...annotationThread, status: "resolved" });
mockAnnotationService.remapOpenThreadsForRoutineDocument.mockResolvedValue([
{
thread: {
...annotationThread,
currentRevisionId: secondRevisionId,
currentRevisionNumber: 2,
},
snapshot: { id: "snapshot-1" },
},
]);
});
it("creates, replies to, resolves, and reopens routine description annotation threads", async () => {
const app = await createApp();
const created = await request(app)
.post(`/api/routines/${routineId}/description/annotations`)
.send({
baseRevisionId: firstRevisionId,
baseRevisionNumber: 1,
selector,
body: "Please tighten this",
})
.expect(201);
expect(created.body.id).toBe(annotationThread.id);
expect(mockAnnotationService.createRoutineThread).toHaveBeenCalledWith(
routineId,
"description",
expect.objectContaining({ baseRevisionId: firstRevisionId, body: "Please tighten this" }),
expect.objectContaining({ actorType: "user", userId: "board-user" }),
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "routine.document_annotation_thread_created",
entityType: "routine",
entityId: routineId,
details: expect.objectContaining({ documentKey: "description", threadId: annotationThread.id }),
}));
await request(app)
.post(`/api/routines/${routineId}/description/annotations/${annotationThread.id}/comments`)
.send({ body: "Reply on the same thread" })
.expect(201);
expect(mockAnnotationService.addRoutineComment).toHaveBeenCalledWith(
routineId,
"description",
annotationThread.id,
expect.objectContaining({ body: "Reply on the same thread" }),
expect.objectContaining({ actorType: "user", userId: "board-user" }),
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "routine.document_annotation_comment_added",
details: expect.objectContaining({ documentKey: "description", threadId: annotationThread.id }),
}));
await request(app)
.patch(`/api/routines/${routineId}/description/annotations/${annotationThread.id}`)
.send({ status: "resolved" })
.expect(200);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "routine.document_annotation_thread_resolved",
details: expect.objectContaining({ documentKey: "description", threadId: annotationThread.id }),
}));
mockAnnotationService.updateRoutineThread.mockResolvedValueOnce({ ...annotationThread, status: "open" });
await request(app)
.patch(`/api/routines/${routineId}/description/annotations/${annotationThread.id}`)
.send({ status: "open" })
.expect(200);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "routine.document_annotation_thread_reopened",
details: expect.objectContaining({ documentKey: "description", threadId: annotationThread.id }),
}));
});
it("remaps open routine description annotations after routine description revisions", async () => {
const updated = await request(await createApp())
.patch(`/api/routines/${routineId}`)
.send({
description: "Alpha updated selected text omega",
baseRevisionId: firstRevisionId,
})
.expect(200);
expect(updated.body.latestRevisionNumber).toBe(2);
expect(mockRoutineService.getDescriptionDocument).toHaveBeenCalledWith(routineId);
expect(mockAnnotationService.remapOpenThreadsForRoutineDocument).toHaveBeenCalledWith({
routineId,
key: "description",
documentId: updatedDescriptionDocument.id,
nextRevisionId: updatedDescriptionDocument.latestRevisionId,
nextRevisionNumber: updatedDescriptionDocument.latestRevisionNumber,
nextBody: updatedDescriptionDocument.body,
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "routine.document_annotation_remapped",
entityType: "routine",
entityId: routineId,
details: expect.objectContaining({
documentKey: "description",
threadId: annotationThread.id,
}),
}));
});
it("rejects agent cross-company routine annotation reads", async () => {
await request(await createApp("agent", otherCompanyId))
.get(`/api/routines/${routineId}/description/annotations`)
.expect(403);
});
});
@@ -101,6 +101,7 @@ const mockRoutineService = vi.hoisted(() => ({
list: vi.fn(),
get: vi.fn(),
getDetail: vi.fn(),
getDescriptionDocument: vi.fn(),
update: vi.fn(),
create: vi.fn(),
listRevisions: vi.fn(),
@@ -115,6 +116,15 @@ const mockRoutineService = vi.hoisted(() => ({
firePublicTrigger: vi.fn(),
}));
const mockAnnotationService = vi.hoisted(() => ({
listThreadsForRoutineDocument: vi.fn(),
getThreadForRoutineDocument: vi.fn(),
createRoutineThread: vi.fn(),
addRoutineComment: vi.fn(),
updateRoutineThread: vi.fn(),
remapOpenThreadsForRoutineDocument: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
}));
@@ -149,6 +159,7 @@ function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
documentAnnotationService: () => mockAnnotationService,
logActivity: mockLogActivity,
routineService: () => mockRoutineService,
}));
@@ -205,6 +216,96 @@ describe("routine routes", () => {
});
mockAccessService.canUser.mockResolvedValue(false);
mockLogActivity.mockResolvedValue(undefined);
mockRoutineService.getDescriptionDocument.mockResolvedValue({
id: "99999999-9999-4999-8999-999999999999",
companyId,
routineId,
key: "description",
title: "Routine description",
format: "markdown",
body: "Alpha selected text omega",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
});
mockAnnotationService.listThreadsForRoutineDocument.mockResolvedValue([]);
mockAnnotationService.getThreadForRoutineDocument.mockResolvedValue(null);
const annotationThread = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId,
issueId: null,
routineId,
documentId: "99999999-9999-4999-8999-999999999999",
documentKey: "description",
status: "open",
anchorState: "active",
anchorConfidence: "exact",
originalRevisionId: revisionId,
originalRevisionNumber: 1,
currentRevisionId: revisionId,
currentRevisionNumber: 1,
selectedText: "selected text",
prefixText: "Alpha ",
suffixText: " omega",
normalizedStart: 6,
normalizedEnd: 19,
markdownStart: 6,
markdownEnd: 19,
anchorSelector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
createdByAgentId: null,
createdByUserId: "board-user",
resolvedByAgentId: null,
resolvedByUserId: null,
resolvedAt: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
comments: [{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
companyId,
threadId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
issueId: null,
routineId,
documentId: "99999999-9999-4999-8999-999999999999",
body: "Please review",
authorType: "user",
authorAgentId: null,
authorUserId: "board-user",
createdByRunId: null,
issueCommentId: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
}],
};
mockAnnotationService.createRoutineThread.mockResolvedValue(annotationThread);
mockAnnotationService.addRoutineComment.mockResolvedValue({
id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
companyId,
threadId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
issueId: null,
routineId,
documentId: "99999999-9999-4999-8999-999999999999",
body: "Reply",
authorType: "user",
authorAgentId: null,
authorUserId: "board-user",
createdByRunId: null,
issueCommentId: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
});
mockAnnotationService.updateRoutineThread.mockResolvedValue({
...annotationThread,
status: "resolved",
});
mockAnnotationService.remapOpenThreadsForRoutineDocument.mockResolvedValue([]);
});
it("passes project filters to the routine list service", async () => {
@@ -240,6 +341,86 @@ describe("routine routes", () => {
expect(res.body[0]).toMatchObject({ id: revisionId, revisionNumber: 1 });
});
it("creates, replies to, and resolves routine description annotation threads", async () => {
const app = await createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: true,
companyIds: [companyId],
});
const selector = {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
};
const created = await request(app)
.post(`/api/routines/${routineId}/description/annotations`)
.send({
baseRevisionId: revisionId,
baseRevisionNumber: 1,
selector,
body: "Please review",
})
.expect(201);
expect(created.body).toMatchObject({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
routineId,
issueId: null,
documentKey: "description",
});
expect(mockAnnotationService.createRoutineThread).toHaveBeenCalledWith(
routineId,
"description",
expect.objectContaining({ body: "Please review" }),
expect.objectContaining({ actorType: "user", userId: "board-user" }),
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "routine.document_annotation_thread_created",
entityType: "routine",
entityId: routineId,
details: expect.objectContaining({ documentKey: "description" }),
}));
await request(app)
.post(`/api/routines/${routineId}/description/annotations/${created.body.id}/comments`)
.send({ body: "Reply" })
.expect(201);
expect(mockAnnotationService.addRoutineComment).toHaveBeenCalledWith(
routineId,
"description",
created.body.id,
expect.objectContaining({ body: "Reply" }),
expect.objectContaining({ actorType: "user", userId: "board-user" }),
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "routine.document_annotation_comment_added",
entityType: "routine",
entityId: routineId,
}));
const resolved = await request(app)
.patch(`/api/routines/${routineId}/description/annotations/${created.body.id}`)
.send({ status: "resolved" })
.expect(200);
expect(resolved.body.status).toBe("resolved");
expect(mockAnnotationService.updateRoutineThread).toHaveBeenCalledWith(
routineId,
"description",
created.body.id,
expect.objectContaining({ status: "resolved" }),
expect.objectContaining({ actorType: "user", userId: "board-user" }),
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "routine.document_annotation_thread_resolved",
entityType: "routine",
entityId: routineId,
}));
});
it("blocks routine revision reads across company scope", async () => {
const app = await createApp({
type: "board",
@@ -149,6 +149,7 @@ function createWorkspaceOperationRecorderDouble() {
companyId: "company-1",
executionWorkspaceId,
heartbeatRunId: "run-1",
issueId: null,
phase: input.phase,
command: input.command ?? null,
cwd: input.cwd ?? null,
+3
View File
@@ -3237,6 +3237,7 @@ export function issueRoutes(
entityType: "issue",
entityId: issue.id,
details: {
key: thread.documentKey,
documentKey: thread.documentKey,
documentId: thread.documentId,
threadId: thread.id,
@@ -3321,6 +3322,7 @@ export function issueRoutes(
entityType: "issue",
entityId: issue.id,
details: {
key: keyParsed.data,
documentKey: keyParsed.data,
threadId: comment.threadId,
commentId: comment.id,
@@ -3374,6 +3376,7 @@ export function issueRoutes(
entityType: "issue",
entityId: issue.id,
details: {
key: thread.documentKey,
documentKey: thread.documentKey,
documentId: thread.documentId,
threadId: thread.id,
+212 -1
View File
@@ -2,15 +2,18 @@ import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
createRoutineSchema,
createDocumentAnnotationCommentSchema,
createDocumentAnnotationThreadSchema,
createRoutineTriggerSchema,
rotateRoutineTriggerSecretSchema,
runRoutineSchema,
updateDocumentAnnotationThreadSchema,
updateRoutineSchema,
updateRoutineTriggerSchema,
} from "@paperclipai/shared";
import { trackRoutineCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { accessService, logActivity, routineService } from "../services/index.js";
import { accessService, documentAnnotationService, logActivity, routineService } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { forbidden, unauthorized } from "../errors.js";
import { getTelemetryClient } from "../telemetry.js";
@@ -24,7 +27,63 @@ export function routineRoutes(
const svc = routineService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
const documentAnnotationsSvc = documentAnnotationService(db);
const access = accessService(db);
const routineDocumentKey = "description";
function parseBooleanQuery(value: unknown) {
return value === true || value === "true" || value === "1";
}
function annotationActorInput(req: Request) {
const actor = getActorInfo(req);
return {
actor,
annotationActor: {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId,
},
};
}
async function remapRoutineDescriptionAnnotations(req: Request, routineId: string) {
const doc = await svc.getDescriptionDocument(routineId);
if (!doc) return;
const remapped = await documentAnnotationsSvc.remapOpenThreadsForRoutineDocument({
routineId,
key: routineDocumentKey,
documentId: doc.id,
nextRevisionId: doc.latestRevisionId,
nextRevisionNumber: doc.latestRevisionNumber,
nextBody: doc.body,
});
const actor = getActorInfo(req);
for (const remap of remapped) {
await logActivity(db, {
companyId: doc.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.document_annotation_remapped",
entityType: "routine",
entityId: routineId,
details: {
key: doc.key,
documentKey: doc.key,
documentId: doc.id,
threadId: remap.thread.id,
revisionNumber: doc.latestRevisionNumber,
anchorState: remap.thread.anchorState,
anchorConfidence: remap.thread.anchorConfidence,
snapshotId: remap.snapshot.id,
},
});
}
}
async function assertBoardCanAssignTasks(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
@@ -149,6 +208,156 @@ export function routineRoutes(
res.json(revisions);
});
router.get("/routines/:id/description/annotations", async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
const status = req.query.status === "resolved" || req.query.status === "all" ? req.query.status : "open";
const threads = await documentAnnotationsSvc.listThreadsForRoutineDocument(routine.id, routineDocumentKey, {
status,
includeComments: parseBooleanQuery(req.query.includeComments),
});
res.json(threads);
});
router.get("/routines/:id/description/annotations/:threadId", async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
const thread = await documentAnnotationsSvc.getThreadForRoutineDocument(
routine.id,
routineDocumentKey,
req.params.threadId as string,
);
if (!thread) {
res.status(404).json({ error: "Annotation thread not found" });
return;
}
res.json(thread);
});
router.post(
"/routines/:id/description/annotations",
validate(createDocumentAnnotationThreadSchema),
async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
const { actor, annotationActor } = annotationActorInput(req);
const thread = await documentAnnotationsSvc.createRoutineThread(
routine.id,
routineDocumentKey,
req.body,
annotationActor,
);
const firstComment = thread.comments[0];
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.document_annotation_thread_created",
entityType: "routine",
entityId: routine.id,
details: {
key: thread.documentKey,
documentKey: thread.documentKey,
documentId: thread.documentId,
threadId: thread.id,
commentId: firstComment?.id ?? null,
revisionNumber: thread.currentRevisionNumber,
quote: thread.selectedText.slice(0, 240),
},
});
res.status(201).json(thread);
},
);
router.post(
"/routines/:id/description/annotations/:threadId/comments",
validate(createDocumentAnnotationCommentSchema),
async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
const { actor, annotationActor } = annotationActorInput(req);
const comment = await documentAnnotationsSvc.addRoutineComment(
routine.id,
routineDocumentKey,
req.params.threadId as string,
req.body,
annotationActor,
);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.document_annotation_comment_added",
entityType: "routine",
entityId: routine.id,
details: {
key: routineDocumentKey,
documentKey: routineDocumentKey,
threadId: comment.threadId,
commentId: comment.id,
bodySnippet: comment.body.slice(0, 120),
},
});
res.status(201).json(comment);
},
);
router.patch(
"/routines/:id/description/annotations/:threadId",
validate(updateDocumentAnnotationThreadSchema),
async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
const { actor, annotationActor } = annotationActorInput(req);
const thread = await documentAnnotationsSvc.updateRoutineThread(
routine.id,
routineDocumentKey,
req.params.threadId as string,
req.body,
annotationActor,
);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: thread.status === "resolved"
? "routine.document_annotation_thread_resolved"
: "routine.document_annotation_thread_reopened",
entityType: "routine",
entityId: routine.id,
details: {
key: thread.documentKey,
documentKey: thread.documentKey,
documentId: thread.documentId,
threadId: thread.id,
status: thread.status,
},
});
res.json(thread);
},
);
router.patch("/routines/:id", validate(updateRoutineSchema), async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
@@ -193,6 +402,7 @@ export function routineRoutes(
details: { title: updated?.title ?? routine.title },
});
if (updated && updated.latestRevisionId !== routine.latestRevisionId) {
await remapRoutineDescriptionAnnotations(req, routine.id);
await logRoutineRevisionCreated(req, {
companyId: routine.companyId,
routineId: routine.id,
@@ -235,6 +445,7 @@ export function routineRoutes(
triggerCount: result.revision.snapshot.triggers.length,
},
});
await remapRoutineDescriptionAnnotations(req, routine.id);
res.json(result);
});
+323
View File
@@ -7,6 +7,7 @@ import {
documents,
issueComments,
issueDocuments,
routineDocuments,
} from "@paperclipai/db";
import {
anchorSnapshotToSelector,
@@ -40,10 +41,21 @@ type IssueDocumentRow = {
latestRevisionNumber: number;
};
type RoutineDocumentRow = {
routineId: string;
companyId: string;
documentId: string;
documentKey: string;
latestBody: string;
latestRevisionId: string | null;
latestRevisionNumber: number;
};
const threadSelect = {
id: documentAnnotationThreads.id,
companyId: documentAnnotationThreads.companyId,
issueId: documentAnnotationThreads.issueId,
routineId: documentAnnotationThreads.routineId,
documentId: documentAnnotationThreads.documentId,
documentKey: documentAnnotationThreads.documentKey,
status: documentAnnotationThreads.status,
@@ -75,6 +87,7 @@ const commentSelect = {
companyId: documentAnnotationComments.companyId,
threadId: documentAnnotationComments.threadId,
issueId: documentAnnotationComments.issueId,
routineId: documentAnnotationComments.routineId,
documentId: documentAnnotationComments.documentId,
body: documentAnnotationComments.body,
authorType: documentAnnotationComments.authorType,
@@ -116,6 +129,27 @@ export function documentAnnotationService(db: Db) {
.then((rows: IssueDocumentRow[]) => rows[0] ?? null);
}
async function getRoutineDocument(
routineId: string,
key: string,
dbOrTx: any = db,
): Promise<RoutineDocumentRow | null> {
return dbOrTx
.select({
routineId: routineDocuments.routineId,
companyId: documents.companyId,
documentId: documents.id,
documentKey: routineDocuments.key,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
})
.from(routineDocuments)
.innerJoin(documents, eq(routineDocuments.documentId, documents.id))
.where(and(eq(routineDocuments.routineId, routineId), eq(routineDocuments.key, key)))
.then((rows: RoutineDocumentRow[]) => rows[0] ?? null);
}
async function getThreadForIssue(
issueId: string,
documentKey: string,
@@ -133,6 +167,23 @@ export function documentAnnotationService(db: Db) {
.then((rows: DocumentAnnotationThread[]) => rows[0] ?? null);
}
async function getThreadForRoutine(
routineId: string,
documentKey: string,
threadId: string,
dbOrTx: any = db,
): Promise<DocumentAnnotationThread | null> {
return dbOrTx
.select(threadSelect)
.from(documentAnnotationThreads)
.where(and(
eq(documentAnnotationThreads.id, threadId),
eq(documentAnnotationThreads.routineId, routineId),
eq(documentAnnotationThreads.documentKey, documentKey),
))
.then((rows: DocumentAnnotationThread[]) => rows[0] ?? null);
}
async function commentsForThreads(threadIds: string[], dbOrTx: any = db): Promise<DocumentAnnotationComment[]> {
if (threadIds.length === 0) return [];
return dbOrTx
@@ -197,6 +248,39 @@ export function documentAnnotationService(db: Db) {
}));
},
listThreadsForRoutineDocument: async (
routineId: string,
key: string,
options: { status?: "open" | "resolved" | "all"; includeComments?: boolean } = {},
) => {
const doc = await getRoutineDocument(routineId, key);
if (!doc) throw notFound("Document not found");
const conditions = [
eq(documentAnnotationThreads.routineId, routineId),
eq(documentAnnotationThreads.documentId, doc.documentId),
];
if (options.status && options.status !== "all") {
conditions.push(eq(documentAnnotationThreads.status, options.status));
}
const threads: DocumentAnnotationThread[] = await db
.select(threadSelect)
.from(documentAnnotationThreads)
.where(and(...conditions))
.orderBy(desc(documentAnnotationThreads.updatedAt), desc(documentAnnotationThreads.id));
if (!options.includeComments) return threads;
const comments = await commentsForThreads(threads.map((thread) => thread.id));
const commentsByThread = new Map<string, DocumentAnnotationComment[]>();
for (const comment of comments) {
const existing = commentsByThread.get(comment.threadId) ?? [];
existing.push(comment);
commentsByThread.set(comment.threadId, existing);
}
return threads.map((thread) => ({
...thread,
comments: commentsByThread.get(thread.id) ?? [],
}));
},
getThreadForIssueDocument: async (issueId: string, key: string, threadId: string) => {
const thread = await getThreadForIssue(issueId, key, threadId);
if (!thread) return null;
@@ -204,6 +288,13 @@ export function documentAnnotationService(db: Db) {
return { ...thread, comments };
},
getThreadForRoutineDocument: async (routineId: string, key: string, threadId: string) => {
const thread = await getThreadForRoutine(routineId, key, threadId);
if (!thread) return null;
const comments = await commentsForThreads([thread.id]);
return { ...thread, comments };
},
createThread: async (
issueId: string,
key: string,
@@ -291,6 +382,94 @@ export function documentAnnotationService(db: Db) {
return { ...thread, comments: [comment] };
}),
createRoutineThread: async (
routineId: string,
key: string,
input: CreateDocumentAnnotationThread,
actor: ActorInput,
) => db.transaction(async (tx) => {
await tx.execute(sql`
select ${documents.id}
from ${routineDocuments}
inner join ${documents} on ${routineDocuments.documentId} = ${documents.id}
where ${and(eq(routineDocuments.routineId, routineId), eq(routineDocuments.key, key))}
for update of ${documents}
`);
const doc = await getRoutineDocument(routineId, key, tx);
if (!doc) throw notFound("Document not found");
if (
input.baseRevisionId !== doc.latestRevisionId
|| input.baseRevisionNumber !== doc.latestRevisionNumber
) {
throw conflict("Annotation anchor requires the current document revision", {
currentRevisionId: doc.latestRevisionId,
currentRevisionNumber: doc.latestRevisionNumber,
});
}
const verification = verifyDocumentAnchorSelector({
markdown: doc.latestBody,
selector: input.selector,
});
if (!verification.ok || !verification.anchor) {
throw unprocessable("Annotation anchor does not match the current document revision", {
reason: verification.reason,
});
}
const now = new Date();
const [thread] = await tx
.insert(documentAnnotationThreads)
.values({
companyId: doc.companyId,
issueId: null,
routineId,
documentId: doc.documentId,
documentKey: doc.documentKey,
status: "open",
anchorState: "active",
anchorConfidence: "exact",
originalRevisionId: doc.latestRevisionId,
originalRevisionNumber: doc.latestRevisionNumber,
currentRevisionId: doc.latestRevisionId,
currentRevisionNumber: doc.latestRevisionNumber,
selectedText: verification.anchor.selectedText,
prefixText: verification.anchor.prefixText,
suffixText: verification.anchor.suffixText,
normalizedStart: verification.anchor.normalizedStart,
normalizedEnd: verification.anchor.normalizedEnd,
markdownStart: verification.anchor.markdownStart,
markdownEnd: verification.anchor.markdownEnd,
anchorSelector: input.selector,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
createdAt: now,
updatedAt: now,
})
.returning(threadSelect);
const [comment] = await tx
.insert(documentAnnotationComments)
.values({
companyId: doc.companyId,
threadId: thread.id,
issueId: null,
routineId,
documentId: doc.documentId,
body: input.body,
authorType: actor.actorType,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
issueCommentId: null,
createdAt: now,
updatedAt: now,
})
.returning(commentSelect);
return { ...thread, comments: [comment] };
}),
addComment: async (
issueId: string,
key: string,
@@ -326,6 +505,41 @@ export function documentAnnotationService(db: Db) {
return comment;
}),
addRoutineComment: async (
routineId: string,
key: string,
threadId: string,
input: CreateDocumentAnnotationComment,
actor: ActorInput,
) => db.transaction(async (tx) => {
const thread = await getThreadForRoutine(routineId, key, threadId, tx);
if (!thread) throw notFound("Annotation thread not found");
const now = new Date();
const [comment] = await tx
.insert(documentAnnotationComments)
.values({
companyId: thread.companyId,
threadId: thread.id,
issueId: null,
routineId: thread.routineId,
documentId: thread.documentId,
body: input.body,
authorType: actor.actorType,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
issueCommentId: null,
createdAt: now,
updatedAt: now,
})
.returning(commentSelect);
await tx
.update(documentAnnotationThreads)
.set({ updatedAt: now })
.where(eq(documentAnnotationThreads.id, thread.id));
return comment;
}),
cleanupForIssueCommentDeletion: async (
issueId: string,
issueCommentId: string,
@@ -425,6 +639,40 @@ export function documentAnnotationService(db: Db) {
return updated;
}),
updateRoutineThread: async (
routineId: string,
key: string,
threadId: string,
input: UpdateDocumentAnnotationThread,
actor: ActorInput,
) => db.transaction(async (tx) => {
const thread = await getThreadForRoutine(routineId, key, threadId, tx);
if (!thread) throw notFound("Annotation thread not found");
if (!input.status || input.status === thread.status) return thread;
const now = new Date();
const [updated] = await tx
.update(documentAnnotationThreads)
.set(input.status === "resolved"
? {
status: "resolved",
resolvedByAgentId: actor.agentId ?? null,
resolvedByUserId: actor.userId ?? null,
resolvedAt: now,
updatedAt: now,
}
: {
status: "open",
resolvedByAgentId: null,
resolvedByUserId: null,
resolvedAt: null,
updatedAt: now,
})
.where(eq(documentAnnotationThreads.id, thread.id))
.returning(threadSelect);
return updated;
}),
remapOpenThreadsForDocument: async (input: {
issueId: string;
key: string;
@@ -500,6 +748,81 @@ export function documentAnnotationService(db: Db) {
return changed;
}),
remapOpenThreadsForRoutineDocument: async (input: {
routineId: string;
key: string;
documentId: string;
nextRevisionId: string | null;
nextRevisionNumber: number;
nextBody: string;
}) => db.transaction(async (tx) => {
const threads: DocumentAnnotationThread[] = await tx
.select(threadSelect)
.from(documentAnnotationThreads)
.where(and(
eq(documentAnnotationThreads.routineId, input.routineId),
eq(documentAnnotationThreads.documentId, input.documentId),
eq(documentAnnotationThreads.status, "open"),
));
const changed = [];
const now = new Date();
for (const thread of threads) {
if (thread.currentRevisionId === input.nextRevisionId) continue;
const previousAnchor = snapshotFromThread(thread);
const remap = remapDocumentAnchor({
previousAnchor,
nextMarkdown: input.nextBody,
});
const nextAnchor = remap.anchor;
const nextSelector = nextAnchor ? anchorSnapshotToSelector(nextAnchor) : thread.anchorSelector;
const [updated] = await tx
.update(documentAnnotationThreads)
.set({
currentRevisionId: input.nextRevisionId,
currentRevisionNumber: input.nextRevisionNumber,
anchorState: remap.anchorState,
anchorConfidence: remap.confidence,
...(nextAnchor
? {
selectedText: nextAnchor.selectedText,
prefixText: nextAnchor.prefixText,
suffixText: nextAnchor.suffixText,
normalizedStart: nextAnchor.normalizedStart,
normalizedEnd: nextAnchor.normalizedEnd,
markdownStart: nextAnchor.markdownStart,
markdownEnd: nextAnchor.markdownEnd,
}
: {}),
anchorSelector: nextSelector,
updatedAt: now,
})
.where(eq(documentAnnotationThreads.id, thread.id))
.returning(threadSelect);
const [snapshot] = await tx
.insert(documentAnnotationAnchorSnapshots)
.values({
companyId: thread.companyId,
threadId: thread.id,
documentId: thread.documentId,
fromRevisionId: thread.currentRevisionId,
fromRevisionNumber: thread.currentRevisionNumber,
toRevisionId: input.nextRevisionId,
toRevisionNumber: input.nextRevisionNumber,
previousAnchor,
nextAnchor,
anchorState: remap.anchorState,
anchorConfidence: remap.confidence,
failureReason: remap.anchor ? null : remap.reason,
createdAt: now,
})
.returning();
changed.push({ thread: updated, snapshot });
}
return changed;
}),
selectorToAnchorSnapshot,
};
}
+1
View File
@@ -8318,6 +8318,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
companyId: agent.companyId,
heartbeatRunId: run.id,
executionWorkspaceId: existingExecutionWorkspace?.id ?? null,
issueId,
});
const executionWorkspaceBase = {
baseCwd: resolvedWorkspace.cwd,
@@ -39,7 +39,7 @@ import {
suggestTasksResultSchema,
} from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { issueService, listUnfinalizedExecutionWorkspaceIds } from "./issues.js";
import { issueService, runWorkspaceIsFinalized } from "./issues.js";
type InteractionActor = {
agentId?: string | null;
@@ -541,7 +541,10 @@ export function issueThreadInteractionService(db: Db) {
async function assertIssueWorkspaceFinalizedForAccept(args: {
db: Pick<Db, "select">;
issue: { id: string; companyId: string };
sourceRunId: string | null;
}) {
if (!args.sourceRunId) return;
const executionWorkspaceId = await args.db
.select({ executionWorkspaceId: issues.executionWorkspaceId })
.from(issues)
@@ -550,17 +553,18 @@ export function issueThreadInteractionService(db: Db) {
if (!executionWorkspaceId) return;
const unfinalized = await listUnfinalizedExecutionWorkspaceIds(
const isFinalized = await runWorkspaceIsFinalized(
args.db,
args.issue.companyId,
[executionWorkspaceId],
executionWorkspaceId,
args.sourceRunId,
);
if (!unfinalized.has(executionWorkspaceId)) return;
if (isFinalized) return;
throw conflict(
"Cannot accept interaction: the issue's most recent run has not completed workspace_finalize. "
"Cannot accept interaction: the run that created this interaction has not finished syncing its workspace. "
+ "Retry once the local worktree has finished syncing.",
{ executionWorkspaceId },
{ executionWorkspaceId, sourceRunId: args.sourceRunId },
);
}
@@ -870,7 +874,7 @@ export function issueThreadInteractionService(db: Db) {
// workspace_finalize gate (PAPA-440) does not apply here.
return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor);
case "request_confirmation": {
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
await assertIssueWorkspaceFinalizedForAccept({ db, issue, sourceRunId: current.sourceRunId });
const accepted = await acceptRequestConfirmation({
issue,
current,
@@ -884,7 +888,7 @@ export function issueThreadInteractionService(db: Db) {
};
}
case "request_checkbox_confirmation": {
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
await assertIssueWorkspaceFinalizedForAccept({ db, issue, sourceRunId: current.sourceRunId });
const accepted = await acceptRequestConfirmation({
issue,
current,
+97 -6
View File
@@ -677,6 +677,94 @@ export async function listUnfinalizedExecutionWorkspaceIds(
return unfinalized;
}
async function listPendingFinalizeBlockerIssueIds(
dbOrTx: Pick<Db, "select">,
companyId: string,
blockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }>,
): Promise<Set<string>> {
const pending = new Set<string>();
const blockerIssueIds = [...new Set(blockerWorkspacePairs.map((pair) => pair.blockerIssueId))];
const executionWorkspaceIds = [...new Set(blockerWorkspacePairs.map((pair) => pair.executionWorkspaceId))];
if (blockerIssueIds.length === 0 || executionWorkspaceIds.length === 0) return pending;
const rows = await dbOrTx
.select({
issueId: workspaceOperations.issueId,
executionWorkspaceId: workspaceOperations.executionWorkspaceId,
phase: workspaceOperations.phase,
status: workspaceOperations.status,
startedAt: workspaceOperations.startedAt,
})
.from(workspaceOperations)
.where(
and(
eq(workspaceOperations.companyId, companyId),
inArray(workspaceOperations.issueId, blockerIssueIds),
inArray(workspaceOperations.executionWorkspaceId, executionWorkspaceIds),
),
);
const latestByBlockerWorkspace = new Map<string, { phase: string; status: string; startedAt: Date }>();
for (const row of rows) {
if (!row.issueId || !row.executionWorkspaceId) continue;
const key = `${row.issueId}:${row.executionWorkspaceId}`;
const current = latestByBlockerWorkspace.get(key);
if (!current || row.startedAt > current.startedAt) {
latestByBlockerWorkspace.set(key, {
phase: row.phase,
status: row.status,
startedAt: row.startedAt,
});
}
}
for (const pair of blockerWorkspacePairs) {
const latest = latestByBlockerWorkspace.get(`${pair.blockerIssueId}:${pair.executionWorkspaceId}`);
if (!latest) continue; // no attributed ops recorded -> nothing to finalize for this blocker
if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue;
pending.add(pair.blockerIssueId);
}
return pending;
}
/**
* Returns whether a specific run's operations on a specific execution workspace
* reached the workspace_finalize barrier.
*
* Runs with no operations on the workspace are considered finalized because
* they never touched the workspace state that accept/review gates protect.
*/
export async function runWorkspaceIsFinalized(
dbOrTx: Pick<Db, "select">,
companyId: string,
executionWorkspaceId: string,
runId: string,
): Promise<boolean> {
const rows = await dbOrTx
.select({
phase: workspaceOperations.phase,
status: workspaceOperations.status,
startedAt: workspaceOperations.startedAt,
})
.from(workspaceOperations)
.where(
and(
eq(workspaceOperations.companyId, companyId),
eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId),
eq(workspaceOperations.heartbeatRunId, runId),
),
);
let latest: { phase: string; status: string; startedAt: Date } | null = null;
for (const row of rows) {
if (!latest || row.startedAt > latest.startedAt) latest = row;
}
if (!latest) return true;
return latest.phase === "workspace_finalize" && latest.status === "succeeded";
}
async function listIssueDependencyReadinessMap(
dbOrTx: Pick<Db, "select">,
companyId: string,
@@ -706,19 +794,22 @@ async function listIssueDependencyReadinessMap(
),
);
// Collect executionWorkspaceIds of "done" blockers — these are the only ones
// Collect issue/workspace pairs of "done" blockers — these are the only ones
// subject to the workspace-finalize barrier. Blockers that aren't done already
// mark the dependent as not-ready and don't need a finalize check.
const doneBlockerWorkspaceIds = new Set<string>();
const doneBlockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }> = [];
for (const row of blockerRows) {
if (row.blockerStatus === "done" && row.blockerExecutionWorkspaceId) {
doneBlockerWorkspaceIds.add(row.blockerExecutionWorkspaceId);
doneBlockerWorkspacePairs.push({
blockerIssueId: row.blockerIssueId,
executionWorkspaceId: row.blockerExecutionWorkspaceId,
});
}
}
const unfinalizedWorkspaceIds = await listUnfinalizedExecutionWorkspaceIds(
const pendingFinalizeBlockerIssueIds = await listPendingFinalizeBlockerIssueIds(
dbOrTx,
companyId,
[...doneBlockerWorkspaceIds],
doneBlockerWorkspacePairs,
);
for (const row of blockerRows) {
@@ -733,7 +824,7 @@ async function listIssueDependencyReadinessMap(
current.isDependencyReady = false;
} else if (
row.blockerExecutionWorkspaceId &&
unfinalizedWorkspaceIds.has(row.blockerExecutionWorkspaceId)
pendingFinalizeBlockerIssueIds.has(row.blockerIssueId)
) {
// Workspace-finalize barrier: the blocker's most recent run on its
// execution workspace hasn't recorded a successful workspace_finalize.
+215 -2
View File
@@ -6,6 +6,8 @@ import {
companySecretBindings,
companySecretVersions,
companySecrets,
documentRevisions,
documents,
executionWorkspaces,
goals,
heartbeatRuns,
@@ -17,6 +19,7 @@ import {
projects,
routineRevisions,
routineRuns,
routineDocuments,
routines,
routineTriggers,
} from "@paperclipai/db";
@@ -25,6 +28,7 @@ import type {
CreateRoutineTrigger,
Routine,
RoutineDetail,
RoutineDescriptionDocument,
RoutineListItem,
RoutineManagedByPlugin,
RoutineRevision,
@@ -80,6 +84,8 @@ type Actor = { agentId?: string | null; userId?: string | null; runId?: string |
type RoutineRow = typeof routines.$inferSelect;
type RoutineTriggerRow = typeof routineTriggers.$inferSelect;
const ROUTINE_DESCRIPTION_DOCUMENT_KEY = "description" as const;
interface RoutineTriggerSecretRestoreMaterial extends RoutineTriggerSecretMaterial {
triggerId: string;
}
@@ -488,6 +494,42 @@ function mapRoutineRevision(row: typeof routineRevisions.$inferSelect): RoutineR
};
}
function mapRoutineDescriptionDocument(row: {
id: string;
companyId: string;
routineId: string;
key: string;
title: string | null;
format: string;
latestBody: string;
latestRevisionId: string | null;
latestRevisionNumber: number;
createdByAgentId: string | null;
createdByUserId: string | null;
updatedByAgentId: string | null;
updatedByUserId: string | null;
createdAt: Date;
updatedAt: Date;
}): RoutineDescriptionDocument {
return {
id: row.id,
companyId: row.companyId,
routineId: row.routineId,
key: ROUTINE_DESCRIPTION_DOCUMENT_KEY,
title: row.title,
format: "markdown",
body: row.latestBody,
latestRevisionId: row.latestRevisionId,
latestRevisionNumber: row.latestRevisionNumber,
createdByAgentId: row.createdByAgentId,
createdByUserId: row.createdByUserId,
updatedByAgentId: row.updatedByAgentId,
updatedByUserId: row.updatedByUserId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export function routineService(
db: Db,
deps: {
@@ -574,6 +616,163 @@ export function routineService(
.then((rows) => rows[0] ?? null);
}
async function getRoutineDescriptionDocument(
routineId: string,
executor: Db | any = db,
): Promise<RoutineDescriptionDocument | null> {
const row = await executor
.select({
id: documents.id,
companyId: documents.companyId,
routineId: routineDocuments.routineId,
key: routineDocuments.key,
title: documents.title,
format: documents.format,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
createdByAgentId: documents.createdByAgentId,
createdByUserId: documents.createdByUserId,
updatedByAgentId: documents.updatedByAgentId,
updatedByUserId: documents.updatedByUserId,
createdAt: documents.createdAt,
updatedAt: documents.updatedAt,
})
.from(routineDocuments)
.innerJoin(documents, eq(routineDocuments.documentId, documents.id))
.where(and(
eq(routineDocuments.routineId, routineId),
eq(routineDocuments.key, ROUTINE_DESCRIPTION_DOCUMENT_KEY),
))
.then((rows: any[]) => rows[0] ?? null);
return row ? mapRoutineDescriptionDocument(row) : null;
}
async function upsertRoutineDescriptionDocument(
executor: Db | any,
routine: RoutineRow,
actor: Actor,
options: { changeSummary?: string | null } = {},
): Promise<RoutineDescriptionDocument> {
const now = new Date();
const body = routine.description ?? "";
const existing = await getRoutineDescriptionDocument(routine.id, executor);
if (existing) {
if (existing.body === body) return existing;
const nextRevisionNumber = existing.latestRevisionNumber + 1;
const [revision] = await executor
.insert(documentRevisions)
.values({
companyId: routine.companyId,
documentId: existing.id,
revisionNumber: nextRevisionNumber,
title: "Routine description",
format: "markdown",
body,
changeSummary: options.changeSummary ?? null,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
createdAt: now,
})
.returning();
await executor
.update(documents)
.set({
title: "Routine description",
format: "markdown",
latestBody: body,
latestRevisionId: revision.id,
latestRevisionNumber: nextRevisionNumber,
updatedByAgentId: actor.agentId ?? null,
updatedByUserId: actor.userId ?? null,
updatedAt: now,
})
.where(eq(documents.id, existing.id));
await executor
.update(routineDocuments)
.set({ updatedAt: now })
.where(eq(routineDocuments.documentId, existing.id));
return {
...existing,
title: "Routine description",
body,
latestRevisionId: revision.id,
latestRevisionNumber: nextRevisionNumber,
updatedByAgentId: actor.agentId ?? null,
updatedByUserId: actor.userId ?? null,
updatedAt: now,
};
}
const [document] = await executor
.insert(documents)
.values({
companyId: routine.companyId,
title: "Routine description",
format: "markdown",
latestBody: body,
latestRevisionId: null,
latestRevisionNumber: 1,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
updatedByAgentId: actor.agentId ?? null,
updatedByUserId: actor.userId ?? null,
createdAt: now,
updatedAt: now,
})
.returning();
const [revision] = await executor
.insert(documentRevisions)
.values({
companyId: routine.companyId,
documentId: document.id,
revisionNumber: 1,
title: "Routine description",
format: "markdown",
body,
changeSummary: options.changeSummary ?? null,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
createdAt: now,
})
.returning();
await executor
.update(documents)
.set({ latestRevisionId: revision.id })
.where(eq(documents.id, document.id));
await executor.insert(routineDocuments).values({
companyId: routine.companyId,
routineId: routine.id,
documentId: document.id,
key: ROUTINE_DESCRIPTION_DOCUMENT_KEY,
createdAt: now,
updatedAt: now,
});
return {
id: document.id,
companyId: routine.companyId,
routineId: routine.id,
key: ROUTINE_DESCRIPTION_DOCUMENT_KEY,
title: document.title,
format: "markdown",
body,
latestRevisionId: revision.id,
latestRevisionNumber: 1,
createdByAgentId: document.createdByAgentId,
createdByUserId: document.createdByUserId,
updatedByAgentId: document.updatedByAgentId,
updatedByUserId: document.updatedByUserId,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
};
}
async function appendRoutineRevision(
executor: Db,
routine: RoutineRow,
@@ -614,8 +813,18 @@ export function routineService(
.where(eq(routines.id, routine.id))
.returning();
const routineForDocument = updatedRoutine ?? {
...routine,
latestRevisionId: revision.id,
latestRevisionNumber: nextRevisionNumber,
updatedAt: now,
};
await upsertRoutineDescriptionDocument(executor, routineForDocument, actor, {
changeSummary: options.changeSummary ?? null,
});
return {
routine: updatedRoutine ?? { ...routine, latestRevisionId: revision.id, latestRevisionNumber: nextRevisionNumber, updatedAt: now },
routine: routineForDocument,
revision: mapRoutineRevision(revision),
};
}
@@ -1488,7 +1697,7 @@ export function routineService(
getDetail: async (id: string): Promise<RoutineDetail | null> => {
const row = await getRoutineById(id);
if (!row) return null;
const [project, assignee, parentIssue, triggers, recentRuns, activeIssue, managedByRoutine] = await Promise.all([
const [project, assignee, parentIssue, descriptionDocument, triggers, recentRuns, activeIssue, managedByRoutine] = await Promise.all([
row.projectId
? db.select().from(projects).where(eq(projects.id, row.projectId)).then((rows) => rows[0] ?? null)
: null,
@@ -1496,6 +1705,7 @@ export function routineService(
? db.select().from(agents).where(eq(agents.id, row.assigneeAgentId)).then((rows) => rows[0] ?? null)
: null,
row.parentIssueId ? issueSvc.getById(row.parentIssueId) : null,
getRoutineDescriptionDocument(row.id),
db.select().from(routineTriggers).where(eq(routineTriggers.routineId, row.id)).orderBy(asc(routineTriggers.createdAt)),
db
.select({
@@ -1578,12 +1788,15 @@ export function routineService(
project,
assignee,
parentIssue,
descriptionDocument,
triggers: triggers as RoutineTrigger[],
recentRuns,
activeIssue,
};
},
getDescriptionDocument: async (routineId: string) => getRoutineDescriptionDocument(routineId),
create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise<Routine> => {
await assertProject(companyId, input.projectId ?? null);
await assertAssignableAgent(db, companyId, input.assigneeAgentId ?? null, { kind: "routine" });
@@ -16,6 +16,7 @@ function toWorkspaceOperation(row: WorkspaceOperationRow): WorkspaceOperation {
companyId: row.companyId,
executionWorkspaceId: row.executionWorkspaceId ?? null,
heartbeatRunId: row.heartbeatRunId ?? null,
issueId: row.issueId ?? null,
phase: row.phase as WorkspaceOperationPhase,
command: row.command ?? null,
cwd: row.cwd ?? null,
@@ -89,6 +90,7 @@ export function workspaceOperationService(db: Db) {
companyId: string;
heartbeatRunId?: string | null;
executionWorkspaceId?: string | null;
issueId?: string | null;
}): WorkspaceOperationRecorder {
let executionWorkspaceId = input.executionWorkspaceId ?? null;
const createdIds: string[] = [];
@@ -136,6 +138,7 @@ export function workspaceOperationService(db: Db) {
companyId: input.companyId,
executionWorkspaceId,
heartbeatRunId: input.heartbeatRunId ?? null,
issueId: input.issueId ?? null,
phase: recordInput.phase,
command: recordInput.command ?? null,
cwd: recordInput.cwd ?? null,
+38 -5
View File
@@ -11,27 +11,50 @@ import { api } from "./client";
export type DocumentAnnotationListFilter = "open" | "resolved" | "all";
export type DocumentAnnotationTarget =
| { kind: "issue"; issueId: string; documentKey: string }
| { kind: "routine"; routineId: string; documentKey: "description" };
function issueTarget(issueId: string, documentKey: string): DocumentAnnotationTarget {
return { kind: "issue", issueId, documentKey };
}
function targetBasePath(target: DocumentAnnotationTarget) {
if (target.kind === "routine") {
return `/routines/${target.routineId}/description/annotations`;
}
return `/issues/${target.issueId}/documents/${encodeURIComponent(target.documentKey)}/annotations`;
}
export const documentAnnotationsApi = {
list: (
issueId: string,
key: string,
options: { status?: DocumentAnnotationListFilter; includeComments?: boolean } = {},
) => documentAnnotationsApi.listForTarget(issueTarget(issueId, key), options),
listForTarget: (
target: DocumentAnnotationTarget,
options: { status?: DocumentAnnotationListFilter; includeComments?: boolean } = {},
) => {
const params = new URLSearchParams();
if (options.status) params.set("status", options.status);
if (options.includeComments) params.set("includeComments", "true");
const qs = params.toString();
return api.get<DocumentAnnotationThreadWithComments[]>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations${qs ? `?${qs}` : ""}`,
`${targetBasePath(target)}${qs ? `?${qs}` : ""}`,
);
},
get: (issueId: string, key: string, threadId: string) =>
documentAnnotationsApi.getForTarget(issueTarget(issueId, key), threadId),
getForTarget: (target: DocumentAnnotationTarget, threadId: string) =>
api.get<DocumentAnnotationThreadWithComments>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
`${targetBasePath(target)}/${threadId}`,
),
create: (issueId: string, key: string, data: CreateDocumentAnnotationThreadRequest) =>
documentAnnotationsApi.createForTarget(issueTarget(issueId, key), data),
createForTarget: (target: DocumentAnnotationTarget, data: CreateDocumentAnnotationThreadRequest) =>
api.post<DocumentAnnotationThreadWithComments>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations`,
targetBasePath(target),
data,
),
addComment: (
@@ -39,9 +62,14 @@ export const documentAnnotationsApi = {
key: string,
threadId: string,
data: CreateDocumentAnnotationCommentRequest,
) => documentAnnotationsApi.addCommentForTarget(issueTarget(issueId, key), threadId, data),
addCommentForTarget: (
target: DocumentAnnotationTarget,
threadId: string,
data: CreateDocumentAnnotationCommentRequest,
) =>
api.post<DocumentAnnotationComment>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}/comments`,
`${targetBasePath(target)}/${threadId}/comments`,
data,
),
updateStatus: (
@@ -49,10 +77,15 @@ export const documentAnnotationsApi = {
key: string,
threadId: string,
status: DocumentAnnotationThreadStatus,
) => documentAnnotationsApi.updateStatusForTarget(issueTarget(issueId, key), threadId, status),
updateStatusForTarget: (
target: DocumentAnnotationTarget,
threadId: string,
status: DocumentAnnotationThreadStatus,
) => {
const payload: UpdateDocumentAnnotationThreadRequest = { status };
return api.patch<DocumentAnnotationThread>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
`${targetBasePath(target)}/${threadId}`,
payload,
);
},
+66 -19
View File
@@ -48,8 +48,17 @@ export interface AnnotationLayerProps {
* pending anchor for the keyboard shortcut path.
*/
captureSelectionRequestId?: number;
/**
* Text of a comment currently being composed. We keep this segment brightly
* highlighted in the document even after the native browser selection is lost
* (e.g. once focus moves into the composer textarea).
*/
pendingHighlightText?: string | null;
}
/** Synthetic thread id used to render the in-progress (pending) comment highlight. */
const PENDING_HIGHLIGHT_THREAD_ID = "__paperclip-pending-annotation__";
interface HighlightRect {
threadId: string;
status: DocumentAnnotationThreadStatus;
@@ -60,6 +69,8 @@ interface HighlightRect {
height: number;
/** True for the last rect of this thread (used to anchor a glyph at the run end). */
isTail: boolean;
/** True when this run should render with the brighter focused/pending treatment. */
focused: boolean;
}
interface ToolbarPosition {
@@ -203,8 +214,10 @@ export function DocumentAnnotationLayer({
newCommentDisabledReason = null,
hideResolved = true,
captureSelectionRequestId,
pendingHighlightText = null,
}: AnnotationLayerProps) {
const [highlightRects, setHighlightRects] = useState<HighlightRect[]>([]);
const [hoveredThreadId, setHoveredThreadId] = useState<string | null>(null);
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const lastCaptureSelectionRequestIdRef = useRef<number>(0);
@@ -231,19 +244,17 @@ export function DocumentAnnotationLayer({
const overlayRect = overlay.getBoundingClientRect();
const next: HighlightRect[] = [];
const nativeRanges = emptyNativeHighlightRanges();
for (const thread of visibleThreads) {
if (thread.anchorState === "orphaned") continue;
const isFocused = thread.id === focusedThreadId;
const isStale = thread.anchorState === "stale";
const isResolved = thread.status === "resolved";
const nativeKind = nativeHighlightKind({
focused: isFocused,
stale: isStale,
resolved: isResolved,
});
const pushRunRects = (run: {
threadId: string;
status: DocumentAnnotationThreadStatus;
anchorState: DocumentAnnotationAnchorState;
focused: boolean;
selectedText: string;
nativeKind: NativeHighlightKind;
}) => {
const ranges = rangesForNormalizedSpan({
container,
selectedText: thread.selectedText,
selectedText: run.selectedText,
});
const startIndex = next.length;
for (const range of ranges) {
@@ -256,9 +267,10 @@ export function DocumentAnnotationLayer({
if (!visibleRect) continue;
rangeIsVisible = true;
next.push({
threadId: thread.id,
status: thread.status,
anchorState: thread.anchorState,
threadId: run.threadId,
status: run.status,
anchorState: run.anchorState,
focused: run.focused,
top: visibleRect.top - overlayRect.top,
left: visibleRect.left - overlayRect.left,
width: visibleRect.width,
@@ -266,15 +278,41 @@ export function DocumentAnnotationLayer({
isTail: false,
});
}
if (rangeIsVisible) nativeRanges[nativeKind].push(range);
if (rangeIsVisible) nativeRanges[run.nativeKind].push(range);
}
if (next.length > startIndex) {
next[next.length - 1].isTail = true;
}
};
for (const thread of visibleThreads) {
if (thread.anchorState === "orphaned") continue;
const isFocused = thread.id === focusedThreadId;
const isStale = thread.anchorState === "stale";
const isResolved = thread.status === "resolved";
pushRunRects({
threadId: thread.id,
status: thread.status,
anchorState: thread.anchorState,
focused: isFocused,
selectedText: thread.selectedText,
nativeKind: nativeHighlightKind({ focused: isFocused, stale: isStale, resolved: isResolved }),
});
}
// Keep the in-progress (pending) comment selection brightly highlighted so the
// segment stays anchored in the document while the composer is open.
if (pendingHighlightText && pendingHighlightText.trim().length > 0) {
pushRunRects({
threadId: PENDING_HIGHLIGHT_THREAD_ID,
status: "open",
anchorState: "active",
focused: true,
selectedText: pendingHighlightText,
nativeKind: "focused",
});
}
setNativeHighlightRanges(nativeHighlightInstanceId, nativeRanges);
setHighlightRects(next);
}, [containerRef, focusedThreadId, nativeHighlightInstanceId, visibleThreads]);
}, [containerRef, focusedThreadId, nativeHighlightInstanceId, pendingHighlightText, visibleThreads]);
useLayoutEffect(() => {
computeHighlightRects();
@@ -398,7 +436,7 @@ export function DocumentAnnotationLayer({
<div className="paperclip-doc-annotation-visual-layer pointer-events-none absolute inset-0 z-0" aria-hidden="true">
<div className="relative h-full w-full">
{highlightRects.map((rect, index) => {
const isFocused = rect.threadId === focusedThreadId;
const isFocused = rect.focused;
const isStale = rect.anchorState === "stale";
const isResolved = rect.status === "resolved";
return (
@@ -437,7 +475,9 @@ export function DocumentAnnotationLayer({
>
<div ref={overlayRef} className="relative h-full w-full">
{highlightRects.map((rect, index) => {
const isFocused = rect.threadId === focusedThreadId;
if (rect.threadId === PENDING_HIGHLIGHT_THREAD_ID) return null;
const isFocused = rect.focused;
const isHovered = rect.threadId === hoveredThreadId;
return (
<button
key={`${rect.threadId}-${index}`}
@@ -446,9 +486,12 @@ export function DocumentAnnotationLayer({
data-anchor-state={rect.anchorState}
data-status={rect.status}
data-focused={isFocused || undefined}
data-hovered={isHovered || undefined}
aria-label="Open annotation thread"
className={cn(
"paperclip-doc-annotation-hit-target pointer-events-auto absolute cursor-pointer rounded-none bg-transparent",
"paperclip-doc-annotation-hit-target pointer-events-auto absolute cursor-pointer rounded-none bg-transparent transition-colors",
// Tint the run on hover so it's obvious which highlight you're over.
isHovered && "bg-amber-400/40 dark:bg-amber-300/30",
isFocused && "ring-1 ring-transparent",
)}
style={{
@@ -457,6 +500,10 @@ export function DocumentAnnotationLayer({
width: rect.width,
height: rect.height,
}}
onMouseEnter={() => setHoveredThreadId(rect.threadId)}
onMouseLeave={() =>
setHoveredThreadId((current) => (current === rect.threadId ? null : current))
}
onMouseDown={(event) => {
event.preventDefault();
onThreadFocus(rect.threadId);
+335 -128
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
DocumentAnnotationComment,
DocumentAnnotationThreadStatus,
@@ -13,7 +13,6 @@ import {
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
@@ -22,26 +21,23 @@ import {
} from "@/components/ui/dropdown-menu";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, relativeTime } from "@/lib/utils";
import { documentAnnotationsApi } from "@/api/document-annotations";
import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations";
import { authApi } from "@/api/auth";
import { queryKeys } from "@/lib/queryKeys";
import { AgentIcon } from "./AgentIconPicker";
import { deriveInitials } from "./Identity";
import { MarkdownBody } from "./MarkdownBody";
import type { PendingAnchor } from "./DocumentAnnotationLayer";
import type { Agent } from "@paperclipai/shared";
import type { CompanyUserProfile } from "@/lib/company-members";
type AnnotationFilter = "open" | "resolved" | "stale" | "orphan";
const FILTERS: { id: AnnotationFilter; label: string }[] = [
{ id: "open", label: "Open" },
{ id: "resolved", label: "Resolved" },
{ id: "stale", label: "Stale" },
{ id: "orphan", label: "Orphaned" },
];
export interface AnnotationPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
issueId: string;
target?: DocumentAnnotationTarget;
issueId?: string;
documentKey: string;
documentRevisionNumber: number;
baseRevisionId: string | null;
@@ -63,7 +59,7 @@ export interface AnnotationPanelProps {
desktopWidth?: number;
className?: string;
/** Resolve `<authorAgentId>` to a display name. */
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
/** Resolve `<authorUserId>` to a display name. */
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}
@@ -107,76 +103,195 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
function AnnotationPanelBody(props: AnnotationPanelProps) {
const queryClient = useQueryClient();
const [filter, setFilter] = useState<AnnotationFilter>("open");
const [composerValue, setComposerValue] = useState("");
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
const [mutationError, setMutationError] = useState<string | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const bodyTestId = props.isMobile ? "document-annotation-panel" : undefined;
const annotationTarget = useMemo<DocumentAnnotationTarget>(() => {
if (props.target) return props.target;
if (!props.issueId) throw new Error("Document annotation panel requires an annotation target.");
return { kind: "issue", issueId: props.issueId, documentKey: props.documentKey };
}, [props.documentKey, props.issueId, props.target]);
const filteredThreads = useMemo(() => {
return props.threads.filter((thread) => {
if (filter === "open") return thread.status === "open" && thread.anchorState !== "orphaned";
if (filter === "resolved") return thread.status === "resolved";
if (filter === "stale") return thread.anchorState === "stale";
if (filter === "orphan") return thread.anchorState === "orphaned";
return true;
});
}, [props.threads, filter]);
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
staleTime: 5 * 60_000,
});
const currentUser = useMemo(() => {
const user = session?.user;
return {
id: user?.id ?? null,
name: user?.name?.trim() || user?.email?.trim() || "You",
image: user?.image ?? null,
};
}, [session]);
const counts = useMemo(() => {
const result = { open: 0, resolved: 0, stale: 0, orphan: 0 };
for (const thread of props.threads) {
if (thread.status === "resolved") result.resolved += 1;
if (thread.anchorState === "stale") result.stale += 1;
if (thread.anchorState === "orphaned") result.orphan += 1;
if (thread.status === "open" && thread.anchorState !== "orphaned") result.open += 1;
}
return result;
}, [props.threads]);
// Show every thread that can be anchored in the document (orphaned threads have
// lost their anchor). Filters were removed in favour of a single simple list.
// Sort in document order (top-to-bottom) — not by recency — so the comment list
// stays congruent with the highlights as you scroll the document.
const visibleThreads = useMemo(
() =>
props.threads
.filter((thread) => thread.anchorState !== "orphaned")
.sort((a, b) =>
(a.normalizedStart - b.normalizedStart)
|| (a.markdownStart - b.markdownStart)
|| (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())),
[props.threads],
);
const annotationsQueryKey = useMemo(
() => annotationTarget.kind === "routine"
? queryKeys.routines.documentAnnotations(annotationTarget.routineId, annotationTarget.documentKey, "all")
: queryKeys.issues.documentAnnotations(annotationTarget.issueId, annotationTarget.documentKey, "all"),
[annotationTarget],
);
const invalidateAll = useCallback(() => {
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey)
&& query.queryKey[0] === "issues"
&& query.queryKey[1] === "document-annotations"
&& query.queryKey[2] === props.issueId
&& query.queryKey[3] === props.documentKey,
predicate: (query) => {
if (!Array.isArray(query.queryKey)) return false;
if (annotationTarget.kind === "routine") {
return query.queryKey[0] === "routines"
&& query.queryKey[1] === "document-annotations"
&& query.queryKey[2] === annotationTarget.routineId
&& query.queryKey[3] === annotationTarget.documentKey;
}
return query.queryKey[0] === "issues"
&& query.queryKey[1] === "document-annotations"
&& query.queryKey[2] === annotationTarget.issueId
&& query.queryKey[3] === annotationTarget.documentKey;
},
});
}, [props.documentKey, props.issueId, queryClient]);
}, [annotationTarget, queryClient]);
const createThread = useMutation({
mutationFn: async (body: string) => {
if (!props.pendingAnchor) throw new Error("No selection to anchor to.");
if (!props.baseRevisionId) throw new Error("Document has no revision yet.");
return documentAnnotationsApi.create(props.issueId, props.documentKey, {
return documentAnnotationsApi.createForTarget(annotationTarget, {
baseRevisionId: props.baseRevisionId,
baseRevisionNumber: props.baseRevisionNumber,
selector: props.pendingAnchor.selector,
body,
});
},
onSuccess: (thread) => {
// Optimistically drop the new thread into the cache so submission feels instant.
onMutate: async (body: string) => {
const anchor = props.pendingAnchor;
if (!anchor || !props.baseRevisionId) return undefined;
setMutationError(null);
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
const optimisticThread = buildOptimisticThread({
body,
selectedText: anchor.selectedText,
target: annotationTarget,
documentKey: annotationTarget.documentKey,
baseRevisionId: props.baseRevisionId,
baseRevisionNumber: props.baseRevisionNumber,
normalizedStart: anchor.selector.position.normalizedStart,
markdownStart: anchor.selector.position.markdownStart,
author: currentUser,
});
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
annotationsQueryKey,
(current) => [...(current ?? []), optimisticThread],
);
props.onFocusThread(optimisticThread.id);
return { previous, optimisticId: optimisticThread.id };
},
onError: (error, _body, context) => {
if (context?.previous) {
queryClient.setQueryData(annotationsQueryKey, context.previous);
}
setMutationError(error instanceof Error && error.message
? error.message
: "Failed to create comment.");
},
onSuccess: (thread, _body, context) => {
// Swap the optimistic placeholder for the real thread before refetch settles.
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
annotationsQueryKey,
(current) => (current ?? []).map((entry) =>
entry.id === context?.optimisticId ? thread : entry,
),
);
props.onClearPendingAnchor();
setComposerValue("");
setMutationError(null);
props.onFocusThread(thread.id);
invalidateAll();
},
onSettled: () => invalidateAll(),
});
const addReply = useMutation({
mutationFn: ({ threadId, body }: { threadId: string; body: string }) =>
documentAnnotationsApi.addComment(props.issueId, props.documentKey, threadId, { body }),
onSuccess: (_data, variables) => {
setReplyDrafts((current) => ({ ...current, [variables.threadId]: "" }));
invalidateAll();
documentAnnotationsApi.addCommentForTarget(annotationTarget, threadId, { body }),
// Optimistically append the reply so it stays on screen through the round-trip.
onMutate: async ({ threadId, body }) => {
setMutationError(null);
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
const optimisticComment = buildOptimisticComment({
body,
threadId,
target: annotationTarget,
author: currentUser,
});
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
annotationsQueryKey,
(current) => (current ?? []).map((thread) =>
thread.id === threadId
? { ...thread, comments: [...thread.comments, optimisticComment], updatedAt: optimisticComment.createdAt }
: thread,
),
);
return { previous };
},
onError: (error, _variables, context) => {
if (context?.previous) {
queryClient.setQueryData(annotationsQueryKey, context.previous);
}
setMutationError(error instanceof Error && error.message
? error.message
: "Failed to add reply.");
},
onSuccess: (_comment, variables) => {
setReplyDrafts((current) => ({ ...current, [variables.threadId]: "" }));
setMutationError(null);
},
onSettled: () => invalidateAll(),
});
const updateStatus = useMutation({
mutationFn: ({ threadId, status }: { threadId: string; status: DocumentAnnotationThreadStatus }) =>
documentAnnotationsApi.updateStatus(props.issueId, props.documentKey, threadId, status),
onSuccess: () => invalidateAll(),
documentAnnotationsApi.updateStatusForTarget(annotationTarget, threadId, status),
onMutate: async ({ threadId, status }) => {
setMutationError(null);
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
annotationsQueryKey,
(current) => (current ?? []).map((thread) =>
thread.id === threadId ? { ...thread, status } : thread,
),
);
return { previous };
},
onError: (error, _variables, context) => {
if (context?.previous) {
queryClient.setQueryData(annotationsQueryKey, context.previous);
}
setMutationError(error instanceof Error && error.message
? error.message
: "Failed to update comment status.");
},
onSuccess: () => setMutationError(null),
onSettled: () => invalidateAll(),
});
useEffect(() => {
@@ -191,28 +306,31 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
}
}, [props.open, props.pendingAnchor]);
// Keep the comment list congruent with the document: when a thread becomes
// focused — whether by clicking its highlight in the doc or by adding a new
// comment — scroll that card into view in the pane.
const listScrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!props.focusedThreadId) return;
const focused = props.threads.find((thread) => thread.id === props.focusedThreadId);
if (!focused) return;
if (focused.anchorState === "orphaned") setFilter("orphan");
else if (focused.anchorState === "stale") setFilter("stale");
else if (focused.status === "resolved") setFilter("resolved");
else setFilter("open");
}, [props.focusedThreadId, props.threads]);
const container = listScrollRef.current;
if (!container) return;
const card = container.querySelector<HTMLElement>(
`[data-thread-id="${props.focusedThreadId}"]`,
);
if (card && typeof card.scrollIntoView === "function") {
card.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [props.focusedThreadId, visibleThreads]);
return (
<>
<header
<div
data-testid={bodyTestId}
className="flex items-start justify-between gap-2 border-b border-border bg-popover px-3 py-2.5"
className="flex items-center justify-end gap-1 border-b border-border bg-popover px-2 py-1.5"
>
<div className="min-w-0 leading-tight">
<p className="text-sm font-medium">Comments</p>
<p className="text-[11px] text-muted-foreground">
rev {props.documentRevisionNumber}
</p>
</div>
<span className="text-[11px] tabular-nums text-muted-foreground">
rev {props.documentRevisionNumber}
</span>
<Button
type="button"
size="icon-xs"
@@ -226,31 +344,6 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
>
<X className="h-4 w-4" />
</Button>
</header>
<div className="flex flex-wrap gap-1 border-b border-border bg-popover px-3 py-2">
{FILTERS.map((entry) => {
const count = counts[entry.id];
const isActive = filter === entry.id;
return (
<button
key={entry.id}
type="button"
onClick={() => setFilter(entry.id)}
data-active={isActive || undefined}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors",
isActive
? "border-border bg-muted text-foreground"
: "border-transparent bg-transparent text-muted-foreground hover:bg-muted/60 hover:text-foreground",
)}
>
<span>{entry.label}</span>
<span className={cn("tabular-nums", isActive ? "text-muted-foreground" : "text-muted-foreground/70")}>
{count}
</span>
</button>
);
})}
</div>
{props.newCommentDisabled && props.newCommentDisabledReason ? (
<p
@@ -260,14 +353,18 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
{props.newCommentDisabledReason}
</p>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto bg-popover px-3 py-2">
{filteredThreads.length === 0 ? (
<p className="py-8 text-center text-xs text-muted-foreground">
{filter === "open" ? "No open comments yet. Select text to add one." : `No ${filter} comments.`}
</p>
) : (
{mutationError ? (
<p
data-testid="document-annotation-error"
className="border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive"
>
{mutationError}
</p>
) : null}
<div ref={listScrollRef} className="min-h-0 flex-1 overflow-y-auto bg-popover px-3 py-2">
{visibleThreads.length === 0 ? null : (
<ul className="space-y-2">
{filteredThreads.map((thread) => (
{visibleThreads.map((thread) => (
<ThreadCard
key={thread.id}
thread={thread}
@@ -303,24 +400,40 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
</div>
{props.pendingAnchor ? (
<div className="border-t border-border bg-popover px-3 py-2">
<blockquote className="mb-2 line-clamp-3 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic text-muted-foreground">
<blockquote className="mb-2 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic leading-5 text-muted-foreground [overflow-wrap:anywhere]">
{truncate(props.pendingAnchor.selectedText, 160)}
</blockquote>
<div className="mb-1.5 flex items-center gap-1.5">
<Avatar size="xs" className="shrink-0">
{currentUser.image ? <AvatarImage src={currentUser.image} alt={currentUser.name} /> : null}
<AvatarFallback>{deriveInitials(currentUser.name)}</AvatarFallback>
</Avatar>
<span className="truncate text-[11px] font-medium text-foreground">{currentUser.name}</span>
</div>
<Textarea
ref={composerRef}
data-testid="document-annotation-composer"
rows={3}
value={composerValue}
onChange={(event) => setComposerValue(event.target.value)}
onKeyDown={(event) => {
if (isSubmitShortcut(event)) {
event.preventDefault();
const body = composerValue.trim();
if (
body
&& !createThread.isPending
&& !props.newCommentDisabled
&& props.baseRevisionId
) {
createThread.mutate(body);
}
}
}}
placeholder="Write a comment…"
disabled={props.newCommentDisabled}
className="resize-y rounded-none text-sm"
/>
{createThread.isError ? (
<p className="mt-1 text-xs text-destructive">
{(createThread.error as Error).message || "Failed to create comment"}
</p>
) : null}
<div className="mt-2 flex items-center justify-end gap-2">
<Button
type="button"
@@ -365,18 +478,10 @@ function ThreadCard(props: {
onCopyLink: () => void;
pendingReply: boolean;
pendingStatus: boolean;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}) {
const { thread } = props;
const statusVariant: { variant: "default" | "outline" | "secondary"; label: string } =
thread.status === "resolved"
? { variant: "outline", label: "Resolved" }
: thread.anchorState === "orphaned"
? { variant: "outline", label: "Orphaned" }
: thread.anchorState === "stale"
? { variant: "outline", label: "Stale" }
: { variant: "default", label: "Open" };
const latestComment = thread.comments[thread.comments.length - 1];
return (
@@ -389,23 +494,17 @@ function ThreadCard(props: {
data-focused={props.expanded || undefined}
aria-labelledby={`thread-quote-${thread.id}`}
className={cn(
"rounded-none border border-border bg-background transition-colors",
props.expanded && "ring-1 ring-ring/70",
"scroll-mt-2 rounded-none border border-border bg-background transition-colors",
props.expanded && "ring-2 ring-primary/80 ring-offset-1 ring-offset-popover",
thread.status === "resolved" && "bg-muted",
)}
tabIndex={0}
onClick={props.onFocus}
>
<div className="flex items-center justify-between gap-2 px-3 pt-2 text-[11px] text-muted-foreground">
<Badge variant={statusVariant.variant} className="px-1.5 py-0 text-[10px] uppercase tracking-[0.12em]">
{statusVariant.label}
</Badge>
<span>{relativeTime(thread.updatedAt)}</span>
</div>
<blockquote
id={`thread-quote-${thread.id}`}
className={cn(
"mx-3 mt-1 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic text-muted-foreground",
"mx-3 mt-2 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic leading-5 text-muted-foreground [overflow-wrap:anywhere]",
(thread.anchorState === "stale" || thread.status === "resolved") && "bg-muted",
)}
>
@@ -427,6 +526,14 @@ function ThreadCard(props: {
rows={2}
value={props.replyDraft}
onChange={(event) => props.onReplyChange(event.target.value)}
onKeyDown={(event) => {
if (isSubmitShortcut(event)) {
event.preventDefault();
if (props.replyDraft.trim() && !props.pendingReply) {
props.onSubmitReply();
}
}
}}
placeholder="Reply…"
className="resize-y rounded-none text-sm"
disabled={props.pendingReply}
@@ -506,7 +613,7 @@ function CommentRow({
}: {
comment: DocumentAnnotationComment;
focused: boolean;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}) {
const author = resolveAuthor(comment, { agentMap, userProfileMap });
@@ -520,31 +627,49 @@ function CommentRow({
)}
>
<div className="mb-0.5 flex items-center justify-between gap-2 text-[11px]">
<span className="min-w-0 truncate">
<span className="font-medium text-foreground">{author.name}</span>
<span className="flex min-w-0 items-center gap-1.5">
<Avatar size="xs" className="shrink-0">
{author.role === "agent" ? (
<AvatarFallback>
<AgentIcon icon={author.agentIcon} className="h-3 w-3" />
</AvatarFallback>
) : (
<>
{author.imageUrl ? <AvatarImage src={author.imageUrl} alt={author.name} /> : null}
<AvatarFallback>{deriveInitials(author.name)}</AvatarFallback>
</>
)}
</Avatar>
<span className="truncate font-medium text-foreground">{author.name}</span>
{author.role === "agent" ? (
<span className="ml-1 text-muted-foreground">· agent</span>
<span className="text-muted-foreground">· agent</span>
) : null}
</span>
<span className="text-muted-foreground">{relativeTime(comment.createdAt)}</span>
<span className="shrink-0 text-muted-foreground">{relativeTime(comment.createdAt)}</span>
</div>
<MarkdownBody className="text-sm leading-6">{comment.body}</MarkdownBody>
</div>
);
}
/** ⌘/Ctrl + Enter submits the composer or reply. */
function isSubmitShortcut(event: React.KeyboardEvent<HTMLTextAreaElement>): boolean {
return event.key === "Enter" && (event.metaKey || event.ctrlKey);
}
function resolveAuthor(
comment: DocumentAnnotationComment,
maps: {
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
},
): { name: string; role: "board" | "agent" } {
): { name: string; role: "board" | "agent"; agentIcon?: Agent["icon"]; imageUrl?: string | null } {
if (comment.authorAgentId) {
const agent = maps.agentMap?.get(comment.authorAgentId);
return {
name: agent?.name ?? comment.authorAgentId.slice(0, 8),
role: "agent",
agentIcon: agent?.icon,
};
}
if (comment.authorUserId) {
@@ -552,11 +677,93 @@ function resolveAuthor(
return {
name: profile?.label ?? comment.authorUserId.slice(0, 8),
role: "board",
imageUrl: profile?.image ?? null,
};
}
return { name: comment.authorType === "agent" ? "Agent" : "Board", role: comment.authorType === "agent" ? "agent" : "board" };
}
interface OptimisticAuthor {
id: string | null;
name: string;
image: string | null;
}
function optimisticId(prefix: string): string {
const random = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
return `${prefix}-${random}`;
}
function buildOptimisticComment(input: {
body: string;
threadId: string;
target: DocumentAnnotationTarget;
author: OptimisticAuthor;
}): DocumentAnnotationComment {
const now = new Date();
return {
id: optimisticId("optimistic-comment"),
companyId: "",
threadId: input.threadId,
issueId: input.target.kind === "issue" ? input.target.issueId : null,
routineId: input.target.kind === "routine" ? input.target.routineId : null,
documentId: "",
body: input.body,
authorType: "user",
authorAgentId: null,
authorUserId: input.author.id,
createdByRunId: null,
issueCommentId: null,
createdAt: now,
updatedAt: now,
};
}
function buildOptimisticThread(input: {
body: string;
selectedText: string;
target: DocumentAnnotationTarget;
documentKey: string;
baseRevisionId: string;
baseRevisionNumber: number;
normalizedStart: number;
markdownStart: number;
author: OptimisticAuthor;
}): DocumentAnnotationThreadWithComments {
const id = optimisticId("optimistic-thread");
const now = new Date();
const comment = buildOptimisticComment({
body: input.body,
threadId: id,
target: input.target,
author: input.author,
});
// Only the fields the panel + overlay read need to be accurate; the optimistic
// thread is swapped for the server copy on success. Cast through unknown so we
// don't have to fabricate every backend-only column.
return {
id,
issueId: input.target.kind === "issue" ? input.target.issueId : null,
routineId: input.target.kind === "routine" ? input.target.routineId : null,
documentKey: input.documentKey,
status: "open",
anchorState: "active",
selectedText: input.selectedText,
normalizedStart: input.normalizedStart,
markdownStart: input.markdownStart,
originalRevisionId: input.baseRevisionId,
originalRevisionNumber: input.baseRevisionNumber,
currentRevisionId: input.baseRevisionId,
currentRevisionNumber: input.baseRevisionNumber,
createdByUserId: input.author.id,
createdAt: now,
updatedAt: now,
comments: [comment],
} as unknown as DocumentAnnotationThreadWithComments;
}
function truncate(value: string, limit: number) {
if (value.length <= limit) return value;
return `${value.slice(0, limit - 1)}…`;
@@ -13,13 +13,31 @@ import {
IssueDocumentAnnotations,
} from "./IssueDocumentAnnotations";
const mockAnnotationsApi = vi.hoisted(() => ({
list: vi.fn(),
get: vi.fn(),
create: vi.fn(),
addComment: vi.fn(),
updateStatus: vi.fn(),
}));
const mockAnnotationsApi = vi.hoisted(() => {
const api = {
list: vi.fn(),
listForTarget: vi.fn(),
get: vi.fn(),
getForTarget: vi.fn(),
create: vi.fn(),
createForTarget: vi.fn(),
addComment: vi.fn(),
addCommentForTarget: vi.fn(),
updateStatus: vi.fn(),
updateStatusForTarget: vi.fn(),
};
api.listForTarget.mockImplementation((target, options) =>
target.kind === "issue" ? api.list(target.issueId, target.documentKey, options) : api.list(target.routineId, target.documentKey, options));
api.getForTarget.mockImplementation((target, threadId) =>
target.kind === "issue" ? api.get(target.issueId, target.documentKey, threadId) : api.get(target.routineId, target.documentKey, threadId));
api.createForTarget.mockImplementation((target, data) =>
target.kind === "issue" ? api.create(target.issueId, target.documentKey, data) : api.create(target.routineId, target.documentKey, data));
api.addCommentForTarget.mockImplementation((target, threadId, data) =>
target.kind === "issue" ? api.addComment(target.issueId, target.documentKey, threadId, data) : api.addComment(target.routineId, target.documentKey, threadId, data));
api.updateStatusForTarget.mockImplementation((target, threadId, status) =>
target.kind === "issue" ? api.updateStatus(target.issueId, target.documentKey, threadId, status) : api.updateStatus(target.routineId, target.documentKey, threadId, status));
return api;
});
const mockPendingAnchor = vi.hoisted(() => ({
selector: {
@@ -110,6 +128,12 @@ function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
function dispatchSubmitShortcut(textarea: HTMLTextAreaElement) {
textarea.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }),
);
}
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
@@ -342,6 +366,61 @@ describe("IssueDocumentAnnotations", () => {
}
});
it("offsets the desktop annotation panel from the document with a left margin when there is room", async () => {
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
const rectFor = (left: number, top: number, right: number, bottom: number) => ({
x: left,
y: top,
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
toJSON: () => ({}),
});
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
if (this instanceof HTMLElement && this.id === "main-content") {
return rectFor(0, 0, 1400, 800);
}
if (
this instanceof HTMLElement
&& this.getAttribute("data-testid") === "document-annotation-body-plan"
) {
return rectFor(80, 120, 640, 620);
}
return originalGetBoundingClientRect.call(this);
});
const root = createRoot(container);
const queryClient = makeQueryClient();
const doc = makeDoc();
try {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<main id="main-content">
<Harness doc={doc} initialPanelOpen />
</main>
</QueryClientProvider>,
);
});
await flush();
await flush();
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]') as HTMLElement | null;
expect(anchor).not.toBeNull();
// The document body ends at 640; the panel should clear it with a margin
// rather than sitting flush against the document's right edge.
expect(parseFloat(anchor!.style.left)).toBeGreaterThan(640);
expect(anchor!.style.left).toBe("664px");
} finally {
rectSpy.mockRestore();
}
});
it("auto-opens the panel and focuses the thread when deep-linked", async () => {
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "thread-99" })]);
const root = createRoot(container);
@@ -387,10 +466,11 @@ describe("IssueDocumentAnnotations", () => {
expect(reason!.textContent).toMatch(/draft/i);
});
it("filters resolved threads behind their tab", async () => {
it("shows open and resolved threads together in a single list (no filter tabs)", async () => {
mockAnnotationsApi.list.mockResolvedValue([
makeThread({ id: "open-1" }),
makeThread({ id: "resolved-1", status: "resolved" }),
makeThread({ id: "orphan-1", anchorState: "orphaned" }),
]);
const root = createRoot(container);
const queryClient = makeQueryClient();
@@ -406,19 +486,43 @@ describe("IssueDocumentAnnotations", () => {
await flush();
await flush();
// Open filter shows only open
// Open + resolved both render without any filter interaction.
expect(container.querySelector('[data-thread-id="open-1"]')).not.toBeNull();
expect(container.querySelector('[data-thread-id="resolved-1"]')).toBeNull();
expect(container.querySelector('[data-thread-id="resolved-1"]')).not.toBeNull();
// Orphaned threads can't be anchored in the doc, so they stay hidden.
expect(container.querySelector('[data-thread-id="orphan-1"]')).toBeNull();
// Switch to Resolved
const resolvedTab = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.startsWith("Resolved"),
// The Open/Resolved/Stale/Orphaned filter chips are gone.
const filterChip = Array.from(container.querySelectorAll("button")).find((button) =>
["Open", "Resolved", "Stale", "Orphaned"].includes((button.textContent ?? "").trim()),
);
expect(resolvedTab).not.toBeUndefined();
await act(async () => resolvedTab!.click());
expect(filterChip).toBeUndefined();
});
it("orders threads by document position, not API/recency order", async () => {
// Returned out of document order: later-in-doc first, earlier-in-doc last.
mockAnnotationsApi.list.mockResolvedValue([
makeThread({ id: "thread-late", normalizedStart: 900, markdownStart: 900 }),
makeThread({ id: "thread-early", normalizedStart: 10, markdownStart: 10 }),
makeThread({ id: "thread-mid", normalizedStart: 400, markdownStart: 400 }),
]);
const root = createRoot(container);
const queryClient = makeQueryClient();
const doc = makeDoc();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Harness doc={doc} initialPanelOpen />
</QueryClientProvider>,
);
});
await flush();
await flush();
expect(container.querySelector('[data-thread-id="resolved-1"]')).not.toBeNull();
const order = Array.from(container.querySelectorAll("[data-thread-id]"))
.map((el) => el.getAttribute("data-thread-id"));
expect(order).toEqual(["thread-early", "thread-mid", "thread-late"]);
});
it("renders author name + role from agent and user maps", async () => {
@@ -500,11 +604,15 @@ describe("IssueDocumentAnnotations", () => {
await act(async () => threadCard!.click());
await flush();
const expandedText = container.querySelector('[data-thread-id="open-1"]')?.textContent ?? "";
const expandedThread = container.querySelector('[data-thread-id="open-1"]');
const expandedText = expandedThread?.textContent ?? "";
expect(expandedText).toContain("Dotta");
expect(expandedText).not.toContain("· board");
expect(expandedText).toContain("UXDesigner");
expect(expandedText).toContain("· agent");
// Each rendered comment shows an author avatar.
const avatars = expandedThread?.querySelectorAll('[data-slot="avatar"]') ?? [];
expect(avatars.length).toBe(2);
});
it("does not render a persistent New comment on selection hint when panel is open", async () => {
@@ -624,6 +732,173 @@ describe("IssueDocumentAnnotations", () => {
expect(mockAnnotationsApi.list.mock.calls.length).toBeGreaterThan(1);
});
it("keeps the composer visible with the draft when creating a thread fails", async () => {
mockAnnotationsApi.list.mockResolvedValue([]);
mockAnnotationsApi.create.mockRejectedValue(new Error("Annotation anchor does not match the current document revision"));
const root = createRoot(container);
const queryClient = makeQueryClient();
const doc = makeDoc();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Harness doc={doc} initialPanelOpen />
</QueryClientProvider>,
);
});
await flush();
await flush();
const selectButton = container.querySelector('[data-testid="mock-annotation-selection"]') as HTMLButtonElement | null;
expect(selectButton).not.toBeNull();
await act(async () => {
selectButton!.click();
});
await flush();
const composer = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null;
expect(composer).not.toBeNull();
await act(async () => {
setTextareaValue(composer!, "New anchored comment");
});
await flush();
const submit = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Comment",
);
expect(submit).not.toBeUndefined();
await act(async () => {
submit!.click();
});
await flush();
await flush();
const composerAfterFailure = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null;
expect(composerAfterFailure).not.toBeNull();
expect(composerAfterFailure!.value).toBe("New anchored comment");
expect(container.querySelector('[data-testid="document-annotation-error"]')?.textContent)
.toContain("Annotation anchor does not match the current document revision");
});
it("submits a new anchored comment with ⌘↵", async () => {
mockAnnotationsApi.list.mockResolvedValue([]);
mockAnnotationsApi.create.mockResolvedValue(makeThread({ id: "created-1" }));
const root = createRoot(container);
const queryClient = makeQueryClient();
const doc = makeDoc();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Harness doc={doc} initialPanelOpen />
</QueryClientProvider>,
);
});
await flush();
await flush();
const selectButton = container.querySelector('[data-testid="mock-annotation-selection"]') as HTMLButtonElement | null;
await act(async () => selectButton!.click());
await flush();
const composer = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null;
expect(composer).not.toBeNull();
await act(async () => setTextareaValue(composer!, "Submitted via shortcut"));
await flush();
await act(async () => dispatchSubmitShortcut(composer!));
await flush();
await flush();
expect(mockAnnotationsApi.create).toHaveBeenCalledWith("issue-1", "plan", {
baseRevisionId: "rev-4",
baseRevisionNumber: 4,
selector: mockPendingAnchor.selector,
body: "Submitted via shortcut",
});
});
it("submits a reply with ⌘↵", async () => {
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "open-1" })]);
mockAnnotationsApi.addComment.mockResolvedValue(makeThread({ id: "open-1" }).comments[0]);
const root = createRoot(container);
const queryClient = makeQueryClient();
const doc = makeDoc();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Harness doc={doc} initialPanelOpen />
</QueryClientProvider>,
);
});
await flush();
await flush();
const openThread = container.querySelector('[data-thread-id="open-1"]') as HTMLElement | null;
await act(async () => openThread!.click());
await flush();
const reply = container.querySelector(
'[data-testid="document-annotation-reply-open-1"]',
) as HTMLTextAreaElement | null;
expect(reply).not.toBeNull();
await act(async () => setTextareaValue(reply!, "Replying via shortcut"));
await flush();
await act(async () => dispatchSubmitShortcut(reply!));
await flush();
await flush();
expect(mockAnnotationsApi.addComment).toHaveBeenCalledWith("issue-1", "plan", "open-1", {
body: "Replying via shortcut",
});
});
it("keeps a reply draft visible when submitting the reply fails", async () => {
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "open-1" })]);
mockAnnotationsApi.addComment.mockRejectedValue(new Error("Failed to add reply"));
const root = createRoot(container);
const queryClient = makeQueryClient();
const doc = makeDoc();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Harness doc={doc} initialPanelOpen />
</QueryClientProvider>,
);
});
await flush();
await flush();
const openThread = container.querySelector('[data-thread-id="open-1"]') as HTMLElement | null;
expect(openThread).not.toBeNull();
await act(async () => openThread!.click());
await flush();
const reply = container.querySelector(
'[data-testid="document-annotation-reply-open-1"]',
) as HTMLTextAreaElement | null;
expect(reply).not.toBeNull();
await act(async () => setTextareaValue(reply!, "Reply should stay visible"));
await flush();
const replyButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Reply",
);
expect(replyButton).not.toBeUndefined();
await act(async () => replyButton!.click());
await flush();
await flush();
const replyAfterFailure = container.querySelector(
'[data-testid="document-annotation-reply-open-1"]',
) as HTMLTextAreaElement | null;
expect(replyAfterFailure).not.toBeNull();
expect(replyAfterFailure!.value).toBe("Reply should stay visible");
expect(container.querySelector('[data-testid="document-annotation-error"]')?.textContent)
.toContain("Failed to add reply");
});
it("shows resolve and reopen actions and updates thread status", async () => {
mockAnnotationsApi.list.mockResolvedValue([
makeThread({ id: "open-1" }),
@@ -657,13 +932,7 @@ describe("IssueDocumentAnnotations", () => {
await flush();
expect(mockAnnotationsApi.updateStatus).toHaveBeenCalledWith("issue-1", "plan", "open-1", "resolved");
const resolvedTab = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.startsWith("Resolved"),
);
expect(resolvedTab).not.toBeUndefined();
await act(async () => resolvedTab!.click());
await flush();
// Resolved threads stay in the same list (filter tabs were removed).
const resolvedThread = container.querySelector('[data-thread-id="resolved-1"]') as HTMLElement | null;
expect(resolvedThread).not.toBeNull();
await act(async () => resolvedThread!.click());
+60 -15
View File
@@ -4,7 +4,7 @@ import type { Agent, DocumentAnnotationThreadWithComments, IssueDocument } from
import { MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { documentAnnotationsApi } from "@/api/document-annotations";
import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations";
import { queryKeys } from "@/lib/queryKeys";
import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
import { DocumentAnnotationLayer, type PendingAnchor } from "./DocumentAnnotationLayer";
@@ -13,12 +13,15 @@ import type { CompanyUserProfile } from "@/lib/company-members";
const DESKTOP_ANNOTATION_PANEL_WIDTH = 360;
const DESKTOP_ANNOTATION_PANEL_MIN_WIDTH = 280;
const DESKTOP_ANNOTATION_PANEL_GAP = 12;
const DESKTOP_ANNOTATION_PANEL_GAP = 24;
const DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN = 16;
type AnnotationDocument = Pick<IssueDocument, "key" | "latestRevisionId" | "latestRevisionNumber">;
export interface IssueDocumentAnnotationsProps {
issueId: string;
doc: IssueDocument;
doc: AnnotationDocument;
target?: DocumentAnnotationTarget;
/** The body that is being rendered/edited (current or historical revision). */
bodyMarkdown: string;
/** True when a draft has unsaved changes or is currently saving. */
@@ -34,7 +37,7 @@ export interface IssueDocumentAnnotationsProps {
/** Controlled panel state. Caller owns this so the count chip can live in the doc header. */
panelOpen: boolean;
onPanelOpenChange: (open: boolean) => void;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
/** Seed which thread is focused on mount. Used by Storybook/screenshot harness. */
defaultFocusedThreadId?: string;
@@ -43,6 +46,7 @@ export interface IssueDocumentAnnotationsProps {
export function IssueDocumentAnnotations({
issueId,
doc,
target,
bodyMarkdown,
draftDirty,
draftConflicted,
@@ -105,7 +109,12 @@ export function IssueDocumentAnnotations({
boundaryWidth - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
);
const desiredWidth = Math.min(DESKTOP_ANNOTATION_PANEL_WIDTH, maxPanelWidth);
const top = Math.max(DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN, rect.top);
// Clamp the panel below the sticky top nav (the scroll container's top edge)
// so the comments thread never tucks under the nav bar while scrolling.
const boundaryTop = boundaryRect?.top ?? DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
const minTop = Math.max(DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN, boundaryTop)
+ DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
const top = Math.max(minTop, rect.top);
const desiredLeft = rect.right + DESKTOP_ANNOTATION_PANEL_GAP;
const spaceRightOfDocument = boundaryRight
- desiredLeft
@@ -147,8 +156,12 @@ export function IssueDocumentAnnotations({
}, [doc.key, isMobile, panelOpen]);
const annotationsQuery = useQuery({
queryKey: queryKeys.issues.documentAnnotations(issueId, doc.key, "all"),
queryFn: () => documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }),
queryKey: target?.kind === "routine"
? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all")
: queryKeys.issues.documentAnnotations(issueId, doc.key, "all"),
queryFn: () => target
? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true })
: documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }),
staleTime: 30_000,
});
const allThreads = annotationsQuery.data ?? [];
@@ -242,6 +255,30 @@ export function IssueDocumentAnnotations({
[allThreads],
);
const fallbackDesktopPanelFrame = useMemo(() => {
if (!panelOpen || isMobile || desktopPanelFrame || typeof window === "undefined") return null;
const width = Math.min(
DESKTOP_ANNOTATION_PANEL_WIDTH,
Math.max(
DESKTOP_ANNOTATION_PANEL_MIN_WIDTH,
window.innerWidth - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
),
);
return {
left: Math.max(
DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
window.innerWidth - width - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
),
top: DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
maxHeight: Math.max(
240,
window.innerHeight - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
),
width,
};
}, [desktopPanelFrame, isMobile, panelOpen]);
const renderedDesktopPanelFrame = desktopPanelFrame ?? fallbackDesktopPanelFrame;
const annotationPanel = panelOpen ? (
<DocumentAnnotationPanel
open={panelOpen}
@@ -255,6 +292,7 @@ export function IssueDocumentAnnotations({
}
}}
issueId={issueId}
target={target}
documentKey={doc.key}
documentRevisionNumber={doc.latestRevisionNumber}
baseRevisionId={doc.latestRevisionId}
@@ -272,7 +310,7 @@ export function IssueDocumentAnnotations({
newCommentDisabled={newCommentDisabled}
newCommentDisabledReason={newCommentDisabledReason}
isMobile={isMobile}
desktopWidth={desktopPanelFrame?.width}
desktopWidth={renderedDesktopPanelFrame?.width}
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
@@ -304,18 +342,19 @@ export function IssueDocumentAnnotations({
newCommentDisabledReason={newCommentDisabledReason}
hideResolved
captureSelectionRequestId={captureSelectionRequestId}
pendingHighlightText={composerAnchor?.selectedText ?? null}
/>
) : null}
</section>
{panelOpen && !isMobile && desktopPanelFrame ? (
{panelOpen && !isMobile && renderedDesktopPanelFrame ? (
<div
data-testid="document-annotation-panel-anchor"
className="pointer-events-auto fixed z-[60] hidden lg:block"
style={{
left: desktopPanelFrame.left,
maxHeight: desktopPanelFrame.maxHeight,
top: desktopPanelFrame.top,
width: desktopPanelFrame.width,
left: renderedDesktopPanelFrame.left,
maxHeight: renderedDesktopPanelFrame.maxHeight,
top: renderedDesktopPanelFrame.top,
width: renderedDesktopPanelFrame.width,
}}
>
{annotationPanel}
@@ -329,6 +368,7 @@ export function IssueDocumentAnnotations({
export interface DocumentAnnotationsCountChipProps {
issueId: string;
docKey: string;
target?: DocumentAnnotationTarget;
panelOpen: boolean;
onToggle: () => void;
}
@@ -340,12 +380,17 @@ export interface DocumentAnnotationsCountChipProps {
export function DocumentAnnotationsCountChip({
issueId,
docKey,
target,
panelOpen,
onToggle,
}: DocumentAnnotationsCountChipProps) {
const annotationsQuery = useQuery({
queryKey: queryKeys.issues.documentAnnotations(issueId, docKey, "all"),
queryFn: () => documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }),
queryKey: target?.kind === "routine"
? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all")
: queryKeys.issues.documentAnnotations(issueId, docKey, "all"),
queryFn: () => target
? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true })
: documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }),
staleTime: 30_000,
});
const threads = annotationsQuery.data ?? [];
+89 -6
View File
@@ -26,6 +26,10 @@ const mockProjectsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
controlRuntimeCommands: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
listLabels: vi.fn(),
@@ -50,6 +54,10 @@ vi.mock("../api/projects", () => ({
projectsApi: mockProjectsApi,
}));
vi.mock("../api/execution-workspaces", () => ({
executionWorkspacesApi: mockExecutionWorkspacesApi,
}));
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
@@ -379,6 +387,7 @@ describe("IssueProperties", () => {
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAgentsApi.adapterModelProfiles.mockResolvedValue([]);
mockProjectsApi.list.mockResolvedValue([]);
mockExecutionWorkspacesApi.controlRuntimeCommands.mockReset();
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listLabels.mockResolvedValue([]);
mockIssuesApi.createLabel.mockResolvedValue(createLabel({
@@ -588,6 +597,11 @@ describe("IssueProperties", () => {
expect(blockerLink).not.toBeNull();
expect(blockerLink?.textContent).toContain("PAP-2");
expect(blockerLink?.closest("button")).toBeNull();
expect(blockerLink?.className).toContain("px-2");
expect(blockerLink?.className).toContain("py-0.5");
expect(blockerLink?.className).toContain("text-xs");
const removeButton = container.querySelector('button[aria-label="Remove PAP-2 as blocker"]');
expect(removeButton?.className).toContain("absolute");
expect(container.textContent).toContain("Add blocker");
expect(container.querySelector('input[placeholder="Search tasks..."]')).toBeNull();
@@ -722,9 +736,17 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("shows a green service link above the workspace row for a live non-main workspace", async () => {
it("shows workspace runtime controls below the workspace row for a non-main workspace", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const serviceUrl = "http://127.0.0.1:62475";
const updatedWorkspace = createExecutionWorkspace({
mode: "isolated_workspace",
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "stopped", stoppedAt: new Date("2026-04-06T12:06:00.000Z") })],
});
mockExecutionWorkspacesApi.controlRuntimeCommands.mockResolvedValue({
workspace: updatedWorkspace,
operation: {},
});
const root = renderProperties(container, {
issue: createIssue({
projectId: "project-1",
@@ -732,7 +754,17 @@ describe("IssueProperties", () => {
executionWorkspaceId: "workspace-1",
currentExecutionWorkspace: createExecutionWorkspace({
mode: "isolated_workspace",
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "running" })],
config: {
environmentId: null,
provisionCommand: null,
teardownCommand: null,
cleanupCommand: null,
desiredState: null,
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
},
},
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "running", configIndex: 0 })],
}),
}),
childIssues: [],
@@ -742,11 +774,27 @@ describe("IssueProperties", () => {
const serviceLink = container.querySelector(`a[href="${serviceUrl}"]`);
expect(serviceLink).not.toBeNull();
expect(serviceLink?.getAttribute("target")).toBe("_blank");
expect(serviceLink?.className).toContain("text-emerald");
expect((container.textContent ?? "").indexOf("Service")).toBeLessThan(
(container.textContent ?? "").indexOf("Workspace"),
expect(serviceLink?.className).toContain("sm:self-start");
expect(serviceLink?.className).not.toContain("sm:self-end");
expect((container.textContent ?? "").indexOf("Workspace")).toBeLessThan(
(container.textContent ?? "").indexOf("Service"),
);
const stopButton = container.querySelector<HTMLButtonElement>('button[aria-label="Stop"]');
expect(stopButton).not.toBeUndefined();
expect(stopButton?.getAttribute("data-size")).toBe("icon-xs");
expect(stopButton?.getAttribute("data-variant")).toBe("outline");
await act(async () => {
stopButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockExecutionWorkspacesApi.controlRuntimeCommands).toHaveBeenCalledWith(
"workspace-1",
"stop",
expect.objectContaining({ action: "stop", runtimeServiceId: "service-1" }),
);
expect(container.textContent).toContain("Workspace service stopped.");
act(() => root.unmount());
});
@@ -797,6 +845,41 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("copies branch and folder values with visible feedback", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const root = renderProperties(container, {
issue: createIssue({
executionWorkspaceId: "workspace-1",
currentExecutionWorkspace: createExecutionWorkspace({
branchName: "pap-1-workspace",
cwd: "/tmp/paperclip/PAP-1",
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
const branchCopyButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Copy pap-1-workspace to clipboard"]',
);
expect(branchCopyButton).not.toBeNull();
await act(async () => {
branchCopyButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(writeText).toHaveBeenCalledWith("pap-1-workspace");
expect(container.textContent).toContain("Copied");
act(() => root.unmount());
});
it("does not show a service link for the main shared workspace", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const serviceUrl = "http://127.0.0.1:62475";
+108 -41
View File
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link } from "@/lib/router";
import type { Issue, IssueLabel, Project, WorkspaceRuntimeService } from "@paperclipai/shared";
import type { Issue, IssueLabel, Project } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AdapterModel } from "../api/agents";
import { accessApi } from "../api/access";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
@@ -57,6 +58,11 @@ import {
type HandoffChipResolvers,
} from "./interrupt-handoff/InterruptHandoffViews";
import { describeReassignInterrupt } from "../lib/interrupt-handoff";
import {
buildWorkspaceRuntimeControlSections,
WorkspaceRuntimeQuickControls,
type WorkspaceRuntimeControlRequest,
} from "./WorkspaceRuntimeControls";
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
const [copied, setCopied] = useState(false);
@@ -78,11 +84,17 @@ function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.C
type="button"
className="text-sm font-mono min-w-0 break-all text-left cursor-pointer hover:text-foreground transition-colors"
onClick={handleCopy}
title={copied ? "Copied!" : "Click to copy"}
title={copied ? "Copied" : "Copy to clipboard"}
aria-label={`Copy ${value} to clipboard`}
>
{value}
</button>
{copied && <Check className="h-3 w-3 text-green-500 shrink-0 mt-0.5" />}
{copied && (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-300" role="status">
<Check className="h-3 w-3 shrink-0" />
Copied
</span>
)}
</div>
);
}
@@ -128,12 +140,6 @@ function isMainIssueWorkspace(input: {
return linkedProjectWorkspaceId === primaryWorkspaceId;
}
function runningRuntimeServiceWithUrl(
runtimeServices: WorkspaceRuntimeService[] | null | undefined,
) {
return runtimeServices?.find((service) => service.status === "running" && service.url?.trim()) ?? null;
}
function toDateTimeLocalValue(value: string | null | undefined) {
if (!value) return "";
const date = new Date(value);
@@ -250,6 +256,11 @@ function RemovableIssueReferencePill({
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const issueLabel = issue.identifier ?? issue.title;
const confirmLabel = issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title;
const chipClassName = cn(
"paperclip-mention-chip paperclip-mention-chip--issue",
"inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline",
issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring",
);
const content = (
<>
<StatusIcon status={issue.status} className="h-3 w-3 shrink-0" />
@@ -269,18 +280,10 @@ function RemovableIssueReferencePill({
return (
<>
<span
data-mention-kind="issue"
className={cn(
"paperclip-mention-chip paperclip-mention-chip--issue group",
"inline-flex items-center gap-1 rounded-full border border-border py-0.5 pl-1 pr-2 text-xs",
)}
title={issue.title}
aria-label={`Task ${issueLabel}: ${issue.title}`}
>
<span className="group relative inline-flex">
<button
type="button"
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-colors transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-ring group-hover:opacity-100"
className="absolute -right-1 -top-1 z-10 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm transition-colors transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-ring group-hover:opacity-100"
aria-label={removeLabel}
title={removeLabel}
onClick={handleRemove}
@@ -290,13 +293,22 @@ function RemovableIssueReferencePill({
{issue.identifier ? (
<Link
to={`/issues/${issueLabel}`}
className="inline-flex min-w-0 items-center gap-1 no-underline hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
data-mention-kind="issue"
className={chipClassName}
title={issue.title}
aria-label={`Task ${issueLabel}: ${issue.title}`}
>
{content}
</Link>
) : (
<span className="inline-flex min-w-0 items-center gap-1">{content}</span>
<span
data-mention-kind="issue"
className={chipClassName}
title={issue.title}
aria-label={`Task: ${issue.title}`}
>
{content}
</span>
)}
</span>
<Dialog open={isConfirmOpen} onOpenChange={setIsConfirmOpen}>
@@ -424,6 +436,8 @@ export function IssueProperties({
const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? "");
const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? "");
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState<string | null>(null);
const normalizedBlockedBySearch = blockedBySearch.trim();
const { data: session } = useQuery({
@@ -525,10 +539,54 @@ export function IssueProperties({
[issue, issueProject],
);
const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace;
const liveWorkspaceService = useMemo(() => {
if (issueUsesMainWorkspace) return null;
return runningRuntimeServiceWithUrl(issue.currentExecutionWorkspace?.runtimeServices);
}, [issue.currentExecutionWorkspace?.runtimeServices, issueUsesMainWorkspace]);
const workspaceRuntimeConfig = issueUsesMainWorkspace
? null
: issue.currentExecutionWorkspace?.config?.workspaceRuntime ?? null;
const workspaceRuntimeServices = issue.currentExecutionWorkspace?.runtimeServices ?? [];
const workspaceCanRunCommands = Boolean(issue.currentExecutionWorkspace?.cwd);
const workspaceCanStartServices = Boolean(workspaceRuntimeConfig) && workspaceCanRunCommands;
const workspaceRuntimeSections = useMemo(() => buildWorkspaceRuntimeControlSections({
runtimeConfig: workspaceRuntimeConfig,
runtimeServices: workspaceRuntimeServices,
canStartServices: workspaceCanStartServices,
canRunJobs: workspaceCanRunCommands,
}), [workspaceCanRunCommands, workspaceCanStartServices, workspaceRuntimeConfig, workspaceRuntimeServices]);
const hasWorkspaceRuntimeControls = !issueUsesMainWorkspace && (
workspaceRuntimeSections.services.length > 0
|| workspaceRuntimeSections.otherServices.length > 0
);
const controlWorkspaceRuntime = useMutation({
mutationFn: (request: WorkspaceRuntimeControlRequest) => {
const workspaceId = issue.currentExecutionWorkspace?.id ?? issue.executionWorkspaceId;
if (!workspaceId) throw new Error("This task is not attached to a workspace.");
return executionWorkspacesApi.controlRuntimeCommands(workspaceId, request.action, request);
},
onSuccess: (result, request) => {
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace);
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
void queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) });
if (companyId) {
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId) });
}
setRuntimeActionErrorMessage(null);
setRuntimeActionMessage(
request.action === "run"
? "Workspace job completed."
: request.action === "stop"
? "Workspace service stopped."
: request.action === "restart"
? "Workspace service restarted."
: "Workspace service started.",
);
},
onError: (error) => {
setRuntimeActionMessage(null);
setRuntimeActionErrorMessage(error instanceof Error ? error.message : "Failed to control workspace commands.");
},
});
const pendingWorkspaceRuntimeAction = controlWorkspaceRuntime.isPending ? controlWorkspaceRuntime.variables ?? null : null;
const referencedIssueIdentifiers = issue.referencedIssueIdentifiers ?? [];
const relatedTasks = useMemo(() => {
const excluded = new Set<string>();
@@ -2137,34 +2195,43 @@ export function IssueProperties({
)}
</div>
{liveWorkspaceService || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
{hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
<>
<Separator />
<div className="space-y-1">
{liveWorkspaceService?.url && (
<PropertyRow label="Service">
<a
href={liveWorkspaceService.url}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-0 items-start gap-1 text-sm font-mono text-emerald-700 hover:text-emerald-800 hover:underline dark:text-emerald-300 dark:hover:text-emerald-200"
>
<span className="min-w-0 break-all">{liveWorkspaceService.url}</span>
<ExternalLink className="mt-1 h-3 w-3 shrink-0" />
</a>
</PropertyRow>
)}
{showWorkspaceDetailLink && issue.executionWorkspaceId && (
<PropertyRow label="Workspace">
<Link
to={`/execution-workspaces/${issue.executionWorkspaceId}`}
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
className="text-sm text-primary hover:underline inline-flex min-w-0 items-center gap-1.5"
>
<Hexagon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
View workspace
<ExternalLink className="h-3 w-3" />
<ExternalLink className="h-3 w-3 shrink-0" />
</Link>
</PropertyRow>
)}
{hasWorkspaceRuntimeControls && (
<PropertyRow label="Service">
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<WorkspaceRuntimeQuickControls
sections={workspaceRuntimeSections}
isPending={controlWorkspaceRuntime.isPending}
pendingRequest={pendingWorkspaceRuntimeAction}
onAction={(request) => controlWorkspaceRuntime.mutate(request)}
square
align="start"
iconOnly
/>
{runtimeActionMessage ? (
<span className="text-xs text-muted-foreground" role="status">{runtimeActionMessage}</span>
) : null}
{runtimeActionErrorMessage ? (
<span className="text-xs text-destructive" role="alert">{runtimeActionErrorMessage}</span>
) : null}
</div>
</PropertyRow>
)}
{issue.currentExecutionWorkspace?.branchName && (
<PropertyRow label="Branch">
<TruncatedCopyable
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import type { WorkspaceRuntimeService } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -14,6 +14,10 @@ import {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void) {
flushSync(callback);
}
function createRuntimeService(overrides: Partial<WorkspaceRuntimeService> = {}): WorkspaceRuntimeService {
return {
id: overrides.id ?? "service-1",
+25 -9
View File
@@ -226,12 +226,14 @@ function CommandActionButtons({
pendingRequest,
onAction,
square,
iconOnly,
}: {
item: WorkspaceRuntimeControlItem;
isPending: boolean;
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined;
onAction: (request: WorkspaceRuntimeControlRequest) => void;
square?: boolean;
iconOnly?: boolean;
}) {
const actions: WorkspaceRuntimeAction[] =
item.kind === "job"
@@ -241,7 +243,7 @@ function CommandActionButtons({
: ["start"];
return (
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
<div className={cn("flex gap-2", iconOnly ? "w-auto flex-row flex-wrap" : "w-full flex-col sm:w-auto sm:flex-row sm:flex-wrap")}>
{actions.map((action) => {
const request = buildRequest(item, action);
const Icon = action === "stop" ? Square : action === "restart" ? RotateCcw : Play;
@@ -261,17 +263,19 @@ function CommandActionButtons({
return (
<Button
key={`${item.key}:${action}`}
variant={action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
size="sm"
variant={iconOnly ? "outline" : action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
size={iconOnly ? "icon-xs" : "sm"}
className={cn(
"w-full justify-start sm:w-auto",
iconOnly ? "shrink-0" : "w-full justify-start sm:w-auto",
square ? "rounded-none" : null,
)}
disabled={disabled}
onClick={() => onAction(request)}
aria-label={label}
title={label}
>
{showSpinner ? <Loader2 className="h-4 w-4 animate-spin" /> : <Icon className="h-4 w-4" />}
{label}
{iconOnly ? <span className="sr-only">{label}</span> : label}
</Button>
);
})}
@@ -289,6 +293,7 @@ function CommandSection({
pendingRequest,
onAction,
square,
iconOnly,
}: {
title: string;
description: string;
@@ -299,6 +304,7 @@ function CommandSection({
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined;
onAction: (request: WorkspaceRuntimeControlRequest) => void;
square?: boolean;
iconOnly?: boolean;
}) {
return (
<div className="space-y-3">
@@ -330,6 +336,7 @@ function CommandSection({
pendingRequest={pendingRequest}
onAction={onAction}
square={square}
iconOnly={iconOnly}
/>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
@@ -466,24 +473,29 @@ export function WorkspaceRuntimeQuickControls({
pendingRequest = null,
onAction,
square,
align = "end",
iconOnly = false,
}: {
sections: WorkspaceRuntimeControlSections;
isPending?: boolean;
pendingRequest?: WorkspaceRuntimeControlRequest | null;
onAction: (request: WorkspaceRuntimeControlRequest) => void;
square?: boolean;
align?: "start" | "end";
iconOnly?: boolean;
}) {
const controlItems = sections.services.length > 0 ? sections.services : sections.otherServices;
const serviceUrl = getRunningRuntimeServiceUrl(sections);
const alignEnd = align === "end";
if (controlItems.length === 0 && !serviceUrl) return null;
return (
<div className="flex min-w-0 flex-col items-stretch gap-2 sm:items-end">
<div className={cn("flex min-w-0 flex-col items-stretch gap-2", alignEnd ? "sm:items-end" : "sm:items-start")}>
{controlItems.length > 0 ? (
<div className="flex max-w-full flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
<div className={cn("flex max-w-full flex-col gap-2 sm:flex-row sm:flex-wrap", alignEnd ? "sm:justify-end" : "sm:justify-start")}>
{controlItems.map((item) => (
<div key={item.key} className="flex min-w-0 flex-col gap-1 sm:items-end">
<div key={item.key} className={cn("flex min-w-0 flex-col gap-1", alignEnd ? "sm:items-end" : "sm:items-start")}>
{controlItems.length > 1 ? (
<span className="truncate text-xs text-muted-foreground">{item.title}</span>
) : null}
@@ -493,6 +505,7 @@ export function WorkspaceRuntimeQuickControls({
pendingRequest={pendingRequest}
onAction={onAction}
square={square}
iconOnly={iconOnly}
/>
</div>
))}
@@ -503,7 +516,10 @@ export function WorkspaceRuntimeQuickControls({
href={serviceUrl}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-0 items-center gap-1 self-start break-all text-xs text-muted-foreground hover:text-foreground hover:underline sm:self-end"
className={cn(
"inline-flex min-w-0 items-center gap-1 self-start break-all text-xs text-muted-foreground hover:text-foreground hover:underline",
alignEnd ? "sm:self-end" : "sm:self-start",
)}
>
{serviceUrl}
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
@@ -27,6 +27,7 @@ import { nextCronFires, previewFirePolicies } from "../../lib/cron-fires";
import { timeAgo } from "../../lib/timeAgo";
import { EmptyState } from "../EmptyState";
import { InlineEntitySelector } from "../InlineEntitySelector";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "../IssueDocumentAnnotations";
import { AgentIcon } from "../AgentIconPicker";
import { MarkdownEditor } from "../MarkdownEditor";
import { ScheduleEditor } from "../ScheduleEditor";
@@ -98,8 +99,11 @@ export function OverviewSection() {
routineRuns,
activity,
saveRoutine,
saveConflict,
isSectionDirty,
navigateToSection,
} = ctx;
const [descriptionAnnotationsOpen, setDescriptionAnnotationsOpen] = useState(false);
const activeTriggers = routine.triggers.length;
const nextFire = useMemo(() => {
@@ -215,20 +219,63 @@ export function OverviewSection() {
) : null}
{/* Instructions */}
<MarkdownEditor
ref={descriptionEditorRef}
value={editDraft.description}
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
placeholder="Add instructions..."
bordered={false}
contentClassName="min-h-[120px] text-[15px] leading-7"
mentions={mentionOptions}
onSubmit={() => {
if (!saveRoutine.isPending && editDraft.title.trim()) {
saveRoutine.mutate();
}
}}
/>
<div className="space-y-2">
<div className="flex items-center justify-end">
{routine.descriptionDocument ? (
<DocumentAnnotationsCountChip
issueId={routine.id}
docKey="description"
target={{ kind: "routine", routineId: routine.id, documentKey: "description" }}
panelOpen={descriptionAnnotationsOpen}
onToggle={() => setDescriptionAnnotationsOpen((open) => !open)}
/>
) : null}
</div>
{routine.descriptionDocument ? (
<IssueDocumentAnnotations
issueId={routine.id}
doc={routine.descriptionDocument}
target={{ kind: "routine", routineId: routine.id, documentKey: "description" }}
bodyMarkdown={editDraft.description}
draftDirty={isSectionDirty("overview") || saveRoutine.isPending}
draftConflicted={saveConflict}
historicalPreview={false}
locationHash={typeof window === "undefined" ? "" : window.location.hash}
panelOpen={descriptionAnnotationsOpen}
onPanelOpenChange={setDescriptionAnnotationsOpen}
>
<MarkdownEditor
ref={descriptionEditorRef}
value={editDraft.description}
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
placeholder="Add instructions..."
bordered={false}
contentClassName="min-h-[120px] text-[15px] leading-7"
mentions={mentionOptions}
onSubmit={() => {
if (!saveRoutine.isPending && editDraft.title.trim()) {
saveRoutine.mutate();
}
}}
/>
</IssueDocumentAnnotations>
) : (
<MarkdownEditor
ref={descriptionEditorRef}
value={editDraft.description}
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
placeholder="Add instructions..."
bordered={false}
contentClassName="min-h-[120px] text-[15px] leading-7"
mentions={mentionOptions}
onSubmit={() => {
if (!saveRoutine.isPending && editDraft.title.trim()) {
saveRoutine.mutate();
}
}}
/>
)}
</div>
{/* Variables peek */}
<div className="space-y-3">
@@ -139,6 +139,9 @@ describe("LiveUpdatesProvider issue invalidation", () => {
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.documentRevisions("issue-1", "plan"),
});
expect(invalidations).toContainEqual({
queryKey: ["issues", "document-annotations", "issue-1", "plan"],
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.documents("PAP-9403"),
});
@@ -148,6 +151,9 @@ describe("LiveUpdatesProvider issue invalidation", () => {
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.documentRevisions("PAP-9403", "plan"),
});
expect(invalidations).toContainEqual({
queryKey: ["issues", "document-annotations", "PAP-9403", "plan"],
});
expect(invalidations).not.toContainEqual({
queryKey: queryKeys.issues.documents("issue-1"),
refetchType: "inactive",
@@ -186,6 +192,83 @@ describe("LiveUpdatesProvider issue invalidation", () => {
expect(invalidations).toContainEqual({
queryKey: ["issues", "document-revisions", "issue-1"],
});
expect(invalidations).toContainEqual({
queryKey: ["issues", "document-annotations", "issue-1"],
});
});
it("refreshes document annotation caches when annotation activity arrives", () => {
const invalidations: unknown[] = [];
const queryClient = {
invalidateQueries: (input: unknown) => {
invalidations.push(input);
},
getQueryData: () => undefined,
};
__liveUpdatesTestUtils.invalidateActivityQueries(
queryClient as never,
"company-1",
{
entityType: "issue",
entityId: "issue-1",
action: "issue.document_annotation_comment_added",
actorType: "user",
actorId: "user-2",
details: {
identifier: "PAP-9403",
documentKey: "plan",
threadId: "thread-1",
commentId: "comment-1",
},
},
{ userId: "user-1", agentId: null },
);
expect(invalidations).toContainEqual({
queryKey: ["issues", "document-annotations", "issue-1", "plan"],
});
expect(invalidations).toContainEqual({
queryKey: ["issues", "document-annotations", "PAP-9403", "plan"],
});
expect(invalidations).not.toContainEqual({
queryKey: queryKeys.issues.documents("issue-1"),
});
});
it("refreshes routine description annotation caches when routine annotation activity arrives", () => {
const invalidations: unknown[] = [];
const queryClient = {
invalidateQueries: (input: unknown) => {
invalidations.push(input);
},
getQueryData: () => undefined,
};
__liveUpdatesTestUtils.invalidateActivityQueries(
queryClient as never,
"company-1",
{
entityType: "routine",
entityId: "routine-1",
action: "routine.document_annotation_comment_added",
actorType: "user",
actorId: "user-2",
details: {
documentKey: "description",
threadId: "thread-1",
commentId: "comment-1",
},
},
{ userId: "user-1", agentId: null },
);
expect(invalidations).toContainEqual({
queryKey: ["routines"],
});
expect(invalidations).toContainEqual({
queryKey: ["routines", "document-annotations", "routine-1", "description"],
});
});
it("keeps self-authored comment events from refetching the active issue tree", () => {
+32
View File
@@ -415,6 +415,20 @@ const ISSUE_DOCUMENT_ACTIVITY_ACTIONS = new Set([
"issue.document_restored",
"issue.document_deleted",
]);
const ISSUE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([
"issue.document_annotation_thread_created",
"issue.document_annotation_comment_added",
"issue.document_annotation_thread_resolved",
"issue.document_annotation_thread_reopened",
"issue.document_annotation_remapped",
]);
const ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([
"routine.document_annotation_thread_created",
"routine.document_annotation_comment_added",
"routine.document_annotation_thread_resolved",
"routine.document_annotation_thread_reopened",
"routine.document_annotation_remapped",
]);
const AGENT_TOAST_STATUSES = new Set(["error"]);
const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]);
@@ -710,6 +724,18 @@ function invalidateActivityQueries(
queryClient.invalidateQueries({ queryKey: ["issues", "document-revisions", ref], ...invalidationOptions });
}
}
if (
action &&
(ISSUE_DOCUMENT_ACTIVITY_ACTIONS.has(action) || ISSUE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS.has(action))
) {
const documentKey = readString(details?.key) ?? readString(details?.documentKey);
queryClient.invalidateQueries({
queryKey: documentKey
? ["issues", "document-annotations", ref, documentKey]
: ["issues", "document-annotations", ref],
...invalidationOptions,
});
}
if (action?.startsWith("issue.thread_interaction_")) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref), ...invalidationOptions });
}
@@ -761,6 +787,12 @@ function invalidateActivityQueries(
if (entityType === "routine" || entityType === "routine_trigger" || entityType === "routine_run") {
queryClient.invalidateQueries({ queryKey: ["routines"] });
if (entityType === "routine" && action && ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS.has(action) && entityId) {
const documentKey = readString(details?.key) ?? readString(details?.documentKey) ?? "description";
queryClient.invalidateQueries({
queryKey: ["routines", "document-annotations", entityId, documentKey],
});
}
return;
}
+2
View File
@@ -122,6 +122,8 @@ export const queryKeys = {
runs: (id: string) => ["routines", "runs", id] as const,
revisions: (id: string) => ["routines", "revisions", id] as const,
activity: (companyId: string, id: string) => ["routines", "activity", companyId, id] as const,
documentAnnotations: (routineId: string, key: "description", status: "open" | "resolved" | "all" = "all") =>
["routines", "document-annotations", routineId, key, status] as const,
},
executionWorkspaces: {
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
@@ -0,0 +1,188 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
DocumentAnnotationThreadWithComments,
DocumentAnnotationThreadStatus,
DocumentAnnotationAnchorState,
} from "@paperclipai/shared";
import { DocumentAnnotationPanel } from "@/components/DocumentAnnotationPanel";
import type { PendingAnchor } from "@/components/DocumentAnnotationLayer";
import type { CompanyUserProfile } from "@/lib/company-members";
import { queryKeys } from "@/lib/queryKeys";
const issueId = "issue-doc-comments";
const documentKey = "plan";
const currentUserId = "user-board";
const userProfileMap = new Map<string, CompanyUserProfile>([
[currentUserId, { label: "Dotta", image: null }],
["user-pm", { label: "Mara Product", image: null }],
]);
function makeThread(
overrides: Partial<DocumentAnnotationThreadWithComments> = {},
): DocumentAnnotationThreadWithComments {
const id = overrides.id ?? "thread-1";
const status: DocumentAnnotationThreadStatus = overrides.status ?? "open";
const anchorState: DocumentAnnotationAnchorState = overrides.anchorState ?? "active";
return {
id,
companyId: "co-1",
issueId,
documentId: "doc-1",
documentKey,
status,
anchorState,
anchorConfidence: "exact",
originalRevisionId: "rev-4",
originalRevisionNumber: 4,
currentRevisionId: "rev-4",
currentRevisionNumber: 4,
selectedText:
"the assistant should keep the existing editor selection highlighted while the comment composer is open",
prefixText: "We agreed ",
suffixText: ".",
normalizedStart: 0,
normalizedEnd: 22,
markdownStart: 0,
markdownEnd: 22,
anchorSelector: {
quote: { exact: "selection", prefix: "We ", suffix: "." },
position: { normalizedStart: 0, normalizedEnd: 22, markdownStart: 0, markdownEnd: 22 },
},
createdByAgentId: null,
createdByUserId: currentUserId,
resolvedByAgentId: null,
resolvedByUserId: null,
resolvedAt: null,
createdAt: new Date("2026-06-12T00:01:00Z"),
updatedAt: new Date("2026-06-12T00:02:00Z"),
comments: [
{
id: `${id}-c1`,
companyId: "co-1",
threadId: id,
issueId,
documentId: "doc-1",
body: "Please confirm this is still the behaviour we want.",
authorType: "user",
authorAgentId: null,
authorUserId: "user-pm",
createdByRunId: null,
createdAt: new Date("2026-06-12T00:01:00Z"),
updatedAt: new Date("2026-06-12T00:01:00Z"),
},
],
...overrides,
};
}
const pendingAnchor: PendingAnchor = {
selector: {
quote: { exact: "extremely snappy", prefix: "feels ", suffix: "." },
position: { normalizedStart: 0, normalizedEnd: 16, markdownStart: 0, markdownEnd: 16 },
},
selectedText:
"submission of comments should be optimistic so the whole interaction feels extremely snappy",
};
function PanelFrame({
label,
threads,
focusedThreadId = null,
pending = null,
}: {
label: string;
threads: DocumentAnnotationThreadWithComments[];
focusedThreadId?: string | null;
pending?: PendingAnchor | null;
}) {
const [client] = useState(() => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(queryKeys.auth.session, {
session: { id: "sess-1", userId: currentUserId },
user: { id: currentUserId, email: "dotta@magicmachine.co", name: "Dotta", image: null },
});
return qc;
});
const [focused, setFocused] = useState<string | null>(focusedThreadId);
return (
<div className="flex flex-col gap-2">
<div className="paperclip-story__label">{label}</div>
<QueryClientProvider client={client}>
<div className="h-[460px]">
<DocumentAnnotationPanel
open
onOpenChange={() => undefined}
issueId={issueId}
documentKey={documentKey}
documentRevisionNumber={4}
baseRevisionId="rev-4"
baseRevisionNumber={4}
threads={threads}
focusedThreadId={focused}
onFocusThread={setFocused}
focusedCommentId={null}
pendingAnchor={pending}
onClearPendingAnchor={() => undefined}
desktopWidth={360}
userProfileMap={userProfileMap}
/>
</div>
</QueryClientProvider>
</div>
);
}
function DocumentCommentsMatrix() {
return (
<div className="paperclip-story">
<main className="paperclip-story__inner">
<section className="paperclip-story__frame overflow-hidden">
<div className="border-b border-border px-5 py-4">
<div className="paperclip-story__label">Document comments · PAP-10960</div>
<h2 className="mt-1 text-xl font-semibold">Simplified annotation panel</h2>
<p className="mt-1 text-sm text-muted-foreground">
No header, no filter chips, no empty-state copy. Revision indicator sits top-right.
The composer shows the author avatar + name, two-line clamped quote, and a plain
"Reply" affordance.
</p>
</div>
<div className="flex flex-wrap items-start gap-6 p-5">
<PanelFrame label="No comments yet" threads={[]} />
<PanelFrame
label="With comments (one expanded)"
threads={[
makeThread({ id: "thread-1" }),
makeThread({ id: "thread-2", status: "resolved", selectedText: "a resolved thread stays in the same list" }),
]}
focusedThreadId="thread-1"
/>
<PanelFrame label="Composing a new comment" threads={[makeThread({ id: "thread-1" })]} pending={pendingAnchor} />
</div>
</section>
</main>
</div>
);
}
const meta = {
title: "Product/Document Comments",
component: DocumentCommentsMatrix,
parameters: {
docs: {
description: {
component:
"Inline document comment panel (PAP-10960): simplified to drop the header, filter chips, and empty-state, surface the author identity in the composer, and clamp quotes to two lines.",
},
},
},
} satisfies Meta<typeof DocumentCommentsMatrix>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Panel: Story = {};