Cases: experimental first-class case object (#9198)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board currently uses issues for execution, but longer-lived
content work needs a separate object that can survive beyond a single
task thread.
> - The Cases subsystem adds an experimental, company-scoped record for
content artifacts and their supporting metadata.
> - The backend needs durable storage, API routes, revision history,
issue linkage, and company-boundary enforcement before the UI can depend
on Cases.
> - The UI needs an opt-in navigation surface, list/detail views,
reference chips, and issue-page context so operators can inspect Cases
without making them the default workflow.
> - The agent-facing skills need a contract for creating and updating
Cases so automated content workflows can dogfood the feature.
> - This pull request ships that experimental end-to-end path behind the
`enableCases` flag.
> - The benefit is a first-class place to collect content work,
references, attachments, revisions, and related execution threads
without polluting the core issue model.

## Linked Issues or Issue Description

No public GitHub issue exists for this experimental feature.

Feature request fields:

### Problem

Content-oriented work such as release notes, announcements, docs, and
campaigns can span many execution issues, which makes the final artifact
hard to find and reason about after the execution thread moves on.

### Proposed solution

Add an experimental Cases object that is company-scoped, linked to
issues, queryable through the API, inspectable in the board UI, and
writable by agent workflows through documented conventions.

### Alternatives considered

Continue encoding content artifacts directly in issues or documents
only. That keeps the data model smaller, but it does not give operators
a stable artifact-centric view or a clean way to link related execution
history.

### Roadmap alignment

Checked `ROADMAP.md`; this PR does not duplicate an existing planned
core roadmap item.

## What Changed

- Added the `cases` data model, migration, schema exports, and
experimental `enableCases` instance setting.
- Added company-scoped Cases API routes for list/detail/update, issue
links, revisions, children, activity events, annotations, attachments,
and idempotent agent-oriented upserts.
- Scoped case and issue lookup helpers before access checks so
inaccessible cross-company identifiers resolve as not found rather than
leaking existence.
- Fixed case PATCH timestamp handling so non-status updates cannot
overwrite `completedAt` from a stale pre-transaction row snapshot.
- Moved Cases list type/status/project filters into the server request
before the server-side limit is applied, including multi-select filters
and no-project filtering.
- Added backend route coverage for creation, updates, idempotency, issue
linking, attribution, company-boundary enforcement, OpenAPI
registration, list filtering, timestamp patch behavior, and inaccessible
lookup regressions.
- Added the experimental Cases UI surface: sidebar entry, gated routes,
list filters/grouping, detail overview, activity, revisions, children,
attachments, and issue-page case rail.
- Added case reference rendering and company-prefixed case href
generation so case links resolve directly inside the active company
route.
- Added Paperclip skill documentation for agent workflows that create or
update Cases.
- Wired release-content skills to emit Cases for dogfooding.
- Rebased onto current `master` and renumbered the Cases migrations to
`0143`/`0144` after the latest upstream migration sequence.

## Verification

- Current PR head: `ecc13be0d`.
- Rebased on current `master` (`606aa4f266`) and pushed to the existing
PR branch.
- `git diff --check origin/master...HEAD` — passed before the first
update push; subsequent committed diffs were also checked with `git diff
--check` before commit.
- Guardrails checked: no `pnpm-lock.yaml` changes, no
`.github/workflows` changes, and changed-file count is below the
Greptile 100-file limit.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/cases-routes.test.ts
src/__tests__/instance-settings-service.test.ts
src/__tests__/openapi-routes.test.ts` — passed, 3 files / 26 tests
before review-fix commits.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/cases-routes.test.ts` — passed after each server-side
Greptile fix, latest 1 file / 15 tests.
- `pnpm --filter @paperclipai/server typecheck` — passed after the
timestamp and lookup fixes.
- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/Cases.test.tsx src/pages/CaseDetail.test.tsx
src/pages/CompanySkills.test.tsx src/App.cases-routing.test.tsx` —
passed, 4 files / 30 tests before review-fix commits.
- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/Cases.test.tsx` — passed after the list-filter fix, 1 file /
12 tests.
- `pnpm --filter @paperclipai/ui typecheck` — passed after the
list-filter fix.
- `pnpm check:token-gates` — passed after UI changes.
- Remote PR checks on head `ecc13be0d` are green: Paperclip CI, build,
typecheck, test matrix, e2e, Canary Dry Run, policy, commit review,
Superagent Security Scan, Socket, Snyk, and Greptile passed; Storybook
visual regression is skipped and security-review is neutral.
- Greptile Review: 5/5 confidence, zero unresolved Greptile threads.

## Risks

- Medium feature risk because this introduces a new experimental domain
object across database, server, shared contracts, skills, and UI.
- The feature is gated behind `enableCases`, which limits default
operator exposure while the model is exercised.
- Case links now prefer company-prefixed hrefs; the unprefixed redirect
remains for externally entered URLs.
- Cases list filtering now sends multi-select filters to the server
before limiting; the UI still applies the same local filters as a second
pass for ancestor/context rows.
- Migrations were renumbered on top of current master; the SQL uses
guarded `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS` patterns where
relevant for safer replay.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 Codex in the Paperclip local coding environment was used
for this PR curation, rebase verification, review-fix implementation,
push, and PR description update. The runtime exposes tool use and shell
execution; context-window size is not exposed by this Paperclip adapter.
Several implementation commits also include AI co-author trailers
recorded in git history.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I searched the GitHub PR list for similar PRs and confirmed this
is not a duplicate
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-07-09 22:11:03 -05:00 committed by GitHub
parent 9a1d4b7983
commit 5c85ae64a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 10342 additions and 114 deletions

View File

@ -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": "<release-case-id>",
"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": "<announcement 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

View File

@ -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": "<contents of releases/vYYYY.MDD.P.md>",
"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.

View File

@ -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

View File

@ -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");

View File

@ -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");

View File

@ -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
}
]
}

View File

@ -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<Record<string, unknown>>().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<Record<string, unknown>>().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),
}),
);

View File

@ -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<IssueCommentAuthorType>().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`,
),
}),
);

View File

@ -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<DocumentAnnotationThreadStatus>().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`,
),
}),
);

View File

@ -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,

View File

@ -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;

View File

@ -49,6 +49,7 @@ export interface InstanceExperimentalSettings {
enableIsolatedWorkspaces: boolean;
enableStreamlinedLeftNavigation: boolean;
enablePipelines: boolean;
enableCases: boolean;
enableConferenceRoomChat: boolean;
enableTaskWatchdogs: boolean;
enableIssuePlanDecompositions: boolean;

View File

@ -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),

View File

@ -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": "<release-case-id>",
"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": "<release-case-id>",
"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 |

View File

@ -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",

View File

@ -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");
});
});

View File

@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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 });
});
});

View File

@ -26,6 +26,7 @@ describe("instance settings service", () => {
enableConferenceRoomChat: false,
enableExternalObjects: false,
enablePipelines: false,
enableCases: false,
enableIssuePlanDecompositions: true,
enableExperimentalFileViewer: true,
enableTaskWatchdogs: true,

View File

@ -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() {

View File

@ -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 }));

1539
server/src/routes/cases.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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<CaseDocumentRow | null> {
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<DocumentAnnotationThread | null> {
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<DocumentAnnotationComment[]> {
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<string, DocumentAnnotationComment[]>();
for (const comment of comments) {
const existing = commentsByThread.get(comment.threadId) ?? [];
existing.push(comment);
commentsByThread.set(comment.threadId, existing);
}
return threads.map((thread) => ({
...thread,
comments: commentsByThread.get(thread.id) ?? [],
}));
},
getThreadForIssueDocument: async (issueId: string, key: string, threadId: string) => {
const thread = await getThreadForIssue(issueId, key, threadId);
if (!thread) return null;
@ -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,
};
}

View File

@ -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,

View File

@ -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.

View File

@ -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.

View File

@ -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 <App>; 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 <App> 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 <App>'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: () => <Outlet /> };
});
// The experimental gate would otherwise hide the page behind a feature flag.
vi.mock("./components/CasesExperimentalGate", () => ({
CasesExperimentalGate: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
// Rendered by <App> outside <Routes> 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: () => <div>CASES_LIST_PAGE</div> }));
vi.mock("./pages/CaseDetail", () => ({ CaseDetail: () => <div>CASE_DETAIL_PAGE</div> }));
// CloudAccessGate must fall through to <Outlet/> (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(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>
</QueryClientProvider>,
);
});
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);
});

