From 857946b9de41b95262f43aabf885cea8e3c8d90e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sat, 22 Aug 2026 22:09:15 +0000 Subject: [PATCH 01/20] task: add library file commenting (Phase 1) Co-Authored-By: Claude Opus 4.6 --- .../2026-08-22-library-file-commenting.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tasks/active/2026-08-22-library-file-commenting.md diff --git a/tasks/active/2026-08-22-library-file-commenting.md b/tasks/active/2026-08-22-library-file-commenting.md new file mode 100644 index 000000000..a0dc6a5e2 --- /dev/null +++ b/tasks/active/2026-08-22-library-file-commenting.md @@ -0,0 +1,181 @@ +# Library File Commenting (Phase 1) + +## Problem Statement + +Users can comment on chat messages using the message-anchored commenting system (shipped in PR #1882). The same commenting UX should extend to library files viewed in the FilePreviewModal — particularly markdown files. Users should be able to select text in a markdown preview and leave comments, just as they do with chat messages. + +Phase 1 delivers file-level comments with quote selection on markdown files. Block-level gutter markers, re-anchoring, send-to-agent, and agent-authored comments are deferred to Phase 2. + +## Research Findings + +### Anchor Architecture + +The existing comment system uses a `MessageCommentAnchor` type (`packages/shared/src/types/comments.ts:39`). The correct approach is to extend this as a discriminated union: + +```typescript +type CommentAnchor = MessageCommentAnchor | LibraryFileCommentAnchor; +``` + +This preserves the existing message comment contract while enabling file comments. + +### DO Migration Constraint + +The `comment_threads` table (DO migration 032, `apps/api/src/durable-objects/migrations.ts:968`) has: +- `CHECK (anchor_kind = 'message')` — must be widened to allow `'library_file'` +- `message_id TEXT NOT NULL` — must become nullable (file comments have no message) +- `session_id TEXT NOT NULL` — must become nullable (file comments are project-scoped) + +SQLite cannot `ALTER CHECK` or `ALTER COLUMN` to remove NOT NULL. Table recreation is required. This is **safe** because: +- `comment_replies` FK: `REFERENCES comment_threads(id) ON DELETE CASCADE` — child table, not parent +- `comment_status_mutations` FK: `REFERENCES comment_threads(id) ON DELETE CASCADE` — child table, not parent +- Both child tables also reference `chat_sessions(id) ON DELETE CASCADE` — their `session_id` columns must also become nullable + +Table recreation order: recreate children first (they reference threads), then threads. + +### Route Structure + +Library file comments need their own route file because: +- They are NOT session-scoped (no `sessionId` in URL) +- They are project+file scoped: `/api/projects/:projectId/library/:fileId/comments` +- They use the same project auth (`requireProjectCapability`) but different URL shape + +### Library File Identity + +Files are identified by ULID `id` in D1 `project_files` table (not in DO SQLite). The DO must verify the file exists via a D1 query or the caller must verify before calling the DO. Since the DO doesn't have D1 access, file existence validation belongs in the route handler (query D1 for the file row, then call the DO). + +### Reusable Components + +These frontend components work with any anchor type and need no changes: +- `CommentComposer.tsx` — accepts `onSubmit(body, quote)` +- `CommentThread.tsx` — renders thread with replies, resolve/reopen +- `CommentPrimitives.tsx` — low-level comment UI atoms +- `useCommentSelection.ts` — text selection with `data-comment-anchor` attribute +- `SelectionPopover` / `SelectionActionBar` — selection UI + +### Frontend Data Fetching + +Existing `useMessageComments.ts` uses TanStack Query (correct pattern per rule 48/60). New `useLibraryFileComments` should follow the same pattern with separate query keys. + +## Implementation Checklist + +### Shared Types (`packages/shared/`) +- [ ] Add `LibraryFileCommentAnchor` type: `{ kind: 'library_file'; fileId: string; quote: string | null }` +- [ ] Add `CommentAnchor = MessageCommentAnchor | LibraryFileCommentAnchor` union +- [ ] Add `LibraryFileCommentThread` type (same shape as `MessageCommentThread` but `sessionId` optional, anchor is `CommentAnchor`) +- [ ] Add `LibraryFileCommentListResponse` type +- [ ] Add `CreateLibraryFileCommentThreadRequest` type + +### DO Migration (`apps/api/src/durable-objects/migrations.ts`) +- [ ] Add migration 033: recreate `comment_threads`, `comment_replies`, `comment_status_mutations` with relaxed constraints + - `anchor_kind CHECK (anchor_kind IN ('message', 'library_file'))` + - `message_id TEXT` (nullable) + - `session_id TEXT` (nullable, FK still references chat_sessions but nullable now) + - Add `file_id TEXT` column + - Add compound CHECK: `(anchor_kind = 'message' AND message_id IS NOT NULL AND session_id IS NOT NULL) OR (anchor_kind = 'library_file' AND file_id IS NOT NULL)` + - Recreate indexes including new `idx_comment_threads_file` on `(file_id, sequence)` + - Child tables: make `session_id` nullable, preserve CASCADE FKs + - UNIQUE constraints: `(session_id, client_mutation_id)` for message threads, `(file_id, client_mutation_id)` for file threads — use a single UNIQUE on `(anchor_kind, COALESCE(session_id,''), COALESCE(file_id,''), client_mutation_id)` or two partial indexes + +### DO Implementation (`apps/api/src/durable-objects/project-data/comments.ts`) +- [ ] Add `ensureFileAnchor()` — validates file_id is a non-empty string (actual file existence checked in route) +- [ ] Update `ThreadRowSchema` — make `session_id` and `message_id` optional in valibot schema +- [ ] Update `mapThread()` — read `anchor_kind` from row, return correct anchor union +- [ ] Update `createCommentThread()` — accept file anchor input, INSERT with anchor_kind='library_file', file_id, null session_id/message_id +- [ ] Update `listCommentThreads()` — support `fileId` filter, don't require `sessionId` for file threads +- [ ] Update `readThreadRows()` — conditional WHERE clause based on filter type +- [ ] Update `hydrateThreads()` — handle optional sessionId +- [ ] Update `getCommentThread()` — handle optional sessionId (use threadId + projectId instead) + +### DO Contracts (`apps/api/src/durable-objects/project-data/comment-contracts.ts`) +- [ ] Add `CreateFileCommentThreadInput` — `{ fileId: string; body: string; quote?: string | null; clientMutationId?: string | null; actor: CommentActor }` +- [ ] Extend `ListCommentThreadsInput` — make `sessionId` optional, add `fileId?: string | null`, add `anchorKind?: 'message' | 'library_file' | null` +- [ ] Add `'Library file'` to `CommentNotFoundError` resource union +- [ ] Update `CreateCommentThreadInput` to be `CreateMessageCommentThreadInput` (rename for clarity) or use a union + +### API Routes (`apps/api/src/routes/`) +- [ ] Create `library-comments.ts` route file + - `GET /api/projects/:projectId/library/:fileId/comments` — list file comment threads + - `POST /api/projects/:projectId/library/:fileId/comments` — create file comment thread + - `POST .../comments/:threadId/replies` — reply (reuse existing reply logic) + - `POST .../comments/:threadId/resolve` — resolve + - `POST .../comments/:threadId/reopen` — reopen + - File existence check: query D1 `project_files` before calling DO + - Auth: `requireProjectCapability(db, projectId, userId, 'task:read'/'task:write')` +- [ ] Mount in `apps/api/src/index.ts` under library routes or directly on `app` + +### API Schemas (`apps/api/src/schemas/comments.ts`) +- [ ] Add `CreateLibraryFileCommentThreadSchema` — `{ body: string, quote?: string | null, clientMutationId?: string | null }` + +### Project Data Service (`apps/api/src/services/project-data.ts`) +- [ ] Add `listFileCommentThreads()` — calls DO with fileId filter +- [ ] Add `createFileCommentThread()` — calls DO with file anchor input + +### Frontend API Client (`apps/web/src/lib/api/comments.ts`) +- [ ] Add `LibraryFileCommentAnchor` type: `{ kind: 'library_file'; fileId: string; quote?: string | null }` +- [ ] Add `CommentAnchor = MessageCommentAnchor | LibraryFileCommentAnchor` union +- [ ] Add `LibraryFileCommentThread` type (mirrors backend, sessionId optional) +- [ ] Add `listLibraryFileComments(projectId, fileId, signal?)` function +- [ ] Add `createLibraryFileCommentThread(projectId, fileId, data)` function +- [ ] Add `createLibraryFileCommentReply(projectId, fileId, threadId, data)` function +- [ ] Add `resolveLibraryFileComment(projectId, fileId, threadId)` function +- [ ] Add `reopenLibraryFileComment(projectId, fileId, threadId)` function +- [ ] Generalize `mapBackendMessageCommentThread` to handle both anchor kinds + +### Frontend Query Options (`apps/web/src/lib/query-options.ts` or similar) +- [ ] Add `libraryFileCommentQueryKeys` factory +- [ ] Add `libraryFileCommentsQueryOptions(projectId, fileId)` for TanStack Query + +### Frontend Hook (`apps/web/src/components/library/`) +- [ ] Create `useLibraryFileComments.ts` hook using TanStack Query + - `useQuery` for listing threads + - `useMutation` for create thread, reply, resolve, reopen + - Optimistic updates following the pattern in `useMessageComments.ts` + - Query key includes user identity scope (rule 48) + +### Frontend Components (`apps/web/src/components/library/`) +- [ ] Create `FileCommentPanel.tsx` — side panel or inline panel listing threads for a file + - Uses `CommentThread` and `CommentComposer` components + - Filter by status (open/resolved/all) + - Thread count badge +- [ ] Modify `FilePreviewModal.tsx` — add comment toggle button in header, conditionally render FileCommentPanel + - When comments are open: split layout (content + panel) on desktop, sheet/overlay on mobile + - Pass `data-comment-anchor={fileId}` to markdown content container + - Wire up `useCommentSelection` for text selection → comment creation with quote +- [ ] Handle selection popover / action bar positioning within the modal + +### MCP Tools (`apps/api/src/routes/mcp/`) +- [ ] Add `list_library_file_comment_threads` tool definition +- [ ] Add `create_library_file_comment_thread` tool definition +- [ ] Add handlers in the MCP comment tools handler file + +### Tests +- [ ] Unit tests for shared types (anchor union, type guards) +- [ ] DO migration test: verify table recreation preserves data, new columns work +- [ ] DO impl tests: create/list/get file comment threads, ensure file anchor validation +- [ ] Integration test: full API route → DO → response for file comments +- [ ] Frontend: useLibraryFileComments hook tests (TanStack Query) +- [ ] Vertical slice test: API creates file comment thread, lists it, replies, resolves + +## Acceptance Criteria + +- [ ] Users can open a markdown file in FilePreviewModal and leave a comment (no quote selection) +- [ ] Users can select text in the markdown preview and create a quoted comment +- [ ] File comments appear in a panel within the preview modal +- [ ] Replies, resolve, and reopen work on file comments +- [ ] File comments persist across modal close/reopen +- [ ] File comments are project-scoped, not session-scoped +- [ ] Existing message comments continue to work unchanged +- [ ] MCP tools can list and create file comment threads +- [ ] No horizontal overflow on mobile (375px) + +## References + +- SAM idea: `01M0N1250YESBW2R497KXDZVSC` +- Prior art (message commenting): PR #1882 +- Existing commenting idea (updated): `01M0JQB842XSJ3W172DYPB37HN` +- Shared types: `packages/shared/src/types/comments.ts` +- DO comments impl: `apps/api/src/durable-objects/project-data/comments.ts` +- DO migration 032: `apps/api/src/durable-objects/migrations.ts:968` +- Frontend API client: `apps/web/src/lib/api/comments.ts` +- FilePreviewModal: `apps/web/src/components/library/FilePreviewModal.tsx` +- Comment components: `apps/web/src/components/project-message-view/comments/` From 160136d2c1368fc4c1bf480c2c19688de0ad88d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sat, 22 Aug 2026 23:24:33 +0000 Subject: [PATCH 02/20] feat: add library file commenting (Phase 1) Extend the message-anchored comment system to support comments on library files viewed in FilePreviewModal. File comments are project-scoped (not session-scoped like message comments). Changes by layer: - Shared: CommentAnchor discriminated union, LibraryFileCommentThread type - DO migration 033: relax constraints (nullable session_id/message_id, add file_id, widen anchor_kind CHECK) - DO impl: file anchor support in create/list/get operations - API routes: 5 endpoints under /api/projects/:projectId/library/:fileId/comments - MCP tools: list_library_file_comment_threads, create_library_file_comment_thread - Frontend: FileCommentPanel, useLibraryFileComments hook (TanStack Query with optimistic updates), FilePreviewModal comment toggle + side panel Co-Authored-By: Claude Opus 4.6 --- apps/api/src/durable-objects/migrations.ts | 146 +++++++++ .../project-data/comment-contracts.ts | 25 +- .../durable-objects/project-data/comments.ts | 276 ++++++++++++----- .../src/durable-objects/project-data/index.ts | 44 ++- apps/api/src/index.ts | 2 + apps/api/src/routes/library-comments.ts | 279 +++++++++++++++++ apps/api/src/routes/mcp/index.ts | 12 + .../routes/mcp/library-file-comment-tools.ts | 218 +++++++++++++ ...-definitions-library-file-comment-tools.ts | 62 ++++ apps/api/src/routes/mcp/tool-definitions.ts | 3 + apps/api/src/schemas/comments.ts | 6 + apps/api/src/services/message-comments.ts | 16 +- apps/api/src/services/project-data.ts | 33 +- .../components/library/FileCommentPanel.tsx | 78 +++++ .../components/library/FilePreviewModal.tsx | 292 ++++++++++-------- .../library/useLibraryFileComments.ts | 246 +++++++++++++++ .../comments/CommentComposer.tsx | 60 ++-- .../comments/CommentThread.tsx | 10 +- .../tool-cards/DocumentCard.tsx | 3 +- apps/web/src/lib/api/comments.ts | 205 ++++++++++++ apps/web/src/lib/query-options/comments.ts | 37 +++ apps/web/src/pages/ProjectLibrary.tsx | 1 + packages/shared/src/types/comments.ts | 81 ++++- packages/shared/src/types/index.ts | 12 +- 24 files changed, 1867 insertions(+), 280 deletions(-) create mode 100644 apps/api/src/routes/library-comments.ts create mode 100644 apps/api/src/routes/mcp/library-file-comment-tools.ts create mode 100644 apps/api/src/routes/mcp/tool-definitions-library-file-comment-tools.ts create mode 100644 apps/web/src/components/library/FileCommentPanel.tsx create mode 100644 apps/web/src/components/library/useLibraryFileComments.ts diff --git a/apps/api/src/durable-objects/migrations.ts b/apps/api/src/durable-objects/migrations.ts index 962ce177b..da9b61bd7 100644 --- a/apps/api/src/durable-objects/migrations.ts +++ b/apps/api/src/durable-objects/migrations.ts @@ -1055,6 +1055,152 @@ export const MIGRATIONS: Migration[] = [ `); }, }, + { + name: '033-library-file-comment-anchors', + run: (sql) => { + // Widen comment_threads to support library_file anchors in addition to + // message anchors. SQLite cannot ALTER CHECK or remove NOT NULL, so we + // recreate the three comment tables. DO SQLite defaults to + // PRAGMA foreign_keys = OFF, so DROP TABLE does not cascade. + + // --- 1. Preserve data ------------------------------------------------- + sql.exec(`CREATE TABLE _comment_threads_bak AS SELECT * FROM comment_threads`); + sql.exec(`CREATE TABLE _comment_replies_bak AS SELECT * FROM comment_replies`); + sql.exec(`CREATE TABLE _comment_status_mutations_bak AS SELECT * FROM comment_status_mutations`); + + // --- 2. Drop old tables (children first, then parent) ----------------- + sql.exec(`DROP TABLE comment_status_mutations`); + sql.exec(`DROP TABLE comment_replies`); + sql.exec(`DROP TABLE comment_threads`); + + // --- 3. Recreate comment_threads with relaxed constraints ------------- + sql.exec(` + CREATE TABLE comment_threads ( + id TEXT PRIMARY KEY, + session_id TEXT REFERENCES chat_sessions(id) ON DELETE CASCADE, + anchor_kind TEXT NOT NULL DEFAULT 'message' CHECK (anchor_kind IN ('message', 'library_file')), + message_id TEXT REFERENCES chat_messages(id) ON DELETE CASCADE, + file_id TEXT, + quote TEXT, + body TEXT NOT NULL, + author_type TEXT NOT NULL CHECK (author_type IN ('human', 'agent')), + author_id TEXT NOT NULL, + author_name TEXT, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'sent', 'resolved')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sequence INTEGER NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + client_mutation_id TEXT, + client_mutation_fingerprint TEXT, + sent_at INTEGER, + sent_by_type TEXT CHECK (sent_by_type IS NULL OR sent_by_type IN ('human', 'agent')), + sent_by_id TEXT, + sent_by_name TEXT, + resolved_at INTEGER, + resolved_by_type TEXT CHECK (resolved_by_type IS NULL OR resolved_by_type IN ('human', 'agent')), + resolved_by_id TEXT, + resolved_by_name TEXT, + reopened_at INTEGER, + reopened_by_type TEXT CHECK (reopened_by_type IS NULL OR reopened_by_type IN ('human', 'agent')), + reopened_by_id TEXT, + reopened_by_name TEXT, + CHECK ( + (anchor_kind = 'message' AND message_id IS NOT NULL AND session_id IS NOT NULL) + OR (anchor_kind = 'library_file' AND file_id IS NOT NULL) + ) + ) + `); + + // --- 4. Recreate comment_replies with nullable session_id ------------- + sql.exec(` + CREATE TABLE comment_replies ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE, + session_id TEXT REFERENCES chat_sessions(id) ON DELETE CASCADE, + body TEXT NOT NULL, + author_type TEXT NOT NULL CHECK (author_type IN ('human', 'agent')), + author_id TEXT NOT NULL, + author_name TEXT, + created_at INTEGER NOT NULL, + sequence INTEGER NOT NULL, + client_mutation_id TEXT, + client_mutation_fingerprint TEXT, + UNIQUE(thread_id, client_mutation_id) + ) + `); + + // --- 5. Recreate comment_status_mutations with nullable session_id ---- + sql.exec(` + CREATE TABLE comment_status_mutations ( + thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE, + session_id TEXT REFERENCES chat_sessions(id) ON DELETE CASCADE, + client_mutation_id TEXT NOT NULL, + target_status TEXT NOT NULL CHECK (target_status IN ('open', 'sent', 'resolved')), + thread_version INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (thread_id, client_mutation_id) + ) + `); + + // --- 6. Restore data -------------------------------------------------- + sql.exec(` + INSERT INTO comment_threads ( + id, session_id, anchor_kind, message_id, file_id, quote, body, + author_type, author_id, author_name, status, created_at, updated_at, + sequence, version, client_mutation_id, client_mutation_fingerprint, + sent_at, sent_by_type, sent_by_id, sent_by_name, + resolved_at, resolved_by_type, resolved_by_id, resolved_by_name, + reopened_at, reopened_by_type, reopened_by_id, reopened_by_name + ) + SELECT + id, session_id, anchor_kind, message_id, NULL, quote, body, + author_type, author_id, author_name, status, created_at, updated_at, + sequence, version, client_mutation_id, client_mutation_fingerprint, + sent_at, sent_by_type, sent_by_id, sent_by_name, + resolved_at, resolved_by_type, resolved_by_id, resolved_by_name, + reopened_at, reopened_by_type, reopened_by_id, reopened_by_name + FROM _comment_threads_bak + `); + sql.exec(`INSERT INTO comment_replies SELECT * FROM _comment_replies_bak`); + sql.exec(`INSERT INTO comment_status_mutations SELECT * FROM _comment_status_mutations_bak`); + + // --- 7. Drop backup tables -------------------------------------------- + sql.exec(`DROP TABLE _comment_threads_bak`); + sql.exec(`DROP TABLE _comment_replies_bak`); + sql.exec(`DROP TABLE _comment_status_mutations_bak`); + + // --- 8. Recreate indexes ---------------------------------------------- + sql.exec(` + CREATE INDEX idx_comment_threads_session_sequence + ON comment_threads(session_id, sequence) + `); + sql.exec(` + CREATE INDEX idx_comment_threads_message + ON comment_threads(session_id, message_id, sequence) + `); + sql.exec(` + CREATE INDEX idx_comment_threads_status + ON comment_threads(session_id, status, sequence) + `); + sql.exec(` + CREATE INDEX idx_comment_threads_file + ON comment_threads(file_id, sequence) + `); + sql.exec(` + CREATE INDEX idx_comment_replies_thread_sequence + ON comment_replies(thread_id, sequence) + `); + sql.exec(` + CREATE INDEX idx_comment_replies_session + ON comment_replies(session_id, thread_id) + `); + sql.exec(` + CREATE INDEX idx_comment_status_mutations_session + ON comment_status_mutations(session_id, created_at) + `); + }, + }, ]; /** diff --git a/apps/api/src/durable-objects/project-data/comment-contracts.ts b/apps/api/src/durable-objects/project-data/comment-contracts.ts index ea073acda..623e7908a 100644 --- a/apps/api/src/durable-objects/project-data/comment-contracts.ts +++ b/apps/api/src/durable-objects/project-data/comment-contracts.ts @@ -1,6 +1,8 @@ import type { + CommentAnchorKind, CommentAuthor, CommentStatus, + LibraryFileCommentThread, MessageCommentReply, MessageCommentThread, } from '@simple-agent-manager/shared'; @@ -16,8 +18,16 @@ export type CreateCommentThreadInput = { actor: CommentActor; }; +export type CreateFileCommentThreadInput = { + fileId: string; + body: string; + quote?: string | null; + clientMutationId?: string | null; + actor: CommentActor; +}; + export type CreateCommentReplyInput = { - sessionId: string; + sessionId?: string | null; threadId: string; body: string; clientMutationId?: string | null; @@ -25,15 +35,17 @@ export type CreateCommentReplyInput = { }; export type ListCommentThreadsInput = { - sessionId: string; + sessionId?: string | null; messageId?: string | null; + fileId?: string | null; + anchorKind?: CommentAnchorKind | null; status?: CommentStatus | null; afterSequence?: number | null; limit?: number | null; }; export type UpdateCommentStatusInput = { - sessionId: string; + sessionId?: string | null; threadId: string; status: CommentStatus; clientMutationId?: string | null; @@ -41,9 +53,8 @@ export type UpdateCommentStatusInput = { }; export type CommentThreadMutationResult = { - thread: MessageCommentThread; + thread: MessageCommentThread | LibraryFileCommentThread; idempotent: boolean; - changed: boolean; }; export type CommentReplyMutationResult = CommentThreadMutationResult & { @@ -51,7 +62,7 @@ export type CommentReplyMutationResult = CommentThreadMutationResult & { }; export type ListCommentThreadsResult = { - threads: MessageCommentThread[]; + threads: (MessageCommentThread | LibraryFileCommentThread)[]; hasMore: boolean; }; @@ -63,7 +74,7 @@ export const COMMENT_LIMIT_EXCEEDED = 'COMMENT_LIMIT_EXCEEDED'; export class CommentNotFoundError extends Error { readonly code = COMMENT_NOT_FOUND; - constructor(readonly resource: 'Chat session' | 'Message' | 'Comment thread') { + constructor(readonly resource: 'Chat session' | 'Message' | 'Comment thread' | 'Library file') { super(`${resource} not found`); this.name = 'CommentNotFoundError'; } diff --git a/apps/api/src/durable-objects/project-data/comments.ts b/apps/api/src/durable-objects/project-data/comments.ts index 44ca1f47e..5f9d878dd 100644 --- a/apps/api/src/durable-objects/project-data/comments.ts +++ b/apps/api/src/durable-objects/project-data/comments.ts @@ -1,5 +1,10 @@ -import type { MessageCommentReply, MessageCommentThread } from '@simple-agent-manager/shared'; +import type { + LibraryFileCommentThread, + MessageCommentReply, + MessageCommentThread, +} from '@simple-agent-manager/shared'; import { + COMMENT_ANCHOR_KINDS, COMMENT_STATUSES, DEFAULT_COMMENT_BODY_MAX_LENGTH, DEFAULT_COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH, @@ -26,10 +31,18 @@ import { CommentValidationError, type CreateCommentReplyInput, type CreateCommentThreadInput, + type CreateFileCommentThreadInput, type ListCommentThreadsInput, type ListCommentThreadsResult, type UpdateCommentStatusInput, } from './comment-contracts'; + +export type InternalCommentThreadMutationResult = CommentThreadMutationResult & { + changed: boolean; +}; +export type InternalCommentReplyMutationResult = CommentReplyMutationResult & { + changed: boolean; +}; import { parseRow } from './row-schemas'; import type { Env } from './types'; import { generateId } from './types'; @@ -52,6 +65,7 @@ export type { CommentThreadMutationResult, CreateCommentReplyInput, CreateCommentThreadInput, + CreateFileCommentThreadInput, ListCommentThreadsInput, ListCommentThreadsResult, UpdateCommentStatusInput, @@ -69,8 +83,10 @@ type CommentLimits = { const ThreadRowSchema = v.object({ id: v.string(), - session_id: v.string(), - message_id: v.string(), + anchor_kind: v.picklist([...COMMENT_ANCHOR_KINDS]), + session_id: v.nullable(v.string()), + message_id: v.nullable(v.string()), + file_id: v.nullable(v.string()), quote: v.nullable(v.string()), body: v.string(), author_type: v.picklist(['human', 'agent']), @@ -99,7 +115,7 @@ const ThreadRowSchema = v.object({ const ReplyRowSchema = v.object({ id: v.string(), thread_id: v.string(), - session_id: v.string(), + session_id: v.nullable(v.string()), body: v.string(), author_type: v.picklist(['human', 'agent']), author_id: v.string(), @@ -225,6 +241,16 @@ function nextThreadSequence(sql: SqlStorage, sessionId: string): number { return (typeof row?.max_sequence === 'number' ? row.max_sequence : 0) + 1; } +function nextFileThreadSequence(sql: SqlStorage, fileId: string): number { + const row = sql + .exec( + 'SELECT COALESCE(MAX(sequence), 0) AS max_sequence FROM comment_threads WHERE file_id = ?', + fileId + ) + .toArray()[0]; + return (typeof row?.max_sequence === 'number' ? row.max_sequence : 0) + 1; +} + function nextReplySequence(sql: SqlStorage, threadId: string): number { const row = sql .exec( @@ -241,7 +267,7 @@ function actorFromColumns( name: string | null ): CommentActor | null { if (!kind || !id) return null; - return { kind, id, name }; + return { kind, id, name: name ?? undefined }; } function mapReply(row: unknown): MessageCommentReply { @@ -249,8 +275,8 @@ function mapReply(row: unknown): MessageCommentReply { return { id: r.id, threadId: r.thread_id, - sessionId: r.session_id, - author: { kind: r.author_type, id: r.author_id, name: r.author_name }, + sessionId: r.session_id ?? undefined, + author: { kind: r.author_type, id: r.author_id, name: r.author_name ?? undefined }, body: r.body, createdAt: r.created_at, sequence: r.sequence, @@ -258,16 +284,14 @@ function mapReply(row: unknown): MessageCommentReply { }; } -function mapThread(row: unknown, replies: MessageCommentReply[]): MessageCommentThread { - const r = parseRow(ThreadRowSchema, row, 'comment_thread'); - return { +export type AnyCommentThread = MessageCommentThread | LibraryFileCommentThread; + +function mapThreadFromParsed( + r: ThreadRow, + replies: MessageCommentReply[] +): AnyCommentThread { + const common = { id: r.id, - sessionId: r.session_id, - anchor: { - kind: 'message', - messageId: r.message_id, - quote: r.quote, - }, author: { kind: r.author_type, id: r.author_id, name: r.author_name }, body: r.body, status: r.status, @@ -276,16 +300,32 @@ function mapThread(row: unknown, replies: MessageCommentReply[]): MessageComment sequence: r.sequence, version: r.version, clientMutationId: r.client_mutation_id, - sentAt: r.sent_at, - sentBy: actorFromColumns(r.sent_by_type, r.sent_by_id, r.sent_by_name), resolvedAt: r.resolved_at, resolvedBy: actorFromColumns(r.resolved_by_type, r.resolved_by_id, r.resolved_by_name), reopenedAt: r.reopened_at, reopenedBy: actorFromColumns(r.reopened_by_type, r.reopened_by_id, r.reopened_by_name), replies, - }; + } as const; + + if (r.anchor_kind === 'library_file') { + return { + ...common, + fileId: r.file_id!, + anchor: { kind: 'library_file', fileId: r.file_id!, quote: r.quote }, + } satisfies LibraryFileCommentThread; + } + + return { + ...common, + sessionId: r.session_id!, + anchor: { kind: 'message', messageId: r.message_id!, quote: r.quote }, + sentAt: r.sent_at, + sentBy: actorFromColumns(r.sent_by_type, r.sent_by_id, r.sent_by_name), + } satisfies MessageCommentThread; } + + function readReplies(sql: SqlStorage, threadIds: string[]): Map { const byThread = new Map(); for (const threadId of threadIds) byThread.set(threadId, []); @@ -318,19 +358,20 @@ function readReplies(sql: SqlStorage, threadIds: string[]): Map '?').join(', '); const rows = sql .exec( - `SELECT id, session_id, message_id, quote, body, author_type, author_id, author_name, - status, created_at, updated_at, sequence, version, client_mutation_id, - sent_at, sent_by_type, sent_by_id, sent_by_name, - resolved_at, resolved_by_type, resolved_by_id, resolved_by_name, - reopened_at, reopened_by_type, reopened_by_id, reopened_by_name + `SELECT ${THREAD_SELECT_COLUMNS} FROM comment_threads - WHERE session_id = ? AND id IN (${placeholders})`, - sessionId, + WHERE id IN (${placeholders})`, ...threadIds ) .toArray(); @@ -342,7 +383,6 @@ function readThreadRows(sql: SqlStorage, sessionId: string, threadIds: string[]) } catch (err) { log.warn('comments.thread_row_skipped', { rowId: typeof row.id === 'string' ? row.id : null, - sessionId, error: String(err), }); } @@ -350,11 +390,7 @@ function readThreadRows(sql: SqlStorage, sessionId: string, threadIds: string[]) return parsed; } -function hydrateThreads( - sql: SqlStorage, - sessionId: string, - rows: unknown[] -): MessageCommentThread[] { +function hydrateThreads(sql: SqlStorage, rows: unknown[]): AnyCommentThread[] { const parsedRows: ThreadRow[] = []; for (const row of rows) { try { @@ -363,7 +399,6 @@ function hydrateThreads( const record = row && typeof row === 'object' ? (row as Record) : {}; log.warn('comments.thread_row_skipped', { rowId: typeof record.id === 'string' ? record.id : null, - sessionId, error: String(err), }); } @@ -373,18 +408,18 @@ function hydrateThreads( sql, parsedRows.map((row) => row.id) ); - return parsedRows.map((row) => mapThread(row, replies.get(row.id) ?? [])); + return parsedRows.map((row) => mapThreadFromParsed(row, replies.get(row.id) ?? [])); } export function getCommentThread( sql: SqlStorage, - sessionId: string, threadId: string -): MessageCommentThread | null { - const rows = readThreadRows(sql, sessionId, [threadId]); - if (rows.length === 0) return null; +): AnyCommentThread | null { + const rows = readThreadRowsByIds(sql, [threadId]); + const row = rows[0]; + if (!row) return null; const replies = readReplies(sql, [threadId]); - return mapThread(rows[0], replies.get(threadId) ?? []); + return mapThreadFromParsed(row, replies.get(threadId) ?? []); } export function listCommentThreads( @@ -392,16 +427,32 @@ export function listCommentThreads( env: Env, input: ListCommentThreadsInput ): ListCommentThreadsResult { - ensureSession(sql, input.sessionId); const limit = resolveCommentListLimit(env, input.limit); - const conditions = ['session_id = ?']; - const params: Array = [input.sessionId]; + const conditions: string[] = []; + const params: Array = []; + + if (input.anchorKind) { + conditions.push('anchor_kind = ?'); + params.push(input.anchorKind); + } + + if (input.sessionId) { + ensureSession(sql, input.sessionId); + conditions.push('session_id = ?'); + params.push(input.sessionId); + + if (input.messageId) { + ensureMessageAnchor(sql, input.sessionId, input.messageId); + conditions.push('message_id = ?'); + params.push(input.messageId); + } + } - if (input.messageId) { - ensureMessageAnchor(sql, input.sessionId, input.messageId); - conditions.push('message_id = ?'); - params.push(input.messageId); + if (input.fileId) { + conditions.push('file_id = ?'); + params.push(input.fileId); } + if (input.status) { conditions.push('status = ?'); params.push(input.status); @@ -411,14 +462,10 @@ export function listCommentThreads( params.push(input.afterSequence); } - const whereClause = conditions.join(' AND '); + const whereClause = conditions.length > 0 ? conditions.join(' AND ') : '1=1'; const rows = sql .exec( - `SELECT id, session_id, message_id, quote, body, author_type, author_id, author_name, - status, created_at, updated_at, sequence, version, client_mutation_id, - sent_at, sent_by_type, sent_by_id, sent_by_name, - resolved_at, resolved_by_type, resolved_by_id, resolved_by_name, - reopened_at, reopened_by_type, reopened_by_id, reopened_by_name + `SELECT ${THREAD_SELECT_COLUMNS} FROM comment_threads WHERE ${whereClause} ORDER BY sequence ASC @@ -430,7 +477,7 @@ export function listCommentThreads( const hasMore = rows.length > limit; return { - threads: hydrateThreads(sql, input.sessionId, hasMore ? rows.slice(0, limit) : rows), + threads: hydrateThreads(sql, hasMore ? rows.slice(0, limit) : rows), hasMore, }; } @@ -439,7 +486,7 @@ export function createCommentThread( sql: SqlStorage, env: Env, input: CreateCommentThreadInput -): CommentThreadMutationResult { +): InternalCommentThreadMutationResult { const limits = resolveCommentLimits(env); const actor = normalizeActor(input.actor); const body = normalizeBody(input.body, limits); @@ -472,7 +519,7 @@ export function createCommentThread( if (existing.client_mutation_fingerprint !== requestFingerprint) { throw new CommentIdempotencyConflictError(); } - const thread = getCommentThread(sql, input.sessionId, String(existing.id)); + const thread = getCommentThread(sql, String(existing.id)); if (!thread) throw new CommentNotFoundError('Comment thread'); return { thread, idempotent: true, changed: false }; } @@ -511,7 +558,84 @@ export function createCommentThread( clientMutationId ? requestFingerprint : null ); - const thread = getCommentThread(sql, input.sessionId, id); + const thread = getCommentThread(sql, id); + if (!thread) throw new CommentNotFoundError('Comment thread'); + return { thread, idempotent: false, changed: true }; +} + +export function createFileCommentThread( + sql: SqlStorage, + env: Env, + input: CreateFileCommentThreadInput +): InternalCommentThreadMutationResult { + const limits = resolveCommentLimits(env); + const actor = normalizeActor(input.actor); + const body = normalizeBody(input.body, limits); + const quote = normalizeQuote(input.quote, limits); + const clientMutationId = normalizeClientMutationId(input.clientMutationId, limits); + const requestFingerprint = fingerprint([ + 'thread', + input.fileId, + body, + quote, + actor.kind, + actor.id, + ]); + + if (clientMutationId) { + const existing = sql + .exec( + `SELECT id, client_mutation_fingerprint + FROM comment_threads + WHERE file_id = ? AND client_mutation_id = ? + LIMIT 1`, + input.fileId, + clientMutationId + ) + .toArray()[0]; + if (existing) { + if (existing.client_mutation_fingerprint !== requestFingerprint) { + throw new CommentIdempotencyConflictError(); + } + const thread = getCommentThread(sql, String(existing.id)); + if (!thread) throw new CommentNotFoundError('Comment thread'); + return { thread, idempotent: true, changed: false }; + } + } + + const countRow = sql + .exec('SELECT COUNT(*) AS count FROM comment_threads WHERE file_id = ?', input.fileId) + .toArray()[0]; + if ((typeof countRow?.count === 'number' ? countRow.count : 0) >= limits.threadsPerSessionMax) { + throw new CommentLimitExceededError( + `file comment thread limit of ${limits.threadsPerSessionMax} reached` + ); + } + + const id = generateId(); + const now = Date.now(); + const sequence = nextFileThreadSequence(sql, input.fileId); + sql.exec( + `INSERT INTO comment_threads + (id, anchor_kind, file_id, quote, body, author_type, author_id, author_name, + status, created_at, updated_at, sequence, version, client_mutation_id, + client_mutation_fingerprint) + VALUES (?, 'library_file', ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, 1, ?, ?)`, + id, + input.fileId, + quote, + body, + actor.kind, + actor.id, + actor.name, + now, + now, + sequence, + clientMutationId, + clientMutationId ? requestFingerprint : null + ); + + const thread = getCommentThread(sql, id); if (!thread) throw new CommentNotFoundError('Comment thread'); return { thread, idempotent: false, changed: true }; } @@ -520,14 +644,14 @@ export function createCommentReply( sql: SqlStorage, env: Env, input: CreateCommentReplyInput -): CommentReplyMutationResult { +): InternalCommentReplyMutationResult { const limits = resolveCommentLimits(env); const actor = normalizeActor(input.actor); const body = normalizeBody(input.body, limits); const clientMutationId = normalizeClientMutationId(input.clientMutationId, limits); const requestFingerprint = fingerprint(['reply', input.threadId, body, actor.kind, actor.id]); - const thread = getCommentThread(sql, input.sessionId, input.threadId); + const thread = getCommentThread(sql, input.threadId); if (!thread) throw new CommentNotFoundError('Comment thread'); if (clientMutationId) { @@ -545,7 +669,7 @@ export function createCommentReply( if (existing.client_mutation_fingerprint !== requestFingerprint) { throw new CommentIdempotencyConflictError(); } - const authoritative = getCommentThread(sql, input.sessionId, input.threadId); + const authoritative = getCommentThread(sql, input.threadId); const reply = authoritative?.replies.find((candidate) => candidate.id === existing.id); if (!authoritative || !reply) throw new CommentNotFoundError('Comment thread'); return { thread: authoritative, reply, idempotent: true, changed: false }; @@ -571,7 +695,7 @@ export function createCommentReply( VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, input.threadId, - input.sessionId, + input.sessionId ?? null, body, actor.kind, actor.id, @@ -584,13 +708,12 @@ export function createCommentReply( sql.exec( `UPDATE comment_threads SET updated_at = ?, version = version + 1 - WHERE id = ? AND session_id = ?`, + WHERE id = ?`, now, - input.threadId, - input.sessionId + input.threadId ); - const authoritative = getCommentThread(sql, input.sessionId, input.threadId); + const authoritative = getCommentThread(sql, input.threadId); const reply = authoritative?.replies.find((candidate) => candidate.id === id); if (!authoritative || !reply) throw new CommentNotFoundError('Comment thread'); return { thread: authoritative, reply, idempotent: false, changed: true }; @@ -600,7 +723,7 @@ export function updateCommentThreadStatus( sql: SqlStorage, env: Env, input: UpdateCommentStatusInput -): CommentThreadMutationResult { +): InternalCommentThreadMutationResult { const limits = resolveCommentLimits(env); const actor = normalizeActor(input.actor); const clientMutationId = normalizeClientMutationId(input.clientMutationId, limits); @@ -608,7 +731,7 @@ export function updateCommentThreadStatus( throw new CommentValidationError('status must be open, sent, or resolved'); } - const current = getCommentThread(sql, input.sessionId, input.threadId); + const current = getCommentThread(sql, input.threadId); if (!current) throw new CommentNotFoundError('Comment thread'); if (clientMutationId) { @@ -637,47 +760,44 @@ export function updateCommentThreadStatus( `UPDATE comment_threads SET status = 'sent', sent_at = ?, sent_by_type = ?, sent_by_id = ?, sent_by_name = ?, updated_at = ?, version = version + 1 - WHERE id = ? AND session_id = ?`, + WHERE id = ?`, now, actor.kind, actor.id, actor.name, now, - input.threadId, - input.sessionId + input.threadId ); } else if (input.status === 'resolved') { sql.exec( `UPDATE comment_threads SET status = 'resolved', resolved_at = ?, resolved_by_type = ?, resolved_by_id = ?, resolved_by_name = ?, updated_at = ?, version = version + 1 - WHERE id = ? AND session_id = ?`, + WHERE id = ?`, now, actor.kind, actor.id, actor.name, now, - input.threadId, - input.sessionId + input.threadId ); } else { sql.exec( `UPDATE comment_threads SET status = 'open', reopened_at = ?, reopened_by_type = ?, reopened_by_id = ?, reopened_by_name = ?, updated_at = ?, version = version + 1 - WHERE id = ? AND session_id = ?`, + WHERE id = ?`, now, actor.kind, actor.id, actor.name, now, - input.threadId, - input.sessionId + input.threadId ); } } - const authoritative = getCommentThread(sql, input.sessionId, input.threadId); + const authoritative = getCommentThread(sql, input.threadId); if (!authoritative) throw new CommentNotFoundError('Comment thread'); if (clientMutationId) { @@ -686,7 +806,7 @@ export function updateCommentThreadStatus( (thread_id, session_id, client_mutation_id, target_status, thread_version, created_at) VALUES (?, ?, ?, ?, ?, ?)`, input.threadId, - input.sessionId, + input.sessionId ?? null, clientMutationId, input.status, authoritative.version, diff --git a/apps/api/src/durable-objects/project-data/index.ts b/apps/api/src/durable-objects/project-data/index.ts index 265df3429..ca9cbc250 100644 --- a/apps/api/src/durable-objects/project-data/index.ts +++ b/apps/api/src/durable-objects/project-data/index.ts @@ -487,15 +487,22 @@ export class ProjectData extends DurableObject { return comments.listCommentThreads(this.sql, this.env, input); } - getCommentThread(input: { sessionId: string; threadId: string }): MessageCommentThread | null { - return comments.getCommentThread(this.sql, input.sessionId, input.threadId); + getCommentThread(input: { threadId: string }): comments.AnyCommentThread | null { + return comments.getCommentThread(this.sql, input.threadId); } createCommentThread(input: comments.CreateCommentThreadInput) { const result = this.ctx.storage.transactionSync(() => comments.createCommentThread(this.sql, this.env, input) ); - if (result.changed) this.broadcastCommentThread(result.thread, 'thread_created'); + if (result.changed) this.broadcastCommentThreadIfSession(result.thread, 'thread_created'); + return { thread: result.thread, idempotent: result.idempotent }; + } + + createFileCommentThread(input: comments.CreateFileCommentThreadInput) { + const result = this.ctx.storage.transactionSync(() => + comments.createFileCommentThread(this.sql, this.env, input) + ); return { thread: result.thread, idempotent: result.idempotent }; } @@ -503,7 +510,7 @@ export class ProjectData extends DurableObject { const result = this.ctx.storage.transactionSync(() => comments.createCommentReply(this.sql, this.env, input) ); - if (result.changed) this.broadcastCommentThread(result.thread, 'reply_created'); + if (result.changed) this.broadcastCommentThreadIfSession(result.thread, 'reply_created'); return { thread: result.thread, reply: result.reply, @@ -516,7 +523,10 @@ export class ProjectData extends DurableObject { comments.updateCommentThreadStatus(this.sql, this.env, input) ); if (result.changed) { - this.broadcastCommentThread(result.thread, this.commentStatusEventReason(input.status)); + this.broadcastCommentThreadIfSession( + result.thread, + this.commentStatusEventReason(input.status) + ); } return { thread: result.thread, idempotent: result.idempotent }; } @@ -1638,19 +1648,21 @@ export class ProjectData extends DurableObject { } } - private broadcastCommentThread( - thread: MessageCommentThread, + private broadcastCommentThreadIfSession( + thread: comments.AnyCommentThread, reason: MessageCommentThreadEventReason ): void { - this.broadcastEvent( - 'comment.thread.changed', - { - sessionId: thread.sessionId, - thread, - reason, - }, - thread.sessionId - ); + if ('sessionId' in thread && thread.sessionId) { + this.broadcastEvent( + 'comment.thread.changed', + { + sessionId: thread.sessionId, + thread: thread as MessageCommentThread, + reason, + }, + thread.sessionId + ); + } } private commentStatusEventReason( diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b51a8d3d3..41d74d3d5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -97,6 +97,7 @@ import { } from './routes/interactive-preview-host'; import { knowledgeRoutes } from './routes/knowledge'; import { libraryRoutes } from './routes/library'; +import { libraryCommentRoutes } from './routes/library-comments'; import { mailboxRoutes } from './routes/mailbox'; import { mcpRoutes } from './routes/mcp'; import { missionRoutes } from './routes/missions'; @@ -727,6 +728,7 @@ app.route('/api/projects/:projectId/sessions', chatRoutes); app.route('/api/projects/:projectId/cached-commands', cachedCommandRoutes); app.route('/api/projects/:projectId/activity', activityRoutes); app.route('/api/projects/:projectId/library', libraryRoutes); +app.route('/api/projects/:projectId/library', libraryCommentRoutes); app.route('/api/projects/:projectId/agent-profiles/:profileId/runtime', profileRuntimeRoutes); app.route('/api/projects/:projectId/agent-profiles', agentProfileRoutes); app.route('/api/projects/:projectId/skills/:skillId/runtime', skillRuntimeRoutes); diff --git a/apps/api/src/routes/library-comments.ts b/apps/api/src/routes/library-comments.ts new file mode 100644 index 000000000..5e01ca562 --- /dev/null +++ b/apps/api/src/routes/library-comments.ts @@ -0,0 +1,279 @@ +/** + * Library file comment routes. + * + * Project-scoped comments on library files (not session-scoped). + * Mounted at /api/projects/:projectId/library + */ +import type { CommentStatus } from '@simple-agent-manager/shared'; +import { COMMENT_STATUSES } from '@simple-agent-manager/shared'; +import { drizzle } from 'drizzle-orm/d1'; +import { Hono } from 'hono'; + +import * as schema from '../db/schema'; +import type { Env } from '../env'; +import { requireRouteParam } from '../lib/route-helpers'; +import { getAuth, getUserId } from '../middleware/auth'; +import { errors } from '../middleware/error'; +import { requireProjectCapability } from '../middleware/project-auth'; +import { jsonValidator } from '../schemas/_validator'; +import { + CommentStatusMutationSchema, + CreateCommentReplySchema, + CreateLibraryFileCommentThreadSchema, +} from '../schemas/comments'; +import * as projectDataService from '../services/project-data'; + +export const libraryCommentRoutes = new Hono<{ Bindings: Env }>(); + +function parseCommentStatus(rawStatus?: string): CommentStatus | null { + if (!rawStatus) return null; + const status = rawStatus.trim().toLowerCase(); + if (COMMENT_STATUSES.includes(status as CommentStatus)) return status as CommentStatus; + throw errors.badRequest('status must be open, sent, or resolved'); +} + +function parsePositiveIntegerQuery(name: string, rawValue?: string): number | null { + if (!rawValue) return null; + const parsed = Number.parseInt(rawValue, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw errors.badRequest(`${name} must be a positive integer`); + } + return parsed; +} + +function parseNonNegativeIntegerQuery(name: string, rawValue?: string): number | null { + if (!rawValue) return null; + const parsed = Number.parseInt(rawValue, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw errors.badRequest(`${name} must be a non-negative integer`); + } + return parsed; +} + +function rethrowCommentError(err: unknown): never { + const code = + err && typeof err === 'object' && 'code' in err && typeof err.code === 'string' + ? err.code + : null; + const name = err instanceof Error ? err.name : null; + + if ( + err instanceof projectDataService.CommentValidationError || + code === 'COMMENT_VALIDATION' || + name === 'CommentValidationError' + ) { + throw errors.badRequest(err instanceof Error ? err.message : 'Invalid comment request'); + } + if ( + err instanceof projectDataService.CommentNotFoundError || + code === 'COMMENT_NOT_FOUND' || + name === 'CommentNotFoundError' + ) { + const resource = + err && typeof err === 'object' && 'resource' in err && typeof err.resource === 'string' + ? err.resource + : 'Resource'; + throw errors.notFound(resource); + } + if ( + err instanceof projectDataService.CommentIdempotencyConflictError || + code === 'COMMENT_IDEMPOTENCY_CONFLICT' || + name === 'CommentIdempotencyConflictError' + ) { + throw errors.conflict( + err instanceof Error + ? err.message + : 'clientMutationId already belongs to a different comment mutation' + ); + } + if ( + err instanceof projectDataService.CommentLimitExceededError || + code === 'COMMENT_LIMIT_EXCEEDED' || + name === 'CommentLimitExceededError' + ) { + throw errors.unprocessable(err instanceof Error ? err.message : 'Comment limit exceeded'); + } + throw err; +} + +async function verifyFileExists(env: Env, projectId: string, fileId: string) { + const db = drizzle(env.DATABASE, { schema }); + const file = await db.query.projectFiles.findFirst({ + where: (f, { eq, and }) => and(eq(f.projectId, projectId), eq(f.id, fileId)), + columns: { id: true }, + }); + if (!file) { + throw errors.notFound('Library file'); + } +} + +/** + * GET /api/projects/:projectId/library/:fileId/comments + * List comment threads for a library file. + */ +libraryCommentRoutes.get('/:fileId/comments', async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const fileId = requireRouteParam(c, 'fileId'); + const db = drizzle(c.env.DATABASE, { schema }); + + await requireProjectCapability(db, projectId, userId, 'task:read'); + await verifyFileExists(c.env, projectId, fileId); + + try { + const result = await projectDataService.listCommentThreads(c.env, projectId, { + fileId, + anchorKind: 'library_file', + status: parseCommentStatus(c.req.query('status')), + afterSequence: parseNonNegativeIntegerQuery('afterSequence', c.req.query('afterSequence')), + limit: parsePositiveIntegerQuery('limit', c.req.query('limit')), + }); + return c.json(result); + } catch (err) { + rethrowCommentError(err); + } +}); + +/** + * POST /api/projects/:projectId/library/:fileId/comments + * Create a comment thread on a library file. + */ +libraryCommentRoutes.post( + '/:fileId/comments', + jsonValidator(CreateLibraryFileCommentThreadSchema), + async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const fileId = requireRouteParam(c, 'fileId'); + const db = drizzle(c.env.DATABASE, { schema }); + + await requireProjectCapability(db, projectId, userId, 'task:write'); + await verifyFileExists(c.env, projectId, fileId); + + const body = c.req.valid('json'); + try { + const result = await projectDataService.createFileCommentThread(c.env, projectId, { + fileId, + body: body.body, + quote: body.quote ?? null, + clientMutationId: body.clientMutationId ?? null, + actor: { + kind: 'human', + id: userId, + name: getAuth(c).user.name ?? getAuth(c).user.email ?? null, + }, + }); + return c.json(result, result.idempotent ? 200 : 201); + } catch (err) { + rethrowCommentError(err); + } + } +); + +/** + * POST /api/projects/:projectId/library/:fileId/comments/:threadId/replies + * Reply to a library file comment thread. + */ +libraryCommentRoutes.post( + '/:fileId/comments/:threadId/replies', + jsonValidator(CreateCommentReplySchema), + async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const fileId = requireRouteParam(c, 'fileId'); + const threadId = requireRouteParam(c, 'threadId'); + const db = drizzle(c.env.DATABASE, { schema }); + + await requireProjectCapability(db, projectId, userId, 'task:write'); + await verifyFileExists(c.env, projectId, fileId); + + const body = c.req.valid('json'); + try { + const result = await projectDataService.createCommentReply(c.env, projectId, { + threadId, + body: body.body, + clientMutationId: body.clientMutationId ?? null, + actor: { + kind: 'human', + id: userId, + name: getAuth(c).user.name ?? getAuth(c).user.email ?? null, + }, + }); + return c.json(result, result.idempotent ? 200 : 201); + } catch (err) { + rethrowCommentError(err); + } + } +); + +/** + * POST /api/projects/:projectId/library/:fileId/comments/:threadId/resolve + */ +libraryCommentRoutes.post( + '/:fileId/comments/:threadId/resolve', + jsonValidator(CommentStatusMutationSchema), + async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const fileId = requireRouteParam(c, 'fileId'); + const threadId = requireRouteParam(c, 'threadId'); + const db = drizzle(c.env.DATABASE, { schema }); + + await requireProjectCapability(db, projectId, userId, 'task:write'); + await verifyFileExists(c.env, projectId, fileId); + + const body = c.req.valid('json'); + try { + return c.json( + await projectDataService.updateCommentThreadStatus(c.env, projectId, { + threadId, + status: 'resolved', + clientMutationId: body.clientMutationId ?? null, + actor: { + kind: 'human', + id: userId, + name: getAuth(c).user.name ?? getAuth(c).user.email ?? null, + }, + }) + ); + } catch (err) { + rethrowCommentError(err); + } + } +); + +/** + * POST /api/projects/:projectId/library/:fileId/comments/:threadId/reopen + */ +libraryCommentRoutes.post( + '/:fileId/comments/:threadId/reopen', + jsonValidator(CommentStatusMutationSchema), + async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const fileId = requireRouteParam(c, 'fileId'); + const threadId = requireRouteParam(c, 'threadId'); + const db = drizzle(c.env.DATABASE, { schema }); + + await requireProjectCapability(db, projectId, userId, 'task:write'); + await verifyFileExists(c.env, projectId, fileId); + + const body = c.req.valid('json'); + try { + return c.json( + await projectDataService.updateCommentThreadStatus(c.env, projectId, { + threadId, + status: 'open', + clientMutationId: body.clientMutationId ?? null, + actor: { + kind: 'human', + id: userId, + name: getAuth(c).user.name ?? getAuth(c).user.email ?? null, + }, + }) + ); + } catch (err) { + rethrowCommentError(err); + } + } +); diff --git a/apps/api/src/routes/mcp/index.ts b/apps/api/src/routes/mcp/index.ts index 92bcd068e..2ab7b2b9f 100644 --- a/apps/api/src/routes/mcp/index.ts +++ b/apps/api/src/routes/mcp/index.ts @@ -35,6 +35,10 @@ import { handleResolveMessageCommentThread, } from './comment-tools'; import { handleBuildAndPublish, handleGetPublishStatus } from './compose-publish-tools'; +import { + handleCreateLibraryFileCommentThread, + handleListLibraryFileCommentThreads, +} from './library-file-comment-tools'; import { handleGetDeploymentGuide } from './deployment-guide-tools'; import { handleCreateDeploymentEnvironment, @@ -343,6 +347,14 @@ mcpRoutes.post('/', async (c) => { return c.json( await handleReopenMessageCommentThread(requestId, toolArgs, tokenData, c.env) ); + case 'list_library_file_comment_threads': + return c.json( + await handleListLibraryFileCommentThreads(requestId, toolArgs, tokenData, c.env) + ); + case 'create_library_file_comment_thread': + return c.json( + await handleCreateLibraryFileCommentThread(requestId, toolArgs, tokenData, c.env) + ); case 'send_message_to_subtask': return c.json(await handleSendMessageToSubtask(requestId, toolArgs, tokenData, c.env)); case 'stop_subtask': diff --git a/apps/api/src/routes/mcp/library-file-comment-tools.ts b/apps/api/src/routes/mcp/library-file-comment-tools.ts new file mode 100644 index 000000000..1bbf4552c --- /dev/null +++ b/apps/api/src/routes/mcp/library-file-comment-tools.ts @@ -0,0 +1,218 @@ +/** + * MCP library-file comment tools. + * + * Project-scoped (no session resolution needed). Agents can list and create + * comment threads on library files without a chat session. + * + * Cross-references: apps/api/src/routes/library-comments.ts (HTTP routes) + */ +import type { MessageCommentThread } from '@simple-agent-manager/shared'; +import { drizzle } from 'drizzle-orm/d1'; + +import * as schema from '../../db/schema'; +import type { Env } from '../../env'; +import { log } from '../../lib/logger'; +import { + boundCommentThread, + boundCommentThreadSummary, + buildAgentCommentAuthor, + clampCommentListLimit, + getMessageCommentConfig, + isMessageCommentServiceError, + normalizeCommentBody, + normalizeCommentQuote, +} from '../../services/message-comments'; +import * as projectDataService from '../../services/project-data'; +import { + INTERNAL_ERROR, + INVALID_PARAMS, + jsonRpcError, + type JsonRpcResponse, + jsonRpcSuccess, + type McpTokenData, +} from './_helpers'; + +const CALLER_DERIVED_FIELDS = [ + 'projectId', + 'userId', + 'author', + 'authorId', + 'authorKind', + 'authorDisplayName', + 'provenance', +]; + +function toolSuccess(requestId: string | number | null, value: unknown): JsonRpcResponse { + return jsonRpcSuccess(requestId, { + content: [{ type: 'text', text: JSON.stringify(value) }], + }); +} + +function rejectCallerDerivedFields( + requestId: string | number | null, + params: Record +): JsonRpcResponse | null { + for (const field of CALLER_DERIVED_FIELDS) { + if (Object.prototype.hasOwnProperty.call(params, field)) { + return jsonRpcError( + requestId, + INVALID_PARAMS, + `${field} is derived from the verified MCP token and must not be supplied` + ); + } + } + return null; +} + +function optionalString(params: Record, field: string): string | null { + const value = params[field]; + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function requiredString( + requestId: string | number | null, + params: Record, + field: string +): string | JsonRpcResponse { + const value = optionalString(params, field); + if (!value) return jsonRpcError(requestId, INVALID_PARAMS, `${field} is required`); + return value; +} + +function parseStatus( + requestId: string | number | null, + params: Record +): 'open' | 'sent' | 'resolved' | 'all' | JsonRpcResponse { + const raw = optionalString(params, 'status') ?? 'open'; + if (raw === 'open' || raw === 'sent' || raw === 'resolved' || raw === 'all') return raw; + return jsonRpcError(requestId, INVALID_PARAMS, 'status must be open, sent, resolved, or all'); +} + +function mapCommentError( + requestId: string | number | null, + err: unknown, + logTag: string, + logContext: Record +): JsonRpcResponse { + if (isMessageCommentServiceError(err)) { + const code = err.code === 'unavailable' ? INTERNAL_ERROR : INVALID_PARAMS; + return jsonRpcError(requestId, code, err.message); + } + log.warn(logTag, { + ...logContext, + error: err instanceof Error ? err.message : String(err), + }); + return jsonRpcError(requestId, INTERNAL_ERROR, 'Comment tool failed'); +} + +function parseCursor(cursor: string | null): number | null { + if (!cursor) return null; + const parsed = Number.parseInt(cursor, 10); + if (!Number.isFinite(parsed) || parsed < 0) return null; + return parsed; +} + +async function verifyFileExists( + requestId: string | number | null, + env: Env, + projectId: string, + fileId: string +): Promise { + const db = drizzle(env.DATABASE, { schema }); + const file = await db.query.projectFiles.findFirst({ + where: (f, { eq, and }) => and(eq(f.projectId, projectId), eq(f.id, fileId)), + columns: { id: true }, + }); + if (!file) { + return jsonRpcError(requestId, INVALID_PARAMS, 'Library file not found'); + } + return null; +} + +export async function handleListLibraryFileCommentThreads( + requestId: string | number | null, + params: Record, + tokenData: McpTokenData, + env: Env +): Promise { + const identityError = rejectCallerDerivedFields(requestId, params); + if (identityError) return identityError; + const fileId = requiredString(requestId, params, 'fileId'); + if (typeof fileId !== 'string') return fileId; + const status = parseStatus(requestId, params); + if (typeof status !== 'string') return status; + + const fileError = await verifyFileExists(requestId, env, tokenData.projectId, fileId); + if (fileError) return fileError; + + const config = getMessageCommentConfig(env); + const limit = clampCommentListLimit(params.limit, config); + const cursor = optionalString(params, 'cursor'); + const afterSequence = parseCursor(cursor); + + try { + const result = await projectDataService.listCommentThreads(env, tokenData.projectId, { + fileId, + anchorKind: 'library_file', + status: status === 'all' ? null : status, + afterSequence, + limit, + }); + const lastThread = result.threads.at(-1); + const nextCursor = + result.hasMore && lastThread && 'sequence' in lastThread && typeof lastThread.sequence === 'number' + ? String(lastThread.sequence) + : null; + return toolSuccess(requestId, { + threads: result.threads.map((thread) => boundCommentThreadSummary(thread, config)), + hasMore: result.hasMore, + nextCursor, + }); + } catch (err) { + return mapCommentError(requestId, err, 'mcp.library_file_comments.list_failed', { + projectId: tokenData.projectId, + fileId, + }); + } +} + +export async function handleCreateLibraryFileCommentThread( + requestId: string | number | null, + params: Record, + tokenData: McpTokenData, + env: Env +): Promise { + const identityError = rejectCallerDerivedFields(requestId, params); + if (identityError) return identityError; + const fileId = requiredString(requestId, params, 'fileId'); + if (typeof fileId !== 'string') return fileId; + const bodyRaw = requiredString(requestId, params, 'body'); + if (typeof bodyRaw !== 'string') return bodyRaw; + + const fileError = await verifyFileExists(requestId, env, tokenData.projectId, fileId); + if (fileError) return fileError; + + const config = getMessageCommentConfig(env); + const body = normalizeCommentBody(bodyRaw, config); + if (!body) return jsonRpcError(requestId, INVALID_PARAMS, 'body is required'); + + const author = buildAgentCommentAuthor(tokenData); + + try { + const result = await projectDataService.createFileCommentThread(env, tokenData.projectId, { + fileId, + body, + quote: normalizeCommentQuote(optionalString(params, 'quote'), config), + clientMutationId: null, + actor: author, + }); + return toolSuccess(requestId, { + thread: boundCommentThread(result.thread as MessageCommentThread, config), + }); + } catch (err) { + return mapCommentError(requestId, err, 'mcp.library_file_comments.create_failed', { + projectId: tokenData.projectId, + fileId, + }); + } +} diff --git a/apps/api/src/routes/mcp/tool-definitions-library-file-comment-tools.ts b/apps/api/src/routes/mcp/tool-definitions-library-file-comment-tools.ts new file mode 100644 index 000000000..64440acc6 --- /dev/null +++ b/apps/api/src/routes/mcp/tool-definitions-library-file-comment-tools.ts @@ -0,0 +1,62 @@ +/** + * MCP tool definitions — library-file comment tools. + * + * Project-scoped (not session-scoped). Agents can list and create comment + * threads on library files without needing a chat session. + */ + +export const LIBRARY_FILE_COMMENT_TOOLS = [ + { + name: 'list_library_file_comment_threads', + description: + 'List comment threads on a project library file. Unlike message comments, these are project-scoped and do not require a chat session. Returns thread summaries; use get_message_comment_thread for full replies on message threads.', + inputSchema: { + type: 'object' as const, + properties: { + fileId: { + type: 'string', + description: 'The library file ID to list comments for (required).', + }, + status: { + type: 'string', + enum: ['open', 'sent', 'resolved', 'all'], + description: 'Filter by thread status. Defaults to open.', + }, + cursor: { + type: 'string', + description: 'Opaque pagination cursor from the previous response.', + }, + limit: { + type: 'number', + description: 'Max threads to return (default 10, server capped).', + }, + }, + required: ['fileId'], + additionalProperties: false, + }, + }, + { + name: 'create_library_file_comment_thread', + description: + 'Create an agent-authored comment thread on a project library file. Author identity and provenance are derived from your verified MCP token; do not provide author fields. The comment is project-scoped and does not require a chat session.', + inputSchema: { + type: 'object' as const, + properties: { + fileId: { + type: 'string', + description: 'The library file ID to comment on (required).', + }, + body: { + type: 'string', + description: 'Comment body (server sanitized and length capped).', + }, + quote: { + type: 'string', + description: 'Optional short quote from the file for citation context.', + }, + }, + required: ['fileId', 'body'], + additionalProperties: false, + }, + }, +]; diff --git a/apps/api/src/routes/mcp/tool-definitions.ts b/apps/api/src/routes/mcp/tool-definitions.ts index edd6ae486..65853d529 100644 --- a/apps/api/src/routes/mcp/tool-definitions.ts +++ b/apps/api/src/routes/mcp/tool-definitions.ts @@ -16,6 +16,7 @@ export { COMMENT_TOOLS } from './tool-definitions-comment-tools'; export { DEPLOYMENT_TOOLS } from './tool-definitions-deployment-tools'; +export { LIBRARY_FILE_COMMENT_TOOLS } from './tool-definitions-library-file-comment-tools'; export { INCIDENT_TOOLS } from './tool-definitions-incident-tools'; export { KNOWLEDGE_TOOLS } from './tool-definitions-knowledge-tools'; export { LIBRARY_TOOLS } from './tool-definitions-library-tools'; @@ -33,6 +34,7 @@ export { WORKSPACE_TOOLS } from './tool-definitions-workspace-tools'; import { COMMENT_TOOLS } from './tool-definitions-comment-tools'; import { DEPLOYMENT_TOOLS } from './tool-definitions-deployment-tools'; +import { LIBRARY_FILE_COMMENT_TOOLS } from './tool-definitions-library-file-comment-tools'; import { INCIDENT_TOOLS } from './tool-definitions-incident-tools'; import { KNOWLEDGE_TOOLS } from './tool-definitions-knowledge-tools'; import { LIBRARY_TOOLS } from './tool-definitions-library-tools'; @@ -57,6 +59,7 @@ export const MCP_TOOLS = [ ...LIBRARY_TOOLS, ...ORCHESTRATION_TOOLS, ...COMMENT_TOOLS, + ...LIBRARY_FILE_COMMENT_TOOLS, ...TRIGGER_TOOLS, ...INCIDENT_TOOLS, ...PROFILE_TOOLS, diff --git a/apps/api/src/schemas/comments.ts b/apps/api/src/schemas/comments.ts index 45ef59538..42c53c209 100644 --- a/apps/api/src/schemas/comments.ts +++ b/apps/api/src/schemas/comments.ts @@ -23,6 +23,12 @@ export const CommentStatusMutationSchema = v.object({ clientMutationId: v.optional(v.nullable(v.string())), }); +export const CreateLibraryFileCommentThreadSchema = v.object({ + body: v.string(), + quote: v.optional(v.nullable(v.string())), + clientMutationId: v.optional(v.nullable(v.string())), +}); + export const SendCommentDirectiveSchema = v.object({ body: v.optional(v.string()), clientMutationId: v.optional(v.nullable(v.string())), diff --git a/apps/api/src/services/message-comments.ts b/apps/api/src/services/message-comments.ts index ba9c04d56..3fb00df9e 100644 --- a/apps/api/src/services/message-comments.ts +++ b/apps/api/src/services/message-comments.ts @@ -194,16 +194,18 @@ export function createProjectDataMessageCommentAdapter(env: Env): MessageComment afterSequence: cursorToAfterSequence(input.cursor), limit: input.limit, }); - return addNextCursor(result); + return addNextCursor({ + threads: result.threads as MessageCommentThread[], + hasMore: result.hasMore, + }); }, async getThread(input) { const thread = await projectDataService.getCommentThread( env, input.projectId, - input.sessionId, input.threadId ); - return thread ? withCommentDisplayNames(thread) : null; + return thread ? withCommentDisplayNames(thread as MessageCommentThread) : null; }, async createThread(input) { const result = await projectDataService.createCommentThread(env, input.projectId, { @@ -214,7 +216,7 @@ export function createProjectDataMessageCommentAdapter(env: Env): MessageComment clientMutationId: null, actor: toStorageActor(input.author), }); - return withCommentDisplayNames(result.thread); + return withCommentDisplayNames(result.thread as MessageCommentThread); }, async replyToThread(input) { const result = await projectDataService.createCommentReply(env, input.projectId, { @@ -224,7 +226,7 @@ export function createProjectDataMessageCommentAdapter(env: Env): MessageComment clientMutationId: null, actor: toStorageActor(input.author), }); - return withCommentDisplayNames(result.thread); + return withCommentDisplayNames(result.thread as MessageCommentThread); }, async updateThreadStatus(input) { const result = await projectDataService.updateCommentThreadStatus(env, input.projectId, { @@ -234,7 +236,7 @@ export function createProjectDataMessageCommentAdapter(env: Env): MessageComment clientMutationId: null, actor: toStorageActor(input.actor), }); - return withCommentDisplayNames(result.thread); + return withCommentDisplayNames(result.thread as MessageCommentThread); }, async markThreadObserved() { return { observed: false }; @@ -253,7 +255,7 @@ export function createProjectDataMessageCommentAdapter(env: Env): MessageComment }, }); return { - ...withCommentDisplayNames(result.thread), + ...withCommentDisplayNames(result.thread as MessageCommentThread), directive: input.delivery, }; }, diff --git a/apps/api/src/services/project-data.ts b/apps/api/src/services/project-data.ts index 7ab52dc14..1734b027b 100644 --- a/apps/api/src/services/project-data.ts +++ b/apps/api/src/services/project-data.ts @@ -16,9 +16,7 @@ import type { CreateCheckpointEpisodeInput, DeliveryState, MessageClass, - MessageCommentListResponse, - MessageCommentMutationResponse, - MessageCommentReplyMutationResponse, + LibraryFileCommentThread, MessageCommentThread, SessionActivityTerminalReason, } from '@simple-agent-manager/shared'; @@ -26,8 +24,12 @@ import { resolveHandoffLimits, resolveMissionStateLimits } from '@simple-agent-m import type { ProjectData } from '../durable-objects/project-data'; import type { + CommentReplyMutationResult, + CommentThreadMutationResult, CreateCommentReplyInput, CreateCommentThreadInput, + CreateFileCommentThreadInput, + ListCommentThreadsResult, ListCommentThreadsInput, UpdateCommentStatusInput, } from '../durable-objects/project-data/comment-contracts'; @@ -441,7 +443,7 @@ export async function listCommentThreads( env: Env, projectId: string, input: ListCommentThreadsInput -): Promise { +): Promise { return callProjectDataWithRetry(env, projectId, 'listCommentThreads', (stub) => stub.listCommentThreads(input) ); @@ -450,11 +452,12 @@ export async function listCommentThreads( export async function getCommentThread( env: Env, projectId: string, - sessionId: string, threadId: string -): Promise { +): Promise { return callProjectDataWithRetry(env, projectId, 'getCommentThread', (stub) => - stub.getCommentThread({ sessionId, threadId }) + stub.getCommentThread({ threadId }) as Promise< + MessageCommentThread | LibraryFileCommentThread | null + > ); } @@ -462,17 +465,27 @@ export async function createCommentThread( env: Env, projectId: string, input: CreateCommentThreadInput -): Promise { +): Promise { return callProjectDataNoRetry(env, projectId, 'createCommentThread', (stub) => stub.createCommentThread(input) ); } +export async function createFileCommentThread( + env: Env, + projectId: string, + input: CreateFileCommentThreadInput +): Promise { + return callProjectDataNoRetry(env, projectId, 'createFileCommentThread', (stub) => + stub.createFileCommentThread(input) + ); +} + export async function createCommentReply( env: Env, projectId: string, input: CreateCommentReplyInput -): Promise { +): Promise { return callProjectDataNoRetry(env, projectId, 'createCommentReply', (stub) => stub.createCommentReply(input) ); @@ -482,7 +495,7 @@ export async function updateCommentThreadStatus( env: Env, projectId: string, input: UpdateCommentStatusInput & { status: CommentStatus } -): Promise { +): Promise { return callProjectDataNoRetry(env, projectId, 'updateCommentThreadStatus', (stub) => stub.updateCommentThreadStatus(input) ); diff --git a/apps/web/src/components/library/FileCommentPanel.tsx b/apps/web/src/components/library/FileCommentPanel.tsx new file mode 100644 index 000000000..5bc7a8a72 --- /dev/null +++ b/apps/web/src/components/library/FileCommentPanel.tsx @@ -0,0 +1,78 @@ +import { Spinner } from '@simple-agent-manager/ui'; +import { X } from 'lucide-react'; + +import type { MessageCommentAction } from '../../lib/api/comments'; +import { CommentComposer } from '../project-message-view/comments/CommentComposer'; +import { CommentThreadList } from '../project-message-view/comments/CommentThread'; +import { useLibraryFileComments } from './useLibraryFileComments'; +import { FOCUS_RING } from './types'; + +interface FileCommentPanelProps { + projectId: string; + fileId: string; + onClose: () => void; +} + +export function FileCommentPanel({ projectId, fileId, onClose }: FileCommentPanelProps) { + const { comments, loading, error, createThread, reply, resolve, reopen } = + useLibraryFileComments(projectId, fileId); + + const handleCreateThread = async (body: string, _action: MessageCommentAction) => { + await createThread(body); + }; + + const handleReply = async (threadId: string, body: string, _action: MessageCommentAction) => { + await reply(threadId, body); + }; + + return ( +
+
+

