* feat(comments): thread-aware list with composite cursor (MUL-2340)
Adds three optional query params to GET /api/issues/{id}/comments and the
matching `multica issue comment list` flags:
- `thread=<comment-uuid>` resolves the anchor to the thread root via a
recursive CTE (defends against any future nested replies) and returns
root + all descendants chronologically. Anchor can be any comment in
the thread, root or reply.
- `recent=<N>` returns the newest N comments for the issue, ordered
chronologically in the response.
- `before=<RFC3339>` + `before-id=<uuid>` form a composite cursor for
stable pagination of `recent`. Both must be set together; a
timestamp-only cursor is rejected because ties on `created_at` would
let the existing `(created_at ASC, id ASC)` total order skip or
duplicate rows across pages.
Flag combination rules: `thread` is exclusive with `recent` and the
cursor; both may combine with `since`. Server and CLI enforce the same
matrix; the CLI fails fast locally so callers don't pay for a 400
round-trip.
Default behaviour (no params) is unchanged — full chronological dump
capped at commentHardCap — so the desktop UI and existing `--since`
polling are untouched. Agent prompt updates land in a follow-up PR so
the new CLI capabilities ship and bake first.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): reject cursor without recent and align CLI/server on invalid --recent (MUL-2340)
Elon's PR #2787 second review flagged two gaps in the flag combination
matrix:
- server: GET /comments?before=...&before_id=... without `recent` was
silently dropped by fetchCommentsForList (RecentN=0 fell through to
the default / since path), so callers got the full timeline instead
of the documented "before X" semantics. Now returns 400.
- CLI: --recent 0 / --recent -3 were collapsed with "flag not passed"
by `recent > 0`, so an explicit invalid value silently fell back to
the default list. Switched to Flags().Changed("recent") so explicit
non-positive values fail loudly. Also enforces that --before /
--before-id only appear with explicit --recent (mirrors the new
server-side rule).
Tests:
- server flag matrix gains `before + before_id without recent → 400`.
- CLI gains TestRunIssueCommentListFlagGuards covering `--recent 0`,
`--recent -3`, cursor-without-recent, and the thread/recent
exclusivity path under the new Changed()-based check. The mock
server fatals if a request reaches /comments, proving the guards
fire before any HTTP round-trip.
Co-authored-by: multica-agent <github@multica.ai>
* feat(comments): make `recent` thread-grouped with a thread cursor (MUL-2340)
Bohan pushed back on the row-based `recent=N` shape: comments form a tree,
not a list, and the newest N rows can come from N unrelated threads, giving
the agent N disjoint conversational tails. Replace the row-based query with
a thread-grouped one before #2787 merges so we never ship the wrong shape:
- `recent=N` now returns the N most recently active threads (root + every
descendant per thread). A thread's recency is MAX(created_at) across its
whole subtree, so a stale-but-recently-replied thread outranks an old
quiet one — exactly the property row-recent loses.
- The cursor is now a *thread* cursor: `before` = a thread's
last_activity_at, `before_id` = its root comment id. The pair walks
threads strictly less recent than the page's oldest-active thread. The
cursor surfaces via `X-Multica-Next-Before` / `X-Multica-Next-Before-Id`
response headers (empty when there are no older threads); the CLI
forwards the same pair to stderr after listing.
- Row-based `recent` is gone — there is no internal caller and the prompt
update has not shipped yet, so there is no compat surface to preserve.
- Response body shape unchanged (flat JSON array, chronological). Default
and `--since` paths untouched. Desktop UI keeps working.
Tests:
- recent=1 returns the freshest-active thread fully; recent=2 returns both
with the older-active thread first (oldest-active → freshest tail).
- Stale-but-fresh: a thread whose root is older but has a fresh reply
outranks a thread whose root is newer but quiet.
- Cursor headers emitted only on full pages; empty on the final page.
- Pagination walks threads root2 → root1 → empty, no skips/duplicates.
- Tie-break: three threads sharing last_activity_at paginate one-at-a-time
using (last_activity_at, root_id) ordering — verifies the timestamp-only
cursor failure mode is fixed for the thread case too.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* refactor(timeline): drop server-side comment + timeline pagination (MUL-1929)
The cursor-paginated /timeline and /comments endpoints were sized for a
problem the data shape doesn't have: prod p99 is ~30 comments per issue
and the all-time max is ~1.1k. Time-based pagination also splits reply
threads across page boundaries (orphan replies), which the frontend was
papering over with an "orphan rescue" that promoted disconnected replies
to top-level — confusing UX with no real benefit.
Replace both endpoints with a single full-issue fetch, capped server-side
at 2000 rows as a defensive safety net (never hit in practice).
Server
- /api/issues/:id/timeline now returns a flat ASC TimelineEntry[]
(matches the legacy desktop contract — older Multica.app builds keep
working because the wrapped TimelineResponse + cursors are gone, and
the raw array shape was always what they consumed).
- /api/issues/:id/comments drops limit/offset; only ?since is honoured
for the CLI agent-polling flow.
- Drop ListCommentsBefore/After/Latest, ListActivitiesBefore/After/Latest
and the timelineCursor encoding.
- Replace with ListCommentsForIssue / ListCommentsSinceForIssue /
ListActivitiesForIssue (capped by argument).
CLI
- multica issue comment list drops --limit / --offset and the X-Total-Count
reporting; --since is preserved for incremental polling.
Frontend
- Replace useInfiniteQuery with useQuery in useIssueTimeline; drop
fetchOlder/Newer, jumpToLatest, isAtLatest, newEntriesBelowCount.
- Remove timeline-cache helpers (mapAllEntries / filterAllEntries /
prependToLatestPage) and the TimelinePage / TimelinePageParam types.
- WS event handlers update the single flat-array cache directly.
- Drop the orphan-reply rescue in issue-detail — every reply's parent
is now guaranteed to be in the same array.
- Strip the "show older / show newer / jump to latest" buttons and their
i18n strings.
Co-authored-by: multica-agent <github@multica.ai>
* fix(timeline): address review feedback on pagination removal
Three issues caught in PR #2322 review:
1. /timeline broke for stale clients between #2128 and this PR. They send
?limit/?before/?after/?around and parse with the wrapped TimelinePageSchema;
the new flat-array response was failing schema validation and falling back
to an empty timeline. Restore the wrapped shape on those query params
(DESC entries, null cursors, has_more_*=false), keeping the flat ASC array
for bare requests. Around-mode now also fills target_index from the merged
slice so legacy clients can still scroll-to-anchor without a follow-up.
2. The agent prompts in runtime_config.go and prompt.go still told agents
that `multica issue comment list` accepts --limit/--offset and to use
`--limit 30` on truncated output. With those flags removed in this PR,
new agent runs would hit "unknown flag" or skip context. Update the
prompt copy to "returns all comments, capped at 2000; --since for
incremental polling".
3. useCreateComment's onSuccess was a bare append to the timeline cache
with no id-dedupe, so a fast comment:created WS event firing before
onSuccess produced a transient duplicate. Restore the id guard the old
prependToLatestPage helper used to provide.
Adds two new boundary tests:
- TestListTimeline_LegacyWrappedShape_OnPaginationParams
- TestListTimeline_LegacyWrappedShape_AroundFillsTargetIndex
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): fix timeline test assertions for handler-package isolation
The TestListTimeline_* assertions assumed CreateIssue would seed an
"issue_created" activity_log row, but the activity listener that publishes
those rows is registered in cmd/server/main.go — handler-package tests
don't wire it up. CI saw 5 entries (3 comments + 2 activities) where the
test expected ≥6.
Drop the auto-activity assumption: assert exactly 5 entries in
TestListTimeline_MergesCommentsAndActivities, and tighten
TestListTimeline_EmptyIssue to assert a fully-empty timeline.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* 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>
* fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968)
Opening an issue from Inbox with thousands of timeline entries used to
hard-freeze the browser tab on a synchronous render of every comment +
activity. The whole pipeline was unbounded: the API returned every row,
TanStack Query cached the full array, and IssueDetail mounted N
CommentCards (each running a full react-markdown + lowlight pipeline)
in one frame.
This swaps the timeline endpoint to keyset cursor pagination and rewires
the frontend to useInfiniteQuery so a long issue costs the same as a
short one on first paint.
API:
- GET /issues/:id/timeline now accepts ?before / ?after / ?around (mutex)
+ ?limit (default 50, max 100); response wraps entries with next/prev
cursors and has_more flags. Cursors are opaque base64 (created_at, id).
- ?around=<entry_id> anchors a window on the target so Inbox notifications
pointing at an old comment never trigger the freeze.
- New composite indexes on (issue_id, created_at DESC, id DESC) replace
the redundant single-column ones so keyset queries are index-only scans.
- /issues/:id/comments default branch now caps at 50 instead of returning
every row unbounded; the unbounded ListComments / ListActivities sqlc
queries are deleted.
Frontend:
- useIssueTimeline switches to useInfiniteQuery, exposes
fetchOlder/fetchNewer/jumpToLatest + isAtLatest + newEntriesBelowCount.
- WS handlers respect the at-latest invariant: comment/activity:created
prepends to pages[0] only when the user is reading the live tail;
otherwise it just bumps a counter so the UI offers a "Jump to latest"
affordance without yanking scroll.
- Optimistic mutations adapted to the InfiniteData shape via shared
helpers (mapAllEntries / filterAllEntries / prependToLatestPage in
core/issues/timeline-cache.ts) and use setQueriesData so all open
windows of the same issue stay in sync.
- IssueDetail Activity section gets a TimelineSkeleton placeholder
during the brief load window plus subtle text-link load-more buttons
matching the existing Subscribe affordance (no Button chrome). Top
uses a divider for boundary clarity; bottom shows
"Jump to latest · N new" weighted slightly heavier when there's
unread state.
- highlightCommentId now flows into the hook's around parameter so
Inbox jumps fetch the surrounding 50 entries directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(agent): default comment list to 50 + prompt hint about long issues
The CLI's "multica issue comment list" used to default to --limit 0
(meaning "fetch every comment"), which lets an agent on a long issue
fill its context window with thousands of rows. The default is now 50;
agents that need older history can pass --limit or --since explicitly.
The local-coding-agent prompt also gains a single-line note about this
in both the comment-triggered and on-assign flows so the agent knows to
scope its fetches when issue size is unknown. Autopilot run-only mode
is intentionally unchanged — it has no issue context to query.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a member replies in a member-started thread without @mentioning the
assigned agent, the on_comment trigger was suppressed — even if the agent
had already replied in that thread. This meant the common flow of
"member posts → agent replies → member follows up" would not re-trigger
the agent on the follow-up.
Add HasAgentRepliedInThread SQL query and check it in isReplyToMemberThread
so that agent participation in a thread is treated as an ongoing conversation.
* fix(server): skip auto-comment when agent already posted during task
In CompleteTask(), check if the agent already posted a comment on the
issue since the task started. If so, skip the automatic output comment
to avoid duplicates. This preserves the fallback for agents that don't
post comments via CLI.
Closes MUL-609
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(server): use StartedAt instead of CreatedAt for duplicate check
CreatedAt is the enqueue time, not execution start. If a previous task
posted a comment between enqueue and start of the next task, it would
incorrectly suppress the auto-comment for the later task.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add --limit, --offset, and --since flags to `multica issue comment list`
to prevent context window overflow when issues have many comments.
The API endpoint now accepts limit, offset, and since (RFC3339) query
parameters. When paginating, the response includes an X-Total-Count
header with the total number of comments.
Enforce workspace isolation at every layer:
- Router: move RequireWorkspaceMember middleware to group level so ALL
workspace-scoped routes (issues, agents, skills, runtimes, inbox,
comments) require workspace context
- SQL: add GetXxxInWorkspace queries that filter by workspace_id,
eliminating cross-workspace data access at the query level
- Handlers: loadXForUser functions use workspace-scoped queries,
no fallback to unscoped queries
- Migration 025: add workspace_id column to comment table with backfill
- ListComments: add workspace_id filter for defense-in-depth
Fix daemon workspace mapping:
- Server returns workspace_id in task claim response (from issue)
- Daemon uses task.WorkspaceID directly instead of unreliable
workspaceIDForRuntime() local map lookup
- Remove workspaceIDForRuntime function
Fix agent/human parity:
- Comment update/delete: use resolveActor for isAuthor check so agents
can edit/delete their own comments
- Event attribution: replace hardcoded "member" with resolveActor in
agent, skill, and subscriber publish calls
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the comment-only list with a Linear-style unified timeline that
interleaves field changes and comments chronologically.
Backend:
- activity_listeners.go: records field changes (status, assignee, description,
task completed/failed) to activity_log table on domain events
- Timeline API: GET /api/issues/{id}/timeline merges activity_log + comments
sorted by created_at
- Comment reply: parent_id column + handler support for threading
Frontend:
- Unified timeline replaces comment list: activity entries as compact muted
lines, comments as Card components with reply threading
- Filter toggle (All / Comments / Activity)
- Reply UI: inline editor under comments with Cancel/Reply buttons
- Real-time sync for activity:created + comment events
- 10 new Go tests, all passing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add HTTP handlers for issues, comments, agents, workspaces, inbox, members, and activity
- Implement JWT authentication middleware with Bearer token validation
- Add sqlc queries for all entities (CRUD operations)
- Extract router into reusable NewRouter() for testability
- Expand SDK with full API client methods (CRUD for all resources)
- Add updateWorkspace to SDK, add Member type to shared types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>