# Scratchpad — Threaded Comment Responses

- Plan slug: `2026-05-13-threaded-comments`
- Created: `2026-05-13`

## What This Is

Working notes for implementing nested/threaded comment responses on tickets and project tasks, plus the supporting first-class `comment_threads` entity that carries email-thread identity. See `PRD.md` for scope, `features.json` for the commit-sized work list, `tests.json` for the test plan.

## Decisions

- (2026-05-13) **Thread as first-class entity.** Every top-level comment is the head of a `comment_threads` row. Inbound/outbound email correlation happens at thread granularity, not ticket. New "Add Comment" always creates a new thread; existing flat comments backfill as single-comment threads.
- (2026-05-13) **F001 migration split.** Created `comment_threads` in `20260513100000_create_comment_threads.cjs` with only the base table, parent CHECK, and ticket/task FKs. Deferred indexes, comment-column FKs, and backfill to their own checklist migrations to preserve the PRD's staged rollout and make each commit reviewable.
- (2026-05-13) **Hybrid (Nested + collapsible drawer)** is the only production reply model. Other modes from the prototype (Flat, Single-level only, Deep nesting only, Quote-reply, Side-panel only) are exploration-only and not shipping. The prototype's Tweaks panel is not part of production.
- (2026-05-13) **Scope:** Tickets + project tasks together in one PR. Email integration is full bidirectional (inbound matching + outbound RFC headers + reply tokens) for tickets only — tasks don't accept inbound email today.
- (2026-05-13) **Backfill model:** one thread per existing comment. Cleanest cut-over; visually identical to today (no thread bars on legacy comments).
- (2026-05-13) **Depth cap:** visual indent capped at 4 levels (matches the design prototype); data has no cap so the model accommodates real-world deep threads.
- (2026-05-13) **Soft-delete only for roots with children.** Leaf comments hard-delete as today. Soft-delete = `deleted_at` set + `note = '[deleted]'`. Keeps tree well-formed without orphaning children.
- (2026-05-13) **Visibility invariant:** a thread's `is_internal` flag denormalizes from its root. Reply must be compatible with root (can't make a reply client-visible inside an internal thread, etc.). Inbound emails inherit thread visibility — clients can only reply to client-visible threads.
- (2026-05-13) **Inbound resolution precedence:** reply-token > In-Reply-To > References (end→start) > provider thread id > ticket-fallback (new top-level thread). First match wins.

## Discoveries / Constraints