Comments

+ +
+ +
+ {loading && ( +
+ +
+ )} + + {error && !loading && ( +

{error}

+ )} + + {!loading && !error && ( + + )} +
+ +
+ +
+
+ ); +} diff --git a/apps/web/src/components/library/FilePreviewModal.tsx b/apps/web/src/components/library/FilePreviewModal.tsx index 882042af1..4c71447eb 100644 --- a/apps/web/src/components/library/FilePreviewModal.tsx +++ b/apps/web/src/components/library/FilePreviewModal.tsx @@ -1,5 +1,5 @@ import { Spinner } from '@simple-agent-manager/ui'; -import { AlertTriangle, Code, Download, Eye, X } from 'lucide-react'; +import { AlertTriangle, Code, Download, Eye, MessageSquare, X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -15,10 +15,12 @@ import { } from '../../lib/file-utils'; import { RenderedMarkdown, SyntaxHighlightedCode } from '../MarkdownRenderer'; import { ImageViewer } from '../shared-file-viewer/ImageViewer'; +import { FileCommentPanel } from './FileCommentPanel'; import { InteractiveHtmlPreview } from './InteractiveHtmlPreview'; import { type FileWithTags, FOCUS_RING } from './types'; export interface FilePreviewModalProps { + projectId: string; file: FileWithTags; previewUrl: string; onClose: () => void; @@ -83,10 +85,17 @@ function ViewToggle({ ); } -export function FilePreviewModal({ file, previewUrl, onClose, onDownload }: FilePreviewModalProps) { +export function FilePreviewModal({ + projectId, + file, + previewUrl, + onClose, + onDownload, +}: FilePreviewModalProps) { const dialogRef = useRef(null); const [pdfLoading, setPdfLoading] = useState(true); const [pdfError, setPdfError] = useState(false); + const [commentsOpen, setCommentsOpen] = useState(false); // Pass the filename so an octet-stream/empty stored type (agent uploads) still // resolves to its real previewable type from the extension. @@ -246,6 +255,20 @@ export function FilePreviewModal({ file, previewUrl, onClose, onDownload }: File Download + - - ) : ( -