feat(issues): add issue documents and inline editing
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -83,6 +83,7 @@ type EmbeddedPostgresCtor = new (opts: {
|
|||||||
password: string;
|
password: string;
|
||||||
port: number;
|
port: number;
|
||||||
persistent: boolean;
|
persistent: boolean;
|
||||||
|
initdbFlags?: string[];
|
||||||
onLog?: (message: unknown) => void;
|
onLog?: (message: unknown) => void;
|
||||||
onError?: (message: unknown) => void;
|
onError?: (message: unknown) => void;
|
||||||
}) => EmbeddedPostgresInstance;
|
}) => EmbeddedPostgresInstance;
|
||||||
|
|||||||
@@ -330,6 +330,34 @@ Operational policy:
|
|||||||
- `asset_id` uuid fk not null
|
- `asset_id` uuid fk not null
|
||||||
- `issue_comment_id` uuid fk null
|
- `issue_comment_id` uuid fk null
|
||||||
|
|
||||||
|
## 7.15 `documents` + `document_revisions` + `issue_documents`
|
||||||
|
|
||||||
|
- `documents` stores editable text-first documents:
|
||||||
|
- `id` uuid pk
|
||||||
|
- `company_id` uuid fk not null
|
||||||
|
- `title` text null
|
||||||
|
- `format` text not null (`markdown`)
|
||||||
|
- `latest_body` text not null
|
||||||
|
- `latest_revision_id` uuid null
|
||||||
|
- `latest_revision_number` int not null
|
||||||
|
- `created_by_agent_id` uuid fk null
|
||||||
|
- `created_by_user_id` uuid/text fk null
|
||||||
|
- `updated_by_agent_id` uuid fk null
|
||||||
|
- `updated_by_user_id` uuid/text fk null
|
||||||
|
- `document_revisions` stores append-only history:
|
||||||
|
- `id` uuid pk
|
||||||
|
- `company_id` uuid fk not null
|
||||||
|
- `document_id` uuid fk not null
|
||||||
|
- `revision_number` int not null
|
||||||
|
- `body` text not null
|
||||||
|
- `change_summary` text null
|
||||||
|
- `issue_documents` links documents to issues with a stable workflow key:
|
||||||
|
- `id` uuid pk
|
||||||
|
- `company_id` uuid fk not null
|
||||||
|
- `issue_id` uuid fk not null
|
||||||
|
- `document_id` uuid fk not null
|
||||||
|
- `key` text not null (`plan`, `design`, `notes`, etc.)
|
||||||
|
|
||||||
## 8. State Machines
|
## 8. State Machines
|
||||||
|
|
||||||
## 8.1 Agent Status
|
## 8.1 Agent Status
|
||||||
@@ -441,6 +469,11 @@ All endpoints are under `/api` and return JSON.
|
|||||||
- `POST /companies/:companyId/issues`
|
- `POST /companies/:companyId/issues`
|
||||||
- `GET /issues/:issueId`
|
- `GET /issues/:issueId`
|
||||||
- `PATCH /issues/:issueId`
|
- `PATCH /issues/:issueId`
|
||||||
|
- `GET /issues/:issueId/documents`
|
||||||
|
- `GET /issues/:issueId/documents/:key`
|
||||||
|
- `PUT /issues/:issueId/documents/:key`
|
||||||
|
- `GET /issues/:issueId/documents/:key/revisions`
|
||||||
|
- `DELETE /issues/:issueId/documents/:key`
|
||||||
- `POST /issues/:issueId/checkout`
|
- `POST /issues/:issueId/checkout`
|
||||||
- `POST /issues/:issueId/release`
|
- `POST /issues/:issueId/release`
|
||||||
- `POST /issues/:issueId/comments`
|
- `POST /issues/:issueId/comments`
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: Issues
|
title: Issues
|
||||||
summary: Issue CRUD, checkout/release, comments, and attachments
|
summary: Issue CRUD, checkout/release, comments, documents, and attachments
|
||||||
---
|
---
|
||||||
|
|
||||||
Issues are the unit of work in Paperclip. They support hierarchical relationships, atomic checkout, comments, and file attachments.
|
Issues are the unit of work in Paperclip. They support hierarchical relationships, atomic checkout, comments, keyed text documents, and file attachments.
|
||||||
|
|
||||||
## List Issues
|
## List Issues
|
||||||
|
|
||||||
@@ -29,6 +29,12 @@ GET /api/issues/{issueId}
|
|||||||
|
|
||||||
Returns the issue with `project`, `goal`, and `ancestors` (parent chain with their projects and goals).
|
Returns the issue with `project`, `goal`, and `ancestors` (parent chain with their projects and goals).
|
||||||
|
|
||||||
|
The response also includes:
|
||||||
|
|
||||||
|
- `planDocument`: the full text of the issue document with key `plan`, when present
|
||||||
|
- `documentSummaries`: metadata for all linked issue documents
|
||||||
|
- `legacyPlanDocument`: a read-only fallback when the description still contains an old `<plan>` block
|
||||||
|
|
||||||
## Create Issue
|
## Create Issue
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -100,6 +106,54 @@ POST /api/issues/{issueId}/comments
|
|||||||
|
|
||||||
@-mentions (`@AgentName`) in comments trigger heartbeats for the mentioned agent.
|
@-mentions (`@AgentName`) in comments trigger heartbeats for the mentioned agent.
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
Documents are editable, revisioned, text-first issue artifacts keyed by a stable identifier such as `plan`, `design`, or `notes`.
|
||||||
|
|
||||||
|
### List
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/issues/{issueId}/documents
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get By Key
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/issues/{issueId}/documents/{key}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Or Update
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/issues/{issueId}/documents/{key}
|
||||||
|
{
|
||||||
|
"title": "Implementation plan",
|
||||||
|
"format": "markdown",
|
||||||
|
"body": "# Plan\n\n...",
|
||||||
|
"baseRevisionId": "{latestRevisionId}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- omit `baseRevisionId` when creating a new document
|
||||||
|
- provide the current `baseRevisionId` when updating an existing document
|
||||||
|
- stale `baseRevisionId` returns `409 Conflict`
|
||||||
|
|
||||||
|
### Revision History
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/issues/{issueId}/documents/{key}/revisions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/issues/{issueId}/documents/{key}
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete is board-only in the current implementation.
|
||||||
|
|
||||||
## Attachments
|
## Attachments
|
||||||
|
|
||||||
### Upload
|
### Upload
|
||||||
|
|||||||
569
docs/plans/2026-03-13-issue-documents-plan.md
Normal file
569
docs/plans/2026-03-13-issue-documents-plan.md
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
# Issue Documents Plan
|
||||||
|
|
||||||
|
Status: Draft
|
||||||
|
Owner: Backend + UI + Agent Protocol
|
||||||
|
Date: 2026-03-13
|
||||||
|
Primary issue: `PAP-448`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add first-class **documents** to Paperclip as editable, revisioned, company-scoped text artifacts that can be linked to issues.
|
||||||
|
|
||||||
|
The first required convention is a document with key `plan`.
|
||||||
|
|
||||||
|
This solves the immediate workflow problem in `PAP-448`:
|
||||||
|
|
||||||
|
- plans should stop living inside issue descriptions as `<plan>` blocks
|
||||||
|
- agents and board users should be able to create/update issue documents directly
|
||||||
|
- `GET /api/issues/:id` should include the full `plan` document and expose the other available documents
|
||||||
|
- issue detail should render documents under the description
|
||||||
|
|
||||||
|
This should be built as the **text-document slice** of the broader artifact system, not as a replacement for attachments/assets.
|
||||||
|
|
||||||
|
## Recommended Product Shape
|
||||||
|
|
||||||
|
### Documents vs attachments vs artifacts
|
||||||
|
|
||||||
|
- **Documents**: editable text content with stable keys and revision history.
|
||||||
|
- **Attachments**: uploaded/generated opaque files backed by storage (`assets` + `issue_attachments`).
|
||||||
|
- **Artifacts**: later umbrella/read-model that can unify documents, attachments, previews, and workspace files.
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
|
||||||
|
- implement **issue documents now**
|
||||||
|
- keep existing attachments as-is
|
||||||
|
- defer full artifact unification until there is a second real consumer beyond issue documents + attachments
|
||||||
|
|
||||||
|
This keeps `PAP-448` focused while still fitting the larger artifact direction.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Give issues first-class keyed documents, starting with `plan`.
|
||||||
|
2. Make documents editable by board users and same-company agents with issue access.
|
||||||
|
3. Preserve change history with append-only revisions.
|
||||||
|
4. Make the `plan` document automatically available in the normal issue fetch used by agents/heartbeats.
|
||||||
|
5. Replace the current `<plan>`-in-description convention in skills/docs.
|
||||||
|
6. Keep the design compatible with a future artifact/deliverables layer.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- full collaborative doc editing
|
||||||
|
- binary-file version history
|
||||||
|
- browser IDE or workspace editor
|
||||||
|
- full artifact-system implementation in the same change
|
||||||
|
- generalized polymorphic relations for every entity type on day one
|
||||||
|
|
||||||
|
## Product Decisions
|
||||||
|
|
||||||
|
### 1. Keyed issue documents
|
||||||
|
|
||||||
|
Each issue can have multiple documents. Each document relation has a stable key:
|
||||||
|
|
||||||
|
- `plan`
|
||||||
|
- `design`
|
||||||
|
- `notes`
|
||||||
|
- `report`
|
||||||
|
- custom keys later
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
|
||||||
|
- unique per issue, case-insensitive
|
||||||
|
- normalized to lowercase slug form
|
||||||
|
- machine-oriented and stable
|
||||||
|
- title is separate and user-facing
|
||||||
|
|
||||||
|
The `plan` key is conventional and reserved by Paperclip workflow/docs.
|
||||||
|
|
||||||
|
### 2. Text-first v1
|
||||||
|
|
||||||
|
V1 documents should be text-first, not arbitrary blobs.
|
||||||
|
|
||||||
|
Recommended supported formats:
|
||||||
|
|
||||||
|
- `markdown`
|
||||||
|
- `plain_text`
|
||||||
|
- `json`
|
||||||
|
- `html`
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
|
||||||
|
- optimize UI for `markdown`
|
||||||
|
- allow raw editing for the others
|
||||||
|
- keep PDFs/images/CSVs/etc as attachments/artifacts, not editable documents
|
||||||
|
|
||||||
|
### 3. Revision model
|
||||||
|
|
||||||
|
Every document update creates a new immutable revision.
|
||||||
|
|
||||||
|
The current document row stores the latest snapshot for fast reads.
|
||||||
|
|
||||||
|
### 4. Concurrency model
|
||||||
|
|
||||||
|
Do not use silent last-write-wins.
|
||||||
|
|
||||||
|
Updates should include `baseRevisionId`:
|
||||||
|
|
||||||
|
- create: no base revision required
|
||||||
|
- update: `baseRevisionId` must match current latest revision
|
||||||
|
- mismatch: return `409 Conflict`
|
||||||
|
|
||||||
|
This is important because both board users and agents may edit the same document.
|
||||||
|
|
||||||
|
### 5. Issue fetch behavior
|
||||||
|
|
||||||
|
`GET /api/issues/:id` should include:
|
||||||
|
|
||||||
|
- full `planDocument` when a `plan` document exists
|
||||||
|
- `documentSummaries` for all linked documents
|
||||||
|
|
||||||
|
It should not inline every document body by default.
|
||||||
|
|
||||||
|
This keeps issue fetches useful for agents without making every issue payload unbounded.
|
||||||
|
|
||||||
|
### 6. Legacy `<plan>` compatibility
|
||||||
|
|
||||||
|
If an issue has no `plan` document but its description contains a legacy `<plan>` block:
|
||||||
|
|
||||||
|
- expose that as a legacy read-only fallback in API/UI
|
||||||
|
- mark it as legacy/synthetic
|
||||||
|
- prefer a real `plan` document when both exist
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
|
||||||
|
- do not auto-rewrite old issue descriptions in the first rollout
|
||||||
|
- provide an explicit import/migrate path later
|
||||||
|
|
||||||
|
## Proposed Data Model
|
||||||
|
|
||||||
|
Recommendation: make documents first-class, but keep issue linkage explicit via a join table.
|
||||||
|
|
||||||
|
This preserves foreign keys today and gives a clean path to future `project_documents` or `company_documents` tables later.
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
### `documents`
|
||||||
|
|
||||||
|
Canonical text document record.
|
||||||
|
|
||||||
|
Suggested columns:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `company_id`
|
||||||
|
- `title`
|
||||||
|
- `format`
|
||||||
|
- `latest_body`
|
||||||
|
- `latest_revision_id`
|
||||||
|
- `latest_revision_number`
|
||||||
|
- `created_by_agent_id`
|
||||||
|
- `created_by_user_id`
|
||||||
|
- `updated_by_agent_id`
|
||||||
|
- `updated_by_user_id`
|
||||||
|
- `created_at`
|
||||||
|
- `updated_at`
|
||||||
|
|
||||||
|
### `document_revisions`
|
||||||
|
|
||||||
|
Append-only history.
|
||||||
|
|
||||||
|
Suggested columns:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `company_id`
|
||||||
|
- `document_id`
|
||||||
|
- `revision_number`
|
||||||
|
- `body`
|
||||||
|
- `change_summary`
|
||||||
|
- `created_by_agent_id`
|
||||||
|
- `created_by_user_id`
|
||||||
|
- `created_at`
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
|
||||||
|
- unique `(document_id, revision_number)`
|
||||||
|
|
||||||
|
### `issue_documents`
|
||||||
|
|
||||||
|
Issue relation + workflow key.
|
||||||
|
|
||||||
|
Suggested columns:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `company_id`
|
||||||
|
- `issue_id`
|
||||||
|
- `document_id`
|
||||||
|
- `key`
|
||||||
|
- `created_at`
|
||||||
|
- `updated_at`
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
|
||||||
|
- unique `(company_id, issue_id, key)`
|
||||||
|
- unique `(document_id)` to keep one issue relation per document in v1
|
||||||
|
|
||||||
|
## Why not use `assets` for this?
|
||||||
|
|
||||||
|
Because `assets` solves blob storage, not:
|
||||||
|
|
||||||
|
- stable keyed semantics like `plan`
|
||||||
|
- inline text editing
|
||||||
|
- revision history
|
||||||
|
- optimistic concurrency
|
||||||
|
- cheap inclusion in `GET /issues/:id`
|
||||||
|
|
||||||
|
Documents and attachments should remain separate primitives, then meet later in a deliverables/artifact read-model.
|
||||||
|
|
||||||
|
## Shared Types and API Contract
|
||||||
|
|
||||||
|
## New shared types
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
- `DocumentFormat`
|
||||||
|
- `IssueDocument`
|
||||||
|
- `IssueDocumentSummary`
|
||||||
|
- `DocumentRevision`
|
||||||
|
|
||||||
|
Recommended `IssueDocument` shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type DocumentFormat = "markdown" | "plain_text" | "json" | "html";
|
||||||
|
|
||||||
|
interface IssueDocument {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
issueId: string;
|
||||||
|
key: string;
|
||||||
|
title: string | null;
|
||||||
|
format: DocumentFormat;
|
||||||
|
body: string;
|
||||||
|
latestRevisionId: string;
|
||||||
|
latestRevisionNumber: number;
|
||||||
|
createdByAgentId: string | null;
|
||||||
|
createdByUserId: string | null;
|
||||||
|
updatedByAgentId: string | null;
|
||||||
|
updatedByUserId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended `IssueDocumentSummary` shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface IssueDocumentSummary {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
title: string | null;
|
||||||
|
format: DocumentFormat;
|
||||||
|
latestRevisionId: string;
|
||||||
|
latestRevisionNumber: number;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Issue type enrichment
|
||||||
|
|
||||||
|
Extend `Issue` with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Issue {
|
||||||
|
...
|
||||||
|
planDocument?: IssueDocument | null;
|
||||||
|
documentSummaries?: IssueDocumentSummary[];
|
||||||
|
legacyPlanDocument?: {
|
||||||
|
key: "plan";
|
||||||
|
body: string;
|
||||||
|
source: "issue_description";
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This directly satisfies the `PAP-448` requirement for heartbeat/API issue fetches.
|
||||||
|
|
||||||
|
## API endpoints
|
||||||
|
|
||||||
|
Recommended endpoints:
|
||||||
|
|
||||||
|
- `GET /api/issues/:issueId/documents`
|
||||||
|
- `GET /api/issues/:issueId/documents/:key`
|
||||||
|
- `PUT /api/issues/:issueId/documents/:key`
|
||||||
|
- `GET /api/issues/:issueId/documents/:key/revisions`
|
||||||
|
- `DELETE /api/issues/:issueId/documents/:key` optionally board-only in v1
|
||||||
|
|
||||||
|
Recommended `PUT` body:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
title?: string | null;
|
||||||
|
format: "markdown" | "plain_text" | "json" | "html";
|
||||||
|
body: string;
|
||||||
|
changeSummary?: string | null;
|
||||||
|
baseRevisionId?: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- missing document + no `baseRevisionId`: create
|
||||||
|
- existing document + matching `baseRevisionId`: update
|
||||||
|
- existing document + stale `baseRevisionId`: `409`
|
||||||
|
|
||||||
|
## Authorization and invariants
|
||||||
|
|
||||||
|
- all document records are company-scoped
|
||||||
|
- issue relation must belong to same company
|
||||||
|
- board access follows existing issue access rules
|
||||||
|
- agent access follows existing same-company issue access rules
|
||||||
|
- every mutation writes activity log entries
|
||||||
|
|
||||||
|
Recommended delete rule for v1:
|
||||||
|
|
||||||
|
- board can delete documents
|
||||||
|
- agents can create/update, but not delete
|
||||||
|
|
||||||
|
That keeps automated systems from removing canonical docs too easily.
|
||||||
|
|
||||||
|
## UI Plan
|
||||||
|
|
||||||
|
## Issue detail
|
||||||
|
|
||||||
|
Add a new **Documents** section directly under the issue description.
|
||||||
|
|
||||||
|
Recommended behavior:
|
||||||
|
|
||||||
|
- show `plan` first when present
|
||||||
|
- show other documents below it
|
||||||
|
- render a gist-like header:
|
||||||
|
- key
|
||||||
|
- title
|
||||||
|
- last updated metadata
|
||||||
|
- revision number
|
||||||
|
- support inline edit
|
||||||
|
- support create new document by key
|
||||||
|
- support revision history drawer or sheet
|
||||||
|
|
||||||
|
Recommended presentation order:
|
||||||
|
|
||||||
|
1. Description
|
||||||
|
2. Documents
|
||||||
|
3. Attachments
|
||||||
|
4. Comments / activity / sub-issues
|
||||||
|
|
||||||
|
This matches the request that documents live under the description while still leaving attachments available.
|
||||||
|
|
||||||
|
## Editing UX
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
|
||||||
|
- use markdown preview + raw edit toggle for markdown docs
|
||||||
|
- use raw textarea editor for non-markdown docs in v1
|
||||||
|
- show explicit save conflicts on `409`
|
||||||
|
- show a clear empty state: "No documents yet"
|
||||||
|
|
||||||
|
## Legacy plan rendering
|
||||||
|
|
||||||
|
If there is no stored `plan` document but legacy `<plan>` exists:
|
||||||
|
|
||||||
|
- show it in the Documents section
|
||||||
|
- mark it `Legacy plan from description`
|
||||||
|
- offer create/import in a later pass
|
||||||
|
|
||||||
|
## Agent Protocol and Skills
|
||||||
|
|
||||||
|
Update the Paperclip agent workflow so planning no longer edits the issue description.
|
||||||
|
|
||||||
|
Required changes:
|
||||||
|
|
||||||
|
- update `skills/paperclip/SKILL.md`
|
||||||
|
- replace the `<plan>` instructions with document creation/update instructions
|
||||||
|
- document the new endpoints in `docs/api/issues.md`
|
||||||
|
- update any internal planning docs that still teach inline `<plan>` blocks
|
||||||
|
|
||||||
|
New rule:
|
||||||
|
|
||||||
|
- when asked to make a plan for an issue, create or update the issue document with key `plan`
|
||||||
|
- leave a comment that the plan document was created/updated
|
||||||
|
- do not mark the issue done
|
||||||
|
|
||||||
|
## Relationship to the Artifact Plan
|
||||||
|
|
||||||
|
This work should explicitly feed the broader artifact/deliverables direction.
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
|
||||||
|
- keep documents as their own primitive in this change
|
||||||
|
- add `document` to any future `ArtifactKind`
|
||||||
|
- later build a deliverables read-model that aggregates:
|
||||||
|
- issue documents
|
||||||
|
- issue attachments
|
||||||
|
- preview URLs
|
||||||
|
- workspace-file references
|
||||||
|
|
||||||
|
The artifact proposal currently has no explicit `document` kind. It should.
|
||||||
|
|
||||||
|
Recommended future shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ArtifactKind =
|
||||||
|
| "document"
|
||||||
|
| "attachment"
|
||||||
|
| "workspace_file"
|
||||||
|
| "preview"
|
||||||
|
| "report_link";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
## Phase 1: Shared contract and schema
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/db/src/schema/documents.ts`
|
||||||
|
- `packages/db/src/schema/document_revisions.ts`
|
||||||
|
- `packages/db/src/schema/issue_documents.ts`
|
||||||
|
- `packages/db/src/schema/index.ts`
|
||||||
|
- `packages/db/src/migrations/*`
|
||||||
|
- `packages/shared/src/types/issue.ts`
|
||||||
|
- `packages/shared/src/validators/issue.ts` or new document validator file
|
||||||
|
- `packages/shared/src/index.ts`
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- schema enforces one key per issue
|
||||||
|
- revisions are append-only
|
||||||
|
- shared types expose plan/document fields on issue fetch
|
||||||
|
|
||||||
|
## Phase 2: Server services and routes
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `server/src/services/issues.ts` or `server/src/services/documents.ts`
|
||||||
|
- `server/src/routes/issues.ts`
|
||||||
|
- `server/src/services/activity.ts` callsites
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- list/get/upsert/delete documents
|
||||||
|
- revision listing
|
||||||
|
- `GET /issues/:id` returns `planDocument` + `documentSummaries`
|
||||||
|
- company boundary checks match issue routes
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- agents and board can fetch/update same-company issue documents
|
||||||
|
- stale edits return `409`
|
||||||
|
- activity timeline shows document changes
|
||||||
|
|
||||||
|
## Phase 3: UI issue documents surface
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `ui/src/api/issues.ts`
|
||||||
|
- `ui/src/lib/queryKeys.ts`
|
||||||
|
- `ui/src/pages/IssueDetail.tsx`
|
||||||
|
- new reusable document UI component if needed
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- render plan + documents under description
|
||||||
|
- create/update by key
|
||||||
|
- open revision history
|
||||||
|
- show conflicts/errors clearly
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- board can create a `plan` doc from issue detail
|
||||||
|
- updated plan appears immediately
|
||||||
|
- issue detail no longer depends on description-embedded `<plan>`
|
||||||
|
|
||||||
|
## Phase 4: Skills/docs migration
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `skills/paperclip/SKILL.md`
|
||||||
|
- `docs/api/issues.md`
|
||||||
|
- `doc/SPEC-implementation.md`
|
||||||
|
- relevant plan/docs that mention `<plan>`
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- planning guidance references issue documents, not inline issue description tags
|
||||||
|
- API docs describe the new document endpoints and issue payload additions
|
||||||
|
|
||||||
|
## Phase 5: Legacy compatibility and follow-up
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- read legacy `<plan>` blocks as fallback
|
||||||
|
- optionally add explicit import/migration command later
|
||||||
|
|
||||||
|
Follow-up, not required for first merge:
|
||||||
|
|
||||||
|
- deliverables/artifact read-model
|
||||||
|
- project/company documents
|
||||||
|
- comment-linked documents
|
||||||
|
- diff view between revisions
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
- document create/read/update/delete lifecycle
|
||||||
|
- revision numbering
|
||||||
|
- `baseRevisionId` conflict handling
|
||||||
|
- company boundary enforcement
|
||||||
|
- agent vs board authorization
|
||||||
|
- issue fetch includes `planDocument` and document summaries
|
||||||
|
- legacy `<plan>` fallback behavior
|
||||||
|
- activity log mutation coverage
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- issue detail shows plan document
|
||||||
|
- create/update flows invalidate queries correctly
|
||||||
|
- conflict and validation errors are surfaced
|
||||||
|
- legacy plan fallback renders correctly
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
Run before implementation is declared complete:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm -r typecheck
|
||||||
|
pnpm test:run
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. Should v1 documents be markdown-only, with `json/html/plain_text` deferred?
|
||||||
|
Recommendation: allow all four in API, optimize UI for markdown only.
|
||||||
|
|
||||||
|
2. Should agents be allowed to create arbitrary keys, or only conventional keys?
|
||||||
|
Recommendation: allow arbitrary keys with normalized validation; reserve `plan` as special behavior only.
|
||||||
|
|
||||||
|
3. Should delete exist in v1?
|
||||||
|
Recommendation: yes, but board-only.
|
||||||
|
|
||||||
|
4. Should legacy `<plan>` blocks ever be auto-migrated?
|
||||||
|
Recommendation: no automatic mutation in the first rollout.
|
||||||
|
|
||||||
|
5. Should documents appear inside a future Deliverables section or remain a top-level Issue section?
|
||||||
|
Recommendation: keep a dedicated Documents section now; later also expose them in Deliverables if an aggregated artifact view is added.
|
||||||
|
|
||||||
|
## Final Recommendation
|
||||||
|
|
||||||
|
Ship **issue documents** as a focused, text-first primitive now.
|
||||||
|
|
||||||
|
Do not try to solve full artifact unification in the same implementation.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
- first-class document tables
|
||||||
|
- issue-level keyed linkage
|
||||||
|
- append-only revisions
|
||||||
|
- `planDocument` embedded in normal issue fetches
|
||||||
|
- legacy `<plan>` fallback
|
||||||
|
- skill/docs migration away from description-embedded plans
|
||||||
|
|
||||||
|
This addresses the real planning workflow problem immediately and leaves the artifact system room to grow cleanly afterward.
|
||||||
@@ -17,6 +17,7 @@ type EmbeddedPostgresCtor = new (opts: {
|
|||||||
password: string;
|
password: string;
|
||||||
port: number;
|
port: number;
|
||||||
persistent: boolean;
|
persistent: boolean;
|
||||||
|
initdbFlags?: string[];
|
||||||
onLog?: (message: unknown) => void;
|
onLog?: (message: unknown) => void;
|
||||||
onError?: (message: unknown) => void;
|
onError?: (message: unknown) => void;
|
||||||
}) => EmbeddedPostgresInstance;
|
}) => EmbeddedPostgresInstance;
|
||||||
|
|||||||
54
packages/db/src/migrations/0028_harsh_goliath.sql
Normal file
54
packages/db/src/migrations/0028_harsh_goliath.sql
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
CREATE TABLE "document_revisions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"document_id" uuid NOT NULL,
|
||||||
|
"revision_number" integer NOT NULL,
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"change_summary" text,
|
||||||
|
"created_by_agent_id" uuid,
|
||||||
|
"created_by_user_id" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "documents" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"title" text,
|
||||||
|
"format" text DEFAULT 'markdown' NOT NULL,
|
||||||
|
"latest_body" text NOT NULL,
|
||||||
|
"latest_revision_id" uuid,
|
||||||
|
"latest_revision_number" integer DEFAULT 1 NOT NULL,
|
||||||
|
"created_by_agent_id" uuid,
|
||||||
|
"created_by_user_id" text,
|
||||||
|
"updated_by_agent_id" uuid,
|
||||||
|
"updated_by_user_id" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "issue_documents" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"issue_id" uuid NOT NULL,
|
||||||
|
"document_id" uuid NOT NULL,
|
||||||
|
"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
|
||||||
|
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_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;--> statement-breakpoint
|
||||||
|
ALTER TABLE "documents" ADD CONSTRAINT "documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "documents" ADD CONSTRAINT "documents_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;--> statement-breakpoint
|
||||||
|
ALTER TABLE "documents" ADD CONSTRAINT "documents_updated_by_agent_id_agents_id_fk" FOREIGN KEY ("updated_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "document_revisions_document_revision_uq" ON "document_revisions" USING btree ("document_id","revision_number");--> statement-breakpoint
|
||||||
|
CREATE INDEX "document_revisions_company_document_created_idx" ON "document_revisions" USING btree ("company_id","document_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "documents_company_updated_idx" ON "documents" USING btree ("company_id","updated_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "documents_company_created_idx" ON "documents" USING btree ("company_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "issue_documents_company_issue_key_uq" ON "issue_documents" USING btree ("company_id","issue_id","key");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "issue_documents_document_uq" ON "issue_documents" USING btree ("document_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "issue_documents_company_issue_updated_idx" ON "issue_documents" USING btree ("company_id","issue_id","updated_at");
|
||||||
6710
packages/db/src/migrations/meta/0028_snapshot.json
Normal file
6710
packages/db/src/migrations/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,13 @@
|
|||||||
"when": 1773150731736,
|
"when": 1773150731736,
|
||||||
"tag": "0027_tranquil_tenebrous",
|
"tag": "0027_tranquil_tenebrous",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773432085646,
|
||||||
|
"tag": "0028_harsh_goliath",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
30
packages/db/src/schema/document_revisions.ts
Normal file
30
packages/db/src/schema/document_revisions.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { pgTable, uuid, text, integer, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { agents } from "./agents.js";
|
||||||
|
import { documents } from "./documents.js";
|
||||||
|
|
||||||
|
export const documentRevisions = pgTable(
|
||||||
|
"document_revisions",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||||
|
revisionNumber: integer("revision_number").notNull(),
|
||||||
|
body: text("body").notNull(),
|
||||||
|
changeSummary: text("change_summary"),
|
||||||
|
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
|
createdByUserId: text("created_by_user_id"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
documentRevisionUq: uniqueIndex("document_revisions_document_revision_uq").on(
|
||||||
|
table.documentId,
|
||||||
|
table.revisionNumber,
|
||||||
|
),
|
||||||
|
companyDocumentCreatedIdx: index("document_revisions_company_document_created_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.documentId,
|
||||||
|
table.createdAt,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
26
packages/db/src/schema/documents.ts
Normal file
26
packages/db/src/schema/documents.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { pgTable, uuid, text, integer, timestamp, index } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { agents } from "./agents.js";
|
||||||
|
|
||||||
|
export const documents = pgTable(
|
||||||
|
"documents",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
title: text("title"),
|
||||||
|
format: text("format").notNull().default("markdown"),
|
||||||
|
latestBody: text("latest_body").notNull(),
|
||||||
|
latestRevisionId: uuid("latest_revision_id"),
|
||||||
|
latestRevisionNumber: integer("latest_revision_number").notNull().default(1),
|
||||||
|
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
|
createdByUserId: text("created_by_user_id"),
|
||||||
|
updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
|
updatedByUserId: text("updated_by_user_id"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyUpdatedIdx: index("documents_company_updated_idx").on(table.companyId, table.updatedAt),
|
||||||
|
companyCreatedIdx: index("documents_company_created_idx").on(table.companyId, table.createdAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -24,6 +24,9 @@ export { issueComments } from "./issue_comments.js";
|
|||||||
export { issueReadStates } from "./issue_read_states.js";
|
export { issueReadStates } from "./issue_read_states.js";
|
||||||
export { assets } from "./assets.js";
|
export { assets } from "./assets.js";
|
||||||
export { issueAttachments } from "./issue_attachments.js";
|
export { issueAttachments } from "./issue_attachments.js";
|
||||||
|
export { documents } from "./documents.js";
|
||||||
|
export { documentRevisions } from "./document_revisions.js";
|
||||||
|
export { issueDocuments } from "./issue_documents.js";
|
||||||
export { heartbeatRuns } from "./heartbeat_runs.js";
|
export { heartbeatRuns } from "./heartbeat_runs.js";
|
||||||
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
|
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
|
||||||
export { costEvents } from "./cost_events.js";
|
export { costEvents } from "./cost_events.js";
|
||||||
|
|||||||
30
packages/db/src/schema/issue_documents.ts
Normal file
30
packages/db/src/schema/issue_documents.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { issues } from "./issues.js";
|
||||||
|
import { documents } from "./documents.js";
|
||||||
|
|
||||||
|
export const issueDocuments = pgTable(
|
||||||
|
"issue_documents",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||||
|
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||||
|
key: text("key").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyIssueKeyUq: uniqueIndex("issue_documents_company_issue_key_uq").on(
|
||||||
|
table.companyId,
|
||||||
|
table.issueId,
|
||||||
|
table.key,
|
||||||
|
),
|
||||||
|
documentUq: uniqueIndex("issue_documents_document_uq").on(table.documentId),
|
||||||
|
companyIssueUpdatedIdx: index("issue_documents_company_issue_updated_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.issueId,
|
||||||
|
table.updatedAt,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -86,6 +86,11 @@ export type {
|
|||||||
Issue,
|
Issue,
|
||||||
IssueAssigneeAdapterOverrides,
|
IssueAssigneeAdapterOverrides,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
|
IssueDocument,
|
||||||
|
IssueDocumentSummary,
|
||||||
|
DocumentRevision,
|
||||||
|
DocumentFormat,
|
||||||
|
LegacyPlanDocument,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
IssueLabel,
|
IssueLabel,
|
||||||
Goal,
|
Goal,
|
||||||
@@ -172,6 +177,9 @@ export {
|
|||||||
addIssueCommentSchema,
|
addIssueCommentSchema,
|
||||||
linkIssueApprovalSchema,
|
linkIssueApprovalSchema,
|
||||||
createIssueAttachmentMetadataSchema,
|
createIssueAttachmentMetadataSchema,
|
||||||
|
issueDocumentFormatSchema,
|
||||||
|
issueDocumentKeySchema,
|
||||||
|
upsertIssueDocumentSchema,
|
||||||
type CreateIssue,
|
type CreateIssue,
|
||||||
type CreateIssueLabel,
|
type CreateIssueLabel,
|
||||||
type UpdateIssue,
|
type UpdateIssue,
|
||||||
@@ -179,6 +187,8 @@ export {
|
|||||||
type AddIssueComment,
|
type AddIssueComment,
|
||||||
type LinkIssueApproval,
|
type LinkIssueApproval,
|
||||||
type CreateIssueAttachmentMetadata,
|
type CreateIssueAttachmentMetadata,
|
||||||
|
type IssueDocumentFormat,
|
||||||
|
type UpsertIssueDocument,
|
||||||
createGoalSchema,
|
createGoalSchema,
|
||||||
updateGoalSchema,
|
updateGoalSchema,
|
||||||
type CreateGoal,
|
type CreateGoal,
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export type {
|
|||||||
Issue,
|
Issue,
|
||||||
IssueAssigneeAdapterOverrides,
|
IssueAssigneeAdapterOverrides,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
|
IssueDocument,
|
||||||
|
IssueDocumentSummary,
|
||||||
|
DocumentRevision,
|
||||||
|
DocumentFormat,
|
||||||
|
LegacyPlanDocument,
|
||||||
IssueAncestor,
|
IssueAncestor,
|
||||||
IssueAncestorProject,
|
IssueAncestorProject,
|
||||||
IssueAncestorGoal,
|
IssueAncestorGoal,
|
||||||
|
|||||||
@@ -50,6 +50,49 @@ export interface IssueAssigneeAdapterOverrides {
|
|||||||
useProjectWorkspace?: boolean;
|
useProjectWorkspace?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DocumentFormat = "markdown";
|
||||||
|
|
||||||
|
export interface IssueDocumentSummary {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
issueId: string;
|
||||||
|
key: string;
|
||||||
|
title: string | null;
|
||||||
|
format: DocumentFormat;
|
||||||
|
latestRevisionId: string;
|
||||||
|
latestRevisionNumber: number;
|
||||||
|
createdByAgentId: string | null;
|
||||||
|
createdByUserId: string | null;
|
||||||
|
updatedByAgentId: string | null;
|
||||||
|
updatedByUserId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueDocument extends IssueDocumentSummary {
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentRevision {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
documentId: string;
|
||||||
|
issueId: string;
|
||||||
|
key: string;
|
||||||
|
revisionNumber: number;
|
||||||
|
body: string;
|
||||||
|
changeSummary: string | null;
|
||||||
|
createdByAgentId: string | null;
|
||||||
|
createdByUserId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyPlanDocument {
|
||||||
|
key: "plan";
|
||||||
|
body: string;
|
||||||
|
source: "issue_description";
|
||||||
|
}
|
||||||
|
|
||||||
export interface Issue {
|
export interface Issue {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -81,6 +124,9 @@ export interface Issue {
|
|||||||
hiddenAt: Date | null;
|
hiddenAt: Date | null;
|
||||||
labelIds?: string[];
|
labelIds?: string[];
|
||||||
labels?: IssueLabel[];
|
labels?: IssueLabel[];
|
||||||
|
planDocument?: IssueDocument | null;
|
||||||
|
documentSummaries?: IssueDocumentSummary[];
|
||||||
|
legacyPlanDocument?: LegacyPlanDocument | null;
|
||||||
project?: Project | null;
|
project?: Project | null;
|
||||||
goal?: Goal | null;
|
goal?: Goal | null;
|
||||||
mentionedProjects?: Project[];
|
mentionedProjects?: Project[];
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ export {
|
|||||||
addIssueCommentSchema,
|
addIssueCommentSchema,
|
||||||
linkIssueApprovalSchema,
|
linkIssueApprovalSchema,
|
||||||
createIssueAttachmentMetadataSchema,
|
createIssueAttachmentMetadataSchema,
|
||||||
|
issueDocumentFormatSchema,
|
||||||
|
issueDocumentKeySchema,
|
||||||
|
upsertIssueDocumentSchema,
|
||||||
type CreateIssue,
|
type CreateIssue,
|
||||||
type CreateIssueLabel,
|
type CreateIssueLabel,
|
||||||
type UpdateIssue,
|
type UpdateIssue,
|
||||||
@@ -74,6 +77,8 @@ export {
|
|||||||
type AddIssueComment,
|
type AddIssueComment,
|
||||||
type LinkIssueApproval,
|
type LinkIssueApproval,
|
||||||
type CreateIssueAttachmentMetadata,
|
type CreateIssueAttachmentMetadata,
|
||||||
|
type IssueDocumentFormat,
|
||||||
|
type UpsertIssueDocument,
|
||||||
} from "./issue.js";
|
} from "./issue.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -87,3 +87,25 @@ export const createIssueAttachmentMetadataSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type CreateIssueAttachmentMetadata = z.infer<typeof createIssueAttachmentMetadataSchema>;
|
export type CreateIssueAttachmentMetadata = z.infer<typeof createIssueAttachmentMetadataSchema>;
|
||||||
|
|
||||||
|
export const ISSUE_DOCUMENT_FORMATS = ["markdown"] as const;
|
||||||
|
|
||||||
|
export const issueDocumentFormatSchema = z.enum(ISSUE_DOCUMENT_FORMATS);
|
||||||
|
|
||||||
|
export const issueDocumentKeySchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(64)
|
||||||
|
.regex(/^[a-z0-9][a-z0-9_-]*$/, "Document key must be lowercase letters, numbers, _ or -");
|
||||||
|
|
||||||
|
export const upsertIssueDocumentSchema = z.object({
|
||||||
|
title: z.string().trim().max(200).nullable().optional(),
|
||||||
|
format: issueDocumentFormatSchema,
|
||||||
|
body: z.string(),
|
||||||
|
changeSummary: z.string().trim().max(500).nullable().optional(),
|
||||||
|
baseRevisionId: z.string().uuid().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IssueDocumentFormat = z.infer<typeof issueDocumentFormatSchema>;
|
||||||
|
export type UpsertIssueDocument = z.infer<typeof upsertIssueDocumentSchema>;
|
||||||
|
|||||||
29
server/src/__tests__/documents.test.ts
Normal file
29
server/src/__tests__/documents.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { extractLegacyPlanBody } from "../services/documents.js";
|
||||||
|
|
||||||
|
describe("extractLegacyPlanBody", () => {
|
||||||
|
it("returns null when no plan block exists", () => {
|
||||||
|
expect(extractLegacyPlanBody("hello world")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts plan body from legacy issue descriptions", () => {
|
||||||
|
expect(
|
||||||
|
extractLegacyPlanBody(`
|
||||||
|
intro
|
||||||
|
|
||||||
|
<plan>
|
||||||
|
|
||||||
|
# Plan
|
||||||
|
|
||||||
|
- one
|
||||||
|
- two
|
||||||
|
|
||||||
|
</plan>
|
||||||
|
`),
|
||||||
|
).toBe("# Plan\n\n- one\n- two");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores empty plan blocks", () => {
|
||||||
|
expect(extractLegacyPlanBody("<plan> </plan>")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,12 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
|
|||||||
"image/jpg",
|
"image/jpg",
|
||||||
"image/webp",
|
"image/webp",
|
||||||
"image/gif",
|
"image/gif",
|
||||||
|
"application/pdf",
|
||||||
|
"text/markdown",
|
||||||
|
"text/plain",
|
||||||
|
"application/json",
|
||||||
|
"text/csv",
|
||||||
|
"text/html",
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
checkoutIssueSchema,
|
checkoutIssueSchema,
|
||||||
createIssueSchema,
|
createIssueSchema,
|
||||||
linkIssueApprovalSchema,
|
linkIssueApprovalSchema,
|
||||||
|
issueDocumentKeySchema,
|
||||||
|
upsertIssueDocumentSchema,
|
||||||
updateIssueSchema,
|
updateIssueSchema,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import type { StorageService } from "../storage/types.js";
|
import type { StorageService } from "../storage/types.js";
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
heartbeatService,
|
heartbeatService,
|
||||||
issueApprovalService,
|
issueApprovalService,
|
||||||
issueService,
|
issueService,
|
||||||
|
documentService,
|
||||||
logActivity,
|
logActivity,
|
||||||
projectService,
|
projectService,
|
||||||
} from "../services/index.js";
|
} from "../services/index.js";
|
||||||
@@ -37,6 +40,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
const projectsSvc = projectService(db);
|
const projectsSvc = projectService(db);
|
||||||
const goalsSvc = goalService(db);
|
const goalsSvc = goalService(db);
|
||||||
const issueApprovalsSvc = issueApprovalService(db);
|
const issueApprovalsSvc = issueApprovalService(db);
|
||||||
|
const documentsSvc = documentService(db);
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||||
@@ -291,7 +295,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, issue.companyId);
|
assertCompanyAccess(req, issue.companyId);
|
||||||
const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([
|
const [ancestors, project, goal, mentionedProjectIds, documentPayload] = await Promise.all([
|
||||||
svc.getAncestors(issue.id),
|
svc.getAncestors(issue.id),
|
||||||
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
||||||
issue.goalId
|
issue.goalId
|
||||||
@@ -300,6 +304,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
|
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
|
||||||
: null,
|
: null,
|
||||||
svc.findMentionedProjectIds(issue.id),
|
svc.findMentionedProjectIds(issue.id),
|
||||||
|
documentsSvc.getIssueDocumentPayload(issue),
|
||||||
]);
|
]);
|
||||||
const mentionedProjects = mentionedProjectIds.length > 0
|
const mentionedProjects = mentionedProjectIds.length > 0
|
||||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||||
@@ -308,12 +313,153 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
...issue,
|
...issue,
|
||||||
goalId: goal?.id ?? issue.goalId,
|
goalId: goal?.id ?? issue.goalId,
|
||||||
ancestors,
|
ancestors,
|
||||||
|
...documentPayload,
|
||||||
project: project ?? null,
|
project: project ?? null,
|
||||||
goal: goal ?? null,
|
goal: goal ?? null,
|
||||||
mentionedProjects,
|
mentionedProjects,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/issues/:id/documents", async (req, res) => {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const issue = await svc.getById(id);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ error: "Issue not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, issue.companyId);
|
||||||
|
const docs = await documentsSvc.listIssueDocuments(issue.id);
|
||||||
|
res.json(docs);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/issues/:id/documents/:key", async (req, res) => {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const issue = await svc.getById(id);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ error: "Issue not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, issue.companyId);
|
||||||
|
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||||
|
if (!keyParsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const doc = await documentsSvc.getIssueDocumentByKey(issue.id, keyParsed.data);
|
||||||
|
if (!doc) {
|
||||||
|
res.status(404).json({ error: "Document not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(doc);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), async (req, res) => {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const issue = await svc.getById(id);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ error: "Issue not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, issue.companyId);
|
||||||
|
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||||
|
if (!keyParsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
const before = await documentsSvc.getIssueDocumentByKey(issue.id, keyParsed.data);
|
||||||
|
const doc = await documentsSvc.upsertIssueDocument({
|
||||||
|
issueId: issue.id,
|
||||||
|
key: keyParsed.data,
|
||||||
|
title: req.body.title ?? null,
|
||||||
|
format: req.body.format,
|
||||||
|
body: req.body.body,
|
||||||
|
changeSummary: req.body.changeSummary ?? null,
|
||||||
|
baseRevisionId: req.body.baseRevisionId ?? null,
|
||||||
|
createdByAgentId: actor.agentId ?? null,
|
||||||
|
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: issue.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: before ? "issue.document_updated" : "issue.document_created",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: {
|
||||||
|
key: doc.key,
|
||||||
|
documentId: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
format: doc.format,
|
||||||
|
revisionNumber: doc.latestRevisionNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(before ? 200 : 201).json(doc);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/issues/:id/documents/:key/revisions", async (req, res) => {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const issue = await svc.getById(id);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ error: "Issue not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, issue.companyId);
|
||||||
|
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||||
|
if (!keyParsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const revisions = await documentsSvc.listIssueDocumentRevisions(issue.id, keyParsed.data);
|
||||||
|
res.json(revisions);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/issues/:id/documents/:key", async (req, res) => {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const issue = await svc.getById(id);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ error: "Issue not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, issue.companyId);
|
||||||
|
if (req.actor.type !== "board") {
|
||||||
|
res.status(403).json({ error: "Board authentication required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||||
|
if (!keyParsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const removed = await documentsSvc.deleteIssueDocument(issue.id, keyParsed.data);
|
||||||
|
if (!removed) {
|
||||||
|
res.status(404).json({ error: "Document not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: issue.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "issue.document_deleted",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: {
|
||||||
|
key: removed.key,
|
||||||
|
documentId: removed.id,
|
||||||
|
title: removed.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/issues/:id/read", async (req, res) => {
|
router.post("/issues/:id/read", async (req, res) => {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const issue = await svc.getById(id);
|
const issue = await svc.getById(id);
|
||||||
|
|||||||
427
server/src/services/documents.ts
Normal file
427
server/src/services/documents.ts
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import { and, asc, desc, eq } from "drizzle-orm";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { documentRevisions, documents, issueDocuments, issues } from "@paperclipai/db";
|
||||||
|
import { issueDocumentKeySchema } from "@paperclipai/shared";
|
||||||
|
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||||
|
|
||||||
|
function normalizeDocumentKey(key: string) {
|
||||||
|
const normalized = key.trim().toLowerCase();
|
||||||
|
const parsed = issueDocumentKeySchema.safeParse(normalized);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw unprocessable("Invalid document key", parsed.error.issues);
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return !!error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "23505";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractLegacyPlanBody(description: string | null | undefined) {
|
||||||
|
if (!description) return null;
|
||||||
|
const match = /<plan>\s*([\s\S]*?)\s*<\/plan>/i.exec(description);
|
||||||
|
if (!match) return null;
|
||||||
|
const body = match[1]?.trim();
|
||||||
|
return body ? body : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapIssueDocumentRow(
|
||||||
|
row: {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
issueId: string;
|
||||||
|
key: string;
|
||||||
|
title: string | null;
|
||||||
|
format: string;
|
||||||
|
latestBody: string;
|
||||||
|
latestRevisionId: string | null;
|
||||||
|
latestRevisionNumber: number;
|
||||||
|
createdByAgentId: string | null;
|
||||||
|
createdByUserId: string | null;
|
||||||
|
updatedByAgentId: string | null;
|
||||||
|
updatedByUserId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
},
|
||||||
|
includeBody: boolean,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
companyId: row.companyId,
|
||||||
|
issueId: row.issueId,
|
||||||
|
key: row.key,
|
||||||
|
title: row.title,
|
||||||
|
format: row.format,
|
||||||
|
...(includeBody ? { body: row.latestBody } : {}),
|
||||||
|
latestRevisionId: row.latestRevisionId ?? "",
|
||||||
|
latestRevisionNumber: row.latestRevisionNumber,
|
||||||
|
createdByAgentId: row.createdByAgentId,
|
||||||
|
createdByUserId: row.createdByUserId,
|
||||||
|
updatedByAgentId: row.updatedByAgentId,
|
||||||
|
updatedByUserId: row.updatedByUserId,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function documentService(db: Db) {
|
||||||
|
return {
|
||||||
|
getIssueDocumentPayload: async (issue: { id: string; description: string | null }) => {
|
||||||
|
const [planDocument, documentSummaries] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: documents.id,
|
||||||
|
companyId: documents.companyId,
|
||||||
|
issueId: issueDocuments.issueId,
|
||||||
|
key: issueDocuments.key,
|
||||||
|
title: documents.title,
|
||||||
|
format: documents.format,
|
||||||
|
latestBody: documents.latestBody,
|
||||||
|
latestRevisionId: documents.latestRevisionId,
|
||||||
|
latestRevisionNumber: documents.latestRevisionNumber,
|
||||||
|
createdByAgentId: documents.createdByAgentId,
|
||||||
|
createdByUserId: documents.createdByUserId,
|
||||||
|
updatedByAgentId: documents.updatedByAgentId,
|
||||||
|
updatedByUserId: documents.updatedByUserId,
|
||||||
|
createdAt: documents.createdAt,
|
||||||
|
updatedAt: documents.updatedAt,
|
||||||
|
})
|
||||||
|
.from(issueDocuments)
|
||||||
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||||
|
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, "plan")))
|
||||||
|
.then((rows) => rows[0] ?? null),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: documents.id,
|
||||||
|
companyId: documents.companyId,
|
||||||
|
issueId: issueDocuments.issueId,
|
||||||
|
key: issueDocuments.key,
|
||||||
|
title: documents.title,
|
||||||
|
format: documents.format,
|
||||||
|
latestBody: documents.latestBody,
|
||||||
|
latestRevisionId: documents.latestRevisionId,
|
||||||
|
latestRevisionNumber: documents.latestRevisionNumber,
|
||||||
|
createdByAgentId: documents.createdByAgentId,
|
||||||
|
createdByUserId: documents.createdByUserId,
|
||||||
|
updatedByAgentId: documents.updatedByAgentId,
|
||||||
|
updatedByUserId: documents.updatedByUserId,
|
||||||
|
createdAt: documents.createdAt,
|
||||||
|
updatedAt: documents.updatedAt,
|
||||||
|
})
|
||||||
|
.from(issueDocuments)
|
||||||
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||||
|
.where(eq(issueDocuments.issueId, issue.id))
|
||||||
|
.orderBy(asc(issueDocuments.key), desc(documents.updatedAt)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const legacyPlanBody = planDocument ? null : extractLegacyPlanBody(issue.description);
|
||||||
|
|
||||||
|
return {
|
||||||
|
planDocument: planDocument ? mapIssueDocumentRow(planDocument, true) : null,
|
||||||
|
documentSummaries: documentSummaries.map((row) => mapIssueDocumentRow(row, false)),
|
||||||
|
legacyPlanDocument: legacyPlanBody
|
||||||
|
? {
|
||||||
|
key: "plan" as const,
|
||||||
|
body: legacyPlanBody,
|
||||||
|
source: "issue_description" as const,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
listIssueDocuments: async (issueId: string) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: documents.id,
|
||||||
|
companyId: documents.companyId,
|
||||||
|
issueId: issueDocuments.issueId,
|
||||||
|
key: issueDocuments.key,
|
||||||
|
title: documents.title,
|
||||||
|
format: documents.format,
|
||||||
|
latestBody: documents.latestBody,
|
||||||
|
latestRevisionId: documents.latestRevisionId,
|
||||||
|
latestRevisionNumber: documents.latestRevisionNumber,
|
||||||
|
createdByAgentId: documents.createdByAgentId,
|
||||||
|
createdByUserId: documents.createdByUserId,
|
||||||
|
updatedByAgentId: documents.updatedByAgentId,
|
||||||
|
updatedByUserId: documents.updatedByUserId,
|
||||||
|
createdAt: documents.createdAt,
|
||||||
|
updatedAt: documents.updatedAt,
|
||||||
|
})
|
||||||
|
.from(issueDocuments)
|
||||||
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||||
|
.where(eq(issueDocuments.issueId, issueId))
|
||||||
|
.orderBy(asc(issueDocuments.key), desc(documents.updatedAt));
|
||||||
|
return rows.map((row) => mapIssueDocumentRow(row, true));
|
||||||
|
},
|
||||||
|
|
||||||
|
getIssueDocumentByKey: async (issueId: string, rawKey: string) => {
|
||||||
|
const key = normalizeDocumentKey(rawKey);
|
||||||
|
const row = await db
|
||||||
|
.select({
|
||||||
|
id: documents.id,
|
||||||
|
companyId: documents.companyId,
|
||||||
|
issueId: issueDocuments.issueId,
|
||||||
|
key: issueDocuments.key,
|
||||||
|
title: documents.title,
|
||||||
|
format: documents.format,
|
||||||
|
latestBody: documents.latestBody,
|
||||||
|
latestRevisionId: documents.latestRevisionId,
|
||||||
|
latestRevisionNumber: documents.latestRevisionNumber,
|
||||||
|
createdByAgentId: documents.createdByAgentId,
|
||||||
|
createdByUserId: documents.createdByUserId,
|
||||||
|
updatedByAgentId: documents.updatedByAgentId,
|
||||||
|
updatedByUserId: documents.updatedByUserId,
|
||||||
|
createdAt: documents.createdAt,
|
||||||
|
updatedAt: documents.updatedAt,
|
||||||
|
})
|
||||||
|
.from(issueDocuments)
|
||||||
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||||
|
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
return row ? mapIssueDocumentRow(row, true) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
listIssueDocumentRevisions: async (issueId: string, rawKey: string) => {
|
||||||
|
const key = normalizeDocumentKey(rawKey);
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: documentRevisions.id,
|
||||||
|
companyId: documentRevisions.companyId,
|
||||||
|
documentId: documentRevisions.documentId,
|
||||||
|
issueId: issueDocuments.issueId,
|
||||||
|
key: issueDocuments.key,
|
||||||
|
revisionNumber: documentRevisions.revisionNumber,
|
||||||
|
body: documentRevisions.body,
|
||||||
|
changeSummary: documentRevisions.changeSummary,
|
||||||
|
createdByAgentId: documentRevisions.createdByAgentId,
|
||||||
|
createdByUserId: documentRevisions.createdByUserId,
|
||||||
|
createdAt: documentRevisions.createdAt,
|
||||||
|
})
|
||||||
|
.from(issueDocuments)
|
||||||
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||||
|
.innerJoin(documentRevisions, eq(documentRevisions.documentId, documents.id))
|
||||||
|
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
||||||
|
.orderBy(desc(documentRevisions.revisionNumber));
|
||||||
|
},
|
||||||
|
|
||||||
|
upsertIssueDocument: async (input: {
|
||||||
|
issueId: string;
|
||||||
|
key: string;
|
||||||
|
title?: string | null;
|
||||||
|
format: string;
|
||||||
|
body: string;
|
||||||
|
changeSummary?: string | null;
|
||||||
|
baseRevisionId?: string | null;
|
||||||
|
createdByAgentId?: string | null;
|
||||||
|
createdByUserId?: string | null;
|
||||||
|
}) => {
|
||||||
|
const key = normalizeDocumentKey(input.key);
|
||||||
|
const issue = await db
|
||||||
|
.select({ id: issues.id, companyId: issues.companyId })
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.id, input.issueId))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!issue) throw notFound("Issue not found");
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
const now = new Date();
|
||||||
|
const existing = await tx
|
||||||
|
.select({
|
||||||
|
id: documents.id,
|
||||||
|
companyId: documents.companyId,
|
||||||
|
issueId: issueDocuments.issueId,
|
||||||
|
key: issueDocuments.key,
|
||||||
|
title: documents.title,
|
||||||
|
format: documents.format,
|
||||||
|
latestBody: documents.latestBody,
|
||||||
|
latestRevisionId: documents.latestRevisionId,
|
||||||
|
latestRevisionNumber: documents.latestRevisionNumber,
|
||||||
|
createdByAgentId: documents.createdByAgentId,
|
||||||
|
createdByUserId: documents.createdByUserId,
|
||||||
|
updatedByAgentId: documents.updatedByAgentId,
|
||||||
|
updatedByUserId: documents.updatedByUserId,
|
||||||
|
createdAt: documents.createdAt,
|
||||||
|
updatedAt: documents.updatedAt,
|
||||||
|
})
|
||||||
|
.from(issueDocuments)
|
||||||
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||||
|
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, key)))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (!input.baseRevisionId) {
|
||||||
|
throw conflict("Document update requires baseRevisionId", {
|
||||||
|
currentRevisionId: existing.latestRevisionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.baseRevisionId !== existing.latestRevisionId) {
|
||||||
|
throw conflict("Document was updated by someone else", {
|
||||||
|
currentRevisionId: existing.latestRevisionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRevisionNumber = existing.latestRevisionNumber + 1;
|
||||||
|
const [revision] = await tx
|
||||||
|
.insert(documentRevisions)
|
||||||
|
.values({
|
||||||
|
companyId: issue.companyId,
|
||||||
|
documentId: existing.id,
|
||||||
|
revisionNumber: nextRevisionNumber,
|
||||||
|
body: input.body,
|
||||||
|
changeSummary: input.changeSummary ?? null,
|
||||||
|
createdByAgentId: input.createdByAgentId ?? null,
|
||||||
|
createdByUserId: input.createdByUserId ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(documents)
|
||||||
|
.set({
|
||||||
|
title: input.title ?? null,
|
||||||
|
format: input.format,
|
||||||
|
latestBody: input.body,
|
||||||
|
latestRevisionId: revision.id,
|
||||||
|
latestRevisionNumber: nextRevisionNumber,
|
||||||
|
updatedByAgentId: input.createdByAgentId ?? null,
|
||||||
|
updatedByUserId: input.createdByUserId ?? null,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(documents.id, existing.id));
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(issueDocuments)
|
||||||
|
.set({ updatedAt: now })
|
||||||
|
.where(eq(issueDocuments.documentId, existing.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...existing,
|
||||||
|
title: input.title ?? null,
|
||||||
|
format: input.format,
|
||||||
|
body: input.body,
|
||||||
|
latestRevisionId: revision.id,
|
||||||
|
latestRevisionNumber: nextRevisionNumber,
|
||||||
|
updatedByAgentId: input.createdByAgentId ?? null,
|
||||||
|
updatedByUserId: input.createdByUserId ?? null,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.baseRevisionId) {
|
||||||
|
throw conflict("Document does not exist yet", { key });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [document] = await tx
|
||||||
|
.insert(documents)
|
||||||
|
.values({
|
||||||
|
companyId: issue.companyId,
|
||||||
|
title: input.title ?? null,
|
||||||
|
format: input.format,
|
||||||
|
latestBody: input.body,
|
||||||
|
latestRevisionId: null,
|
||||||
|
latestRevisionNumber: 1,
|
||||||
|
createdByAgentId: input.createdByAgentId ?? null,
|
||||||
|
createdByUserId: input.createdByUserId ?? null,
|
||||||
|
updatedByAgentId: input.createdByAgentId ?? null,
|
||||||
|
updatedByUserId: input.createdByUserId ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const [revision] = await tx
|
||||||
|
.insert(documentRevisions)
|
||||||
|
.values({
|
||||||
|
companyId: issue.companyId,
|
||||||
|
documentId: document.id,
|
||||||
|
revisionNumber: 1,
|
||||||
|
body: input.body,
|
||||||
|
changeSummary: input.changeSummary ?? null,
|
||||||
|
createdByAgentId: input.createdByAgentId ?? null,
|
||||||
|
createdByUserId: input.createdByUserId ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(documents)
|
||||||
|
.set({ latestRevisionId: revision.id })
|
||||||
|
.where(eq(documents.id, document.id));
|
||||||
|
|
||||||
|
await tx.insert(issueDocuments).values({
|
||||||
|
companyId: issue.companyId,
|
||||||
|
issueId: issue.id,
|
||||||
|
documentId: document.id,
|
||||||
|
key,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: document.id,
|
||||||
|
companyId: issue.companyId,
|
||||||
|
issueId: issue.id,
|
||||||
|
key,
|
||||||
|
title: document.title,
|
||||||
|
format: document.format,
|
||||||
|
body: document.latestBody,
|
||||||
|
latestRevisionId: revision.id,
|
||||||
|
latestRevisionNumber: 1,
|
||||||
|
createdByAgentId: document.createdByAgentId,
|
||||||
|
createdByUserId: document.createdByUserId,
|
||||||
|
updatedByAgentId: document.updatedByAgentId,
|
||||||
|
updatedByUserId: document.updatedByUserId,
|
||||||
|
createdAt: document.createdAt,
|
||||||
|
updatedAt: document.updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw conflict("Document key already exists on this issue", { key });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteIssueDocument: async (issueId: string, rawKey: string) => {
|
||||||
|
const key = normalizeDocumentKey(rawKey);
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const existing = await tx
|
||||||
|
.select({
|
||||||
|
id: documents.id,
|
||||||
|
companyId: documents.companyId,
|
||||||
|
issueId: issueDocuments.issueId,
|
||||||
|
key: issueDocuments.key,
|
||||||
|
title: documents.title,
|
||||||
|
format: documents.format,
|
||||||
|
latestBody: documents.latestBody,
|
||||||
|
latestRevisionId: documents.latestRevisionId,
|
||||||
|
latestRevisionNumber: documents.latestRevisionNumber,
|
||||||
|
createdByAgentId: documents.createdByAgentId,
|
||||||
|
createdByUserId: documents.createdByUserId,
|
||||||
|
updatedByAgentId: documents.updatedByAgentId,
|
||||||
|
updatedByUserId: documents.updatedByUserId,
|
||||||
|
createdAt: documents.createdAt,
|
||||||
|
updatedAt: documents.updatedAt,
|
||||||
|
})
|
||||||
|
.from(issueDocuments)
|
||||||
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||||
|
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
await tx.delete(issueDocuments).where(eq(issueDocuments.documentId, existing.id));
|
||||||
|
await tx.delete(documents).where(eq(documents.id, existing.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...existing,
|
||||||
|
body: existing.latestBody,
|
||||||
|
latestRevisionId: existing.latestRevisionId ?? "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export { companyService } from "./companies.js";
|
export { companyService } from "./companies.js";
|
||||||
export { agentService, deduplicateAgentName } from "./agents.js";
|
export { agentService, deduplicateAgentName } from "./agents.js";
|
||||||
export { assetService } from "./assets.js";
|
export { assetService } from "./assets.js";
|
||||||
|
export { documentService, extractLegacyPlanBody } from "./documents.js";
|
||||||
export { projectService } from "./projects.js";
|
export { projectService } from "./projects.js";
|
||||||
export { issueService, type IssueFilters } from "./issues.js";
|
export { issueService, type IssueFilters } from "./issues.js";
|
||||||
export { issueApprovalService } from "./issue-approvals.js";
|
export { issueApprovalService } from "./issue-approvals.js";
|
||||||
|
|||||||
@@ -168,31 +168,23 @@ Submitted CTO hire request and linked it for board review.
|
|||||||
|
|
||||||
## Planning (Required when planning requested)
|
## Planning (Required when planning requested)
|
||||||
|
|
||||||
If you're asked to make a plan, create that plan in your regular way (e.g. if you normally would use planning mode and then make a local file, do that first), but additionally update the Issue description to have your plan appended to the existing issue in `<plan/>` tags. You MUST keep the original Issue description exactly in tact. ONLY add/edit your plan. If you're asked for plan revisions, update your `<plan/>` with the revision. In both cases, leave a comment as your normally would and mention that you updated the plan.
|
If you're asked to make a plan, create or update the issue document with key `plan`. Do not append plans into the issue description anymore. If you're asked for plan revisions, update that same `plan` document. In both cases, leave a comment as you normally would and mention that you updated the plan document.
|
||||||
|
|
||||||
If you're asked to make a plan, _do not mark the issue as done_. Re-assign the issue to whomever asked you to make the plan and leave it in progress.
|
If you're asked to make a plan, _do not mark the issue as done_. Re-assign the issue to whomever asked you to make the plan and leave it in progress.
|
||||||
|
|
||||||
Example:
|
Recommended API flow:
|
||||||
|
|
||||||
Original Issue Description:
|
```bash
|
||||||
|
PUT /api/issues/{issueId}/documents/plan
|
||||||
```
|
{
|
||||||
pls show the costs in either token or dollars on the /issues/{id} page. Make a plan first.
|
"title": "Plan",
|
||||||
|
"format": "markdown",
|
||||||
|
"body": "# Plan\n\n[your plan here]",
|
||||||
|
"baseRevisionId": null
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
After:
|
If `plan` already exists, fetch the current document first and send its latest `baseRevisionId` when you update it.
|
||||||
|
|
||||||
```
|
|
||||||
pls show the costs in either token or dollars on the /issues/{id} page. Make a plan first.
|
|
||||||
|
|
||||||
<plan>
|
|
||||||
|
|
||||||
[your plan here]
|
|
||||||
|
|
||||||
</plan>
|
|
||||||
```
|
|
||||||
|
|
||||||
\*make sure to have a newline after/before your <plan/> tags
|
|
||||||
|
|
||||||
## Setting Agent Instructions Path
|
## Setting Agent Instructions Path
|
||||||
|
|
||||||
@@ -229,6 +221,10 @@ PATCH /api/agents/{agentId}/instructions-path
|
|||||||
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` |
|
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` |
|
||||||
| Checkout task | `POST /api/issues/:issueId/checkout` |
|
| Checkout task | `POST /api/issues/:issueId/checkout` |
|
||||||
| Get task + ancestors | `GET /api/issues/:issueId` |
|
| Get task + ancestors | `GET /api/issues/:issueId` |
|
||||||
|
| List issue documents | `GET /api/issues/:issueId/documents` |
|
||||||
|
| Get issue document | `GET /api/issues/:issueId/documents/:key` |
|
||||||
|
| Create/update issue document | `PUT /api/issues/:issueId/documents/:key` |
|
||||||
|
| Get issue document revisions | `GET /api/issues/:issueId/documents/:key/revisions` |
|
||||||
| Get comments | `GET /api/issues/:issueId/comments` |
|
| Get comments | `GET /api/issues/:issueId/comments` |
|
||||||
| Get specific comment | `GET /api/issues/:issueId/comments/:commentId` |
|
| Get specific comment | `GET /api/issues/:issueId/comments/:commentId` |
|
||||||
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
|
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const api = {
|
|||||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||||
postForm: <T>(path: string, body: FormData) =>
|
postForm: <T>(path: string, body: FormData) =>
|
||||||
request<T>(path, { method: "POST", body }),
|
request<T>(path, { method: "POST", body }),
|
||||||
|
put: <T>(path: string, body: unknown) =>
|
||||||
|
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
patch: <T>(path: string, body: unknown) =>
|
patch: <T>(path: string, body: unknown) =>
|
||||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import type { Approval, Issue, IssueAttachment, IssueComment, IssueLabel } from "@paperclipai/shared";
|
import type {
|
||||||
|
Approval,
|
||||||
|
DocumentRevision,
|
||||||
|
Issue,
|
||||||
|
IssueAttachment,
|
||||||
|
IssueComment,
|
||||||
|
IssueDocument,
|
||||||
|
IssueLabel,
|
||||||
|
UpsertIssueDocument,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
export const issuesApi = {
|
export const issuesApi = {
|
||||||
@@ -53,6 +62,14 @@ export const issuesApi = {
|
|||||||
...(interrupt === undefined ? {} : { interrupt }),
|
...(interrupt === undefined ? {} : { interrupt }),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
listDocuments: (id: string) => api.get<IssueDocument[]>(`/issues/${id}/documents`),
|
||||||
|
getDocument: (id: string, key: string) => api.get<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
||||||
|
upsertDocument: (id: string, key: string, data: UpsertIssueDocument) =>
|
||||||
|
api.put<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`, data),
|
||||||
|
listDocumentRevisions: (id: string, key: string) =>
|
||||||
|
api.get<DocumentRevision[]>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions`),
|
||||||
|
deleteDocument: (id: string, key: string) =>
|
||||||
|
api.delete<{ ok: true }>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
||||||
listAttachments: (id: string) => api.get<IssueAttachment[]>(`/issues/${id}/attachments`),
|
listAttachments: (id: string) => api.get<IssueAttachment[]>(`/issues/${id}/attachments`),
|
||||||
uploadAttachment: (
|
uploadAttachment: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ const ACTION_VERBS: Record<string, string> = {
|
|||||||
"issue.comment_added": "commented on",
|
"issue.comment_added": "commented on",
|
||||||
"issue.attachment_added": "attached file to",
|
"issue.attachment_added": "attached file to",
|
||||||
"issue.attachment_removed": "removed attachment from",
|
"issue.attachment_removed": "removed attachment from",
|
||||||
|
"issue.document_created": "created document for",
|
||||||
|
"issue.document_updated": "updated document on",
|
||||||
|
"issue.document_deleted": "deleted document from",
|
||||||
"issue.commented": "commented on",
|
"issue.commented": "commented on",
|
||||||
"issue.deleted": "deleted",
|
"issue.deleted": "deleted",
|
||||||
"agent.created": "created",
|
"agent.created": "created",
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||||
import { MarkdownBody } from "./MarkdownBody";
|
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
|
||||||
|
|
||||||
interface InlineEditorProps {
|
interface InlineEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
onSave: (value: string) => void;
|
onSave: (value: string) => void | Promise<unknown>;
|
||||||
as?: "h1" | "h2" | "p" | "span";
|
as?: "h1" | "h2" | "p" | "span";
|
||||||
className?: string;
|
className?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -17,6 +16,8 @@ interface InlineEditorProps {
|
|||||||
|
|
||||||
/** Shared padding so display and edit modes occupy the exact same box. */
|
/** Shared padding so display and edit modes occupy the exact same box. */
|
||||||
const pad = "px-1 -mx-1";
|
const pad = "px-1 -mx-1";
|
||||||
|
const markdownPad = "px-1";
|
||||||
|
const AUTOSAVE_DEBOUNCE_MS = 900;
|
||||||
|
|
||||||
export function InlineEditor({
|
export function InlineEditor({
|
||||||
value,
|
value,
|
||||||
@@ -29,12 +30,30 @@ export function InlineEditor({
|
|||||||
mentions,
|
mentions,
|
||||||
}: InlineEditorProps) {
|
}: InlineEditorProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [multilineFocused, setMultilineFocused] = useState(false);
|
||||||
const [draft, setDraft] = useState(value);
|
const [draft, setDraft] = useState(value);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const markdownRef = useRef<MarkdownEditorRef>(null);
|
||||||
|
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const {
|
||||||
|
state: autosaveState,
|
||||||
|
markDirty,
|
||||||
|
reset,
|
||||||
|
runSave,
|
||||||
|
} = useAutosaveIndicator();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (multiline && multilineFocused) return;
|
||||||
setDraft(value);
|
setDraft(value);
|
||||||
}, [value]);
|
}, [value, multiline, multilineFocused]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (autosaveDebounceRef.current) {
|
||||||
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const autoSize = useCallback((el: HTMLTextAreaElement | null) => {
|
const autoSize = useCallback((el: HTMLTextAreaElement | null) => {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -52,59 +71,141 @@ export function InlineEditor({
|
|||||||
}
|
}
|
||||||
}, [editing, autoSize]);
|
}, [editing, autoSize]);
|
||||||
|
|
||||||
function commit() {
|
useEffect(() => {
|
||||||
const trimmed = draft.trim();
|
if (!editing || !multiline) return;
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
markdownRef.current?.focus();
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [editing, multiline]);
|
||||||
|
|
||||||
|
const commit = useCallback(async (nextValue = draft) => {
|
||||||
|
const trimmed = nextValue.trim();
|
||||||
if (trimmed && trimmed !== value) {
|
if (trimmed && trimmed !== value) {
|
||||||
onSave(trimmed);
|
await Promise.resolve(onSave(trimmed));
|
||||||
} else {
|
} else {
|
||||||
setDraft(value);
|
setDraft(value);
|
||||||
}
|
}
|
||||||
|
if (!multiline) {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}
|
}
|
||||||
|
}, [draft, multiline, onSave, value]);
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
if (e.key === "Enter" && !multiline) {
|
if (e.key === "Enter" && !multiline) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
commit();
|
void commit();
|
||||||
}
|
}
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
|
if (autosaveDebounceRef.current) {
|
||||||
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
}
|
||||||
|
reset();
|
||||||
setDraft(value);
|
setDraft(value);
|
||||||
|
if (multiline) {
|
||||||
|
setMultilineFocused(false);
|
||||||
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!multiline) return;
|
||||||
|
if (!multilineFocused) return;
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (!trimmed || trimmed === value) {
|
||||||
|
if (autosaveState !== "saved") {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markDirty();
|
||||||
|
if (autosaveDebounceRef.current) {
|
||||||
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
}
|
||||||
|
autosaveDebounceRef.current = setTimeout(() => {
|
||||||
|
void runSave(() => commit(trimmed));
|
||||||
|
}, AUTOSAVE_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (autosaveDebounceRef.current) {
|
||||||
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, reset, runSave, value]);
|
||||||
|
|
||||||
if (editing) {
|
|
||||||
if (multiline) {
|
if (multiline) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-2", pad)}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
markdownPad,
|
||||||
|
"rounded transition-colors",
|
||||||
|
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
|
||||||
|
)}
|
||||||
|
onFocusCapture={() => setMultilineFocused(true)}
|
||||||
|
onBlurCapture={(event) => {
|
||||||
|
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||||
|
if (autosaveDebounceRef.current) {
|
||||||
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
}
|
||||||
|
setMultilineFocused(false);
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (!trimmed || trimmed === value) {
|
||||||
|
reset();
|
||||||
|
void commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void runSave(() => commit());
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
|
ref={markdownRef}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={setDraft}
|
onChange={setDraft}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
contentClassName={className}
|
bordered={false}
|
||||||
|
className="bg-transparent"
|
||||||
|
contentClassName={cn("paperclip-edit-in-place-content", className)}
|
||||||
imageUploadHandler={imageUploadHandler}
|
imageUploadHandler={imageUploadHandler}
|
||||||
mentions={mentions}
|
mentions={mentions}
|
||||||
onSubmit={commit}
|
onSubmit={() => {
|
||||||
/>
|
const trimmed = draft.trim();
|
||||||
<div className="flex items-center justify-end gap-2">
|
if (!trimmed || trimmed === value) {
|
||||||
<Button
|
reset();
|
||||||
variant="outline"
|
void commit();
|
||||||
size="sm"
|
return;
|
||||||
onClick={() => {
|
}
|
||||||
setDraft(value);
|
void runSave(() => commit());
|
||||||
setEditing(false);
|
|
||||||
}}
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex min-h-4 items-center justify-end pr-1">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] transition-opacity duration-150",
|
||||||
|
autosaveState === "error" ? "text-destructive" : "text-muted-foreground",
|
||||||
|
autosaveState === "idle" ? "opacity-0" : "opacity-100",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Cancel
|
{autosaveState === "saving"
|
||||||
</Button>
|
? "Autosaving..."
|
||||||
<Button size="sm" onClick={commit}>
|
: autosaveState === "saved"
|
||||||
Save
|
? "Saved"
|
||||||
</Button>
|
: autosaveState === "error"
|
||||||
|
? "Could not save"
|
||||||
|
: "Idle"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -114,7 +215,9 @@ export function InlineEditor({
|
|||||||
setDraft(e.target.value);
|
setDraft(e.target.value);
|
||||||
autoSize(e.target);
|
autoSize(e.target);
|
||||||
}}
|
}}
|
||||||
onBlur={commit}
|
onBlur={() => {
|
||||||
|
void commit();
|
||||||
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-transparent rounded outline-none resize-none overflow-hidden",
|
"w-full bg-transparent rounded outline-none resize-none overflow-hidden",
|
||||||
@@ -135,15 +238,11 @@ export function InlineEditor({
|
|||||||
"cursor-pointer rounded hover:bg-accent/50 transition-colors overflow-hidden",
|
"cursor-pointer rounded hover:bg-accent/50 transition-colors overflow-hidden",
|
||||||
pad,
|
pad,
|
||||||
!value && "text-muted-foreground italic",
|
!value && "text-muted-foreground italic",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={() => setEditing(true)}
|
onClick={() => setEditing(true)}
|
||||||
>
|
>
|
||||||
{value && multiline ? (
|
{value || placeholder}
|
||||||
<MarkdownBody>{value}</MarkdownBody>
|
|
||||||
) : (
|
|
||||||
value || placeholder
|
|
||||||
)}
|
|
||||||
</DisplayTag>
|
</DisplayTag>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
510
ui/src/components/IssueDocumentsSection.tsx
Normal file
510
ui/src/components/IssueDocumentsSection.tsx
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { relativeTime } from "../lib/utils";
|
||||||
|
import { MarkdownBody } from "./MarkdownBody";
|
||||||
|
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||||
|
|
||||||
|
type DraftState = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
baseRevisionId: string | null;
|
||||||
|
isNew: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900;
|
||||||
|
|
||||||
|
function renderBody(body: string, className?: string) {
|
||||||
|
return <MarkdownBody className={className}>{body}</MarkdownBody>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlanKey(key: string) {
|
||||||
|
return key.trim().toLowerCase() === "plan";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueDocumentsSection({
|
||||||
|
issue,
|
||||||
|
canDeleteDocuments,
|
||||||
|
mentions,
|
||||||
|
imageUploadHandler,
|
||||||
|
}: {
|
||||||
|
issue: Issue;
|
||||||
|
canDeleteDocuments: boolean;
|
||||||
|
mentions?: MentionOption[];
|
||||||
|
imageUploadHandler?: (file: File) => Promise<string>;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState<DraftState | null>(null);
|
||||||
|
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const {
|
||||||
|
state: autosaveState,
|
||||||
|
markDirty,
|
||||||
|
reset,
|
||||||
|
runSave,
|
||||||
|
} = useAutosaveIndicator();
|
||||||
|
|
||||||
|
const { data: documents } = useQuery({
|
||||||
|
queryKey: queryKeys.issues.documents(issue.id),
|
||||||
|
queryFn: () => issuesApi.listDocuments(issue.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidateIssueDocuments = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertDocument = useMutation({
|
||||||
|
mutationFn: async (nextDraft: DraftState) =>
|
||||||
|
issuesApi.upsertDocument(issue.id, nextDraft.key, {
|
||||||
|
title: isPlanKey(nextDraft.key) ? null : nextDraft.title.trim() || null,
|
||||||
|
format: "markdown",
|
||||||
|
body: nextDraft.body,
|
||||||
|
baseRevisionId: nextDraft.baseRevisionId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteDocument = useMutation({
|
||||||
|
mutationFn: (key: string) => issuesApi.deleteDocument(issue.id, key),
|
||||||
|
onSuccess: () => {
|
||||||
|
setError(null);
|
||||||
|
setConfirmDeleteKey(null);
|
||||||
|
invalidateIssueDocuments();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to delete document");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedDocuments = useMemo(() => {
|
||||||
|
return [...(documents ?? [])].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]);
|
||||||
|
|
||||||
|
const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan");
|
||||||
|
|
||||||
|
const beginNewDocument = () => {
|
||||||
|
reset();
|
||||||
|
setDraft({
|
||||||
|
key: "",
|
||||||
|
title: "",
|
||||||
|
body: "",
|
||||||
|
baseRevisionId: null,
|
||||||
|
isNew: true,
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginEdit = (key: string) => {
|
||||||
|
const doc = sortedDocuments.find((entry) => entry.key === key);
|
||||||
|
if (!doc) return;
|
||||||
|
reset();
|
||||||
|
setDraft({
|
||||||
|
key: doc.key,
|
||||||
|
title: doc.title ?? "",
|
||||||
|
body: doc.body,
|
||||||
|
baseRevisionId: doc.latestRevisionId,
|
||||||
|
isNew: false,
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelDraft = () => {
|
||||||
|
if (autosaveDebounceRef.current) {
|
||||||
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
setDraft(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitDraft = useCallback(async (
|
||||||
|
currentDraft: DraftState | null,
|
||||||
|
options?: { clearAfterSave?: boolean; trackAutosave?: boolean },
|
||||||
|
) => {
|
||||||
|
if (!currentDraft || upsertDocument.isPending) return false;
|
||||||
|
const normalizedKey = currentDraft.key.trim().toLowerCase();
|
||||||
|
const normalizedBody = currentDraft.body.trim();
|
||||||
|
const normalizedTitle = currentDraft.title.trim();
|
||||||
|
|
||||||
|
if (!normalizedKey || !normalizedBody) {
|
||||||
|
if (currentDraft.isNew) {
|
||||||
|
setError("Document key and body are required");
|
||||||
|
} else if (!normalizedBody) {
|
||||||
|
setError("Document body cannot be empty");
|
||||||
|
}
|
||||||
|
if (options?.trackAutosave) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = sortedDocuments.find((doc) => doc.key === normalizedKey);
|
||||||
|
if (
|
||||||
|
!currentDraft.isNew &&
|
||||||
|
existing &&
|
||||||
|
existing.body === currentDraft.body &&
|
||||||
|
(existing.title ?? "") === currentDraft.title
|
||||||
|
) {
|
||||||
|
if (options?.clearAfterSave) {
|
||||||
|
setDraft((value) => (value?.key === normalizedKey ? null : value));
|
||||||
|
}
|
||||||
|
if (options?.trackAutosave) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const saved = await upsertDocument.mutateAsync({
|
||||||
|
...currentDraft,
|
||||||
|
key: normalizedKey,
|
||||||
|
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
||||||
|
body: currentDraft.body,
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
setDraft((value) => {
|
||||||
|
if (!value || value.key !== normalizedKey) return value;
|
||||||
|
if (options?.clearAfterSave) return null;
|
||||||
|
return {
|
||||||
|
key: saved.key,
|
||||||
|
title: saved.title ?? "",
|
||||||
|
body: saved.body,
|
||||||
|
baseRevisionId: saved.latestRevisionId,
|
||||||
|
isNew: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
invalidateIssueDocuments();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (options?.trackAutosave) {
|
||||||
|
await runSave(save);
|
||||||
|
} else {
|
||||||
|
await save();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to save document");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [invalidateIssueDocuments, reset, runSave, sortedDocuments, upsertDocument]);
|
||||||
|
|
||||||
|
const handleDraftBlur = async (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
|
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||||
|
if (autosaveDebounceRef.current) {
|
||||||
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
}
|
||||||
|
await commitDraft(draft, { clearAfterSave: true, trackAutosave: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDraftKeyDown = async (event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelDraft();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (autosaveDebounceRef.current) {
|
||||||
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
}
|
||||||
|
await commitDraft(draft, { clearAfterSave: false, trackAutosave: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (autosaveDebounceRef.current) {
|
||||||
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draft || draft.isNew) return;
|
||||||
|
const existing = sortedDocuments.find((doc) => doc.key === draft.key);
|
||||||
|
if (!existing) return;
|
||||||
|
const hasChanges =
|
||||||
|
existing.body !== draft.body ||
|
||||||
|
(existing.title ?? "") !== draft.title;
|
||||||
|
if (!hasChanges) {
|
||||||
|
if (autosaveState !== "saved") {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markDirty();
|
||||||
|
if (autosaveDebounceRef.current) {
|
||||||
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
}
|
||||||
|
autosaveDebounceRef.current = setTimeout(() => {
|
||||||
|
void commitDraft(draft, { clearAfterSave: false, trackAutosave: true });
|
||||||
|
}, DOCUMENT_AUTOSAVE_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (autosaveDebounceRef.current) {
|
||||||
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [autosaveState, commitDraft, draft, markDirty, reset, sortedDocuments]);
|
||||||
|
|
||||||
|
const documentBodyShellClassName = "mt-3 overflow-hidden rounded-md border border-border bg-background";
|
||||||
|
const documentBodyPaddingClassName = "px-3 py-3";
|
||||||
|
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
||||||
|
<Button variant="outline" size="sm" onClick={beginNewDocument}>
|
||||||
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
New document
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
{draft?.isNew && (
|
||||||
|
<div
|
||||||
|
className="space-y-3 rounded-lg border border-border bg-accent/10 p-3"
|
||||||
|
onBlurCapture={handleDraftBlur}
|
||||||
|
onKeyDown={handleDraftKeyDown}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={draft.key}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => current ? { ...current, key: event.target.value.toLowerCase() } : current)
|
||||||
|
}
|
||||||
|
placeholder="Document key"
|
||||||
|
/>
|
||||||
|
{!isPlanKey(draft.key) && (
|
||||||
|
<Input
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => current ? { ...current, title: event.target.value } : current)
|
||||||
|
}
|
||||||
|
placeholder="Optional title"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MarkdownEditor
|
||||||
|
value={draft.body}
|
||||||
|
onChange={(body) =>
|
||||||
|
setDraft((current) => current ? { ...current, body } : current)
|
||||||
|
}
|
||||||
|
placeholder="Markdown body"
|
||||||
|
bordered={false}
|
||||||
|
className="bg-transparent"
|
||||||
|
contentClassName="min-h-[220px] px-3 py-3 text-[15px] leading-7"
|
||||||
|
mentions={mentions}
|
||||||
|
imageUploadHandler={imageUploadHandler}
|
||||||
|
onSubmit={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={cancelDraft}>
|
||||||
|
<X className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })}
|
||||||
|
disabled={upsertDocument.isPending}
|
||||||
|
>
|
||||||
|
{upsertDocument.isPending ? "Saving..." : "Create document"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sortedDocuments.length === 0 && !issue.legacyPlanDocument ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No documents yet.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!hasRealPlan && issue.legacyPlanDocument ? (
|
||||||
|
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4 text-amber-600" />
|
||||||
|
<span className="rounded-full border border-amber-500/30 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-amber-700 dark:text-amber-300">
|
||||||
|
PLAN
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={documentBodyPaddingClassName}>
|
||||||
|
{renderBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedDocuments.map((doc) => {
|
||||||
|
const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
|
||||||
|
const showTitle = !isPlanKey(doc.key) && !!doc.title?.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={doc.id} className="rounded-lg border border-border p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
|
{doc.key}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
rev {doc.latestRevisionNumber} • updated {relativeTime(doc.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>}
|
||||||
|
</div>
|
||||||
|
{canDeleteDocuments && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
title="Document actions"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setConfirmDeleteKey(doc.key)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete document
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mt-3 space-y-3"
|
||||||
|
onFocusCapture={() => {
|
||||||
|
if (!activeDraft) {
|
||||||
|
beginEdit(doc.key);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlurCapture={async (event) => {
|
||||||
|
if (activeDraft) {
|
||||||
|
await handleDraftBlur(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={async (event) => {
|
||||||
|
if (activeDraft) {
|
||||||
|
await handleDraftKeyDown(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeDraft && !isPlanKey(doc.key) && (
|
||||||
|
<Input
|
||||||
|
value={activeDraft.title}
|
||||||
|
onChange={(event) => {
|
||||||
|
markDirty();
|
||||||
|
setDraft((current) => current ? { ...current, title: event.target.value } : current);
|
||||||
|
}}
|
||||||
|
placeholder="Optional title"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${
|
||||||
|
activeDraft ? "" : "hover:bg-accent/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MarkdownEditor
|
||||||
|
value={activeDraft?.body ?? doc.body}
|
||||||
|
onChange={(body) => {
|
||||||
|
markDirty();
|
||||||
|
setDraft((current) => {
|
||||||
|
if (current && current.key === doc.key && !current.isNew) {
|
||||||
|
return { ...current, body };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: doc.key,
|
||||||
|
title: doc.title ?? "",
|
||||||
|
body,
|
||||||
|
baseRevisionId: doc.latestRevisionId,
|
||||||
|
isNew: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="Markdown body"
|
||||||
|
bordered={false}
|
||||||
|
className="bg-transparent"
|
||||||
|
contentClassName={documentBodyContentClassName}
|
||||||
|
mentions={mentions}
|
||||||
|
imageUploadHandler={imageUploadHandler}
|
||||||
|
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-4 items-center justify-end px-1">
|
||||||
|
<span
|
||||||
|
className={`text-[11px] transition-opacity duration-150 ${
|
||||||
|
autosaveState === "error" ? "text-destructive" : "text-muted-foreground"
|
||||||
|
} ${activeDraft ? "opacity-100" : "opacity-0"}`}
|
||||||
|
>
|
||||||
|
{activeDraft
|
||||||
|
? autosaveState === "saving"
|
||||||
|
? "Autosaving..."
|
||||||
|
: autosaveState === "saved"
|
||||||
|
? "Saved"
|
||||||
|
: autosaveState === "error"
|
||||||
|
? "Could not save"
|
||||||
|
: ""
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirmDeleteKey === doc.key && (
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border border-destructive/20 bg-destructive/5 px-4 py-3">
|
||||||
|
<p className="text-sm text-destructive font-medium">
|
||||||
|
Delete this document? This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setConfirmDeleteKey(null)}
|
||||||
|
disabled={deleteDocument.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteDocument.mutate(doc.key)}
|
||||||
|
disabled={deleteDocument.isPending}
|
||||||
|
>
|
||||||
|
{deleteDocument.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
ui/src/hooks/useAutosaveIndicator.ts
Normal file
72
ui/src/hooks/useAutosaveIndicator.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type AutosaveState = "idle" | "saving" | "saved" | "error";
|
||||||
|
|
||||||
|
const SAVING_DELAY_MS = 250;
|
||||||
|
const SAVED_LINGER_MS = 1600;
|
||||||
|
|
||||||
|
export function useAutosaveIndicator() {
|
||||||
|
const [state, setState] = useState<AutosaveState>("idle");
|
||||||
|
const saveIdRef = useRef(0);
|
||||||
|
const savingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const clearSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const clearTimers = useCallback(() => {
|
||||||
|
if (savingTimerRef.current) {
|
||||||
|
clearTimeout(savingTimerRef.current);
|
||||||
|
savingTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (clearSavedTimerRef.current) {
|
||||||
|
clearTimeout(clearSavedTimerRef.current);
|
||||||
|
clearSavedTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => clearTimers, [clearTimers]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
saveIdRef.current += 1;
|
||||||
|
clearTimers();
|
||||||
|
setState("idle");
|
||||||
|
}, [clearTimers]);
|
||||||
|
|
||||||
|
const markDirty = useCallback(() => {
|
||||||
|
clearTimers();
|
||||||
|
setState("idle");
|
||||||
|
}, [clearTimers]);
|
||||||
|
|
||||||
|
const runSave = useCallback(async (save: () => Promise<void>) => {
|
||||||
|
const saveId = saveIdRef.current + 1;
|
||||||
|
saveIdRef.current = saveId;
|
||||||
|
clearTimers();
|
||||||
|
savingTimerRef.current = setTimeout(() => {
|
||||||
|
if (saveIdRef.current === saveId) {
|
||||||
|
setState("saving");
|
||||||
|
}
|
||||||
|
}, SAVING_DELAY_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await save();
|
||||||
|
if (saveIdRef.current !== saveId) return;
|
||||||
|
clearTimers();
|
||||||
|
setState("saved");
|
||||||
|
clearSavedTimerRef.current = setTimeout(() => {
|
||||||
|
if (saveIdRef.current === saveId) {
|
||||||
|
setState("idle");
|
||||||
|
}
|
||||||
|
}, SAVED_LINGER_MS);
|
||||||
|
} catch (error) {
|
||||||
|
if (saveIdRef.current !== saveId) throw error;
|
||||||
|
clearTimers();
|
||||||
|
setState("error");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [clearTimers]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
markDirty,
|
||||||
|
reset,
|
||||||
|
runSave,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -307,6 +307,11 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.paperclip-edit-in-place-content {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor-content > *:first-child {
|
.paperclip-mdxeditor-content > *:first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
@@ -317,11 +322,11 @@
|
|||||||
|
|
||||||
.paperclip-mdxeditor-content p {
|
.paperclip-mdxeditor-content p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.4;
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor-content p + p {
|
.paperclip-mdxeditor-content p + p {
|
||||||
margin-top: 0.6rem;
|
margin-top: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor-content a.paperclip-project-mention-chip {
|
.paperclip-mdxeditor-content a.paperclip-project-mention-chip {
|
||||||
@@ -342,8 +347,8 @@
|
|||||||
|
|
||||||
.paperclip-mdxeditor-content ul,
|
.paperclip-mdxeditor-content ul,
|
||||||
.paperclip-mdxeditor-content ol {
|
.paperclip-mdxeditor-content ol {
|
||||||
margin: 0.35rem 0;
|
margin: 1.1em 0;
|
||||||
padding-left: 1.1rem;
|
padding-left: 1.6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor-content ul {
|
.paperclip-mdxeditor-content ul {
|
||||||
@@ -356,32 +361,46 @@
|
|||||||
|
|
||||||
.paperclip-mdxeditor-content li {
|
.paperclip-mdxeditor-content li {
|
||||||
display: list-item;
|
display: list-item;
|
||||||
margin: 0.15rem 0;
|
margin: 0.3em 0;
|
||||||
line-height: 1.4;
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor-content li::marker {
|
.paperclip-mdxeditor-content li::marker {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor-content h1,
|
.paperclip-mdxeditor-content h1 {
|
||||||
.paperclip-mdxeditor-content h2,
|
margin: 0 0 0.9em;
|
||||||
.paperclip-mdxeditor-content h3 {
|
font-size: 1.75em;
|
||||||
margin: 0.4rem 0 0.25rem;
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-mdxeditor-content h2 {
|
||||||
|
margin: 0 0 0.85em;
|
||||||
|
font-size: 1.35em;
|
||||||
|
font-weight: 700;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.paperclip-mdxeditor-content h3 {
|
||||||
|
margin: 0 0 0.8em;
|
||||||
|
font-size: 1.15em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor-content img {
|
.paperclip-mdxeditor-content img {
|
||||||
max-height: 18rem;
|
max-height: 18rem;
|
||||||
border-radius: calc(var(--radius) - 2px);
|
border-radius: calc(var(--radius) - 2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor-content blockquote {
|
.paperclip-mdxeditor-content blockquote {
|
||||||
margin: 0.45rem 0;
|
margin: 1.2em 0;
|
||||||
padding-left: 0.7rem;
|
padding-left: 1em;
|
||||||
border-left: 2px solid var(--border);
|
border-left: 3px solid var(--border);
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
line-height: 1.4;
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor-content code {
|
.paperclip-mdxeditor-content code {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export const queryKeys = {
|
|||||||
detail: (id: string) => ["issues", "detail", id] as const,
|
detail: (id: string) => ["issues", "detail", id] as const,
|
||||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||||
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||||
|
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
||||||
|
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,
|
||||||
activity: (issueId: string) => ["issues", "activity", issueId] as const,
|
activity: (issueId: string) => ["issues", "activity", issueId] as const,
|
||||||
runs: (issueId: string) => ["issues", "runs", issueId] as const,
|
runs: (issueId: string) => ["issues", "runs", issueId] as const,
|
||||||
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
|
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
@@ -16,6 +16,7 @@ import { useProjectOrder } from "../hooks/useProjectOrder";
|
|||||||
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
||||||
import { InlineEditor } from "../components/InlineEditor";
|
import { InlineEditor } from "../components/InlineEditor";
|
||||||
import { CommentThread } from "../components/CommentThread";
|
import { CommentThread } from "../components/CommentThread";
|
||||||
|
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||||
import { IssueProperties } from "../components/IssueProperties";
|
import { IssueProperties } from "../components/IssueProperties";
|
||||||
import { LiveRunWidget } from "../components/LiveRunWidget";
|
import { LiveRunWidget } from "../components/LiveRunWidget";
|
||||||
import type { MentionOption } from "../components/MarkdownEditor";
|
import type { MentionOption } from "../components/MarkdownEditor";
|
||||||
@@ -60,6 +61,9 @@ const ACTION_LABELS: Record<string, string> = {
|
|||||||
"issue.comment_added": "added a comment",
|
"issue.comment_added": "added a comment",
|
||||||
"issue.attachment_added": "added an attachment",
|
"issue.attachment_added": "added an attachment",
|
||||||
"issue.attachment_removed": "removed an attachment",
|
"issue.attachment_removed": "removed an attachment",
|
||||||
|
"issue.document_created": "created a document",
|
||||||
|
"issue.document_updated": "updated a document",
|
||||||
|
"issue.document_deleted": "deleted a document",
|
||||||
"issue.deleted": "deleted the issue",
|
"issue.deleted": "deleted the issue",
|
||||||
"agent.created": "created an agent",
|
"agent.created": "created an agent",
|
||||||
"agent.updated": "updated the agent",
|
"agent.updated": "updated the agent",
|
||||||
@@ -97,6 +101,36 @@ function truncate(text: string, max: number): string {
|
|||||||
return text.slice(0, max - 1) + "\u2026";
|
return text.slice(0, max - 1) + "\u2026";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMarkdownFile(file: File) {
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
return (
|
||||||
|
name.endsWith(".md") ||
|
||||||
|
name.endsWith(".markdown") ||
|
||||||
|
file.type === "text/markdown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileBaseName(filename: string) {
|
||||||
|
return filename.replace(/\.[^.]+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugifyDocumentKey(input: string) {
|
||||||
|
const slug = input
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
return slug || "document";
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleizeFilename(input: string) {
|
||||||
|
return input
|
||||||
|
.split(/[-_ ]+/g)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
function formatAction(action: string, details?: Record<string, unknown> | null): string {
|
function formatAction(action: string, details?: Record<string, unknown> | null): string {
|
||||||
if (action === "issue.updated" && details) {
|
if (action === "issue.updated" && details) {
|
||||||
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
||||||
@@ -130,6 +164,14 @@ function formatAction(action: string, details?: Record<string, unknown> | null):
|
|||||||
|
|
||||||
if (parts.length > 0) return parts.join(", ");
|
if (parts.length > 0) return parts.join(", ");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
(action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") &&
|
||||||
|
details
|
||||||
|
) {
|
||||||
|
const key = typeof details.key === "string" ? details.key : "document";
|
||||||
|
const title = typeof details.title === "string" && details.title ? ` (${details.title})` : "";
|
||||||
|
return `${ACTION_LABELS[action] ?? action} ${key}${title}`;
|
||||||
|
}
|
||||||
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
|
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +202,7 @@ export function IssueDetail() {
|
|||||||
cost: false,
|
cost: false,
|
||||||
});
|
});
|
||||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||||
|
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -384,6 +427,7 @@ export function IssueDetail() {
|
|||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
||||||
if (selectedCompanyId) {
|
if (selectedCompanyId) {
|
||||||
@@ -458,6 +502,30 @@ export function IssueDetail() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const importMarkdownDocument = useMutation({
|
||||||
|
mutationFn: async (file: File) => {
|
||||||
|
const baseName = fileBaseName(file.name);
|
||||||
|
const key = slugifyDocumentKey(baseName);
|
||||||
|
const existing = (issue?.documentSummaries ?? []).find((doc) => doc.key === key) ?? null;
|
||||||
|
const body = await file.text();
|
||||||
|
const inferredTitle = titleizeFilename(baseName);
|
||||||
|
const nextTitle = existing?.title ?? inferredTitle ?? null;
|
||||||
|
return issuesApi.upsertDocument(issueId!, key, {
|
||||||
|
title: key === "plan" ? null : nextTitle,
|
||||||
|
format: "markdown",
|
||||||
|
body,
|
||||||
|
baseRevisionId: existing?.latestRevisionId ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setAttachmentError(null);
|
||||||
|
invalidateIssue();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setAttachmentError(err instanceof Error ? err.message : "Document import failed");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const deleteAttachment = useMutation({
|
const deleteAttachment = useMutation({
|
||||||
mutationFn: (attachmentId: string) => issuesApi.deleteAttachment(attachmentId),
|
mutationFn: (attachmentId: string) => issuesApi.deleteAttachment(attachmentId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -509,14 +577,34 @@ export function IssueDetail() {
|
|||||||
const ancestors = issue.ancestors ?? [];
|
const ancestors = issue.ancestors ?? [];
|
||||||
|
|
||||||
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
|
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = evt.target.files?.[0];
|
const files = evt.target.files;
|
||||||
if (!file) return;
|
if (!files || files.length === 0) return;
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
if (isMarkdownFile(file)) {
|
||||||
|
await importMarkdownDocument.mutateAsync(file);
|
||||||
|
} else {
|
||||||
await uploadAttachment.mutateAsync(file);
|
await uploadAttachment.mutateAsync(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAttachmentDrop = async (evt: DragEvent<HTMLDivElement>) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
setAttachmentDragActive(false);
|
||||||
|
const files = evt.dataTransfer.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
if (isMarkdownFile(file)) {
|
||||||
|
await importMarkdownDocument.mutateAsync(file);
|
||||||
|
} else {
|
||||||
|
await uploadAttachment.mutateAsync(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -658,14 +746,14 @@ export function IssueDetail() {
|
|||||||
|
|
||||||
<InlineEditor
|
<InlineEditor
|
||||||
value={issue.title}
|
value={issue.title}
|
||||||
onSave={(title) => updateIssue.mutate({ title })}
|
onSave={(title) => updateIssue.mutateAsync({ title })}
|
||||||
as="h2"
|
as="h2"
|
||||||
className="text-xl font-bold"
|
className="text-xl font-bold"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InlineEditor
|
<InlineEditor
|
||||||
value={issue.description ?? ""}
|
value={issue.description ?? ""}
|
||||||
onSave={(description) => updateIssue.mutate({ description })}
|
onSave={(description) => updateIssue.mutateAsync({ description })}
|
||||||
as="p"
|
as="p"
|
||||||
className="text-[15px] leading-7 text-foreground"
|
className="text-[15px] leading-7 text-foreground"
|
||||||
placeholder="Add a description..."
|
placeholder="Add a description..."
|
||||||
@@ -678,25 +766,62 @@ export function IssueDetail() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<IssueDocumentsSection
|
||||||
|
issue={issue}
|
||||||
|
canDeleteDocuments={Boolean(session?.user?.id)}
|
||||||
|
mentions={mentionOptions}
|
||||||
|
imageUploadHandler={async (file) => {
|
||||||
|
const attachment = await uploadAttachment.mutateAsync(file);
|
||||||
|
return attachment.contentPath;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"space-y-3 rounded-lg transition-colors",
|
||||||
|
)}
|
||||||
|
onDragEnter={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
setAttachmentDragActive(true);
|
||||||
|
}}
|
||||||
|
onDragOver={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
setAttachmentDragActive(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={(evt) => {
|
||||||
|
if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
|
||||||
|
setAttachmentDragActive(false);
|
||||||
|
}}
|
||||||
|
onDrop={(evt) => void handleAttachmentDrop(evt)}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
|
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border border-dashed border-border p-1 transition-colors",
|
||||||
|
attachmentDragActive && "border-primary bg-primary/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
accept="image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFilePicked}
|
onChange={handleFilePicked}
|
||||||
|
multiple
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploadAttachment.isPending}
|
disabled={uploadAttachment.isPending || importMarkdownDocument.isPending}
|
||||||
|
className={cn(
|
||||||
|
"border-transparent bg-transparent shadow-none",
|
||||||
|
attachmentDragActive && "bg-transparent",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
||||||
{uploadAttachment.isPending ? "Uploading..." : "Upload image"}
|
{uploadAttachment.isPending || importMarkdownDocument.isPending ? "Uploading..." : "Upload attachment"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user