- (2026-05-13) **F002 indexes.** Added `20260513100500_add_comment_threads_indexes.cjs` with `comment_threads_ticket_idx`, `comment_threads_task_idx`, and partial `comment_threads_email_msgid_idx`. The list indexes include `last_activity_at DESC` because thread ordering is by thread activity, not individual comment chronology.
- (2026-05-13) **F003 ticket comment columns.** Added nullable `comments.thread_id`, `comments.parent_comment_id`, and `comments.deleted_at` in `20260513101000_add_threading_columns_to_comments.cjs`. `thread_id` is intentionally nullable until the backfill and F007 enforcement migration. Added tenant-scoped FK to `comment_threads` plus self-FK for parent comments.
- (2026-05-13) **F004 project task comment columns.** Added matching nullable threading columns to `project_task_comments` in `20260513101500_add_threading_columns_to_project_task_comments.cjs`, with FK to `comment_threads` and a self-FK on `(tenant, parent_comment_id)`.
- (2026-05-13) **F005 ticket comment backfill.** Added chunked backfill in `20260513102000_backfill_comment_threads_for_comments.cjs`. Legacy ticket comments use `thread_id = comment_id`, which makes reruns idempotent via `ON CONFLICT (tenant, thread_id) DO NOTHING` and avoids a temporary mapping table. `email_message_id` is populated from `metadata->'email'->>'messageId'`.
- (2026-05-13) **F006 task comment backfill.** Added `20260513102500_backfill_comment_threads_for_project_task_comments.cjs` using the same deterministic legacy ID pattern (`thread_id = task_comment_id`). Task threads set `project_task_id`, leave `ticket_id` null, and use `is_internal=false`.
- (2026-05-13) **F007 NOT NULL enforcement.** Added `20260513103000_enforce_comment_thread_ids_not_null.cjs`. It raises a clear migration exception if either comment table still has null `thread_id` values, then alters both columns to NOT NULL.
- (2026-05-13) **F008 email log linkage.** Added nullable `email_sending_logs.comment_thread_id` in `20260513103500_add_comment_thread_id_to_email_sending_logs.cjs` with tenant-scoped FK to `comment_threads`. Left existing `email_sending_logs.thread_id` untouched as the provider thread id and added a partial `(tenant, comment_thread_id, created_at DESC)` index for outbound latest-message lookup.
- (2026-05-13) **F009 type contract.** Added `packages/types/src/interfaces/commentThread.interface.ts` with `ICommentThread` covering all `comment_threads` columns and exported it from `packages/types/src/interfaces/index.ts`. Verified with `npx tsc -p packages/types/tsconfig.json --noEmit`.
- (2026-05-13) **F010 ticket comment type fields.** Extended `IComment` with `thread_id`, `parent_comment_id`, and `deleted_at`. Kept them optional at the interface boundary because `IComment` is currently used for both persisted rows and create payloads; runtime model code will guarantee `thread_id` for persisted reads after F012-F016. Verified with `npx tsc -p packages/types/tsconfig.json --noEmit`.
- (2026-05-13) **F011 task comment type fields.** Extended `IProjectTaskComment` with camelCase `threadId`, `parentCommentId`, and `deletedAt`, matching the existing task-comment interface convention while mapping to DB columns `thread_id`, `parent_comment_id`, and `deleted_at` in actions/models. Kept fields optional for create payload compatibility. Verified with `npx tsc -p packages/types/tsconfig.json --noEmit`.
- (2026-05-13) **F012 top-level ticket comment creation.** Updated `packages/tickets/src/models/comment.ts` so top-level inserts generate `comment_id` + `thread_id`, insert `comment_threads` first, then insert `comments.thread_id` in the same transaction. Also updated `shared/models/ticketModel.ts` direct comment creation for the same NOT NULL requirement; otherwise workflow/email paths would fail after F007. Reply parent resolution remains guarded for F013. Verified with `npx tsc -p packages/types/tsconfig.json --noEmit` and `npx tsc -p packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **F013 ticket reply parent resolution.** Updated `Comment.insert` to resolve `parent_comment_id` against the same tenant and ticket, reject soft-deleted parents, inherit the parent's `thread_id`, and default/validate `is_internal` against `comment_threads.is_internal`. Verified with `npx tsc -p packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **F014 ticket reply counters.** `Comment.insert` now increments `comment_threads.reply_count` and updates `last_activity_at` after successful reply insert, in the same transaction supplied by the action/model caller. Verified with `npx tsc -p packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **F015 ticket comment delete semantics.** `Comment.delete` now checks for children. Comments with children are soft-deleted with `note`/`markdown_content = '[deleted]'` and `deleted_at` set. Leaf replies hard-delete and decrement `reply_count`; leaf roots hard-delete and remove their now-empty `comment_threads` row. Verified with `npx tsc -p packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **F016 ticket comment reads.** No code change needed: `Comment.getAllbyTicketId` already selects `comments.*`, filters only by tenant/ticket, and orders by `comments.created_at ASC`, so the new `thread_id`, `parent_comment_id`, and `deleted_at` columns are returned and soft-deleted comments remain in the result.
- (2026-05-13) **F017 top-level task comment creation.** Updated `createTaskComment` to validate the task first, generate `task_comment_id` + `thread_id`, insert `comment_threads` with `project_task_id` and `is_internal=false`, then insert `project_task_comments.thread_id` in the same transaction. Reply parent resolution remains guarded for F018. Verified with `npx tsc -p packages/projects/tsconfig.json --noEmit`.
- (2026-05-13) **F018 task reply parent resolution.** `createTaskComment` now resolves `parentCommentId`, validates the parent is on the same task and not deleted, inherits `thread_id`, and inserts the reply with `parent_comment_id`. Verified with `npx tsc -p packages/projects/tsconfig.json --noEmit`.
- (2026-05-13) **F019 task reply counters.** Task replies now increment `comment_threads.reply_count` and bump `last_activity_at` in the same transaction after successful insert. Verified with `npx tsc -p packages/projects/tsconfig.json --noEmit`.
- (2026-05-13) **F020 task comment delete semantics.** `deleteTaskComment` now soft-deletes task comments with children, hard-deletes leaves, decrements `reply_count` for leaf replies, and removes empty root thread rows. Reactions are still explicitly deleted before hard-delete for Citus compatibility. Verified with `npx tsc -p packages/projects/tsconfig.json --noEmit`.
- (2026-05-13) **F021 task comment reads.** `getTaskComments` now maps `thread_id`, `parent_comment_id`, and `deleted_at` from `project_task_comments.*` into `threadId`, `parentCommentId`, and `deletedAt` on `IProjectTaskCommentWithUser`. Verified with `npx tsc -p packages/projects/tsconfig.json --noEmit`.
- (2026-05-13) **F022 ticket create action pass-through.** No code change needed: after F010, `createComment(comment: Omit<IComment, 'tenant'>)` accepts optional `parent_comment_id`; the action copies the payload into `commentToInsert` and passes it to `Comment.insert`, which resolves the parent in F013.
- (2026-05-13) **F023 ticket comment event payload.** `TICKET_COMMENT_ADDED` now includes `thread_id`, `parent_comment_id`, and `is_reply` at the payload level and inside the legacy `comment` object. The action fetches the inserted comment after model insert so the event uses the resolved thread id. Verified with `npx tsc -p packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **F024 ticket reply visibility enforcement.** No extra action code needed: `createComment` already rejects internal comments from non-internal authors, and F013 model enforcement rejects any reply whose `is_internal` differs from `comment_threads.is_internal` (including client-visible reply inside an internal thread and internal reply inside a client thread).
- (2026-05-13) **F025 task create action parent payload.** `createTaskComment` now accepts `parent_comment_id` in addition to camelCase `parentCommentId` and normalizes both to the same parent resolution path. Verified with `npx tsc -p packages/projects/tsconfig.json --noEmit`.
- (2026-05-13) **F026 task comment event payload.** `TASK_COMMENT_ADDED` now includes both camelCase and snake_case thread fields: `threadId`/`thread_id`, `parentCommentId`/`parent_comment_id`, and `isReply`/`is_reply`. Verified with `npx tsc -p packages/projects/tsconfig.json --noEmit`.
- (2026-05-13) **F027 task reply ownership.** No code change needed: task replies use the same `project_task_comments.user_id` ownership field as top-level comments, so `assertOwnCommentOrInternalUser` already allows internal users and the reply's owner while rejecting other client users.
- (2026-05-13) **F028 inbound reply-token thread routing.** Added `resolveReplyTargetFromComment` in `processInboundEmailInApp` so a reply token tied to a `comment_id` resolves that comment's `thread_id` and uses the latest comment in that thread as `parent_comment_id`. Extended `createCommentFromEmail` and shared `TicketModel.createComment` to create replies when `parent_comment_id` is supplied, inheriting thread visibility and incrementing counters. Verified with `npx tsc -p shared/tsconfig.json --noEmit`.
- (2026-05-13) **F029 inbound In-Reply-To routing.** Added `resolveReplyTargetFromOutboundMessageId` to look up `email_sending_logs.rfc_message_id`, read `comment_thread_id`, resolve the latest comment in that thread, and pass it as `parent_comment_id` for inbound replies. This runs before legacy ticket-level header matching. Verified with `npx tsc -p shared/tsconfig.json --noEmit`.
- (2026-05-13) **F030 inbound References routing.** Added reverse-order `References[]` lookup using the same `email_sending_logs.rfc_message_id` resolver. If `In-Reply-To` is absent or does not resolve, the first matching reference from the end of the chain routes to that comment thread. Verified with `npx tsc -p shared/tsconfig.json --noEmit`.
- (2026-05-13) **F031 inbound provider thread routing.** Added `resolveReplyTargetFromProviderThreadId`, which matches `emailData.threadId` to `comment_threads.email_provider_thread_id` for ticket threads and routes to the latest comment in that thread before falling back to legacy ticket-level matching. Verified with `npx tsc -p shared/tsconfig.json --noEmit`.
- (2026-05-13) **F032 inbound ticket fallback.** No additional code needed after F028: legacy ticket-level header matching still calls `createCommentFromEmail` without `parent_comment_id`, and shared `TicketModel.createComment` now treats that as a new top-level thread. This preserves today's ticket-level fallback while avoiding accidental attachment to an existing comment thread.
- (2026-05-13) **F033 inbound comment creation.** Thread-specific inbound resolution now always passes the latest comment in the resolved thread as `parent_comment_id`; `TicketModel.createComment` stores the inherited `thread_id` and `parent_comment_id` on the new comment. Top-level fallback omits `parent_comment_id` and creates a fresh thread.
- (2026-05-13) **F034 outbound top-level Message-ID.** `BaseEmailService` now generates a RFC `Message-ID` header for comment emails when the caller did not provide one, returns/logs that RFC id, and updates `comment_threads.email_message_id` for successful sends where the associated comment is a top-level/root comment. No `In-Reply-To` header is added in this top-level path. Verified with `npx tsc -p packages/email/tsconfig.json --noEmit`.
- (2026-05-13) **T039 outbound top-level Message-ID test.** Added `server/src/test/unit/email/baseEmailServiceCommentThreading.test.ts` with a mocked provider and tenant knex. It verifies a top-level comment email gets a generated RFC `Message-ID`, does not emit `In-Reply-To`/`References`, returns that RFC id, and stores it on `comment_threads.email_message_id` with the provider thread id. Verified with `cd server && npx vitest run src/test/unit/email/baseEmailServiceCommentThreading.test.ts -t "T039"`.
- (2026-05-13) **F035 outbound reply In-Reply-To.** Added best-effort header enrichment in `BaseEmailService`: when sending an email for a reply comment (`comments.parent_comment_id` set), it looks up the latest successful `email_sending_logs.rfc_message_id` for that `comment_thread_id` and sets `In-Reply-To` unless the caller already supplied one. Top-level comments remain unchanged. Verified with `npx tsc -p packages/email/tsconfig.json --noEmit`.
- (2026-05-13) **T040 outbound reply In-Reply-To test.** Extended `baseEmailServiceCommentThreading.test.ts` to model a reply comment plus latest sent `email_sending_logs.rfc_message_id`; provider sees `In-Reply-To` set to that latest outbound RFC id and gets a new generated `Message-ID` for the outbound reply. Verified with `cd server && npx vitest run src/test/unit/email/baseEmailServiceCommentThreading.test.ts -t "T040"`.
- (2026-05-13) **F036 outbound References chain.** `BaseEmailService` now builds thread-specific `References` for reply comments from `comment_threads.email_references + latest outbound rfc_message_id`, emits that header with the email, and persists the deduped array back to `comment_threads.email_references` only after a successful send. Thread-specific headers intentionally override legacy ticket-level headers for comment replies. Verified with `npx tsc -p packages/email/tsconfig.json --noEmit`.
- (2026-05-13) **T041 outbound References test.** Added unit coverage for reply emails with an existing `comment_threads.email_references` chain. The provider receives the stored chain plus the latest outbound RFC id as the `References` header, and `BaseEmailService` persists the same array back to `comment_threads.email_references`. Verified with `cd server && npx vitest run src/test/unit/email/baseEmailServiceCommentThreading.test.ts -t "T041"`.
- (2026-05-13) **F037 outbound reply tokens.** No code change needed: `sendEventEmail` already generates a fresh `randomUUID()` token when `replyContext` is present, embeds the `ALGA-REPLY-TOKEN` marker in HTML/text, and persists `email_reply_tokens.comment_id` plus `recipient_email` for the outbound comment. Confirmed in `server/src/lib/notifications/sendEventEmail.ts`.
- (2026-05-13) **T042 outbound reply-token persistence test.** Tightened `ticketEmailDelimiters.test.ts` reply-marker coverage so the comment notification test now verifies the in-memory `email_reply_tokens` row includes tenant, ticket_id, comment_id, recipient_email, and ticket entity_type. Updated the `@alga-psa/email` mock to include `StaticTemplateProcessor`, matching the current `sendEventEmail` dependency. Verified with `cd server && npx vitest run src/test/integration/ticketEmailDelimiters.test.ts -t "T042"`.
- (2026-05-13) **F038 outbound send log thread linkage.** `sendEventEmail` now passes the generated reply token through to `BaseEmailService` so `reply_token_hash`/suffix are populated even when callers did not provide `conversationToken`. `BaseEmailService.logEmailSendResult` looks up the outbound comment's `thread_id` and writes it to `email_sending_logs.comment_thread_id` alongside provider/RFC IDs and token fields. Verified with `npx tsc -p packages/email/tsconfig.json --noEmit` and `npx tsc -p server/tsconfig.json --noEmit`.
- (2026-05-13) **T043 outbound email log linkage test.** Added `BaseEmailService` unit coverage that waits for the best-effort `email_sending_logs` insert and verifies `comment_thread_id`, generated `rfc_message_id`, `provider_message_id`, `thread_id` (provider), `comment_id`, and reply-token hash/suffix are populated. Verified with `cd server && npx vitest run src/test/unit/email/baseEmailServiceCommentThreading.test.ts -t "T043"`.
- (2026-05-13) **T044 outbound/inbound round-trip test.** Added `server/src/test/unit/email/outboundInboundThreadRoundTrip.test.ts`, sharing an in-memory mocked DB between `BaseEmailService` and `processInboundEmailInApp`. The test sends a top-level comment email, captures the generated/logged RFC `Message-ID`, then processes an inbound email with matching `In-Reply-To` and verifies it creates a reply on the original ticket/thread parent. Verified with `cd server && npx vitest run src/test/unit/email/outboundInboundThreadRoundTrip.test.ts -t "T044"`.
- (2026-05-13) **T045 thread grouping/sorting test.** Added `packages/ui/src/components/CommentThreadList.test.ts` covering `buildCommentThreadGroups`: one group per `threadId`, root/child mapping, reply count, chronological in-thread comments, and oldest/newest sorting by thread last activity. Verified with `cd server && npx vitest run ../packages/ui/src/components/CommentThreadList.test.ts -t "T045"`.
- (2026-05-13) **T046 recursive HybridThreadNode test.** Added `packages/ui/src/components/HybridThreadNode.test.tsx` with root → reply → subreply data. It renders the recursive node tree and verifies depth 0 is not a sub-thread while depth 1+ nodes are marked as sub-threads via render context. Verified with `cd server && npx vitest run ../packages/ui/src/components/HybridThreadNode.test.tsx -t "T046"`.
- (2026-05-13) **T047 collapse/expand bar test.** Extended `HybridThreadNode.test.tsx` to click the root `Collapse` button, assert child comments are hidden, and verify the collapsed bar exposes `Expand` plus `Open in drawer` when `onOpenPanel` is available. Verified with `cd server && npx vitest run ../packages/ui/src/components/HybridThreadNode.test.tsx -t "T047"`.
- (2026-05-13) **T048 open-in-drawer test.** Extended `HybridThreadNode.test.tsx` with a harness that collapses the root thread, clicks `Open in drawer`, and verifies `CommentThreadDrawer` opens with root, reply, and subreply content. Mocked `TextEditor` to keep the drawer test focused. Verified with `cd server && npx vitest run ../packages/ui/src/components/HybridThreadNode.test.tsx -t "T048"`.
- (2026-05-13) **T049 inline reply composer controls test.** Added `packages/ui/src/components/InlineReplyComposer.test.tsx` to verify the inline reply composer shows `Mark as Internal`, omits `Mark as Resolution`, and submits the inherited `initialInternal=true` value with the parent comment id. Verified with `cd server && npx vitest run ../packages/ui/src/components/InlineReplyComposer.test.tsx -t "T049"`.
- (2026-05-13) **T050 drawer reply submit test.** Extended `HybridThreadNode.test.tsx` with a parent-managed drawer harness: submitting the drawer composer calls `onSubmitReply` with `parentCommentId=root`, appends the new reply to the flat comment state, closes the drawer, and the inline tree re-renders the new child. Verified with `cd server && npx vitest run ../packages/ui/src/components/HybridThreadNode.test.tsx -t "T050"`.
- (2026-05-13) **T051 visual depth cap test.** Extended `HybridThreadNode.test.tsx` with a six-comment root-to-depth-5 chain. The test verifies the depth-5 comment still renders with data depth 5 but visual depth 4, and that repeated `.thread-children.depth-4` wrappers enforce the CSS cap. Verified with `cd server && npx vitest run ../packages/ui/src/components/HybridThreadNode.test.tsx -t "T051"`.
- (2026-05-13) **T052 sub-thread bar style test.** Extended `HybridThreadNode.test.tsx` to verify root bars render without `comment-thread-bar-subthread`, nested bars render with it at depth 1, and `CommentThread.module.css` maps that class to dashed border plus white background while base bars keep `#f9fafb`. Verified with `cd server && npx vitest run ../packages/ui/src/components/HybridThreadNode.test.tsx -t "T052"`.
- (2026-05-13) **T053 ticket Reply action reveal test.** Extended `CommentItem.metadataDebug.test.tsx` to render a ticket comment with `onReply`, click the accessible `Reply to comment` button, verify the callback receives the comment, and assert the button lives inside `.c-actions`. The same test checks shared CSS keeps `.c-actions` hidden by default and reveals it on hover/focus-within. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/CommentItem.metadataDebug.test.tsx -t "T053"`.
- (2026-05-13) **T054 soft-deleted comment UI test.** Added ticket and project-task coverage for deleted comments: ticket `CommentItem` and project `TaskComment` both render `[deleted]` with `opacity-70` and suppress the `Reply to comment` action even when `onReply` is provided. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/CommentItem.metadataDebug.test.tsx ../packages/projects/src/components/TaskComment.test.tsx -t "T054"`.
- (2026-05-13) **T055 thread-level tab counts test.** Factored ticket tab bucketing into `ticketConversationThreadTabs.ts` and used its thread counts in the `TicketConversation` tab labels. Added `ticketConversationThreadTabs.test.ts` covering All/Client/Internal/Resolution counts by root thread visibility and any-resolution-in-thread behavior, including client portal internal-thread exclusion. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/ticketConversationThreadTabs.test.ts -t "T055"` and `cd server && npx tsc -p ../packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **T056 ticket thread sort preference test.** Extended `TicketConversation.commentOrderPreference.test.tsx` with threaded comment data where an older root has a later reply. The test verifies oldest-first renders the less-active thread first, toggling stores `newestFirst=true`, and newest-first reorders whole threads while keeping root/reply chronological inside the active thread. Added test harness mocks for `DocumentsCrossFeatureContext` and `IntersectionObserver`, and timestamped the legacy preference fixtures so thread sorting is deterministic. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/TicketConversation.commentOrderPreference.test.tsx`.
- (2026-05-13) **T057 ticket reply composer interaction test.** Added `TicketConversation.replyComposer.e2e.contract.test.tsx` as a lightweight local e2e contract around `TicketConversation` with the real `CommentItem` and thread wiring. The test hovers the rendered comment, verifies the `Reply to comment` action is in the `.c-actions` row, clicks it, and asserts the inline reply composer plus editor and submit button render for the parent comment. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/TicketConversation.replyComposer.e2e.contract.test.tsx -t "T057"`; Vitest still emits the repository's localstorage-file warning and a React act warning from component state effects.
- (2026-05-13) **T058 ticket inline reply submit test.** Extended `TicketConversation.replyComposer.e2e.contract.test.tsx` with a stateful conversation harness whose `onAddReplyComment` appends a child comment to the same thread. Submitting the inline composer now verifies the reply row renders under its parent, the default thread bar shows `1 reply` with `Collapse`, and the reply sits inside `.thread-children.depth-1`. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/TicketConversation.replyComposer.e2e.contract.test.tsx`; Vitest still emits the repository's localstorage-file warning and React act warnings from component state effects.
- (2026-05-13) **T059 ticket collapse/expand interaction test.** Extended `TicketConversation.replyComposer.e2e.contract.test.tsx` with an existing root + reply ticket thread. The test collapses the thread, verifies the child comment is hidden while the bar shows `1 reply`, `Expand`, and `Open in drawer`, then expands and verifies the child returns with `Collapse`. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/TicketConversation.replyComposer.e2e.contract.test.tsx -t "T059"`; Vitest still emits the repository's localstorage-file warning and React act warnings from component state effects.
- (2026-05-13) **T060 ticket drawer open/close interaction test.** Extended `TicketConversation.replyComposer.e2e.contract.test.tsx` to collapse an existing ticket thread, click `Open in drawer`, verify the drawer contains both root and reply comments, then close it and confirm the inline view remains collapsed with `Expand` visible. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/TicketConversation.replyComposer.e2e.contract.test.tsx -t "T060"`; jsdom still reports Radix focus/portal act warnings while the test exits successfully.
- (2026-05-13) **T061 ticket drawer reply reflection test.** Extended `TicketConversation.replyComposer.e2e.contract.test.tsx` with a stateful drawer-reply harness. The first run exposed that the inline thread stayed collapsed after drawer submit, hiding the new reply. Fixed `TicketConversation` by keying each inline `HybridThreadNode` by `threadId-replyCount`, so a thread remounts expanded when its reply count changes while a no-op drawer close preserves collapse state. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/TicketConversation.replyComposer.e2e.contract.test.tsx` and `npx tsc -p packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **T062 ticket nested reply sub-thread test.** Extended `TicketConversation.replyComposer.e2e.contract.test.tsx` with a root + reply thread and a stateful nested reply submit. The test clicks Reply on the existing reply, submits, verifies the nested child renders, and asserts the nested bar has `.comment-thread-bar-subthread.depth-1` with `1 reply` while the child sits under `.thread-children-subthread`. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/TicketConversation.replyComposer.e2e.contract.test.tsx`.
- (2026-05-13) **T063 ticket internal tab interaction test.** Extended the ticket reply contract test's `CustomTabs` mock to render tab buttons and active content by controlled `value`. Added a ticket tab harness with one client-rooted thread and one internal-rooted thread, verifying tab labels show thread-level counts (`All Comments (2)`, `Client (1)`, `Internal (1)`) and switching to Internal hides the client root + reply while keeping the internal thread visible. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/TicketConversation.replyComposer.e2e.contract.test.tsx -t "T063"`.
- (2026-05-13) **T064 project task inline reply test.** Added `TaskCommentThread.threadedReplies.test.tsx` with mocked project-task comment actions backed by an in-memory comment list. The test renders `TaskCommentThread`, opens Reply on the root task comment, submits through `TaskCommentForm`, reloads via `getTaskComments`, and verifies the child comment renders under `.thread-children.depth-1` with a `1 reply` bar. Verified with `cd server && npx vitest run ../packages/projects/src/components/TaskCommentThread.threadedReplies.test.tsx -t "T064"` and `npx tsc -p packages/projects/tsconfig.json --noEmit`.
- (2026-05-13) **T065 project task collapse/drawer test.** Extended `TaskCommentThread.threadedReplies.test.tsx` with a preloaded root + reply task thread. The test collapses the thread, verifies the reply hides and `Expand` + `Open in drawer` appear, opens the drawer with root + reply content, closes it, and verifies the inline thread remains collapsed. Verified with `cd server && npx vitest run ../packages/projects/src/components/TaskCommentThread.threadedReplies.test.tsx`.
- (2026-05-13) **T066 project task reply visibility-control test.** Extended `TaskCommentThread.threadedReplies.test.tsx` to open the inline task reply composer and assert the editor appears without any `Mark as Internal` toggle, matching the task-only internal model. Verified with `cd server && npx vitest run ../packages/projects/src/components/TaskCommentThread.threadedReplies.test.tsx -t "T066"`.
- (2026-05-13) **T067 legacy ticket render regression test.** Extended `TicketConversation.replyComposer.e2e.contract.test.tsx` with two legacy flat ticket comments that omit `thread_id` and `parent_comment_id`. The test verifies both comments render with no `.comment-thread-bar` and no `.thread-children` indentation wrapper, preserving the pre-threading layout for single-comment legacy threads. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/TicketConversation.replyComposer.e2e.contract.test.tsx -t "T067"`.
- (2026-05-13) **T068 legacy project task render regression test.** Extended `TaskCommentThread.threadedReplies.test.tsx` with two legacy flat task comments that omit `threadId` and `parentCommentId`. The test verifies both comments render without a thread bar or `.thread-children` wrapper, preserving the old flat task comment layout. Verified with `cd server && npx vitest run ../packages/projects/src/components/TaskCommentThread.threadedReplies.test.tsx -t "T068"`.
- (2026-05-13) **T069 legacy ticket edit regression test.** Extended `CommentItem.metadataDebug.test.tsx` to render a legacy comment in edit mode, click Save, and verify the save payload includes normal note/internal/resolution fields without `thread_id` or `parent_comment_id`. The same test reads `packages/tickets/src/models/comment.ts` to assert the update path still stamps `updated_at: new Date().toISOString()`. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/CommentItem.metadataDebug.test.tsx -t "T069"`.
- (2026-05-13) **T070 reply reaction regression test.** Mocked `ReactionDisplay` in `CommentItem.metadataDebug.test.tsx` and added a reply comment with `thread_id` + `parent_comment_id`. Clicking the reaction verifies `CommentItem` calls `onToggleReaction` with the reply's own `comment_id`, so reactions attach per comment rather than per root thread. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/CommentItem.metadataDebug.test.tsx -t "T070"`.
- (2026-05-13) **T071 thread list render performance test.** Added `CommentThreadList.performance.test.tsx` with 100 synthetic threads and five replies each. It renders through `CommentThreadList` + `HybridThreadNode`, verifies all 600 comment nodes and 100 thread bars render, and asserts the component render stays under a conservative 1500ms budget. Verified with `cd server && npx vitest run ../packages/ui/src/components/CommentThreadList.performance.test.tsx -t "T071"` (observed test body around 133ms in this local run).
- (2026-05-13) **T072 ticket cascade integration test.** Extended `server/src/test/integration/commentThreadsMigration.integration.test.ts` with a rollback-only transaction that creates an isolated ticket + `comment_threads` row, deletes the ticket, and verifies the thread row is removed by the `comment_threads_ticket_fk` cascade. An initial attempt against a shared fixture ticket failed because existing `comments` rows block ticket deletion via their older FK, so the final test creates its own no-comment ticket. Verified with `cd server && npx vitest run src/test/integration/commentThreadsMigration.integration.test.ts -t "T072"`; the suite still prints unrelated seed warnings about `generate-system-email-workflow.cjs`.
- (2026-05-13) **T073 project-task cascade integration test.** Extended `commentThreadsMigration.integration.test.ts` with a rollback-only transaction that creates an isolated project task + `comment_threads` row, deletes the task, and verifies the thread row is removed by the `comment_threads_project_task_fk` cascade. Verified with `cd server && npx vitest run src/test/integration/commentThreadsMigration.integration.test.ts -t "T073"`; the suite still prints unrelated seed warnings about `generate-system-email-workflow.cjs`.
- (2026-05-13) **T074 comment_threads parent CHECK integration test.** Extended `commentThreadsMigration.integration.test.ts` to attempt inserting a thread with both parent IDs null and another with both `ticket_id` and `project_task_id` set. Both inserts reject via `comment_threads_exactly_one_parent_check`, proving the polymorphic parent invariant is enforced by the database. Verified with `cd server && npx vitest run src/test/integration/commentThreadsMigration.integration.test.ts -t "T074"`; the suite still prints unrelated seed warnings about `generate-system-email-workflow.cjs`.
- (2026-05-13) **T075 inbound email idempotency test.** Extended `unifiedInboundEmailQueueJobProcessor.fetch.test.ts` with a duplicate Microsoft message carrying `In-Reply-To` and `References` headers. The simulated `email_processed_messages` unique violation returns a deduped/skipped result before `processInboundEmailInApp` runs, proving thread-routing paths cannot create a duplicate comment for replayed provider messages. Verified with `cd server && npx vitest run src/test/unit/unifiedInboundEmailQueueJobProcessor.fetch.test.ts -t "T075"`; Vitest still prints the existing duplicate `on` mock warning and coverage table.
- (2026-05-13) **T076 multiple-recipient reply token test.** Extended `ticketEmailDelimiters.test.ts` so the ticket-comment notification path sends to a contact and an internal assignee/resource address, then verifies `email_reply_tokens` receives two rows for the outbound comment with distinct tokens and recipient-specific `recipient_email` values. Added lightweight mocks for Knex `.modify()`, `comments as cm`, and `tenant_settings` needed by the current subscriber path. Verified with `cd server && npx vitest run src/test/integration/ticketEmailDelimiters.test.ts -t "T076"`; the run still prints an existing auth-session warning from notification gating but passes.
- (2026-05-13) **T077 concurrent reply counter test.** Extended `commentModelThreading.integration.test.ts` with two parallel `Comment.insert` calls replying to the same root comment. Both inserts complete, both child rows inherit the root thread, and `comment_threads.reply_count` ends at 2, covering the atomic `reply_count + 1` update against lost increments. Verified with `cd server && npx vitest run src/test/integration/commentModelThreading.integration.test.ts -t "T077"`; the integration setup still prints normal migration/seed noise and coverage output.
- (2026-05-13) **T078 client reply RBAC test and enforcement.** Added `assertClientCanCreateComment` to `createComment` so client users can only post their own non-internal comments on tickets whose `client_id` matches their session/contact client. Extended `commentActionsThreading.integration.test.ts` to verify a client can reply on their client-visible thread, cannot reply to an internal thread, and cannot reply on another client's ticket. Verified with `cd server && npx vitest run src/test/integration/commentActionsThreading.integration.test.ts -t "T078"` and `npx tsc -p packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **F039 shared thread grouping UI.** Added generic `CommentThreadList` and `buildCommentThreadGroups` in `packages/ui/src/components/CommentThreadList.tsx`. It accepts flat comments plus accessors, groups by `thread_id` (falling back to comment id for legacy rows), builds `childrenByParentId`, keeps replies chronological, derives `lastActivityAt`, and renders one group per thread in oldest/newest order. Exported from `packages/ui/src/components/index.ts`. Verified with `npx tsc -p packages/ui/tsconfig.json --noEmit`.
- (2026-05-13) **F040 recursive HybridThreadNode.** Added generic `HybridThreadNode` in `packages/ui/src/components/HybridThreadNode.tsx`. It renders a comment, a thread bar when children exist, and recursive child nodes from `childrenByParentId`; depth class emission caps at `depth-4` while recursion continues for unlimited data depth. Exported from `packages/ui/src/components/index.ts`. Verified with `npx tsc -p packages/ui/tsconfig.json --noEmit`.
- (2026-05-13) **F041 per-node collapse state.** `HybridThreadNode` now owns expanded/collapsed state for each node with children. The default thread bar shows `Collapse` while expanded and `Expand` plus `Open in drawer` while collapsed; collapsed nodes hide only their child subtree. Verified with `npx tsc -p packages/ui/tsconfig.json --noEmit`.
- (2026-05-13) **F042 open-panel callback.** No additional code needed after F041: `HybridThreadNode` exposes `onOpenPanel?: (commentId) => void`, passes it recursively, and the default collapsed thread bar calls it from `Open in drawer`.
- (2026-05-13) **F043 inline reply composer.** Added `InlineReplyComposer` in `packages/ui/src/components/InlineReplyComposer.tsx`. It uses BlockNote `TextEditor`, defaults internal visibility from the parent, exposes only the Mark-as-Internal switch (no resolution control), and submits `{ parentCommentId, content, isInternal }`. Exported from `packages/ui/src/components/index.ts`. Verified with `npx tsc -p packages/ui/tsconfig.json --noEmit`.
- (2026-05-13) **F044 comment thread drawer.** Added generic `CommentThreadDrawer` in `packages/ui/src/components/CommentThreadDrawer.tsx`. It wraps the existing Radix-backed `Drawer` at 480px, renders the root thread tree via `HybridThreadNode`, and shows an `InlineReplyComposer` at the bottom. Exported from `packages/ui/src/components/index.ts`. Verified with `npx tsc -p packages/ui/tsconfig.json --noEmit`.
- (2026-05-13) **F045 shared comment thread CSS.** Added `packages/ui/src/components/CommentThread.module.css` and imported it from `HybridThreadNode`. It defines global selectors for the left rail, thread-bar pill, dashed sub-thread bars, depth classes through `depth-4`, drawer spacing, inline composer spacing, and `.c-actions` hover/focus reveal. Verified with `npx tsc -p packages/ui/tsconfig.json --noEmit`.
- (2026-05-13) **F046 ticket Reply action.** `CommentItem` now accepts optional `onReply(comment)`, renders a `CornerUpLeft` reply button in the `.c-actions` hover/focus row ahead of edit/delete, and keeps existing call sites working when no reply handler is supplied. Verified with `npx tsc -p packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **F047 ticket conversation thread renderer.** Replaced `TicketConversation`'s flat comment mapping with `CommentThreadList<IComment>` + `HybridThreadNode<IComment>`, preserving the existing top-level composer, tabs, sort toggle, edit/delete, upload session, metadata debug, and reactions through the existing `CommentItem` renderer. Verified with `npx tsc -p packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **F048 ticket thread-level tabs.** `TicketConversation` now derives thread groups before tab filtering. All/Client/Internal select threads by root visibility, Resolution selects threads containing any resolution comment, and the rendered list includes all comments in matching threads so children stay attached. Client portal mode still excludes internal-rooted threads. Verified with `npx tsc -p packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **F049 ticket thread sort.** No additional code needed after F039/F047: `CommentThreadList` receives the existing `reverseOrder` preference as `newestFirst`, sorts thread groups by derived `lastActivityAt`, and keeps comments inside each thread chronological.
- (2026-05-13) **F050 ticket drawer state.** `TicketConversation` now tracks `openPanelCommentId`, passes `onOpenPanel` through `HybridThreadNode`, resolves the containing thread group for that comment, and renders `CommentThreadDrawer` with the selected comment as the drawer reply parent. Reply submission is intentionally still a placeholder for F053. Verified with `npx tsc -p packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **F051 ticket inline reply open.** `TicketConversation` now tracks `replyingToCommentId`, passes `onReply` into `CommentItem`, and renders `InlineReplyComposer` directly beneath the selected comment. The composer inherits the parent comment's `is_internal` value and hides the internal switch in client portal mode. Submission is intentionally still a placeholder for F052. Verified with `npx tsc -p packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **F052 ticket inline reply submit.** Added `onAddReplyComment` from `TicketDetails` into `TicketConversation`. Inline submit calls `createComment` with `parent_comment_id`, `is_resolution=false`, and the chosen visibility, then refreshes `findCommentsByTicketId` into local state and closes the composer on success. Verified with `npx tsc -p packages/tickets/tsconfig.json --noEmit` and `npx tsc -p packages/client-portal/tsconfig.json --noEmit`.
- (2026-05-13) **F053 ticket drawer reply submit.** `CommentThreadDrawer` in `TicketConversation` now calls the same `onAddReplyComment` path with `parent_comment_id`, then closes the drawer after a successful local refresh. Verified with `npx tsc -p packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **F054 task Reply action.** `TaskComment` now accepts optional `onReply(comment)`, renders a `CornerUpLeft` reply button in the `.c-actions` hover/focus row ahead of edit/delete, and preserves existing callers when no reply handler is supplied. Verified with `npx tsc -p packages/projects/tsconfig.json --noEmit`.
- (2026-05-13) **F055 task thread renderer.** `TaskCommentThread` now renders `CommentThreadList<IProjectTaskCommentWithUser>` + `HybridThreadNode<IProjectTaskCommentWithUser>` instead of a flat sorted map, preserving existing add-comment form, sort toggle, edit/delete, and reactions through `TaskComment`. Verified with `npx tsc -p packages/projects/tsconfig.json --noEmit`.
- (2026-05-13) **F056 task inline reply submit.** `TaskCommentForm` now accepts `parentCommentId` and sends it as `parent_comment_id` to `createTaskComment`. `TaskCommentThread` tracks `replyingToCommentId`, renders the form under the selected task comment, refreshes comments on success, and has no internal/client visibility toggle because task comments are internal-only. Verified with `npx tsc -p packages/projects/tsconfig.json --noEmit`.
- (2026-05-13) **F057 task drawer flow.** `TaskCommentThread` now tracks `openPanelCommentId`, passes `onOpenPanel` to `HybridThreadNode`, resolves the selected thread group, and renders `CommentThreadDrawer`. Drawer replies call `createTaskComment` with `parent_comment_id`, refresh comments, and hide the internal toggle. Verified with `npx tsc -p packages/projects/tsconfig.json --noEmit`.
- (2026-05-13) **F058 soft-deleted comment UI.** Ticket `CommentItem` and project `TaskComment` now render deleted rows as reduced-opacity `[deleted]` placeholders, preserve the node in the tree, and suppress Reply/Edit/Delete actions for deleted comments. Verified with `npx tsc -p packages/tickets/tsconfig.json --noEmit` and `npx tsc -p packages/projects/tsconfig.json --noEmit`.
- (2026-05-13) **F059 depth cap.** No additional code needed after F040/F045: `HybridThreadNode` caps emitted visual depth classes at `depth-4` while continuing recursion, and `CommentThread.module.css` keeps `thread-children.depth-4` at the capped indent.
- (2026-05-13) **F060 sub-thread bar styling.** No additional code needed after F040/F045: `HybridThreadNode` marks depth > 0 bars with `comment-thread-bar-subthread`, and `CommentThread.module.css` styles those bars with dashed border, white background, and indigo action/count color.
- (2026-05-13) **F061 sort preference persistence.** No additional code needed after F039/F047/F049: `TicketConversation` still reads/writes the existing `ticketConversationOrderPreference`, and its `reverseOrder` value is now consumed by `CommentThreadList` as thread-level `newestFirst`.
- (2026-05-13) **F062 dev seed compatibility.** T001 migration integration test exposed that `server/seeds/dev/15_comments.cjs` inserted direct comment rows after `comments.thread_id` became NOT NULL. Updated the seed to create one `comment_threads` row per seeded comment and insert `comments.thread_id`, matching the legacy backfill shape.
- (2026-05-13) **T002 migration index test.** Extended `commentThreadsMigration.integration.test.ts` to assert the ticket/task list indexes and partial `email_message_id` index via `pg_indexes`. This covers the thread lookup/index requirements against the migrated schema.
- (2026-05-13) **T003 comments column/FK test.** Added migrated-schema coverage for `comments.thread_id`, `parent_comment_id`, `deleted_at`, plus `comments_thread_fk` and `comments_parent_comment_fk`. Because the integration DB runs all migrations, `thread_id` is no longer nullable by this point; T009 separately verifies the final NOT NULL behavior.
- (2026-05-13) **T004 task comments column/FK test.** Added the same migrated-schema coverage for `project_task_comments.thread_id`, `parent_comment_id`, `deleted_at`, `project_task_comments_thread_fk`, and `project_task_comments_parent_comment_fk`.
- (2026-05-13) **T005 ticket backfill test.** Added a controlled legacy-row test that temporarily relaxes `comments.thread_id`, inserts a null-thread ticket comment, runs `20260513102000_backfill_comment_threads_for_comments.cjs`, verifies `thread_id = comment_id` and the single-comment `comment_threads` row, then cleans up and restores NOT NULL.
- (2026-05-13) **T006 ticket email metadata backfill test.** Added a second controlled legacy-row case with `metadata.email.messageId` and verified the backfill writes it to `comment_threads.email_message_id`.
- (2026-05-13) **T007 backfill idempotency test.** Added a rerun check for both ticket and task comment backfill migrations; with all rows already threaded, `comment_threads` count must stay unchanged.
- (2026-05-13) **T008 task backfill test.** Added a controlled legacy-row test for `project_task_comments` that temporarily relaxes `thread_id`, runs `20260513102500_backfill_comment_threads_for_project_task_comments.cjs`, verifies `thread_id = task_comment_id` and the `project_task_id` thread row, then restores NOT NULL.
- (2026-05-13) **T009 NOT NULL enforcement test.** Added final-schema assertions that direct inserts with `thread_id = NULL` fail for both `comments` and `project_task_comments`.
- (2026-05-13) **T010 email log linkage test.** Added migrated-schema coverage for `email_sending_logs.comment_thread_id`, the tenant-scoped FK to `comment_threads`, and the partial lookup index used for latest outbound message lookup.
- (2026-05-13) **T011 comment thread type test.** Added `packages/types/src/commentThread.typecheck.test.ts` using `expectTypeOf` to verify `ICommentThread` is exported from `@alga-psa/types` with all persisted `comment_threads` fields.
- (2026-05-13) **T012 ticket comment type test.** Extended the typecheck test to verify `IComment.thread_id`, `parent_comment_id`, and `deleted_at` are present with create/read-compatible optional types.
- (2026-05-13) **T013 task comment type test.** Extended the same typecheck test to verify `IProjectTaskComment.threadId`, `parentCommentId`, and `deletedAt` are present with the camelCase task-comment interface convention.
- (2026-05-13) **T014 ticket top-level model test.** Added `server/src/test/integration/commentModelThreading.integration.test.ts` to call `Comment.insert` for a top-level ticket comment and verify the inserted comment's `thread_id` points at a new `comment_threads` row whose `root_comment_id` is the new comment.
- (2026-05-13) **T015 ticket reply inheritance test.** Extended the ticket comment model integration test to insert a root and a reply, then verify the reply's `thread_id` matches the parent/root thread and `parent_comment_id` stores the parent id.
- (2026-05-13) **T016 ticket reply same-ticket guard.** Added a model integration test that creates a parent on one ticket and verifies `Comment.insert` rejects a reply payload targeting a different ticket with `Parent comment must belong to the same ticket`.
- (2026-05-13) **T017 deleted-parent guard.** Added a model integration test that soft-deletes a parent comment and verifies `Comment.insert` rejects replies with `Cannot reply to a deleted comment`.
- (2026-05-13) **T018 reply visibility invariant.** Added model integration coverage for both forbidden directions: internal reply on a client-visible thread and client-visible reply on an internal thread. Both must fail with `Reply visibility must match the thread root visibility`.
- (2026-05-13) **T019 reply counter/activity test.** Added ticket model integration coverage that inserts a reply and verifies `comment_threads.reply_count` increments by one and `last_activity_at` advances/stays current.
- (2026-05-13) **T020 leaf delete counter test.** Added ticket model integration coverage that deletes a leaf reply through `Comment.delete`, then verifies the reply row is gone and the owning thread's `reply_count` decrements to zero.
- (2026-05-13) **T021 root soft-delete test.** Added ticket model integration coverage that deletes a root comment with a child, verifies the root remains as `[deleted]` with `deleted_at` set, and confirms the child reply still points at that root.
- (2026-05-13) **T022 ticket read contract test.** Added ticket model integration coverage that soft-deletes a root with a child and verifies `Comment.getAllbyTicketId` returns both rows with `thread_id`, `parent_comment_id`, and `deleted_at` preserved for tree rendering.
- (2026-05-13) **T023 task top-level model test.** Added `server/src/test/integration/projectTaskCommentThreading.integration.test.ts` with mocked auth/tenant resolution around the real `createTaskComment` action, verifying a top-level task comment creates a `comment_threads` row with `project_task_id` set and `is_internal=false`.
- (2026-05-13) **T024 task reply inheritance/counter test.** Extended the project task comment integration test to create a reply through `createTaskComment`, verify it inherits the root thread, stores `parent_comment_id`, increments `reply_count`, and bumps `last_activity_at`.
- (2026-05-13) **T025 task delete semantics test.** Extended the project task comment integration test to call `deleteTaskComment` for both a leaf reply and a root with children, verifying leaf hard-delete/decrement and root `[deleted]` soft-delete while preserving the child.
- (2026-05-13) **T026 task read contract test.** Extended the project task comment integration test to soft-delete a root with a child and verify `getTaskComments` returns `threadId`, `parentCommentId`, and `deletedAt` for both root and reply.
- (2026-05-13) **T027 ticket action parent pass-through test.** Added `server/src/test/integration/commentActionsThreading.integration.test.ts` with mocked auth/tenant resolution around the real `createComment` action, verifying an action-level reply stores `parent_comment_id` and inherits the root thread.
- (2026-05-13) **T028 ticket comment event payload test.** Extended the ticket action integration test to create a reply and inspect the mocked `publishEvent` call, verifying `TICKET_COMMENT_ADDED` includes `thread_id`, `parent_comment_id`, and `is_reply` at the top level and inside the legacy `comment` object.
- (2026-05-13) **T029 client internal-reply rejection test.** Extended the ticket action integration test with a client user attempting to create an internal reply. `createComment` rejects with the action-level failure and no invalid internal child row is persisted.
- (2026-05-13) **T030 task action parent pass-through coverage.** No new test code needed: T024 already calls the real `projectTaskCommentActions.createTaskComment` action with `parent_comment_id` and verifies the reply row inherits the root thread and stores the parent id.
- (2026-05-13) **T031 task comment event payload test.** Extended the project task comment integration test to inspect the mocked `publishEvent` call for a reply and verify `TASK_COMMENT_ADDED` includes both camelCase and snake_case threading fields plus reply booleans.
- (2026-05-13) **T032 task reply ownership test.** Extended the project task comment integration test with two temporary client-owned replies. The real `deleteTaskComment` action allows the current client to delete their own reply and rejects deleting another client's reply via `assertOwnCommentOrInternalUser`.
- (2026-05-13) **T033 inbound reply-token thread routing test.** Added a mocked inbound-email unit test where `findTicketByReplyToken` returns a `commentId`; `processInboundEmailInApp` resolves that comment's thread, looks up the latest comment in the thread, and forwards that latest id as `parent_comment_id` to `createCommentFromEmail`.
- (2026-05-13) **T034 inbound In-Reply-To routing test.** Extended the inbound-email threading unit test so `emailData.inReplyTo` matches `email_sending_logs.rfc_message_id`; the processor resolves `comment_thread_id`, loads the thread's latest comment, and sends that id as `parent_comment_id` with `matchedBy: thread_headers`.
- (2026-05-13) **T035 inbound References routing test.** Added a unit test proving `References[]` is walked from newest to oldest: the newest reference misses, the next one matches `email_sending_logs.rfc_message_id`, and older references are not queried once a thread target is found.
- (2026-05-13) **T036 inbound provider-thread routing test.** Added unit coverage for provider thread id fallback: `emailData.threadId` matches `comment_threads.email_provider_thread_id`, then the processor resolves the ticket thread and forwards the latest comment as `parent_comment_id`.
- (2026-05-13) **T037 inbound ticket fallback test.** Added unit coverage for the legacy ticket-level match after all thread-specific strategies miss. The resulting `createCommentFromEmail` call intentionally omits `parent_comment_id`, so shared ticket comment creation creates a new top-level thread on the matched ticket.
- (2026-05-13) **T038 inbound precedence test.** Added unit coverage where a reply token and `In-Reply-To` are both present. The token path wins, the email log header lookup is never queried, and the inbound comment is parented from the token-derived thread.
- (2026-05-13) **T079 drawer focus management.** Added focus restoration in `TicketConversation` by remembering the element that opened the comment-thread drawer and refocusing it after close. Reinforced `Drawer` content focus on open so focus moves inside the Radix drawer instead of remaining on the collapsed thread bar. Extended the ticket conversation contract test to verify focus enters the drawer and returns to the `Open in drawer` button after close. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/TicketConversation.replyComposer.e2e.contract.test.tsx -t "T079"` and `npx tsc -p packages/tickets/tsconfig.json --noEmit`. `npx tsc -p packages/ui/tsconfig.json --noEmit` is still blocked by pre-existing jest-dom matcher type errors in `HybridThreadNode.test.tsx` and `InlineReplyComposer.test.tsx`.
- (2026-05-13) **T080 top-level add contract test.** Extended the ticket conversation contract test with a stateful top-level composer flow on a conversation that already has a root + reply. Submitting `Add Comment` appends a new root thread, verifies the reply callback was not called, confirms the new root is not rendered inside `.thread-children`, and leaves the existing reply indented under its original root. Verified with `cd server && npx vitest run ../packages/tickets/src/components/ticket/TicketConversation.replyComposer.e2e.contract.test.tsx -t "T080"` and `npx tsc -p packages/tickets/tsconfig.json --noEmit`.
- (2026-05-13) **No existing threading columns.** `comments` and `project_task_comments` are flat today; no `parent_id`, `reply_to_id`, or `thread_id`. Confirmed by reading `packages/types/src/interfaces/comment.interface.ts` (line 38).
- (2026-05-13) **Separate tables.** Tickets use `comments`; project tasks use `project_task_comments` (migration `20251118140000_create_project_task_comments.cjs`). No sharing — keep `comment_threads` polymorphic across both.
- (2026-05-13) **Today the ticket IS the email thread.** `tickets.email_metadata` (jsonb) stores `messageId`, `threadId`, `inReplyTo`, `references` for inbound matching. All inbound emails for a ticket collapse to flat comments. This is what the thread-as-entity refactor unblocks.
- (2026-05-13) **Outbound currently uses reply tokens, not RFC headers.** `email_reply_tokens` maps token → `(ticket_id | comment_id | project_id)`. The token marker `[ALGA-REPLY-TOKEN:...]` is embedded in the body. New work: ALSO set proper `In-Reply-To`/`References` so mail clients thread our messages, AND keep issuing tokens scoped to the new outbound comment.
- (2026-05-13) **`email_sending_logs` already has a `thread_id` column** (migration `20260331110000_add_email_threading_diagnostics_columns.cjs`). It looks like it refers to the provider's threadId. We will add a separate `comment_thread_id` column rather than overload — clearer and avoids semantic drift. If safe, rename the existing one to `email_provider_thread_id` in the same migration.
- (2026-05-13) **Drawer is a Radix Dialog.** Per `MEMORY.md`, the existing `packages/ui` `Drawer` uses Radix's `Dialog` internally with `modal={true}`. Nested dialogs are already handled by `InsideDialogContext` in `ModalityContext.tsx` — no extra wiring needed for the comment drawer.
- (2026-05-13) **Project task comments are internal-only today.** `author_type` is hardcoded 'internal' in `IProjectTaskComment`. So the task inline reply composer should NOT show a Mark-as-Internal toggle (different from ticket flow).
- (2026-05-13) **Reactions** already attach via `commentReactionActions`; nothing to change for replies — same per-comment reaction model applies.
- (2026-05-13) **Existing `ticketConversationOrderPreference`** stores newest-first preference per user. Reused as-is; applied to thread ordering after this change.

