mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 07:10:49 +02:00
* feat(comments): resolve threads with collapsible bar (MUL-1895)
Adds a Linear-style resolve action on comment thread roots. Resolved
threads collapse to a single "N resolved comments from X" bar in the
activity feed; clicking expands the thread inline (per-session, not
persisted). Replying inside a resolved thread auto-unresolves it.
Backend
- migration 069: resolved_at, resolved_by_type, resolved_by_id on comment
- sqlc ResolveComment / UnresolveComment queries (idempotent via COALESCE)
- POST/DELETE /api/comments/{id}/resolve handlers, root-only validation
- CreateComment auto-clears resolved_at when a reply lands in a resolved
thread, publishing comment:unresolved
- comment:resolved / comment:unresolved events; CommentResponse and
TimelineEntry both surface the new fields
Frontend
- Comment + TimelineEntry types extended; payloads typed; WS sync wired
- useResolveComment optimistic mutation with rollback
- ResolvedThreadBar component for the collapsed view
- Resolve / Unresolve menu items on root comments; Collapse strip on the
expanded resolved card
- en + zh-Hans locale strings
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): cover agent reply path, expand-state hygiene, nested counts (MUL-1895)
Addresses three review issues from Emacs on PR #2300:
1. TaskService.createAgentComment bypasses Handler.CreateComment, so the
auto-unresolve wired into the handler did not fire when an agent replied
in a resolved thread (task / mention / on_comment paths). Extracted the
logic to TaskService.AutoUnresolveThreadOnReply so both reply paths share
it; rewired Handler.CreateComment to call the new method.
2. Resolving an already-expanded thread no longer collapses it back to the
bar because expandedResolved still contained the id. Added
clearResolvedExpand + handleResolveToggle wrapper so resolve / unresolve
always wipe the session expand entry.
3. ResolvedThreadBar received only direct children, while CommentCard's
expanded view recurses through descendants. Extracted the recursive
walk into thread-utils.collectThreadReplies and called from both —
counts and author lists now match.
Co-authored-by: multica-agent <github@multica.ai>
* test(comments): mock useResolveComment + add zh-Hans plural key
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
109 lines
3.4 KiB
SQL
109 lines
3.4 KiB
SQL
-- name: ListCommentsPaginated :many
|
|
SELECT * FROM comment
|
|
WHERE issue_id = $1 AND workspace_id = $2
|
|
ORDER BY created_at ASC
|
|
LIMIT $3 OFFSET $4;
|
|
|
|
-- name: ListCommentsSince :many
|
|
SELECT * FROM comment
|
|
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: ListCommentsSincePaginated :many
|
|
SELECT * FROM comment
|
|
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
|
|
ORDER BY created_at ASC
|
|
LIMIT $4 OFFSET $5;
|
|
|
|
-- name: ListCommentsLatest :many
|
|
-- Top N comments for an issue, newest first. Backs the default cursor
|
|
-- pagination entry point (no cursor → return the most recent page).
|
|
SELECT * FROM comment
|
|
WHERE issue_id = $1 AND workspace_id = $2
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT $3;
|
|
|
|
-- name: ListCommentsBefore :many
|
|
-- Keyset pagination: comments older than ($3, $4) tuple. Returns DESC so the
|
|
-- caller can stitch pages without re-sorting.
|
|
SELECT * FROM comment
|
|
WHERE issue_id = $1 AND workspace_id = $2
|
|
AND (created_at, id) < ($3::timestamptz, $4::uuid)
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT $5;
|
|
|
|
-- name: ListCommentsAfter :many
|
|
-- Keyset pagination: comments newer than ($3, $4) tuple. Returns ASC because
|
|
-- "newer" pagination naturally walks forward in time; the merge layer
|
|
-- normalizes to the response order.
|
|
SELECT * FROM comment
|
|
WHERE issue_id = $1 AND workspace_id = $2
|
|
AND (created_at, id) > ($3::timestamptz, $4::uuid)
|
|
ORDER BY created_at ASC, id ASC
|
|
LIMIT $5;
|
|
|
|
-- name: CountComments :one
|
|
SELECT count(*) FROM comment
|
|
WHERE issue_id = $1 AND workspace_id = $2;
|
|
|
|
-- name: GetComment :one
|
|
SELECT * FROM comment
|
|
WHERE id = $1;
|
|
|
|
-- name: GetCommentInWorkspace :one
|
|
SELECT * FROM comment
|
|
WHERE id = $1 AND workspace_id = $2;
|
|
|
|
-- name: CreateComment :one
|
|
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(parent_id))
|
|
RETURNING *;
|
|
|
|
-- name: UpdateComment :one
|
|
UPDATE comment SET
|
|
content = $2,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: HasAgentCommentedSince :one
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM comment
|
|
WHERE issue_id = @issue_id
|
|
AND author_type = 'agent'
|
|
AND author_id = @author_id
|
|
AND created_at >= @since
|
|
) AS commented;
|
|
|
|
-- name: HasAgentRepliedInThread :one
|
|
-- Returns true if the given agent has posted a reply in the thread rooted at
|
|
-- the specified parent comment. Used to detect agent participation in a
|
|
-- member-started thread so that follow-up member replies still trigger the agent.
|
|
SELECT count(*) > 0 AS has_replied FROM comment
|
|
WHERE parent_id = @parent_id AND author_type = 'agent' AND author_id = @agent_id;
|
|
|
|
-- name: DeleteComment :exec
|
|
DELETE FROM comment WHERE id = $1;
|
|
|
|
-- name: ResolveComment :one
|
|
-- Idempotent: re-resolving keeps the original resolved_at + resolver. Always
|
|
-- returns the row so the handler can surface the canonical state.
|
|
UPDATE comment SET
|
|
resolved_at = COALESCE(resolved_at, now()),
|
|
resolved_by_type = COALESCE(resolved_by_type, $2),
|
|
resolved_by_id = COALESCE(resolved_by_id, $3),
|
|
updated_at = CASE WHEN resolved_at IS NULL THEN now() ELSE updated_at END
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UnresolveComment :one
|
|
-- Idempotent: a no-op clear (already unresolved) just returns the row.
|
|
UPDATE comment SET
|
|
resolved_at = NULL,
|
|
resolved_by_type = NULL,
|
|
resolved_by_id = NULL,
|
|
updated_at = CASE WHEN resolved_at IS NOT NULL THEN now() ELSE updated_at END
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|