diff --git a/.agents/skills/release-changelog-discord-message/SKILL.md b/.agents/skills/release-changelog-discord-message/SKILL.md index df6c85925b..374f3a089d 100644 --- a/.agents/skills/release-changelog-discord-message/SKILL.md +++ b/.agents/skills/release-changelog-discord-message/SKILL.md @@ -43,6 +43,7 @@ current Paperclip work — not invented. A single fenced markdown code block, ready to paste into Discord. Attached as issue document key `discord_announcement` on the release issue, and pasted verbatim into a comment on that issue so the human can copy it out. +When Cases are enabled, also upsert the social child case described below. ```bash PUT /api/issues/{releaseIssueId}/documents/discord_announcement @@ -177,18 +178,71 @@ Mimic this register; do not invent a "professional" tone. 1. Read the matching `releases/vYYYY.MDD.P.md` produced by `release-changelog`. Use the version and contributor list from that file — never re-derive them. -2. Read the **release issue thread** (the one assigned to you that ran the +2. Resolve the parent `release` case with key `paperclip-release:vYYYY.MDD.P`. + If it does not exist and Cases are enabled, create it using the schema in + `.agents/skills/release-changelog/SKILL.md` before creating child cases. +3. Read the **release issue thread** (the one assigned to you that ran the release routine) — comments + linked issues + recent issues in the company are the source for `WHATS NEXT` and `What's on my mind`. Pull real themes, not invented ones. -3. Re-read the three verbatim examples below — they're the canonical voice. -4. Draft the announcement using the template above. -5. PUT it as the `discord_announcement` document on the release issue (see +4. Re-read the three verbatim examples below — they're the canonical voice. +5. Draft the announcement using the template above. +6. PUT it as the `discord_announcement` document on the release issue (see "Output" above). If updating, send the latest `baseRevisionId`. -6. Post a comment on the release issue that includes the announcement inside a +7. Upsert the `tweet_storm` child case with `parentCaseId` set to the release + case id, then PUT its `body` document to the announcement body. +8. Post a comment on the release issue that includes the announcement inside a single fenced markdown code block, so dotta can copy-paste it into Discord without opening the document. +## Tweet Storm Case Schema + +Use this child case for the Discord/social announcement thread. The key must be +stable so retries update the same child case: + +```http +POST /api/companies/:companyId/cases +{ + "caseType": "tweet_storm", + "key": "paperclip-release:vYYYY.MDD.P:tweet-storm", + "title": "Paperclip vYYYY.MDD.P tweet storm", + "summary": "Social announcement thread for Paperclip vYYYY.MDD.P.", + "status": "in_review", + "parentCaseId": "", + "fields": { + "schema_version": 1, + "version": "vYYYY.MDD.P", + "channel": "x", + "discord_source": true, + "post_count": 1, + "target_audience": ["operators", "contributors", "agent-company builders"], + "links": { + "release_notes": "https://github.com/paperclipai/paperclip/blob/master/releases/vYYYY.MDD.P.md", + "official_account": "https://x.com/papercliping" + }, + "review": { + "needs_human_copy_paste": true, + "approved_by": null + } + } +} +``` + +Then write the body document: + +```http +PUT /api/cases/:tweetStormCaseId/documents/body +{ + "title": "Paperclip vYYYY.MDD.P tweet storm body", + "format": "markdown", + "body": "", + "changeSummary": "Draft social announcement" +} +``` + +If updating an existing document, fetch the case and pass the latest +`baseRevisionId`. + Do not publish to Discord. This skill only prepares the artifact. ## Verbatim previous examples diff --git a/.agents/skills/release-changelog/SKILL.md b/.agents/skills/release-changelog/SKILL.md index e9d9177f58..8bf662ed6b 100644 --- a/.agents/skills/release-changelog/SKILL.md +++ b/.agents/skills/release-changelog/SKILL.md @@ -23,6 +23,8 @@ intended release date (UTC) plus the next same-day stable patch slot. Output: - `releases/vYYYY.MDD.P.md` +- a `release` Case, upserted by `(caseType, key)` when Cases are enabled, with a + `body` document revision containing the changelog body Important rules: @@ -188,6 +190,66 @@ List contributors in alphabetical order by GitHub username (case-insensitive). If there are no contributors left after exclusions, then just skip this section and don't mention it. +## Step 5b — Upsert The Release Case + +After writing `releases/vYYYY.MDD.P.md`, emit or refresh the top-level release +case when the run has Paperclip API context. Use `skills/paperclip/references/cases.md` +as the API contract. If the API returns `403 Cases are disabled`, report that +Cases must be enabled and continue with the changelog file only. + +Request: + +```http +POST /api/companies/:companyId/cases +{ + "caseType": "release", + "key": "paperclip-release:vYYYY.MDD.P", + "title": "Paperclip vYYYY.MDD.P release", + "summary": "Stable Paperclip release notes for vYYYY.MDD.P.", + "status": "in_progress", + "fields": { + "schema_version": 1, + "version": "vYYYY.MDD.P", + "release_date": "YYYY-MM-DD", + "release_patch": 0, + "stable": true, + "channels": ["changelog", "blog_post", "tweet_storm"], + "artifacts": { + "changelog_path": "releases/vYYYY.MDD.P.md", + "github_release_url": null + }, + "verification": { + "typecheck": "unknown", + "tests": "unknown", + "build": "unknown", + "smoke": "unknown" + }, + "notes": null + } +} +``` + +This fields schema deliberately exercises every generic field value type: +string, number, boolean, array, object, and null. Keep the keys stable across +runs and send the full object on every upsert because fields are replaced, not +deep-merged. + +Then write the changelog into the case body document: + +```http +PUT /api/cases/:releaseCaseId/documents/body +{ + "title": "Paperclip vYYYY.MDD.P changelog", + "format": "markdown", + "body": "", + "changeSummary": "Initial stable changelog" +} +``` + +If updating an existing body document, fetch the case first and pass the latest +`baseRevisionId`. On `409 stale_base_revision`, refetch, merge intentionally, +and retry once. + ## Step 6 — Review Before Release Before handing it off: @@ -195,6 +257,7 @@ Before handing it off: 1. confirm the H1 heading is `# Paperclip {version}` (e.g. `# Paperclip v2026.618.0`) with the stable version only 2. confirm there is no `-canary` language in the title or filename 3. confirm any breaking changes have an upgrade path -4. present the draft for human sign-off +4. confirm the `release` case exists or explain why Cases were unavailable +5. present the draft for human sign-off This skill never publishes anything. It only prepares the stable changelog artifact. diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 8f8e7ca25d..55a72248e2 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -18,6 +18,8 @@ This skill coordinates: - manual stable promotion from a chosen source ref - GitHub Release creation - website / announcement follow-up tasks +- release-content Cases dogfood: a top-level `release` case with child + `blog_post` and `tweet_storm` cases, all linked to the release issue/run ## Trigger @@ -214,6 +216,78 @@ Create or verify follow-up work for: These should reference the stable release, not the canary. +## Step 8 — Emit Release-Content Cases + +When Cases are enabled, every stable release-content run must materialize a +deterministic case tree. This is part of the release dogfood path, not an +optional artifact. If the API returns `403 Cases are disabled`, stop and report +that the operator must enable `experimental.enableCases`. + +Use the current release issue's `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, +`PAPERCLIP_API_KEY`, and `PAPERCLIP_RUN_ID`. Include `X-Paperclip-Run-Id` on +all writes so the case activity feed can attribute the run back to the issue. + +Create or upsert the parent `release` case first: + +```http +POST /api/companies/:companyId/cases +{ + "caseType": "release", + "key": "paperclip-release:vYYYY.MDD.P", + "title": "Paperclip vYYYY.MDD.P release", + "summary": "Stable release content package for Paperclip vYYYY.MDD.P.", + "status": "in_progress", + "fields": { + "schema_version": 1, + "version": "vYYYY.MDD.P", + "release_date": "YYYY-MM-DD", + "source_ref": "git-sha-or-ref", + "stable": true, + "channels": ["changelog", "blog_post", "tweet_storm"], + "artifacts": { + "changelog_path": "releases/vYYYY.MDD.P.md", + "github_release_url": null + }, + "verification": { + "typecheck": "unknown", + "tests": "unknown", + "build": "unknown", + "smoke": "unknown" + }, + "notes": null + } +} +``` + +The `fields` schema intentionally uses all generic JSON value types: strings, +numbers, booleans, arrays, objects, and nulls. Send the complete fields object on +each upsert because case fields replace as a whole object. + +Write the parent body document immediately after the upsert: + +```http +PUT /api/cases/:releaseCaseId/documents/body +{ + "title": "Paperclip vYYYY.MDD.P release body", + "format": "markdown", + "body": "# Paperclip vYYYY.MDD.P\n\nRelease summary and links...", + "changeSummary": "Initial release case body" +} +``` + +Then create or upsert these child cases with `parentCaseId` set to the release +case id: + +- `blog_post`, key `paperclip-release:vYYYY.MDD.P:blog-post`, status + `in_progress`, body document key `body` +- `tweet_storm`, key `paperclip-release:vYYYY.MDD.P:tweet-storm`, status + `in_progress`, body document key `body` + +Use deterministic keys exactly so rerunning the release-content flow upserts the +same three cases instead of duplicating them. After the child body documents are +written, list the resulting case identifiers and links in the release issue and +in the parent acceptance issue when one exists. + ## Failure Handling If the canary is bad: @@ -244,4 +318,6 @@ When the skill completes, provide: - smoke-test status - git tag / GitHub Release status - website / announcement follow-up status +- release-content case tree links: parent `release` case plus `blog_post` and + `tweet_storm` children - rollback recommendation if anything is still partially complete diff --git a/packages/db/src/migrations/0143_cases_foundation.sql b/packages/db/src/migrations/0143_cases_foundation.sql new file mode 100644 index 0000000000..4b88cc97a7 --- /dev/null +++ b/packages/db/src/migrations/0143_cases_foundation.sql @@ -0,0 +1,248 @@ +CREATE TABLE IF NOT EXISTS "cases" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "project_id" uuid, + "case_number" integer NOT NULL, + "identifier" text NOT NULL, + "case_type" text NOT NULL, + "key" text, + "title" text NOT NULL, + "summary" text, + "status" text DEFAULT 'draft' NOT NULL, + "fields" jsonb DEFAULT '{}'::jsonb NOT NULL, + "parent_case_id" uuid, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "completed_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, + CONSTRAINT "cases_status_check" CHECK ("cases"."status" in ('draft', 'in_progress', 'in_review', 'approved', 'done', 'cancelled')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "case_issue_links" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "case_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "role" text NOT NULL, + "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, + CONSTRAINT "case_issue_links_role_check" CHECK ("case_issue_links"."role" in ('origin', 'work', 'reference')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "case_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "case_id" uuid NOT NULL, + "kind" text NOT NULL, + "actor_type" text NOT NULL, + "actor_user_id" text, + "actor_agent_id" uuid, + "run_id" uuid, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "case_events_kind_check" CHECK ("case_events"."kind" in ( + 'created', + 'updated', + 'fields_changed', + 'status_changed', + 'issue_linked', + 'issue_unlinked', + 'document_revised', + 'child_linked', + 'attachment_added', + 'label_added', + 'label_removed' + )), + CONSTRAINT "case_events_actor_type_check" CHECK ("case_events"."actor_type" in ('user', 'agent', 'system')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "case_documents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "case_id" uuid NOT NULL, + "document_id" uuid NOT NULL, + "key" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "case_labels" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "case_id" uuid NOT NULL, + "label_id" uuid NOT NULL, + "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 "case_attachments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "case_id" uuid NOT NULL, + "asset_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "cases" ADD CONSTRAINT "cases_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "cases" ADD CONSTRAINT "cases_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "cases" ADD CONSTRAINT "cases_parent_case_id_cases_id_fk" FOREIGN KEY ("parent_case_id") REFERENCES "public"."cases"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "cases" ADD CONSTRAINT "cases_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; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_issue_links" ADD CONSTRAINT "case_issue_links_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_issue_links" ADD CONSTRAINT "case_issue_links_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_issue_links" ADD CONSTRAINT "case_issue_links_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_events" ADD CONSTRAINT "case_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_events" ADD CONSTRAINT "case_events_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_events" ADD CONSTRAINT "case_events_actor_agent_id_agents_id_fk" FOREIGN KEY ("actor_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_documents" ADD CONSTRAINT "case_documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_documents" ADD CONSTRAINT "case_documents_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_documents" ADD CONSTRAINT "case_documents_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_labels" ADD CONSTRAINT "case_labels_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_labels" ADD CONSTRAINT "case_labels_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_labels" ADD CONSTRAINT "case_labels_label_id_labels_id_fk" FOREIGN KEY ("label_id") REFERENCES "public"."labels"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_attachments" ADD CONSTRAINT "case_attachments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_attachments" ADD CONSTRAINT "case_attachments_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_attachments" ADD CONSTRAINT "case_attachments_asset_id_assets_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."assets"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "cases_company_case_number_uq" ON "cases" USING btree ("company_id","case_number"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "cases_identifier_uq" ON "cases" USING btree ("identifier"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "cases_company_type_key_uq" ON "cases" USING btree ("company_id","case_type","key"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cases_company_status_idx" ON "cases" USING btree ("company_id","status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cases_company_type_idx" ON "cases" USING btree ("company_id","case_type"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cases_company_project_idx" ON "cases" USING btree ("company_id","project_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cases_parent_idx" ON "cases" USING btree ("parent_case_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cases_title_search_idx" ON "cases" USING gin ("title" gin_trgm_ops); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cases_identifier_search_idx" ON "cases" USING gin ("identifier" gin_trgm_ops); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cases_summary_search_idx" ON "cases" USING gin ("summary" gin_trgm_ops); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "case_issue_links_case_issue_uq" ON "case_issue_links" USING btree ("case_id","issue_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_issue_links_company_case_idx" ON "case_issue_links" USING btree ("company_id","case_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_issue_links_issue_idx" ON "case_issue_links" USING btree ("issue_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_events_case_created_idx" ON "case_events" USING btree ("case_id","created_at"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_events_company_case_idx" ON "case_events" USING btree ("company_id","case_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "case_documents_company_case_key_uq" ON "case_documents" USING btree ("company_id","case_id","key"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "case_documents_document_uq" ON "case_documents" USING btree ("document_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_documents_company_case_updated_idx" ON "case_documents" USING btree ("company_id","case_id","updated_at"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "case_labels_case_label_uq" ON "case_labels" USING btree ("case_id","label_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_labels_company_case_idx" ON "case_labels" USING btree ("company_id","case_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_labels_label_idx" ON "case_labels" USING btree ("label_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_attachments_company_case_idx" ON "case_attachments" USING btree ("company_id","case_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "case_attachments_asset_uq" ON "case_attachments" USING btree ("asset_id"); diff --git a/packages/db/src/migrations/0144_case_document_annotations.sql b/packages/db/src/migrations/0144_case_document_annotations.sql new file mode 100644 index 0000000000..144d708a4c --- /dev/null +++ b/packages/db/src/migrations/0144_case_document_annotations.sql @@ -0,0 +1,31 @@ +ALTER TABLE "document_annotation_threads" ADD COLUMN IF NOT EXISTS "case_id" uuid; +--> statement-breakpoint +ALTER TABLE "document_annotation_comments" ADD COLUMN IF NOT EXISTS "case_id" uuid; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +ALTER TABLE "document_annotation_threads" DROP CONSTRAINT IF EXISTS "document_annotation_threads_owner_check"; +--> statement-breakpoint +ALTER TABLE "document_annotation_threads" DROP CONSTRAINT IF EXISTS "document_annotation_threads_exactly_one_owner_chk"; +--> statement-breakpoint +ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_exactly_one_owner_chk" CHECK (num_nonnulls("issue_id", "routine_id", "case_id") = 1); +--> statement-breakpoint +ALTER TABLE "document_annotation_comments" DROP CONSTRAINT IF EXISTS "document_annotation_comments_owner_check"; +--> statement-breakpoint +ALTER TABLE "document_annotation_comments" DROP CONSTRAINT IF EXISTS "document_annotation_comments_exactly_one_owner_chk"; +--> statement-breakpoint +ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_exactly_one_owner_chk" CHECK (num_nonnulls("issue_id", "routine_id", "case_id") = 1); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_case_status_idx" ON "document_annotation_threads" USING btree ("company_id","case_id","status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_case_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","case_id","created_at"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 6c793f8906..fe570bd9dc 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -988,6 +988,20 @@ "when": 1783555301100, "tag": "0142_company_search_sort_indexes", "breakpoints": true + }, + { + "idx": 143, + "version": "7", + "when": 1783457051766, + "tag": "0143_cases_foundation", + "breakpoints": true + }, + { + "idx": 144, + "version": "7", + "when": 1783520000000, + "tag": "0144_case_document_annotations", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/cases.ts b/packages/db/src/schema/cases.ts new file mode 100644 index 0000000000..0a45029ff6 --- /dev/null +++ b/packages/db/src/schema/cases.ts @@ -0,0 +1,168 @@ +import { sql } from "drizzle-orm"; +import { + type AnyPgColumn, + check, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { assets } from "./assets.js"; +import { companies } from "./companies.js"; +import { documents } from "./documents.js"; +import { issues } from "./issues.js"; +import { labels } from "./labels.js"; +import { projects } from "./projects.js"; + +export const cases = pgTable( + "cases", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }), + caseNumber: integer("case_number").notNull(), + identifier: text("identifier").notNull(), + caseType: text("case_type").notNull(), + key: text("key"), + title: text("title").notNull(), + summary: text("summary"), + status: text("status").notNull().default("draft"), + fields: jsonb("fields").$type>().notNull().default({}), + parentCaseId: uuid("parent_case_id").references((): AnyPgColumn => cases.id, { onDelete: "set null" }), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + completedAt: timestamp("completed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyCaseNumberUq: uniqueIndex("cases_company_case_number_uq").on(table.companyId, table.caseNumber), + identifierUq: uniqueIndex("cases_identifier_uq").on(table.identifier), + companyTypeKeyUq: uniqueIndex("cases_company_type_key_uq").on(table.companyId, table.caseType, table.key), + companyStatusIdx: index("cases_company_status_idx").on(table.companyId, table.status), + companyTypeIdx: index("cases_company_type_idx").on(table.companyId, table.caseType), + companyProjectIdx: index("cases_company_project_idx").on(table.companyId, table.projectId), + parentIdx: index("cases_parent_idx").on(table.parentCaseId), + titleSearchIdx: index("cases_title_search_idx").using("gin", table.title.op("gin_trgm_ops")), + identifierSearchIdx: index("cases_identifier_search_idx").using("gin", table.identifier.op("gin_trgm_ops")), + summarySearchIdx: index("cases_summary_search_idx").using("gin", table.summary.op("gin_trgm_ops")), + statusCheck: check( + "cases_status_check", + sql`${table.status} in ('draft', 'in_progress', 'in_review', 'approved', 'done', 'cancelled')`, + ), + }), +); + +export const caseIssueLinks = pgTable( + "case_issue_links", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + caseId: uuid("case_id").notNull().references(() => cases.id, { onDelete: "cascade" }), + issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + role: text("role").notNull(), + createdByRunId: uuid("created_by_run_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + caseIssueUq: uniqueIndex("case_issue_links_case_issue_uq").on(table.caseId, table.issueId), + companyCaseIdx: index("case_issue_links_company_case_idx").on(table.companyId, table.caseId), + issueIdx: index("case_issue_links_issue_idx").on(table.issueId), + roleCheck: check("case_issue_links_role_check", sql`${table.role} in ('origin', 'work', 'reference')`), + }), +); + +export const caseEvents = pgTable( + "case_events", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + caseId: uuid("case_id").notNull().references(() => cases.id, { onDelete: "cascade" }), + kind: text("kind").notNull(), + actorType: text("actor_type").notNull(), + actorUserId: text("actor_user_id"), + actorAgentId: uuid("actor_agent_id").references(() => agents.id, { onDelete: "set null" }), + runId: uuid("run_id"), + payload: jsonb("payload").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + caseCreatedIdx: index("case_events_case_created_idx").on(table.caseId, table.createdAt), + companyCaseIdx: index("case_events_company_case_idx").on(table.companyId, table.caseId), + kindCheck: check( + "case_events_kind_check", + sql`${table.kind} in ( + 'created', + 'updated', + 'fields_changed', + 'status_changed', + 'issue_linked', + 'issue_unlinked', + 'document_revised', + 'child_linked', + 'attachment_added', + 'label_added', + 'label_removed' + )`, + ), + actorTypeCheck: check("case_events_actor_type_check", sql`${table.actorType} in ('user', 'agent', 'system')`), + }), +); + +export const caseDocuments = pgTable( + "case_documents", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + caseId: uuid("case_id").notNull().references(() => cases.id, { onDelete: "cascade" }), + documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), + key: text("key").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyCaseKeyUq: uniqueIndex("case_documents_company_case_key_uq").on(table.companyId, table.caseId, table.key), + documentUq: uniqueIndex("case_documents_document_uq").on(table.documentId), + companyCaseUpdatedIdx: index("case_documents_company_case_updated_idx").on(table.companyId, table.caseId, table.updatedAt), + }), +); + +export const caseLabels = pgTable( + "case_labels", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + caseId: uuid("case_id").notNull().references(() => cases.id, { onDelete: "cascade" }), + labelId: uuid("label_id").notNull().references(() => labels.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + caseLabelUq: uniqueIndex("case_labels_case_label_uq").on(table.caseId, table.labelId), + companyCaseIdx: index("case_labels_company_case_idx").on(table.companyId, table.caseId), + labelIdx: index("case_labels_label_idx").on(table.labelId), + }), +); + +export const caseAttachments = pgTable( + "case_attachments", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + caseId: uuid("case_id").notNull().references(() => cases.id, { onDelete: "cascade" }), + assetId: uuid("asset_id").notNull().references(() => assets.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyCaseIdx: index("case_attachments_company_case_idx").on(table.companyId, table.caseId), + assetUq: uniqueIndex("case_attachments_asset_uq").on(table.assetId), + }), +); diff --git a/packages/db/src/schema/document_annotation_comments.ts b/packages/db/src/schema/document_annotation_comments.ts index 082a1acb6e..e6fa7da205 100644 --- a/packages/db/src/schema/document_annotation_comments.ts +++ b/packages/db/src/schema/document_annotation_comments.ts @@ -9,6 +9,7 @@ import { heartbeatRuns } from "./heartbeat_runs.js"; import { issueComments } from "./issue_comments.js"; import { issues } from "./issues.js"; import { routines } from "./routines.js"; +import { cases } from "./cases.js"; export const documentAnnotationComments = pgTable( "document_annotation_comments", @@ -18,6 +19,7 @@ export const documentAnnotationComments = pgTable( threadId: uuid("thread_id").notNull().references(() => documentAnnotationThreads.id, { onDelete: "cascade" }), issueId: uuid("issue_id").references(() => issues.id, { onDelete: "cascade" }), routineId: uuid("routine_id").references(() => routines.id, { onDelete: "cascade" }), + caseId: uuid("case_id").references(() => cases.id, { onDelete: "cascade" }), documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), body: text("body").notNull(), authorType: text("author_type").$type().notNull(), @@ -45,6 +47,11 @@ export const documentAnnotationComments = pgTable( table.routineId, table.createdAt, ), + companyCaseCreatedAtIdx: index("document_annotation_comments_company_case_created_at_idx").on( + table.companyId, + table.caseId, + table.createdAt, + ), companyDocumentCreatedAtIdx: index("document_annotation_comments_company_document_created_at_idx").on( table.companyId, table.documentId, @@ -54,7 +61,7 @@ export const documentAnnotationComments = pgTable( bodySearchIdx: index("document_annotation_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")), exactlyOneOwnerChk: check( "document_annotation_comments_exactly_one_owner_chk", - sql`num_nonnulls(${table.issueId}, ${table.routineId}) = 1`, + sql`num_nonnulls(${table.issueId}, ${table.routineId}, ${table.caseId}) = 1`, ), }), ); diff --git a/packages/db/src/schema/document_annotation_threads.ts b/packages/db/src/schema/document_annotation_threads.ts index d09c40d28d..b3aae3ee92 100644 --- a/packages/db/src/schema/document_annotation_threads.ts +++ b/packages/db/src/schema/document_annotation_threads.ts @@ -12,6 +12,7 @@ import { documentRevisions } from "./document_revisions.js"; import { documents } from "./documents.js"; import { issues } from "./issues.js"; import { routines } from "./routines.js"; +import { cases } from "./cases.js"; export const documentAnnotationThreads = pgTable( "document_annotation_threads", @@ -20,6 +21,7 @@ export const documentAnnotationThreads = pgTable( companyId: uuid("company_id").notNull().references(() => companies.id), issueId: uuid("issue_id").references(() => issues.id, { onDelete: "cascade" }), routineId: uuid("routine_id").references(() => routines.id, { onDelete: "cascade" }), + caseId: uuid("case_id").references(() => cases.id, { onDelete: "cascade" }), documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), documentKey: text("document_key").notNull(), status: text("status").$type().notNull().default("open"), @@ -64,6 +66,11 @@ export const documentAnnotationThreads = pgTable( table.routineId, table.status, ), + companyCaseStatusIdx: index("document_annotation_threads_company_case_status_idx").on( + table.companyId, + table.caseId, + table.status, + ), companyCurrentRevisionOpenIdx: index("document_annotation_threads_company_current_revision_open_idx").on( table.companyId, table.documentId, @@ -76,7 +83,7 @@ export const documentAnnotationThreads = pgTable( ), exactlyOneOwnerChk: check( "document_annotation_threads_exactly_one_owner_chk", - sql`num_nonnulls(${table.issueId}, ${table.routineId}) = 1`, + sql`num_nonnulls(${table.issueId}, ${table.routineId}, ${table.caseId}) = 1`, ), }), ); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index aadaaf63db..735fd4e621 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -44,6 +44,14 @@ export { externalObjectMentions } from "./external_object_mentions.js"; export { issueRelations } from "./issue_relations.js"; export { routines, routineRevisions, routineTriggers, routineRuns } from "./routines.js"; export { pipelines, pipelineStages, pipelineTransitions } from "./pipelines.js"; +export { + cases, + caseAttachments, + caseDocuments, + caseEvents, + caseIssueLinks, + caseLabels, +} from "./cases.js"; export { pipelineCases, pipelineCaseIssueLinks, diff --git a/packages/shared/src/types/document-annotation.ts b/packages/shared/src/types/document-annotation.ts index 7c57fdf3e1..43a193cd75 100644 --- a/packages/shared/src/types/document-annotation.ts +++ b/packages/shared/src/types/document-annotation.ts @@ -60,6 +60,7 @@ export interface DocumentAnnotationThread { companyId: string; issueId: string | null; routineId?: string | null; + caseId?: string | null; documentId: string; documentKey: string; status: DocumentAnnotationThreadStatus; @@ -92,6 +93,7 @@ export interface DocumentAnnotationComment { threadId: string; issueId: string | null; routineId?: string | null; + caseId?: string | null; documentId: string; body: string; authorType: IssueCommentAuthorType; diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 10a72e0c6d..b34581ba74 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -49,6 +49,7 @@ export interface InstanceExperimentalSettings { enableIsolatedWorkspaces: boolean; enableStreamlinedLeftNavigation: boolean; enablePipelines: boolean; + enableCases: boolean; enableConferenceRoomChat: boolean; enableTaskWatchdogs: boolean; enableIssuePlanDecompositions: boolean; diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 8798e19f69..612e5d1a19 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -43,6 +43,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableIsolatedWorkspaces: z.boolean().default(false), enableStreamlinedLeftNavigation: z.boolean().default(true), enablePipelines: z.boolean().default(false), + enableCases: z.boolean().default(false), enableConferenceRoomChat: z.boolean().default(false), enableTaskWatchdogs: z.boolean().default(false), enableIssuePlanDecompositions: z.boolean().default(false), diff --git a/packages/skills-catalog/catalog/optional/content/release-announcement/SKILL.md b/packages/skills-catalog/catalog/optional/content/release-announcement/SKILL.md index 8870aa6580..88c8a965ee 100644 --- a/packages/skills-catalog/catalog/optional/content/release-announcement/SKILL.md +++ b/packages/skills-catalog/catalog/optional/content/release-announcement/SKILL.md @@ -28,6 +28,96 @@ Write the channel-appropriate announcement for a release without churn. Differen - An internal-only change with no user impact. Update internal docs; do not announce. - The release is incomplete (still in active development). Wait until it ships, even if marketing wants the post. +## Paperclip Cases output + +When this skill runs inside Paperclip and `experimental.enableCases` is enabled, +emit durable release-content cases before handing off the copy. Cases preserve +the inspectable output; the issue coordinates the work. + +Use `skills/paperclip/references/cases.md` for the API contract. Include +`X-Paperclip-Run-Id` on writes when `PAPERCLIP_RUN_ID` is set. If the API returns +`403 Cases are disabled`, report that limitation and continue with the requested +copy artifact. + +Upsert the parent release case first when it does not already exist: + +```json +{ + "caseType": "release", + "key": "paperclip-release:vYYYY.MDD.P", + "title": "Paperclip vYYYY.MDD.P release", + "status": "in_progress", + "fields": { + "schema_version": 1, + "version": "vYYYY.MDD.P", + "release_date": "YYYY-MM-DD", + "release_patch": 0, + "stable": true, + "channels": ["blog_post", "tweet_storm"], + "artifacts": { + "changelog_path": "releases/vYYYY.MDD.P.md", + "publish_url": null + } + } +} +``` + +For a dev blog, upsert a child case with `parentCaseId` set to the release case: + +```json +{ + "caseType": "blog_post", + "key": "paperclip-release:vYYYY.MDD.P:blog-post", + "title": "Paperclip vYYYY.MDD.P launch post", + "status": "in_review", + "parentCaseId": "", + "fields": { + "schema_version": 1, + "version": "vYYYY.MDD.P", + "slug": "paperclip-vYYYY-MDD-P", + "word_count_target": 650, + "target_audience": ["operators", "developers"], + "requires_screenshot": false, + "links": { + "release_notes": "releases/vYYYY.MDD.P.md", + "publish_url": null + }, + "sections": ["hook", "whats_new", "upgrade", "whats_next"] + } +} +``` + +For social output, upsert a sibling child case: + +```json +{ + "caseType": "tweet_storm", + "key": "paperclip-release:vYYYY.MDD.P:tweet-storm", + "title": "Paperclip vYYYY.MDD.P tweet storm", + "status": "in_review", + "parentCaseId": "", + "fields": { + "schema_version": 1, + "version": "vYYYY.MDD.P", + "post_count": 1, + "channel": "x", + "target_audience": ["operators", "contributors"], + "links": { + "release_notes": "releases/vYYYY.MDD.P.md", + "publish_url": null + }, + "review": { + "needs_human_copy_paste": true, + "approved_by": null + } + } +} +``` + +Write the produced copy to `PUT /api/cases/:caseId/documents/body` with +`format: "markdown"` and a `changeSummary`. Fetch the latest document revision +and pass `baseRevisionId` when updating an existing body document. + ## Determine the audience and channel first | Audience | Best channel | Tone | diff --git a/packages/skills-catalog/generated/catalog.json b/packages/skills-catalog/generated/catalog.json index 791750aa2a..aa64ff9229 100644 --- a/packages/skills-catalog/generated/catalog.json +++ b/packages/skills-catalog/generated/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "packageName": "@paperclipai/skills-catalog", "packageVersion": "0.3.1", - "generatedAt": "2026-07-09T15:02:07.574Z", + "generatedAt": "2026-07-10T00:27:11.902Z", "skills": [ { "id": "paperclipai:bundled:docs:doc-maintenance", @@ -409,11 +409,11 @@ { "path": "SKILL.md", "kind": "skill", - "sizeBytes": 4416, - "sha256": "062810ac34e9edc89efa701fec2eee60f16949d1944cc2cae49803cb91e8cbf4" + "sizeBytes": 6945, + "sha256": "d745cca96350518fabae14f5c407d779e1df2c5d091b5b4a5c55bb888b569e5f" } ], - "contentHash": "sha256:f22a9ed696e6614c6db2757a149f48b3295e81f78c27d065d9cb164cf4f8a9bd" + "contentHash": "sha256:efe8ea89b552df95222609867c9c75f1b40e16f457d34e7e4a124f54daa33efa" }, { "id": "paperclipai:optional:finance:ramp", diff --git a/packages/skills-catalog/src/release-content-cases-contract.test.ts b/packages/skills-catalog/src/release-content-cases-contract.test.ts new file mode 100644 index 0000000000..64bd29a337 --- /dev/null +++ b/packages/skills-catalog/src/release-content-cases-contract.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +function readRepoFile(path: string) { + return readFileSync(new URL(`../../../${path}`, import.meta.url), "utf8"); +} + +describe("release-content Cases contract", () => { + it("keeps release-content skills wired to emit the required case tree", () => { + const release = readRepoFile(".agents/skills/release/SKILL.md"); + const changelog = readRepoFile(".agents/skills/release-changelog/SKILL.md"); + const discord = readRepoFile(".agents/skills/release-changelog-discord-message/SKILL.md"); + const announcement = readRepoFile("packages/skills-catalog/catalog/optional/content/release-announcement/SKILL.md"); + const combined = [release, changelog, discord, announcement].join("\n"); + + for (const required of [ + "\"caseType\": \"release\"", + "\"caseType\": \"blog_post\"", + "\"caseType\": \"tweet_storm\"", + "\"parentCaseId\"", + "PUT /api/cases/:caseId/documents/body", + "paperclip-release:vYYYY.MDD.P", + "X-Paperclip-Run-Id", + ]) { + expect(combined).toContain(required); + } + + expect(release).toContain("same three cases instead of duplicating them"); + expect(changelog).toContain("\"release_patch\": 0"); + expect(changelog).toContain("\"stable\": true"); + expect(changelog).toContain("\"channels\": [\"changelog\", \"blog_post\", \"tweet_storm\"]"); + expect(changelog).toContain("\"artifacts\""); + expect(changelog).toContain("\"verification\""); + expect(changelog).toContain("\"notes\": null"); + expect(announcement).toContain("\"word_count_target\": 650"); + expect(announcement).toContain("\"requires_screenshot\": false"); + expect(discord).toContain("\"needs_human_copy_paste\": true"); + expect(discord).toContain("\"approved_by\": null"); + }); +}); diff --git a/server/src/__tests__/cases-routes.test.ts b/server/src/__tests__/cases-routes.test.ts new file mode 100644 index 0000000000..d758670c7f --- /dev/null +++ b/server/src/__tests__/cases-routes.test.ts @@ -0,0 +1,904 @@ +import { createHash, randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + assets, + caseAttachments, + caseDocuments, + caseEvents, + caseIssueLinks, + caseLabels, + cases, + companies, + createDb, + documentAnnotationComments, + documentAnnotationThreads, + documents, + documentRevisions, + heartbeatRuns, + instanceSettings, + issues, + labels, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { actorMiddleware } from "../middleware/auth.js"; +import { createLocalAgentJwt } from "../agent-auth-jwt.js"; +import { buildCasePatchUpdateValues, caseRoutes } from "../routes/cases.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; +import type { StorageService } from "../storage/types.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres cases route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("cases routes", () => { + it("omits completedAt from non-status case patches", () => { + const now = new Date("2026-07-10T00:00:00.000Z"); + const completedAt = new Date("2026-07-09T00:00:00.000Z"); + + expect(buildCasePatchUpdateValues({ title: "Rename" }, { status: "todo", completedAt: null }, now)).not.toHaveProperty("completedAt"); + expect(buildCasePatchUpdateValues({ title: "Rename" }, { status: "done", completedAt }, now)).not.toHaveProperty("completedAt"); + + const statusPatch = buildCasePatchUpdateValues({ status: "done" }, { status: "todo", completedAt: null }, now); + expect(statusPatch).toHaveProperty("completedAt"); + expect(statusPatch.completedAt).toBeInstanceOf(Date); + }); + + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const previousAgentJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; + + const storage: StorageService = { + provider: "local_disk", + async putFile(input) { + return { + provider: "local_disk", + objectKey: `${input.namespace}/${randomUUID()}`, + contentType: input.contentType, + byteSize: input.body.length, + sha256: createHash("sha256").update(input.body).digest("hex"), + originalFilename: input.originalFilename, + }; + }, + async getObject() { + throw new Error("not used"); + }, + async headObject() { + return { exists: false }; + }, + async deleteObject() {}, + }; + + beforeAll(async () => { + process.env.PAPERCLIP_AGENT_JWT_SECRET = "cases-routes-test-secret"; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cases-routes-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(documentAnnotationComments); + await db.delete(documentAnnotationThreads); + await db.delete(caseAttachments); + await db.delete(caseLabels); + await db.delete(caseDocuments); + await db.delete(caseIssueLinks); + await db.delete(caseEvents); + await db.delete(cases); + await db.delete(documentRevisions); + await db.delete(documents); + await db.delete(assets); + await db.delete(labels); + await db.delete(issues); + await db.delete(heartbeatRuns); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + await db.delete(instanceSettings); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + if (previousAgentJwtSecret === undefined) { + delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + } else { + process.env.PAPERCLIP_AGENT_JWT_SECRET = previousAgentJwtSecret; + } + }); + + function app(actor: Express.Request["actor"]) { + const instance = express(); + instance.use(express.json()); + instance.use((req, _res, next) => { + req.actor = actor; + next(); + }); + instance.use("/api", caseRoutes(db, storage)); + instance.use(errorHandler); + return instance; + } + + function authenticatedApp() { + const instance = express(); + instance.use(express.json()); + instance.use(actorMiddleware(db, { deploymentMode: "authenticated" })); + instance.use("/api", caseRoutes(db, storage)); + instance.use(errorHandler); + return instance; + } + + async function enableCases() { + await instanceSettingsService(db).updateExperimental({ enableCases: true }); + } + + async function seedCompany(prefix = "CASE") { + const [company] = await db.insert(companies).values({ + name: `${prefix} Co`, + issuePrefix: `${prefix}${randomUUID().replace(/-/g, "").slice(0, 4)}`, + }).returning(); + return company!; + } + + async function seedAgent(companyId: string) { + const [agent] = await db.insert(agents).values({ + companyId, + name: "Cases Agent", + role: "engineer", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }).returning(); + return agent!; + } + + const boardActor: Express.Request["actor"] = { + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + }; + + it("gates every case route when enableCases is off", async () => { + const company = await seedCompany("OFF"); + const [caseRow] = await db.insert(cases).values({ + companyId: company.id, + caseNumber: 1, + identifier: `${company.issuePrefix}-C1`, + caseType: "bug", + title: "Hidden case", + }).returning(); + const http = request(app(boardActor)); + + await http.get(`/api/companies/${company.id}/cases`).expect(403); + await http.post(`/api/companies/${company.id}/cases`).send({ caseType: "bug", title: "Bug" }).expect(403); + await http.get(`/api/cases/${caseRow!.id}`).expect(403); + await http.patch(`/api/cases/${caseRow!.id}`).send({ status: "in_progress" }).expect(403); + await http.put(`/api/cases/${caseRow!.id}/documents/body`).send({ body: "Body" }).expect(403); + await http.get(`/api/cases/${caseRow!.id}/documents/body/annotations`).expect(403); + await http.post(`/api/cases/${caseRow!.id}/links`).send({ issueId: randomUUID(), role: "work" }).expect(403); + await http.post(`/api/cases/${caseRow!.id}/attachments`).attach("file", Buffer.from("x"), "x.txt").expect(403); + await http.get(`/api/cases/${caseRow!.id}/events`).expect(403); + }); + + it("falls through shared /cases paths to later routers when the id is not a Cases row", async () => { + // Pipelines mounts its own /cases/:caseId routes after caseRoutes in app.ts; + // pipeline case ids must reach that router regardless of the enableCases flag. + const instance = express(); + instance.use(express.json()); + instance.use((req, _res, next) => { + req.actor = boardActor; + next(); + }); + instance.use("/api", caseRoutes(db, storage)); + const pipelinesStandIn = express.Router(); + pipelinesStandIn.get("/cases/:caseId", (_req, res) => res.json({ handledBy: "pipelines" })); + pipelinesStandIn.patch("/cases/:caseId", (_req, res) => res.json({ handledBy: "pipelines" })); + pipelinesStandIn.put("/cases/:caseId/documents/:key", (_req, res) => res.json({ handledBy: "pipelines" })); + pipelinesStandIn.get("/cases/:caseId/documents/:key/revisions", (_req, res) => res.json({ handledBy: "pipelines" })); + pipelinesStandIn.get("/cases/:caseId/events", (_req, res) => res.json({ handledBy: "pipelines" })); + instance.use("/api", pipelinesStandIn); + instance.use(errorHandler); + const http = request(instance); + + const foreignId = randomUUID(); + // Flag off: non-Cases ids are not blocked by the Cases gate. + await http.get(`/api/cases/${foreignId}`).expect(200, { handledBy: "pipelines" }); + // Body is not validated against Cases schemas before falling through. + await http.patch(`/api/cases/${foreignId}`).send({ stageKey: "review" }).expect(200, { handledBy: "pipelines" }); + await http.put(`/api/cases/${foreignId}/documents/body`).send({ markdown: "x" }).expect(200, { handledBy: "pipelines" }); + await http.get(`/api/cases/${foreignId}/documents/body/revisions`).expect(200, { handledBy: "pipelines" }); + await http.get(`/api/cases/${foreignId}/events`).expect(200, { handledBy: "pipelines" }); + + // Flag on: real Cases rows are still handled by the cases router, unknown ids still fall through. + await enableCases(); + const company = await seedCompany("FALL"); + const [caseRow] = await db.insert(cases).values({ + companyId: company.id, + caseNumber: 1, + identifier: `${company.issuePrefix}-C1`, + caseType: "bug", + title: "Ours", + }).returning(); + const detail = await http.get(`/api/cases/${caseRow!.id}`).expect(200); + expect(detail.body.identifier).toBe(caseRow!.identifier); + await http.get(`/api/cases/${foreignId}`).expect(200, { handledBy: "pipelines" }); + }); + + it("creates cases and upserts idempotently by type and key", async () => { + await enableCases(); + const company = await seedCompany("UPS"); + const http = request(app(boardActor)); + + const first = await http + .post(`/api/companies/${company.id}/cases`) + .send({ + caseType: "security", + key: "CVE-1", + title: "Investigate report", + fields: { severity: "high" }, + }) + .expect(201); + const second = await http + .post(`/api/companies/${company.id}/cases`) + .send({ + caseType: "security", + key: "CVE-1", + title: "Investigate report again", + fields: { severity: "critical" }, + }) + .expect(200); + + expect(second.body.id).toBe(first.body.id); + expect(first.body.identifier).toBe(`${company.issuePrefix.toUpperCase()}-C1`); + const all = await db.select().from(cases); + expect(all).toHaveLength(1); + expect(all[0]!.title).toBe("Investigate report again"); + expect(all[0]!.fields).toEqual({ severity: "critical" }); + }); + + it("converges concurrent keyed upserts to one case", async () => { + await enableCases(); + const company = await seedCompany("RCE"); + const http = request(app(boardActor)); + + const requests = [ + http.post(`/api/companies/${company.id}/cases`).send({ + caseType: "release_note", + key: "2026-07-07", + title: "Release note A", + fields: { channel: "stable" }, + }), + http.post(`/api/companies/${company.id}/cases`).send({ + caseType: "release_note", + key: "2026-07-07", + title: "Release note B", + fields: { channel: "canary" }, + }), + ]; + + const responses = await Promise.all(requests); + expect(responses.map((res) => res.status).sort()).toEqual([200, 201]); + expect(responses[0]!.body.id).toBe(responses[1]!.body.id); + + const all = await db.select().from(cases); + expect(all).toHaveLength(1); + expect(all[0]!.caseType).toBe("release_note"); + expect(all[0]!.key).toBe("2026-07-07"); + expect(["Release note A", "Release note B"]).toContain(all[0]!.title); + expect([{ channel: "stable" }, { channel: "canary" }]).toContainEqual(all[0]!.fields); + }); + + it("upserts keyless cases by company and type", async () => { + await enableCases(); + const company = await seedCompany("NUL"); + const http = request(app(boardActor)); + + const first = await http + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "release_note", title: "Draft release note" }) + .expect(201); + const second = await http + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "release_note", title: "Updated release note" }) + .expect(200); + + expect(second.body.id).toBe(first.body.id); + expect(second.body.key).toBeNull(); + expect(second.body.title).toBe("Updated release note"); + const all = await db.select().from(cases); + expect(all).toHaveLength(1); + }); + + it("resolves cases by identifier", async () => { + await enableCases(); + const company = await seedCompany("REF"); + const http = request(app(boardActor)); + + const created = await http + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "blog_post", key: "launch", title: "Launch post" }) + .expect(201); + + const byIdentifier = await http.get(`/api/cases/${created.body.identifier}`).expect(200); + expect(byIdentifier.body.id).toBe(created.body.id); + expect(byIdentifier.body.identifier).toMatch(/^REF[A-Z0-9]{4}-C1$/); + }); + + it("auto-links run writes to their issue with a work link and event", async () => { + await enableCases(); + const company = await seedCompany("RUN"); + const agent = await seedAgent(company.id); + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId: company.id, + agentId: agent.id, + status: "running", + }); + const [issue] = await db.insert(issues).values({ + companyId: company.id, + title: "Source task", + status: "in_progress", + executionRunId: runId, + }).returning(); + const created = await request(app(boardActor)) + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "bug", title: "Bug" }) + .expect(201); + + const agentActor: Express.Request["actor"] = { + type: "agent", + companyId: company.id, + agentId: agent.id, + runId, + source: "agent_jwt", + onBehalfOfUserId: null, + onBehalfOfMemberships: [], + }; + await request(app(agentActor)) + .patch(`/api/cases/${created.body.id}`) + .send({ fields: { rootCause: "missing coverage" } }) + .expect(200); + + const links = await db.select().from(caseIssueLinks); + expect(links).toHaveLength(1); + expect(links[0]!.caseId).toBe(created.body.id); + expect(links[0]!.issueId).toBe(issue!.id); + expect(links[0]!.role).toBe("work"); + expect(links[0]!.createdByRunId).toBe(runId); + + const linkedEvents = await db.select().from(caseEvents).where(eq(caseEvents.kind, "issue_linked")); + expect(linkedEvents).toHaveLength(1); + expect(linkedEvents[0]!.actorAgentId).toBe(agent.id); + expect(linkedEvents[0]!.runId).toBe(runId); + expect(linkedEvents[0]!.payload).toMatchObject({ issueId: issue!.id, role: "work", autoLinked: true }); + }); + + it("lets a run-scoped agent JWT complete the case happy path without manual linking", async () => { + await enableCases(); + const company = await seedCompany("JWT"); + const agent = await seedAgent(company.id); + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId: company.id, + agentId: agent.id, + status: "running", + }); + const [issue] = await db.insert(issues).values({ + companyId: company.id, + title: "Agent case source", + status: "in_progress", + executionRunId: runId, + }).returning(); + const token = createLocalAgentJwt(agent.id, company.id, agent.adapterType, runId); + expect(token).toBeTruthy(); + + const http = request(authenticatedApp()); + const createResponse = await http + .post(`/api/companies/${company.id}/cases`) + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", runId) + .send({ + caseType: "blog_post", + key: "launch-post", + title: "Launch post", + fields: { slug: "launch-post", target_audience: "operators" }, + }) + .expect(201); + const caseId = createResponse.body.id as string; + + await http + .put(`/api/cases/${createResponse.body.identifier}/documents/body`) + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", runId) + .send({ body: "# Launch\n\nDraft body." }) + .expect(200); + + await http + .patch(`/api/cases/${caseId}`) + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", runId) + .send({ + status: "in_review", + fields: { slug: "launch-post", target_audience: "operators", publish_url: "https://example.com/launch" }, + }) + .expect(200); + + await http + .post(`/api/cases/${caseId}/attachments`) + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", runId) + .attach("file", Buffer.from("asset"), "asset.txt") + .expect(201); + + const links = await db.select().from(caseIssueLinks); + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ + companyId: company.id, + caseId, + issueId: issue!.id, + role: "origin", + createdByRunId: runId, + }); + + const detail = await http + .get(`/api/cases/${createResponse.body.identifier}`) + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", runId) + .expect(200); + expect(detail.body.status).toBe("in_review"); + expect(detail.body.documents).toHaveLength(1); + expect(detail.body.attachments).toHaveLength(1); + expect(detail.body.issueLinks).toHaveLength(1); + + const eventRows = await db.select().from(caseEvents); + expect(eventRows.map((event) => event.kind)).toEqual(expect.arrayContaining([ + "created", + "issue_linked", + "document_revised", + "status_changed", + "attachment_added", + ])); + expect(eventRows.filter((event) => event.runId === runId)).toHaveLength(eventRows.length); + }); + + it("rejects cross-company agent access across the cases route surface", async () => { + await enableCases(); + const ownCompany = await seedCompany("OWN"); + const otherCompany = await seedCompany("OTH"); + const agent = await seedAgent(ownCompany.id); + const [otherIssue] = await db.insert(issues).values({ + companyId: otherCompany.id, + identifier: `${otherCompany.issuePrefix.toUpperCase()}-1`, + title: "Other company task", + status: "todo", + }).returning(); + const [caseRow] = await db.insert(cases).values({ + companyId: otherCompany.id, + caseNumber: 1, + identifier: `${otherCompany.issuePrefix.toUpperCase()}-C1`, + caseType: "bug", + title: "Other company case", + }).returning(); + const [ownCase] = await db.insert(cases).values({ + companyId: ownCompany.id, + caseNumber: 1, + identifier: `${ownCompany.issuePrefix.toUpperCase()}-C1`, + caseType: "bug", + title: "Own company case", + }).returning(); + await db.insert(caseEvents).values({ + companyId: otherCompany.id, + caseId: caseRow!.id, + kind: "created", + actorType: "system", + payload: {}, + }); + + const agentActor: Express.Request["actor"] = { + type: "agent", + companyId: ownCompany.id, + agentId: agent.id, + source: "agent_key", + keyId: "key-1", + onBehalfOfUserId: "user-1", + onBehalfOfMemberships: [], + }; + const http = request(app(agentActor)); + + await http.get(`/api/companies/${otherCompany.id}/cases`).expect(403); + await http + .post(`/api/companies/${otherCompany.id}/cases`) + .send({ caseType: "bug", title: "Wrong company create" }) + .expect(403); + await http.get(`/api/cases/${caseRow!.id}`).expect(404); + await http.get(`/api/cases/${caseRow!.identifier}`).expect(404); + await http.patch(`/api/cases/${caseRow!.id}`).send({ status: "in_progress" }).expect(404); + await http.put(`/api/cases/${caseRow!.id}/documents/body`).send({ body: "Body" }).expect(404); + await http + .post(`/api/cases/${caseRow!.id}/links`) + .send({ issueId: otherIssue!.id, role: "reference" }) + .expect(404); + await http + .post(`/api/cases/${caseRow!.id}/attachments`) + .attach("file", Buffer.from("artifact"), "artifact.txt") + .expect(404); + await http.get(`/api/cases/${caseRow!.id}/events`).expect(404); + await http.get(`/api/issues/${otherIssue!.id}/cases`).expect(404); + await http.get(`/api/issues/${otherIssue!.identifier}/cases`).expect(404); + + const limitedBoardActor: Express.Request["actor"] = { + type: "board", + userId: "limited-board-user", + source: "session", + isInstanceAdmin: false, + companyIds: [ownCompany.id], + memberships: [{ companyId: ownCompany.id, membershipRole: "operator", status: "active" }], + }; + const limitedBoardHttp = request(app(limitedBoardActor)); + const ownCaseResponse = await limitedBoardHttp.get(`/api/cases/${ownCase!.id}`).expect(200); + expect(ownCaseResponse.body.id).toBe(ownCase!.id); + await limitedBoardHttp.get(`/api/cases/${caseRow!.id}`).expect(404); + await limitedBoardHttp.get(`/api/cases/${caseRow!.identifier}`).expect(404); + await limitedBoardHttp.get(`/api/issues/${otherIssue!.id}/cases`).expect(404); + await limitedBoardHttp.get(`/api/issues/${otherIssue!.identifier}/cases`).expect(404); + + const scopedAdminHttp = request(app({ ...limitedBoardActor, isInstanceAdmin: true })); + await scopedAdminHttp.get(`/api/cases/${caseRow!.id}`).expect(404); + await scopedAdminHttp.get(`/api/cases/${caseRow!.identifier}`).expect(404); + await scopedAdminHttp.get(`/api/issues/${otherIssue!.id}/cases`).expect(404); + await scopedAdminHttp.get(`/api/issues/${otherIssue!.identifier}/cases`).expect(404); + + expect(await db.select().from(cases)).toHaveLength(2); + expect(await db.select().from(caseDocuments)).toHaveLength(0); + expect(await db.select().from(documents)).toHaveLength(0); + expect(await db.select().from(caseIssueLinks)).toHaveLength(0); + expect(await db.select().from(caseAttachments)).toHaveLength(0); + expect(await db.select().from(assets)).toHaveLength(0); + expect(await db.select().from(caseEvents)).toHaveLength(1); + }); + + it("supports documents, manual issue links, attachment links, events, and list filters", async () => { + await enableCases(); + const company = await seedCompany("SUR"); + const [label] = await db.insert(labels).values({ + companyId: company.id, + name: "Needs Review", + color: "#f59e0b", + }).returning(); + const [issue] = await db.insert(issues).values({ + companyId: company.id, + identifier: `${company.issuePrefix.toUpperCase()}-12`, + title: "Related task", + status: "todo", + }).returning(); + const http = request(app(boardActor)); + const created = await http + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "incident", title: "Production incident", status: "in_progress" }) + .expect(201); + + await http.patch(`/api/cases/${created.body.id}`).send({ labels: [label!.id] }).expect(200); + await http.put(`/api/cases/${created.body.identifier}/documents/runbook`).send({ body: "Steps" }).expect(200); + await http.post(`/api/cases/${created.body.id}/links`).send({ issueId: issue!.id, role: "reference" }).expect(201); + await http.post(`/api/cases/${created.body.id}/attachments`).attach("file", Buffer.from("artifact"), "artifact.txt").expect(201); + + const activeList = await http + .get(`/api/companies/${company.id}/cases`) + .query({ status: "active", label: label!.id, q: "Production" }) + .expect(200); + expect(activeList.body).toHaveLength(1); + expect(activeList.body[0].id).toBe(created.body.id); + + const [project] = await db.insert(projects).values({ companyId: company.id, name: "Launch" }).returning(); + const [projectCase] = await db.insert(cases).values({ + companyId: company.id, + projectId: project!.id, + caseNumber: 50, + identifier: `${company.issuePrefix.toUpperCase()}-C50`, + caseType: "brief", + title: "Project brief", + status: "draft", + }).returning(); + const multiFiltered = await http + .get(`/api/companies/${company.id}/cases`) + .query({ types: ["incident", "brief"], statuses: ["in_progress", "draft"], projectIds: [project!.id], includeNoProject: "true" }) + .expect(200); + expect(multiFiltered.body.map((row: { id: string }) => row.id).sort()).toEqual([created.body.id, projectCase!.id].sort()); + + await db.insert(cases).values(Array.from({ length: 205 }, (_, index) => ({ + companyId: company.id, + caseNumber: 100 + index, + identifier: `${company.issuePrefix.toUpperCase()}-C${100 + index}`, + caseType: "incident", + title: `Filler incident ${index}`, + status: "in_progress", + updatedAt: new Date(`2030-01-01T00:${String(index % 60).padStart(2, "0")}:00.000Z`), + }))); + const deepFiltered = await http + .get(`/api/companies/${company.id}/cases`) + .query({ q: "Production", limit: 1 }) + .expect(200); + expect(deepFiltered.body).toHaveLength(1); + expect(deepFiltered.body[0].id).toBe(created.body.id); + + const detail = await http.get(`/api/cases/${created.body.identifier}`).expect(200); + expect(detail.body.labels).toHaveLength(1); + expect(detail.body.documents).toHaveLength(1); + expect(detail.body.issueLinks).toHaveLength(1); + expect(detail.body.attachments).toHaveLength(1); + + const events = await http.get(`/api/cases/${created.body.id}/events`).expect(200); + expect(events.body.map((event: { kind: string }) => event.kind)).toEqual( + expect.arrayContaining(["created", "label_added", "document_revised", "issue_linked", "attachment_added"]), + ); + const linkedEvent = events.body.find((event: { kind: string }) => event.kind === "issue_linked"); + expect(linkedEvent.issue).toMatchObject({ + id: issue!.id, + identifier: issue!.identifier, + title: "Related task", + status: "todo", + }); + }); + + it("enriches events and revisions with actor name and run→issue attribution", async () => { + await enableCases(); + const company = await seedCompany("ATT"); + const agent = await seedAgent(company.id); + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId: company.id, + agentId: agent.id, + status: "running", + }); + const [issue] = await db.insert(issues).values({ + companyId: company.id, + title: "Attribution source task", + status: "in_progress", + executionRunId: runId, + }).returning(); + + const agentActor: Express.Request["actor"] = { + type: "agent", + companyId: company.id, + agentId: agent.id, + runId, + source: "agent_jwt", + onBehalfOfUserId: null, + onBehalfOfMemberships: [], + }; + const http = request(app(agentActor)); + + const created = await http + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "blog_post", title: "Attribution post" }) + .expect(201); + // Two revisions on the body document. + const rev1 = await http + .put(`/api/cases/${created.body.id}/documents/body`) + .send({ body: "# v1" }) + .expect(200); + await http + .put(`/api/cases/${created.body.id}/documents/body`) + .send({ body: "# v2", baseRevisionId: rev1.body.revision.id, changeSummary: "polish" }) + .expect(200); + + const events = await http.get(`/api/cases/${created.body.id}/events`).expect(200); + const revisedEvent = events.body.find((e: { kind: string }) => e.kind === "document_revised"); + expect(revisedEvent.actorAgentName).toBe("Cases Agent"); + expect(revisedEvent.issue).toMatchObject({ id: issue!.id, title: "Attribution source task" }); + + const revisions = await http + .get(`/api/cases/${created.body.id}/documents/body/revisions`) + .expect(200); + expect(revisions.body.revisions).toHaveLength(2); + expect(revisions.body.revisions[0].revisionNumber).toBe(2); + expect(revisions.body.revisions[0].body).toBe("# v2"); + expect(revisions.body.revisions[0].changeSummary).toBe("polish"); + expect(revisions.body.revisions[0].actorAgentName).toBe("Cases Agent"); + expect(revisions.body.revisions[0].issue).toMatchObject({ id: issue!.id }); + }); + + it("locks, unlocks, deletes, and restores case documents through shared document controls", async () => { + await enableCases(); + const company = await seedCompany("DOC"); + const http = request(app(boardActor)); + const created = await http + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "blog_post", title: "Document controls" }) + .expect(201); + + const firstRevision = await http + .put(`/api/cases/${created.body.id}/documents/body`) + .send({ body: "# v1" }) + .expect(200); + const secondRevision = await http + .put(`/api/cases/${created.body.id}/documents/body`) + .send({ body: "# v2", baseRevisionId: firstRevision.body.revision.id }) + .expect(200); + + const loaded = await http.get(`/api/cases/${created.body.id}/documents/body`).expect(200); + expect(loaded.body.body).toBe("# v2"); + expect(loaded.body.latestRevisionNumber).toBe(2); + + const locked = await http.post(`/api/cases/${created.body.id}/documents/body/lock`).expect(200); + expect(locked.body.lockedAt).toBeTruthy(); + await http + .put(`/api/cases/${created.body.id}/documents/body`) + .send({ body: "# blocked", baseRevisionId: secondRevision.body.revision.id }) + .expect(409); + await http.delete(`/api/cases/${created.body.id}/documents/body`).expect(409); + + const unlocked = await http.post(`/api/cases/${created.body.id}/documents/body/unlock`).expect(200); + expect(unlocked.body.lockedAt).toBeNull(); + + const restored = await http + .post(`/api/cases/${created.body.id}/documents/body/revisions/${firstRevision.body.revision.id}/restore`) + .expect(200); + expect(restored.body.document.body).toBe("# v1"); + expect(restored.body.document.latestRevisionNumber).toBe(3); + expect(restored.body.restoredFromRevisionNumber).toBe(1); + + const revisions = await http.get(`/api/cases/${created.body.id}/documents/body/revisions`).expect(200); + expect(revisions.body.revisions).toHaveLength(3); + expect(revisions.body.revisions[0].changeSummary).toBe("Restored from revision 1"); + + await http.delete(`/api/cases/${created.body.id}/documents/body`).expect(200); + await http.get(`/api/cases/${created.body.id}`).expect(200).expect((res) => { + expect(res.body.documents).toHaveLength(0); + }); + }); + + it("creates, replies to, resolves, reopens, and remaps case document annotations", async () => { + await enableCases(); + const company = await seedCompany("ANN"); + const http = request(app(boardActor)); + const created = await http + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "brief", title: "Annotated case" }) + .expect(201); + const document = await http + .put(`/api/cases/${created.body.id}/documents/body`) + .send({ body: "Alpha beta gamma" }) + .expect(200); + + const annotation = await http + .post(`/api/cases/${created.body.id}/documents/body/annotations`) + .send({ + baseRevisionId: document.body.revision.id, + baseRevisionNumber: document.body.revision.revisionNumber, + selector: { + quote: { exact: "beta", prefix: "Alpha ", suffix: " gamma" }, + position: { normalizedStart: 6, normalizedEnd: 10, markdownStart: 6, markdownEnd: 10 }, + }, + body: "Clarify this word.", + }) + .expect(201); + + expect(annotation.body.caseId).toBe(created.body.id); + expect(annotation.body.issueId).toBeNull(); + expect(annotation.body.routineId).toBeNull(); + expect(annotation.body.comments[0].caseId).toBe(created.body.id); + + const listed = await http + .get(`/api/cases/${created.body.identifier}/documents/body/annotations?status=all&includeComments=true`) + .expect(200); + expect(listed.body).toHaveLength(1); + expect(listed.body[0].comments).toHaveLength(1); + + const reply = await http + .post(`/api/cases/${created.body.id}/documents/body/annotations/${annotation.body.id}/comments`) + .send({ body: "Added context." }) + .expect(201); + expect(reply.body.caseId).toBe(created.body.id); + + const resolved = await http + .patch(`/api/cases/${created.body.id}/documents/body/annotations/${annotation.body.id}`) + .send({ status: "resolved" }) + .expect(200); + expect(resolved.body.status).toBe("resolved"); + + const reopened = await http + .patch(`/api/cases/${created.body.id}/documents/body/annotations/${annotation.body.id}`) + .send({ status: "open" }) + .expect(200); + expect(reopened.body.status).toBe("open"); + + const updatedDocument = await http + .put(`/api/cases/${created.body.id}/documents/body`) + .send({ + body: "Alpha beta gamma delta", + baseRevisionId: document.body.revision.id, + }) + .expect(200); + const remapped = await http + .get(`/api/cases/${created.body.id}/documents/body/annotations/${annotation.body.id}`) + .expect(200); + expect(remapped.body.currentRevisionNumber).toBe(updatedDocument.body.revision.revisionNumber); + expect(remapped.body.comments).toHaveLength(2); + + const activities = await db + .select({ action: activityLog.action, entityType: activityLog.entityType, entityId: activityLog.entityId }) + .from(activityLog) + .where(eq(activityLog.entityId, created.body.id)); + expect(activities).toEqual(expect.arrayContaining([ + expect.objectContaining({ entityType: "case", action: "case.document_annotation_thread_created" }), + expect.objectContaining({ entityType: "case", action: "case.document_annotation_comment_added" }), + expect.objectContaining({ entityType: "case", action: "case.document_annotation_thread_resolved" }), + expect.objectContaining({ entityType: "case", action: "case.document_annotation_thread_reopened" }), + expect.objectContaining({ entityType: "case", action: "case.document_annotation_remapped" }), + ])); + }); + + it("lists children by parent, exposes parent in detail, and lists cases for an issue", async () => { + await enableCases(); + const company = await seedCompany("TREE"); + const boardHttp = request(app(boardActor)); + const parent = await boardHttp + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "epic", title: "Parent epic" }) + .expect(201); + const child = await boardHttp + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "task", title: "Child task", parentCaseId: parent.body.id }) + .expect(201); + + const children = await boardHttp + .get(`/api/companies/${company.id}/cases`) + .query({ parent: parent.body.id }) + .expect(200); + expect(children.body).toHaveLength(1); + expect(children.body[0].id).toBe(child.body.id); + + const searchOnly = await boardHttp + .get(`/api/companies/${company.id}/cases`) + .query({ q: "Child task" }) + .expect(200); + expect(searchOnly.body.map((row: { id: string }) => row.id)).toEqual([child.body.id]); + + const searchWithAncestors = await boardHttp + .get(`/api/companies/${company.id}/cases`) + .query({ q: "Child task", includeAncestors: "true" }) + .expect(200); + expect(searchWithAncestors.body).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: child.body.id, matchesListFilters: true }), + expect.objectContaining({ id: parent.body.id, matchesListFilters: false }), + ])); + + const childDetail = await boardHttp.get(`/api/cases/${child.body.id}`).expect(200); + expect(childDetail.body.parent).toMatchObject({ id: parent.body.id, identifier: parent.body.identifier }); + + // Link the child case to an issue, then resolve cases-for-issue. + const [issue] = await db.insert(issues).values({ + companyId: company.id, + title: "Issue with cases", + status: "todo", + }).returning(); + await boardHttp + .post(`/api/cases/${child.body.id}/links`) + .send({ issueId: issue!.id, role: "work" }) + .expect(201); + + const forIssue = await boardHttp.get(`/api/issues/${issue!.id}/cases`).expect(200); + expect(forIssue.body).toHaveLength(1); + expect(forIssue.body[0]).toMatchObject({ role: "work" }); + expect(forIssue.body[0].case).toMatchObject({ id: child.body.id, identifier: child.body.identifier, status: child.body.status }); + }); +}); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 85cb08c0e4..089a2877e8 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -26,6 +26,7 @@ describe("instance settings service", () => { enableConferenceRoomChat: false, enableExternalObjects: false, enablePipelines: false, + enableCases: false, enableIssuePlanDecompositions: true, enableExperimentalFileViewer: true, enableTaskWatchdogs: true, diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 9736c25694..5eb8c8e87e 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -55,6 +55,8 @@ const HTTP_METHODS = new Set(["get", "put", "post", "delete", "options", "head", const explicitOpenApiCoverageExclusions = new Set([ // Pipeline routes are experimental and not yet represented in the public OpenAPI document. "pipelines.ts", + // Case routes are experimental (enableCases flag) and not yet in the public OpenAPI document. + "cases.ts", ]); function createApp() { diff --git a/server/src/app.ts b/server/src/app.ts index e5c266f469..75e0f1fe24 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -20,6 +20,7 @@ import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.js"; import { issueTreeControlRoutes } from "./routes/issue-tree-control.js"; +import { caseRoutes } from "./routes/cases.js"; import { fileResourceRoutes } from "./routes/file-resources.js"; import { routineRoutes } from "./routes/routines.js"; import { pipelineRoutes } from "./routes/pipelines.js"; @@ -238,6 +239,7 @@ export async function createApp( feedbackExportService: opts.feedbackExportService, pluginWorkerManager: workerManager, })); + api.use(caseRoutes(db, opts.storageService)); api.use(issueTreeControlRoutes(db)); api.use(fileResourceRoutes(db)); api.use(routineRoutes(db, { pluginWorkerManager: workerManager })); diff --git a/server/src/routes/cases.ts b/server/src/routes/cases.ts new file mode 100644 index 0000000000..15a3c43e4e --- /dev/null +++ b/server/src/routes/cases.ts @@ -0,0 +1,1539 @@ +import { Router, type Request, type Response } from "express"; +import multer from "multer"; +import { z } from "zod"; +import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agents, + assets, + caseAttachments, + caseDocuments, + caseEvents, + caseIssueLinks, + caseLabels, + cases, + companies, + documents, + documentRevisions, + issues, + labels, + projects, +} from "@paperclipai/db"; +import { + createDocumentAnnotationCommentSchema, + createDocumentAnnotationThreadSchema, + updateDocumentAnnotationThreadSchema, + isUuidLike, +} from "@paperclipai/shared"; +import { normalizeContentType } from "../attachment-types.js"; +import { badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js"; +import { validate } from "../middleware/validate.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; +import { documentAnnotationService, logActivity } from "../services/index.js"; +import type { StorageService } from "../storage/types.js"; +import { assertCompanyAccess, getActorInfo } from "./authz.js"; + +type CaseRouteDb = Db | Parameters[0]>[0]; +type CaseActor = ReturnType; + +const CASE_STATUSES = ["draft", "in_progress", "in_review", "approved", "done", "cancelled"] as const; +const CASE_LINK_ROLES = ["origin", "work", "reference"] as const; +const DEFAULT_EVENTS_LIMIT = 100; +const MAX_EVENTS_LIMIT = 500; + +const jsonObjectSchema = z.record(z.string(), z.unknown()); +const caseStatusSchema = z.enum(CASE_STATUSES); +const caseTypeSchema = z.string().trim().min(1).max(120); +const caseKeySchema = z.string().trim().min(1).max(512); +const documentKeySchema = z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9_.:-]+$/); + +const createCaseSchema = z.object({ + projectId: z.string().uuid().nullable().optional(), + caseType: caseTypeSchema, + key: caseKeySchema.nullable().optional(), + title: z.string().trim().min(1).max(500), + summary: z.string().max(8_000).nullable().optional(), + status: caseStatusSchema.optional(), + fields: jsonObjectSchema.optional(), + parentCaseId: z.string().uuid().nullable().optional(), +}).strict(); + +const patchCaseSchema = z.object({ + projectId: z.string().uuid().nullable().optional(), + title: z.string().trim().min(1).max(500).optional(), + summary: z.string().max(8_000).nullable().optional(), + status: caseStatusSchema.optional(), + fields: jsonObjectSchema.optional(), + parentCaseId: z.string().uuid().nullable().optional(), + labels: z.array(z.string().uuid()).max(100).optional(), + labelIds: z.array(z.string().uuid()).max(100).optional(), +}).strict(); + +const createIssueLinkSchema = z.object({ + issueId: z.string().uuid(), + role: z.enum(CASE_LINK_ROLES), +}).strict(); + +const upsertCaseDocumentSchema = z.object({ + title: z.string().trim().min(1).max(200).optional(), + format: z.string().trim().min(1).max(80).optional().default("markdown"), + body: z.string().max(200_000), + changeSummary: z.string().trim().max(1_000).nullable().optional(), + baseRevisionId: z.string().uuid().nullable().optional(), +}).strict(); + +const queryListParamSchema = z.union([z.string(), z.array(z.string())]).optional(); + +const listCasesQuerySchema = z.object({ + type: z.string().trim().min(1).max(120).optional(), + types: queryListParamSchema, + status: z.string().trim().min(1).max(120).optional(), + statuses: queryListParamSchema, + project: z.string().uuid().optional(), + projectId: z.string().uuid().optional(), + projectIds: queryListParamSchema, + includeNoProject: z.enum(["true", "false", "1", "0"]).optional(), + label: z.string().uuid().optional(), + labelId: z.string().uuid().optional(), + parent: z.string().uuid().optional(), + q: z.string().trim().min(1).max(200).optional(), + includeAncestors: z.enum(["true", "false", "1", "0"]).optional(), + limit: z.coerce.number().int().min(1).max(200).optional().default(100), +}).strict(); + +const listEventsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(MAX_EVENTS_LIMIT).optional().default(DEFAULT_EVENTS_LIMIT), +}).strict(); + +function eventActorValues(actor: CaseActor) { + return { + actorType: actor.actorType, + actorUserId: actor.actorType === "user" ? actor.actorId : null, + actorAgentId: actor.agentId, + runId: actor.runId && isUuidLike(actor.runId) ? actor.runId : null, + }; +} + +async function assertCasesEnabled(db: Db) { + const experimental = await instanceSettingsService(db).getExperimental(); + if (!experimental.enableCases) { + throw forbidden("Cases are disabled"); + } +} + +async function lockCaseUpsertKey(db: CaseRouteDb, input: { companyId: string; caseType: string; key: string | null | undefined }) { + const lockKey = `paperclip:case-upsert:${input.companyId}:${input.caseType}:${input.key ?? ""}`; + await db.execute(sql`select pg_advisory_xact_lock(hashtext(${lockKey}))`); +} + +async function lockCaseDocumentKey(db: CaseRouteDb, input: { companyId: string; caseId: string; key: string }) { + const lockKey = `paperclip:case-document:${input.companyId}:${input.caseId}:${input.key}`; + await db.execute(sql`select pg_advisory_xact_lock(hashtext(${lockKey}))`); +} + +async function lockCaseLabels(db: CaseRouteDb, input: { companyId: string; caseId: string }) { + const lockKey = `paperclip:case-labels:${input.companyId}:${input.caseId}`; + await db.execute(sql`select pg_advisory_xact_lock(hashtext(${lockKey}))`); +} + +function parseDocumentKey(raw: string | undefined) { + const parsed = documentKeySchema.safeParse(raw); + if (!parsed.success) throw badRequest("Invalid document key", parsed.error.issues); + return parsed.data; +} + +function parseBooleanQuery(value: unknown) { + return value === true || value === "true" || value === "1"; +} + +function parseQueryList(value: string | string[] | undefined): string[] { + const values = Array.isArray(value) ? value : value ? [value] : []; + return values.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean); +} + +function annotationActorInput(req: Request) { + const actor = getActorInfo(req); + return { + actor, + annotationActor: { + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + runId: actor.runId, + }, + }; +} + +async function loadCaseByIdOrIdentifier(db: CaseRouteDb, idOrIdentifier: string, companyIds?: string[]) { + if (companyIds && companyIds.length === 0) return null; + const normalizedIdentifier = idOrIdentifier.trim().toUpperCase(); + const identityWhere = isUuidLike(idOrIdentifier) + ? or(eq(cases.id, idOrIdentifier), eq(cases.identifier, normalizedIdentifier)) + : eq(cases.identifier, normalizedIdentifier); + const where = companyIds + ? and(identityWhere, inArray(cases.companyId, companyIds)) + : identityWhere; + return db.select().from(cases).where(where).limit(1).then((rows) => rows[0] ?? null); +} + +async function loadIssueByIdOrIdentifier(db: CaseRouteDb, idOrIdentifier: string, companyIds?: string[]) { + if (companyIds && companyIds.length === 0) return null; + const normalizedIdentifier = idOrIdentifier.trim().toUpperCase(); + const identityWhere = isUuidLike(idOrIdentifier) + ? or(eq(issues.id, idOrIdentifier), eq(issues.identifier, normalizedIdentifier)) + : eq(issues.identifier, normalizedIdentifier); + const where = companyIds + ? and(identityWhere, inArray(issues.companyId, companyIds)) + : identityWhere; + return db + .select({ id: issues.id, companyId: issues.companyId }) + .from(issues) + .where(where) + .limit(1) + .then((rows) => rows[0] ?? null); +} + +function caseLookupCompanyIds(req: Request) { + if (req.actor.type === "agent") return req.actor.companyId ? [req.actor.companyId] : []; + if (req.actor.type === "board" && req.actor.source === "local_implicit") return undefined; + if (req.actor.type === "board" && Array.isArray(req.actor.companyIds) && req.actor.companyIds.length > 0) { + return req.actor.companyIds; + } + if (req.actor.type === "board" && req.actor.isInstanceAdmin) return undefined; + return []; +} + +async function assertCaseAccess(db: Db, req: Request, idOrIdentifier: string) { + const row = await loadCaseByIdOrIdentifier(db, idOrIdentifier, caseLookupCompanyIds(req)); + if (!row) throw notFound("Case not found"); + assertCompanyAccess(req, row.companyId); + return row; +} + +// The pipelines feature registers its own /cases/:caseId routes after this +// router. On paths both features share, return null (caller falls through via +// next()) when the id is not a new-Cases row so pipeline case requests still +// reach their handler regardless of the enableCases flag. +async function resolveSharedPathCase(db: Db, req: Request, idOrIdentifier: string) { + const companyIds = caseLookupCompanyIds(req); + const row = await loadCaseByIdOrIdentifier(db, idOrIdentifier, companyIds); + if (!row) return null; + await assertCasesEnabled(db); + assertCompanyAccess(req, row.companyId); + return row; +} + +async function assertProjectBelongsToCompany(db: CaseRouteDb, input: { companyId: string; projectId: string | null }) { + if (!input.projectId) return; + const row = await db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.id, input.projectId), eq(projects.companyId, input.companyId))) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!row) throw unprocessable("Project does not belong to company"); +} + +async function assertParentCaseBelongsToCompany(db: CaseRouteDb, input: { + companyId: string; + caseId?: string; + parentCaseId: string | null; +}) { + if (!input.parentCaseId) return; + if (input.caseId && input.parentCaseId === input.caseId) { + throw unprocessable("A case cannot be its own parent"); + } + const row = await db + .select({ id: cases.id }) + .from(cases) + .where(and(eq(cases.id, input.parentCaseId), eq(cases.companyId, input.companyId))) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!row) throw unprocessable("Parent case does not belong to company"); +} + +async function assertLabelsBelongToCompany(db: CaseRouteDb, companyId: string, labelIds: string[]) { + if (labelIds.length === 0) return; + const uniqueIds = [...new Set(labelIds)]; + const rows = await db + .select({ id: labels.id }) + .from(labels) + .where(and(eq(labels.companyId, companyId), inArray(labels.id, uniqueIds))); + if (rows.length !== uniqueIds.length) { + throw unprocessable("One or more labels do not belong to company"); + } +} + +async function insertCaseEvent(db: CaseRouteDb, input: { + companyId: string; + caseId: string; + kind: typeof caseEvents.$inferInsert["kind"]; + actor: CaseActor; + payload?: Record; +}) { + const now = new Date(); + const [event] = await db.insert(caseEvents).values({ + companyId: input.companyId, + caseId: input.caseId, + kind: input.kind, + ...eventActorValues(input.actor), + payload: input.payload ?? {}, + createdAt: now, + updatedAt: now, + }).returning(); + return event!; +} + +async function resolveIssueForRun(db: CaseRouteDb, companyId: string, runId: string | null | undefined) { + if (!runId || !isUuidLike(runId)) return null; + return db + .select({ id: issues.id }) + .from(issues) + .where(and( + eq(issues.companyId, companyId), + or( + eq(issues.executionRunId, runId), + eq(issues.checkoutRunId, runId), + eq(issues.originRunId, runId), + ), + )) + .orderBy(desc(issues.updatedAt), desc(issues.createdAt)) + .limit(1) + .then((rows) => rows[0] ?? null); +} + +/** Batch resolve agent display names for a set of agent ids. */ +async function resolveAgentNames(db: CaseRouteDb, agentIds: (string | null)[]) { + const valid = [...new Set(agentIds.filter((id): id is string => !!id))]; + if (valid.length === 0) return new Map(); + const rows = await db + .select({ id: agents.id, name: agents.name }) + .from(agents) + .where(inArray(agents.id, valid)); + return new Map(rows.map((row) => [row.id, row.name])); +} + +/** + * Batch resolve run → issue attribution. Mirrors resolveIssueForRun's precedence + * (latest-updated issue whose execution/checkout/origin run matches), but for a + * whole set of runs at once so the activity feed / revisions rail avoid N+1s. + */ +async function resolveIssuesForRuns(db: CaseRouteDb, companyId: string, runIds: (string | null)[]) { + const valid = [...new Set(runIds.filter((id): id is string => !!id && isUuidLike(id)))]; + const map = new Map(); + if (valid.length === 0) return map; + const rows = await db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + executionRunId: issues.executionRunId, + checkoutRunId: issues.checkoutRunId, + originRunId: issues.originRunId, + updatedAt: issues.updatedAt, + createdAt: issues.createdAt, + }) + .from(issues) + .where(and( + eq(issues.companyId, companyId), + or( + inArray(issues.executionRunId, valid), + inArray(issues.checkoutRunId, valid), + inArray(issues.originRunId, valid), + ), + )) + .orderBy(desc(issues.updatedAt), desc(issues.createdAt)); + for (const runId of valid) { + const match = rows.find( + (row) => row.executionRunId === runId || row.checkoutRunId === runId || row.originRunId === runId, + ); + if (match) { + map.set(runId, { id: match.id, identifier: match.identifier ?? match.id, title: match.title, status: match.status }); + } + } + return map; +} + +function payloadIssueIdForEvent(kind: string, payload: Record | null | undefined) { + if (kind !== "issue_linked" && kind !== "issue_unlinked") return null; + const issueId = payload?.issueId; + return typeof issueId === "string" && isUuidLike(issueId) ? issueId : null; +} + +async function resolveIssuesByIds(db: CaseRouteDb, companyId: string, issueIds: (string | null)[]) { + const valid = [...new Set(issueIds.filter((id): id is string => !!id && isUuidLike(id)))]; + const map = new Map(); + if (valid.length === 0) return map; + const rows = await db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), inArray(issues.id, valid))); + for (const row of rows) { + map.set(row.id, { id: row.id, identifier: row.identifier ?? row.id, title: row.title, status: row.status }); + } + return map; +} + +async function autoLinkRunIssue(db: CaseRouteDb, input: { + companyId: string; + caseId: string; + actor: CaseActor; + role: "origin" | "work"; +}) { + const issue = await resolveIssueForRun(db, input.companyId, input.actor.runId); + if (!issue) return null; + const now = new Date(); + const [link] = await db.insert(caseIssueLinks).values({ + companyId: input.companyId, + caseId: input.caseId, + issueId: issue.id, + role: input.role, + createdByRunId: input.actor.runId && isUuidLike(input.actor.runId) ? input.actor.runId : null, + createdAt: now, + updatedAt: now, + }).onConflictDoNothing({ + target: [caseIssueLinks.caseId, caseIssueLinks.issueId], + }).returning(); + if (!link) return null; + await insertCaseEvent(db, { + companyId: input.companyId, + caseId: input.caseId, + kind: "issue_linked", + actor: input.actor, + payload: { issueId: issue.id, role: input.role, autoLinked: true }, + }); + return link; +} + +async function nextCaseIdentity(db: CaseRouteDb, companyId: string) { + await db.execute(sql`select pg_advisory_xact_lock(hashtext(${`paperclip:cases:${companyId}`}))`); + const [company] = await db + .select({ issuePrefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, companyId)) + .limit(1); + if (!company) throw notFound("Company not found"); + const [maxRow] = await db + .select({ maxNum: sql`coalesce(max(${cases.caseNumber}), 0)` }) + .from(cases) + .where(eq(cases.companyId, companyId)); + const caseNumber = (maxRow?.maxNum ?? 0) + 1; + return { + caseNumber, + identifier: `${company.issuePrefix.toUpperCase()}-C${caseNumber}`, + }; +} + +function completedAtForStatus(status: string, previous?: Date | null) { + if (status === "done" || status === "cancelled") return previous ?? new Date(); + return null; +} + +type PatchCaseBody = z.infer; + +export function buildCasePatchUpdateValues( + body: PatchCaseBody, + caseRow: Pick, + now: Date, +) { + const status = body.status ?? caseRow.status; + return { + ...(Object.hasOwn(body, "projectId") ? { projectId: body.projectId ?? null } : {}), + ...(body.title !== undefined ? { title: body.title } : {}), + ...(Object.hasOwn(body, "summary") ? { summary: body.summary ?? null } : {}), + ...(body.status !== undefined ? { status, completedAt: completedAtForStatus(status, caseRow.completedAt) } : {}), + ...(body.fields !== undefined ? { fields: body.fields } : {}), + ...(Object.hasOwn(body, "parentCaseId") ? { parentCaseId: body.parentCaseId ?? null } : {}), + updatedAt: now, + }; +} + +async function loadCaseDetail(db: CaseRouteDb, row: typeof cases.$inferSelect) { + const [labelRows, linkRows, documentRows, attachmentRows] = await Promise.all([ + db + .select({ label: labels }) + .from(caseLabels) + .innerJoin(labels, eq(caseLabels.labelId, labels.id)) + .where(and(eq(caseLabels.companyId, row.companyId), eq(caseLabels.caseId, row.id))) + .orderBy(asc(labels.name)), + db + .select({ link: caseIssueLinks, issue: issues }) + .from(caseIssueLinks) + .innerJoin(issues, eq(caseIssueLinks.issueId, issues.id)) + .where(and(eq(caseIssueLinks.companyId, row.companyId), eq(caseIssueLinks.caseId, row.id))) + .orderBy(asc(caseIssueLinks.createdAt)), + db + .select({ link: caseDocuments, document: documents }) + .from(caseDocuments) + .innerJoin(documents, eq(caseDocuments.documentId, documents.id)) + .where(and(eq(caseDocuments.companyId, row.companyId), eq(caseDocuments.caseId, row.id))) + .orderBy(asc(caseDocuments.key)), + db + .select({ link: caseAttachments, asset: assets }) + .from(caseAttachments) + .innerJoin(assets, eq(caseAttachments.assetId, assets.id)) + .where(and(eq(caseAttachments.companyId, row.companyId), eq(caseAttachments.caseId, row.id))) + .orderBy(asc(caseAttachments.createdAt)), + ]); + const parent = row.parentCaseId + ? await db + .select({ + id: cases.id, + identifier: cases.identifier, + title: cases.title, + caseType: cases.caseType, + status: cases.status, + }) + .from(cases) + .where(eq(cases.id, row.parentCaseId)) + .limit(1) + .then((rows) => rows[0] ?? null) + : null; + return { + ...row, + parent, + labels: labelRows.map((item) => item.label), + issueLinks: linkRows.map((item) => ({ + ...item.link, + issue: { + id: item.issue.id, + identifier: item.issue.identifier, + title: item.issue.title, + status: item.issue.status, + }, + })), + documents: documentRows.map((item) => ({ + key: item.link.key, + document: item.document, + })), + attachments: attachmentRows.map((item) => ({ + id: item.link.id, + asset: item.asset, + createdAt: item.link.createdAt, + updatedAt: item.link.updatedAt, + })), + }; +} + +async function loadCaseDocumentLink(db: CaseRouteDb, input: { companyId: string; caseId: string; key: string }) { + return db + .select({ link: caseDocuments, document: documents }) + .from(caseDocuments) + .innerJoin(documents, eq(caseDocuments.documentId, documents.id)) + .where(and( + eq(caseDocuments.companyId, input.companyId), + eq(caseDocuments.caseId, input.caseId), + eq(caseDocuments.key, input.key), + )) + .limit(1) + .then((rows) => rows[0] ?? null); +} + +async function includeCaseAncestors( + db: CaseRouteDb, + companyId: string, + baseRows: Array, +) { + const baseIds = new Set(baseRows.map((row) => row.id)); + const rowsById = new Map(baseRows.map((row) => [row.id, row])); + const ancestorRows: Array = []; + let pending = [...new Set( + baseRows + .map((row) => row.parentCaseId) + .filter((id): id is string => { + if (!id) return false; + return !rowsById.has(id); + }), + )]; + + while (pending.length > 0) { + const ancestors = await db + .select() + .from(cases) + .where(and(eq(cases.companyId, companyId), inArray(cases.id, pending))); + const nextPending = new Set(); + for (const row of ancestors) { + if (rowsById.has(row.id)) continue; + rowsById.set(row.id, row); + ancestorRows.push(row); + if (row.parentCaseId && !rowsById.has(row.parentCaseId)) { + nextPending.add(row.parentCaseId); + } + } + pending = [...nextPending]; + } + + return [...baseRows, ...ancestorRows].map((row) => ({ + ...row, + matchesListFilters: baseIds.has(row.id), + })); +} + +function caseDocumentResponse(input: { key: string; document: typeof documents.$inferSelect }) { + return { + ...input.document, + key: input.key, + body: input.document.latestBody, + }; +} + +function singleFileUpload(req: Request, res: Response, maxBytes: number) { + const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: maxBytes, files: 1 }, + }).single("file"); + return new Promise((resolve, reject) => { + upload(req, res, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +export function caseRoutes(db: Db, storage: StorageService) { + const router = Router(); + const documentAnnotationsSvc = documentAnnotationService(db); + + async function logCaseAnnotationRemaps(input: { + caseRow: typeof cases.$inferSelect; + key: string; + document: Pick; + body: string; + actor: CaseActor; + }) { + const remapped = await documentAnnotationsSvc.remapOpenThreadsForCaseDocument({ + caseId: input.caseRow.id, + key: input.key, + documentId: input.document.id, + nextRevisionId: input.document.latestRevisionId, + nextRevisionNumber: input.document.latestRevisionNumber, + nextBody: input.body, + }); + for (const remap of remapped) { + await logActivity(db, { + companyId: input.caseRow.companyId, + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId, + runId: input.actor.runId, + action: "case.document_annotation_remapped", + entityType: "case", + entityId: input.caseRow.id, + details: { + key: input.key, + documentKey: input.key, + documentId: input.document.id, + threadId: remap.thread.id, + revisionNumber: input.document.latestRevisionNumber, + anchorState: remap.thread.anchorState, + anchorConfidence: remap.thread.anchorConfidence, + snapshotId: remap.snapshot.id, + }, + }); + } + } + + router.post("/companies/:companyId/cases", validate(createCaseSchema), async (req, res) => { + await assertCasesEnabled(db); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const actor = getActorInfo(req); + const body = req.body as z.infer; + + const result = await db.transaction(async (tx) => { + await assertProjectBelongsToCompany(tx, { companyId, projectId: body.projectId ?? null }); + await assertParentCaseBelongsToCompany(tx, { companyId, parentCaseId: body.parentCaseId ?? null }); + await lockCaseUpsertKey(tx, { companyId, caseType: body.caseType, key: body.key }); + const keyFilter = body.key ? eq(cases.key, body.key) : isNull(cases.key); + + const now = new Date(); + const existing = await tx + .select() + .from(cases) + .where(and(eq(cases.companyId, companyId), eq(cases.caseType, body.caseType), keyFilter)) + .limit(1) + .then((rows) => rows[0] ?? null); + + if (existing) { + const status = body.status ?? existing.status; + const [updated] = await tx.update(cases).set({ + projectId: body.projectId ?? existing.projectId, + title: body.title, + summary: body.summary ?? existing.summary, + status, + fields: body.fields ?? existing.fields, + parentCaseId: body.parentCaseId ?? existing.parentCaseId, + completedAt: completedAtForStatus(status, existing.completedAt), + updatedAt: now, + }).where(eq(cases.id, existing.id)).returning(); + await insertCaseEvent(tx, { + companyId, + caseId: existing.id, + kind: "updated", + actor, + payload: { upsert: true }, + }); + await autoLinkRunIssue(tx, { companyId, caseId: existing.id, actor, role: "origin" }); + return { created: false, row: updated! }; + } + + const identity = await nextCaseIdentity(tx, companyId); + const status = body.status ?? "draft"; + const [created] = await tx.insert(cases).values({ + companyId, + projectId: body.projectId ?? null, + ...identity, + caseType: body.caseType, + key: body.key ?? null, + title: body.title, + summary: body.summary ?? null, + status, + fields: body.fields ?? {}, + parentCaseId: body.parentCaseId ?? null, + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + completedAt: completedAtForStatus(status), + createdAt: now, + updatedAt: now, + }).returning(); + await insertCaseEvent(tx, { + companyId, + caseId: created!.id, + kind: "created", + actor, + payload: { caseType: body.caseType, key: body.key ?? null }, + }); + await autoLinkRunIssue(tx, { companyId, caseId: created!.id, actor, role: "origin" }); + return { created: true, row: created! }; + }); + + res.status(result.created ? 201 : 200).json(await loadCaseDetail(db, result.row)); + }); + + router.get("/companies/:companyId/cases", async (req, res) => { + await assertCasesEnabled(db); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const parsed = listCasesQuerySchema.safeParse(req.query); + if (!parsed.success) throw badRequest("Invalid case list query", parsed.error.issues); + const query = parsed.data; + const filters = [eq(cases.companyId, companyId)]; + const typeFilters = parseQueryList(query.types ?? query.type); + if (typeFilters.length === 1) filters.push(eq(cases.caseType, typeFilters[0]!)); + else if (typeFilters.length > 1) filters.push(inArray(cases.caseType, typeFilters)); + + const statusFilters = parseQueryList(query.statuses ?? (query.status === "active" ? undefined : query.status)); + if (query.status === "active" && statusFilters.length === 0) { + filters.push(sql`${cases.status} not in ('done', 'cancelled')`); + } else if (statusFilters.length > 0) { + for (const status of statusFilters) { + if (!CASE_STATUSES.includes(status as (typeof CASE_STATUSES)[number])) { + throw badRequest("Invalid case status"); + } + } + filters.push(statusFilters.length === 1 ? eq(cases.status, statusFilters[0]!) : inArray(cases.status, statusFilters)); + } + + const projectFilters = parseQueryList(query.projectIds ?? query.projectId ?? query.project); + for (const projectId of projectFilters) { + if (!isUuidLike(projectId)) throw badRequest("Invalid project id"); + } + const includeNoProject = parseBooleanQuery(query.includeNoProject); + if (projectFilters.length > 0 && includeNoProject) { + filters.push(or(inArray(cases.projectId, projectFilters), isNull(cases.projectId))!); + } else if (projectFilters.length === 1) { + filters.push(eq(cases.projectId, projectFilters[0]!)); + } else if (projectFilters.length > 1) { + filters.push(inArray(cases.projectId, projectFilters)); + } else if (includeNoProject) { + filters.push(isNull(cases.projectId)); + } + if (query.parent) filters.push(eq(cases.parentCaseId, query.parent)); + const labelId = query.labelId ?? query.label; + if (labelId) { + filters.push(sql`${cases.id} in ( + select ${caseLabels.caseId} from ${caseLabels} + where ${caseLabels.companyId} = ${companyId} and ${caseLabels.labelId} = ${labelId} + )`); + } + if (query.q) { + const pattern = `%${query.q.replaceAll("%", "\\%").replaceAll("_", "\\_")}%`; + filters.push(or( + ilike(cases.identifier, pattern), + ilike(cases.title, pattern), + ilike(cases.summary, pattern), + ilike(cases.key, pattern), + )!); + } + + const rows = await db + .select() + .from(cases) + .where(and(...filters)) + .orderBy(desc(cases.updatedAt), desc(cases.createdAt)) + .limit(query.limit); + res.json(parseBooleanQuery(query.includeAncestors) ? await includeCaseAncestors(db, companyId, rows) : rows); + }); + + router.get("/cases/:id/documents/:key", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const link = await loadCaseDocumentLink(db, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + if (!link) throw notFound("Case document not found"); + res.json(caseDocumentResponse({ key, document: link.document })); + }); + + router.get("/cases/:id/documents/:key/annotations", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const status = req.query.status === "resolved" || req.query.status === "all" ? req.query.status : "open"; + const threads = await documentAnnotationsSvc.listThreadsForCaseDocument(caseRow.id, key, { + status, + includeComments: parseBooleanQuery(req.query.includeComments), + }); + res.json(threads); + }); + + router.get("/cases/:id/documents/:key/annotations/:threadId", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const thread = await documentAnnotationsSvc.getThreadForCaseDocument( + caseRow.id, + key, + req.params.threadId as string, + ); + if (!thread) throw notFound("Annotation thread not found"); + res.json(thread); + }); + + router.post( + "/cases/:id/documents/:key/annotations", + validate(createDocumentAnnotationThreadSchema), + async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const { actor, annotationActor } = annotationActorInput(req); + const thread = await documentAnnotationsSvc.createCaseThread( + caseRow.id, + key, + req.body, + annotationActor, + ); + const firstComment = thread.comments[0]; + await logActivity(db, { + companyId: caseRow.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "case.document_annotation_thread_created", + entityType: "case", + entityId: caseRow.id, + details: { + key: thread.documentKey, + documentKey: thread.documentKey, + documentId: thread.documentId, + threadId: thread.id, + commentId: firstComment?.id ?? null, + revisionNumber: thread.currentRevisionNumber, + quote: thread.selectedText.slice(0, 240), + }, + }); + res.status(201).json(thread); + }, + ); + + router.post( + "/cases/:id/documents/:key/annotations/:threadId/comments", + validate(createDocumentAnnotationCommentSchema), + async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const { actor, annotationActor } = annotationActorInput(req); + const comment = await documentAnnotationsSvc.addCaseComment( + caseRow.id, + key, + req.params.threadId as string, + req.body, + annotationActor, + ); + await logActivity(db, { + companyId: caseRow.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "case.document_annotation_comment_added", + entityType: "case", + entityId: caseRow.id, + details: { + key, + documentKey: key, + threadId: comment.threadId, + commentId: comment.id, + bodySnippet: comment.body.slice(0, 120), + }, + }); + res.status(201).json(comment); + }, + ); + + router.patch( + "/cases/:id/documents/:key/annotations/:threadId", + validate(updateDocumentAnnotationThreadSchema), + async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const { actor, annotationActor } = annotationActorInput(req); + const thread = await documentAnnotationsSvc.updateCaseThread( + caseRow.id, + key, + req.params.threadId as string, + req.body, + annotationActor, + ); + await logActivity(db, { + companyId: caseRow.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: thread.status === "resolved" + ? "case.document_annotation_thread_resolved" + : "case.document_annotation_thread_reopened", + entityType: "case", + entityId: caseRow.id, + details: { + key: thread.documentKey, + documentKey: thread.documentKey, + documentId: thread.documentId, + threadId: thread.id, + status: thread.status, + }, + }); + res.json(thread); + }, + ); + + router.put("/cases/:id/documents/:key", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const actor = getActorInfo(req); + const body = upsertCaseDocumentSchema.parse(req.body); + + const result = await db.transaction(async (tx) => { + await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + const existing = await tx + .select({ link: caseDocuments, document: documents, revision: documentRevisions }) + .from(caseDocuments) + .innerJoin(documents, eq(caseDocuments.documentId, documents.id)) + .leftJoin(documentRevisions, eq(documents.latestRevisionId, documentRevisions.id)) + .where(and( + eq(caseDocuments.companyId, caseRow.companyId), + eq(caseDocuments.caseId, caseRow.id), + eq(caseDocuments.key, key), + )) + .limit(1) + .then((rows) => rows[0] ?? null); + + if (existing?.document.lockedAt) { + throw conflict("Document is locked", { + key, + documentId: existing.document.id, + lockedAt: existing.document.lockedAt, + }); + } + if (existing && !body.baseRevisionId) { + throw conflict("Case document update requires baseRevisionId", { + code: "stale_base_revision", + latestRevisionId: existing.document.latestRevisionId, + latestRevisionNumber: existing.document.latestRevisionNumber, + }); + } + if (existing && body.baseRevisionId !== existing.document.latestRevisionId) { + throw conflict("Case document was updated by someone else", { + code: "stale_base_revision", + latestRevisionId: existing.document.latestRevisionId, + latestRevisionNumber: existing.document.latestRevisionNumber, + latestRevision: existing.revision, + }); + } + if (!existing && body.baseRevisionId) { + throw conflict("Case document does not exist yet", { + code: "stale_base_revision", + latestRevisionId: null, + latestRevisionNumber: null, + }); + } + + const now = new Date(); + const [document] = existing + ? await tx.update(documents).set({ + title: body.title ?? existing.document.title, + format: body.format, + updatedAt: now, + updatedByAgentId: actor.agentId, + updatedByUserId: actor.actorType === "user" ? actor.actorId : null, + }).where(eq(documents.id, existing.document.id)).returning() + : await tx.insert(documents).values({ + companyId: caseRow.companyId, + title: body.title ?? key, + format: body.format, + latestBody: body.body, + latestRevisionNumber: 1, + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + updatedByAgentId: actor.agentId, + updatedByUserId: actor.actorType === "user" ? actor.actorId : null, + createdAt: now, + updatedAt: now, + }).returning(); + const nextRevisionNumber = existing ? existing.document.latestRevisionNumber + 1 : 1; + const [revision] = await tx.insert(documentRevisions).values({ + companyId: caseRow.companyId, + documentId: document!.id, + revisionNumber: nextRevisionNumber, + title: body.title ?? document!.title, + format: body.format, + body: body.body, + changeSummary: body.changeSummary ?? null, + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + createdByRunId: actor.runId && isUuidLike(actor.runId) ? actor.runId : null, + createdAt: now, + }).returning(); + await tx.update(documents).set({ + title: body.title ?? document!.title, + format: body.format, + latestBody: body.body, + latestRevisionId: revision!.id, + latestRevisionNumber: revision!.revisionNumber, + updatedByAgentId: actor.agentId, + updatedByUserId: actor.actorType === "user" ? actor.actorId : null, + updatedAt: now, + }).where(eq(documents.id, document!.id)); + if (!existing) { + await tx.insert(caseDocuments).values({ + companyId: caseRow.companyId, + caseId: caseRow.id, + documentId: document!.id, + key, + createdAt: now, + updatedAt: now, + }); + } else { + await tx.update(caseDocuments).set({ updatedAt: now }).where(eq(caseDocuments.documentId, document!.id)); + } + await insertCaseEvent(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + kind: "document_revised", + actor, + payload: { key, documentId: document!.id, revisionId: revision!.id, revisionNumber: revision!.revisionNumber }, + }); + await autoLinkRunIssue(tx, { companyId: caseRow.companyId, caseId: caseRow.id, actor, role: "work" }); + return { + document: { + ...document!, + title: body.title ?? document!.title, + format: body.format, + latestBody: body.body, + latestRevisionId: revision!.id, + latestRevisionNumber: revision!.revisionNumber, + updatedAt: now, + }, + revision, + }; + }); + await logCaseAnnotationRemaps({ + caseRow, + key, + document: result.document, + body: result.document.latestBody, + actor, + }); + res.json(result); + }); + + router.post("/cases/:id/documents/:key/lock", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const actor = getActorInfo(req); + const result = await db.transaction(async (tx) => { + await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + const link = await loadCaseDocumentLink(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + if (!link) throw notFound("Case document not found"); + if (link.document.lockedAt) return caseDocumentResponse({ key, document: link.document }); + const now = new Date(); + const [document] = await tx.update(documents).set({ + lockedAt: now, + lockedByAgentId: actor.agentId, + lockedByUserId: actor.actorType === "user" ? actor.actorId : null, + updatedAt: now, + }).where(eq(documents.id, link.document.id)).returning(); + await tx.update(caseDocuments).set({ updatedAt: now }).where(eq(caseDocuments.documentId, link.document.id)); + return caseDocumentResponse({ key, document: document! }); + }); + res.json(result); + }); + + router.post("/cases/:id/documents/:key/unlock", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const result = await db.transaction(async (tx) => { + await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + const link = await loadCaseDocumentLink(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + if (!link) throw notFound("Case document not found"); + if (!link.document.lockedAt) return caseDocumentResponse({ key, document: link.document }); + const now = new Date(); + const [document] = await tx.update(documents).set({ + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, + updatedAt: now, + }).where(eq(documents.id, link.document.id)).returning(); + await tx.update(caseDocuments).set({ updatedAt: now }).where(eq(caseDocuments.documentId, link.document.id)); + return caseDocumentResponse({ key, document: document! }); + }); + res.json(result); + }); + + router.post("/cases/:id/documents/:key/revisions/:revisionId/restore", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const revisionId = req.params.revisionId as string; + const actor = getActorInfo(req); + + const result = await db.transaction(async (tx) => { + await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + const existing = await loadCaseDocumentLink(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + if (!existing) throw notFound("Case document not found"); + if (existing.document.lockedAt) { + throw conflict("Document is locked", { + key, + documentId: existing.document.id, + lockedAt: existing.document.lockedAt, + }); + } + const sourceRevision = await tx + .select() + .from(documentRevisions) + .where(and(eq(documentRevisions.id, revisionId), eq(documentRevisions.documentId, existing.document.id))) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!sourceRevision) throw notFound("Case document revision not found"); + if (existing.document.latestRevisionId === sourceRevision.id) { + throw conflict("Selected revision is already the latest revision", { + currentRevisionId: existing.document.latestRevisionId, + }); + } + + const now = new Date(); + const nextRevisionNumber = existing.document.latestRevisionNumber + 1; + const [restoredRevision] = await tx.insert(documentRevisions).values({ + companyId: caseRow.companyId, + documentId: existing.document.id, + revisionNumber: nextRevisionNumber, + title: sourceRevision.title ?? null, + format: sourceRevision.format, + body: sourceRevision.body, + changeSummary: `Restored from revision ${sourceRevision.revisionNumber}`, + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + createdByRunId: actor.runId && isUuidLike(actor.runId) ? actor.runId : null, + createdAt: now, + }).returning(); + const [document] = await tx.update(documents).set({ + title: sourceRevision.title ?? null, + format: sourceRevision.format, + latestBody: sourceRevision.body, + latestRevisionId: restoredRevision!.id, + latestRevisionNumber: nextRevisionNumber, + updatedByAgentId: actor.agentId, + updatedByUserId: actor.actorType === "user" ? actor.actorId : null, + updatedAt: now, + }).where(eq(documents.id, existing.document.id)).returning(); + await tx.update(caseDocuments).set({ updatedAt: now }).where(eq(caseDocuments.documentId, existing.document.id)); + await insertCaseEvent(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + kind: "document_revised", + actor, + payload: { + key, + documentId: existing.document.id, + revisionId: restoredRevision!.id, + revisionNumber: restoredRevision!.revisionNumber, + restoredFromRevisionId: sourceRevision.id, + restoredFromRevisionNumber: sourceRevision.revisionNumber, + }, + }); + await autoLinkRunIssue(tx, { companyId: caseRow.companyId, caseId: caseRow.id, actor, role: "work" }); + return { + document: caseDocumentResponse({ key, document: document! }), + revision: restoredRevision!, + restoredFromRevisionId: sourceRevision.id, + restoredFromRevisionNumber: sourceRevision.revisionNumber, + }; + }); + await logCaseAnnotationRemaps({ + caseRow, + key, + document: result.document, + body: result.document.body, + actor, + }); + res.json(result); + }); + + router.delete("/cases/:id/documents/:key", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + await db.transaction(async (tx) => { + await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + const link = await loadCaseDocumentLink(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + if (!link) return; + if (link.document.lockedAt) { + throw conflict("Document is locked", { + key, + documentId: link.document.id, + lockedAt: link.document.lockedAt, + }); + } + await tx.delete(caseDocuments).where(eq(caseDocuments.documentId, link.document.id)); + await tx.delete(documents).where(eq(documents.id, link.document.id)); + }); + res.json({ ok: true }); + }); + + router.post("/cases/:id/links", validate(createIssueLinkSchema), async (req, res) => { + await assertCasesEnabled(db); + const caseRow = await assertCaseAccess(db, req, req.params.id as string); + const actor = getActorInfo(req); + const body = req.body as z.infer; + + const result = await db.transaction(async (tx) => { + const issue = await tx + .select({ id: issues.id }) + .from(issues) + .where(and(eq(issues.id, body.issueId), eq(issues.companyId, caseRow.companyId))) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!issue) throw unprocessable("Issue does not belong to case company"); + const now = new Date(); + const [link] = await tx.insert(caseIssueLinks).values({ + companyId: caseRow.companyId, + caseId: caseRow.id, + issueId: body.issueId, + role: body.role, + createdByRunId: actor.runId && isUuidLike(actor.runId) ? actor.runId : null, + createdAt: now, + updatedAt: now, + }).onConflictDoNothing({ + target: [caseIssueLinks.caseId, caseIssueLinks.issueId], + }).returning(); + if (link) { + await insertCaseEvent(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + kind: "issue_linked", + actor, + payload: { issueId: body.issueId, role: body.role, autoLinked: false }, + }); + } + return link ?? await tx + .select() + .from(caseIssueLinks) + .where(and(eq(caseIssueLinks.caseId, caseRow.id), eq(caseIssueLinks.issueId, body.issueId))) + .limit(1) + .then((rows) => rows[0]); + }); + res.status(201).json(result); + }); + + router.post("/cases/:id/attachments", async (req, res) => { + await assertCasesEnabled(db); + const caseRow = await assertCaseAccess(db, req, req.params.id as string); + const actor = getActorInfo(req); + const [company] = await db + .select({ attachmentMaxBytes: companies.attachmentMaxBytes }) + .from(companies) + .where(eq(companies.id, caseRow.companyId)) + .limit(1); + const maxBytes = company?.attachmentMaxBytes ?? 10 * 1024 * 1024; + + try { + await singleFileUpload(req, res, maxBytes); + } catch (err) { + if (err instanceof multer.MulterError) { + if (err.code === "LIMIT_FILE_SIZE") { + throw unprocessable(`Attachment exceeds ${maxBytes} bytes`); + } + throw badRequest(err.message); + } + throw err; + } + const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string } }).file; + if (!file) throw badRequest("Missing file field 'file'"); + if (file.buffer.length <= 0) throw unprocessable("Attachment is empty"); + + const stored = await storage.putFile({ + companyId: caseRow.companyId, + namespace: `cases/${caseRow.id}`, + originalFilename: file.originalname || null, + contentType: normalizeContentType(file.mimetype), + body: file.buffer, + }); + const result = await db.transaction(async (tx) => { + const now = new Date(); + const [asset] = await tx.insert(assets).values({ + companyId: caseRow.companyId, + provider: stored.provider, + objectKey: stored.objectKey, + contentType: stored.contentType, + byteSize: stored.byteSize, + sha256: stored.sha256, + originalFilename: stored.originalFilename, + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + createdAt: now, + updatedAt: now, + }).returning(); + const [attachment] = await tx.insert(caseAttachments).values({ + companyId: caseRow.companyId, + caseId: caseRow.id, + assetId: asset!.id, + createdAt: now, + updatedAt: now, + }).returning(); + await insertCaseEvent(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + kind: "attachment_added", + actor, + payload: { attachmentId: attachment!.id, assetId: asset!.id, originalFilename: asset!.originalFilename }, + }); + await autoLinkRunIssue(tx, { companyId: caseRow.companyId, caseId: caseRow.id, actor, role: "work" }); + return { ...attachment!, asset }; + }); + res.status(201).json(result); + }); + + router.get("/cases/:id/events", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const parsed = listEventsQuerySchema.safeParse(req.query); + if (!parsed.success) throw badRequest("Invalid case events query", parsed.error.issues); + const rows = await db + .select() + .from(caseEvents) + .where(and(eq(caseEvents.companyId, caseRow.companyId), eq(caseEvents.caseId, caseRow.id))) + .orderBy(desc(caseEvents.createdAt), desc(caseEvents.id)) + .limit(parsed.data.limit); + // Enrich each row with its actor's display name, run→issue attribution, + // and the linked issue captured in link/unlink payloads. + const payloadIssueIds = rows.map((row) => payloadIssueIdForEvent(row.kind, row.payload)); + const [agentNames, issueMap, payloadIssueMap] = await Promise.all([ + resolveAgentNames(db, rows.map((row) => row.actorAgentId)), + resolveIssuesForRuns(db, caseRow.companyId, rows.map((row) => row.runId)), + resolveIssuesByIds(db, caseRow.companyId, payloadIssueIds), + ]); + res.json(rows.map((row) => ({ + ...row, + actorAgentName: row.actorAgentId ? agentNames.get(row.actorAgentId) ?? null : null, + issue: payloadIssueMap.get(payloadIssueIdForEvent(row.kind, row.payload) ?? "") + ?? (row.runId ? issueMap.get(row.runId) ?? null : null), + }))); + }); + + router.get("/cases/:id/documents/:key/revisions", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const link = await db + .select({ documentId: caseDocuments.documentId, document: documents }) + .from(caseDocuments) + .innerJoin(documents, eq(caseDocuments.documentId, documents.id)) + .where(and( + eq(caseDocuments.companyId, caseRow.companyId), + eq(caseDocuments.caseId, caseRow.id), + eq(caseDocuments.key, key), + )) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!link) throw notFound("Case document not found"); + const revisions = await db + .select() + .from(documentRevisions) + .where(and( + eq(documentRevisions.companyId, caseRow.companyId), + eq(documentRevisions.documentId, link.documentId), + )) + .orderBy(desc(documentRevisions.revisionNumber)); + const [agentNames, issueMap] = await Promise.all([ + resolveAgentNames(db, revisions.map((rev) => rev.createdByAgentId)), + resolveIssuesForRuns(db, caseRow.companyId, revisions.map((rev) => rev.createdByRunId)), + ]); + res.json({ + key, + document: { + id: link.document.id, + title: link.document.title, + format: link.document.format, + latestRevisionId: link.document.latestRevisionId, + latestRevisionNumber: link.document.latestRevisionNumber, + }, + revisions: revisions.map((rev) => ({ + id: rev.id, + revisionNumber: rev.revisionNumber, + title: rev.title, + format: rev.format, + body: rev.body, + changeSummary: rev.changeSummary, + createdAt: rev.createdAt, + createdByAgentId: rev.createdByAgentId, + createdByUserId: rev.createdByUserId, + createdByRunId: rev.createdByRunId, + actorAgentName: rev.createdByAgentId ? agentNames.get(rev.createdByAgentId) ?? null : null, + issue: rev.createdByRunId ? issueMap.get(rev.createdByRunId) ?? null : null, + })), + }); + }); + + router.get("/issues/:issueId/cases", async (req, res) => { + await assertCasesEnabled(db); + const issueIdOrIdentifier = (req.params.issueId as string).trim(); + const issue = await loadIssueByIdOrIdentifier(db, issueIdOrIdentifier, caseLookupCompanyIds(req)); + if (!issue) throw notFound("Issue not found"); + assertCompanyAccess(req, issue.companyId); + const rows = await db + .select({ link: caseIssueLinks, caseRow: cases }) + .from(caseIssueLinks) + .innerJoin(cases, eq(caseIssueLinks.caseId, cases.id)) + .where(and(eq(caseIssueLinks.companyId, issue.companyId), eq(caseIssueLinks.issueId, issue.id))) + .orderBy(asc(caseIssueLinks.createdAt)); + res.json(rows.map((row) => ({ + id: row.link.id, + role: row.link.role, + createdAt: row.link.createdAt, + case: { + id: row.caseRow.id, + identifier: row.caseRow.identifier, + title: row.caseRow.title, + caseType: row.caseRow.caseType, + status: row.caseRow.status, + }, + }))); + }); + + router.get("/cases/:id", async (req, res, next) => { + const row = await resolveSharedPathCase(db, req, req.params.id as string); + if (!row) return next(); + res.json(await loadCaseDetail(db, row)); + }); + + router.patch("/cases/:id", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const actor = getActorInfo(req); + const body = patchCaseSchema.parse(req.body); + const nextLabelIds = body.labelIds ?? body.labels; + + const updated = await db.transaction(async (tx) => { + await assertProjectBelongsToCompany(tx, { companyId: caseRow.companyId, projectId: body.projectId ?? null }); + await assertParentCaseBelongsToCompany(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + parentCaseId: body.parentCaseId ?? null, + }); + if (nextLabelIds) await assertLabelsBelongToCompany(tx, caseRow.companyId, nextLabelIds); + + const now = new Date(); + const [row] = await tx.update(cases).set(buildCasePatchUpdateValues(body, caseRow, now)).where(eq(cases.id, caseRow.id)).returning(); + + if (nextLabelIds) { + await lockCaseLabels(tx, { companyId: caseRow.companyId, caseId: caseRow.id }); + const current = await tx + .select({ labelId: caseLabels.labelId }) + .from(caseLabels) + .where(and(eq(caseLabels.companyId, caseRow.companyId), eq(caseLabels.caseId, caseRow.id))); + const currentIds = new Set(current.map((item) => item.labelId)); + const desiredIds = new Set(nextLabelIds); + const added = [...desiredIds].filter((id) => !currentIds.has(id)); + const removed = [...currentIds].filter((id) => !desiredIds.has(id)); + if (removed.length > 0) { + await tx.delete(caseLabels).where(and(eq(caseLabels.caseId, caseRow.id), inArray(caseLabels.labelId, removed))); + for (const labelId of removed) { + await insertCaseEvent(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + kind: "label_removed", + actor, + payload: { labelId }, + }); + } + } + if (added.length > 0) { + await tx.insert(caseLabels).values(added.map((labelId) => ({ + companyId: caseRow.companyId, + caseId: caseRow.id, + labelId, + createdAt: now, + updatedAt: now, + }))).onConflictDoNothing(); + for (const labelId of added) { + await insertCaseEvent(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + kind: "label_added", + actor, + payload: { labelId }, + }); + } + } + } + + const kind = body.status !== undefined + ? "status_changed" + : body.fields !== undefined + ? "fields_changed" + : Object.hasOwn(body, "parentCaseId") && body.parentCaseId + ? "child_linked" + : "updated"; + await insertCaseEvent(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + kind, + actor, + payload: { + previousStatus: body.status !== undefined ? caseRow.status : undefined, + status: body.status, + parentCaseId: body.parentCaseId, + }, + }); + await autoLinkRunIssue(tx, { companyId: caseRow.companyId, caseId: caseRow.id, actor, role: "work" }); + return row!; + }); + res.json(await loadCaseDetail(db, updated)); + }); + + return router; +} diff --git a/server/src/services/document-annotations.ts b/server/src/services/document-annotations.ts index 207b7f2f52..8199a6ef41 100644 --- a/server/src/services/document-annotations.ts +++ b/server/src/services/document-annotations.ts @@ -5,6 +5,7 @@ import { documentAnnotationComments, documentAnnotationThreads, documents, + caseDocuments, issueComments, issueDocuments, routineDocuments, @@ -51,11 +52,22 @@ type RoutineDocumentRow = { latestRevisionNumber: number; }; +type CaseDocumentRow = { + caseId: string; + companyId: string; + documentId: string; + documentKey: string; + latestBody: string; + latestRevisionId: string | null; + latestRevisionNumber: number; +}; + const threadSelect = { id: documentAnnotationThreads.id, companyId: documentAnnotationThreads.companyId, issueId: documentAnnotationThreads.issueId, routineId: documentAnnotationThreads.routineId, + caseId: documentAnnotationThreads.caseId, documentId: documentAnnotationThreads.documentId, documentKey: documentAnnotationThreads.documentKey, status: documentAnnotationThreads.status, @@ -88,6 +100,7 @@ const commentSelect = { threadId: documentAnnotationComments.threadId, issueId: documentAnnotationComments.issueId, routineId: documentAnnotationComments.routineId, + caseId: documentAnnotationComments.caseId, documentId: documentAnnotationComments.documentId, body: documentAnnotationComments.body, authorType: documentAnnotationComments.authorType, @@ -154,6 +167,31 @@ export function documentAnnotationService(db: Db) { .then((rows: RoutineDocumentRow[]) => rows[0] ?? null); } + async function getCaseDocument( + caseId: string, + key: string, + dbOrTx: any = db, + ): Promise { + return dbOrTx + .select({ + caseId: caseDocuments.caseId, + companyId: documents.companyId, + documentId: documents.id, + documentKey: caseDocuments.key, + latestBody: documents.latestBody, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + }) + .from(caseDocuments) + .innerJoin(documents, eq(caseDocuments.documentId, documents.id)) + .where(and( + eq(caseDocuments.caseId, caseId), + eq(caseDocuments.key, key), + eq(caseDocuments.companyId, documents.companyId), + )) + .then((rows: CaseDocumentRow[]) => rows[0] ?? null); + } + async function getThreadForIssue( issueId: string, documentKey: string, @@ -192,6 +230,27 @@ export function documentAnnotationService(db: Db) { .then((rows: DocumentAnnotationThread[]) => rows[0] ?? null); } + async function getThreadForCase( + caseId: string, + documentKey: string, + threadId: string, + companyId: string, + documentId: string, + dbOrTx: any = db, + ): Promise { + return dbOrTx + .select(threadSelect) + .from(documentAnnotationThreads) + .where(and( + eq(documentAnnotationThreads.id, threadId), + eq(documentAnnotationThreads.companyId, companyId), + eq(documentAnnotationThreads.caseId, caseId), + eq(documentAnnotationThreads.documentId, documentId), + eq(documentAnnotationThreads.documentKey, documentKey), + )) + .then((rows: DocumentAnnotationThread[]) => rows[0] ?? null); + } + async function commentsForThreads(threadIds: string[], dbOrTx: any = db): Promise { if (threadIds.length === 0) return []; return dbOrTx @@ -290,6 +349,40 @@ export function documentAnnotationService(db: Db) { })); }, + listThreadsForCaseDocument: async ( + caseId: string, + key: string, + options: { status?: "open" | "resolved" | "all"; includeComments?: boolean } = {}, + ) => { + const doc = await getCaseDocument(caseId, key); + if (!doc) throw notFound("Document not found"); + const conditions = [ + eq(documentAnnotationThreads.companyId, doc.companyId), + eq(documentAnnotationThreads.caseId, caseId), + 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(); + 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; @@ -306,6 +399,15 @@ export function documentAnnotationService(db: Db) { return { ...thread, comments }; }, + getThreadForCaseDocument: async (caseId: string, key: string, threadId: string) => { + const doc = await getCaseDocument(caseId, key); + if (!doc) return null; + const thread = await getThreadForCase(caseId, key, threadId, doc.companyId, doc.documentId); + if (!thread) return null; + const comments = await commentsForThreads([thread.id]); + return { ...thread, comments }; + }, + createThread: async ( issueId: string, key: string, @@ -481,6 +583,96 @@ export function documentAnnotationService(db: Db) { return { ...thread, comments: [comment] }; }), + createCaseThread: async ( + caseId: string, + key: string, + input: CreateDocumentAnnotationThread, + actor: ActorInput, + ) => db.transaction(async (tx) => { + await tx.execute(sql` + select ${documents.id} + from ${caseDocuments} + inner join ${documents} on ${caseDocuments.documentId} = ${documents.id} + where ${and(eq(caseDocuments.caseId, caseId), eq(caseDocuments.key, key))} + for update of ${documents} + `); + const doc = await getCaseDocument(caseId, key, tx); + if (!doc) throw notFound("Document not found"); + if ( + input.baseRevisionId !== doc.latestRevisionId + || input.baseRevisionNumber !== doc.latestRevisionNumber + ) { + throw conflict("Annotation anchor requires the current document revision", { + currentRevisionId: doc.latestRevisionId, + currentRevisionNumber: doc.latestRevisionNumber, + }); + } + + const verification = verifyDocumentAnchorSelector({ + markdown: doc.latestBody, + selector: input.selector, + }); + if (!verification.ok || !verification.anchor) { + throw unprocessable("Annotation anchor does not match the current document revision", { + reason: verification.reason, + }); + } + + const now = new Date(); + const [thread] = await tx + .insert(documentAnnotationThreads) + .values({ + companyId: doc.companyId, + issueId: null, + routineId: null, + caseId, + documentId: doc.documentId, + documentKey: doc.documentKey, + status: "open", + anchorState: "active", + anchorConfidence: "exact", + originalRevisionId: doc.latestRevisionId, + originalRevisionNumber: doc.latestRevisionNumber, + currentRevisionId: doc.latestRevisionId, + currentRevisionNumber: doc.latestRevisionNumber, + selectedText: verification.anchor.selectedText, + prefixText: verification.anchor.prefixText, + suffixText: verification.anchor.suffixText, + normalizedStart: verification.anchor.normalizedStart, + normalizedEnd: verification.anchor.normalizedEnd, + markdownStart: verification.anchor.markdownStart, + markdownEnd: verification.anchor.markdownEnd, + anchorSelector: input.selector, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + createdAt: now, + updatedAt: now, + }) + .returning(threadSelect); + + const [comment] = await tx + .insert(documentAnnotationComments) + .values({ + companyId: doc.companyId, + threadId: thread.id, + issueId: null, + routineId: null, + caseId, + documentId: doc.documentId, + body: input.body, + authorType: actor.actorType, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + issueCommentId: null, + createdAt: now, + updatedAt: now, + }) + .returning(commentSelect); + + return { ...thread, comments: [comment] }; + }), + addComment: async ( issueId: string, key: string, @@ -553,6 +745,44 @@ export function documentAnnotationService(db: Db) { return comment; }), + addCaseComment: async ( + caseId: string, + key: string, + threadId: string, + input: CreateDocumentAnnotationComment, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const doc = await getCaseDocument(caseId, key, tx); + if (!doc) throw notFound("Document not found"); + const thread = await getThreadForCase(caseId, key, threadId, doc.companyId, doc.documentId, tx); + if (!thread) throw notFound("Annotation thread not found"); + const now = new Date(); + const [comment] = await tx + .insert(documentAnnotationComments) + .values({ + companyId: thread.companyId, + threadId: thread.id, + issueId: null, + routineId: null, + caseId: thread.caseId, + documentId: thread.documentId, + body: input.body, + authorType: actor.actorType, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + issueCommentId: null, + createdAt: now, + updatedAt: now, + }) + .returning(commentSelect); + await tx + .update(documentAnnotationThreads) + .set({ updatedAt: now }) + .where(eq(documentAnnotationThreads.id, thread.id)); + return comment; + }), + cleanupForIssueCommentDeletion: async ( issueId: string, issueCommentId: string, @@ -688,6 +918,42 @@ export function documentAnnotationService(db: Db) { return updated; }), + updateCaseThread: async ( + caseId: string, + key: string, + threadId: string, + input: UpdateDocumentAnnotationThread, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const doc = await getCaseDocument(caseId, key, tx); + if (!doc) throw notFound("Document not found"); + const thread = await getThreadForCase(caseId, key, threadId, doc.companyId, doc.documentId, 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; @@ -838,6 +1104,81 @@ export function documentAnnotationService(db: Db) { return changed; }), + remapOpenThreadsForCaseDocument: async (input: { + caseId: 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.caseId, input.caseId), + 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, }; } diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 4301adf3ba..01e33d1163 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -48,6 +48,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true, enablePipelines: parsed.data.enablePipelines ?? false, + enableCases: parsed.data.enableCases ?? false, enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false, enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false, enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false, @@ -72,6 +73,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableIsolatedWorkspaces: false, enableStreamlinedLeftNavigation: true, enablePipelines: false, + enableCases: false, enableConferenceRoomChat: false, enableTaskWatchdogs: false, enableIssuePlanDecompositions: false, diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 3c9b66555c..bf4d86634b 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -269,6 +269,11 @@ Load `references/workflows.md` when the task matches one of these: - CEO-safe company imports/exports (preview/apply). - App-level self-test playbook. +## Cases + +Load `references/cases.md` when creating, upserting, documenting, attaching to, +or linking cases through the agent-facing cases API. + ## Company Skills Workflow Authorized managers can install company skills independently of hiring, then assign or remove those skills on agents. diff --git a/skills/paperclip/references/cases.md b/skills/paperclip/references/cases.md new file mode 100644 index 0000000000..99ba843736 --- /dev/null +++ b/skills/paperclip/references/cases.md @@ -0,0 +1,295 @@ +# Cases + +Cases are agent-owned work records for durable outputs such as blog posts, +research packets, release notes, incidents, QA runs, or generated asset sets. +They are company-scoped and live beside issues: issues coordinate work, while +cases preserve the structured object an agent is producing. + +Cases are experimental and must be enabled with `experimental.enableCases`. +If a route returns `403 Cases are disabled`, stop and report that the operator +must enable cases before the skill can use this surface. + +## Core Model + +A case has: + +- `identifier`: server-assigned display id such as `PAP-C42` +- `caseType`: skill-owned type such as `blog_post`, `image_assets`, or `incident` +- `key`: optional deterministic upsert key inside `(companyId, caseType)` +- `title` and optional `summary` +- `status`: `draft`, `in_progress`, `in_review`, `approved`, `done`, or `cancelled` +- `fields`: JSON object owned by the skill using the case +- `parentCaseId`: optional parent case for child work +- documents, attachments, issue links, labels, and events + +Use deterministic `caseType` + `key` when a skill may be retried. Repeating +`POST /api/companies/:companyId/cases` with the same `caseType` and `key` +upserts the same case instead of creating a duplicate. + +## Upsert Semantics + +`POST /api/companies/:companyId/cases` creates or upserts a case. + +Request: + +```json +{ + "caseType": "blog_post", + "key": "launch-announcement", + "title": "Launch announcement", + "summary": "Draft launch post for operators.", + "status": "draft", + "fields": { + "slug": "launch-announcement", + "target_audience": "operators" + } +} +``` + +Response: + +- `201` when a new case was created +- `200` when an existing `(caseType, key)` case was updated + +Field behavior on upsert: + +- `title` is required and replaces the previous title. +- `projectId`, `summary`, `status`, `fields`, and `parentCaseId` replace the + previous value when present. +- Omitted optional values preserve the previous value during upsert. +- `fields` is replaced as a whole object when provided. It is not deep-merged. + Send the complete desired JSON object each time. +- Concurrent retries with the same `(caseType, key)` converge to one case. + +Do not use a random `key` for retryable skills. Use a stable content slug, +external id, source URL hash, or parent-derived request key. + +## Read And Search + +Get a case by UUID or identifier: + +```http +GET /api/cases/PAP-C42 +``` + +List cases for a company: + +```http +GET /api/companies/:companyId/cases?type=blog_post&status=active&q=launch +``` + +Useful filters: + +- `type`: exact `caseType` +- `status`: exact lifecycle status, or `active` for non-terminal cases +- `projectId` / `project`: project UUID +- `labelId` / `label`: label UUID +- `q`: identifier, title, summary, or key search +- `limit`: 1-200, default 100 + +## Documents + +Use case documents for rich bodies such as drafts, briefs, reports, or plans. + +```http +PUT /api/cases/:caseIdOrIdentifier/documents/body +Content-Type: application/json + +{ + "title": "Launch announcement body", + "format": "markdown", + "body": "# Launch announcement\n\nDraft copy...", + "changeSummary": "Initial draft" +} +``` + +Updating an existing case document requires `baseRevisionId`: + +```json +{ + "baseRevisionId": "latest-revision-uuid", + "body": "Updated body" +} +``` + +If you get `409 stale_base_revision`, refetch the case detail, read the latest +document revision id, merge intentionally, and retry with that `baseRevisionId`. + +## Fields + +Each skill owns the schema of `fields` for the `caseType` it creates. Keep fields +small, typed, and stable enough for other agents to inspect. + +Examples: + +```json +{ + "slug": "launch-announcement", + "target_audience": "operators", + "publish_url": "https://example.com/blog/launch-announcement" +} +``` + +Patch fields or status with: + +```http +PATCH /api/cases/:caseIdOrIdentifier +Content-Type: application/json + +{ + "status": "in_review", + "fields": { + "slug": "launch-announcement", + "target_audience": "operators", + "publish_url": "https://example.com/blog/launch-announcement" + } +} +``` + +Remember: `fields` replaces the whole object when present. + +## Issue Links + +Link cases to issues explicitly when needed: + +```http +POST /api/cases/:caseIdOrIdentifier/links +Content-Type: application/json + +{ + "issueId": "issue-uuid", + "role": "reference" +} +``` + +Roles: + +- `origin`: the issue/run that created the case +- `work`: an issue/run that changed the case +- `reference`: related issue context + +Agent run writes auto-link the run's issue when Paperclip can resolve it from +the run JWT or `X-Paperclip-Run-Id`. Creation/upsert writes use `origin`; later +document, patch, and attachment writes use `work` when no link already exists. +You do not need to manually link the current issue before writing the case. + +## Child Cases + +Create child cases by setting `parentCaseId` to the parent case UUID. + +```json +{ + "caseType": "image_assets", + "key": "launch-announcement:hero-images", + "title": "Hero images for launch announcement", + "parentCaseId": "parent-case-uuid", + "fields": { + "required_assets": ["hero", "social-card"] + } +} +``` + +Use child cases when the output has independently inspectable pieces or when +another agent can work on a bounded part without editing the parent case body. + +## Attachments + +Attach generated files with multipart form data: + +```http +POST /api/cases/:caseIdOrIdentifier/attachments +Content-Type: multipart/form-data + +file=@hero.png +``` + +The server records an asset and adds an `attachment_added` case event. + +## Lifecycle + +Use the lifecycle consistently: + +- `draft`: case exists but useful work has not started +- `in_progress`: an agent is actively producing or revising it +- `in_review`: ready for reviewer, board, or downstream approval +- `approved`: accepted but not finally shipped or archived +- `done`: complete and no further action remains +- `cancelled`: intentionally abandoned + +Terminal statuses are `done` and `cancelled`; setting either records +`completedAt`. Moving back to a non-terminal status clears `completedAt`. + +## Worked Blog Post Example + +Create or upsert the parent blog post: + +```http +POST /api/companies/:companyId/cases +Content-Type: application/json + +{ + "caseType": "blog_post", + "key": "paperclip-cases-launch", + "title": "Introducing Paperclip Cases", + "summary": "Blog post explaining the cases surface for agent outputs.", + "status": "in_progress", + "fields": { + "slug": "paperclip-cases-launch", + "target_audience": "AI company operators", + "publish_url": null + } +} +``` + +Write the body: + +```http +PUT /api/cases/PAP-C42/documents/body +Content-Type: application/json + +{ + "title": "Introducing Paperclip Cases", + "format": "markdown", + "body": "# Introducing Paperclip Cases\n\n..." +} +``` + +Create the child image-assets case: + +```http +POST /api/companies/:companyId/cases +Content-Type: application/json + +{ + "caseType": "image_assets", + "key": "paperclip-cases-launch:image-assets", + "title": "Image assets for Introducing Paperclip Cases", + "parentCaseId": "parent-case-uuid", + "status": "in_progress", + "fields": { + "slug": "paperclip-cases-launch", + "required_assets": ["hero", "social-card"], + "publish_url": null + } +} +``` + +Attach generated assets to the child, then patch both cases as they move through +review: + +```http +PATCH /api/cases/PAP-C42 +Content-Type: application/json + +{ + "status": "in_review", + "fields": { + "slug": "paperclip-cases-launch", + "target_audience": "AI company operators", + "publish_url": "https://example.com/blog/paperclip-cases-launch" + } +} +``` + +If the same skill retries the example with the same keys, it updates the parent +and child cases rather than creating duplicates. diff --git a/ui/src/App.cases-routing.test.tsx b/ui/src/App.cases-routing.test.tsx new file mode 100644 index 0000000000..d555a52357 --- /dev/null +++ b/ui/src/App.cases-routing.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment jsdom + +// Regression guard for PAP-13002: the experimental Cases UI emits *unprefixed* +// links (`/cases`, `/cases/:id`) — the same global-unprefixed pattern Pipelines +// uses. Those only resolve if `cases` and `cases/:caseIdentifier` are registered +// as reserved unprefixed redirect routes in ; otherwise the first path +// segment is parsed as a company prefix ("CASES") and the page 404s with +// "No company matches prefix". This drives the real route table so a +// future removal of those redirect routes fails loudly. + +import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +// jsdom's CSS parser rejects the custom-property marker rule stitches inserts +// (`--sxs{--sxs:N}`), pulled into 's eager import graph transitively via +// @codesandbox/sandpack-react. Substitute a benign, valid rule on parse failure +// so stitches' index bookkeeping stays intact and the module graph evaluates. +// (sandpack itself is never exercised by the routing under test.) +beforeAll(() => { + const sheetProto = window.CSSStyleSheet.prototype as unknown as { + insertRule: (rule: string, index?: number) => number; + __pap13002Patched?: boolean; + }; + if (!sheetProto.__pap13002Patched) { + const original = sheetProto.insertRule; + sheetProto.insertRule = function patched(this: CSSStyleSheet, rule: string, index?: number) { + try { + return original.call(this, rule, index); + } catch { + try { + return original.call(this, ".pap13002-noop{}", index); + } catch { + return this.cssRules?.length ?? 0; + } + } + }; + sheetProto.__pap13002Patched = true; + } +}); + +// Real Layout renders the full authenticated shell (sidebar, data queries) and +// owns the "No company matches prefix" NotFound. For routing we only need it to +// resolve the :companyPrefix segment and render its nested routes. +vi.mock("./components/Layout", async () => { + const { Outlet } = await import("react-router-dom"); + return { Layout: () => }; +}); + +// The experimental gate would otherwise hide the page behind a feature flag. +vi.mock("./components/CasesExperimentalGate", () => ({ + CasesExperimentalGate: ({ children }: { children: ReactNode }) => <>{children}, +})); + +// Rendered by outside and needs DialogProvider; irrelevant here. +vi.mock("./components/OnboardingWizardVariant", () => ({ + OnboardingWizardVariant: () => null, +})); + +// Sentinel pages so we can assert *which* route resolved. +vi.mock("./pages/Cases", () => ({ Cases: () =>
CASES_LIST_PAGE
})); +vi.mock("./pages/CaseDetail", () => ({ CaseDetail: () =>
CASE_DETAIL_PAGE
})); + +// CloudAccessGate must fall through to (authorized w/ company access). +const mockHealthApi = vi.hoisted(() => ({ get: vi.fn() })); +const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() })); +const mockAccessApi = vi.hoisted(() => ({ + getCurrentBoardAccess: vi.fn(), + claimBootstrapAdmin: vi.fn(), +})); +vi.mock("./api/health", () => ({ healthApi: mockHealthApi })); +vi.mock("./api/auth", () => ({ authApi: mockAuthApi })); +vi.mock("./api/access", () => ({ accessApi: mockAccessApi })); + +// The prefix resolver + redirect logic both read the active company. +const PAP_COMPANY = { + id: "company-1", + name: "Paperclip", + issuePrefix: "PAP", + status: "active", +}; +vi.mock("./context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [PAP_COMPANY], + selectedCompanyId: PAP_COMPANY.id, + selectedCompany: PAP_COMPANY, + loading: false, + }), + CompanyProvider: ({ children }: { children: ReactNode }) => <>{children}, +})); + +async function flushReact() { + for (let i = 0; i < 20; i += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +async function waitForText(container: HTMLElement, text: string) { + for (let attempt = 0; attempt < 25; attempt += 1) { + if (container.textContent?.includes(text)) return; + await flushReact(); + } + expect(container.textContent).toContain(text); +} + +async function renderAppAt(container: HTMLElement, path: string) { + const { App } = await import("./App"); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + flushSync(() => { + root.render( + + + + + , + ); + }); + return root; +} + +describe("App Cases routing (PAP-13002)", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockHealthApi.get.mockResolvedValue({ + status: "ok", + deploymentMode: "authenticated", + deploymentExposure: "private", + bootstrapStatus: "ready", + }); + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + }); + mockAccessApi.getCurrentBoardAccess.mockResolvedValue({ + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + userId: "user-1", + isInstanceAdmin: false, + companyIds: [PAP_COMPANY.id], + source: "session", + keyId: null, + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("redirects unprefixed /cases to the company-prefixed list page", async () => { + const root = await renderAppAt(container, "/cases"); + await waitForText(container, "CASES_LIST_PAGE"); + expect(container.textContent).not.toContain("No company matches prefix"); + flushSync(() => root.unmount()); + }, 20000); + + it("redirects unprefixed /cases/:id to the company-prefixed detail page", async () => { + const root = await renderAppAt(container, "/cases/PAP-C5"); + await waitForText(container, "CASE_DETAIL_PAGE"); + expect(container.textContent).not.toContain("No company matches prefix"); + flushSync(() => root.unmount()); + }, 20000); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b09498d7e9..076484b302 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -4,6 +4,9 @@ import { useTranslation } from "@/i18n"; import { Layout } from "./components/Layout"; import { ConferenceRoomChatGate } from "./components/ConferenceRoomChatGate"; import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGate"; +import { CasesExperimentalGate } from "./components/CasesExperimentalGate"; +import { Cases } from "./pages/Cases"; +import { CaseDetail } from "./pages/CaseDetail"; import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant"; import { CloudAccessGate } from "./components/CloudAccessGate"; import { Dashboard } from "./pages/Dashboard"; @@ -145,6 +148,14 @@ function boardRoutes() { } /> ) : null} } /> + } + /> + } + /> } @@ -444,6 +455,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/api/cases.ts b/ui/src/api/cases.ts new file mode 100644 index 0000000000..a43738e5d6 --- /dev/null +++ b/ui/src/api/cases.ts @@ -0,0 +1,332 @@ +import type { DocumentRevision, IssueDocument, IssueLabel } from "@paperclipai/shared"; +import { api } from "./client"; + +// ----------------------------------------------------------------------------- +// Cases API (experimental — PAP-12947). Mirrors server/src/routes/cases.ts. +// Human-writable in v1 = status + labels only; everything else is agent-authored. +// ----------------------------------------------------------------------------- + +export const CASE_STATUSES = [ + "draft", + "in_progress", + "in_review", + "approved", + "done", + "cancelled", +] as const; +export type CaseStatus = (typeof CASE_STATUSES)[number]; + +/** Statuses hidden by the list's default `Active` filter. */ +export const TERMINAL_CASE_STATUSES: readonly CaseStatus[] = ["done", "cancelled"]; + +export type CaseLinkRole = "origin" | "work" | "reference"; + +/** A case row as returned by the list endpoint. */ +export interface CaseSummary { + id: string; + companyId: string; + projectId: string | null; + caseNumber: number; + identifier: string; + caseType: string; + key: string | null; + title: string; + summary: string | null; + status: CaseStatus; + fields: Record; + parentCaseId: string | null; + createdByAgentId: string | null; + createdByUserId: string | null; + completedAt: string | null; + createdAt: string; + updatedAt: string; + /** + * Present only when `includeAncestors` is requested. Ancestor rows included for + * tree context are `false`; rows that matched the list query are `true`. + */ + matchesListFilters?: boolean; +} + +export interface CaseDocumentRef { + key: string; + document: CaseDocument; +} + +export interface CaseDocument { + id: string; + companyId: string; + title: string | null; + format: string; + latestBody: string | null; + latestRevisionId: string | null; + latestRevisionNumber: number | null; + createdByAgentId: string | null; + createdByUserId: string | null; + updatedByAgentId: string | null; + updatedByUserId: string | null; + lockedAt: string | null; + lockedByAgentId: string | null; + lockedByUserId: string | null; + sourceTrust?: IssueDocument["sourceTrust"]; + createdAt: string; + updatedAt: string; +} + +export interface CaseIssueLink { + id: string; + caseId: string; + issueId: string; + role: CaseLinkRole; + createdAt: string; + issue: { + id: string; + identifier: string; + title: string; + status: string; + }; +} + +export interface CaseAttachmentRef { + id: string; + asset: { + id: string; + contentType: string; + byteSize: number; + originalFilename: string | null; + }; + createdAt: string; + updatedAt: string; +} + +/** A lightweight parent reference embedded in the detail payload. */ +export interface CaseParentRef { + id: string; + identifier: string; + title: string; + caseType: string; + status: CaseStatus; +} + +/** Content URL for an attachment's asset (served by the assets route). */ +export function caseAttachmentUrl(attachment: CaseAttachmentRef): string { + return `/api/assets/${attachment.asset.id}/content`; +} + +export function isImageAttachment(attachment: CaseAttachmentRef): boolean { + return attachment.asset.contentType.startsWith("image/"); +} + +/** The full detail payload (loadCaseDetail on the server). */ +export interface CaseDetail extends CaseSummary { + parent: CaseParentRef | null; + labels: IssueLabel[]; + issueLinks: CaseIssueLink[]; + documents: CaseDocumentRef[]; + attachments: CaseAttachmentRef[]; +} + +export type CaseEventKind = + | "created" + | "updated" + | "fields_changed" + | "status_changed" + | "issue_linked" + | "issue_unlinked" + | "document_revised" + | "child_linked" + | "attachment_added" + | "label_added" + | "label_removed"; + +/** Run→issue attribution shared by feed rows and revisions. */ +export interface CaseAttributionIssue { + id: string; + identifier: string; + title: string; + status: string; +} + +export interface CaseEvent { + id: string; + caseId: string; + kind: CaseEventKind; + actorType: "user" | "agent" | "system"; + actorUserId: string | null; + actorAgentId: string | null; + runId: string | null; + payload: Record; + createdAt: string; + /** Display name of the acting agent (P4 enrichment), null for user/system. */ + actorAgentName: string | null; + /** Issue linked by this event, or the issue whose run produced it. */ + issue: CaseAttributionIssue | null; +} + +/** One revision of a case document, with author + via-issue attribution. */ +export interface CaseDocumentRevision { + id: string; + companyId?: string; + documentId?: string; + revisionNumber: number; + title: string; + format: string; + body: string | null; + changeSummary: string | null; + createdAt: string; + createdByAgentId: string | null; + createdByUserId: string | null; + createdByRunId: string | null; + actorAgentName: string | null; + issue: CaseAttributionIssue | null; +} + +export interface CaseDocumentRevisions { + key: string; + document: { + id: string; + title: string; + format: string; + latestRevisionId: string | null; + latestRevisionNumber: number | null; + }; + revisions: CaseDocumentRevision[]; +} + +/** A case linked to an issue, as returned by the issue-page rail endpoint. */ +export interface IssueCaseLink { + id: string; + role: CaseLinkRole; + createdAt: string; + case: { + id: string; + identifier: string; + title: string; + caseType: string; + status: CaseStatus; + }; +} + +export interface ListCasesParams { + type?: string; + types?: string[]; + status?: string; + statuses?: string[]; + projectId?: string; + projectIds?: string[]; + includeNoProject?: boolean; + labelId?: string; + /** Filter to direct children of a parent case id (P4 children tree). */ + parent?: string; + q?: string; + includeAncestors?: boolean; + limit?: number; +} + +function appendAll(search: URLSearchParams, key: string, values: readonly string[] | undefined) { + for (const value of values ?? []) search.append(key, value); +} + +function toQuery(params: ListCasesParams): string { + const search = new URLSearchParams(); + if (params.type) search.set("type", params.type); + appendAll(search, "types", params.types); + if (params.status) search.set("status", params.status); + appendAll(search, "statuses", params.statuses); + if (params.projectId) search.set("projectId", params.projectId); + appendAll(search, "projectIds", params.projectIds); + if (params.includeNoProject) search.set("includeNoProject", "true"); + if (params.labelId) search.set("labelId", params.labelId); + if (params.parent) search.set("parent", params.parent); + if (params.q) search.set("q", params.q); + if (params.includeAncestors) search.set("includeAncestors", "true"); + if (params.limit != null) search.set("limit", String(params.limit)); + const qs = search.toString(); + return qs ? `?${qs}` : ""; +} + +export interface PatchCaseInput { + status?: CaseStatus; + labelIds?: string[]; +} + +export function caseDocumentToIssueDocument(caseId: string, key: string, document: CaseDocument): IssueDocument { + return { + id: document.id, + companyId: document.companyId, + issueId: caseId, + key, + title: document.title, + format: "markdown", + body: document.latestBody ?? "", + latestRevisionId: document.latestRevisionId, + latestRevisionNumber: document.latestRevisionNumber ?? 1, + createdByAgentId: document.createdByAgentId, + createdByUserId: document.createdByUserId, + updatedByAgentId: document.updatedByAgentId, + updatedByUserId: document.updatedByUserId, + lockedAt: document.lockedAt ? new Date(document.lockedAt) : null, + lockedByAgentId: document.lockedByAgentId, + lockedByUserId: document.lockedByUserId, + sourceTrust: document.sourceTrust, + createdAt: new Date(document.createdAt), + updatedAt: new Date(document.updatedAt), + }; +} + +export function caseRevisionToDocumentRevision(caseId: string, key: string, revision: CaseDocumentRevision): DocumentRevision { + return { + id: revision.id, + companyId: revision.companyId ?? "", + documentId: revision.documentId ?? "", + issueId: caseId, + key, + revisionNumber: revision.revisionNumber, + title: revision.title, + format: "markdown", + body: revision.body ?? "", + changeSummary: revision.changeSummary, + createdByAgentId: revision.createdByAgentId, + createdByUserId: revision.createdByUserId, + createdAt: new Date(revision.createdAt), + }; +} + +export const casesApi = { + list: (companyId: string, params: ListCasesParams = {}) => + api.get(`/companies/${companyId}/cases${toQuery(params)}`), + get: (idOrIdentifier: string) => api.get(`/cases/${idOrIdentifier}`), + patch: (idOrIdentifier: string, input: PatchCaseInput) => + api.patch(`/cases/${idOrIdentifier}`, input), + listEvents: (idOrIdentifier: string, limit = 100) => + api.get(`/cases/${idOrIdentifier}/events?limit=${limit}`), + listChildren: (companyId: string, parentId: string) => + api.get(`/companies/${companyId}/cases${toQuery({ parent: parentId, limit: 200 })}`), + getDocument: (idOrIdentifier: string, key: string) => + api.get(`/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}`), + upsertDocument: ( + idOrIdentifier: string, + key: string, + data: { title?: string | null; format?: string; body: string; baseRevisionId?: string | null }, + ) => + api.put<{ document: CaseDocument & { key: string; body: string }; revision: CaseDocumentRevision }>( + `/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}`, + data, + ), + lockDocument: (idOrIdentifier: string, key: string) => + api.post(`/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}/lock`, {}), + unlockDocument: (idOrIdentifier: string, key: string) => + api.post(`/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}/unlock`, {}), + restoreDocumentRevision: (idOrIdentifier: string, key: string, revisionId: string) => + api.post<{ + document: CaseDocument & { key: string; body: string }; + revision: CaseDocumentRevision; + restoredFromRevisionId: string; + restoredFromRevisionNumber: number; + }>(`/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}/revisions/${revisionId}/restore`, {}), + deleteDocument: (idOrIdentifier: string, key: string) => + api.delete<{ ok: true }>(`/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}`), + listRevisions: (idOrIdentifier: string, key: string) => + api.get(`/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}/revisions`), + listForIssue: (issueIdOrIdentifier: string) => + api.get(`/issues/${issueIdOrIdentifier}/cases`), +}; diff --git a/ui/src/api/document-annotations.ts b/ui/src/api/document-annotations.ts index 5a939c3a4c..7292d73056 100644 --- a/ui/src/api/document-annotations.ts +++ b/ui/src/api/document-annotations.ts @@ -13,6 +13,7 @@ export type DocumentAnnotationListFilter = "open" | "resolved" | "all"; export type DocumentAnnotationTarget = | { kind: "issue"; issueId: string; documentKey: string } + | { kind: "case"; caseId: string; documentKey: string } | { kind: "routine"; routineId: string; documentKey: "description" }; function issueTarget(issueId: string, documentKey: string): DocumentAnnotationTarget { @@ -23,6 +24,9 @@ function targetBasePath(target: DocumentAnnotationTarget) { if (target.kind === "routine") { return `/routines/${target.routineId}/description/annotations`; } + if (target.kind === "case") { + return `/cases/${target.caseId}/documents/${encodeURIComponent(target.documentKey)}/annotations`; + } return `/issues/${target.issueId}/documents/${encodeURIComponent(target.documentKey)}/annotations`; } diff --git a/ui/src/components/CaseActivityFeed.test.tsx b/ui/src/components/CaseActivityFeed.test.tsx new file mode 100644 index 0000000000..4279dd3cb5 --- /dev/null +++ b/ui/src/components/CaseActivityFeed.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { AnchorHTMLAttributes } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CaseActivityFeed } from "./CaseActivityFeed"; +import type { CaseEvent } from "@/api/cases"; + +function act(callback: () => void) { + flushSync(callback); +} + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function event(overrides: Partial): CaseEvent { + return { + id: Math.random().toString(36).slice(2), + caseId: "case-1", + kind: "created", + actorType: "system", + actorUserId: null, + actorAgentId: null, + runId: null, + payload: {}, + createdAt: "2026-07-07T00:00:00.000Z", + actorAgentName: null, + issue: null, + ...overrides, + }; +} + +describe("CaseActivityFeed", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + afterEach(() => { + container.remove(); + }); + + function render(events: CaseEvent[]) { + const root = createRoot(container); + act(() => root.render()); + return root; + } + + it("shows the empty state when there are no events", () => { + const root = render([]); + expect(container.textContent).toContain("No activity yet"); + act(() => root.unmount()); + }); + + it("renders actor name and run→issue attribution", () => { + const root = render([ + event({ + kind: "document_revised", + actorType: "agent", + actorAgentId: "agent-1", + actorAgentName: "Cases Agent", + runId: "run-1", + issue: { id: "i1", identifier: "PAP-42", title: "Source task", status: "in_progress" }, + }), + ]); + const text = container.textContent ?? ""; + expect(text).toContain("document revised"); + expect(text).toContain("Cases Agent"); + expect(text).toContain("via"); + // The issue chip links to the issue detail. + const issueLink = container.querySelector('a[href="/issues/PAP-42"]'); + expect(issueLink?.textContent).toContain("PAP-42"); + expect(issueLink?.textContent).toContain("Source task"); + act(() => root.unmount()); + }); + + it("renders an auto-link event as a system actor with a linked issue", () => { + const root = render([ + event({ + kind: "issue_linked", + actorType: "system", + issue: { id: "i2", identifier: "PAP-9", title: "Auto", status: "todo" }, + }), + ]); + const text = container.textContent ?? ""; + expect(text).toContain("issue linked"); + expect(text).toContain("System"); + expect(text).toContain("issue"); + expect(container.querySelector('a[href="/issues/PAP-9"]')).not.toBeNull(); + act(() => root.unmount()); + }); + + it("filters rows by kind when a filter chip is toggled", () => { + const root = render([ + event({ kind: "created" }), + event({ kind: "status_changed", payload: { previousStatus: "draft", status: "in_review" } }), + ]); + // The status-transition detail only appears in the status_changed row. + expect(container.textContent).toContain("draft → in_review"); + + // Open the activity filter dropdown and choose "created"; only created + // rows remain, so the status-transition detail disappears. + const filterButton = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.includes("All activity"), + ); + expect(filterButton).toBeTruthy(); + act(() => filterButton!.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }))); + const createdItem = Array.from(document.body.querySelectorAll('[role="menuitemcheckbox"]')).find( + (item) => item.textContent === "created", + ); + expect(createdItem).toBeTruthy(); + act(() => createdItem!.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(container.textContent).not.toContain("draft → in_review"); + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/CaseActivityFeed.tsx b/ui/src/components/CaseActivityFeed.tsx new file mode 100644 index 0000000000..b404c71501 --- /dev/null +++ b/ui/src/components/CaseActivityFeed.tsx @@ -0,0 +1,165 @@ +import { useMemo, useState } from "react"; +import { Link } from "@/lib/router"; +import { Bot, User, Cog, ChevronDown, ListFilter } from "lucide-react"; +import type { CaseEvent, CaseEventKind } from "@/api/cases"; +import { Button } from "@/components/ui/button"; +import { StatusIcon } from "@/components/StatusIcon"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn, relativeTime } from "@/lib/utils"; + +const EVENT_LABEL: Record = { + created: "created", + updated: "updated", + fields_changed: "fields changed", + status_changed: "status changed", + issue_linked: "issue linked", + issue_unlinked: "issue unlinked", + document_revised: "document revised", + child_linked: "child linked", + attachment_added: "attachment added", + label_added: "label added", + label_removed: "label removed", +}; + +/** Human label for the actor, preferring the resolved agent name. */ +function actorLabel(event: CaseEvent): string { + if (event.actorType === "agent") return event.actorAgentName ?? "Agent"; + if (event.actorType === "user") return "User"; + return "System"; +} + +function ActorIcon({ event }: { event: CaseEvent }) { + const Icon = event.actorType === "agent" ? Bot : event.actorType === "user" ? User : Cog; + return ; +} + +function issueRelationLabel(event: CaseEvent): string { + return event.kind === "issue_linked" || event.kind === "issue_unlinked" ? "issue" : "via"; +} + +/** One event with actor + run→issue attribution (P4 §1). */ +export function CaseEventRow({ event, compact = false }: { event: CaseEvent; compact?: boolean }) { + const detail = + event.kind === "status_changed" && event.payload + ? `${(event.payload.previousStatus as string) ?? "?"} → ${(event.payload.status as string) ?? "?"}` + : ""; + return ( +
+ +
+
+ {EVENT_LABEL[event.kind] ?? event.kind} + {detail && · {detail}} +
+
+ {actorLabel(event)} + {event.issue && ( + <> + · + {issueRelationLabel(event)} + + + {event.issue.identifier} + {event.issue.title} + + + )} + · + {relativeTime(event.createdAt)} +
+
+
+ ); +} + +/** The full activity feed with kind filters (detail-page Activity tab). */ +export function CaseActivityFeed({ events }: { events: CaseEvent[] }) { + const [active, setActive] = useState>(new Set()); + + // Only offer filters for kinds actually present, in first-seen order. + const presentKinds = useMemo(() => { + const seen: CaseEventKind[] = []; + for (const e of events) if (!seen.includes(e.kind)) seen.push(e.kind); + return seen; + }, [events]); + + const filtered = useMemo( + () => (active.size === 0 ? events : events.filter((e) => active.has(e.kind))), + [events, active], + ); + + function toggle(kind: CaseEventKind) { + setActive((prev) => { + const next = new Set(prev); + if (next.has(kind)) next.delete(kind); + else next.add(kind); + return next; + }); + } + + const filterLabel = active.size === 0 + ? "All activity" + : active.size === 1 + ? EVENT_LABEL[[...active][0]!] ?? [...active][0]! + : `${active.size} filters`; + + if (events.length === 0) { + return

No activity yet.

; + } + + return ( +
+
+

+ {filtered.length} of {events.length} events +

+ + + + + + Activity filter + setActive(new Set())}> + All activity + + + {presentKinds.map((kind) => ( + toggle(kind)} + > + {EVENT_LABEL[kind] ?? kind} + + ))} + + +
+ {filtered.length === 0 ? ( +

No events match this filter.

+ ) : ( +
+ {filtered.map((event) => ( + + ))} +
+ )} +
+ ); +} diff --git a/ui/src/components/CaseAttachmentsGallery.tsx b/ui/src/components/CaseAttachmentsGallery.tsx new file mode 100644 index 0000000000..96c6464590 --- /dev/null +++ b/ui/src/components/CaseAttachmentsGallery.tsx @@ -0,0 +1,91 @@ +import { useMemo, useState } from "react"; +import { FileText } from "lucide-react"; +import { + caseAttachmentUrl, + isImageAttachment, + type CaseAttachmentRef, +} from "@/api/cases"; +import { ImageGalleryModal, type GalleryMediaItem } from "@/components/ImageGalleryModal"; +import { cn } from "@/lib/utils"; + +function humanBytes(size: number): string { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(0)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Attachments gallery (P4 §4): image-friendly grid. Image cases hold variations + * as attachments, so images render as thumbnails that open the shared + * lightbox; non-image assets fall back to a labelled file tile. No + * variation-picker (out of scope). + */ +export function CaseAttachmentsGallery({ attachments }: { attachments: CaseAttachmentRef[] }) { + const [galleryIndex, setGalleryIndex] = useState(null); + + // The lightbox only navigates across image attachments. + const imageItems = useMemo( + () => + attachments.filter(isImageAttachment).map((a) => ({ + id: a.id, + contentPath: caseAttachmentUrl(a), + contentType: a.asset.contentType, + originalFilename: a.asset.originalFilename, + })), + [attachments], + ); + + if (attachments.length === 0) { + return

No attachments.

; + } + + return ( + <> +
+ {attachments.map((attachment) => { + const isImage = isImageAttachment(attachment); + const filename = attachment.asset.originalFilename ?? "attachment"; + const imageIdx = isImage ? imageItems.findIndex((i) => i.id === attachment.id) : -1; + return ( + + ); + })} +
+ {galleryIndex !== null && ( + !open && setGalleryIndex(null)} + /> + )} + + ); +} diff --git a/ui/src/components/CaseChildrenTree.test.tsx b/ui/src/components/CaseChildrenTree.test.tsx new file mode 100644 index 0000000000..317d49ba69 --- /dev/null +++ b/ui/src/components/CaseChildrenTree.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { AnchorHTMLAttributes } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CaseChildrenTree } from "./CaseChildrenTree"; +import type { CaseSummary } from "@/api/cases"; + +function act(callback: () => void) { + flushSync(callback); +} + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), + useCaseHref: () => (...segments: string[]) => + `/PAP/${["cases", ...segments].filter(Boolean).join("/")}`, +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function child(overrides: Partial): CaseSummary { + return { + id: Math.random().toString(36).slice(2), + companyId: "c1", + projectId: null, + caseNumber: 1, + identifier: "PAP-C1", + caseType: "task", + key: null, + title: "A child", + summary: null, + status: "in_progress", + fields: {}, + parentCaseId: "parent", + createdByAgentId: null, + createdByUserId: null, + completedAt: null, + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + ...overrides, + }; +} + +describe("CaseChildrenTree", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + afterEach(() => container.remove()); + + function render(children: CaseSummary[]) { + const root = createRoot(container); + act(() => root.render()); + return root; + } + + it("shows the empty state with no children", () => { + const root = render([]); + expect(container.textContent).toContain("No child cases"); + act(() => root.unmount()); + }); + + it("renders each child with identifier, type and status chips linking to detail without keys", () => { + const root = render([ + child({ identifier: "PAP-C8", key: "launch/post", caseType: "blog_post", status: "in_review", title: "Post" }), + child({ identifier: "PAP-C9", caseType: "image", status: "done", title: "Hero image" }), + ]); + const text = container.textContent ?? ""; + expect(text).toContain("PAP-C8"); + expect(text).not.toContain("launch/post"); + expect(text).toContain("blog_post"); + // StatusBadge renders the status with underscores as spaces. + expect(text).toContain("in review"); + expect(text).toContain("Hero image"); + expect(container.querySelector('a[href="/PAP/cases/PAP-C8"]')).not.toBeNull(); + expect(container.querySelector('a[href="/PAP/cases/PAP-C9"]')).not.toBeNull(); + expect(container.querySelector('a[href="/PAP/cases/PAP-C8"]')?.className).not.toContain("border"); + act(() => root.unmount()); + }); + + it("caps long child lists until show more is clicked", () => { + const root = createRoot(container); + const children = Array.from({ length: 7 }, (_, index) => + child({ id: `child-${index + 1}`, identifier: `PAP-C${index + 1}`, title: `Child ${index + 1}` }) + ); + act(() => root.render()); + + expect(container.textContent).toContain("Child 5"); + expect(container.textContent).not.toContain("Child 6"); + expect(container.textContent).toContain("Show 2 more"); + + const showMore = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Show 2 more") + ); + expect(showMore).toBeTruthy(); + act(() => { + showMore!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("Child 6"); + expect(container.textContent).toContain("Child 7"); + expect(container.textContent).not.toContain("Show 2 more"); + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/CaseChildrenTree.tsx b/ui/src/components/CaseChildrenTree.tsx new file mode 100644 index 0000000000..65504bed5f --- /dev/null +++ b/ui/src/components/CaseChildrenTree.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { ChevronDown } from "lucide-react"; +import { Link, useCaseHref } from "@/lib/router"; +import type { CaseSummary } from "@/api/cases"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { StatusBadge } from "@/components/StatusBadge"; +import { CaseCopyableToken } from "@/components/CaseIdentifierKey"; + +type CaseRelationRow = Pick & { + key?: string | null; +}; + +/** + * Children tree (P4 §3): the parent's direct child cases with type + status + * chips. Display only — no rollup semantics. Renders nothing structural beyond + * a flat list; nesting depth is intentionally one level in v1. + */ +export function CaseChildrenTree({ + children, + maxVisible, +}: { + children: CaseRelationRow[]; + maxVisible?: number; +}) { + const caseHref = useCaseHref(); + const [expanded, setExpanded] = useState(false); + if (children.length === 0) { + return

No child cases.

; + } + + const shouldCap = maxVisible != null && children.length > maxVisible; + const visibleChildren = shouldCap && !expanded ? children.slice(0, maxVisible) : children; + const hiddenCount = children.length - visibleChildren.length; + + return ( +
+
    + {visibleChildren.map((child) => ( +
  • + + + {child.title} + {child.caseType} + + +
  • + ))} +
+ {hiddenCount > 0 ? ( + + ) : null} +
+ ); +} diff --git a/ui/src/components/CaseFieldsPanel.test.tsx b/ui/src/components/CaseFieldsPanel.test.tsx new file mode 100644 index 0000000000..1c85a9f814 --- /dev/null +++ b/ui/src/components/CaseFieldsPanel.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { AnchorHTMLAttributes } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CaseFieldsPanel } from "./CaseFieldsPanel"; + +function act(callback: () => void) { + flushSync(callback); +} + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), + useCaseHref: () => (...segments: string[]) => + `/PAP/${["cases", ...segments].filter(Boolean).join("/")}`, +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("CaseFieldsPanel", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + afterEach(() => { + container.remove(); + }); + + function render(fields: Record) { + const root = createRoot(container); + act(() => { + root.render(); + }); + return root; + } + + it("shows the empty state when there are no fields", () => { + const root = render({}); + expect(container.textContent).toContain("No fields set"); + act(() => root.unmount()); + }); + + it("renders all four generic value types per spec", () => { + const root = render({ + slug: "hermes-agent-post", + word_count: 1850, + published: true, + draft_only: false, + tags: ["ai", "launch"], + publish_url: "https://example.com/post", + related_case: "PAP-C12", + missing: null, + config: { nested: "x" }, + }); + + // string + expect(container.textContent).toContain("hermes-agent-post"); + // number — locale grouped, tabular + expect(container.textContent).toContain("1,850"); + // string[] — chips + expect(container.textContent).toContain("ai"); + expect(container.textContent).toContain("launch"); + // url → external link + const urlLink = [...container.querySelectorAll("a")].find( + (a) => a.getAttribute("href") === "https://example.com/post", + ); + expect(urlLink).toBeTruthy(); + expect(urlLink?.getAttribute("target")).toBe("_blank"); + // case identifier → case link chip + const caseLink = [...container.querySelectorAll("a")].find( + (a) => a.getAttribute("href") === "/PAP/cases/PAP-C12", + ); + expect(caseLink).toBeTruthy(); + // boolean never renders raw "true"/"false" + expect(container.textContent).not.toContain("true"); + expect(container.textContent).not.toContain("false"); + // null → em-dash present + expect(container.textContent).toContain("—"); + // object fallback → pretty-printed mono JSON block + expect(container.textContent).toContain('"nested": "x"'); + // key insertion order preserved (slug before word_count) + const text = container.textContent ?? ""; + expect(text.indexOf("slug")).toBeLessThan(text.indexOf("word_count")); + + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/CaseFieldsPanel.tsx b/ui/src/components/CaseFieldsPanel.tsx new file mode 100644 index 0000000000..cd14c73a86 --- /dev/null +++ b/ui/src/components/CaseFieldsPanel.tsx @@ -0,0 +1,262 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { Check } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { IssueReferencePill } from "@/components/IssueReferencePill"; +import { Link, useCaseHref } from "@/lib/router"; +import { copyTextToClipboard } from "@/lib/clipboard"; +import { cn } from "@/lib/utils"; + +// ----------------------------------------------------------------------------- +// CaseFieldsPanel (PAP-12968 §3) — the generic key-value renderer for a case's +// `fields` JSON blob. The server stores arbitrary agent-authored JSON, so the UI +// renders by *value type* (Postel's law: never crash on unexpected shapes) and +// preserves the skill's key insertion order (does NOT alphabetize). +// ----------------------------------------------------------------------------- + +const URL_RE = /^https?:\/\/\S+$/i; +const CASE_ID_RE = /^[A-Z][A-Z0-9]*-C\d+$/; +const ISSUE_ID_RE = /^[A-Z][A-Z0-9]*-\d+$/; +const ISSUE_ID_IN_TEXT_RE = /\b[A-Z][A-Z0-9]*-\d+\b/g; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** A muted em-dash for null / empty / missing values. */ +function EmptyValue() { + return ; +} + +function isIssueIdentifierField(fieldKey: string | undefined): boolean { + if (!fieldKey) return false; + const normalized = fieldKey.toLowerCase().replace(/[^a-z0-9]/g, ""); + return normalized.includes("issueidentifier") || normalized.includes("taskidentifier"); +} + +function stringifyCopyValue(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value, null, 2); +} + +function extractIssueIdentifiers(value: unknown, fieldKey?: string): string[] { + if (!isIssueIdentifierField(fieldKey)) { + return typeof value === "string" && ISSUE_ID_RE.test(value.trim()) ? [value.trim()] : []; + } + + const identifiers: string[] = []; + const add = (candidate: unknown) => { + if (typeof candidate !== "string") return; + for (const match of candidate.matchAll(ISSUE_ID_IN_TEXT_RE)) identifiers.push(match[0]); + }; + + if (Array.isArray(value)) value.forEach(add); + else add(value); + + return [...new Set(identifiers)]; +} + +function IssueIdentifierValue({ identifiers }: { identifiers: string[] }) { + return ( + + {identifiers.map((identifier) => ( + + ))} + + ); +} + +function CopyableCompactValue({ + value, + children, + className, +}: { + value: unknown; + children: ReactNode; + className?: string; +}) { + const text = stringifyCopyValue(value); + const [copied, setCopied] = useState(false); + const timerRef = useRef>(undefined); + + useEffect(() => () => clearTimeout(timerRef.current), []); + + const handleCopy = useCallback(() => { + void copyTextToClipboard(text).then(() => { + setCopied(true); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 1500); + }); + }, [text]); + + return ( + + + {copied ? ( + + + Copied + + ) : null} + + ); +} + +function StringValue({ value, variant }: { value: string; variant: "compact" | "full" }) { + const caseHref = useCaseHref(); + const trimmed = value.trim(); + if (trimmed === "") return ; + if (URL_RE.test(trimmed)) { + return ( + + {trimmed} + + + ); + } + if (CASE_ID_RE.test(trimmed)) { + return ( + + {trimmed} + + ); + } + if (variant === "compact") { + return ( + + {value} + + ); + } + return {value}; +} + +export function CaseFieldValue({ + value, + fieldKey, + variant = "full", +}: { + value: unknown; + fieldKey?: string; + variant?: "compact" | "full"; +}) { + if (value === null || value === undefined) return ; + + const issueIdentifiers = extractIssueIdentifiers(value, fieldKey); + if (issueIdentifiers.length > 0) return ; + + if (typeof value === "string") return ; + + if (typeof value === "number") { + if (!Number.isFinite(value)) return {String(value)}; + if (variant === "compact") { + return ( + + {value.toLocaleString()} + + ); + } + return {value.toLocaleString()}; + } + + if (typeof value === "boolean") { + return value ? ( + + ) : ( + + ); + } + + if (Array.isArray(value)) { + if (value.length === 0) return ; + if (variant === "compact") { + return ( + + {stringifyCopyValue(value)} + + ); + } + return ( +
+ {value.map((item, index) => ( + + {typeof item === "string" || typeof item === "number" || typeof item === "boolean" + ? String(item) + : JSON.stringify(item)} + + ))} +
+ ); + } + + if (isPlainObject(value)) { + const snippet = JSON.stringify(value); + if (variant === "compact") { + return ( + + {snippet} + + ); + } + return ( +
+        {JSON.stringify(value, null, 2)}
+      
+ ); + } + + return {String(value)}; +} + +export function CaseFieldsPanel({ fields }: { fields: Record }) { + const entries = Object.entries(fields ?? {}); + + return ( +
+
+

Fields

+ from the skill's schema — rendered generically +
+ + {entries.length === 0 ? ( +
No fields set
+ ) : ( +
+ {entries.map(([key, value]) => ( +
+
{key}
+
+ +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/ui/src/components/CaseIdentifierKey.tsx b/ui/src/components/CaseIdentifierKey.tsx new file mode 100644 index 0000000000..90e3e1613c --- /dev/null +++ b/ui/src/components/CaseIdentifierKey.tsx @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react"; +import { Check } from "lucide-react"; +import { copyTextToClipboard } from "@/lib/clipboard"; +import { cn } from "@/lib/utils"; + +export function CaseCopyableToken({ + value, + label, + className, + containerClassName, + truncate = true, + stopPropagation, +}: { + value: string; + label: string; + className?: string; + containerClassName?: string; + truncate?: boolean; + stopPropagation?: boolean; +}) { + const [copied, setCopied] = useState(false); + const timerRef = useRef>(undefined); + + useEffect(() => () => clearTimeout(timerRef.current), []); + + const handleCopy = useCallback((event: MouseEvent) => { + if (stopPropagation) { + event.preventDefault(); + event.stopPropagation(); + } + void copyTextToClipboard(value).then(() => { + setCopied(true); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 1500); + }); + }, [stopPropagation, value]); + + return ( + + + {copied ? ( + + + Copied + + ) : null} + + ); +} + +export function CaseIdentifierKey({ + identifier, + caseKey, + className, + stopPropagation, +}: { + identifier: string; + caseKey?: string | null; + className?: string; + stopPropagation?: boolean; +}) { + return ( + + + {caseKey ? ( + + ) : null} + + ); +} diff --git a/ui/src/components/CaseRevisionRail.test.tsx b/ui/src/components/CaseRevisionRail.test.tsx new file mode 100644 index 0000000000..b8cd879771 --- /dev/null +++ b/ui/src/components/CaseRevisionRail.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { AnchorHTMLAttributes } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CaseDocumentRevisions } from "@/api/cases"; +import { CaseRevisionRail } from "./CaseRevisionRail"; + +function act(callback: () => void) { + flushSync(callback); +} + +const mockCasesApi = vi.hoisted(() => ({ listRevisions: vi.fn() })); + +vi.mock("@/api/cases", async (importOriginal) => ({ + ...(await importOriginal()), + casesApi: mockCasesApi, +})); +vi.mock("@/components/MarkdownBody", () => ({ + MarkdownBody: ({ children }: { children: string }) =>
{children}
, +})); +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +async function flush() { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + } + flushSync(() => {}); +} + +const revisions: CaseDocumentRevisions = { + key: "body", + document: { id: "doc-1", title: "body", format: "markdown", latestRevisionId: "r2", latestRevisionNumber: 2 }, + revisions: [ + { + id: "r2", + revisionNumber: 2, + title: "body", + format: "markdown", + body: "# Second version", + changeSummary: "polish wording", + createdAt: "2026-07-07T02:00:00.000Z", + createdByAgentId: "agent-1", + createdByUserId: null, + createdByRunId: "run-2", + actorAgentName: "Cases Agent", + issue: { id: "i1", identifier: "PAP-42", title: "Task", status: "in_progress" }, + }, + { + id: "r1", + revisionNumber: 1, + title: "body", + format: "markdown", + body: "# First version", + changeSummary: null, + createdAt: "2026-07-07T01:00:00.000Z", + createdByAgentId: "agent-1", + createdByUserId: null, + createdByRunId: "run-1", + actorAgentName: "Cases Agent", + issue: null, + }, + ], +}; + +describe("CaseRevisionRail", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockCasesApi.listRevisions.mockReset(); + }); + afterEach(() => container.remove()); + + async function render() { + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + act(() => { + root.render( + + + , + ); + }); + await flush(); + return root; + } + + it("renders both revisions and shows the latest body by default", async () => { + mockCasesApi.listRevisions.mockResolvedValue(revisions); + const root = await render(); + const text = container.textContent ?? ""; + expect(text).toContain("rev 2"); + expect(text).toContain("rev 1"); + expect(text).toContain("latest"); + expect(text).toContain("polish wording"); + expect(text).toContain("Cases Agent"); + // Latest (rev 2) selected → its body renders; via-issue attribution shown. + expect(container.querySelector('[data-testid="md"]')?.textContent).toBe("# Second version"); + expect(container.querySelector('a[href="/issues/PAP-42"]')).not.toBeNull(); + act(() => root.unmount()); + }); + + it("switches the rendered body when an older revision is picked", async () => { + mockCasesApi.listRevisions.mockResolvedValue(revisions); + const root = await render(); + const rev1Button = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("rev 1"), + ); + expect(rev1Button).toBeTruthy(); + act(() => rev1Button!.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + await flush(); + expect(container.querySelector('[data-testid="md"]')?.textContent).toBe("# First version"); + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/CaseRevisionRail.tsx b/ui/src/components/CaseRevisionRail.tsx new file mode 100644 index 0000000000..c31d5eb431 --- /dev/null +++ b/ui/src/components/CaseRevisionRail.tsx @@ -0,0 +1,286 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@/lib/router"; +import { casesApi, type CaseDocumentRevision } from "@/api/cases"; +import { queryKeys } from "@/lib/queryKeys"; +import { buildLineDiff, type DiffRow } from "@/lib/line-diff"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { MarkdownBody } from "@/components/MarkdownBody"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { cn, relativeTime } from "@/lib/utils"; +import { Diff } from "lucide-react"; + +/** Author + via-issue attribution line for a revision. */ +function RevisionByline({ revision }: { revision: CaseDocumentRevision }) { + const author = revision.actorAgentName ?? (revision.createdByUserId ? "User" : "System"); + return ( + + {author} + {revision.issue && ( + <> + · + via + e.stopPropagation()} + title={revision.issue.title} + > + {revision.issue.identifier} + + + )} + + ); +} + +function getRevisionLabel(revision: CaseDocumentRevision) { + const actor = revision.actorAgentName ?? (revision.createdByUserId ? "board" : "system"); + return `rev ${revision.revisionNumber} - ${relativeTime(revision.createdAt)} - ${actor}`; +} + +function CaseDocumentDiffModal({ + documentKey, + revisions, + latestRevisionNumber, + open, + onOpenChange, +}: { + documentKey: string; + revisions: CaseDocumentRevision[]; + latestRevisionNumber: number; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [leftRevisionId, setLeftRevisionId] = useState(null); + const [rightRevisionId, setRightRevisionId] = useState(null); + + const effectiveLeftId = leftRevisionId ?? revisions.find( + (revision) => revision.revisionNumber === latestRevisionNumber - 1, + )?.id ?? null; + const effectiveRightId = rightRevisionId ?? revisions.find( + (revision) => revision.revisionNumber === latestRevisionNumber, + )?.id ?? null; + const leftRevision = revisions.find((revision) => revision.id === effectiveLeftId) ?? null; + const rightRevision = revisions.find((revision) => revision.id === effectiveRightId) ?? null; + const diffRows = buildLineDiff(leftRevision?.body ?? "", rightRevision?.body ?? ""); + const lineClassesByKind: Record = { + context: "bg-transparent", + removed: "bg-red-500/10 text-red-900 dark:text-red-100", + added: "bg-green-500/10 text-green-900 dark:text-green-100", + }; + const markerByKind: Record = { + context: " ", + removed: "-", + added: "+", + }; + + return ( + + +
+ + + Diff - {documentKey} + + +
+
+ Old + +
+
+ New + +
+
+
+ +
+ {!leftRevision || !rightRevision ? ( +
Select two revisions to compare.
+ ) : leftRevision.id === rightRevision.id ? ( +
Both sides are the same revision.
+ ) : ( +
+
+ Old + New + + Content +
+ {diffRows.map((row, index) => ( +
+ + {row.oldLineNumber ?? ""} + + + {row.newLineNumber ?? ""} + + + {markerByKind[row.kind]} + +
+                    {row.text.length > 0 ? row.text : " "}
+                  
+
+ ))} +
+ )} +
+
+
+ ); +} + +/** + * Revision rail (P4 §2): read-only body document with a per-revision list. The + * newest revision is selected by default; picking another swaps the rendered + * body. No editing UI in v1. + */ +export function CaseRevisionRail({ + caseIdentifier, + documentKey = "body", +}: { + caseIdentifier: string; + documentKey?: string; +}) { + const revisionsQuery = useQuery({ + queryKey: queryKeys.cases.revisions(caseIdentifier, documentKey), + queryFn: () => casesApi.listRevisions(caseIdentifier, documentKey), + }); + const revisions = revisionsQuery.data?.revisions ?? []; + const [selectedId, setSelectedId] = useState(null); + const [diffOpen, setDiffOpen] = useState(false); + + // Default to the latest revision once loaded; keep a valid selection if the + // list changes underneath us. + useEffect(() => { + if (revisions.length === 0) return; + if (!selectedId || !revisions.some((r) => r.id === selectedId)) { + setSelectedId(revisions[0]!.id); + } + }, [revisions, selectedId]); + + if (revisionsQuery.isLoading) { + return

Loading revisions…

; + } + if (revisionsQuery.isError) { + return

Could not load revisions.

; + } + if (revisions.length === 0) { + return

No revisions yet.

; + } + + const selected = revisions.find((r) => r.id === selectedId) ?? revisions[0]!; + const latestRevisionNumber = revisionsQuery.data?.document.latestRevisionNumber ?? selected.revisionNumber; + + return ( +
+ + + +
+ rev {selected.revisionNumber} + +
+ {selected.body ? ( + + {selected.body} + + ) : ( +

This revision has no body.

+ )} +
+ {revisions.length > 1 ? ( + + ) : null} +
+ ); +} diff --git a/ui/src/components/CasesExperimentalGate.tsx b/ui/src/components/CasesExperimentalGate.tsx new file mode 100644 index 0000000000..07bd76d1f2 --- /dev/null +++ b/ui/src/components/CasesExperimentalGate.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Navigate } from "@/lib/router"; +import { instanceSettingsApi } from "@/api/instanceSettings"; +import { queryKeys } from "@/lib/queryKeys"; + +/** + * Route guard for the experimental Cases feature (PAP-12947). Redirects to the + * dashboard when `enableCases` is off, mirroring {@link PipelinesExperimentalGate}. + */ +export function CasesExperimentalGate({ children }: { children: ReactNode }) { + const { data: experimentalSettings, isFetched } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + + if (!isFetched) return null; + if (experimentalSettings?.enableCases !== true) { + return ; + } + return <>{children}; +} diff --git a/ui/src/components/DocumentAnnotationPanel.tsx b/ui/src/components/DocumentAnnotationPanel.tsx index d18607cb46..e2c030ae69 100644 --- a/ui/src/components/DocumentAnnotationPanel.tsx +++ b/ui/src/components/DocumentAnnotationPanel.tsx @@ -146,6 +146,8 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { const annotationsQueryKey = useMemo( () => annotationTarget.kind === "routine" ? queryKeys.routines.documentAnnotations(annotationTarget.routineId, annotationTarget.documentKey, "all") + : annotationTarget.kind === "case" + ? queryKeys.cases.documentAnnotations(annotationTarget.caseId, annotationTarget.documentKey, "all") : queryKeys.issues.documentAnnotations(annotationTarget.issueId, annotationTarget.documentKey, "all"), [annotationTarget], ); @@ -160,6 +162,12 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { && query.queryKey[2] === annotationTarget.routineId && query.queryKey[3] === annotationTarget.documentKey; } + if (annotationTarget.kind === "case") { + return query.queryKey[0] === "cases" + && query.queryKey[1] === "document-annotations" + && query.queryKey[2] === annotationTarget.caseId + && query.queryKey[3] === annotationTarget.documentKey; + } return query.queryKey[0] === "issues" && query.queryKey[1] === "document-annotations" && query.queryKey[2] === annotationTarget.issueId @@ -709,6 +717,7 @@ function buildOptimisticComment(input: { threadId: input.threadId, issueId: input.target.kind === "issue" ? input.target.issueId : null, routineId: input.target.kind === "routine" ? input.target.routineId : null, + caseId: input.target.kind === "case" ? input.target.caseId : null, documentId: "", body: input.body, authorType: "user", @@ -747,6 +756,7 @@ function buildOptimisticThread(input: { id, issueId: input.target.kind === "issue" ? input.target.issueId : null, routineId: input.target.kind === "routine" ? input.target.routineId : null, + caseId: input.target.kind === "case" ? input.target.caseId : null, documentKey: input.documentKey, status: "open", anchorState: "active", diff --git a/ui/src/components/DocumentDiffModal.tsx b/ui/src/components/DocumentDiffModal.tsx index 8c406749bb..68f3880d44 100644 --- a/ui/src/components/DocumentDiffModal.tsx +++ b/ui/src/components/DocumentDiffModal.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import type { QueryKey } from "@tanstack/react-query"; import type { DocumentRevision } from "@paperclipai/shared"; import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; @@ -35,16 +36,20 @@ export function DocumentDiffModal({ latestRevisionNumber, open, onOpenChange, + revisionsQueryKey, + revisionsQueryFn, }: { - issueId: string; + issueId?: string; documentKey: string; latestRevisionNumber: number; open: boolean; onOpenChange: (open: boolean) => void; + revisionsQueryKey?: QueryKey; + revisionsQueryFn?: () => Promise; }) { const { data: revisions } = useQuery({ - queryKey: queryKeys.issues.documentRevisions(issueId, documentKey), - queryFn: () => issuesApi.listDocumentRevisions(issueId, documentKey), + queryKey: revisionsQueryKey ?? queryKeys.issues.documentRevisions(issueId ?? "", documentKey), + queryFn: () => revisionsQueryFn ? revisionsQueryFn() : issuesApi.listDocumentRevisions(issueId ?? "", documentKey), enabled: open, }); diff --git a/ui/src/components/IssueCasesPanel.test.tsx b/ui/src/components/IssueCasesPanel.test.tsx new file mode 100644 index 0000000000..b9d88c9a51 --- /dev/null +++ b/ui/src/components/IssueCasesPanel.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { AnchorHTMLAttributes } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { IssueCaseLink } from "@/api/cases"; +import { IssueCasesPanel } from "./IssueCasesPanel"; + +function act(callback: () => void) { + flushSync(callback); +} + +const mockCasesApi = vi.hoisted(() => ({ listForIssue: vi.fn() })); +const mockInstanceApi = vi.hoisted(() => ({ getExperimental: vi.fn() })); + +vi.mock("@/api/cases", async (importOriginal) => ({ + ...(await importOriginal()), + casesApi: mockCasesApi, +})); +vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceApi })); +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), + useCaseHref: () => (...segments: string[]) => + `/PAP/${["cases", ...segments].filter(Boolean).join("/")}`, +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +async function flush() { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + } + flushSync(() => {}); +} + +const links: IssueCaseLink[] = [ + { + id: "l1", + role: "work", + createdAt: "2026-07-07T00:00:00.000Z", + case: { id: "c1", identifier: "PAP-C7", title: "Launch post", caseType: "blog_post", status: "in_review" }, + }, +]; + +describe("IssueCasesPanel", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockCasesApi.listForIssue.mockReset(); + mockInstanceApi.getExperimental.mockReset(); + }); + afterEach(() => container.remove()); + + async function render() { + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + act(() => { + root.render( + + + , + ); + }); + await flush(); + return root; + } + + it("renders nothing when the Cases flag is off", async () => { + mockInstanceApi.getExperimental.mockResolvedValue({ enableCases: false }); + mockCasesApi.listForIssue.mockResolvedValue(links); + const root = await render(); + expect(container.textContent).toBe(""); + expect(mockCasesApi.listForIssue).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("renders linked cases with role + status when enabled", async () => { + mockInstanceApi.getExperimental.mockResolvedValue({ enableCases: true }); + mockCasesApi.listForIssue.mockResolvedValue(links); + const root = await render(); + const text = container.textContent ?? ""; + expect(text).toContain("Cases"); + expect(text).toContain("PAP-C7"); + expect(text).toContain("Launch post"); + expect(text).toContain("work"); + expect(text).toContain("in review"); + expect(container.querySelector('a[href="/PAP/cases/PAP-C7"]')).not.toBeNull(); + act(() => root.unmount()); + }); + + it("renders nothing when enabled but no cases are linked", async () => { + mockInstanceApi.getExperimental.mockResolvedValue({ enableCases: true }); + mockCasesApi.listForIssue.mockResolvedValue([]); + const root = await render(); + expect(container.textContent).toBe(""); + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/IssueCasesPanel.tsx b/ui/src/components/IssueCasesPanel.tsx new file mode 100644 index 0000000000..0c1ca68b76 --- /dev/null +++ b/ui/src/components/IssueCasesPanel.tsx @@ -0,0 +1,57 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useCaseHref } from "@/lib/router"; +import { casesApi, type CaseLinkRole } from "@/api/cases"; +import { instanceSettingsApi } from "@/api/instanceSettings"; +import { queryKeys } from "@/lib/queryKeys"; +import { Badge } from "@/components/ui/badge"; +import { StatusBadge } from "@/components/StatusBadge"; + +const ROLE_LABEL: Record = { + origin: "origin", + work: "work", + reference: "reference", +}; + +/** + * Issue-page right-rail section (P4 §5): the cases linked to this issue, each + * with its link role + case status. Self-gates on the experimental Cases flag + * and renders nothing when the flag is off or no cases are linked, so it can be + * dropped into the issue properties panel unconditionally. + */ +export function IssueCasesPanel({ issueId }: { issueId: string }) { + const caseHref = useCaseHref(); + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + const enabled = experimentalSettings?.enableCases === true; + + const casesQuery = useQuery({ + queryKey: queryKeys.cases.forIssue(issueId), + queryFn: () => casesApi.listForIssue(issueId), + enabled: enabled && !!issueId, + }); + + const links = casesQuery.data ?? []; + if (!enabled || links.length === 0) return null; + + return ( +
+

Cases

+
+ {links.map((link) => ( + + {link.case.identifier} + {link.case.title} + {ROLE_LABEL[link.role]} + + + ))} +
+
+ ); +} diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 1b7f5ebf41..d5e233887d 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -225,6 +225,8 @@ interface IssueChatMessageContext { issueStatus?: string; successfulRunHandoff?: SuccessfulRunHandoffState | null; externalReferences?: MarkdownExternalReferenceMap; + /** Linkify `PAP-C7` case chips in comment bodies (experimental Cases flag). */ + linkCaseReferences?: boolean; } const IssueChatCtx = createContext({ @@ -523,6 +525,8 @@ interface IssueChatThreadProps { */ onRefreshLatestComments?: () => Promise | void; externalReferences?: MarkdownExternalReferenceMap; + /** Linkify `PAP-C7` case chips in comment bodies (experimental Cases flag). */ + linkCaseReferences?: boolean; } type IssueChatErrorBoundaryProps = { @@ -784,7 +788,7 @@ function commentDateLabel(date: Date | string | undefined): string { } const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed, onAccent }: { text: string; recessed?: boolean; onAccent?: boolean }) { - const { onImageClick, externalReferences } = useContext(IssueChatCtx); + const { onImageClick, externalReferences, linkCaseReferences } = useContext(IssueChatCtx); if (isSuccessfulRunHandoffComment(text)) { return ; } @@ -795,6 +799,7 @@ const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed, onAc softBreaks onImageClick={onImageClick} externalReferences={externalReferences} + linkCaseReferences={linkCaseReferences} > {text} @@ -4237,6 +4242,7 @@ export function IssueChatThread({ onResumeFromBacklog, resumeFromBacklogPending = false, externalReferences, + linkCaseReferences = false, }: IssueChatThreadProps) { const location = useLocation(); const lastScrolledHashRef = useRef(null); @@ -4777,6 +4783,7 @@ export function IssueChatThread({ issueStatus, successfulRunHandoff, externalReferences, + linkCaseReferences, }), [ feedbackDataSharingPreference, @@ -4803,6 +4810,7 @@ export function IssueChatThread({ issueStatus, successfulRunHandoff, externalReferences, + linkCaseReferences, ], ); diff --git a/ui/src/components/IssueDocumentAnnotations.test.tsx b/ui/src/components/IssueDocumentAnnotations.test.tsx index b99165579c..1af735cd53 100644 --- a/ui/src/components/IssueDocumentAnnotations.test.tsx +++ b/ui/src/components/IssueDocumentAnnotations.test.tsx @@ -27,15 +27,35 @@ const mockAnnotationsApi = vi.hoisted(() => { updateStatusForTarget: vi.fn(), }; api.listForTarget.mockImplementation((target, options) => - target.kind === "issue" ? api.list(target.issueId, target.documentKey, options) : api.list(target.routineId, target.documentKey, options)); + target.kind === "issue" + ? api.list(target.issueId, target.documentKey, options) + : target.kind === "case" + ? api.list(target.caseId, target.documentKey, options) + : api.list(target.routineId, target.documentKey, options)); api.getForTarget.mockImplementation((target, threadId) => - target.kind === "issue" ? api.get(target.issueId, target.documentKey, threadId) : api.get(target.routineId, target.documentKey, threadId)); + target.kind === "issue" + ? api.get(target.issueId, target.documentKey, threadId) + : target.kind === "case" + ? api.get(target.caseId, target.documentKey, threadId) + : api.get(target.routineId, target.documentKey, threadId)); api.createForTarget.mockImplementation((target, data) => - target.kind === "issue" ? api.create(target.issueId, target.documentKey, data) : api.create(target.routineId, target.documentKey, data)); + target.kind === "issue" + ? api.create(target.issueId, target.documentKey, data) + : target.kind === "case" + ? api.create(target.caseId, target.documentKey, data) + : api.create(target.routineId, target.documentKey, data)); api.addCommentForTarget.mockImplementation((target, threadId, data) => - target.kind === "issue" ? api.addComment(target.issueId, target.documentKey, threadId, data) : api.addComment(target.routineId, target.documentKey, threadId, data)); + target.kind === "issue" + ? api.addComment(target.issueId, target.documentKey, threadId, data) + : target.kind === "case" + ? api.addComment(target.caseId, target.documentKey, threadId, data) + : api.addComment(target.routineId, target.documentKey, threadId, data)); api.updateStatusForTarget.mockImplementation((target, threadId, status) => - target.kind === "issue" ? api.updateStatus(target.issueId, target.documentKey, threadId, status) : api.updateStatus(target.routineId, target.documentKey, threadId, status)); + target.kind === "issue" + ? api.updateStatus(target.issueId, target.documentKey, threadId, status) + : target.kind === "case" + ? api.updateStatus(target.caseId, target.documentKey, threadId, status) + : api.updateStatus(target.routineId, target.documentKey, threadId, status)); return api; }); diff --git a/ui/src/components/IssueDocumentAnnotations.tsx b/ui/src/components/IssueDocumentAnnotations.tsx index 2e959c15bd..cff00f8f55 100644 --- a/ui/src/components/IssueDocumentAnnotations.tsx +++ b/ui/src/components/IssueDocumentAnnotations.tsx @@ -167,6 +167,8 @@ export function IssueDocumentAnnotations({ const annotationsQuery = useQuery({ queryKey: target?.kind === "routine" ? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all") + : target?.kind === "case" + ? queryKeys.cases.documentAnnotations(target.caseId, target.documentKey, "all") : queryKeys.issues.documentAnnotations(issueId, doc.key, "all"), queryFn: () => target ? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true }) @@ -406,6 +408,8 @@ export function DocumentAnnotationsCountChip({ const annotationsQuery = useQuery({ queryKey: target?.kind === "routine" ? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all") + : target?.kind === "case" + ? queryKeys.cases.documentAnnotations(target.caseId, target.documentKey, "all") : queryKeys.issues.documentAnnotations(issueId, docKey, "all"), queryFn: () => target ? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true }) diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index 7a1c54339d..cc3639120d 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -152,9 +152,6 @@ vi.mock("@/components/ui/dropdown-menu", async () => { }; }); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; - const localStorageEntries = new Map(); function ensureLocalStorageMock() { @@ -592,6 +589,7 @@ describe("IssueDocumentsSection", () => { await act(async () => { restoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + await flush(); expect(mockIssuesApi.restoreDocumentRevision).toHaveBeenCalledWith("issue-1", "plan", "revision-3"); expect(container.textContent).toContain("Restored plan body"); @@ -899,4 +897,84 @@ describe("IssueDocumentsSection", () => { }); queryClient.clear(); }); + + it("renders and locks documents for a non-issue document subject", async () => { + const caseDocument = createIssueDocument({ + id: "case-document-1", + issueId: "case-1", + key: "body", + title: "Body", + body: "Reusable case document body", + latestRevisionId: "case-revision-2", + latestRevisionNumber: 2, + updatedByAgentId: "agent-1", + updatedByUserId: null, + }); + const lockedCaseDocument = { + ...caseDocument, + lockedAt: new Date("2026-03-31T12:06:00.000Z"), + lockedByUserId: "user-1", + updatedAt: new Date("2026-03-31T12:06:00.000Z"), + }; + const listDocuments = vi.fn() + .mockResolvedValueOnce([caseDocument]) + .mockResolvedValue([lockedCaseDocument]); + const setDocumentLock = vi.fn().mockResolvedValue(lockedCaseDocument); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + ["cases", "revisions", "case-1", key], + listDocuments, + listDocumentRevisions: vi.fn().mockResolvedValue([]), + getDocument: vi.fn().mockResolvedValue(caseDocument), + upsertDocument: vi.fn().mockResolvedValue(caseDocument), + deleteDocument: vi.fn().mockResolvedValue({ ok: true }), + restoreDocumentRevision: vi.fn().mockResolvedValue(caseDocument), + setDocumentLock, + hideSystemDocuments: false, + legacyPlanDocument: null, + annotations: null, + }} + canDeleteDocuments + canManageDocumentLocks + /> + , + ); + }); + await flush(); + await flush(); + + expect(listDocuments).toHaveBeenCalled(); + expect(container.textContent).toContain("Reusable case document body"); + expect(container.textContent).toContain("body"); + + const lockButton = container.querySelector('button[title="Lock document"]'); + expect(lockButton).toBeTruthy(); + + await act(async () => { + lockButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(setDocumentLock).toHaveBeenCalledWith("body", true); + expect(container.querySelector('button[title="Unlock document"]')).toBeTruthy(); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); }); diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 64f2b3ae89..1e1938e58f 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { Agent, DocumentRevision, @@ -20,6 +21,7 @@ import { queryKeys } from "../lib/queryKeys"; import { cn, relativeTime } from "../lib/utils"; import { FoldCurtain } from "./FoldCurtain"; import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "./IssueDocumentAnnotations"; +import type { DocumentAnnotationTarget } from "@/api/document-annotations"; import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody"; import { MarkdownEditor, type MentionOption } from "./MarkdownEditor"; import { OutputFeedbackButtons } from "./OutputFeedbackButtons"; @@ -53,6 +55,33 @@ type DocumentConflictState = { showRemote: boolean; }; +type DocumentSubjectConfig = { + id: string; + detailQueryKey?: QueryKey; + documentsQueryKey: QueryKey; + idleDocumentRevisionsQueryKey: QueryKey; + documentRevisionsQueryKey: (key: string) => QueryKey; + listDocuments: () => Promise; + listDocumentRevisions: (key: string) => Promise; + getDocument: (key: string) => Promise; + upsertDocument: (key: string, data: { + title: string | null; + format: "markdown"; + body: string; + baseRevisionId: string | null; + }) => Promise; + deleteDocument?: (key: string) => Promise; + restoreDocumentRevision?: (key: string, revisionId: string) => Promise; + setDocumentLock?: (key: string, locked: boolean) => Promise; + syncDetailCache?: (queryClient: QueryClient, document: IssueDocument) => void; + hideSystemDocuments?: boolean; + legacyPlanDocument?: { body: string } | null; + annotations?: { + issueId: string; + target?: DocumentAnnotationTarget | ((documentKey: string) => DocumentAnnotationTarget); + } | null; +}; + const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900; const DOCUMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/; const getFoldedDocumentsStorageKey = (issueId: string) => `paperclip:issue-document-folds:${issueId}`; @@ -170,8 +199,50 @@ function toDocumentSummary(document: IssueDocument) { }; } +function makeIssueDocumentSubject(issue: Issue): DocumentSubjectConfig { + return { + id: issue.id, + detailQueryKey: queryKeys.issues.detail(issue.id), + documentsQueryKey: queryKeys.issues.documents(issue.id), + idleDocumentRevisionsQueryKey: ["issues", "document-revisions", issue.id, "__idle__"], + documentRevisionsQueryKey: (key) => queryKeys.issues.documentRevisions(issue.id, key), + listDocuments: () => issuesApi.listDocuments(issue.id), + listDocumentRevisions: (key) => issuesApi.listDocumentRevisions(issue.id, key), + getDocument: (key) => issuesApi.getDocument(issue.id, key), + upsertDocument: (key, data) => issuesApi.upsertDocument(issue.id, key, data), + deleteDocument: (key) => issuesApi.deleteDocument(issue.id, key), + restoreDocumentRevision: (key, revisionId) => issuesApi.restoreDocumentRevision(issue.id, key, revisionId), + setDocumentLock: (key, locked) => + locked ? issuesApi.lockDocument(issue.id, key) : issuesApi.unlockDocument(issue.id, key), + syncDetailCache: (queryClient, document) => { + queryClient.setQueryData( + queryKeys.issues.detail(issue.id), + (current) => { + if (!current) return current; + const nextSummaries = (() => { + const summary = toDocumentSummary(document); + const existingIndex = (current.documentSummaries ?? []).findIndex((entry) => entry.key === document.key); + if (existingIndex === -1) return [...(current.documentSummaries ?? []), summary]; + return (current.documentSummaries ?? []).map((entry, index) => index === existingIndex ? summary : entry); + })(); + return { + ...current, + planDocument: document.key === "plan" ? document : current.planDocument ?? null, + documentSummaries: nextSummaries, + legacyPlanDocument: document.key === "plan" ? null : current.legacyPlanDocument ?? null, + }; + }, + ); + }, + hideSystemDocuments: true, + legacyPlanDocument: issue.legacyPlanDocument, + annotations: { issueId: issue.id }, + }; +} + export function IssueDocumentsSection({ issue, + subject, canDeleteDocuments, canManageDocumentLocks = false, feedbackVotes = [], @@ -188,7 +259,8 @@ export function IssueDocumentsSection({ forceEditDocumentKey, externalReferences, }: { - issue: Issue; + issue?: Issue; + subject?: DocumentSubjectConfig; canDeleteDocuments: boolean; canManageDocumentLocks?: boolean; feedbackVotes?: FeedbackVote[]; @@ -217,11 +289,24 @@ export function IssueDocumentsSection({ }) { const queryClient = useQueryClient(); const location = useLocation(); + const documentSubject = useMemo(() => { + if (subject) return subject; + if (!issue) throw new Error("IssueDocumentsSection requires either issue or subject"); + return makeIssueDocumentSubject(issue); + }, [issue, subject]); + const annotationTargetForKey = useCallback((documentKey: string) => { + const configured = documentSubject.annotations?.target; + if (!configured) return undefined; + if (typeof configured === "function") return configured(documentKey); + if (configured.kind === "issue") return { ...configured, documentKey }; + if (configured.kind === "case") return { ...configured, documentKey }; + return configured; + }, [documentSubject]); const [confirmDeleteKey, setConfirmDeleteKey] = useState(null); const [error, setError] = useState(null); const [draft, setDraft] = useState(null); const [documentConflict, setDocumentConflict] = useState(null); - const [foldedDocumentKeys, setFoldedDocumentKeys] = useState(() => loadFoldedDocumentKeys(issue.id)); + const [foldedDocumentKeys, setFoldedDocumentKeys] = useState(() => loadFoldedDocumentKeys(documentSubject.id)); const [annotationPanelOpenKeys, setAnnotationPanelOpenKeys] = useState( () => (defaultAnnotationPanelOpenKeys ?? []), ); @@ -242,39 +327,42 @@ export function IssueDocumentsSection({ } = useAutosaveIndicator(); const { data: documents } = useQuery({ - queryKey: queryKeys.issues.documents(issue.id), - queryFn: () => issuesApi.listDocuments(issue.id), + queryKey: documentSubject.documentsQueryKey, + queryFn: documentSubject.listDocuments, }); const { data: activeDocumentRevisions, isFetching: isFetchingDocumentRevisions } = useQuery({ queryKey: revisionMenuOpenKey - ? queryKeys.issues.documentRevisions(issue.id, revisionMenuOpenKey) - : ["issues", "document-revisions", issue.id, "__idle__"], + ? documentSubject.documentRevisionsQueryKey(revisionMenuOpenKey) + : documentSubject.idleDocumentRevisionsQueryKey, queryFn: async () => { if (!revisionMenuOpenKey) return []; - return issuesApi.listDocumentRevisions(issue.id, revisionMenuOpenKey); + return documentSubject.listDocumentRevisions(revisionMenuOpenKey); }, enabled: Boolean(revisionMenuOpenKey), }); const invalidateIssueDocuments = useCallback(() => { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) }); + if (documentSubject.detailQueryKey) { + queryClient.invalidateQueries({ queryKey: documentSubject.detailQueryKey }); + } + queryClient.invalidateQueries({ queryKey: documentSubject.documentsQueryKey }); queryClient.invalidateQueries({ predicate: (query) => Array.isArray(query.queryKey) - && query.queryKey[0] === "issues" + && query.queryKey.includes(documentSubject.id) && ( - (query.queryKey[1] === "document-revisions" && query.queryKey[2] === issue.id) - || (query.queryKey[1] === "document-annotations" && query.queryKey[2] === issue.id) + query.queryKey.includes("document-revisions") + || query.queryKey.includes("document-annotations") + || query.queryKey.includes("revisions") ), }); - }, [issue.id, queryClient]); + }, [documentSubject, queryClient]); const syncDocumentCaches = useCallback((document: IssueDocument) => { - if (isSystemIssueDocumentKey(document.key)) return; + if (documentSubject.hideSystemDocuments && isSystemIssueDocumentKey(document.key)) return; queryClient.setQueryData( - queryKeys.issues.documents(issue.id), + documentSubject.documentsQueryKey, (current) => { if (!current) return [document]; const existingIndex = current.findIndex((entry) => entry.key === document.key); @@ -282,29 +370,12 @@ export function IssueDocumentsSection({ return current.map((entry, index) => index === existingIndex ? document : entry); }, ); - queryClient.setQueryData( - queryKeys.issues.detail(issue.id), - (current) => { - if (!current) return current; - const nextSummaries = (() => { - const summary = toDocumentSummary(document); - const existingIndex = (current.documentSummaries ?? []).findIndex((entry) => entry.key === document.key); - if (existingIndex === -1) return [...(current.documentSummaries ?? []), summary]; - return (current.documentSummaries ?? []).map((entry, index) => index === existingIndex ? summary : entry); - })(); - return { - ...current, - planDocument: document.key === "plan" ? document : current.planDocument ?? null, - documentSummaries: nextSummaries, - legacyPlanDocument: document.key === "plan" ? null : current.legacyPlanDocument ?? null, - }; - }, - ); - }, [issue.id, queryClient]); + documentSubject.syncDetailCache?.(queryClient, document); + }, [documentSubject, queryClient]); const upsertDocument = useMutation({ mutationFn: async (nextDraft: DraftState) => - issuesApi.upsertDocument(issue.id, nextDraft.key, { + documentSubject.upsertDocument(nextDraft.key, { title: isPlanKey(nextDraft.key) ? null : nextDraft.title.trim() || null, format: "markdown", body: nextDraft.body, @@ -313,7 +384,9 @@ export function IssueDocumentsSection({ }); const deleteDocument = useMutation({ - mutationFn: (key: string) => issuesApi.deleteDocument(issue.id, key), + mutationFn: (key: string) => documentSubject.deleteDocument + ? documentSubject.deleteDocument(key) + : Promise.reject(new Error("Document deletion is not available")), onSuccess: () => { setError(null); setConfirmDeleteKey(null); @@ -326,7 +399,9 @@ export function IssueDocumentsSection({ const restoreDocumentRevision = useMutation({ mutationFn: ({ key, revisionId }: { key: string; revisionId: string }) => - issuesApi.restoreDocumentRevision(issue.id, key, revisionId), + documentSubject.restoreDocumentRevision + ? documentSubject.restoreDocumentRevision(key, revisionId) + : Promise.reject(new Error("Document revision restore is not available")), onSuccess: (document, variables) => { syncDocumentCaches(document); setSelectedRevisionIds((current) => ({ ...current, [variables.key]: null })); @@ -343,7 +418,9 @@ export function IssueDocumentsSection({ const setDocumentLock = useMutation({ mutationFn: ({ key, locked }: { key: string; locked: boolean }) => - locked ? issuesApi.lockDocument(issue.id, key) : issuesApi.unlockDocument(issue.id, key), + documentSubject.setDocumentLock + ? documentSubject.setDocumentLock(key, locked) + : Promise.reject(new Error("Document locking is not available")), onSuccess: (document) => { syncDocumentCaches(document); setDraft((current) => current?.key === document.key ? null : current); @@ -358,12 +435,12 @@ export function IssueDocumentsSection({ }); const sortedDocuments = useMemo(() => { - return (documents ?? []).filter((doc) => !isSystemIssueDocumentKey(doc.key)).sort((a, b) => { + return (documents ?? []).filter((doc) => !documentSubject.hideSystemDocuments || !isSystemIssueDocumentKey(doc.key)).sort((a, b) => { if (a.key === "plan" && b.key !== "plan") return -1; if (a.key !== "plan" && b.key === "plan") return 1; return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); }); - }, [documents]); + }, [documentSubject.hideSystemDocuments, documents]); const feedbackVoteByTargetId = useMemo(() => { const map = new Map(); @@ -375,7 +452,7 @@ export function IssueDocumentsSection({ }, [feedbackVotes]); const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan"); - const isEmpty = sortedDocuments.length === 0 && !issue.legacyPlanDocument; + const isEmpty = sortedDocuments.length === 0 && !documentSubject.legacyPlanDocument; const newDocumentKeyError = draft?.isNew && draft.key.trim().length > 0 && !DOCUMENT_KEY_PATTERN.test(draft.key.trim()) ? "Use lowercase letters, numbers, -, or _, and start with a letter or number." @@ -539,7 +616,7 @@ export function IssueDocumentsSection({ } if (isDocumentConflictError(err)) { try { - const latestDocument = await issuesApi.getDocument(issue.id, normalizedKey); + const latestDocument = await documentSubject.getDocument(normalizedKey); setDocumentConflict({ key: normalizedKey, serverDocument: latestDocument, @@ -564,7 +641,7 @@ export function IssueDocumentsSection({ setError(err instanceof Error ? err.message : "Failed to save document"); return false; } - }, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, syncDocumentCaches, upsertDocument]); + }, [documentConflict, documentSubject, invalidateIssueDocuments, resetAutosaveState, runSave, sortedDocuments, syncDocumentCaches, upsertDocument]); const reloadDocumentFromServer = useCallback((key: string) => { if (documentConflict?.key !== key) return; @@ -627,11 +704,11 @@ export function IssueDocumentsSection({ }, []); const getDocumentRevisions = useCallback((key: string) => { - const cached = queryClient.getQueryData(queryKeys.issues.documentRevisions(issue.id, key)); + const cached = queryClient.getQueryData(documentSubject.documentRevisionsQueryKey(key)); if (cached) return cached; if (revisionMenuOpenKey === key) return activeDocumentRevisions ?? []; return []; - }, [activeDocumentRevisions, issue.id, queryClient, revisionMenuOpenKey]); + }, [activeDocumentRevisions, documentSubject, queryClient, revisionMenuOpenKey]); const returnToLatestRevision = useCallback((key: string) => { setSelectedRevisionIds((current) => ({ ...current, [key]: null })); @@ -691,27 +768,27 @@ export function IssueDocumentsSection({ }; useEffect(() => { - setFoldedDocumentKeys(loadFoldedDocumentKeys(issue.id)); - }, [issue.id]); + setFoldedDocumentKeys(loadFoldedDocumentKeys(documentSubject.id)); + }, [documentSubject.id]); useEffect(() => { hasScrolledToHashRef.current = false; - }, [issue.id, location.hash]); + }, [documentSubject.id, location.hash]); useEffect(() => { const validKeys = new Set(sortedDocuments.map((doc) => doc.key)); setFoldedDocumentKeys((current) => { const next = current.filter((key) => validKeys.has(key)); if (next.length !== current.length) { - saveFoldedDocumentKeys(issue.id, next); + saveFoldedDocumentKeys(documentSubject.id, next); } return next; }); - }, [issue.id, sortedDocuments]); + }, [documentSubject.id, sortedDocuments]); useEffect(() => { - saveFoldedDocumentKeys(issue.id, foldedDocumentKeys); - }, [foldedDocumentKeys, issue.id]); + saveFoldedDocumentKeys(documentSubject.id, foldedDocumentKeys); + }, [documentSubject.id, foldedDocumentKeys]); useEffect(() => { if (!documentConflict) return; @@ -729,7 +806,7 @@ export function IssueDocumentsSection({ if (!hash.startsWith("#document-")) return; const documentKey = decodeURIComponent(hash.slice("#document-".length)); const targetExists = sortedDocuments.some((doc) => doc.key === documentKey) - || (documentKey === "plan" && Boolean(issue.legacyPlanDocument)); + || (documentKey === "plan" && Boolean(documentSubject.legacyPlanDocument)); if (!targetExists || hasScrolledToHashRef.current) return; setFoldedDocumentKeys((current) => current.filter((key) => key !== documentKey)); const element = document.getElementById(`document-${documentKey}`); @@ -739,7 +816,7 @@ export function IssueDocumentsSection({ element.scrollIntoView({ behavior: "smooth", block: "center" }); const timer = setTimeout(() => setHighlightDocumentKey((current) => current === documentKey ? null : current), 3000); return () => clearTimeout(timer); - }, [issue.legacyPlanDocument, location.hash, sortedDocuments]); + }, [documentSubject.legacyPlanDocument, location.hash, sortedDocuments]); useEffect(() => { return () => { @@ -892,7 +969,7 @@ export function IssueDocumentsSection({ )} - {!hasRealPlan && issue.legacyPlanDocument ? ( + {!hasRealPlan && documentSubject.legacyPlanDocument ? (
- {renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName, externalReferences)} + {renderFoldableBody(documentSubject.legacyPlanDocument.body, documentBodyContentClassName, externalReferences)}
) : null} @@ -936,6 +1013,7 @@ export function IssueDocumentsSection({ const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key); const canVoteOnDocument = Boolean(doc.latestRevisionId && doc.updatedByAgentId && !doc.updatedByUserId && onVote); const lockActionPending = setDocumentLock.isPending && setDocumentLock.variables?.key === doc.key; + const annotationTarget = annotationTargetForKey(doc.key); return (
previewRevision(doc, revisionId), }} updatedAt={displayedUpdatedAt} - annotationSlot={!isSystemIssueDocumentKey(doc.key) ? ( + annotationSlot={documentSubject.annotations && !isSystemIssueDocumentKey(doc.key) ? ( toggleAnnotationPanel(doc.key)} @@ -1193,24 +1272,8 @@ export function IssueDocumentsSection({ activeDraft || isHistoricalPreview ? "" : "rounded-md hover:bg-accent/10" }`} > - setAnnotationPanelOpen(doc.key, next)} - agentMap={agentMap} - userProfileMap={userProfileMap} - defaultFocusedThreadId={defaultAnnotationFocusedThreadIds?.[doc.key]} - > - {isHistoricalPreview ? ( + {(() => { + const renderedDocumentBody = isHistoricalPreview ? ( renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences) ) : activeDraft ? ( ) : ( renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences) - )} - + ); + + return documentSubject.annotations ? ( + setAnnotationPanelOpen(doc.key, next)} + agentMap={agentMap} + userProfileMap={userProfileMap} + defaultFocusedThreadId={defaultAnnotationFocusedThreadIds?.[doc.key]} + > + {renderedDocumentBody} + + ) : renderedDocumentBody; + })()}
documentSubject.listDocumentRevisions(diffDoc.key)} open onOpenChange={(open) => { if (!open) setDiffViewKey(null); }} /> diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 73b5f8a968..497250de9c 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -134,6 +134,7 @@ vi.mock("./AgentIconPicker", () => ({ vi.mock("@/lib/router", () => ({ Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => {children}, + useCaseHref: () => (caseId: string) => `/cases/${caseId}`, })); vi.mock("@/components/ui/separator", () => ({ diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index b64f31ef27..cdcd49ed46 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -4,13 +4,23 @@ import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react"; import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; -import { Link } from "@/lib/router"; +import { Link, useCaseHref } from "@/lib/router"; import { useTheme } from "../context/ThemeContext"; import { useOptionalCompany } from "../context/CompanyContext"; import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference"; +import { remarkLinkCaseReferences } from "../lib/case-reference"; + +const CASE_HREF_RE = /^\/cases\/([A-Z][A-Z0-9]*-C\d+)$/i; + +/** Recover the case identifier from a `/cases/PAP-C7` href produced by the plugin. */ +function caseIdentifierFromHref(href: string | undefined): string | null { + if (!href) return null; + const match = decodeURIComponent(href.trim()).match(CASE_HREF_RE); + return match ? match[1]!.toUpperCase() : null; +} import { parseWorkspaceFileHref, remarkWorkspaceFileRefs, WORKSPACE_FILE_HREF_PREFIX } from "../lib/remark-workspace-file-refs"; import { remarkSoftBreaks } from "../lib/remark-soft-breaks"; import { StatusIcon } from "./StatusIcon"; @@ -52,6 +62,11 @@ interface MarkdownBodyProps { style?: React.CSSProperties; softBreaks?: boolean; linkIssueReferences?: boolean; + /** + * Linkify bare case identifiers (`PAP-C7`) to the case detail page. Off by + * default; enabled on surfaces behind the experimental Cases flag (PAP-12969). + */ + linkCaseReferences?: boolean; /** Opt into Obsidian-style [[target]] / [[target|label]] wikilinks. */ enableWikiLinks?: boolean; /** Base href used for wikilinks when no resolver is supplied. */ @@ -110,6 +125,28 @@ function MarkdownIssueLink({ ); } +function MarkdownCaseLink({ + identifier, + children, +}: { + identifier: string; + children: ReactNode; +}) { + // Cases resolve via the get-by-identifier route; navigate there on click. + // Kept boxless/underlined to match the issue mention treatment. + const caseHref = useCaseHref(); + return ( + + {children} + + ); +} + function MarkdownExternalLink({ href, reference, @@ -652,6 +689,7 @@ function MarkdownBodyImpl({ style, softBreaks = true, linkIssueReferences = true, + linkCaseReferences = false, enableWikiLinks = false, wikiLinkRoot, resolveWikiLinkHref, @@ -698,11 +736,14 @@ function MarkdownBodyImpl({ if (linkIssueReferences) { plugins.push([remarkLinkIssueReferences, { knownPrefixes }]); } + if (linkCaseReferences) { + plugins.push([remarkLinkCaseReferences, { knownPrefixes }]); + } if (softBreaks) { plugins.push(remarkSoftBreaks); } return plugins; - }, [enableWikiLinks, wikiLinkRoot, resolveWikiLinkHref, linkWorkspaceFileRefs, linkIssueReferences, knownPrefixes, softBreaks]); + }, [enableWikiLinks, wikiLinkRoot, resolveWikiLinkHref, linkWorkspaceFileRefs, linkIssueReferences, linkCaseReferences, knownPrefixes, softBreaks]); const components = useMemo(() => { const map: Components = { p: ({ node: _node, style: paragraphStyle, children: paragraphChildren, ...paragraphProps }) => ( @@ -785,6 +826,11 @@ function MarkdownBodyImpl({ ); } + const caseIdentifier = linkCaseReferences ? caseIdentifierFromHref(href) : null; + if (caseIdentifier) { + return {linkChildren}; + } + const parsed = href ? parseMentionChipHref(href) : null; if (parsed) { const targetHref = parsed.kind === "project" @@ -861,7 +907,7 @@ function MarkdownBodyImpl({ }; } return map; - }, [theme, linkIssueReferences, externalReferenceLookup, resolveImageSrc, onImageClick]); + }, [theme, linkIssueReferences, linkCaseReferences, externalReferenceLookup, resolveImageSrc, onImageClick]); return (
+ {showCases ? ( + + ) : null} {showPipelines ? ( diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index e9f41bd41e..911228a24b 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -73,6 +73,7 @@ import { } from "./helpers"; import { PropertyPicker } from "./property-picker"; import { PropertyChip, PropertyRow, PropertySection } from "./primitives"; +import { IssueCasesPanel } from "../IssueCasesPanel"; import { ExpandRelationListButton, RemovableIssueReferencePill } from "./relation-controls"; import { Badge } from "@/components/ui/badge"; @@ -2298,6 +2299,12 @@ export function IssueProperties({ )} + + {/* Experimental Cases rail (PAP-12969) — self-gates on the flag and + renders nothing when no cases are linked. */} +
+ +
); } diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 1fe52db88c..422046a52c 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -441,6 +441,47 @@ describe("LiveUpdatesProvider issue invalidation", () => { }); }); + it("refreshes case document annotation caches when case annotation activity arrives", () => { + const invalidations: unknown[] = []; + const queryClient = { + invalidateQueries: (input: unknown) => { + invalidations.push(input); + }, + getQueryData: () => undefined, + }; + + __liveUpdatesTestUtils.invalidateActivityQueries( + queryClient as never, + "company-1", + { + entityType: "case", + entityId: "case-1", + action: "case.document_annotation_comment_added", + actorType: "user", + actorId: "user-2", + details: { + documentKey: "body", + threadId: "thread-1", + commentId: "comment-1", + }, + }, + { userId: "user-1", agentId: null }, + ); + + expect(invalidations).toContainEqual({ + queryKey: queryKeys.cases.list("company-1"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.cases.detail("case-1"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.cases.events("case-1"), + }); + expect(invalidations).toContainEqual({ + queryKey: ["cases", "document-annotations", "case-1", "body"], + }); + }); + it("keeps self-authored comment events from refetching the active issue tree", () => { const invalidations: unknown[] = []; const queryClient = { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 7a3976ba08..cef24b1f13 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -587,6 +587,13 @@ const ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([ "routine.document_annotation_thread_reopened", "routine.document_annotation_remapped", ]); +const CASE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([ + "case.document_annotation_thread_created", + "case.document_annotation_comment_added", + "case.document_annotation_thread_resolved", + "case.document_annotation_thread_reopened", + "case.document_annotation_remapped", +]); const AGENT_TOAST_STATUSES = new Set(["error"]); const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]); @@ -975,6 +982,25 @@ function invalidateActivityQueries( return; } + if (entityType === "case") { + queryClient.invalidateQueries({ queryKey: queryKeys.cases.list(companyId) }); + if (entityId) { + queryClient.invalidateQueries({ queryKey: queryKeys.cases.detail(entityId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.cases.events(entityId) }); + if (action && CASE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS.has(action)) { + const documentKey = readString(details?.key) ?? readString(details?.documentKey); + const caseInvalidationOptions = ownActorActivity ? { refetchType: "inactive" as const } : undefined; + queryClient.invalidateQueries({ + queryKey: documentKey + ? ["cases", "document-annotations", entityId, documentKey] + : ["cases", "document-annotations", entityId], + ...caseInvalidationOptions, + }); + } + } + return; + } + if (entityType === "company") { queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); } diff --git a/ui/src/index.css b/ui/src/index.css index c76beed8ae..43430a2fda 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1682,6 +1682,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { --pct-90: 90%; /* Extracted from ui/src/components/DocumentDiffModal.tsx (max-w-[90%]). */ --pct-50: 50%; /* Extracted from ui/src/components/ImageGalleryModal.tsx (max-w-[50%]). */ --pct-85: 85%; /* Extracted from ui/src/components/IssueChatThread.test.tsx (max-w-[85%]). */ + --pct-70: 70%; /* Extracted from ui/src/components/CaseFieldsPanel.tsx (max-w-[70%]). */ --pct-neg-50: -50%; /* Extracted from ui/src/components/ui/alert-dialog.tsx (translate-x-[-50%]). */ --pct-72: 72%; /* Extracted from ui/src/pages/IssueDetail.tsx (w-[72%]). */ --shadow-extract-1: 0 16px 40px rgba(37,99,235,0.08); /* ActiveAgentsPanel.tsx live-box glow. Gallery feedback r2: liveness glow recolored cyan->status blue (value edit, site unchanged; originally extracted as rgba(6,182,212,0.08)). */ @@ -1739,6 +1740,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { */ :root { --gtc-1: 56px 56px 24px minmax(0,1fr); /* Extracted from ui/src/components/DocumentDiffModal.tsx (grid-cols-[56px_56px_24px_minmax(0,1fr)]). */ + --gtc-case-revisions: 16rem 1fr; /* Extracted from ui/src/components/CaseRevisionRail.tsx (grid-cols-[16rem_1fr]). */ --gtc-2: auto minmax(0,1fr) 2.25rem; /* Extracted from ui/src/components/FileTree.tsx (grid-cols-[auto_minmax(0,1fr)_2.25rem]). */ --gtc-3: minmax(0,1fr) 2.25rem; /* Extracted from ui/src/components/FileTree.tsx (grid-cols-[minmax(0,1fr)_2.25rem]). */ --gtc-4: minmax(0,1fr); /* Extracted from ui/src/components/FileTree.tsx (grid-cols-[minmax(0,1fr)]). */ @@ -1825,6 +1827,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { --z-9999: 9999; /* Extracted from ui/src/components/MarkdownEditor.tsx (z-[9999]). */ --z-120: 120; /* Extracted from ui/src/components/ToastViewport.tsx (z-[120]). */ --s-0_98: 0.98; /* Extracted from ui/src/pages/Inbox.tsx (scale-[0.98]). */ + --s-1_02: 1.02; /* Extracted from ui/src/components/CaseAttachmentsGallery.tsx (scale-[1.02]). */ --e-cubic-bezier-0_16-1-0_3-1: cubic-bezier(0.16,1,0.3,1); /* Extracted from ui/src/components/ui/alert-dialog.tsx (ease-[cubic-bezier(0.16,1,0.3,1)]). */ --va-0_125em: -0.125em; /* Extracted from ui/src/components/ExternalObjectStatusIcon.tsx (align-[-0.125em]). */ --sw-2_3: 2.3; /* Extracted from ui/src/components/MobileBottomNav.tsx (stroke-[2.3]). */ diff --git a/ui/src/lib/case-reference.test.ts b/ui/src/lib/case-reference.test.ts new file mode 100644 index 0000000000..e139b7f2f1 --- /dev/null +++ b/ui/src/lib/case-reference.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { parseCaseReferenceFromHref, remarkLinkCaseReferences } from "./case-reference"; + +describe("parseCaseReferenceFromHref", () => { + it("linkifies a bare case identifier to the case detail path", () => { + expect(parseCaseReferenceFromHref("PAP-C7")).toEqual({ + identifier: "PAP-C7", + href: "/cases/PAP-C7", + }); + }); + + it("normalizes case to upper", () => { + expect(parseCaseReferenceFromHref("pap-c12")?.identifier).toBe("PAP-C12"); + }); + + it("ignores plain issue identifiers (no -C infix)", () => { + expect(parseCaseReferenceFromHref("PAP-123")).toBeNull(); + expect(parseCaseReferenceFromHref("PAP-7")).toBeNull(); + }); + + it("respects the known-prefix allowlist when provided", () => { + expect(parseCaseReferenceFromHref("FOO-C1", new Set(["PAP"]))).toBeNull(); + expect(parseCaseReferenceFromHref("PAP-C1", new Set(["PAP"]))?.identifier).toBe("PAP-C1"); + }); + + it("stays permissive when no prefixes are known", () => { + expect(parseCaseReferenceFromHref("FOO-C1")?.identifier).toBe("FOO-C1"); + }); +}); + +describe("remarkLinkCaseReferences", () => { + it("rewrites a bare token inside a text node into a link node", () => { + const tree = { + type: "root", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "see PAP-C7 for details" }], + }, + ], + }; + remarkLinkCaseReferences()(tree as never); + const paragraph = (tree.children[0] as { children: Array<{ type: string; url?: string }> }); + const link = paragraph.children.find((c) => c.type === "link"); + expect(link?.url).toBe("/cases/PAP-C7"); + // Surrounding text is preserved on both sides. + expect(paragraph.children[0]).toMatchObject({ type: "text", value: "see " }); + expect(paragraph.children.at(-1)).toMatchObject({ type: "text", value: " for details" }); + }); + + it("rewrites inline code that is exactly a case identifier", () => { + const tree = { + type: "root", + children: [ + { type: "paragraph", children: [{ type: "inlineCode", value: "PAP-C42" }] }, + ], + }; + remarkLinkCaseReferences()(tree as never); + const paragraph = tree.children[0] as { children: Array<{ type: string; url?: string }> }; + expect(paragraph.children[0]).toMatchObject({ type: "link", url: "/cases/PAP-C42" }); + }); + + it("does not descend into existing links", () => { + const tree = { + type: "root", + children: [ + { + type: "link", + url: "https://example.com", + children: [{ type: "text", value: "PAP-C7" }], + }, + ], + }; + remarkLinkCaseReferences()(tree as never); + const link = tree.children[0] as { url: string; children: Array<{ type: string }> }; + expect(link.url).toBe("https://example.com"); + expect(link.children[0]!.type).toBe("text"); + }); +}); diff --git a/ui/src/lib/case-reference.ts b/ui/src/lib/case-reference.ts new file mode 100644 index 0000000000..233d5d03a7 --- /dev/null +++ b/ui/src/lib/case-reference.ts @@ -0,0 +1,109 @@ +// Linkify bare case identifiers (e.g. `PAP-C7`) inside markdown so they render +// as clickable chips pointing at the case detail page. Mirrors the sibling +// issue-reference plugin, but the `-C` infix keeps case tokens from ever +// colliding with plain issue identifiers (`PREFIX-`), so the two plugins can +// run side by side on the same tree. (PAP-12969 — Cases P4 comment chips.) + +type MarkdownNode = { + type: string; + value?: string; + url?: string; + children?: MarkdownNode[]; +}; + +const BARE_CASE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]*-C\d+$/i; +const CASE_REFERENCE_TOKEN_RE = /\b[A-Z][A-Z0-9]*-C\d+\b/gi; + +export function parseCaseReferenceFromHref( + value: string | null | undefined, + knownPrefixes?: Set, +): { identifier: string; href: string } | null { + if (!value) return null; + const trimmed = value.trim(); + if (!BARE_CASE_IDENTIFIER_RE.test(trimmed)) return null; + const normalized = trimmed.toUpperCase(); + // Only auto-link when the prefix belongs to a known company (mirrors the + // issue-reference gate). An empty/omitted set stays permissive so provider-less + // render surfaces still linkify deliberate references. + if (knownPrefixes && knownPrefixes.size > 0) { + const prefix = normalized.split("-")[0]; + if (!prefix || !knownPrefixes.has(prefix)) return null; + } + return { identifier: normalized, href: `/cases/${encodeURIComponent(normalized)}` }; +} + +function createCaseLinkNode( + value: string, + href: string, + childType: "text" | "inlineCode" = "text", +): MarkdownNode { + return { type: "link", url: href, children: [{ type: childType, value }] }; +} + +function linkifyCaseReferencesInText(value: string, knownPrefixes?: Set): MarkdownNode[] | null { + const nodes: MarkdownNode[] = []; + let cursor = 0; + let matched = false; + for (const match of value.matchAll(CASE_REFERENCE_TOKEN_RE)) { + const raw = match[0]; + if (!raw) continue; + const caseRef = parseCaseReferenceFromHref(raw, knownPrefixes); + if (!caseRef) continue; + const start = match.index ?? 0; + matched = true; + if (start > cursor) nodes.push({ type: "text", value: value.slice(cursor, start) }); + nodes.push(createCaseLinkNode(raw, caseRef.href)); + cursor = start + raw.length; + } + if (!matched) return null; + if (cursor < value.length) nodes.push({ type: "text", value: value.slice(cursor) }); + return nodes; +} + +function rewriteMarkdownTree(node: MarkdownNode, knownPrefixes?: Set) { + if (!Array.isArray(node.children) || node.children.length === 0) return; + if ( + node.type === "link" || + node.type === "linkReference" || + node.type === "code" || + node.type === "definition" || + node.type === "html" + ) { + return; + } + const nextChildren: MarkdownNode[] = []; + for (const child of node.children) { + if (child.type === "inlineCode" && typeof child.value === "string") { + const caseRef = parseCaseReferenceFromHref(child.value, knownPrefixes); + if (caseRef) { + nextChildren.push(createCaseLinkNode(child.value, caseRef.href, "inlineCode")); + continue; + } + } + if (child.type === "text" && typeof child.value === "string") { + const linked = linkifyCaseReferencesInText(child.value, knownPrefixes); + if (linked) { + nextChildren.push(...linked); + continue; + } + } + rewriteMarkdownTree(child, knownPrefixes); + nextChildren.push(child); + } + node.children = nextChildren; +} + +export interface RemarkLinkCaseReferencesOptions { + /** Company prefixes eligible for auto-linking (see parseCaseReferenceFromHref). */ + knownPrefixes?: string[]; +} + +export function remarkLinkCaseReferences(options?: RemarkLinkCaseReferencesOptions) { + const knownPrefixes = + options?.knownPrefixes && options.knownPrefixes.length > 0 + ? new Set(options.knownPrefixes.map((prefix) => prefix.toUpperCase())) + : undefined; + return (tree: MarkdownNode) => { + rewriteMarkdownTree(tree, knownPrefixes); + }; +} diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 05a2c6a0b7..220c6ed16d 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -83,6 +83,27 @@ export function applyCompanyPrefix(path: string, companyPrefix: string | null | return `/${prefix}${pathname}${search}${hash}`; } +/** + * Build a company-prefixed href for an experimental Cases route, e.g. + * `caseHref("PAP", "PAP-C5")` → `/PAP/cases/PAP-C5`. + * + * Case paths carry identifiers like `PAP-C5` in the first segment, which the + * generic {@link applyCompanyPrefix} mistakes for a company prefix ("CASES") and + * therefore leaves `/cases/...` unprefixed — every case link then only resolves + * via the PAP-13002 unprefixed→prefixed redirect. This builder emits the + * prefixed href directly so case-to-case navigation matches the rest of the app. + * Falls back to the unprefixed path (still valid via the redirect) when no + * company is active. + */ +export function caseHref( + companyPrefix: string | null | undefined, + ...segments: string[] +): string { + const suffix = ["cases", ...segments].filter(Boolean).join("/"); + if (!companyPrefix) return `/${suffix}`; + return `/${normalizeCompanyPrefix(companyPrefix)}/${suffix}`; +} + export function toCompanyRelativePath(path: string): string { const { pathname, search, hash } = splitPath(path); const segments = pathname.split("/").filter(Boolean); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index edfe492644..457863ac54 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -185,6 +185,17 @@ export const queryKeys = { list: (companyId: string) => ["projects", companyId] as const, detail: (id: string) => ["projects", "detail", id] as const, }, + cases: { + list: (companyId: string) => ["cases", companyId] as const, + detail: (id: string) => ["cases", "detail", id] as const, + documents: (id: string) => ["cases", "documents", id] as const, + documentAnnotations: (caseId: string, key: string, status: "open" | "resolved" | "all" = "all") => + ["cases", "document-annotations", caseId, key, status] as const, + events: (id: string) => ["cases", "events", id] as const, + children: (parentId: string) => ["cases", "children", parentId] as const, + revisions: (id: string, key: string) => ["cases", "revisions", id, key] as const, + forIssue: (issueId: string) => ["cases", "for-issue", issueId] as const, + }, externalObjects: { byIssue: (issueId: string) => ["external-objects", "by-issue", issueId] as const, issueSummary: (issueId: string) => ["external-objects", "issue-summary", issueId] as const, diff --git a/ui/src/lib/router.tsx b/ui/src/lib/router.tsx index 488911441a..5870865204 100644 --- a/ui/src/lib/router.tsx +++ b/ui/src/lib/router.tsx @@ -6,6 +6,7 @@ import { useCompany } from "@/context/CompanyContext"; import { IssueLinkQuicklook } from "@/components/IssueLinkQuicklook"; import { applyCompanyPrefix, + caseHref, extractCompanyPrefixFromPath, normalizeCompanyPrefix, } from "@/lib/company-routes"; @@ -26,7 +27,7 @@ function resolveTo(to: To, companyPrefix: string | null): To { return to; } -function useActiveCompanyPrefix(): string | null { +export function useActiveCompanyPrefix(): string | null { const { selectedCompany } = useCompany(); const params = RouterDom.useParams<{ companyPrefix?: string }>(); const location = RouterDom.useLocation(); @@ -41,6 +42,19 @@ function useActiveCompanyPrefix(): string | null { return selectedCompany ? normalizeCompanyPrefix(selectedCompany.issuePrefix) : null; } +/** + * Returns a builder for company-prefixed Cases hrefs bound to the active company + * (e.g. `/PAP/cases/PAP-C5`). Use for all case-to-case links so they emit + * prefixed paths directly instead of leaning on the PAP-13002 redirect. + */ +export function useCaseHref(): (...segments: string[]) => string { + const companyPrefix = useActiveCompanyPrefix(); + return React.useCallback( + (...segments: string[]) => caseHref(companyPrefix, ...segments), + [companyPrefix], + ); +} + export * from "react-router-dom"; type CompanyLinkProps = React.ComponentProps & { diff --git a/ui/src/lib/status-colors.ts b/ui/src/lib/status-colors.ts index fa6b2d3b73..bdedbf3d7d 100644 --- a/ui/src/lib/status-colors.ts +++ b/ui/src/lib/status-colors.ts @@ -104,6 +104,11 @@ export const statusBadge: Record = { approved: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300", rejected: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300", + // Case statuses (PAP-12968 E3) — `draft` is the neutral pre-work state, + // rendered as a muted gray alias of `backlog`/`planned`. The other case + // statuses (in_progress/in_review/approved/done/cancelled) already map above. + draft: "bg-muted text-muted-foreground", + // Issue statuses — consistent hues with issueStatusIcon above (PAP-75 brand // mapping: todo → amber, in_progress → blue "liveness"). backlog: "bg-muted text-muted-foreground", diff --git a/ui/src/pages/CaseDetail.test.tsx b/ui/src/pages/CaseDetail.test.tsx new file mode 100644 index 0000000000..88a4c9a283 --- /dev/null +++ b/ui/src/pages/CaseDetail.test.tsx @@ -0,0 +1,461 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { AnchorHTMLAttributes } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CaseDetail as CaseDetailData, CaseSummary } from "@/api/cases"; +import { CaseDetail } from "./CaseDetail"; + +function act(callback: () => void) { + flushSync(callback); +} + +const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1" })); +const mockCasesApi = vi.hoisted(() => ({ + get: vi.fn(), + listEvents: vi.fn(), + list: vi.fn(), + listChildren: vi.fn(), + patch: vi.fn(), + getDocument: vi.fn(), + listRevisions: vi.fn(), + upsertDocument: vi.fn(), + lockDocument: vi.fn(), + unlockDocument: vi.fn(), + restoreDocumentRevision: vi.fn(), + deleteDocument: vi.fn(), +})); +const mockIssuesApi = vi.hoisted(() => ({ listLabels: vi.fn(), createLabel: vi.fn() })); +const panelState = vi.hoisted(() => ({ openPanel: vi.fn(), closePanel: vi.fn() })); +const mockCopyTextToClipboard = vi.hoisted(() => vi.fn(() => Promise.resolve())); + +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => companyState })); +vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) })); +vi.mock("@/context/PanelContext", () => ({ usePanel: () => panelState })); +vi.mock("@/api/cases", async (importOriginal) => ({ + ...(await importOriginal()), + casesApi: mockCasesApi, +})); +vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi })); +vi.mock("@/lib/clipboard", () => ({ copyTextToClipboard: mockCopyTextToClipboard })); +vi.mock("@/components/MarkdownBody", () => ({ + MarkdownBody: ({ children }: { children: string }) =>
{children}
, +})); +vi.mock("@/components/IssueDocumentsSection", () => ({ + IssueDocumentsSection: ({ subject }: { subject?: { id: string } }) => ( +
Documents {subject?.id}
+ ), +})); +vi.mock("@/lib/router", () => ({ + useParams: () => ({ caseIdentifier: "PAP-C7" }), + useLocation: () => ({ hash: "" }), + Navigate: () => null, + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), + useCaseHref: () => (...segments: string[]) => + `/PAP/${["cases", ...segments].filter(Boolean).join("/")}`, +})); + +async function flush() { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + } + flushSync(() => {}); +} +async function waitForAssertion(assertion: () => void, attempts = 20) { + let lastError: unknown; + for (let i = 0; i < attempts; i += 1) { + try { + assertion(); + return; + } catch (e) { + lastError = e; + await flush(); + } + } + throw lastError; +} + +function detail(): CaseDetailData { + return { + id: "case-1", + companyId: "company-1", + projectId: null, + caseNumber: 7, + identifier: "PAP-C7", + caseType: "blog_post", + key: "v2026.707/hermes-agent-post", + title: "Hermes agent launch post", + summary: null, + status: "in_review", + fields: { + slug: "hermes-agent-post", + body: "Legacy body field", + runbook: "Legacy runbook field", + word_count: 1850, + published: true, + description: "Launch narrative", + issue_identifiers: ["PAP-12947"], + }, + parent: null, + parentCaseId: null, + createdByAgentId: null, + createdByUserId: null, + completedAt: null, + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + labels: [], + issueLinks: [ + { + id: "link-1", + caseId: "case-1", + issueId: "issue-1", + role: "reference", + createdAt: "2026-07-07T00:00:00.000Z", + issue: { + id: "issue-1", + identifier: "PAP-12947", + title: "Case object exploration", + status: "in_progress", + }, + }, + ], + documents: [ + { + key: "body", + document: { + id: "doc-1", + companyId: "company-1", + title: "body", + format: "markdown", + latestBody: "# Draft body\n\nSome content.", + latestRevisionId: "rev-8", + latestRevisionNumber: 8, + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: "agent-1", + updatedByUserId: null, + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + }, + }, + { + key: "runbook", + document: { + id: "doc-2", + companyId: "company-1", + title: "runbook", + format: "markdown", + latestBody: "# Runbook\n\nSteps.", + latestRevisionId: "rev-2", + latestRevisionNumber: 2, + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: "agent-1", + updatedByUserId: null, + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + }, + }, + ], + attachments: [], + }; +} + +function childCase(index: number): CaseSummary { + return { + id: `child-${index}`, + companyId: "company-1", + projectId: null, + caseNumber: index, + identifier: `PAP-C${index}`, + caseType: "child_case", + key: null, + title: `Child case ${index}`, + summary: null, + status: "in_progress", + fields: {}, + parentCaseId: "case-1", + createdByAgentId: null, + createdByUserId: null, + completedAt: null, + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + }; +} + +function renderPage(container: HTMLDivElement) { + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + act(() => { + root.render( + + + , + ); + }); + return root; +} + +describe("CaseDetail", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + panelState.openPanel.mockClear(); + panelState.closePanel.mockClear(); + mockCasesApi.get.mockReset().mockResolvedValue(detail()); + mockCasesApi.listEvents.mockReset().mockResolvedValue([]); + mockCasesApi.list.mockReset().mockResolvedValue([]); + mockCasesApi.listChildren.mockReset().mockResolvedValue([]); + mockCasesApi.getDocument.mockReset(); + mockCasesApi.listRevisions.mockReset().mockResolvedValue({ + key: "body", + document: { + id: "doc-1", + title: "body", + format: "markdown", + latestRevisionId: "rev-8", + latestRevisionNumber: 8, + }, + revisions: [], + }); + mockCasesApi.upsertDocument.mockReset(); + mockCasesApi.lockDocument.mockReset(); + mockCasesApi.unlockDocument.mockReset(); + mockCasesApi.restoreDocumentRevision.mockReset(); + mockCasesApi.deleteDocument.mockReset(); + mockIssuesApi.listLabels.mockReset().mockResolvedValue([]); + mockCopyTextToClipboard.mockClear(); + }); + afterEach(() => { + container.remove(); + }); + + it("renders the case header and body-first overview without duplicating generic fields", async () => { + const root = renderPage(container); + + await waitForAssertion(() => { + // header + expect(container.textContent).toContain("PAP-C7"); + expect(container.textContent).toContain("blog_post"); + expect(container.textContent).toContain("Hermes agent launch post"); + // upsert key (detail-only) + expect(container.textContent).toContain("v2026.707/hermes-agent-post"); + // shared document section + expect(container.textContent).toContain("Documents case-1"); + expect(container.textContent).toContain("Launch narrative"); + expect(container.textContent).not.toContain("Revisions"); + expect(container.textContent).not.toContain("1,850"); + }); + + act(() => root.unmount()); + }); + + it("copies the case identifier from the header", async () => { + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("PAP-C7"); + }); + + const caseIdButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent === "PAP-C7" + ); + expect(caseIdButton).toBeTruthy(); + act(() => { + caseIdButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockCopyTextToClipboard).toHaveBeenCalledWith("PAP-C7"); + expect(caseIdButton!.parentElement?.textContent).toContain("Copied"); + }); + + act(() => root.unmount()); + }); + + it("keeps the case identifier and key in one copyable header group", async () => { + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("v2026.707/hermes-agent-post"); + }); + + const identityGroup = container.querySelector('[data-case-identity-group="true"]'); + expect(identityGroup).not.toBeNull(); + expect(identityGroup?.className).toContain("whitespace-nowrap"); + + const keyButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent === "v2026.707/hermes-agent-post" + ); + expect(keyButton).toBeTruthy(); + act(() => { + keyButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockCopyTextToClipboard).toHaveBeenCalledWith("v2026.707/hermes-agent-post"); + expect(keyButton!.parentElement?.textContent).toContain("Copied"); + }); + + act(() => root.unmount()); + }); + + it("shows parent and capped children together above the tabs", async () => { + const caseWithParent = { + ...detail(), + parentCaseId: "case-parent", + parent: { + id: "case-parent", + identifier: "PAP-C2", + title: "Parent case", + caseType: "campaign", + status: "approved" as const, + }, + }; + mockCasesApi.get.mockResolvedValue(caseWithParent); + mockCasesApi.listChildren.mockResolvedValue(Array.from({ length: 6 }, (_, index) => childCase(index + 3))); + + const root = renderPage(container); + + await waitForAssertion(() => { + const text = container.textContent ?? ""; + expect(text).toContain("Parent"); + expect(text).toContain("PAP-C2"); + expect(text).toContain("Parent case"); + expect(text).toContain("Children 6"); + expect(text).toContain("Child case 7"); + expect(text).not.toContain("Child case 8"); + expect(text).toContain("Show 1 more"); + expect(text.indexOf("Parent")).toBeLessThan(text.indexOf("Overview")); + expect(text.indexOf("Children 6")).toBeLessThan(text.indexOf("Overview")); + }); + + const showMore = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Show 1 more") + ); + expect(showMore).toBeTruthy(); + act(() => { + showMore!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Child case 8"); + expect(container.textContent).not.toContain("Show 1 more"); + }); + + act(() => root.unmount()); + }); + + it("renders primary fields and task references in the compact properties panel", async () => { + const root = renderPage(container); + + await waitForAssertion(() => { + expect(panelState.openPanel).toHaveBeenCalled(); + }); + + const panelContainer = document.createElement("div"); + document.body.appendChild(panelContainer); + const panelRoot = createRoot(panelContainer); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + act(() => { + panelRoot.render( + + {panelState.openPanel.mock.calls.at(-1)?.[0]} + , + ); + }); + + await waitForAssertion(() => { + const text = panelContainer.textContent ?? ""; + expect(text).toContain("Fields"); + expect(text).toContain("v2026.707/hermes-agent-post"); + expect(text).toContain("title"); + expect(text).toContain("Hermes agent launch post"); + expect(text).toContain("description"); + expect(text).toContain("Launch narrative"); + expect(text).toContain("word_count"); + expect(text).toContain("Linked tasks"); + expect(text).toContain("PAP-12947"); + expect(text).not.toContain("body"); + expect(text).not.toContain("Legacy body field"); + expect(text).not.toContain("runbook"); + expect(text).not.toContain("Legacy runbook field"); + expect(text).not.toContain("Documents"); + expect(text).not.toContain("reference"); + expect(text).not.toContain("Activity"); + }); + + const keyValue = Array.from(panelContainer.querySelectorAll("button")).find((button) => + button.textContent === "v2026.707/hermes-agent-post" + ); + expect(keyValue).toBeTruthy(); + act(() => { + keyValue!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockCopyTextToClipboard).toHaveBeenCalledWith("v2026.707/hermes-agent-post"); + expect(keyValue!.parentElement?.textContent).toContain("Copied"); + }); + + const titleValue = Array.from(panelContainer.querySelectorAll("button")).find((button) => + button.textContent === "Hermes agent launch post" + ); + expect(titleValue).toBeTruthy(); + act(() => { + titleValue!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockCopyTextToClipboard).toHaveBeenCalledWith("Hermes agent launch post"); + expect(titleValue!.parentElement?.textContent).toContain("Copied"); + }); + + act(() => panelRoot.unmount()); + panelContainer.remove(); + act(() => root.unmount()); + }); + + it("adds a full properties tab with expanded values", async () => { + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Properties"); + }); + + const propertiesTab = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Properties") + ); + expect(propertiesTab).toBeTruthy(); + act(() => { + propertiesTab!.focus(); + propertiesTab!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + + await waitForAssertion(() => { + const text = container.textContent ?? ""; + expect(text).toContain("title"); + expect(text).toContain("Hermes agent launch post"); + expect(text).toContain("issue_identifiers"); + expect(container.querySelector('a[data-mention-kind="issue"][href="/issues/PAP-12947"]')).not.toBeNull(); + expect(text).not.toContain("body"); + expect(text).not.toContain("Legacy body field"); + expect(text).not.toContain("runbook"); + expect(text).not.toContain("Legacy runbook field"); + }); + + act(() => root.unmount()); + }); +}); diff --git a/ui/src/pages/CaseDetail.tsx b/ui/src/pages/CaseDetail.tsx new file mode 100644 index 0000000000..222845d975 --- /dev/null +++ b/ui/src/pages/CaseDetail.tsx @@ -0,0 +1,719 @@ +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, ChevronDown, Copy, MoreVertical, Plus, SlidersHorizontal } from "lucide-react"; +import { Link, Navigate, useCaseHref, useParams } from "@/lib/router"; +import { useCompany } from "@/context/CompanyContext"; +import { useBreadcrumbs } from "@/context/BreadcrumbContext"; +import { usePanel } from "@/context/PanelContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { + casesApi, + CASE_STATUSES, + caseDocumentToIssueDocument, + caseRevisionToDocumentRevision, + type CaseDocument, + type CaseDetail as CaseDetailData, + type CaseParentRef, + type CaseStatus, + type CaseSummary, +} from "@/api/cases"; +import { issuesApi } from "@/api/issues"; +import type { IssueDocument } from "@paperclipai/shared"; +import { PROJECT_COLORS, type IssueLabel } from "@paperclipai/shared"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { StatusBadge } from "@/components/StatusBadge"; +import { PageSkeleton } from "@/components/PageSkeleton"; +import { CaseFieldValue } from "@/components/CaseFieldsPanel"; +import { CaseActivityFeed } from "@/components/CaseActivityFeed"; +import { CaseChildrenTree } from "@/components/CaseChildrenTree"; +import { CaseAttachmentsGallery } from "@/components/CaseAttachmentsGallery"; +import { IssueReferencePill } from "@/components/IssueReferencePill"; +import { PropertyChip, PropertyRow, PropertySection } from "@/components/issue-properties"; +import { IssueDocumentsSection } from "@/components/IssueDocumentsSection"; +import { CaseCopyableToken, CaseIdentifierKey } from "@/components/CaseIdentifierKey"; +import { copyTextToClipboard } from "@/lib/clipboard"; +import { cn } from "@/lib/utils"; + +const STATUS_LABEL: Record = { + draft: "Draft", + in_progress: "In progress", + in_review: "In review", + approved: "Approved", + done: "Done", + cancelled: "Cancelled", +}; + +const PRIMARY_FIELD_KEYS = ["name", "title", "body", "description"] as const; +const ISSUE_REFERENCE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"] as const; + +type CasePropertyDisplayMode = "compact" | "full"; +type IssueReferenceStatus = (typeof ISSUE_REFERENCE_STATUSES)[number]; + +function issueReferenceStatus(status: string): IssueReferenceStatus | undefined { + return ISSUE_REFERENCE_STATUSES.includes(status as IssueReferenceStatus) + ? status as IssueReferenceStatus + : undefined; +} + +function fieldValueByName(fields: Record, name: string): unknown { + return fields[name] ?? fields[name.charAt(0).toUpperCase() + name.slice(1)]; +} + +function caseFieldKeyVariants(key: string): string[] { + if (!key) return [key]; + return [key, key.charAt(0).toUpperCase() + key.slice(1), key.charAt(0).toLowerCase() + key.slice(1)]; +} + +function hasFieldValue(value: unknown): boolean { + return value !== null && value !== undefined && !(typeof value === "string" && value.trim() === ""); +} + +function casePropertyRows(caseData: CaseDetailData) { + const reservedKeys = new Set(PRIMARY_FIELD_KEYS.flatMap((key) => caseFieldKeyVariants(key))); + const documentKeys = new Set(caseData.documents.flatMap((documentRef) => caseFieldKeyVariants(documentRef.key))); + const primary = PRIMARY_FIELD_KEYS.map((key) => { + if (caseFieldKeyVariants(key).some((variant) => documentKeys.has(variant))) return null; + let value: unknown; + if (key === "title") value = fieldValueByName(caseData.fields, key) ?? caseData.title; + else if (key === "body") value = fieldValueByName(caseData.fields, key); + else value = fieldValueByName(caseData.fields, key); + return { key, label: key, value }; + }).filter((row): row is { key: typeof PRIMARY_FIELD_KEYS[number]; label: typeof PRIMARY_FIELD_KEYS[number]; value: unknown } => + row !== null && hasFieldValue(row.value) + ); + + const generic = Object.entries(caseData.fields) + .filter(([key]) => !reservedKeys.has(key) && !documentKeys.has(key)) + .map(([key, value]) => ({ key, label: key, value })); + + return [...primary, ...generic]; +} + +function issueDocumentToCaseDocument(document: IssueDocument): CaseDocument { + return { + id: document.id, + companyId: document.companyId, + title: document.title, + format: document.format, + latestBody: document.body, + latestRevisionId: document.latestRevisionId, + latestRevisionNumber: document.latestRevisionNumber, + createdByAgentId: document.createdByAgentId, + createdByUserId: document.createdByUserId, + updatedByAgentId: document.updatedByAgentId, + updatedByUserId: document.updatedByUserId, + lockedAt: document.lockedAt ? new Date(document.lockedAt).toISOString() : null, + lockedByAgentId: document.lockedByAgentId, + lockedByUserId: document.lockedByUserId, + sourceTrust: document.sourceTrust, + createdAt: new Date(document.createdAt).toISOString(), + updatedAt: new Date(document.updatedAt).toISOString(), + }; +} + +function CaseRelationshipsSection({ + parent, + children, +}: { + parent: CaseParentRef | null; + children: CaseSummary[]; +}) { + if (!parent && children.length === 0) return null; + + return ( +
+ {parent ? ( +
+

Parent

+ +
+ ) : null} + {children.length > 0 ? ( +
+

Children {children.length}

+ +
+ ) : null} +
+ ); +} + +function CasePropertyRow({ + label, + children, + wrap, + mode, +}: { + label: string; + children: ReactNode; + wrap?: boolean; + mode: CasePropertyDisplayMode; +}) { + if (mode === "compact") { + return ( + + {children} + + ); + } + + return ( +
+ + {label} + +
{children}
+
+ ); +} + +/** Status dropdown — the primary human write in v1 (§3). */ +function CaseStatusPicker({ + status, + onChange, + disabled, +}: { + status: CaseStatus; + onChange: (next: CaseStatus) => void; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + return ( + + + + + + {CASE_STATUSES.map((s) => ( + + ))} + + + ); +} + +/** Label editor — the second human write in v1 (§3). Reuses company labels. */ +function CaseLabelsPicker({ + companyId, + selected, + onChange, +}: { + companyId: string; + selected: IssueLabel[]; + onChange: (labelIds: string[]) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [newColor, setNewColor] = useState(PROJECT_COLORS[0]); + const queryClient = useQueryClient(); + const labelsQuery = useQuery({ + queryKey: queryKeys.issues.labels(companyId), + queryFn: () => issuesApi.listLabels(companyId), + enabled: open, + }); + const selectedIds = new Set(selected.map((l) => l.id)); + const createLabel = useMutation({ + mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId, data), + onSuccess: (label) => { + queryClient.setQueryData(queryKeys.issues.labels(companyId), (prev) => + prev ? [...prev, label] : [label], + ); + onChange([...selectedIds, label.id]); + setSearch(""); + }, + }); + + const all = labelsQuery.data ?? []; + const filtered = search.trim() + ? all.filter((l) => l.name.toLowerCase().includes(search.trim().toLowerCase())) + : all; + + function toggle(id: string) { + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); + else next.add(id); + onChange([...next]); + } + + return ( + + + + + + setSearch(e.target.value)} + placeholder="Search labels…" + className="mb-2 h-7 text-xs" + /> +
+ {filtered.map((l) => ( + + ))} + {filtered.length === 0 && !search.trim() && ( +

No labels yet.

+ )} +
+ {search.trim() && !all.some((l) => l.name.toLowerCase() === search.trim().toLowerCase()) && ( +
+ setNewColor(e.target.value)} + className="h-6 w-6 shrink-0 cursor-pointer rounded border border-border bg-transparent" + aria-label="New label color" + /> + +
+ )} +
+
+ ); +} + +/** Right-rail content pushed into the shared PropertiesPanel (§3). */ +function CasePropertiesContent({ + caseData, + childCases, + companyId, + labelsPending, + onLabelIdsChange, + mode, +}: { + caseData: CaseDetailData; + childCases: CaseSummary[]; + companyId: string | null | undefined; + labelsPending?: boolean; + onLabelIdsChange: (labelIds: string[]) => void; + mode: CasePropertyDisplayMode; +}) { + const propertyRows = casePropertyRows(caseData); + const isFull = mode === "full"; + + return ( +
+ + + {caseData.caseType} + + {caseData.key ? ( + + + + ) : null} + + {caseData.labels.length > 0 ? ( + caseData.labels.map((label) => ( + + {label.name} + + )) + ) : ( + None + )} + {companyId ? ( + + ) : null} + {labelsPending ? Saving... : null} + + + + {propertyRows.length > 0 ? ( + + {propertyRows.map(({ key, label, value }) => ( + + + + + + ))} + + ) : null} + + + {caseData.issueLinks.length === 0 ? ( + + None yet + + ) : ( + +
+ {caseData.issueLinks.map((link) => ( + + ))} +
+
+ )} +
+ + 0 ? ` ${childCases.length}` : ""}`}> + + + + {caseData.attachments.length > 0 ? ( + + + + {caseData.attachments.length} {caseData.attachments.length === 1 ? "file" : "files"} + + + + ) : null} +
+ ); +} + +export function CaseDetail() { + const { caseIdentifier } = useParams<{ caseIdentifier: string }>(); + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { openPanel, closePanel } = usePanel(); + const queryClient = useQueryClient(); + const caseHref = useCaseHref(); + const [copied, setCopied] = useState(false); + + const caseQuery = useQuery({ + queryKey: queryKeys.cases.detail(caseIdentifier ?? ""), + queryFn: () => casesApi.get(caseIdentifier!), + enabled: !!caseIdentifier, + }); + const caseData = caseQuery.data; + const caseDetailQueryKey = queryKeys.cases.detail(caseIdentifier ?? ""); + + const eventsQuery = useQuery({ + queryKey: queryKeys.cases.events(caseIdentifier ?? ""), + queryFn: () => casesApi.listEvents(caseIdentifier!, 100), + enabled: !!caseIdentifier, + }); + + // Children come from the server-side parent filter (P4). All statuses, so the + // tree shows completed/cancelled children too — it's a structural view, not a + // work queue. + const childrenQuery = useQuery({ + queryKey: queryKeys.cases.children(caseData?.id ?? ""), + queryFn: () => casesApi.listChildren(selectedCompanyId!, caseData!.id), + enabled: !!selectedCompanyId && !!caseData?.id, + }); + const children = useMemo(() => childrenQuery.data ?? [], [childrenQuery.data]); + + const patchMutation = useMutation({ + mutationFn: (input: { status?: CaseStatus; labelIds?: string[] }) => + casesApi.patch(caseIdentifier!, input), + onSuccess: (updated) => { + queryClient.setQueryData(queryKeys.cases.detail(caseIdentifier ?? ""), updated); + queryClient.invalidateQueries({ queryKey: queryKeys.cases.events(caseIdentifier ?? "") }); + }, + }); + + const handleLabelIdsChange = useCallback((labelIds: string[]) => { + patchMutation.mutate({ labelIds }); + }, [patchMutation.mutate]); + + useEffect(() => { + setBreadcrumbs([ + { label: "Cases", href: caseHref() }, + { label: caseData ? `${caseData.identifier} — ${caseData.title}` : (caseIdentifier ?? "Case") }, + ]); + }, [setBreadcrumbs, caseData, caseIdentifier, caseHref]); + + const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery.data]); + const caseDocumentSubject = useMemo(() => { + if (!caseData || !caseIdentifier) return null; + return { + id: caseData.id, + detailQueryKey: caseDetailQueryKey, + documentsQueryKey: queryKeys.cases.documents(caseData.id), + idleDocumentRevisionsQueryKey: ["cases", "revisions", caseData.id, "__idle__"] as const, + documentRevisionsQueryKey: (key: string) => queryKeys.cases.revisions(caseData.id, key), + listDocuments: async () => { + const cached = queryClient.getQueryData(caseDetailQueryKey); + const detail = cached ?? await casesApi.get(caseIdentifier); + return detail.documents.map((documentRef) => + caseDocumentToIssueDocument(detail.id, documentRef.key, documentRef.document) + ); + }, + listDocumentRevisions: async (key: string) => { + const revisions = await casesApi.listRevisions(caseIdentifier, key); + return revisions.revisions.map((revision) => caseRevisionToDocumentRevision(caseData.id, key, revision)); + }, + getDocument: async (key: string) => { + const document = await casesApi.getDocument(caseIdentifier, key); + return caseDocumentToIssueDocument(caseData.id, document.key, document); + }, + upsertDocument: async (key: string, data: { title: string | null; format: "markdown"; body: string; baseRevisionId: string | null }) => { + const result = await casesApi.upsertDocument(caseIdentifier, key, data); + return caseDocumentToIssueDocument(caseData.id, result.document.key, result.document); + }, + deleteDocument: (key: string) => casesApi.deleteDocument(caseIdentifier, key), + restoreDocumentRevision: async (key: string, revisionId: string) => { + const result = await casesApi.restoreDocumentRevision(caseIdentifier, key, revisionId); + return caseDocumentToIssueDocument(caseData.id, result.document.key, result.document); + }, + setDocumentLock: async (key: string, locked: boolean) => { + const document = locked + ? await casesApi.lockDocument(caseIdentifier, key) + : await casesApi.unlockDocument(caseIdentifier, key); + return caseDocumentToIssueDocument(caseData.id, document.key, document); + }, + syncDetailCache: (cache: typeof queryClient, document: IssueDocument) => { + cache.setQueryData(caseDetailQueryKey, (current) => { + if (!current) return current; + const nextDocumentRef = { + key: document.key, + document: issueDocumentToCaseDocument(document), + }; + const existingIndex = current.documents.findIndex((entry) => entry.key === document.key); + const documents = existingIndex === -1 + ? [...current.documents, nextDocumentRef] + : current.documents.map((entry, index) => index === existingIndex ? nextDocumentRef : entry); + return { + ...current, + documents, + updatedAt: new Date(document.updatedAt).toISOString(), + }; + }); + }, + hideSystemDocuments: false, + legacyPlanDocument: null, + annotations: { + issueId: caseData.id, + target: (documentKey: string) => ({ kind: "case" as const, caseId: caseData.id, documentKey }), + }, + }; + }, [caseData, caseDetailQueryKey, caseIdentifier, queryClient]); + const panelContent = useMemo(() => { + if (!caseData) return null; + return ( + + ); + }, [caseData, children, selectedCompanyId, patchMutation.isPending, handleLabelIdsChange]); + + useEffect(() => { + if (!panelContent) return; + openPanel(panelContent); + return () => closePanel(); + }, [panelContent, openPanel, closePanel]); + + if (!caseIdentifier) return ; + if (caseQuery.isLoading) return ; + if (caseQuery.isError || !caseData) { + return ( +
+

Case not found.

+ + ← Back to cases + +
+ ); + } + + const description = caseData.fields.description ?? caseData.fields.Description ?? null; + + function copyCaseToClipboard(currentCase: CaseDetailData) { + const markdown = [ + `# ${currentCase.identifier} ${currentCase.title}`, + "", + `- Key: ${currentCase.key ?? "none"}`, + `- Type: ${currentCase.caseType}`, + `- Status: ${STATUS_LABEL[currentCase.status]}`, + currentCase.labels.length > 0 ? `- Labels: ${currentCase.labels.map((label) => label.name).join(", ")}` : "- Labels: none", + ].join("\n"); + void copyTextToClipboard(markdown).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }); + } + + return ( +
+
+
+
+ +

{caseData.title}

+
+
+ patchMutation.mutate({ status })} + /> + + + + + + + + + +
+
+ +
+ {caseData.caseType} +
+ + +
+ + + + Overview + Properties + + Activity{events.length > 0 && {events.length}} + + + + + {caseDocumentSubject ? ( + + ) : null} + + {description ? ( +
+

Description

+ + + +
+ ) : null} + + {caseData.attachments.length > 0 && ( +
+

Attachments ({caseData.attachments.length})

+ +
+ )} +
+ + + + + + + + +
+
+ ); +} diff --git a/ui/src/pages/Cases.test.tsx b/ui/src/pages/Cases.test.tsx new file mode 100644 index 0000000000..3caec1c8cf --- /dev/null +++ b/ui/src/pages/Cases.test.tsx @@ -0,0 +1,599 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { AnchorHTMLAttributes } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CaseSummary } from "@/api/cases"; +import { Cases } from "./Cases"; + +function act(callback: () => void) { + flushSync(callback); +} + +const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1" })); +const mockCasesApi = vi.hoisted(() => ({ list: vi.fn() })); +const mockProjectsApi = vi.hoisted(() => ({ list: vi.fn() })); +const mockIssuesApi = vi.hoisted(() => ({ listLabels: vi.fn() })); +const mockCopyTextToClipboard = vi.hoisted(() => vi.fn(() => Promise.resolve())); +const mockNavigate = vi.hoisted(() => vi.fn()); +const generalSettingsState = vi.hoisted(() => ({ keyboardShortcutsEnabled: false })); + +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => companyState })); +vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) })); +vi.mock("@/context/GeneralSettingsContext", () => ({ useGeneralSettings: () => generalSettingsState })); +vi.mock("@/api/cases", async (importOriginal) => ({ + ...(await importOriginal()), + casesApi: mockCasesApi, +})); +vi.mock("@/api/projects", () => ({ projectsApi: mockProjectsApi })); +vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi })); +vi.mock("@/lib/clipboard", () => ({ copyTextToClipboard: mockCopyTextToClipboard })); +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), + useNavigate: () => mockNavigate, + useCaseHref: () => (...segments: string[]) => + `/PAP/${["cases", ...segments].filter(Boolean).join("/")}`, +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +async function flush() { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + } + flushSync(() => {}); +} +async function waitForAssertion(assertion: () => void, attempts = 20) { + let lastError: unknown; + for (let i = 0; i < attempts; i += 1) { + try { + assertion(); + return; + } catch (e) { + lastError = e; + await flush(); + } + } + throw lastError; +} +function dispatchShortcut(key: string) { + act(() => { + document.body.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })); + }); +} + +function createCase(overrides: Partial): CaseSummary { + return { + id: overrides.id ?? "case-1", + companyId: "company-1", + projectId: null, + caseNumber: 1, + identifier: overrides.identifier ?? "PAP-C1", + caseType: overrides.caseType ?? "blog_post", + key: null, + title: overrides.title ?? "A case", + summary: null, + status: overrides.status ?? "in_progress", + fields: {}, + parentCaseId: null, + createdByAgentId: null, + createdByUserId: null, + completedAt: null, + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + ...overrides, + }; +} + +function renderPage(container: HTMLDivElement) { + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + act(() => { + root.render( + + + , + ); + }); + return root; +} + +describe("Cases list", () => { + let container: HTMLDivElement; + beforeEach(() => { + window.localStorage.clear(); + container = document.createElement("div"); + document.body.appendChild(container); + mockCasesApi.list.mockReset(); + mockProjectsApi.list.mockReset().mockResolvedValue([]); + mockIssuesApi.listLabels.mockReset().mockResolvedValue([]); + mockCopyTextToClipboard.mockClear(); + mockNavigate.mockClear(); + generalSettingsState.keyboardShortcutsEnabled = false; + HTMLElement.prototype.scrollIntoView = vi.fn(); + }); + afterEach(() => { + container.remove(); + }); + + it("loads cases by default and hides terminal cases client-side", async () => { + mockCasesApi.list.mockResolvedValue([ + createCase({ id: "a", identifier: "PAP-C1", title: "Active post", status: "in_progress" }), + createCase({ id: "b", identifier: "PAP-C2", title: "Done post", status: "done" }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Active post"); + expect(container.textContent).not.toContain("Done post"); + expect(container.textContent).not.toContain("active ·"); + expect(mockCasesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ + limit: 200, + })); + }); + + act(() => root.unmount()); + }); + + it("sends search filters to the cases API instead of filtering a fetched page locally", async () => { + mockCasesApi.list.mockResolvedValue([ + createCase({ id: "a", identifier: "PAP-C1", title: "Active post", status: "in_progress" }), + ]); + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Active post"); + }); + + const input = container.querySelector("input[placeholder='Search cases...']"); + expect(input).toBeTruthy(); + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(input, "launch"); + input!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockCasesApi.list).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ + q: "launch", + limit: 200, + })); + }); + + act(() => root.unmount()); + }); + + it("renders the onboarding hero when there are no cases at all", async () => { + mockCasesApi.list.mockResolvedValue([]); + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("No cases yet"); + expect(container.textContent).toContain("references/cases.md"); + }); + + // No create-case UI anywhere (agent-only v1). + expect(container.textContent).not.toContain("New case"); + expect(container.textContent).not.toContain("Create case"); + + act(() => root.unmount()); + }); + + it("shows default columns in id, title, status, updated order grouped by type without keys", async () => { + mockCasesApi.list.mockResolvedValue([ + createCase({ id: "a", identifier: "PAP-C1", key: "launch/post-one", title: "Post one", caseType: "blog_post" }), + createCase({ id: "b", identifier: "PAP-C2", title: "Storm one", caseType: "tweet_storm" }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("blog_post"); + expect(container.textContent).toContain("tweet_storm"); + expect(container.textContent).toContain("Post one"); + expect(container.textContent).not.toContain("launch/post-one"); + expect(container.textContent).toContain("Storm one"); + }); + + const text = container.textContent ?? ""; + expect(text.indexOf("ID")).toBeGreaterThanOrEqual(0); + expect(text.indexOf("Title")).toBeGreaterThan(text.indexOf("ID")); + expect(text.indexOf("Status")).toBeGreaterThan(text.indexOf("Title")); + expect(text.indexOf("Updated")).toBeGreaterThan(text.indexOf("Status")); + expect(text).not.toContain("Key"); + expect(text).not.toContain("Project"); + const headerGrid = Array.from(container.querySelectorAll("div > span[style*='grid-template-columns']")).find((element) => + element.textContent?.includes("ID") + && element.textContent.includes("Title") + && element.textContent.includes("Status") + ); + expect(headerGrid?.style.gridTemplateColumns).toBe( + "max-content minmax(12rem, 1fr) minmax(6rem, 7rem) minmax(5rem, 6rem)", + ); + + const blogGroupIndex = text.indexOf("blog_post"); + const tweetGroupIndex = text.indexOf("tweet_storm"); + expect(blogGroupIndex).toBeGreaterThanOrEqual(0); + expect(tweetGroupIndex).toBeGreaterThan(blogGroupIndex); + + act(() => root.unmount()); + }); + + it("copies case list identifiers with feedback without following the row link", async () => { + mockCasesApi.list.mockResolvedValue([ + createCase({ id: "a", identifier: "PAP-C1", key: "launch/post-one", title: "Post one", caseType: "blog_post" }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("PAP-C1"); + expect(container.textContent).not.toContain("launch/post-one"); + }); + + const idButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent === "PAP-C1" + ); + expect(idButton).toBeTruthy(); + const click = new MouseEvent("click", { bubbles: true, cancelable: true }); + act(() => { + idButton!.dispatchEvent(click); + }); + + await waitForAssertion(() => { + expect(click.defaultPrevented).toBe(true); + expect(mockCopyTextToClipboard).toHaveBeenCalledWith("PAP-C1"); + expect(idButton!.parentElement?.textContent).toContain("Copied"); + }); + + act(() => root.unmount()); + }); + + it("shows and copies keys only when the key column is enabled", async () => { + window.localStorage.setItem( + "paperclip:cases:company-1:view", + JSON.stringify({ + columns: ["id", "key", "title", "status", "updated"], + }), + ); + mockCasesApi.list.mockResolvedValue([ + createCase({ id: "a", identifier: "PAP-C1", key: "launch/post-one", title: "Post one", caseType: "blog_post" }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Key"); + expect(container.textContent).toContain("launch/post-one"); + }); + + const keyButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent === "launch/post-one" + ); + expect(keyButton).toBeTruthy(); + const click = new MouseEvent("click", { bubbles: true, cancelable: true }); + act(() => { + keyButton!.dispatchEvent(click); + }); + + await waitForAssertion(() => { + expect(click.defaultPrevented).toBe(true); + expect(mockCopyTextToClipboard).toHaveBeenCalledWith("launch/post-one"); + expect(keyButton!.parentElement?.textContent).toContain("Copied"); + }); + + act(() => root.unmount()); + }); + + it("tree mode forces an ungrouped parent-child order and adds the type column", async () => { + window.localStorage.setItem( + "paperclip:cases:company-1:view", + JSON.stringify({ + treeView: true, + groupBy: "type", + columns: ["id", "title", "status", "updated"], + sortField: "updated", + sortDir: "desc", + }), + ); + mockCasesApi.list.mockResolvedValue([ + createCase({ + id: "child", + identifier: "PAP-C2", + title: "Child case", + parentCaseId: "parent", + caseType: "asset", + updatedAt: "2026-07-08T00:00:00.000Z", + }), + createCase({ + id: "parent", + identifier: "PAP-C1", + title: "Parent case", + caseType: "brief", + updatedAt: "2026-07-07T00:00:00.000Z", + }), + createCase({ + id: "sibling", + identifier: "PAP-C3", + title: "Sibling case", + caseType: "brief", + updatedAt: "2026-07-06T00:00:00.000Z", + }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Type"); + expect(container.textContent).toContain("brief"); + expect(container.textContent).toContain("asset"); + expect(container.querySelector('button[title="Show flat case list"]')).not.toBeNull(); + expect(mockCasesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ + includeAncestors: true, + limit: 200, + })); + }); + + const text = container.textContent ?? ""; + expect(text.indexOf("Type")).toBeGreaterThan(text.indexOf("Title")); + expect(text.indexOf("Status")).toBeGreaterThan(text.indexOf("Type")); + expect(text.indexOf("Parent case")).toBeGreaterThanOrEqual(0); + expect(text.indexOf("Child case")).toBeGreaterThan(text.indexOf("Parent case")); + expect(text.indexOf("Sibling case")).toBeGreaterThan(text.indexOf("Child case")); + expect(text).not.toContain("1 child"); + + const collapseParent = container.querySelector('button[aria-label="Collapse Parent case"]'); + expect(collapseParent).toBeTruthy(); + expect(collapseParent?.getAttribute("aria-expanded")).toBe("true"); + act(() => { + collapseParent!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Parent case"); + expect(container.textContent).not.toContain("Child case"); + expect(container.querySelector('button[aria-label="Expand Parent case"]')?.getAttribute("aria-expanded")).toBe("false"); + }); + + act(() => root.unmount()); + }); + + it("keeps filtered-out ancestors visible in tree mode when descendants match", async () => { + window.localStorage.setItem( + "paperclip:cases:company-1:view", + JSON.stringify({ + treeView: true, + columns: ["id", "title", "type", "status", "updated"], + }), + ); + mockCasesApi.list.mockResolvedValue([ + createCase({ + id: "child", + identifier: "PAP-C2", + title: "Active child", + parentCaseId: "parent", + status: "in_progress", + updatedAt: "2026-07-08T00:00:00.000Z", + }), + createCase({ + id: "done-sibling", + identifier: "PAP-C3", + title: "Done sibling", + status: "done", + updatedAt: "2026-07-09T00:00:00.000Z", + }), + createCase({ + id: "parent", + identifier: "PAP-C1", + title: "Done parent", + status: "done", + updatedAt: "2026-07-07T00:00:00.000Z", + }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Done parent"); + expect(container.textContent).toContain("Active child"); + expect(container.textContent).not.toContain("Done sibling"); + }); + + const text = container.textContent ?? ""; + expect(text.indexOf("Done parent")).toBeGreaterThanOrEqual(0); + expect(text.indexOf("Active child")).toBeGreaterThan(text.indexOf("Done parent")); + + act(() => root.unmount()); + }); + + it("supports inbox-style keyboard navigation, group folding, and opening on grouped case rows", async () => { + generalSettingsState.keyboardShortcutsEnabled = true; + mockCasesApi.list.mockResolvedValue([ + createCase({ + id: "blog", + identifier: "PAP-C1", + title: "Blog active", + caseType: "blog_post", + updatedAt: "2026-07-08T00:00:00.000Z", + }), + createCase({ + id: "docs", + identifier: "PAP-C2", + title: "Docs active", + caseType: "docs_page", + updatedAt: "2026-07-07T00:00:00.000Z", + }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Blog active"); + expect(container.textContent).toContain("Docs active"); + }); + + dispatchShortcut("j"); + await flush(); + dispatchShortcut("ArrowLeft"); + + await waitForAssertion(() => { + expect(container.textContent).toContain("blog_post"); + expect(container.textContent).not.toContain("Blog active"); + }); + + dispatchShortcut("ArrowRight"); + await waitForAssertion(() => { + expect(container.textContent).toContain("Blog active"); + }); + + dispatchShortcut("j"); + await flush(); + dispatchShortcut("Enter"); + + expect(mockNavigate).toHaveBeenCalledWith("/PAP/cases/PAP-C1"); + + act(() => root.unmount()); + }); + + it("supports keyboard tree folding and opening parent case rows", async () => { + generalSettingsState.keyboardShortcutsEnabled = true; + window.localStorage.setItem( + "paperclip:cases:company-1:view", + JSON.stringify({ + treeView: true, + columns: ["id", "title", "type", "status", "updated"], + }), + ); + mockCasesApi.list.mockResolvedValue([ + createCase({ + id: "child", + identifier: "PAP-C2", + title: "Child case", + parentCaseId: "parent", + caseType: "asset", + updatedAt: "2026-07-08T00:00:00.000Z", + }), + createCase({ + id: "parent", + identifier: "PAP-C1", + title: "Parent case", + caseType: "brief", + updatedAt: "2026-07-07T00:00:00.000Z", + }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Parent case"); + expect(container.textContent).toContain("Child case"); + }); + + dispatchShortcut("j"); + await flush(); + dispatchShortcut("ArrowLeft"); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Parent case"); + expect(container.textContent).not.toContain("Child case"); + }); + + dispatchShortcut("ArrowRight"); + await waitForAssertion(() => { + expect(container.textContent).toContain("Child case"); + }); + + dispatchShortcut("Enter"); + + expect(mockNavigate).toHaveBeenCalledWith("/PAP/cases/PAP-C1"); + + act(() => root.unmount()); + }); + + it("restores persisted search, filters, group, sort, and columns", async () => { + window.localStorage.setItem( + "paperclip:cases:company-1:view", + JSON.stringify({ + search: "launch", + statusFilters: ["done"], + typeFilters: ["blog_post"], + projectFilters: [], + labelFilter: "__all__", + groupBy: "status", + sortField: "created", + sortDir: "asc", + columns: ["id", "title", "status", "updated", "created"], + }), + ); + mockCasesApi.list.mockResolvedValue([ + createCase({ + id: "a", + identifier: "PAP-C1", + title: "Active launch", + status: "in_progress", + caseType: "blog_post", + }), + createCase({ + id: "b", + identifier: "PAP-C2", + title: "Done launch", + status: "done", + caseType: "blog_post", + }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(mockCasesApi.list).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ + q: "launch", + limit: 200, + })); + expect(container.textContent).toContain("Done launch"); + expect(container.textContent).not.toContain("Active launch"); + expect(container.textContent).toContain("Created at"); + }); + + act(() => root.unmount()); + }); + + it("applies multi-select type and status filters from persisted state", async () => { + window.localStorage.setItem( + "paperclip:cases:company-1:view", + JSON.stringify({ + statusFilters: ["in_progress", "done"], + typeFilters: ["blog_post", "docs_page"], + projectFilters: ["project-1", "__all__"], + }), + ); + mockCasesApi.list.mockResolvedValue([ + createCase({ id: "a", identifier: "PAP-C1", title: "Blog active", status: "in_progress", caseType: "blog_post" }), + createCase({ id: "b", identifier: "PAP-C2", title: "Docs done", status: "done", caseType: "docs_page" }), + createCase({ id: "c", identifier: "PAP-C3", title: "Tweet active", status: "in_progress", caseType: "tweet_storm" }), + createCase({ id: "d", identifier: "PAP-C4", title: "Blog cancelled", status: "cancelled", caseType: "blog_post" }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(mockCasesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ + types: ["blog_post", "docs_page"], + statuses: ["in_progress", "done"], + projectIds: ["project-1"], + includeNoProject: true, + })); + expect(container.textContent).toContain("Blog active"); + expect(container.textContent).toContain("Docs done"); + expect(container.textContent).not.toContain("Tweet active"); + expect(container.textContent).not.toContain("Blog cancelled"); + }); + + act(() => root.unmount()); + }); +}); diff --git a/ui/src/pages/Cases.tsx b/ui/src/pages/Cases.tsx new file mode 100644 index 0000000000..43fb327007 --- /dev/null +++ b/ui/src/pages/Cases.tsx @@ -0,0 +1,1427 @@ +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowUpDown, Check, ChevronDown, Columns3, Filter, Layers, ListTree, Search, SearchX } from "lucide-react"; +import { Link, useCaseHref, useNavigate } from "@/lib/router"; +import { useCompany } from "@/context/CompanyContext"; +import { useBreadcrumbs } from "@/context/BreadcrumbContext"; +import { useGeneralSettings } from "@/context/GeneralSettingsContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { casesApi, CASE_STATUSES, TERMINAL_CASE_STATUSES, type CaseStatus, type CaseSummary } from "@/api/cases"; +import { projectsApi } from "@/api/projects"; +import { issuesApi } from "@/api/issues"; +import { Badge } from "@/components/ui/badge"; +import { StatusBadge } from "@/components/StatusBadge"; +import { EmptyState } from "@/components/EmptyState"; +import { PageSkeleton } from "@/components/PageSkeleton"; +import { FilterBar, type FilterValue } from "@/components/FilterBar"; +import { IssueGroupHeader } from "@/components/IssueGroupHeader"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; +import { CaseCopyableToken } from "@/components/CaseIdentifierKey"; +import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "@/lib/keyboardShortcuts"; +import { cn, relativeTime } from "@/lib/utils"; + +type GroupBy = "type" | "project" | "status" | "none"; +type CaseColumn = "id" | "key" | "title" | "status" | "updated" | "created" | "type" | "project" | "parent"; +type CaseSortField = "updated" | "created" | "title" | "status" | "id" | "type" | "project"; +type CaseViewState = { + search: string; + typeFilters: string[]; + statusFilters: CaseStatus[]; + projectFilters: string[]; + labelFilter: string; + groupBy: GroupBy; + sortField: CaseSortField; + sortDir: "asc" | "desc"; + columns: CaseColumn[]; + treeView: boolean; +}; + +const STATUS_FILTER_OPTIONS: { value: CaseStatus; label: string }[] = [ + { value: "draft", label: "Draft" }, + { value: "in_progress", label: "In progress" }, + { value: "in_review", label: "In review" }, + { value: "approved", label: "Approved" }, + { value: "done", label: "Done" }, + { value: "cancelled", label: "Cancelled" }, +]; + +const ALL = "__all__"; +const DEFAULT_STATUS_FILTERS: CaseStatus[] = CASE_STATUSES.filter((status) => !TERMINAL_CASE_STATUSES.includes(status)); +const DEFAULT_CASE_COLUMNS: CaseColumn[] = ["id", "title", "status", "updated"]; +const CASE_COLUMN_ORDER: CaseColumn[] = ["id", "key", "title", "type", "status", "updated", "created", "project", "parent"]; +const CASE_COLUMN_LABELS: Record = { + id: "ID", + key: "Key", + title: "Title", + status: "Status", + updated: "Updated", + created: "Created at", + type: "Type", + project: "Project", + parent: "Parent case", +}; +const CASE_SORT_LABELS: Record = { + updated: "Last updated", + created: "Created at", + title: "Title", + status: "Status", + id: "ID", + type: "Type", + project: "Project", +}; +const defaultCaseViewState: CaseViewState = { + search: "", + typeFilters: [], + statusFilters: DEFAULT_STATUS_FILTERS, + projectFilters: [], + labelFilter: ALL, + groupBy: "type", + sortField: "updated", + sortDir: "desc", + columns: DEFAULT_CASE_COLUMNS, + treeView: false, +}; + +function getCaseViewStorageKey(companyId: string | null | undefined): string | null { + return companyId ? `paperclip:cases:${companyId}:view` : null; +} + +function normalizeCaseColumns(value: unknown): CaseColumn[] { + if (!Array.isArray(value)) return DEFAULT_CASE_COLUMNS; + const valid = CASE_COLUMN_ORDER.filter((column) => value.includes(column)); + return valid.length > 0 ? valid : DEFAULT_CASE_COLUMNS; +} + +function normalizeCaseStatuses(value: unknown): CaseStatus[] { + if (!Array.isArray(value)) return DEFAULT_STATUS_FILTERS; + const valid = CASE_STATUSES.filter((status) => value.includes(status)); + return valid.length > 0 ? valid : DEFAULT_STATUS_FILTERS; +} + +function normalizeStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function normalizeGroupBy(value: unknown): GroupBy { + return value === "project" || value === "status" || value === "type" || value === "none" ? value : defaultCaseViewState.groupBy; +} + +function normalizeSortField(value: unknown): CaseSortField { + return value === "created" + || value === "title" + || value === "status" + || value === "id" + || value === "type" + || value === "project" + || value === "updated" + ? value + : defaultCaseViewState.sortField; +} + +function loadCaseViewState(storageKey: string | null): CaseViewState { + if (typeof window === "undefined" || !storageKey) return { ...defaultCaseViewState }; + try { + const parsed = JSON.parse(window.localStorage.getItem(storageKey) ?? "null"); + if (!parsed || typeof parsed !== "object") return { ...defaultCaseViewState }; + const record = parsed as Record; + const treeView = record.treeView === true; + const columns = treeView + ? normalizeCaseColumns([...normalizeCaseColumns(record.columns), "type"]) + : normalizeCaseColumns(record.columns); + return { + search: typeof record.search === "string" ? record.search : defaultCaseViewState.search, + typeFilters: normalizeStringArray(record.typeFilters), + statusFilters: normalizeCaseStatuses(record.statusFilters), + projectFilters: normalizeStringArray(record.projectFilters), + labelFilter: typeof record.labelFilter === "string" ? record.labelFilter : defaultCaseViewState.labelFilter, + groupBy: treeView ? "none" : normalizeGroupBy(record.groupBy), + sortField: normalizeSortField(record.sortField), + sortDir: record.sortDir === "asc" ? "asc" : "desc", + columns, + treeView, + }; + } catch { + return { ...defaultCaseViewState }; + } +} + +function saveCaseViewState(storageKey: string | null, state: CaseViewState) { + if (typeof window === "undefined" || !storageKey) return; + try { + window.localStorage.setItem(storageKey, JSON.stringify(state)); + } catch { + // Ignore localStorage failures; the controls still work for the session. + } +} + +function caseTrailingGridTemplate(columns: CaseColumn[]): string { + return columns + .map((column) => { + if (column === "title") return "minmax(12rem, 1fr)"; + if (column === "id") return "max-content"; + if (column === "key") return "minmax(8rem, 12rem)"; + if (column === "status") return "minmax(6rem, 7rem)"; + if (column === "type") return "minmax(5rem, 8rem)"; + if (column === "project") return "minmax(5rem, 8rem)"; + if (column === "parent") return "minmax(4rem, 6rem)"; + return "minmax(5rem, 6rem)"; + }) + .join(" "); +} + +function sameStringSet(a: readonly string[], b: readonly string[]) { + return a.length === b.length && a.every((value) => b.includes(value)); +} + +function sameStatusSet(a: readonly CaseStatus[], b: readonly CaseStatus[]) { + return a.length === b.length && a.every((value) => b.includes(value)); +} + +function ensureCaseColumn(columns: readonly CaseColumn[], column: CaseColumn): CaseColumn[] { + return columns.includes(column) ? [...columns] : normalizeCaseColumns([...columns, column]); +} + +function caseMatchesViewFilters(caseRow: CaseSummary, viewState: CaseViewState) { + if (caseRow.matchesListFilters === false) return false; + if (viewState.typeFilters.length > 0 && !viewState.typeFilters.includes(caseRow.caseType)) return false; + if (!viewState.statusFilters.includes(caseRow.status)) return false; + if (viewState.projectFilters.length > 0) { + const projectKey = caseRow.projectId ?? ALL; + if (!viewState.projectFilters.includes(projectKey)) return false; + } + return true; +} + +function treeTitleIndentClass(depth: number): string { + if (depth <= 0) return ""; + if (depth === 1) return "pl-4"; + if (depth === 2) return "pl-8"; + if (depth === 3) return "pl-12"; + return "pl-16"; +} + +function CaseStatusPicker({ + status, + onChange, + disabled, +}: { + status: CaseStatus; + onChange: (next: CaseStatus) => void; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + return ( + + + + + { + event.preventDefault(); + event.stopPropagation(); + }} + > + {STATUS_FILTER_OPTIONS.map((option) => ( + + ))} + + + ); +} + +function CaseTrailingColumns({ + row, + columns, + projectName, + onStatusChange, + statusPending, + treeDepth = 0, + childCount = 0, + treeView = false, + treeCollapsed = false, + onTreeToggle, +}: { + row: CaseSummary; + columns: CaseColumn[]; + projectName: string | null; + onStatusChange: (caseId: string, status: CaseStatus) => void; + statusPending: boolean; + treeDepth?: number; + childCount?: number; + treeView?: boolean; + treeCollapsed?: boolean; + onTreeToggle?: (caseId: string) => void; +}) { + return ( + + {columns.map((column) => { + if (column === "id") { + return ( + + ); + } + if (column === "key") { + return row.key ? ( + + ) : ( + None + ); + } + if (column === "title") { + return ( + + {treeView ? ( + + {childCount > 0 ? ( + + ) : treeDepth > 0 ? ( + + ) : null} + + ) : null} + {row.title} + + ); + } + if (column === "status") { + return ( + + onStatusChange(row.id, status)} + /> + + ); + } + if (column === "type") { + return {row.caseType}; + } + if (column === "project") { + return {projectName ?? "No project"}; + } + if (column === "parent") { + return ( + + {row.parentCaseId ? "Parent" : "None"} + + ); + } + if (column === "created") { + return ( + + {relativeTime(row.createdAt)} + + ); + } + return ( + + {relativeTime(row.updatedAt)} + + ); + })} + + ); +} + +function CaseListRow({ + row, + projectName, + visibleColumnSet, + trailingColumns, + onStatusChange, + statusPending, + treeDepth, + childCount, + treeView, + treeCollapsed, + onTreeToggle, + selected = false, + onSelect, +}: { + row: CaseSummary; + projectName: string | null; + visibleColumnSet: ReadonlySet; + trailingColumns: CaseColumn[]; + onStatusChange: (caseId: string, status: CaseStatus) => void; + statusPending: boolean; + treeDepth?: number; + childCount?: number; + treeView?: boolean; + treeCollapsed?: boolean; + onTreeToggle?: (caseId: string) => void; + selected?: boolean; + onSelect?: () => void; +}) { + const caseHref = useCaseHref(); + return ( + + + + {visibleColumnSet.has("title") ? row.title : row.identifier} + + + {visibleColumnSet.has("status") ? ( + onStatusChange(row.id, status)} + /> + ) : null} + {visibleColumnSet.has("id") ? ( + + ) : null} + {visibleColumnSet.has("key") && row.key ? ( + + ) : null} + {relativeTime(row.updatedAt)} + + + {trailingColumns.length > 0 ? ( + + + + ) : null} + + ); +} + +function CaseColumnHeader({ + visibleColumnSet, + trailingColumns, +}: { + visibleColumnSet: ReadonlySet; + trailingColumns: CaseColumn[]; +}) { + return ( +
+ {trailingColumns.length > 0 ? ( + + {trailingColumns.map((column) => ( + + {CASE_COLUMN_LABELS[column]} + + ))} + + ) : null} +
+ ); +} + +function CaseGroup({ + label, + count, + collapsed, + selected, + onToggle, + onSelect, + children, +}: { + label: string; + count: number; + collapsed: boolean; + selected: boolean; + onToggle: () => void; + onSelect: () => void; + children: React.ReactNode; +}) { + return ( +
+
+ + {count} {count === 1 ? "case" : "cases"} + + )} + /> +
+ {!collapsed &&
{children}
} +
+ ); +} + +type CaseGroupedRows = { + key: string; + label: string | null; + rows: CaseSummary[]; +}; + +type CaseTreeRow = { + row: CaseSummary; + depth: number; + childCount: number; + collapsed: boolean; +}; +type CaseKeyboardNavEntry = + | { type: "group"; groupKey: string; collapsed: boolean } + | { type: "case"; row: CaseSummary; childCount: number; collapsed: boolean }; + +function getCaseKeyboardSelectionIndex( + previousIndex: number, + itemCount: number, + direction: "next" | "previous", +): number { + if (itemCount === 0) return -1; + if (previousIndex < 0) return 0; + return direction === "next" + ? Math.min(previousIndex + 1, itemCount - 1) + : Math.max(previousIndex - 1, 0); +} + +function CaseToolbarButton({ + icon: Icon, + title, + active, + children, +}: { + icon: typeof Filter; + title: string; + active?: boolean; + children: ReactNode; +}) { + return ( + + + + + {children} + + ); +} + +function FilterField({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+ {children} +
+ ); +} + +function FilterCheckboxRow({ + label, + checked, + onCheckedChange, +}: { + label: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +function CaseColumnPicker({ + visibleColumns, + onToggle, + onReset, +}: { + visibleColumns: ReadonlySet; + onToggle: (column: CaseColumn, enabled: boolean) => void; + onReset: () => void; +}) { + return ( + + +
+
+ Desktop case rows +
+
Choose visible columns
+
+
+ {CASE_COLUMN_ORDER.map((column) => ( + + ))} +
+
+ +
+
+
+ ); +} + +function CaseSortPicker({ + sortField, + sortDir, + onChange, +}: { + sortField: CaseSortField; + sortDir: "asc" | "desc"; + onChange: (patch: Pick) => void; +}) { + return ( + + + {(Object.keys(CASE_SORT_LABELS) as CaseSortField[]).map((field) => ( + + ))} + + + ); +} + +/** Full-page onboarding hero shown when the company has zero cases (§6). */ +function CasesEmptyHero() { + return ( +
+ +

No cases yet

+

+ Cases are durable work products — blog posts, tweet storms, docs pages — that tasks create and + iterate on. In v1 they're created by agents, not from the UI. +

+
+

To start creating cases, add this to a skill:

+
+{`"Create a case of type blog_post with fields
+{slug, target_audience, publish_url} and key /."`}
+        
+

+ See the paperclip skill → references/cases.md for the API. +

+
+

+ Feature is gated by the enableCases experimental flag + (Settings → Experimental). +

+
+ ); +} + +export function Cases() { + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { keyboardShortcutsEnabled } = useGeneralSettings(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const caseHref = useCaseHref(); + const caseListRef = useRef(null); + + const viewStorageKey = getCaseViewStorageKey(selectedCompanyId); + const [viewState, setViewState] = useState(() => loadCaseViewState(viewStorageKey)); + const [collapsedTreeCaseIds, setCollapsedTreeCaseIds] = useState>(() => new Set()); + const [collapsedGroupKeys, setCollapsedGroupKeys] = useState>(() => new Set()); + const [selectedIndex, setSelectedIndex] = useState(-1); + + useEffect(() => { + setBreadcrumbs([{ label: "Cases" }]); + }, [setBreadcrumbs]); + + useEffect(() => { + setViewState(loadCaseViewState(viewStorageKey)); + setCollapsedGroupKeys(new Set()); + setCollapsedTreeCaseIds(new Set()); + setSelectedIndex(-1); + }, [viewStorageKey]); + + function updateView(patch: Partial) { + setViewState((current) => { + const next = { ...current, ...patch }; + if (current.treeView && patch.groupBy && patch.groupBy !== "none") { + next.treeView = false; + } + if (patch.treeView === true) { + next.groupBy = "none"; + next.columns = ensureCaseColumn(next.columns, "type"); + } else if (next.treeView) { + next.groupBy = "none"; + } + saveCaseViewState(viewStorageKey, next); + return next; + }); + } + + const usesDefaultStatusFilter = sameStatusSet(viewState.statusFilters, DEFAULT_STATUS_FILTERS); + + const listFilters = useMemo(() => { + const projectIds = viewState.projectFilters.filter((projectId) => projectId !== ALL); + const statusFilters = usesDefaultStatusFilter || viewState.statusFilters.length === CASE_STATUSES.length + ? undefined + : viewState.statusFilters; + return { + types: viewState.typeFilters.length > 0 ? viewState.typeFilters : undefined, + status: usesDefaultStatusFilter ? "active" : undefined, + statuses: statusFilters, + projectIds: projectIds.length > 0 ? projectIds : undefined, + includeNoProject: viewState.projectFilters.includes(ALL) || undefined, + labelId: viewState.labelFilter === ALL ? undefined : viewState.labelFilter, + q: viewState.search.trim() || undefined, + includeAncestors: viewState.treeView ? true : undefined, + limit: 200, + }; + }, [usesDefaultStatusFilter, viewState.labelFilter, viewState.projectFilters, viewState.search, viewState.statusFilters, viewState.treeView, viewState.typeFilters]); + const casesQuery = useQuery({ + queryKey: [...queryKeys.cases.list(selectedCompanyId ?? ""), listFilters], + queryFn: () => casesApi.list(selectedCompanyId!, listFilters), + enabled: !!selectedCompanyId, + }); + const projectsQuery = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId ?? ""), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const labelsQuery = useQuery({ + queryKey: queryKeys.issues.labels(selectedCompanyId ?? ""), + queryFn: () => issuesApi.listLabels(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const patchCase = useMutation({ + mutationFn: ({ caseId, status }: { caseId: string; status: CaseStatus }) => + casesApi.patch(caseId, { status }), + onSuccess: (updated) => { + queryClient.setQueryData( + [...queryKeys.cases.list(selectedCompanyId ?? ""), listFilters], + (current) => current?.map((row) => row.id === updated.id ? { ...row, ...updated } : row), + ); + queryClient.invalidateQueries({ queryKey: queryKeys.cases.list(selectedCompanyId ?? "") }); + }, + }); + + const allCases = useMemo(() => casesQuery.data ?? [], [casesQuery.data]); + const projectName = useMemo(() => { + const map = new Map(); + for (const p of projectsQuery.data ?? []) map.set(p.id, p.name); + return map; + }, [projectsQuery.data]); + + const distinctTypes = useMemo( + () => [...new Set([ + ...allCases.map((c) => c.caseType), + ...viewState.typeFilters, + ])].sort(), + [allCases, viewState.typeFilters], + ); + + const filtered = useMemo(() => { + return allCases.filter((caseRow) => caseMatchesViewFilters(caseRow, viewState)); + }, [allCases, viewState]); + + const caseSortCompare = useMemo(() => { + return (a: CaseSummary, b: CaseSummary) => { + let result = 0; + if (viewState.sortField === "updated") result = Date.parse(a.updatedAt) - Date.parse(b.updatedAt); + else if (viewState.sortField === "created") result = Date.parse(a.createdAt) - Date.parse(b.createdAt); + else if (viewState.sortField === "title") result = a.title.localeCompare(b.title); + else if (viewState.sortField === "status") result = a.status.localeCompare(b.status); + else if (viewState.sortField === "id") result = a.identifier.localeCompare(b.identifier); + else if (viewState.sortField === "type") result = a.caseType.localeCompare(b.caseType); + else { + const aProject = a.projectId ? projectName.get(a.projectId) ?? "" : ""; + const bProject = b.projectId ? projectName.get(b.projectId) ?? "" : ""; + result = aProject.localeCompare(bProject); + } + return viewState.sortDir === "desc" ? -result : result; + }; + }, [projectName, viewState.sortDir, viewState.sortField]); + + const sorted = useMemo(() => { + const rows = [...filtered]; + rows.sort(caseSortCompare); + return rows; + }, [caseSortCompare, filtered]); + + const sortedTreeSource = useMemo(() => { + const rows = [...allCases]; + rows.sort(caseSortCompare); + return rows; + }, [allCases, caseSortCompare]); + + const visibleColumnSet = useMemo(() => new Set(viewState.columns), [viewState.columns]); + const trailingColumns = useMemo( + () => CASE_COLUMN_ORDER.filter((column) => visibleColumnSet.has(column)), + [visibleColumnSet], + ); + + const groupedRows = useMemo((): CaseGroupedRows[] => { + if (viewState.groupBy === "none") { + return [{ key: "__all__", label: null, rows: sorted }]; + } + const map = new Map(); + for (const c of sorted) { + let key: string; + if (viewState.groupBy === "type") key = c.caseType; + else if (viewState.groupBy === "status") key = c.status; + else key = c.projectId ? projectName.get(c.projectId) ?? "Unknown project" : "No project"; + const bucket = map.get(key); + if (bucket) bucket.push(c); + else map.set(key, [c]); + } + return [...map.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([label, rows]) => ({ key: label, label, rows })); + }, [sorted, viewState.groupBy, projectName]); + + const treeRows = useMemo((): CaseTreeRow[] => { + const rowById = new Map(sortedTreeSource.map((row) => [row.id, row])); + const visibleIds = new Set(filtered.map((row) => row.id)); + for (const row of filtered) { + const seen = new Set([row.id]); + let parentCaseId = row.parentCaseId; + while (parentCaseId && !seen.has(parentCaseId)) { + const parent = rowById.get(parentCaseId); + if (!parent) break; + visibleIds.add(parent.id); + seen.add(parent.id); + parentCaseId = parent.parentCaseId; + } + } + + const childrenByParent = new Map(); + for (const row of sortedTreeSource) { + if ( + !visibleIds.has(row.id) + || !row.parentCaseId + || !visibleIds.has(row.parentCaseId) + || !rowById.has(row.parentCaseId) + ) { + continue; + } + const children = childrenByParent.get(row.parentCaseId) ?? []; + children.push(row); + childrenByParent.set(row.parentCaseId, children); + } + + const roots = sortedTreeSource.filter((row) => + visibleIds.has(row.id) + && (!row.parentCaseId || !visibleIds.has(row.parentCaseId) || !rowById.has(row.parentCaseId)) + ); + const rows: CaseTreeRow[] = []; + const visited = new Set(); + const hidden = new Set(); + + function markHidden(row: CaseSummary, ancestors: Set) { + if (hidden.has(row.id) || ancestors.has(row.id)) return; + hidden.add(row.id); + const nextAncestors = new Set(ancestors); + nextAncestors.add(row.id); + for (const child of childrenByParent.get(row.id) ?? []) { + markHidden(child, nextAncestors); + } + } + + function walk(row: CaseSummary, depth: number, ancestors: Set) { + if (visited.has(row.id) || hidden.has(row.id)) return; + visited.add(row.id); + const children = childrenByParent.get(row.id) ?? []; + const collapsed = collapsedTreeCaseIds.has(row.id); + rows.push({ row, depth, childCount: children.length, collapsed }); + if (ancestors.has(row.id)) return; + const nextAncestors = new Set(ancestors); + nextAncestors.add(row.id); + if (collapsed) { + for (const child of children) { + markHidden(child, nextAncestors); + } + return; + } + for (const child of children) { + walk(child, depth + 1, nextAncestors); + } + } + + for (const root of roots) { + walk(root, 0, new Set()); + } + for (const row of sortedTreeSource) { + if (!visibleIds.has(row.id)) continue; + walk(row, 0, new Set()); + } + + return rows; + }, [collapsedTreeCaseIds, filtered, sortedTreeSource]); + + const keyboardNavItems = useMemo((): CaseKeyboardNavEntry[] => { + if (viewState.treeView) { + return treeRows.map(({ row, childCount, collapsed }) => ({ + type: "case", + row, + childCount, + collapsed, + })); + } + + const entries: CaseKeyboardNavEntry[] = []; + for (const group of groupedRows) { + const collapsed = collapsedGroupKeys.has(group.key); + if (group.label) { + entries.push({ type: "group", groupKey: group.key, collapsed }); + } + if (collapsed) continue; + for (const row of group.rows) { + entries.push({ type: "case", row, childCount: 0, collapsed: false }); + } + } + return entries; + }, [collapsedGroupKeys, groupedRows, treeRows, viewState.treeView]); + + useEffect(() => { + setSelectedIndex((current) => { + if (keyboardNavItems.length === 0) return -1; + if (current < 0) return -1; + return Math.min(current, keyboardNavItems.length - 1); + }); + }, [keyboardNavItems.length]); + + useEffect(() => { + if (selectedIndex < 0 || !caseListRef.current) return; + const rows = caseListRef.current.querySelectorAll("[data-case-item]"); + rows[selectedIndex]?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + const activeFilters: FilterValue[] = []; + if (viewState.search.trim()) activeFilters.push({ key: "search", label: "Search", value: viewState.search.trim() }); + if (viewState.typeFilters.length > 0) { + activeFilters.push({ key: "type", label: "Type", value: viewState.typeFilters.join(", ") }); + } + if (!usesDefaultStatusFilter) { + activeFilters.push({ + key: "status", + label: "Status", + value: viewState.statusFilters.length === CASE_STATUSES.length + ? "All" + : viewState.statusFilters + .map((status) => STATUS_FILTER_OPTIONS.find((option) => option.value === status)?.label ?? status) + .join(", "), + }); + } + if (viewState.projectFilters.length > 0) { + activeFilters.push({ + key: "project", + label: "Project", + value: viewState.projectFilters + .map((projectId) => projectId === ALL ? "No project" : projectName.get(projectId) ?? "Project") + .join(", "), + }); + } + if (viewState.labelFilter !== ALL) { + const name = (labelsQuery.data ?? []).find((l) => l.id === viewState.labelFilter)?.name ?? "Label"; + activeFilters.push({ key: "label", label: "Label", value: name }); + } + + function removeFilter(key: string) { + if (key === "search") updateView({ search: "" }); + else if (key === "type") updateView({ typeFilters: [] }); + else if (key === "status") updateView({ statusFilters: DEFAULT_STATUS_FILTERS }); + else if (key === "project") updateView({ projectFilters: [] }); + else if (key === "label") updateView({ labelFilter: ALL }); + } + function clearFilters() { + updateView({ + search: "", + typeFilters: [], + statusFilters: DEFAULT_STATUS_FILTERS, + projectFilters: [], + labelFilter: ALL, + }); + } + function toggleColumn(column: CaseColumn, enabled: boolean) { + const next = enabled + ? [...viewState.columns, column] + : viewState.columns.filter((value) => value !== column); + updateView({ columns: normalizeCaseColumns(next) }); + } + function resetColumns() { + updateView({ columns: viewState.treeView ? ensureCaseColumn(DEFAULT_CASE_COLUMNS, "type") : DEFAULT_CASE_COLUMNS }); + } + function toggleTreeRow(caseId: string) { + setCollapsedTreeCaseIds((current) => { + const next = new Set(current); + if (next.has(caseId)) next.delete(caseId); + else next.add(caseId); + return next; + }); + } + function setTreeRowCollapsed(caseId: string, collapsed: boolean) { + setCollapsedTreeCaseIds((current) => { + const next = new Set(current); + if (collapsed) next.add(caseId); + else next.delete(caseId); + return next; + }); + } + function toggleGroup(groupKey: string) { + setCollapsedGroupKeys((current) => { + const next = new Set(current); + if (next.has(groupKey)) next.delete(groupKey); + else next.add(groupKey); + return next; + }); + } + function setGroupCollapsed(groupKey: string, collapsed: boolean) { + setCollapsedGroupKeys((current) => { + const next = new Set(current); + if (collapsed) next.add(groupKey); + else next.delete(groupKey); + return next; + }); + } + function toggleStringFilter(key: "typeFilters" | "projectFilters", value: string, enabled: boolean) { + const current = viewState[key]; + updateView({ + [key]: enabled + ? [...current, value] + : current.filter((item) => item !== value), + }); + } + function toggleStatusFilter(status: CaseStatus, enabled: boolean) { + const next = enabled + ? [...viewState.statusFilters, status] + : viewState.statusFilters.filter((item) => item !== status); + updateView({ statusFilters: normalizeCaseStatuses(next) }); + } + + useEffect(() => { + if (!keyboardShortcutsEnabled) return; + + function handleKeyDown(event: KeyboardEvent) { + if (event.defaultPrevented) return; + const target = event.target; + if ( + !(target instanceof HTMLElement) + || isKeyboardShortcutTextInputTarget(target) + || hasBlockingShortcutDialog(document) + || event.metaKey + || event.ctrlKey + || event.altKey + ) { + return; + } + + const navCount = keyboardNavItems.length; + if (navCount === 0) return; + + switch (event.key) { + case "j": + case "ArrowDown": + event.preventDefault(); + setSelectedIndex((current) => getCaseKeyboardSelectionIndex(current, navCount, "next")); + break; + case "k": + case "ArrowUp": + event.preventDefault(); + setSelectedIndex((current) => getCaseKeyboardSelectionIndex(current, navCount, "previous")); + break; + case "ArrowLeft": + case "ArrowRight": { + if (selectedIndex < 0 || selectedIndex >= navCount) return; + const entry = keyboardNavItems[selectedIndex]; + if (!entry) return; + if (entry.type === "group") { + event.preventDefault(); + setGroupCollapsed(entry.groupKey, event.key === "ArrowLeft"); + return; + } + if (viewState.treeView && entry.childCount > 0) { + event.preventDefault(); + setTreeRowCollapsed(entry.row.id, event.key === "ArrowLeft"); + } + break; + } + case "Enter": { + if (selectedIndex < 0 || selectedIndex >= navCount) return; + const entry = keyboardNavItems[selectedIndex]; + if (!entry || entry.type !== "case") return; + event.preventDefault(); + navigate(caseHref(entry.row.identifier)); + break; + } + default: + return; + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [caseHref, keyboardNavItems, keyboardShortcutsEnabled, navigate, selectedIndex, viewState.treeView]); + + if (casesQuery.isLoading) return ; + + const noCasesAtAll = allCases.length === 0 && activeFilters.length === 0; + const hasActiveFilters = activeFilters.length > 0; + + return ( +
+
+
+

Cases

+ Experimental +
+
+ + {noCasesAtAll ? ( + + ) : ( + <> +
+
+ + updateView({ search: e.target.value })} + placeholder="Search cases..." + className="pl-7 text-xs sm:text-sm" + aria-label="Search cases" + data-page-search-target="true" + /> +
+ +
+ + + + + + +
+ +
+ {distinctTypes.length === 0 ? ( +

No types yet

+ ) : distinctTypes.map((type) => ( + toggleStringFilter("typeFilters", type, checked)} + /> + ))} +
+
+ +
+ {STATUS_FILTER_OPTIONS.map((option) => ( + toggleStatusFilter(option.value, checked)} + /> + ))} +
+
+ +
+ toggleStringFilter("projectFilters", ALL, checked)} + /> + {(projectsQuery.data ?? []).map((project) => ( + toggleStringFilter("projectFilters", project.id, checked)} + /> + ))} +
+
+ +
+ { + if (checked) updateView({ labelFilter: ALL }); + }} + /> + {(labelsQuery.data ?? []).map((label) => ( + updateView({ labelFilter: checked ? label.id : ALL })} + /> + ))} +
+
+ +
+
+
+ + + + + + {([ + ["type", "Type"], + ["project", "Project"], + ["status", "Status"], + ["none", "None"], + ] as const).map(([value, label]) => ( + + ))} + + +
+
+ + + + {filtered.length === 0 ? ( + + ) : ( +
+ + {viewState.treeView ? ( + treeRows.map(({ row, depth, childCount, collapsed }) => { + const navIndex = keyboardNavItems.findIndex((item) => item.type === "case" && item.row.id === row.id); + return ( + patchCase.mutate({ caseId, status })} + treeDepth={depth} + childCount={childCount} + treeCollapsed={collapsed} + onTreeToggle={toggleTreeRow} + selected={selectedIndex === navIndex} + onSelect={() => setSelectedIndex(navIndex)} + treeView + /> + ); + }) + ) : ( + groupedRows.map((group) => group.label ? ( + item.type === "group" && item.groupKey === group.key)} + onToggle={() => toggleGroup(group.key)} + onSelect={() => setSelectedIndex(keyboardNavItems.findIndex((item) => item.type === "group" && item.groupKey === group.key))} + > + {group.rows.map((row) => { + const navIndex = keyboardNavItems.findIndex((item) => item.type === "case" && item.row.id === row.id); + return ( + patchCase.mutate({ caseId, status })} + selected={selectedIndex === navIndex} + onSelect={() => setSelectedIndex(navIndex)} + /> + ); + })} + + ) : ( +
+ {group.rows.map((row) => { + const navIndex = keyboardNavItems.findIndex((item) => item.type === "case" && item.row.id === row.id); + return ( + patchCase.mutate({ caseId, status })} + selected={selectedIndex === navIndex} + onSelect={() => setSelectedIndex(navIndex)} + /> + ); + })} +
+ )) + )} +
+ )} + + )} +
+ ); +} diff --git a/ui/src/pages/CompanySkills.test.tsx b/ui/src/pages/CompanySkills.test.tsx index 4171bd60bd..4da07676bf 100644 --- a/ui/src/pages/CompanySkills.test.tsx +++ b/ui/src/pages/CompanySkills.test.tsx @@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client"; import type { CompanySkillDetail, CompanySkillVersion } from "@paperclipai/shared"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DiscoveryGrid, SkillDetailPage, getSkillVersionDiffSelection } from "./CompanySkills"; +import { skillStudioNewRoute } from "../lib/company-skill-routes"; vi.mock("@/lib/router", () => ({ Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( @@ -327,6 +328,12 @@ describe("DiscoveryGrid Studio entry points", () => { }); }); +describe("skillStudioNewRoute", () => { + it("builds a direct fork draft URL for a specific skill", () => { + expect(skillStudioNewRoute("skill 1")).toBe("/skills/studio/new?forkFrom=skill%201"); + }); +}); + describe("SkillDetailPage versions tab", () => { it("opens per-row version diffs for newest and oldest revisions", async () => { const v1 = makeVersion(1, "# Demo Skill\n\nFirst line"); @@ -358,7 +365,32 @@ describe("SkillDetailPage versions tab", () => { }); describe("SkillDetailPage settings", () => { - it("saves category edits with spaces from the settings dialog", async () => { + it("shows a direct fork action for read-only skills", async () => { + const v1 = makeVersion(1, "# Demo Skill"); + const onFork = vi.fn(); + const node = await renderSkillDetail([v1], { + activeTab: "overview", + detail: makeDetail(v1, { + editable: false, + editableReason: "Remote GitHub skills are read-only. Fork or import locally to edit them.", + sourceBadge: "github", + sourceLabel: "GitHub", + sourceType: "github", + }), + onFork, + }); + + expect(node.textContent).not.toContain("Fork or import locally"); + + const forkButton = buttonsNamed(node, "Fork")[0] as HTMLButtonElement; + expect(forkButton).toBeTruthy(); + + await click(forkButton); + + expect(onFork).toHaveBeenCalledOnce(); + }); + + it("saves normalized category edits from the settings dialog", async () => { const v1 = makeVersion(1, "# Demo Skill"); const onUpdateSettings = vi.fn(); const node = await renderSkillDetail([v1], { diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index a3e9a25180..c907b6cf43 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -8,6 +8,7 @@ import type { CatalogSkillFileDetail, CatalogSkillSource, CompanySkillCompatibility, + CompanySkillCreateRequest, CompanySkillDetail, CompanySkillFileDetail, CompanySkillFileInventoryEntry, @@ -73,8 +74,15 @@ import { type CompanySkillRouteSubject, } from "../lib/company-skill-routes"; import { + SKILL_CREATE_ACCENTS, + buildBlankSkillDraft, + buildForkSkillDraft, + defaultSkillMarkdown, normalizeSkillDraftSlug, + skillAccentColor, + skillCreateDraftToPayload, splitCategoryDraft, + type SkillCreateDraft, } from "../lib/skill-create"; import { SkillCardIcon } from "../components/SkillCardIcon"; import { Button } from "@/components/ui/button"; @@ -1151,6 +1159,241 @@ export function DiscoveryGrid({ ); } +function NewSkillWizard({ + initialDraft, + onCreate, + isPending, + error, + onCancel, +}: { + initialDraft: SkillCreateDraft; + onCreate: (payload: CompanySkillCreateRequest) => void; + isPending: boolean; + error: string | null; + onCancel: () => void; +}) { + const [step, setStep] = useState(0); + const [draft, setDraft] = useState(initialDraft); + const [slugDirty, setSlugDirty] = useState(initialDraft.slug.trim().length > 0); + const categoryDraft = draft.categories.join(", "); + const steps = ["Basics", "Design", "Content", "Review"]; + + useEffect(() => { + setStep(0); + setDraft(initialDraft); + setSlugDirty(initialDraft.slug.trim().length > 0); + }, [initialDraft]); + + function patchDraft(patch: Partial) { + setDraft((current) => ({ ...current, ...patch })); + } + + const nameValid = draft.name.trim().length > 0; + const effectiveSlug = draft.slug.trim() || normalizeSkillDraftSlug(draft.name); + function submit() { + onCreate(skillCreateDraftToPayload(draft)); + } + + return ( +
+
+ {steps.map((label, index) => ( + + ))} +
+ + {draft.forkedFromName ? ( +
+ + Forking {draft.forkedFromName} +
+ ) : null} + + {step === 0 ? ( +
+ { + const nextName = event.target.value; + patchDraft({ + name: nextName, + slug: slugDirty ? draft.slug : normalizeSkillDraftSlug(nextName), + markdown: draft.markdown === defaultSkillMarkdown(draft.name, draft.tagline) + ? defaultSkillMarkdown(nextName, draft.tagline) + : draft.markdown, + }); + }} + placeholder="Skill name" + className="h-9" + /> + { + const nextSlug = normalizeSkillDraftSlug(event.target.value); + setSlugDirty(nextSlug.length > 0); + patchDraft({ slug: nextSlug }); + }} + placeholder="skill-shortname" + className="h-9 font-mono" + /> +