mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[codex] Add document annotations and comments (#6733)
## Thinking Path > - Paperclip orchestrates AI-agent companies through issues, documents, runs, and durable company-scoped state. > - Issue documents are where agents and operators capture plans, handoffs, and work products. > - Before this change, document collaboration could only happen through whole-document edits and detached issue comments. > - Inline document annotations need stable anchors, revision-aware persistence, and UI affordances that do not break existing document editing. > - This pull request adds company-scoped document annotation threads, comments, anchor snapshots, API routes, and board UI. > - The benefit is that operators and agents can discuss specific document passages without losing context as documents evolve. ## What Changed - Added document annotation tables, schema exports, shared types, validators, anchor hashing, and text-anchor helpers. - Added server-side document annotation services and issue routes for listing, creating, commenting, resolving, and reopening annotation threads. - Included annotation summaries in relevant issue document reads and backup/recovery document workspace behavior. - Added React UI for inline document highlights, comment panels, mobile sheet behavior, deep-link focus, and resolved/open filtering. - Added annotation design artifacts, Storybook coverage, screenshots, and a screenshot helper script. - Rebased the branch onto current `paperclipai/paperclip` `master` and renumbered the annotation migration from `0085_old_swarm` to `0091_old_swarm`; the SQL uses `IF NOT EXISTS` guards so environments that previously applied the old migration number can safely apply the new one. - Adjusted the new annotation UI tests to use a local async flush helper because this workspace's React 19.2.4 export does not expose `React.act`. ## Verification - `pnpm run preflight:workspace-links && pnpm exec vitest run packages/shared/src/document-anchors.test.ts server/src/__tests__/document-annotation-routes.test.ts server/src/__tests__/document-annotations-service.test.ts ui/src/components/DocumentAnnotationLayer.test.tsx ui/src/components/IssueDocumentAnnotations.test.tsx ui/src/lib/document-annotation-hash.test.ts ui/src/lib/document-annotation-selection.test.ts` - Confirmed `git diff --check` passes. - Confirmed no `pnpm-lock.yaml` or `.github/workflows/*` files are included in the PR diff. ## Risks - Medium risk: this adds new persisted annotation tables and routes across db/shared/server/ui. - Migration risk is reduced by moving the branch migration to `0091_old_swarm` after upstream `0090_resource_memberships` and keeping the SQL idempotent for old `0085_old_swarm` adopters. - UI risk is mostly around text range anchoring and panel positioning across long documents, folded content, and mobile layouts; the PR includes focused unit coverage and design screenshots. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-using software engineering mode. Context window size is not exposed in this Paperclip runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
f0ddd24d61
commit
b7545823be
55 changed files with 25070 additions and 31 deletions
189
packages/db/src/migrations/0091_old_swarm.sql
Normal file
189
packages/db/src/migrations/0091_old_swarm.sql
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS "document_annotation_threads" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"issue_id" uuid NOT NULL,
|
||||||
|
"document_id" uuid NOT NULL,
|
||||||
|
"document_key" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'open' NOT NULL,
|
||||||
|
"anchor_state" text DEFAULT 'active' NOT NULL,
|
||||||
|
"original_revision_id" uuid,
|
||||||
|
"original_revision_number" integer NOT NULL,
|
||||||
|
"current_revision_id" uuid,
|
||||||
|
"current_revision_number" integer NOT NULL,
|
||||||
|
"selected_text" text NOT NULL,
|
||||||
|
"prefix_text" text DEFAULT '' NOT NULL,
|
||||||
|
"suffix_text" text DEFAULT '' NOT NULL,
|
||||||
|
"normalized_start" integer NOT NULL,
|
||||||
|
"normalized_end" integer NOT NULL,
|
||||||
|
"markdown_start" integer NOT NULL,
|
||||||
|
"markdown_end" integer NOT NULL,
|
||||||
|
"anchor_confidence" text DEFAULT 'exact' NOT NULL,
|
||||||
|
"anchor_selector" jsonb NOT NULL,
|
||||||
|
"created_by_agent_id" uuid,
|
||||||
|
"created_by_user_id" text,
|
||||||
|
"resolved_by_agent_id" uuid,
|
||||||
|
"resolved_by_user_id" text,
|
||||||
|
"resolved_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "document_annotation_comments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"thread_id" uuid NOT NULL,
|
||||||
|
"issue_id" uuid NOT NULL,
|
||||||
|
"document_id" uuid NOT NULL,
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"author_type" text NOT NULL,
|
||||||
|
"author_agent_id" uuid,
|
||||||
|
"author_user_id" text,
|
||||||
|
"created_by_run_id" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "document_annotation_anchor_snapshots" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"thread_id" uuid NOT NULL,
|
||||||
|
"document_id" uuid NOT NULL,
|
||||||
|
"from_revision_id" uuid,
|
||||||
|
"from_revision_number" integer,
|
||||||
|
"to_revision_id" uuid,
|
||||||
|
"to_revision_number" integer NOT NULL,
|
||||||
|
"previous_anchor" jsonb NOT NULL,
|
||||||
|
"next_anchor" jsonb,
|
||||||
|
"anchor_state" text NOT NULL,
|
||||||
|
"anchor_confidence" text NOT NULL,
|
||||||
|
"failure_reason" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_company_id_companies_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_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 = 'document_annotation_threads_issue_id_issues_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("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_threads_document_id_documents_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_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
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_original_revision_id_document_revisions_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_original_revision_id_document_revisions_id_fk" FOREIGN KEY ("original_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_current_revision_id_document_revisions_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_current_revision_id_document_revisions_id_fk" FOREIGN KEY ("current_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_created_by_agent_id_agents_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_resolved_by_agent_id_agents_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_resolved_by_agent_id_agents_id_fk" FOREIGN KEY ("resolved_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_company_id_companies_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_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 = 'document_annotation_comments_thread_id_document_annotation_threads_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_thread_id_document_annotation_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."document_annotation_threads"("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_issue_id_issues_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("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_document_id_documents_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_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
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_author_agent_id_agents_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_author_agent_id_agents_id_fk" FOREIGN KEY ("author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_company_id_companies_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_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 = 'document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."document_annotation_threads"("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_anchor_snapshots_document_id_documents_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_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
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk" FOREIGN KEY ("from_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk') THEN
|
||||||
|
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk" FOREIGN KEY ("to_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_document_status_idx" ON "document_annotation_threads" USING btree ("company_id","document_id","status");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_issue_status_idx" ON "document_annotation_threads" USING btree ("company_id","issue_id","status");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_current_revision_open_idx" ON "document_annotation_threads" USING btree ("company_id","document_id","current_revision_id","status");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_anchor_state_idx" ON "document_annotation_threads" USING btree ("company_id","anchor_state");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_thread_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","thread_id","created_at");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_issue_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","issue_id","created_at");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_document_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","document_id","created_at");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "document_annotation_comments_body_search_idx" ON "document_annotation_comments" USING gin ("body" gin_trgm_ops);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "document_annotation_anchor_snapshots_company_thread_created_at_idx" ON "document_annotation_anchor_snapshots" USING btree ("company_id","thread_id","created_at");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "document_annotation_anchor_snapshots_company_document_revision_idx" ON "document_annotation_anchor_snapshots" USING btree ("company_id","document_id","to_revision_number");
|
||||||
18843
packages/db/src/migrations/meta/0091_snapshot.json
Normal file
18843
packages/db/src/migrations/meta/0091_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -638,6 +638,13 @@
|
||||||
"when": 1779573019125,
|
"when": 1779573019125,
|
||||||
"tag": "0090_resource_memberships",
|
"tag": "0090_resource_memberships",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 91,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778810394522,
|
||||||
|
"tag": "0091_old_swarm",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import type {
|
||||||
|
DocumentAnnotationAnchorConfidence,
|
||||||
|
DocumentAnnotationAnchorSnapshot,
|
||||||
|
DocumentAnnotationAnchorState,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { documentAnnotationThreads } from "./document_annotation_threads.js";
|
||||||
|
import { documentRevisions } from "./document_revisions.js";
|
||||||
|
import { documents } from "./documents.js";
|
||||||
|
|
||||||
|
export const documentAnnotationAnchorSnapshots = pgTable(
|
||||||
|
"document_annotation_anchor_snapshots",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
threadId: uuid("thread_id").notNull().references(() => documentAnnotationThreads.id, { onDelete: "cascade" }),
|
||||||
|
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||||
|
fromRevisionId: uuid("from_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
|
||||||
|
fromRevisionNumber: integer("from_revision_number"),
|
||||||
|
toRevisionId: uuid("to_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
|
||||||
|
toRevisionNumber: integer("to_revision_number").notNull(),
|
||||||
|
previousAnchor: jsonb("previous_anchor").$type<DocumentAnnotationAnchorSnapshot>().notNull(),
|
||||||
|
nextAnchor: jsonb("next_anchor").$type<DocumentAnnotationAnchorSnapshot | null>(),
|
||||||
|
anchorState: text("anchor_state").$type<DocumentAnnotationAnchorState>().notNull(),
|
||||||
|
anchorConfidence: text("anchor_confidence").$type<DocumentAnnotationAnchorConfidence>().notNull(),
|
||||||
|
failureReason: text("failure_reason"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyThreadCreatedAtIdx: index("document_annotation_anchor_snapshots_company_thread_created_at_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.threadId,
|
||||||
|
table.createdAt,
|
||||||
|
),
|
||||||
|
companyDocumentRevisionIdx: index("document_annotation_anchor_snapshots_company_document_revision_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.documentId,
|
||||||
|
table.toRevisionNumber,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
44
packages/db/src/schema/document_annotation_comments.ts
Normal file
44
packages/db/src/schema/document_annotation_comments.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import type { IssueCommentAuthorType } from "@paperclipai/shared";
|
||||||
|
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { agents } from "./agents.js";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { documentAnnotationThreads } from "./document_annotation_threads.js";
|
||||||
|
import { documents } from "./documents.js";
|
||||||
|
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||||
|
import { issues } from "./issues.js";
|
||||||
|
|
||||||
|
export const documentAnnotationComments = pgTable(
|
||||||
|
"document_annotation_comments",
|
||||||
|
{
|
||||||
|
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" }),
|
||||||
|
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||||
|
body: text("body").notNull(),
|
||||||
|
authorType: text("author_type").$type<IssueCommentAuthorType>().notNull(),
|
||||||
|
authorAgentId: uuid("author_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
|
authorUserId: text("author_user_id"),
|
||||||
|
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyThreadCreatedAtIdx: index("document_annotation_comments_company_thread_created_at_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.threadId,
|
||||||
|
table.createdAt,
|
||||||
|
),
|
||||||
|
companyIssueCreatedAtIdx: index("document_annotation_comments_company_issue_created_at_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.issueId,
|
||||||
|
table.createdAt,
|
||||||
|
),
|
||||||
|
companyDocumentCreatedAtIdx: index("document_annotation_comments_company_document_created_at_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.documentId,
|
||||||
|
table.createdAt,
|
||||||
|
),
|
||||||
|
bodySearchIdx: index("document_annotation_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")),
|
||||||
|
}),
|
||||||
|
);
|
||||||
70
packages/db/src/schema/document_annotation_threads.ts
Normal file
70
packages/db/src/schema/document_annotation_threads.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import type {
|
||||||
|
DocumentAnnotationAnchorConfidence,
|
||||||
|
DocumentAnnotationAnchorSelector,
|
||||||
|
DocumentAnnotationAnchorState,
|
||||||
|
DocumentAnnotationThreadStatus,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { agents } from "./agents.js";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { documentRevisions } from "./document_revisions.js";
|
||||||
|
import { documents } from "./documents.js";
|
||||||
|
import { issues } from "./issues.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" }),
|
||||||
|
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||||
|
documentKey: text("document_key").notNull(),
|
||||||
|
status: text("status").$type<DocumentAnnotationThreadStatus>().notNull().default("open"),
|
||||||
|
anchorState: text("anchor_state").$type<DocumentAnnotationAnchorState>().notNull().default("active"),
|
||||||
|
originalRevisionId: uuid("original_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
|
||||||
|
originalRevisionNumber: integer("original_revision_number").notNull(),
|
||||||
|
currentRevisionId: uuid("current_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
|
||||||
|
currentRevisionNumber: integer("current_revision_number").notNull(),
|
||||||
|
selectedText: text("selected_text").notNull(),
|
||||||
|
prefixText: text("prefix_text").notNull().default(""),
|
||||||
|
suffixText: text("suffix_text").notNull().default(""),
|
||||||
|
normalizedStart: integer("normalized_start").notNull(),
|
||||||
|
normalizedEnd: integer("normalized_end").notNull(),
|
||||||
|
markdownStart: integer("markdown_start").notNull(),
|
||||||
|
markdownEnd: integer("markdown_end").notNull(),
|
||||||
|
anchorConfidence: text("anchor_confidence")
|
||||||
|
.$type<DocumentAnnotationAnchorConfidence>()
|
||||||
|
.notNull()
|
||||||
|
.default("exact"),
|
||||||
|
anchorSelector: jsonb("anchor_selector").$type<DocumentAnnotationAnchorSelector>().notNull(),
|
||||||
|
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
|
createdByUserId: text("created_by_user_id"),
|
||||||
|
resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
|
resolvedByUserId: text("resolved_by_user_id"),
|
||||||
|
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyDocumentStatusIdx: index("document_annotation_threads_company_document_status_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.documentId,
|
||||||
|
table.status,
|
||||||
|
),
|
||||||
|
companyIssueStatusIdx: index("document_annotation_threads_company_issue_status_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.issueId,
|
||||||
|
table.status,
|
||||||
|
),
|
||||||
|
companyCurrentRevisionOpenIdx: index("document_annotation_threads_company_current_revision_open_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.documentId,
|
||||||
|
table.currentRevisionId,
|
||||||
|
table.status,
|
||||||
|
),
|
||||||
|
companyAnchorStateIdx: index("document_annotation_threads_company_anchor_state_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.anchorState,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -55,6 +55,9 @@ export { issueAttachments } from "./issue_attachments.js";
|
||||||
export { documents } from "./documents.js";
|
export { documents } from "./documents.js";
|
||||||
export { documentRevisions } from "./document_revisions.js";
|
export { documentRevisions } from "./document_revisions.js";
|
||||||
export { issueDocuments } from "./issue_documents.js";
|
export { issueDocuments } from "./issue_documents.js";
|
||||||
|
export { documentAnnotationThreads } from "./document_annotation_threads.js";
|
||||||
|
export { documentAnnotationComments } from "./document_annotation_comments.js";
|
||||||
|
export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_snapshots.js";
|
||||||
export { heartbeatRuns } from "./heartbeat_runs.js";
|
export { heartbeatRuns } from "./heartbeat_runs.js";
|
||||||
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
|
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
|
||||||
export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js";
|
export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js";
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,22 @@ export function isSystemIssueDocumentKey(key: string): key is SystemIssueDocumen
|
||||||
export const ISSUE_REFERENCE_SOURCE_KINDS = ["title", "description", "comment", "document"] as const;
|
export const ISSUE_REFERENCE_SOURCE_KINDS = ["title", "description", "comment", "document"] as const;
|
||||||
export type IssueReferenceSourceKind = (typeof ISSUE_REFERENCE_SOURCE_KINDS)[number];
|
export type IssueReferenceSourceKind = (typeof ISSUE_REFERENCE_SOURCE_KINDS)[number];
|
||||||
|
|
||||||
|
export const DOCUMENT_ANNOTATION_THREAD_STATUSES = ["open", "resolved"] as const;
|
||||||
|
export type DocumentAnnotationThreadStatus = (typeof DOCUMENT_ANNOTATION_THREAD_STATUSES)[number];
|
||||||
|
|
||||||
|
export const DOCUMENT_ANNOTATION_ANCHOR_STATES = ["active", "stale", "orphaned"] as const;
|
||||||
|
export type DocumentAnnotationAnchorState = (typeof DOCUMENT_ANNOTATION_ANCHOR_STATES)[number];
|
||||||
|
|
||||||
|
export const DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES = [
|
||||||
|
"exact",
|
||||||
|
"duplicate",
|
||||||
|
"fuzzy",
|
||||||
|
"ambiguous",
|
||||||
|
"missing",
|
||||||
|
] as const;
|
||||||
|
export type DocumentAnnotationAnchorConfidence =
|
||||||
|
(typeof DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES)[number];
|
||||||
|
|
||||||
export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const;
|
export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const;
|
||||||
export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number];
|
export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number];
|
||||||
|
|
||||||
|
|
|
||||||
183
packages/shared/src/document-anchors.test.ts
Normal file
183
packages/shared/src/document-anchors.test.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createDocumentAnchorSelector,
|
||||||
|
projectMarkdownToText,
|
||||||
|
remapDocumentAnchor,
|
||||||
|
resolveProjectionRange,
|
||||||
|
verifyDocumentAnchorSelector,
|
||||||
|
} from "./document-anchors.js";
|
||||||
|
|
||||||
|
function selectorFor(markdown: string, quote: string) {
|
||||||
|
const projection = projectMarkdownToText(markdown);
|
||||||
|
const start = projection.text.indexOf(quote);
|
||||||
|
expect(start).toBeGreaterThanOrEqual(0);
|
||||||
|
const range = resolveProjectionRange(projection, start, start + quote.length);
|
||||||
|
expect(range).not.toBeNull();
|
||||||
|
return createDocumentAnchorSelector(projection, range!);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("document text projection", () => {
|
||||||
|
it("projects markdown into normalized rendered text with source ranges", () => {
|
||||||
|
const markdown = [
|
||||||
|
"# Heading",
|
||||||
|
"",
|
||||||
|
"- Ship **bold** [link text](https://example.com) and `code span`.",
|
||||||
|
"| Name | Value |",
|
||||||
|
"| --- | --- |",
|
||||||
|
"| Alpha | Beta |",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const projection = projectMarkdownToText(markdown);
|
||||||
|
|
||||||
|
expect(projection.text).toContain("Heading");
|
||||||
|
expect(projection.text).toContain("Ship bold link text and code span.");
|
||||||
|
expect(projection.text).toContain("Name Value");
|
||||||
|
expect(projection.text).toContain("Alpha Beta");
|
||||||
|
expect(projection.text).not.toContain("https://example.com");
|
||||||
|
expect(projection.positions).toHaveLength(projection.text.length);
|
||||||
|
|
||||||
|
const linkStart = projection.text.indexOf("link text");
|
||||||
|
const range = resolveProjectionRange(projection, linkStart, linkStart + "link text".length);
|
||||||
|
expect(range?.markdownStart).toBe(markdown.indexOf("link text"));
|
||||||
|
expect(range?.markdownEnd).toBe(markdown.indexOf("link text") + "link text".length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes whitespace while retaining markdown offsets", () => {
|
||||||
|
const markdown = "First line\n\nSecond\t\tline";
|
||||||
|
const projection = projectMarkdownToText(markdown);
|
||||||
|
|
||||||
|
expect(projection.text).toBe("First line Second line");
|
||||||
|
const range = resolveProjectionRange(projection, projection.text.indexOf("Second"), projection.text.length);
|
||||||
|
expect(range?.markdownStart).toBe(markdown.indexOf("Second"));
|
||||||
|
expect(range?.markdownEnd).toBe(markdown.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves non-link punctuation", () => {
|
||||||
|
const markdown = "Keep (parenthetical) [plain brackets] visible.";
|
||||||
|
const projection = projectMarkdownToText(markdown);
|
||||||
|
|
||||||
|
expect(projection.text).toBe("Keep (parenthetical) [plain brackets] visible.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("document anchor verification and remapping", () => {
|
||||||
|
it("verifies a selector against its base revision", () => {
|
||||||
|
const markdown = "Intro text with **selected text** inside.";
|
||||||
|
const selector = selectorFor(markdown, "selected text");
|
||||||
|
|
||||||
|
const result = verifyDocumentAnchorSelector({ markdown, selector });
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.anchor?.selectedText).toBe("selected text");
|
||||||
|
expect(result.anchor?.markdownStart).toBe(markdown.indexOf("selected text"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remaps exact anchors after surrounding text moves", () => {
|
||||||
|
const selector = selectorFor("Alpha paragraph.\n\nTarget sentence here.\n\nOmega paragraph.", "Target sentence here.");
|
||||||
|
const previousAnchor = {
|
||||||
|
selectedText: selector.quote.exact,
|
||||||
|
prefixText: selector.quote.prefix,
|
||||||
|
suffixText: selector.quote.suffix,
|
||||||
|
normalizedStart: selector.position.normalizedStart,
|
||||||
|
normalizedEnd: selector.position.normalizedEnd,
|
||||||
|
markdownStart: selector.position.markdownStart,
|
||||||
|
markdownEnd: selector.position.markdownEnd,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = remapDocumentAnchor({
|
||||||
|
previousAnchor,
|
||||||
|
nextMarkdown: "Omega paragraph.\n\nAlpha paragraph.\n\nTarget sentence here.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.anchorState).toBe("active");
|
||||||
|
expect(result.confidence).toBe("exact");
|
||||||
|
expect(result.anchor?.selectedText).toBe("Target sentence here.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses context and proximity to disambiguate duplicate quotes", () => {
|
||||||
|
const selector = selectorFor("One apple near the start.\n\nTwo apple near the end.", "apple");
|
||||||
|
const previousAnchor = {
|
||||||
|
selectedText: selector.quote.exact,
|
||||||
|
prefixText: selector.quote.prefix,
|
||||||
|
suffixText: selector.quote.suffix,
|
||||||
|
normalizedStart: selector.position.normalizedStart,
|
||||||
|
normalizedEnd: selector.position.normalizedEnd,
|
||||||
|
markdownStart: selector.position.markdownStart,
|
||||||
|
markdownEnd: selector.position.markdownEnd,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = remapDocumentAnchor({
|
||||||
|
previousAnchor,
|
||||||
|
nextMarkdown: "Zero apple elsewhere.\n\nOne apple near the start.\n\nTwo apple near the end.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.anchorState).toBe("active");
|
||||||
|
expect(result.confidence).toBe("duplicate");
|
||||||
|
expect(result.anchor?.prefixText).toContain("One");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks duplicate anchors ambiguous when context cannot distinguish them", () => {
|
||||||
|
const selector = selectorFor("apple apple", "apple");
|
||||||
|
const previousAnchor = {
|
||||||
|
selectedText: selector.quote.exact,
|
||||||
|
prefixText: "",
|
||||||
|
suffixText: "",
|
||||||
|
normalizedStart: selector.position.normalizedStart,
|
||||||
|
normalizedEnd: selector.position.normalizedEnd,
|
||||||
|
markdownStart: selector.position.markdownStart,
|
||||||
|
markdownEnd: selector.position.markdownEnd,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = remapDocumentAnchor({ previousAnchor, nextMarkdown: "apple apple" });
|
||||||
|
|
||||||
|
expect(result.anchorState).toBe("stale");
|
||||||
|
expect(result.confidence).toBe("ambiguous");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps edited anchors as stale fuzzy matches", () => {
|
||||||
|
const selector = selectorFor("We rely on an important launch assumption for scope.", "important launch assumption");
|
||||||
|
const previousAnchor = {
|
||||||
|
selectedText: selector.quote.exact,
|
||||||
|
prefixText: selector.quote.prefix,
|
||||||
|
suffixText: selector.quote.suffix,
|
||||||
|
normalizedStart: selector.position.normalizedStart,
|
||||||
|
normalizedEnd: selector.position.normalizedEnd,
|
||||||
|
markdownStart: selector.position.markdownStart,
|
||||||
|
markdownEnd: selector.position.markdownEnd,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = remapDocumentAnchor({
|
||||||
|
previousAnchor,
|
||||||
|
nextMarkdown: "We rely on an important product launch assumption for scope.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.anchorState).toBe("stale");
|
||||||
|
expect(result.confidence).toBe("fuzzy");
|
||||||
|
expect(result.anchor?.selectedText).toBe("important product launch assumption");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks deleted anchors orphaned and allows future remapping from the latest known anchor", () => {
|
||||||
|
const selector = selectorFor("Keep this reviewed phrase in mind.", "reviewed phrase");
|
||||||
|
const previousAnchor = {
|
||||||
|
selectedText: selector.quote.exact,
|
||||||
|
prefixText: selector.quote.prefix,
|
||||||
|
suffixText: selector.quote.suffix,
|
||||||
|
normalizedStart: selector.position.normalizedStart,
|
||||||
|
normalizedEnd: selector.position.normalizedEnd,
|
||||||
|
markdownStart: selector.position.markdownStart,
|
||||||
|
markdownEnd: selector.position.markdownEnd,
|
||||||
|
};
|
||||||
|
|
||||||
|
const missing = remapDocumentAnchor({ previousAnchor, nextMarkdown: "The target disappeared." });
|
||||||
|
const recovered = remapDocumentAnchor({
|
||||||
|
previousAnchor,
|
||||||
|
nextMarkdown: "The target came back: reviewed phrase.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(missing.anchorState).toBe("orphaned");
|
||||||
|
expect(missing.confidence).toBe("missing");
|
||||||
|
expect(missing.anchor).toBeNull();
|
||||||
|
expect(recovered.anchorState).toBe("active");
|
||||||
|
expect(recovered.anchor?.selectedText).toBe("reviewed phrase");
|
||||||
|
});
|
||||||
|
});
|
||||||
464
packages/shared/src/document-anchors.ts
Normal file
464
packages/shared/src/document-anchors.ts
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
import type {
|
||||||
|
DocumentAnnotationAnchorConfidence,
|
||||||
|
DocumentAnnotationAnchorState,
|
||||||
|
} from "./constants.js";
|
||||||
|
import type {
|
||||||
|
DocumentAnnotationAnchorSelector,
|
||||||
|
DocumentAnnotationAnchorSnapshot,
|
||||||
|
DocumentTextPosition,
|
||||||
|
DocumentTextProjection,
|
||||||
|
DocumentTextRange,
|
||||||
|
} from "./types/document-annotation.js";
|
||||||
|
|
||||||
|
export interface CreateDocumentAnchorSelectorOptions {
|
||||||
|
contextLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyDocumentAnchorSelectorInput {
|
||||||
|
markdown: string;
|
||||||
|
selector: DocumentAnnotationAnchorSelector;
|
||||||
|
contextLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyDocumentAnchorSelectorResult {
|
||||||
|
ok: boolean;
|
||||||
|
anchor: DocumentAnnotationAnchorSnapshot | null;
|
||||||
|
projection: DocumentTextProjection;
|
||||||
|
reason: "verified" | "quote_mismatch" | "position_mismatch" | "invalid_range";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemapDocumentAnchorInput {
|
||||||
|
previousAnchor: DocumentAnnotationAnchorSnapshot;
|
||||||
|
nextMarkdown: string;
|
||||||
|
contextLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemapDocumentAnchorResult {
|
||||||
|
anchorState: DocumentAnnotationAnchorState;
|
||||||
|
confidence: DocumentAnnotationAnchorConfidence;
|
||||||
|
anchor: DocumentAnnotationAnchorSnapshot | null;
|
||||||
|
projection: DocumentTextProjection;
|
||||||
|
reason: "exact" | "duplicate" | "fuzzy" | "ambiguous" | "missing";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Candidate {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
score: number;
|
||||||
|
reason: RemapDocumentAnchorResult["reason"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONTEXT_LENGTH = 48;
|
||||||
|
|
||||||
|
export function normalizeAnchorText(value: string): string {
|
||||||
|
return value.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectMarkdownToText(markdown: string): DocumentTextProjection {
|
||||||
|
const builder = new ProjectionBuilder(markdown);
|
||||||
|
const lines = markdown.match(/[^\n]*(?:\n|$)/g) ?? [markdown];
|
||||||
|
let offset = 0;
|
||||||
|
let inFence = false;
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
if (rawLine === "") continue;
|
||||||
|
const hasNewline = rawLine.endsWith("\n");
|
||||||
|
const line = hasNewline ? rawLine.slice(0, -1) : rawLine;
|
||||||
|
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
||||||
|
|
||||||
|
if (fenceMatch) {
|
||||||
|
inFence = !inFence;
|
||||||
|
offset += rawLine.length;
|
||||||
|
builder.addSeparator(offset - (hasNewline ? 1 : 0));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inFence) {
|
||||||
|
builder.addText(line, offset);
|
||||||
|
builder.addSeparator(offset + line.length);
|
||||||
|
offset += rawLine.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { text, sourceOffset } = stripBlockSyntax(line, offset);
|
||||||
|
addInlineMarkdownText(builder, text, sourceOffset);
|
||||||
|
builder.addSeparator(offset + line.length);
|
||||||
|
offset += rawLine.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.toProjection();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProjectionRange(
|
||||||
|
projection: DocumentTextProjection,
|
||||||
|
normalizedStart: number,
|
||||||
|
normalizedEnd: number,
|
||||||
|
): DocumentTextRange | null {
|
||||||
|
if (
|
||||||
|
normalizedStart < 0
|
||||||
|
|| normalizedEnd <= normalizedStart
|
||||||
|
|| normalizedEnd > projection.text.length
|
||||||
|
|| normalizedStart >= projection.positions.length
|
||||||
|
|| normalizedEnd - 1 >= projection.positions.length
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: projection.text.slice(normalizedStart, normalizedEnd),
|
||||||
|
normalizedStart,
|
||||||
|
normalizedEnd,
|
||||||
|
markdownStart: projection.positions[normalizedStart]?.sourceStart ?? 0,
|
||||||
|
markdownEnd: projection.positions[normalizedEnd - 1]?.sourceEnd ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDocumentAnchorSelector(
|
||||||
|
projection: DocumentTextProjection,
|
||||||
|
range: DocumentTextRange,
|
||||||
|
options: CreateDocumentAnchorSelectorOptions = {},
|
||||||
|
): DocumentAnnotationAnchorSelector {
|
||||||
|
const contextLength = options.contextLength ?? DEFAULT_CONTEXT_LENGTH;
|
||||||
|
return {
|
||||||
|
quote: {
|
||||||
|
exact: range.text,
|
||||||
|
prefix: projection.text.slice(Math.max(0, range.normalizedStart - contextLength), range.normalizedStart),
|
||||||
|
suffix: projection.text.slice(range.normalizedEnd, range.normalizedEnd + contextLength),
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
normalizedStart: range.normalizedStart,
|
||||||
|
normalizedEnd: range.normalizedEnd,
|
||||||
|
markdownStart: range.markdownStart,
|
||||||
|
markdownEnd: range.markdownEnd,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectorToAnchorSnapshot(selector: DocumentAnnotationAnchorSelector): DocumentAnnotationAnchorSnapshot {
|
||||||
|
return {
|
||||||
|
selectedText: selector.quote.exact,
|
||||||
|
prefixText: selector.quote.prefix,
|
||||||
|
suffixText: selector.quote.suffix,
|
||||||
|
normalizedStart: selector.position.normalizedStart,
|
||||||
|
normalizedEnd: selector.position.normalizedEnd,
|
||||||
|
markdownStart: selector.position.markdownStart,
|
||||||
|
markdownEnd: selector.position.markdownEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function anchorSnapshotToSelector(anchor: DocumentAnnotationAnchorSnapshot): DocumentAnnotationAnchorSelector {
|
||||||
|
return {
|
||||||
|
quote: {
|
||||||
|
exact: anchor.selectedText,
|
||||||
|
prefix: anchor.prefixText,
|
||||||
|
suffix: anchor.suffixText,
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
normalizedStart: anchor.normalizedStart,
|
||||||
|
normalizedEnd: anchor.normalizedEnd,
|
||||||
|
markdownStart: anchor.markdownStart,
|
||||||
|
markdownEnd: anchor.markdownEnd,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyDocumentAnchorSelector(
|
||||||
|
input: VerifyDocumentAnchorSelectorInput,
|
||||||
|
): VerifyDocumentAnchorSelectorResult {
|
||||||
|
const projection = projectMarkdownToText(input.markdown);
|
||||||
|
const range = resolveProjectionRange(
|
||||||
|
projection,
|
||||||
|
input.selector.position.normalizedStart,
|
||||||
|
input.selector.position.normalizedEnd,
|
||||||
|
);
|
||||||
|
if (!range) {
|
||||||
|
return { ok: false, anchor: null, projection, reason: "invalid_range" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeAnchorText(range.text) !== normalizeAnchorText(input.selector.quote.exact)) {
|
||||||
|
return { ok: false, anchor: null, projection, reason: "quote_mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
range.markdownStart !== input.selector.position.markdownStart
|
||||||
|
|| range.markdownEnd !== input.selector.position.markdownEnd
|
||||||
|
) {
|
||||||
|
return { ok: false, anchor: null, projection, reason: "position_mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selector = createDocumentAnchorSelector(projection, range, {
|
||||||
|
contextLength: input.contextLength ?? DEFAULT_CONTEXT_LENGTH,
|
||||||
|
});
|
||||||
|
return { ok: true, anchor: selectorToAnchorSnapshot(selector), projection, reason: "verified" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remapDocumentAnchor(input: RemapDocumentAnchorInput): RemapDocumentAnchorResult {
|
||||||
|
const projection = projectMarkdownToText(input.nextMarkdown);
|
||||||
|
const contextLength = input.contextLength ?? DEFAULT_CONTEXT_LENGTH;
|
||||||
|
const quote = normalizeAnchorText(input.previousAnchor.selectedText);
|
||||||
|
if (!quote) {
|
||||||
|
return { anchorState: "orphaned", confidence: "missing", anchor: null, projection, reason: "missing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactCandidates = findOccurrences(projection.text, quote).map((start) => scoreCandidate({
|
||||||
|
projection,
|
||||||
|
start,
|
||||||
|
end: start + quote.length,
|
||||||
|
previousAnchor: input.previousAnchor,
|
||||||
|
reason: "exact",
|
||||||
|
contextLength,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (exactCandidates.length > 0) {
|
||||||
|
exactCandidates.sort((a, b) => b.score - a.score);
|
||||||
|
const [best, second] = exactCandidates;
|
||||||
|
if (exactCandidates.length > 1 && (!second || Math.abs(best.score - second.score) < 0.05)) {
|
||||||
|
return {
|
||||||
|
anchorState: "stale",
|
||||||
|
confidence: "ambiguous",
|
||||||
|
anchor: buildAnchorSnapshot(projection, best.start, best.end, contextLength),
|
||||||
|
projection,
|
||||||
|
reason: "ambiguous",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
anchorState: "active",
|
||||||
|
confidence: exactCandidates.length === 1 ? "exact" : "duplicate",
|
||||||
|
anchor: buildAnchorSnapshot(projection, best.start, best.end, contextLength),
|
||||||
|
projection,
|
||||||
|
reason: exactCandidates.length === 1 ? "exact" : "duplicate",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fuzzy = findFuzzyCandidate(projection, input.previousAnchor, contextLength);
|
||||||
|
if (fuzzy && fuzzy.score >= 0.58) {
|
||||||
|
return {
|
||||||
|
anchorState: "stale",
|
||||||
|
confidence: "fuzzy",
|
||||||
|
anchor: buildAnchorSnapshot(projection, fuzzy.start, fuzzy.end, contextLength),
|
||||||
|
projection,
|
||||||
|
reason: "fuzzy",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { anchorState: "orphaned", confidence: "missing", anchor: null, projection, reason: "missing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripBlockSyntax(line: string, absoluteOffset: number): { text: string; sourceOffset: number } {
|
||||||
|
const blockMatch = line.match(/^\s{0,3}(?:(#{1,6})\s+|(?:[-+*]|\d+[.)])\s+|>\s?)/);
|
||||||
|
if (!blockMatch) return { text: line, sourceOffset: absoluteOffset };
|
||||||
|
return { text: line.slice(blockMatch[0].length), sourceOffset: absoluteOffset + blockMatch[0].length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInlineMarkdownText(builder: ProjectionBuilder, text: string, sourceOffset: number): void {
|
||||||
|
for (let index = 0; index < text.length; index += 1) {
|
||||||
|
const char = text[index] ?? "";
|
||||||
|
const absolute = sourceOffset + index;
|
||||||
|
const rest = text.slice(index);
|
||||||
|
|
||||||
|
const image = rest.match(/^!\[([^\]]*)\]\(([^)]*)\)/);
|
||||||
|
if (image) {
|
||||||
|
const altStart = absolute + 2;
|
||||||
|
builder.addText(image[1] ?? "", altStart);
|
||||||
|
index += image[0].length - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = rest.match(/^\[([^\]]+)\]\(([^)]*)\)/);
|
||||||
|
if (link) {
|
||||||
|
const labelStart = absolute + 1;
|
||||||
|
builder.addText(link[1] ?? "", labelStart);
|
||||||
|
index += link[0].length - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "`") {
|
||||||
|
const closing = text.indexOf("`", index + 1);
|
||||||
|
if (closing > index + 1) {
|
||||||
|
builder.addText(text.slice(index + 1, closing), absolute + 1);
|
||||||
|
index = closing;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "|" || char === "\t") {
|
||||||
|
builder.addSeparator(absolute);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMarkdownFormattingChar(char, text, index)) continue;
|
||||||
|
|
||||||
|
builder.addChar(char, absolute, absolute + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarkdownFormattingChar(char: string, text: string, index: number): boolean {
|
||||||
|
if (char === "*" || char === "_" || char === "~") return true;
|
||||||
|
if (char === "\\" && index + 1 < text.length) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOccurrences(text: string, quote: string): number[] {
|
||||||
|
const starts: number[] = [];
|
||||||
|
let start = text.indexOf(quote);
|
||||||
|
while (start !== -1) {
|
||||||
|
starts.push(start);
|
||||||
|
start = text.indexOf(quote, start + 1);
|
||||||
|
}
|
||||||
|
return starts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreCandidate(args: {
|
||||||
|
projection: DocumentTextProjection;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
previousAnchor: DocumentAnnotationAnchorSnapshot;
|
||||||
|
reason: Candidate["reason"];
|
||||||
|
contextLength: number;
|
||||||
|
}): Candidate {
|
||||||
|
const before = args.projection.text.slice(Math.max(0, args.start - args.contextLength), args.start);
|
||||||
|
const after = args.projection.text.slice(args.end, args.end + args.contextLength);
|
||||||
|
const prefixScore = suffixOverlapScore(args.previousAnchor.prefixText, before);
|
||||||
|
const suffixScore = prefixOverlapScore(args.previousAnchor.suffixText, after);
|
||||||
|
const distance = Math.abs(args.start - args.previousAnchor.normalizedStart);
|
||||||
|
const proximity = 1 / (1 + distance / 200);
|
||||||
|
return {
|
||||||
|
start: args.start,
|
||||||
|
end: args.end,
|
||||||
|
score: prefixScore * 0.35 + suffixScore * 0.35 + proximity * 0.3,
|
||||||
|
reason: args.reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFuzzyCandidate(
|
||||||
|
projection: DocumentTextProjection,
|
||||||
|
previousAnchor: DocumentAnnotationAnchorSnapshot,
|
||||||
|
contextLength: number,
|
||||||
|
): Candidate | null {
|
||||||
|
const words = normalizeAnchorText(previousAnchor.selectedText).split(" ").filter(Boolean);
|
||||||
|
if (words.length === 0) return null;
|
||||||
|
const textWords = [...projection.text.matchAll(/\S+/g)].map((match) => ({
|
||||||
|
text: match[0],
|
||||||
|
start: match.index ?? 0,
|
||||||
|
end: (match.index ?? 0) + match[0].length,
|
||||||
|
}));
|
||||||
|
const windowSizes = new Set([words.length - 1, words.length, words.length + 1, words.length + 2].filter((n) => n > 0));
|
||||||
|
let best: Candidate | null = null;
|
||||||
|
|
||||||
|
for (const size of windowSizes) {
|
||||||
|
for (let index = 0; index + size <= textWords.length; index += 1) {
|
||||||
|
const window = textWords.slice(index, index + size);
|
||||||
|
const candidateText = window.map((word) => word.text).join(" ");
|
||||||
|
const similarity = similarityScore(normalizeAnchorText(previousAnchor.selectedText), candidateText);
|
||||||
|
if (similarity < 0.45) continue;
|
||||||
|
const scored = scoreCandidate({
|
||||||
|
projection,
|
||||||
|
start: window[0]?.start ?? 0,
|
||||||
|
end: window[window.length - 1]?.end ?? 0,
|
||||||
|
previousAnchor,
|
||||||
|
reason: "fuzzy",
|
||||||
|
contextLength,
|
||||||
|
});
|
||||||
|
scored.score = scored.score * 0.35 + similarity * 0.65;
|
||||||
|
if (!best || scored.score > best.score) best = scored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnchorSnapshot(
|
||||||
|
projection: DocumentTextProjection,
|
||||||
|
normalizedStart: number,
|
||||||
|
normalizedEnd: number,
|
||||||
|
contextLength: number,
|
||||||
|
): DocumentAnnotationAnchorSnapshot {
|
||||||
|
const range = resolveProjectionRange(projection, normalizedStart, normalizedEnd);
|
||||||
|
if (!range) {
|
||||||
|
return {
|
||||||
|
selectedText: "",
|
||||||
|
prefixText: "",
|
||||||
|
suffixText: "",
|
||||||
|
normalizedStart,
|
||||||
|
normalizedEnd,
|
||||||
|
markdownStart: 0,
|
||||||
|
markdownEnd: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const selector = createDocumentAnchorSelector(projection, range, { contextLength });
|
||||||
|
return selectorToAnchorSnapshot(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefixOverlapScore(expectedPrefix: string, actualPrefix: string): number {
|
||||||
|
const expected = normalizeAnchorText(expectedPrefix);
|
||||||
|
const actual = normalizeAnchorText(actualPrefix);
|
||||||
|
if (!expected) return 0.5;
|
||||||
|
for (let size = Math.min(expected.length, actual.length); size > 0; size -= 1) {
|
||||||
|
if (expected.slice(0, size) === actual.slice(0, size)) return size / expected.length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function suffixOverlapScore(expectedPrefix: string, actualPrefix: string): number {
|
||||||
|
const expected = normalizeAnchorText(expectedPrefix);
|
||||||
|
const actual = normalizeAnchorText(actualPrefix);
|
||||||
|
if (!expected) return 0.5;
|
||||||
|
for (let size = Math.min(expected.length, actual.length); size > 0; size -= 1) {
|
||||||
|
if (expected.slice(-size) === actual.slice(-size)) return size / expected.length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function similarityScore(left: string, right: string): number {
|
||||||
|
if (left === right) return 1;
|
||||||
|
const leftWords = new Set(left.toLowerCase().split(/\s+/).filter(Boolean));
|
||||||
|
const rightWords = new Set(right.toLowerCase().split(/\s+/).filter(Boolean));
|
||||||
|
const intersection = [...leftWords].filter((word) => rightWords.has(word)).length;
|
||||||
|
const union = new Set([...leftWords, ...rightWords]).size || 1;
|
||||||
|
const jaccard = intersection / union;
|
||||||
|
const lengthRatio = Math.min(left.length, right.length) / Math.max(left.length, right.length, 1);
|
||||||
|
return jaccard * 0.75 + lengthRatio * 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProjectionBuilder {
|
||||||
|
private text = "";
|
||||||
|
private positions: DocumentTextPosition[] = [];
|
||||||
|
private pendingSpace: DocumentTextPosition | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly source: string) {}
|
||||||
|
|
||||||
|
addText(text: string, sourceOffset: number): void {
|
||||||
|
for (let index = 0; index < text.length; index += 1) {
|
||||||
|
this.addChar(text[index] ?? "", sourceOffset + index, sourceOffset + index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addSeparator(sourceOffset: number): void {
|
||||||
|
this.addChar(" ", sourceOffset, sourceOffset + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
addChar(char: string, sourceStart: number, sourceEnd: number): void {
|
||||||
|
if (/\s/.test(char)) {
|
||||||
|
if (this.text.length > 0 && !this.pendingSpace) {
|
||||||
|
this.pendingSpace = { sourceStart, sourceEnd };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pendingSpace && this.text.length > 0) {
|
||||||
|
this.text += " ";
|
||||||
|
this.positions.push(this.pendingSpace);
|
||||||
|
}
|
||||||
|
this.pendingSpace = null;
|
||||||
|
this.text += char;
|
||||||
|
this.positions.push({ sourceStart, sourceEnd });
|
||||||
|
}
|
||||||
|
|
||||||
|
toProjection(): DocumentTextProjection {
|
||||||
|
return {
|
||||||
|
source: this.source,
|
||||||
|
text: this.text,
|
||||||
|
positions: this.positions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,9 @@ export {
|
||||||
SYSTEM_ISSUE_DOCUMENT_KEYS,
|
SYSTEM_ISSUE_DOCUMENT_KEYS,
|
||||||
isSystemIssueDocumentKey,
|
isSystemIssueDocumentKey,
|
||||||
ISSUE_REFERENCE_SOURCE_KINDS,
|
ISSUE_REFERENCE_SOURCE_KINDS,
|
||||||
|
DOCUMENT_ANNOTATION_THREAD_STATUSES,
|
||||||
|
DOCUMENT_ANNOTATION_ANCHOR_STATES,
|
||||||
|
DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES,
|
||||||
ISSUE_EXECUTION_POLICY_MODES,
|
ISSUE_EXECUTION_POLICY_MODES,
|
||||||
ISSUE_EXECUTION_STAGE_TYPES,
|
ISSUE_EXECUTION_STAGE_TYPES,
|
||||||
ISSUE_MONITOR_SCHEDULED_BY,
|
ISSUE_MONITOR_SCHEDULED_BY,
|
||||||
|
|
@ -164,6 +167,9 @@ export {
|
||||||
type IssueTreeHoldStatus,
|
type IssueTreeHoldStatus,
|
||||||
type SystemIssueDocumentKey,
|
type SystemIssueDocumentKey,
|
||||||
type IssueReferenceSourceKind,
|
type IssueReferenceSourceKind,
|
||||||
|
type DocumentAnnotationThreadStatus,
|
||||||
|
type DocumentAnnotationAnchorState,
|
||||||
|
type DocumentAnnotationAnchorConfidence,
|
||||||
type IssueExecutionPolicyMode,
|
type IssueExecutionPolicyMode,
|
||||||
type IssueExecutionStageType,
|
type IssueExecutionStageType,
|
||||||
type IssueMonitorScheduledBy,
|
type IssueMonitorScheduledBy,
|
||||||
|
|
@ -376,6 +382,20 @@ export type {
|
||||||
IssueWorkProductProvider,
|
IssueWorkProductProvider,
|
||||||
IssueWorkProductStatus,
|
IssueWorkProductStatus,
|
||||||
IssueWorkProductReviewState,
|
IssueWorkProductReviewState,
|
||||||
|
CreateDocumentAnnotationCommentRequest,
|
||||||
|
CreateDocumentAnnotationThreadRequest,
|
||||||
|
DocumentAnnotationAnchorRemapSnapshot,
|
||||||
|
DocumentAnnotationAnchorSelector,
|
||||||
|
DocumentAnnotationAnchorSnapshot,
|
||||||
|
DocumentAnnotationComment,
|
||||||
|
DocumentAnnotationTextPositionSelector,
|
||||||
|
DocumentAnnotationTextQuoteSelector,
|
||||||
|
DocumentAnnotationThread,
|
||||||
|
DocumentAnnotationThreadWithComments,
|
||||||
|
DocumentTextPosition,
|
||||||
|
DocumentTextProjection,
|
||||||
|
DocumentTextRange,
|
||||||
|
UpdateDocumentAnnotationThreadRequest,
|
||||||
Issue,
|
Issue,
|
||||||
IssueAssigneeAdapterOverrides,
|
IssueAssigneeAdapterOverrides,
|
||||||
IssueBlockerAttention,
|
IssueBlockerAttention,
|
||||||
|
|
@ -654,6 +674,22 @@ export {
|
||||||
type IssueReferenceMatch,
|
type IssueReferenceMatch,
|
||||||
} from "./issue-references.js";
|
} from "./issue-references.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
anchorSnapshotToSelector,
|
||||||
|
createDocumentAnchorSelector,
|
||||||
|
normalizeAnchorText,
|
||||||
|
projectMarkdownToText,
|
||||||
|
remapDocumentAnchor,
|
||||||
|
resolveProjectionRange,
|
||||||
|
selectorToAnchorSnapshot,
|
||||||
|
verifyDocumentAnchorSelector,
|
||||||
|
type CreateDocumentAnchorSelectorOptions,
|
||||||
|
type RemapDocumentAnchorInput,
|
||||||
|
type RemapDocumentAnchorResult,
|
||||||
|
type VerifyDocumentAnchorSelectorInput,
|
||||||
|
type VerifyDocumentAnchorSelectorResult,
|
||||||
|
} from "./document-anchors.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
sidebarOrderPreferenceSchema,
|
sidebarOrderPreferenceSchema,
|
||||||
upsertSidebarOrderPreferenceSchema,
|
upsertSidebarOrderPreferenceSchema,
|
||||||
|
|
@ -795,6 +831,18 @@ export {
|
||||||
type CreateProjectWorkspace,
|
type CreateProjectWorkspace,
|
||||||
type UpdateProjectWorkspace,
|
type UpdateProjectWorkspace,
|
||||||
projectExecutionWorkspacePolicySchema,
|
projectExecutionWorkspacePolicySchema,
|
||||||
|
createDocumentAnnotationCommentSchema,
|
||||||
|
createDocumentAnnotationThreadSchema,
|
||||||
|
documentAnnotationAnchorConfidenceSchema,
|
||||||
|
documentAnnotationAnchorSelectorSchema,
|
||||||
|
documentAnnotationAnchorStateSchema,
|
||||||
|
documentAnnotationTextPositionSelectorSchema,
|
||||||
|
documentAnnotationTextQuoteSelectorSchema,
|
||||||
|
documentAnnotationThreadStatusSchema,
|
||||||
|
updateDocumentAnnotationThreadSchema,
|
||||||
|
type CreateDocumentAnnotationComment,
|
||||||
|
type CreateDocumentAnnotationThread,
|
||||||
|
type UpdateDocumentAnnotationThread,
|
||||||
companySearchQuerySchema,
|
companySearchQuerySchema,
|
||||||
COMPANY_SEARCH_DEFAULT_LIMIT,
|
COMPANY_SEARCH_DEFAULT_LIMIT,
|
||||||
COMPANY_SEARCH_MAX_LIMIT,
|
COMPANY_SEARCH_MAX_LIMIT,
|
||||||
|
|
|
||||||
134
packages/shared/src/types/document-annotation.ts
Normal file
134
packages/shared/src/types/document-annotation.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import type {
|
||||||
|
DocumentAnnotationAnchorConfidence,
|
||||||
|
DocumentAnnotationAnchorState,
|
||||||
|
DocumentAnnotationThreadStatus,
|
||||||
|
IssueCommentAuthorType,
|
||||||
|
} from "../constants.js";
|
||||||
|
|
||||||
|
export interface DocumentTextPosition {
|
||||||
|
sourceStart: number;
|
||||||
|
sourceEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTextProjection {
|
||||||
|
source: string;
|
||||||
|
text: string;
|
||||||
|
positions: DocumentTextPosition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTextRange {
|
||||||
|
text: string;
|
||||||
|
normalizedStart: number;
|
||||||
|
normalizedEnd: number;
|
||||||
|
markdownStart: number;
|
||||||
|
markdownEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentAnnotationTextQuoteSelector {
|
||||||
|
exact: string;
|
||||||
|
prefix: string;
|
||||||
|
suffix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentAnnotationTextPositionSelector {
|
||||||
|
normalizedStart: number;
|
||||||
|
normalizedEnd: number;
|
||||||
|
markdownStart: number;
|
||||||
|
markdownEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentAnnotationAnchorSelector {
|
||||||
|
quote: DocumentAnnotationTextQuoteSelector;
|
||||||
|
position: DocumentAnnotationTextPositionSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentAnnotationAnchorSnapshot {
|
||||||
|
selectedText: string;
|
||||||
|
prefixText: string;
|
||||||
|
suffixText: string;
|
||||||
|
normalizedStart: number;
|
||||||
|
normalizedEnd: number;
|
||||||
|
markdownStart: number;
|
||||||
|
markdownEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentAnnotationThread {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
issueId: string;
|
||||||
|
documentId: string;
|
||||||
|
documentKey: string;
|
||||||
|
status: DocumentAnnotationThreadStatus;
|
||||||
|
anchorState: DocumentAnnotationAnchorState;
|
||||||
|
anchorConfidence: DocumentAnnotationAnchorConfidence;
|
||||||
|
originalRevisionId: string | null;
|
||||||
|
originalRevisionNumber: number;
|
||||||
|
currentRevisionId: string | null;
|
||||||
|
currentRevisionNumber: number;
|
||||||
|
selectedText: string;
|
||||||
|
prefixText: string;
|
||||||
|
suffixText: string;
|
||||||
|
normalizedStart: number;
|
||||||
|
normalizedEnd: number;
|
||||||
|
markdownStart: number;
|
||||||
|
markdownEnd: number;
|
||||||
|
anchorSelector: DocumentAnnotationAnchorSelector;
|
||||||
|
createdByAgentId: string | null;
|
||||||
|
createdByUserId: string | null;
|
||||||
|
resolvedByAgentId: string | null;
|
||||||
|
resolvedByUserId: string | null;
|
||||||
|
resolvedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentAnnotationComment {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
threadId: string;
|
||||||
|
issueId: string;
|
||||||
|
documentId: string;
|
||||||
|
body: string;
|
||||||
|
authorType: IssueCommentAuthorType;
|
||||||
|
authorAgentId: string | null;
|
||||||
|
authorUserId: string | null;
|
||||||
|
createdByRunId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentAnnotationAnchorRemapSnapshot {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
threadId: string;
|
||||||
|
documentId: string;
|
||||||
|
fromRevisionId: string | null;
|
||||||
|
fromRevisionNumber: number | null;
|
||||||
|
toRevisionId: string | null;
|
||||||
|
toRevisionNumber: number;
|
||||||
|
previousAnchor: DocumentAnnotationAnchorSnapshot;
|
||||||
|
nextAnchor: DocumentAnnotationAnchorSnapshot | null;
|
||||||
|
anchorState: DocumentAnnotationAnchorState;
|
||||||
|
anchorConfidence: DocumentAnnotationAnchorConfidence;
|
||||||
|
failureReason: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentAnnotationThreadWithComments extends DocumentAnnotationThread {
|
||||||
|
comments: DocumentAnnotationComment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDocumentAnnotationThreadRequest {
|
||||||
|
baseRevisionId: string;
|
||||||
|
baseRevisionNumber: number;
|
||||||
|
selector: DocumentAnnotationAnchorSelector;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDocumentAnnotationCommentRequest {
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDocumentAnnotationThreadRequest {
|
||||||
|
status?: DocumentAnnotationThreadStatus;
|
||||||
|
}
|
||||||
|
|
@ -89,6 +89,22 @@ export type {
|
||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
} from "./agent.js";
|
} from "./agent.js";
|
||||||
export type { AssetImage } from "./asset.js";
|
export type { AssetImage } from "./asset.js";
|
||||||
|
export type {
|
||||||
|
CreateDocumentAnnotationCommentRequest,
|
||||||
|
CreateDocumentAnnotationThreadRequest,
|
||||||
|
DocumentAnnotationAnchorRemapSnapshot,
|
||||||
|
DocumentAnnotationAnchorSelector,
|
||||||
|
DocumentAnnotationAnchorSnapshot,
|
||||||
|
DocumentAnnotationComment,
|
||||||
|
DocumentAnnotationTextPositionSelector,
|
||||||
|
DocumentAnnotationTextQuoteSelector,
|
||||||
|
DocumentAnnotationThread,
|
||||||
|
DocumentAnnotationThreadWithComments,
|
||||||
|
DocumentTextPosition,
|
||||||
|
DocumentTextProjection,
|
||||||
|
DocumentTextRange,
|
||||||
|
UpdateDocumentAnnotationThreadRequest,
|
||||||
|
} from "./document-annotation.js";
|
||||||
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
|
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
|
||||||
export type {
|
export type {
|
||||||
CompanySearchHighlight,
|
CompanySearchHighlight,
|
||||||
|
|
|
||||||
65
packages/shared/src/validators/document-annotation.ts
Normal file
65
packages/shared/src/validators/document-annotation.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES,
|
||||||
|
DOCUMENT_ANNOTATION_ANCHOR_STATES,
|
||||||
|
DOCUMENT_ANNOTATION_THREAD_STATUSES,
|
||||||
|
} from "../constants.js";
|
||||||
|
import { multilineTextSchema } from "./text.js";
|
||||||
|
|
||||||
|
export const documentAnnotationThreadStatusSchema = z.enum(DOCUMENT_ANNOTATION_THREAD_STATUSES);
|
||||||
|
export const documentAnnotationAnchorStateSchema = z.enum(DOCUMENT_ANNOTATION_ANCHOR_STATES);
|
||||||
|
export const documentAnnotationAnchorConfidenceSchema = z.enum(DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES);
|
||||||
|
|
||||||
|
export const documentAnnotationTextQuoteSelectorSchema = z.object({
|
||||||
|
exact: z.string().min(1).max(10_000),
|
||||||
|
prefix: z.string().max(1_000).default(""),
|
||||||
|
suffix: z.string().max(1_000).default(""),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const documentAnnotationTextPositionSelectorSchema = z.object({
|
||||||
|
normalizedStart: z.number().int().nonnegative(),
|
||||||
|
normalizedEnd: z.number().int().nonnegative(),
|
||||||
|
markdownStart: z.number().int().nonnegative(),
|
||||||
|
markdownEnd: z.number().int().nonnegative(),
|
||||||
|
}).strict().superRefine((value, ctx) => {
|
||||||
|
if (value.normalizedEnd <= value.normalizedStart) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "normalizedEnd must be greater than normalizedStart",
|
||||||
|
path: ["normalizedEnd"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value.markdownEnd <= value.markdownStart) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "markdownEnd must be greater than markdownStart",
|
||||||
|
path: ["markdownEnd"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const documentAnnotationAnchorSelectorSchema = z.object({
|
||||||
|
quote: documentAnnotationTextQuoteSelectorSchema,
|
||||||
|
position: documentAnnotationTextPositionSelectorSchema,
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const createDocumentAnnotationThreadSchema = z.object({
|
||||||
|
baseRevisionId: z.string().uuid(),
|
||||||
|
baseRevisionNumber: z.number().int().positive(),
|
||||||
|
selector: documentAnnotationAnchorSelectorSchema,
|
||||||
|
body: multilineTextSchema.pipe(z.string().min(1).max(20_000)),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const createDocumentAnnotationCommentSchema = z.object({
|
||||||
|
body: multilineTextSchema.pipe(z.string().min(1).max(20_000)),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const updateDocumentAnnotationThreadSchema = z.object({
|
||||||
|
status: documentAnnotationThreadStatusSchema.optional(),
|
||||||
|
}).strict().refine((value) => value.status != null, {
|
||||||
|
message: "At least one field must be provided",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateDocumentAnnotationThread = z.infer<typeof createDocumentAnnotationThreadSchema>;
|
||||||
|
export type CreateDocumentAnnotationComment = z.infer<typeof createDocumentAnnotationCommentSchema>;
|
||||||
|
export type UpdateDocumentAnnotationThread = z.infer<typeof updateDocumentAnnotationThreadSchema>;
|
||||||
|
|
@ -152,6 +152,21 @@ export {
|
||||||
type ProjectExecutionWorkspacePolicy,
|
type ProjectExecutionWorkspacePolicy,
|
||||||
} from "./project.js";
|
} from "./project.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
createDocumentAnnotationCommentSchema,
|
||||||
|
createDocumentAnnotationThreadSchema,
|
||||||
|
documentAnnotationAnchorConfidenceSchema,
|
||||||
|
documentAnnotationAnchorSelectorSchema,
|
||||||
|
documentAnnotationAnchorStateSchema,
|
||||||
|
documentAnnotationTextPositionSelectorSchema,
|
||||||
|
documentAnnotationTextQuoteSelectorSchema,
|
||||||
|
documentAnnotationThreadStatusSchema,
|
||||||
|
updateDocumentAnnotationThreadSchema,
|
||||||
|
type CreateDocumentAnnotationComment,
|
||||||
|
type CreateDocumentAnnotationThread,
|
||||||
|
type UpdateDocumentAnnotationThread,
|
||||||
|
} from "./document-annotation.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createIssueSchema,
|
createIssueSchema,
|
||||||
createIssueInputSchema,
|
createIssueInputSchema,
|
||||||
|
|
|
||||||
288
server/src/__tests__/document-annotation-routes.test.ts
Normal file
288
server/src/__tests__/document-annotation-routes.test.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const issueId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const otherCompanyId = "33333333-3333-4333-8333-333333333333";
|
||||||
|
|
||||||
|
const mockIssueService = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
assertCheckoutOwner: vi.fn(),
|
||||||
|
}));
|
||||||
|
const mockDocumentService = vi.hoisted(() => ({
|
||||||
|
getIssueDocumentByKey: vi.fn(),
|
||||||
|
}));
|
||||||
|
const mockAnnotationService = vi.hoisted(() => ({
|
||||||
|
listThreadsForIssueDocument: vi.fn(),
|
||||||
|
getThreadForIssueDocument: vi.fn(),
|
||||||
|
createThread: vi.fn(),
|
||||||
|
addComment: vi.fn(),
|
||||||
|
updateThread: vi.fn(),
|
||||||
|
remapOpenThreadsForDocument: vi.fn(),
|
||||||
|
}));
|
||||||
|
const mockIssueReferenceService = vi.hoisted(() => ({
|
||||||
|
diffIssueReferenceSummary: vi.fn(() => ({
|
||||||
|
addedReferencedIssues: [],
|
||||||
|
removedReferencedIssues: [],
|
||||||
|
currentReferencedIssues: [],
|
||||||
|
})),
|
||||||
|
emptySummary: vi.fn(() => ({ outbound: [], inbound: [] })),
|
||||||
|
listIssueReferenceSummary: vi.fn(async () => ({ outbound: [], inbound: [] })),
|
||||||
|
syncAnnotationComment: vi.fn(async () => undefined),
|
||||||
|
syncComment: vi.fn(async () => undefined),
|
||||||
|
syncDocument: vi.fn(async () => undefined),
|
||||||
|
syncIssue: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
const mockHeartbeatService = vi.hoisted(() => ({
|
||||||
|
wakeup: vi.fn(async () => undefined),
|
||||||
|
reportRunActivity: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||||
|
|
||||||
|
const documentPayload = {
|
||||||
|
id: "document-1",
|
||||||
|
companyId,
|
||||||
|
issueId,
|
||||||
|
key: "plan",
|
||||||
|
title: "Plan",
|
||||||
|
format: "markdown",
|
||||||
|
body: "Alpha selected text omega",
|
||||||
|
latestRevisionId: "44444444-4444-4444-8444-444444444444",
|
||||||
|
latestRevisionNumber: 1,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "board-user",
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: "board-user",
|
||||||
|
createdAt: new Date("2026-05-14T12:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-05-14T12:00:00.000Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const annotationThread = {
|
||||||
|
id: "55555555-5555-4555-8555-555555555555",
|
||||||
|
companyId,
|
||||||
|
issueId,
|
||||||
|
documentId: "document-1",
|
||||||
|
documentKey: "plan",
|
||||||
|
status: "open",
|
||||||
|
anchorState: "active",
|
||||||
|
anchorConfidence: "exact",
|
||||||
|
originalRevisionId: documentPayload.latestRevisionId,
|
||||||
|
originalRevisionNumber: 1,
|
||||||
|
currentRevisionId: documentPayload.latestRevisionId,
|
||||||
|
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-05-14T12:01:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-05-14T12:01:00.000Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const annotationComment = {
|
||||||
|
id: "66666666-6666-4666-8666-666666666666",
|
||||||
|
companyId,
|
||||||
|
threadId: annotationThread.id,
|
||||||
|
issueId,
|
||||||
|
documentId: "document-1",
|
||||||
|
body: "Please review PAP-1",
|
||||||
|
authorType: "user",
|
||||||
|
authorAgentId: null,
|
||||||
|
authorUserId: "board-user",
|
||||||
|
createdByRunId: null,
|
||||||
|
createdAt: new Date("2026-05-14T12:01:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-05-14T12:01:00.000Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function registerModuleMocks() {
|
||||||
|
vi.doMock("../services/index.js", () => ({
|
||||||
|
accessService: () => ({ canUser: vi.fn(), hasPermission: vi.fn(async () => false) }),
|
||||||
|
agentService: () => ({ getById: vi.fn(), list: vi.fn(async () => []) }),
|
||||||
|
companyService: () => ({ getById: vi.fn(async () => ({ id: companyId, attachmentMaxBytes: 10_000_000 })) }),
|
||||||
|
documentAnnotationService: () => mockAnnotationService,
|
||||||
|
documentService: () => mockDocumentService,
|
||||||
|
environmentService: () => ({}),
|
||||||
|
executionWorkspaceService: () => ({}),
|
||||||
|
feedbackService: () => ({}),
|
||||||
|
goalService: () => ({}),
|
||||||
|
heartbeatService: () => mockHeartbeatService,
|
||||||
|
instanceSettingsService: () => ({
|
||||||
|
get: vi.fn(async () => ({ id: "settings", general: {} })),
|
||||||
|
getExperimental: vi.fn(async () => ({})),
|
||||||
|
getGeneral: vi.fn(async () => ({})),
|
||||||
|
listCompanyIds: vi.fn(async () => [companyId]),
|
||||||
|
}),
|
||||||
|
issueApprovalService: () => ({}),
|
||||||
|
issueRecoveryActionService: () => ({
|
||||||
|
getActiveForIssue: vi.fn(async () => null),
|
||||||
|
listActiveForIssues: vi.fn(async () => new Map()),
|
||||||
|
}),
|
||||||
|
issueReferenceService: () => mockIssueReferenceService,
|
||||||
|
issueService: () => mockIssueService,
|
||||||
|
issueThreadInteractionService: () => ({
|
||||||
|
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||||
|
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||||
|
}),
|
||||||
|
logActivity: mockLogActivity,
|
||||||
|
projectService: () => ({}),
|
||||||
|
routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }),
|
||||||
|
workProductService: () => ({}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApp(actor: "board" | "agent" = "board", actorCompanyId = companyId) {
|
||||||
|
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||||
|
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.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: "77777777-7777-4777-8777-777777777777",
|
||||||
|
companyId: actorCompanyId,
|
||||||
|
runId: "88888888-8888-4888-8888-888888888888",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "board",
|
||||||
|
userId: "board-user",
|
||||||
|
companyIds: [actorCompanyId],
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/api", issueRoutes({} as any, {} as any));
|
||||||
|
app.use(errorHandler);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("document annotation routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doUnmock("../routes/issues.js");
|
||||||
|
vi.doUnmock("../middleware/index.js");
|
||||||
|
registerModuleMocks();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockIssueService.getById.mockResolvedValue({
|
||||||
|
id: issueId,
|
||||||
|
companyId,
|
||||||
|
title: "Annotation API",
|
||||||
|
status: "in_progress",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
});
|
||||||
|
mockIssueService.assertCheckoutOwner.mockResolvedValue({});
|
||||||
|
mockDocumentService.getIssueDocumentByKey.mockResolvedValue(documentPayload);
|
||||||
|
mockAnnotationService.listThreadsForIssueDocument.mockImplementation(async (
|
||||||
|
_issueId: string,
|
||||||
|
_key: string,
|
||||||
|
options?: { includeComments?: boolean },
|
||||||
|
) => (
|
||||||
|
options?.includeComments
|
||||||
|
? [{ ...annotationThread, comments: [annotationComment] }]
|
||||||
|
: [annotationThread]
|
||||||
|
));
|
||||||
|
mockAnnotationService.getThreadForIssueDocument.mockResolvedValue({ ...annotationThread, comments: [annotationComment] });
|
||||||
|
mockAnnotationService.createThread.mockResolvedValue({ ...annotationThread, comments: [annotationComment] });
|
||||||
|
mockAnnotationService.addComment.mockResolvedValue(annotationComment);
|
||||||
|
mockAnnotationService.updateThread.mockResolvedValue({ ...annotationThread, status: "resolved" });
|
||||||
|
mockAnnotationService.remapOpenThreadsForDocument.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes compact open annotations without comment bodies by default for agent document reads", async () => {
|
||||||
|
const res = await request(await createApp("agent"))
|
||||||
|
.get(`/api/issues/${issueId}/documents/plan`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.annotations).toHaveLength(1);
|
||||||
|
expect(res.body.annotations[0].comments).toBeUndefined();
|
||||||
|
expect(mockAnnotationService.listThreadsForIssueDocument).toHaveBeenCalledWith(issueId, "plan", {
|
||||||
|
status: "open",
|
||||||
|
includeComments: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes annotation comment bodies on document reads only when explicitly requested", async () => {
|
||||||
|
const res = await request(await createApp("agent"))
|
||||||
|
.get(`/api/issues/${issueId}/documents/plan?includeAnnotationComments=true`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.annotations[0].comments[0].body).toBe("Please review PAP-1");
|
||||||
|
expect(mockAnnotationService.listThreadsForIssueDocument).toHaveBeenCalledWith(issueId, "plan", {
|
||||||
|
status: "open",
|
||||||
|
includeComments: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates annotation threads, syncs references, logs activity, and wakes the assignee", async () => {
|
||||||
|
mockIssueService.getById.mockResolvedValue({
|
||||||
|
id: issueId,
|
||||||
|
companyId,
|
||||||
|
title: "Annotation API",
|
||||||
|
status: "todo",
|
||||||
|
assigneeAgentId: "99999999-9999-4999-8999-999999999999",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(await createApp())
|
||||||
|
.post(`/api/issues/${issueId}/documents/plan/annotations`)
|
||||||
|
.send({
|
||||||
|
baseRevisionId: documentPayload.latestRevisionId,
|
||||||
|
baseRevisionNumber: 1,
|
||||||
|
selector: annotationThread.anchorSelector,
|
||||||
|
body: "Please review PAP-1",
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(res.body.id).toBe(annotationThread.id);
|
||||||
|
expect(mockIssueReferenceService.syncAnnotationComment).toHaveBeenCalledWith(annotationComment.id);
|
||||||
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||||
|
action: "issue.document_annotation_thread_created",
|
||||||
|
}));
|
||||||
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||||
|
"99999999-9999-4999-8999-999999999999",
|
||||||
|
expect.objectContaining({
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
annotationThreadId: annotationThread.id,
|
||||||
|
annotationCommentId: annotationComment.id,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects agent cross-company annotation reads", async () => {
|
||||||
|
await request(await createApp("agent", otherCompanyId))
|
||||||
|
.get(`/api/issues/${issueId}/documents/plan/annotations`)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds annotation comments and resolves threads", async () => {
|
||||||
|
await request(await createApp())
|
||||||
|
.post(`/api/issues/${issueId}/documents/plan/annotations/${annotationThread.id}/comments`)
|
||||||
|
.send({ body: "Reply with PAP-2" })
|
||||||
|
.expect(201);
|
||||||
|
expect(mockIssueReferenceService.syncAnnotationComment).toHaveBeenCalledWith(annotationComment.id);
|
||||||
|
|
||||||
|
const resolved = await request(await createApp())
|
||||||
|
.patch(`/api/issues/${issueId}/documents/plan/annotations/${annotationThread.id}`)
|
||||||
|
.send({ status: "resolved" })
|
||||||
|
.expect(200);
|
||||||
|
expect(resolved.body.status).toBe("resolved");
|
||||||
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||||
|
action: "issue.document_annotation_thread_resolved",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
183
server/src/__tests__/document-annotations-service.test.ts
Normal file
183
server/src/__tests__/document-annotations-service.test.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
companies,
|
||||||
|
createDb,
|
||||||
|
documentAnnotationAnchorSnapshots,
|
||||||
|
documentAnnotationComments,
|
||||||
|
documentAnnotationThreads,
|
||||||
|
documentRevisions,
|
||||||
|
documents,
|
||||||
|
issueDocuments,
|
||||||
|
issues,
|
||||||
|
} from "@paperclipai/db";
|
||||||
|
import {
|
||||||
|
getEmbeddedPostgresTestSupport,
|
||||||
|
startEmbeddedPostgresTestDatabase,
|
||||||
|
} from "./helpers/embedded-postgres.js";
|
||||||
|
import { documentAnnotationService } from "../services/document-annotations.js";
|
||||||
|
import { documentService } from "../services/documents.js";
|
||||||
|
|
||||||
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||||
|
|
||||||
|
if (!embeddedPostgresSupport.supported) {
|
||||||
|
console.warn(
|
||||||
|
`Skipping embedded Postgres document annotation service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||||
|
resolve = promiseResolve;
|
||||||
|
reject = promiseReject;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
describeEmbeddedPostgres("documentAnnotationService", () => {
|
||||||
|
let db!: ReturnType<typeof createDb>;
|
||||||
|
let annotations!: ReturnType<typeof documentAnnotationService>;
|
||||||
|
let docs!: ReturnType<typeof documentService>;
|
||||||
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-document-annotations-");
|
||||||
|
db = createDb(tempDb.connectionString);
|
||||||
|
annotations = documentAnnotationService(db);
|
||||||
|
docs = documentService(db);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.delete(documentAnnotationAnchorSnapshots);
|
||||||
|
await db.delete(documentAnnotationComments);
|
||||||
|
await db.delete(documentAnnotationThreads);
|
||||||
|
await db.delete(documentRevisions);
|
||||||
|
await db.delete(issueDocuments);
|
||||||
|
await db.delete(documents);
|
||||||
|
await db.delete(issues);
|
||||||
|
await db.delete(companies);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await tempDb?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createIssueWithDocument() {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const issueId = randomUUID();
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(issues).values({
|
||||||
|
id: issueId,
|
||||||
|
companyId,
|
||||||
|
identifier: "PAP-9442",
|
||||||
|
title: "Annotation race",
|
||||||
|
description: "Validate annotation revision guards",
|
||||||
|
status: "in_progress",
|
||||||
|
priority: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await docs.upsertIssueDocument({
|
||||||
|
issueId,
|
||||||
|
key: "plan",
|
||||||
|
title: "Plan",
|
||||||
|
format: "markdown",
|
||||||
|
body: "Alpha selected text omega",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { companyId, issueId, document: created.document };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("fails closed when a concurrent document update wins before annotation thread creation commits", async () => {
|
||||||
|
const { companyId, issueId, document } = await createIssueWithDocument();
|
||||||
|
const concurrentUpdateCanCommit = deferred<void>();
|
||||||
|
const concurrentUpdateHasWritten = deferred<void>();
|
||||||
|
|
||||||
|
const concurrentUpdate = db.transaction(async (tx) => {
|
||||||
|
const now = new Date();
|
||||||
|
const [revision] = await tx
|
||||||
|
.insert(documentRevisions)
|
||||||
|
.values({
|
||||||
|
companyId,
|
||||||
|
documentId: document.id,
|
||||||
|
revisionNumber: document.latestRevisionNumber + 1,
|
||||||
|
title: "Plan",
|
||||||
|
format: "markdown",
|
||||||
|
body: "Alpha changed text omega",
|
||||||
|
changeSummary: "Concurrent edit",
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(documents)
|
||||||
|
.set({
|
||||||
|
latestBody: "Alpha changed text omega",
|
||||||
|
latestRevisionId: revision.id,
|
||||||
|
latestRevisionNumber: document.latestRevisionNumber + 1,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(documents.id, document.id));
|
||||||
|
|
||||||
|
concurrentUpdateHasWritten.resolve();
|
||||||
|
await concurrentUpdateCanCommit.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
await concurrentUpdateHasWritten.promise;
|
||||||
|
|
||||||
|
let annotationSettled = false;
|
||||||
|
const annotationResult = annotations
|
||||||
|
.createThread(
|
||||||
|
issueId,
|
||||||
|
"plan",
|
||||||
|
{
|
||||||
|
baseRevisionId: document.latestRevisionId!,
|
||||||
|
baseRevisionNumber: document.latestRevisionNumber,
|
||||||
|
selector: {
|
||||||
|
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
|
||||||
|
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
|
||||||
|
},
|
||||||
|
body: "Please review this text",
|
||||||
|
},
|
||||||
|
{ actorType: "user", actorId: "board-user", userId: "board-user" },
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() => ({ status: "fulfilled" as const }),
|
||||||
|
(error: unknown) => ({ status: "rejected" as const, error }),
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
annotationSettled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
expect(annotationSettled).toBe(false);
|
||||||
|
|
||||||
|
concurrentUpdateCanCommit.resolve();
|
||||||
|
await concurrentUpdate;
|
||||||
|
|
||||||
|
const result = await annotationResult;
|
||||||
|
expect(result.status).toBe("rejected");
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
expect(result.error).toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
message: "Annotation anchor requires the current document revision",
|
||||||
|
details: {
|
||||||
|
currentRevisionNumber: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const threads = await db.select().from(documentAnnotationThreads);
|
||||||
|
expect(threads).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -90,6 +90,7 @@ vi.mock("../services/index.js", () => ({
|
||||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||||
}),
|
}),
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
routineService: () => ({}),
|
routineService: () => ({}),
|
||||||
workProductService: () => ({}),
|
workProductService: () => ({}),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ function registerModuleMocks() {
|
||||||
agentService: () => ({
|
agentService: () => ({
|
||||||
getById: vi.fn(async () => null),
|
getById: vi.fn(async () => null),
|
||||||
}),
|
}),
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => ({}),
|
executionWorkspaceService: () => ({}),
|
||||||
feedbackService: () => mockFeedbackService,
|
feedbackService: () => mockFeedbackService,
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ function registerRouteMocks() {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock("../services/documents.js", () => ({
|
vi.doMock("../services/documents.js", () => ({
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => mockDocumentService,
|
documentService: () => mockDocumentService,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -116,6 +117,7 @@ function registerRouteMocks() {
|
||||||
accessService: () => mockAccessService,
|
accessService: () => mockAccessService,
|
||||||
agentService: () => mockAgentService,
|
agentService: () => mockAgentService,
|
||||||
companyService: () => mockCompanyService,
|
companyService: () => mockCompanyService,
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => mockDocumentService,
|
documentService: () => mockDocumentService,
|
||||||
executionWorkspaceService: () => ({}),
|
executionWorkspaceService: () => ({}),
|
||||||
feedbackService: () => ({
|
feedbackService: () => ({
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ vi.mock("../services/index.js", () => ({
|
||||||
companyService: () => ({
|
companyService: () => ({
|
||||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||||
}),
|
}),
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({
|
documentService: () => ({
|
||||||
getIssueDocumentPayload: vi.fn(async () => ({})),
|
getIssueDocumentPayload: vi.fn(async () => ({})),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ function registerRouteMocks() {
|
||||||
getById: vi.fn(),
|
getById: vi.fn(),
|
||||||
}),
|
}),
|
||||||
companyService: () => mockCompanyService,
|
companyService: () => mockCompanyService,
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => ({}),
|
executionWorkspaceService: () => ({}),
|
||||||
feedbackService: () => ({
|
feedbackService: () => ({
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ function registerServiceMocks() {
|
||||||
agentService: () => ({
|
agentService: () => ({
|
||||||
getById: vi.fn(async () => null),
|
getById: vi.fn(async () => null),
|
||||||
}),
|
}),
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||||
feedbackService: () => ({
|
feedbackService: () => ({
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ function registerModuleMocks() {
|
||||||
}),
|
}),
|
||||||
accessService: () => mockAccessService,
|
accessService: () => mockAccessService,
|
||||||
agentService: () => ({ getById: vi.fn(async () => null) }),
|
agentService: () => ({ getById: vi.fn(async () => null) }),
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => ({}),
|
executionWorkspaceService: () => ({}),
|
||||||
feedbackService: () => mockFeedbackService,
|
feedbackService: () => mockFeedbackService,
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ vi.mock("../services/index.js", () => ({
|
||||||
}),
|
}),
|
||||||
accessService: () => mockAccessService,
|
accessService: () => mockAccessService,
|
||||||
agentService: () => mockAgentService,
|
agentService: () => mockAgentService,
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => ({}),
|
executionWorkspaceService: () => ({}),
|
||||||
feedbackService: () => mockFeedbackService,
|
feedbackService: () => mockFeedbackService,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ vi.mock("../services/index.js", () => ({
|
||||||
agentService: () => ({
|
agentService: () => ({
|
||||||
getById: vi.fn(),
|
getById: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({
|
documentService: () => ({
|
||||||
getIssueDocumentPayload: vi.fn(async () => ({})),
|
getIssueDocumentPayload: vi.fn(async () => ({})),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ function registerModuleMocks() {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock("../services/documents.js", () => ({
|
vi.doMock("../services/documents.js", () => ({
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => mockDocumentsService,
|
documentService: () => mockDocumentsService,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -113,6 +114,7 @@ function registerModuleMocks() {
|
||||||
}),
|
}),
|
||||||
accessService: () => mockAccessService,
|
accessService: () => mockAccessService,
|
||||||
agentService: () => mockAgentService,
|
agentService: () => mockAgentService,
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => mockDocumentsService,
|
documentService: () => mockDocumentsService,
|
||||||
executionWorkspaceService: () => ({}),
|
executionWorkspaceService: () => ({}),
|
||||||
feedbackService: () => ({}),
|
feedbackService: () => ({}),
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ function registerModuleMocks() {
|
||||||
agentService: () => ({
|
agentService: () => ({
|
||||||
getById: vi.fn(async () => null),
|
getById: vi.fn(async () => null),
|
||||||
}),
|
}),
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => ({}),
|
executionWorkspaceService: () => ({}),
|
||||||
feedbackService: () => ({
|
feedbackService: () => ({
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ function registerModuleMocks() {
|
||||||
}),
|
}),
|
||||||
accessService: () => mockAccessService,
|
accessService: () => mockAccessService,
|
||||||
agentService: () => mockAgentService,
|
agentService: () => mockAgentService,
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||||
goalService: () => ({}),
|
goalService: () => ({}),
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ function registerModuleMocks() {
|
||||||
hasPermission: vi.fn(),
|
hasPermission: vi.fn(),
|
||||||
}),
|
}),
|
||||||
agentService: () => mockAgentService,
|
agentService: () => mockAgentService,
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => ({}),
|
executionWorkspaceService: () => ({}),
|
||||||
feedbackService: () => ({}),
|
feedbackService: () => ({}),
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ function registerModuleMocks() {
|
||||||
clampIssueListLimit: (value: number) => value,
|
clampIssueListLimit: (value: number) => value,
|
||||||
ISSUE_LIST_DEFAULT_LIMIT: 500,
|
ISSUE_LIST_DEFAULT_LIMIT: 500,
|
||||||
ISSUE_LIST_MAX_LIMIT: 1000,
|
ISSUE_LIST_MAX_LIMIT: 1000,
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => ({}),
|
executionWorkspaceService: () => ({}),
|
||||||
feedbackService: () => ({
|
feedbackService: () => ({
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ vi.mock("../services/index.js", () => ({
|
||||||
agent: { id: raw },
|
agent: { id: raw },
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => ({}),
|
executionWorkspaceService: () => ({}),
|
||||||
feedbackService: () => ({
|
feedbackService: () => ({
|
||||||
|
|
@ -116,6 +117,7 @@ function registerModuleMocks() {
|
||||||
agent: { id: raw },
|
agent: { id: raw },
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => ({}),
|
executionWorkspaceService: () => ({}),
|
||||||
feedbackService: () => ({
|
feedbackService: () => ({
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ function registerRouteMocks() {
|
||||||
}),
|
}),
|
||||||
accessService: () => mockAccessService,
|
accessService: () => mockAccessService,
|
||||||
agentService: () => mockAgentService,
|
agentService: () => mockAgentService,
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||||
feedbackService: () => mockFeedbackService,
|
feedbackService: () => mockFeedbackService,
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ vi.mock("../services/index.js", () => ({
|
||||||
}),
|
}),
|
||||||
accessService: () => mockAccessService,
|
accessService: () => mockAccessService,
|
||||||
agentService: () => mockAgentService,
|
agentService: () => mockAgentService,
|
||||||
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => mockDocumentsService,
|
documentService: () => mockDocumentsService,
|
||||||
environmentService: () => mockEnvironmentService,
|
environmentService: () => mockEnvironmentService,
|
||||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import {
|
||||||
createIssueWorkProductSchema,
|
createIssueWorkProductSchema,
|
||||||
createIssueLabelSchema,
|
createIssueLabelSchema,
|
||||||
checkoutIssueSchema,
|
checkoutIssueSchema,
|
||||||
|
createDocumentAnnotationCommentSchema,
|
||||||
|
createDocumentAnnotationThreadSchema,
|
||||||
createChildIssueSchema,
|
createChildIssueSchema,
|
||||||
createIssueSchema,
|
createIssueSchema,
|
||||||
resolveCreateIssueStatusDefault,
|
resolveCreateIssueStatusDefault,
|
||||||
|
|
@ -38,6 +40,7 @@ import {
|
||||||
restoreIssueDocumentRevisionSchema,
|
restoreIssueDocumentRevisionSchema,
|
||||||
respondIssueThreadInteractionSchema,
|
respondIssueThreadInteractionSchema,
|
||||||
updateIssueWorkProductSchema,
|
updateIssueWorkProductSchema,
|
||||||
|
updateDocumentAnnotationThreadSchema,
|
||||||
upsertIssueDocumentSchema,
|
upsertIssueDocumentSchema,
|
||||||
updateIssueSchema,
|
updateIssueSchema,
|
||||||
getClosedIsolatedExecutionWorkspaceMessage,
|
getClosedIsolatedExecutionWorkspaceMessage,
|
||||||
|
|
@ -71,6 +74,7 @@ import {
|
||||||
issueService,
|
issueService,
|
||||||
clampIssueListLimit,
|
clampIssueListLimit,
|
||||||
documentService,
|
documentService,
|
||||||
|
documentAnnotationService,
|
||||||
logActivity,
|
logActivity,
|
||||||
projectService,
|
projectService,
|
||||||
routineService,
|
routineService,
|
||||||
|
|
@ -868,6 +872,7 @@ export function issueRoutes(
|
||||||
const executionWorkspacesSvc = executionWorkspaceServiceDirect(db);
|
const executionWorkspacesSvc = executionWorkspaceServiceDirect(db);
|
||||||
const workProductsSvc = workProductService(db);
|
const workProductsSvc = workProductService(db);
|
||||||
const documentsSvc = documentService(db);
|
const documentsSvc = documentService(db);
|
||||||
|
const documentAnnotationsSvc = documentAnnotationService(db);
|
||||||
const issueReferencesSvc = issueReferenceService(db);
|
const issueReferencesSvc = issueReferenceService(db);
|
||||||
const issueThreadInteractionsSvc = issueThreadInteractionService(db);
|
const issueThreadInteractionsSvc = issueThreadInteractionService(db);
|
||||||
const routinesSvc = routineService(db, {
|
const routinesSvc = routineService(db, {
|
||||||
|
|
@ -1106,6 +1111,69 @@ export function issueRoutes(
|
||||||
return value === true || value === "true" || value === "1";
|
return value === true || value === "true" || value === "1";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldIncludeDocumentAnnotations(req: Request) {
|
||||||
|
if (req.query.includeAnnotations === "false" || req.query.includeAnnotations === "0") return false;
|
||||||
|
return req.actor.type === "agent" || parseBooleanQuery(req.query.includeAnnotations);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIncludeDocumentAnnotationComments(req: Request) {
|
||||||
|
return parseBooleanQuery(req.query.includeAnnotationComments);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueAnnotationCommentWakeup(input: {
|
||||||
|
issue: { id: string; assigneeAgentId: string | null; status: string };
|
||||||
|
actor: { actorType: "user" | "agent"; actorId: string };
|
||||||
|
threadId: string;
|
||||||
|
commentId: string;
|
||||||
|
documentKey: string;
|
||||||
|
}) {
|
||||||
|
const assigneeId = input.issue.assigneeAgentId;
|
||||||
|
const selfComment = input.actor.actorType === "agent" && input.actor.actorId === assigneeId;
|
||||||
|
if (!assigneeId || selfComment || isClosedIssueStatus(input.issue.status)) return;
|
||||||
|
void heartbeat.wakeup(assigneeId, {
|
||||||
|
source: "automation",
|
||||||
|
triggerDetail: "system",
|
||||||
|
reason: "issue_commented",
|
||||||
|
payload: {
|
||||||
|
issueId: input.issue.id,
|
||||||
|
annotationThreadId: input.threadId,
|
||||||
|
annotationCommentId: input.commentId,
|
||||||
|
documentKey: input.documentKey,
|
||||||
|
mutation: "document_annotation_comment",
|
||||||
|
},
|
||||||
|
requestedByActorType: input.actor.actorType,
|
||||||
|
requestedByActorId: input.actor.actorId,
|
||||||
|
contextSnapshot: {
|
||||||
|
issueId: input.issue.id,
|
||||||
|
taskId: input.issue.id,
|
||||||
|
annotationThreadId: input.threadId,
|
||||||
|
annotationCommentId: input.commentId,
|
||||||
|
documentKey: input.documentKey,
|
||||||
|
source: "issue.document.annotation",
|
||||||
|
wakeReason: "issue_commented",
|
||||||
|
},
|
||||||
|
}).catch((err) => logger.warn({
|
||||||
|
err,
|
||||||
|
issueId: input.issue.id,
|
||||||
|
annotationThreadId: input.threadId,
|
||||||
|
annotationCommentId: input.commentId,
|
||||||
|
}, "failed to wake assignee on document annotation comment"));
|
||||||
|
}
|
||||||
|
|
||||||
async function assertIssueEnvironmentSelection(
|
async function assertIssueEnvironmentSelection(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
environmentId: string | null | undefined,
|
environmentId: string | null | undefined,
|
||||||
|
|
@ -2448,9 +2516,239 @@ export function issueRoutes(
|
||||||
res.status(404).json({ error: "Document not found" });
|
res.status(404).json({ error: "Document not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json(doc);
|
if (!shouldIncludeDocumentAnnotations(req)) {
|
||||||
|
res.json(doc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const annotations = await documentAnnotationsSvc.listThreadsForIssueDocument(issue.id, keyParsed.data, {
|
||||||
|
status: "open",
|
||||||
|
includeComments: shouldIncludeDocumentAnnotationComments(req),
|
||||||
|
});
|
||||||
|
res.json({ ...doc, annotations });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/issues/:id/documents/:key/annotations", async (req, res) => {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const issue = await svc.getById(id);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ error: "Issue not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, issue.companyId);
|
||||||
|
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||||
|
if (!keyParsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const status = req.query.status === "resolved" || req.query.status === "all" ? req.query.status : "open";
|
||||||
|
const threads = await documentAnnotationsSvc.listThreadsForIssueDocument(issue.id, keyParsed.data, {
|
||||||
|
status,
|
||||||
|
includeComments: parseBooleanQuery(req.query.includeComments),
|
||||||
|
});
|
||||||
|
res.json(threads);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/issues/:id/documents/:key/annotations",
|
||||||
|
validate(createDocumentAnnotationThreadSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const issue = await svc.getById(id);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ error: "Issue not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, issue.companyId);
|
||||||
|
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||||
|
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||||
|
if (!keyParsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actor, annotationActor } = annotationActorInput(req);
|
||||||
|
const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||||
|
const thread = await documentAnnotationsSvc.createThread(issue.id, keyParsed.data, req.body, annotationActor);
|
||||||
|
const firstComment = thread.comments[0];
|
||||||
|
if (firstComment) await issueReferencesSvc.syncAnnotationComment(firstComment.id);
|
||||||
|
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||||
|
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
||||||
|
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: issue.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "issue.document_annotation_thread_created",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: {
|
||||||
|
documentKey: thread.documentKey,
|
||||||
|
documentId: thread.documentId,
|
||||||
|
threadId: thread.id,
|
||||||
|
commentId: firstComment?.id ?? null,
|
||||||
|
revisionNumber: thread.currentRevisionNumber,
|
||||||
|
quote: thread.selectedText.slice(0, 240),
|
||||||
|
...summarizeIssueReferenceActivityDetails({
|
||||||
|
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||||
|
removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||||
|
currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (firstComment) {
|
||||||
|
queueAnnotationCommentWakeup({
|
||||||
|
issue,
|
||||||
|
actor,
|
||||||
|
threadId: thread.id,
|
||||||
|
commentId: firstComment.id,
|
||||||
|
documentKey: thread.documentKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json(thread);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get("/issues/:id/documents/:key/annotations/:threadId", async (req, res) => {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const issue = await svc.getById(id);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ error: "Issue not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, issue.companyId);
|
||||||
|
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||||
|
if (!keyParsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const thread = await documentAnnotationsSvc.getThreadForIssueDocument(
|
||||||
|
issue.id,
|
||||||
|
keyParsed.data,
|
||||||
|
req.params.threadId as string,
|
||||||
|
);
|
||||||
|
if (!thread) {
|
||||||
|
res.status(404).json({ error: "Annotation thread not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(thread);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/issues/:id/documents/:key/annotations/:threadId/comments",
|
||||||
|
validate(createDocumentAnnotationCommentSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const issue = await svc.getById(id);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ error: "Issue not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, issue.companyId);
|
||||||
|
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||||
|
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||||
|
if (!keyParsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actor, annotationActor } = annotationActorInput(req);
|
||||||
|
const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||||
|
const comment = await documentAnnotationsSvc.addComment(
|
||||||
|
issue.id,
|
||||||
|
keyParsed.data,
|
||||||
|
req.params.threadId as string,
|
||||||
|
req.body,
|
||||||
|
annotationActor,
|
||||||
|
);
|
||||||
|
await issueReferencesSvc.syncAnnotationComment(comment.id);
|
||||||
|
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||||
|
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
||||||
|
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: issue.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "issue.document_annotation_comment_added",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: {
|
||||||
|
documentKey: keyParsed.data,
|
||||||
|
threadId: comment.threadId,
|
||||||
|
commentId: comment.id,
|
||||||
|
bodySnippet: comment.body.slice(0, 120),
|
||||||
|
...summarizeIssueReferenceActivityDetails({
|
||||||
|
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||||
|
removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||||
|
currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queueAnnotationCommentWakeup({
|
||||||
|
issue,
|
||||||
|
actor,
|
||||||
|
threadId: comment.threadId,
|
||||||
|
commentId: comment.id,
|
||||||
|
documentKey: keyParsed.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(comment);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/issues/:id/documents/:key/annotations/:threadId",
|
||||||
|
validate(updateDocumentAnnotationThreadSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const issue = await svc.getById(id);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ error: "Issue not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, issue.companyId);
|
||||||
|
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||||
|
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||||
|
if (!keyParsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { actor, annotationActor } = annotationActorInput(req);
|
||||||
|
const thread = await documentAnnotationsSvc.updateThread(
|
||||||
|
issue.id,
|
||||||
|
keyParsed.data,
|
||||||
|
req.params.threadId as string,
|
||||||
|
req.body,
|
||||||
|
annotationActor,
|
||||||
|
);
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: issue.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: thread.status === "resolved"
|
||||||
|
? "issue.document_annotation_thread_resolved"
|
||||||
|
: "issue.document_annotation_thread_reopened",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: {
|
||||||
|
documentKey: thread.documentKey,
|
||||||
|
documentId: thread.documentId,
|
||||||
|
threadId: thread.id,
|
||||||
|
status: thread.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json(thread);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), async (req, res) => {
|
router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), async (req, res) => {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const issue = await svc.getById(id);
|
const issue = await svc.getById(id);
|
||||||
|
|
@ -2488,6 +2786,16 @@ export function issueRoutes(
|
||||||
await issueReferencesSvc.syncDocument(doc.id);
|
await issueReferencesSvc.syncDocument(doc.id);
|
||||||
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||||
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
||||||
|
const remappedAnnotations = result.created
|
||||||
|
? []
|
||||||
|
: await documentAnnotationsSvc.remapOpenThreadsForDocument({
|
||||||
|
issueId: issue.id,
|
||||||
|
key: doc.key,
|
||||||
|
documentId: doc.id,
|
||||||
|
nextRevisionId: doc.latestRevisionId,
|
||||||
|
nextRevisionNumber: doc.latestRevisionNumber,
|
||||||
|
nextBody: doc.body,
|
||||||
|
});
|
||||||
|
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
companyId: issue.companyId,
|
companyId: issue.companyId,
|
||||||
|
|
@ -2513,6 +2821,28 @@ export function issueRoutes(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const remap of remappedAnnotations) {
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: issue.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "issue.document_annotation_remapped",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: {
|
||||||
|
key: doc.key,
|
||||||
|
documentId: doc.id,
|
||||||
|
threadId: remap.thread.id,
|
||||||
|
revisionNumber: doc.latestRevisionNumber,
|
||||||
|
anchorState: remap.thread.anchorState,
|
||||||
|
anchorConfidence: remap.thread.anchorConfidence,
|
||||||
|
snapshotId: remap.snapshot.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.created) {
|
if (!result.created) {
|
||||||
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
|
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
|
||||||
issue,
|
issue,
|
||||||
|
|
@ -2684,6 +3014,14 @@ export function issueRoutes(
|
||||||
await issueReferencesSvc.syncDocument(result.document.id);
|
await issueReferencesSvc.syncDocument(result.document.id);
|
||||||
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||||
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
||||||
|
const remappedAnnotations = await documentAnnotationsSvc.remapOpenThreadsForDocument({
|
||||||
|
issueId: issue.id,
|
||||||
|
key: result.document.key,
|
||||||
|
documentId: result.document.id,
|
||||||
|
nextRevisionId: result.document.latestRevisionId,
|
||||||
|
nextRevisionNumber: result.document.latestRevisionNumber,
|
||||||
|
nextBody: result.document.body,
|
||||||
|
});
|
||||||
|
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
companyId: issue.companyId,
|
companyId: issue.companyId,
|
||||||
|
|
@ -2710,6 +3048,28 @@ export function issueRoutes(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const remap of remappedAnnotations) {
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: issue.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "issue.document_annotation_remapped",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: {
|
||||||
|
key: result.document.key,
|
||||||
|
documentId: result.document.id,
|
||||||
|
threadId: remap.thread.id,
|
||||||
|
revisionNumber: result.document.latestRevisionNumber,
|
||||||
|
anchorState: remap.thread.anchorState,
|
||||||
|
anchorConfidence: remap.thread.anchorConfidence,
|
||||||
|
snapshotId: remap.snapshot.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
|
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
|
||||||
issue,
|
issue,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
413
server/src/services/document-annotations.ts
Normal file
413
server/src/services/document-annotations.ts
Normal file
|
|
@ -0,0 +1,413 @@
|
||||||
|
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import {
|
||||||
|
documentAnnotationAnchorSnapshots,
|
||||||
|
documentAnnotationComments,
|
||||||
|
documentAnnotationThreads,
|
||||||
|
documents,
|
||||||
|
issueDocuments,
|
||||||
|
} from "@paperclipai/db";
|
||||||
|
import {
|
||||||
|
anchorSnapshotToSelector,
|
||||||
|
remapDocumentAnchor,
|
||||||
|
selectorToAnchorSnapshot,
|
||||||
|
verifyDocumentAnchorSelector,
|
||||||
|
type DocumentAnnotationAnchorSnapshot,
|
||||||
|
type DocumentAnnotationComment,
|
||||||
|
type DocumentAnnotationThread,
|
||||||
|
CreateDocumentAnnotationComment,
|
||||||
|
CreateDocumentAnnotationThread,
|
||||||
|
UpdateDocumentAnnotationThread,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||||
|
|
||||||
|
type ActorInput = {
|
||||||
|
actorType: "agent" | "user";
|
||||||
|
actorId: string;
|
||||||
|
agentId?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
|
runId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IssueDocumentRow = {
|
||||||
|
issueId: 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,
|
||||||
|
documentId: documentAnnotationThreads.documentId,
|
||||||
|
documentKey: documentAnnotationThreads.documentKey,
|
||||||
|
status: documentAnnotationThreads.status,
|
||||||
|
anchorState: documentAnnotationThreads.anchorState,
|
||||||
|
anchorConfidence: documentAnnotationThreads.anchorConfidence,
|
||||||
|
originalRevisionId: documentAnnotationThreads.originalRevisionId,
|
||||||
|
originalRevisionNumber: documentAnnotationThreads.originalRevisionNumber,
|
||||||
|
currentRevisionId: documentAnnotationThreads.currentRevisionId,
|
||||||
|
currentRevisionNumber: documentAnnotationThreads.currentRevisionNumber,
|
||||||
|
selectedText: documentAnnotationThreads.selectedText,
|
||||||
|
prefixText: documentAnnotationThreads.prefixText,
|
||||||
|
suffixText: documentAnnotationThreads.suffixText,
|
||||||
|
normalizedStart: documentAnnotationThreads.normalizedStart,
|
||||||
|
normalizedEnd: documentAnnotationThreads.normalizedEnd,
|
||||||
|
markdownStart: documentAnnotationThreads.markdownStart,
|
||||||
|
markdownEnd: documentAnnotationThreads.markdownEnd,
|
||||||
|
anchorSelector: documentAnnotationThreads.anchorSelector,
|
||||||
|
createdByAgentId: documentAnnotationThreads.createdByAgentId,
|
||||||
|
createdByUserId: documentAnnotationThreads.createdByUserId,
|
||||||
|
resolvedByAgentId: documentAnnotationThreads.resolvedByAgentId,
|
||||||
|
resolvedByUserId: documentAnnotationThreads.resolvedByUserId,
|
||||||
|
resolvedAt: documentAnnotationThreads.resolvedAt,
|
||||||
|
createdAt: documentAnnotationThreads.createdAt,
|
||||||
|
updatedAt: documentAnnotationThreads.updatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const commentSelect = {
|
||||||
|
id: documentAnnotationComments.id,
|
||||||
|
companyId: documentAnnotationComments.companyId,
|
||||||
|
threadId: documentAnnotationComments.threadId,
|
||||||
|
issueId: documentAnnotationComments.issueId,
|
||||||
|
documentId: documentAnnotationComments.documentId,
|
||||||
|
body: documentAnnotationComments.body,
|
||||||
|
authorType: documentAnnotationComments.authorType,
|
||||||
|
authorAgentId: documentAnnotationComments.authorAgentId,
|
||||||
|
authorUserId: documentAnnotationComments.authorUserId,
|
||||||
|
createdByRunId: documentAnnotationComments.createdByRunId,
|
||||||
|
createdAt: documentAnnotationComments.createdAt,
|
||||||
|
updatedAt: documentAnnotationComments.updatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
function snapshotFromThread(thread: Pick<DocumentAnnotationThread, "selectedText" | "prefixText" | "suffixText" | "normalizedStart" | "normalizedEnd" | "markdownStart" | "markdownEnd">): DocumentAnnotationAnchorSnapshot {
|
||||||
|
return {
|
||||||
|
selectedText: thread.selectedText,
|
||||||
|
prefixText: thread.prefixText,
|
||||||
|
suffixText: thread.suffixText,
|
||||||
|
normalizedStart: thread.normalizedStart,
|
||||||
|
normalizedEnd: thread.normalizedEnd,
|
||||||
|
markdownStart: thread.markdownStart,
|
||||||
|
markdownEnd: thread.markdownEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function documentAnnotationService(db: Db) {
|
||||||
|
async function getIssueDocument(issueId: string, key: string, dbOrTx: any = db): Promise<IssueDocumentRow | null> {
|
||||||
|
return dbOrTx
|
||||||
|
.select({
|
||||||
|
issueId: issueDocuments.issueId,
|
||||||
|
companyId: documents.companyId,
|
||||||
|
documentId: documents.id,
|
||||||
|
documentKey: issueDocuments.key,
|
||||||
|
latestBody: documents.latestBody,
|
||||||
|
latestRevisionId: documents.latestRevisionId,
|
||||||
|
latestRevisionNumber: documents.latestRevisionNumber,
|
||||||
|
})
|
||||||
|
.from(issueDocuments)
|
||||||
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||||
|
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
||||||
|
.then((rows: IssueDocumentRow[]) => rows[0] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getThreadForIssue(
|
||||||
|
issueId: 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.issueId, issueId),
|
||||||
|
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
|
||||||
|
.select(commentSelect)
|
||||||
|
.from(documentAnnotationComments)
|
||||||
|
.where(inArray(documentAnnotationComments.threadId, threadIds))
|
||||||
|
.orderBy(asc(documentAnnotationComments.createdAt), asc(documentAnnotationComments.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
listThreadsForIssueDocument: async (
|
||||||
|
issueId: string,
|
||||||
|
key: string,
|
||||||
|
options: { status?: "open" | "resolved" | "all"; includeComments?: boolean } = {},
|
||||||
|
) => {
|
||||||
|
const doc = await getIssueDocument(issueId, key);
|
||||||
|
if (!doc) throw notFound("Document not found");
|
||||||
|
const conditions = [
|
||||||
|
eq(documentAnnotationThreads.issueId, issueId),
|
||||||
|
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;
|
||||||
|
const comments = await commentsForThreads([thread.id]);
|
||||||
|
return { ...thread, comments };
|
||||||
|
},
|
||||||
|
|
||||||
|
createThread: async (
|
||||||
|
issueId: string,
|
||||||
|
key: string,
|
||||||
|
input: CreateDocumentAnnotationThread,
|
||||||
|
actor: ActorInput,
|
||||||
|
) => db.transaction(async (tx) => {
|
||||||
|
await tx.execute(sql`
|
||||||
|
select ${documents.id}
|
||||||
|
from ${issueDocuments}
|
||||||
|
inner join ${documents} on ${issueDocuments.documentId} = ${documents.id}
|
||||||
|
where ${and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key))}
|
||||||
|
for update of ${documents}
|
||||||
|
`);
|
||||||
|
const doc = await getIssueDocument(issueId, 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,
|
||||||
|
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,
|
||||||
|
documentId: doc.documentId,
|
||||||
|
body: input.body,
|
||||||
|
authorType: actor.actorType,
|
||||||
|
authorAgentId: actor.agentId ?? null,
|
||||||
|
authorUserId: actor.userId ?? null,
|
||||||
|
createdByRunId: actor.runId ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.returning(commentSelect);
|
||||||
|
|
||||||
|
return { ...thread, comments: [comment] };
|
||||||
|
}),
|
||||||
|
|
||||||
|
addComment: async (
|
||||||
|
issueId: string,
|
||||||
|
key: string,
|
||||||
|
threadId: string,
|
||||||
|
input: CreateDocumentAnnotationComment,
|
||||||
|
actor: ActorInput,
|
||||||
|
) => db.transaction(async (tx) => {
|
||||||
|
const thread = await getThreadForIssue(issueId, 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: thread.issueId,
|
||||||
|
documentId: thread.documentId,
|
||||||
|
body: input.body,
|
||||||
|
authorType: actor.actorType,
|
||||||
|
authorAgentId: actor.agentId ?? null,
|
||||||
|
authorUserId: actor.userId ?? null,
|
||||||
|
createdByRunId: actor.runId ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.returning(commentSelect);
|
||||||
|
await tx
|
||||||
|
.update(documentAnnotationThreads)
|
||||||
|
.set({ updatedAt: now })
|
||||||
|
.where(eq(documentAnnotationThreads.id, thread.id));
|
||||||
|
return comment;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateThread: async (
|
||||||
|
issueId: string,
|
||||||
|
key: string,
|
||||||
|
threadId: string,
|
||||||
|
input: UpdateDocumentAnnotationThread,
|
||||||
|
actor: ActorInput,
|
||||||
|
) => db.transaction(async (tx) => {
|
||||||
|
const thread = await getThreadForIssue(issueId, 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;
|
||||||
|
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.issueId, input.issueId),
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,8 @@ import {
|
||||||
activityLog,
|
activityLog,
|
||||||
approvals,
|
approvals,
|
||||||
companySkills as companySkillsTable,
|
companySkills as companySkillsTable,
|
||||||
|
documentAnnotationComments,
|
||||||
|
documentAnnotationThreads,
|
||||||
documentRevisions,
|
documentRevisions,
|
||||||
issueDocuments,
|
issueDocuments,
|
||||||
heartbeatRunEvents,
|
heartbeatRunEvents,
|
||||||
|
|
@ -1981,6 +1983,7 @@ async function buildPaperclipWakePayload(input: {
|
||||||
}) {
|
}) {
|
||||||
const executionStage = parseObject(input.contextSnapshot.executionStage);
|
const executionStage = parseObject(input.contextSnapshot.executionStage);
|
||||||
const commentIds = extractWakeCommentIds(input.contextSnapshot);
|
const commentIds = extractWakeCommentIds(input.contextSnapshot);
|
||||||
|
const annotationCommentId = readNonEmptyString(input.contextSnapshot.annotationCommentId);
|
||||||
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
|
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
|
||||||
const continuationSummary = input.continuationSummary ?? null;
|
const continuationSummary = input.continuationSummary ?? null;
|
||||||
const issueSummary =
|
const issueSummary =
|
||||||
|
|
@ -2071,6 +2074,57 @@ async function buildPaperclipWakePayload(input: {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const annotationDeltas = annotationCommentId
|
||||||
|
? await input.db
|
||||||
|
.select({
|
||||||
|
id: documentAnnotationComments.id,
|
||||||
|
issueId: documentAnnotationComments.issueId,
|
||||||
|
threadId: documentAnnotationComments.threadId,
|
||||||
|
body: documentAnnotationComments.body,
|
||||||
|
authorType: documentAnnotationComments.authorType,
|
||||||
|
authorAgentId: documentAnnotationComments.authorAgentId,
|
||||||
|
authorUserId: documentAnnotationComments.authorUserId,
|
||||||
|
createdAt: documentAnnotationComments.createdAt,
|
||||||
|
documentKey: documentAnnotationThreads.documentKey,
|
||||||
|
status: documentAnnotationThreads.status,
|
||||||
|
anchorState: documentAnnotationThreads.anchorState,
|
||||||
|
anchorConfidence: documentAnnotationThreads.anchorConfidence,
|
||||||
|
currentRevisionNumber: documentAnnotationThreads.currentRevisionNumber,
|
||||||
|
selectedText: documentAnnotationThreads.selectedText,
|
||||||
|
prefixText: documentAnnotationThreads.prefixText,
|
||||||
|
suffixText: documentAnnotationThreads.suffixText,
|
||||||
|
})
|
||||||
|
.from(documentAnnotationComments)
|
||||||
|
.innerJoin(documentAnnotationThreads, eq(documentAnnotationComments.threadId, documentAnnotationThreads.id))
|
||||||
|
.where(and(
|
||||||
|
eq(documentAnnotationComments.companyId, input.companyId),
|
||||||
|
eq(documentAnnotationComments.id, annotationCommentId),
|
||||||
|
))
|
||||||
|
.then((rows) => rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
issueId: row.issueId,
|
||||||
|
threadId: row.threadId,
|
||||||
|
documentKey: row.documentKey,
|
||||||
|
revisionNumber: row.currentRevisionNumber,
|
||||||
|
quote: row.selectedText,
|
||||||
|
prefix: row.prefixText,
|
||||||
|
suffix: row.suffixText,
|
||||||
|
threadStatus: row.status,
|
||||||
|
anchorState: row.anchorState,
|
||||||
|
anchorConfidence: row.anchorConfidence,
|
||||||
|
body: row.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS
|
||||||
|
? row.body.slice(0, MAX_INLINE_WAKE_COMMENT_BODY_CHARS)
|
||||||
|
: row.body,
|
||||||
|
bodyTruncated: row.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
author: row.authorAgentId
|
||||||
|
? { type: "agent", id: row.authorAgentId }
|
||||||
|
: row.authorUserId
|
||||||
|
? { type: "user", id: row.authorUserId }
|
||||||
|
: { type: row.authorType, id: null },
|
||||||
|
})))
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reason: readNonEmptyString(input.contextSnapshot.wakeReason),
|
reason: readNonEmptyString(input.contextSnapshot.wakeReason),
|
||||||
issue: issueSummary
|
issue: issueSummary
|
||||||
|
|
@ -2128,6 +2182,7 @@ async function buildPaperclipWakePayload(input: {
|
||||||
commentIds,
|
commentIds,
|
||||||
latestCommentId: commentIds[commentIds.length - 1] ?? null,
|
latestCommentId: commentIds[commentIds.length - 1] ?? null,
|
||||||
comments,
|
comments,
|
||||||
|
annotationDeltas,
|
||||||
commentWindow: {
|
commentWindow: {
|
||||||
requestedCount: commentIds.length,
|
requestedCount: commentIds.length,
|
||||||
includedCount: comments.length,
|
includedCount: comments.length,
|
||||||
|
|
@ -4080,7 +4135,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||||
continuationAttempt: decision.nextAttempt,
|
continuationAttempt: decision.nextAttempt,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(heartbeatRuns.id, continuationRun.id));
|
.where(eq(heartbeatRuns.id, run.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export { agentService, deduplicateAgentName } from "./agents.js";
|
||||||
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
|
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
|
||||||
export { assetService } from "./assets.js";
|
export { assetService } from "./assets.js";
|
||||||
export { documentService, extractLegacyPlanBody } from "./documents.js";
|
export { documentService, extractLegacyPlanBody } from "./documents.js";
|
||||||
|
export { documentAnnotationService } from "./document-annotations.js";
|
||||||
export {
|
export {
|
||||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||||
buildContinuationSummaryMarkdown,
|
buildContinuationSummaryMarkdown,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { and, asc, eq, inArray, isNull } from "drizzle-orm";
|
import { and, asc, eq, inArray, isNull } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { documents, issueComments, issueDocuments, issueReferenceMentions, issues } from "@paperclipai/db";
|
import {
|
||||||
|
documentAnnotationComments,
|
||||||
|
documents,
|
||||||
|
issueComments,
|
||||||
|
issueDocuments,
|
||||||
|
issueReferenceMentions,
|
||||||
|
issues,
|
||||||
|
} from "@paperclipai/db";
|
||||||
import type {
|
import type {
|
||||||
IssueReferenceSource,
|
IssueReferenceSource,
|
||||||
IssueReferenceSourceKind,
|
IssueReferenceSourceKind,
|
||||||
|
|
@ -230,6 +237,29 @@ export function issueReferenceService(db: Db) {
|
||||||
}, dbOrTx);
|
}, dbOrTx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncAnnotationComment(commentId: string, dbOrTx: any = db) {
|
||||||
|
const comment = await dbOrTx
|
||||||
|
.select({
|
||||||
|
id: documentAnnotationComments.id,
|
||||||
|
companyId: documentAnnotationComments.companyId,
|
||||||
|
issueId: documentAnnotationComments.issueId,
|
||||||
|
body: documentAnnotationComments.body,
|
||||||
|
})
|
||||||
|
.from(documentAnnotationComments)
|
||||||
|
.where(eq(documentAnnotationComments.id, commentId))
|
||||||
|
.then((rows: Array<{ id: string; companyId: string; issueId: string; body: string }>) => rows[0] ?? null);
|
||||||
|
if (!comment) throw notFound("Document annotation comment not found");
|
||||||
|
|
||||||
|
await replaceSourceMentions({
|
||||||
|
companyId: comment.companyId,
|
||||||
|
sourceIssueId: comment.issueId,
|
||||||
|
sourceKind: "comment",
|
||||||
|
sourceRecordId: comment.id,
|
||||||
|
documentKey: null,
|
||||||
|
text: comment.body,
|
||||||
|
}, dbOrTx);
|
||||||
|
}
|
||||||
|
|
||||||
async function syncDocument(documentId: string, dbOrTx: any = db) {
|
async function syncDocument(documentId: string, dbOrTx: any = db) {
|
||||||
const document = await dbOrTx
|
const document = await dbOrTx
|
||||||
.select({
|
.select({
|
||||||
|
|
@ -396,6 +426,7 @@ export function issueReferenceService(db: Db) {
|
||||||
return {
|
return {
|
||||||
syncIssue,
|
syncIssue,
|
||||||
syncComment,
|
syncComment,
|
||||||
|
syncAnnotationComment,
|
||||||
syncDocument,
|
syncDocument,
|
||||||
deleteDocumentSource,
|
deleteDocumentSource,
|
||||||
syncAllForIssue,
|
syncAllForIssue,
|
||||||
|
|
|
||||||
|
|
@ -3248,7 +3248,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||||
let escalation: Awaited<ReturnType<typeof issuesSvc.create>>;
|
let escalation: Awaited<ReturnType<typeof issuesSvc.create>>;
|
||||||
try {
|
try {
|
||||||
escalation = await issuesSvc.create(issue.companyId, {
|
escalation = await issuesSvc.create(issue.companyId, {
|
||||||
title: `Unblock liveness incident for ${recoveryIssue.identifier ?? recoveryIssue.title}`,
|
title: `Unblock liveness incident for ${issue.identifier ?? issue.id}`,
|
||||||
description: buildLivenessEscalationDescription(input.finding),
|
description: buildLivenessEscalationDescription(input.finding),
|
||||||
status: "todo",
|
status: "todo",
|
||||||
priority: "high",
|
priority: "high",
|
||||||
|
|
|
||||||
59
ui/src/api/document-annotations.ts
Normal file
59
ui/src/api/document-annotations.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import type {
|
||||||
|
CreateDocumentAnnotationCommentRequest,
|
||||||
|
CreateDocumentAnnotationThreadRequest,
|
||||||
|
DocumentAnnotationComment,
|
||||||
|
DocumentAnnotationThread,
|
||||||
|
DocumentAnnotationThreadStatus,
|
||||||
|
DocumentAnnotationThreadWithComments,
|
||||||
|
UpdateDocumentAnnotationThreadRequest,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export type DocumentAnnotationListFilter = "open" | "resolved" | "all";
|
||||||
|
|
||||||
|
export const documentAnnotationsApi = {
|
||||||
|
list: (
|
||||||
|
issueId: string,
|
||||||
|
key: string,
|
||||||
|
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}` : ""}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
get: (issueId: string, key: string, threadId: string) =>
|
||||||
|
api.get<DocumentAnnotationThreadWithComments>(
|
||||||
|
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
|
||||||
|
),
|
||||||
|
create: (issueId: string, key: string, data: CreateDocumentAnnotationThreadRequest) =>
|
||||||
|
api.post<DocumentAnnotationThreadWithComments>(
|
||||||
|
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations`,
|
||||||
|
data,
|
||||||
|
),
|
||||||
|
addComment: (
|
||||||
|
issueId: string,
|
||||||
|
key: string,
|
||||||
|
threadId: string,
|
||||||
|
data: CreateDocumentAnnotationCommentRequest,
|
||||||
|
) =>
|
||||||
|
api.post<DocumentAnnotationComment>(
|
||||||
|
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}/comments`,
|
||||||
|
data,
|
||||||
|
),
|
||||||
|
updateStatus: (
|
||||||
|
issueId: string,
|
||||||
|
key: string,
|
||||||
|
threadId: string,
|
||||||
|
status: DocumentAnnotationThreadStatus,
|
||||||
|
) => {
|
||||||
|
const payload: UpdateDocumentAnnotationThreadRequest = { status };
|
||||||
|
return api.patch<DocumentAnnotationThread>(
|
||||||
|
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
200
ui/src/components/DocumentAnnotationLayer.test.tsx
Normal file
200
ui/src/components/DocumentAnnotationLayer.test.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { DocumentAnnotationLayer } from "./DocumentAnnotationLayer";
|
||||||
|
|
||||||
|
const mockRangesForNormalizedSpan = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@/lib/document-annotation-selection", () => ({
|
||||||
|
buildAnchorFromContainerSelection: vi.fn(),
|
||||||
|
getContainerTextOffset: vi.fn(),
|
||||||
|
rangesForNormalizedSpan: mockRangesForNormalizedSpan,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
async function act(callback: () => void | Promise<void>) {
|
||||||
|
await callback();
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRect(left: number, top: number, width: number, height: number): DOMRect {
|
||||||
|
return {
|
||||||
|
x: left,
|
||||||
|
y: top,
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
right: left + width,
|
||||||
|
bottom: top + height,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRange(rects: DOMRect[], commonAncestorContainer: Node = document.createTextNode("")): Range {
|
||||||
|
return {
|
||||||
|
commonAncestorContainer,
|
||||||
|
getClientRects: () => rects,
|
||||||
|
} as unknown as Range;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DocumentAnnotationLayer", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let rectSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let root: Root | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
mockRangesForNormalizedSpan.mockReturnValue([makeRange([makeRect(8, 12, 80, 18)])]);
|
||||||
|
rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(makeRect(0, 0, 400, 300));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (root) {
|
||||||
|
await act(() => root?.unmount());
|
||||||
|
root = null;
|
||||||
|
}
|
||||||
|
rectSpy.mockRestore();
|
||||||
|
container.remove();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses solid yellow backgrounds for annotation highlights in light and dark themes", async () => {
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.textContent = "Annotated body text.";
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<DocumentAnnotationLayer
|
||||||
|
containerRef={{ current: body }}
|
||||||
|
markdown="Annotated body text."
|
||||||
|
threads={[
|
||||||
|
{ id: "active", selectedText: "Annotated", status: "open", anchorState: "active" },
|
||||||
|
{ id: "focused", selectedText: "body", status: "open", anchorState: "active" },
|
||||||
|
{ id: "stale", selectedText: "text", status: "open", anchorState: "stale" },
|
||||||
|
{ id: "resolved", selectedText: "body text", status: "resolved", anchorState: "active" },
|
||||||
|
]}
|
||||||
|
focusedThreadId="focused"
|
||||||
|
onThreadFocus={vi.fn()}
|
||||||
|
pendingAnchor={null}
|
||||||
|
onPendingAnchorChange={vi.fn()}
|
||||||
|
onRequestComment={vi.fn()}
|
||||||
|
hideResolved={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
||||||
|
});
|
||||||
|
|
||||||
|
const highlights = Array.from(container.querySelectorAll(".paperclip-doc-annotation-highlight"));
|
||||||
|
expect(highlights).toHaveLength(4);
|
||||||
|
|
||||||
|
for (const highlight of highlights) {
|
||||||
|
const backgroundClasses = Array.from(highlight.classList).filter((className) =>
|
||||||
|
/^(dark:|hover:|dark:hover:)?bg-yellow-\d+$/.test(className)
|
||||||
|
|| /^(dark:|hover:|dark:hover:)?bg-yellow-\d+\//.test(className),
|
||||||
|
);
|
||||||
|
expect(backgroundClasses.some((className) => className.includes("/"))).toBe(false);
|
||||||
|
expect(backgroundClasses.some((className) => className.startsWith("bg-yellow-"))).toBe(true);
|
||||||
|
expect(backgroundClasses.some((className) => className.startsWith("dark:bg-yellow-"))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render highlights for text clipped by folded document content", async () => {
|
||||||
|
const body = document.createElement("div");
|
||||||
|
const clippedContent = document.createElement("div");
|
||||||
|
clippedContent.className = "fold-curtain__content";
|
||||||
|
const hiddenText = document.createTextNode("Hidden folded text");
|
||||||
|
clippedContent.appendChild(hiddenText);
|
||||||
|
body.appendChild(clippedContent);
|
||||||
|
mockRangesForNormalizedSpan.mockReturnValue([makeRange([makeRect(8, 60, 80, 18)], hiddenText)]);
|
||||||
|
rectSpy.mockImplementation(function (this: HTMLElement) {
|
||||||
|
if (this === clippedContent) return makeRect(0, 0, 400, 40);
|
||||||
|
return makeRect(0, 0, 400, 120);
|
||||||
|
});
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<DocumentAnnotationLayer
|
||||||
|
containerRef={{ current: body }}
|
||||||
|
markdown="Hidden folded text"
|
||||||
|
threads={[
|
||||||
|
{ id: "hidden", selectedText: "Hidden folded text", status: "open", anchorState: "active" },
|
||||||
|
]}
|
||||||
|
focusedThreadId={null}
|
||||||
|
onThreadFocus={vi.fn()}
|
||||||
|
pendingAnchor={null}
|
||||||
|
onPendingAnchorChange={vi.fn()}
|
||||||
|
onRequestComment={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector(".paperclip-doc-annotation-highlight")).toBeNull();
|
||||||
|
expect(container.querySelector(".paperclip-doc-annotation-hit-target")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses native CSS highlights for visual paint when the browser supports them", async () => {
|
||||||
|
const originalCss = globalThis.CSS;
|
||||||
|
const originalHighlight = (globalThis as { Highlight?: unknown }).Highlight;
|
||||||
|
const setHighlight = vi.fn();
|
||||||
|
const deleteHighlight = vi.fn();
|
||||||
|
class MockHighlight {
|
||||||
|
ranges: Range[];
|
||||||
|
|
||||||
|
constructor(...ranges: Range[]) {
|
||||||
|
this.ranges = ranges;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(globalThis as { CSS?: unknown }).CSS = {
|
||||||
|
...(originalCss ?? {}),
|
||||||
|
highlights: {
|
||||||
|
set: setHighlight,
|
||||||
|
delete: deleteHighlight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(globalThis as { Highlight?: unknown }).Highlight = MockHighlight;
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.textContent = "Annotated body text.";
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<DocumentAnnotationLayer
|
||||||
|
containerRef={{ current: body }}
|
||||||
|
markdown="Annotated body text."
|
||||||
|
threads={[
|
||||||
|
{ id: "active", selectedText: "Annotated", status: "open", anchorState: "active" },
|
||||||
|
]}
|
||||||
|
focusedThreadId={null}
|
||||||
|
onThreadFocus={vi.fn()}
|
||||||
|
pendingAnchor={null}
|
||||||
|
onPendingAnchorChange={vi.fn()}
|
||||||
|
onRequestComment={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector(".paperclip-doc-annotation-highlight")).toBeNull();
|
||||||
|
expect(container.querySelector(".paperclip-doc-annotation-hit-target")).not.toBeNull();
|
||||||
|
const openHighlightCall = setHighlight.mock.calls.find(([name]) => name === "paperclip-doc-annotation-open");
|
||||||
|
expect(openHighlightCall).toBeTruthy();
|
||||||
|
expect((openHighlightCall?.[1] as MockHighlight).ranges).toHaveLength(1);
|
||||||
|
|
||||||
|
await act(async () => root?.unmount());
|
||||||
|
root = null;
|
||||||
|
expect(deleteHighlight).toHaveBeenCalledWith("paperclip-doc-annotation-open");
|
||||||
|
|
||||||
|
(globalThis as { CSS?: unknown }).CSS = originalCss;
|
||||||
|
(globalThis as { Highlight?: unknown }).Highlight = originalHighlight;
|
||||||
|
});
|
||||||
|
});
|
||||||
515
ui/src/components/DocumentAnnotationLayer.tsx
Normal file
515
ui/src/components/DocumentAnnotationLayer.tsx
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { AlertTriangle, MessageSquarePlus } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
DocumentAnnotationAnchorState,
|
||||||
|
DocumentAnnotationThreadStatus,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
buildAnchorFromContainerSelection,
|
||||||
|
getContainerTextOffset,
|
||||||
|
rangesForNormalizedSpan,
|
||||||
|
} from "@/lib/document-annotation-selection";
|
||||||
|
import type { DocumentAnnotationAnchorSelector } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
export interface AnnotationOverlayThread {
|
||||||
|
id: string;
|
||||||
|
selectedText: string;
|
||||||
|
status: DocumentAnnotationThreadStatus;
|
||||||
|
anchorState: DocumentAnnotationAnchorState;
|
||||||
|
unreadCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingAnchor {
|
||||||
|
selector: DocumentAnnotationAnchorSelector;
|
||||||
|
selectedText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationLayerProps {
|
||||||
|
containerRef: React.RefObject<HTMLElement | null>;
|
||||||
|
markdown: string;
|
||||||
|
threads: AnnotationOverlayThread[];
|
||||||
|
focusedThreadId: string | null;
|
||||||
|
onThreadFocus: (threadId: string) => void;
|
||||||
|
/** Tracks the most recently captured pending selection. */
|
||||||
|
pendingAnchor: PendingAnchor | null;
|
||||||
|
onPendingAnchorChange: (anchor: PendingAnchor | null) => void;
|
||||||
|
onRequestComment: (anchor: PendingAnchor) => void;
|
||||||
|
/** Disables the "add comment" affordance when set. */
|
||||||
|
newCommentDisabled?: boolean;
|
||||||
|
newCommentDisabledReason?: string | null;
|
||||||
|
/** Hide resolved highlights even when included in the threads list. */
|
||||||
|
hideResolved?: boolean;
|
||||||
|
/** Test-only: override window object for layout calculations. */
|
||||||
|
testWindow?: { innerWidth: number; innerHeight: number };
|
||||||
|
/**
|
||||||
|
* When this number changes, re-read the current document selection and emit a
|
||||||
|
* pending anchor for the keyboard shortcut path.
|
||||||
|
*/
|
||||||
|
captureSelectionRequestId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HighlightRect {
|
||||||
|
threadId: string;
|
||||||
|
status: DocumentAnnotationThreadStatus;
|
||||||
|
anchorState: DocumentAnnotationAnchorState;
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** True for the last rect of this thread (used to anchor a glyph at the run end). */
|
||||||
|
isTail: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolbarPosition {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NativeHighlightKind = "open" | "focused" | "stale" | "resolved";
|
||||||
|
|
||||||
|
type NativeHighlightRanges = Record<NativeHighlightKind, Range[]>;
|
||||||
|
|
||||||
|
type CssHighlight = object;
|
||||||
|
|
||||||
|
type HighlightConstructor = new (...ranges: Range[]) => CssHighlight;
|
||||||
|
|
||||||
|
type HighlightRegistry = {
|
||||||
|
set: (name: string, highlight: CssHighlight) => void;
|
||||||
|
delete: (name: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NATIVE_HIGHLIGHT_NAMES: Record<NativeHighlightKind, string> = {
|
||||||
|
open: "paperclip-doc-annotation-open",
|
||||||
|
focused: "paperclip-doc-annotation-focused",
|
||||||
|
stale: "paperclip-doc-annotation-stale",
|
||||||
|
resolved: "paperclip-doc-annotation-resolved",
|
||||||
|
};
|
||||||
|
|
||||||
|
const nativeHighlightInstances = new Map<string, NativeHighlightRanges>();
|
||||||
|
|
||||||
|
function getNativeHighlightApi(): { registry: HighlightRegistry; HighlightCtor: HighlightConstructor } | null {
|
||||||
|
const css = (globalThis as { CSS?: { highlights?: HighlightRegistry } }).CSS;
|
||||||
|
const HighlightCtor = (globalThis as { Highlight?: HighlightConstructor }).Highlight;
|
||||||
|
if (!css?.highlights || typeof HighlightCtor !== "function") return null;
|
||||||
|
return { registry: css.highlights, HighlightCtor };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyNativeHighlightRanges(): NativeHighlightRanges {
|
||||||
|
return {
|
||||||
|
open: [],
|
||||||
|
focused: [],
|
||||||
|
stale: [],
|
||||||
|
resolved: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncNativeHighlights(api = getNativeHighlightApi()) {
|
||||||
|
if (!api) return;
|
||||||
|
for (const kind of Object.keys(NATIVE_HIGHLIGHT_NAMES) as NativeHighlightKind[]) {
|
||||||
|
const ranges = Array.from(nativeHighlightInstances.values()).flatMap((entry) => entry[kind]);
|
||||||
|
const name = NATIVE_HIGHLIGHT_NAMES[kind];
|
||||||
|
if (ranges.length === 0) {
|
||||||
|
api.registry.delete(name);
|
||||||
|
} else {
|
||||||
|
api.registry.set(name, new api.HighlightCtor(...ranges));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNativeHighlightRanges(instanceId: string, ranges: NativeHighlightRanges) {
|
||||||
|
if (!getNativeHighlightApi()) return;
|
||||||
|
nativeHighlightInstances.set(instanceId, ranges);
|
||||||
|
syncNativeHighlights();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearNativeHighlightRanges(instanceId: string) {
|
||||||
|
if (!nativeHighlightInstances.delete(instanceId)) return;
|
||||||
|
syncNativeHighlights();
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementFromNode(node: Node | null | undefined): HTMLElement | null {
|
||||||
|
if (!node) return null;
|
||||||
|
if (node instanceof HTMLElement) return node;
|
||||||
|
const parent = node.parentElement;
|
||||||
|
return parent instanceof HTMLElement ? parent : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersectRects(a: DOMRect, b: DOMRect): DOMRect | null {
|
||||||
|
const left = Math.max(a.left, b.left);
|
||||||
|
const top = Math.max(a.top, b.top);
|
||||||
|
const right = Math.min(a.right, b.right);
|
||||||
|
const bottom = Math.min(a.bottom, b.bottom);
|
||||||
|
if (right <= left || bottom <= top) return null;
|
||||||
|
return {
|
||||||
|
x: left,
|
||||||
|
y: top,
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
right,
|
||||||
|
bottom,
|
||||||
|
width: right - left,
|
||||||
|
height: bottom - top,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipsOverflow(element: HTMLElement) {
|
||||||
|
if (element.classList.contains("fold-curtain__content")) return true;
|
||||||
|
if (typeof window === "undefined" || typeof window.getComputedStyle !== "function") return false;
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
return [style.overflow, style.overflowX, style.overflowY].some((value) =>
|
||||||
|
value === "hidden" || value === "clip" || value === "auto" || value === "scroll",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleClipRectForRange(range: Range, container: HTMLElement): DOMRect | null {
|
||||||
|
let clipRect = container.getBoundingClientRect();
|
||||||
|
let element = elementFromNode(range.commonAncestorContainer);
|
||||||
|
while (element) {
|
||||||
|
if (clipsOverflow(element)) {
|
||||||
|
const nextClipRect = intersectRects(clipRect, element.getBoundingClientRect());
|
||||||
|
if (!nextClipRect) return null;
|
||||||
|
clipRect = nextClipRect;
|
||||||
|
}
|
||||||
|
if (element === container) break;
|
||||||
|
element = element.parentElement;
|
||||||
|
}
|
||||||
|
return clipRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeHighlightKind(input: {
|
||||||
|
focused: boolean;
|
||||||
|
stale: boolean;
|
||||||
|
resolved: boolean;
|
||||||
|
}): NativeHighlightKind {
|
||||||
|
if (input.resolved) return "resolved";
|
||||||
|
if (input.stale) return "stale";
|
||||||
|
if (input.focused) return "focused";
|
||||||
|
return "open";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentAnnotationLayer({
|
||||||
|
containerRef,
|
||||||
|
markdown,
|
||||||
|
threads,
|
||||||
|
focusedThreadId,
|
||||||
|
onThreadFocus,
|
||||||
|
pendingAnchor,
|
||||||
|
onPendingAnchorChange,
|
||||||
|
onRequestComment,
|
||||||
|
newCommentDisabled = false,
|
||||||
|
newCommentDisabledReason = null,
|
||||||
|
hideResolved = true,
|
||||||
|
captureSelectionRequestId,
|
||||||
|
}: AnnotationLayerProps) {
|
||||||
|
const [highlightRects, setHighlightRects] = useState<HighlightRect[]>([]);
|
||||||
|
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition | null>(null);
|
||||||
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const lastCaptureSelectionRequestIdRef = useRef<number>(0);
|
||||||
|
const reactId = useId();
|
||||||
|
const nativeHighlightInstanceId = useMemo(
|
||||||
|
() => `document-annotation-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`,
|
||||||
|
[reactId],
|
||||||
|
);
|
||||||
|
const nativeHighlightsSupported = getNativeHighlightApi() !== null;
|
||||||
|
|
||||||
|
const visibleThreads = useMemo(() => {
|
||||||
|
if (!hideResolved) return threads;
|
||||||
|
return threads.filter((thread) => thread.status !== "resolved" || thread.anchorState === "orphaned" || thread.id === focusedThreadId);
|
||||||
|
}, [threads, hideResolved, focusedThreadId]);
|
||||||
|
|
||||||
|
const computeHighlightRects = useCallback(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const overlay = overlayRef.current;
|
||||||
|
if (!container || !overlay) {
|
||||||
|
clearNativeHighlightRanges(nativeHighlightInstanceId);
|
||||||
|
setHighlightRects([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 ranges = rangesForNormalizedSpan({
|
||||||
|
container,
|
||||||
|
selectedText: thread.selectedText,
|
||||||
|
});
|
||||||
|
const startIndex = next.length;
|
||||||
|
for (const range of ranges) {
|
||||||
|
const visibleClipRect = visibleClipRectForRange(range, container);
|
||||||
|
if (!visibleClipRect) continue;
|
||||||
|
let rangeIsVisible = false;
|
||||||
|
for (const rect of Array.from(range.getClientRects())) {
|
||||||
|
if (rect.width === 0 || rect.height === 0) continue;
|
||||||
|
const visibleRect = intersectRects(rect, visibleClipRect);
|
||||||
|
if (!visibleRect) continue;
|
||||||
|
rangeIsVisible = true;
|
||||||
|
next.push({
|
||||||
|
threadId: thread.id,
|
||||||
|
status: thread.status,
|
||||||
|
anchorState: thread.anchorState,
|
||||||
|
top: visibleRect.top - overlayRect.top,
|
||||||
|
left: visibleRect.left - overlayRect.left,
|
||||||
|
width: visibleRect.width,
|
||||||
|
height: visibleRect.height,
|
||||||
|
isTail: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rangeIsVisible) nativeRanges[nativeKind].push(range);
|
||||||
|
}
|
||||||
|
if (next.length > startIndex) {
|
||||||
|
next[next.length - 1].isTail = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setNativeHighlightRanges(nativeHighlightInstanceId, nativeRanges);
|
||||||
|
setHighlightRects(next);
|
||||||
|
}, [containerRef, focusedThreadId, nativeHighlightInstanceId, visibleThreads]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
computeHighlightRects();
|
||||||
|
}, [computeHighlightRects]);
|
||||||
|
|
||||||
|
useEffect(() => () => clearNativeHighlightRanges(nativeHighlightInstanceId), [nativeHighlightInstanceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const container = containerRef.current;
|
||||||
|
const overlay = overlayRef.current;
|
||||||
|
let cancelled = false;
|
||||||
|
let frame: number | null = null;
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (cancelled || frame !== null) return;
|
||||||
|
frame = window.requestAnimationFrame(() => {
|
||||||
|
frame = null;
|
||||||
|
if (!cancelled) computeHighlightRects();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResizeOrScroll = () => schedule();
|
||||||
|
window.addEventListener("resize", handleResizeOrScroll);
|
||||||
|
window.addEventListener("scroll", handleResizeOrScroll, true);
|
||||||
|
|
||||||
|
const resizeObserver = typeof window.ResizeObserver === "function"
|
||||||
|
? new window.ResizeObserver(schedule)
|
||||||
|
: null;
|
||||||
|
if (resizeObserver && container) resizeObserver.observe(container);
|
||||||
|
if (resizeObserver && overlay) resizeObserver.observe(overlay);
|
||||||
|
|
||||||
|
const mutationObserver = typeof window.MutationObserver === "function" && container
|
||||||
|
? new window.MutationObserver((mutations) => {
|
||||||
|
const onlyLayerMutations = mutations.every((mutation) => {
|
||||||
|
const target = elementFromNode(mutation.target);
|
||||||
|
return !!target?.closest(".paperclip-doc-annotation-layer, .paperclip-doc-annotation-visual-layer");
|
||||||
|
});
|
||||||
|
if (!onlyLayerMutations) schedule();
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (mutationObserver && container) {
|
||||||
|
mutationObserver.observe(container, {
|
||||||
|
childList: true,
|
||||||
|
characterData: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class", "style", "data-state", "open", "hidden", "aria-expanded"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
mutationObserver?.disconnect();
|
||||||
|
window.removeEventListener("resize", handleResizeOrScroll);
|
||||||
|
window.removeEventListener("scroll", handleResizeOrScroll, true);
|
||||||
|
};
|
||||||
|
}, [computeHighlightRects, containerRef]);
|
||||||
|
|
||||||
|
const captureSelection = useCallback((): PendingAnchor | null => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const overlay = overlayRef.current;
|
||||||
|
if (!container || !overlay) return null;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
if (!container.contains(range.commonAncestorContainer)) return null;
|
||||||
|
const containerOffset = getContainerTextOffset(container, range);
|
||||||
|
if (!containerOffset) return null;
|
||||||
|
const anchor = buildAnchorFromContainerSelection({ markdown, containerOffset });
|
||||||
|
if (!anchor) return null;
|
||||||
|
const overlayRect = overlay.getBoundingClientRect();
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
const top = Math.max(0, rect.top - overlayRect.top - 36);
|
||||||
|
const left = Math.max(0, rect.left - overlayRect.left + rect.width / 2 - 80);
|
||||||
|
setToolbarPosition({ top, left });
|
||||||
|
return {
|
||||||
|
selector: anchor.selector,
|
||||||
|
selectedText: containerOffset.selectedText,
|
||||||
|
};
|
||||||
|
}, [containerRef, markdown]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const handleSelectionChange = () => {
|
||||||
|
const anchor = captureSelection();
|
||||||
|
if (!anchor) {
|
||||||
|
onPendingAnchorChange(null);
|
||||||
|
setToolbarPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPendingAnchorChange(anchor);
|
||||||
|
};
|
||||||
|
document.addEventListener("selectionchange", handleSelectionChange);
|
||||||
|
return () => document.removeEventListener("selectionchange", handleSelectionChange);
|
||||||
|
}, [captureSelection, onPendingAnchorChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (captureSelectionRequestId === undefined) return;
|
||||||
|
if (captureSelectionRequestId === 0) return;
|
||||||
|
if (lastCaptureSelectionRequestIdRef.current === captureSelectionRequestId) return;
|
||||||
|
lastCaptureSelectionRequestIdRef.current = captureSelectionRequestId;
|
||||||
|
const anchor = captureSelection();
|
||||||
|
if (anchor) {
|
||||||
|
onPendingAnchorChange(anchor);
|
||||||
|
onRequestComment(anchor);
|
||||||
|
}
|
||||||
|
}, [captureSelectionRequestId, captureSelection, onPendingAnchorChange, onRequestComment]);
|
||||||
|
|
||||||
|
const handleAddComment = () => {
|
||||||
|
if (pendingAnchor) onRequestComment(pendingAnchor);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!nativeHighlightsSupported ? (
|
||||||
|
<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 isStale = rect.anchorState === "stale";
|
||||||
|
const isResolved = rect.status === "resolved";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`visual-${rect.threadId}-${index}`}
|
||||||
|
data-thread-id={rect.threadId}
|
||||||
|
data-anchor-state={rect.anchorState}
|
||||||
|
data-status={rect.status}
|
||||||
|
data-focused={isFocused || undefined}
|
||||||
|
className={cn(
|
||||||
|
"paperclip-doc-annotation-highlight absolute rounded-none transition-colors",
|
||||||
|
// base box treatment (replaces the previous baseline border)
|
||||||
|
isResolved
|
||||||
|
? "bg-yellow-100 outline outline-1 outline-dashed outline-offset-0 outline-yellow-700/45 dark:bg-yellow-700 dark:outline-yellow-200/45"
|
||||||
|
: isStale
|
||||||
|
? "bg-yellow-200 outline outline-2 outline-dashed outline-offset-0 outline-yellow-700/65 dark:bg-yellow-600 dark:outline-yellow-200/70"
|
||||||
|
: isFocused
|
||||||
|
? "bg-yellow-300 outline outline-2 outline-offset-0 outline-yellow-700/85 shadow-[0_0_0_1px_var(--color-background)] dark:bg-yellow-500 dark:outline-yellow-200/85"
|
||||||
|
: "bg-yellow-200 dark:bg-yellow-600",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="paperclip-doc-annotation-layer pointer-events-none absolute inset-0 z-[2]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div ref={overlayRef} className="relative h-full w-full">
|
||||||
|
{highlightRects.map((rect, index) => {
|
||||||
|
const isFocused = rect.threadId === focusedThreadId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${rect.threadId}-${index}`}
|
||||||
|
type="button"
|
||||||
|
data-thread-id={rect.threadId}
|
||||||
|
data-anchor-state={rect.anchorState}
|
||||||
|
data-status={rect.status}
|
||||||
|
data-focused={isFocused || undefined}
|
||||||
|
aria-label="Open annotation thread"
|
||||||
|
className={cn(
|
||||||
|
"paperclip-doc-annotation-hit-target pointer-events-auto absolute cursor-pointer rounded-none bg-transparent",
|
||||||
|
isFocused && "ring-1 ring-transparent",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onThreadFocus(rect.threadId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{highlightRects.map((rect, index) =>
|
||||||
|
rect.isTail && rect.anchorState === "stale" ? (
|
||||||
|
<span
|
||||||
|
key={`tail-${rect.threadId}-${index}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
data-thread-id={rect.threadId}
|
||||||
|
className="paperclip-doc-annotation-tail pointer-events-none absolute inline-flex items-center justify-center rounded-sm bg-amber-500/95 text-amber-50 shadow-sm dark:bg-amber-500/90 dark:text-amber-50"
|
||||||
|
style={{
|
||||||
|
top: rect.top + Math.max(0, rect.height / 2 - 8),
|
||||||
|
left: rect.left + rect.width + 2,
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
}}
|
||||||
|
title="Anchor moved — needs review"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
{pendingAnchor && toolbarPosition ? (
|
||||||
|
<div
|
||||||
|
data-testid="document-annotation-selection-toolbar"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Selection actions"
|
||||||
|
className="paperclip-doc-annotation-selection-toolbar pointer-events-auto absolute z-10 flex items-center gap-1 rounded-md border border-border bg-popover px-1 py-1 shadow-md"
|
||||||
|
style={{ top: toolbarPosition.top, left: toolbarPosition.left }}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 gap-1 px-2 text-xs"
|
||||||
|
onClick={handleAddComment}
|
||||||
|
disabled={newCommentDisabled}
|
||||||
|
title={newCommentDisabled
|
||||||
|
? newCommentDisabledReason ?? undefined
|
||||||
|
: "Add comment on selection (⌘⇧M)"}
|
||||||
|
>
|
||||||
|
<MessageSquarePlus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
Comment
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
574
ui/src/components/DocumentAnnotationPanel.tsx
Normal file
574
ui/src/components/DocumentAnnotationPanel.tsx
Normal file
|
|
@ -0,0 +1,574 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type {
|
||||||
|
DocumentAnnotationComment,
|
||||||
|
DocumentAnnotationThreadStatus,
|
||||||
|
DocumentAnnotationThreadWithComments,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
MoreHorizontal,
|
||||||
|
RotateCcw,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { cn, relativeTime } from "@/lib/utils";
|
||||||
|
import { documentAnnotationsApi } from "@/api/document-annotations";
|
||||||
|
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;
|
||||||
|
documentKey: string;
|
||||||
|
documentRevisionNumber: number;
|
||||||
|
baseRevisionId: string | null;
|
||||||
|
baseRevisionNumber: number;
|
||||||
|
threads: DocumentAnnotationThreadWithComments[];
|
||||||
|
focusedThreadId: string | null;
|
||||||
|
onFocusThread: (threadId: string | null) => void;
|
||||||
|
focusedCommentId: string | null;
|
||||||
|
/** External pending anchor captured from the layer for the composer. */
|
||||||
|
pendingAnchor: PendingAnchor | null;
|
||||||
|
onClearPendingAnchor: () => void;
|
||||||
|
/** Request the body layer to start a comment from the current text selection (⌘⇧M). */
|
||||||
|
onRequestCommentFromSelection?: () => void;
|
||||||
|
newCommentDisabled?: boolean;
|
||||||
|
newCommentDisabledReason?: string | null;
|
||||||
|
/** When mobile is true, render via shadcn Sheet at the bottom instead of side panel. */
|
||||||
|
isMobile?: boolean;
|
||||||
|
/** Desktop panel width calculated by the document frame. */
|
||||||
|
desktopWidth?: number;
|
||||||
|
className?: string;
|
||||||
|
/** Resolve `<authorAgentId>` to a display name. */
|
||||||
|
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||||
|
/** Resolve `<authorUserId>` to a display name. */
|
||||||
|
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
|
||||||
|
if (props.isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="paperclip-doc-annotation-sheet flex max-h-[88vh] flex-col rounded-none border-t border-border bg-background p-0"
|
||||||
|
>
|
||||||
|
<SheetTitle className="sr-only">
|
||||||
|
Comments on {props.documentKey} revision {props.documentRevisionNumber}
|
||||||
|
</SheetTitle>
|
||||||
|
<div className="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-muted-foreground/30" aria-hidden="true" />
|
||||||
|
<AnnotationPanelBody {...props} />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
role="complementary"
|
||||||
|
aria-label={`Annotations for ${props.documentKey.toUpperCase()}, revision ${props.documentRevisionNumber}`}
|
||||||
|
data-testid="document-annotation-panel"
|
||||||
|
className={cn(
|
||||||
|
"flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col overflow-hidden rounded-none border border-border bg-card shadow-md",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
style={props.desktopWidth ? { width: props.desktopWidth, maxWidth: props.desktopWidth } : undefined}
|
||||||
|
>
|
||||||
|
<AnnotationPanelBody {...props} />
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const bodyTestId = props.isMobile ? "document-annotation-panel" : undefined;
|
||||||
|
|
||||||
|
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 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]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}, [props.documentKey, props.issueId, 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, {
|
||||||
|
baseRevisionId: props.baseRevisionId,
|
||||||
|
baseRevisionNumber: props.baseRevisionNumber,
|
||||||
|
selector: props.pendingAnchor.selector,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (thread) => {
|
||||||
|
props.onClearPendingAnchor();
|
||||||
|
setComposerValue("");
|
||||||
|
props.onFocusThread(thread.id);
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStatus = useMutation({
|
||||||
|
mutationFn: ({ threadId, status }: { threadId: string; status: DocumentAnnotationThreadStatus }) =>
|
||||||
|
documentAnnotationsApi.updateStatus(props.issueId, props.documentKey, threadId, status),
|
||||||
|
onSuccess: () => invalidateAll(),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!props.open) {
|
||||||
|
setComposerValue("");
|
||||||
|
}
|
||||||
|
}, [props.open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.pendingAnchor && props.open) {
|
||||||
|
composerRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [props.open, props.pendingAnchor]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header
|
||||||
|
data-testid={bodyTestId}
|
||||||
|
className="flex items-start justify-between gap-2 border-b border-border px-3 py-2.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>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
props.onFocusThread(null);
|
||||||
|
props.onOpenChange(false);
|
||||||
|
}}
|
||||||
|
aria-label="Close annotation panel"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div className="flex flex-wrap gap-1 border-b border-border 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
|
||||||
|
data-testid="document-annotation-disabled-reason"
|
||||||
|
className="border-b border-border bg-muted/40 px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{props.newCommentDisabledReason}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto 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>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{filteredThreads.map((thread) => (
|
||||||
|
<ThreadCard
|
||||||
|
key={thread.id}
|
||||||
|
thread={thread}
|
||||||
|
expanded={thread.id === props.focusedThreadId}
|
||||||
|
focusedCommentId={
|
||||||
|
thread.id === props.focusedThreadId ? props.focusedCommentId : null
|
||||||
|
}
|
||||||
|
onFocus={() => props.onFocusThread(thread.id)}
|
||||||
|
replyDraft={replyDrafts[thread.id] ?? ""}
|
||||||
|
onReplyChange={(value) =>
|
||||||
|
setReplyDrafts((current) => ({ ...current, [thread.id]: value }))
|
||||||
|
}
|
||||||
|
onSubmitReply={() => {
|
||||||
|
const body = (replyDrafts[thread.id] ?? "").trim();
|
||||||
|
if (!body) return;
|
||||||
|
addReply.mutate({ threadId: thread.id, body });
|
||||||
|
}}
|
||||||
|
onResolveToggle={() =>
|
||||||
|
updateStatus.mutate({
|
||||||
|
threadId: thread.id,
|
||||||
|
status: thread.status === "resolved" ? "open" : "resolved",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCopyLink={() => copyAnnotationLink(props.documentKey, thread.id)}
|
||||||
|
pendingReply={addReply.isPending && addReply.variables?.threadId === thread.id}
|
||||||
|
pendingStatus={updateStatus.isPending && updateStatus.variables?.threadId === thread.id}
|
||||||
|
agentMap={props.agentMap}
|
||||||
|
userProfileMap={props.userProfileMap}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{props.pendingAnchor ? (
|
||||||
|
<div className="border-t border-border bg-muted/20 px-3 py-2">
|
||||||
|
<blockquote className="mb-2 line-clamp-3 overflow-hidden rounded-none bg-background px-2 py-1 text-xs italic text-muted-foreground">
|
||||||
|
{truncate(props.pendingAnchor.selectedText, 160)}
|
||||||
|
</blockquote>
|
||||||
|
<Textarea
|
||||||
|
ref={composerRef}
|
||||||
|
data-testid="document-annotation-composer"
|
||||||
|
rows={3}
|
||||||
|
value={composerValue}
|
||||||
|
onChange={(event) => setComposerValue(event.target.value)}
|
||||||
|
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"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
props.onClearPendingAnchor();
|
||||||
|
setComposerValue("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
createThread.isPending
|
||||||
|
|| !composerValue.trim()
|
||||||
|
|| props.newCommentDisabled
|
||||||
|
|| !props.baseRevisionId
|
||||||
|
}
|
||||||
|
onClick={() => createThread.mutate(composerValue.trim())}
|
||||||
|
>
|
||||||
|
{createThread.isPending ? "Posting…" : "Comment"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThreadCard(props: {
|
||||||
|
thread: DocumentAnnotationThreadWithComments;
|
||||||
|
expanded: boolean;
|
||||||
|
focusedCommentId: string | null;
|
||||||
|
onFocus: () => void;
|
||||||
|
replyDraft: string;
|
||||||
|
onReplyChange: (value: string) => void;
|
||||||
|
onSubmitReply: () => void;
|
||||||
|
onResolveToggle: () => void;
|
||||||
|
onCopyLink: () => void;
|
||||||
|
pendingReply: boolean;
|
||||||
|
pendingStatus: boolean;
|
||||||
|
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||||
|
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 (
|
||||||
|
<li>
|
||||||
|
<article
|
||||||
|
role="article"
|
||||||
|
data-thread-id={thread.id}
|
||||||
|
data-anchor-state={thread.anchorState}
|
||||||
|
data-status={thread.status}
|
||||||
|
data-focused={props.expanded || undefined}
|
||||||
|
aria-labelledby={`thread-quote-${thread.id}`}
|
||||||
|
className={cn(
|
||||||
|
"rounded-none border border-border bg-card transition-colors",
|
||||||
|
props.expanded && "ring-1 ring-ring/70",
|
||||||
|
thread.status === "resolved" && "bg-muted/30",
|
||||||
|
)}
|
||||||
|
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/40 px-2 py-1 text-xs italic text-muted-foreground",
|
||||||
|
(thread.anchorState === "stale" || thread.status === "resolved") && "bg-muted/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{truncate(thread.selectedText, 120)}
|
||||||
|
</blockquote>
|
||||||
|
{props.expanded ? (
|
||||||
|
<div className="space-y-2 px-3 py-2">
|
||||||
|
{thread.comments.map((comment) => (
|
||||||
|
<CommentRow
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
focused={props.focusedCommentId === comment.id}
|
||||||
|
agentMap={props.agentMap}
|
||||||
|
userProfileMap={props.userProfileMap}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Textarea
|
||||||
|
data-testid={`document-annotation-reply-${thread.id}`}
|
||||||
|
rows={2}
|
||||||
|
value={props.replyDraft}
|
||||||
|
onChange={(event) => props.onReplyChange(event.target.value)}
|
||||||
|
placeholder="Reply…"
|
||||||
|
className="resize-y rounded-none text-sm"
|
||||||
|
disabled={props.pendingReply}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={props.onResolveToggle}
|
||||||
|
disabled={props.pendingStatus}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
{thread.status === "resolved" ? (
|
||||||
|
<>
|
||||||
|
<RotateCcw className="h-3 w-3" /> Reopen
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="h-3 w-3" /> Resolve
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!props.replyDraft.trim() || props.pendingReply}
|
||||||
|
onClick={props.onSubmitReply}
|
||||||
|
>
|
||||||
|
{props.pendingReply ? "Sending…" : "Reply"}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
title="More actions"
|
||||||
|
aria-label="More thread actions"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
props.onCopyLink();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
Copy link
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{thread.comments.length} comment{thread.comments.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
{latestComment ? <span className="ml-1">· {truncate(latestComment.body, 120)}</span> : null}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentRow({
|
||||||
|
comment,
|
||||||
|
focused,
|
||||||
|
agentMap,
|
||||||
|
userProfileMap,
|
||||||
|
}: {
|
||||||
|
comment: DocumentAnnotationComment;
|
||||||
|
focused: boolean;
|
||||||
|
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||||
|
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||||
|
}) {
|
||||||
|
const author = resolveAuthor(comment, { agentMap, userProfileMap });
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`comment-${comment.id}`}
|
||||||
|
data-focused={focused || undefined}
|
||||||
|
className={cn(
|
||||||
|
"rounded-none border border-border bg-background px-2 py-1.5",
|
||||||
|
focused && "ring-2 ring-primary/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{author.role === "agent" ? (
|
||||||
|
<span className="ml-1 text-muted-foreground">· agent</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">{relativeTime(comment.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<MarkdownBody className="text-sm leading-6">{comment.body}</MarkdownBody>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAuthor(
|
||||||
|
comment: DocumentAnnotationComment,
|
||||||
|
maps: {
|
||||||
|
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||||
|
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||||
|
},
|
||||||
|
): { name: string; role: "board" | "agent" } {
|
||||||
|
if (comment.authorAgentId) {
|
||||||
|
const agent = maps.agentMap?.get(comment.authorAgentId);
|
||||||
|
return {
|
||||||
|
name: agent?.name ?? comment.authorAgentId.slice(0, 8),
|
||||||
|
role: "agent",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (comment.authorUserId) {
|
||||||
|
const profile = maps.userProfileMap?.get(comment.authorUserId);
|
||||||
|
return {
|
||||||
|
name: profile?.label ?? comment.authorUserId.slice(0, 8),
|
||||||
|
role: "board",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { name: comment.authorType === "agent" ? "Agent" : "Board", role: comment.authorType === "agent" ? "agent" : "board" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(value: string, limit: number) {
|
||||||
|
if (value.length <= limit) return value;
|
||||||
|
return `${value.slice(0, limit - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyAnnotationLink(documentKey: string, threadId: string) {
|
||||||
|
if (typeof window === "undefined" || !navigator.clipboard) return;
|
||||||
|
const { pathname } = window.location;
|
||||||
|
const hash = `#document-${encodeURIComponent(documentKey)}&thread=${encodeURIComponent(threadId)}`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(`${window.location.origin}${pathname}${hash}`);
|
||||||
|
} catch {
|
||||||
|
/* swallow */
|
||||||
|
}
|
||||||
|
}
|
||||||
722
ui/src/components/IssueDocumentAnnotations.test.tsx
Normal file
722
ui/src/components/IssueDocumentAnnotations.test.tsx
Normal file
|
|
@ -0,0 +1,722 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type {
|
||||||
|
DocumentAnnotationThreadWithComments,
|
||||||
|
IssueDocument,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
DocumentAnnotationsCountChip,
|
||||||
|
IssueDocumentAnnotations,
|
||||||
|
} from "./IssueDocumentAnnotations";
|
||||||
|
|
||||||
|
const mockAnnotationsApi = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
addComment: vi.fn(),
|
||||||
|
updateStatus: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockPendingAnchor = vi.hoisted(() => ({
|
||||||
|
selector: {
|
||||||
|
quote: { exact: "should keep the editor", prefix: "We ", suffix: "." },
|
||||||
|
position: { normalizedStart: 10, normalizedEnd: 32, markdownStart: 10, markdownEnd: 32 },
|
||||||
|
},
|
||||||
|
selectedText: "should keep the editor",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/api/document-annotations", () => ({
|
||||||
|
documentAnnotationsApi: mockAnnotationsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./MarkdownBody", () => ({
|
||||||
|
MarkdownBody: ({ children }: { children: string }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/sheet", () => ({
|
||||||
|
Sheet: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||||
|
open ? <div data-slot="sheet">{children}</div> : null,
|
||||||
|
SheetContent: ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
side,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
side?: string;
|
||||||
|
}) => (
|
||||||
|
<div data-slot="sheet-content" data-side={side} className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
SheetTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||||
|
<div data-slot="sheet-title" className={className}>{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./DocumentAnnotationLayer", () => ({
|
||||||
|
DocumentAnnotationLayer: (props: {
|
||||||
|
newCommentDisabled?: boolean;
|
||||||
|
onPendingAnchorChange: (anchor: typeof mockPendingAnchor | null) => void;
|
||||||
|
onRequestComment: (anchor: typeof mockPendingAnchor) => void;
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="mock-annotation-selection"
|
||||||
|
disabled={props.newCommentDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
props.onPendingAnchorChange(mockPendingAnchor);
|
||||||
|
props.onRequestComment(mockPendingAnchor);
|
||||||
|
props.onPendingAnchorChange(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mock selection
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="mock-annotation-selection-only"
|
||||||
|
disabled={props.newCommentDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
props.onPendingAnchorChange(mockPendingAnchor);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mock captured selection
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
async function act(callback: () => void | Promise<void>) {
|
||||||
|
await callback();
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
await act(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
||||||
|
setter?.call(textarea, value);
|
||||||
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDoc(overrides: Partial<IssueDocument> = {}): IssueDocument {
|
||||||
|
return {
|
||||||
|
id: "doc-1",
|
||||||
|
companyId: "co-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
key: "plan",
|
||||||
|
title: "Plan",
|
||||||
|
format: "markdown",
|
||||||
|
body: "# Plan\n\nWe should keep the editor.",
|
||||||
|
latestRevisionId: "rev-4",
|
||||||
|
latestRevisionNumber: 4,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-1",
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: "user-1",
|
||||||
|
lockedAt: null,
|
||||||
|
lockedByAgentId: null,
|
||||||
|
lockedByUserId: null,
|
||||||
|
createdAt: new Date("2026-04-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-04-01T00:01:00Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeThread(
|
||||||
|
overrides: Partial<DocumentAnnotationThreadWithComments> = {},
|
||||||
|
): DocumentAnnotationThreadWithComments {
|
||||||
|
const id = overrides.id ?? "thread-1";
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
companyId: "co-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
documentId: "doc-1",
|
||||||
|
documentKey: "plan",
|
||||||
|
status: "open",
|
||||||
|
anchorState: "active",
|
||||||
|
anchorConfidence: "exact",
|
||||||
|
originalRevisionId: "rev-4",
|
||||||
|
originalRevisionNumber: 4,
|
||||||
|
currentRevisionId: "rev-4",
|
||||||
|
currentRevisionNumber: 4,
|
||||||
|
selectedText: "should keep the editor",
|
||||||
|
prefixText: "We ",
|
||||||
|
suffixText: ".",
|
||||||
|
normalizedStart: 0,
|
||||||
|
normalizedEnd: 22,
|
||||||
|
markdownStart: 0,
|
||||||
|
markdownEnd: 22,
|
||||||
|
anchorSelector: {
|
||||||
|
quote: { exact: "should keep the editor", prefix: "We ", suffix: "." },
|
||||||
|
position: { normalizedStart: 0, normalizedEnd: 22, markdownStart: 0, markdownEnd: 22 },
|
||||||
|
},
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-1",
|
||||||
|
resolvedByAgentId: null,
|
||||||
|
resolvedByUserId: null,
|
||||||
|
resolvedAt: null,
|
||||||
|
createdAt: new Date("2026-04-01T00:01:00Z"),
|
||||||
|
updatedAt: new Date("2026-04-01T00:02:00Z"),
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
id: "comment-1",
|
||||||
|
companyId: "co-1",
|
||||||
|
threadId: id,
|
||||||
|
issueId: "issue-1",
|
||||||
|
documentId: "doc-1",
|
||||||
|
body: "Please clarify this assumption.",
|
||||||
|
authorType: "user",
|
||||||
|
authorAgentId: null,
|
||||||
|
authorUserId: "user-1",
|
||||||
|
createdByRunId: null,
|
||||||
|
createdAt: new Date("2026-04-01T00:01:00Z"),
|
||||||
|
updatedAt: new Date("2026-04-01T00:01:00Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Harness({
|
||||||
|
doc,
|
||||||
|
draftDirty = false,
|
||||||
|
draftConflicted = false,
|
||||||
|
historicalPreview = false,
|
||||||
|
locationHash = "",
|
||||||
|
initialPanelOpen = false,
|
||||||
|
}: {
|
||||||
|
doc: IssueDocument;
|
||||||
|
draftDirty?: boolean;
|
||||||
|
draftConflicted?: boolean;
|
||||||
|
historicalPreview?: boolean;
|
||||||
|
locationHash?: string;
|
||||||
|
initialPanelOpen?: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(initialPanelOpen);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DocumentAnnotationsCountChip
|
||||||
|
issueId="issue-1"
|
||||||
|
docKey={doc.key}
|
||||||
|
panelOpen={open}
|
||||||
|
onToggle={() => setOpen((current) => !current)}
|
||||||
|
/>
|
||||||
|
<IssueDocumentAnnotations
|
||||||
|
issueId="issue-1"
|
||||||
|
doc={doc}
|
||||||
|
bodyMarkdown={doc.body}
|
||||||
|
draftDirty={draftDirty}
|
||||||
|
draftConflicted={draftConflicted}
|
||||||
|
historicalPreview={historicalPreview}
|
||||||
|
locationHash={locationHash}
|
||||||
|
panelOpen={open}
|
||||||
|
onPanelOpenChange={setOpen}
|
||||||
|
>
|
||||||
|
<p>Body content</p>
|
||||||
|
</IssueDocumentAnnotations>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("IssueDocumentAnnotations", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the open count chip and opens the panel on click", async () => {
|
||||||
|
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = makeQueryClient();
|
||||||
|
const doc = makeDoc();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Harness doc={doc} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const chip = container.querySelector('[data-testid="document-annotation-count-plan"]');
|
||||||
|
expect(chip).not.toBeNull();
|
||||||
|
expect(chip!.textContent).toContain("1");
|
||||||
|
expect(mockAnnotationsApi.list).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
(chip as HTMLButtonElement).click();
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
const panel = container.querySelector('[data-testid="document-annotation-panel"]');
|
||||||
|
expect(panel).not.toBeNull();
|
||||||
|
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]');
|
||||||
|
expect(anchor).not.toBeNull();
|
||||||
|
expect(anchor?.className).toContain("fixed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the desktop annotation panel inside the issue content area when properties are visible", 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, 900, 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;
|
||||||
|
const panel = container.querySelector('[data-testid="document-annotation-panel"]') as HTMLElement | null;
|
||||||
|
expect(anchor).not.toBeNull();
|
||||||
|
expect(panel).not.toBeNull();
|
||||||
|
expect(anchor!.style.left).toBe("524px");
|
||||||
|
expect(anchor!.style.width).toBe("360px");
|
||||||
|
expect(panel!.style.width).toBe("360px");
|
||||||
|
expect(parseFloat(anchor!.style.left) + parseFloat(anchor!.style.width)).toBeLessThanOrEqual(884);
|
||||||
|
} 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);
|
||||||
|
const queryClient = makeQueryClient();
|
||||||
|
const doc = makeDoc();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Harness doc={doc} locationHash="#document-plan&thread=thread-99" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const panel = container.querySelector('[data-testid="document-annotation-panel"]');
|
||||||
|
expect(panel).not.toBeNull();
|
||||||
|
const focusedThread = container.querySelector('[data-thread-id="thread-99"][data-focused]');
|
||||||
|
expect(focusedThread).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a disabled reason in the panel when the draft is dirty", async () => {
|
||||||
|
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = makeQueryClient();
|
||||||
|
const doc = makeDoc();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Harness doc={doc} draftDirty initialPanelOpen />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const reason = container.querySelector(
|
||||||
|
'[data-testid="document-annotation-disabled-reason"]',
|
||||||
|
);
|
||||||
|
expect(reason).not.toBeNull();
|
||||||
|
expect(reason!.textContent).toMatch(/draft/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters resolved threads behind their tab", async () => {
|
||||||
|
mockAnnotationsApi.list.mockResolvedValue([
|
||||||
|
makeThread({ id: "open-1" }),
|
||||||
|
makeThread({ id: "resolved-1", status: "resolved" }),
|
||||||
|
]);
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Open filter shows only open
|
||||||
|
expect(container.querySelector('[data-thread-id="open-1"]')).not.toBeNull();
|
||||||
|
expect(container.querySelector('[data-thread-id="resolved-1"]')).toBeNull();
|
||||||
|
|
||||||
|
// Switch to 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();
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-thread-id="resolved-1"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders author name + role from agent and user maps", async () => {
|
||||||
|
mockAnnotationsApi.list.mockResolvedValue([
|
||||||
|
makeThread({
|
||||||
|
id: "open-1",
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
id: "comment-board",
|
||||||
|
companyId: "co-1",
|
||||||
|
threadId: "open-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
documentId: "doc-1",
|
||||||
|
body: "From the board.",
|
||||||
|
authorType: "user",
|
||||||
|
authorAgentId: null,
|
||||||
|
authorUserId: "user-1",
|
||||||
|
createdByRunId: null,
|
||||||
|
createdAt: new Date("2026-04-01T00:01:00Z"),
|
||||||
|
updatedAt: new Date("2026-04-01T00:01:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "comment-agent",
|
||||||
|
companyId: "co-1",
|
||||||
|
threadId: "open-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
documentId: "doc-1",
|
||||||
|
body: "From the agent.",
|
||||||
|
authorType: "agent",
|
||||||
|
authorAgentId: "agent-uxdesigner",
|
||||||
|
authorUserId: null,
|
||||||
|
createdByRunId: "run-1",
|
||||||
|
createdAt: new Date("2026-04-01T00:02:00Z"),
|
||||||
|
updatedAt: new Date("2026-04-01T00:02:00Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = makeQueryClient();
|
||||||
|
const doc = makeDoc();
|
||||||
|
|
||||||
|
const agentMap = new Map([["agent-uxdesigner", { id: "agent-uxdesigner", name: "UXDesigner" }]]);
|
||||||
|
const userProfileMap = new Map([["user-1", { label: "Dotta", image: null }]]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<DocumentAnnotationsCountChip
|
||||||
|
issueId="issue-1"
|
||||||
|
docKey={doc.key}
|
||||||
|
panelOpen
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>
|
||||||
|
<IssueDocumentAnnotations
|
||||||
|
issueId="issue-1"
|
||||||
|
doc={doc}
|
||||||
|
bodyMarkdown={doc.body}
|
||||||
|
draftDirty={false}
|
||||||
|
draftConflicted={false}
|
||||||
|
historicalPreview={false}
|
||||||
|
locationHash=""
|
||||||
|
panelOpen
|
||||||
|
onPanelOpenChange={() => {}}
|
||||||
|
agentMap={agentMap}
|
||||||
|
userProfileMap={userProfileMap}
|
||||||
|
>
|
||||||
|
<p>Body</p>
|
||||||
|
</IssueDocumentAnnotations>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Click the open thread to expand it.
|
||||||
|
const threadCard = container.querySelector('[data-thread-id="open-1"]') as HTMLElement | null;
|
||||||
|
expect(threadCard).not.toBeNull();
|
||||||
|
await act(async () => threadCard!.click());
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const expandedText = container.querySelector('[data-thread-id="open-1"]')?.textContent ?? "";
|
||||||
|
expect(expandedText).toContain("Dotta");
|
||||||
|
expect(expandedText).not.toContain("· board");
|
||||||
|
expect(expandedText).toContain("UXDesigner");
|
||||||
|
expect(expandedText).toContain("· agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render a persistent New comment on selection hint when panel is open", async () => {
|
||||||
|
mockAnnotationsApi.list.mockResolvedValue([]);
|
||||||
|
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 cta = container.querySelector('[data-testid="document-annotation-new-comment-cta"]');
|
||||||
|
expect(cta).toBeNull();
|
||||||
|
expect(container.textContent).not.toMatch(/New comment on selection/i);
|
||||||
|
expect(container.textContent).not.toMatch(/⌘⇧M/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a captured selection from opening the composer until the layer requests a comment", async () => {
|
||||||
|
mockAnnotationsApi.list.mockResolvedValue([]);
|
||||||
|
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 selectOnlyButton = container.querySelector(
|
||||||
|
'[data-testid="mock-annotation-selection-only"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(selectOnlyButton).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
selectOnlyButton!.click();
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="document-annotation-composer"]')).toBeNull();
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="document-annotation-new-comment-cta"]')).toBeNull();
|
||||||
|
const directRequestButton = container.querySelector(
|
||||||
|
'[data-testid="mock-annotation-selection"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(directRequestButton).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
directRequestButton!.click();
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const composer = container.querySelector(
|
||||||
|
'[data-testid="document-annotation-composer"]',
|
||||||
|
) as HTMLTextAreaElement | null;
|
||||||
|
expect(composer).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain(mockPendingAnchor.selectedText);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a thread from a captured selection and refreshes the shared annotations query", 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();
|
||||||
|
expect(mockAnnotationsApi.list).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
expect(mockAnnotationsApi.create).toHaveBeenCalledWith("issue-1", "plan", {
|
||||||
|
baseRevisionId: "rev-4",
|
||||||
|
baseRevisionNumber: 4,
|
||||||
|
selector: mockPendingAnchor.selector,
|
||||||
|
body: "New anchored comment",
|
||||||
|
});
|
||||||
|
expect(mockAnnotationsApi.list.mock.calls.length).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows resolve and reopen actions and updates thread status", async () => {
|
||||||
|
mockAnnotationsApi.list.mockResolvedValue([
|
||||||
|
makeThread({ id: "open-1" }),
|
||||||
|
makeThread({ id: "resolved-1", status: "resolved" }),
|
||||||
|
]);
|
||||||
|
mockAnnotationsApi.updateStatus.mockResolvedValue(makeThread({ id: "open-1", status: "resolved" }));
|
||||||
|
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 resolveButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) => /\bResolve\b/.test(button.textContent ?? ""),
|
||||||
|
);
|
||||||
|
expect(resolveButton).not.toBeUndefined();
|
||||||
|
await act(async () => resolveButton!.click());
|
||||||
|
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();
|
||||||
|
|
||||||
|
const resolvedThread = container.querySelector('[data-thread-id="resolved-1"]') as HTMLElement | null;
|
||||||
|
expect(resolvedThread).not.toBeNull();
|
||||||
|
await act(async () => resolvedThread!.click());
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const reopenButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) => button.textContent?.includes("Reopen"),
|
||||||
|
);
|
||||||
|
expect(reopenButton).not.toBeUndefined();
|
||||||
|
await act(async () => reopenButton!.click());
|
||||||
|
await flush();
|
||||||
|
expect(mockAnnotationsApi.updateStatus).toHaveBeenCalledWith("issue-1", "plan", "resolved-1", "open");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the mobile annotation panel through the sheet path", async () => {
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: true,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = makeQueryClient();
|
||||||
|
const doc = makeDoc();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Harness doc={doc} initialPanelOpen />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const sheet = container.querySelector('[data-slot="sheet-content"]');
|
||||||
|
expect(sheet).not.toBeNull();
|
||||||
|
expect(sheet?.getAttribute("data-side")).toBe("bottom");
|
||||||
|
expect(sheet?.className).toContain("paperclip-doc-annotation-sheet");
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalMatchMedia,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
382
ui/src/components/IssueDocumentAnnotations.tsx
Normal file
382
ui/src/components/IssueDocumentAnnotations.tsx
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { Agent, DocumentAnnotationThreadWithComments, IssueDocument } from "@paperclipai/shared";
|
||||||
|
import { MessageSquare } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { documentAnnotationsApi } from "@/api/document-annotations";
|
||||||
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
|
import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
|
||||||
|
import { DocumentAnnotationLayer, type PendingAnchor } from "./DocumentAnnotationLayer";
|
||||||
|
import { DocumentAnnotationPanel } from "./DocumentAnnotationPanel";
|
||||||
|
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_VIEWPORT_MARGIN = 16;
|
||||||
|
|
||||||
|
export interface IssueDocumentAnnotationsProps {
|
||||||
|
issueId: string;
|
||||||
|
doc: IssueDocument;
|
||||||
|
/** The body that is being rendered/edited (current or historical revision). */
|
||||||
|
bodyMarkdown: string;
|
||||||
|
/** True when a draft has unsaved changes or is currently saving. */
|
||||||
|
draftDirty: boolean;
|
||||||
|
/** True when there is a remote conflict that requires user resolution. */
|
||||||
|
draftConflicted: boolean;
|
||||||
|
/** True when the document is being viewed in historical revision preview. */
|
||||||
|
historicalPreview: boolean;
|
||||||
|
/** Render the document body (rendered MarkdownBody or MarkdownEditor) inside the wrapper. */
|
||||||
|
children: ReactNode;
|
||||||
|
/** Current location hash so we can resolve deep-link targets. */
|
||||||
|
locationHash: string;
|
||||||
|
/** 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">>;
|
||||||
|
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||||
|
/** Seed which thread is focused on mount. Used by Storybook/screenshot harness. */
|
||||||
|
defaultFocusedThreadId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueDocumentAnnotations({
|
||||||
|
issueId,
|
||||||
|
doc,
|
||||||
|
bodyMarkdown,
|
||||||
|
draftDirty,
|
||||||
|
draftConflicted,
|
||||||
|
historicalPreview,
|
||||||
|
children,
|
||||||
|
locationHash,
|
||||||
|
panelOpen,
|
||||||
|
onPanelOpenChange,
|
||||||
|
agentMap,
|
||||||
|
userProfileMap,
|
||||||
|
defaultFocusedThreadId,
|
||||||
|
}: IssueDocumentAnnotationsProps) {
|
||||||
|
const containerRef = useRef<HTMLElement | null>(null);
|
||||||
|
const [focusedThreadId, setFocusedThreadId] = useState<string | null>(defaultFocusedThreadId ?? null);
|
||||||
|
const [focusedCommentId, setFocusedCommentId] = useState<string | null>(null);
|
||||||
|
const [selectionAnchor, setSelectionAnchor] = useState<PendingAnchor | null>(null);
|
||||||
|
const [composerAnchor, setComposerAnchor] = useState<PendingAnchor | null>(null);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [desktopPanelFrame, setDesktopPanelFrame] = useState<{
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
maxHeight: number;
|
||||||
|
width: number;
|
||||||
|
} | null>(null);
|
||||||
|
const hashHandledRef = useRef<string | null>(null);
|
||||||
|
// Bus token to ask the body layer to capture the current selection into a pendingAnchor.
|
||||||
|
const [captureSelectionRequestId, setCaptureSelectionRequestId] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||||
|
const mediaQuery = window.matchMedia("(max-width: 1023px)");
|
||||||
|
const handler = () => setIsMobile(mediaQuery.matches);
|
||||||
|
handler();
|
||||||
|
if (typeof mediaQuery.addEventListener === "function") {
|
||||||
|
mediaQuery.addEventListener("change", handler);
|
||||||
|
return () => mediaQuery.removeEventListener("change", handler);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!panelOpen || isMobile || typeof window === "undefined") {
|
||||||
|
setDesktopPanelFrame(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePanelFrame = () => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const rect = container?.getBoundingClientRect();
|
||||||
|
if (!container || !rect) {
|
||||||
|
setDesktopPanelFrame(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const boundaryRect = container.closest("main")?.getBoundingClientRect();
|
||||||
|
const boundaryLeft = boundaryRect?.left ?? 0;
|
||||||
|
const boundaryRight = boundaryRect?.right ?? window.innerWidth;
|
||||||
|
const boundaryWidth = Math.max(0, boundaryRight - boundaryLeft);
|
||||||
|
const maxPanelWidth = Math.max(
|
||||||
|
DESKTOP_ANNOTATION_PANEL_MIN_WIDTH,
|
||||||
|
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);
|
||||||
|
const desiredLeft = rect.right + DESKTOP_ANNOTATION_PANEL_GAP;
|
||||||
|
const spaceRightOfDocument = boundaryRight
|
||||||
|
- desiredLeft
|
||||||
|
- DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
|
||||||
|
const width = spaceRightOfDocument >= DESKTOP_ANNOTATION_PANEL_MIN_WIDTH
|
||||||
|
? Math.min(desiredWidth, spaceRightOfDocument)
|
||||||
|
: desiredWidth;
|
||||||
|
const maxVisibleLeft = boundaryRight
|
||||||
|
- width
|
||||||
|
- DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
|
||||||
|
setDesktopPanelFrame({
|
||||||
|
left: Math.max(
|
||||||
|
boundaryLeft + DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
|
||||||
|
Math.min(desiredLeft, maxVisibleLeft),
|
||||||
|
),
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
maxHeight: Math.max(240, window.innerHeight - top - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updatePanelFrame();
|
||||||
|
window.addEventListener("resize", updatePanelFrame);
|
||||||
|
window.addEventListener("scroll", updatePanelFrame, true);
|
||||||
|
const resizeObserver = typeof window.ResizeObserver === "function"
|
||||||
|
? new window.ResizeObserver(updatePanelFrame)
|
||||||
|
: null;
|
||||||
|
const observedContainer = containerRef.current;
|
||||||
|
if (resizeObserver && observedContainer) {
|
||||||
|
resizeObserver.observe(observedContainer);
|
||||||
|
const main = observedContainer.closest("main");
|
||||||
|
if (main) resizeObserver.observe(main);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", updatePanelFrame);
|
||||||
|
window.removeEventListener("scroll", updatePanelFrame, true);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
};
|
||||||
|
}, [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 }),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
const allThreads = annotationsQuery.data ?? [];
|
||||||
|
|
||||||
|
// Resolve deep link `#document-<key>&thread=...&comment=...` once per change.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!locationHash) return;
|
||||||
|
if (hashHandledRef.current === locationHash) return;
|
||||||
|
const target = parseDocumentAnnotationHash(locationHash);
|
||||||
|
if (!target || target.documentKey !== doc.key) return;
|
||||||
|
if (!target.threadId) return;
|
||||||
|
hashHandledRef.current = locationHash;
|
||||||
|
onPanelOpenChange(true);
|
||||||
|
setFocusedThreadId(target.threadId);
|
||||||
|
setFocusedCommentId(target.commentId);
|
||||||
|
}, [doc.key, locationHash, onPanelOpenChange]);
|
||||||
|
|
||||||
|
const newCommentDisabled = draftDirty || draftConflicted || historicalPreview || !doc.latestRevisionId;
|
||||||
|
const newCommentDisabledReason = historicalPreview
|
||||||
|
? "New comments are disabled while previewing a historical revision."
|
||||||
|
: draftConflicted
|
||||||
|
? "Resolve the document conflict before adding new comments."
|
||||||
|
: draftDirty
|
||||||
|
? "Save the draft to anchor new comments."
|
||||||
|
: !doc.latestRevisionId
|
||||||
|
? "Document has no saved revision yet."
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleSelectionAnchorChange = useCallback((anchor: PendingAnchor | null) => {
|
||||||
|
setSelectionAnchor(anchor);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearComposerAnchor = useCallback(() => {
|
||||||
|
setSelectionAnchor(null);
|
||||||
|
setComposerAnchor(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRequestComment = useCallback((anchor: PendingAnchor) => {
|
||||||
|
if (newCommentDisabled) return;
|
||||||
|
setSelectionAnchor(null);
|
||||||
|
setComposerAnchor(anchor);
|
||||||
|
onPanelOpenChange(true);
|
||||||
|
}, [newCommentDisabled, onPanelOpenChange]);
|
||||||
|
|
||||||
|
const handleThreadFocus = useCallback((threadId: string | null) => {
|
||||||
|
setFocusedThreadId(threadId);
|
||||||
|
if (threadId) {
|
||||||
|
onPanelOpenChange(true);
|
||||||
|
setFocusedCommentId(null);
|
||||||
|
}
|
||||||
|
}, [onPanelOpenChange]);
|
||||||
|
|
||||||
|
const handleRequestCommentFromSelection = useCallback(() => {
|
||||||
|
if (newCommentDisabled) return;
|
||||||
|
if (selectionAnchor) {
|
||||||
|
handleRequestComment(selectionAnchor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Trigger the layer to re-read the current selection and emit a pendingAnchor.
|
||||||
|
setCaptureSelectionRequestId((current) => current + 1);
|
||||||
|
}, [handleRequestComment, newCommentDisabled, selectionAnchor]);
|
||||||
|
|
||||||
|
// ⌘⇧M / Ctrl+Shift+M global shortcut while the panel is open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!panelOpen) return;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.defaultPrevented) return;
|
||||||
|
const isMeta = event.metaKey || event.ctrlKey;
|
||||||
|
if (!isMeta || !event.shiftKey) return;
|
||||||
|
if (event.key.toLowerCase() !== "m") return;
|
||||||
|
event.preventDefault();
|
||||||
|
handleRequestCommentFromSelection();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [panelOpen, handleRequestCommentFromSelection]);
|
||||||
|
|
||||||
|
const focusedThread = useMemo(() => {
|
||||||
|
if (!focusedThreadId) return null;
|
||||||
|
return allThreads.find((thread) => thread.id === focusedThreadId) ?? null;
|
||||||
|
}, [allThreads, focusedThreadId]);
|
||||||
|
|
||||||
|
const overlayThreads = useMemo(
|
||||||
|
() => allThreads.map((thread) => ({
|
||||||
|
id: thread.id,
|
||||||
|
selectedText: thread.selectedText,
|
||||||
|
status: thread.status,
|
||||||
|
anchorState: thread.anchorState,
|
||||||
|
})),
|
||||||
|
[allThreads],
|
||||||
|
);
|
||||||
|
|
||||||
|
const annotationPanel = panelOpen ? (
|
||||||
|
<DocumentAnnotationPanel
|
||||||
|
open={panelOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
onPanelOpenChange(open);
|
||||||
|
if (!open) {
|
||||||
|
setSelectionAnchor(null);
|
||||||
|
setComposerAnchor(null);
|
||||||
|
setFocusedThreadId(null);
|
||||||
|
setFocusedCommentId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
issueId={issueId}
|
||||||
|
documentKey={doc.key}
|
||||||
|
documentRevisionNumber={doc.latestRevisionNumber}
|
||||||
|
baseRevisionId={doc.latestRevisionId}
|
||||||
|
baseRevisionNumber={doc.latestRevisionNumber}
|
||||||
|
threads={allThreads as DocumentAnnotationThreadWithComments[]}
|
||||||
|
focusedThreadId={focusedThreadId}
|
||||||
|
focusedCommentId={focusedCommentId}
|
||||||
|
onFocusThread={(id) => {
|
||||||
|
setFocusedThreadId(id);
|
||||||
|
if (!id) setFocusedCommentId(null);
|
||||||
|
}}
|
||||||
|
pendingAnchor={composerAnchor}
|
||||||
|
onClearPendingAnchor={handleClearComposerAnchor}
|
||||||
|
onRequestCommentFromSelection={handleRequestCommentFromSelection}
|
||||||
|
newCommentDisabled={newCommentDisabled}
|
||||||
|
newCommentDisabledReason={newCommentDisabledReason}
|
||||||
|
isMobile={isMobile}
|
||||||
|
desktopWidth={desktopPanelFrame?.width}
|
||||||
|
agentMap={agentMap}
|
||||||
|
userProfileMap={userProfileMap}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="paperclip-doc-annotation-host relative">
|
||||||
|
<section
|
||||||
|
ref={(element) => {
|
||||||
|
containerRef.current = element;
|
||||||
|
}}
|
||||||
|
className="relative min-w-0"
|
||||||
|
data-testid={`document-annotation-body-${doc.key}`}
|
||||||
|
>
|
||||||
|
<div className="relative z-[1]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{!historicalPreview && doc.latestRevisionId ? (
|
||||||
|
<DocumentAnnotationLayer
|
||||||
|
containerRef={containerRef}
|
||||||
|
markdown={bodyMarkdown}
|
||||||
|
threads={overlayThreads}
|
||||||
|
focusedThreadId={focusedThread?.id ?? null}
|
||||||
|
onThreadFocus={handleThreadFocus}
|
||||||
|
pendingAnchor={selectionAnchor}
|
||||||
|
onPendingAnchorChange={handleSelectionAnchorChange}
|
||||||
|
onRequestComment={handleRequestComment}
|
||||||
|
newCommentDisabled={newCommentDisabled}
|
||||||
|
newCommentDisabledReason={newCommentDisabledReason}
|
||||||
|
hideResolved
|
||||||
|
captureSelectionRequestId={captureSelectionRequestId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
{panelOpen && !isMobile && desktopPanelFrame ? (
|
||||||
|
<div
|
||||||
|
data-testid="document-annotation-panel-anchor"
|
||||||
|
className="pointer-events-auto fixed hidden lg:block"
|
||||||
|
style={{
|
||||||
|
left: desktopPanelFrame.left,
|
||||||
|
maxHeight: desktopPanelFrame.maxHeight,
|
||||||
|
top: desktopPanelFrame.top,
|
||||||
|
width: desktopPanelFrame.width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{annotationPanel}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{panelOpen && isMobile ? annotationPanel : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentAnnotationsCountChipProps {
|
||||||
|
issueId: string;
|
||||||
|
docKey: string;
|
||||||
|
panelOpen: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the unresolved-count chip for a document. Lives in the document header row
|
||||||
|
* (next to `rev N ▾`) so it stays visible when the document is folded.
|
||||||
|
*/
|
||||||
|
export function DocumentAnnotationsCountChip({
|
||||||
|
issueId,
|
||||||
|
docKey,
|
||||||
|
panelOpen,
|
||||||
|
onToggle,
|
||||||
|
}: DocumentAnnotationsCountChipProps) {
|
||||||
|
const annotationsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.issues.documentAnnotations(issueId, docKey, "all"),
|
||||||
|
queryFn: () => documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
const threads = annotationsQuery.data ?? [];
|
||||||
|
const openCount = useMemo(
|
||||||
|
() => threads.filter((thread) => thread.status === "open" && thread.anchorState !== "orphaned").length,
|
||||||
|
[threads],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
data-state={panelOpen ? "open" : "closed"}
|
||||||
|
className={cn(
|
||||||
|
"h-auto gap-1 rounded-md px-1.5 py-0 text-[11px] font-normal text-muted-foreground hover:text-foreground",
|
||||||
|
panelOpen && "bg-muted text-foreground",
|
||||||
|
openCount > 0 && "text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={onToggle}
|
||||||
|
data-testid={`document-annotation-count-${docKey}`}
|
||||||
|
aria-label={openCount === 0
|
||||||
|
? `Open comments on ${docKey}`
|
||||||
|
: `Open ${openCount} unresolved comments on ${docKey}`}
|
||||||
|
aria-expanded={panelOpen}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-3 w-3" aria-hidden="true" />
|
||||||
|
<span className="tabular-nums">{openCount}</span>
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{openCount === 1 ? "comment" : "comments"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type {
|
import type {
|
||||||
|
Agent,
|
||||||
DocumentRevision,
|
DocumentRevision,
|
||||||
FeedbackDataSharingPreference,
|
FeedbackDataSharingPreference,
|
||||||
FeedbackVote,
|
FeedbackVote,
|
||||||
|
|
@ -14,9 +15,11 @@ import { ApiError } from "../api/client";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||||
import { deriveDocumentRevisionState } from "../lib/document-revisions";
|
import { deriveDocumentRevisionState } from "../lib/document-revisions";
|
||||||
|
import type { CompanyUserProfile } from "../lib/company-members";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn, relativeTime } from "../lib/utils";
|
import { cn, relativeTime } from "../lib/utils";
|
||||||
import { FoldCurtain } from "./FoldCurtain";
|
import { FoldCurtain } from "./FoldCurtain";
|
||||||
|
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
|
||||||
import { MarkdownBody } from "./MarkdownBody";
|
import { MarkdownBody } from "./MarkdownBody";
|
||||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||||
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
||||||
|
|
@ -151,6 +154,11 @@ export function IssueDocumentsSection({
|
||||||
imageUploadHandler,
|
imageUploadHandler,
|
||||||
onVote,
|
onVote,
|
||||||
extraActions,
|
extraActions,
|
||||||
|
agentMap,
|
||||||
|
userProfileMap,
|
||||||
|
defaultAnnotationPanelOpenKeys,
|
||||||
|
defaultAnnotationFocusedThreadIds,
|
||||||
|
forceEditDocumentKey,
|
||||||
}: {
|
}: {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
canDeleteDocuments: boolean;
|
canDeleteDocuments: boolean;
|
||||||
|
|
@ -166,6 +174,17 @@ export function IssueDocumentsSection({
|
||||||
options?: { allowSharing?: boolean; reason?: string },
|
options?: { allowSharing?: boolean; reason?: string },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
extraActions?: ReactNode;
|
extraActions?: ReactNode;
|
||||||
|
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||||
|
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||||
|
/**
|
||||||
|
* Seed which document annotation panels are open on first render. Mostly useful
|
||||||
|
* for Storybook / screenshot harnesses; runtime callers usually omit this.
|
||||||
|
*/
|
||||||
|
defaultAnnotationPanelOpenKeys?: string[];
|
||||||
|
/** Per-doc seed for the focused annotation thread id (Storybook-only). */
|
||||||
|
defaultAnnotationFocusedThreadIds?: Readonly<Record<string, string>>;
|
||||||
|
/** Force a doc into edit mode on mount (Storybook-only). */
|
||||||
|
forceEditDocumentKey?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
@ -174,6 +193,9 @@ export function IssueDocumentsSection({
|
||||||
const [draft, setDraft] = useState<DraftState | null>(null);
|
const [draft, setDraft] = useState<DraftState | null>(null);
|
||||||
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
|
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
|
||||||
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(issue.id));
|
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(issue.id));
|
||||||
|
const [annotationPanelOpenKeys, setAnnotationPanelOpenKeys] = useState<string[]>(
|
||||||
|
() => (defaultAnnotationPanelOpenKeys ?? []),
|
||||||
|
);
|
||||||
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
|
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
|
||||||
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
|
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
|
||||||
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
||||||
|
|
@ -213,8 +235,10 @@ export function IssueDocumentsSection({
|
||||||
predicate: (query) =>
|
predicate: (query) =>
|
||||||
Array.isArray(query.queryKey)
|
Array.isArray(query.queryKey)
|
||||||
&& query.queryKey[0] === "issues"
|
&& query.queryKey[0] === "issues"
|
||||||
&& query.queryKey[1] === "document-revisions"
|
&& (
|
||||||
&& query.queryKey[2] === issue.id,
|
(query.queryKey[1] === "document-revisions" && query.queryKey[2] === issue.id)
|
||||||
|
|| (query.queryKey[1] === "document-annotations" && query.queryKey[2] === issue.id)
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}, [issue.id, queryClient]);
|
}, [issue.id, queryClient]);
|
||||||
|
|
||||||
|
|
@ -368,6 +392,17 @@ export function IssueDocumentsSection({
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initialEditAppliedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!forceEditDocumentKey) return;
|
||||||
|
if (initialEditAppliedRef.current) return;
|
||||||
|
const target = (documents ?? []).find((entry) => entry.key === forceEditDocumentKey);
|
||||||
|
if (!target) return;
|
||||||
|
initialEditAppliedRef.current = true;
|
||||||
|
beginEdit(forceEditDocumentKey);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [forceEditDocumentKey, documents]);
|
||||||
|
|
||||||
const cancelDraft = () => {
|
const cancelDraft = () => {
|
||||||
if (autosaveDebounceRef.current) {
|
if (autosaveDebounceRef.current) {
|
||||||
clearTimeout(autosaveDebounceRef.current);
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
|
@ -726,6 +761,24 @@ export function IssueDocumentsSection({
|
||||||
: [...current, key],
|
: [...current, key],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const setAnnotationPanelOpen = useCallback((key: string, nextOpen: boolean) => {
|
||||||
|
setAnnotationPanelOpenKeys((current) => {
|
||||||
|
const isOpen = current.includes(key);
|
||||||
|
if (nextOpen && !isOpen) return [...current, key];
|
||||||
|
if (!nextOpen && isOpen) return current.filter((entry) => entry !== key);
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
if (nextOpen) {
|
||||||
|
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const toggleAnnotationPanel = useCallback((key: string) => {
|
||||||
|
setAnnotationPanelOpenKeys((current) => {
|
||||||
|
if (current.includes(key)) return current.filter((entry) => entry !== key);
|
||||||
|
setFoldedDocumentKeys((folded) => folded.filter((entry) => entry !== key));
|
||||||
|
return [...current, key];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -936,6 +989,14 @@ export function IssueDocumentsSection({
|
||||||
>
|
>
|
||||||
updated {relativeTime(displayedUpdatedAt)}
|
updated {relativeTime(displayedUpdatedAt)}
|
||||||
</a>
|
</a>
|
||||||
|
{!isSystemIssueDocumentKey(doc.key) ? (
|
||||||
|
<DocumentAnnotationsCountChip
|
||||||
|
issueId={issue.id}
|
||||||
|
docKey={doc.key}
|
||||||
|
panelOpen={annotationPanelOpenKeys.includes(doc.key)}
|
||||||
|
onToggle={() => toggleAnnotationPanel(doc.key)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{showTitle && <p className="mt-2 text-sm font-medium">{displayedTitle}</p>}
|
{showTitle && <p className="mt-2 text-sm font-medium">{displayedTitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1153,31 +1214,49 @@ export function IssueDocumentsSection({
|
||||||
activeDraft || isHistoricalPreview ? "" : "rounded-md hover:bg-accent/10"
|
activeDraft || isHistoricalPreview ? "" : "rounded-md hover:bg-accent/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isHistoricalPreview ? (
|
<IssueDocumentAnnotations
|
||||||
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
issueId={issue.id}
|
||||||
) : activeDraft ? (
|
doc={doc}
|
||||||
<MarkdownEditor
|
bodyMarkdown={displayedBody}
|
||||||
value={displayedBody}
|
draftDirty={Boolean(activeDraft) && (
|
||||||
onChange={(body) => {
|
(activeDraft?.body ?? doc.body) !== doc.body
|
||||||
markDocumentDirty(doc.key);
|
|| (autosaveDocumentKey === doc.key && autosaveState === "saving")
|
||||||
setDraft((current) => {
|
)}
|
||||||
if (current && current.key === doc.key && !current.isNew) {
|
draftConflicted={Boolean(activeConflict)}
|
||||||
return { ...current, body };
|
historicalPreview={isHistoricalPreview}
|
||||||
}
|
locationHash={location.hash}
|
||||||
return current;
|
panelOpen={annotationPanelOpenKeys.includes(doc.key)}
|
||||||
});
|
onPanelOpenChange={(next) => setAnnotationPanelOpen(doc.key, next)}
|
||||||
}}
|
agentMap={agentMap}
|
||||||
placeholder="Markdown body"
|
userProfileMap={userProfileMap}
|
||||||
bordered={false}
|
defaultFocusedThreadId={defaultAnnotationFocusedThreadIds?.[doc.key]}
|
||||||
className="bg-transparent"
|
>
|
||||||
contentClassName={documentBodyContentClassName}
|
{isHistoricalPreview ? (
|
||||||
mentions={mentions}
|
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
||||||
imageUploadHandler={imageUploadHandler}
|
) : activeDraft ? (
|
||||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
<MarkdownEditor
|
||||||
/>
|
value={displayedBody}
|
||||||
) : (
|
onChange={(body) => {
|
||||||
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
markDocumentDirty(doc.key);
|
||||||
)}
|
setDraft((current) => {
|
||||||
|
if (current && current.key === doc.key && !current.isNew) {
|
||||||
|
return { ...current, body };
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="Markdown body"
|
||||||
|
bordered={false}
|
||||||
|
className="bg-transparent"
|
||||||
|
contentClassName={documentBodyContentClassName}
|
||||||
|
mentions={mentions}
|
||||||
|
imageUploadHandler={imageUploadHandler}
|
||||||
|
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
||||||
|
)}
|
||||||
|
</IssueDocumentAnnotations>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-4 items-center justify-end px-1">
|
<div className="flex min-h-4 items-center justify-end px-1">
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,10 @@
|
||||||
--chip-match-identifier-bg: var(--muted);
|
--chip-match-identifier-bg: var(--muted);
|
||||||
--chip-match-identifier-fg: var(--muted-foreground);
|
--chip-match-identifier-fg: var(--muted-foreground);
|
||||||
--chip-match-identifier-border: var(--border);
|
--chip-match-identifier-border: var(--border);
|
||||||
|
--paperclip-doc-annotation-highlight-open: #fef08a;
|
||||||
|
--paperclip-doc-annotation-highlight-focused: #fde047;
|
||||||
|
--paperclip-doc-annotation-highlight-stale: #fef08a;
|
||||||
|
--paperclip-doc-annotation-highlight-resolved: #fef9c3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
|
@ -136,6 +140,30 @@
|
||||||
--chip-match-identifier-bg: var(--muted);
|
--chip-match-identifier-bg: var(--muted);
|
||||||
--chip-match-identifier-fg: var(--muted-foreground);
|
--chip-match-identifier-fg: var(--muted-foreground);
|
||||||
--chip-match-identifier-border: var(--border);
|
--chip-match-identifier-border: var(--border);
|
||||||
|
--paperclip-doc-annotation-highlight-open: #a16207;
|
||||||
|
--paperclip-doc-annotation-highlight-focused: #ca8a04;
|
||||||
|
--paperclip-doc-annotation-highlight-stale: #854d0e;
|
||||||
|
--paperclip-doc-annotation-highlight-resolved: #713f12;
|
||||||
|
}
|
||||||
|
|
||||||
|
::highlight(paperclip-doc-annotation-open) {
|
||||||
|
background-color: var(--paperclip-doc-annotation-highlight-open);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::highlight(paperclip-doc-annotation-focused) {
|
||||||
|
background-color: var(--paperclip-doc-annotation-highlight-focused);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::highlight(paperclip-doc-annotation-stale) {
|
||||||
|
background-color: var(--paperclip-doc-annotation-highlight-stale);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::highlight(paperclip-doc-annotation-resolved) {
|
||||||
|
background-color: var(--paperclip-doc-annotation-highlight-resolved);
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|
|
||||||
63
ui/src/lib/document-annotation-hash.test.ts
Normal file
63
ui/src/lib/document-annotation-hash.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildDocumentAnnotationHash,
|
||||||
|
parseDocumentAnnotationHash,
|
||||||
|
} from "./document-annotation-hash";
|
||||||
|
|
||||||
|
describe("parseDocumentAnnotationHash", () => {
|
||||||
|
it("returns null for non-document hashes", () => {
|
||||||
|
expect(parseDocumentAnnotationHash("")).toBeNull();
|
||||||
|
expect(parseDocumentAnnotationHash("#issue-foo")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses document key only", () => {
|
||||||
|
expect(parseDocumentAnnotationHash("#document-plan")).toEqual({
|
||||||
|
documentKey: "plan",
|
||||||
|
threadId: null,
|
||||||
|
commentId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses thread and comment targets", () => {
|
||||||
|
expect(
|
||||||
|
parseDocumentAnnotationHash("#document-plan&thread=t1&comment=c2"),
|
||||||
|
).toEqual({
|
||||||
|
documentKey: "plan",
|
||||||
|
threadId: "t1",
|
||||||
|
commentId: "c2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes URI-encoded keys", () => {
|
||||||
|
expect(parseDocumentAnnotationHash("#document-my%20notes&thread=abc")).toEqual({
|
||||||
|
documentKey: "my notes",
|
||||||
|
threadId: "abc",
|
||||||
|
commentId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildDocumentAnnotationHash", () => {
|
||||||
|
it("builds a hash without thread or comment", () => {
|
||||||
|
expect(buildDocumentAnnotationHash({ documentKey: "plan", threadId: null, commentId: null })).toBe(
|
||||||
|
"#document-plan",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes thread target", () => {
|
||||||
|
expect(
|
||||||
|
buildDocumentAnnotationHash({ documentKey: "plan", threadId: "t1", commentId: null }),
|
||||||
|
).toBe("#document-plan&thread=t1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes both targets", () => {
|
||||||
|
expect(
|
||||||
|
buildDocumentAnnotationHash({ documentKey: "plan", threadId: "t1", commentId: "c2" }),
|
||||||
|
).toBe("#document-plan&thread=t1&comment=c2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a round trip", () => {
|
||||||
|
const target = { documentKey: "plan-2", threadId: "t-abc", commentId: "c-xyz" };
|
||||||
|
expect(parseDocumentAnnotationHash(buildDocumentAnnotationHash(target))).toEqual(target);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
ui/src/lib/document-annotation-hash.ts
Normal file
32
ui/src/lib/document-annotation-hash.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
export interface DocumentAnnotationHashTarget {
|
||||||
|
documentKey: string;
|
||||||
|
threadId: string | null;
|
||||||
|
commentId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOCUMENT_HASH_PREFIX = "#document-";
|
||||||
|
|
||||||
|
export function parseDocumentAnnotationHash(hash: string): DocumentAnnotationHashTarget | null {
|
||||||
|
if (!hash.startsWith(DOCUMENT_HASH_PREFIX)) return null;
|
||||||
|
const stripped = hash.slice(DOCUMENT_HASH_PREFIX.length);
|
||||||
|
const [rawKey, ...rest] = stripped.split("&");
|
||||||
|
if (!rawKey) return null;
|
||||||
|
const documentKey = decodeURIComponent(rawKey);
|
||||||
|
const params = new URLSearchParams(rest.join("&"));
|
||||||
|
const threadId = params.get("thread");
|
||||||
|
const commentId = params.get("comment");
|
||||||
|
return {
|
||||||
|
documentKey,
|
||||||
|
threadId: threadId && threadId.length > 0 ? threadId : null,
|
||||||
|
commentId: commentId && commentId.length > 0 ? commentId : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDocumentAnnotationHash(target: DocumentAnnotationHashTarget): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (target.threadId) params.set("thread", target.threadId);
|
||||||
|
if (target.commentId) params.set("comment", target.commentId);
|
||||||
|
const qs = params.toString();
|
||||||
|
const encodedKey = encodeURIComponent(target.documentKey);
|
||||||
|
return qs ? `${DOCUMENT_HASH_PREFIX}${encodedKey}&${qs}` : `${DOCUMENT_HASH_PREFIX}${encodedKey}`;
|
||||||
|
}
|
||||||
118
ui/src/lib/document-annotation-selection.test.ts
Normal file
118
ui/src/lib/document-annotation-selection.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { verifyDocumentAnchorSelector } from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
buildAnchorFromContainerSelection,
|
||||||
|
getContainerTextOffset,
|
||||||
|
rangesForNormalizedSpan,
|
||||||
|
} from "./document-annotation-selection";
|
||||||
|
|
||||||
|
const MARKDOWN = `# Plan
|
||||||
|
|
||||||
|
We **should** keep the current markdown stack for the first version.
|
||||||
|
|
||||||
|
- Highlight a text segment in a plan document.
|
||||||
|
- Anchor comments without mutating markdown.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
The annotation feature is ready when the basic flow works.`;
|
||||||
|
|
||||||
|
const RENDERED_HTML = `
|
||||||
|
<div>
|
||||||
|
<h1>Plan</h1>
|
||||||
|
<p>We should keep the current markdown stack for the first version.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Highlight a text segment in a plan document.</li>
|
||||||
|
<li>Anchor comments without mutating markdown.</li>
|
||||||
|
</ul>
|
||||||
|
<h2>Acceptance</h2>
|
||||||
|
<p>The annotation feature is ready when the basic flow works.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
function makeContainer(): HTMLElement {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.innerHTML = RENDERED_HTML;
|
||||||
|
document.body.appendChild(div);
|
||||||
|
return div.firstElementChild as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectText(container: HTMLElement, needle: string): Range {
|
||||||
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
|
||||||
|
let node = walker.nextNode();
|
||||||
|
while (node) {
|
||||||
|
const data = (node as Text).data;
|
||||||
|
const index = data.indexOf(needle);
|
||||||
|
if (index !== -1) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(node, index);
|
||||||
|
range.setEnd(node, index + needle.length);
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
node = walker.nextNode();
|
||||||
|
}
|
||||||
|
throw new Error(`Could not find "${needle}" in container`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildAnchorFromContainerSelection", () => {
|
||||||
|
it("produces a selector that verifies against the same markdown", () => {
|
||||||
|
const container = makeContainer();
|
||||||
|
const range = selectText(container, "current markdown stack");
|
||||||
|
const offset = getContainerTextOffset(container, range);
|
||||||
|
expect(offset).not.toBeNull();
|
||||||
|
const anchor = buildAnchorFromContainerSelection({
|
||||||
|
markdown: MARKDOWN,
|
||||||
|
containerOffset: offset!,
|
||||||
|
});
|
||||||
|
expect(anchor).not.toBeNull();
|
||||||
|
const verified = verifyDocumentAnchorSelector({
|
||||||
|
markdown: MARKDOWN,
|
||||||
|
selector: anchor!.selector,
|
||||||
|
});
|
||||||
|
expect(verified.ok).toBe(true);
|
||||||
|
expect(verified.anchor?.selectedText).toBe("current markdown stack");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty selections", () => {
|
||||||
|
const container = makeContainer();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(container, 0);
|
||||||
|
range.setEnd(container, 0);
|
||||||
|
const offset = getContainerTextOffset(container, range);
|
||||||
|
expect(offset).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when selection is outside container", () => {
|
||||||
|
const container = makeContainer();
|
||||||
|
const outside = document.createElement("div");
|
||||||
|
outside.textContent = "outside";
|
||||||
|
document.body.appendChild(outside);
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(outside);
|
||||||
|
const offset = getContainerTextOffset(container, range);
|
||||||
|
expect(offset).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rangesForNormalizedSpan", () => {
|
||||||
|
it("walks DOM text nodes to find span ranges", () => {
|
||||||
|
const container = makeContainer();
|
||||||
|
const ranges = rangesForNormalizedSpan({
|
||||||
|
container,
|
||||||
|
selectedText: "Highlight a text segment",
|
||||||
|
});
|
||||||
|
expect(ranges.length).toBeGreaterThan(0);
|
||||||
|
const merged = ranges.map((range) => range.toString()).join("");
|
||||||
|
expect(merged.replace(/\s+/g, " ")).toContain("Highlight a text segment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array if selected text is missing", () => {
|
||||||
|
const container = makeContainer();
|
||||||
|
const ranges = rangesForNormalizedSpan({
|
||||||
|
container,
|
||||||
|
selectedText: "this string does not exist in the document",
|
||||||
|
});
|
||||||
|
expect(ranges).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
202
ui/src/lib/document-annotation-selection.ts
Normal file
202
ui/src/lib/document-annotation-selection.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import {
|
||||||
|
createDocumentAnchorSelector,
|
||||||
|
normalizeAnchorText,
|
||||||
|
projectMarkdownToText,
|
||||||
|
resolveProjectionRange,
|
||||||
|
type DocumentAnnotationAnchorSelector,
|
||||||
|
type DocumentTextProjection,
|
||||||
|
type DocumentTextRange,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
|
||||||
|
export interface ContainerTextOffset {
|
||||||
|
/** Byte offset of the selection start within the flattened container text. */
|
||||||
|
startOffset: number;
|
||||||
|
/** Byte offset of the selection end within the flattened container text. */
|
||||||
|
endOffset: number;
|
||||||
|
/** Raw flattened text content of the container. */
|
||||||
|
containerText: string;
|
||||||
|
/** Raw text inside the selection. */
|
||||||
|
selectedText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContainerTextOffset(
|
||||||
|
container: HTMLElement,
|
||||||
|
range: Range,
|
||||||
|
): ContainerTextOffset | null {
|
||||||
|
if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const preRange = document.createRange();
|
||||||
|
preRange.selectNodeContents(container);
|
||||||
|
preRange.setEnd(range.startContainer, range.startOffset);
|
||||||
|
const startOffset = preRange.toString().length;
|
||||||
|
preRange.setEnd(range.endContainer, range.endOffset);
|
||||||
|
const endOffset = preRange.toString().length;
|
||||||
|
if (endOffset <= startOffset) return null;
|
||||||
|
return {
|
||||||
|
startOffset,
|
||||||
|
endOffset,
|
||||||
|
containerText: container.textContent ?? "",
|
||||||
|
selectedText: range.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionAnchorResult {
|
||||||
|
selector: DocumentAnnotationAnchorSelector;
|
||||||
|
range: DocumentTextRange;
|
||||||
|
projection: DocumentTextProjection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAnchorFromContainerSelection(input: {
|
||||||
|
markdown: string;
|
||||||
|
containerOffset: ContainerTextOffset;
|
||||||
|
}): SelectionAnchorResult | null {
|
||||||
|
const projection = projectMarkdownToText(input.markdown);
|
||||||
|
const needle = normalizeAnchorText(input.containerOffset.selectedText);
|
||||||
|
if (!needle) return null;
|
||||||
|
|
||||||
|
const occurrences = findAllOccurrences(projection.text, needle);
|
||||||
|
if (occurrences.length === 0) return null;
|
||||||
|
|
||||||
|
const renderedTextLength = Math.max(1, normalizeAnchorText(input.containerOffset.containerText).length);
|
||||||
|
const renderedRatio = input.containerOffset.startOffset / renderedTextLength;
|
||||||
|
const projectionLength = Math.max(1, projection.text.length);
|
||||||
|
const expectedNormalized = Math.round(renderedRatio * projectionLength);
|
||||||
|
|
||||||
|
const best = pickClosestOccurrence(occurrences, expectedNormalized);
|
||||||
|
if (best == null) return null;
|
||||||
|
|
||||||
|
const normalizedStart = best;
|
||||||
|
const normalizedEnd = best + needle.length;
|
||||||
|
const range = resolveProjectionRange(projection, normalizedStart, normalizedEnd);
|
||||||
|
if (!range) return null;
|
||||||
|
if (normalizeAnchorText(range.text) !== needle) return null;
|
||||||
|
|
||||||
|
const selector = createDocumentAnchorSelector(projection, range);
|
||||||
|
return { selector, range, projection };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAllOccurrences(haystack: string, needle: string): number[] {
|
||||||
|
if (!needle) return [];
|
||||||
|
const out: number[] = [];
|
||||||
|
let cursor = haystack.indexOf(needle);
|
||||||
|
while (cursor !== -1) {
|
||||||
|
out.push(cursor);
|
||||||
|
cursor = haystack.indexOf(needle, cursor + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickClosestOccurrence(occurrences: number[], expected: number): number | null {
|
||||||
|
if (occurrences.length === 0) return null;
|
||||||
|
if (occurrences.length === 1) return occurrences[0] ?? null;
|
||||||
|
let best = occurrences[0] ?? 0;
|
||||||
|
let bestDistance = Math.abs(best - expected);
|
||||||
|
for (const candidate of occurrences) {
|
||||||
|
const distance = Math.abs(candidate - expected);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
best = candidate;
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk text nodes inside `container` and return a list of `Range`s that cover the
|
||||||
|
* normalized-text span `[normalizedStart, normalizedEnd)`. Each Range can be
|
||||||
|
* rectangle-projected to draw a highlight overlay.
|
||||||
|
*/
|
||||||
|
export function rangesForNormalizedSpan(input: {
|
||||||
|
container: HTMLElement;
|
||||||
|
selectedText: string;
|
||||||
|
}): Range[] {
|
||||||
|
const normalizedNeedle = normalizeAnchorText(input.selectedText);
|
||||||
|
if (!normalizedNeedle) return [];
|
||||||
|
const containerText = input.container.textContent ?? "";
|
||||||
|
const normalizedContainerText = normalizeAnchorText(containerText);
|
||||||
|
const containerOccurrenceIndex = normalizedContainerText.indexOf(normalizedNeedle);
|
||||||
|
if (containerOccurrenceIndex === -1) return [];
|
||||||
|
|
||||||
|
// Convert from normalized container offset back to raw container offset
|
||||||
|
// by walking the raw text and matching whitespace squashing.
|
||||||
|
const rawIndex = mapNormalizedOffsetToRaw(containerText, containerOccurrenceIndex);
|
||||||
|
if (rawIndex < 0) return [];
|
||||||
|
|
||||||
|
const rawNeedleLength = matchRawLengthForNormalized(
|
||||||
|
containerText.slice(rawIndex),
|
||||||
|
normalizedNeedle.length,
|
||||||
|
);
|
||||||
|
if (rawNeedleLength <= 0) return [];
|
||||||
|
|
||||||
|
const rawStart = rawIndex;
|
||||||
|
const rawEnd = rawIndex + rawNeedleLength;
|
||||||
|
return buildRangesForRawSpan(input.container, rawStart, rawEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapNormalizedOffsetToRaw(rawText: string, normalizedOffset: number): number {
|
||||||
|
let normalizedCursor = 0;
|
||||||
|
let lastWasWhitespace = true; // mimic trim() at start
|
||||||
|
for (let index = 0; index < rawText.length; index += 1) {
|
||||||
|
const char = rawText[index] ?? "";
|
||||||
|
if (/\s/.test(char)) {
|
||||||
|
if (!lastWasWhitespace) {
|
||||||
|
if (normalizedCursor === normalizedOffset) return index;
|
||||||
|
normalizedCursor += 1;
|
||||||
|
lastWasWhitespace = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (normalizedCursor === normalizedOffset) return index;
|
||||||
|
normalizedCursor += 1;
|
||||||
|
lastWasWhitespace = false;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchRawLengthForNormalized(rawTail: string, normalizedLength: number): number {
|
||||||
|
let normalizedCount = 0;
|
||||||
|
let lastWasWhitespace = false;
|
||||||
|
for (let index = 0; index < rawTail.length; index += 1) {
|
||||||
|
const char = rawTail[index] ?? "";
|
||||||
|
if (/\s/.test(char)) {
|
||||||
|
if (!lastWasWhitespace) {
|
||||||
|
normalizedCount += 1;
|
||||||
|
if (normalizedCount >= normalizedLength) return index;
|
||||||
|
lastWasWhitespace = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
normalizedCount += 1;
|
||||||
|
lastWasWhitespace = false;
|
||||||
|
if (normalizedCount >= normalizedLength) return index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rawTail.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRangesForRawSpan(container: HTMLElement, rawStart: number, rawEnd: number): Range[] {
|
||||||
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
|
||||||
|
const ranges: Range[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
let node: Node | null = walker.nextNode();
|
||||||
|
while (node) {
|
||||||
|
const textNode = node as Text;
|
||||||
|
const length = textNode.data.length;
|
||||||
|
const nodeStart = cursor;
|
||||||
|
const nodeEnd = cursor + length;
|
||||||
|
if (nodeEnd > rawStart && nodeStart < rawEnd) {
|
||||||
|
const startWithin = Math.max(0, rawStart - nodeStart);
|
||||||
|
const endWithin = Math.min(length, rawEnd - nodeStart);
|
||||||
|
if (endWithin > startWithin) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(textNode, startWithin);
|
||||||
|
range.setEnd(textNode, endWithin);
|
||||||
|
ranges.push(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor = nodeEnd;
|
||||||
|
if (cursor >= rawEnd) break;
|
||||||
|
node = walker.nextNode();
|
||||||
|
}
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,8 @@ export const queryKeys = {
|
||||||
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
||||||
document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const,
|
document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const,
|
||||||
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,
|
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,
|
||||||
|
documentAnnotations: (issueId: string, key: string, status: "open" | "resolved" | "all" = "all") =>
|
||||||
|
["issues", "document-annotations", issueId, key, status] as const,
|
||||||
activity: (issueId: string) => ["issues", "activity", issueId] as const,
|
activity: (issueId: string) => ["issues", "activity", issueId] as const,
|
||||||
runs: (issueId: string) => ["issues", "runs", issueId] as const,
|
runs: (issueId: string) => ["issues", "runs", issueId] as const,
|
||||||
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
|
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
|
||||||
|
|
|
||||||
|
|
@ -3736,6 +3736,8 @@ export function IssueDetail() {
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
extraActions={!hasAttachments ? attachmentUploadButton : null}
|
extraActions={!hasAttachments ? attachmentUploadButton : null}
|
||||||
|
agentMap={agentMap}
|
||||||
|
userProfileMap={userProfileMap}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{attachmentsInitialLoading ? (
|
{attachmentsInitialLoading ? (
|
||||||
|
|
|
||||||
571
ui/storybook/stories/document-annotations.stories.tsx
Normal file
571
ui/storybook/stories/document-annotations.stories.tsx
Normal file
|
|
@ -0,0 +1,571 @@
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type {
|
||||||
|
Agent,
|
||||||
|
DocumentAnnotationThreadWithComments,
|
||||||
|
Issue,
|
||||||
|
IssueDocument,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { DocumentAnnotationPanel } from "@/components/DocumentAnnotationPanel";
|
||||||
|
import { DocumentAnnotationLayer, type PendingAnchor } from "@/components/DocumentAnnotationLayer";
|
||||||
|
import {
|
||||||
|
DocumentAnnotationsCountChip,
|
||||||
|
IssueDocumentAnnotations,
|
||||||
|
} from "@/components/IssueDocumentAnnotations";
|
||||||
|
import { IssueDocumentsSection } from "@/components/IssueDocumentsSection";
|
||||||
|
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||||
|
import { MarkdownEditor } from "@/components/MarkdownEditor";
|
||||||
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
|
import type { CompanyUserProfile } from "@/lib/company-members";
|
||||||
|
|
||||||
|
const sampleMarkdown = `# Plan: Document Highlights And Comment Threads
|
||||||
|
|
||||||
|
We should **keep** the current markdown document stack for the first version.
|
||||||
|
The existing editor is MDXEditor on top of Lexical, and the current code already uses Lexical-level customization.
|
||||||
|
|
||||||
|
## Reader And Goal
|
||||||
|
|
||||||
|
Reader: board reviewer, CTO, and implementing engineers.
|
||||||
|
|
||||||
|
## Anchor Strategy
|
||||||
|
|
||||||
|
Do not insert comment markers into markdown. The markdown document body must
|
||||||
|
remain portable and readable.
|
||||||
|
|
||||||
|
Use a sidecar anchor made from two selectors:
|
||||||
|
|
||||||
|
- Text quote selector: exact selected text plus prefix/suffix context.
|
||||||
|
- Text position selector: normalized rendered-text offsets plus markdown source offsets.
|
||||||
|
|
||||||
|
## Future Work
|
||||||
|
|
||||||
|
Phase 5 covers QA validation across desktop and mobile.`;
|
||||||
|
|
||||||
|
function makeThread(
|
||||||
|
overrides: Partial<DocumentAnnotationThreadWithComments> = {},
|
||||||
|
): DocumentAnnotationThreadWithComments {
|
||||||
|
const id = overrides.id ?? "thread-1";
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
companyId: "co-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
documentId: "doc-1",
|
||||||
|
documentKey: "plan",
|
||||||
|
status: "open",
|
||||||
|
anchorState: "active",
|
||||||
|
anchorConfidence: "exact",
|
||||||
|
originalRevisionId: "rev-4",
|
||||||
|
originalRevisionNumber: 4,
|
||||||
|
currentRevisionId: "rev-4",
|
||||||
|
currentRevisionNumber: 4,
|
||||||
|
selectedText: "keep the current markdown document stack",
|
||||||
|
prefixText: "We should ",
|
||||||
|
suffixText: " for the first version",
|
||||||
|
normalizedStart: 0,
|
||||||
|
normalizedEnd: 40,
|
||||||
|
markdownStart: 0,
|
||||||
|
markdownEnd: 40,
|
||||||
|
anchorSelector: {
|
||||||
|
quote: {
|
||||||
|
exact: "keep the current markdown document stack",
|
||||||
|
prefix: "We should ",
|
||||||
|
suffix: " for the first version",
|
||||||
|
},
|
||||||
|
position: { normalizedStart: 0, normalizedEnd: 40, markdownStart: 0, markdownEnd: 40 },
|
||||||
|
},
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-1",
|
||||||
|
resolvedByAgentId: null,
|
||||||
|
resolvedByUserId: null,
|
||||||
|
resolvedAt: null,
|
||||||
|
createdAt: new Date("2026-05-12T10:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-05-12T10:01:00Z"),
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
id: "comment-1",
|
||||||
|
companyId: "co-1",
|
||||||
|
threadId: id,
|
||||||
|
issueId: "issue-1",
|
||||||
|
documentId: "doc-1",
|
||||||
|
body: "Could we benchmark the editor against a CRDT alternative before committing?",
|
||||||
|
authorType: "user",
|
||||||
|
authorAgentId: null,
|
||||||
|
authorUserId: "user-1",
|
||||||
|
createdByRunId: null,
|
||||||
|
createdAt: new Date("2026-05-12T10:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-05-12T10:00:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "comment-2",
|
||||||
|
companyId: "co-1",
|
||||||
|
threadId: id,
|
||||||
|
issueId: "issue-1",
|
||||||
|
documentId: "doc-1",
|
||||||
|
body: "We did a small spike — happy to share results in the plan.",
|
||||||
|
authorType: "agent",
|
||||||
|
authorAgentId: "agent-uxdesigner",
|
||||||
|
authorUserId: null,
|
||||||
|
createdByRunId: "run-1",
|
||||||
|
createdAt: new Date("2026-05-12T10:01:00Z"),
|
||||||
|
updatedAt: new Date("2026-05-12T10:01:00Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseThreads: DocumentAnnotationThreadWithComments[] = [
|
||||||
|
makeThread({ id: "open-1" }),
|
||||||
|
makeThread({
|
||||||
|
id: "stale-1",
|
||||||
|
anchorState: "stale",
|
||||||
|
anchorConfidence: "fuzzy",
|
||||||
|
selectedText: "two selectors",
|
||||||
|
prefixText: "anchor made from ",
|
||||||
|
suffixText: ":",
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
id: "comment-stale",
|
||||||
|
companyId: "co-1",
|
||||||
|
threadId: "stale-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
documentId: "doc-1",
|
||||||
|
body: "Original wording was slightly different — re-anchor when convenient.",
|
||||||
|
authorType: "user",
|
||||||
|
authorAgentId: null,
|
||||||
|
authorUserId: "user-1",
|
||||||
|
createdByRunId: null,
|
||||||
|
createdAt: new Date("2026-05-12T11:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-05-12T11:00:00Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
makeThread({
|
||||||
|
id: "resolved-1",
|
||||||
|
status: "resolved",
|
||||||
|
selectedText: "Reader: board reviewer, CTO, and implementing engineers",
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
id: "comment-resolved",
|
||||||
|
companyId: "co-1",
|
||||||
|
threadId: "resolved-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
documentId: "doc-1",
|
||||||
|
body: "Updated reader list to add the security lead.",
|
||||||
|
authorType: "agent",
|
||||||
|
authorAgentId: "agent-uxdesigner",
|
||||||
|
authorUserId: null,
|
||||||
|
createdByRunId: "run-1",
|
||||||
|
createdAt: new Date("2026-05-12T12:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-05-12T12:00:00Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
makeThread({
|
||||||
|
id: "orphan-1",
|
||||||
|
anchorState: "orphaned",
|
||||||
|
selectedText: "an earlier paragraph that has been rewritten",
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
id: "comment-orphan",
|
||||||
|
companyId: "co-1",
|
||||||
|
threadId: "orphan-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
documentId: "doc-1",
|
||||||
|
body: "This anchor lost its location after the rewrite. Original quote preserved.",
|
||||||
|
authorType: "user",
|
||||||
|
authorAgentId: null,
|
||||||
|
authorUserId: "user-1",
|
||||||
|
createdByRunId: null,
|
||||||
|
createdAt: new Date("2026-05-12T13:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-05-12T13:00:00Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const integratedAgentMap: ReadonlyMap<string, Pick<Agent, "id" | "name">> = new Map([
|
||||||
|
["agent-uxdesigner", { id: "agent-uxdesigner", name: "UXDesigner" }],
|
||||||
|
]);
|
||||||
|
const integratedUserProfileMap: ReadonlyMap<string, CompanyUserProfile> = new Map([
|
||||||
|
["user-1", { label: "Dotta", image: null }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function makeClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false, staleTime: Number.POSITIVE_INFINITY },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const integratedIssueId = "issue-storybook-1";
|
||||||
|
const integratedDoc: IssueDocument = {
|
||||||
|
id: "doc-storybook-1",
|
||||||
|
companyId: "co-1",
|
||||||
|
issueId: integratedIssueId,
|
||||||
|
key: "plan",
|
||||||
|
title: "Plan",
|
||||||
|
format: "markdown",
|
||||||
|
body: sampleMarkdown,
|
||||||
|
latestRevisionId: "rev-4",
|
||||||
|
latestRevisionNumber: 4,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-1",
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: "user-1",
|
||||||
|
lockedAt: null,
|
||||||
|
lockedByAgentId: null,
|
||||||
|
lockedByUserId: null,
|
||||||
|
createdAt: new Date("2026-05-12T09:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-05-12T10:01:00Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeIntegratedIssue(): Issue {
|
||||||
|
return {
|
||||||
|
id: integratedIssueId,
|
||||||
|
companyId: "co-1",
|
||||||
|
projectId: null,
|
||||||
|
projectWorkspaceId: null,
|
||||||
|
goalId: null,
|
||||||
|
parentId: null,
|
||||||
|
title: "Highlighting and comments on documents",
|
||||||
|
description: null,
|
||||||
|
status: "in_progress",
|
||||||
|
workMode: "standard",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
checkoutRunId: null,
|
||||||
|
executionRunId: null,
|
||||||
|
executionAgentNameKey: null,
|
||||||
|
executionLockedAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-1",
|
||||||
|
issueNumber: 9402,
|
||||||
|
identifier: "PAP-9402",
|
||||||
|
requestDepth: 0,
|
||||||
|
billingCode: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
executionWorkspacePreference: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
hiddenAt: null,
|
||||||
|
documentSummaries: [
|
||||||
|
{
|
||||||
|
id: integratedDoc.id,
|
||||||
|
companyId: integratedDoc.companyId,
|
||||||
|
issueId: integratedIssueId,
|
||||||
|
key: integratedDoc.key,
|
||||||
|
title: integratedDoc.title,
|
||||||
|
format: integratedDoc.format,
|
||||||
|
latestRevisionId: integratedDoc.latestRevisionId,
|
||||||
|
latestRevisionNumber: integratedDoc.latestRevisionNumber,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-1",
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: "user-1",
|
||||||
|
lockedAt: integratedDoc.lockedAt,
|
||||||
|
lockedByAgentId: integratedDoc.lockedByAgentId,
|
||||||
|
lockedByUserId: integratedDoc.lockedByUserId,
|
||||||
|
createdAt: integratedDoc.createdAt,
|
||||||
|
updatedAt: integratedDoc.updatedAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
legacyPlanDocument: null,
|
||||||
|
planDocument: integratedDoc,
|
||||||
|
createdAt: new Date("2026-05-10T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-05-12T10:01:00Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storybook fetch stub for the integrated stories. The annotation surface is
|
||||||
|
* driven by prefilled React Query data, but MarkdownEditor in edit mode can
|
||||||
|
* fire an autosave PUT on first onChange. Without this stub the cell would
|
||||||
|
* render a "Request failed: 404" string from the section's error state — which
|
||||||
|
* defeats the purpose of the integrated capture.
|
||||||
|
*/
|
||||||
|
function useIntegratedFetchStub(issueId: string, doc: IssueDocument) {
|
||||||
|
// Install once per mount; the cleanup restores the previous fetch.
|
||||||
|
// The preview's global fetch fixture is still in place — we only intercept
|
||||||
|
// the document mutation URL pattern for this issue.
|
||||||
|
useMemo(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const upsertUrlPath = `/api/issues/${issueId}/documents/${doc.key}`;
|
||||||
|
const original = window.fetch.bind(window);
|
||||||
|
const wrapped: typeof window.fetch = async (input, init) => {
|
||||||
|
const rawUrl = typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.href
|
||||||
|
: input.url;
|
||||||
|
const method = (init?.method ?? (typeof input === "object" && "method" in input ? (input as Request).method : "GET")).toUpperCase();
|
||||||
|
const url = new URL(rawUrl, window.location.origin);
|
||||||
|
if (url.pathname === upsertUrlPath && (method === "PUT" || method === "GET")) {
|
||||||
|
return Response.json({ ...doc, latestRevisionNumber: doc.latestRevisionNumber + 1 });
|
||||||
|
}
|
||||||
|
return original(input, init);
|
||||||
|
};
|
||||||
|
window.fetch = wrapped;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [issueId, doc.key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntegratedSurface({
|
||||||
|
threads = baseThreads,
|
||||||
|
focusedThreadId = "open-1",
|
||||||
|
initialPanelOpen = true,
|
||||||
|
beginEditOnMount = false,
|
||||||
|
}: {
|
||||||
|
threads?: DocumentAnnotationThreadWithComments[];
|
||||||
|
focusedThreadId?: string | null;
|
||||||
|
initialPanelOpen?: boolean;
|
||||||
|
beginEditOnMount?: boolean;
|
||||||
|
}) {
|
||||||
|
const issue = useMemo(makeIntegratedIssue, []);
|
||||||
|
useIntegratedFetchStub(issue.id, integratedDoc);
|
||||||
|
const queryClient = useMemo(() => {
|
||||||
|
const client = makeClient();
|
||||||
|
// Prefill documents + annotations cache so React Query renders without hitting the network.
|
||||||
|
client.setQueryData(queryKeys.issues.documents(issue.id), [integratedDoc]);
|
||||||
|
client.setQueryData(
|
||||||
|
queryKeys.issues.documentAnnotations(issue.id, integratedDoc.key, "all"),
|
||||||
|
threads,
|
||||||
|
);
|
||||||
|
return client;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [issue.id]);
|
||||||
|
|
||||||
|
const panelKeys = initialPanelOpen ? [integratedDoc.key] : [];
|
||||||
|
const focusedThreadIds = focusedThreadId ? { [integratedDoc.key]: focusedThreadId } : undefined;
|
||||||
|
const editKey = beginEditOnMount ? integratedDoc.key : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<div className="paperclip-doc-annotation-integrated mx-auto max-w-[1320px] p-4">
|
||||||
|
<div className="rounded-lg border border-border bg-background p-4">
|
||||||
|
<IssueDocumentsSection
|
||||||
|
issue={issue}
|
||||||
|
canDeleteDocuments={false}
|
||||||
|
agentMap={integratedAgentMap}
|
||||||
|
userProfileMap={integratedUserProfileMap}
|
||||||
|
defaultAnnotationPanelOpenKeys={panelKeys}
|
||||||
|
defaultAnnotationFocusedThreadIds={focusedThreadIds}
|
||||||
|
forceEditDocumentKey={editKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirtyDraftWithIntegratedHeader() {
|
||||||
|
const issue = useMemo(makeIntegratedIssue, []);
|
||||||
|
useIntegratedFetchStub(issue.id, integratedDoc);
|
||||||
|
const queryClient = useMemo(() => {
|
||||||
|
const client = makeClient();
|
||||||
|
client.setQueryData(queryKeys.issues.documents(issue.id), [integratedDoc]);
|
||||||
|
client.setQueryData(
|
||||||
|
queryKeys.issues.documentAnnotations(issue.id, integratedDoc.key, "all"),
|
||||||
|
baseThreads,
|
||||||
|
);
|
||||||
|
return client;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [issue.id]);
|
||||||
|
const [panelOpen, setPanelOpen] = useState(true);
|
||||||
|
const [draftBody, setDraftBody] = useState(`${sampleMarkdown}\n\nA work-in-progress edit that is unsaved.`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<div className="paperclip-doc-annotation-integrated mx-auto max-w-[1320px] p-4">
|
||||||
|
<div className="rounded-lg border border-border bg-background p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||||
|
<h3 className="w-full text-sm font-medium text-muted-foreground shrink-0 sm:w-auto">Documents</h3>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground">▾</span>
|
||||||
|
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
|
plan
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">rev 4 ▾</span>
|
||||||
|
<span className="truncate text-[11px] text-muted-foreground">updated 2h ago</span>
|
||||||
|
<DocumentAnnotationsCountChip
|
||||||
|
issueId={issue.id}
|
||||||
|
docKey={integratedDoc.key}
|
||||||
|
panelOpen={panelOpen}
|
||||||
|
onToggle={() => setPanelOpen((current) => !current)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<IssueDocumentAnnotations
|
||||||
|
issueId={issue.id}
|
||||||
|
doc={integratedDoc}
|
||||||
|
bodyMarkdown={draftBody}
|
||||||
|
draftDirty
|
||||||
|
draftConflicted={false}
|
||||||
|
historicalPreview={false}
|
||||||
|
locationHash=""
|
||||||
|
panelOpen={panelOpen}
|
||||||
|
onPanelOpenChange={setPanelOpen}
|
||||||
|
agentMap={integratedAgentMap}
|
||||||
|
userProfileMap={integratedUserProfileMap}
|
||||||
|
defaultFocusedThreadId="open-1"
|
||||||
|
>
|
||||||
|
<MarkdownEditor
|
||||||
|
value={draftBody}
|
||||||
|
onChange={(body) => setDraftBody(body)}
|
||||||
|
placeholder="Markdown body"
|
||||||
|
bordered={false}
|
||||||
|
className="bg-transparent"
|
||||||
|
contentClassName="paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7"
|
||||||
|
/>
|
||||||
|
</IssueDocumentAnnotations>
|
||||||
|
<div className="flex min-h-4 items-center justify-end px-1">
|
||||||
|
<span className="text-[11px] text-amber-300">Autosaving…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatesShowcase({ focusedThreadId = "open-1" }: { focusedThreadId?: string }) {
|
||||||
|
const queryClient = useMemo(() => makeClient(), []);
|
||||||
|
const bodyRef = useRef<HTMLElement | null>(null);
|
||||||
|
const [pendingAnchor, setPendingAnchor] = useState<PendingAnchor | null>(null);
|
||||||
|
const [focused, setFocused] = useState<string | null>(focusedThreadId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
|
<div className="relative rounded-lg border border-border bg-card p-4">
|
||||||
|
<section
|
||||||
|
ref={(element) => {
|
||||||
|
bodyRef.current = element;
|
||||||
|
}}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<MarkdownBody className="text-[15px] leading-7">{sampleMarkdown}</MarkdownBody>
|
||||||
|
<DocumentAnnotationLayer
|
||||||
|
containerRef={bodyRef}
|
||||||
|
markdown={sampleMarkdown}
|
||||||
|
threads={baseThreads.map((thread) => ({
|
||||||
|
id: thread.id,
|
||||||
|
selectedText: thread.selectedText,
|
||||||
|
status: thread.status,
|
||||||
|
anchorState: thread.anchorState,
|
||||||
|
}))}
|
||||||
|
focusedThreadId={focused}
|
||||||
|
onThreadFocus={(id) => setFocused(id)}
|
||||||
|
pendingAnchor={pendingAnchor}
|
||||||
|
onPendingAnchorChange={setPendingAnchor}
|
||||||
|
onRequestComment={() => {}}
|
||||||
|
hideResolved={false}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<DocumentAnnotationPanel
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
issueId="issue-1"
|
||||||
|
documentKey="plan"
|
||||||
|
documentRevisionNumber={4}
|
||||||
|
baseRevisionId="rev-4"
|
||||||
|
baseRevisionNumber={4}
|
||||||
|
threads={baseThreads}
|
||||||
|
focusedThreadId={focused}
|
||||||
|
focusedCommentId={null}
|
||||||
|
onFocusThread={(id) => setFocused(id)}
|
||||||
|
pendingAnchor={null}
|
||||||
|
onClearPendingAnchor={() => setPendingAnchor(null)}
|
||||||
|
agentMap={integratedAgentMap}
|
||||||
|
userProfileMap={integratedUserProfileMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Product/Documents/Annotations",
|
||||||
|
component: StatesShowcase,
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
"Document annotation surface for issue documents. Stories under 'Integrated' render the real IssueDocumentsSection chrome (count chip in header, panel + body in their actual layout). Stories under 'States' isolate the panel/layer for unit-level visual debugging.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof StatesShowcase>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Integrated stories — render IssueDocumentsSection with all chrome.
|
||||||
|
// These are the captures the UX gate requires.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const IntegratedDesktopOpen: Story = {
|
||||||
|
parameters: { viewport: { defaultViewport: "responsive" } },
|
||||||
|
render: () => <IntegratedSurface focusedThreadId="open-1" initialPanelOpen />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IntegratedDesktopZeroComments: Story = {
|
||||||
|
parameters: { viewport: { defaultViewport: "responsive" } },
|
||||||
|
render: () => <IntegratedSurface threads={[]} initialPanelOpen={false} focusedThreadId={null} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IntegratedDesktopEditMode: Story = {
|
||||||
|
parameters: { viewport: { defaultViewport: "responsive" } },
|
||||||
|
render: () => (
|
||||||
|
<IntegratedSurface focusedThreadId="open-1" initialPanelOpen beginEditOnMount />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IntegratedDesktopDirtyDraft: Story = {
|
||||||
|
parameters: { viewport: { defaultViewport: "responsive" } },
|
||||||
|
render: () => <DirtyDraftWithIntegratedHeader />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IntegratedMobileBottomSheet: Story = {
|
||||||
|
parameters: { viewport: { defaultViewport: "mobile1" } },
|
||||||
|
render: () => <IntegratedSurface focusedThreadId="open-1" initialPanelOpen />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Isolated state stories (kept for unit-level visual debugging).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const DesktopOpenFocused: Story = {
|
||||||
|
render: () => <StatesShowcase focusedThreadId="open-1" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DesktopResolvedFocused: Story = {
|
||||||
|
render: () => <StatesShowcase focusedThreadId="resolved-1" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DesktopStaleFocused: Story = {
|
||||||
|
render: () => <StatesShowcase focusedThreadId="stale-1" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DesktopOrphanedFocused: Story = {
|
||||||
|
render: () => <StatesShowcase focusedThreadId="orphan-1" />,
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue