Merge pull request #837 from paperclipai/paperclip-issue-documents
feat(issues): add issue documents and inline editing
This commit is contained in:
@@ -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.
|
||||||
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 | null;
|
||||||
|
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().max(524288),
|
||||||
|
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";
|
||||||
@@ -39,6 +42,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 },
|
||||||
@@ -293,7 +297,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
|
||||||
@@ -302,6 +306,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)
|
||||||
@@ -310,6 +315,7 @@ 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,
|
||||||
@@ -389,6 +395,146 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 result = 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,
|
||||||
|
});
|
||||||
|
const doc = result.document;
|
||||||
|
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: issue.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: result.created ? "issue.document_created" : "issue.document_updated",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: {
|
||||||
|
key: doc.key,
|
||||||
|
documentId: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
format: doc.format,
|
||||||
|
revisionNumber: doc.latestRevisionNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(result.created ? 201 : 200).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);
|
||||||
|
|||||||
433
server/src/services/documents.ts
Normal file
433
server/src/services/documents.ts
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
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 ?? null,
|
||||||
|
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 {
|
||||||
|
created: false as const,
|
||||||
|
document: {
|
||||||
|
...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 {
|
||||||
|
created: true as const,
|
||||||
|
document: {
|
||||||
|
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 ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import {
|
|||||||
assets,
|
assets,
|
||||||
companies,
|
companies,
|
||||||
companyMemberships,
|
companyMemberships,
|
||||||
|
documents,
|
||||||
goals,
|
goals,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
issueAttachments,
|
issueAttachments,
|
||||||
issueLabels,
|
issueLabels,
|
||||||
issueComments,
|
issueComments,
|
||||||
|
issueDocuments,
|
||||||
issueReadStates,
|
issueReadStates,
|
||||||
issues,
|
issues,
|
||||||
labels,
|
labels,
|
||||||
@@ -790,6 +792,10 @@ export function issueService(db: Db) {
|
|||||||
.select({ assetId: issueAttachments.assetId })
|
.select({ assetId: issueAttachments.assetId })
|
||||||
.from(issueAttachments)
|
.from(issueAttachments)
|
||||||
.where(eq(issueAttachments.issueId, id));
|
.where(eq(issueAttachments.issueId, id));
|
||||||
|
const issueDocumentIds = await tx
|
||||||
|
.select({ documentId: issueDocuments.documentId })
|
||||||
|
.from(issueDocuments)
|
||||||
|
.where(eq(issueDocuments.issueId, id));
|
||||||
|
|
||||||
const removedIssue = await tx
|
const removedIssue = await tx
|
||||||
.delete(issues)
|
.delete(issues)
|
||||||
@@ -803,6 +809,12 @@ export function issueService(db: Db) {
|
|||||||
.where(inArray(assets.id, attachmentAssetIds.map((row) => row.assetId)));
|
.where(inArray(assets.id, attachmentAssetIds.map((row) => row.assetId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (removedIssue && issueDocumentIds.length > 0) {
|
||||||
|
await tx
|
||||||
|
.delete(documents)
|
||||||
|
.where(inArray(documents.id, issueDocumentIds.map((row) => row.documentId)));
|
||||||
|
}
|
||||||
|
|
||||||
if (!removedIssue) return null;
|
if (!removedIssue) return null;
|
||||||
const [enriched] = await withIssueLabels(tx, [removedIssue]);
|
const [enriched] = await withIssueLabels(tx, [removedIssue]);
|
||||||
return enriched;
|
return enriched;
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ When posting issue comments, use concise markdown with:
|
|||||||
|
|
||||||
- Issues: `/<prefix>/issues/<issue-identifier>` (e.g., `/PAP/issues/PAP-224`)
|
- Issues: `/<prefix>/issues/<issue-identifier>` (e.g., `/PAP/issues/PAP-224`)
|
||||||
- Issue comments: `/<prefix>/issues/<issue-identifier>#comment-<comment-id>` (deep link to a specific comment)
|
- Issue comments: `/<prefix>/issues/<issue-identifier>#comment-<comment-id>` (deep link to a specific comment)
|
||||||
|
- Issue documents: `/<prefix>/issues/<issue-identifier>#document-<document-key>` (deep link to a specific document such as `plan`)
|
||||||
- Agents: `/<prefix>/agents/<agent-url-key>` (e.g., `/PAP/agents/claudecoder`)
|
- Agents: `/<prefix>/agents/<agent-url-key>` (e.g., `/PAP/agents/claudecoder`)
|
||||||
- Projects: `/<prefix>/projects/<project-url-key>` (id fallback allowed)
|
- Projects: `/<prefix>/projects/<project-url-key>` (id fallback allowed)
|
||||||
- Approvals: `/<prefix>/approvals/<approval-id>`
|
- Approvals: `/<prefix>/approvals/<approval-id>`
|
||||||
@@ -175,31 +176,30 @@ 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.
|
||||||
|
|
||||||
|
When you mention a plan or another issue document in a comment, include a direct document link using the key:
|
||||||
|
|
||||||
|
- Plan: `/<prefix>/issues/<issue-identifier>#document-plan`
|
||||||
|
- Generic document: `/<prefix>/issues/<issue-identifier>#document-<document-key>`
|
||||||
|
|
||||||
|
If the issue identifier is available, prefer the document deep link over a plain issue link so the reader lands directly on the updated 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
|
||||||
|
|
||||||
@@ -237,6 +237,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 compact heartbeat context | `GET /api/issues/:issueId/heartbeat-context` |
|
| Get compact heartbeat context | `GET /api/issues/:issueId/heartbeat-context` |
|
||||||
| Get comments | `GET /api/issues/:issueId/comments` |
|
| Get comments | `GET /api/issues/:issueId/comments` |
|
||||||
| Get comment delta | `GET /api/issues/:issueId/comments?after=:commentId&order=asc` |
|
| Get comment delta | `GET /api/issues/:issueId/comments?after=:commentId&order=asc` |
|
||||||
|
|||||||
@@ -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,58 +71,140 @@ 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);
|
||||||
}
|
}
|
||||||
setEditing(false);
|
if (!multiline) {
|
||||||
}
|
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);
|
||||||
setEditing(false);
|
if (multiline) {
|
||||||
|
setMultilineFocused(false);
|
||||||
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editing) {
|
useEffect(() => {
|
||||||
if (multiline) {
|
if (!multiline) return;
|
||||||
return (
|
if (!multilineFocused) return;
|
||||||
<div className={cn("space-y-2", pad)}>
|
const trimmed = draft.trim();
|
||||||
<MarkdownEditor
|
if (!trimmed || trimmed === value) {
|
||||||
value={draft}
|
if (autosaveState !== "saved") {
|
||||||
onChange={setDraft}
|
reset();
|
||||||
placeholder={placeholder}
|
}
|
||||||
contentClassName={className}
|
return;
|
||||||
imageUploadHandler={imageUploadHandler}
|
|
||||||
mentions={mentions}
|
|
||||||
onSubmit={commit}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setDraft(value);
|
|
||||||
setEditing(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={commit}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
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 (multiline) {
|
||||||
|
return (
|
||||||
|
<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
|
||||||
|
ref={markdownRef}
|
||||||
|
value={draft}
|
||||||
|
onChange={setDraft}
|
||||||
|
placeholder={placeholder}
|
||||||
|
bordered={false}
|
||||||
|
className="bg-transparent"
|
||||||
|
contentClassName={cn("paperclip-edit-in-place-content", className)}
|
||||||
|
imageUploadHandler={imageUploadHandler}
|
||||||
|
mentions={mentions}
|
||||||
|
onSubmit={() => {
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (!trimmed || trimmed === value) {
|
||||||
|
reset();
|
||||||
|
void commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void runSave(() => commit());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{autosaveState === "saving"
|
||||||
|
? "Autosaving..."
|
||||||
|
: autosaveState === "saved"
|
||||||
|
? "Saved"
|
||||||
|
: autosaveState === "error"
|
||||||
|
? "Could not save"
|
||||||
|
: "Idle"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
889
ui/src/components/IssueDocumentsSection.tsx
Normal file
889
ui/src/components/IssueDocumentsSection.tsx
Normal file
@@ -0,0 +1,889 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Issue, IssueDocument } from "@paperclipai/shared";
|
||||||
|
import { useLocation } from "@/lib/router";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { cn, 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 { Check, ChevronDown, ChevronRight, Copy, Download, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||||
|
|
||||||
|
type DraftState = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
baseRevisionId: string | null;
|
||||||
|
isNew: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocumentConflictState = {
|
||||||
|
key: string;
|
||||||
|
serverDocument: IssueDocument;
|
||||||
|
localDraft: DraftState;
|
||||||
|
showRemote: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900;
|
||||||
|
const DOCUMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
||||||
|
const getFoldedDocumentsStorageKey = (issueId: string) => `paperclip:issue-document-folds:${issueId}`;
|
||||||
|
|
||||||
|
function loadFoldedDocumentKeys(issueId: string) {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(getFoldedDocumentsStorageKey(issueId));
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBody(body: string, className?: string) {
|
||||||
|
return <MarkdownBody className={className}>{body}</MarkdownBody>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlanKey(key: string) {
|
||||||
|
return key.trim().toLowerCase() === "plan";
|
||||||
|
}
|
||||||
|
|
||||||
|
function titlesMatchKey(title: string | null | undefined, key: string) {
|
||||||
|
return (title ?? "").trim().toLowerCase() === key.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDocumentConflictError(error: unknown) {
|
||||||
|
return error instanceof ApiError && error.status === 409;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadDocumentFile(key: string, body: string) {
|
||||||
|
const blob = new Blob([body], { type: "text/markdown;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `${key}.md`;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueDocumentsSection({
|
||||||
|
issue,
|
||||||
|
canDeleteDocuments,
|
||||||
|
mentions,
|
||||||
|
imageUploadHandler,
|
||||||
|
extraActions,
|
||||||
|
}: {
|
||||||
|
issue: Issue;
|
||||||
|
canDeleteDocuments: boolean;
|
||||||
|
mentions?: MentionOption[];
|
||||||
|
imageUploadHandler?: (file: File) => Promise<string>;
|
||||||
|
extraActions?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const location = useLocation();
|
||||||
|
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState<DraftState | null>(null);
|
||||||
|
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
|
||||||
|
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(issue.id));
|
||||||
|
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
|
||||||
|
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
|
||||||
|
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
||||||
|
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const hasScrolledToHashRef = useRef(false);
|
||||||
|
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 isEmpty = sortedDocuments.length === 0 && !issue.legacyPlanDocument;
|
||||||
|
const newDocumentKeyError =
|
||||||
|
draft?.isNew && draft.key.trim().length > 0 && !DOCUMENT_KEY_PATTERN.test(draft.key.trim())
|
||||||
|
? "Use lowercase letters, numbers, -, or _, and start with a letter or number."
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const resetAutosaveState = useCallback(() => {
|
||||||
|
setAutosaveDocumentKey(null);
|
||||||
|
reset();
|
||||||
|
}, [reset]);
|
||||||
|
|
||||||
|
const markDocumentDirty = useCallback((key: string) => {
|
||||||
|
setAutosaveDocumentKey(key);
|
||||||
|
markDirty();
|
||||||
|
}, [markDirty]);
|
||||||
|
|
||||||
|
const beginNewDocument = () => {
|
||||||
|
resetAutosaveState();
|
||||||
|
setDocumentConflict(null);
|
||||||
|
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;
|
||||||
|
const conflictedDraft = documentConflict?.key === key ? documentConflict.localDraft : null;
|
||||||
|
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key));
|
||||||
|
resetAutosaveState();
|
||||||
|
setDocumentConflict((current) => current?.key === key ? current : null);
|
||||||
|
setDraft({
|
||||||
|
key: conflictedDraft?.key ?? doc.key,
|
||||||
|
title: conflictedDraft?.title ?? doc.title ?? "",
|
||||||
|
body: conflictedDraft?.body ?? doc.body,
|
||||||
|
baseRevisionId: conflictedDraft?.baseRevisionId ?? doc.latestRevisionId,
|
||||||
|
isNew: false,
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelDraft = () => {
|
||||||
|
if (autosaveDebounceRef.current) {
|
||||||
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
}
|
||||||
|
resetAutosaveState();
|
||||||
|
setDocumentConflict(null);
|
||||||
|
setDraft(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitDraft = useCallback(async (
|
||||||
|
currentDraft: DraftState | null,
|
||||||
|
options?: { clearAfterSave?: boolean; trackAutosave?: boolean; overrideConflict?: boolean },
|
||||||
|
) => {
|
||||||
|
if (!currentDraft || upsertDocument.isPending) return false;
|
||||||
|
const normalizedKey = currentDraft.key.trim().toLowerCase();
|
||||||
|
const normalizedBody = currentDraft.body.trim();
|
||||||
|
const normalizedTitle = currentDraft.title.trim();
|
||||||
|
const activeConflict = documentConflict?.key === normalizedKey ? documentConflict : null;
|
||||||
|
|
||||||
|
if (activeConflict && !options?.overrideConflict) {
|
||||||
|
if (options?.trackAutosave) {
|
||||||
|
resetAutosaveState();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
resetAutosaveState();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DOCUMENT_KEY_PATTERN.test(normalizedKey)) {
|
||||||
|
setError("Document key must start with a letter or number and use only lowercase letters, numbers, -, or _.");
|
||||||
|
if (options?.trackAutosave) {
|
||||||
|
resetAutosaveState();
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
resetAutosaveState();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const saved = await upsertDocument.mutateAsync({
|
||||||
|
...currentDraft,
|
||||||
|
key: normalizedKey,
|
||||||
|
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
||||||
|
body: currentDraft.body,
|
||||||
|
baseRevisionId: options?.overrideConflict
|
||||||
|
? activeConflict?.serverDocument.latestRevisionId ?? currentDraft.baseRevisionId
|
||||||
|
: currentDraft.baseRevisionId,
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
setDocumentConflict((current) => current?.key === normalizedKey ? null : current);
|
||||||
|
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) {
|
||||||
|
setAutosaveDocumentKey(normalizedKey);
|
||||||
|
await runSave(save);
|
||||||
|
} else {
|
||||||
|
await save();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (isDocumentConflictError(err)) {
|
||||||
|
try {
|
||||||
|
const latestDocument = await issuesApi.getDocument(issue.id, normalizedKey);
|
||||||
|
setDocumentConflict({
|
||||||
|
key: normalizedKey,
|
||||||
|
serverDocument: latestDocument,
|
||||||
|
localDraft: {
|
||||||
|
key: normalizedKey,
|
||||||
|
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
||||||
|
body: currentDraft.body,
|
||||||
|
baseRevisionId: currentDraft.baseRevisionId,
|
||||||
|
isNew: false,
|
||||||
|
},
|
||||||
|
showRemote: true,
|
||||||
|
});
|
||||||
|
setFoldedDocumentKeys((current) => current.filter((key) => key !== normalizedKey));
|
||||||
|
setError(null);
|
||||||
|
resetAutosaveState();
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
setError("Document changed remotely and the latest version could not be loaded");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to save document");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, upsertDocument]);
|
||||||
|
|
||||||
|
const reloadDocumentFromServer = useCallback((key: string) => {
|
||||||
|
if (documentConflict?.key !== key) return;
|
||||||
|
const serverDocument = documentConflict.serverDocument;
|
||||||
|
setDraft({
|
||||||
|
key: serverDocument.key,
|
||||||
|
title: serverDocument.title ?? "",
|
||||||
|
body: serverDocument.body,
|
||||||
|
baseRevisionId: serverDocument.latestRevisionId,
|
||||||
|
isNew: false,
|
||||||
|
});
|
||||||
|
setDocumentConflict(null);
|
||||||
|
resetAutosaveState();
|
||||||
|
setError(null);
|
||||||
|
}, [documentConflict, resetAutosaveState]);
|
||||||
|
|
||||||
|
const overwriteDocumentFromDraft = useCallback(async (key: string) => {
|
||||||
|
if (documentConflict?.key !== key) return;
|
||||||
|
const sourceDraft =
|
||||||
|
draft && draft.key === key && !draft.isNew
|
||||||
|
? draft
|
||||||
|
: documentConflict.localDraft;
|
||||||
|
await commitDraft(
|
||||||
|
{
|
||||||
|
...sourceDraft,
|
||||||
|
baseRevisionId: documentConflict.serverDocument.latestRevisionId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clearAfterSave: false,
|
||||||
|
trackAutosave: true,
|
||||||
|
overrideConflict: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [commitDraft, documentConflict, draft]);
|
||||||
|
|
||||||
|
const keepConflictedDraft = useCallback((key: string) => {
|
||||||
|
if (documentConflict?.key !== key) return;
|
||||||
|
setDraft(documentConflict.localDraft);
|
||||||
|
setDocumentConflict((current) =>
|
||||||
|
current?.key === key
|
||||||
|
? { ...current, showRemote: false }
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
setError(null);
|
||||||
|
}, [documentConflict]);
|
||||||
|
|
||||||
|
const copyDocumentBody = useCallback(async (key: string, body: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(body);
|
||||||
|
setCopiedDocumentKey(key);
|
||||||
|
if (copiedDocumentTimerRef.current) {
|
||||||
|
clearTimeout(copiedDocumentTimerRef.current);
|
||||||
|
}
|
||||||
|
copiedDocumentTimerRef.current = setTimeout(() => {
|
||||||
|
setCopiedDocumentKey((current) => current === key ? null : current);
|
||||||
|
}, 1400);
|
||||||
|
} catch {
|
||||||
|
setError("Could not copy document");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
setFoldedDocumentKeys(loadFoldedDocumentKeys(issue.id));
|
||||||
|
}, [issue.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hasScrolledToHashRef.current = false;
|
||||||
|
}, [issue.id, location.hash]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const validKeys = new Set(sortedDocuments.map((doc) => doc.key));
|
||||||
|
setFoldedDocumentKeys((current) => {
|
||||||
|
const next = current.filter((key) => validKeys.has(key));
|
||||||
|
if (next.length !== current.length) {
|
||||||
|
saveFoldedDocumentKeys(issue.id, next);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [issue.id, sortedDocuments]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveFoldedDocumentKeys(issue.id, foldedDocumentKeys);
|
||||||
|
}, [foldedDocumentKeys, issue.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!documentConflict) return;
|
||||||
|
const latest = sortedDocuments.find((doc) => doc.key === documentConflict.key);
|
||||||
|
if (!latest || latest.latestRevisionId === documentConflict.serverDocument.latestRevisionId) return;
|
||||||
|
setDocumentConflict((current) =>
|
||||||
|
current?.key === latest.key
|
||||||
|
? { ...current, serverDocument: latest }
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
}, [documentConflict, sortedDocuments]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = location.hash;
|
||||||
|
if (!hash.startsWith("#document-")) return;
|
||||||
|
const documentKey = decodeURIComponent(hash.slice("#document-".length));
|
||||||
|
const targetExists = sortedDocuments.some((doc) => doc.key === documentKey)
|
||||||
|
|| (documentKey === "plan" && Boolean(issue.legacyPlanDocument));
|
||||||
|
if (!targetExists || hasScrolledToHashRef.current) return;
|
||||||
|
setFoldedDocumentKeys((current) => current.filter((key) => key !== documentKey));
|
||||||
|
const element = document.getElementById(`document-${documentKey}`);
|
||||||
|
if (!element) return;
|
||||||
|
hasScrolledToHashRef.current = true;
|
||||||
|
setHighlightDocumentKey(documentKey);
|
||||||
|
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
const timer = setTimeout(() => setHighlightDocumentKey((current) => current === documentKey ? null : current), 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [issue.legacyPlanDocument, location.hash, sortedDocuments]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (autosaveDebounceRef.current) {
|
||||||
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
}
|
||||||
|
if (copiedDocumentTimerRef.current) {
|
||||||
|
clearTimeout(copiedDocumentTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draft || draft.isNew) return;
|
||||||
|
if (documentConflict?.key === draft.key) 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") {
|
||||||
|
resetAutosaveState();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markDocumentDirty(draft.key);
|
||||||
|
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, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]);
|
||||||
|
|
||||||
|
const documentBodyShellClassName = "mt-3 overflow-hidden rounded-md";
|
||||||
|
const documentBodyPaddingClassName = "";
|
||||||
|
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7";
|
||||||
|
const toggleFoldedDocument = (key: string) => {
|
||||||
|
setFoldedDocumentKeys((current) =>
|
||||||
|
current.includes(key)
|
||||||
|
? current.filter((entry) => entry !== key)
|
||||||
|
: [...current, key],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isEmpty && !draft?.isNew ? (
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{extraActions}
|
||||||
|
<Button variant="outline" size="sm" onClick={beginNewDocument}>
|
||||||
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
New document
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{extraActions}
|
||||||
|
<Button variant="outline" size="sm" onClick={beginNewDocument}>
|
||||||
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
New document
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
{newDocumentKeyError && (
|
||||||
|
<p className="text-xs text-destructive">{newDocumentKeyError}</p>
|
||||||
|
)}
|
||||||
|
{!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] 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasRealPlan && issue.legacyPlanDocument ? (
|
||||||
|
<div
|
||||||
|
id="document-plan"
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 transition-colors duration-1000",
|
||||||
|
highlightDocumentKey === "plan" && "border-primary/50 bg-primary/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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 activeConflict = documentConflict?.key === doc.key ? documentConflict : null;
|
||||||
|
const isFolded = foldedDocumentKeys.includes(doc.key);
|
||||||
|
const showTitle = !isPlanKey(doc.key) && !!doc.title?.trim() && !titlesMatchKey(doc.title, doc.key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
id={`document-${doc.key}`}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-border p-3 transition-colors duration-1000",
|
||||||
|
highlightDocumentKey === doc.key && "border-primary/50 bg-primary/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-5 w-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
onClick={() => toggleFoldedDocument(doc.key)}
|
||||||
|
aria-label={isFolded ? `Expand ${doc.key} document` : `Collapse ${doc.key} document`}
|
||||||
|
aria-expanded={!isFolded}
|
||||||
|
>
|
||||||
|
{isFolded ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
<a
|
||||||
|
href={`#document-${encodeURIComponent(doc.key)}`}
|
||||||
|
className="text-[11px] text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
rev {doc.latestRevisionNumber} • updated {relativeTime(doc.updatedAt)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground transition-colors",
|
||||||
|
copiedDocumentKey === doc.key && "text-foreground",
|
||||||
|
)}
|
||||||
|
title={copiedDocumentKey === doc.key ? "Copied" : "Copy document"}
|
||||||
|
onClick={() => void copyDocumentBody(doc.key, activeDraft?.body ?? doc.body)}
|
||||||
|
>
|
||||||
|
{copiedDocumentKey === doc.key ? (
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<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">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => downloadDocumentFile(doc.key, activeDraft?.body ?? doc.body)}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
Download document
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{canDeleteDocuments ? <DropdownMenuSeparator /> : null}
|
||||||
|
{canDeleteDocuments ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setConfirmDeleteKey(doc.key)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete document
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isFolded ? (
|
||||||
|
<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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeConflict && (
|
||||||
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-amber-200">Out of date</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This document changed while you were editing. Your local draft is preserved and autosave is paused.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setDocumentConflict((current) =>
|
||||||
|
current?.key === doc.key
|
||||||
|
? { ...current, showRemote: !current.showRemote }
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{activeConflict.showRemote ? "Hide remote" : "Review remote"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => keepConflictedDraft(doc.key)}
|
||||||
|
>
|
||||||
|
Keep my draft
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => reloadDocumentFromServer(doc.key)}
|
||||||
|
>
|
||||||
|
Reload remote
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void overwriteDocumentFromDraft(doc.key)}
|
||||||
|
disabled={upsertDocument.isPending}
|
||||||
|
>
|
||||||
|
{upsertDocument.isPending ? "Saving..." : "Overwrite remote"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{activeConflict.showRemote && (
|
||||||
|
<div className="mt-3 rounded-md border border-border/70 bg-background/60 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||||
|
<span>Remote revision {activeConflict.serverDocument.latestRevisionNumber}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>updated {relativeTime(activeConflict.serverDocument.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
{!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (
|
||||||
|
<p className="mb-2 text-sm font-medium">{activeConflict.serverDocument.title}</p>
|
||||||
|
) : null}
|
||||||
|
{renderBody(activeConflict.serverDocument.body, "text-[14px] leading-7")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeDraft && !isPlanKey(doc.key) && (
|
||||||
|
<Input
|
||||||
|
value={activeDraft.title}
|
||||||
|
onChange={(event) => {
|
||||||
|
markDocumentDirty(doc.key);
|
||||||
|
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) => {
|
||||||
|
markDocumentDirty(doc.key);
|
||||||
|
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 ${
|
||||||
|
activeConflict
|
||||||
|
? "text-amber-300"
|
||||||
|
: autosaveState === "error"
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
} ${activeDraft ? "opacity-100" : "opacity-0"}`}
|
||||||
|
>
|
||||||
|
{activeDraft
|
||||||
|
? activeConflict
|
||||||
|
? "Out of date"
|
||||||
|
: autosaveDocumentKey === doc.key
|
||||||
|
? autosaveState === "saving"
|
||||||
|
? "Autosaving..."
|
||||||
|
: autosaveState === "saved"
|
||||||
|
? "Saved"
|
||||||
|
: autosaveState === "error"
|
||||||
|
? "Could not save"
|
||||||
|
: ""
|
||||||
|
: ""
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
@@ -10,6 +10,7 @@ import { assetsApi } from "../api/assets";
|
|||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
import {
|
import {
|
||||||
assigneeValueFromSelection,
|
assigneeValueFromSelection,
|
||||||
currentUserAssigneeOption,
|
currentUserAssigneeOption,
|
||||||
@@ -39,7 +40,9 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Calendar,
|
Calendar,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { extractProviderIdWithFallback } from "../lib/model-utils";
|
import { extractProviderIdWithFallback } from "../lib/model-utils";
|
||||||
@@ -77,7 +80,16 @@ interface IssueDraft {
|
|||||||
useIsolatedExecutionWorkspace: boolean;
|
useIsolatedExecutionWorkspace: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StagedIssueFile = {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
kind: "document" | "attachment";
|
||||||
|
documentKey?: string;
|
||||||
|
title?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
||||||
|
const STAGED_FILE_ACCEPT = "image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown";
|
||||||
|
|
||||||
const ISSUE_THINKING_EFFORT_OPTIONS = {
|
const ISSUE_THINKING_EFFORT_OPTIONS = {
|
||||||
claude_local: [
|
claude_local: [
|
||||||
@@ -156,6 +168,59 @@ function clearDraft() {
|
|||||||
localStorage.removeItem(DRAFT_KEY);
|
localStorage.removeItem(DRAFT_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTextDocumentFile(file: File) {
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
return (
|
||||||
|
name.endsWith(".md") ||
|
||||||
|
name.endsWith(".markdown") ||
|
||||||
|
name.endsWith(".txt") ||
|
||||||
|
file.type === "text/markdown" ||
|
||||||
|
file.type === "text/plain"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 createUniqueDocumentKey(baseKey: string, stagedFiles: StagedIssueFile[]) {
|
||||||
|
const existingKeys = new Set(
|
||||||
|
stagedFiles
|
||||||
|
.filter((file) => file.kind === "document")
|
||||||
|
.map((file) => file.documentKey)
|
||||||
|
.filter((key): key is string => Boolean(key)),
|
||||||
|
);
|
||||||
|
if (!existingKeys.has(baseKey)) return baseKey;
|
||||||
|
let suffix = 2;
|
||||||
|
while (existingKeys.has(`${baseKey}-${suffix}`)) {
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
return `${baseKey}-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(file: File) {
|
||||||
|
if (file.size < 1024) return `${file.size} B`;
|
||||||
|
if (file.size < 1024 * 1024) return `${(file.size / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(file.size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
const statuses = [
|
const statuses = [
|
||||||
{ value: "backlog", label: "Backlog", color: issueStatusText.backlog ?? issueStatusTextDefault },
|
{ value: "backlog", label: "Backlog", color: issueStatusText.backlog ?? issueStatusTextDefault },
|
||||||
{ value: "todo", label: "Todo", color: issueStatusText.todo ?? issueStatusTextDefault },
|
{ value: "todo", label: "Todo", color: issueStatusText.todo ?? issueStatusTextDefault },
|
||||||
@@ -175,6 +240,7 @@ export function NewIssueDialog() {
|
|||||||
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
||||||
const { companies, selectedCompanyId, selectedCompany } = useCompany();
|
const { companies, selectedCompanyId, selectedCompany } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { pushToast } = useToast();
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [status, setStatus] = useState("todo");
|
const [status, setStatus] = useState("todo");
|
||||||
@@ -188,6 +254,8 @@ export function NewIssueDialog() {
|
|||||||
const [useIsolatedExecutionWorkspace, setUseIsolatedExecutionWorkspace] = useState(false);
|
const [useIsolatedExecutionWorkspace, setUseIsolatedExecutionWorkspace] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
|
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
|
||||||
|
const [stagedFiles, setStagedFiles] = useState<StagedIssueFile[]>([]);
|
||||||
|
const [isFileDragOver, setIsFileDragOver] = useState(false);
|
||||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const executionWorkspaceDefaultProjectId = useRef<string | null>(null);
|
const executionWorkspaceDefaultProjectId = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -200,7 +268,7 @@ export function NewIssueDialog() {
|
|||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
const [companyOpen, setCompanyOpen] = useState(false);
|
const [companyOpen, setCompanyOpen] = useState(false);
|
||||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
const stageFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
|
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
@@ -268,11 +336,49 @@ export function NewIssueDialog() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createIssue = useMutation({
|
const createIssue = useMutation({
|
||||||
mutationFn: ({ companyId, ...data }: { companyId: string } & Record<string, unknown>) =>
|
mutationFn: async ({
|
||||||
issuesApi.create(companyId, data),
|
companyId,
|
||||||
onSuccess: () => {
|
stagedFiles: pendingStagedFiles,
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) });
|
...data
|
||||||
|
}: { companyId: string; stagedFiles: StagedIssueFile[] } & Record<string, unknown>) => {
|
||||||
|
const issue = await issuesApi.create(companyId, data);
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
for (const stagedFile of pendingStagedFiles) {
|
||||||
|
try {
|
||||||
|
if (stagedFile.kind === "document") {
|
||||||
|
const body = await stagedFile.file.text();
|
||||||
|
await issuesApi.upsertDocument(issue.id, stagedFile.documentKey ?? "document", {
|
||||||
|
title: stagedFile.documentKey === "plan" ? null : stagedFile.title ?? null,
|
||||||
|
format: "markdown",
|
||||||
|
body,
|
||||||
|
baseRevisionId: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await issuesApi.uploadAttachment(companyId, issue.id, stagedFile.file);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
failures.push(stagedFile.file.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { issue, companyId, failures };
|
||||||
|
},
|
||||||
|
onSuccess: ({ issue, companyId, failures }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||||
|
if (failures.length > 0) {
|
||||||
|
const prefix = (companies.find((company) => company.id === companyId)?.issuePrefix ?? "").trim();
|
||||||
|
const issueRef = issue.identifier ?? issue.id;
|
||||||
|
pushToast({
|
||||||
|
title: `Created ${issueRef} with upload warnings`,
|
||||||
|
body: `${failures.length} staged ${failures.length === 1 ? "file" : "files"} could not be added.`,
|
||||||
|
tone: "warn",
|
||||||
|
action: prefix
|
||||||
|
? { label: `Open ${issueRef}`, href: `/${prefix}/issues/${issueRef}` }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
clearDraft();
|
clearDraft();
|
||||||
reset();
|
reset();
|
||||||
closeNewIssue();
|
closeNewIssue();
|
||||||
@@ -413,6 +519,8 @@ export function NewIssueDialog() {
|
|||||||
setUseIsolatedExecutionWorkspace(false);
|
setUseIsolatedExecutionWorkspace(false);
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
setDialogCompanyId(null);
|
setDialogCompanyId(null);
|
||||||
|
setStagedFiles([]);
|
||||||
|
setIsFileDragOver(false);
|
||||||
setCompanyOpen(false);
|
setCompanyOpen(false);
|
||||||
executionWorkspaceDefaultProjectId.current = null;
|
executionWorkspaceDefaultProjectId.current = null;
|
||||||
}
|
}
|
||||||
@@ -453,6 +561,7 @@ export function NewIssueDialog() {
|
|||||||
: null;
|
: null;
|
||||||
createIssue.mutate({
|
createIssue.mutate({
|
||||||
companyId: effectiveCompanyId,
|
companyId: effectiveCompanyId,
|
||||||
|
stagedFiles,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
status,
|
status,
|
||||||
@@ -472,22 +581,70 @@ export function NewIssueDialog() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAttachImage(evt: ChangeEvent<HTMLInputElement>) {
|
function stageFiles(files: File[]) {
|
||||||
const file = evt.target.files?.[0];
|
if (files.length === 0) return;
|
||||||
if (!file) return;
|
setStagedFiles((current) => {
|
||||||
try {
|
const next = [...current];
|
||||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
for (const file of files) {
|
||||||
const name = file.name || "image";
|
if (isTextDocumentFile(file)) {
|
||||||
setDescription((prev) => {
|
const baseName = fileBaseName(file.name);
|
||||||
const suffix = ``;
|
const documentKey = createUniqueDocumentKey(slugifyDocumentKey(baseName), next);
|
||||||
return prev ? `${prev}\n\n${suffix}` : suffix;
|
next.push({
|
||||||
});
|
id: `${file.name}:${file.size}:${file.lastModified}:${documentKey}`,
|
||||||
} finally {
|
file,
|
||||||
if (attachInputRef.current) attachInputRef.current.value = "";
|
kind: "document",
|
||||||
|
documentKey,
|
||||||
|
title: titleizeFilename(baseName),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next.push({
|
||||||
|
id: `${file.name}:${file.size}:${file.lastModified}`,
|
||||||
|
file,
|
||||||
|
kind: "attachment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStageFilesPicked(evt: ChangeEvent<HTMLInputElement>) {
|
||||||
|
stageFiles(Array.from(evt.target.files ?? []));
|
||||||
|
if (stageFileInputRef.current) {
|
||||||
|
stageFileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasDraft = title.trim().length > 0 || description.trim().length > 0;
|
function handleFileDragEnter(evt: DragEvent<HTMLDivElement>) {
|
||||||
|
if (!evt.dataTransfer.types.includes("Files")) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
setIsFileDragOver(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileDragOver(evt: DragEvent<HTMLDivElement>) {
|
||||||
|
if (!evt.dataTransfer.types.includes("Files")) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.dataTransfer.dropEffect = "copy";
|
||||||
|
setIsFileDragOver(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileDragLeave(evt: DragEvent<HTMLDivElement>) {
|
||||||
|
if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
|
||||||
|
setIsFileDragOver(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileDrop(evt: DragEvent<HTMLDivElement>) {
|
||||||
|
if (!evt.dataTransfer.files.length) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
setIsFileDragOver(false);
|
||||||
|
stageFiles(Array.from(evt.dataTransfer.files));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStagedFile(id: string) {
|
||||||
|
setStagedFiles((current) => current.filter((file) => file.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDraft = title.trim().length > 0 || description.trim().length > 0 || stagedFiles.length > 0;
|
||||||
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
||||||
const currentPriority = priorities.find((p) => p.value === priority);
|
const currentPriority = priorities.find((p) => p.value === priority);
|
||||||
const currentAssignee = selectedAssigneeAgentId
|
const currentAssignee = selectedAssigneeAgentId
|
||||||
@@ -541,6 +698,8 @@ export function NewIssueDialog() {
|
|||||||
const canDiscardDraft = hasDraft || hasSavedDraft;
|
const canDiscardDraft = hasDraft || hasSavedDraft;
|
||||||
const createIssueErrorMessage =
|
const createIssueErrorMessage =
|
||||||
createIssue.error instanceof Error ? createIssue.error.message : "Failed to create issue. Try again.";
|
createIssue.error instanceof Error ? createIssue.error.message : "Failed to create issue. Try again.";
|
||||||
|
const stagedDocuments = stagedFiles.filter((file) => file.kind === "document");
|
||||||
|
const stagedAttachments = stagedFiles.filter((file) => file.kind === "attachment");
|
||||||
|
|
||||||
const handleProjectChange = useCallback((nextProjectId: string) => {
|
const handleProjectChange = useCallback((nextProjectId: string) => {
|
||||||
setProjectId(nextProjectId);
|
setProjectId(nextProjectId);
|
||||||
@@ -938,20 +1097,103 @@ export function NewIssueDialog() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className={cn("px-4 pb-2 overflow-y-auto min-h-0 border-t border-border/60 pt-3", expanded ? "flex-1" : "")}>
|
<div
|
||||||
<MarkdownEditor
|
className={cn("px-4 pb-2 overflow-y-auto min-h-0 border-t border-border/60 pt-3", expanded ? "flex-1" : "")}
|
||||||
ref={descriptionEditorRef}
|
onDragEnter={handleFileDragEnter}
|
||||||
value={description}
|
onDragOver={handleFileDragOver}
|
||||||
onChange={setDescription}
|
onDragLeave={handleFileDragLeave}
|
||||||
placeholder="Add description..."
|
onDrop={handleFileDrop}
|
||||||
bordered={false}
|
>
|
||||||
mentions={mentionOptions}
|
<div
|
||||||
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
className={cn(
|
||||||
imageUploadHandler={async (file) => {
|
"rounded-md transition-colors",
|
||||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
isFileDragOver && "bg-accent/20",
|
||||||
return asset.contentPath;
|
)}
|
||||||
}}
|
>
|
||||||
/>
|
<MarkdownEditor
|
||||||
|
ref={descriptionEditorRef}
|
||||||
|
value={description}
|
||||||
|
onChange={setDescription}
|
||||||
|
placeholder="Add description..."
|
||||||
|
bordered={false}
|
||||||
|
mentions={mentionOptions}
|
||||||
|
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||||
|
imageUploadHandler={async (file) => {
|
||||||
|
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||||
|
return asset.contentPath;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{stagedFiles.length > 0 ? (
|
||||||
|
<div className="mt-4 space-y-3 rounded-lg border border-border/70 p-3">
|
||||||
|
{stagedDocuments.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Documents</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stagedDocuments.map((file) => (
|
||||||
|
<div key={file.id} className="flex items-start justify-between gap-3 rounded-md border border-border/70 px-3 py-2">
|
||||||
|
<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">
|
||||||
|
{file.documentKey}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm">{file.file.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||||
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
<span>{file.title || file.file.name}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatFileSize(file.file)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
onClick={() => removeStagedFile(file.id)}
|
||||||
|
disabled={createIssue.isPending}
|
||||||
|
title="Remove document"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{stagedAttachments.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Attachments</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stagedAttachments.map((file) => (
|
||||||
|
<div key={file.id} className="flex items-start justify-between gap-3 rounded-md border border-border/70 px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Paperclip className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate text-sm">{file.file.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
{file.file.type || "application/octet-stream"} • {formatFileSize(file.file)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
onClick={() => removeStagedFile(file.id)}
|
||||||
|
disabled={createIssue.isPending}
|
||||||
|
title="Remove attachment"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Property chips bar */}
|
{/* Property chips bar */}
|
||||||
@@ -1021,21 +1263,21 @@ export function NewIssueDialog() {
|
|||||||
Labels
|
Labels
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Attach image chip */}
|
|
||||||
<input
|
<input
|
||||||
ref={attachInputRef}
|
ref={stageFileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
accept={STAGED_FILE_ACCEPT}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleAttachImage}
|
onChange={handleStageFilesPicked}
|
||||||
|
multiple
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground"
|
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground"
|
||||||
onClick={() => attachInputRef.current?.click()}
|
onClick={() => stageFileInputRef.current?.click()}
|
||||||
disabled={uploadDescriptionImage.isPending}
|
disabled={createIssue.isPending}
|
||||||
>
|
>
|
||||||
<Paperclip className="h-3 w-3" />
|
<Paperclip className="h-3 w-3" />
|
||||||
{uploadDescriptionImage.isPending ? "Uploading..." : "Image"}
|
Upload
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* More (dates) */}
|
{/* More (dates) */}
|
||||||
|
|||||||
@@ -369,6 +369,9 @@ function invalidateActivityQueries(
|
|||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(ref) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(ref) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(ref) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) });
|
||||||
}
|
}
|
||||||
|
|||||||
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,15 +577,62 @@ 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;
|
||||||
await uploadAttachment.mutateAsync(file);
|
for (const file of Array.from(files)) {
|
||||||
|
if (isMarkdownFile(file)) {
|
||||||
|
await importMarkdownDocument.mutateAsync(file);
|
||||||
|
} else {
|
||||||
|
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/");
|
||||||
|
const attachmentList = attachments ?? [];
|
||||||
|
const hasAttachments = attachmentList.length > 0;
|
||||||
|
const attachmentUploadButton = (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFilePicked}
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploadAttachment.isPending || importMarkdownDocument.isPending}
|
||||||
|
className={cn(
|
||||||
|
"shadow-none",
|
||||||
|
attachmentDragActive && "border-primary bg-primary/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
{uploadAttachment.isPending || importMarkdownDocument.isPending ? "Uploading..." : "Upload attachment"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-2xl space-y-6">
|
||||||
@@ -658,14 +773,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,77 +793,86 @@ 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;
|
||||||
|
}}
|
||||||
|
extraActions={!hasAttachments ? attachmentUploadButton : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasAttachments ? (
|
||||||
|
<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">
|
{attachmentUploadButton}
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFilePicked}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={uploadAttachment.isPending}
|
|
||||||
>
|
|
||||||
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
{uploadAttachment.isPending ? "Uploading..." : "Upload image"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{attachmentError && (
|
{attachmentError && (
|
||||||
<p className="text-xs text-destructive">{attachmentError}</p>
|
<p className="text-xs text-destructive">{attachmentError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(!attachments || attachments.length === 0) ? (
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground">No attachments yet.</p>
|
{attachmentList.map((attachment) => (
|
||||||
) : (
|
<div key={attachment.id} className="border border-border rounded-md p-2">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{attachments.map((attachment) => (
|
<a
|
||||||
<div key={attachment.id} className="border border-border rounded-md p-2">
|
href={attachment.contentPath}
|
||||||
<div className="flex items-center justify-between gap-2">
|
target="_blank"
|
||||||
<a
|
rel="noreferrer"
|
||||||
href={attachment.contentPath}
|
className="text-xs hover:underline truncate"
|
||||||
target="_blank"
|
title={attachment.originalFilename ?? attachment.id}
|
||||||
rel="noreferrer"
|
>
|
||||||
className="text-xs hover:underline truncate"
|
{attachment.originalFilename ?? attachment.id}
|
||||||
title={attachment.originalFilename ?? attachment.id}
|
</a>
|
||||||
>
|
<button
|
||||||
{attachment.originalFilename ?? attachment.id}
|
type="button"
|
||||||
</a>
|
className="text-muted-foreground hover:text-destructive"
|
||||||
<button
|
onClick={() => deleteAttachment.mutate(attachment.id)}
|
||||||
type="button"
|
disabled={deleteAttachment.isPending}
|
||||||
className="text-muted-foreground hover:text-destructive"
|
title="Delete attachment"
|
||||||
onClick={() => deleteAttachment.mutate(attachment.id)}
|
>
|
||||||
disabled={deleteAttachment.isPending}
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
title="Delete attachment"
|
</button>
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
|
||||||
</p>
|
|
||||||
{isImageAttachment(attachment) && (
|
|
||||||
<a href={attachment.contentPath} target="_blank" rel="noreferrer">
|
|
||||||
<img
|
|
||||||
src={attachment.contentPath}
|
|
||||||
alt={attachment.originalFilename ?? "attachment"}
|
|
||||||
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<p className="text-[11px] text-muted-foreground">
|
||||||
</div>
|
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
||||||
)}
|
</p>
|
||||||
</div>
|
{isImageAttachment(attachment) && (
|
||||||
|
<a href={attachment.contentPath} target="_blank" rel="noreferrer">
|
||||||
|
<img
|
||||||
|
src={attachment.contentPath}
|
||||||
|
alt={attachment.originalFilename ?? "attachment"}
|
||||||
|
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user