## Commands / Runbooks

- (2026-05-13) T001 migration test:
  ```
  cd server && npx vitest run src/test/integration/commentThreadsMigration.integration.test.ts
  ```
  Result after F062 seed fix: passed (`1 passed`).
- (2026-05-13) Run migrations locally:
  ```
  cd server && npm run db:migrate:latest
  ```
- (2026-05-13) Dev server (Docker, builds from current worktree per the alga-dev-env-manager skill):
  ```
  alga dev up
  alga dev rebuild server   # after schema or types change
  ```
- (2026-05-13) Run a single migration backwards (for iterating on backfill logic):
  ```
  cd server && npx knex migrate:down
  ```
- (2026-05-13) Playwright e2e — see the `playwright-testing` skill conventions; tests live under `server/src/__tests__/e2e/`.
- (2026-05-13) Email loop test (manual): use `shared/services/email/processInboundEmailInApp.ts` test fixture path; or trigger via the IMAP/MS Graph mock.

## Links / References

- **Design handoff bundle** (extracted): `/tmp/design_extract/comment-responses/`
  - `README.md` — handoff instructions
  - `chats/chat1.md` — user's design conversation; the source of intent
  - `project/Comment responses.html` — entry HTML
  - `project/conversation.jsx`, `comment.jsx`, `data.jsx`, `app.jsx` — prototype implementation we're recreating in real components
  - `project/styles.css` — pixel-level visual reference for thread bars, drawer, indent rail, depth cap