View File

@ -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() {
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
) : null}
<Route path="routines" element={<Routines />} />
<Route
path="cases"
element={<CasesExperimentalGate><Cases /></CasesExperimentalGate>}
/>
<Route
path="cases/:caseIdentifier"
element={<CasesExperimentalGate><CaseDetail /></CasesExperimentalGate>}
/>
<Route
path="review-queue"
element={<PipelinesExperimentalGate><ReviewQueue /></PipelinesExperimentalGate>}
@ -444,6 +455,8 @@ export function App() {
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
<Route path="review-queue" element={<UnprefixedBoardRedirect />} />
<Route path="learnings" element={<UnprefixedBoardRedirect />} />
<Route path="cases" element={<UnprefixedBoardRedirect />} />
<Route path="cases/:caseIdentifier" element={<UnprefixedBoardRedirect />} />
<Route path="pipelines" element={<UnprefixedBoardRedirect />} />
<Route path="pipelines/:pipelineId" element={<UnprefixedBoardRedirect />} />
<Route path="pipelines/:pipelineId/add" element={<UnprefixedBoardRedirect />} />

332
ui/src/api/cases.ts Normal file
View File

@ -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<string, unknown>;
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<string, unknown>;
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<CaseSummary[]>(`/companies/${companyId}/cases${toQuery(params)}`),
get: (idOrIdentifier: string) => api.get<CaseDetail>(`/cases/${idOrIdentifier}`),
patch: (idOrIdentifier: string, input: PatchCaseInput) =>
api.patch<CaseDetail>(`/cases/${idOrIdentifier}`, input),
listEvents: (idOrIdentifier: string, limit = 100) =>
api.get<CaseEvent[]>(`/cases/${idOrIdentifier}/events?limit=${limit}`),
listChildren: (companyId: string, parentId: string) =>
api.get<CaseSummary[]>(`/companies/${companyId}/cases${toQuery({ parent: parentId, limit: 200 })}`),
getDocument: (idOrIdentifier: string, key: string) =>
api.get<CaseDocument & { key: string; body: string }>(`/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<CaseDocument & { key: string; body: string }>(`/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}/lock`, {}),
unlockDocument: (idOrIdentifier: string, key: string) =>
api.post<CaseDocument & { key: string; body: string }>(`/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<CaseDocumentRevisions>(`/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}/revisions`),
listForIssue: (issueIdOrIdentifier: string) =>
api.get<IssueCaseLink[]>(`/issues/${issueIdOrIdentifier}/cases`),
};

View File

