* fix: keep issue draft attachment records
Co-authored-by: multica-agent <github@multica.ai>
* fix: avoid persisting signed draft attachment urls
Co-authored-by: multica-agent <github@multica.ai>
* fix: reuse resolved media url for draft previews
Co-authored-by: multica-agent <github@multica.ai>
* fix: address draft attachment review nits
- Backfill an empty caller download_url from the in-session upload on id
collision so a just-pasted image first-paints from the signed URL
instead of detouring through markdown_url.
- Prune draft attachments no longer referenced by the persisted
description when the create dialog reopens.
- Backfill EMPTY_DRAFT defaults on draft-store rehydrate so drafts
persisted before the attachments field existed get a stable shape.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The preview answer depends on live queue state (pending-task dedup), not
just the mention set, so three staleness bugs showed up around it:
- staleTime: Infinity pinned a "nobody triggers" snapshot taken while
the mentioned agent was still queued — the chip never appeared even
though sending really did wake the agent (create recomputes).
-> staleTime: 0, cached signatures revalidate in the background.
- The in-flight gap on a signature change rendered as an empty agent
list, flickering the chips and wiping the composer's suppressed-id
set via the pruning effect. -> placeholderData: keepPreviousData.
- Nothing refreshed an open composer when an agent's task finished.
-> the WS task-lifecycle handler now also invalidates the
commentTriggerPreviewAll prefix, so chips appear mid-typing the
moment the agent becomes triggerable again.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Add comment trigger preview suppression
Co-authored-by: multica-agent <github@multica.ai>
* Use TanStack Query for trigger preview
Co-authored-by: multica-agent <github@multica.ai>
* Test note comments skip create triggers
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): redesign comment trigger chips as avatar chips
Single agent renders as avatar + presence dot + full sentence; several
agents collapse to an overlapping stack + active count, mirroring the
header working chip. Per-agent skip moves into a click-opened popover
(hover layers stay read-only tooltips); suppression reads as brightness,
not a ban glyph. Loading and preview errors render nothing.
Also: share one tooltip body across chip and popover rows, invalidate
cached previews after a comment lands (the enqueued task changes the
dedup answer), move the preview query key into issueKeys, and drop the
now-unconsumed status field from useCommentTriggerPreview.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(server): drop comment trigger wrappers kept only for tests
enqueueMentionedAgentTasks and shouldEnqueueSquadLeaderOnComment had no
production callers after the compute/enqueue split — the comment path
goes through computeCommentAgentTriggers. Tests now exercise the compute
functions directly via package-local helpers, so the legacy adapters
cannot drift from the real path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(skills): sync mentioning/squads source maps with shared trigger computation
The squads source map still pointed the comment-trigger contract at the
pre-refactor call chain (comment.go:940 -> shouldEnqueueSquadLeaderOnComment),
and the mentioning skill referenced the deleted wrapper. Re-anchor both
to computeCommentAgentTriggers / computeAssignedSquadLeaderCommentTrigger
/ computeMentionedAgentCommentTriggers with current line numbers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Comment / issue / chat images uploaded inside the Desktop app rendered
as the broken-image fallback. The editor was persisting a site-relative
`/api/attachments/<id>/download` URL into markdown — that path only
resolves when the document origin proxies /api to the API host (apps/web
via Next.js rewrite). On Electron's file:// origin it never resolved.
Per GPT-Boy's plan, move the durable-URL choice from the client to the
server so the persisted shape is correct regardless of which client
performed the upload.
Server:
- AttachmentResponse gains a markdown_url field, computed by
buildMarkdownURL from the deployment policy:
• storage URL is already absolute + unsigned (public CDN, S3 public
bucket, LocalStorage with MULTICA_LOCAL_UPLOAD_BASE_URL on https) →
use it verbatim;
• CloudFront-signed mode → never expose the raw S3 URL (private
bucket); return cfg.PublicURL + /api/attachments/<id>/download so
the server can re-sign on every request;
• LocalStorage relative + cfg.PublicURL set → same prefixed API
endpoint;
• cfg.PublicURL unset → fall back to site-relative path so web's
Next.js rewrite still works.
- isDurablePublicURL helper rejects URLs carrying CloudFront / S3
signature query params, so a freshly-signed download_url can never
leak into persistence — the original MUL-3130 bug stays closed.
Frontend:
- Attachment type + AttachmentResponseSchema (and apps/mobile mirror)
carry markdown_url. Schema lenient-defaults to '' so a backend old
enough to predate this field doesn't break clients.
- useFileUpload picks markdownLink with three-layer fallback:
(1) att.markdown_url (modern server),
(2) attachmentDownloadPath(att.id) — legacy site-relative shape,
retained for backends old enough to omit markdown_url,
(3) att.url — no-workspace avatar branch with no attachment-row id.
- attachment.tsx keeps the relative→absolute absolutize pass, but
reframed as the legacy-compat fallback for already-persisted
/api/attachments/<id>/download or /uploads/<key> URLs in old
bodies. New content writes absolute URLs and skips this path.
- ContentEditor still tracks freshly-uploaded records into
AttachmentDownloadProvider so Quick Create's editor can swap the URL
via the resolver during the same session even before the server-side
binding lands.
Tests:
- server/internal/handler/file_test.go: 5 new buildMarkdownURL matrix
tests (public CDN passthrough, CloudFront-signed swap, relative
prefixing, PublicURL unset fallback, trailing-slash strip) + 15
table-driven isDurablePublicURL cases.
- packages/core/hooks/use-file-upload.test.ts: new file, 4 cases
covering modern server / legacy server / no-id avatar / oversize.
- packages/views/editor/attachment.test.tsx + content-editor.test.tsx:
10 cases for the absolutize matrix and in-session attachment merge.
- 6 existing test fixtures updated to include markdown_url.
Verification: 1236 @multica/views tests pass; 514 @multica/core tests
pass (4 new); server handler package tests pass for the new matrix
plus all pre-existing TestAttachmentToResponse* and TestDownload*
cases. Typecheck green for views/core/web/desktop. Lint clean on
touched files.
Quick Create attachment_ids binding (orphaned attachment relationship
on the resulting issue) is a follow-up — it requires a new --attachment-id
CLI flag and daemon prompt-template work and is intentionally scoped
out of this PR.
Refs: MUL-3192
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(realtime): invalidate per-issue caches on WS reconnect (MUL-3189)
Per-issue caches (timeline, reactions, subscribers, usage, attachments,
tasks) are keyed without wsId, so the issueKeys.all(wsId) prefix in
invalidateWorkspaceScopedQueries never reached them. With the
staleTime: Infinity default they rely entirely on WS events for
freshness, so a comment:created event lost during a disconnect (e.g.
macOS sleep) left the timeline stale until a full view reload — the
inbox showed the agent's new comment while the issue's comment area
stayed empty.
Add *All prefix helpers for the per-issue key families and invalidate
them in the reconnect / WS-instance-change recovery path. Inactive
caches are only marked stale and refetch on next mount; the mounted
issue refetches immediately, matching its existing useWSReconnect
behavior, so this does not reintroduce the MUL-1941 memo thrash.
Fixes#3953
Co-authored-by: multica-agent <github@multica.ai>
* refactor(core): define issueKeys.tasks via tasksAll prefix helper
Review nit on #3992 — keep the per-issue key families consistently
defined in terms of their *All prefix helpers. No behavior change.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
A thread could hold multiple resolved comments at once: ResolveComment
was a plain per-row setter that never cleared the prior resolution, and
"replacing" one was a display-only illusion (deriveThreadResolution
picks the max resolved_at). The stale rows stayed resolved in the DB and
the optimistic update flashed the new resolution, then reverted.
Make single-resolution-per-thread a write invariant:
- ClearOtherThreadResolutions: thread-scoped clear via a RECURSIVE CTE
(root + descendants of the target, id <> target), returns each cleared
row.
- ResolveComment handler runs the clear + set in one tx so the replace
is atomic. It emits comment:unresolved per cleared sibling (granular
realtime consumers patch a single comment in place and would otherwise
keep showing the stale resolution). Target keeps its COALESCE
idempotency and the re-resolve event suppression.
- Frontend optimistic update mirrors the invariant: resolving clears
every other resolution in the same thread, so the cache never shows
two at once. Unresolve still only clears its own row.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): store start_date/due_date as DATE, not timestamp (MUL-2925)
These fields are calendar days (the pickers offer no time-of-day), but were
stored as TIMESTAMPTZ. A client serializing local midnight via toISOString()
folded its timezone into the instant, so the day shifted by the local offset
(GH #3618). Migrate the columns to DATE and parse/serialize date-only
"YYYY-MM-DD". ParseCalendarDate still accepts legacy RFC3339 (truncated to the
UTC day) so older clients keep working.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): render start_date/due_date as timezone-stable calendar days (MUL-2925)
Pickers now emit date-only "YYYY-MM-DD" (local calendar day) instead of
toISOString(), and every read formats via the shared @multica/core/issues/date
helpers with timeZone:"UTC" so the day never shifts with the viewer's offset.
The Gantt's existing UTC bucketing is now correct. Covers web/desktop pickers,
quick-set menu, list/board/detail/activity, and the mobile due-date picker.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): address date-only review — loud-fail ambiguous dates, finish display sweep (MUL-2925)
Review follow-ups on #3692:
- ParseCalendarDate no longer silently truncates a legacy non-midnight RFC3339
to the wrong UTC day; it accepts only YYYY-MM-DD or an exact UTC-midnight
instant and rejects ambiguous ones loudly. Adds util unit tests.
- migration 112 pins the TIMESTAMPTZ->DATE conversion to UTC explicitly via
AT TIME ZONE 'UTC' (was session-timezone dependent); down migration too.
- Convert remaining date-change display sites to formatDateOnly: inbox detail
label (web) and mobile activity + inbox labels (were new Date()+local format).
- CLI --start-date/--due-date help now says YYYY-MM-DD, not RFC3339.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
cleanupDeletedIssueCaches now also calls a new
useRecentIssuesStore.forgetIssue(wsId, issueId) action so the persisted
Recent Issues bucket no longer keeps deleted ids around. Both the delete
mutation and the WS delete event flow through the same cleanup, so this
covers self-delete and cross-client delete. Without this, Cmd+K fires a
detail query for every recent id on open and returns a steady stream of
404s for issues the user has deleted (#3413).
MUL-2765
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(comments): align UpdateComment post-processing with CreateComment (#2965 follow-up)
Part 1 — PR #2965 code review follow-ups:
- Fix sqlc Column3 naming → AttachmentIds via sqlc.arg(attachment_ids)
- Return 500 on ReplaceCommentAttachments failure instead of logging + 200
- Remove optional marker from onEdit attachmentIds (always passed)
- Add optimistic update for attachments in useUpdateComment
- Extract useEditAttachmentState hook from CommentRow/CommentCardImpl
- Add integration tests for attachment replacement scenarios
Part 2 — Edit-comment logic alignment:
- Add ExpandIssueIdentifiers to UpdateComment (bare identifiers now expand)
- Add handleEditMentionDiff: diff old vs new agent/squad mentions on edit,
cancel tasks for removed mentions, enqueue tasks for added mentions,
cancel + re-trigger when content changes but mentions are unchanged
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(sqlc): regenerate with v1.31.1 + add mention diff integration tests
Fixes sqlc version downgrade (v1.31.1 → v1.30.0) that was introduced
when the original PR was authored with a local v1.30.0 binary.
Regenerated all sqlc output with v1.31.1 to match main.
Adds integration tests for handleEditMentionDiff covering: edit adds
mention → task enqueued, edit removes mention → task cancelled, edit
changes content with same mentions → cancel + re-trigger.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(comments): simplify edit post-processing to cancel-all + re-trigger
Replace handleEditMentionDiff (120-line mention diff) with a simpler
model: when content changes, cancel all tasks triggered by this comment,
then re-run the same three trigger paths as CreateComment (assignee,
squad leader, mentions). Fixes gap where assignee/squad-leader tasks
were not cancelled or re-triggered on edit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(comments): extract triggerTasksForComment to unify Create/Edit trigger paths
Create and Edit duplicated the same three trigger paths (assignee,
squad leader, mentioned agents). A fourth path would need changes
in two places. Extract into a shared function so the composition is:
Create: trigger() + unresolve()
Edit: cancel() + trigger()
Delete: cancel()
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(views): swimlane supports parent / project / assignee grouping (MUL-2711)
The swimlane view was hard-coded to group by parent issue. This adds a
display dropdown so users can pick parent (default), project, or
assignee — analogous to how the board view exposes its grouping option.
- Generalise the lane builder in swimlane-view.tsx behind a `LaneGroup`
abstraction (matcher + per-grouping `moveUpdates` payload) so the
drag-end handler no longer branches on grouping. Cell ids gain a
`<grouping>:<rawId>` prefix and lane sortable ids include the
grouping so dnd-kit cannot collide entries from different groupings.
- Extend the view store with `swimlaneGrouping`, `swimlaneOrders` (one
saved order per grouping), and a grouping-keyed `collapsedSwimlanes`.
The persist `merge` defends against the old `string[]` shape so a
pre-upgrade snapshot doesn't crash on first read.
- Wire `setSwimlaneGrouping` into the issues display popover next to
the existing board grouping control. Add en / zh-Hans copy for the
three swimlane buckets (Parent issue / Project / Assignee) and the
two new pinned lanes (No project / Unassigned).
- Expand swimlane tests with parent / project / assignee smoke cases
and update existing mocks to the new lane-id format. Add stable
`useActorName` / `projectListOptions` mocks to avoid the
set-state-in-effect loop that an unstable `getActorName` would
trigger via the cells-rebuild memo.
Co-authored-by: multica-agent <github@multica.ai>
* feat(views): default swimlane grouping to assignee
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The recent server-side-sort change (#3228) keyed the issue-list cache by
sort but did not update the load-more hooks: useLoadMoreByStatus used a
prefix-match that could pick a stale cache variant, and neither hook
forwarded sort to the API request. As a result, scroll-to-load-more
fired its request, but the response was either appended to a cache no
useQuery was subscribed to, or it appended rows in an unsorted order
into a sorted bucket.
Pass `sort` explicitly through Board/List/Swimlane and into the hooks.
The hook now targets the full sorted key via setQueryData and forwards
sort to the listIssues / listGroupedIssues calls so the appended page
lines up with the existing items.
Also adds focused tests for both load-more hooks: stale-sort cache is
untouched, sort is forwarded to the API, and sort-less callers still hit
the {} key path used by actor-issues-panel.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(editor): split rich text styles
* feat(issues): server-side sort + fix drag position corruption in non-manual sort
Backend: ListIssues and ListGroupedIssues now accept `sort` and `direction`
query params (position/priority/title/created_at/start_date/due_date).
ListIssues converted from sqlc to hand-written SQL for dynamic ORDER BY.
Priority sort uses CASE expression for semantic ordering.
Frontend: query keys include sort so changing sort triggers server refetch.
Client-side sortIssues() removed from board-view and list-view.
Drag-and-drop: non-manual sort disables within-column reorder (prevents
silent position corruption). Cross-column drag only updates status/assignee,
preserves original position. Column overlay shows current sort during drag.
Cache: query key split into prefix (list) for invalidation and full key
(listSorted) for queryOptions. All optimistic update paths use prefix
matching via getQueriesData to work with any active sort.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(board): prevent drag flicker by settling columns until mutation refetch
After drag-and-drop, the optimistic cache patch updates position values
without reordering the bucket array. The useEffect that rebuilds columns
from TQ data would overwrite the correct local drag order, causing cards
to snap back then forward. Fix: isSettlingRef blocks column rebuilds
between drag end and mutation onSettled.
Also invalidate issueKeys.list on WS position changes so other windows
refetch correctly sorted data instead of showing stale bucket order.
Includes debug logs (to be removed after verification).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(board): stabilize drag-and-drop for non-manual sort modes
Three behavioral fixes for board drag when sort != position:
1. Settling: isSettlingRef + settleVersion blocks column rebuilds
between drag-end and mutation settle, preventing the optimistic
cache patch (which updates position values without reordering the
bucket array) from overwriting the correct local column state.
2. Non-manual cross-column: handleDragOver returns prev (no visual
card movement — column highlight + sort label is sufficient).
handleDragEnd uses overCol directly instead of findColumn on the
card's current position (which would be the source column).
Cards use useSortable({ disabled: { droppable: true } }) to
suppress within-column insertion indicators.
3. Collision detection: when no card droppables exist (disabled in
non-manual sort), return column droppables from pointerWithin
instead of falling through to closestCenter, so isOver reflects
the column the pointer is actually inside.
Also: WS position changes now invalidate issueKeys.list so other
windows refetch correctly sorted data.
Insertion-position prediction intentionally omitted — PostgreSQL's
en_US.utf8 collation (glibc) cannot be faithfully replicated in
JavaScript (ICU/V8), and an inaccurate indicator is worse than none.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sort): manual sort ignores direction param on both ends
Manual sort (position) is user-defined order via drag-and-drop —
reversing it has no product meaning.
Backend: sort=position now skips the direction query param and
always uses ASC. Both ListIssues and ListGroupedIssues handlers.
Frontend: sort object omits sort_direction when sortBy is position.
Direction toggle hidden in the display popover for manual mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(board): memo columns + stabilize references to reduce re-renders
- BoardColumn, PaginatedBoardColumn, PaginatedAssigneeBoardColumn
wrapped in memo() — only columns with changed props re-render
- IssueAgentActivityIndicator wrapped in memo() — 111 snapshot
subscribers no longer trigger full re-render on every WS task event
- buildColumns rewritten from O(groups × issues) to single-pass O(n)
- EMPTY_IDS constant replaces ?? [] fallbacks (stable reference)
- EMPTY_CHILD_PROGRESS constant replaces new Map() default
- BOARD_COL_WIDTH / BOARD_CARD_WIDTH constants shared between
column and DragOverlay for consistent card dimensions
- issueListOptions + issueAssigneeGroupsOptions use
placeholderData: keepPreviousData so sort/filter changes don't
flash a full-page skeleton
- Loading skeleton scoped to content area only — header stays
rendered during data transitions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: remove outdated server-side sort implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: sort timeline entries by created_at on WebSocket append
When multiple agents post comments concurrently, WebSocket events may
arrive out of chronological order. The handlers blindly appended new
entries to the end of the cached timeline array, causing display
misordering. This fix sorts the array by created_at (with id as
tie-breaker) after each insert.
Changes:
- use-issue-timeline.ts: sort after comment:created and activity:created
- issue-ws-updaters.ts: sort in appendTimelineEntry
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(views): extract sortTimelineEntriesAsc helper, cover mutation onSuccess
Review feedback from @Bohan-J: useCreateComment.onSuccess also appends
unsorted (mutations.ts:558). When the local user posts a comment whose
HTTP response returns after a concurrent WS event, the unsorted append
leaves the cache misordered and the subsequent WS dedup skips re-sort.
Extract sortTimelineEntriesAsc helper and reuse it in all three web
cache writers:
- comment:created WS handler
- activity:created WS handler
- useCreateComment.onSuccess
Mobile keeps its own inline sort (apps/mobile/CLAUDE.md boundary).
Add regression tests for sort position (mid-insert and oldest-insert).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(server): broadcast task:running event
The dispatched → running transition was silent: only task:queued,
task:dispatch, task:cancelled, task:completed and task:failed
broadcast over WS. Any UI that distinguishes "queued" from "running"
(e.g. the new issue-card agent activity indicator) would lag by up to
the 30s agentTaskSnapshot staleTime on the most user-visible
transition. StartTask now broadcasts task:running so the workspace
snapshot invalidates immediately, keeping the agent activity UI live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(issues): live agent activity chip + per-issue indicator + filter
Surfaces "which agents are working on what, right now" in the Issues
and My Issues views, with a one-click filter to narrow the list to
issues that have a running agent task.
Two visual surfaces:
- **Workspace chip** in the header (left of Filter). Shows the
brand-tinted avatar stack of agents currently running on visible
issues. Click toggles a page-scoped filter; idle state renders a
static "0 working" button with a hover-card placeholder. When the
filter is active the chip pins to brand fill across hover and popover
states (the Button outline variant otherwise repaints back to
neutral). A muted "Viewing only working agents" hint sits to the
left of the chip whenever the filter is on, so users notice the
active state without having to hover.
- **Per-issue indicator** on every board card and list row (top-right
of the identifier line). Renders the avatar stack of agents in
running or queued state on that issue, full-opacity ring at brand/70
when ≥1 is running, half-opacity stack when only queued. Returns
null when nothing is in flight.
Both surfaces open the same hover-card body that lists each active
task with the agent avatar, status dot (composed via the existing
availability + workload tokens), and a live-ticking duration.
Adds a new "All" scope to /my-issues that unions assignee, creator,
and involves_user_id via three parallel fetches deduped on the
client — no backend changes for this part. The chip's count and the
quick-filter both use the page's currently visible issue ids so they
stay in sync with the active scope.
State is per-user (Zustand + localStorage) and the agentRunningFilter
is intentionally omitted from partialize — running state changes
second-to-second and a stored toggle would land users in an
unexplained empty list. WS task:running, already added in the
preceding commit, drives real-time updates without polling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(issues): swap indicator ring pulse for shimmer text label
Earlier iterations layered a brand ring with various opacity-pulse
cadences around the per-issue avatar stack. Every tuning attempt was
either invisible (transparent ring + faded pulse) or oppressive (a
visible ring that flashed on a dense board). Moves the "alive" signal
onto a small text label and reuses chat's existing
`animate-chat-text-shimmer` utility — a soft light sweep across the
glyphs that already powers the ChatGPT-style "thinking" cue in
task-status-pill.
Indicator now reads as a 12 px avatar stack + 10 px label:
- Running → full-opacity avatars + shimmering localized "Working"
- Queued → half-opacity avatars + muted static "Queued"
- Idle → render nothing (unchanged)
Avatars and the surrounding card stay completely still; only the few
glyphs animate. The label is i18n-driven via the existing
`status_running` / `status_queued` keys, so no locale changes are
required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(issues): per-issue metadata KV (MUL-2017)
Adds a small JSONB KV map to every issue for agent pipeline state (attempts,
PR number, pipeline status, ...). Keys match a narrow regex, values are
primitives (string / number / bool), capped at 50 keys per issue and 8KB
per blob. Defense-in-depth via two CHECK constraints (object shape + size).
All mutations are single-key atomic (jsonb_set / `- key`). `UpdateIssue`
intentionally does NOT touch metadata: a whole-blob overwrite would race
with concurrent agent writes.
GET /api/issues/:id/metadata
PUT /api/issues/:id/metadata/:key body: { "value": <primitive> }
DELETE /api/issues/:id/metadata/:key
Containment filter on list: GET /api/issues?metadata=<json-object> uses
PG `@>` against a `jsonb_path_ops` GIN index. Mirrored across ListIssues,
CountIssues, ListOpenIssues, and the hand-rolled ListGroupedIssues SQL so
CLI/API and UI grouped views stay consistent.
CLI: multica issue metadata {list,get,set,delete}
multica issue list --metadata key=value (repeatable, AND)
set has --type to override the default value-sniffing
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): metadata test bugs + wire realtime + read-only display (MUL-2017)
- Fix two failing handler tests blocking backend CI:
- reset decode target after delete so map merge does not mask removal
- url.PathEscape the key segment so spaces no longer panic NewRequest
- Wire issue_metadata:changed end to end so the detail / list / my-issues
caches stay in sync with set/delete events (other tabs, CLI writes).
- Add a read-only Metadata strip to the issue detail sidebar; hidden when
the issue has no keys so it stays quiet in the common case.
Co-authored-by: multica-agent <github@multica.ai>
* feat(runtime): teach agents to read/write issue metadata (MUL-2017)
Add an `## Issue Metadata` section to the runtime brief plus a
`metadata list` step on entry and a `metadata set`/`delete` step on
exit. Section only emits when the task carries an issue id (comment- or
assignment-triggered); chat / quick-create / run-only autopilot stay
clean so they don't fire failing CLI calls.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): bump metadata migration to 105 and drop attempts as example (MUL-2017)
main is now at 104_drop_runtime_timezone; the migrator picks
LatestVersion() by sorted filename, so a slot before the tail would
let DBs that have already run 099–104 think they're up-to-date while
the issue.metadata column is missing — runtime would then fail with
column does not exist. Renumbering to 105 puts the migration at the
tail and forces it to run.
Also drop attempts as a positive example across docs/code comments and
test fixtures — the runtime instruction prompt already lists it under
"What NOT to pin" (runtime bookkeeping). Replace with pr_number, which
is in the recommended-keys set, so docs/tests speak the same language
as the prompt.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): surface "agent working" on board + add Working filter (MUL-2452)
Adds a brand-color "agent working" badge to board cards / list rows so
users can see at a glance which issues have an active agent task, plus a
new "Working" toggle on the `/issues` and `/my-issues` headers (next to
the existing scope segmented control) that filters to those issues. The
toggle shows an avatar stack of the agents currently active on the
current surface + scope. Pure frontend: re-shapes the existing
workspace-wide `agentTaskSnapshot` cache via two new selectors
(`activeTasksByIssueOptions` / `workingIssueIdsOptions`), no new SQL,
endpoint, or DB field; WS `task:*` events already invalidate the
snapshot so the badge / filter update in realtime.
Project detail page keeps the per-card badge but intentionally omits the
header toggle (`showWorkingToggle={false}`) to leave the project
surface's filter dimensions unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): working filter column header reflects filtered count (MUL-2452)
Assignee-grouped board column headers kept showing the unfiltered cache
total when Working was on, because `PaginatedAssigneeBoardColumn` passed
`useLoadMoreByAssigneeGroup`'s cache-derived `total` straight to
`BoardColumn`. The hook still needs the cache total for hasMore, but the
displayed count must follow the visible-after-filter set.
Split the two: when Working is active the column header now uses
`group.totalCount` (set by applyWorkingFilterToGroups) for the assignee
path, and `issueIds.length` for the status path. Load-more keeps reading
from cache so paginated columns still see the full server total.
Regression tests cover applyWorkingFilterToGroups (total rewrite +
empty-group preservation), filterIssues workingOnly combinations, and an
end-to-end assertion via IssuesPage that proves the column header equals
the filtered count, not the cached value.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(projects): scheduled-only Gantt data source + WS reactivity (MUL-1881)
Project Gantt now fetches its own scheduled-only data instead of riding the
Board/List pagination cache. The Unscheduled drawer and pagination warning
banner are gone, and any WS-driven issue change (create / update / delete)
invalidates the new cache so the timeline stays live.
- Backend: `GET /api/issues?scheduled=true` adds an
`(i.start_date IS NOT NULL OR i.due_date IS NOT NULL)` predicate on both
ListIssues and CountIssues. New SQL filter is plumbed through sqlc + handler.
- Frontend: new `projectGanttIssuesOptions(wsId, projectId)` issues a single
fetch and lives under its own cache key. WS handlers and mutations
invalidate the prefix on create/update/delete so the bar reacts to
start_date / due_date changes from other tabs and from this tab without
waiting on the WS round-trip.
- GanttView: drops the Unscheduled section, the pagination warning banner,
and the load-all button; renders only scheduled rows.
- Removes now-dead `useLoadAllRemaining`, `myIssueListPaginationOptions`,
`summarizeIssueListPagination`, and the gantt locale strings that
supported the old plumbing.
Co-authored-by: multica-agent <github@multica.ai>
* fix(projects): page through Gantt fetch and isolate per-view data sources
- Walk paginated `scheduled=true` issues until total is reached so projects
with more than 500 scheduled bars no longer silently truncate.
- Gantt mode disables the bucketed Board/List query and reads its own
scheduled cache for the project empty-state check, so the page never
short-circuits Gantt with a Board-derived "no issues" CTA.
- `onIssueLabelsChanged` patches matching rows in the Project Gantt cache
in-place, keeping label filters consistent after attach/detach from
other tabs or agents.
MUL-1881
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(projects): add Project Gantt view (MUL-1881)
Adds Gantt as a third option in the Project page's view toggle (Board /
List / Gantt). Bars span start_date → due_date; issues with only one
date render as markers, issues with neither are collapsed into an
Unscheduled section. Toolbar exposes day/week/month zoom and a
show-completed toggle. The Gantt view shares the existing IssuesHeader
filters/sort.
Implementation is self-rendered SVG/HTML — no new dependencies. UTC
day-aligned date math keeps bars on the right columns regardless of
viewer timezone.
Co-authored-by: multica-agent <github@multica.ai>
* fix(projects): scope Gantt to project surface + warn on hidden pages
- IssuesHeader / IssueDisplayControls now take `allowGantt` (default false);
only Project Detail opts in. /issues, /my-issues and the actor panel no
longer expose a Gantt option that silently fell through to List, and the
toggle icon falls back to List when a stored `viewMode === "gantt"` lands
on a surface that doesn't render it.
- Project Gantt now surfaces a banner with hidden-issue count plus a
Load-all action that drains every remaining paginated page into the
cache via the new `useLoadAllRemaining` helper. Pagination summary comes
from `myIssueListPaginationOptions`, which shares the existing cache key
with `myIssueListOptions` so totals stay in sync with Board/List.
- ScheduledRow normalizes a `start_date > due_date` anomaly to min/max and
outlines the bar with a destructive ring + tooltip note, instead of
silently dropping the row.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Re-introduces the `involves_user_id` filter on the issues list / open-list /
count / grouped paths, but with the semantics nailed down for the second time
around: tab 3 surfaces issues whose assignee is an *indirect* extension of the
user (owned agent, or a squad they're a human member of / lead via owned agent
/ have an owned agent inside) — and explicitly NOT direct member assignment,
which is tab 1's meaning.
- server/pkg/db/queries/issue.sql: 4-branch filter on ListIssues /
ListOpenIssues / CountIssues. Each subquery clamps workspace_id because
issue.assignee_id is polymorphic with no FK. Leader resolution reads
squad.leader_id directly, not the squad_member copy row (squad.go ignores
errors when seeding that copy, so it can be missing). FindActiveDuplicateIssue
switched from positional $2/$3/$4 to named sqlc.arg() — pure hygiene so the
generated struct field names don't drift when new nargs are added.
- server/internal/handler/issue.go: parse involves_user_id and plumb it into
the three sqlc params; ListGroupedIssues (hand-written dynamic SQL) gets a
mirrored 4-branch fragment, no shortcut.
- packages/core: ListIssuesParams / ListGroupedIssuesParams / MyIssuesFilter /
api.listIssues / api.listGroupedIssues all carry the new param through.
- packages/views/my-issues: tab 3 switches from client-side agent-fanout to
involves_user_id=user.id. agentListOptions import and the myAgentIds memo
go away.
- server/internal/handler/issue_involves_test.go: 13 integration tests cover
every branch (positive + cross-workspace negatives) plus the critical
ExcludesDirectMemberAssignee negative on BOTH the sqlc and the grouped paths,
locking tab 3 ∩ tab 1 = ∅.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(my-issues): cover squad assignees via involves_user_id (MUL-2364)
The "My Agents" tab on /my-issues only resolved agents owned by the
caller, so issues assigned to squads (member, leader, or agent-member of
mine) never surfaced. This added a UNION-based involves_user_id filter
that the backend expands to "me + agents I own + squads I relate to" in
a single query.
- SQL: ListIssues / ListOpenIssues / CountIssues accept narg
involves_user_id and OR a workspace-scoped 3-branch UNION on the
squad assignee subquery. Leader is sourced from canonical
squad.leader_id (not the best-effort squad_member copy row whose
AddSquadMember error is dropped in squad.go:177-188 and :259-263).
- Handler: parses involves_user_id via parseUUIDOrBadRequest, plumbs
into all three list params, and mirrors the same UNION fragment into
the grouped dynamic SQL path.
- Frontend: ListIssuesParams / ListGroupedIssuesParams / MyIssuesFilter
gain involves_user_id; api client forwards it to the querystring.
- My Issues page: "agents" scope now passes involves_user_id instead of
fanning out owned-agent IDs client-side. Tab label widens to
"我的智能体 / 小队" / "My Agents / Squads".
- Tests: Go suite covers all three squad relations including the
canonical-leader-without-squad_member-copy variant, cross-workspace
isolation for agent / leader / squad_member branches, combination
with creator_id, and the malformed-UUID 400 path. Client test pins
the involves_user_id querystring wiring for both list endpoints.
The FindActiveDuplicateIssue query gets explicit sqlc.arg() names so
sqlc regeneration keeps the existing struct field names regardless of
the local sqlc version (no behavior change).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(my-issues): tighten cross-workspace negatives for involves_user_id UNION
Cross-workspace negative tests previously put both the foreign actor and the
foreign issue in the foreign workspace, so the outer i.workspace_id = $1
already excluded the row before the UNION branches were exercised. Stripping
a.workspace_id = $1 / s.workspace_id = $1 from any of the UNION subqueries
would not have failed the tests.
Rewrite the three existing negative cases to seed the issue in
testWorkspaceID with a polymorphic assignee_id pointing at a foreign-workspace
agent or squad (issue.assignee_id has no FK per migrations/001_init.up.sql:61).
Now each UNION branch must enforce its own workspace scoping for the issue to
stay out of the result.
Also add ExcludesOtherWorkspaceSquadAgentMember: the squad_member.agent UNION
branch had only positive coverage; this test pins that s.workspace_id = $1
and a.workspace_id = $1 must both hold there too.
Verified by mutation: stripping the workspace clause from each branch makes
the corresponding test fail.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(agents): list-only tasks panel with issue search (MUL-2391)
Replace the agent detail tasks view-mode toggle with a fixed list view and
add a search bar that filters by issue title, identifier, or pinyin.
Co-authored-by: multica-agent <github@multica.ai>
* fix(actor-issues): only show search empty state when searching
Previously the panel rendered the search empty state whenever the
filtered issue list was empty, which masked ListView's own status-based
empty states when status/priority/assignee/project/label filters
narrowed the list to 0. Now search_empty only renders when
`search.trim()` is non-empty and results are 0; otherwise ListView
takes over and shows its native empty states.
Refs MUL-2391
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Sidebar "新建 issue" button, command palette "New Issue", and the `c`
shortcut all hard-coded which create modal to open, ignoring the
persisted lastMode in useCreateModeStore. Pressing `c` after switching
from agent → manual reverted to agent on the next open.
Add `openCreateIssueWithPreference(data?)` helper next to the store.
Generic entries call it; entries that pre-seed manual-only fields
(status, project_id, parent_issue_id from board / list / project /
sub-issue actions) keep opening "create-issue" directly because agent
mode does not honour those seeds.
Co-authored-by: multica-agent <github@multica.ai>
Text/code attachments (markdown, JSON, .ts, .log, …) need an attachment id
to render through `/api/attachments/{id}/content`. The composer pipeline
was dropping that id at the upload-hook boundary, so the Eye preview gate
only fired for media (PDF / video / audio via filename fallback).
- `useFileUpload` now returns the full `Attachment` (with `link` kept as a
`url` alias) so editor providers can resolve content-type and id.
- New-comment and reply composers hold a `pendingAttachments` state and
feed it to `ContentEditor`; the active subset (those still referenced in
the markdown) is sent on submit as before.
- Comment edit modes (CommentRow + CommentCardImpl) merge pending uploads
with `entry.attachments` for the editor and pipe `attachment_ids` into
`onEdit` so newly uploaded files actually bind to the comment.
- Issue description editor pushes pending `attachment_ids` on every
debounced save and invalidates `issueKeys.attachments` so the preview
Eye survives a refresh.
- `UpdateComment` and `UpdateIssue` handlers accept `attachment_ids` and
call the existing `linkAttachmentsByIDs` / `linkAttachmentsByIssueIDs`
helpers; the bind is idempotent so re-sending an existing id is safe.
Closes MUL-2153.
Co-authored-by: multica-agent <github@multica.ai>
* fix: execution log name rendering and squad assignee support
- Strip mention markdown in trigger_summary ([@Name](mention://...) → @Name)
so execution log rows show clean text instead of raw markdown
- Add squad to ActorFilterValue type so squad assignees are filterable
- Add squad section to assignee filter dropdown in issues-header
- Add i18n keys for squads_group (en/zh-Hans)
Co-authored-by: multica-agent <github@multica.ai>
* fix: address PR #2575 review feedback
1. Extract stripMentionMarkdown as reusable helper with proper regex
- Handles escaped brackets in names (e.g. David\[TF\])
- Skips backslash-escaped mentions (\[@...])
- Handles issue mentions (no @ prefix)
- Does not touch regular markdown links
- 10 unit tests added
2. Squad only appears in Assignee filter, not Creator
- Added showSquads prop to ActorSubContent (default true)
- Creator filter passes showSquads={false}
3. Squad included in Agents scope
- issues-page scope filter now includes squad in agents scope
- 2 regression tests added for scope coverage
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(quick-create): searchable actor picker + squad support (MUL-2163)
- Replaces the flat agent dropdown in the "Create with agent" modal with a
searchable PropertyPicker that lists Agents and Squads in separate
sections, so users can filter by name and pick a squad as the creator.
- Persists the selection as (lastActorType, lastActorId), removing the
agent-only lastAgentId field on the quick-create store.
- Adds squad_id to the quick-create API request and stamps it onto the
task's QuickCreateContext. The handler resolves the squad to its leader
agent (re-using validateAssigneePair) and the daemon claim path injects
the squad-leader briefing when the task carries a squad hint, matching
the behavior of issue-bound squad tasks.
Co-authored-by: multica-agent <github@multica.ai>
* fix(create-issue): forward squad picks across manual→agent switch
Manual mode → agent mode previously only carried `agent_id`, so picking
a squad and then flipping to agent silently fell back to the persisted
actor / first visible agent and lost the user's choice. Carry `squad_id`
on the same branch so the agent panel honors the squad pick.
Adds a sibling test alongside the existing project-carry case.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* perf(views): virtualize issue detail timeline with react-virtuoso
The unvirtualized timeline at issue-detail.tsx full-mounted every
entry, freezing first paint for several seconds at 500+ comments
(markdown parse + lowlight per CommentCard on mount). Production p99
is ~30 comments but the all-time max is ~1.1k and the server hard-caps
at 2000 — long-tail issues were unusable.
Swap the inline `.map` for `<Virtuoso customScrollParent>` driven by a
flattened TimelineItem discriminated union. TanStack Query stays the
source of truth; existing memo machinery (`prevThreadRepliesRef`,
`EMPTY_REPLIES`) and WS handlers are untouched. `followOutput="auto"`
matches Slack/Discord — users at the bottom auto-follow new comments,
users mid-scroll are not yanked back down.
Comment drafts move to a new persisted Zustand store
(`comment-draft-store`) so virtualization-driven unmount can no longer
drop in-progress edits or new comments. Hydrates via ContentEditor
`defaultValue`, flushes on update / blur / visibilitychange.
Deep-link from inbox is rewritten from `getElementById` +
`scrollIntoView` to `virtuosoRef.scrollToIndex` with a double-rAF
mitigation for the Virtuoso #883 initial-scroll race. Highlight flash
bumped 2s→3s to outlast mount latency on cold cards.
Cmd-F shows a once-per-session toast on long timelines since browser
find-in-page can't reach off-screen virtualized items. Real in-app
search lands in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(views): repair deep-link scroll and isolate comment drafts
The first virtualization landing had three latent issues that runtime
testing on perf fixtures (10 → 5000 comments) exposed:
1. Deep-link landing position was wrong by ~380px on every issue.
In customScrollParent mode Virtuoso computes scrollTop from the
list's internal coordinate space only — it doesn't account for
sibling content (title editor, description, sub-issues, agent
card) sitting above the list inside the same scroll parent. The
useEffect now uses Virtuoso scrollToIndex only to MOUNT the
target into the DOM, then polls a `data-comment-id` anchor and
delegates positioning to the browser's scrollIntoView, which
honors getBoundingClientRect and lands accurately every time.
2. Scroll-up was being yanked back to the deep-link anchor on every
ResizeObserver tick. Root cause was `followOutput="auto"`, which
stays "stuck to bottom" once the deep-link lands there and resets
scrollTop to maxScrollTop on each height change. Issue detail is
document-shaped, not chat-shaped, so removing followOutput
altogether is the right tradeoff. Likewise `initialTopMostItemIndex`
acts as a persistent anchor in customScrollParent mode (Virtuoso
#458) — dropped entirely and replaced with imperative scroll.
`defaultItemHeight` is also dropped so Virtuoso probes real
heights instead of estimating + correcting visually.
3. Reply-comment deep-links from the inbox would short-circuit
because the reply id isn't in the flat items[] array. Added a
replyToRoot map so deep-link falls back to the enclosing thread's
root index, scrolls there, and lets the reply's own ring fire
once the thread is in view.
Also fixes a latent cross-issue draft leak in `<CommentInput>`:
web's /issues/[id] route doesn't remount IssueDetail on issueId
change, so without an explicit `key={id}` the editor kept the
previous issue's in-memory content and the next keystroke would
flush it under the new issue's draft key. The same fix incidentally
repairs the pre-existing "submit composer from issue A while viewing
issue B" submit-target bug.
Highlight UX polish: bg-brand/5 was too faint to notice; ring upgraded
to ring-brand/60 as the sole signal. transition-colors didn't actually
animate ring/box-shadow — switched to transition-shadow duration-500
ease-out so highlight has visible fade in / fade out. Flash duration
3s → 4s. Polling failure now still sets highlight + warns so a manual
scroll to the target still flashes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core): namespace recent-issues by workspace id in state
The recent-issues store was using createWorkspaceAwareStorage, which
namespaces the storage key by the current slug. That broke whenever a
setter ran before WorkspaceRouteLayout's mount-effect set the slug —
child effects fire before parent effects in React, so recordVisit from
issue-detail wrote to the un-namespaced bare key, leaking visits across
workspaces. The /<slug>/issues page then fanned out a per-id GET for
each leaked id, mostly 404s.
Move the namespacing into the store state itself (byWorkspace keyed by
wsId), so reads/writes pick the right bucket at call time and don't
depend on a singleton being set before module hydration. Drop the
storage-level namespacing and the rehydration registration for this
store.
Add pruneWorkspaces to evict buckets for workspaces the user is no
longer a member of, wired into useDashboardGuard so it runs whenever
the workspace list resolves. As a defense against the prune never
firing, cap the total tracked workspaces at 50 (LRU on oldest visit).
Bump persist version to 1; the v0 entries don't know which workspace
they belonged to, so migrate drops them and the cache repopulates as
the user visits issues.
* fix(core): fail closed on null slug in workspace-aware storage
createWorkspaceAwareStorage used to fall back to the un-namespaced bare
key when no workspace was active. That fallback let any setter firing
before WorkspaceRouteLayout's mount-effect (e.g. a child component's
own mount-effect) leak workspace-scoped data into a global slot
visible to every workspace. Initial zustand persist hydration also ran
in this null-slug window, so every store would read the polluted bare
key on first load.
Drop the fallback: null slug → getItem returns null, setItem/removeItem
are no-ops. Stores still get a correct read via their registered
rehydrate fn once setCurrentWorkspace fires. The remaining nine stores
using this storage no longer rely on the bare-key path either; their
data has always been intended to be workspace-scoped.
---------
Co-authored-by: YYClaw <yyclaw0@gmail.com>
* fix(attachments): re-sign CloudFront download URLs at click time
The attachment download buttons opened `download_url` directly from cached
timeline/comment payloads. The signed URL is valid for 30 minutes, so a page
left open past that window would 403 with `AccessDenied` (MUL-2038 /
GitHub #2397).
- Add `GET /api/attachments/{id}` client method that re-signs on every call,
validated by a stricter `AttachmentResponseSchema` (enforces `url`,
`download_url`, `filename` so a malformed response degrades to the
EMPTY_ATTACHMENT record instead of opening `undefined`).
- Introduce `useDownloadAttachment` hook with two execution shapes:
- Web: synchronously open `about:blank` inside the click gesture to keep
popup activation, then hydrate `location.href` after the fetch. Cannot
pass `noopener` here — HTML spec dom-open step 17 makes that return
null.
- Desktop: skip the placeholder (Electron's setWindowOpenHandler rejects
about:blank) and hand the fresh URL to `openExternal`.
- Wire the hook into the standalone attachment buttons (comment-card) and
the inline `<img>` / file-card buttons inside `ReadonlyContent`. Inline
buttons resolve the attachment id by URL match; external URLs fall back
to `openExternal`.
Co-authored-by: multica-agent <github@multica.ai>
* fix(editor): re-sign downloads from ContentEditor file/image NodeViews
The previous commit only wired the click-time fresh-sign through
ReadonlyContent + the standalone attachment list. The Tiptap NodeViews
inside ContentEditor still opened the raw URL with
`window.open(href, "_blank", "noopener,noreferrer")`, leaving two
download surfaces on stale signatures:
- Issue description (always renders via ContentEditor)
- Comment edit mode (transient ContentEditor instance)
- Add AttachmentDownloadContext + AttachmentDownloadProvider so NodeViews
can resolve markdown URLs to an attachment id and call the existing
`useDownloadAttachment` hook. The default fallback (no provider mounted)
hands the raw URL to `openExternal`, keeping non-editor mounts unaffected.
- ContentEditor accepts `attachments?: Attachment[]` and wraps EditorContent
with the provider.
- file-card.tsx and image-view.tsx NodeViews swap their `window.open(...)`
calls for `openByUrl(href|src)` from the provider.
- issue-detail.tsx threads `useQuery(issueAttachmentsOptions(id))` into
ContentEditor for the description.
- comment-card.tsx passes `entry.attachments` to both edit-mode editors.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
- Sub-issue rows on the parent issue's detail page now expose inline StatusPicker and AssigneePicker, optimistically syncing the children cache via a useUpdateIssue parent-id fallback that scans loaded children caches.
- Hover-revealed checkbox + indeterminate select-all in the section header drive batch selection through the existing useIssueSelectionStore; the BatchActionToolbar gains a "placement" prop and renders inline directly under the sub-issues header so the action is right next to the rows.
- useBatchUpdateIssues / useBatchDeleteIssues now mirror their optimistic patches into every loaded children cache (with rollback) and invalidate children + childProgress on settle.
- SubIssueRow restructure: AppLink wraps only the identifier + title, so the checkbox / picker areas no longer accidentally fire navigation.
Refs MUL-2005.
* perf(issues): stop full timeline re-render on every WS event (MUL-1941)
Two compounding causes made every Comment/reply WS event re-render every
sibling thread on the issue detail page — visible during AI streaming as
a flash across all 10 nested replies under a parent and as the green
reply-input losing its draft.
1) `useCreateComment.onSettled` invalidated the timeline query, forcing a
full `GET /timeline` refetch on every comment submit. The response
replaced every entry's reference even when the content was unchanged,
poisoning every downstream React.memo. The `comment:created` WS
broadcast already keeps the cache fresh and `useWSReconnect` invalidates
on disconnect, so the redundant refetch had no upside. Drop it.
2) The `timelineView` useMemo passed the full `repliesByParent: Map` to
every CommentCard. Each WS event rebuilt the Map (new ref), so React.memo
on CommentCard fell back to a re-render for *every* card, not just the
one whose thread changed. Replace the Map prop with a per-thread
`replies: TimelineEntry[]` slice, precomputed once via
`collectThreadReplies` and stabilized against the prior render — when a
thread's flat list is shallow-equal to last time, reuse the previous
array reference so unrelated cards keep their memo.
ResolvedThreadBar gets the same `replies` prop, so the collapsed count +
author list still match the expanded view without re-walking the graph.
Verified: pnpm typecheck + pnpm test for @multica/views and @multica/core
(334 + 214 tests, all passing).
Co-authored-by: multica-agent <github@multica.ai>
* fix(realtime): mark timeline stale without refetching active queries (MUL-1941)
Per GPT-Boy's review on PR #2329: dropping `useCreateComment.onSettled`'s
invalidate wasn't enough. The global `useRealtimeSync` runs in WSProvider
for the lifetime of the app and re-invalidates the timeline on every
`comment:created` / `comment:updated` / `comment:deleted` /
`comment:resolved` / `comment:unresolved` / `activity:created` /
`reaction:added` / `reaction:removed` event. With `staleTime: Infinity` on
the QueryClient default, the active timeline query refetches on every
invalidate — replacing every entry's reference and busting the per-thread
memoization the prior commit just put in place.
Switch the global handler's `invalidateQueries` to `refetchType: "none"`.
Active observers now stay fresh via the granular `setQueryData` handlers
in `useIssueTimeline`; inactive issues' caches are still marked stale, so
when IssueDetail mounts later, `refetchOnMount` triggers a fresh fetch
the same way it did before.
`comment:resolved` / `comment:unresolved` previously had no granular
handler — only the global invalidate kept the cache in sync. Add
useWSEvent handlers in `useIssueTimeline` that replace the matching
entry via `commentToTimelineEntry`, and extend that helper to carry the
resolved_at / resolved_by_type / resolved_by_id fields so resolved state
survives the round-trip (it was silently dropped on every
`comment:updated` too — fixed as a side effect).
Tests: 3 new cases covering resolved / unresolved / cross-issue isolation
in the timeline hook. All 337 + 214 unit tests + full monorepo typecheck
pass.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(quick-create): add project picker that remembers last pick
Quick-create users targeting one project repeatedly had to restate "in
project X" in every prompt. The modal now exposes a project picker beside
the agent picker, persists the selection per-workspace, and pins the
agent's `multica issue create` invocation to that project so the prompt
text doesn't have to.
The picked project also flows to the daemon as ProjectID/ProjectTitle and
its github_repo resources override the workspace repo fallback — same
treatment issue-bound tasks already get.
Co-authored-by: multica-agent <github@multica.ai>
* fix(quick-create): move project picker into property pill row
Reviewer feedback: the picker felt out of place wedged next to the agent
header. Move it into a property toolbar row above the footer, reusing the
shared `ProjectPicker` + `PillButton` so its placement and styling line up
exactly with the manual create panel.
This also drops the bespoke dropdown / aria / label strings that were only
needed while the picker rendered inline beside "Created by".
Co-authored-by: multica-agent <github@multica.ai>
* fix(quick-create): clear stale persisted project + carry across mode switch
Two review-blocking bugs in PR #2321:
1. The stale-id sweep in AgentCreatePanel only fired when projects.length > 0
and only cleared local state, leaving lastProjectId pointing at a deleted
project. The next open re-seeded the dead UUID and submit hit the server's
`project not found` rejection. Gate on the query's `isSuccess` so we can
tell "loading" apart from "loaded as empty", and clear both local state
and the persisted preference when the selection isn't in the resolved list.
2. ManualCreatePanel's switchToAgent dropped the picked project from the carry
payload, so flipping manual → agent silently fell back to the agent panel's
own lastProjectId — potentially routing the issue to a different project
than the one shown in manual mode. Forward project_id alongside prompt /
agent_id, and add a regression test.
Co-authored-by: multica-agent <github@multica.ai>
* test(quick-create): pass new isExpanded props in stale-project tests
Main got an expand button on AgentCreatePanel via #2320 while this branch
was open, adding `isExpanded` / `setIsExpanded` to the panel's required
props. The two new stale-project tests still passed `{ onClose }` only,
which CI's typecheck (run on the main+branch merge) caught while my
local run did not.
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>
The priority badge in the issue/project priority picker dropdown used a
parallel `bg-priority` orange color family (with opacity gradient for level
intensity), while the standalone PriorityIcon outside the dropdown used
semantic tokens — destructive for Urgent, warning for High/Medium, info for
Low. The two languages produced an inconsistency users noticed most clearly
on Low: blue in the list, orange in the picker.
Switch the dropdown badges to the same semantic tokens as the icon, and
remove the now-unused `--priority` / `--color-priority` design token from
both `packages/ui/styles/tokens.css` and `apps/web/app/custom.css`.
Closesmultica-ai/multica#2289
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>
`onIssueLabelsChanged` patched the embedded `labels` field in the
issue list and detail caches but never touched `labelKeys.byIssue`,
the cache backing the issue-detail Properties LabelPicker. Mutations
already covered all three caches; WS-driven changes (agents, other
tabs) left the picker stale until remount, since `staleTime: Infinity`
plus `refetchOnWindowFocus: false` prevent recovery on focus.
When creating an issue with agent, the input content was lost when
navigating away (e.g., to view a ticket) and returning. Manual create
already persisted its draft - now agent create does too.
Changes:
- Add prompt field to useQuickCreateStore (persisted with workspace)
- AgentCreatePanel reads initial prompt from draft store if no transient
data.prompt is provided
- onUpdate now saves prompt to draft store (not just hasContent)
- clearPrompt() called after successful submit
Fixes: #1957