- **Implementation plan**: `/Users/natalliabukhtsik/.claude/plans/immutable-foraging-wirth.md`
- **Key existing files**:
  - `packages/tickets/src/components/ticket/TicketConversation.tsx`
  - `packages/tickets/src/components/ticket/CommentItem.tsx`
  - `packages/tickets/src/components/ticket/TicketDetails.module.css`
  - `packages/tickets/src/models/comment.ts`
  - `packages/tickets/src/actions/comment-actions/commentActions.ts`
  - `packages/projects/src/components/TaskComment.tsx`
  - `packages/projects/src/components/TaskCommentThread.tsx`
  - `packages/projects/src/components/TaskCommentForm.tsx`
  - `packages/projects/src/models/projectTaskComment.ts`
  - `packages/projects/src/actions/projectTaskCommentActions.ts`
  - `packages/types/src/interfaces/comment.interface.ts`
  - `packages/types/src/interfaces/projectTaskComment.interface.ts`
  - `shared/services/email/processInboundEmailInApp.ts`
  - `shared/workflow/actions/emailWorkflowActions.ts`
  - `server/migrations/202409071803_initial_schema.cjs` (comments table origin)
  - `server/migrations/20251118140000_create_project_task_comments.cjs` (project_task_comments origin)
  - `server/migrations/20260331110000_add_email_threading_diagnostics_columns.cjs` (email_sending_logs thread fields)

## Open Questions

- Exact rename/co-existence strategy for `email_sending_logs.thread_id` (provider) vs new `comment_thread_id`. Default plan: add new column, leave the old one alone; revisit if it causes confusion.
- Should `comment_threads.email_references` be capped in length (mail clients can emit 50+ References entries)? Default plan: no cap initially; revisit if storage growth is an issue.
- Whether project-task comment soft-delete needs the same "[deleted]" UX text or a different copy (tasks have less context). Default plan: same text, revisit during UI review.