@ -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`;
}

View File

@ -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<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function event(overrides: Partial<CaseEvent>): 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(<CaseActivityFeed events={events} />));
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());
});
});

View File

@ -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<CaseEventKind, string> = {
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 <Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />;
}
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 (
<div className={cn("flex items-start gap-2 text-xs", compact ? "py-1.5" : "py-2")}>
<span className="mt-1"><ActorIcon event={event} /></span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-1.5">
<span className="font-medium">{EVENT_LABEL[event.kind] ?? event.kind}</span>
{detail && <span className="text-muted-foreground">· {detail}</span>}
</div>
<div className="flex flex-wrap items-center gap-x-1.5 text-muted-foreground">
<span>{actorLabel(event)}</span>
{event.issue && (
<>
<span aria-hidden>·</span>
<span>{issueRelationLabel(event)}</span>
<Link
to={`/issues/${event.issue.identifier}`}
className="inline-flex min-w-0 items-center gap-1 text-foreground/80 hover:underline"
title={event.issue.title}
>
<StatusIcon status={event.issue.status} size="sm" />
<span className="shrink-0 font-mono">{event.issue.identifier}</span>
<span className="min-w-0 truncate">{event.issue.title}</span>
</Link>
</>
)}
<span aria-hidden>·</span>
<span>{relativeTime(event.createdAt)}</span>
</div>
</div>
</div>
);
}
/** The full activity feed with kind filters (detail-page Activity tab). */
export function CaseActivityFeed({ events }: { events: CaseEvent[] }) {
const [active, setActive] = useState<Set<CaseEventKind>>(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 <p className="py-6 text-center text-sm text-muted-foreground">No activity yet.</p>;
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
{filtered.length} of {events.length} events
</p>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-1.5">
<ListFilter className="h-3.5 w-3.5" />
{filterLabel}
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Activity filter</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => setActive(new Set())}>
All activity
</DropdownMenuItem>
<DropdownMenuSeparator />
{presentKinds.map((kind) => (
<DropdownMenuCheckboxItem
key={kind}
checked={active.has(kind)}
onCheckedChange={() => toggle(kind)}
>
{EVENT_LABEL[kind] ?? kind}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{filtered.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No events match this filter.</p>
) : (
<div className="divide-y divide-border">
{filtered.map((event) => (
<CaseEventRow key={event.id} event={event} />
))}
</div>
)}
</div>
);
}

View File

@ -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<number | null>(null);
// The lightbox only navigates across image attachments.
const imageItems = useMemo<GalleryMediaItem[]>(
() =>
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 <p className="text-xs text-muted-foreground">No attachments.</p>;
}
return (
<>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
{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 (
<button
key={attachment.id}
type="button"
onClick={() => isImage && imageIdx >= 0 && setGalleryIndex(imageIdx)}
disabled={!isImage}
title={filename}
className={cn(
"group relative flex aspect-square flex-col overflow-hidden rounded-lg border border-border bg-muted/40 text-left",
isImage && "cursor-pointer hover:border-primary/50",
)}
>
{isImage ? (
<img
src={caseAttachmentUrl(attachment)}
alt={filename}
loading="lazy"
className="h-full w-full object-cover transition-transform group-hover:scale-(--s-1_02)"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2 text-muted-foreground">
<FileText className="h-6 w-6" aria-hidden />
<span className="w-full truncate text-center text-(length:--text-micro)">{filename}</span>
</div>
)}
<span className="pointer-events-none absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-black/60 to-transparent px-1.5 py-1 text-(length:--text-nano) text-white/90">
{humanBytes(attachment.asset.byteSize)}
</span>
</button>
);
})}
</div>
{galleryIndex !== null && (
<ImageGalleryModal
items={imageItems}
initialIndex={galleryIndex}
open={galleryIndex !== null}
onOpenChange={(open) => !open && setGalleryIndex(null)}
/>
)}
</>
);
}

View File

@ -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<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
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>): 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(<CaseChildrenTree children={children} />));
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(<CaseChildrenTree children={children} maxVisible={5} />));
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());
});
});

View File

@ -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<CaseSummary, "id" | "identifier" | "title" | "caseType" | "status"> & {
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 <p className="text-xs text-muted-foreground">No child cases.</p>;
}
const shouldCap = maxVisible != null && children.length > maxVisible;
const visibleChildren = shouldCap && !expanded ? children.slice(0, maxVisible) : children;
const hiddenCount = children.length - visibleChildren.length;
return (
<div className="space-y-1">
<ul className="space-y-1">
{visibleChildren.map((child) => (
<li key={child.id}>
<Link
to={caseHref(child.identifier)}
className="flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-accent/50"
>
<CaseCopyableToken
value={child.identifier}
label="case ID"
className="shrink-0 font-mono text-xs text-muted-foreground"
containerClassName="shrink-0"
stopPropagation
/>
<span className="min-w-0 flex-1 truncate" title={child.title}>{child.title}</span>
<Badge variant="secondary" className="shrink-0">{child.caseType}</Badge>
<StatusBadge status={child.status} />
</Link>
</li>
))}
</ul>
{hiddenCount > 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs text-muted-foreground"
onClick={() => setExpanded(true)}
>
<ChevronDown className="h-3.5 w-3.5" />
Show {hiddenCount} more
</Button>
) : null}
</div>
);
}

View File

@ -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<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
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<string, unknown>) {
const root = createRoot(container);
act(() => {
root.render(<CaseFieldsPanel fields={fields} />);
});
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());
});
});

View File

@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** A muted em-dash for null / empty / missing values. */
function EmptyValue() {
return <span className="text-muted-foreground"></span>;
}
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 (
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
{identifiers.map((identifier) => (
<IssueReferencePill
key={identifier}
issue={{ id: identifier, identifier, title: identifier }}
/>
))}
</span>
);
}
function CopyableCompactValue({
value,
children,
className,
}: {
value: unknown;
children: ReactNode;
className?: string;
}) {
const text = stringifyCopyValue(value);
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(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 (
<span className="relative inline-flex min-w-0 max-w-full">
<button
type="button"
className={cn("min-w-0 max-w-full cursor-copy truncate text-left transition-colors hover:text-foreground", className)}
title={text}
onClick={handleCopy}
>
{children}
</button>
{copied ? (
<span
role="status"
aria-live="polite"
className="pointer-events-none absolute bottom-full left-1/2 mb-1.5 inline-flex -translate-x-1/2 items-center gap-1 rounded-md bg-foreground px-2 py-1 text-xs whitespace-nowrap text-background"
>
<Check className="h-3 w-3 shrink-0" />
Copied
</span>
) : null}
</span>
);
}
function StringValue({ value, variant }: { value: string; variant: "compact" | "full" }) {
const caseHref = useCaseHref();
const trimmed = value.trim();
if (trimmed === "") return <EmptyValue />;
if (URL_RE.test(trimmed)) {
return (
<a
href={trimmed}
target="_blank"
rel="noreferrer"
className={cn(
"inline-flex max-w-full items-center gap-0.5 text-sm text-primary hover:underline",
variant === "compact" ? "truncate" : "break-all",
)}
title={trimmed}
>
<span className={variant === "compact" ? "truncate" : "break-all"}>{trimmed}</span>
<span aria-hidden></span>
</a>
);
}
if (CASE_ID_RE.test(trimmed)) {
return (
<Link to={caseHref(trimmed)} className="font-mono text-sm text-primary hover:underline">
{trimmed}
</Link>
);
}
if (variant === "compact") {
return (
<CopyableCompactValue value={value} className="text-sm">
{value}
</CopyableCompactValue>
);
}
return <span className="text-sm break-words">{value}</span>;
}
export function CaseFieldValue({
value,
fieldKey,
variant = "full",
}: {
value: unknown;
fieldKey?: string;
variant?: "compact" | "full";
}) {
if (value === null || value === undefined) return <EmptyValue />;
const issueIdentifiers = extractIssueIdentifiers(value, fieldKey);
if (issueIdentifiers.length > 0) return <IssueIdentifierValue identifiers={issueIdentifiers} />;
if (typeof value === "string") return <StringValue value={value} variant={variant} />;
if (typeof value === "number") {
if (!Number.isFinite(value)) return <span className="text-sm">{String(value)}</span>;
if (variant === "compact") {
return (
<CopyableCompactValue value={value} className="text-sm tabular-nums">
{value.toLocaleString()}
</CopyableCompactValue>
);
}
return <span className="text-sm tabular-nums">{value.toLocaleString()}</span>;
}
if (typeof value === "boolean") {
return value ? (
<Check className="h-4 w-4 text-green-600 dark:text-green-400" aria-label="true" />
) : (
<EmptyValue />
);
}
if (Array.isArray(value)) {
if (value.length === 0) return <EmptyValue />;
if (variant === "compact") {
return (
<CopyableCompactValue value={value} className="text-sm text-muted-foreground">
{stringifyCopyValue(value)}
</CopyableCompactValue>
);
}
return (
<div className="flex flex-wrap justify-start gap-1">
{value.map((item, index) => (
<Badge key={index} variant="secondary" className="font-normal">
{typeof item === "string" || typeof item === "number" || typeof item === "boolean"
? String(item)
: JSON.stringify(item)}
</Badge>
))}
</div>
);
}
if (isPlainObject(value)) {
const snippet = JSON.stringify(value);
if (variant === "compact") {
return (
<CopyableCompactValue value={value} className="font-mono text-xs text-muted-foreground">
{snippet}
</CopyableCompactValue>
);
}
return (
<pre className="max-w-full whitespace-pre-wrap break-words font-mono text-xs text-muted-foreground">
{JSON.stringify(value, null, 2)}
</pre>
);
}
return <span className="text-sm">{String(value)}</span>;
}
export function CaseFieldsPanel({ fields }: { fields: Record<string, unknown> }) {
const entries = Object.entries(fields ?? {});
return (
<section className="space-y-2">
<div className="flex items-baseline gap-2">
<h2 className="text-sm font-semibold">Fields</h2>
<span className="text-xs text-muted-foreground">from the skill&apos;s schema rendered generically</span>
</div>
<Card className="gap-0 py-0">
{entries.length === 0 ? (
<div className="px-4 py-3 text-sm text-muted-foreground">No fields set</div>
) : (
<dl className="divide-y divide-border">
{entries.map(([key, value]) => (
<div key={key} className="flex items-start justify-between gap-4 px-4 py-1.5">
<dt className="shrink-0 text-xs text-muted-foreground">{key}</dt>
<dd className="min-w-0 max-w-(--pct-70) text-right">
<CaseFieldValue value={value} fieldKey={key} />
</dd>
</div>
))}
</dl>
)}
</Card>
</section>
);
}

View File

@ -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<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timerRef.current), []);
const handleCopy = useCallback((event: MouseEvent<HTMLButtonElement>) => {
if (stopPropagation) {
event.preventDefault();
event.stopPropagation();
}
void copyTextToClipboard(value).then(() => {
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
});
}, [stopPropagation, value]);
return (
<span className={cn("relative inline-flex min-w-0 max-w-full", containerClassName)}>
<button
type="button"
className={cn(
"min-w-0 cursor-copy text-left transition-colors hover:text-foreground",
truncate ? "truncate" : "whitespace-normal break-all",
className,
)}
title={value}
aria-label={`Copy ${label} ${value}`}
onClick={handleCopy}
>
{value}
</button>
{copied ? (
<span
role="status"
aria-live="polite"
className="pointer-events-none absolute bottom-full left-1/2 mb-1.5 inline-flex -translate-x-1/2 items-center gap-1 rounded-md bg-foreground px-2 py-1 text-xs whitespace-nowrap text-background"
>
<Check className="h-3 w-3 shrink-0" />
Copied
</span>
) : null}
</span>
);
}
export function CaseIdentifierKey({
identifier,
caseKey,
className,
stopPropagation,
}: {
identifier: string;
caseKey?: string | null;
className?: string;
stopPropagation?: boolean;
}) {
return (
<span
className={cn("inline-flex min-w-0 max-w-full items-center gap-2 whitespace-nowrap", className)}
data-case-identity-group="true"
>
<CaseCopyableToken
value={identifier}
label="case ID"
className="shrink-0 font-mono text-xs text-muted-foreground"
containerClassName="shrink-0"
stopPropagation={stopPropagation}
/>
{caseKey ? (
<CaseCopyableToken
value={caseKey}
label="case key"
className="font-mono text-xs text-muted-foreground"
stopPropagation={stopPropagation}
/>
) : null}
</span>
);
}

View File

@ -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<typeof import("@/api/cases")>()),
casesApi: mockCasesApi,
}));
vi.mock("@/components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => <div data-testid="md">{children}</div>,
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
(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(
<QueryClientProvider client={queryClient}>
<CaseRevisionRail caseIdentifier="PAP-C7" documentKey="body" />
</QueryClientProvider>,
);
});
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());
});
});

View File

@ -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 (
<span className="flex flex-wrap items-center gap-x-1 text-(length:--text-micro) text-muted-foreground">
<span>{author}</span>
{revision.issue && (
<>
<span aria-hidden>·</span>
<span>via</span>
<Link
to={`/issues/${revision.issue.identifier}`}
className="font-mono text-foreground/80 hover:underline"
onClick={(e) => e.stopPropagation()}
title={revision.issue.title}
>
{revision.issue.identifier}
</Link>
</>
)}
</span>
);
}
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<string | null>(null);
const [rightRevisionId, setRightRevisionId] = useState<string | null>(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<DiffRow["kind"], string> = {
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<DiffRow["kind"], string> = {
context: " ",
removed: "-",
added: "+",
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-(--pct-90) flex max-h-(--sz-85vh) w-full flex-col overflow-hidden">
<div className="flex items-center justify-between gap-4">
<DialogHeader className="shrink-0">
<DialogTitle>
Diff - <span className="font-mono text-sm">{documentKey}</span>
</DialogTitle>
</DialogHeader>
<div className="flex shrink-0 items-center gap-4">
<div className="flex items-center gap-2">
<span className="rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-(length:--text-nano) font-medium uppercase tracking-(--tracking-caps) text-red-400">Old</span>
<Select value={effectiveLeftId ?? ""} onValueChange={setLeftRevisionId}>
<SelectTrigger className="h-7 w-60 border-border/60 text-xs">
<SelectValue placeholder="Select revision" />
</SelectTrigger>
<SelectContent>
{revisions.map((revision) => (
<SelectItem key={revision.id} value={revision.id} className="text-xs">
{getRevisionLabel(revision)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-(length:--text-nano) font-medium uppercase tracking-(--tracking-caps) text-green-400">New</span>
<Select value={effectiveRightId ?? ""} onValueChange={setRightRevisionId}>
<SelectTrigger className="h-7 w-60 border-border/60 text-xs">
<SelectValue placeholder="Select revision" />
</SelectTrigger>
<SelectContent>
{revisions.map((revision) => (
<SelectItem key={revision.id} value={revision.id} className="text-xs">
{getRevisionLabel(revision)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="flex-1 overflow-auto rounded-md border border-border text-xs">
{!leftRevision || !rightRevision ? (
<div className="p-6 text-center text-sm text-muted-foreground">Select two revisions to compare.</div>
) : leftRevision.id === rightRevision.id ? (
<div className="p-6 text-center text-sm text-muted-foreground">Both sides are the same revision.</div>
) : (
<div className="font-mono text-xs leading-6">
<div className="grid grid-cols-(--gtc-1) border-b border-border/60 bg-muted/30 px-3 py-2 text-(length:--text-micro) uppercase tracking-(--tracking-caps) text-muted-foreground">
<span>Old</span>
<span>New</span>
<span />
<span>Content</span>
</div>
{diffRows.map((row, index) => (
<div
key={`${row.kind}-${index}-${row.oldLineNumber ?? "x"}-${row.newLineNumber ?? "x"}`}
className={cn("grid grid-cols-(--gtc-1) gap-0 border-b border-border/30 px-3", lineClassesByKind[row.kind])}
>
<span className="select-none border-r border-border/30 pr-3 text-right text-muted-foreground">
{row.oldLineNumber ?? ""}
</span>
<span className="select-none border-r border-border/30 px-3 text-right text-muted-foreground">
{row.newLineNumber ?? ""}
</span>
<span className="select-none px-3 text-center text-muted-foreground">
{markerByKind[row.kind]}
</span>
<pre className="overflow-x-auto whitespace-pre-wrap break-words px-3 py-0 text-inherit">
{row.text.length > 0 ? row.text : " "}
</pre>
</div>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
/**
* 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<string | null>(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 <p className="py-6 text-center text-sm text-muted-foreground">Loading revisions</p>;
}
if (revisionsQuery.isError) {
return <p className="py-6 text-center text-sm text-muted-foreground">Could not load revisions.</p>;
}
if (revisions.length === 0) {
return <p className="py-6 text-center text-sm text-muted-foreground">No revisions yet.</p>;
}
const selected = revisions.find((r) => r.id === selectedId) ?? revisions[0]!;
const latestRevisionNumber = revisionsQuery.data?.document.latestRevisionNumber ?? selected.revisionNumber;
return (
<div className="grid gap-4 md:grid-cols-(--gtc-case-revisions)">
<aside className="space-y-1">
<div className="flex items-center justify-between gap-2 px-1">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Revisions
</h3>
{revisions.length > 1 ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs"
onClick={() => setDiffOpen(true)}
>
<Diff className="h-3.5 w-3.5" />
Diff
</Button>
) : null}
</div>
<ol className="space-y-1">
{revisions.map((rev, index) => (
<li key={rev.id}>
<button
type="button"
onClick={() => setSelectedId(rev.id)}
aria-current={rev.id === selected.id}
className={cn(
"w-full rounded-md border px-2.5 py-1.5 text-left transition-colors",
rev.id === selected.id
? "border-primary bg-primary/5"
: "border-border hover:bg-accent/50",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">
rev {rev.revisionNumber}
{index === 0 && (
<span className="ml-1.5 rounded bg-muted px-1 py-0.5 text-(length:--text-nano) text-muted-foreground">
latest
</span>
)}
</span>
<span className="text-(length:--text-micro) text-muted-foreground">{relativeTime(rev.createdAt)}</span>
</div>
{rev.changeSummary && (
<p className="mt-0.5 truncate text-(length:--text-micro) text-muted-foreground" title={rev.changeSummary}>
{rev.changeSummary}
</p>
)}
<RevisionByline revision={rev} />
</button>
</li>
))}
</ol>
</aside>
<Card className="min-w-0 px-4 py-3">
<div className="mb-2 flex items-baseline justify-between border-b border-border pb-2">
<span className="text-sm font-medium">rev {selected.revisionNumber}</span>
<RevisionByline revision={selected} />
</div>
{selected.body ? (
<MarkdownBody linkIssueReferences linkCaseReferences>
{selected.body}
</MarkdownBody>
) : (
<p className="text-sm text-muted-foreground">This revision has no body.</p>
)}
</Card>
{revisions.length > 1 ? (
<CaseDocumentDiffModal
documentKey={documentKey}
revisions={revisions}
latestRevisionNumber={latestRevisionNumber}
open={diffOpen}
onOpenChange={setDiffOpen}
/>
) : null}
</div>
);
}

View File

@ -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 <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
}

View File

@ -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",

View File

@ -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<DocumentRevision[]>;
}) {
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,
});

View File

@ -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<typeof import("@/api/cases")>()),
casesApi: mockCasesApi,
}));
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceApi }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
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(
<QueryClientProvider client={queryClient}>
<IssueCasesPanel issueId="issue-1" />
</QueryClientProvider>,
);
});
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());
});
});

View File

@ -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<CaseLinkRole, string> = {
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 (
<section className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Cases</h3>
<div className="space-y-1">
{links.map((link) => (
<Link
key={link.id}
to={caseHref(link.case.identifier)}
className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5 text-sm transition-colors hover:bg-accent/50"
>
<span className="font-mono text-xs text-muted-foreground shrink-0">{link.case.identifier}</span>
<span className="min-w-0 flex-1 truncate" title={link.case.title}>{link.case.title}</span>
<Badge variant="secondary" className="shrink-0">{ROLE_LABEL[link.role]}</Badge>
<StatusBadge status={link.case.status} />
</Link>
))}
</div>
</section>
);
}

View File

@ -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<IssueChatMessageContext>({
@ -523,6 +525,8 @@ interface IssueChatThreadProps {
*/
onRefreshLatestComments?: () => Promise<unknown> | 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 <SuccessfulRunHandoffCommentCallout text={text} recessed={recessed} onImageClick={onImageClick} />;
}
@ -795,6 +799,7 @@ const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed, onAc
softBreaks
onImageClick={onImageClick}
externalReferences={externalReferences}
linkCaseReferences={linkCaseReferences}
>
{text}
</WorkspaceFileMarkdownBody>
@ -4237,6 +4242,7 @@ export function IssueChatThread({
onResumeFromBacklog,
resumeFromBacklogPending = false,
externalReferences,
linkCaseReferences = false,
}: IssueChatThreadProps) {
const location = useLocation();
const lastScrolledHashRef = useRef<string | null>(null);
@ -4777,6 +4783,7 @@ export function IssueChatThread({
issueStatus,
successfulRunHandoff,
externalReferences,
linkCaseReferences,
}),
[
feedbackDataSharingPreference,
@ -4803,6 +4810,7 @@ export function IssueChatThread({
issueStatus,
successfulRunHandoff,
externalReferences,
linkCaseReferences,
],
);

View File

@ -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;
});

View File

@ -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 })

View File

@ -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<string, string>();
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(
<QueryClientProvider client={queryClient}>
<IssueDocumentsSection
subject={{
id: "case-1",
documentsQueryKey: ["cases", "documents", "case-1"],
idleDocumentRevisionsQueryKey: ["cases", "revisions", "case-1", "__idle__"],
documentRevisionsQueryKey: (key) => ["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
/>
</QueryClientProvider>,
);
});
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();
});
});

View File

@ -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<IssueDocument[]>;
listDocumentRevisions: (key: string) => Promise<DocumentRevision[]>;
getDocument: (key: string) => Promise<IssueDocument>;
upsertDocument: (key: string, data: {
title: string | null;
format: "markdown";
body: string;
baseRevisionId: string | null;
}) => Promise<IssueDocument>;
deleteDocument?: (key: string) => Promise<unknown>;
restoreDocumentRevision?: (key: string, revisionId: string) => Promise<IssueDocument>;
setDocumentLock?: (key: string, locked: boolean) => Promise<IssueDocument>;
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<Issue | undefined>(
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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<DraftState | null>(null);
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(issue.id));
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(documentSubject.id));
const [annotationPanelOpenKeys, setAnnotationPanelOpenKeys] = useState<string[]>(
() => (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<IssueDocument[] | undefined>(
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<Issue | undefined>(
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<string, FeedbackVoteValue>();
@ -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<DocumentRevision[]>(queryKeys.issues.documentRevisions(issue.id, key));
const cached = queryClient.getQueryData<DocumentRevision[]>(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({
</div>
)}
{!hasRealPlan && issue.legacyPlanDocument ? (
{!hasRealPlan && documentSubject.legacyPlanDocument ? (
<div
id="document-plan"
className={cn(
@ -907,7 +984,7 @@ export function IssueDocumentsSection({
</Badge>
</div>
<div className={documentBodyPaddingClassName}>
{renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName, externalReferences)}
{renderFoldableBody(documentSubject.legacyPlanDocument.body, documentBodyContentClassName, externalReferences)}
</div>
</div>
) : 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 (
<div
@ -968,9 +1046,10 @@ export function IssueDocumentsSection({
onSelectRevision: (revisionId) => previewRevision(doc, revisionId),
}}
updatedAt={displayedUpdatedAt}
annotationSlot={!isSystemIssueDocumentKey(doc.key) ? (
annotationSlot={documentSubject.annotations && !isSystemIssueDocumentKey(doc.key) ? (
<DocumentAnnotationsCountChip
issueId={issue.id}
issueId={documentSubject.annotations.issueId}
target={annotationTarget}
docKey={doc.key}
panelOpen={annotationPanelOpenKeys.includes(doc.key)}
onToggle={() => toggleAnnotationPanel(doc.key)}
@ -1193,24 +1272,8 @@ export function IssueDocumentsSection({
activeDraft || isHistoricalPreview ? "" : "rounded-md hover:bg-accent/10"
}`}
>
<IssueDocumentAnnotations
issueId={issue.id}
doc={doc}
bodyMarkdown={displayedBody}
draftDirty={Boolean(activeDraft) && (
(activeDraft?.body ?? doc.body) !== doc.body
|| (autosaveDocumentKey === doc.key && autosaveState === "saving")
)}
draftConflicted={Boolean(activeConflict)}
historicalPreview={isHistoricalPreview}
locationHash={location.hash}
panelOpen={annotationPanelOpenKeys.includes(doc.key)}
onPanelOpenChange={(next) => setAnnotationPanelOpen(doc.key, next)}
agentMap={agentMap}
userProfileMap={userProfileMap}
defaultFocusedThreadId={defaultAnnotationFocusedThreadIds?.[doc.key]}
>
{isHistoricalPreview ? (
{(() => {
const renderedDocumentBody = isHistoricalPreview ? (
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences)
) : activeDraft ? (
<MarkdownEditor
@ -1234,8 +1297,31 @@ export function IssueDocumentsSection({
/>
) : (
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences)
)}
</IssueDocumentAnnotations>
);
return documentSubject.annotations ? (
<IssueDocumentAnnotations
issueId={documentSubject.annotations.issueId}
target={annotationTarget}
doc={doc}
bodyMarkdown={displayedBody}
draftDirty={Boolean(activeDraft) && (
(activeDraft?.body ?? doc.body) !== doc.body
|| (autosaveDocumentKey === doc.key && autosaveState === "saving")
)}
draftConflicted={Boolean(activeConflict)}
historicalPreview={isHistoricalPreview}
locationHash={location.hash}
panelOpen={annotationPanelOpenKeys.includes(doc.key)}
onPanelOpenChange={(next) => setAnnotationPanelOpen(doc.key, next)}
agentMap={agentMap}
userProfileMap={userProfileMap}
defaultFocusedThreadId={defaultAnnotationFocusedThreadIds?.[doc.key]}
>
{renderedDocumentBody}
</IssueDocumentAnnotations>
) : renderedDocumentBody;
})()}
</div>
<div className="flex min-h-4 items-center justify-end px-1">
<span
@ -1314,9 +1400,10 @@ export function IssueDocumentsSection({
if (!diffDoc) return null;
return (
<DocumentDiffModal
issueId={issue.id}
documentKey={diffDoc.key}
latestRevisionNumber={diffDoc.latestRevisionNumber}
revisionsQueryKey={documentSubject.documentRevisionsQueryKey(diffDoc.key)}
revisionsQueryFn={() => documentSubject.listDocumentRevisions(diffDoc.key)}
open
onOpenChange={(open) => { if (!open) setDiffViewKey(null); }}
/>

View File

@ -134,6 +134,7 @@ vi.mock("./AgentIconPicker", () => ({
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => <a href={to} {...props}>{children}</a>,
useCaseHref: () => (caseId: string) => `/cases/${caseId}`,
}));
vi.mock("@/components/ui/separator", () => ({

View File

@ -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 (
<Link
to={caseHref(identifier)}
data-mention-kind="case"
className={cn("paperclip-markdown-case-ref", "font-normal underline")}
aria-label={`Case ${identifier}`}
>
{children}
</Link>
);
}
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<Components>(() => {
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 <MarkdownCaseLink identifier={caseIdentifier}>{linkChildren}</MarkdownCaseLink>;
}
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 (
<div

View File

@ -10,6 +10,7 @@ import {
Network,
Boxes,
Repeat,
Layers,
GitBranch,
Package,
Settings,
@ -78,6 +79,7 @@ export function Sidebar() {
const showPipelines = experimentalSettings?.enablePipelines === true;
const goalsLinkPending = experimentalSettings === undefined;
const showGoalsLink = experimentalSettings?.enableGoalsSidebarLink === true;
const showCases = experimentalSettings?.enableCases === true;
// Streamlined left navigation (top-level Projects link + starred children) is
// now the standard product sidebar (PAP-12472). The former experimental
// opt-out was retired; classic per-project collapsible mode is no longer
@ -195,6 +197,9 @@ export function Sidebar() {
<SidebarSection label="Work" collapsible={{ open: workOpen, onOpenChange: setWorkOpen }}>
<SidebarNavItem to="/issues" label="Tasks" icon={CircleDot} />
{showCases ? (
<SidebarNavItem to="/cases" label="Cases" icon={Layers} textBadge="beta" />
) : null}
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
{showPipelines ? (
<SidebarNavItem to="/pipelines" label="Pipelines" icon={GitBranch} />

View File

@ -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({
</PropertyRow>
)}
</PropertySection>
{/* Experimental Cases rail (PAP-12969) self-gates on the flag and
renders nothing when no cases are linked. */}
<div className="pt-3">
<IssueCasesPanel issueId={issue.id} />
</div>
</div>
);
}

View File

@ -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 = {

View File

@ -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 });
}

View File

@ -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]). */

View File

@ -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");
});
});

View File

@ -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<n>` infix keeps case tokens from ever
// colliding with plain issue identifiers (`PREFIX-<n>`), 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<string>,
): { 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<string>): 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<string>) {
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);
};
}

View File

@ -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 unprefixedprefixed 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);

View File

@ -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,

View File

@ -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<typeof RouterDom.Link> & {

View File

@ -104,6 +104,11 @@ export const statusBadge: Record<string, string> = {
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",

View File

@ -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<typeof import("@/api/cases")>()),
casesApi: mockCasesApi,
}));
vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("@/lib/clipboard", () => ({ copyTextToClipboard: mockCopyTextToClipboard }));
vi.mock("@/components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => <div data-testid="md">{children}</div>,
}));
vi.mock("@/components/IssueDocumentsSection", () => ({
IssueDocumentsSection: ({ subject }: { subject?: { id: string } }) => (
<div data-testid="case-documents-section">Documents {subject?.id}</div>
),
}));
vi.mock("@/lib/router", () => ({
useParams: () => ({ caseIdentifier: "PAP-C7" }),
useLocation: () => ({ hash: "" }),
Navigate: () => null,
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
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(
<QueryClientProvider client={queryClient}>
<CaseDetail />
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
{panelState.openPanel.mock.calls.at(-1)?.[0]}
</QueryClientProvider>,
);
});
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());
});
});

719
ui/src/pages/CaseDetail.tsx Normal file
View File

@ -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<CaseStatus, string> = {
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<string, unknown>, 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 (
<section className="space-y-3" aria-label="Case relationships">
{parent ? (
<div className="space-y-1">
<h2 className="text-xs font-medium text-muted-foreground">Parent</h2>
<CaseChildrenTree children={[parent]} />
</div>
) : null}
{children.length > 0 ? (
<div className="space-y-1">
<h2 className="text-xs font-medium text-muted-foreground">Children {children.length}</h2>
<CaseChildrenTree children={children} maxVisible={5} />
</div>
) : null}
</section>
);
}
function CasePropertyRow({
label,
children,
wrap,
mode,
}: {
label: string;
children: ReactNode;
wrap?: boolean;
mode: CasePropertyDisplayMode;
}) {
if (mode === "compact") {
return (
<PropertyRow label={label} wrap={wrap}>
{children}
</PropertyRow>
);
}
return (
<div
className={cn(
"flex w-full min-w-0 gap-3 py-1",
wrap ? "items-start" : "items-center",
)}
data-property-row="true"
>
<span
className={cn(
"w-40 shrink-0 break-words text-xs text-muted-foreground",
wrap && "mt-0.5",
)}
data-property-label={label}
>
{label}
</span>
<div className={cn("flex min-w-0 flex-1 items-center gap-1.5", wrap && "flex-wrap")}>{children}</div>
</div>
);
}
/** 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className="inline-flex items-center gap-1 rounded-md hover:bg-accent/50 disabled:opacity-50"
aria-label="Change case status"
>
<StatusBadge status={status} />
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-1">
{CASE_STATUSES.map((s) => (
<button
key={s}
type="button"
onClick={() => {
setOpen(false);
if (s !== status) onChange(s);
}}
className="flex w-full items-center justify-between rounded px-2 py-1.5 text-left hover:bg-accent"
>
<StatusBadge status={s} />
{s === status && <Check className="h-4 w-4 text-muted-foreground" />}
</button>
))}
</PopoverContent>
</Popover>
);
}
/** 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<string>(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<IssueLabel[]>(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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs text-muted-foreground">
<Plus className="h-3.5 w-3.5" /> Labels
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search labels…"
className="mb-2 h-7 text-xs"
/>
<div className="max-h-52 space-y-0.5 overflow-y-auto">
{filtered.map((l) => (
<button
key={l.id}
type="button"
onClick={() => toggle(l.id)}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-accent"
>
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: l.color }} />
<span className="flex-1 truncate">{l.name}</span>
{selectedIds.has(l.id) && <Check className="h-4 w-4 text-muted-foreground" />}
</button>
))}
{filtered.length === 0 && !search.trim() && (
<p className="px-2 py-1 text-xs text-muted-foreground">No labels yet.</p>
)}
</div>
{search.trim() && !all.some((l) => l.name.toLowerCase() === search.trim().toLowerCase()) && (
<div className="mt-2 flex items-center gap-2 border-t border-border pt-2">
<input
type="color"
value={newColor}
onChange={(e) => setNewColor(e.target.value)}
className="h-6 w-6 shrink-0 cursor-pointer rounded border border-border bg-transparent"
aria-label="New label color"
/>
<Button
size="sm"
variant="secondary"
className="h-7 flex-1 text-xs"
disabled={createLabel.isPending}
onClick={() => createLabel.mutate({ name: search.trim(), color: newColor })}
>
Create {search.trim()}
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
/** 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 (
<div className={cn("space-y-4", isFull && "space-y-6")}>
<PropertySection title="Case" first>
<CasePropertyRow label="Type" mode={mode}>
<PropertyChip>{caseData.caseType}</PropertyChip>
</CasePropertyRow>
{caseData.key ? (
<CasePropertyRow label="Key" mode={mode}>
<CaseCopyableToken
value={caseData.key}
label="case key"
className="font-mono text-xs text-muted-foreground"
truncate={!isFull}
/>
</CasePropertyRow>
) : null}
<CasePropertyRow label="Labels" wrap mode={mode}>
{caseData.labels.length > 0 ? (
caseData.labels.map((label) => (
<PropertyChip
key={label.id}
style={{ borderColor: label.color, color: label.color }}
className="bg-transparent"
>
{label.name}
</PropertyChip>
))
) : (
<span className="text-xs text-muted-foreground">None</span>
)}
{companyId ? (
<CaseLabelsPicker
companyId={companyId}
selected={caseData.labels}
onChange={onLabelIdsChange}
/>
) : null}
{labelsPending ? <span className="text-xs text-muted-foreground">Saving...</span> : null}
</CasePropertyRow>
</PropertySection>
{propertyRows.length > 0 ? (
<PropertySection title="Fields">
{propertyRows.map(({ key, label, value }) => (
<CasePropertyRow
key={key}
label={label}
wrap={isFull || Array.isArray(value) || (typeof value === "object" && value !== null)}
mode={mode}
>
<span className={cn("min-w-0 text-sm", !isFull && "truncate")}>
<CaseFieldValue value={value} fieldKey={key} variant={mode} />
</span>
</CasePropertyRow>
))}
</PropertySection>
) : null}
<PropertySection title="Linked tasks">
{caseData.issueLinks.length === 0 ? (
<CasePropertyRow label="Tasks" mode={mode}>
<span className="text-xs text-muted-foreground">None yet</span>
</CasePropertyRow>
) : (
<CasePropertyRow label="Tasks" wrap mode={mode}>
<div className="flex flex-wrap items-center gap-1.5">
{caseData.issueLinks.map((link) => (
<IssueReferencePill
key={link.id}
issue={{
id: link.issue.id,
identifier: link.issue.identifier,
title: link.issue.title,
status: issueReferenceStatus(link.issue.status),
}}
/>
))}
</div>
</CasePropertyRow>
)}
</PropertySection>
<PropertySection title={`Children${childCases.length > 0 ? ` ${childCases.length}` : ""}`}>
<CaseChildrenTree children={childCases} />
</PropertySection>
{caseData.attachments.length > 0 ? (
<PropertySection title="Attachments">
<CasePropertyRow label="Files" mode={mode}>
<span className="text-xs text-muted-foreground">
{caseData.attachments.length} {caseData.attachments.length === 1 ? "file" : "files"}
</span>
</CasePropertyRow>
</PropertySection>
) : null}
</div>
);
}
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<CaseDetailData>(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<CaseDetailData | undefined>(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 (
<CasePropertiesContent
caseData={caseData}
childCases={children}
companyId={selectedCompanyId}
labelsPending={patchMutation.isPending}
onLabelIdsChange={handleLabelIdsChange}
mode="compact"
/>
);
}, [caseData, children, selectedCompanyId, patchMutation.isPending, handleLabelIdsChange]);
useEffect(() => {
if (!panelContent) return;
openPanel(panelContent);
return () => closePanel();
}, [panelContent, openPanel, closePanel]);
if (!caseIdentifier) return <Navigate to={caseHref()} replace />;
if (caseQuery.isLoading) return <PageSkeleton variant="detail" />;
if (caseQuery.isError || !caseData) {
return (
<div className="mx-auto max-w-md py-16 text-center">
<p className="text-sm text-muted-foreground">Case not found.</p>
<Link to={caseHref()} className="mt-2 inline-block text-sm text-primary hover:underline">
Back to cases
</Link>
</div>
);
}
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 (
<div className="mx-auto max-w-3xl space-y-6">
<header className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<CaseIdentifierKey identifier={caseData.identifier} caseKey={caseData.key} />
<h1 className="text-xl font-bold">{caseData.title}</h1>
</div>
<div className="flex shrink-0 items-center gap-1">
<CaseStatusPicker
status={caseData.status}
disabled={patchMutation.isPending}
onChange={(status) => patchMutation.mutate({ status })}
/>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon-xs" aria-label="More case actions" title="More case actions">
<MoreVertical className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-48 p-1">
<button
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50"
onClick={() => copyCaseToClipboard(caseData)}
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
Copy as markdown
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50"
onClick={() => {
if (panelContent) openPanel(panelContent);
}}
>
<SlidersHorizontal className="h-3 w-3" />
Properties
</button>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{caseData.caseType}</Badge>
</div>
<CaseRelationshipsSection parent={caseData.parent} children={children} />
</header>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList variant="line" className="w-full justify-start gap-1">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="properties">Properties</TabsTrigger>
<TabsTrigger value="activity">
Activity{events.length > 0 && <span className="ml-1 text-muted-foreground">{events.length}</span>}
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
{caseDocumentSubject ? (
<IssueDocumentsSection
subject={caseDocumentSubject}
canDeleteDocuments
canManageDocumentLocks
/>
) : null}
{description ? (
<section className="space-y-2">
<h2 className="text-sm font-semibold">Description</h2>
<Card className="px-4 py-3">
<CaseFieldValue value={description} />
</Card>
</section>
) : null}
{caseData.attachments.length > 0 && (
<section className="space-y-2">
<h2 className="text-sm font-semibold">Attachments ({caseData.attachments.length})</h2>
<CaseAttachmentsGallery attachments={caseData.attachments} />
</section>
)}
</TabsContent>
<TabsContent value="properties">
<CasePropertiesContent
caseData={caseData}
childCases={children}
companyId={selectedCompanyId}
labelsPending={patchMutation.isPending}
onLabelIdsChange={handleLabelIdsChange}
mode="full"
/>
</TabsContent>
<TabsContent value="activity">
<CaseActivityFeed events={events} />
</TabsContent>
</Tabs>
</div>
);
}

599
ui/src/pages/Cases.test.tsx Normal file
View File

@ -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<typeof import("@/api/cases")>()),
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<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
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>): 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(
<QueryClientProvider client={queryClient}>
<Cases />
</QueryClientProvider>,
);
});
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<HTMLInputElement>("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<HTMLElement>("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<HTMLButtonElement>('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<HTMLButtonElement>('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());
});
});

1427
ui/src/pages/Cases.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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], {

View File

@ -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<SkillCreateDraft>(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<SkillCreateDraft>) {
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 (
<div className="space-y-4">
<div className="flex items-center gap-2 border-b border-border pb-3">
{steps.map((label, index) => (
<button
key={label}
type="button"
onClick={() => setStep(index)}
className={cn(
"rounded-md px-2 py-1 text-xs",
step === index ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
))}
</div>
{draft.forkedFromName ? (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<GitFork className="h-3.5 w-3.5" />
Forking {draft.forkedFromName}
</div>
) : null}
{step === 0 ? (
<div className="space-y-3">
<Input
value={draft.name}
onChange={(event) => {
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"
/>
<Input
value={draft.slug}
onChange={(event) => {
const nextSlug = normalizeSkillDraftSlug(event.target.value);
setSlugDirty(nextSlug.length > 0);
patchDraft({ slug: nextSlug });
}}
placeholder="skill-shortname"
className="h-9 font-mono"
/>
<Textarea
value={draft.tagline}
onChange={(event) => {
const nextTagline = event.target.value;
patchDraft({
tagline: nextTagline,
description: draft.description ? draft.description : nextTagline,
markdown: draft.markdown === defaultSkillMarkdown(draft.name, draft.tagline)
? defaultSkillMarkdown(draft.name, nextTagline)
: draft.markdown,
});
}}
placeholder="One-line promise for the skill"
className="min-h-20"
/>
</div>
) : step === 1 ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<SkillCardIcon
size={48}
card={{
key: effectiveSlug || draft.name || "new-skill",
name: draft.name || "New Skill",
slug: effectiveSlug || "skill",
iconUrl: null,
color: draft.color,
}}
/>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{draft.name || "New Skill"}</div>
<div className="truncate text-xs text-muted-foreground">{draft.tagline || "No tagline yet."}</div>
</div>
</div>
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-muted-foreground">Color</label>
<div className="flex flex-wrap gap-2">
{SKILL_CREATE_ACCENTS.map((color) => (
<button
key={color}
type="button"
onClick={() => patchDraft({ color })}
className={cn(
"h-7 w-7 rounded-md border",
draft.color === color ? "border-foreground" : "border-border",
)}
style={{ backgroundColor: color }}
aria-label={`Use ${color}`}
/>
))}
<Input
value={draft.color}
onChange={(event) => patchDraft({ color: event.target.value })}
className="h-7 w-28 font-mono text-xs"
/>
</div>
</div>
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-muted-foreground">Categories</label>
<Input
value={categoryDraft}
onChange={(event) => patchDraft({ categories: splitCategoryDraft(event.target.value) })}
placeholder="engineering, review, memory"
className="h-9"
/>
</div>
</div>
) : step === 2 ? (
<div className="space-y-2">
<Textarea
value={draft.markdown}
onChange={(event) => patchDraft({ markdown: event.target.value })}
className="h-(--sz-calc-34) resize-y font-mono text-xs"
/>
</div>
) : (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-(--gtc-26) gap-y-2">
<span className="text-muted-foreground">Name</span>
<span>{draft.name || "Untitled"}</span>
<span className="text-muted-foreground">Slug</span>
<span className="font-mono">{effectiveSlug || "skill"}</span>
<span className="text-muted-foreground">Scope</span>
<span>{draft.sharingScope === "private" ? "Private" : "Company"}</span>
<span className="text-muted-foreground">Categories</span>
<span>{draft.categories.length ? draft.categories.join(", ") : "none"}</span>
</div>
<div className="space-y-2">
<label className="block text-xs font-medium uppercase tracking-wide text-muted-foreground">Sharing</label>
<div className="grid gap-2 sm:grid-cols-3">
{(["company", "private"] as const).map((scope) => (
<button
key={scope}
type="button"
onClick={() => patchDraft({ sharingScope: scope })}
className={cn(
"rounded-md border px-3 py-2 text-left text-sm",
draft.sharingScope === scope ? "border-foreground bg-accent/50" : "border-border",
)}
>
<span className="block font-medium">{scope === "company" ? "Company" : "Private"}</span>
<span className="mt-1 block text-xs text-muted-foreground">
{scope === "company" ? "Visible inside this company." : "Only visible in your library."}
</span>
</button>
))}
<button
type="button"
disabled
className="rounded-md border border-dashed border-border px-3 py-2 text-left text-sm text-muted-foreground"
>
<span className="block font-medium">Public link</span>
<span className="mt-1 block text-xs">Coming later.</span>
</button>
</div>
</div>
</div>
)}
{error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<div className="flex items-center justify-between gap-2 border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onCancel} disabled={isPending}>
Cancel
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setStep((value) => Math.max(0, value - 1))} disabled={isPending || step === 0}>
Back
</Button>
{step < steps.length - 1 ? (
<Button size="sm" onClick={() => setStep((value) => Math.min(steps.length - 1, value + 1))} disabled={!nameValid}>
Next
</Button>
) : (
<Button size="sm" onClick={submit} disabled={isPending || !nameValid}>
{isPending ? "Creating..." : draft.forkedFromSkillId ? "Create fork" : "Create skill"}
</Button>
)}
</div>
</div>
</div>
);
}
function CatalogList({
skills,
kindFilter,
@ -2406,6 +2649,17 @@ export function SkillDetailPage({
<Pencil className="mr-1.5 h-3.5 w-3.5" /> Edit
</Button>
)
) : !skill.editable ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={onFork}
title={skill.editableReason ?? "Fork this skill to edit it."}
>
<GitFork className="mr-1.5 h-3.5 w-3.5" />
Fork
</Button>
) : null}
</div>
</div>
@ -2463,7 +2717,19 @@ export function SkillDetailPage({
</div>
<div className="min-w-0 border-b border-border py-2">
<div className="text-xs text-muted-foreground">Mode</div>
<div className="mt-1">{skill.editable ? "Editable" : skill.editableReason ?? "Read only"}</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
{skill.editable ? (
"Editable"
) : (
<>
<span>Read only</span>
<Button type="button" variant="outline" size="xs" onClick={onFork}>
<GitFork className="mr-1 h-3 w-3" />
Fork
</Button>
</>
)}
</div>
</div>
</section>
</div>
@ -3340,9 +3606,11 @@ export function CompanySkills() {
}>({ open: false, catalogSkill: null, conflict: null, defaultSlug: null, defaultForce: false, defaultAction: "install", error: null });
const [discoverySearch, setDiscoverySearch] = useState("");
const [discoverySort, setDiscoverySort] = useState<DiscoverySort>("agents");
const [createError, setCreateError] = useState<string | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]);
const routeSkillToken = parsedRoute.skillToken;
const isStudioNew = routePath === "studio/new";
const routeSkillToken = isStudioNew ? null : parsedRoute.skillToken;
const selectedPath = parsedRoute.filePath;
const viewParam = searchParams.get("view");
const activeView: "installed" | "catalog" = viewParam === "catalog" ? "catalog" : "installed";
@ -3361,9 +3629,10 @@ export function CompanySkills() {
? "files"
: "overview";
const discoveryCategory = searchParams.get("category");
const studioForkFromId = isStudioNew ? searchParams.get("forkFrom")?.trim() || null : null;
// Discovery grid owns `/skills` whenever no specific skill or catalog entry is
// selected; selecting either drops into the existing master/detail surfaces.
const isDiscovery = !routeSkillToken && !selectedCatalogRef;
const isDiscovery = !isStudioNew && !routeSkillToken && !selectedCatalogRef;
function setDiscoveryTab(tab: DiscoveryTab) {
setSearchParams((current) => {
@ -3412,12 +3681,17 @@ export function CompanySkills() {
setCatalogSelectedPath(path);
}
useEffect(() => {
if (!isStudioNew) return;
setCreateError(null);
}, [isStudioNew, studioForkFromId]);
useEffect(() => {
setBreadcrumbs([
{ label: "Skills", href: "/skills" },
...(routeSkillToken ? [{ label: "Detail" }] : []),
...(isStudioNew ? [{ label: studioForkFromId ? "Fork skill" : "New skill" }] : routeSkillToken ? [{ label: "Detail" }] : []),
]);
}, [routeSkillToken, setBreadcrumbs]);
}, [isStudioNew, routeSkillToken, setBreadcrumbs, studioForkFromId]);
// The old split catalog view no longer exists — catalog/bundled skills now open
// as a regular full page keyed by `?catalog=<ref>`. Strip the legacy `view`
@ -3468,6 +3742,20 @@ export function CompanySkills() {
enabled: Boolean(selectedCompanyId && selectedSkillId),
});
const studioForkDetailQuery = useQuery({
queryKey: queryKeys.companySkills.detail(selectedCompanyId ?? "", studioForkFromId ?? ""),
queryFn: () => companySkillsApi.detail(selectedCompanyId!, studioForkFromId!),
enabled: Boolean(selectedCompanyId && isStudioNew && studioForkFromId),
});
const studioDraft = useMemo(() => {
if (!isStudioNew) return buildBlankSkillDraft();
if (studioForkFromId) {
return studioForkDetailQuery.data ? buildForkSkillDraft(studioForkDetailQuery.data) : buildBlankSkillDraft();
}
return buildBlankSkillDraft();
}, [isStudioNew, studioForkDetailQuery.data, studioForkFromId]);
const updateStatusQuery = useQuery({
queryKey: queryKeys.companySkills.updateStatus(selectedCompanyId ?? "", selectedSkillId ?? ""),
queryFn: () => companySkillsApi.updateStatus(selectedCompanyId!, selectedSkillId!),
@ -3625,6 +3913,30 @@ export function CompanySkills() {
},
});
const createSkill = useMutation({
mutationFn: (payload: CompanySkillCreateRequest) => companySkillsApi.create(selectedCompanyId!, payload),
onSuccess: async (skill) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
navigate(routeForSkill(skill));
setCreateError(null);
pushToast({
tone: "success",
title: skill.forkedFromSkillId ? "Skill fork created" : "Skill created",
body: `${skill.name} is now editable in the Paperclip workspace.`,
});
},
onError: (error) => {
const message = error instanceof Error ? error.message : "Failed to create skill.";
setCreateError(message);
pushToast({
tone: "error",
title: "Skill creation failed",
body: message,
});
},
});
const saveFile = useMutation({
mutationFn: () => companySkillsApi.updateFile(
selectedCompanyId!,
@ -4030,6 +4342,11 @@ export function CompanySkills() {
const catalogSourceForDetail = activeDetail
? (catalogListQuery.data ?? []).find((entry) => entry.key === activeDetail.key)?.source ?? null
: null;
const studioBackHref = studioForkDetailQuery.data ? routeForSkill(studioForkDetailQuery.data) : "/skills";
const studioTitle = studioForkFromId ? "Fork skill" : "Create a new skill";
const studioDescription = studioForkFromId
? "Review the fork metadata and create an editable company copy."
: "Create an editable company skill in the Paperclip workspace.";
return (
<>
@ -4193,7 +4510,38 @@ export function CompanySkills() {
</DialogContent>
</Dialog>
{isDiscovery ? (
{isStudioNew ? (
<div className="min-h-(--sz-calc-30)">
<div className="border-b border-border px-4 py-5">
<Link
to={studioBackHref}
className="mb-3 inline-flex items-center gap-1.5 text-sm text-muted-foreground no-underline transition-colors hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" />
Back
</Link>
<h1 className="text-2xl font-semibold">{studioTitle}</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">{studioDescription}</p>
</div>
<div className="px-4 py-4">
<div className="max-w-3xl">
{studioForkFromId && studioForkDetailQuery.isLoading ? (
<PageSkeleton variant="detail" />
) : studioForkFromId && !studioForkDetailQuery.data ? (
<EmptyState icon={Boxes} message="Fork source skill not found." />
) : (
<NewSkillWizard
initialDraft={studioDraft}
onCreate={(payload) => createSkill.mutate(payload)}
isPending={createSkill.isPending}
error={createError}
onCancel={() => navigate(studioBackHref)}
/>
)}
</div>
</div>
</div>
) : isDiscovery ? (
<DiscoveryGrid
tab={discoveryTab}
tabCounts={discoveryTabCounts}

View File

@ -57,6 +57,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: true,
enablePipelines: false,
enableCases: false,
enableConferenceRoomChat: false,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,

View File

@ -11,6 +11,7 @@ import { isWorktreeRuntime } from "../lib/worktree-branding";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@ -249,6 +250,7 @@ export function InstanceExperimentalSettings() {
const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true;
const enableBuiltInAgents = experimentalQuery.data?.enableBuiltInAgents === true;
const enableGoalsSidebarLink = experimentalQuery.data?.enableGoalsSidebarLink === true;
const enableCases = experimentalQuery.data?.enableCases === true;
const enableServerInfoDebugView = experimentalQuery.data?.enableServerInfoDebugView === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
const enableIssueGraphLivenessAutoRecovery =
@ -346,6 +348,30 @@ export function InstanceExperimentalSettings() {
</Card>
) : null}
<Card className="block p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold">Cases</h2>
<Badge variant="secondary">Experimental</Badge>
</div>
<p className="max-w-2xl text-sm text-muted-foreground">
Durable work products (blog posts, tweet storms) that tasks create and iterate on. Adds the
Cases tab and the agent case API.
</p>
<p className="max-w-2xl text-xs text-muted-foreground">
Turning Cases off hides the tab and blocks the case API; existing case data is kept.
</p>
</div>
<ToggleSwitch
checked={enableCases}
onCheckedChange={() => toggleMutation.mutate({ enableCases: !enableCases })}
disabled={toggleMutation.isPending}
aria-label="Toggle cases experimental setting"
/>
</div>
</Card>
<Card className="block p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">

View File

@ -921,6 +921,7 @@ type IssueDetailChatTabProps = {
onResumeFromBacklog?: () => Promise<void> | void;
resumeFromBacklogPending?: boolean;
externalReferences?: MarkdownExternalReferenceMap;
linkCaseReferences?: boolean;
};
const IssueDetailChatTab = memo(function IssueDetailChatTab({
@ -992,6 +993,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onResumeFromBacklog,
resumeFromBacklogPending,
externalReferences,
linkCaseReferences,
}: IssueDetailChatTabProps) {
const ThreadComponent = IssueChatThread;
const { data: activity } = useQuery({
@ -1222,6 +1224,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
resumeFromBacklogPending={resumeFromBacklogPending}
footer={footer}
externalReferences={externalReferences}
linkCaseReferences={linkCaseReferences}
/>
</div>
);
@ -1754,6 +1757,8 @@ export function IssueDetail() {
retry: false,
});
const keyboardShortcutsEnabled = instanceGeneralSettings?.keyboardShortcuts === true;
// Experimental Cases: linkify `PAP-C7` chips in this issue's comment bodies.
const casesChipsEnabled = instanceExperimentalSettings?.enableCases === true;
const feedbackDataSharingPreference = instanceGeneralSettings?.feedbackDataSharingPreference ?? "prompt";
const showPlanDecompositionsSection =
instanceExperimentalSettings?.enableIssuePlanDecompositions === true;
@ -4743,6 +4748,7 @@ export function IssueDetail() {
updateIssue.isPending && updateIssue.variables?.status === "todo"
}
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
linkCaseReferences={casesChipsEnabled}
/>
) : null}
</TabsContent>